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,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://<TEST_WP_HOST>`
- 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.

View File

@@ -3964,6 +3964,7 @@ async function sendMessage() {
model, model,
cli, cli,
attachments: pendingAttachments.length ? pendingAttachments : undefined, attachments: pendingAttachments.length ? pendingAttachments : undefined,
externalTestingEnabled: !!builderState.externalTestingEnabled // Send feature toggle state to server
}; };
// Preserve opencodeSessionId to continue in the same session // Preserve opencodeSessionId to continue in the same session
if (currentSession && currentSession.opencodeSessionId) { if (currentSession && currentSession.opencodeSessionId) {

View File

@@ -8800,6 +8800,14 @@ async function sendToOpencode({ session, model, content, message, cli, streamCal
args.push('--session', opencodeSessionId); 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 // Ensure content is properly passed as the final argument
if (typeof clean !== 'string' || clean.length === 0) { if (typeof clean !== 'string' || clean.length === 0) {
throw new Error('Message content is invalid or empty after sanitization'); 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 // Use the OpenCode process manager for execution
// This ensures all sessions share the same OpenCode instance when possible // 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( const { stdout, stderr } = await opencodeManager.executeInSession(
session?.id || 'standalone', session?.id || 'standalone',
workspaceDir, workspaceDir,
@@ -8873,10 +8911,7 @@ async function sendToOpencode({ session, model, content, message, cli, streamCal
args, args,
{ {
timeout: 600000, // 10 minute timeout to prevent stuck processes timeout: 600000, // 10 minute timeout to prevent stuck processes
env: { env: executionEnv,
...process.env,
OPENAI_API_KEY: OPENCODE_OLLAMA_API_KEY
},
onData: (type, chunk) => { onData: (type, chunk) => {
try { try {
const now = Date.now(); const now = Date.now();

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 } 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 ?? []) result.plugin = deduplicatePlugins(result.plugin ?? [])
return { return {