Skip to content

Commit 467a01f

Browse files
fix(mcp): agent_prompt reads agents through the protocol read GET /meta/agent/NAME serves (#31565)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XBK9EgGPfyQH8cn35jeTJe3)_ domain:server#6 · os-project-manager · session_01FEkQ5kNHZKmo4BTWcwTdNs Fixes #31514 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:MCP stdio 服务器的 `agent_prompt` 现在通过协议层的按名读取(就是 `GET /api/v1/meta/agent/NAME` 用的那个 `getMetaItem`)取 agent;决定是否列出 `agent_prompt` 的 agent 名单也改读协议的合并列表(`GET /api/v1/meta/agent` 用的 `getMetaItems`)。没有协议服务的宿主照旧读内核 metadata 服务,每个宿主只有一个读者:用协议还是用 metadata 服务,由新的 `agentReadOf` 按宿主一次决定(两种读取都有才用协议),名单和按名读取都用这一个决定。 - 为什么改:包里自带的 agent 只在协议读取的注册表里,内核 metadata 服务没有它的副本。以前 MCP 读 metadata 服务,所以只装了包内 agent 的环境根本不列出 `agent_prompt`,也取不到这个 agent,而 `/meta/agent/NAME` 能取到。两个“按名取 agent”的入口答案不一致。 - 风险与回滚:环境自己的 agent 照旧可用;找不到时仍回答 `Error: Agent "X" not found`;协议读取报 503 时仍是原来的不可用提示、不给指令。`McpMergedMetadataRead` 类型多了一个可选成员 `getMetaItem`(公开类型放宽)。回滚即还原本 PR。 - 先看:`packages/mcp/src/mcp-server-runtime.ts` 的 `agentReadOf`(新,唯一的决定)、`readAgentByName`(新)、`buildAgentPromptResult` 里改用它的一行、`readAgentRoster` 改读 `mergedDiagnosedList`。 - 两分钟验证:`pnpm --filter @objectstack/mcp exec vitest run src/mcp-server-runtime.agent-prompt-advertised.test.ts -t "one reader with GET"`,预期 8 个用例通过:只有包内 agent 时列出 `agent_prompt`、取到的指令与协议按名读取一致、环境 agent 照旧、未知名字照旧报未找到、503 照旧报不可用、只有列表没有按名读取的宿主两边都读 metadata 服务、其他异常照旧抛出、协议在场时名单降级照旧列出。 - 需要你做的:无,席位落地。 ## What changes `packages/mcp/src/mcp-server-runtime.ts`, the long-lived (stdio) server's prompt bridge: - **`agentReadOf` (new, module-private): the one decision per host.** It answers the host's read when that read carries both the merged listing (`getMetaItems`) and the by-name read (`getMetaItem`), and `undefined` (the metadata service) otherwise. The roster and the get both take this one answer, so a host whose read has the listing but no by-name read reads the metadata service for both, rather than advertising a package agent that the get answers "not found" (the #28940 defect again; contract review should-fix). `bridgePrompts` computes it once as `agentRead` and passes it to both. - **`readAgentByName` (new, module-private).** Where `agentReadOf` picks the host's `mergedRead` (the `protocol` service, which `plugin.ts` already hands `bridgePrompts`) carries a by-name read, the agent is read through `getMetaItem({ type: 'agent', name })`, the read `GET /api/v1/meta/agent/NAME` serves, and its `item` is the agent. A throw coded `SERVICE_UNAVAILABLE` (the protocol's 503 when the metadata service behind it could not be read and nothing resolved) is the #6055 outage; any other throw travels, as a throw of the metadata service's read always did. Where it picks the metadata service, its `getDiagnosed`/`get` is read exactly as before. One reader per host, never a lookup that tries both (order ruling). - **`buildAgentPromptResult`** takes the protocol as an optional fourth argument and reads through `readAgentByName`. Its three answers are unchanged: the instructions plus any UI context; `Error: Agent "X" not found` on a miss; the `SERVICE_UNAVAILABLE` sentence on an outage, with no instructions. - **`readAgentRoster` (#28940)** reads `mergedDiagnosedList(metadataService, agentReadOf(mergedRead), 'agent')`: the protocol's merged listing, `GET /api/v1/meta/agent`'s read, with the metadata service's `listDiagnosed` completeness verdict on top, the composition the skill listing already uses. It missed package-shipped agents too (measured below), so per the ruling it moves to the same reader; a boot whose only agent a package ships now advertises `agent_prompt` and names that agent in the argument description. - **`McpMergedMetadataRead`** gains an optional `getMetaItem(request)` member. It is the type of `MCPServerRuntime.bridgePrompts`' second parameter, so it is in the published `index.d.ts`; that is why `Clause-②` reads `yes (widening)`. No `plugin.ts` change: the object it passes is the `protocol` service, which has both reads. - **Changeset** `.changeset/31514-agent-prompt-protocol-read.md`: `@objectstack/mcp` patch, `Clause-②: yes (widening)`, `Metadata-protocol: none`. Naming is the bare name `/meta/agent/NAME` serves today; nothing here anticipates #31527's package-scoped address. ## Tests **Unit, `packages/mcp/src/mcp-server-runtime.agent-prompt-advertised.test.ts`** (the existing `agent_prompt` harness, a new top-level `describe`, driven over the real MCP wire with an in-memory transport). The metadata double holds no copy of the package agent; the protocol double serves it through `getMetaItems` and `getMetaItem`. 1. A boot whose only agent a package ships lists `agent_prompt`, and the `agentName` description names `"pkg_helper"`. 2. `prompts/get` serves exactly the instructions the protocol's `getMetaItem({ type: 'agent', name })` answers; the metadata double is never asked for that agent. 3. CONTROL: an environment agent is listed and served as before (with its UI context line). 4. An unknown name answers `[{ role: 'user', content: { type: 'text', text: 'Error: Agent "nope" not found' } }]`, today's miss. 5. A by-name read thrown `{ code: 'SERVICE_UNAVAILABLE', status: 503 }` answers one `user` message starting `Error: SERVICE_UNAVAILABLE — `, carrying no instructions, and warns `agent prompt refused`. 6. A host whose read is `{ getMetaItems }` only (no `getMetaItem`) reads the metadata service for both: the argument names the environment agent `"support_triage"` and not `pkg_helper`, the listing is never asked for `agent`, and the environment agent is served as before. 7. A by-name read thrown with any other code (`INTERNAL`) rejects `prompts/get` with that error, serves no instructions, and does not warn `agent prompt refused`. 8. With the protocol present, a degraded roster verdict and no agent named keeps the prompt listed with no example, and warns with the verdict's errors. **Real kernel, `packages/qa/dogfood/test/mcp-stdio-package-object-discovery.test.ts`** (the existing MCP stdio package-discovery harness, a new top-level `describe` per boot shape and registration). A package that ships one agent and no object is registered through the `manifest` service on a real `ObjectKernel` with `MetadataPlugin` + `ObjectQLPlugin`, and the real `MCPServerPlugin` stdio server is driven over JSON-RPC. On all four rows (`env_local` and no `environmentId`, registered during the boot and installed after it): `prompts/list` advertises `agent_prompt` naming the package agent, and `prompts/get` serves the instructions the kernel's own `protocol.getMetaItem({ type: 'agent', name })` answers; a miss keeps its answer. The `boot` helper's `pkg` parameter is narrowed to `{ artifact(): unknown }`, its only use, and `Booted` gains `rpc`. <details><summary>Red first, on main dabef0f (cases 1–5) and on 959a0c6 (cases 6–8)</summary> - Unit: 4 failed, 9 passed. Case 1 `expected [ 'case_management' ] to include 'agent_prompt'`; cases 2, 4, 5 `MCP error -32602: Prompt agent_prompt not found` (the prompt was never advertised). The control passed. - Dogfood: 8 failed, 80 passed, all four rows. In every row the test's first assertion, that the kernel's `protocol.getMetaItem` serves the package agent's instructions, passed; then `prompts/get` and `prompts/list` answered `-32601 Method not found`: with no agent in the roster and no skill, the server advertised no prompts at all. - Cases 6–8, added after the contract review on 959a0c6: case 6 red (`expected 'Name of the agent to load (e.g. "pkg_…' to contain '"support_triage"'`: the roster read the protocol listing while the get stayed on the metadata service); 7 and 8 green there, as they pin behaviour 959a0c6 already had (their ablations below show they can go red). </details> <details><summary>Ablations (scripts/ablation-replace.mjs on committed heads 2946313 and 9d09636; unit tests import sources, no dist on the path; each restored with blob == HEAD and an empty git diff HEAD)</summary> - **The get read back on the metadata service.** In `buildAgentPromptResult`, `readAgentByName(metadataService, protocol, agentName)` → `diagnosedGet(metadataService, 'agent', agentName)`. Case 2 goes red (`expected [ { role: 'user', … } ] to deeply equal [ { role: 'assistant', … } ]`: the package agent answered "not found"), and case 5. Cases 1, 3, 4 stay green. Restored. - **The roster back on the metadata service.** In `readAgentRoster`, `mergedDiagnosedList(metadataService, mergedRead, 'agent')` → `diagnosedList(metadataService, 'agent')`. Cases 1, 2, 4, 5 go red (the prompt is not advertised); the control stays green. Restored. - **The shared gate split again (9d09636).** In `agentReadOf`, the `&& typeof mergedRead.getMetaItem === 'function'` clause deleted. Case 6 goes red (`expected 'Name of the agent to load (e.g. "pkg_…' to contain '"support_triage"'`); the other 15 stay green. Restored. - **Every by-name throw read as an outage (9d09636).** In `readAgentByName`, the `SERVICE_UNAVAILABLE` test and `throw err` replaced by an unconditional degraded answer. Case 7 goes red (`promise resolved "{ messages: [ { role: 'user', …(1) } ] }" instead of rejecting`). Restored. - **A degraded roster read as complete (9d09636).** In `readAgentRoster`, `if (read.degraded) return { state: 'unknown', errors: read.errors };` deleted. Case 8 goes red, and the existing metadata-only degraded pin with it. Restored. </details> ## Acceptance notes - **The HTTP surface (`/api/v1/mcp`) has no `agent_prompt`.** Its per-request server bridges skill prompts only (`buildMcpBridge` in `packages/runtime`), so this PR has no second surface to point. - **Degraded roster.** The roster's completeness verdict is still the metadata service's `listDiagnosed`, as for skill prompts (#8328): a degraded verdict with no agent named keeps the prompt listed with no example, as before (case 8 pins it with the protocol present). - **Governed surface.** None of the four paths is in the register (`node scripts/pm/check-governed-merges.mjs --branch HEAD`, see Verification). - **Branch.** The harness branch was restarted from `origin/main` after #31464 merged; its remote had been deleted on merge, so the first push created it fresh and no force push was needed. ## Verification All readings are on head `863aa0f5`: the fix commits `2946313b` and `9d09636b` (the review's should-fix), with `origin/main` merged three times, never rebased (`959a0c61`, `72b76773`, `863aa0f5`; none of the merged commits touches `packages/mcp` or `packages/qa/dogfood`). Each exit code is captured before any pipe. - **mcp suite**: `pnpm --filter @objectstack/mcp test` (through `scripts/pm/os-verify-lock.sh`) exit 0 on `72b76773`, Tests 915 passed (915); `pnpm --filter @objectstack/mcp exec vitest run` exit 0 again on `863aa0f5`, Tests 915 passed (915). - **dogfood file**: after `pnpm --filter "@objectstack/dogfood^..." build` (exit 0), `pnpm --filter @objectstack/dogfood exec vitest run test/mcp-stdio-package-object-discovery.test.ts`, exit 0, Tests 88 passed (88), on `72b76773`. - **Typecheck**: `pnpm --filter @objectstack/mcp typecheck`, exit 0; the test layer holds at its recorded debt (6 files, 53 errors, 8 pinned signatures, none new). `pnpm --filter @objectstack/dogfood typecheck`, exit 0. - **Lint**: `eslint packages/mcp packages/qa/dogfood/test/mcp-stdio-package-object-discovery.test.ts --no-inline-config` (scoped to the touched paths), exit 0. - **i18n**: `pnpm check:i18n-coverage`, exit 0 after the last merge. - **Derived gate union**: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 families on `72b76773`, all 67 exit 0 (`check:dual-build-cjs-loads` included); the last merge added one family, `check:adr-symbol-anchors`, run on `863aa0f5`, exit 0. `--ran`: "68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED". - **Governed surface**: `node scripts/pm/check-governed-merges.mjs --branch HEAD`, exit 0, "NOT governed"; 395 changed lines, under the 3000 threshold. The `test:repo` project and `Test Core` shards are CI's and this PR's check-runs carry them. The dogfood shards are skipped on `pull_request` and run only in the merge queue, so the PR's check-runs do not cover the dogfood file; the local run above does. ## 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. (scoped to the touched paths) - [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: its generated artifacts are regenerated and committed. (not touched) - [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. (the 维护者速读 block is the lane's required Chinese summary) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5kNHZKmo4BTWcwTdNs --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kNHZKmo4BTWcwTdNs)_ <!-- signed: domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ed4b832 commit 467a01f

4 files changed

Lines changed: 381 additions & 14 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/mcp': patch
3+
---
4+
5+
fix(mcp): `agent_prompt` reads an agent through the protocol read `GET /api/v1/meta/agent/NAME` serves, so an agent a package ships is advertised and served
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
The stdio MCP server's `agent_prompt` read agents from the kernel `metadata` service, which holds no copy of an agent a package ships (it lives in the registry the protocol reads). So a boot whose only agent came from a package did not list `agent_prompt`, and a get could not serve that agent, while `GET /api/v1/meta/agent/NAME` served it. Now, where the host's read has both the protocol's reads (the `protocol` service does), `agent_prompt` resolves the agent through its by-name read (`getMetaItem`) and the roster that decides whether the prompt is advertised reads its merged listing (`getMetaItems`), with the metadata service's completeness verdict kept on top as for skill prompts. One reader per host, decided once for both the roster and the get: a host without both reads reads the metadata service exactly as before, for both. A miss still answers `Error: Agent "X" not found`, and a by-name read refused `SERVICE_UNAVAILABLE` still answers the outage text with no instructions.
11+
12+
`McpMergedMetadataRead`, the type of `MCPServerRuntime.bridgePrompts`' second parameter, gains an optional `getMetaItem(request)` member; a host that passes an object without it is read as before, the roster included.

‎packages/mcp/src/mcp-server-runtime.agent-prompt-advertised.test.ts‎

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,200 @@ describe('agent_prompt — advertised only where an agent can answer it', () =>
232232
}
233233
});
234234
});
235+
236+
/**
237+
* [#31514] `agent_prompt` reads an agent through the protocol, the read
238+
* `GET /api/v1/meta/agent/NAME` serves, and its advertisement reads the
239+
* protocol's listing, `GET /api/v1/meta/agent`'s. An agent a package ships
240+
* lives in the registry the protocol reads; the kernel `metadata` service holds
241+
* no copy of it, so the metadata double below holds none (`get` answers
242+
* `undefined`, `list` answers `[]`), and the protocol double serves it. The
243+
* real-kernel twin of these pins is
244+
* `packages/qa/dogfood/test/mcp-stdio-package-object-discovery.test.ts`.
245+
*/
246+
describe('agent_prompt — one reader with GET /meta/agent/NAME (the protocol)', () => {
247+
const PACKAGE_AGENT = {
248+
name: 'pkg_helper',
249+
label: 'Package Helper',
250+
instructions: 'You help with what the package ships.',
251+
active: true,
252+
};
253+
254+
/** A protocol double: the merged listing and the by-name read, over `agents` and `skills`. */
255+
function protocol({ agents = [PACKAGE_AGENT], skills = [SKILL], byName }: {
256+
agents?: unknown[];
257+
skills?: unknown[];
258+
byName?: (request: { type: string; name: string }) => Promise<unknown>;
259+
} = {}) {
260+
const itemsOf = (type: string) => (type === 'agent' ? agents : type === 'skill' ? skills : []);
261+
return {
262+
getMetaItems: vi.fn(async ({ type }: { type: string }) => ({ type, items: itemsOf(type) })),
263+
getMetaItem: vi.fn(byName ?? (async ({ type, name }: { type: string; name: string }) => ({
264+
type,
265+
name,
266+
item: (itemsOf(type) as Array<{ name: string }>).find((i) => i.name === name),
267+
}))),
268+
};
269+
}
270+
271+
async function connectWith(service: ReturnType<typeof metadataService>, read: unknown) {
272+
const log = logger();
273+
const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9', logger: log as never });
274+
await runtime.bridgePrompts(service as never, read as never);
275+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
276+
const client = new Client({ name: 'test-client', version: '0.0.0' });
277+
await Promise.all([runtime.server.connect(serverTransport), client.connect(clientTransport)]);
278+
return {
279+
client,
280+
log,
281+
close: async () => {
282+
await client.close();
283+
await runtime.stop().catch(() => {});
284+
},
285+
};
286+
}
287+
288+
/** Every call the metadata double took for the `agent` type. */
289+
const agentCallsOn = (service: ReturnType<typeof metadataService>) =>
290+
[service.get, service.list, service.listDiagnosed]
291+
.flatMap((fn) => (fn as ReturnType<typeof vi.fn>).mock.calls)
292+
.filter((args) => args[0] === 'agent');
293+
294+
it('a boot whose only agent a package ships lists agent_prompt, and the argument names that agent', async () => {
295+
const service = metadataService({ agents: [] });
296+
const session = await connectWith(service, protocol());
297+
try {
298+
const listed = await session.client.listPrompts();
299+
expect(listed.prompts.map((p) => p.name)).toContain('agent_prompt');
300+
expect(agentNameDescription(listed.prompts)).toContain('"pkg_helper"');
301+
} finally {
302+
await session.close();
303+
}
304+
});
305+
306+
it('serves the package agent with the instructions the protocol\'s by-name read answers, and never asks the metadata service for it', async () => {
307+
const service = metadataService({ agents: [] });
308+
const read = protocol();
309+
const session = await connectWith(service, read);
310+
try {
311+
const served = await session.client.getPrompt({ name: 'agent_prompt', arguments: { agentName: 'pkg_helper' } });
312+
const answered = (await read.getMetaItem({ type: 'agent', name: 'pkg_helper' })) as { item: { instructions: string } };
313+
expect(served.messages).toEqual([
314+
{ role: 'assistant', content: { type: 'text', text: answered.item.instructions } },
315+
]);
316+
expect(read.getMetaItem).toHaveBeenCalledWith({ type: 'agent', name: 'pkg_helper' });
317+
expect(agentCallsOn(service).filter((args) => args[1] === 'pkg_helper')).toEqual([]);
318+
} finally {
319+
await session.close();
320+
}
321+
});
322+
323+
it('CONTROL: an environment agent is served as before', async () => {
324+
const service = metadataService({ agents: [AGENT] });
325+
const session = await connectWith(service, protocol({ agents: [AGENT, PACKAGE_AGENT] }));
326+
try {
327+
const listed = await session.client.listPrompts();
328+
expect(agentNameDescription(listed.prompts)).toContain('"support_triage"');
329+
const served = await session.client.getPrompt({
330+
name: 'agent_prompt',
331+
arguments: { agentName: 'support_triage', objectName: 'case' },
332+
});
333+
expect(served.messages).toEqual([
334+
{ role: 'assistant', content: { type: 'text', text: 'You triage support cases.\n\n--- Current Context ---\nCurrent object: case' } },
335+
]);
336+
} finally {
337+
await session.close();
338+
}
339+
});
340+
341+
it('a name the protocol does not serve keeps today\'s miss answer', async () => {
342+
const session = await connectWith(metadataService({ agents: [] }), protocol());
343+
try {
344+
const miss = await session.client.getPrompt({ name: 'agent_prompt', arguments: { agentName: 'nope' } });
345+
expect(miss.messages).toEqual([
346+
{ role: 'user', content: { type: 'text', text: 'Error: Agent "nope" not found' } },
347+
]);
348+
} finally {
349+
await session.close();
350+
}
351+
});
352+
353+
it('a by-name read refused SERVICE_UNAVAILABLE (503) answers the #6055 outage text and serves no instructions', async () => {
354+
const outage = Object.assign(new Error('The metadata store could not be read.'), { code: 'SERVICE_UNAVAILABLE', status: 503 });
355+
const session = await connectWith(
356+
metadataService({ agents: [] }),
357+
protocol({ byName: async () => { throw outage; } }),
358+
);
359+
try {
360+
const answer = await session.client.getPrompt({ name: 'agent_prompt', arguments: { agentName: 'pkg_helper' } });
361+
expect(answer.messages).toHaveLength(1);
362+
expect(answer.messages[0].role).toBe('user');
363+
const text = (answer.messages[0].content as { text: string }).text;
364+
expect(text.startsWith('Error: SERVICE_UNAVAILABLE — ')).toBe(true);
365+
expect(text).not.toContain(PACKAGE_AGENT.instructions);
366+
expect(session.log.warn).toHaveBeenCalledWith(
367+
expect.stringContaining('agent prompt refused'),
368+
expect.objectContaining({ agentName: 'pkg_helper' }),
369+
);
370+
} finally {
371+
await session.close();
372+
}
373+
});
374+
it('a host whose read has the listing but no by-name read reads the metadata service for both the roster and the get', async () => {
375+
// One decision per host. The listing alone would let the roster advertise a
376+
// package agent that the get, still on the metadata service, answers "not
377+
// found": the #28940 defect again, on a host `bridgePrompts`' parameter type
378+
// accepts.
379+
const service = metadataService({ agents: [AGENT] });
380+
const listingOnly = { getMetaItems: vi.fn(protocol().getMetaItems) };
381+
const session = await connectWith(service, listingOnly);
382+
try {
383+
const listed = await session.client.listPrompts();
384+
const description = agentNameDescription(listed.prompts) ?? '';
385+
expect(description).toContain('"support_triage"');
386+
expect(description).not.toContain('pkg_helper');
387+
expect(listingOnly.getMetaItems).not.toHaveBeenCalledWith({ type: 'agent' });
388+
const served = await session.client.getPrompt({ name: 'agent_prompt', arguments: { agentName: 'support_triage' } });
389+
expect(served.messages).toEqual([
390+
{ role: 'assistant', content: { type: 'text', text: 'You triage support cases.' } },
391+
]);
392+
} finally {
393+
await session.close();
394+
}
395+
});
396+
397+
it('a by-name read that throws anything but SERVICE_UNAVAILABLE travels as a protocol error and serves no instructions', async () => {
398+
const session = await connectWith(
399+
metadataService({ agents: [] }),
400+
protocol({ byName: async () => { throw Object.assign(new Error('registry exploded'), { code: 'INTERNAL' }); } }),
401+
);
402+
try {
403+
const got = session.client.getPrompt({ name: 'agent_prompt', arguments: { agentName: 'pkg_helper' } });
404+
await expect(got).rejects.toThrow(/registry exploded/);
405+
await expect(got).rejects.not.toThrow(PACKAGE_AGENT.instructions);
406+
expect(session.log.warn).not.toHaveBeenCalledWith(expect.stringContaining('agent prompt refused'), expect.anything());
407+
} finally {
408+
await session.close();
409+
}
410+
});
411+
412+
it('a degraded roster verdict with the protocol present and no agent named keeps the prompt listed, naming no agent', async () => {
413+
const session = await connectWith(
414+
metadataService({
415+
agentList: async () => ({ items: [], degraded: true, errors: ['loader sys_metadata: connection refused'] }),
416+
}),
417+
protocol({ agents: [] }),
418+
);
419+
try {
420+
const listed = await session.client.listPrompts();
421+
expect(listed.prompts.map((p) => p.name)).toContain('agent_prompt');
422+
expect(agentNameDescription(listed.prompts) ?? '').not.toContain('e.g.');
423+
expect(session.log.warn).toHaveBeenCalledWith(
424+
expect.stringContaining('agent_prompt'),
425+
expect.objectContaining({ errors: ['loader sys_metadata: connection refused'] }),
426+
);
427+
} finally {
428+
await session.close();
429+
}
430+
});
431+
});

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

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,14 @@ async function diagnoseListRead(
526526
export interface McpMergedMetadataRead {
527527
/** The protocol's merged listing for one metadata type. */
528528
getMetaItems(request: { type: string }): Promise<unknown>;
529+
/**
530+
* [#31514] The protocol's by-name read, the one `GET /api/v1/meta/TYPE/NAME`
531+
* serves. `agent_prompt` resolves an agent through it where the host has it
532+
* ({@link buildAgentPromptResult}), so an agent a package ships, which lives
533+
* in the registry this read consults and has no copy in the metadata
534+
* service, is served by name the way the REST read serves it.
535+
*/
536+
getMetaItem?(request: { type: string; name: string }): Promise<unknown>;
529537
}
530538

531539
/**
@@ -698,6 +706,60 @@ export interface AgentPromptResult {
698706
messages: Array<{ role: 'user' | 'assistant'; content: { type: 'text'; text: string } }>;
699707
}
700708

709+
/**
710+
* [#31514] Read one agent by name through the protocol's by-name read, the read
711+
* `GET /api/v1/meta/agent/NAME` serves, where this host has one; the metadata
712+
* service otherwise, as before. Which of the two is {@link agentReadOf}'s
713+
* decision, the one the roster takes too. ONE reader per host, never both:
714+
* the defect this closes is that the two disagree (an agent a package ships
715+
* lives in the registry the protocol reads, and the metadata service holds no
716+
* copy of it), so a lookup that tried one and then the other would keep both
717+
* answers alive.
718+
*
719+
* The protocol answers `{ type, name, item }`, with `item` absent on a miss,
720+
* and throws `SERVICE_UNAVAILABLE` (503) when the metadata service behind it
721+
* could not be read and the whole chain resolved nothing: that is the #6055
722+
* outage, reported as {@link DiagnosedRead.degraded}. Any other throw travels
723+
* to the caller, as a throw of the metadata service's read always did.
724+
*/
725+
async function readAgentByName(
726+
metadataService: IMetadataService,
727+
protocol: McpMergedMetadataRead | undefined,
728+
name: string,
729+
): Promise<DiagnosedRead> {
730+
const read = agentReadOf(protocol);
731+
if (!read?.getMetaItem) {
732+
return diagnosedGet(metadataService, 'agent', name);
733+
}
734+
let envelope: unknown;
735+
try {
736+
envelope = await read.getMetaItem({ type: 'agent', name });
737+
} catch (err) {
738+
if ((err as { code?: unknown } | null)?.code === METADATA_UNAVAILABLE_CODE) {
739+
return { data: undefined, degraded: true, errors: [err instanceof Error ? err.message : String(err)] };
740+
}
741+
throw err;
742+
}
743+
return { data: (envelope as { item?: unknown } | null | undefined)?.item, degraded: false, errors: [] };
744+
}
745+
746+
/**
747+
* [#31514] The ONE decision, per host, of which layer `agent_prompt` reads:
748+
* the protocol when the host's read carries both the merged listing and the
749+
* by-name read, the metadata service (`undefined` here) otherwise. The roster
750+
* ({@link readAgentRoster}) and the get ({@link readAgentByName}) both take
751+
* this answer, so they cannot split: a read with the listing but no by-name
752+
* read would otherwise advertise a package agent that the get, left on the
753+
* metadata service, answers "not found", the #28940 defect again.
754+
*/
755+
function agentReadOf(mergedRead: McpMergedMetadataRead | undefined): McpMergedMetadataRead | undefined {
756+
return mergedRead
757+
&& typeof mergedRead.getMetaItems === 'function'
758+
&& typeof mergedRead.getMetaItem === 'function'
759+
? mergedRead
760+
: undefined;
761+
}
762+
701763
/** An `Error: …` answer on the prompt surface — this file's existing shape. */
702764
function promptError(text: string): AgentPromptResult {
703765
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text } }] };
@@ -727,18 +789,24 @@ function promptError(text: string): AgentPromptResult {
727789
* `degraded` only decides the answer once the read has resolved NOTHING, i.e.
728790
* only when the answer would otherwise have been the unfounded "not found".
729791
* A read that answered a body is served as it always was.
792+
*
793+
* [#31514] The agent is read through `protocol`, the read
794+
* `GET /api/v1/meta/agent/NAME` serves, where the host has both its reads
795+
* ({@link agentReadOf}, {@link readAgentByName}); the three states above are
796+
* unchanged.
730797
*/
731798
export async function buildAgentPromptResult(
732799
metadataService: IMetadataService,
733800
args: { agentName?: unknown; objectName?: unknown; recordId?: unknown; viewName?: unknown },
734801
logger?: Logger,
802+
protocol?: McpMergedMetadataRead,
735803
): Promise<AgentPromptResult> {
736804
const agentName = String(args.agentName ?? '');
737805
if (!agentName) {
738806
return promptError('Error: agentName argument is required');
739807
}
740808

741-
const read = await diagnosedGet(metadataService, 'agent', agentName);
809+
const read = await readAgentByName(metadataService, protocol, agentName);
742810

743811
if (read.data === undefined || read.data === null) {
744812
if (read.degraded) {
@@ -799,17 +867,26 @@ type AgentRoster =
799867
* [#28940] Read which agents this boot holds, for the `agent_prompt`
800868
* advertisement.
801869
*
802-
* The roster is read from the metadata service — the same layer
803-
* {@link buildAgentPromptResult} resolves a name from — so the names the
804-
* argument teaches are names a `prompts/get` can answer. `agent` is a
805-
* code-only type (ADR-0063 §2 closes `*.agent.ts` to third parties, and the
806-
* save door refuses a runtime create), so no runtime write grows the set after
807-
* boot, and the merged overlay read the skill snapshot uses has nothing to add.
870+
* The roster is read from the same layer {@link buildAgentPromptResult}
871+
* resolves a name from, so the names the argument teaches are names a
872+
* `prompts/get` can answer. [#31514] That layer is the protocol where the host
873+
* has both its reads ({@link agentReadOf}, the decision the get takes too):
874+
* its merged listing, `GET /api/v1/meta/agent`'s read
875+
* ({@link mergedDiagnosedList}), which holds the agents a package ships. The
876+
* metadata service holds no copy of those, so a roster read from it alone told
877+
* a boot whose only agent a package ships that it held none, and the prompt
878+
* was not advertised. The completeness verdict is still the metadata
879+
* service's, as for the skill listing. `agent` is a code-only type (ADR-0063 §2
880+
* closes `*.agent.ts` to third parties, and the save door refuses a runtime
881+
* create), so no runtime write grows the set after boot.
808882
*/
809-
async function readAgentRoster(metadataService: IMetadataService): Promise<AgentRoster> {
883+
async function readAgentRoster(
884+
metadataService: IMetadataService,
885+
mergedRead: McpMergedMetadataRead | undefined,
886+
): Promise<AgentRoster> {
810887
let read: DiagnosedListRead;
811888
try {
812-
read = await diagnosedList(metadataService, 'agent');
889+
read = await mergedDiagnosedList(metadataService, agentReadOf(mergedRead), 'agent');
813890
} catch (err) {
814891
return { state: 'unknown', errors: [err instanceof Error ? err.message : String(err)] };
815892
}
@@ -1708,11 +1785,15 @@ export class MCPServerRuntime {
17081785
// once the transport attaches. A roster read that cannot say (degraded, or
17091786
// it threw) keeps the prompt, which then answers the honest #6055 outage
17101787
// text at get time.
1711-
const roster = await readAgentRoster(metadataService);
1788+
// [#31514] Through the protocol where the host has both its reads, the
1789+
// metadata service otherwise: one decision ({@link agentReadOf}) that the
1790+
// get below takes too.
1791+
const agentRead = agentReadOf(mergedRead);
1792+
const roster = await readAgentRoster(metadataService, agentRead);
17121793
if (roster.state === 'none') {
17131794
logger?.info(
17141795
'[MCP] agent_prompt not advertised — this boot registers no agent, so nothing could answer it. '
1715-
+ 'It is listed on a boot whose metadata service holds at least one agent.',
1796+
+ 'It is listed on a boot that holds at least one agent, one a package ships included.',
17161797
);
17171798
} else {
17181799
if (roster.state === 'unknown') {
@@ -1743,8 +1824,9 @@ export class MCPServerRuntime {
17431824
},
17441825
async (args) =>
17451826
// [#6055] Outage vs. miss lives in the builder — see
1746-
// {@link buildAgentPromptResult}.
1747-
buildAgentPromptResult(metadataService, args, logger),
1827+
// {@link buildAgentPromptResult}. [#31514] The agent is read through
1828+
// the protocol's by-name read where `agentRead` is the protocol.
1829+
buildAgentPromptResult(metadataService, args, logger, agentRead),
17481830
);
17491831

17501832
logger?.info('[MCP] Agent prompts bridged');

0 commit comments

Comments
 (0)