Skip to content

Commit 31670f5

Browse files
fix(mcp): stdio lists agent_prompt only on a boot that holds an agent (#28940) (#29201)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9Lw5nYyWNaPeqb4qxEBGi2P)_ Fixes #28940 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:stdio 通道的 MCP 服务器只在本次启动确实注册了 agent 时才在提示列表里列出 `agent_prompt`;参数说明里的示例名改为取自已注册的 agent,不再写死 "ask" / "build"。 - 为什么改:开源框架启动时一个 agent 都没有,客户端却能看到 `agent_prompt`,照说明填 "ask" 得到一条「成功」的提示,内容是 `Error: Agent "ask" not found`,模型会把它当指令读。 - 风险与回滚:有 agent 的启动对客户端无变化(照样列出,未命中文字逐字不变);agent 元数据读不全时照旧列出,不把故障当成「没有」。回滚即 revert 本 PR。 - 先看:`packages/mcp/src/mcp-server-runtime.ts` 的 `bridgePrompts` 开头(是否注册的分支)与 `readAgentRoster`;`packages/mcp/src/mcp-server-runtime.agent-prompt-advertised.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/mcp exec vitest run src/mcp-server-runtime.agent-prompt-advertised.test.ts`,预期 8/8 通过。 - 需要你做的:无,席位落地。 ## What changes - `bridgePrompts` (the long-lived server the stdio transport serves) reads the agent roster from the metadata service before registering `agent_prompt`, at the same moment as the existing skill snapshot: - **no agent, complete read**: `agent_prompt` is not registered. `prompts/list` does not carry it, a `prompts/get` on it is the SDK's own unknown-prompt error (JSON-RPC `-32602`), and one boot `info` line says why. A boot with no agent and no skill therefore advertises no `prompts` capability, which is the #8034 capability-equals-served rule. - **at least one agent**: registered as before. The `agentName` description draws its examples from the registered names (first two) instead of the fixed `e.g. "ask", "build"`. The served body and the miss text `Error: Agent "X" not found` are unchanged (#6055 kept that text byte-stable on purpose). - **degraded or thrown read**: registered as before, because an outage is not absence (the #6055 discipline). The description names no agent, and a `warn` says the roster could not be read and points at the loaders. - `packages/mcp/README.md` Prompts section says when `agent_prompt` is listed. - Changeset `.changeset/28940-mcp-agent-prompt-advertised.md` (`@objectstack/mcp` patch). ## Why The same file's skill projection is built on "declared IS served": a skill that cannot be served is not listed. `agent_prompt` was the opposite shape on a boot with no agent. It was a prompt advertised where nothing can serve it, with an argument teaching two names that resolve to nothing, and a miss answered as a success. Route check against recorded decisions (the delta asked for it): - **#6055** (`buildAgentPromptResult` docblock): it fixed outage vs miss inside the get and kept the miss text byte-identical. Untouched. The new roster read applies the same outage-is-not-absence rule one step earlier. - **#14461** (the old comment on the description): its concern was that the example an author copies is a canonical id, never a retired alias. Examples drawn from registered agents keep that by construction, because a registered agent's name is its id. The old `[#14461]` comment is replaced by one pointing at `agentNameArgumentDescription`, which carries the reasoning. - **ADR-0063 §2**: `agent` is code-only, with no runtime create, so a roster read at bridge time cannot be outgrown by a runtime write. **The two transports after this change:** - stdio serves skill prompts, plus `agent_prompt` only on a boot holding an agent. - HTTP (`/api/v1/mcp`, `handleHttpRequest` → `registerSkillPrompts`) serves skill prompts only and never `agent_prompt`. That is unchanged. On a boot with no agent, the open-framework case the card measured, the two transports now agree. ## Tests - New `packages/mcp/src/mcp-server-runtime.agent-prompt-advertised.test.ts`, 8 cases over the real wire (an in-memory transport into the same `McpServer` the stdio transport attaches to). **Red first** on unmodified code: 7/8 failed, and each failure was the defect itself (`expected [ 'agent_prompt', 'case_management' ] to not include 'agent_prompt'`; a get on the no-agent boot returned `Error: Agent "ask" not found` as a result; the description read `(e.g. "ask"…`; `capabilities.prompts` was `{ listChanged: true }` with nothing to list). The 8th case, served body plus byte-stable miss, is the preservation pin and passes on both trees. - **Forced test edits** (named per the order): - `packages/mcp/src/mcp-stdio-tools.test.ts`, the #8034 "bridged server" double: `list` now returns one agent for `agent`. That test asserts the assembly really serves all three primitives. Its double held no agent and no skill, so after this change it would serve no prompts. - `packages/mcp/src/__tests__/mcp-server-runtime.test.ts`, "survives a metadata service that cannot list skills": its replacement `list` kept answering `[]` for `agent`. It now keeps the double's agents, because the case is about the skill read alone. - `pnpm --filter @objectstack/mcp exec vitest run`: 54 files, 652/652 passed. `pnpm --filter @objectstack/mcp typecheck`: exit 0. <details><summary>Ablations (one per negative pin, each restored with a blob-hash match)</summary> Every mutation went through `scripts/ablation-replace.mjs` (the anchor hit once, the replacement landed, then a restore). The suite imports `./mcp-server-runtime.js` from source, so no dist rebuild was involved. After the run, `git diff HEAD` was empty and the file blob was back at `09f63b589939`. - A1, withholding disabled (`if (roster.state === 'none' && false)`) turned 4 red: no-agent list, get is `-32602`, boot line, no-capability case. - A2, the fixed `e.g. "ask", "build"` description put back turned 2 red: agents-boot description and degraded description. - A3, the degraded branch deleted (a degraded empty read becomes `none`) turned 1 red: degraded keeps the prompt. - A4, a thrown read mapped to `none` turned 1 red: throw keeps the prompt. </details> ## Acceptance notes - With agents registered, stdio lists `agent_prompt` and HTTP still does not. That divergence predates this card and is not in its acceptance. Noted, not filed. - The roster is a snapshot taken at MCP plugin `start()`, beside the skill snapshot. An agent provider that registers after that point in the same boot would leave `agent_prompt` withheld on stdio until restart. No such provider exists in this repository (no open package registers an agent). Whether a cloud host registers its platform agents later than `start()` cannot be measured from here, and the stdio transport is a local-host surface. Raised as an open question in the report, not worked around. ## Verification All readings at head `9126fc31`. <details><summary>Derived gate list and lint</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 59 commands. On the first sweep 56 exited 0. Three exited 3 because a prerequisite was missing, which is NOT MEASURED, not a pass: - `node scripts/check-plugin-teardown-shape.mjs --self-test` needed its pinned positive-control commit. After `git fetch origin 621a487…` it exited 0 (48 cases). - `pnpm check:dual-build-cjs-loads` and `pnpm check:lean-entry-closure` read built output. After a full `pnpm build` (117/117 tasks) both exited 0. `pnpm check:dts-closure` was rerun on the same build and exited 0 (293/293 declaration files present). - `--ran` reconciliation: 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN. - Not in the derived list but run anyway: `pnpm check:startup-registry-verdict` exited 0, reporting none recording a verdict the boot can contradict. - `pnpm lint` (`eslint . --no-inline-config`, whole repo) printed no diagnostic. The four changed `.ts` files linted alone also exited 0. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmTPD7FhpC9y956LJJ9)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1b22286 commit 31670f5

6 files changed

Lines changed: 360 additions & 27 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/mcp': patch
3+
---
4+
5+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
The stdio MCP server lists `agent_prompt` only on a boot that registers at least one agent. A boot with no agent, which is every open-framework boot, used to list it anyway. Its `agentName` argument suggested `"ask"` and `"build"`, and every `prompts/get` came back as a successful prompt whose one message read `Error: Agent "ask" not found`, which an MCP client passes to its model as instructions. On such a boot the prompt is now left out of `prompts/list`, a get on it is the protocol's own unknown-prompt error, and the boot log says once why. On a boot with agents nothing changes for a client: the prompt is listed, its argument suggests names the boot actually holds, and a miss still answers `Error: Agent "X" not found`. If the agent metadata cannot be fully read at boot, the prompt stays listed, because an outage is not proof that no agent exists. Its argument then suggests no names.

‎packages/mcp/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,9 @@ Two prompt families are bridged — there is no per-item prompt registration cal
201201

202202
1. **`agent_prompt`** — one dynamic prompt that loads a registered agent's
203203
system prompt by name, with optional UI context (`objectName`, `recordId`,
204-
`viewName`).
204+
`viewName`). It is listed only when the boot registers at least one agent,
205+
and its `agentName` argument names agents that boot holds; the open
206+
framework registers none, so there it is not listed.
205207
2. **One prompt per authored skill** that carries `instructions` (#3905). Write
206208
a `*.skill.ts`, and its instructions become a prompt any connected MCP client
207209
can list and fetch.

‎packages/mcp/src/__tests__/mcp-server-runtime.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,12 @@ describe('MCPServerRuntime', () => {
293293

294294
it('survives a metadata service that cannot list skills', async () => {
295295
const metadataService = createMockMetadataService();
296+
const agents = await metadataService.list('agent');
296297
metadataService.list = vi.fn(async (type: string) => {
297298
if (type === 'skill') throw new Error('unknown metadata type');
299+
// [#28940] The agents stay listed: `agent_prompt` is advertised only on
300+
// a boot that holds one, and this case is about the skill read alone.
301+
if (type === 'agent') return agents;
298302
return [];
299303
}) as any;
300304

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `agent_prompt` is advertised only where an agent can answer it.
5+
*
6+
* The long-lived server (the one the stdio transport serves) used to register
7+
* `agent_prompt` unconditionally, with an `agentName` argument described as
8+
* `e.g. "ask", "build"`. On an open-framework boot that registers no agent at
9+
* all, `prompts/list` therefore advertised a prompt nothing could serve, its
10+
* description taught two names that resolve to nothing, and `prompts/get`
11+
* answered each of them with a SUCCESSFUL prompt result whose one message is
12+
* `Error: Agent "ask" not found` — text an MCP client hands its model as
13+
* instructions.
14+
*
15+
* The skill projection in the same file already follows "declared IS served":
16+
* a skill that cannot be served is not listed, and a get on it is a JSON-RPC
17+
* -32602. These pins hold `agent_prompt` to the same rule:
18+
*
19+
* - no agent registered, and the read says so ⇒ not listed; a get is the
20+
* protocol's own unknown-prompt error, not a fabricated success;
21+
* - agents registered ⇒ listed, the argument names only registered agents,
22+
* and a miss answers exactly what it answered before (#6055 kept that text
23+
* byte-stable on purpose);
24+
* - the read could not say (degraded, or it threw) ⇒ still listed: an outage
25+
* is not absence, and the prompt then answers the honest #6055 outage text
26+
* at get time. The argument names no agent, since none is known.
27+
*
28+
* Driven over the real wire (an in-memory transport into the same `McpServer`
29+
* the stdio transport attaches to): the decision under test is what a client
30+
* can list and fetch, so the client's view is the assertion.
31+
*/
32+
33+
import { describe, it, expect, vi } from 'vitest';
34+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
35+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
36+
import { MCPServerRuntime } from './mcp-server-runtime.js';
37+
38+
const SKILL = {
39+
name: 'case_management',
40+
label: 'Case Management',
41+
description: 'Handles support case lifecycle',
42+
instructions: 'Triage the case, then resolve it once the caller confirms.',
43+
active: true,
44+
};
45+
46+
const AGENT = {
47+
name: 'support_triage',
48+
label: 'Support Triage',
49+
instructions: 'You triage support cases.',
50+
active: true,
51+
};
52+
53+
interface ServiceShape {
54+
agents?: unknown[];
55+
skills?: unknown[];
56+
agentList?: () => Promise<{ items: unknown[]; degraded: boolean; errors: string[] }>;
57+
}
58+
59+
function metadataService({ agents = [], skills = [SKILL], agentList }: ServiceShape = {}) {
60+
const byName = (items: unknown[], name: string) =>
61+
(items as Array<{ name: string }>).find((i) => i.name === name) ?? undefined;
62+
return {
63+
listObjects: vi.fn(async () => []),
64+
getObject: vi.fn(async () => undefined),
65+
get: vi.fn(async (type: string, name: string) => {
66+
if (type === 'agent') return byName(agents, name);
67+
if (type === 'skill') return byName(skills, name);
68+
return undefined;
69+
}),
70+
list: vi.fn(async (type: string) => {
71+
if (type === 'agent') return agents;
72+
if (type === 'skill') return skills;
73+
return [];
74+
}),
75+
listDiagnosed: vi.fn(async (type: string) => {
76+
if (type === 'agent' && agentList) return agentList();
77+
const items = type === 'agent' ? agents : type === 'skill' ? skills : [];
78+
return { items, degraded: false, errors: [] };
79+
}),
80+
exists: vi.fn(async () => false),
81+
register: vi.fn(),
82+
unregister: vi.fn(),
83+
};
84+
}
85+
86+
function logger() {
87+
return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
88+
}
89+
90+
async function connect(service: ReturnType<typeof metadataService>, log = logger()) {
91+
const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9', logger: log as never });
92+
await runtime.bridgePrompts(service as never);
93+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
94+
const client = new Client({ name: 'test-client', version: '0.0.0' });
95+
await Promise.all([runtime.server.connect(serverTransport), client.connect(clientTransport)]);
96+
return {
97+
client,
98+
log,
99+
close: async () => {
100+
await client.close();
101+
await runtime.stop().catch(() => {});
102+
},
103+
};
104+
}
105+
106+
function agentNameDescription(prompts: Array<{ name: string; arguments?: Array<{ name: string; description?: string }> }>) {
107+
const prompt = prompts.find((p) => p.name === 'agent_prompt');
108+
return prompt?.arguments?.find((a) => a.name === 'agentName')?.description;
109+
}
110+
111+
describe('agent_prompt — advertised only where an agent can answer it', () => {
112+
it('a boot that registers no agent does not list agent_prompt', async () => {
113+
const session = await connect(metadataService({ agents: [] }));
114+
try {
115+
const listed = await session.client.listPrompts();
116+
const names = listed.prompts.map((p) => p.name);
117+
// The skill is still projected, so the absence below is a decision about
118+
// this one prompt, not an empty or missing prompt surface.
119+
expect(names).toContain('case_management');
120+
expect(names).not.toContain('agent_prompt');
121+
} finally {
122+
await session.close();
123+
}
124+
});
125+
126+
it('a get of agent_prompt on that boot is the protocol error, not a successful "not found" message', async () => {
127+
const session = await connect(metadataService({ agents: [] }));
128+
try {
129+
const outcome = await session.client
130+
.getPrompt({ name: 'agent_prompt', arguments: { agentName: 'ask' } })
131+
.then((result) => ({ result, error: undefined }), (error: { code?: number }) => ({ result: undefined, error }));
132+
expect(outcome.result).toBeUndefined();
133+
expect(outcome.error?.code).toBe(-32602);
134+
} finally {
135+
await session.close();
136+
}
137+
});
138+
139+
it('says once at boot why the prompt is not advertised', async () => {
140+
const session = await connect(metadataService({ agents: [] }));
141+
try {
142+
const lines = session.log.info.mock.calls.map((c) => String(c[0]));
143+
expect(lines.some((l) => l.includes('agent_prompt') && l.includes('no agent'))).toBe(true);
144+
expect(lines).not.toContain('[MCP] Agent prompts bridged');
145+
} finally {
146+
await session.close();
147+
}
148+
});
149+
150+
it('a boot with agents lists it, and the argument names only agents the boot holds', async () => {
151+
const session = await connect(metadataService({ agents: [AGENT] }));
152+
try {
153+
const listed = await session.client.listPrompts();
154+
expect(listed.prompts.map((p) => p.name)).toContain('agent_prompt');
155+
const description = agentNameDescription(listed.prompts);
156+
expect(description).toContain('"support_triage"');
157+
expect(description).not.toContain('"ask"');
158+
expect(description).not.toContain('"build"');
159+
expect(session.log.info).toHaveBeenCalledWith('[MCP] Agent prompts bridged');
160+
} finally {
161+
await session.close();
162+
}
163+
});
164+
165+
it('on that boot a registered agent is served and a miss answers exactly as before (#6055)', async () => {
166+
const session = await connect(metadataService({ agents: [AGENT] }));
167+
try {
168+
const served = await session.client.getPrompt({ name: 'agent_prompt', arguments: { agentName: 'support_triage' } });
169+
expect(served.messages[0].content).toEqual({ type: 'text', text: 'You triage support cases.' });
170+
171+
const miss = await session.client.getPrompt({ name: 'agent_prompt', arguments: { agentName: 'nope' } });
172+
expect(miss.messages).toEqual([
173+
{ role: 'user', content: { type: 'text', text: 'Error: Agent "nope" not found' } },
174+
]);
175+
} finally {
176+
await session.close();
177+
}
178+
});
179+
180+
it('an agent read that reports itself degraded keeps the prompt listed, naming no agent', async () => {
181+
const session = await connect(
182+
metadataService({
183+
agentList: async () => ({ items: [], degraded: true, errors: ['loader sys_metadata: connection refused'] }),
184+
}),
185+
);
186+
try {
187+
const listed = await session.client.listPrompts();
188+
expect(listed.prompts.map((p) => p.name)).toContain('agent_prompt');
189+
const description = agentNameDescription(listed.prompts) ?? '';
190+
expect(description).not.toContain('"ask"');
191+
expect(description).not.toContain('e.g.');
192+
expect(session.log.warn).toHaveBeenCalledWith(
193+
expect.stringContaining('agent_prompt'),
194+
expect.objectContaining({ errors: ['loader sys_metadata: connection refused'] }),
195+
);
196+
} finally {
197+
await session.close();
198+
}
199+
});
200+
201+
it('an agent read that throws keeps the prompt listed', async () => {
202+
const session = await connect(
203+
metadataService({
204+
agentList: async () => {
205+
throw new Error('unknown metadata type');
206+
},
207+
}),
208+
);
209+
try {
210+
const listed = await session.client.listPrompts();
211+
expect(listed.prompts.map((p) => p.name)).toContain('agent_prompt');
212+
expect(session.log.warn).toHaveBeenCalledWith(
213+
expect.stringContaining('agent_prompt'),
214+
expect.objectContaining({ errors: ['unknown metadata type'] }),
215+
);
216+
} finally {
217+
await session.close();
218+
}
219+
});
220+
221+
it('with no agent and no skill the prompts capability is not advertised at all (#8034)', async () => {
222+
const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' });
223+
await runtime.bridgePrompts(metadataService({ agents: [], skills: [] }) as never);
224+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
225+
const client = new Client({ name: 'test-client', version: '0.0.0' });
226+
await Promise.all([runtime.server.connect(serverTransport), client.connect(clientTransport)]);
227+
try {
228+
expect(client.getServerCapabilities()?.prompts).toBeUndefined();
229+
} finally {
230+
await client.close();
231+
await runtime.stop().catch(() => {});
232+
}
233+
});
234+
});

0 commit comments

Comments
 (0)