Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a64d7ea
chore: add SEP-2640 requirement-traceability YAML (Skills Extension)
panyam Jun 4, 2026
2ecad13
style: apply yaml formatter pass
panyam Jun 4, 2026
4fa8b10
chore(sep-2640): record spec source provenance
panyam Jun 4, 2026
fc60fee
Merge branch 'modelcontextprotocol:main' into chore/sep-2640-yaml
panyam Jun 5, 2026
1e50cad
chore(sep-2640): re-extract against SEP HEAD 556154c (drops mcp-resou…
panyam Jun 5, 2026
9a16e54
Merge branch 'main' into chore/sep-2640-yaml
panyam Jun 16, 2026
f1b0695
feat(sep-2640): ResourcesDirectoryReadScenario for resources/director…
panyam Jun 16, 2026
bf08157
Merge pull request #18 from panyam/feat/sep-2640-directory-read
panyam Jun 16, 2026
1711d76
Merge remote-tracking branch 'upstream/main' into chore/sep-2640-yaml
panyam Jul 1, 2026
b8ca9a9
Merge branch 'main' into chore/sep-2640-yaml
panyam Aug 4, 2026
2117d61
feat(sep-2640): expand skills conformance — index + manifest scenario…
panyam Aug 4, 2026
0d077f2
Merge branch 'modelcontextprotocol:main' into chore/sep-2640-yaml
panyam Aug 28, 2026
0707a3e
feat(sep-2640): re-extract against the 2026-08-21 rewrite
panyam Aug 29, 2026
3d75cb0
feat(sep-2640): close the extraction gaps found by a normative-senten…
panyam Aug 29, 2026
22f4ecb
style: apply prettier to the SEP-2640 scenarios and yaml
panyam Aug 29, 2026
e782847
docs(sep-2640): note why resultType is not declared in this yaml
panyam Aug 29, 2026
c119e75
Merge branch 'main' into chore/sep-2640-yaml
panyam Sep 4, 2026
1f21fba
fix(sep-2640): follow nextCursor on resources/directory/read
panyam Sep 4, 2026
fa8aaec
feat(sep-2640): client scenario for the no-prefetch MUST NOT
panyam Sep 5, 2026
378f258
feat(sep-2640): client scenarios for the four read-time verification …
panyam Sep 5, 2026
4f80ecd
fix(sep-2640): gate the cache-attributes check on the negotiated version
panyam Sep 7, 2026
afc6dbb
docs(sep-2640): how to run the skills scenarios against any implement…
panyam Sep 7, 2026
e4725e5
Merge branch 'main' into chore/sep-2640-yaml
panyam Sep 7, 2026
18c6efd
Merge upstream main into chore/sep-2640-yaml
panyam Sep 7, 2026
5c233ad
fix(sep-2640): advertise skills on server/discover, not only initialize
panyam Sep 8, 2026
a9f6280
fix(sep-2640): give the capability and naming checks something to fai…
panyam Sep 8, 2026
b93dd2c
docs(sep-2640): name skills/list in the discovery prose, not index.json
panyam Sep 8, 2026
88e1b3e
fix(sep-2640): grade unlisted reads against the entry, not one fixtur…
panyam Sep 9, 2026
8751c6f
fix(sep-2640): require the manifest read, and retire the unlisted sce…
panyam Sep 10, 2026
73ac2c4
feat(sep-2640): check skills/get cache attributes and the resources c…
panyam Sep 10, 2026
5a5c52e
fix(sep-2640): compare frontmatter by content, not key order
pcarleton Sep 11, 2026
f70f8e4
fix(sep-2640): make the client-scenario mocks answer like a conforman…
pcarleton Sep 11, 2026
cd41e4e
docs(sep-2640): run from the published package; --force only with --s…
pcarleton Sep 11, 2026
c4d7949
docs(sep-2640): drop RUNNING_SEP2640.md
pcarleton Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/scenarios/client/draft-result-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type { BaseHttpScenario } from './http-base';
import { RequestMetadataScenario } from './request-metadata';
import { MRTRClientScenario } from './mrtr-client';
import { JsonSchemaRefDerefScenario } from './json-schema-ref-deref';
import { SkillsNoPrefetchScenario } from './skills/no-prefetch';
import { SkillsVerificationScenario } from './skills/verification';

/**
* Pins that the hand-rolled mock servers used by client-direction scenarios
Expand Down Expand Up @@ -528,3 +530,96 @@ describe('sep-2322-client-request-state mock results (2026-07-28)', () => {
}
});
});

describe('sep-2640 skills client mock results', () => {
const skillsScenarios: Array<{
name: string;
make: () => BaseHttpScenario;
}> = [
{
name: 'sep-2640-client-no-prefetch',
make: () => new SkillsNoPrefetchScenario()
},
{
name: 'sep-2640-client-verify-digest',
make: () => new SkillsVerificationScenario('digest')
},
{
name: 'sep-2640-client-verify-size',
make: () => new SkillsVerificationScenario('size')
},
{
name: 'sep-2640-client-verify-frontmatter',
make: () => new SkillsVerificationScenario('frontmatter')
}
];

for (const s of skillsScenarios) {
it(`${s.name} carries the draft-required members on skills/list and resources/read`, async () => {
const scenario = s.make();
const { serverUrl } = await scenario.start(
testScenarioContext(DRAFT_PROTOCOL_VERSION)
);
try {
let id = 1;
for (const [method, params] of [
['skills/list', {}],
['resources/read', { uri: 'skill://pdf-processing/SKILL.md' }]
] as const) {
const { status, body } = await post(
serverUrl,
{
jsonrpc: '2.0',
id: id++,
method,
params: { ...params, _meta: meta }
},
{
'mcp-protocol-version': DRAFT_PROTOCOL_VERSION,
'Mcp-Method': method
}
);
expect(status, method).toBe(200);
expect(body.result, method).toMatchObject(CACHEABLE_FIELDS);
if (method === 'resources/read') {
expect(
wireSchemaErrors(DRAFT_PROTOCOL_VERSION, body, method),
method
).toEqual([]);
}
}
} finally {
await scenario.stop();
}
});

// The runner hands these scenarios' clients 2025-11-25 unless
// --spec-version says otherwise, and the extension has no 2026-07-28
// dependency, so the mock answers at the version the client asked for.
it(`${s.name} answers initialize at the requested protocol version`, async () => {
const scenario = s.make();
const { serverUrl } = await scenario.start(testScenarioContext());
try {
let id = 1;
for (const protocolVersion of ['2025-11-25', DRAFT_PROTOCOL_VERSION]) {
const { status, body } = await post(serverUrl, {
jsonrpc: '2.0',
id: id++,
method: 'initialize',
params: {
protocolVersion,
capabilities: {},
clientInfo: { name: 'test', version: '1.0' }
}
});
expect(status, protocolVersion).toBe(200);
expect(body.result.protocolVersion, protocolVersion).toBe(
protocolVersion
);
}
} finally {
await scenario.stop();
}
});
}
});
83 changes: 83 additions & 0 deletions src/scenarios/client/skills/capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, test, expect } from 'vitest';
import { testScenarioContext } from '../../../mock-server/testing';
import { sendStatelessRequest } from '../../../connection/stateless';
import { DRAFT_PROTOCOL_VERSION } from '../../../types';
import { SkillsNoPrefetchScenario } from './no-prefetch';
import { SkillsVerificationScenario } from './verification';

/**
* Regression for PR #330 review: both skills client scenarios declared the
* skills extension only in their `initialize` reply, but `BaseHttpScenario`
* intercepts `server/discover` before `handlePost` runs. A 2026-07-28
* discover-first client therefore saw the base class default (`{tools: {}}`)
* and had nothing to gate a `skills/list` call on.
*
* The scenarios passed anyway because `server/discover` is optional for
* clients and the clients we ran did not use it. That is exactly the failure
* mode where a check goes green because its condition never arose, so both
* lifecycles are asserted here rather than left to the next client to notice.
*/

const SKILLS_EXTENSION_ID = 'io.modelcontextprotocol/skills';

type Caps = {
resources?: unknown;
extensions?: Record<string, unknown>;
};

function capsOf(result: unknown): Caps {
return ((result as { capabilities?: Caps })?.capabilities ?? {}) as Caps;
}

function expectSkillsAdvertised(caps: Caps, where: string): void {
expect(caps.extensions, `${where}: no extensions block`).toBeDefined();
expect(
caps.extensions?.[SKILLS_EXTENSION_ID],
`${where}: skills extension not advertised`
).toBeDefined();
// resources/read is how every skill file is fetched, so a client that gates
// on capabilities needs this too.
expect(caps.resources, `${where}: resources not advertised`).toBeDefined();
}

const SCENARIOS: Array<
[string, () => SkillsNoPrefetchScenario | SkillsVerificationScenario]
> = [
['no-prefetch', () => new SkillsNoPrefetchScenario()],
['verify-digest', () => new SkillsVerificationScenario('digest')],
['verify-size', () => new SkillsVerificationScenario('size')],
['verify-frontmatter', () => new SkillsVerificationScenario('frontmatter')]
];

describe('SEP-2640 client scenarios advertise skills on both lifecycles', () => {
test.each(SCENARIOS)('%s: server/discover', async (label, make) => {
const scenario = make();
const { serverUrl } = await scenario.start(testScenarioContext());
try {
const discover = await sendStatelessRequest(serverUrl, 'server/discover');
expect(discover.status).toBe(200);
expectSkillsAdvertised(
capsOf(discover.body?.result),
`${label} discover`
);
} finally {
await scenario.stop();
}
});

test.each(SCENARIOS)('%s: initialize', async (label, make) => {
const scenario = make();
const { serverUrl } = await scenario.start(testScenarioContext());
try {
const init = await sendStatelessRequest(serverUrl, 'initialize', {
protocolVersion: DRAFT_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: 'capabilities-test', version: '1.0.0' }
});
expect(init.status).toBe(200);
expectSkillsAdvertised(capsOf(init.body?.result), `${label} initialize`);
} finally {
await scenario.stop();
}
});
});
52 changes: 52 additions & 0 deletions src/scenarios/client/skills/mock-results.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Results for the hand-rolled SEP-2640 client-scenario mocks, shaped like a
* conformant server's so a strict client is graded on its skills behaviour
* rather than turned away by the harness.
*/

import {
DRAFT_PROTOCOL_VERSION,
NEGOTIABLE_PROTOCOL_VERSIONS
} from '../../../types.js';
import { withRequiredDraftResultFields } from '../../../mock-server/index.js';

/**
* `ListSkillsResult` is cacheable like `resources/list`, but `skills/list` is
* an extension method and not in the shared cacheable set, so the caching
* members are stamped here.
*/
export function skillsListResult(skills: object[]): object {
return { resultType: 'complete', ttlMs: 0, cacheScope: 'private', skills };
}

export function readResult(uri: unknown, text: string): unknown {
return withRequiredDraftResultFields('resources/read', {
contents: [{ uri, mimeType: 'text/markdown', text }]
});
}

/**
* The `initialize` result at the version the client asked for, when the suite
* can speak it. The extension has no 2026-07-28 dependency, and the runner
* hands these scenarios' clients 2025-11-25 unless `--spec-version` says
* otherwise, so a fixed 2026-07-28 reply would turn a correct stateful client
* away before it ever lists.
*/
export function initializeResult(
name: string,
request: { params?: { protocolVersion?: unknown } },
capabilities: object
): object {
const requested = request.params?.protocolVersion;
const protocolVersion =
typeof requested === 'string' &&
NEGOTIABLE_PROTOCOL_VERSIONS.includes(requested)
? requested
: DRAFT_PROTOCOL_VERSION;
return {
resultType: 'complete',
protocolVersion,
serverInfo: { name: `${name}-server`, version: '1.0.0' },
capabilities
};
}
Loading
Loading