From 0c4852a92aedfe8c5ad9295d6b03e8cd185a19eb Mon Sep 17 00:00:00 2001 From: Harsh Singh Date: Mon, 10 Aug 2026 01:23:50 +0530 Subject: [PATCH] feat(mcp): declare MCP servers in a skill, gated like the code they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A skill that needs an MCP server had to say so in prose and hope you configured it. Now it declares the server in `[mcp.servers.]`, `compile` writes the native config for every target that has one, and the install gate reviews it first. The research mattered more than the code. Of the eleven targets, only three have a dedicated, project-scoped, primary-source-confirmed MCP config file: claude-code (.mcp.json), copilot (.github/mcp.json) and agent-plugins (/mcp.json). The rest have no MCP mechanism (aider, agentsmd), no project scope (cline, windsurf), or keep servers in a shared settings file full of unrelated user config (zed, gemini) — and cursor's file cannot express a tools allowlist, so a declaration would be silently widened. Emitting a plausible-looking file for any of those produces something that reads as configured and does nothing, which is the failure this project exists to prevent. They get a typed warning naming the reason, and no file. Nothing is passed through verbatim: the client dialects genuinely disagree, so `streamable-http` is translated to `http` for Claude Code and Copilot, an explicit `type` is always emitted (a url with no type is a documented hard error in Claude Code), and `tools` is written only where the format has a field — with a warning where it does not, never a silent drop. `tools` is required in the manifest because it is the one field kitbash cannot synthesize, Copilot's format demands it, and deny-by-default is the point. Secrets may only appear as ${VAR}; where a format defines no credential reference and expands no variables, the server is omitted with a warning rather than emitted with a reference that would never resolve. The MCP lints join the four safety lints as non-bypassable, because a server declaration is a request to run third-party code with the agent's permissions: shell strings, shell metacharacters, unpinned versions, plain http off loopback, credentials in a URL, a missing tools allowlist, and malformed or duplicate names all block install with --yes. Note the manifest uses ${VAR} references rather than the inline-table `{ secret = "NAME" }` form: this repo's TOML parser has no inline-table support, and extending it was more risk than the syntax was worth. Adds 28 tests. 0.19.0. --- CHANGELOG.md | 13 + README.md | 21 ++ packages/cli/package.json | 2 +- packages/cli/scripts/test.mjs | 139 +++++++++++ packages/cli/src/commands.ts | 57 ++++- packages/cli/src/ksf.ts | 7 +- packages/cli/src/mcp.ts | 435 ++++++++++++++++++++++++++++++++++ site/changelog.html | 15 +- site/index.html | 2 +- 9 files changed, 685 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/mcp.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fb81c40..cfd8c62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ Format: [Keep a Changelog](https://keepachangelog.com). Versioning: semver — for skills *and* for this CLI, breaking prompt changes are breaking changes. +## [0.19.0] — 2026-08-09 + +MCP servers, declared in the skill and gated like the code they are. A skill that needs an MCP server previously had to say so in prose and hope you configured it; now it declares the server, `compile` writes the native config for every target that has one, and the install gate reviews it first. + +The research behind this mattered more than the code. Of the eleven targets, only **three** have a dedicated, project-scoped, primary-source-confirmed MCP config file. The rest either have no MCP mechanism at all, no project scope, or keep servers in a shared settings file full of unrelated user config. Emitting a plausible-looking file for those would produce something that reads as configured and does nothing — the exact silent failure this project exists to prevent — so they get a typed warning naming the reason, and no file. + +### Added +- **`[mcp.servers.]` in `skill.toml`** — declare a server with `transport` (`stdio` / `streamable-http` / `sse`), `command` + `args`, or `url`, plus `env`, `headers`, `timeout_ms`, and a **required** `tools` allowlist. `tools` is required because it is the one field Kitbash cannot synthesize, Copilot's format demands it, and deny-by-default is the point; `tools = ["*"]` stays legal and is shown prominently at the gate. +- **Compiles to the three targets that can actually carry it** — `claude-code` → `.mcp.json`, `copilot` → `.github/mcp.json`, `agent-plugins` → `/mcp.json`. Nothing is passed through: the client dialects genuinely disagree, so the manifest's `streamable-http` is translated to `http` for Claude Code and Copilot and left as `streamable-http` for Agent Plugins, an explicit `type` is always emitted (a `url` with no `type` is a documented hard error in Claude Code), and `tools` is written only where the format has a field for it — with a warning where it does not, rather than a silent drop. +- **Secrets are references, never literals.** A credential may only appear as `${VAR}`. A literal that looks like a credential, or any value under a key named like one, is an install-blocking failure. Where a format defines no credential reference and expands no variables — Agent Plugins — the server is **omitted with a warning** rather than emitted with a reference that would never resolve. +- **The MCP lints block install, `--yes` included**, alongside the four existing safety lints: a shell string where a single executable belongs, shell metacharacters in `command`/`args`, an unpinned server version (`@latest`, `^`, `>=`), a plain-`http` URL off loopback, credentials embedded in a URL, a missing `tools` allowlist, and duplicate or malformed server names. A server declaration is a request to run third-party code with your agent's permissions, so "probably fine" is not a category. +- **`on_unsupported = "error"`** turns "this target cannot carry the declaration" into a build failure for repos that would rather not ship half-configured. + ## [0.18.0] — 2026-08-09 Kitbash in CI. Everything it checks — the install-gate safety lints, the declared token budgets, the compiled output's drift from its source — was previously a thing one maintainer ran locally and everyone else took on trust. This release makes it a required check on the pull request instead. diff --git a/README.md b/README.md index 375a720..3abc327 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,27 @@ max_budget = 6000 # cap per-skill context budget # deny_remote_exec = false # opt out of the curl|sh body lint (default: on) ``` +## MCP servers + +A skill can declare the MCP servers it needs. `compile` writes the native config for every target that has one: + +```toml +[mcp.servers.deploy-tools] +transport = "stdio" # stdio | streamable-http | sse +command = "npx" # a single executable, never a shell string +args = ["-y", "@acme/deploy-mcp@2.4.1"] # pin the version — unpinned fails the gate +tools = ["plan_diff", "list_environments"] # required: deny-by-default allowlist + +[mcp.servers.deploy-tools.env] +DEPLOY_TOKEN = "${ACME_DEPLOY_TOKEN}" # a reference; a literal credential fails the gate +``` + +Three targets have a project-scoped MCP config file, and those are the three Kitbash writes: `claude-code` → `.mcp.json`, `copilot` → `.github/mcp.json`, `agent-plugins` → `/mcp.json`. Nothing is passed through verbatim — the client dialects disagree (the HTTP transport is `http` in Claude Code and Copilot, `streamable-http` in Agent Plugins), so every emitter translates and always writes an explicit `type`. + +The other eight targets get a warning naming the specific reason — `no-mcp-surface` (aider, AGENTS.md have no configuration mechanism), `no-project-scope` (Cline and Windsurf are user-global only), `needs-shared-file-merge` (Zed and Gemini keep servers in a settings file full of unrelated user config) — and **no file**. A config the client never reads would look configured and do nothing, which is the failure mode this project exists to prevent. + +A declared MCP server is third-party code that will run with your agent's permissions, so its lints are non-bypassable, like the four safety lints: shell strings, unpinned versions, literal credentials, plain-`http` URLs off loopback, and a missing `tools` allowlist all block install, `--yes` included. Secrets may only appear as `${VAR}`; where a format defines no credential reference and expands no variables, the server is omitted with a warning rather than emitted broken. + ## In CI Everything above is only as good as the last time someone ran it locally. The action makes it a check on the pull request: diff --git a/packages/cli/package.json b/packages/cli/package.json index 6c822d3..40eef0e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "kitbash", - "version": "0.18.0", + "version": "0.19.0", "description": "The package manager and compiler for AI agent skills — write once, run in every coding agent", "license": "Apache-2.0", "author": "Harsh Singh", diff --git a/packages/cli/scripts/test.mjs b/packages/cli/scripts/test.mjs index fc0644e..b3b5efa 100644 --- a/packages/cli/scripts/test.mjs +++ b/packages/cli/scripts/test.mjs @@ -1446,6 +1446,145 @@ try { rmSync(sar, { recursive: true, force: true }); } +// ── MCP server declarations ────────────────────────────────────────────────── +// A declared MCP server is third-party code the user will run, so its lints are +// install-blocking; and only targets with a confirmed project-scoped config file +// may emit anything — the rest must say why rather than write a dead file. +const mcpTmp = mkdtempSync(join(tmpdir(), "kitbash-mcp-")); +try { + const src = join(mcpTmp, "src"); + mkdirSync(src, { recursive: true }); + writeFileSync( + join(src, "skill.toml"), + [ + "[skill]", + 'name = "deploy-review"', + 'version = "1.0.0"', + 'description = "Reviews a deploy plan against the live environment"', + "[context]", + "budget = 1500", + "standing = 80", + "", + "[mcp.servers.deploy-tools]", + 'transport = "stdio"', + 'command = "npx"', + 'args = ["-y", "@acme/deploy-mcp@2.4.1"]', + 'tools = ["plan_diff"]', + "timeout_ms = 60000", + "", + "[mcp.servers.status-api]", + 'transport = "streamable-http"', + 'url = "https://status.acme.com/mcp"', + 'tools = ["read_status"]', + "", + ].join("\n"), + ); + writeFileSync(join(src, "SKILL.md"), "# Deploy review\n\nReview the plan before it ships.\n"); + writeFileSync(join(mcpTmp, "kitbash.toml"), '[project]\ntargets = ["claude-code", "copilot", "agent-plugins", "aider", "cline"]\n'); + + const mi = run(["install", `file:${src}`, "--yes"], mcpTmp); + check("mcp: a well-formed declaration installs", mi.status === 0, mi.out); + const mc = run(["compile"], mcpTmp); + check("mcp: compile exits 0", mc.status === 0, mc.out); + + const claude = JSON.parse(readFileSync(join(mcpTmp, ".mcp.json"), "utf8")); + const copilot = JSON.parse(readFileSync(join(mcpTmp, ".github/mcp.json"), "utf8")); + const plugin = JSON.parse(readFileSync(join(mcpTmp, "agent-plugin/mcp.json"), "utf8")); + check("mcp: claude-code gets .mcp.json with mcpServers", !!claude.mcpServers["deploy-tools"]); + check("mcp: copilot gets .github/mcp.json", !!copilot.mcpServers["deploy-tools"]); + check("mcp: agent-plugins gets mcp.json inside the plugin root", !!plugin.mcpServers["deploy-tools"]); + check("mcp: agent-plugins mcp.json carries the exact $schema const", plugin.$schema === "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", plugin.$schema); + check("mcp: agent-plugins mcp.json has only the two allowed top-level keys", Object.keys(plugin).sort().join(",") === "$schema,mcpServers", Object.keys(plugin).join(",")); + + // The transport spellings genuinely differ per client — this is the translation. + check("mcp: HTTP is 'http' for claude-code", claude.mcpServers["status-api"].type === "http", claude.mcpServers["status-api"].type); + check("mcp: HTTP is 'http' for copilot", copilot.mcpServers["status-api"].type === "http", copilot.mcpServers["status-api"].type); + check("mcp: HTTP is 'streamable-http' for agent-plugins", plugin.mcpServers["status-api"].type === "streamable-http", plugin.mcpServers["status-api"].type); + check("mcp: every entry declares an explicit type", [claude, copilot, plugin].every((d) => Object.values(d.mcpServers).every((s) => !!s.type))); + check("mcp: copilot carries the tools allowlist", JSON.stringify(copilot.mcpServers["deploy-tools"].tools) === '["plan_diff"]'); + check("mcp: claude-code does not invent a tools field", claude.mcpServers["deploy-tools"].tools === undefined); + check("mcp: dropping the tools allowlist is warned about, not silent", mc.out.includes("tools allowlist"), mc.out); + + // Targets that cannot honor a declaration must say why and write nothing. + check("mcp: aider warns with no-mcp-surface", mc.out.includes("no-mcp-surface"), mc.out); + check("mcp: cline warns with no-project-scope", mc.out.includes("no-project-scope"), mc.out); + check("mcp: no MCP file is written for unsupported targets", !existsSync(join(mcpTmp, ".agents/mcp.json")) && !existsSync(join(mcpTmp, ".clinerules/mcp.json"))); + + // Hostile declarations are install-blocking, even with --yes. + const badSrc = join(mcpTmp, "bad"); + mkdirSync(badSrc, { recursive: true }); + writeFileSync( + join(badSrc, "skill.toml"), + [ + "[skill]", + 'name = "sneaky"', + 'version = "1.0.0"', + 'description = "A skill with a hostile MCP declaration"', + "[context]", + "budget = 1500", + "", + "[mcp.servers.evil]", + 'transport = "stdio"', + "command = \"sh -c 'echo hi'\"", + 'args = ["@acme/thing@latest"]', + 'tools = ["a"]', + "", + "[mcp.servers.evil.env]", + 'GITHUB_TOKEN = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345"', + "", + "[mcp.servers.leaky]", + 'transport = "streamable-http"', + 'url = "http://evil.example.com/mcp"', + 'tools = ["x"]', + "", + ].join("\n"), + ); + writeFileSync(join(badSrc, "SKILL.md"), "# Sneaky\n\nNothing to see.\n"); + const bad = run(["install", `file:${badSrc}`, "--yes"], mcpTmp); + check("mcp: a hostile declaration is blocked despite --yes", bad.status === 1, bad.out); + check("mcp: shell string in command is caught", bad.out.includes("single executable"), bad.out); + check("mcp: unpinned server version is caught", bad.out.includes("not pinned to an exact version"), bad.out); + check("mcp: a literal credential is caught", bad.out.includes("GitHub token"), bad.out); + check("mcp: plain http off-loopback is caught", bad.out.includes("plain http off-loopback"), bad.out); + check("mcp: the blocked skill was not installed", !existsSync(join(mcpTmp, ".kitbash/skills/sneaky"))); + + // A missing tools allowlist fails: it is the one field kitbash cannot synthesize. + const noTools = join(mcpTmp, "notools"); + mkdirSync(noTools, { recursive: true }); + writeFileSync( + join(noTools, "skill.toml"), + '[skill]\nname = "notools"\nversion = "1.0.0"\ndescription = "Declares a server with no tool allowlist"\n[context]\nbudget = 1500\n\n[mcp.servers.wide]\ntransport = "stdio"\ncommand = "npx"\ntools = []\n', + ); + writeFileSync(join(noTools, "SKILL.md"), "# No tools\n\nBody.\n"); + const nt = run(["install", `file:${noTools}`, "--yes"], mcpTmp); + check("mcp: a missing tools allowlist blocks install", nt.status === 1 && nt.out.includes("tools is required"), nt.out); +} finally { + rmSync(mcpTmp, { recursive: true, force: true }); +} + +// A secret must be a ${VAR} reference, and a format that cannot expand one must +// omit the server rather than emit a reference that will never resolve. +const refTmp = mkdtempSync(join(tmpdir(), "kitbash-mcpref-")); +try { + const rs = join(refTmp, "src"); + mkdirSync(rs, { recursive: true }); + writeFileSync( + join(rs, "skill.toml"), + '[skill]\nname = "reffy"\nversion = "1.0.0"\ndescription = "Uses a secret reference rather than a literal"\n[context]\nbudget = 1500\n\n[mcp.servers.api]\ntransport = "streamable-http"\nurl = "https://api.example.com/mcp"\ntools = ["query"]\n\n[mcp.servers.api.headers]\nAuthorization = "Bearer ${ACME_TOKEN}"\n', + ); + writeFileSync(join(rs, "SKILL.md"), "# Reffy\n\nBody.\n"); + writeFileSync(join(refTmp, "kitbash.toml"), '[project]\ntargets = ["claude-code", "agent-plugins"]\n'); + const ri = run(["install", `file:${rs}`, "--yes"], refTmp); + check("mcp: a ${VAR} secret reference installs cleanly", ri.status === 0, ri.out); + const rc = run(["compile"], refTmp); + const rClaude = JSON.parse(readFileSync(join(refTmp, ".mcp.json"), "utf8")); + check("mcp: the reference passes through where the client expands it", rClaude.mcpServers.api.headers.Authorization === "Bearer ${ACME_TOKEN}"); + check("mcp: agent-plugins omits a server it cannot express", !existsSync(join(refTmp, "agent-plugin/mcp.json")), rc.out); + check("mcp: and says why", rc.out.includes("expands no variables"), rc.out); +} finally { + rmSync(refTmp, { recursive: true, force: true }); +} + if (failures) { console.error(`\n${failures} test(s) failed`); process.exit(1); diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 8129d53..a75ade5 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -11,6 +11,7 @@ import { dropLock, integrityOf, readLock, upsertLock, walk, LOCK_FILE } from "./ import { fileChanges, manifestDelta, textOf, unifiedDiff } from "./diff.js"; import { collectImports, driftGroups, type ImportedSource } from "./importers.js"; import { estimateTokens, loadInstalledSkills, loadInstalledSkillsSafe, loadSkill, resolveBody, schemaLints, standingStub, COMMAND_RE, NAME_RE, SKILLS_DIR, type LoadedSkill } from "./ksf.js"; +import { collectServers, emitMcp, mcpLints, mcpWarnings } from "./mcp.js"; import { toSarif, type SarifFinding } from "./sarif.js"; import { parseToml } from "./toml.js"; @@ -24,7 +25,9 @@ const VERSION: string = createRequire(import.meta.url)("../package.json").versio * else staticChecks reports — schema, budgets, dead refs — is quality, surfaced * at `kitbash test`, and never stops an install. */ -const SAFETY_LINTS = new Set(["visible-text", "dynamic-context", "remote-exec", "secrets"]); +// "mcp" is here because a declared MCP server is a request to run third-party +// code with the agent's permissions — a heavier ask than anything in a body. +const SAFETY_LINTS = new Set(["visible-text", "dynamic-context", "remote-exec", "secrets", "mcp"]); const INIT_CONFIG = `# kitbash project configuration — https://github.com/singhharsh1708/kitbash [project] @@ -906,6 +909,43 @@ export async function cmdCompile(args: string[]): Promise { writeFileSync(abs, f.content.endsWith("\n") ? f.content : `${f.content}\n`); console.log(`→ ${f.path}`); } + // MCP: server declarations are repo-level config, not per-skill output, so they + // are aggregated across skills and written once per target that can honor them. + // Targets that cannot get a specific warning and no file — a plausible-looking + // config the client never reads is the silent failure this whole design avoids. + const { servers: mcpServers, conflicts } = collectServers(skills); + if (conflicts.length) { + for (const c of conflicts) console.error(`✗ ${c}`); + return 1; + } + if (mcpServers.length) { + const unsupported: string[] = []; + for (const adapter of adapters) { + const out = emitMcp(adapter.id, mcpServers, AGENT_PLUGIN_DIR); + for (const f of out.files) { + if (!resolveSubpath(root, f.path)) { + console.error(`✗ refusing to write outside the project: ${f.path}`); + return 1; + } + const abs = join(root, f.path); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, f.content); + files.set(f.path, f.content); // counts as written, so pruning leaves it alone + console.log(`→ ${f.path}`); + } + // on_unsupported = "error" turns "this target can't carry it" into a build failure. + for (const w of out.warnings) (w.includes("cannot honor") ? unsupported : warnings).push(w); + } + for (const u of unsupported) { + if (skills.some((s) => s.manifest.mcp.onUnsupported === "error")) { + console.error(`✗ ${u}`); + } else { + warnings.push(u); + } + } + if (unsupported.length && skills.some((s) => s.manifest.mcp.onUnsupported === "error")) return 1; + } + // Agent Plugins: the skills/ folder is written per-skill by its adapter above. // The plugin.json manifest that makes that folder a valid plugin is one // repo-level file describing the whole package, so the compiler — not any one @@ -1051,6 +1091,21 @@ function staticChecks(skill: LoadedSkill): Check[] { }); } + // MCP servers: a declaration is a request to run third-party code, so its + // problems are install-blocking failures rather than quality warnings. Only + // reported when the skill actually declares one — a skill with no [mcp] table + // should not grow a check row. + if (m.mcp.servers.length) { + const bad = mcpLints(m.mcp); + const soft = mcpWarnings(m.mcp); + checks.push({ + name: "mcp", + ok: bad.length === 0, + warn: bad.length === 0 && soft.length > 0, + detail: bad.length ? bad.join("; ") : soft.length ? soft.join("; ") : `${m.mcp.servers.length} server(s): ${m.mcp.servers.map((s) => s.name).join(", ")}`, + }); + } + // artifact refs must be name@version const badArtifacts = [...m.artifacts.produces, ...m.artifacts.consumes].filter((a) => !ARTIFACT_RE.test(a)); if (m.artifacts.produces.length || m.artifacts.consumes.length) { diff --git a/packages/cli/src/ksf.ts b/packages/cli/src/ksf.ts index 2586b93..d6c5613 100644 --- a/packages/cli/src/ksf.ts +++ b/packages/cli/src/ksf.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; import { basename, join } from "node:path"; +import { parseMcp, type McpConfig } from "./mcp.js"; import { parseToml, type TomlTable } from "./toml.js"; export interface SkillManifest { @@ -19,6 +20,8 @@ export interface SkillManifest { targets: { requires: string[]; mode: "skill" | "gate" }; lore: { reads: string[]; writes: string[] }; dependencies: Record; + /** Declared MCP servers (spec: [mcp.servers.]). Empty when the skill declares none. */ + mcp: McpConfig; } export interface LoadedSkill { @@ -107,6 +110,7 @@ function loadBareSkill(dir: string, bodyPath: string, nameHint?: string): Loaded targets: { requires: [], mode: "skill" }, lore: { reads: [], writes: [] }, dependencies: {}, + mcp: { servers: [], onUnsupported: "warn" }, }, }; } @@ -187,7 +191,7 @@ export function resolveBody(skill: LoadedSkill): string { return resolved; } -const KNOWN_TABLES = ["skill", "context", "triggers", "permissions", "artifacts", "targets", "lore", "dependencies"]; +const KNOWN_TABLES = ["skill", "context", "triggers", "permissions", "artifacts", "targets", "lore", "dependencies", "mcp"]; /** (table, key) pairs the schema types as arrays; validate() rejects a scalar there. */ const ARRAY_FIELDS: [string, string][] = [ ["triggers", "commands"], ["triggers", "auto"], ["triggers", "events"], @@ -285,6 +289,7 @@ function validate(raw: TomlTable, source: string): SkillManifest { targets: { requires: strs(t("targets"), "requires"), mode: str(t("targets"), "mode") === "gate" ? "gate" : "skill" }, lore: { reads: strs(t("lore"), "reads"), writes: strs(t("lore"), "writes") }, dependencies: deps(raw), + mcp: parseMcp(raw), }; } diff --git a/packages/cli/src/mcp.ts b/packages/cli/src/mcp.ts new file mode 100644 index 0000000..7c1036a --- /dev/null +++ b/packages/cli/src/mcp.ts @@ -0,0 +1,435 @@ +/** + * MCP (Model Context Protocol) server declarations. + * + * A skill can declare the MCP servers it needs; `compile` writes those into the + * native configuration each agent reads. Two things make this different from + * every other thing Kitbash compiles, and both shape the code below. + * + * 1. **A declared MCP server is arbitrary third-party code the user will run.** + * It is a heavier request than anything in a SKILL.md body, so it is gated at + * install like one: the lints here are hard failures, not warnings. + * + * 2. **Most targets cannot honor it.** Of the eleven compile targets, only three + * have a dedicated, project-scoped, primary-source-confirmed config file. + * The rest either have no MCP mechanism at all (aider, agentsmd), no project + * scope (cline, windsurf), or need a merge into a shared settings file that + * also holds unrelated user config (zed, gemini). Emitting a plausible-looking + * file for those would produce something that reads as configured and does + * nothing — the exact silent failure Kitbash exists to prevent. They get a + * typed warning naming the reason instead, and no file. + * + * Client config dialects genuinely disagree (the HTTP transport is spelled + * `streamable-http` by Agent Plugins, `http` by Claude Code and Copilot; timeouts + * are ms in some clients and seconds in others), so the manifest field is + * `transport` and every emitter TRANSLATES. Nothing is passed through verbatim. + */ + +import type { LoadedSkill } from "./ksf.js"; +import type { TomlTable } from "./toml.js"; + +/** The MCP spec's transport identifiers — the manifest's vocabulary, not any one client's. */ +export type McpTransport = "stdio" | "streamable-http" | "sse"; + +export interface McpServer { + /** server name as declared: [mcp.servers.] */ + name: string; + transport: McpTransport; + /** stdio: a single executable token (no shell string) */ + command?: string; + args: string[]; + /** streamable-http / sse */ + url?: string; + env: Record; + headers: Record; + /** Deny-by-default tool allowlist. Required — see mcpLints. `["*"]` is legal but loud. */ + tools: string[]; + timeoutMs?: number; +} + +export interface McpConfig { + servers: McpServer[]; + /** How a target that cannot honor the declaration behaves. No "skip": that would be silent degradation. */ + onUnsupported: "warn" | "error"; +} + +/** A `${VAR}` reference — the only way a secret may appear in a manifest. */ +const REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; +/** A whole value that is exactly one reference. */ +const WHOLE_REF_RE = /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/; + +export function hasRef(v: string): boolean { + REF_RE.lastIndex = 0; + return REF_RE.test(v); +} + +/** Every `${VAR}` name mentioned anywhere in a server declaration. */ +export function refsOf(s: McpServer): string[] { + const out = new Set(); + const scan = (v: string) => { + for (const m of v.matchAll(REF_RE)) out.add(m[1]!); + }; + if (s.command) scan(s.command); + s.args.forEach(scan); + if (s.url) scan(s.url); + Object.values(s.env).forEach(scan); + Object.values(s.headers).forEach(scan); + return [...out].sort(); +} + +const TRANSPORTS: McpTransport[] = ["stdio", "streamable-http", "sse"]; +/** Server names become JSON object keys and appear in tool ids — keep them boring. */ +const SERVER_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; + +function strMap(t: TomlTable, key: string): Record { + const v = t[key]; + if (!v || typeof v !== "object" || Array.isArray(v)) return {}; + const out: Record = {}; + for (const [k, val] of Object.entries(v as TomlTable)) if (typeof val === "string") out[k] = val; + return out; +} + +/** + * Read `[mcp]` out of a parsed manifest. Shape problems are reported by mcpLints + * rather than thrown here: a malformed declaration should surface as a readable + * lint failure at install, not a parse error with no context. + */ +export function parseMcp(raw: TomlTable): McpConfig { + const mcp = raw["mcp"]; + const empty: McpConfig = { servers: [], onUnsupported: "warn" }; + if (!mcp || typeof mcp !== "object" || Array.isArray(mcp)) return empty; + const table = mcp as TomlTable; + const serversRaw = table["servers"]; + const onUnsupported = table["on_unsupported"] === "error" ? "error" : "warn"; + if (!serversRaw || typeof serversRaw !== "object" || Array.isArray(serversRaw)) return { servers: [], onUnsupported }; + + const servers: McpServer[] = []; + for (const [name, defRaw] of Object.entries(serversRaw as TomlTable)) { + if (!defRaw || typeof defRaw !== "object" || Array.isArray(defRaw)) continue; + const def = defRaw as TomlTable; + const transport = typeof def["transport"] === "string" ? (def["transport"] as McpTransport) : ("stdio" as McpTransport); + const arr = (k: string): string[] => (Array.isArray(def[k]) ? (def[k] as unknown[]).filter((x): x is string => typeof x === "string") : []); + servers.push({ + name, + transport, + ...(typeof def["command"] === "string" ? { command: def["command"] as string } : {}), + args: arr("args"), + ...(typeof def["url"] === "string" ? { url: def["url"] as string } : {}), + env: strMap(def, "env"), + headers: strMap(def, "headers"), + tools: arr("tools"), + ...(typeof def["timeout_ms"] === "number" ? { timeoutMs: def["timeout_ms"] as number } : {}), + }); + } + servers.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + return { servers, onUnsupported }; +} + +// ── lints ──────────────────────────────────────────────────────────────────── + +/** Shell metacharacters in an argv element mean someone expects a shell that isn't there. */ +const SHELL_META_RE = /[;&|><`$(){}\[\]!*?~\n]/; +/** A version range where an exact pin belongs — the rug-pull surface. */ +const UNPINNED_RE = /(?:@latest|@\^|@~|@>=|@>|:latest|@\*)/; +/** Value shapes that are a credential rather than a reference to one. */ +const SECRET_SHAPE: { re: RegExp; what: string }[] = [ + { re: /^(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}$/, what: "an API key" }, + { re: /^gh[pousr]_[A-Za-z0-9]{16,}$/, what: "a GitHub token" }, + { re: /^xox[abprs]-[A-Za-z0-9-]{10,}$/, what: "a Slack token" }, + { re: /^AKIA[0-9A-Z]{12,}$/, what: "an AWS access key id" }, + { re: /^ey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\./, what: "a JWT" }, + { re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/, what: "a private key" }, +]; +/** Keys whose value is a credential by name, so a literal there is a leak regardless of shape. */ +const SECRET_KEY_RE = /(?:token|secret|password|passwd|api[_-]?key|credential|auth)/i; + +/** + * Hard failures. Every one of these blocks install — an MCP declaration is a + * request to run third-party code, so "probably fine" is not a category. + * Returned as messages; the caller renders them as failed checks. + */ +export function mcpLints(cfg: McpConfig): string[] { + const out: string[] = []; + const seen = new Set(); + for (const s of cfg.servers) { + const at = `mcp.servers.${s.name}`; + if (!SERVER_NAME_RE.test(s.name)) out.push(`${at}: server name must match ${SERVER_NAME_RE}`); + if (seen.has(s.name.toLowerCase())) out.push(`${at}: duplicate server name (names are case-insensitive across clients)`); + seen.add(s.name.toLowerCase()); + + if (!TRANSPORTS.includes(s.transport)) { + out.push(`${at}: transport "${s.transport}" must be one of ${TRANSPORTS.join(", ")}`); + continue; + } + + // A tool allowlist is required: it is the one field Kitbash cannot synthesize, + // Copilot marks it required, and deny-by-default is the whole point. + if (!s.tools.length) out.push(`${at}: tools is required — list the tools this server may expose, or ["*"] to allow all (which the install gate will show prominently)`); + + if (s.transport === "stdio") { + if (!s.command) out.push(`${at}: stdio transport requires a command`); + if (s.url) out.push(`${at}: stdio transport must not declare a url`); + if (s.command && /\s/.test(s.command)) out.push(`${at}: command must be a single executable, not a shell string — put arguments in args`); + if (s.command && SHELL_META_RE.test(s.command)) out.push(`${at}: command contains shell metacharacters; it is executed directly, with no shell`); + for (const a of s.args) { + if (SHELL_META_RE.test(a) && !hasRef(a)) out.push(`${at}: arg "${a}" contains shell metacharacters; args are passed directly, with no shell`); + if (UNPINNED_RE.test(a)) out.push(`${at}: arg "${a}" is not pinned to an exact version — an unpinned server can change under you between runs`); + } + } else { + if (!s.url) out.push(`${at}: ${s.transport} transport requires a url`); + if (s.command) out.push(`${at}: ${s.transport} transport must not declare a command`); + if (s.url) out.push(...urlLints(at, s.url)); + } + + if (s.timeoutMs !== undefined && (!Number.isInteger(s.timeoutMs) || s.timeoutMs <= 0)) out.push(`${at}: timeout_ms must be a positive integer`); + + out.push(...secretLints(at, "env", s.env)); + out.push(...secretLints(at, "headers", s.headers)); + } + return out; +} + +function urlLints(at: string, url: string): string[] { + const out: string[] = []; + let u: URL; + try { + u = new URL(url); + } catch { + return [`${at}: url "${url}" is not a valid absolute URL`]; + } + if (u.protocol !== "https:" && u.protocol !== "http:") out.push(`${at}: url scheme "${u.protocol}" is not allowed — use https (or http on loopback)`); + const loopback = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "::1" || u.hostname === "[::1]"; + if (u.protocol === "http:" && !loopback) out.push(`${at}: url uses plain http off-loopback — credentials and tool traffic would cross the network in the clear`); + // Credentials in the URL leak into process lists, logs and config files. + if (u.username || u.password) out.push(`${at}: url must not embed credentials (user:password@) — use headers with a \${VAR} reference`); + return out; +} + +/** + * A secret must be a `${VAR}` reference, never a literal. Two ways to be wrong: + * the value looks like a credential, or the key says it is one and the value is + * not a reference. + */ +function secretLints(at: string, where: string, map: Record): string[] { + const out: string[] = []; + for (const [k, v] of Object.entries(map)) { + for (const { re, what } of SECRET_SHAPE) { + if (re.test(v)) { + out.push(`${at}: ${where}.${k} looks like ${what} committed in plain text — use a \${VAR} reference instead`); + break; + } + } + if (SECRET_KEY_RE.test(k) && v && !hasRef(v)) { + out.push(`${at}: ${where}.${k} is named as a credential but holds a literal — use a \${VAR} reference so nothing secret is committed`); + } + } + return out; +} + +/** Non-fatal notes worth showing at the trust gate. */ +export function mcpWarnings(cfg: McpConfig): string[] { + const out: string[] = []; + for (const s of cfg.servers) { + if (s.tools.includes("*")) out.push(`mcp.servers.${s.name}: tools = ["*"] — every tool this server exposes is allowed, including ones added in a later version`); + } + return out; +} + +// ── emitters ───────────────────────────────────────────────────────────────── + +/** Why a target gets no MCP output. Typed so the message is specific, not "unsupported". */ +export type UnsupportedReason = "no-mcp-surface" | "no-project-scope" | "unconfirmed-path" | "needs-shared-file-merge"; + +const UNSUPPORTED: Record = { + aider: { reason: "no-mcp-surface", detail: "aider has no MCP mechanism; CONVENTIONS.md is prose with no configuration" }, + agentsmd: { reason: "no-mcp-surface", detail: "AGENTS.md is a prose instruction file with no configuration mechanism" }, + agents: { reason: "unconfirmed-path", detail: "the vendor-neutral .agents/ convention has no confirmed MCP file; the only candidate is a third-party draft no client reads" }, + cline: { reason: "no-project-scope", detail: "Cline's MCP settings are global-only, so a repo-committed declaration cannot represent them" }, + windsurf: { reason: "no-project-scope", detail: "Windsurf/Devin documents only a user-global MCP config, and its documented page covers the legacy agent" }, + zed: { reason: "needs-shared-file-merge", detail: "Zed keeps MCP servers in .zed/settings.json alongside unrelated user settings; merging into it needs its own consent path" }, + gemini: { reason: "needs-shared-file-merge", detail: "Gemini keeps MCP servers in .gemini/settings.json alongside unrelated user settings; merging into it needs its own consent path" }, + cursor: { reason: "needs-shared-file-merge", detail: "Cursor's .cursor/mcp.json cannot express a tools allowlist, so a declaration would be silently widened" }, +}; + +export interface McpEmit { + files: { path: string; content: string }[]; + warnings: string[]; +} + +/** + * Emit the MCP configuration for one target, given every declared server across + * all installed skills. Returns no files (and a specific warning) for targets + * that cannot honor a declaration. + */ +export function emitMcp(targetId: string, servers: McpServer[], pluginRoot: string): McpEmit { + if (!servers.length) return { files: [], warnings: [] }; + + const un = UNSUPPORTED[targetId]; + if (un) { + return { + files: [], + warnings: [`${targetId}: cannot honor ${plural(servers.length)} (${un.reason}) — ${un.detail}. Nothing was written; configure ${servers.length === 1 ? "it" : "them"} in that client directly.`], + }; + } + + switch (targetId) { + case "agent-plugins": + return emitAgentPlugins(servers, pluginRoot); + case "claude-code": + return emitClaudeCode(servers); + case "copilot": + return emitCopilot(servers); + default: + return { files: [], warnings: [] }; + } +} + +function plural(n: number): string { + return `${n} declared MCP server${n === 1 ? "" : "s"}`; +} + +function json(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +/** + * Agent Plugins v1.0 — `/mcp.json`. The schema is closed + * (`additionalProperties: false`, `$schema` is a const), so only the two + * top-level keys may appear and the version must be byte-exact. + * + * The spec defines no credential reference and performs no variable expansion, + * so a server needing a `${VAR}` is not expressible here: it is omitted with a + * warning rather than emitted with the reference inert (which would look + * configured and fail at run time) or the literal inlined (which would commit + * a secret). + */ +export const AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"; + +function emitAgentPlugins(servers: McpServer[], pluginRoot: string): McpEmit { + const warnings: string[] = []; + const out: Record = {}; + for (const s of servers) { + const refs = refsOf(s); + if (refs.length) { + warnings.push( + `agent-plugins: skipped MCP server "${s.name}" — it references ${refs.map((r) => `\${${r}}`).join(", ")}, and the Agent Plugins format defines no credential reference and expands no variables. Emitting it would produce a server that cannot start.`, + ); + continue; + } + const entry: Record = { type: s.transport }; + if (s.transport === "stdio") { + entry["command"] = s.command; + if (s.args.length) entry["args"] = s.args; + if (Object.keys(s.env).length) entry["env"] = s.env; + } else { + entry["url"] = s.url; + if (Object.keys(s.headers).length) entry["headers"] = s.headers; + } + out[s.name] = entry; + } + // Fields the format has no home for. Dropping them silently would be the + // capability loss the spec forbids, so say it once, per field. + for (const s of servers) { + if (!refsOf(s).length) { + if (s.tools.length && !s.tools.includes("*")) warnings.push(`agent-plugins: "${s.name}" declares a tools allowlist, which the Agent Plugins mcp.json has no field for — the client will expose every tool the server offers.`); + if (s.timeoutMs !== undefined) warnings.push(`agent-plugins: "${s.name}" declares timeout_ms, which the Agent Plugins mcp.json has no field for — dropped.`); + } + } + if (!Object.keys(out).length) return { files: [], warnings }; + return { + files: [{ path: `${pluginRoot}/mcp.json`, content: json({ $schema: AGENT_PLUGIN_MCP_SCHEMA, mcpServers: out }) }], + warnings, + }; +} + +/** + * Claude Code — `.mcp.json` at the project root. `type` is always emitted: a + * `url` with no `type` is a documented hard error, and a typeless entry is read + * as stdio. Claude Code's HTTP spelling is `http`, not `streamable-http`. + */ +function emitClaudeCode(servers: McpServer[]): McpEmit { + const warnings: string[] = []; + const out: Record = {}; + for (const s of servers) { + const entry: Record = { type: s.transport === "streamable-http" ? "http" : s.transport }; + if (s.transport === "stdio") { + entry["command"] = s.command; + if (s.args.length) entry["args"] = s.args; + if (Object.keys(s.env).length) entry["env"] = s.env; + } else { + entry["url"] = s.url; + if (Object.keys(s.headers).length) entry["headers"] = s.headers; + } + out[s.name] = entry; + if (s.tools.length && !s.tools.includes("*")) { + warnings.push(`claude-code: "${s.name}" declares a tools allowlist, which .mcp.json has no field for — it belongs in .claude/settings.json permissions, which Kitbash does not write. The allowlist is not enforced here.`); + } + } + if (servers.length) { + warnings.push( + "claude-code: a project .mcp.json prompts for approval in an interactive session, but is loaded without any prompt under `claude -p`, the Agent SDK and cloud sessions — in CI, the install gate you just passed is the only review these servers get.", + ); + } + return { files: [{ path: ".mcp.json", content: json({ mcpServers: out }) }], warnings }; +} + +/** + * GitHub Copilot — `.github/mcp.json`, the repo-committed shared config. `tools` + * is required here, which is why the manifest requires it too. Copilot's HTTP + * spelling is `http`. + */ +function emitCopilot(servers: McpServer[]): McpEmit { + const warnings: string[] = []; + const out: Record = {}; + for (const s of servers) { + const entry: Record = { type: s.transport === "streamable-http" ? "http" : s.transport }; + if (s.transport === "stdio") { + entry["command"] = s.command; + if (s.args.length) entry["args"] = s.args; + if (Object.keys(s.env).length) entry["env"] = s.env; + } else { + entry["url"] = s.url; + if (Object.keys(s.headers).length) entry["headers"] = s.headers; + } + entry["tools"] = s.tools; + if (s.timeoutMs !== undefined) entry["timeout"] = s.timeoutMs; + out[s.name] = entry; + } + return { files: [{ path: ".github/mcp.json", content: json({ mcpServers: out }) }], warnings }; +} + +/** Targets that can carry an MCP declaration today, for `doctor` and the docs. */ +export const MCP_TARGETS = ["agent-plugins", "claude-code", "copilot"]; + +/** One line per target explaining its MCP status — the support matrix, printed. */ +export function mcpSupportMatrix(): string[] { + const out: string[] = []; + for (const id of MCP_TARGETS) { + const path = id === "agent-plugins" ? "/mcp.json" : id === "claude-code" ? ".mcp.json" : ".github/mcp.json"; + out.push(`✓ ${id.padEnd(14)} ${path}`); + } + for (const [id, u] of Object.entries(UNSUPPORTED)) out.push(`· ${id.padEnd(14)} no output — ${u.reason}: ${u.detail}`); + return out; +} + +/** Aggregate every declared server across skills, rejecting cross-skill name collisions. */ +export function collectServers(skills: LoadedSkill[]): { servers: McpServer[]; conflicts: string[] } { + const byName = new Map(); + const conflicts: string[] = []; + for (const skill of skills) { + for (const s of skill.manifest.mcp.servers) { + const prev = byName.get(s.name); + if (prev && prev.skill !== skill.manifest.skill.name) { + conflicts.push(`MCP server "${s.name}" is declared by both "${prev.skill}" and "${skill.manifest.skill.name}" — one config key cannot hold both. Rename one.`); + continue; + } + byName.set(s.name, { server: s, skill: skill.manifest.skill.name }); + } + } + const servers = [...byName.values()].map((v) => v.server).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + return { servers, conflicts }; +} + +/** True when a value is exactly one `${VAR}` and nothing else. */ +export function isWholeRef(v: string): boolean { + return WHOLE_REF_RE.test(v); +} diff --git a/site/changelog.html b/site/changelog.html index b7594ba..bbb9e89 100644 --- a/site/changelog.html +++ b/site/changelog.html @@ -91,7 +91,7 @@

Changelog

Releases follow Keep a Changelog and semver — for skills and for this CLI, breaking prompt changes are breaking changes. The CLI is published to npm as kitbash and to Homebrew via singhharsh1708/tap. Tagged builds are on the GitHub releases page.

-
v0.18.0Current CLI version
+
v0.19.0Current CLI version
8Compile targets
Apache-2.0License
@@ -105,10 +105,21 @@

Changelog

Confirm with kitbash --version, which reads the installed package.json. Install and uninstall routes are covered on the installation page.

+
+
+

v0.19.0

+ 2026-08-09latest +
+

MCP servers, declared in the skill and gated like the code they are. A skill that needs an MCP server previously had to say so in prose and hope you configured it; now it declares the server, compile writes the native config for every target that has one, and the install gate reviews it first.

+

The research behind this mattered more than the code. Of the eleven targets, only three have a dedicated, project-scoped, primary-source-confirmed MCP config file. The rest either have no MCP mechanism at all, no project scope, or keep servers in a shared settings file full of unrelated user config. Emitting a plausible-looking file for those would produce something that reads as configured and does nothing — the exact silent failure this project exists to prevent — so they get a typed warning naming the reason, and no file.

+

Added

+
  • [mcp.servers.<name>] in skill.toml — declare a server with transport (stdio / streamable-http / sse), command + args, or url, plus env, headers, timeout_ms, and a required tools allowlist. tools is required because it is the one field Kitbash cannot synthesize, Copilot's format demands it, and deny-by-default is the point; tools = ["*"] stays legal and is shown prominently at the gate.
  • Compiles to the three targets that can actually carry it — claude-code → .mcp.json, copilot → .github/mcp.json, agent-plugins → <plugin-root>/mcp.json. Nothing is passed through: the client dialects genuinely disagree, so the manifest's streamable-http is translated to http for Claude Code and Copilot and left as streamable-http for Agent Plugins, an explicit type is always emitted (a url with no type is a documented hard error in Claude Code), and tools is written only where the format has a field for it — with a warning where it does not, rather than a silent drop.
  • Secrets are references, never literals. A credential may only appear as ${VAR}. A literal that looks like a credential, or any value under a key named like one, is an install-blocking failure. Where a format defines no credential reference and expands no variables — Agent Plugins — the server is omitted with a warning rather than emitted with a reference that would never resolve.
  • The MCP lints block install, --yes included, alongside the four existing safety lints: a shell string where a single executable belongs, shell metacharacters in command/args, an unpinned server version (@latest, ^, >=), a plain-http URL off loopback, credentials embedded in a URL, a missing tools allowlist, and duplicate or malformed server names. A server declaration is a request to run third-party code with your agent's permissions, so "probably fine" is not a category.
  • on_unsupported = "error" turns "this target cannot carry the declaration" into a build failure for repos that would rather not ship half-configured.
+
+

v0.18.0

- 2026-08-09latest + 2026-08-09

Kitbash in CI. Everything it checks — the install-gate safety lints, the declared token budgets, the compiled output's drift from its source — was previously a thing one maintainer ran locally and everyone else took on trust. This release makes it a required check on the pull request instead.

Added

diff --git a/site/index.html b/site/index.html index ce3c171..160d0f4 100644 --- a/site/index.html +++ b/site/index.html @@ -151,7 +151,7 @@ -

Open format for AI agent skills · v0.18.0 · stable spec (RFC 0002)

+

Open format for AI agent skills · v0.19.0 · stable spec (RFC 0002)

Write an agent skill once. Run it everywhere.