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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<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.

## [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.
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` → `<plugin-root>/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:
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.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",
Expand Down
139 changes: 139 additions & 0 deletions packages/cli/scripts/test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
57 changes: 56 additions & 1 deletion packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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]
Expand Down Expand Up @@ -906,6 +909,43 @@ export async function cmdCompile(args: string[]): Promise<number> {
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
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading