Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
65 changes: 62 additions & 3 deletions src/scenarios/server/skills/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -39,25 +39,32 @@ 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,
METHOD_ID,
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. */
Expand Down Expand Up @@ -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.`;

Expand Down Expand Up @@ -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(
Expand All @@ -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<DirectoryReadResult>(
'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);
Expand Down Expand Up @@ -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();
Expand Down
113 changes: 113 additions & 0 deletions src/scenarios/server/skills/negative.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
formatWireViolation
} from '../../../validation/wire-schema';
import { SkillsEnumerationScenario } from './enumeration';
import { SkillsDirectoryReadScenario } from './directory';
import { SKILLS_EXTENSION_ID } from './helpers';

/**
Expand Down Expand Up @@ -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<string, unknown>
): 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<string, unknown>) {
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<void>((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');
});
});
6 changes: 4 additions & 2 deletions src/seps/sep-2640.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading