diff --git a/src/scenarios/server/skills/directory.ts b/src/scenarios/server/skills/directory.ts index 91dae452..746b30cf 100644 --- a/src/scenarios/server/skills/directory.ts +++ b/src/scenarios/server/skills/directory.ts @@ -2,7 +2,7 @@ * SEP-2640 Skills extension — the `resources/directory/read` surface (added in * spec commit 2e04c48d, 2026-06-09). * - * One scenario, six checks (per AGENTS.md "fewer scenarios, more checks"). + * One scenario, seven checks (per AGENTS.md "fewer scenarios, more checks"). * Each check's verbatim spec quote lives next to its check ID in * src/seps/sep-2640.yaml. * @@ -39,12 +39,16 @@ import { const DIRECTORY_MIME = 'inode/directory'; +const CACHE_TEXT = + 'ReadResourceDirectoryResult extends CacheableResult, so ttlMs and cacheScope are REQUIRED, as they are on resources/list.'; + const CAPABILITY_ID = 'sep-2640-capability-directory-read-flag'; const METHOD_ID = 'sep-2640-directory-read-method-registered'; const SHAPE_ID = 'sep-2640-directory-read-result-resources-shape'; const SUBDIR_ID = 'sep-2640-directory-read-subdir-mimetype'; const INVALID_PARAMS_ID = 'sep-2640-directory-read-invalid-params'; const PAGINATION_ID = 'sep-2640-directory-read-pagination'; +const CACHE_ID = 'sep-2640-directory-read-cache-attributes'; const ALL_IDS = [ CAPABILITY_ID, @@ -52,12 +56,15 @@ const ALL_IDS = [ SHAPE_ID, SUBDIR_ID, INVALID_PARAMS_ID, - PAGINATION_ID + PAGINATION_ID, + CACHE_ID ]; interface DirectoryReadResult { resources?: SkillResource[]; nextCursor?: string; + ttlMs?: unknown; + cacheScope?: unknown; } /** A directory to exercise plus, when known, a non-directory resource under it. */ @@ -136,6 +143,7 @@ export class SkillsDirectoryReadScenario implements ClientScenario { - \`sep-2640-directory-read-subdir-mimetype\` — subdirectory children carry \`inode/directory\` (MUST) - \`sep-2640-directory-read-invalid-params\` — a non-directory URI returns \`-32602\` (MUST) - \`sep-2640-directory-read-pagination\` — \`nextCursor\` round-trips per resources/list (single-page is conformant) +- \`sep-2640-directory-read-cache-attributes\` — from protocol 2026-07-28, the result carries \`ttlMs\` and \`cacheScope\` (MUST; ext-skills#157) **Gating & discovery**: the checks SKIP when the skills extension or its \`directoryRead\` flag is undeclared. The directory to exercise is discovered dynamically from \`skills/list\` / \`resources/list\` — no fixture URI is hardcoded.`; @@ -193,7 +201,8 @@ export class SkillsDirectoryReadScenario implements ClientScenario { [ PAGINATION_ID, 'nextCursor round-trips per the resources/list contract.' - ] + ], + [CACHE_ID, CACHE_TEXT] ]; for (const [id, desc] of rest) { checks.push( @@ -205,12 +214,14 @@ export class SkillsDirectoryReadScenario implements ClientScenario { // === Happy path: list the discovered directory === let happy: DirectoryReadResult | undefined; + let firstPage: DirectoryReadResult | undefined; let happyErr: unknown; try { happy = await conn.request( 'resources/directory/read', { uri: target.dirUri } ); + firstPage = happy; // Every check below inspects the directory's children, so they need // the whole directory rather than whichever slice fits one page. const all = await directoryReadAll(conn, target.dirUri); @@ -385,6 +396,54 @@ export class SkillsDirectoryReadScenario implements ClientScenario { ) ); + // Check 7: caching attributes. ext-skills#157 made + // ReadResourceDirectoryResult extend CacheableResult, matching every base + // list-shaped result. Gated like the skills/list and skills/get checks: + // below 2026-07-28 CacheableResult is undefined by the negotiated + // schema, so omission there is correct rather than a defect. + const cacheApplies = ctx.specVersion >= '2026-07-28'; + if (firstPage === undefined) { + checks.push( + skillsCheck(CACHE_ID, CACHE_TEXT, 'FAILURE', { + errorMessage: 'directory read did not return a result' + }) + ); + } else if (!cacheApplies) { + checks.push( + skillsCheck(CACHE_ID, CACHE_TEXT, 'SKIPPED', { + errorMessage: `not applicable on negotiated protocol ${ctx.specVersion}: ttlMs and cacheScope are defined from 2026-07-28`, + details: { + specVersion: ctx.specVersion, + ttlMs: firstPage.ttlMs, + cacheScope: firstPage.cacheScope + } + }) + ); + } else { + const hasTtl = firstPage.ttlMs !== undefined; + const hasScope = firstPage.cacheScope !== undefined; + const missing = [!hasTtl && 'ttlMs', !hasScope && 'cacheScope'] + .filter(Boolean) + .join(' and '); + checks.push( + skillsCheck( + CACHE_ID, + CACHE_TEXT, + hasTtl && hasScope ? 'SUCCESS' : 'FAILURE', + hasTtl && hasScope + ? { + details: { + ttlMs: firstPage.ttlMs, + cacheScope: firstPage.cacheScope + } + } + : { + errorMessage: `resources/directory/read result omits ${missing} on protocol ${ctx.specVersion}, where the stable spec page requires both.` + } + ) + ); + } + return checks; } finally { await conn.close(); diff --git a/src/scenarios/server/skills/negative.test.ts b/src/scenarios/server/skills/negative.test.ts index c53298bb..fff43736 100644 --- a/src/scenarios/server/skills/negative.test.ts +++ b/src/scenarios/server/skills/negative.test.ts @@ -10,6 +10,7 @@ import { formatWireViolation } from '../../../validation/wire-schema'; import { SkillsEnumerationScenario } from './enumeration'; +import { SkillsDirectoryReadScenario } from './directory'; import { SKILLS_EXTENSION_ID } from './helpers'; /** @@ -299,3 +300,115 @@ describe('SEP-2640 frontmatter identity', () => { expect(checks.get(IDENTICAL_ID)?.status).toBe('FAILURE'); }); }); + +/** + * `sep-2640-directory-read-cache-attributes` (ext-skills#157): from protocol + * 2026-07-28, `ReadResourceDirectoryResult` extends `CacheableResult`, so a + * directory read that omits `ttlMs` or `cacheScope` is non-conformant. The + * shared stateless mock only stamps those fields on base cacheable methods, + * so this server sends exactly what each case hands it. + */ +const DIR_CACHE_ID = 'sep-2640-directory-read-cache-attributes'; + +function startDirectoryServer( + cacheFields: Record +): Promise<{ url: string; server: Server }> { + const skillUri = 'skill://demo/SKILL.md'; + const server = createServer(async (req, res) => { + if (req.method !== 'POST') { + res.writeHead(405).end(); + return; + } + const body = await readJsonBody(req); + const method = body.method as string; + const id = body.id; + const params = (body.params ?? {}) as { uri?: string }; + const send = (result: object) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(jsonRpcResult(id, method, result)); + }; + const fail = (code: number, message: string) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })); + }; + + switch (method) { + case 'server/discover': + send({ + supportedVersions: [DRAFT_PROTOCOL_VERSION], + capabilities: { + resources: {}, + extensions: { [SKILLS_EXTENSION_ID]: { directoryRead: true } } + }, + serverInfo: { name: 'skills-directory-negative', version: '1.0.0' } + }); + return; + case 'skills/list': + send({ skills: [entryFor('demo')], ttlMs: 0, cacheScope: 'private' }); + return; + case 'resources/list': + send({ resources: [] }); + return; + case 'resources/directory/read': + if (params.uri === 'skill://demo') { + send({ + resources: [ + { uri: skillUri, name: 'SKILL.md', mimeType: 'text/markdown' }, + { + uri: 'skill://demo/templates', + name: 'templates', + mimeType: 'inode/directory' + } + ], + ...cacheFields + }); + } else { + fail(-32602, `not a directory: ${params.uri}`); + } + return; + default: + fail(-32601, `Method not found: ${method}`); + } + }); + + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, () => { + const addr = server.address() as AddressInfo; + resolve({ url: `http://localhost:${addr.port}/mcp`, server }); + }); + }); +} + +async function directoryChecksFor(cacheFields: Record) { + const { url, server } = await startDirectoryServer(cacheFields); + try { + const scenario = new SkillsDirectoryReadScenario(); + const checks = await scenario.run(testContext(url, DRAFT_PROTOCOL_VERSION)); + // A result missing CacheableResult fields may also trip wire validation + // where the harness knows the method; drain so the global hook stays quiet. + takeWireViolations(); + return new Map(checks.map((c) => [c.id, c])); + } finally { + await new Promise((r) => server.close(() => r())); + } +} + +describe('SEP-2640 directory-read caching attributes', () => { + test('a result carrying ttlMs and cacheScope passes', async () => { + const checks = await directoryChecksFor({ + ttlMs: 300000, + cacheScope: 'public' + }); + expect(checks.get(DIR_CACHE_ID)?.status).toBe('SUCCESS'); + }); + + test.each([ + ['both fields', {}], + ['ttlMs', { cacheScope: 'public' }], + ['cacheScope', { ttlMs: 300000 }] + ])('a result omitting %s is a FAILURE', async (_label, fields) => { + const checks = await directoryChecksFor(fields); + expect(checks.get(DIR_CACHE_ID)?.status).toBe('FAILURE'); + }); +}); diff --git a/src/seps/sep-2640.yaml b/src/seps/sep-2640.yaml index ed82fdf9..c219b12d 100644 --- a/src/seps/sep-2640.yaml +++ b/src/seps/sep-2640.yaml @@ -25,9 +25,9 @@ # # coverage: extracted against branch head a3e147ca2710 (2026-08-25), the commit # the CM vote is running on. A keyword sweep of the source finds 97 sentences -# carrying an RFC 2119 term; this file declares 89 checks plus 7 excluded rows. +# carrying an RFC 2119 term; this file declares 90 checks plus 7 excluded rows. # The residual handful are restatements that fold into a declared row rather -# than standing alone. Of the 89 declared checks, 40 are exercised on the wire +# than standing alone. Of the 90 declared checks, 41 are exercised on the wire # by the three server scenarios; the other 49 are host obligations (retrieval # policy, approval binding, cache isolation, context handling) that a # server-side harness cannot observe and that would need a host-side scenario @@ -283,6 +283,8 @@ requirements: text: 'The method applies only to directory resources. If the URI does not exist, or exists but is not a directory resource, the server MUST return error `-32602` (Invalid params) — the same code `resources/read` uses for unknown resources.' - check: sep-2640-directory-read-pagination text: 'Pagination mirrors `resources/list`: when the result includes `nextCursor`, the client passes it back as `cursor` to retrieve the next page.' + - check: sep-2640-directory-read-cache-attributes + text: '`ReadResourceDirectoryResult` extends `CacheableResult`, so `ttlMs` and `cacheScope` are **REQUIRED**, as they are on `resources/list`, and carry the same semantics. They describe the freshness of the directory listing, not of the held entry.' - check: sep-2640-directory-read-empty-dir text: 'An empty directory yields an empty `resources` array.' - check: sep-2640-directory-read-not-manifest-extension