From 2456fa3709ffccd9ba955cd45be58ed10200073f Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Wed, 9 Sep 2026 09:24:42 -0400 Subject: [PATCH] (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 | 50 +++- src/main.ts | 138 ++++++++++- src/mcp-server.ts | 89 +++++--- src/tools/agent-tools.ts | 214 ++++++++++++++++++ src/tools/environment.ts | 37 ++- src/tools/index.ts | 66 +++++- tests/mcp-server.test.ts | 148 ++++++++++++ .../__snapshots__/definitions.test.ts.snap | 71 ++++++ tests/tools/agent-tools.test.ts | 204 +++++++++++++++++ tests/tools/index.test.ts | 73 +++++- 11 files changed, 1039 insertions(+), 54 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 3dcfa8f..70863bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,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 af47408..7859164 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,50 @@ 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). Sites remain registered even when stopped, so the MCP endpoint is always reachable. Tools that need running services (WP-CLI, database) return appropriate errors; file-based tools (config, logs, site info) work regardless. Config is refreshed on each tool call, so starting a site automatically makes database tools work without reconnecting. +### The global endpoint + +`/sites/mcp` is not tied to any site, so you configure it once and use it from anywhere — no need to open a particular site folder first. It serves the tools that address Local itself (`list_sites`, `create_site`, `list_service_versions`), the site lifecycle tools (`site_start` and friends, which take an explicit `siteId`), and the tools that turn Agent Tools on and off per site (`enable_agent_tools`, `disable_agent_tools`, `agent_tools_status`). + +That makes it the way to bootstrap: connect to the global endpoint, create or find a site, enable Agent Tools on it, and `enable_agent_tools` hands back that site's own endpoint URL for the site-scoped work. + +The site-scoped tools (`wp_cli`, the log readers, the wp-config tools, `get_site_info`, `site_health_check`) are deliberately not served here — they need a bound site, and calling one returns an error pointing at the per-site endpoint instead. + +To add it to Claude Code, using the persisted port: + +```bash +claude mcp add --scope user --transport http local-wp-global \ + "http://localhost:$(cat ~/.local-agent-tools/port)/sites/mcp" +``` + +`--scope user` is the part that makes it global. Without it `claude mcp add` defaults to `--scope local`, which registers the server only for the directory you ran it in — the opposite of the point here. Use `--scope project` instead if you want it committed to a repo's `.mcp.json` for the team. + +Or 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" } + } +} +``` + +`claude mcp list` confirms it connected. + +`curl http://localhost:{port}/health` lists the port's registered sites and confirms the global endpoint is up. + +One caveat: the global endpoint is reachable by any process on the machine, as the per-site endpoints already are — the server binds `127.0.0.1` and has no authentication. It widens what that means in practice, since site management and `create_site` are now reachable from one well-known URL rather than only from an enabled site's. + ## Supported Agents | Agent | MCP Config | Context File | @@ -36,7 +70,7 @@ Sites remain registered even when stopped, so the MCP endpoint is always reachab | Windsurf | `.windsurf/mcp.json` | `.windsurfrules` | | VS Code Copilot | `.vscode/mcp.json` | `.github/copilot-instructions.md` | -## MCP Tools (14 total) +## MCP Tools (17 total) | Category | Tools | Description | | --------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------- | @@ -55,6 +89,9 @@ Sites remain registered even when stopped, so the MCP endpoint is always reachab | | `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` | +| **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 @@ -148,19 +185,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 6cb129c..439ed6e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,8 +12,23 @@ import { } from './helpers/paths'; import { SiteConfig, SiteConfigRegistry } from './helpers/site-config'; import { findAvailablePort, savePort, removePortFile, removePortFileSync } from './helpers/port'; -import { createMcpHttpServer, startMcpHttpServer, stopMcpHttpServer, closeSessionsForSite } from './mcp-server'; -import { LocalApi, CreateSiteOptions, CreateSiteResult, ServiceVersion, ServiceVersions } from './tools'; +import { + createMcpHttpServer, + startMcpHttpServer, + stopMcpHttpServer, + closeSessionsForSite, + GLOBAL_MCP_PATH, +} from './mcp-server'; +import { + LocalApi, + CreateSiteOptions, + CreateSiteResult, + ServiceVersion, + ServiceVersions, + AgentToolsSiteStatus, + EnableAgentToolsOptions, + AgentName, +} from './tools'; import { BUILT_IN_SITE_DEFAULTS, NewSiteDefaults, @@ -193,8 +208,12 @@ async function buildSiteConfig(site: Local.Site): Promise { * Builds the MCP server entry for a specific agent. * Each agent has different JSON shapes for HTTP MCP servers. */ +function buildSiteMcpUrl(port: number, siteId: string): string { + return `http://localhost:${port}/sites/${siteId}/mcp`; +} + function buildMcpServerEntry(agent: AgentTarget, port: number, siteId: string): Record { - const url = `http://localhost:${port}/sites/${siteId}/mcp`; + const url = buildSiteMcpUrl(port, siteId); switch (agent) { case 'claude': @@ -603,6 +622,44 @@ async function updateAgents(site: Local.Site, newAgents: AgentTarget[], notifier }); } +/** + * 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; @@ -656,6 +713,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 // --------------------------------------------------------------------------- @@ -714,8 +798,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 { @@ -960,6 +1046,40 @@ function createLocalApi(options: LocalApiOptions): LocalApi { return describe(site, 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); + }, }; } @@ -972,7 +1092,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 @@ -999,6 +1120,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 (regardless of running status). // This ensures the MCP endpoint is always reachable — tools that need the site // running (WP-CLI, DB) will return appropriate errors; file-based tools still work. diff --git a/src/mcp-server.ts b/src/mcp-server.ts index db6c98c..e14b790 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -1,7 +1,7 @@ import * as http from 'http'; import { randomUUID } from 'crypto'; -import { SiteConfigRegistry } from './helpers/site-config'; -import { allToolDefinitions, handleToolCall, LocalApi } from './tools'; +import { SiteConfig, SiteConfigRegistry } from './helpers/site-config'; +import { allToolDefinitions, 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; } @@ -126,25 +127,30 @@ 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} (site: ${siteId})`); + console.log(`[Agent Tools] Tool called: ${name} (${siteId ? `site: ${siteId}` : 'global'})`); // 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(siteId); - if (!config) { - return { - content: [{ type: 'text', text: `Site ${siteId} is no longer registered.` }], - isError: true, - }; + let config: SiteConfig | null = null; + if (siteId) { + config = registry.get(siteId) ?? null; + if (!config) { + return { + content: [{ type: 'text', text: `Site ${siteId} is no longer registered.` }], + isError: true, + }; + } } try { @@ -188,6 +194,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$/); @@ -209,29 +222,43 @@ export function createMcpHttpServer(options: McpHttpServerOptions): http.Server if (url === '/health' && method === 'GET') { const sites = registry.getAllIds(); res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok', sites, 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; - } - - 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.`, + status: 'ok', + sites, + globalEndpoint: GLOBAL_MCP_PATH, + activeSessions: sessions.size, }), ); return; } + // Two MCP endpoints: the Local-wide one, and one per registered site. + // 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; + } + + 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; try { @@ -302,7 +329,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 f46e0b2..7aa7279 100644 --- a/src/tools/environment.ts +++ b/src/tools/environment.ts @@ -64,6 +64,30 @@ export interface ServiceVersions { note: 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 }>; @@ -79,6 +103,12 @@ export interface LocalApi { listSites(): Promise>; createSite(options: CreateSiteOptions): Promise; listServiceVersions(): Promise; + /** 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 ─────────────────────────────────────────────────── @@ -267,7 +297,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 { @@ -299,10 +329,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 2a77b3d..8e8fd7f 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -4,6 +4,7 @@ import { toolDefinitions as logTools, handleTool as handleLogTool } from './logs import { toolDefinitions as configTools, handleTool as handleConfigTool } from './config'; import { toolDefinitions as siteTools, handleTool as handleSiteTool } from './site'; import { toolDefinitions as environmentTools, handleTool as handleEnvironmentTool, LocalApi } from './environment'; +import { toolDefinitions as agentToolsTools, handleTool as handleAgentToolsTool } from './agent-tools'; export type { LocalApi, @@ -13,59 +14,106 @@ export type { AgentName, ServiceVersion, ServiceVersions, + AgentToolsSiteStatus, + EnableAgentToolsOptions, } from './environment'; export type ToolResult = { content: Array<{ type: string; text: string }> }; -// All tool definitions aggregated -export const allToolDefinitions = [...wpcliTools, ...logTools, ...configTools, ...siteTools, ...environmentTools]; +/** + * 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]; + +/** + * Tools that address Local itself, or address a site by an explicit siteId argument. + * Reachable from both the global endpoint and any per-site endpoint. + */ +const globalTools = [...environmentTools, ...agentToolsTools]; + +/** Full surface, served on a per-site endpoint. */ +export const allToolDefinitions = [...siteScopedTools, ...globalTools]; + +/** Subset served on the global endpoint (/sites/mcp), where no site is bound. */ +export const globalToolDefinitions = [...globalTools]; + +/** Names of the tools that cannot run without a bound site. */ +export const siteScopedToolNames = new Set(siteScopedTools.map((t) => 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); } +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/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 755f809..4a9c5e0 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -45,14 +45,81 @@ 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 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], }; + /** 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: { 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: { + 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', @@ -95,6 +162,7 @@ describe('MCP HTTP Server', () => { const body = JSON.parse(res.body); expect(body.status).toBe('ok'); expect(body.sites).toContain('test-site'); + expect(body.globalEndpoint).toBe('/sites/mcp'); }); it('POST /sites/{siteId}/mcp with initialize creates session', async () => { @@ -167,4 +235,84 @@ 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: { 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', + 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 c2952d3..7cdb413 100644 --- a/tests/tools/__snapshots__/definitions.test.ts.snap +++ b/tests/tools/__snapshots__/definitions.test.ts.snap @@ -272,6 +272,65 @@ exports[`Tool definitions snapshot > matches the expected tool API surface 1`] = }, "name": "create_site", }, + { + "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", + }, ] `; @@ -348,5 +407,17 @@ Always confirm with the user before running these commands.", "description": "Create a new WordPress site in Local. Provisions the site services and installs WordPress. Returns as soon as the site exists (usually within a second) — provisioning continues in the background and typically takes one to several minutes, longer when service binaries must be downloaded first. Poll site_status with the returned id until it reports "running". Pass wait:true to block until the site is fully ready instead. Note: unless Local is set to localhost router mode, Local will prompt the user for their administrator password to update /etc/hosts partway through.", "name": "create_site", }, + { + "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'); + }); });