Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
47 changes: 47 additions & 0 deletions packages/cli/scripts/test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
40 changes: 39 additions & 1 deletion packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<number> {
Expand Down Expand Up @@ -633,6 +635,15 @@ export async function cmdDoctor(): Promise<number> {
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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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")
: [],
};
}

Expand Down Expand Up @@ -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;
}

Expand Down
12 changes: 2 additions & 10 deletions packages/cli/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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[] {
Expand Down Expand Up @@ -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);
}
16 changes: 14 additions & 2 deletions site/changelog.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ <h1>Changelog</h1>
<p>Releases follow <a href="https://keepachangelog.com" target="_blank" rel="noopener">Keep a Changelog</a> and semver — for skills <em>and</em> for this CLI, breaking prompt changes are breaking changes. The CLI is published to npm as <a href="https://www.npmjs.com/package/kitbash" target="_blank" rel="noopener"><code>kitbash</code></a> and to Homebrew via <code>singhharsh1708/tap</code>. Tagged builds are on the <a href="https://github.com/singhharsh1708/kitbash/releases" target="_blank" rel="noopener">GitHub releases page</a>.</p>

<div class="stat-row">
<div class="stat"><b><span data-version>v0.19.0</span></b><span>Current CLI version</span></div>
<div class="stat"><b><span data-version>v0.19.1</span></b><span>Current CLI version</span></div>
<div class="stat"><b>8</b><span>Compile targets</span></div>
<div class="stat"><b>Apache-2.0</b><span>License</span></div>
</div>
Expand All @@ -105,10 +105,22 @@ <h1>Changelog</h1>
<p>Confirm with <code>kitbash --version</code>, which reads the installed package.json. Install and uninstall routes are covered on the <a href="docs/install">installation page</a>.</p>

<!-- changelog:begin -->
<article class="release" id="v0.19.1">
<div class="release-head">
<h2><a href="#v0.19.1">v0.19.1</a></h2>
<span class="release-date">2026-08-09</span><span class="release-tag">latest</span>
</div>
<p class="release-intro">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.</p>
<h3 class="group">Added</h3>
<ul><li><strong><code>deny_mcp</code> and <code>allow_mcp_servers</code> in <code>[policy]</code>.</strong> An org allowlist that could not say "no MCP servers" was incomplete — a declared server is the largest surface a skill can ask for. <code>deny_mcp = true</code> refuses any skill declaring one; <code>allow_mcp_servers</code> takes the same globs as <code>allow_sources</code> 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 <code>doctor</code>, like every other policy rule.</li><li><strong><code>doctor</code> prints the MCP support matrix</strong> 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.</li></ul>
<h3 class="group">Fixed</h3>
<ul><li>Removed two exports added in 0.19.0 that nothing called (<code>isWholeRef</code>, and the needlessly public <code>MCP_TARGETS</code>).</li></ul>
</article>

<article class="release" id="v0.19.0">
<div class="release-head">
<h2><a href="#v0.19.0">v0.19.0</a></h2>
<span class="release-date">2026-08-09</span><span class="release-tag">latest</span>
<span class="release-date">2026-08-09</span>
</div>
<p class="release-intro">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, <code>compile</code> writes the native config for every target that has one, and the install gate reviews it first.</p>
<p class="release-intro">The research behind this mattered more than the code. Of the eleven targets, only <strong>three</strong> 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.</p>
Expand Down
2 changes: 1 addition & 1 deletion site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@
<circle cx="256" cy="256" r="238" fill="none" stroke="#ffb454" stroke-width="5"/>
</svg>
</div>
<p class="eyebrow">Open format for AI agent skills · <span data-version>v0.19.0</span> · stable spec (RFC 0002)</p>
<p class="eyebrow">Open format for AI agent skills · <span data-version>v0.19.1</span> · stable spec (RFC 0002)</p>
<h1>Write an agent skill once. Run it <em>everywhere</em>.</h1>
<div class="actions">
<a class="button" href="docs/quickstart">Get started</a>
Expand Down
Loading