diff --git a/EXTERNAL_WP_CLI_TESTING_SETUP.md b/EXTERNAL_WP_CLI_TESTING_SETUP.md new file mode 100644 index 0000000..af54717 --- /dev/null +++ b/EXTERNAL_WP_CLI_TESTING_SETUP.md @@ -0,0 +1,103 @@ +# External WP-CLI Testing Setup + +This guide explains how to enable and run External WP-CLI Testing from the Builder, with tools loaded into OpenCode only when the feature toggle is enabled. + +## What This Adds + +- A gated MCP tool named `test_plugin_external_wp` (plus a helper tool `external_wp_testing_config`). +- Tools are injected into OpenCode only when the Builder toggle is on. +- All connection/auth settings are controlled via environment variables. + +## Prerequisites + +1. A reachable WordPress host with WP-CLI installed. +2. SSH access to that host from the server running the chat app. +3. The SSH user must be able to run WP-CLI at the configured path. + +## Required Environment Variables + +Set these in the environment where the chat server runs (the same process that launches OpenCode). + +- `TEST_WP_HOST` (or `EXTERNAL_WP_HOST`) + - Hostname or IP of the WordPress server. +- `TEST_WP_SSH_USER` (or `EXTERNAL_WP_SSH_USER`) + - SSH username for the WordPress server. +- `TEST_WP_SSH_KEY` (or `TEST_WP_SSH_KEY_PATH`, or `EXTERNAL_WP_SSH_KEY`) + - Path to the SSH private key file. + +If these are missing, the tool will return a clear error. + +## Optional Environment Variables + +- `TEST_WP_PATH` (or `EXTERNAL_WP_PATH`) + - Default: `/var/www/html` + - Path to the WordPress root on the remote host. +- `TEST_WP_BASE_URL` (or `EXTERNAL_WP_BASE_URL`) + - Default: `https://` + - Base URL for the site. +- `TEST_WP_MULTISITE` (or `EXTERNAL_WP_MULTISITE`) + - Default: `true` + - Enables multisite subsite isolation. +- `TEST_WP_SUBSITE_PREFIX` (or `EXTERNAL_WP_SUBSITE_PREFIX`) + - Default: `test` + - Prefix for created subsites. +- `TEST_WP_SUBSITE_DOMAIN` (or `EXTERNAL_WP_SUBSITE_DOMAIN`) + - Default: empty (falls back to host) + - Domain used when subsite mode is subdomain. +- `TEST_WP_SUBSITE_MODE` (or `EXTERNAL_WP_SUBSITE_MODE`) + - Default: `subdirectory` + - Allowed values: `subdirectory`, `subdomain`. +- `TEST_WP_ERROR_LOG` (or `EXTERNAL_WP_ERROR_LOG`) + - Default: `/var/log/wp-errors.log` + - Used by the default "Scan error log" scenario. +- `TEST_SSH_STRICT` (or `EXTERNAL_WP_SSH_STRICT`) + - Default: `false` + - When `true`, enforces strict host key checking. +- `TEST_MAX_CONCURRENT` (or `EXTERNAL_WP_MAX_CONCURRENT`) + - Default: `20` + - Max concurrent tests. +- `TEST_QUEUE_TIMEOUT` (or `EXTERNAL_WP_QUEUE_TIMEOUT`) + - Default: `300000` (ms) +- `TEST_TIMEOUT` (or `EXTERNAL_WP_TEST_TIMEOUT`) + - Default: `600000` (ms) +- `TEST_AUTO_CLEANUP` (or `EXTERNAL_WP_AUTO_CLEANUP`) + - Default: `true` + - Cleans up subsites after tests complete. +- `TEST_CLEANUP_DELAY` (or `EXTERNAL_WP_CLEANUP_DELAY`) + - Default: `3600000` (ms) + +## Example .env + +TEST_WP_HOST=wp-test.example.com +TEST_WP_SSH_USER=wordpress +TEST_WP_SSH_KEY=C:\\keys\\wp-test.pem +TEST_WP_PATH=/var/www/html +TEST_WP_BASE_URL=https://wp-test.example.com +TEST_WP_MULTISITE=true +TEST_WP_SUBSITE_MODE=subdirectory +TEST_WP_SUBSITE_PREFIX=test +TEST_WP_ERROR_LOG=/var/log/wp-errors.log +TEST_SSH_STRICT=false +TEST_MAX_CONCURRENT=20 +TEST_TIMEOUT=600000 + +## Builder Toggle Behavior + +- Open the Builder and enable the "External WP CLI testing" toggle. +- When enabled, the chat server injects the MCP server definition via `OPENCODE_EXTRA_MCP_SERVERS`. +- When disabled, the environment variable is removed before OpenCode is spawned, so the tools are not exposed. + +## Running a Test + +1. Enable the toggle in Builder. +2. Proceed with the build or let the AI call `test_plugin_external_wp`. +3. The tool runs WP-CLI commands over SSH and returns a JSON report. + +## Quick Sanity Check Tool + +You can ask the AI to call `external_wp_testing_config` to validate your settings. It returns the resolved config and a list of missing required variables. + +## Notes + +- `test_plugin_external_wp` only supports `test_mode=cli` in the current implementation. +- Ensure the SSH user can execute WP-CLI and write to the plugin directory. diff --git a/chat/public/builder.js b/chat/public/builder.js index 33b7173..6930800 100644 --- a/chat/public/builder.js +++ b/chat/public/builder.js @@ -3964,6 +3964,7 @@ async function sendMessage() { model, cli, attachments: pendingAttachments.length ? pendingAttachments : undefined, + externalTestingEnabled: !!builderState.externalTestingEnabled // Send feature toggle state to server }; // Preserve opencodeSessionId to continue in the same session if (currentSession && currentSession.opencodeSessionId) { diff --git a/chat/server.js b/chat/server.js index 3986d19..fcefa49 100644 --- a/chat/server.js +++ b/chat/server.js @@ -8800,6 +8800,14 @@ async function sendToOpencode({ session, model, content, message, cli, streamCal args.push('--session', opencodeSessionId); } + // Configure WP CLI Testing MCP server if feature is enabled + const wpCliMcpEnabled = message?.externalTestingEnabled === true; + log('WP CLI Testing MCP server configuration', { + enabled: wpCliMcpEnabled, + messageId: message?.id, + sessionId: session.id + }); + // Ensure content is properly passed as the final argument if (typeof clean !== 'string' || clean.length === 0) { throw new Error('Message content is invalid or empty after sanitization'); @@ -8866,6 +8874,36 @@ async function sendToOpencode({ session, model, content, message, cli, streamCal // Use the OpenCode process manager for execution // This ensures all sessions share the same OpenCode instance when possible + + // Build environment variables including WP CLI MCP server config + const executionEnv = { + ...process.env, + OPENAI_API_KEY: OPENCODE_OLLAMA_API_KEY + }; + + // Add WP CLI Testing MCP server to OpenCode config if enabled + if (wpCliMcpEnabled) { + const wpMcpServerPath = path.resolve(__dirname, '../opencode/mcp-servers/wp-cli-testing/index.js'); + log('Enabling WP CLI Testing MCP server', { path: wpMcpServerPath, messageId: message?.id }); + + // Configure OpenCode to include the WP CLI testing MCP server + // We pass this via environment variable that OpenCode can read + executionEnv.OPENCODE_EXTRA_MCP_SERVERS = JSON.stringify([ + { + name: 'wp-cli-testing', + command: 'node', + args: [wpMcpServerPath], + disabled: false + } + ]); + } else { + // Safety: ensure the tools are not injected when the builder toggle is off, + // even if the parent process environment happens to have the variable set. + if (executionEnv.OPENCODE_EXTRA_MCP_SERVERS) { + delete executionEnv.OPENCODE_EXTRA_MCP_SERVERS; + } + } + const { stdout, stderr } = await opencodeManager.executeInSession( session?.id || 'standalone', workspaceDir, @@ -8873,10 +8911,7 @@ async function sendToOpencode({ session, model, content, message, cli, streamCal args, { timeout: 600000, // 10 minute timeout to prevent stuck processes - env: { - ...process.env, - OPENAI_API_KEY: OPENCODE_OLLAMA_API_KEY - }, + env: executionEnv, onData: (type, chunk) => { try { const now = Date.now(); diff --git a/opencode/mcp-servers/wp-cli-testing/index.js b/opencode/mcp-servers/wp-cli-testing/index.js new file mode 100644 index 0000000..d5195e8 --- /dev/null +++ b/opencode/mcp-servers/wp-cli-testing/index.js @@ -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 don’t 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) diff --git a/opencode/packages/opencode/src/config/config.ts b/opencode/packages/opencode/src/config/config.ts index a231a53..055b4d6 100644 --- a/opencode/packages/opencode/src/config/config.ts +++ b/opencode/packages/opencode/src/config/config.ts @@ -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 | undefined = + env && typeof env === "object" && !Array.isArray(env) ? (env as Record) : 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 {