a plugin can hand the agent its own MCP server

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 18:53:22 +01:00
co-authored by Claude Opus 5
parent dd8c7fd7bd
commit 912ecd04f7
2 changed files with 72 additions and 2 deletions
+25
View File
@@ -523,6 +523,31 @@ export type PluginManifest = {
*/
hooks?: Partial<Record<string, string>>;
/**
* 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/<plugin>/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[];
/**
+47 -2
View File
@@ -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<string> {
// 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<string, { type: 'http'; url: string; headers: Record<string, string> }> = {};
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})`);