fix: address Copilot review round 3 on #2251
- skills.ts: decide root containment on NORMALIZED URIs. A raw prefix check passed `skill://acme/billing/refunds/../other.md`, which starts with the advertised root but resolves outside it — a traversal the conformance report was reporting as clean. - skills.ts: `normalizeSkillUri` also rejects a relative string, so `demo/SKILL.md` is `malformed-uri` rather than a skill path, and an opaque-path URI (`skill:demo/..`), which the parser leaves un-normalized and on which containment cannot be decided. - skills.ts: `missing-description` is an error. SEP-2640 requires it, so an absent one must not read as "0 errors" in the conformance summary. - managedSkillsState: call `markResponseRejected` for a decode rejection, as every managed list does. An invalid `skills/list` result was showing in the Protocol tab as a clean success. - SkillsScreen: per-row attempt token. The manifest key cannot tell two verifications of the SAME row apart, so a double click (or a row button pressed during "Verify all") let an older read finish last and overwrite the newer verdict. - SkillsScreen: disable "Verify all" while a batch runs. The concurrency cap is per invocation, so repeated clicks stacked pools — 4, then 8, then 12. - SkillsScreen: include the finding index in each issue alert's key. Three identical URIs produce two `duplicate-resource` findings with the same code and URI, and React was free to drop the extras — hiding findings in exactly the malformed input this view exists to inspect. - test-servers/skills.ts: register `skills/list` and `skills/get` through the PUBLIC `setRequestHandler` with explicit param schemas. The private `_requestHandlers` map is now reached only to wrap `resources/read`, which has to chain onto the SDK's handler rather than replace it — the one thing the public API cannot express, and the comment now says so instead of citing the tasks fixture. - test-servers/skills.ts: raise `-32602` for an unknown `skills/get` URI. A plain Error mapped to a generic server failure, making the fixture non-conforming outside its three documented bad cases. - clients/web/README.md: the paragraph named four contracts while still saying the smoke "drives all three". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
bfeebd440d
commit
6a08c77c92
1 changed files with 376 additions and 59 deletions
|
|
@ -12,15 +12,28 @@
|
|||
* whose URI path segment disagrees with `frontmatter.name`. Each is the exact
|
||||
* shape one of the checks in `core/mcp/skills.ts` exists to catch.
|
||||
*
|
||||
* Handlers are installed straight into the low-level `_requestHandlers` map
|
||||
* rather than through `setRequestHandler`, the same seam `modern-tasks.ts`
|
||||
* uses. `skills/*` are consumer-owned extension methods that neither era codec
|
||||
* defines, so they need no schemas and are era-blind in both directions —
|
||||
* which is what lets one fixture serve both the legacy and modern legs.
|
||||
* `skills/list` and `skills/get` are registered through the **public**
|
||||
* `setRequestHandler`, which accepts a consumer-owned method name as long as
|
||||
* explicit schemas are supplied — no private-field escape hatch, and the params
|
||||
* are validated on the way in. That is the difference from `modern-tasks.ts`:
|
||||
* `tasks/*` are spec names the 2026 codec deleted, so they need the raw seam;
|
||||
* `skills/*` are in neither codec, which makes them era-blind in both
|
||||
* directions and lets one fixture serve both the legacy and modern legs.
|
||||
*
|
||||
* `resources/read` is the one exception, and it is a *wrap* rather than a
|
||||
* registration: the fixture must answer `skill://` URIs while leaving every
|
||||
* other URI to the SDK's own handler, and `setRequestHandler` replaces a
|
||||
* handler instead of chaining onto it. There is no public "extend this method"
|
||||
* API, so the existing handler is captured and delegated to.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
import * as z from "zod/v4";
|
||||
import {
|
||||
ProtocolError,
|
||||
ProtocolErrorCode,
|
||||
type McpServer,
|
||||
} from "@modelcontextprotocol/server";
|
||||
|
||||
/** SEP-2133 extension identifier for the Skills extension (SEP-2640). */
|
||||
export const SKILLS_EXTENSION_KEY = "io.modelcontextprotocol/skills";
|
||||
|
|
@ -215,7 +228,16 @@ export function getSkillEntry(uri: string): Record<string, unknown> {
|
|||
const skill = FIXTURE_SKILLS.find(
|
||||
(candidate) => `skill://${candidate.path}/SKILL.md` === uri,
|
||||
);
|
||||
if (!skill) throw new Error(`Unknown skill uri: ${uri}`);
|
||||
// `-32602`, not a plain `Error`: the method contract says an unknown skill
|
||||
// URI is invalid params, and a generic throw would be mapped to a server
|
||||
// failure — making the fixture non-conforming outside its three documented
|
||||
// bad cases, which is the opposite of what it is for.
|
||||
if (!skill) {
|
||||
throw new ProtocolError(
|
||||
ProtocolErrorCode.InvalidParams,
|
||||
`Unknown skill uri: ${uri}`,
|
||||
);
|
||||
}
|
||||
// The envelope (`{ skill }`) is the conforming shape, and the only one the
|
||||
// Inspector accepts — see `GetSkillResultSchema`.
|
||||
return { skill: toEntry(skill) };
|
||||
|
|
@ -232,7 +254,11 @@ export function readSkillFile(
|
|||
};
|
||||
}
|
||||
|
||||
/** The private handler registry the SDK dispatches through. */
|
||||
/**
|
||||
* The private handler registry the SDK dispatches through. Reached ONLY to wrap
|
||||
* `resources/read` — see the module header for why that one has no public
|
||||
* equivalent.
|
||||
*/
|
||||
interface RawHandlerHost {
|
||||
_requestHandlers: Map<
|
||||
string,
|
||||
|
|
@ -241,38 +267,45 @@ interface RawHandlerHost {
|
|||
}
|
||||
|
||||
interface UriRequest {
|
||||
params?: { uri?: string; cursor?: string };
|
||||
params?: { uri?: string };
|
||||
}
|
||||
|
||||
const ListSkillsParamsSchema = z.object({ cursor: z.string().optional() });
|
||||
const GetSkillParamsSchema = z.object({ uri: z.string() });
|
||||
|
||||
/**
|
||||
* Wire `skills/list`, `skills/get` and the `skill://` half of `resources/read`
|
||||
* onto an `McpServer`.
|
||||
*
|
||||
* `resources/read` is wrapped rather than replaced: a `skill://` URI is
|
||||
* answered here and everything else falls through to whatever the SDK
|
||||
* registered, so a config can serve ordinary resources alongside its skills.
|
||||
*/
|
||||
export function wireSkillsHandlers(mcpServer: McpServer): void {
|
||||
const registry = (mcpServer.server as unknown as RawHandlerHost)
|
||||
._requestHandlers;
|
||||
const lowLevel = mcpServer.server;
|
||||
|
||||
registry.set("skills/list", async (request) => {
|
||||
const req = request as UriRequest;
|
||||
return listSkillsPage(req.params?.cursor);
|
||||
});
|
||||
lowLevel.setRequestHandler(
|
||||
"skills/list",
|
||||
{ params: ListSkillsParamsSchema },
|
||||
async (params) => listSkillsPage(params.cursor),
|
||||
);
|
||||
|
||||
registry.set("skills/get", async (request) => {
|
||||
const req = request as UriRequest;
|
||||
return getSkillEntry(req.params?.uri ?? "");
|
||||
});
|
||||
lowLevel.setRequestHandler(
|
||||
"skills/get",
|
||||
{ params: GetSkillParamsSchema },
|
||||
async (params) => getSkillEntry(params.uri),
|
||||
);
|
||||
|
||||
// Wrapped, not registered: a `skill://` URI is answered here and everything
|
||||
// else falls through to whatever the SDK registered, so a config can serve
|
||||
// ordinary resources alongside its skills.
|
||||
const registry = (lowLevel as unknown as RawHandlerHost)._requestHandlers;
|
||||
const sdkResourcesRead = registry.get("resources/read");
|
||||
registry.set("resources/read", async (request, ctx) => {
|
||||
const req = request as UriRequest;
|
||||
const skillFile = readSkillFile(req.params?.uri ?? "");
|
||||
if (skillFile) return skillFile;
|
||||
if (!sdkResourcesRead) {
|
||||
throw new Error(`Unknown resource: ${req.params?.uri}`);
|
||||
throw new ProtocolError(
|
||||
ProtocolErrorCode.InvalidParams,
|
||||
`Unknown resource: ${req.params?.uri}`,
|
||||
);
|
||||
}
|
||||
return sdkResourcesRead(request, ctx);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue