From 912ecd04f7da434659818b6b76935b2401bd1976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 18:53:22 +0100 Subject: [PATCH] a plugin can hand the agent its own MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openseo is the first: it fronts something that already ships an MCP server, and the choice was to reimplement that vocabulary as Officer tools or hand the real one over. Reimplementing means a second copy that goes stale, and it fails silently — a tool exists upstream that Officer cannot see. Handing it over costs one manifest field. What a wrapper would have bought — authentication, logging, refusal — is already true of the path, because the agent reaches it through Officer's own API with Officer's own gate in front. Wrapping would buy it twice. The manifest declares a PATH and never a URL, and that is the safety property rather than a convention: mcp-host.json carries OFFICER_AUTH_TOKEN, which signs as the owner, so a manifest able to name an arbitrary host would be a manifest able to post the owner's credential anywhere. A path can only reach this machine. Installed AND enabled, since a disabled plugin's routes are not mounted and offering the agent a tool list that 404s is worse than offering nothing. A plugin that cannot be read loses its tools for the session rather than stopping the agent from starting. `generateMcpConfig` becomes async because it now asks the database. Awaited, not fire-and-forget: setMcpConfigPath is what the manager reads when it spawns, so a config arriving later is an agent running without its plugin tools and no sign of why. Co-Authored-By: Claude Opus 5 --- src/servers/plugins/manifest.ts | 25 +++++++++++ src/servers/sidecar/claude/user-instance.ts | 49 ++++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/servers/plugins/manifest.ts b/src/servers/plugins/manifest.ts index 52fada86..62e6aa6d 100644 --- a/src/servers/plugins/manifest.ts +++ b/src/servers/plugins/manifest.ts @@ -523,6 +523,31 @@ export type PluginManifest = { */ hooks?: Partial>; + /** + * An MCP server this plugin serves, offered to the agent as its own. + * + * ── Why a path and not a URL ── + * + * The path is under this plugin's own mount, so `/mcp` becomes `/api//mcp` on THIS Officer. + * A URL would let a plugin point the agent at any host, and the agent's config carries a token that + * signs as the owner — so the one field that must not be free-form is the destination. + * + * ── Why this exists rather than wrapping the tools ── + * + * A plugin that fronts something with its own MCP server — openseo is the first — can either + * reimplement that vocabulary as Officer tools or hand the real one over. Reimplementing means a + * second copy that goes stale, and it fails silently: a tool exists upstream that Officer cannot see. + * Handing it over costs this field. + * + * What a wrapper would have bought — authentication, logging, refusal — is already true of the path, + * because the agent reaches it through Officer's own API with Officer's own gate in front. Wrapping + * would buy it a second time. + */ + mcp?: { + /** Under the plugin's mount. `/mcp` on a plugin mounted at `/api/openseo` is `/api/openseo/mcp`. */ + path: string; + }; + osDependencies?: OsDependency[]; /** diff --git a/src/servers/sidecar/claude/user-instance.ts b/src/servers/sidecar/claude/user-instance.ts index 16458cea..d2dfefc2 100644 --- a/src/servers/sidecar/claude/user-instance.ts +++ b/src/servers/sidecar/claude/user-instance.ts @@ -18,6 +18,8 @@ import { createSidecarConnector } from '../connect'; import { sign } from '../../jwt'; import { getUserByEmail, getOwnerUser } from 'officerdb'; import { DATA_PATH, findEmailAccountName } from '../../data-path'; +import { listPluginInstalls } from 'officerdb'; +import { discoverPlugins } from '../../plugins/discover'; import { API_URL, OFFICER_API_URL, ANTHROPIC_PROXY_URL } from '../../officer-url.mjs'; // PM2 starts this sidecar with no user in its env, so resolve the owner from the database rather than @@ -140,7 +142,7 @@ loadState(dbUser.id); // ── MCP config ── -function generateMcpConfig(): string { +async function generateMcpConfig(): Promise { // Was `.container-context`, from the architecture where each user ran inside their own Docker // container and this directory described that container to the agent. Nothing about it is // container-related now — it holds exactly one file, the MCP server config handed to the CLI. The @@ -152,8 +154,45 @@ function generateMcpConfig(): string { const userRoot = join(DATA_PATH, email!); const hostToolsDirs = [globalToolsDir, ...(existsSync(userToolsDir) ? [userToolsDir] : [])].join(':'); + + /* + MCP servers contributed by plugins. + + A plugin that fronts something with its own MCP server can either reimplement that vocabulary as + Officer tools or hand the real one over. Reimplementing means a second copy that goes stale, and it + fails silently — a tool exists upstream that Officer cannot see. Handing it over costs this block. + + The plugin declares a PATH, never a URL, and it is resolved against this Officer's own API. That is + the whole safety property: the config below carries `OFFICER_AUTH_TOKEN`, a token that signs as the + owner, so a manifest able to name an arbitrary host would be a manifest able to post the owner's + credential anywhere. A path can only ever reach this machine, through this machine's own gate. + + Installed AND enabled, because a disabled plugin's routes are not mounted — offering the agent a + tool list that 404s is worse than not offering it. + */ + const pluginMcpServers: Record }> = {}; + try { + const [installs, discovered] = await Promise.all([listPluginInstalls(), discoverPlugins()]); + const live = new Set(installs.filter((entry) => entry.enabled).map((entry) => entry.appName)); + + for (const plugin of discovered.plugins) { + const declared = plugin.manifest?.mcp?.path; + if (!declared || !live.has(plugin.appName)) continue; + const path = declared.startsWith('/') ? declared : `/${declared}`; + pluginMcpServers[plugin.appName] = { + type: 'http', + url: `${OFFICER_API_URL}/api/${plugin.appName}${path}`, + headers: { Authorization: `Bearer ${OFFICER_AUTH_TOKEN}` }, + }; + } + } catch { + // A plugin that cannot be read must not stop an agent from starting. It loses its MCP tools for this + // session and everything else works, which is the right side to fail on. + } + const hostConfig = { mcpServers: { + ...pluginMcpServers, 'officer-tools': { type: 'stdio', command: 'bun', @@ -236,7 +275,13 @@ function ensureAnthropicEnv(): void { // deliberately not written — it would pollute the personal global ~/.claude/CLAUDE.md that the // terminal `claude` loads too. -setMcpConfigPath(generateMcpConfig()); +/* + Awaited, because the config now asks the database which plugins are installed. + + Not fire-and-forget: `setMcpConfigPath` is what the agent manager reads when it spawns, so a config + that arrives after the first spawn is an agent running without its plugin tools and no sign of why. +*/ +setMcpConfigPath(await generateMcpConfig()); ensureAnthropicEnv(); console.log(`[agent] started for ${email} (HOME=${homeDir})`);