completed tools and setup
This commit is contained in:
103
EXTERNAL_WP_CLI_TESTING_SETUP.md
Normal file
103
EXTERNAL_WP_CLI_TESTING_SETUP.md
Normal 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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
274
opencode/mcp-servers/wp-cli-testing/index.js
Normal file
274
opencode/mcp-servers/wp-cli-testing/index.js
Normal 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 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)
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user