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:
@@ -523,6 +523,31 @@ export type PluginManifest = {
|
|||||||
*/
|
*/
|
||||||
hooks?: Partial<Record<string, string>>;
|
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[];
|
osDependencies?: OsDependency[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { createSidecarConnector } from '../connect';
|
|||||||
import { sign } from '../../jwt';
|
import { sign } from '../../jwt';
|
||||||
import { getUserByEmail, getOwnerUser } from 'officerdb';
|
import { getUserByEmail, getOwnerUser } from 'officerdb';
|
||||||
import { DATA_PATH, findEmailAccountName } from '../../data-path';
|
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';
|
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
|
// 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 ──
|
// ── MCP config ──
|
||||||
|
|
||||||
function generateMcpConfig(): string {
|
async function generateMcpConfig(): Promise<string> {
|
||||||
// Was `.container-context`, from the architecture where each user ran inside their own Docker
|
// 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 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
|
// 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 userRoot = join(DATA_PATH, email!);
|
||||||
|
|
||||||
const hostToolsDirs = [globalToolsDir, ...(existsSync(userToolsDir) ? [userToolsDir] : [])].join(':');
|
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 = {
|
const hostConfig = {
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
|
...pluginMcpServers,
|
||||||
'officer-tools': {
|
'officer-tools': {
|
||||||
type: 'stdio',
|
type: 'stdio',
|
||||||
command: 'bun',
|
command: 'bun',
|
||||||
@@ -236,7 +275,13 @@ function ensureAnthropicEnv(): void {
|
|||||||
// deliberately not written — it would pollute the personal global ~/.claude/CLAUDE.md that the
|
// deliberately not written — it would pollute the personal global ~/.claude/CLAUDE.md that the
|
||||||
// terminal `claude` loads too.
|
// 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();
|
ensureAnthropicEnv();
|
||||||
|
|
||||||
console.log(`[agent] started for ${email} (HOME=${homeDir})`);
|
console.log(`[agent] started for ${email} (HOME=${homeDir})`);
|
||||||
|
|||||||
Reference in New Issue
Block a user