From bcb18c87176cc8737bc74100c6441ada1a000501 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Wed, 9 Sep 2026 09:24:42 -0400 Subject: [PATCH 1/2] (add): Add global MCP endpoint and Agent Tools management tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /sites/mcp, a Local-wide MCP endpoint that is not bound to any site, alongside the existing per-site /sites/{siteId}/mcp routes. It can be configured once at user scope and used from any directory, which removes the bootstrap problem: previously the only way to reach the MCP server was to enable a site in Local's UI and then open that site's folder. The route is collision-free — /sites/mcp is one path segment after /sites, so it cannot match the two-segment per-site pattern, and a site whose id is literally "mcp" still resolves at /sites/mcp/mcp. Tool surface is now split in two. Site-scoped tools (wp_cli, the log readers, the wp-config tools, get_site_info, site_health_check) stay on the per-site endpoints, since they need a bound SiteConfig. Tools that address Local itself or take an explicit siteId are served on both. Sessions carry a nullable siteId and handleToolCall takes a nullable config, rejecting a site-scoped tool called globally with a message pointing at the per-site endpoint. Three new tools wrap the setup and teardown paths that were previously reachable only through the renderer's IPC handlers: - enable_agent_tools registers a site, writes its MCP config and project context, and returns the site's own endpoint URL - disable_agent_tools tears down and is idempotent - agent_tools_status reports enablement, agents, project dir, registration and endpoint URL for one site or all of them Two things surfaced while wiring those up: setupSite only ever adds files, so it cannot be reused as-is for a site that is already enabled — moving the project dir would strand config at the old location, and narrowing the agent list would strand the dropped agents' files. enable_agent_tools now routes through changeProjectDir and updateAgents, which clean up after themselves, then regenerateConfig. projectDir lands in path.join(sitePath, projectDir). That was safe coming from the UI's folder picker, but over MCP an absolute path or a `..` segment would write agent config anywhere on disk, so it is now validated. Adds 33 tests, including a real MCP handshake against the global endpoint covering the advertised tool list, a global tool call, the site-scoped refusal, and the /sites/mcp/mcp case. --- CHANGELOG.md | 3 + README.md | 63 +++++- src/main.ts | 140 +++++++++++- src/mcp-server.ts | 101 ++++++--- src/tools/agent-tools.ts | 214 ++++++++++++++++++ src/tools/environment.ts | 37 ++- src/tools/index.ts | 79 +++++-- src/tools/preview.ts | 27 ++- tests/mcp-server.test.ts | 166 ++++++++++++++ .../__snapshots__/definitions.test.ts.snap | 73 +++++- tests/tools/agent-tools.test.ts | 204 +++++++++++++++++ tests/tools/index.test.ts | 73 +++++- 12 files changed, 1102 insertions(+), 78 deletions(-) create mode 100644 src/tools/agent-tools.ts create mode 100644 tests/tools/agent-tools.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d2cb69..8e7a844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ All notable changes to this project will be documented in this file, per [the Ke ### Added +- Global MCP endpoint at `/sites/mcp`, not bound to any site — configure it once (e.g. in `~/.claude.json`) and manage Local from anywhere, instead of needing an enabled site's folder open first. It serves the Local-wide tools (`list_sites`, `create_site`, `list_service_versions`), the site lifecycle tools, and the new Agent Tools management tools. Site-scoped tools stay on `/sites/{siteId}/mcp` and return an error pointing there if called globally. +- `enable_agent_tools` and `disable_agent_tools` MCP tools — turn Agent Tools on or off for an existing site over MCP, the same as clicking Enable in Local's UI. `enable_agent_tools` returns the site's own MCP endpoint URL, so an agent can bootstrap from the global endpoint to a site-scoped one. +- `agent_tools_status` MCP tool — report which sites have Agent Tools enabled, which agents are configured, the project directory, whether the site is registered with the MCP server, and each site's MCP endpoint URL. - `create_site` MCP tool — create a new WordPress site in Local, with optional PHP / database / web server versions, multisite mode, WordPress admin credentials, and Xdebug. Returns as soon as the site is registered so the call does not outlive the MCP client's request timeout; poll `site_status` until the site reports `running`, or pass `wait: true` to block (props [@ivanlopez](https://github.com/ivanlopez) via [#80](https://github.com/10up/localwp-agent-tools/pull/80)). - `create_site` can enable Agent Tools on the site it creates via `enableAgentTools`, registering it with the MCP server and writing its MCP config and context files for the agents named in `agents` (props [@ivanlopez](https://github.com/ivanlopez) via [#80](https://github.com/10up/localwp-agent-tools/pull/80)). - `list_service_versions` MCP tool — list the PHP, database, and web server versions available to `create_site`, flagging which are already installed versus downloaded on demand (props [@ivanlopez](https://github.com/ivanlopez) via [#80](https://github.com/10up/localwp-agent-tools/pull/80)). diff --git a/README.md b/README.md index 7fbb6c4..09cf1e3 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,11 @@ Then open the site folder in your AI tool of choice and you're ready to go. ## Architecture -The MCP server runs as a single HTTP server inside Local's Electron main process — no separate Node.js processes per site. Each site gets its own endpoint: +The MCP server runs as a single HTTP server inside Local's Electron main process — no separate Node.js processes per site. It serves two kinds of endpoint: ``` -http://localhost:{port}/sites/{siteId}/mcp +http://localhost:{port}/sites/mcp # global — all of Local +http://localhost:{port}/sites/{siteId}/mcp # one specific site ``` The server uses the MCP Streamable HTTP transport. The port is stable across restarts (persisted at `~/.local-agent-tools/port`, default 24842). @@ -65,6 +66,49 @@ Local lets you choose where the add-on writes its project files: Site Root, Word The server only answers requests whose `Host` header is exactly `localhost:{port}` or `127.0.0.1:{port}`. A hand-written MCP config must use one of those two values. +## The global endpoint + +`/sites/mcp` is not tied to any site. You configure it once and use it from any directory, so you do not need to open a site folder first. + +It serves the tools that do not need a bound site: + +- The tools that address Local itself: `list_sites`, `create_site`, and `list_service_versions`. +- The site lifecycle tools, `site_start` and friends, which take an explicit `siteId`. +- The preview tools. `preview_list` and `preview_destroy` address Local itself. `preview_start` needs a `siteId` here, because no site is bound to this endpoint. +- The tools that turn Agent Tools on and off per site: `enable_agent_tools`, `disable_agent_tools`, and `agent_tools_status`. + +This makes the global endpoint the way to bootstrap. Connect to it, create or find a site, and enable Agent Tools on it. `enable_agent_tools` returns that site's own endpoint URL for the site-scoped work. + +The site-scoped tools are not served here: `wp_cli`, the log readers, the wp-config tools, `get_site_info`, and `site_health_check`. They need a bound site. Calling one on the global endpoint returns an error that points at the per-site endpoint. + +The global endpoint uses the same bearer token and the same Host and Origin checks as the per-site endpoints. To add it to Claude Code, using the persisted port and token: + +```bash +claude mcp add --scope user --transport http local-wp-global \ + "http://localhost:$(cat ~/.local-agent-tools/port)/sites/mcp" \ + --header "Authorization: Bearer $(cat ~/.local-agent-tools/token)" +``` + +`--scope user` is the part that makes it global. Without it, `claude mcp add` uses `--scope local`, which registers the server only for the directory you ran it in. Do not use `--scope project` for this server: a project-scoped `.mcp.json` is committed to the repo, and this entry carries the token. + +You can also add it by hand, as a top-level `mcpServers` entry in `~/.claude.json`. Cursor, Windsurf, and VS Code use the same shapes as the per-site config the add-on writes. + +```json +{ + "mcpServers": { + "local-wp-global": { + "type": "http", + "url": "http://localhost:24842/sites/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +`claude mcp list` confirms it connected. + +The global endpoint puts site management and `create_site` behind one well-known URL. Before, they were reachable only from an enabled site's endpoint. The token still gates every request, so the trust model in [What the token does not protect against](#what-the-token-does-not-protect-against) applies unchanged. + ## Supported Agents | Agent | MCP Config | Context File | @@ -74,7 +118,7 @@ The server only answers requests whose `Host` header is exactly `localhost:{port | Windsurf | `.windsurf/mcp.json` | `.windsurfrules` | | VS Code Copilot | `.vscode/mcp.json` | `.github/copilot-instructions.md` | -## MCP Tools (15 total) +## MCP Tools (21 total) | Category | Tools | Description | | --------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -93,6 +137,12 @@ The server only answers requests whose `Host` header is exactly `localhost:{port | | `list_sites` | List all Local sites with status | | | `create_site` | Create a new WordPress site in Local, optionally enabling Agent Tools on it | | | `list_service_versions` | PHP, database, and web server versions available to `create_site` | +| **Preview** | `preview_start` | Clone a site into a disposable preview site with its own database, processes, domain, and MCP endpoint | +| | `preview_list` | List preview sites and their MCP endpoint URLs | +| | `preview_destroy` | Delete a preview site; refuses to delete regular sites | +| **Agent Tools** | `enable_agent_tools` | Enable Agent Tools on a site: register it and write its MCP config and context files | +| | `disable_agent_tools` | Disable Agent Tools on a site and remove what it wrote | +| | `agent_tools_status` | Report which sites have Agent Tools enabled, their agents, and their MCP endpoint URLs | ### Creating sites @@ -186,19 +236,20 @@ agent-tools/ ├── src/ # Add-on source (TypeScript) │ ├── main.ts # Main process — lifecycle hooks, IPC, MCP server startup │ ├── renderer.tsx # Renderer process — React UI -│ ├── mcp-server.ts # HTTP MCP server — session management, Streamable HTTP transport +│ ├── mcp-server.ts # HTTP MCP server — routing, session management, Streamable HTTP transport │ ├── helpers/ │ │ ├── site-config.ts # SiteConfig type and SiteConfigRegistry │ │ ├── paths.ts # Platform-specific binary resolution (PHP, MySQL, WP-CLI) │ │ ├── new-site.ts # Pure helpers for create_site: nicename, domain, and path validation │ │ └── port.ts # Stable port allocation with file persistence │ └── tools/ # MCP tool implementations -│ ├── index.ts # Aggregates definitions, routes handleToolCall() +│ ├── index.ts # Aggregates definitions (full vs global), routes handleToolCall() │ ├── wpcli.ts # wp_cli │ ├── logs.ts # read_error_log, read_access_log, wp_debug_toggle │ ├── config.ts # read_wp_config, edit_wp_config │ ├── site.ts # get_site_info, site_health_check -│ └── environment.ts # site_start, site_stop, site_restart, site_status, list_sites, create_site, list_service_versions +│ ├── environment.ts # site_start, site_stop, site_restart, site_status, list_sites, create_site, list_service_versions +│ └── agent-tools.ts # enable_agent_tools, disable_agent_tools, agent_tools_status ├── lib/ # Compiled output ├── package.json └── tsconfig.json diff --git a/src/main.ts b/src/main.ts index e0df92d..af5fed6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,8 +22,24 @@ import { mergeMcpConfig, } from './helpers/mcp-config'; import { copyTree, retargetSymlinks } from './helpers/fast-copy'; -import { createMcpHttpServer, startMcpHttpServer, stopMcpHttpServer, closeSessionsForSite } from './mcp-server'; -import { LocalApi, CreateSiteOptions, CreateSiteResult, PreviewInfo, ServiceVersion, ServiceVersions } from './tools'; +import { + createMcpHttpServer, + startMcpHttpServer, + stopMcpHttpServer, + closeSessionsForSite, + GLOBAL_MCP_PATH, +} from './mcp-server'; +import { + LocalApi, + CreateSiteOptions, + CreateSiteResult, + PreviewInfo, + ServiceVersion, + ServiceVersions, + AgentToolsSiteStatus, + EnableAgentToolsOptions, + AgentName, +} from './tools'; import { execWpCli } from './tools/wpcli'; import { BUILT_IN_SITE_DEFAULTS, @@ -227,6 +243,13 @@ async function buildSiteConfig(site: Local.Site): Promise { // they're pure (no Local runtime imports) so they can be unit tested there. // --------------------------------------------------------------------------- +/** + * The per-site MCP endpoint URL that agent_tools_status and enable_agent_tools report. + */ +function buildSiteMcpUrl(port: number, siteId: string): string { + return `http://localhost:${port}/sites/${siteId}/mcp`; +} + /** * Removes our MCP server entry from a config file. * Handles both mcpServers and servers top-level keys. @@ -615,6 +638,44 @@ async function writeMcpConfigsForSite(site: Local.Site, projectPath: string, age } } +/** + * Enable Agent Tools on a site, or re-apply it to one that is already enabled. + * + * setupSite() only ever adds files, so it cannot be reused verbatim for a site + * that is already enabled: moving the project dir would strand config at the old + * location, and narrowing the agent list would strand the dropped agents' files. + * changeProjectDir() and updateAgents() are the paths that clean up after + * themselves, so route through them and let regenerateConfig() refresh the rest. + */ +async function applyAgentToolsSetup( + site: Local.Site, + notifier: any, + projectDir: string, + agents: AgentTarget[], +): Promise { + if (!isAgentToolsEnabled(site)) { + await setupSite(site, notifier, projectDir, agents); + return; + } + + // Move first, using the stored agent set, so the files that move are the ones + // that currently exist. Each step writes through SiteData, so re-read between + // them or the next step would persist stale customOptions. + let current = site; + + if (getStoredProjectDir(current) !== projectDir) { + await changeProjectDir(current, projectDir, notifier); + current = LocalMain.SiteData.getSite(site.id) ?? current; + } + + await updateAgents(current, agents, notifier); + current = LocalMain.SiteData.getSite(site.id) ?? current; + + // Rewrite MCP config and context for the resulting agent set — updateAgents + // only touches the agents that changed. + await regenerateConfig(current); +} + async function regenerateConfig(site: Local.Site): Promise { if (!isAgentToolsEnabled(site)) return; requireAuthToken(); @@ -667,6 +728,33 @@ async function getStatus(site: Local.Site): Promise { }; } +/** + * Agent Tools state for one site, as reported over the global MCP endpoint. + * Reads straight from SiteData, so it is accurate for sites that were never enabled. + */ +function describeAgentToolsStatus(site: Local.Site): AgentToolsSiteStatus { + const enabled = isAgentToolsEnabled(site); + + return { + id: site.id, + name: site.name, + domain: site.domain || '', + sitePath: getSitePath(site), + projectDir: getStoredProjectDir(site), + enabled, + agents: getStoredAgents(site) as AgentName[], + registered: siteConfigRegistry.has(site.id), + mcpUrl: enabled && mcpServerPort ? buildSiteMcpUrl(mcpServerPort, site.id) : null, + }; +} + +/** Look up a site by id, with a consistent error for the MCP tools. */ +function requireSite(siteId: string): Local.Site { + const site = LocalMain.SiteData.getSite(siteId); + if (!site) throw new Error(`Site not found: ${siteId}. Use list_sites to see available site IDs.`); + return site; +} + // --------------------------------------------------------------------------- // LocalApi Implementation — wraps Local's SiteProcessManager // --------------------------------------------------------------------------- @@ -725,8 +813,10 @@ async function waitForSiteByDomain(domain: string, timeoutMs = 5_000): Promise(); interface LocalApiOptions { - /** Enables Agent Tools on a freshly created site. Wired to setupSite() by the add-on entry point. */ - enableAgentTools(site: Local.Site, agents: AgentTarget[]): Promise; + /** Enables Agent Tools on a site. Wired to setupSite() by the add-on entry point. */ + enableAgentTools(site: Local.Site, agents: AgentTarget[], projectDir?: string): Promise; + /** Disables Agent Tools on a site. Wired to teardownSite() by the add-on entry point. */ + disableAgentTools(site: Local.Site): Promise; } function createLocalApi(options: LocalApiOptions): LocalApi { @@ -1299,6 +1389,40 @@ function createLocalApi(options: LocalApiOptions): LocalApi { return { id: site.id, name: site.name, deleted: true }; }, + + async enableAgentTools({ siteId, agents, projectDir }: EnableAgentToolsOptions) { + const site = requireSite(siteId); + const targets = (agents?.length ? agents : ['claude']) as AgentTarget[]; + + await options.enableAgentTools(site, targets, projectDir ?? ''); + + // setupSite writes customOptions through SiteData, so re-read to report + // the state that was actually persisted. + return describeAgentToolsStatus(LocalMain.SiteData.getSite(siteId) ?? site); + }, + + async disableAgentTools(siteId: string) { + const site = requireSite(siteId); + + // Idempotent: teardown on a site that was never enabled would still + // rewrite its .gitignore and fire a misleading notification. + if (!isAgentToolsEnabled(site)) { + return describeAgentToolsStatus(site); + } + + await options.disableAgentTools(site); + + return describeAgentToolsStatus(LocalMain.SiteData.getSite(siteId) ?? site); + }, + + async getAgentToolsStatus(siteId?: string) { + if (siteId) { + return [describeAgentToolsStatus(requireSite(siteId))]; + } + + const sites = LocalMain.SiteData.getSites(); + return (Object.values(sites) as Local.Site[]).map(describeAgentToolsStatus); + }, }; } @@ -1311,7 +1435,8 @@ export default function (context: LocalMain.AddonMainContext): void { let httpServer: ReturnType | null = null; const localApi = createLocalApi({ - enableAgentTools: (site, agents) => setupSite(site, notifier, '', agents), + enableAgentTools: (site, agents, projectDir) => applyAgentToolsSetup(site, notifier, projectDir ?? '', agents), + disableAgentTools: (site) => teardownSite(site, notifier), }); // Start the MCP HTTP server @@ -1349,6 +1474,11 @@ export default function (context: LocalMain.AddonMainContext): void { await savePort(mcpServerPort); + console.log( + `[Agent Tools] Global endpoint: http://localhost:${mcpServerPort}${GLOBAL_MCP_PATH} ` + + `(per-site: http://localhost:${mcpServerPort}/sites/{siteId}/mcp)`, + ); + // Register configs for all sites with Agent Tools enabled and all preview sites // (regardless of running status). // This ensures the MCP endpoint is always reachable — tools that need the site diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 0c8cb05..8ff9ad9 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -1,7 +1,7 @@ import * as http from 'http'; import { randomUUID, timingSafeEqual } from 'crypto'; -import { SiteConfigRegistry } from './helpers/site-config'; -import { allToolDefinitions, crossSiteToolNames, handleToolCall, LocalApi } from './tools'; +import { SiteConfig, SiteConfigRegistry } from './helpers/site-config'; +import { allToolDefinitions, crossSiteToolNames, globalToolDefinitions, handleToolCall, LocalApi } from './tools'; // --------------------------------------------------------------------------- // MCP SDK — loaded via require() for CJS compatibility. @@ -33,7 +33,8 @@ type McpRequest = any; interface SessionEntry { transport: McpTransport; server: McpServer; - siteId: string; + /** null for sessions on the global endpoint, which is not bound to a site. */ + siteId: string | null; lastActivity: number; } @@ -130,36 +131,43 @@ function closeAllSessions(): void { // MCP Server Factory — creates a Server instance for a specific site // --------------------------------------------------------------------------- -function createMcpServer(siteId: string, registry: SiteConfigRegistry, localApi: LocalApi): McpServer { +function createMcpServer(siteId: string | null, registry: SiteConfigRegistry, localApi: LocalApi): McpServer { const server = new Server({ name: 'local-wp', version: '1.0.0' }, { capabilities: { tools: {} } }); + // The global endpoint has no bound site, so it only advertises the tools that + // address Local itself or take an explicit siteId. server.setRequestHandler(ListToolsRequestSchema, async () => { - return { tools: allToolDefinitions }; + return { tools: siteId ? allToolDefinitions : globalToolDefinitions }; }); server.setRequestHandler(CallToolRequestSchema, async (request: McpRequest) => { const { name, arguments: args } = request.params; + console.log(`[Agent Tools] Tool called: ${name} (${siteId ? `site: ${siteId}` : 'global'})`); // Site-bound tools accept an optional siteId to target another registered // site (e.g. a preview) without the client reconnecting to its endpoint. + // A global session has no bound site, so it never resolves a SiteConfig: + // handleToolCall refuses site-scoped tools there. const argSiteId = (args as Record | undefined)?.siteId; const targetSiteId = - crossSiteToolNames.has(name) && typeof argSiteId === 'string' && argSiteId ? argSiteId : siteId; - console.log(`[Agent Tools] Tool called: ${name} (site: ${targetSiteId})`); + siteId && crossSiteToolNames.has(name) && typeof argSiteId === 'string' && argSiteId ? argSiteId : siteId; // Look up config fresh on every call so we always use the latest // (e.g., after site start updates socket paths, PHP binary, etc.) - const config = registry.get(targetSiteId); - if (!config) { - const text = - targetSiteId === siteId - ? `Site ${siteId} is no longer registered.` - : `Site ${targetSiteId} is not registered with Agent Tools. Use preview_list or list_sites to find ` + - 'valid targets; previews register when preview_start completes.'; - return { - content: [{ type: 'text', text }], - isError: true, - }; + let config: SiteConfig | null = null; + if (targetSiteId) { + config = registry.get(targetSiteId) ?? null; + if (!config) { + const text = + targetSiteId === siteId + ? `Site ${siteId} is no longer registered.` + : `Site ${targetSiteId} is not registered with Agent Tools. Use preview_list or list_sites to find ` + + 'valid targets; previews register when preview_start completes.'; + return { + content: [{ type: 'text', text }], + isError: true, + }; + } } try { @@ -203,6 +211,13 @@ function readBody(req: http.IncomingMessage): Promise { // URL Routing // --------------------------------------------------------------------------- +/** + * The Local-wide endpoint. One path segment after /sites, so it can never collide + * with the two-segment per-site route below — even for a site whose id is "mcp", + * which lives at /sites/mcp/mcp. + */ +export const GLOBAL_MCP_PATH = '/sites/mcp'; + /** Extract siteId from URL path like /sites/{siteId}/mcp */ function parseSiteId(url: string): string | null { const match = url.match(/^\/sites\/([^/]+)\/mcp$/); @@ -361,27 +376,37 @@ export function createMcpHttpServer(options: McpHttpServerOptions): http.Server // Health check — no siteId enumeration; auth is already enforced above. if (url === '/health' && method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok', activeSessions: sessions.size })); + res.end( + JSON.stringify({ status: 'ok', globalEndpoint: GLOBAL_MCP_PATH, activeSessions: sessions.size }), + ); return; } - // MCP endpoint: /sites/:siteId/mcp - const siteId = parseSiteId(url); - if (!siteId) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Not found. Use /sites/{siteId}/mcp' })); - return; - } + // Two MCP endpoints: the Local-wide one, and one per registered site. + // Both sit behind the auth and Host/Origin gates above. + // A global session has no bound site, so siteId stays null for it. + const isGlobal = url === GLOBAL_MCP_PATH; + let siteId: string | null = null; + + if (!isGlobal) { + siteId = parseSiteId(url); + if (!siteId) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Not found. Use ${GLOBAL_MCP_PATH} or /sites/{siteId}/mcp` })); + return; + } - const config = registry.get(siteId); - if (!config) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: `Site not registered: ${siteId}. The site may not be running or Agent Tools may not be enabled.`, - }), - ); - return; + if (!registry.get(siteId)) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: + `Site not registered: ${siteId}. Agent Tools may not be enabled for it — ` + + `enable it from Local, or call enable_agent_tools on ${GLOBAL_MCP_PATH}.`, + }), + ); + return; + } } const sessionId = req.headers['mcp-session-id'] as string | undefined; @@ -461,7 +486,11 @@ export function createMcpHttpServer(options: McpHttpServerOptions): http.Server siteId, lastActivity: Date.now(), }); - console.log(`[Agent Tools] New MCP session ${newSessionId} for site ${siteId}`); + console.log( + `[Agent Tools] New MCP session ${newSessionId} for ${ + siteId ? `site ${siteId}` : 'the global endpoint' + }`, + ); }, }); diff --git a/src/tools/agent-tools.ts b/src/tools/agent-tools.ts new file mode 100644 index 0000000..70dc787 --- /dev/null +++ b/src/tools/agent-tools.ts @@ -0,0 +1,214 @@ +import * as path from 'path'; +import { AGENT_NAMES, AgentName, LocalApi } from './environment'; + +// ── Tool Definitions ─────────────────────────────────────────────────── +export const toolDefinitions = [ + { + name: 'enable_agent_tools', + description: + 'Enable Agent Tools on a Local site — registers it with this MCP server and writes its MCP ' + + 'config and project context files for the chosen agents. Same effect as clicking Enable in ' + + "Local's UI. Returns the site's own MCP endpoint URL, which is the endpoint to use for " + + 'site-scoped tools like wp_cli and the log readers.', + inputSchema: { + type: 'object' as const, + properties: { + siteId: { + type: 'string', + description: 'The Local site ID. Use list_sites or agent_tools_status to find it.', + }, + agents: { + type: 'array', + items: { type: 'string', enum: [...AGENT_NAMES] }, + description: 'Which agents to configure. Optional — defaults to ["claude"].', + }, + projectDir: { + type: 'string', + description: + 'Subdirectory of the site folder to write the agent config into, relative to the ' + + 'site root (e.g. "app/public/wp-content/themes/my-theme"). Optional — defaults to ' + + 'the site root. Must stay inside the site folder.', + }, + }, + required: ['siteId'], + }, + }, + { + name: 'disable_agent_tools', + description: + 'Disable Agent Tools on a Local site — unregisters it from this MCP server, closes its open ' + + 'MCP sessions, and removes the MCP config entry and project context it wrote. Leaves the site ' + + 'itself untouched. No-op if the site was not enabled.', + inputSchema: { + type: 'object' as const, + properties: { + siteId: { + type: 'string', + description: 'The Local site ID. Use list_sites or agent_tools_status to find it.', + }, + }, + required: ['siteId'], + }, + }, + { + name: 'agent_tools_status', + description: + 'Report Agent Tools state for Local sites: whether it is enabled, which agents are configured, ' + + 'the project directory its config was written to, whether the site is currently registered with ' + + "this MCP server, and the site's MCP endpoint URL. Omit siteId to report on every site.", + inputSchema: { + type: 'object' as const, + properties: { + siteId: { + type: 'string', + description: 'Optional — restrict the report to a single site.', + }, + }, + }, + }, +]; + +// ── Argument Parsing ─────────────────────────────────────────────────── + +/** Narrow a required siteId argument. Returns the id, or an error string. */ +export function parseSiteIdArg(args: Record): string | { error: string } { + const siteId = args.siteId; + if (typeof siteId !== 'string' || !siteId.trim()) { + return { error: 'Error: "siteId" is required and must be a non-empty string.' }; + } + return siteId.trim(); +} + +/** + * Narrow the optional agents argument. + * Returns undefined when not provided, so the caller falls back to its default. + */ +export function parseAgentsArg(args: Record): AgentName[] | undefined | { error: string } { + const value = args.agents; + if (value === undefined || value === null) return undefined; + if (!Array.isArray(value) || value.length === 0) { + return { error: 'Error: "agents" must be a non-empty array when provided.' }; + } + const invalid = value.filter((a) => typeof a !== 'string' || !AGENT_NAMES.includes(a as AgentName)); + if (invalid.length) { + return { + error: `Error: unknown agent(s): ${invalid.join(', ')}. Valid agents: ${AGENT_NAMES.join(', ')}.`, + }; + } + return value as AgentName[]; +} + +/** + * Narrow the optional projectDir argument. + * + * This lands in path.join(sitePath, projectDir) on the main process side, so an + * absolute path or a `..` segment would write agent config outside the site + * folder. Reject both rather than trusting the caller. + */ +export function parseProjectDirArg(args: Record): string | { error: string } { + const value = args.projectDir; + if (value === undefined || value === null) return ''; + if (typeof value !== 'string') { + return { error: 'Error: "projectDir" must be a string when provided.' }; + } + + const trimmed = value.trim(); + if (!trimmed) return ''; + + if (path.isAbsolute(trimmed) || /^[a-zA-Z]:[\\/]/.test(trimmed)) { + return { error: 'Error: "projectDir" must be relative to the site folder, not an absolute path.' }; + } + + const normalized = path.normalize(trimmed).replace(/[\\/]+$/, ''); + const segments = normalized.split(/[\\/]/); + if (segments.includes('..')) { + return { error: 'Error: "projectDir" must stay inside the site folder.' }; + } + + return normalized === '.' ? '' : normalized; +} + +// ── Tool Handler ─────────────────────────────────────────────────────── +export async function handleTool( + name: string, + args: Record, + localApi: LocalApi, +): Promise<{ content: Array<{ type: string; text: string }> }> { + try { + switch (name) { + case 'enable_agent_tools': + return await handleEnable(args, localApi); + case 'disable_agent_tools': + return await handleDisable(args, localApi); + case 'agent_tools_status': + return await handleStatus(args, localApi); + default: + return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] }; + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: 'text', text: `Agent Tools Error: ${msg}` }] }; + } +} + +function isError(value: unknown): value is { error: string } { + return typeof value === 'object' && value !== null && 'error' in value; +} + +async function handleEnable( + args: Record, + localApi: LocalApi, +): Promise<{ content: Array<{ type: string; text: string }> }> { + const siteId = parseSiteIdArg(args); + if (isError(siteId)) return { content: [{ type: 'text', text: siteId.error }] }; + + const agents = parseAgentsArg(args); + if (isError(agents)) return { content: [{ type: 'text', text: agents.error }] }; + + const projectDir = parseProjectDirArg(args); + if (isError(projectDir)) return { content: [{ type: 'text', text: projectDir.error }] }; + + try { + const status = await localApi.enableAgentTools({ siteId, agents, projectDir }); + return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: 'text', text: `Failed to enable Agent Tools: ${msg}` }] }; + } +} + +async function handleDisable( + args: Record, + localApi: LocalApi, +): Promise<{ content: Array<{ type: string; text: string }> }> { + const siteId = parseSiteIdArg(args); + if (isError(siteId)) return { content: [{ type: 'text', text: siteId.error }] }; + + try { + const status = await localApi.disableAgentTools(siteId); + return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: 'text', text: `Failed to disable Agent Tools: ${msg}` }] }; + } +} + +async function handleStatus( + args: Record, + localApi: LocalApi, +): Promise<{ content: Array<{ type: string; text: string }> }> { + let siteId: string | undefined; + if (args.siteId !== undefined && args.siteId !== null) { + const parsed = parseSiteIdArg(args); + if (isError(parsed)) return { content: [{ type: 'text', text: parsed.error }] }; + siteId = parsed; + } + + try { + const status = await localApi.getAgentToolsStatus(siteId); + return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: 'text', text: `Failed to read Agent Tools status: ${msg}` }] }; + } +} diff --git a/src/tools/environment.ts b/src/tools/environment.ts index f866d3d..6955254 100644 --- a/src/tools/environment.ts +++ b/src/tools/environment.ts @@ -76,6 +76,30 @@ export interface PreviewInfo { mcpUrl: string; } +/** Agent Tools enablement state for one Local site. */ +export interface AgentToolsSiteStatus { + id: string; + name: string; + domain: string; + sitePath: string; + /** Subdirectory the agent config was written to, relative to the site root. Empty means site root. */ + projectDir: string; + enabled: boolean; + agents: AgentName[]; + /** True when the site is currently registered with the MCP server, so its endpoint is live. */ + registered: boolean; + /** The site's own MCP endpoint, or null when Agent Tools is not enabled for it. */ + mcpUrl: string | null; +} + +export interface EnableAgentToolsOptions { + siteId: string; + /** Defaults to ["claude"] when omitted. */ + agents?: AgentName[]; + /** Relative to the site root. Empty or omitted means the site root. */ + projectDir?: string; +} + export interface LocalApi { startSite(siteId: string): Promise<{ id: string; name?: string; status: string; message?: string }>; stopSite(siteId: string): Promise<{ id: string; name?: string; status: string; message?: string }>; @@ -94,6 +118,12 @@ export interface LocalApi { createPreview(parentSiteId: string, label?: string): Promise; listPreviews(): Promise; destroyPreview(siteId: string): Promise<{ id: string; name: string; deleted: true }>; + /** Enables Agent Tools on an existing site, writing its MCP config and project context. */ + enableAgentTools(options: EnableAgentToolsOptions): Promise; + /** Disables Agent Tools on a site, removing what it wrote. Idempotent. */ + disableAgentTools(siteId: string): Promise; + /** Reports Agent Tools state for one site, or for every site when siteId is omitted. */ + getAgentToolsStatus(siteId?: string): Promise; } // ── Tool Definitions ─────────────────────────────────────────────────── @@ -282,7 +312,7 @@ export const toolDefinitions = [ export async function handleTool( name: string, args: Record, - config: SiteConfig, + config: SiteConfig | null, localApi: LocalApi, ): Promise<{ content: Array<{ type: string; text: string }> }> { try { @@ -314,10 +344,11 @@ export async function handleTool( async function handleSiteAction( action: 'start' | 'stop' | 'restart' | 'status', args: Record, - config: SiteConfig, + config: SiteConfig | null, localApi: LocalApi, ): Promise<{ content: Array<{ type: string; text: string }> }> { - const siteId = (args.siteId as string) || config.siteId; + // On the global endpoint there is no bound site, so siteId is mandatory there. + const siteId = (args.siteId as string) || config?.siteId; if (!siteId) { return { diff --git a/src/tools/index.ts b/src/tools/index.ts index 5469921..70fbfe2 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -5,6 +5,7 @@ import { toolDefinitions as configTools, handleTool as handleConfigTool } from ' import { toolDefinitions as siteTools, handleTool as handleSiteTool } from './site'; import { toolDefinitions as environmentTools, handleTool as handleEnvironmentTool, LocalApi } from './environment'; import { toolDefinitions as previewTools, handleTool as handlePreviewTool } from './preview'; +import { toolDefinitions as agentToolsTools, handleTool as handleAgentToolsTool } from './agent-tools'; export type { LocalApi, @@ -15,10 +16,18 @@ export type { AgentName, ServiceVersion, ServiceVersions, + AgentToolsSiteStatus, + EnableAgentToolsOptions, } from './environment'; export type ToolResult = { content: Array<{ type: string; text: string }> }; +/** + * Tools that operate on one specific site and need its SiteConfig. + * Only reachable through a per-site endpoint (/sites/{siteId}/mcp). + */ +const siteScopedTools = [...wpcliTools, ...logTools, ...configTools, ...siteTools]; + /** * Site-bound tools (WP-CLI, logs, config, site info) resolve against the * endpoint's site by default, but accept an optional siteId so a session @@ -28,9 +37,7 @@ export type ToolResult = { content: Array<{ type: string; text: string }> }; * Environment tools (site_start etc.) are excluded: they route their own * siteId against ALL Local sites, registered or not. */ -export const crossSiteToolNames = new Set( - [...wpcliTools, ...logTools, ...configTools, ...siteTools].map((t) => t.name), -); +export const crossSiteToolNames = new Set(siteScopedTools.map((t) => t.name)); const SITE_ID_PARAM = { type: 'string', @@ -53,38 +60,47 @@ function withSiteIdParam t.name)); // Unified handler type: (name, args, config, localApi) => ToolResult type ToolHandler = ( name: string, args: Record, - config: SiteConfig, + config: SiteConfig | null, localApi: LocalApi, ) => Promise; // Build handler map — routes tool name to the correct module const toolHandlerMap: Record = {}; +// Site-scoped modules are only ever reached with a non-null config; handleToolCall +// rejects the call before dispatch otherwise, so the assertions below hold. for (const tool of wpcliTools) { - toolHandlerMap[tool.name] = (name, args, config, _localApi) => handleWpcliTool(name, args, config); + toolHandlerMap[tool.name] = (name, args, config, _localApi) => handleWpcliTool(name, args, config as SiteConfig); } for (const tool of logTools) { - toolHandlerMap[tool.name] = (name, args, config, _localApi) => handleLogTool(name, args, config); + toolHandlerMap[tool.name] = (name, args, config, _localApi) => handleLogTool(name, args, config as SiteConfig); } for (const tool of configTools) { - toolHandlerMap[tool.name] = (name, args, config, _localApi) => handleConfigTool(name, args, config); + toolHandlerMap[tool.name] = (name, args, config, _localApi) => handleConfigTool(name, args, config as SiteConfig); } for (const tool of siteTools) { - toolHandlerMap[tool.name] = (name, args, config, _localApi) => handleSiteTool(name, args, config); + toolHandlerMap[tool.name] = (name, args, config, _localApi) => handleSiteTool(name, args, config as SiteConfig); } for (const tool of environmentTools) { toolHandlerMap[tool.name] = (name, args, config, localApi) => handleEnvironmentTool(name, args, config, localApi); @@ -92,26 +108,51 @@ for (const tool of environmentTools) { for (const tool of previewTools) { toolHandlerMap[tool.name] = (name, args, config, localApi) => handlePreviewTool(name, args, config, localApi); } +for (const tool of agentToolsTools) { + toolHandlerMap[tool.name] = (name, args, _config, localApi) => handleAgentToolsTool(name, args, localApi); +} /** * Handle a tool call, routing to the correct module based on tool name. + * + * `config` is null for sessions on the global endpoint, which has no bound site. + * Site-scoped tools are not advertised there, but guard against them being called + * anyway rather than dispatching with a missing config. */ export async function handleToolCall( name: string, args: Record, - config: SiteConfig, + config: SiteConfig | null, localApi: LocalApi, ): Promise { + const available = config ? allToolDefinitions : globalToolDefinitions; const handler = toolHandlerMap[name]; + if (!handler) { return { content: [ { type: 'text', - text: `Unknown tool: ${name}. Available tools: ${allToolDefinitions.map((t) => t.name).join(', ')}`, + text: `Unknown tool: ${name}. Available tools: ${available.map((t) => t.name).join(', ')}`, }, ], }; } + + if (!config && siteScopedToolNames.has(name)) { + return { + content: [ + { + type: 'text', + text: + `Error: ${name} operates on a single site and is not available on the global endpoint. ` + + "Connect to that site's own endpoint (/sites/{siteId}/mcp) instead — " + + 'agent_tools_status reports the URL for each enabled site, and enable_agent_tools ' + + 'creates one for a site that is not enabled yet.', + }, + ], + }; + } + return handler(name, args, config, localApi); } diff --git a/src/tools/preview.ts b/src/tools/preview.ts index de5eeb8..303265b 100644 --- a/src/tools/preview.ts +++ b/src/tools/preview.ts @@ -17,7 +17,8 @@ export const toolDefinitions = [ properties: { siteId: { type: 'string', - description: 'The Local site ID of the site to clone. Optional — defaults to the current site.', + description: + 'The Local site ID of the site to clone. Optional on a per-site endpoint, where it defaults to the current site. Required on the global endpoint.', }, label: { type: 'string', @@ -59,19 +60,31 @@ export const toolDefinitions = [ export async function handleTool( name: string, args: Record, - config: SiteConfig, + config: SiteConfig | null, localApi: LocalApi, ): Promise<{ content: Array<{ type: string; text: string }> }> { try { let result: unknown; switch (name) { - case 'preview_start': - result = await localApi.createPreview( - (args.siteId as string) || config.siteId, - args.label as string | undefined, - ); + case 'preview_start': { + // On the global endpoint there is no bound site, so siteId is mandatory there. + const parentSiteId = (args.siteId as string) || config?.siteId; + if (!parentSiteId) { + return { + content: [ + { + type: 'text', + text: + 'Error: preview_start needs a siteId on the global endpoint, because no site is bound to it. ' + + 'Pass the siteId of the site to clone. Use list_sites to find site IDs.', + }, + ], + }; + } + result = await localApi.createPreview(parentSiteId, args.label as string | undefined); break; + } case 'preview_list': result = await localApi.listPreviews(); break; diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 7198ab8..3e7ae79 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -14,14 +14,97 @@ describe('MCP HTTP Server', () => { let port: number; const registry = new SiteConfigRegistry(); + const mockStatus = { + id: 'test-site', + name: 'Test Site', + domain: 'test.local', + sitePath: '/tmp/test-site', + projectDir: '', + enabled: true, + agents: ['claude' as const], + registered: true, + mcpUrl: 'http://localhost:24842/sites/test-site/mcp', + }; + + const mockPreview = { + id: 'preview-site', + name: 'Test Site - fix', + domain: 'test-site-fix.local', + siteUrl: 'https://test-site-fix.local', + sitePath: '/tmp/test-site-fix', + wpPath: '/tmp/test-site-fix/app/public', + status: 'running', + parentSiteId: 'test-site', + mcpUrl: 'http://localhost:24842/sites/preview-site/mcp', + }; + const mockLocalApi: LocalApi = { startSite: async () => ({ id: 'test', status: 'running' }), stopSite: async () => ({ id: 'test', status: 'halted' }), restartSite: async () => ({ id: 'test', status: 'running' }), getSiteStatus: async () => ({ id: 'test', status: 'running' }), listSites: async () => [], + createSite: async (opts) => ({ + id: 'new-site', + name: opts.name, + domain: 'new-site.local', + path: '/tmp/new-site', + url: 'http://new-site.local', + status: 'adding', + phpVersion: '8.2.29', + database: 'mysql-8.4.0', + webServer: 'nginx-1.26.1', + multisite: 'none' as const, + wpAdminUsername: 'admin', + wpAdminPassword: 'admin', + wpAdminEmail: 'dev@local', + agentToolsEnabled: false, + pending: true, + }), + listServiceVersions: async () => ({ php: [], database: [], webServer: [], note: '' }), + enableAgentTools: async () => mockStatus, + disableAgentTools: async () => ({ ...mockStatus, enabled: false, mcpUrl: null }), + getAgentToolsStatus: async () => [mockStatus], + createPreview: async (parentSiteId, label) => ({ ...mockPreview, parentSiteId, name: `Test Site - ${label}` }), + listPreviews: async () => [mockPreview], + destroyPreview: async (siteId) => ({ id: siteId, name: 'Test Site - fix', deleted: true as const }), }; + /** Run the MCP handshake against an endpoint and return its session id. */ + async function initSession(path: string): Promise { + const res = await makeRequest(port, { + method: 'POST', + path, + headers: { ...AUTH_HEADERS, Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'test', version: '1.0' }, + }, + }), + }); + return res.headers['mcp-session-id'] as string; + } + + /** Send a JSON-RPC request on an established session. */ + async function rpc(path: string, sessionId: string, method: string, params: unknown = {}) { + const res = await makeRequest(port, { + method: 'POST', + path, + headers: { + ...AUTH_HEADERS, + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method, params }), + }); + return JSON.parse(res.body); + } + beforeAll(async () => { registry.register({ siteId: 'test-site', @@ -63,6 +146,8 @@ describe('MCP HTTP Server', () => { const body = JSON.parse(res.body); expect(body.status).toBe('ok'); expect(body.activeSessions).toBeDefined(); + expect(body.sites).toBeUndefined(); + expect(body.globalEndpoint).toBe('/sites/mcp'); }); it('POST /sites/{siteId}/mcp with initialize creates session', async () => { @@ -140,4 +225,85 @@ describe('MCP HTTP Server', () => { const body = JSON.parse(res.body); expect(body.error.code).toBe(-32000); }); + + // ── Global endpoint ───────────────────────────────────────────────── + + describe('global endpoint /sites/mcp', () => { + it('accepts initialize without any site being registered', async () => { + const res = await makeRequest(port, { + method: 'POST', + path: '/sites/mcp', + headers: { ...AUTH_HEADERS, Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'test', version: '1.0' }, + }, + }), + }); + expect(res.statusCode).toBe(200); + expect(res.headers['mcp-session-id']).toBeDefined(); + }); + + it('advertises the Local-wide tools but not the site-scoped ones', async () => { + const sessionId = await initSession('/sites/mcp'); + const body = await rpc('/sites/mcp', sessionId, 'tools/list'); + const names = body.result.tools.map((t: { name: string }) => t.name); + + expect(names).toContain('list_sites'); + expect(names).toContain('create_site'); + expect(names).toContain('enable_agent_tools'); + expect(names).toContain('disable_agent_tools'); + expect(names).toContain('agent_tools_status'); + + expect(names).not.toContain('wp_cli'); + expect(names).not.toContain('read_error_log'); + expect(names).not.toContain('get_site_info'); + }); + + it('runs a Local-wide tool with no bound site', async () => { + const sessionId = await initSession('/sites/mcp'); + const body = await rpc('/sites/mcp', sessionId, 'tools/call', { + name: 'agent_tools_status', + arguments: {}, + }); + expect(body.result.content[0].text).toContain('test-site'); + }); + + it('refuses a site-scoped tool and points at the per-site endpoint', async () => { + const sessionId = await initSession('/sites/mcp'); + const body = await rpc('/sites/mcp', sessionId, 'tools/call', { + name: 'wp_cli', + arguments: { command: 'plugin list' }, + }); + expect(body.result.content[0].text).toContain('not available on the global endpoint'); + expect(body.result.content[0].text).toContain('/sites/{siteId}/mcp'); + }); + + it('still serves the full surface on a per-site endpoint', async () => { + const sessionId = await initSession('/sites/test-site/mcp'); + const body = await rpc('/sites/test-site/mcp', sessionId, 'tools/list'); + const names = body.result.tools.map((t: { name: string }) => t.name); + + expect(names).toContain('wp_cli'); + expect(names).toContain('list_sites'); + expect(names).toContain('enable_agent_tools'); + }); + + it('does not shadow a site whose id is literally "mcp"', async () => { + // /sites/mcp is the global route; a site called "mcp" lives at /sites/mcp/mcp. + const res = await makeRequest(port, { + method: 'POST', + path: '/sites/mcp/mcp', + headers: AUTH_HEADERS, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }), + }); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).error).toContain('Site not registered: mcp'); + }); + }); }); diff --git a/tests/tools/__snapshots__/definitions.test.ts.snap b/tests/tools/__snapshots__/definitions.test.ts.snap index 3e4b92d..7f10fad 100644 --- a/tests/tools/__snapshots__/definitions.test.ts.snap +++ b/tests/tools/__snapshots__/definitions.test.ts.snap @@ -319,7 +319,7 @@ exports[`Tool definitions snapshot > matches the expected tool API surface 1`] = "type": "string", }, "siteId": { - "description": "The Local site ID of the site to clone. Optional — defaults to the current site.", + "description": "The Local site ID of the site to clone. Optional on a per-site endpoint, where it defaults to the current site. Required on the global endpoint.", "type": "string", }, }, @@ -349,6 +349,65 @@ exports[`Tool definitions snapshot > matches the expected tool API surface 1`] = }, "name": "preview_destroy", }, + { + "inputSchema": { + "properties": { + "agents": { + "description": "Which agents to configure. Optional — defaults to ["claude"].", + "items": { + "enum": [ + "claude", + "cursor", + "windsurf", + "vscode", + ], + "type": "string", + }, + "type": "array", + }, + "projectDir": { + "description": "Subdirectory of the site folder to write the agent config into, relative to the site root (e.g. "app/public/wp-content/themes/my-theme"). Optional — defaults to the site root. Must stay inside the site folder.", + "type": "string", + }, + "siteId": { + "description": "The Local site ID. Use list_sites or agent_tools_status to find it.", + "type": "string", + }, + }, + "required": [ + "siteId", + ], + "type": "object", + }, + "name": "enable_agent_tools", + }, + { + "inputSchema": { + "properties": { + "siteId": { + "description": "The Local site ID. Use list_sites or agent_tools_status to find it.", + "type": "string", + }, + }, + "required": [ + "siteId", + ], + "type": "object", + }, + "name": "disable_agent_tools", + }, + { + "inputSchema": { + "properties": { + "siteId": { + "description": "Optional — restrict the report to a single site.", + "type": "string", + }, + }, + "type": "object", + }, + "name": "agent_tools_status", + }, ] `; @@ -441,5 +500,17 @@ Always pass a "label" describing the purpose of the preview (the task, branch, o "description": "Permanently delete a preview site, with its files moved to trash. ONLY works on sites created by preview_start and refuses to delete regular sites.", "name": "preview_destroy", }, + { + "description": "Enable Agent Tools on a Local site — registers it with this MCP server and writes its MCP config and project context files for the chosen agents. Same effect as clicking Enable in Local's UI. Returns the site's own MCP endpoint URL, which is the endpoint to use for site-scoped tools like wp_cli and the log readers.", + "name": "enable_agent_tools", + }, + { + "description": "Disable Agent Tools on a Local site — unregisters it from this MCP server, closes its open MCP sessions, and removes the MCP config entry and project context it wrote. Leaves the site itself untouched. No-op if the site was not enabled.", + "name": "disable_agent_tools", + }, + { + "description": "Report Agent Tools state for Local sites: whether it is enabled, which agents are configured, the project directory its config was written to, whether the site is currently registered with this MCP server, and the site's MCP endpoint URL. Omit siteId to report on every site.", + "name": "agent_tools_status", + }, ] `; diff --git a/tests/tools/agent-tools.test.ts b/tests/tools/agent-tools.test.ts new file mode 100644 index 0000000..da7a762 --- /dev/null +++ b/tests/tools/agent-tools.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect } from 'vitest'; +import { + toolDefinitions, + handleTool, + parseSiteIdArg, + parseAgentsArg, + parseProjectDirArg, +} from '../../src/tools/agent-tools'; +import type { AgentToolsSiteStatus, LocalApi } from '../../src/tools/environment'; + +const status: AgentToolsSiteStatus = { + id: 'abc123', + name: 'Test Site', + domain: 'test.local', + sitePath: '/tmp/test-site', + projectDir: '', + enabled: true, + agents: ['claude'], + registered: true, + mcpUrl: 'http://localhost:24842/sites/abc123/mcp', +}; + +function makeLocalApi(overrides: Partial = {}): LocalApi { + return { + startSite: async () => ({ id: 'abc123', status: 'running' }), + stopSite: async () => ({ id: 'abc123', status: 'halted' }), + restartSite: async () => ({ id: 'abc123', status: 'running' }), + getSiteStatus: async () => ({ id: 'abc123', status: 'running' }), + listSites: async () => [], + createSite: async () => { + throw new Error('not used'); + }, + listServiceVersions: async () => ({ php: [], database: [], webServer: [], note: '' }), + enableAgentTools: async () => status, + disableAgentTools: async () => ({ ...status, enabled: false, mcpUrl: null }), + getAgentToolsStatus: async () => [status], + ...overrides, + }; +} + +describe('agent-tools tool definitions', () => { + it('exposes the three management tools', () => { + expect(toolDefinitions.map((t) => t.name)).toEqual([ + 'enable_agent_tools', + 'disable_agent_tools', + 'agent_tools_status', + ]); + }); + + it('requires siteId for enable and disable but not for status', () => { + const required = Object.fromEntries( + toolDefinitions.map((t) => [t.name, (t.inputSchema as { required?: string[] }).required ?? []]), + ); + expect(required.enable_agent_tools).toEqual(['siteId']); + expect(required.disable_agent_tools).toEqual(['siteId']); + expect(required.agent_tools_status).toEqual([]); + }); +}); + +describe('parseSiteIdArg', () => { + it('accepts and trims a non-empty string', () => { + expect(parseSiteIdArg({ siteId: ' abc123 ' })).toBe('abc123'); + }); + + it('rejects missing, empty and non-string values', () => { + expect(parseSiteIdArg({})).toHaveProperty('error'); + expect(parseSiteIdArg({ siteId: ' ' })).toHaveProperty('error'); + expect(parseSiteIdArg({ siteId: 42 })).toHaveProperty('error'); + }); +}); + +describe('parseAgentsArg', () => { + it('returns undefined when omitted so the caller can default', () => { + expect(parseAgentsArg({})).toBeUndefined(); + }); + + it('accepts known agents', () => { + expect(parseAgentsArg({ agents: ['claude', 'cursor'] })).toEqual(['claude', 'cursor']); + }); + + it('rejects unknown agents and empty arrays', () => { + expect(parseAgentsArg({ agents: ['emacs'] })).toHaveProperty('error'); + expect(parseAgentsArg({ agents: [] })).toHaveProperty('error'); + expect(parseAgentsArg({ agents: 'claude' })).toHaveProperty('error'); + }); +}); + +describe('parseProjectDirArg', () => { + it('defaults to the site root', () => { + expect(parseProjectDirArg({})).toBe(''); + expect(parseProjectDirArg({ projectDir: ' ' })).toBe(''); + expect(parseProjectDirArg({ projectDir: '.' })).toBe(''); + }); + + it('accepts a nested relative path', () => { + expect(parseProjectDirArg({ projectDir: 'app/public/wp-content/themes/mine' })).toBe( + 'app/public/wp-content/themes/mine', + ); + }); + + it('strips a trailing separator', () => { + expect(parseProjectDirArg({ projectDir: 'app/public/' })).toBe('app/public'); + }); + + // projectDir is joined onto the site path in the main process, so traversal + // out of the site folder would write agent config anywhere on disk. + it('rejects absolute paths', () => { + expect(parseProjectDirArg({ projectDir: '/etc' })).toHaveProperty('error'); + expect(parseProjectDirArg({ projectDir: 'C:\\Windows' })).toHaveProperty('error'); + }); + + it('rejects traversal out of the site folder', () => { + expect(parseProjectDirArg({ projectDir: '..' })).toHaveProperty('error'); + expect(parseProjectDirArg({ projectDir: '../../../etc' })).toHaveProperty('error'); + expect(parseProjectDirArg({ projectDir: 'app/../../escape' })).toHaveProperty('error'); + }); + + it('rejects non-string values', () => { + expect(parseProjectDirArg({ projectDir: 5 })).toHaveProperty('error'); + }); +}); + +describe('handleTool', () => { + it('enable_agent_tools passes parsed options through and returns the status', async () => { + let received: unknown; + const api = makeLocalApi({ + enableAgentTools: async (options) => { + received = options; + return status; + }, + }); + + const result = await handleTool( + 'enable_agent_tools', + { siteId: 'abc123', agents: ['cursor'], projectDir: 'app/public' }, + api, + ); + + expect(received).toEqual({ siteId: 'abc123', agents: ['cursor'], projectDir: 'app/public' }); + expect(result.content[0].text).toContain('abc123'); + expect(result.content[0].text).toContain('/sites/abc123/mcp'); + }); + + it('enable_agent_tools leaves agents undefined so the main process defaults it', async () => { + let received: { agents?: unknown } = {}; + const api = makeLocalApi({ + enableAgentTools: async (options) => { + received = options; + return status; + }, + }); + + await handleTool('enable_agent_tools', { siteId: 'abc123' }, api); + expect(received.agents).toBeUndefined(); + }); + + it('enable_agent_tools rejects a traversing projectDir before calling Local', async () => { + let called = false; + const api = makeLocalApi({ + enableAgentTools: async () => { + called = true; + return status; + }, + }); + + const result = await handleTool('enable_agent_tools', { siteId: 'abc123', projectDir: '../..' }, api); + expect(called).toBe(false); + expect(result.content[0].text).toContain('must stay inside the site folder'); + }); + + it('disable_agent_tools reports the site as no longer enabled', async () => { + const result = await handleTool('disable_agent_tools', { siteId: 'abc123' }, makeLocalApi()); + expect(JSON.parse(result.content[0].text)).toMatchObject({ enabled: false, mcpUrl: null }); + }); + + it('agent_tools_status reports every site when siteId is omitted', async () => { + const api = makeLocalApi({ + getAgentToolsStatus: async (siteId) => { + expect(siteId).toBeUndefined(); + return [status, { ...status, id: 'def456', enabled: false, mcpUrl: null }]; + }, + }); + + const result = await handleTool('agent_tools_status', {}, api); + expect(JSON.parse(result.content[0].text)).toHaveLength(2); + }); + + it('surfaces a Local error as tool text rather than throwing', async () => { + const api = makeLocalApi({ + enableAgentTools: async () => { + throw new Error('Site not found: nope'); + }, + }); + + const result = await handleTool('enable_agent_tools', { siteId: 'nope' }, api); + expect(result.content[0].text).toContain('Failed to enable Agent Tools'); + expect(result.content[0].text).toContain('Site not found: nope'); + }); + + it('returns an unknown tool message for a name it does not own', async () => { + const result = await handleTool('nope', {}, makeLocalApi()); + expect(result.content[0].text).toContain('Unknown tool: nope'); + }); +}); diff --git a/tests/tools/index.test.ts b/tests/tools/index.test.ts index db2cb82..e048164 100644 --- a/tests/tools/index.test.ts +++ b/tests/tools/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { allToolDefinitions, handleToolCall } from '../../src/tools/index'; +import { allToolDefinitions, globalToolDefinitions, siteScopedToolNames, handleToolCall } from '../../src/tools/index'; import type { SiteConfig } from '../../src/helpers/site-config'; import type { LocalApi } from '../../src/tools/environment'; @@ -46,6 +46,21 @@ const mockLocalApi: LocalApi = { pending: true, }), listServiceVersions: async () => ({ php: [], database: [], webServer: [], note: '' }), + enableAgentTools: async () => mockAgentToolsStatus, + disableAgentTools: async () => ({ ...mockAgentToolsStatus, enabled: false, mcpUrl: null }), + getAgentToolsStatus: async () => [mockAgentToolsStatus], +}; + +const mockAgentToolsStatus = { + id: 'test-site', + name: 'Test Site', + domain: 'test.local', + sitePath: '/tmp/test-site', + projectDir: '', + enabled: true, + agents: ['claude' as const], + registered: true, + mcpUrl: 'http://localhost:24842/sites/test-site/mcp', }; describe('allToolDefinitions', () => { @@ -60,6 +75,9 @@ describe('allToolDefinitions', () => { expect(names).toContain('list_sites'); expect(names).toContain('create_site'); expect(names).toContain('list_service_versions'); + expect(names).toContain('enable_agent_tools'); + expect(names).toContain('disable_agent_tools'); + expect(names).toContain('agent_tools_status'); }); it('each tool has name, description, and inputSchema', () => { @@ -72,6 +90,37 @@ describe('allToolDefinitions', () => { }); }); +describe('globalToolDefinitions', () => { + it('is a strict subset of the full surface', () => { + const all = new Set(allToolDefinitions.map((t) => t.name)); + for (const tool of globalToolDefinitions) { + expect(all.has(tool.name)).toBe(true); + } + expect(globalToolDefinitions.length).toBeLessThan(allToolDefinitions.length); + }); + + it('carries the Local-wide tools', () => { + const names = globalToolDefinitions.map((t) => t.name); + expect(names).toContain('list_sites'); + expect(names).toContain('create_site'); + expect(names).toContain('list_service_versions'); + expect(names).toContain('site_start'); + expect(names).toContain('enable_agent_tools'); + expect(names).toContain('disable_agent_tools'); + expect(names).toContain('agent_tools_status'); + }); + + it('excludes every tool that needs a bound site', () => { + const names = globalToolDefinitions.map((t) => t.name); + for (const scoped of siteScopedToolNames) { + expect(names).not.toContain(scoped); + } + expect([...siteScopedToolNames]).toContain('wp_cli'); + expect([...siteScopedToolNames]).toContain('read_error_log'); + expect([...siteScopedToolNames]).toContain('get_site_info'); + }); +}); + describe('handleToolCall', () => { it('returns error for unknown tool name listing available tools', async () => { const result = await handleToolCall('nonexistent_tool', {}, mockConfig, mockLocalApi); @@ -79,4 +128,26 @@ describe('handleToolCall', () => { expect(result.content[0].text).toContain('nonexistent_tool'); expect(result.content[0].text).toContain('wp_cli'); }); + + it('lists only the global tools when there is no bound site', async () => { + const result = await handleToolCall('nonexistent_tool', {}, null, mockLocalApi); + expect(result.content[0].text).toContain('list_sites'); + expect(result.content[0].text).not.toContain('wp_cli'); + }); + + it('refuses a site-scoped tool with no bound site', async () => { + const result = await handleToolCall('wp_cli', { command: 'plugin list' }, null, mockLocalApi); + expect(result.content[0].text).toContain('not available on the global endpoint'); + expect(result.content[0].text).toContain('/sites/{siteId}/mcp'); + }); + + it('runs a Local-wide tool with no bound site', async () => { + const result = await handleToolCall('agent_tools_status', {}, null, mockLocalApi); + expect(result.content[0].text).toContain('test-site'); + }); + + it('requires an explicit siteId for site_status with no bound site', async () => { + const result = await handleToolCall('site_status', {}, null, mockLocalApi); + expect(result.content[0].text).toContain('No siteId provided'); + }); }); From e39530c933e67685eb4c4e328182124091b9a5cf Mon Sep 17 00:00:00 2001 From: Christian Chung Date: Sun, 4 Oct 2026 21:29:30 -0400 Subject: [PATCH 2/2] fix(mcp): keep previews registered on disable and test global endpoint auth The global endpoint now has tests that prove it sits behind the same bearer token and Host/Origin checks as the per-site endpoint, including on a live global session. New tests also cover the preview tools on the global endpoint. disable_agent_tools no longer unregisters a preview site, so the endpoint that preview_start returned keeps working. The changelog entry moves from the released 0.3.0 section to Unreleased. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QkatqVq5EZnCMVQwGJnSCU --- CHANGELOG.md | 9 ++- src/main.ts | 10 +++- tests/auth.test.ts | 116 +++++++++++++++++++++++++++++++++++++-- tests/mcp-server.test.ts | 44 +++++++++++++++ tests/security.test.ts | 31 ++++++++++- 5 files changed, 199 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e7a844..69e35ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file, per [the Ke ## [Unreleased] +### Added + +- Global MCP endpoint at `/sites/mcp`, not bound to any site — configure it once (e.g. in `~/.claude.json`) and manage Local from anywhere, instead of needing an enabled site's folder open first. It requires the same bearer token and passes the same `Host` and `Origin` checks as the per-site endpoints. It serves the Local-wide tools (`list_sites`, `create_site`, `list_service_versions`), the site lifecycle tools, the preview tools (`preview_start` needs an explicit `siteId` there), and the new Agent Tools management tools. Site-scoped tools stay on `/sites/{siteId}/mcp` and return an error pointing there if called globally. +- `enable_agent_tools` and `disable_agent_tools` MCP tools — turn Agent Tools on or off for an existing site over MCP, the same as clicking Enable in Local's UI. `enable_agent_tools` returns the site's own MCP endpoint URL, so an agent can bootstrap from the global endpoint to a site-scoped one. +- `agent_tools_status` MCP tool — report which sites have Agent Tools enabled, which agents are configured, the project directory, whether the site is registered with the MCP server, and each site's MCP endpoint URL. + ## [0.3.1] - 2026-09-22 ### Fixed @@ -27,9 +33,6 @@ All notable changes to this project will be documented in this file, per [the Ke ### Added -- Global MCP endpoint at `/sites/mcp`, not bound to any site — configure it once (e.g. in `~/.claude.json`) and manage Local from anywhere, instead of needing an enabled site's folder open first. It serves the Local-wide tools (`list_sites`, `create_site`, `list_service_versions`), the site lifecycle tools, and the new Agent Tools management tools. Site-scoped tools stay on `/sites/{siteId}/mcp` and return an error pointing there if called globally. -- `enable_agent_tools` and `disable_agent_tools` MCP tools — turn Agent Tools on or off for an existing site over MCP, the same as clicking Enable in Local's UI. `enable_agent_tools` returns the site's own MCP endpoint URL, so an agent can bootstrap from the global endpoint to a site-scoped one. -- `agent_tools_status` MCP tool — report which sites have Agent Tools enabled, which agents are configured, the project directory, whether the site is registered with the MCP server, and each site's MCP endpoint URL. - `create_site` MCP tool — create a new WordPress site in Local, with optional PHP / database / web server versions, multisite mode, WordPress admin credentials, and Xdebug. Returns as soon as the site is registered so the call does not outlive the MCP client's request timeout; poll `site_status` until the site reports `running`, or pass `wait: true` to block (props [@ivanlopez](https://github.com/ivanlopez) via [#80](https://github.com/10up/localwp-agent-tools/pull/80)). - `create_site` can enable Agent Tools on the site it creates via `enableAgentTools`, registering it with the MCP server and writing its MCP config and context files for the agents named in `agents` (props [@ivanlopez](https://github.com/ivanlopez) via [#80](https://github.com/10up/localwp-agent-tools/pull/80)). - `list_service_versions` MCP tool — list the PHP, database, and web server versions available to `create_site`, flagging which are already installed versus downloaded on demand (props [@ivanlopez](https://github.com/ivanlopez) via [#80](https://github.com/10up/localwp-agent-tools/pull/80)). diff --git a/src/main.ts b/src/main.ts index af5fed6..2465fd6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -477,9 +477,13 @@ async function teardownSite(site: Local.Site, notifier: any): Promise { const projectPath = getProjectPath(sitePath, projectDir); const agents = getStoredAgents(site); - // 1. Unregister from config registry and close MCP sessions - siteConfigRegistry.unregister(site.id); - closeSessionsForSite(site.id); + // 1. Unregister from config registry and close MCP sessions. A preview stays + // registered: preview_start handed out its endpoint, and the startup loop + // registers every preview whether or not Agent Tools is enabled on it. + if (!isPreviewSite(site)) { + siteConfigRegistry.unregister(site.id); + closeSessionsForSite(site.id); + } // 2. Remove legacy .agent-tools/ directory if present (from old stdio architecture) await fs.remove(path.join(sitePath, '.agent-tools')); diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 0099008..fd0d310 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -104,7 +104,14 @@ describe('MCP HTTP Server: bearer auth + DNS-rebinding protection', () => { // method/path pair may reach a route without the token. describe('no method on any path is reachable without the token', () => { const methods = ['GET', 'POST', 'DELETE', 'PUT', 'OPTIONS', 'HEAD']; - const paths = ['/health', '/sites/test-site/mcp', '/sites/unknown/mcp', '/nope']; + const paths = [ + '/health', + '/sites/test-site/mcp', + '/sites/unknown/mcp', + '/sites/mcp', + '/sites/mcp/mcp', + '/nope', + ]; for (const method of methods) { for (const path of paths) { @@ -335,6 +342,87 @@ describe('MCP HTTP Server: bearer auth + DNS-rebinding protection', () => { }); }); + // The global endpoint /sites/mcp has no bound site, but it sits behind the + // same gates. A live global session must never let a request skip auth. + describe('global endpoint session — every method still requires Authorization', () => { + let sessionId: string; + + beforeAll(async () => { + const res = await makeRequest(port, { + method: 'POST', + path: '/sites/mcp', + headers: { ...AUTH_HEADERS, Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'test', version: '1.0' }, + }, + }), + }); + expect(res.statusCode).toBe(200); + sessionId = res.headers['mcp-session-id'] as string; + expect(sessionId).toBeDefined(); + }); + + it('rejects initialize on the global endpoint with no Authorization header', async () => { + const res = await makeRequest(port, { + method: 'POST', + path: '/sites/mcp', + headers: { Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }), + }); + expect(res.statusCode).toBe(401); + expect(JSON.parse(res.body)).toEqual(UNAUTHORIZED_BODY); + }); + + it('rejects initialize on the global endpoint with the wrong token', async () => { + const res = await makeRequest(port, { + method: 'POST', + path: '/sites/mcp', + headers: { Authorization: 'Bearer wrong-token', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }), + }); + expect(res.statusCode).toBe(401); + }); + + it('rejects a management tool call on a live global session with no Authorization header', async () => { + const res = await makeRequest(port, { + method: 'POST', + path: '/sites/mcp', + headers: { 'mcp-session-id': sessionId, Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'enable_agent_tools', arguments: { siteId: 'test-site' } }, + }), + }); + expect(res.statusCode).toBe(401); + }); + + it('rejects a GET (SSE stream) on a live global session with no Authorization header', async () => { + const res = await makeRequest(port, { + method: 'GET', + path: '/sites/mcp', + headers: { 'mcp-session-id': sessionId }, + }); + expect(res.statusCode).toBe(401); + }); + + it('rejects a DELETE on a live global session with no Authorization header', async () => { + const res = await makeRequest(port, { + method: 'DELETE', + path: '/sites/mcp', + headers: { 'mcp-session-id': sessionId }, + }); + expect(res.statusCode).toBe(401); + }); + }); + // Both rebinding layers — our own Host/Origin gate and the SDK's // `allowedHosts`/`allowedOrigins` on the transport — have to accept and // reject the same set. They do the same exact compare against the same two @@ -384,7 +472,7 @@ describe('MCP HTTP Server: bearer auth + DNS-rebinding protection', () => { }); } - it.each(rows)('%s → %d on /health and on the MCP route', async (_label, expected, buildHeaders) => { + it.each(rows)('%s → %d on /health and on both MCP routes', async (_label, expected, buildHeaders) => { const rowHeaders = buildHeaders(port); const health = await makeRequest(port, { @@ -404,13 +492,26 @@ describe('MCP HTTP Server: bearer auth + DNS-rebinding protection', () => { body: initializeBody(), }); + const globalMcp = await makeRequest(port, { + method: 'POST', + path: '/sites/mcp', + headers: { + ...AUTH_HEADERS, + Accept: 'application/json, text/event-stream', + ...rowHeaders, + }, + body: initializeBody(), + }); + expect(health.statusCode).toBe(expected); expect(mcp.statusCode).toBe(health.statusCode); + expect(globalMcp.statusCode).toBe(health.statusCode); if (expected === 200) { - // A 200 on the MCP route only counts if the handshake actually + // A 200 on an MCP route only counts if the handshake actually // completed — the transport's own check has to have passed too. expect(mcp.headers['mcp-session-id']).toBeDefined(); + expect(globalMcp.headers['mcp-session-id']).toBeDefined(); } else { expect(JSON.parse(health.body)).toEqual({ error: 'Forbidden host' }); expect(health.headers['cache-control']).toBe('no-store'); @@ -421,7 +522,7 @@ describe('MCP HTTP Server: bearer auth + DNS-rebinding protection', () => { // server-side parser rejects an HTTP/1.1 request without one before our // handler runs, so the missing-Host row needs a raw HTTP/1.0 request. // The gate rejects before any body is read, so no body is needed. - it('missing Host header → 403 on /health and on the MCP route', async () => { + it('missing Host header → 403 on /health and on both MCP routes', async () => { const health = await makeRawRequest(port, { method: 'GET', path: '/health', @@ -433,8 +534,15 @@ describe('MCP HTTP Server: bearer auth + DNS-rebinding protection', () => { headers: AUTH_HEADERS, }); + const globalMcp = await makeRawRequest(port, { + method: 'POST', + path: '/sites/mcp', + headers: AUTH_HEADERS, + }); + expect(health.statusCode).toBe(403); expect(mcp.statusCode).toBe(403); + expect(globalMcp.statusCode).toBe(403); }); }); }); diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 3e7ae79..34bd315 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -259,6 +259,9 @@ describe('MCP HTTP Server', () => { expect(names).toContain('enable_agent_tools'); expect(names).toContain('disable_agent_tools'); expect(names).toContain('agent_tools_status'); + expect(names).toContain('preview_start'); + expect(names).toContain('preview_list'); + expect(names).toContain('preview_destroy'); expect(names).not.toContain('wp_cli'); expect(names).not.toContain('read_error_log'); @@ -284,6 +287,47 @@ describe('MCP HTTP Server', () => { expect(body.result.content[0].text).toContain('/sites/{siteId}/mcp'); }); + it('refuses a site-scoped tool even when it names a siteId', async () => { + const sessionId = await initSession('/sites/mcp'); + const body = await rpc('/sites/mcp', sessionId, 'tools/call', { + name: 'wp_cli', + arguments: { command: 'plugin list', siteId: 'test-site' }, + }); + expect(body.result.content[0].text).toContain('not available on the global endpoint'); + }); + + it('refuses preview_start without a siteId, since no site is bound', async () => { + const sessionId = await initSession('/sites/mcp'); + const body = await rpc('/sites/mcp', sessionId, 'tools/call', { + name: 'preview_start', + arguments: { label: 'fix' }, + }); + expect(body.result.content[0].text).toContain('preview_start needs a siteId on the global endpoint'); + }); + + it('runs preview_start with an explicit siteId', async () => { + const sessionId = await initSession('/sites/mcp'); + const body = await rpc('/sites/mcp', sessionId, 'tools/call', { + name: 'preview_start', + arguments: { siteId: 'test-site', label: 'fix' }, + }); + const result = JSON.parse(body.result.content[0].text); + expect(result.parentSiteId).toBe('test-site'); + expect(result.mcpUrl).toContain('/sites/preview-site/mcp'); + }); + + it('runs preview_list and preview_destroy with no bound site', async () => { + const sessionId = await initSession('/sites/mcp'); + const list = await rpc('/sites/mcp', sessionId, 'tools/call', { name: 'preview_list', arguments: {} }); + expect(list.result.content[0].text).toContain('preview-site'); + + const destroyed = await rpc('/sites/mcp', sessionId, 'tools/call', { + name: 'preview_destroy', + arguments: { siteId: 'preview-site' }, + }); + expect(JSON.parse(destroyed.result.content[0].text).deleted).toBe(true); + }); + it('still serves the full surface on a per-site endpoint', async () => { const sessionId = await initSession('/sites/test-site/mcp'); const body = await rpc('/sites/test-site/mcp', sessionId, 'tools/list'); diff --git a/tests/security.test.ts b/tests/security.test.ts index 0038a74..20da8e1 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { splitArgs, isBlockedCommand } from '../src/tools/wpcli'; -import { allToolDefinitions } from '../src/tools'; +import { allToolDefinitions, globalToolDefinitions } from '../src/tools'; describe('Security: isBlockedCommand', () => { it('blocks eval', () => { @@ -141,3 +141,32 @@ describe('Security: input validation in edit_wp_config', () => { expect(validValue.test('rm -rf /')).toBe(false); }); }); + +describe('Security: the global endpoint serves only Local-wide tools', () => { + // /sites/mcp has no bound site. A tool that reads or writes one site's files + // or database must stay on that site's own endpoint. + const SITE_SCOPED = [ + 'wp_cli', + 'read_error_log', + 'read_access_log', + 'read_wp_config', + 'edit_wp_config', + 'wp_debug_toggle', + 'get_site_info', + 'site_health_check', + ]; + + it('advertises no site-scoped tool', () => { + const names = globalToolDefinitions.map((t) => t.name); + for (const name of SITE_SCOPED) { + expect(names).not.toContain(name); + } + }); + + it('is a subset of the per-site surface, so the credential check above covers it', () => { + const all = new Set(allToolDefinitions.map((t) => t.name)); + for (const tool of globalToolDefinitions) { + expect(all.has(tool.name)).toBe(true); + } + }); +});