Skip to content

Commit ff4ba6a

Browse files
qq9340100Claudeclaude
authored
fix(mcp): read skill prompts from the protocol's merged metadata listing (#8724)
* fix(mcp): read skill prompts from the protocol's merged metadata listing The MCP prompt bridge read `IMetadataService.list('skill')` — one layer below where the `sys_metadata` overlay merge happens — so a runtime `PUT /api/v1/meta/skill/<name>` returned 200 and never reached MCP prompts, while `GET /api/v1/meta/skill` served the overridden row from the protocol's `getMetaItems`. The bridge now takes an optional merged read (the protocol service, wired by the plugin assembly that can see both services) and takes its items from there, keeping #6504's completeness verdict by asking `listDiagnosed` for it alongside — `getMetaItems` cannot express that verdict, so taking its answer alone would have spent the contract. A merged read that throws does NOT fall back to the un-merged listing: that would answer registry rows in the shape of merged ones at exactly the moment an overlay is most likely to be missed. Also marks the trap at its source: `IMetadataService.list()` now states that it performs no overlay merging (comment only). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn * test(mcp): make the provenance case discriminate in reverse verification The case asserted that `list()` was never called, which is true in both directions — `diagnosedList` prefers `listDiagnosed` whenever the service has it, so the un-merged path never touches `list` either. Measured 5 red / 3 green against a predicted 6 / 2; the third green was this defective assertion, not a third invariant. Now asserts provenance directly: the service holds a projectable skill, the merged read holds none, and nothing is bridged. Re-measured 6 / 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn * chore: changeset for the MCP merged skill read (#8328) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn --------- Co-authored-by: Claude <sales@objectstack.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 38ba5b7 commit ff4ba6a

5 files changed

Lines changed: 440 additions & 4 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/mcp": patch
3+
---
4+
5+
fix(mcp): the skill prompt bridge reads the protocol's merged metadata listing, so a runtime `PUT /api/v1/meta/skill/<name>` reaches MCP prompts (#8328)
6+
7+
The bridge read `IMetadataService.list('skill')` — one layer below where the
8+
`sys_metadata` overlay merge happens — so an override returned 200 and never
9+
reached the prompt surface while `GET /api/v1/meta/skill` served it. The
10+
long-lived (stdio) server's bridge now takes its items from the protocol's
11+
`getMetaItems` when the host can supply it, and keeps the #6504 completeness
12+
verdict by asking `listDiagnosed` for it alongside. A host assembled without the
13+
metadata protocol reads exactly as before, and a merged read that throws does not
14+
fall back to the un-merged listing.
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#8328] The skill prompt bridge reads the MERGED metadata listing, so a
5+
* runtime meta override reaches MCP prompts.
6+
*
7+
* ---------------------------------------------------------------------------
8+
* The defect
9+
* ---------------------------------------------------------------------------
10+
* `PUT /api/v1/meta/skill/<name>` with `{active:true}` returned 200 and the
11+
* flip never reached the prompt surface. The two surfaces read different
12+
* layers: the meta HTTP list goes through the protocol's `getMetaItems`, which
13+
* merges the `sys_metadata` overlay over the registry / MetadataService
14+
* baselines, while this bridge read `IMetadataService.list('skill')` — one
15+
* layer BELOW where any overlay merging happens. Same skill name, two answers.
16+
*
17+
* Measured on a booted showcase before the fix: `GET /api/v1/meta/skill`
18+
* served the overridden row and `[MCP] Bridged 0 skill prompts` was logged at
19+
* the same boot. The `active` flip is what makes the count binary — a row with
20+
* `active:false` is not projected at all (`projectSkillPrompt`) — so the
21+
* override's arrival is visible as 0 → 1 rather than as a body diff.
22+
*
23+
* ---------------------------------------------------------------------------
24+
* What this file pins, and what it deliberately does NOT
25+
* ---------------------------------------------------------------------------
26+
* It pins the LAYER the bridge reads from, and that #6504's completeness
27+
* verdict survives the layer change. It does not re-pin the projection rules
28+
* (`skill-prompts.test.ts` owns those) or the overlay merge itself
29+
* (`packages/metadata-protocol` owns that — `getMetaItems` is a double here).
30+
*
31+
* ⚠️ Scope, stated so a reader does not over-read a green file: this covers the
32+
* LONG-LIVED server's bridge, which is the stdio transport's prompt surface and
33+
* the half of #8328 that lives in this package. The HTTP surface at
34+
* `/api/v1/mcp` builds its bridge in `packages/runtime`
35+
* (`domains/mcp.ts` → `buildMcpBridge`), whose `listSkills` is a separate read
36+
* that this file cannot reach and that is NOT fixed by this change.
37+
*
38+
* ---------------------------------------------------------------------------
39+
* Reverse verification, direction predicted BEFORE running
40+
* ---------------------------------------------------------------------------
41+
* Ordinary red, on the consumer. The reversion is behavioural: `listSkills`
42+
* goes back to `diagnosedList(metadataService, 'skill')`, ignoring the merged
43+
* read. Predicted, written down before running: **6 red / 2 green**.
44+
*
45+
* The two predicted GREEN are invariant pins, green in both directions on
46+
* purpose:
47+
* - *"no merged read: the bridge reads exactly as it did before"* — that case
48+
* IS the pre-fix behaviour, so it must not move;
49+
* - *"a degraded verdict still reaches the operator"* — the warn came from
50+
* `listDiagnosed` before this change and still does. It would go red if a
51+
* future change spent the #6504 contract while switching layers, which is
52+
* the specific regression this fix had to avoid.
53+
*
54+
* MEASURED: **5 red / 3 green** — the prediction was wrong, and the third green
55+
* was a defective assertion rather than a third invariant. The case then read
56+
* *"the un-merged `list()` is not consulted"*, which is true in BOTH directions:
57+
* `diagnosedList` prefers `listDiagnosed` whenever the service has it, so the
58+
* un-merged path never touches `list` either. It was rewritten to assert
59+
* PROVENANCE (the service holds a projectable skill, the merged read holds
60+
* none, and nothing is bridged), which discriminates. Re-measured after that
61+
* rewrite: **6 red / 2 green**, as recorded in the PR body.
62+
*
63+
* The doubles declare metadata reads only — no engine write verb — so there is
64+
* no `delete`/`update` dispatch for `check:engine-double-contract` to scan.
65+
*/
66+
67+
import { describe, it, expect, vi } from 'vitest';
68+
import type { IMetadataService, Logger } from '@objectstack/spec/contracts';
69+
import { MCPServerRuntime } from './mcp-server-runtime.js';
70+
import type { McpMergedMetadataRead } from './mcp-server-runtime.js';
71+
72+
type AnyRecord = Record<string, any>;
73+
74+
/** See the sibling outage suite: typed because the TEST_DEBT ratchet reads it. */
75+
type MockLogger = Logger & {
76+
debug: ReturnType<typeof vi.fn>;
77+
info: ReturnType<typeof vi.fn>;
78+
warn: ReturnType<typeof vi.fn>;
79+
error: ReturnType<typeof vi.fn>;
80+
};
81+
82+
const makeLogger = (): MockLogger =>
83+
({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }) as unknown as MockLogger;
84+
85+
const infoLines = (logger: MockLogger): string =>
86+
logger.info.mock.calls.map((c: unknown[]) => String(c[0])).join('\n');
87+
const warnLines = (logger: MockLogger): string =>
88+
logger.warn.mock.calls.map((c: unknown[]) => String(c[0])).join('\n');
89+
90+
const LOADER_FAILURE = 'database: connect ECONNREFUSED 10.0.0.5:5432';
91+
92+
/**
93+
* The packaged skill as the registry/loader layer holds it: authored inactive,
94+
* so it projects to NO prompt.
95+
*/
96+
const PACKAGED_SKILL = {
97+
name: 'case_management',
98+
label: 'Case Management',
99+
instructions: 'Handle the support case lifecycle.',
100+
active: false,
101+
};
102+
103+
/**
104+
* The same skill as the protocol's merged read answers AFTER a runtime
105+
* `PUT /api/v1/meta/skill/case_management` with `{active:true}` — the overlay
106+
* row won its slot, so the row is now projectable.
107+
*/
108+
const OVERRIDDEN_SKILL = { ...PACKAGED_SKILL, active: true };
109+
110+
/**
111+
* Build a metadata-service double. Every required member throws, so a path that
112+
* reaches one this change should not touch fails loudly rather than resolving
113+
* an empty array and looking like the very absence under test.
114+
*/
115+
function makeService(overrides: AnyRecord): IMetadataService {
116+
const unexpected = (member: string) => async (): Promise<never> => {
117+
throw new Error(`double: ${member}() should not be called by this surface`);
118+
};
119+
return {
120+
register: unexpected('register'),
121+
get: unexpected('get'),
122+
list: unexpected('list'),
123+
unregister: unexpected('unregister'),
124+
exists: unexpected('exists'),
125+
listNames: unexpected('listNames'),
126+
getObject: unexpected('getObject'),
127+
listObjects: unexpected('listObjects'),
128+
...overrides,
129+
} as unknown as IMetadataService;
130+
}
131+
132+
/** A healthy service holding the packaged (inactive) row. */
133+
const packagedService = (items: unknown[] = [PACKAGED_SKILL]) =>
134+
makeService({
135+
list: vi.fn(async () => items),
136+
listDiagnosed: vi.fn(async () => ({ items, degraded: false, errors: [] })),
137+
});
138+
139+
/** A service whose loader set is short — the #6504 verdict is `degraded`. */
140+
const degradedService = (items: unknown[] = [PACKAGED_SKILL]) =>
141+
makeService({
142+
list: vi.fn(async () => items),
143+
listDiagnosed: vi.fn(async () => ({ items, degraded: true, errors: [LOADER_FAILURE] })),
144+
});
145+
146+
/** A service predating #6504: no `listDiagnosed` to probe. */
147+
const undiagnosableService = (items: unknown[] = [PACKAGED_SKILL]) =>
148+
makeService({ list: vi.fn(async () => items) });
149+
150+
/** The protocol's merged listing, in its native `{ type, items }` envelope. */
151+
const mergedRead = (items: unknown[]): McpMergedMetadataRead => ({
152+
getMetaItems: vi.fn(async ({ type }: { type: string }) => ({ type, items })),
153+
});
154+
155+
const bridge = async (
156+
service: IMetadataService,
157+
logger: MockLogger,
158+
merged?: McpMergedMetadataRead,
159+
): Promise<void> => {
160+
const runtime = new MCPServerRuntime({ name: 'merged-skill-read', version: '0.0.0', logger });
161+
await runtime.bridgePrompts(service, merged);
162+
};
163+
164+
describe('#8328 — the skill read goes through the protocol\'s merged listing', () => {
165+
it('THE DEFECT: a runtime override that only the merged read can see reaches the prompt surface', async () => {
166+
const logger = makeLogger();
167+
// The registry/loader layer still holds `active:false`; only the merged
168+
// read carries the overlay's `active:true`. Before this fix the bridge read
169+
// the former and registered nothing.
170+
await bridge(packagedService(), logger, mergedRead([OVERRIDDEN_SKILL]));
171+
172+
expect(infoLines(logger)).toMatch(/Bridged 1 skill prompts/);
173+
});
174+
175+
it('the metadata service\'s own listing contributes NO items', async () => {
176+
const logger = makeLogger();
177+
// Provenance, asserted the only way that discriminates: the service holds a
178+
// perfectly projectable skill and the merged read holds none. If any item
179+
// still reached the surface it came from the layer this fix stopped
180+
// reading.
181+
//
182+
// An earlier draft asserted `list()` was never called, which is TRUE in
183+
// both directions and therefore measures nothing: `diagnosedList` prefers
184+
// `listDiagnosed` when the service has it, so the un-merged path reads that
185+
// member instead. Kept as a secondary assertion, not the discriminator.
186+
const service = packagedService([{ ...PACKAGED_SKILL, name: 'stale_skill', active: true }]);
187+
await bridge(service, logger, mergedRead([]));
188+
189+
expect(infoLines(logger)).toMatch(/Bridged 0 skill prompts/);
190+
expect(service.list).not.toHaveBeenCalled();
191+
});
192+
193+
it('asks the merged read for the `skill` type specifically', async () => {
194+
const logger = makeLogger();
195+
const merged = mergedRead([OVERRIDDEN_SKILL]);
196+
await bridge(packagedService(), logger, merged);
197+
198+
expect(merged.getMetaItems).toHaveBeenCalledWith({ type: 'skill' });
199+
});
200+
201+
it('accepts a bare array from a host whose merged read is not enveloped', async () => {
202+
const logger = makeLogger();
203+
const merged = { getMetaItems: vi.fn(async () => [OVERRIDDEN_SKILL]) };
204+
await bridge(packagedService(), logger, merged);
205+
206+
expect(infoLines(logger)).toMatch(/Bridged 1 skill prompts/);
207+
});
208+
209+
it('an override that DEACTIVATES a packaged skill retires its prompt', async () => {
210+
const logger = makeLogger();
211+
// The mirror of the repro, and the case that proves the merged read is the
212+
// authority rather than merely an additional source: the registry says
213+
// active, the overlay says inactive, and the surface follows the overlay.
214+
const active = { ...PACKAGED_SKILL, active: true };
215+
await bridge(
216+
packagedService([active]),
217+
logger,
218+
mergedRead([{ ...active, active: false }]),
219+
);
220+
221+
expect(infoLines(logger)).toMatch(/Bridged 0 skill prompts/);
222+
});
223+
224+
it('a merged read that FAILS never falls back to the un-merged listing', async () => {
225+
const logger = makeLogger();
226+
const service = packagedService();
227+
const merged = {
228+
getMetaItems: vi.fn(async () => {
229+
throw new Error('sys_metadata unreadable');
230+
}),
231+
};
232+
await bridge(service, logger, merged);
233+
234+
// Falling back would answer with registry rows in the shape of merged ones
235+
// — this defect, restored silently at exactly the moment an overlay is most
236+
// likely to be the thing being missed.
237+
expect(service.list).not.toHaveBeenCalled();
238+
expect(warnLines(logger)).toMatch(/Could not read skill metadata/);
239+
expect(infoLines(logger)).not.toMatch(/Bridged \d+ skill prompts/);
240+
});
241+
242+
it('#6504 SURVIVES: a degraded verdict still reaches the operator through the merged read', async () => {
243+
const logger = makeLogger();
244+
// `getMetaItems` cannot express this — it swallows a MetadataService read
245+
// failure into its own catch — so the verdict is asked of `listDiagnosed`
246+
// alongside it. Losing this was the live risk of the layer change.
247+
await bridge(degradedService(), logger, mergedRead([OVERRIDDEN_SKILL]));
248+
249+
const lines = warnLines(logger);
250+
expect(lines).toMatch(/INCOMPLETE/);
251+
expect(lines).toMatch(/missing, NOT undeclared/);
252+
expect(logger.warn.mock.calls[0]![1].errors).toEqual([LOADER_FAILURE]);
253+
});
254+
255+
it('no merged read: the bridge reads exactly as it did before #8328', async () => {
256+
const logger = makeLogger();
257+
// A host assembled without the metadata protocol has no merged read to
258+
// give. `undefined` means "this host cannot merge", never "merging was
259+
// skipped", so the pre-#8328 read is the honest answer rather than a
260+
// regression — and a service predating #6504 keeps working too.
261+
const service = undiagnosableService([{ ...PACKAGED_SKILL, active: true }]);
262+
await bridge(service, logger, undefined);
263+
264+
expect(service.list).toHaveBeenCalledWith('skill');
265+
expect(infoLines(logger)).toMatch(/Bridged 1 skill prompts/);
266+
});
267+
});

0 commit comments

Comments
 (0)