completed tools and setup

This commit is contained in:
southseact-3d
2026-02-08 20:02:30 +00:00
parent 39136e863f
commit bd6817f697
5 changed files with 480 additions and 4 deletions

View File

@@ -0,0 +1,274 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { z } from "zod"
// This MCP server wraps the existing external WP testing implementation used by the chat app.
// It is intentionally kept small and env-configurable so host apps can load it only when needed.
const ToolInput = {
plugin_path: z.string().min(1),
plugin_slug: z.string().optional(),
session_id: z.string().optional(),
test_mode: z.enum(["cli", "visual", "both"]).optional().default("cli"),
required_plugins: z
.array(
z.object({
plugin_slug: z.string().min(1),
source: z.enum(["wordpress.org", "url", "local"]).optional(),
source_url: z.string().optional(),
activate: z.boolean().optional(),
}),
)
.optional(),
test_scenarios: z
.array(
z.object({
name: z.string().optional(),
type: z.string().optional(),
url: z.string().optional(),
selector: z.string().optional(),
expected_text: z.string().optional(),
wp_cli_command: z.string().optional(),
shortcode: z.string().optional(),
hook: z.string().optional(),
ajax_action: z.string().optional(),
method: z.string().optional(),
body: z.string().optional(),
assertions: z
.object({
status_code: z.number().optional(),
contains: z.array(z.string()).optional(),
not_contains: z.array(z.string()).optional(),
wp_cli_success: z.boolean().optional(),
})
.optional(),
}),
)
.optional(),
config_overrides: z.record(z.any()).optional(),
}
function toJsonSchema(schema) {
// zod v4 exposes toJSONSchema under z.* in OpenCode, but in plain node usage
// we keep schema definitions explicit below for MCP.
// This helper exists only for future extension.
void schema
return {
type: "object",
additionalProperties: false,
}
}
const TestToolInputJsonSchema = {
type: "object",
additionalProperties: false,
required: ["plugin_path"],
properties: {
plugin_path: { type: "string", description: "Local filesystem path to plugin root" },
plugin_slug: { type: "string", description: "Plugin slug (defaults to folder name)" },
session_id: { type: "string", description: "Optional session id for isolation" },
test_mode: {
type: "string",
enum: ["cli", "visual", "both"],
description: "Testing mode (only 'cli' is implemented in this server)",
},
required_plugins: {
type: "array",
description: "Plugins to install/activate before running scenarios",
items: {
type: "object",
additionalProperties: false,
required: ["plugin_slug"],
properties: {
plugin_slug: { type: "string" },
source: { type: "string", enum: ["wordpress.org", "url", "local"] },
source_url: { type: "string" },
activate: { type: "boolean" },
},
},
},
test_scenarios: {
type: "array",
description: "Scenario list; if omitted, defaults are generated",
items: {
type: "object",
additionalProperties: true,
},
},
config_overrides: {
type: "object",
description: "Overrides for external WP testing configuration (see docs/env vars)",
additionalProperties: true,
},
},
}
const server = new Server(
{
name: "wp-cli-testing",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
},
)
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "test_plugin_external_wp",
description:
"Run WP-CLI based verification against an externally hosted WordPress test site (over SSH).",
inputSchema: TestToolInputJsonSchema,
},
{
name: "external_wp_testing_config",
description:
"Return the resolved external WP testing config (from env vars) and highlight missing/invalid settings.",
inputSchema: {
type: "object",
additionalProperties: false,
properties: {
config_overrides: {
type: "object",
additionalProperties: true,
description: "Optional config overrides merged on top of env defaults",
},
},
},
},
],
}
})
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const toolName = req.params.name
const args = (req.params.arguments ?? {})
// Lazy-load to avoid paying cost unless tool is actually invoked.
// Note: external-wp-testing.js is CommonJS; import default gives module.exports.
const externalTesting = await import("../../../chat/external-wp-testing.js")
const mod = externalTesting.default ?? externalTesting
const createExternalWpTester = mod?.createExternalWpTester
const getExternalTestingConfig = mod?.getExternalTestingConfig
if (typeof createExternalWpTester !== "function" || typeof getExternalTestingConfig !== "function") {
return {
content: [
{
type: "text",
text:
"wp-cli-testing MCP server misconfigured: could not load chat/external-wp-testing.js exports.",
},
],
isError: true,
}
}
if (toolName === "external_wp_testing_config") {
const overrides =
args && typeof args === "object" && (args.config_overrides && typeof args.config_overrides === "object")
? args.config_overrides
: {}
const config = getExternalTestingConfig(overrides)
const missing = []
if (!config.wpHost) missing.push("TEST_WP_HOST (or EXTERNAL_WP_HOST)")
if (!config.wpSshUser) missing.push("TEST_WP_SSH_USER (or EXTERNAL_WP_SSH_USER)")
if (!config.wpSshKey) missing.push("TEST_WP_SSH_KEY (or EXTERNAL_WP_SSH_KEY)")
const safeConfig = {
...config,
// avoid leaking filesystem layout too much; keep key path but dont include any private key contents.
wpSshKey: config.wpSshKey,
}
return {
content: [
{
type: "text",
text: JSON.stringify(
{
ok: missing.length === 0,
missing,
config: safeConfig,
},
null,
2,
),
},
],
}
}
if (toolName === "test_plugin_external_wp") {
const parsed = z
.object(ToolInput)
.safeParse(args && typeof args === "object" ? args : {})
if (!parsed.success) {
return {
content: [{ type: "text", text: JSON.stringify({ ok: false, error: parsed.error.message }, null, 2) }],
isError: true,
}
}
if (parsed.data.test_mode !== "cli") {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
ok: false,
error:
"Only test_mode='cli' is implemented by this MCP server. Use cli mode or extend the server.",
},
null,
2,
),
},
],
isError: true,
}
}
const tester = createExternalWpTester({})
const result = await tester.runTest(
{
plugin_path: parsed.data.plugin_path,
plugin_slug: parsed.data.plugin_slug,
session_id: parsed.data.session_id,
required_plugins: parsed.data.required_plugins ?? [],
test_scenarios: parsed.data.test_scenarios ?? [],
test_mode: "cli",
},
{
configOverrides:
parsed.data.config_overrides && typeof parsed.data.config_overrides === "object"
? parsed.data.config_overrides
: {},
},
)
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
}
}
return {
content: [{ type: "text", text: `Unknown tool: ${toolName}` }],
isError: true,
}
})
const transport = new StdioServerTransport()
await server.connect(transport)

View File

@@ -235,6 +235,69 @@ export namespace Config {
result.compaction = { ...result.compaction, prune: false }
}
// Allow host applications to inject additional MCP servers at runtime.
// This is intentionally env-driven so tools can be loaded only when needed
// (e.g. a Builder feature toggle) without requiring writing config files.
const extraMcpRaw = process.env.OPENCODE_EXTRA_MCP_SERVERS
if (extraMcpRaw && typeof extraMcpRaw === "string") {
try {
const parsed = JSON.parse(extraMcpRaw)
if (Array.isArray(parsed)) {
result.mcp ??= {}
for (const entry of parsed) {
if (!entry || typeof entry !== "object") continue
const name = typeof (entry as any).name === "string" ? (entry as any).name.trim() : ""
if (!name) continue
const enabledFromEntry = (entry as any).enabled
const disabledFromEntry = (entry as any).disabled
const enabled =
typeof enabledFromEntry === "boolean"
? enabledFromEntry
: typeof disabledFromEntry === "boolean"
? !disabledFromEntry
: true
const env = (entry as any).env
const envRecord: Record<string, string> | undefined =
env && typeof env === "object" && !Array.isArray(env) ? (env as Record<string, string>) : undefined
const timeout = (entry as any).timeout
const timeoutMs = Number.isFinite(timeout) ? Math.max(0, Number(timeout)) : undefined
// Accept either { command: 'node', args: [...] } or { command: ['node', ...] }
const commandVal = (entry as any).command
const argsVal = (entry as any).args
const commandParts: string[] = Array.isArray(commandVal)
? commandVal.filter((x: any) => typeof x === "string" && x.length)
: typeof commandVal === "string" && commandVal.trim().length
? [commandVal.trim()]
: []
const argsParts: string[] = Array.isArray(argsVal) ? argsVal.filter((x: any) => typeof x === "string") : []
if (commandParts.length === 0) {
log.warn("Ignoring OPENCODE_EXTRA_MCP_SERVERS entry without command", { name })
continue
}
result.mcp[name] = {
type: "local",
command: [...commandParts, ...argsParts],
env: envRecord,
enabled,
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
}
}
} else {
log.warn("OPENCODE_EXTRA_MCP_SERVERS must be a JSON array; ignoring", { valueType: typeof parsed })
}
} catch (err) {
log.warn("Failed to parse OPENCODE_EXTRA_MCP_SERVERS; ignoring", { error: err instanceof Error ? err.message : String(err) })
}
}
result.plugin = deduplicatePlugins(result.plugin ?? [])
return {