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[];
+};