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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
src/lib/mcp/apps/generated/managed-auth-app.ts
src/lib/mcp/skills/generated/skills.ts
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
162 changes: 162 additions & 0 deletions scripts/build-skills.mjs
Original file line number Diff line number Diff line change
@@ -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://<dir name>/.
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 });
}
48 changes: 48 additions & 0 deletions src/app/[transport]/dual-era.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,6 +11,7 @@ import {
type StoredOAuthTokens,
} from "@modelcontextprotocol/client";
import { CallToolResultSchema } from "@modelcontextprotocol/core";
import { z } from "zod";

const fixture = Bun.spawn({
cmd: [
Expand Down Expand Up @@ -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" });
Expand Down
16 changes: 16 additions & 0 deletions src/lib/mcp/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
9 changes: 6 additions & 3 deletions src/lib/mcp/register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/lib/mcp/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions src/lib/mcp/skills/generated/skills.ts
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
];
Loading
Loading