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
16 changes: 11 additions & 5 deletions docs/providers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,17 @@ documentation.

### Cross-cutting notes

- **`AGENTS.md` is universal.** Every install run touches `AGENTS.md`
if any provider is active — `getTargetFilenames` adds it
unconditionally (`agents-file.ts`). Providers with their own
`instructions.filename` get the same marker blocks duplicated into
that file.
- **Instructions filename follows the provider.** `getTargetFilenames`
(`agents-file.ts`) returns the union of `instructions.filename` across
the active providers — `AGENTS.md` for most universal-spec providers,
`CLAUDE.md` for Claude Code, `.github/copilot-instructions.md` for
Copilot, `replit.md` for Replit, etc. Capa does **not** write a file
no configured provider will read; running `capa install --provider
claude-code` only produces `CLAUDE.md`. When two providers declare
different filenames (e.g. `claude-code` + `codex`) the same marker
blocks are written into both. `AGENTS.md` is also used as a
last-resort fallback if no active provider declares an instructions
filename.
- **Sub-agent MCP entries** are only written for providers whose
`mcp.supportsSubAgentEntries === true`. Cursor and GitHub Copilot opt
out (Cursor additionally has `purgeStaleSubAgentMcp` to clean up
Expand Down
2 changes: 1 addition & 1 deletion docs/providers/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Source-of-truth definition: [`src/shared/providers/registry.ts → claude-code`]
| --- | --- | --- |
| Skills | `.claude/skills/<id>/` | Standard SKILL.md tree. |
| MCP | `.mcp.json` → `mcpServers.capa.url` | JSON map; per-sub-agent entries (`mcpServers.capa-<agentId>`) supported. |
| Instructions | `CLAUDE.md` | Universal marker blocks; `AGENTS.md` also written if any other provider is active. |
| Instructions | `CLAUDE.md` | Universal marker blocks. `AGENTS.md` is **only** written if another active provider declares it (e.g. `codex`, `cursor`); a claude-code-only install never produces an `AGENTS.md`. |
| Rules | `.claude/rules/<id>.md` | YAML frontmatter — capa's `appliesTo` maps to `paths`. A file with no `paths` is loaded unconditionally. |
| Sub-agents | `.claude/agents/<id>.md` | Markdown + frontmatter (`name`, `description`, `model: inherit`). Also folds a `sub-agent:<id>` snippet into `CLAUDE.md`. |
| Hooks | `.claude/settings.json` → `hooks` | JSON map keyed by event (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, `SessionEnd`, `Stop`, `SubagentStop`, `PreCompact`, `Notification`). Capa upserts `[{ matcher, hooks: [{ name: "capa:<id>", type, command, timeout }] }]` and only touches the entries it tagged. |
Expand Down
17 changes: 16 additions & 1 deletion skills/capabilities-manager/references/capabilities-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ providers:
- claude-code

options:
toolExposure: expose-all # or 'on-demand'
toolExposure: expose-all # 'expose-all' | 'on-demand' | 'none' (see notes below)
# security: { blockedPhrases, allowedCharacters }
# requiresCommands: [ { cli, description? } ]

Expand Down Expand Up @@ -79,6 +79,21 @@ Both forms accept an optional pinning suffix:

Optional top-level `description` is shown in `capa sh`.

## Tool Exposure (`options.toolExposure`)

Controls how capa exposes skill tools to the MCP client. Three modes:

| Mode | `tools/list` returns | Per-install MCP file writes | Agent invocation path |
|------|----------------------|------------------------------|------------------------|
| `'expose-all'` (default) | Every tool required by any active skill, with full input schemas | Yes — main `capa` entry + sub-agent `capa-<id>` entries | Direct MCP `tools/call` |
| `'on-demand'` | Only the meta-tools `setup_tools` and `call_tool` | Yes — same as expose-all | Agent calls `setup_tools(['<skill>'])` (returns compact `name(required, optional?)` signature list), then `call_tool(name, data)`. If the call is invalid the full schema is returned in the error so the agent can self-correct without re-running setup. |
| `'none'` | Empty list | **No** — capa skips all project-local MCP config files (`.mcp.json`, `.cursor/mcp.json`, `.codex/config.toml` `mcp_servers.capa`, sub-agent `capa-<id>` entries). Any previously-written entries are removed on install. | The agent must use `capa sh <group> <tool> [--args]` (see [`commands.md`](./commands.md)). Sub-agent instruction files are still installed for documentation but their tools are not reachable over MCP. |

Notes on `'none'`:
- The capa HTTP server still runs and the project endpoints stay live; `tools/list` just returns empty and `tools/call` rejects with a hint to use `capa sh`.
- Useful when the user prefers to keep `.mcp.json` clean or has policy restrictions against writing per-project MCP configs.
- Switching to `'none'` on a project that previously installed under another mode cleans up the old entries on the next `capa install`.

## Security Options (`options.security`)

- **blockedPhrases**: List of strings, or `{ file: "./path.txt" }`. Blocks install if any skill file contains a phrase. Omit to disable.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,9 +384,12 @@ tools:
required: true
```

With `on-demand` mode, the agent starts with only `setup_tools()` available and calls:
- `setup_tools(["researcher"])` → Loads `brave.search`
- `setup_tools(["data-analyst"])` → Loads `pandas_query`
With `on-demand` mode, the agent starts with only `setup_tools()` and `call_tool()` available and calls:
- `setup_tools(["researcher"])` → Activates skill `researcher`; returns the compact signature list `["brave.search(query, count?, freshness?, …)"]` (full schemas are intentionally *not* returned — they're only shown in `call_tool` error responses when an invocation is invalid).
- `setup_tools(["data-analyst"])` → Adds `pandas_query(file, query)` to the active set.
- `call_tool({ name: "brave.search", data: { query: "MCP spec" } })` → Invokes the tool. If `data` is missing required args or fails validation, the error response includes the full input schema so the agent can retry without re-running `setup_tools`.

For a third option that bypasses MCP entirely, see `toolExposure: 'none'` in [`capabilities-schema.md`](./capabilities-schema.md#tool-exposure-optionstoolexposure) — capa skips writing any `.mcp.json` / `.cursor/mcp.json` / `.codex/config.toml` entries and the agent is expected to invoke tools via `capa sh <group> <tool> [--args]` instead.

### Example 5: CLI Prerequisites

Expand Down
45 changes: 42 additions & 3 deletions src/cli/commands/__tests__/clean.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,16 @@ tools: []

it('removes capa-managed snippets from CLAUDE.md / AGENTS.md even when capabilities.yaml has no `agents:` block', async () => {
// Sub-agent integrations (e.g. Claude's foldSubAgentsIntoInstructions)
// can write capa snippets into CLAUDE.md / AGENTS.md without a top-level
// `agents:` block, so `capa clean` must always run the agents-file cleanup.
// can write capa snippets into the provider's instructions file without
// a top-level `agents:` block, so `capa clean` must always run the
// agents-file cleanup for each active provider.
//
// With the `getTargetFilenames` fix, claude-code only manages `CLAUDE.md`
// (no stray `AGENTS.md`), so this test enables both `claude-code` and
// `codex` to exercise the multi-file cleanup path.
writeFileSync(
join(projectDir, 'capabilities.yaml'),
`providers: [claude-code]
`providers: [claude-code, codex]
options:
toolExposure: on-demand
skills: []
Expand Down Expand Up @@ -155,6 +160,40 @@ researcher block
expect(existsSync(join(projectDir, 'AGENTS.md'))).toBe(false);
});

it('leaves a pre-existing AGENTS.md alone when claude-code is the only provider', async () => {
// Regression: capa used to seed AGENTS.md unconditionally into the target
// file list, so a claude-code-only `capa clean` would scan AGENTS.md even
// though no claude-code install ever writes one. With the fix,
// `getTargetFilenames(['claude-code'])` returns just `CLAUDE.md` and a
// hand-authored AGENTS.md (e.g. for another tool the user is also using)
// must not be touched.
writeFileSync(
join(projectDir, 'capabilities.yaml'),
`providers: [claude-code]
options:
toolExposure: on-demand
skills: []
servers: []
tools: []
`,
'utf-8'
);
writeFileSync(
join(projectDir, 'AGENTS.md'),
`# My project

Hand-authored AGENTS.md unrelated to claude-code.
`,
'utf-8'
);

await captureOutput(() => cleanCommand());

expect(existsSync(join(projectDir, 'AGENTS.md'))).toBe(true);
const remaining = readFileSync(join(projectDir, 'AGENTS.md'), 'utf-8');
expect(remaining).toContain('Hand-authored AGENTS.md unrelated to claude-code.');
});

it('preserves non-capa content in CLAUDE.md while removing capa snippets', async () => {
writeFileSync(
join(projectDir, 'capabilities.yaml'),
Expand Down
45 changes: 40 additions & 5 deletions src/cli/commands/install-tasks/install-subagents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,28 @@ export function installSubagentsTask(): Task<InstallCtx> {
},
task: async (ctx, task) => {
const providers = ctx.capabilitiesToUse.providers ?? ctx.resolvedProviders;
const toolExposure = ctx.capabilitiesToUse.options?.toolExposure;
const skipMcpWrites = toolExposure === 'none';
const installedAgents = ctx.db.getSubAgents(ctx.projectId);
const currentSubagents = ctx.capabilitiesToUse.subagents ?? [];
const currentAgentIds = new Set(currentSubagents.map((a) => a.id));
const removedAgents = installedAgents.filter(({ agent_id }) => !currentAgentIds.has(agent_id));

// Under `toolExposure: 'none'` we also strip *previously-installed*
// sub-agent MCP entries — having a `capa-<id>` entry pointing at a
// capa endpoint we said we wouldn't expose is a footgun.
const agentsNeedingMcpCleanup = skipMcpWrites
? installedAgents.map(({ agent_id }) => agent_id)
: removedAgents.map(({ agent_id }) => agent_id);

// Purge stale sub-agent MCP entries for providers that need a sweep
// (Cursor doesn't model per-sub-agent entries — its `capa-<id>` entries
// can only be cleaned by `purgeCursorSubAgentMCPEntries`). This must
// run under `toolExposure: 'none'` too: that's *exactly* the case where
// every previously-registered `capa-<id>` entry is now stale and
// contradicts the "no .mcp writes" contract. The per-sub-agent
// unregister loop below is a no-op for those providers, so without
// this purge their entries would linger forever.
const needsPurge = providers.some((id) => {
const provider = getProvider(id);
return (
Expand All @@ -44,28 +61,46 @@ export function installSubagentsTask(): Task<InstallCtx> {
await purgeCursorSubAgentMCPEntries(ctx.projectPath);
}

// Drop MCP entries for any sub-agent that needs them gone — either
// because the agent itself was removed from the capabilities file, or
// because `toolExposure: 'none'` rescinds MCP wiring entirely.
const cleanupSet = new Set(agentsNeedingMcpCleanup);
for (const { agent_id } of removedAgents) {
step++;
task.output = `[${step}/${total}] removing ${agent_id}`;
await unregisterSubAgentMCPServer(ctx.projectPath, agent_id, providers);
removeSubAgentInstructions(ctx.projectPath, agent_id, providers);
ctx.db.removeSubAgent(ctx.projectId, agent_id);
cleanupSet.delete(agent_id);
}
for (const agent_id of cleanupSet) {
await unregisterSubAgentMCPServer(ctx.projectPath, agent_id, providers);
}

for (const subAgent of currentSubagents) {
step++;
task.output = `[${step}/${total}] ${subAgent.id}`;
const agentMcpUrl = `${ctx.serverStatus.url}/${ctx.projectId}/agents/${subAgent.id}/mcp`;
await registerSubAgentMCPServer(ctx.projectPath, subAgent.id, agentMcpUrl, providers);
if (!skipMcpWrites) {
const agentMcpUrl = `${ctx.serverStatus.url}/${ctx.projectId}/agents/${subAgent.id}/mcp`;
await registerSubAgentMCPServer(ctx.projectPath, subAgent.id, agentMcpUrl, providers);
}
// Instructions are still informative even without MCP wiring — they
// describe the agent's purpose. The agent file itself encodes the
// expected MCP server key, but missing entries simply mean those
// tools won't be available to that sub-agent (an explicit choice).
installSubAgentInstructions(ctx.projectPath, subAgent, ctx.capabilitiesToUse, providers);
ctx.db.upsertSubAgent(ctx.projectId, subAgent.id);
ctx.added++;
}

const installed = currentSubagents.length;
task.title = installed > 0
? `Installed ${installed} sub-agent${installed === 1 ? '' : 's'}`
: 'Sub-agents up to date';
if (skipMcpWrites && installed > 0) {
task.title = `Installed ${installed} sub-agent${installed === 1 ? '' : 's'} (no MCP wiring — toolExposure: none)`;
} else {
task.title = installed > 0
? `Installed ${installed} sub-agent${installed === 1 ? '' : 's'}`
: 'Sub-agents up to date';
}
},
};
}
14 changes: 13 additions & 1 deletion src/cli/commands/install-tasks/register-mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,22 @@ import type { InstallCtx } from './context';
export function registerMcpServerTask(): Task<InstallCtx> {
return {
title: 'Registering MCP server',
task: async (ctx) => {
task: async (ctx, task) => {
const providers = ctx.capabilitiesToUse.providers ?? ctx.resolvedProviders;
const toolExposure = ctx.capabilitiesToUse.options?.toolExposure;
const hasTools = ctx.capabilitiesToUse.tools.length > 0;
const hasSubagents = (ctx.capabilitiesToUse.subagents ?? []).length > 0;

// `toolExposure: 'none'` is an explicit opt-out from MCP wiring — the
// agent is expected to discover/run tools via `capa sh` instead. We
// still call `unregisterMCPServer` to clean up any entry left behind
// by a previous install under a different mode.
if (toolExposure === 'none') {
await unregisterMCPServer(ctx.projectPath, ctx.projectId, providers);
task.title = 'MCP registration skipped (toolExposure: none)';
return;
}

if (hasTools || hasSubagents) {
await registerMCPServer(ctx.projectPath, ctx.projectId, ctx.mcpUrl, providers);
} else {
Expand Down
Loading
Loading