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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

Format: [Keep a Changelog](https://keepachangelog.com). Versioning: semver — for skills *and* for this CLI, breaking prompt changes are breaking changes.

## [0.20.0] — 2026-08-09

The token-cost argument, applied to MCP — the largest standing-context line item Kitbash was not measuring.

A skill body's cost is statically countable, which is why `context.budget` can be enforced at compile time. An MCP server's real cost is not: the tokens it adds are the JSON schema of every tool it exposes, and knowing those means asking the server — which for a stdio server means **executing it**. That is exactly what the install gate exists to prevent, so Kitbash does not do it, and does not print an estimate it cannot derive.

What it can count exactly is the thing the manifest already states. Every tool in a declared allowlist is a tool definition the agent carries for the whole session, so the allowlist is an exact **floor** on the cost.

### Added
- **MCP tool budget, reported on every compile and by `doctor`** — `MCP tool budget: 8 tool(s) across 2 server(s); cap 100`, alongside an explicit statement that the token cost is unmeasured and why. A server declaring `tools = ["*"]` has no countable bound, so the total renders as `8+ (1 server(s) declare "*")` and warns that it cannot be bounded, rather than folding into a total that would be wrong.
- **`max_mcp_tools` in `[policy]`** — caps declared MCP tools across all skills. A breach warns and fails `--strict`. The default ceiling is **100**, the one primary-source numeric limit found across the clients (Windsurf documents it as a hard cap past which tools are dropped). A widely-repeated "40 tool" Cursor limit is deliberately **not** encoded: it appears only in forum posts and blogs, never in Cursor's own docs, and a tool whose pitch is measurement must not ship a folklore number.

## [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.
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ 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
# max_mcp_tools = 100 # cap declared MCP tools across all skills
# deny_remote_exec = false # opt out of the curl|sh body lint (default: on)
```

Expand All @@ -190,6 +191,12 @@ Three targets have a project-scoped MCP config file, and those are the three Kit

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.

### What it costs you

`compile` and `doctor` report the tool budget: `MCP tool budget: 8 tool(s) across 2 server(s); cap 100`. Every tool in a declared allowlist is a tool definition the agent carries for the whole session, so the allowlist is an exact floor on the cost — and `max_mcp_tools` in `[policy]` caps it, warning on breach and failing `--strict`. The default ceiling is 100, the one limit any client documents (Windsurf, past which tools are dropped).

The *real* token cost is reported as unmeasured, and stays that way on purpose. It is the JSON schema of every tool a server exposes, which is only knowable by asking the server — and asking a stdio server means executing it, which is precisely what the install gate exists to prevent. A floor Kitbash can prove beats an estimate it cannot.

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
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.1",
"version": "0.20.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",
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 @@ -1632,6 +1632,53 @@ try {
rmSync(polTmp, { recursive: true, force: true });
}

// ── MCP tool budget ──────────────────────────────────────────────────────────
// The standing-cost argument applied to MCP. Counted from the declared allowlist
// (an exact floor), never estimated — measuring the real cost means executing the
// server, which is what the install gate exists to prevent.
const budTmp = mkdtempSync(join(tmpdir(), "kitbash-budget-"));
try {
const bs = join(budTmp, "src");
mkdirSync(bs, { recursive: true });
writeFileSync(
join(bs, "skill.toml"),
'[skill]\nname = "toolheavy"\nversion = "1.0.0"\ndescription = "Declares several MCP tools across two servers"\n[context]\nbudget = 1500\n\n[mcp.servers.a]\ntransport = "stdio"\ncommand = "npx"\nargs = ["-y", "@acme/a@1.0.0"]\ntools = ["t1","t2","t3","t4","t5"]\n\n[mcp.servers.b]\ntransport = "streamable-http"\nurl = "https://b.example.com/mcp"\ntools = ["u1","u2","u3"]\n',
);
writeFileSync(join(bs, "SKILL.md"), "# Tool heavy\n\nBody.\n");
writeFileSync(join(budTmp, "kitbash.toml"), '[project]\ntargets = ["claude-code"]\n');
run(["install", `file:${bs}`, "--yes"], budTmp);

const def = run(["compile"], budTmp);
check("budget: the tool count is reported on every compile", def.out.includes("MCP tool budget: 8 tool(s) across 2 server(s)"), def.out);
check("budget: token cost is reported as unmeasured, not guessed", def.out.includes("unmeasured"), def.out);
check("budget: under the cap is a note, not a warning", def.status === 0 && !def.out.includes("budget exceeded"), def.out);

writeFileSync(join(budTmp, "kitbash.toml"), '[project]\ntargets = ["claude-code"]\n[policy]\nmax_mcp_tools = 5\n');
const over = run(["compile"], budTmp);
check("budget: a breach of max_mcp_tools warns", over.out.includes("MCP tool budget exceeded"), over.out);
check("budget: a breach still compiles by default", over.status === 0, over.out);
const strictBudget = run(["compile", "--strict"], budTmp);
check("budget: a breach fails --strict", strictBudget.status === 1, strictBudget.out);

// A wildcard server cannot be bounded — say so rather than fold it into a total.
const ws = join(budTmp, "wild");
mkdirSync(ws, { recursive: true });
writeFileSync(
join(ws, "skill.toml"),
'[skill]\nname = "wildy"\nversion = "1.0.0"\ndescription = "Declares a wildcard MCP tool allowlist"\n[context]\nbudget = 1500\n\n[mcp.servers.wild]\ntransport = "streamable-http"\nurl = "https://w.example.com/mcp"\ntools = ["*"]\n',
);
writeFileSync(join(ws, "SKILL.md"), "# Wild\n\nBody.\n");
writeFileSync(join(budTmp, "kitbash.toml"), '[project]\ntargets = ["claude-code"]\n');
run(["install", `file:${ws}`, "--yes"], budTmp);
const wild = run(["compile"], budTmp);
check("budget: a wildcard server makes the total a floor, marked +", wild.out.includes('8+ (1 server(s) declare "*")'), wild.out);
check("budget: and says it cannot be bounded", wild.out.includes("cannot be bounded"), wild.out);
const wdoc = run(["doctor"], budTmp);
check("budget: doctor reports the same budget line", wdoc.out.includes("MCP tool budget:"), wdoc.out);
} finally {
rmSync(budTmp, { recursive: true, force: true });
}

if (failures) {
console.error(`\n${failures} test(s) failed`);
process.exit(1);
Expand Down
14 changes: 13 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, mcpSupportMatrix, mcpWarnings } from "./mcp.js";
import { collectServers, DEFAULT_MAX_TOOLS, emitMcp, mcpLints, mcpSupportMatrix, mcpWarnings, toolBudget, toolBudgetReport } from "./mcp.js";
import { toSarif, type SarifFinding } from "./sarif.js";
import { parseToml } from "./toml.js";

Expand Down Expand Up @@ -42,6 +42,7 @@ const INIT_CONFIG = `# kitbash project configuration — https://github.com/sing
# 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
# max_mcp_tools = 100 # cap declared MCP tools across all skills (default: 100)
# allow_mcp_servers = ["https://mcp.your-org.com/*"] # globs; matched against a server's command or url
`;

Expand Down Expand Up @@ -641,6 +642,7 @@ export async function cmdDoctor(): Promise<number> {
const declared = collectServers(skills).servers;
if (declared.length) {
console.log(`\nMCP servers declared: ${declared.map((s) => s.name).join(", ")}`);
console.log(` ${toolBudgetReport(toolBudget(declared), loadPolicy(root)?.maxMcpTools ?? DEFAULT_MAX_TOOLS).line}`);
for (const line of mcpSupportMatrix()) console.log(` ${line}`);
}

Expand Down Expand Up @@ -721,6 +723,8 @@ interface Policy {
denyRemoteExec: boolean;
/** Refuse any skill that declares an MCP server at all. */
denyMcp: boolean;
/** Cap on declared MCP tools across all skills. Absent uses the documented 100-tool ceiling. */
maxMcpTools?: number | undefined;
/**
* 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
Expand All @@ -747,6 +751,7 @@ function loadPolicy(root: string): Policy | null {
// Absent means true — you must opt OUT of the remote-exec block explicitly.
denyRemoteExec: tbl["deny_remote_exec"] !== false,
denyMcp: tbl["deny_mcp"] === true,
maxMcpTools: typeof tbl["max_mcp_tools"] === "number" ? (tbl["max_mcp_tools"] as number) : undefined,
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 @@ -957,6 +962,13 @@ export async function cmdCompile(args: string[]): Promise<number> {
return 1;
}
if (mcpServers.length) {
// The standing-cost argument, applied to MCP. Counted from the declared
// allowlist — an exact floor, never an estimate — and reported as a note so
// the number shows on every compile, with a breach as a real warning.
const report = toolBudgetReport(toolBudget(mcpServers), loadPolicy(root)?.maxMcpTools ?? DEFAULT_MAX_TOOLS);
notes.push(report.line);
warnings.push(...report.warnings);

const unsupported: string[] = [];
for (const adapter of adapters) {
const out = emitMcp(adapter.id, mcpServers, AGENT_PLUGIN_DIR);
Expand Down
63 changes: 63 additions & 0 deletions packages/cli/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,69 @@ function emitCopilot(servers: McpServer[]): McpEmit {
return { files: [{ path: ".github/mcp.json", content: json({ mcpServers: out }) }], warnings };
}

// ── tool budget ──────────────────────────────────────────────────────────────

/**
* The tool budget: how many MCP tools a repo's skills put in front of the model.
*
* This is the standing-cost argument applied to MCP. A skill body's cost is
* statically countable, which is why Kitbash can enforce `context.budget`. An
* MCP server's real cost — the JSON schema of every tool it exposes — is not
* knowable without asking the server, and asking a stdio server means executing
* it. That is precisely what the install gate exists to prevent, so Kitbash does
* not do it, and does not estimate a number it cannot derive.
*
* What it does count is the thing the manifest already states: the declared tool
* allowlist. Every one of those tools is a tool definition the agent carries, so
* the count is a real floor on the cost, and it is exact whenever the allowlist
* is exact. A server declaring `["*"]` has no countable bound — that is reported
* as unbounded rather than folded into a total that would then be wrong.
*/
export interface ToolBudget {
/** Tools named explicitly across all servers — an exact floor on what is loaded. */
declared: number;
/** Servers declaring `["*"]`: real cost unknown without asking the server. */
unbounded: string[];
servers: number;
}

/**
* Windsurf documents a hard cap of 100 tools total; past it, tools are dropped.
* It is the only primary-source numeric limit found across the clients, so it is
* the default ceiling. (A widely-repeated Cursor "40 tool" limit appears only in
* forum posts and blogs, never in Cursor's own docs, so it is deliberately not
* encoded here — a tool whose pitch is measurement must not ship a folklore number.)
*/
export const DEFAULT_MAX_TOOLS = 100;

export function toolBudget(servers: McpServer[]): ToolBudget {
let declared = 0;
const unbounded: string[] = [];
for (const s of servers) {
if (s.tools.includes("*")) unbounded.push(s.name);
else declared += s.tools.length;
}
return { declared, unbounded, servers: servers.length };
}

/**
* Report the budget, and fail when a countable total breaches the cap. Servers
* declaring `["*"]` can never be proven under a cap, so they are surfaced as the
* reason the total is a floor rather than silently passing.
*/
export function toolBudgetReport(b: ToolBudget, max: number): { line: string; warnings: string[]; over: boolean } {
const bound = b.unbounded.length ? `${b.declared}+ (${b.unbounded.length} server(s) declare "*")` : `${b.declared}`;
const line = `MCP tool budget: ${bound} tool(s) across ${b.servers} server(s); cap ${max}. Real token cost is unmeasured — it needs the server's tool schemas, and reading those means running it.`;
const warnings: string[] = [];
const over = b.declared > max;
if (over) {
warnings.push(`MCP tool budget exceeded: ${b.declared} declared tools against a cap of ${max}. Windsurf documents 100 total tools as a hard cap, past which tools are dropped — and every tool definition is carried in context for the whole session.`);
} else if (b.unbounded.length) {
warnings.push(`MCP tool budget cannot be bounded: ${b.unbounded.join(", ")} declare tools = ["*"], so the total is at least ${b.declared} and may exceed the cap of ${max}. Name the tools to make it countable.`);
}
return { line, warnings, over };
}

/** Targets that can carry an MCP declaration today. */
const MCP_TARGETS = ["agent-plugins", "claude-code", "copilot"];

Expand Down
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.1</span></b><span>Current CLI version</span></div>
<div class="stat"><b><span data-version>v0.20.0</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.20.0">
<div class="release-head">
<h2><a href="#v0.20.0">v0.20.0</a></h2>
<span class="release-date">2026-08-09</span><span class="release-tag">latest</span>
</div>
<p class="release-intro">The token-cost argument, applied to MCP — the largest standing-context line item Kitbash was not measuring.</p>
<p class="release-intro">A skill body's cost is statically countable, which is why <code>context.budget</code> can be enforced at compile time. An MCP server's real cost is not: the tokens it adds are the JSON schema of every tool it exposes, and knowing those means asking the server — which for a stdio server means <strong>executing it</strong>. That is exactly what the install gate exists to prevent, so Kitbash does not do it, and does not print an estimate it cannot derive.</p>
<p class="release-intro">What it can count exactly is the thing the manifest already states. Every tool in a declared allowlist is a tool definition the agent carries for the whole session, so the allowlist is an exact <strong>floor</strong> on the cost.</p>
<h3 class="group">Added</h3>
<ul><li><strong>MCP tool budget, reported on every compile and by <code>doctor</code></strong> — <code>MCP tool budget: 8 tool(s) across 2 server(s); cap 100</code>, alongside an explicit statement that the token cost is unmeasured and why. A server declaring <code>tools = ["*"]</code> has no countable bound, so the total renders as <code>8+ (1 server(s) declare "*")</code> and warns that it cannot be bounded, rather than folding into a total that would be wrong.</li><li><strong><code>max_mcp_tools</code> in <code>[policy]</code></strong> — caps declared MCP tools across all skills. A breach warns and fails <code>--strict</code>. The default ceiling is <strong>100</strong>, the one primary-source numeric limit found across the clients (Windsurf documents it as a hard cap past which tools are dropped). A widely-repeated "40 tool" Cursor limit is deliberately <strong>not</strong> encoded: it appears only in forum posts and blogs, never in Cursor's own docs, and a tool whose pitch is measurement must not ship a folklore number.</li></ul>
</article>

<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>
<span class="release-date">2026-08-09</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>
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.1</span> · stable spec (RFC 0002)</p>
<p class="eyebrow">Open format for AI agent skills · <span data-version>v0.20.0</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