|
| 1 | +import { describe, test, expect } from 'vitest'; |
| 2 | +import { testScenarioContext } from '../../../mock-server/testing'; |
| 3 | +import { sendStatelessRequest } from '../../../connection/stateless'; |
| 4 | +import { DRAFT_PROTOCOL_VERSION } from '../../../types'; |
| 5 | +import { SkillsNoPrefetchScenario } from './no-prefetch'; |
| 6 | +import { SkillsVerificationScenario } from './verification'; |
| 7 | + |
| 8 | +/** |
| 9 | + * Regression for PR #330 review: both skills client scenarios declared the |
| 10 | + * skills extension only in their `initialize` reply, but `BaseHttpScenario` |
| 11 | + * intercepts `server/discover` before `handlePost` runs. A 2026-07-28 |
| 12 | + * discover-first client therefore saw the base class default (`{tools: {}}`) |
| 13 | + * and had nothing to gate a `skills/list` call on. |
| 14 | + * |
| 15 | + * The scenarios passed anyway because `server/discover` is optional for |
| 16 | + * clients and the clients we ran did not use it. That is exactly the failure |
| 17 | + * mode where a check goes green because its condition never arose, so both |
| 18 | + * lifecycles are asserted here rather than left to the next client to notice. |
| 19 | + */ |
| 20 | + |
| 21 | +const SKILLS_EXTENSION_ID = 'io.modelcontextprotocol/skills'; |
| 22 | + |
| 23 | +type Caps = { |
| 24 | + resources?: unknown; |
| 25 | + extensions?: Record<string, unknown>; |
| 26 | +}; |
| 27 | + |
| 28 | +function capsOf(result: unknown): Caps { |
| 29 | + return ((result as { capabilities?: Caps })?.capabilities ?? {}) as Caps; |
| 30 | +} |
| 31 | + |
| 32 | +function expectSkillsAdvertised(caps: Caps, where: string): void { |
| 33 | + expect(caps.extensions, `${where}: no extensions block`).toBeDefined(); |
| 34 | + expect( |
| 35 | + caps.extensions?.[SKILLS_EXTENSION_ID], |
| 36 | + `${where}: skills extension not advertised` |
| 37 | + ).toBeDefined(); |
| 38 | + // resources/read is how every skill file is fetched, so a client that gates |
| 39 | + // on capabilities needs this too. |
| 40 | + expect(caps.resources, `${where}: resources not advertised`).toBeDefined(); |
| 41 | +} |
| 42 | + |
| 43 | +const SCENARIOS: Array< |
| 44 | + [string, () => SkillsNoPrefetchScenario | SkillsVerificationScenario] |
| 45 | +> = [ |
| 46 | + ['no-prefetch', () => new SkillsNoPrefetchScenario()], |
| 47 | + ['verify-digest', () => new SkillsVerificationScenario('digest')], |
| 48 | + ['verify-size', () => new SkillsVerificationScenario('size')], |
| 49 | + ['verify-frontmatter', () => new SkillsVerificationScenario('frontmatter')] |
| 50 | +]; |
| 51 | + |
| 52 | +describe('SEP-2640 client scenarios advertise skills on both lifecycles', () => { |
| 53 | + test.each(SCENARIOS)('%s: server/discover', async (label, make) => { |
| 54 | + const scenario = make(); |
| 55 | + const { serverUrl } = await scenario.start(testScenarioContext()); |
| 56 | + try { |
| 57 | + const discover = await sendStatelessRequest(serverUrl, 'server/discover'); |
| 58 | + expect(discover.status).toBe(200); |
| 59 | + expectSkillsAdvertised( |
| 60 | + capsOf(discover.body?.result), |
| 61 | + `${label} discover` |
| 62 | + ); |
| 63 | + } finally { |
| 64 | + await scenario.stop(); |
| 65 | + } |
| 66 | + }); |
| 67 | + |
| 68 | + test.each(SCENARIOS)('%s: initialize', async (label, make) => { |
| 69 | + const scenario = make(); |
| 70 | + const { serverUrl } = await scenario.start(testScenarioContext()); |
| 71 | + try { |
| 72 | + const init = await sendStatelessRequest(serverUrl, 'initialize', { |
| 73 | + protocolVersion: DRAFT_PROTOCOL_VERSION, |
| 74 | + capabilities: {}, |
| 75 | + clientInfo: { name: 'capabilities-test', version: '1.0.0' } |
| 76 | + }); |
| 77 | + expect(init.status).toBe(200); |
| 78 | + expectSkillsAdvertised(capsOf(init.body?.result), `${label} initialize`); |
| 79 | + } finally { |
| 80 | + await scenario.stop(); |
| 81 | + } |
| 82 | + }); |
| 83 | +}); |
0 commit comments