feat(skills): address Copilot review round 16 on #2234
Round 16 reported no inline comments and three suppressed findings, all valid: - `getSkillsExtension` treated a non-object extension value (`false`, `"skills"`) as a declaration. SEP-2133 declares an extension as an object of sub-options, so a primitive is not one — now rejected, matching `appElicitation.ts`. - The `onGetSkill` prop doc claimed the two responses "must agree" and that a difference means a broken server, contradicting the implementation, which reports a difference as a finding rather than a fault. Reworded to what the code does. - No real-transport test exercised the fixture's `skills/*` handlers. Added `src/test/integration/mcp/inspectorClient-skills.test.ts`, parameterized over both protocol eras: capability advertisement, the paged `skills/list` walk (direct and through `ManagedSkillsState`), `skills/get`, the `-32602` for an unknown URI, `resources/read` of a skill file, and delegation of an ordinary resource through the fixture's `resources/read` wrapper. 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
a561cd1daa
commit
1ee958091e
4 changed files with 208 additions and 10 deletions
|
|
@ -127,10 +127,15 @@ export interface SkillsScreenProps {
|
||||||
/** Fetch one skill file's contents via `resources/read`, on demand. */
|
/** Fetch one skill file's contents via `resources/read`, on demand. */
|
||||||
onReadSkillFile: (uri: string) => Promise<SkillFileContents>;
|
onReadSkillFile: (uri: string) => Promise<SkillFileContents>;
|
||||||
/**
|
/**
|
||||||
* Re-fetch the selected entry through `skills/get` (SEP-2640). Distinct from
|
* Re-fetch the selected entry through `skills/get` (SEP-2640) — the
|
||||||
* the entry `skills/list` already returned, and the point of exercising it is
|
* extension's second required method, which nothing else in the app calls.
|
||||||
* that the two must agree: a server whose `skills/get` disagrees with its own
|
*
|
||||||
* listing is broken in a way only a side-by-side fetch can show.
|
* It is a **fresh point-in-time snapshot**, so a conforming result may
|
||||||
|
* legitimately differ from an older listing, and the screen presents a
|
||||||
|
* difference as an updated snapshot rather than a fault. What it does treat
|
||||||
|
* as a fault is the fetched entry being non-conforming in its own right, or
|
||||||
|
* answering for a different URI than the one requested — neither of which a
|
||||||
|
* fresh read excuses.
|
||||||
*/
|
*/
|
||||||
onGetSkill: (uri: string) => Promise<SkillEntry>;
|
onGetSkill: (uri: string) => Promise<SkillEntry>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,10 +88,16 @@ describe("getSkillsExtension", () => {
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a non-object declaration as declared with no sub-options", () => {
|
it("rejects a non-object declaration", () => {
|
||||||
expect(getSkillsExtension(caps({ [SKILLS_EXTENSION_KEY]: true }))).toEqual({
|
// SEP-2133 declares an extension as an object of sub-options, so a
|
||||||
directoryRead: false,
|
// primitive is not a declaration — and treating one as support would show
|
||||||
});
|
// the Skills tab and send `skills/list` to a server that never claimed to
|
||||||
|
// serve it. Same parsing as the UI extension in `appElicitation.ts`.
|
||||||
|
for (const declared of [true, false, "skills", 1]) {
|
||||||
|
expect(
|
||||||
|
getSkillsExtension(caps({ [SKILLS_EXTENSION_KEY]: declared })),
|
||||||
|
).toBeUndefined();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("isSkillsExtensionSupported mirrors presence", () => {
|
it("isSkillsExtensionSupported mirrors presence", () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||||
|
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
|
||||||
|
import { getSkillsExtension } from "@inspector/core/mcp/skills.js";
|
||||||
|
import { ManagedSkillsState } from "@inspector/core/mcp/state/managedSkillsState.js";
|
||||||
|
import {
|
||||||
|
createTestServerHttp,
|
||||||
|
type TestServerHttp,
|
||||||
|
createTestServerInfo,
|
||||||
|
} from "@modelcontextprotocol/inspector-test-server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live coverage of the Skills extension (SEP-2640, #2234) over a real
|
||||||
|
* transport against the real fixture.
|
||||||
|
*
|
||||||
|
* Everything else that covers this feature stubs the seam it is about: the
|
||||||
|
* client unit tests replace `client.request`, the screen tests mock the
|
||||||
|
* callbacks, and the store tests use a fake client. That leaves precisely the
|
||||||
|
* integration-sensitive claims unguarded — that `skills/list` and `skills/get`
|
||||||
|
* can be served through the SDK's **public** `setRequestHandler` for a
|
||||||
|
* consumer-owned method, that the fixture's `resources/read` wrapper answers
|
||||||
|
* `skill://` URIs while leaving other URIs to the SDK, that the cursor walk
|
||||||
|
* actually pages, and that all of it works on **both** protocol eras. Each of
|
||||||
|
* those is an assertion about the SDK's behavior, so only a real connection
|
||||||
|
* can check it.
|
||||||
|
*
|
||||||
|
* The era coverage is the point of the parameterization: `skills/*` are in
|
||||||
|
* neither era codec, which is *why* one fixture is expected to serve both
|
||||||
|
* legs — and that expectation had no test until this one.
|
||||||
|
*/
|
||||||
|
describe("Skills extension over a real transport (#2234)", () => {
|
||||||
|
let client: InspectorClient | null = null;
|
||||||
|
const servers: TestServerHttp[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
await client.disconnect();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
while (servers.length) {
|
||||||
|
const s = servers.pop();
|
||||||
|
try {
|
||||||
|
await s?.stop();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startSkillsServer(modern: boolean): Promise<TestServerHttp> {
|
||||||
|
const started = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo("skills-integration", "1.0.0"),
|
||||||
|
// An ordinary resource alongside the skills, so the fixture's
|
||||||
|
// `resources/read` wrapper is proven to DELEGATE rather than swallow.
|
||||||
|
resources: [
|
||||||
|
{
|
||||||
|
name: "plain",
|
||||||
|
uri: "foobar://plain",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
text: "plain",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: true,
|
||||||
|
...(modern && { modern: {} }),
|
||||||
|
});
|
||||||
|
await started.start();
|
||||||
|
servers.push(started);
|
||||||
|
return started;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(
|
||||||
|
url: string,
|
||||||
|
modern: boolean,
|
||||||
|
): Promise<InspectorClient> {
|
||||||
|
const connected = new InspectorClient(
|
||||||
|
{
|
||||||
|
type: "streamable-http",
|
||||||
|
url,
|
||||||
|
...(modern && { protocolEra: "modern" as const }),
|
||||||
|
},
|
||||||
|
{ environment: { transport: createTransportNode } },
|
||||||
|
);
|
||||||
|
await connected.connect();
|
||||||
|
client = connected;
|
||||||
|
return connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const modern of [false, true]) {
|
||||||
|
const era = modern ? "modern" : "legacy";
|
||||||
|
|
||||||
|
describe(`on the ${era} era`, () => {
|
||||||
|
it("advertises the extension in its capabilities", async () => {
|
||||||
|
const started = await startSkillsServer(modern);
|
||||||
|
const connected = await connect(started.url, modern);
|
||||||
|
// Bare, per the fixture: no `directoryRead` until phase 3 serves it.
|
||||||
|
expect(getSkillsExtension(connected.getCapabilities())).toEqual({
|
||||||
|
directoryRead: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves skills/list as a paged walk", async () => {
|
||||||
|
const started = await startSkillsServer(modern);
|
||||||
|
const connected = await connect(started.url, modern);
|
||||||
|
|
||||||
|
const first = await connected.listSkills();
|
||||||
|
// On the modern leg this call resolving is itself the envelope
|
||||||
|
// assertion: `listSkills` selects `ModernListSkillsResultSchema` from
|
||||||
|
// the negotiated era, and that schema rejects a page without
|
||||||
|
// `resultType` / `ttlMs` / `cacheScope`. It cannot be asserted on the
|
||||||
|
// returned value — `listSkills` narrows its result to the two fields
|
||||||
|
// below — so a modern page missing the envelope surfaces here as a
|
||||||
|
// rejection rather than as a missing property.
|
||||||
|
//
|
||||||
|
// The fixture pages at two, so a client that stops here sees half.
|
||||||
|
expect(first.skills).toHaveLength(2);
|
||||||
|
expect(first.nextCursor).toBeDefined();
|
||||||
|
|
||||||
|
const second = await connected.listSkills(first.nextCursor);
|
||||||
|
expect(second.skills).toHaveLength(2);
|
||||||
|
expect(second.nextCursor).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks every page through the managed store", async () => {
|
||||||
|
const started = await startSkillsServer(modern);
|
||||||
|
const connected = await connect(started.url, modern);
|
||||||
|
const store = new ManagedSkillsState(connected);
|
||||||
|
try {
|
||||||
|
const skills = await store.refresh();
|
||||||
|
expect(skills.map((s) => s.frontmatter.name)).toEqual([
|
||||||
|
"data-analysis",
|
||||||
|
"tampered-notes",
|
||||||
|
"dynamic-report",
|
||||||
|
"right-name",
|
||||||
|
]);
|
||||||
|
expect(store.getPagination()).toEqual({ pageCount: 2 });
|
||||||
|
} finally {
|
||||||
|
store.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves skills/get for one entry", async () => {
|
||||||
|
const started = await startSkillsServer(modern);
|
||||||
|
const connected = await connect(started.url, modern);
|
||||||
|
const entry = await connected.getSkill(
|
||||||
|
"skill://data-analysis/SKILL.md",
|
||||||
|
);
|
||||||
|
expect(entry.frontmatter.name).toBe("data-analysis");
|
||||||
|
expect(Array.isArray(entry.resources)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers -32602 for an unknown skill uri", async () => {
|
||||||
|
const started = await startSkillsServer(modern);
|
||||||
|
const connected = await connect(started.url, modern);
|
||||||
|
await expect(
|
||||||
|
connected.getSkill("skill://nope/SKILL.md"),
|
||||||
|
).rejects.toThrow(/Unknown skill uri/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a skill file through resources/read", async () => {
|
||||||
|
const started = await startSkillsServer(modern);
|
||||||
|
const connected = await connect(started.url, modern);
|
||||||
|
const read = await connected.readResource(
|
||||||
|
"skill://data-analysis/reference.md",
|
||||||
|
);
|
||||||
|
const block = read.result.contents[0];
|
||||||
|
expect(block.uri).toBe("skill://data-analysis/reference.md");
|
||||||
|
expect("text" in block && block.text).toContain("Column rules");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still serves an ordinary resource — the wrapper delegates", async () => {
|
||||||
|
// The one thing the `resources/read` wrap must not break.
|
||||||
|
const started = await startSkillsServer(modern);
|
||||||
|
const connected = await connect(started.url, modern);
|
||||||
|
const read = await connected.readResource("foobar://plain");
|
||||||
|
expect(read.result.contents[0].uri).toBe("foobar://plain");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -109,9 +109,13 @@ export function getSkillsExtension(
|
||||||
capabilities: ServerCapabilities | undefined,
|
capabilities: ServerCapabilities | undefined,
|
||||||
): SkillsExtensionSupport | undefined {
|
): SkillsExtensionSupport | undefined {
|
||||||
const declared = capabilities?.extensions?.[SKILLS_EXTENSION_KEY];
|
const declared = capabilities?.extensions?.[SKILLS_EXTENSION_KEY];
|
||||||
if (declared === undefined || declared === null) return undefined;
|
// Must be an OBJECT. SEP-2133 declares an extension as an object of
|
||||||
|
// sub-options, so a primitive (`false`, `"skills"`) is not a declaration —
|
||||||
|
// and treating one as support would show the Skills tab and send
|
||||||
|
// `skills/list` to a server that never claimed to serve it. Matches how
|
||||||
|
// `appElicitation.ts` parses the UI extension.
|
||||||
|
if (typeof declared !== "object" || declared === null) return undefined;
|
||||||
const directoryRead =
|
const directoryRead =
|
||||||
typeof declared === "object" &&
|
|
||||||
(declared as { directoryRead?: unknown }).directoryRead === true;
|
(declared as { directoryRead?: unknown }).directoryRead === true;
|
||||||
return { directoryRead };
|
return { directoryRead };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue