diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cae4bf3..bae8e9e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,9 @@ jobs: - name: Check managed-auth App bundle run: bun run check:managed-auth-app + - name: Check bundled skills + run: bun run check:skills + - name: Type check run: bunx tsc --noEmit --incremental false diff --git a/.prettierignore b/.prettierignore index 231db60a..ab6909e5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ src/lib/mcp/apps/generated/managed-auth-app.ts +src/lib/mcp/skills/generated/skills.ts diff --git a/AGENTS.md b/AGENTS.md index 031c2f55..c0eca6fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,10 @@ bun run format Prettier formats the following file types: `*.ts`, `*.js`, `*.json`, `*.md` +### Skills + +The server serves agent skills through the MCP Skills extension (`io.modelcontextprotocol/skills`). They come from [kernel/skills](https://github.com/kernel/skills) at the commit pinned in `scripts/build-skills.mjs` and are committed as `src/lib/mcp/skills/generated/skills.ts`. To pick up skill changes, bump `SKILLS_COMMIT` (or add a directory to `SKILL_DIRECTORIES`) and run `bun run build:skills`. CI runs `bun run check:skills` to confirm the committed module matches the pin. + ### MCP responses Model-facing JSON (tool results and resources) is compact: use `jsonResponse` or `JSON.stringify(value)` without indentation. diff --git a/package.json b/package.json index 863a33be..c5e29ee7 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "dev": "next dev -p 3002", "build:managed-auth-app": "bun scripts/build-managed-auth-app.mjs", "check:managed-auth-app": "bun scripts/build-managed-auth-app.mjs --check", + "build:skills": "bun scripts/build-skills.mjs", + "check:skills": "bun scripts/build-skills.mjs --check", "build": "bun run check:managed-auth-app && next build", "start": "next start -p 3002", "lint": "next lint", diff --git a/scripts/build-skills.mjs b/scripts/build-skills.mjs new file mode 100644 index 00000000..706870af --- /dev/null +++ b/scripts/build-skills.mjs @@ -0,0 +1,162 @@ +// Generates the skills served over the MCP Skills extension from kernel/skills +// at a pinned commit. Production build uses the committed module; regenerate +// after bumping SKILLS_COMMIT with: bun run build:skills +// --check fetches the pinned commit and fails if the committed module differs. +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { extname, join, resolve } from "node:path"; + +const SKILLS_REPOSITORY = "https://github.com/kernel/skills.git"; +const SKILLS_COMMIT = "4376bb1057e60d1377fb42dbe41de35689b4aa5a"; +// Skill directories in the repository. Each is served at skill:///. +const SKILL_DIRECTORIES = ["skills/kernel-mcp"]; + +// Per-skill limits every conforming host must accept (SEP-2640). +const MAX_FILES_PER_SKILL = 512; +const MAX_BYTES_PER_SKILL = 16 * 1024 * 1024; + +const MIME_TYPES = { + ".md": "text/markdown", + ".txt": "text/plain", + ".json": "application/json", + ".yaml": "application/yaml", + ".yml": "application/yaml", + ".py": "text/x-python", + ".sh": "text/x-shellscript", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".ts": "text/typescript", + ".html": "text/html", + ".svg": "image/svg+xml", + ".png": "image/png", +}; + +const root = resolve(import.meta.dirname, ".."); +const generatedPath = join(root, "src/lib/mcp/skills/generated/skills.ts"); +const check = process.argv.includes("--check"); +const temp = await mkdtemp(join(tmpdir(), "kernel-skills-")); + +async function git(args, { binary = false } = {}) { + const proc = Bun.spawn(["git", "-C", temp, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).arrayBuffer(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (code !== 0) throw new Error(`git ${args.join(" ")} failed: ${stderr}`); + return binary ? new Uint8Array(stdout) : new TextDecoder().decode(stdout); +} + +function parseFrontmatter(text, path) { + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text); + if (!match) throw new Error(`${path} has no YAML frontmatter`); + const frontmatter = Bun.YAML.parse(match[1]); + if ( + typeof frontmatter?.name !== "string" || + typeof frontmatter?.description !== "string" + ) { + throw new Error(`${path} frontmatter needs a name and description`); + } + return frontmatter; +} + +try { + await git(["init", "--quiet"]); + await git([ + "fetch", + "--quiet", + "--depth=1", + SKILLS_REPOSITORY, + SKILLS_COMMIT, + ]); + + const skills = []; + for (const directory of SKILL_DIRECTORIES) { + const skillName = directory.split("/").at(-1); + const tree = await git([ + "ls-tree", + "-r", + "-z", + SKILLS_COMMIT, + "--", + `${directory}/`, + ]); + const files = []; + for (const entry of tree.split("\0").filter(Boolean)) { + const [info, path] = entry.split("\t"); + const [mode, type, object] = info.split(" "); + if (type !== "blob" || mode === "120000") { + throw new Error(`${path} is not a regular file`); + } + const bytes = await git(["cat-file", "blob", object], { binary: true }); + const relativePath = path.slice(directory.length + 1); + const mimeType = + MIME_TYPES[extname(relativePath).toLowerCase()] ?? + "application/octet-stream"; + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + // Binary content is served as a base64 blob. + } + files.push({ + path: relativePath, + mimeType, + size: bytes.byteLength, + digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + ...(text === undefined + ? { blob: Buffer.from(bytes).toString("base64") } + : { text }), + }); + } + + const skillFile = files.find((file) => file.path === "SKILL.md"); + if (!skillFile?.text) throw new Error(`${directory} has no SKILL.md`); + const frontmatter = parseFrontmatter( + skillFile.text, + `${directory}/SKILL.md`, + ); + if (frontmatter.name !== skillName) { + throw new Error( + `${directory}/SKILL.md name "${frontmatter.name}" must match its directory`, + ); + } + const totalBytes = files.reduce((sum, file) => sum + file.size, 0); + if ( + files.length > MAX_FILES_PER_SKILL || + totalBytes > MAX_BYTES_PER_SKILL + ) { + throw new Error(`${directory} exceeds the per-skill limits`); + } + skills.push({ name: skillName, frontmatter, files }); + } + + const generated = `// Generated by scripts/build-skills.mjs from kernel/skills@${SKILLS_COMMIT}. Do not edit. +import type { BundledSkill } from "@/lib/mcp/skills/types"; + +export const BUNDLED_SKILLS: BundledSkill[] = ${JSON.stringify(skills, null, 2)}; +`; + + if (check) { + let current = ""; + try { + current = await readFile(generatedPath, "utf8"); + } catch { + // Report the same actionable stale-module error below. + } + if (current !== generated) { + console.error("Bundled skills are stale. Run: bun run build:skills"); + process.exitCode = 1; + } + } else { + await mkdir(resolve(generatedPath, ".."), { recursive: true }); + await writeFile(generatedPath, generated); + console.log(`Generated ${generatedPath}`); + } +} finally { + await rm(temp, { recursive: true, force: true }); +} diff --git a/src/app/[transport]/dual-era.test.ts b/src/app/[transport]/dual-era.test.ts index b06e8c72..dfb77f88 100644 --- a/src/app/[transport]/dual-era.test.ts +++ b/src/app/[transport]/dual-era.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { Client as LegacyClient } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport as LegacyTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { ResultSchema as LegacyResultSchema } from "@modelcontextprotocol/sdk/types.js"; import { Client, StreamableHTTPClientTransport, @@ -10,6 +11,7 @@ import { type StoredOAuthTokens, } from "@modelcontextprotocol/client"; import { CallToolResultSchema } from "@modelcontextprotocol/core"; +import { z } from "zod"; const fixture = Bun.spawn({ cmd: [ @@ -430,6 +432,52 @@ for (const era of ["legacy", "modern"] as const) { } }); + test("serves bundled skills through the skills extension", async () => { + const { client, connect, exchanges } = connection(era, { + token: "sk_valid", + }); + try { + await connect(); + // The v1 SDK drops capability extensions, so read them off the wire. + const handshake = exchanges.find( + (exchange) => + exchange.body?.method === + (era === "legacy" ? "initialize" : "server/discover"), + ); + expect( + wireResult(handshake!).capabilities.extensions[ + "io.modelcontextprotocol/skills" + ], + ).toEqual({}); + const listSkills = { method: "skills/list", params: {} }; + const ListSkillsResult = z.object({ + skills: z.array( + z.object({ + uri: z.string(), + resources: z.array(z.object({ uri: z.string() })), + }), + ), + }); + const { skills } = ListSkillsResult.parse( + client instanceof LegacyClient + ? await client.request(listSkills, LegacyResultSchema) + : await client.request(listSkills, ListSkillsResult), + ); + const skill = skills.find( + (entry) => entry.uri === "skill://kernel-mcp/SKILL.md", + ); + expect(skill?.resources.map((resource) => resource.uri)).toContain( + "skill://kernel-mcp/SKILL.md", + ); + const { contents } = await client.readResource({ + uri: "skill://kernel-mcp/SKILL.md", + }); + expect(contents[0]).toMatchObject({ mimeType: "text/markdown" }); + } finally { + await client.close(); + } + }); + test("keeps MCP Apps available only to clients declaring the extension", async () => { const withApps = connection(era, { token: "sk_valid", apps: true }); const withoutApps = connection(era, { token: "sk_valid" }); diff --git a/src/lib/mcp/prompts.test.ts b/src/lib/mcp/prompts.test.ts index 0c0f313a..ed73766d 100644 --- a/src/lib/mcp/prompts.test.ts +++ b/src/lib/mcp/prompts.test.ts @@ -4,6 +4,7 @@ import { registerKernelPrompts } from "@/lib/mcp/prompts"; import { registerBrowserCapabilities } from "@/lib/mcp/tools/browsers"; import { instrumentMcpAnalytics } from "@/lib/mcp/analytics"; import { registerMcpCapabilities } from "@/lib/mcp/register"; +import { BUNDLED_SKILLS } from "@/lib/mcp/skills/generated/skills"; const BLOCKED_LANGUAGE = /stealth|bot[ _-]?detect|anti-?bot|bot-protected|fingerprint|akamai|cloudflare|imperva|evad|evasion|bypass|scrap|automation detection|unblock|\bsolv/gi; @@ -82,6 +83,21 @@ async function advertisedTexts( issue_description: "page fails to load", }), ); + // Skill bodies quote api fields such as `stealth` in code, so only their + // prose is held to the advertised-language rules. + for (const skill of BUNDLED_SKILLS) { + texts.push(skill.frontmatter.description); + for (const file of skill.files) { + if ("text" in file && file.mimeType === "text/markdown") { + texts.push( + file.text + .replace(/^---[\s\S]*?\n---\n/, "") + .replace(/```[\s\S]*?```/g, "") + .replace(/`[^`]*`/g, ""), + ); + } + } + } return texts; } diff --git a/src/lib/mcp/register.test.ts b/src/lib/mcp/register.test.ts index 1d5791cc..ebc9a2ef 100644 --- a/src/lib/mcp/register.test.ts +++ b/src/lib/mcp/register.test.ts @@ -157,7 +157,7 @@ describe("MCP Apps additive registration", () => { "manage_auth_connections", ]); expect(base.appTools).toEqual([]); - expect(base.resources).toEqual([]); + expect(base.resources).toEqual(["kernel-mcp"]); const withApps = await captureRegistration(true); expect(withApps.legacyTools).toEqual([ @@ -168,7 +168,10 @@ describe("MCP Apps additive registration", () => { "open_auth_login", "begin_auth_login", ]); - expect(withApps.resources).toEqual(["kernel-managed-auth-login"]); + expect(withApps.resources).toEqual([ + "kernel-mcp", + "kernel-managed-auth-login", + ]); } finally { if (previous === undefined) { delete process.env.KERNEL_MCP_DISABLED_TOOLSETS; @@ -259,7 +262,7 @@ describe("MCP toolset allowlist", () => { "browser_repl", ]); expect(registration.appTools).toEqual([]); - expect(registration.resources).toEqual([]); + expect(registration.resources).toEqual(["kernel-mcp"]); } finally { if (previousEnabled === undefined) { delete process.env.KERNEL_MCP_ENABLED_TOOLSETS; diff --git a/src/lib/mcp/register.ts b/src/lib/mcp/register.ts index 22f2802c..355c35f8 100644 --- a/src/lib/mcp/register.ts +++ b/src/lib/mcp/register.ts @@ -4,6 +4,7 @@ import { type McpDependencies, } from "@/lib/mcp/dependencies"; import { registerKernelPrompts } from "@/lib/mcp/prompts"; +import { registerSkills } from "@/lib/mcp/skills"; import { registerAPIKeyCapabilities } from "@/lib/mcp/tools/api-keys"; import { registerAppCapabilities } from "@/lib/mcp/tools/apps"; import { registerAuthConnectionTools } from "@/lib/mcp/tools/auth-connections"; @@ -203,6 +204,7 @@ export function registerMcpCapabilities( const disabledToolsets = disabledMcpToolsetsFromEnv(); registerKernelPrompts(server); + registerSkills(server); // Connection metadata remains available so clients can select the correct // project target even when other toolsets are disabled. registerConnectionContextTool(server); diff --git a/src/lib/mcp/skills/generated/skills.ts b/src/lib/mcp/skills/generated/skills.ts new file mode 100644 index 00000000..e109dc3b --- /dev/null +++ b/src/lib/mcp/skills/generated/skills.ts @@ -0,0 +1,21 @@ +// Generated by scripts/build-skills.mjs from kernel/skills@4376bb1057e60d1377fb42dbe41de35689b4aa5a. Do not edit. +import type { BundledSkill } from "@/lib/mcp/skills/types"; + +export const BUNDLED_SKILLS: BundledSkill[] = [ + { + "name": "kernel-mcp", + "frontmatter": { + "name": "kernel-mcp", + "description": "use KERNEL cloud browsers through the KERNEL mcp server. create cloud browser sessions, automate pages with playwright, the browser repl, or computer-use actions, reuse logged-in profiles and managed auth connections, and record replays. use whenever a task needs a real browser, a site that rejects ordinary automated clients, a logged-in session, or screenshots of a live page." + }, + "files": [ + { + "path": "SKILL.md", + "mimeType": "text/markdown", + "size": 4242, + "digest": "sha256:28643607ffe1102cade8020baebac7d7e1fe1cf742a41152e1537a45812685b7", + "text": "---\nname: kernel-mcp\ndescription: use KERNEL cloud browsers through the KERNEL mcp server. create cloud browser sessions, automate pages with playwright, the browser repl, or computer-use actions, reuse logged-in profiles and managed auth connections, and record replays. use whenever a task needs a real browser, a site that rejects ordinary automated clients, a logged-in session, or screenshots of a live page.\n---\n\n# KERNEL mcp\n\nthe KERNEL mcp server gives you hosted chromium sessions. nothing runs on the user's machine. every browser is a cloud vm that you create, drive, and delete through mcp tools.\n\non first use, the user signs in to KERNEL over oauth in their browser. during authorization they can grant org-wide access or limit it to one KERNEL project.\n\n## tools\n\n### session lifecycle\n- **manage_browsers**: `create`, `list`, `get`, `update`, `delete`, and `get_telemetry` for browser sessions. `create` returns a session id, cdp url, and live view url. options include `stealth`, `headless`, `proxy_id`, `profile_name`, `viewport_width`/`viewport_height`, and `timeout_seconds`.\n- **manage_browser_pools**: pre-warmed pools for high-throughput work. create a pool, `acquire` a browser, and `release` it when done.\n\n### driving a browser\n- **execute_playwright_code**: run playwright typescript against an existing session. `page` is already in scope. return a value to get it back.\n- **browser_repl**: persistent javascript repl inside the browser vm with playwright, patchright, and raw cdp. state survives across calls. start with `repl.help()`.\n- **computer_action**: mouse, keyboard, scroll, and screenshot actions. batch several actions per call and end with a screenshot.\n- **webmcp**: call structured tools a page exposes through webmcp. check for these before writing selectors for a site action.\n- **browser_curl**: send http requests from inside the browser session, using its cookies and network path.\n- **exec_command**: run shell commands inside the browser vm, for example to check dns, files, or logs.\n\n### state and auth\n- **manage_profiles**: saved cookies and local storage. `setup` opens a guided session so the user can log in once. pass `profile_name` to `manage_browsers` `create` to reuse it.\n- **manage_auth_connections**: managed auth for third-party sites. before a task that needs an account, `list` connections for the domain. if none is authenticated, `login` starts a hosted sign-in flow the user completes in their own browser, so credentials and mfa stay out of chat.\n- **manage_proxies**: datacenter, isp, residential, mobile, or custom proxies with country, state, or city targeting.\n- **manage_extensions**: list and manage uploaded chrome extensions.\n\n### other\n- **manage_replays**: `start` and `stop` mp4 recordings of a session (paid plans).\n- **manage_apps**: list apps, check deployment status, and invoke KERNEL apps.\n- **search_docs**: search the KERNEL documentation.\n\n## workflows\n\n### browse a page and extract data\n1. `manage_browsers` `create`. set `stealth: true` to apply site-compatibility settings, only on sites the user is authorized to access.\n2. `execute_playwright_code` to navigate and extract:\n ```typescript\n await page.goto(\"https://news.ycombinator.com\");\n return await page.$$eval(\".titleline > a\", els =>\n els.slice(0, 10).map(e => e.textContent)\n );\n ```\n3. `manage_browsers` `delete` when finished.\n\n### work on a site that needs a login\n1. `manage_auth_connections` `list` with the site's domain.\n2. if a connection is `AUTHENTICATED`, create the browser with its `profile_name`. otherwise `create` a connection, run `login`, share the hosted url with the user, and `wait` until it completes.\n3. run the task, then delete the browser.\n\n### visual or coordinate-based tasks\n1. `computer_action` with a `screenshot` to see the page.\n2. batch `click_mouse`, `type_text`, and `press_key`, ending with another `screenshot`.\n\n## tips\n- delete browsers when you are done. set `timeout_seconds` as a backstop.\n- use `headless: true` when nobody needs the live view. it starts faster.\n- share the live view url when the user needs to watch or take over.\n- if a session misbehaves, `manage_browsers` `get_telemetry` works on active and deleted sessions.\n" + } + ] + } +]; diff --git a/src/lib/mcp/skills/index.ts b/src/lib/mcp/skills/index.ts new file mode 100644 index 00000000..565a3260 --- /dev/null +++ b/src/lib/mcp/skills/index.ts @@ -0,0 +1,89 @@ +import { + ProtocolError, + ProtocolErrorCode, + type McpServer, +} from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { BUNDLED_SKILLS } from "@/lib/mcp/skills/generated/skills"; +import type { BundledSkill } from "@/lib/mcp/skills/types"; + +export const MCP_SKILLS_EXTENSION = "io.modelcontextprotocol/skills"; + +// Bundled skills only change on deploy, so every caller may share a cached listing. +const SKILLS_CACHE = { ttlMs: 60 * 60 * 1000, cacheScope: "public" } as const; + +function skillFileUri(skill: BundledSkill, path: string) { + return `skill://${skill.name}/${path}`; +} + +function skillEntry(skill: BundledSkill) { + return { + uri: skillFileUri(skill, "SKILL.md"), + frontmatter: skill.frontmatter, + resources: skill.files.map((file) => ({ + uri: skillFileUri(skill, file.path), + digest: file.digest, + size: file.size, + })), + }; +} + +/** + * Serves the bundled skills through the MCP Skills extension (SEP-2640): + * skills/list and skills/get return each skill's manifest, and every file is a + * skill:// resource read with resources/read. + */ +export function registerSkills( + server: McpServer, + skills: BundledSkill[] = BUNDLED_SKILLS, +) { + server.server.registerCapabilities({ + resources: {}, + extensions: { [MCP_SKILLS_EXTENSION]: {} }, + }); + + for (const skill of skills) { + for (const file of skill.files) { + const uri = skillFileUri(skill, file.path); + const isSkillFile = file.path === "SKILL.md"; + server.registerResource( + isSkillFile ? skill.name : `${skill.name}/${file.path}`, + uri, + { + mimeType: file.mimeType, + ...(isSkillFile && { description: skill.frontmatter.description }), + }, + async () => ({ + contents: [ + "text" in file + ? { uri, mimeType: file.mimeType, text: file.text } + : { uri, mimeType: file.mimeType, blob: file.blob }, + ], + }), + ); + } + } + + server.server.setRequestHandler( + "skills/list", + { params: z.object({ cursor: z.string().optional() }).passthrough() }, + async () => ({ skills: skills.map(skillEntry), ...SKILLS_CACHE }), + ); + + server.server.setRequestHandler( + "skills/get", + { params: z.object({ uri: z.string() }).passthrough() }, + async ({ uri }) => { + const skill = skills.find( + (candidate) => skillFileUri(candidate, "SKILL.md") === uri, + ); + if (!skill) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `unknown skill: ${uri}`, + ); + } + return { skill: skillEntry(skill), ...SKILLS_CACHE }; + }, + ); +} diff --git a/src/lib/mcp/skills/skills.test.ts b/src/lib/mcp/skills/skills.test.ts new file mode 100644 index 00000000..93150463 --- /dev/null +++ b/src/lib/mcp/skills/skills.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"; +import { + Client, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; +import { z } from "zod"; +import { connectTestMcp } from "@/lib/mcp/mcp-test-fixtures"; +import { MCP_SKILLS_EXTENSION, registerSkills } from "@/lib/mcp/skills"; +import { BUNDLED_SKILLS } from "@/lib/mcp/skills/generated/skills"; +import type { BundledSkill } from "@/lib/mcp/skills/types"; + +const SkillEntry = z.object({ + uri: z.string(), + frontmatter: z.record(z.string(), z.unknown()), + resources: z.array( + z.object({ uri: z.string(), digest: z.string(), size: z.number() }), + ), +}); +const ListSkillsResult = z.object({ + skills: z.array(SkillEntry), + ttlMs: z.number(), + cacheScope: z.string(), +}); +const GetSkillResult = z.object({ skill: SkillEntry }); + +const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0xff]); +const fixtureSkill: BundledSkill = { + name: "example", + frontmatter: { name: "example", description: "an example", license: "MIT" }, + files: [ + { + path: "SKILL.md", + mimeType: "text/markdown", + size: 9, + digest: `sha256:${createHash("sha256").update("# example").digest("hex")}`, + text: "# example", + }, + { + path: "assets/logo.png", + mimeType: "image/png", + size: pngBytes.byteLength, + digest: `sha256:${createHash("sha256").update(pngBytes).digest("hex")}`, + blob: Buffer.from(pngBytes).toString("base64"), + }, + ], +}; + +function frontmatterOf(text: string) { + const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text); + return match ? Bun.YAML.parse(match[1]) : null; +} + +describe("skills extension", () => { + test("advertises the extension and lists bundled skills with complete manifests", async () => { + const mcp = await connectTestMcp((server) => registerSkills(server), {}); + try { + expect( + mcp.client.getServerCapabilities()?.extensions?.[MCP_SKILLS_EXTENSION], + ).toEqual({}); + + const { skills, ttlMs, cacheScope } = await mcp.client.request( + { method: "skills/list", params: {} }, + ListSkillsResult, + ); + expect(skills.map((skill) => skill.uri)).toEqual( + BUNDLED_SKILLS.map((skill) => `skill://${skill.name}/SKILL.md`), + ); + expect(ttlMs).toBeGreaterThan(0); + expect(cacheScope).toBe("public"); + + for (const skill of skills) { + expect(skill.uri.split("/").at(-2)).toBe( + skill.frontmatter.name as string, + ); + for (const resource of skill.resources) { + const { contents } = await mcp.client.readResource({ + uri: resource.uri, + }); + const content = contents[0]; + const bytes = + "text" in content + ? new TextEncoder().encode(content.text) + : Buffer.from(content.blob, "base64"); + expect(bytes.byteLength).toBe(resource.size); + expect(resource.digest).toBe( + `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + ); + if (resource.uri === skill.uri && "text" in content) { + expect(frontmatterOf(content.text)).toEqual(skill.frontmatter); + } + } + } + } finally { + await mcp.close(); + } + }); + + test("serves supporting files, including binary ones, and gets skills by uri", async () => { + const mcp = await connectTestMcp( + (server) => registerSkills(server, [fixtureSkill]), + {}, + ); + try { + const { skill } = await mcp.client.request( + { + method: "skills/get", + params: { uri: "skill://example/SKILL.md" }, + }, + GetSkillResult, + ); + expect(skill.frontmatter).toEqual(fixtureSkill.frontmatter); + expect(skill.resources.map((resource) => resource.uri)).toEqual([ + "skill://example/SKILL.md", + "skill://example/assets/logo.png", + ]); + + const { contents } = await mcp.client.readResource({ + uri: "skill://example/assets/logo.png", + }); + expect(contents[0]).toMatchObject({ + mimeType: "image/png", + blob: Buffer.from(pngBytes).toString("base64"), + }); + + const { resources } = await mcp.client.listResources(); + expect(resources).toContainEqual( + expect.objectContaining({ + uri: "skill://example/SKILL.md", + name: "example", + description: "an example", + mimeType: "text/markdown", + }), + ); + + await expect( + mcp.client.request( + { + method: "skills/get", + params: { uri: "skill://missing/SKILL.md" }, + }, + GetSkillResult, + ), + ).rejects.toMatchObject({ code: -32602 }); + } finally { + await mcp.close(); + } + }); + + test("advertises the extension through server/discover", async () => { + const handler = createMcpHandler(() => { + const server = new McpServer({ name: "test", version: "0.0.0" }); + registerSkills(server); + return server; + }); + const client = new Client( + { name: "test-client", version: "0.0.0" }, + { versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + await client.connect( + new StreamableHTTPClientTransport(new URL("http://localhost/mcp"), { + fetch: (input, init) => handler.fetch(new Request(input, init), {}), + }), + ); + try { + expect( + client.getServerCapabilities()?.extensions?.[MCP_SKILLS_EXTENSION], + ).toEqual({}); + const { skills } = await client.request( + { method: "skills/list", params: {} }, + ListSkillsResult, + ); + expect(skills.map((skill) => skill.frontmatter.name)).toContain( + "kernel-mcp", + ); + } finally { + await client.close(); + } + }); +}); diff --git a/src/lib/mcp/skills/types.ts b/src/lib/mcp/skills/types.ts new file mode 100644 index 00000000..aa13094d --- /dev/null +++ b/src/lib/mcp/skills/types.ts @@ -0,0 +1,12 @@ +export type BundledSkillFile = { + path: string; + mimeType: string; + size: number; + digest: string; +} & ({ text: string } | { blob: string }); + +export type BundledSkill = { + name: string; + frontmatter: { name: string; description: string; [key: string]: unknown }; + files: BundledSkillFile[]; +};