Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 57 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 <token from ~/.local-agent-tools/token>" }
}
}
}
```

`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 |
Expand All @@ -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 |
| --------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
150 changes: 142 additions & 8 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
findWpCli,
} from './helpers/paths';
import { SiteConfig, SiteConfigRegistry } from './helpers/site-config';
import { findAvailablePort, savePort, removePortFile, removePortFileSync } from './helpers/port';

Check warning on line 14 in src/main.ts

View workflow job for this annotation

GitHub Actions / Lint

'removePortFile' is defined but never used
import { getOrCreateToken } from './helpers/auth';
import {
AgentTarget,
Expand All @@ -22,8 +22,24 @@
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,
Expand Down Expand Up @@ -227,6 +243,13 @@
// 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.
Expand Down Expand Up @@ -394,7 +417,7 @@
// Core Functions
// ---------------------------------------------------------------------------

async function setupSite(site: Local.Site, notifier: any, projectDir: string, agents: AgentTarget[]): Promise<void> {

Check warning on line 420 in src/main.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
requireAuthToken();

const sitePath = getSitePath(site);
Expand Down Expand Up @@ -448,15 +471,19 @@
});
}

async function teardownSite(site: Local.Site, notifier: any): Promise<void> {

Check warning on line 474 in src/main.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
const sitePath = getSitePath(site);
const projectDir = getStoredProjectDir(site);
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'));
Expand Down Expand Up @@ -487,7 +514,7 @@
});
}

async function changeProjectDir(site: Local.Site, newProjectDir: string, notifier: any): Promise<void> {

Check warning on line 517 in src/main.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
requireAuthToken();

const sitePath = getSitePath(site);
Expand Down Expand Up @@ -538,7 +565,7 @@
});
}

async function updateAgents(site: Local.Site, newAgents: AgentTarget[], notifier: any): Promise<void> {

Check warning on line 568 in src/main.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
if (!isAgentToolsEnabled(site)) return;
requireAuthToken();

Expand Down Expand Up @@ -615,6 +642,44 @@
}
}

/**
* 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<void> {
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<void> {
if (!isAgentToolsEnabled(site)) return;
requireAuthToken();
Expand Down Expand Up @@ -667,6 +732,33 @@
};
}

/**
* 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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -725,8 +817,10 @@
const siteCreationFailures = new Map<string, string>();

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<void>;
/** Enables Agent Tools on a site. Wired to setupSite() by the add-on entry point. */
enableAgentTools(site: Local.Site, agents: AgentTarget[], projectDir?: string): Promise<void>;
/** Disables Agent Tools on a site. Wired to teardownSite() by the add-on entry point. */
disableAgentTools(site: Local.Site): Promise<void>;
}

function createLocalApi(options: LocalApiOptions): LocalApi {
Expand Down Expand Up @@ -1299,6 +1393,40 @@

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);
},
};
}

Expand All @@ -1311,7 +1439,8 @@

let httpServer: ReturnType<typeof createMcpHttpServer> | 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
Expand Down Expand Up @@ -1349,6 +1478,11 @@

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
Expand Down
Loading
Loading