From eae650e9ed46d2cd738dd1ff5e110e37802812d0 Mon Sep 17 00:00:00 2001 From: Harsh Singh Date: Mon, 10 Aug 2026 01:44:50 +0530 Subject: [PATCH] feat(policy): give [policy] a say over MCP servers, and show the matrix in doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An org allowlist that could not say "no MCP servers" was incomplete: a declared server is the largest surface a skill can request — third-party code running with the agent's permissions. `deny_mcp = true` refuses any skill declaring one. `allow_mcp_servers` takes the same globs as allow_sources and matches a server's command line (stdio) or url (remote), so an org can pin servers to its own registry instead of banning MCP outright. Both are enforced at install and rechecked by doctor, like every other policy rule. doctor now prints the MCP support matrix whenever a skill declares a server: which targets carry it and to which file, and for the eight that cannot, the specific reason. That only surfaced as a compile warning before — after the decision to install had already been made. Also removes two exports added in 0.19.0 that nothing called (isWholeRef, and the needlessly public MCP_TARGETS). Adds 7 tests. 0.19.1. --- CHANGELOG.md | 11 ++++++++ README.md | 2 ++ packages/cli/package.json | 2 +- packages/cli/scripts/test.mjs | 47 +++++++++++++++++++++++++++++++++++ packages/cli/src/commands.ts | 40 ++++++++++++++++++++++++++++- packages/cli/src/mcp.ts | 12 ++------- site/changelog.html | 16 ++++++++++-- site/index.html | 2 +- 8 files changed, 117 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfd8c62..93ab9e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ Format: [Keep a Changelog](https://keepachangelog.com). Versioning: semver — for skills *and* for this CLI, breaking prompt changes are breaking changes. +## [0.19.1] — 2026-08-09 + +Follow-through on 0.19.0: policy gets a say over MCP servers, and the support matrix becomes something you can read before you compile. + +### Added +- **`deny_mcp` and `allow_mcp_servers` in `[policy]`.** An org allowlist that could not say "no MCP servers" was incomplete — a declared server is the largest surface a skill can ask for. `deny_mcp = true` refuses any skill declaring one; `allow_mcp_servers` takes the same globs as `allow_sources` and matches a server's command line (stdio) or url (remote), so an org can pin servers to its own registry without banning MCP outright. Enforced at install and rechecked by `doctor`, like every other policy rule. +- **`doctor` prints the MCP support matrix** when any skill declares a server: which targets carry it and to which file, and for the eight that cannot, the specific reason. Previously that only surfaced as a compile-time warning, after the decision to install had already been made. + +### Fixed +- Removed two exports added in 0.19.0 that nothing called (`isWholeRef`, and the needlessly public `MCP_TARGETS`). + ## [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. diff --git a/README.md b/README.md index 3abc327..fae5391 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,8 @@ allow_sources = ["gh:your-org/*"] # only skills from your org deny_network = true # refuse skills declaring network access deny_write = true # refuse skills declaring write access max_budget = 6000 # cap per-skill context budget +# deny_mcp = true # refuse skills declaring any MCP server +# allow_mcp_servers = ["https://mcp.your-org.com/*"] # globs; matched against a server's command or url # deny_remote_exec = false # opt out of the curl|sh body lint (default: on) ``` diff --git a/packages/cli/package.json b/packages/cli/package.json index 40eef0e..b2dddfa 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "kitbash", - "version": "0.19.0", + "version": "0.19.1", "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 b3b5efa..46e0e2d 100644 --- a/packages/cli/scripts/test.mjs +++ b/packages/cli/scripts/test.mjs @@ -1585,6 +1585,53 @@ try { rmSync(refTmp, { recursive: true, force: true }); } +// ── [policy] control over MCP servers ──────────────────────────────────────── +// An org allowlist that cannot say "no MCP servers" is incomplete: a server is +// the largest surface a skill can request. +const polTmp = mkdtempSync(join(tmpdir(), "kitbash-mcppolicy-")); +try { + const ps = join(polTmp, "src"); + mkdirSync(ps, { recursive: true }); + writeFileSync( + join(ps, "skill.toml"), + '[skill]\nname = "needs-mcp"\nversion = "1.0.0"\ndescription = "Declares an MCP server outside the org allowlist"\n[context]\nbudget = 1500\n\n[mcp.servers.outside]\ntransport = "streamable-http"\nurl = "https://random.example.com/mcp"\ntools = ["x"]\n', + ); + writeFileSync(join(ps, "SKILL.md"), "# Needs MCP\n\nBody.\n"); + + writeFileSync(join(polTmp, "kitbash.toml"), "[policy]\ndeny_mcp = true\n"); + const denied = run(["install", `file:${ps}`, "--yes"], polTmp); + check("policy: deny_mcp blocks a skill declaring a server", denied.status === 1 && denied.out.includes("deny_mcp = true"), denied.out); + + writeFileSync(join(polTmp, "kitbash.toml"), '[policy]\nallow_mcp_servers = ["https://mcp.acme.com/*"]\n'); + const off = run(["install", `file:${ps}`, "--yes"], polTmp); + check("policy: a server outside allow_mcp_servers is blocked", off.status === 1 && off.out.includes("not in allow_mcp_servers"), off.out); + + writeFileSync(join(polTmp, "kitbash.toml"), '[policy]\nallow_mcp_servers = ["https://random.example.com/*"]\n'); + const on = run(["install", `file:${ps}`, "--yes"], polTmp); + check("policy: a matching server installs", on.status === 0, on.out); + + // doctor prints the support matrix, so "most targets cannot honor this" is + // visible before compile rather than only as a compile-time warning. + const doc = run(["doctor"], polTmp); + check("doctor: lists declared MCP servers", doc.out.includes("MCP servers declared: outside"), doc.out); + check("doctor: shows which targets can carry them", doc.out.includes(".mcp.json") && doc.out.includes(".github/mcp.json"), doc.out); + check("doctor: shows why the others cannot", doc.out.includes("no-mcp-surface") && doc.out.includes("needs-shared-file-merge"), doc.out); + + // A stdio server is matched on its command line, not a url. + const stdioSrc = join(polTmp, "stdio"); + mkdirSync(stdioSrc, { recursive: true }); + writeFileSync( + join(stdioSrc, "skill.toml"), + '[skill]\nname = "stdio-mcp"\nversion = "1.0.0"\ndescription = "Declares a stdio MCP server for policy matching"\n[context]\nbudget = 1500\n\n[mcp.servers.local]\ntransport = "stdio"\ncommand = "npx"\nargs = ["-y", "@acme/tool@1.0.0"]\ntools = ["x"]\n', + ); + writeFileSync(join(stdioSrc, "SKILL.md"), "# Stdio\n\nBody.\n"); + writeFileSync(join(polTmp, "kitbash.toml"), '[policy]\nallow_mcp_servers = ["npx -y @acme/*"]\n'); + const stdioOk = run(["install", `file:${stdioSrc}`, "--yes"], polTmp); + check("policy: a stdio server is matched on its command line", stdioOk.status === 0, stdioOk.out); +} finally { + rmSync(polTmp, { 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 a75ade5..f7af5cb 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -11,7 +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 { collectServers, emitMcp, mcpLints, mcpSupportMatrix, mcpWarnings } from "./mcp.js"; import { toSarif, type SarifFinding } from "./sarif.js"; import { parseToml } from "./toml.js"; @@ -41,6 +41,8 @@ const INIT_CONFIG = `# kitbash project configuration — https://github.com/sing # deny_write = true # refuse skills declaring write permission # max_budget = 6000 # refuse skills with a larger context budget # deny_remote_exec = false # opt OUT of the download-and-execute body lint (default: on) +# deny_mcp = true # refuse skills declaring any MCP server +# allow_mcp_servers = ["https://mcp.your-org.com/*"] # globs; matched against a server's command or url `; export async function cmdInit(): Promise { @@ -633,6 +635,15 @@ export async function cmdDoctor(): Promise { console.log(`installed skills: ${skills.length}`); console.log(`standing context cost: ~${standing} tokens (stubs); worst-case active: ${active} tokens (budgets)`); + // MCP declarations are the one thing a skill can ask for that most targets + // cannot honor, so where they do and don't land is printed, not buried in a + // compile warning. Shown only when something actually declares a server. + const declared = collectServers(skills).servers; + if (declared.length) { + console.log(`\nMCP servers declared: ${declared.map((s) => s.name).join(", ")}`); + for (const line of mcpSupportMatrix()) console.log(` ${line}`); + } + let problems = 0; // A skill whose manifest no longer loads (hand-edited, or the exact tamper doctor // exists to catch). Count it, don't throw — the integrity loop below must still run. @@ -708,6 +719,14 @@ interface Policy { maxBudget?: number | undefined; /** The remote-exec lint is a hard fail by default; a policy may consciously exempt it. */ denyRemoteExec: boolean; + /** Refuse any skill that declares an MCP server at all. */ + denyMcp: boolean; + /** + * Globs an MCP server's command (stdio) or url (remote) must match. Empty means + * unrestricted — the same shape as allow_sources, so an org can pin servers to + * its own registry without banning MCP outright. + */ + allowMcpServers: string[]; } function loadPolicy(root: string): Policy | null { @@ -727,6 +746,10 @@ function loadPolicy(root: string): Policy | null { maxBudget: typeof tbl["max_budget"] === "number" ? (tbl["max_budget"] as number) : undefined, // Absent means true — you must opt OUT of the remote-exec block explicitly. denyRemoteExec: tbl["deny_remote_exec"] !== false, + denyMcp: tbl["deny_mcp"] === true, + allowMcpServers: Array.isArray(tbl["allow_mcp_servers"]) + ? (tbl["allow_mcp_servers"] as unknown[]).filter((x): x is string => typeof x === "string") + : [], }; } @@ -764,6 +787,21 @@ function manifestViolations(policy: Policy, skill: LoadedSkill): string[] { if (policy.maxBudget !== undefined && m.context.budget > policy.maxBudget) { out.push(`${name} budget ${m.context.budget} exceeds max_budget ${policy.maxBudget}`); } + // An MCP server is third-party code that runs with the agent's permissions — + // the largest surface a skill can ask for, so policy gets a say over it. + for (const s of m.mcp.servers) { + if (policy.denyMcp) { + out.push(`${name} declares MCP server "${s.name}" and deny_mcp = true`); + continue; + } + if (!policy.allowMcpServers.length) continue; + // Match against whatever identifies the server: the executable for stdio, + // the endpoint for remote. A server matching neither is not allowed. + const subject = s.transport === "stdio" ? [s.command ?? "", ...s.args].join(" ").trim() : (s.url ?? ""); + if (!policy.allowMcpServers.some((p) => sourceMatches(p, subject))) { + out.push(`${name} MCP server "${s.name}" (${subject}) is not in allow_mcp_servers (${policy.allowMcpServers.join(", ")})`); + } + } return out; } diff --git a/packages/cli/src/mcp.ts b/packages/cli/src/mcp.ts index 7c1036a..8853f56 100644 --- a/packages/cli/src/mcp.ts +++ b/packages/cli/src/mcp.ts @@ -54,9 +54,6 @@ export interface McpConfig { /** 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); @@ -397,8 +394,8 @@ function emitCopilot(servers: McpServer[]): McpEmit { 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"]; +/** Targets that can carry an MCP declaration today. */ +const MCP_TARGETS = ["agent-plugins", "claude-code", "copilot"]; /** One line per target explaining its MCP status — the support matrix, printed. */ export function mcpSupportMatrix(): string[] { @@ -428,8 +425,3 @@ export function collectServers(skills: LoadedSkill[]): { servers: McpServer[]; c 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 bbb9e89..8264471 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.19.0Current CLI version
+
v0.19.1Current CLI version
8Compile targets
Apache-2.0License
@@ -105,10 +105,22 @@

Changelog

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

+
+
+

v0.19.1

+ 2026-08-09latest +
+

Follow-through on 0.19.0: policy gets a say over MCP servers, and the support matrix becomes something you can read before you compile.

+

Added

+
  • deny_mcp and allow_mcp_servers in [policy]. An org allowlist that could not say "no MCP servers" was incomplete — a declared server is the largest surface a skill can ask for. deny_mcp = true refuses any skill declaring one; allow_mcp_servers takes the same globs as allow_sources and matches a server's command line (stdio) or url (remote), so an org can pin servers to its own registry without banning MCP outright. Enforced at install and rechecked by doctor, like every other policy rule.
  • doctor prints the MCP support matrix when any skill declares a server: which targets carry it and to which file, and for the eight that cannot, the specific reason. Previously that only surfaced as a compile-time warning, after the decision to install had already been made.
+

Fixed

+
  • Removed two exports added in 0.19.0 that nothing called (isWholeRef, and the needlessly public MCP_TARGETS).
+
+

v0.19.0

- 2026-08-09latest + 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.

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

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

+

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

Write an agent skill once. Run it everywhere.