ADR 0007: MCP Catalog Shape
| Field | Value |
|---|---|
| Status | Accepted |
| Date | Pre-alpha |
| Deciders | Project maintainers |
| Supersedes | — |
| Superseded by | — |
| Related ADRs | 0001 (Runtime+language), 0005 (Adapter shape), 0006 (Stack-pack shape) |
Summary
The MCP catalog is a TypeScript const array in @aidokit/mcp-catalog, not a plugin-discoverable registry. Triggers use a restricted predicate grammar with OR-only semantics — no negation, no AND, no boolean expressions. Each entry declares per-adapter install specs inline in the same entry rather than splitting across adapter-specific catalogs. A securitySensitive: true flag enforces hard, non-bypassable confirmation gates that even --yes cannot override. Entries are never removed, only marked deprecated. Stack packs reference catalog ids only — they cannot define MCPs inline.
This ADR captures the shape; the content (entries, full type definitions) lives in docs/specs/mcp-catalog.md.
Context
The MCP catalog sits at the intersection of four concerns:
-
Discoverability — users need to find useful MCPs without manually searching the MCP ecosystem. A typical user knows they want "docs lookup" or "browser automation" but doesn't know which MCP server provides it.
-
Curation — every catalog entry has been vetted for triggers, role-scoping, and security implications. The catalog is a quality gate.
-
Per-adapter portability — the same MCP (e.g., Context7) installs differently on Claude Code (
claude mcp add ...) vs Codex (config-file mutation). The catalog must serve both. -
Security model — some MCPs (filesystem write, shell execution) have elevated privileges. The catalog must surface this clearly and gate installation.
The tension is between openness (anyone should be able to add an MCP) and safety (the catalog is a trust signal users rely on). A plugin-based catalog with runtime registration is maximally open but provides zero trust. A locked catalog managed only by core maintainers is maximally safe but creates a bottleneck.
Beyond these primary concerns, there's a secondary pressure: stack pack ↔ catalog interaction. Stack packs reference MCPs via id strings (per ADR 0006). The catalog must be the only authoritative source of MCP definitions — if packs could define MCPs inline, the ecosystem fragments.
Finally, this is a decision that's hard to reverse. Once users have projects with state.json recording installed MCPs, changing the catalog model (e.g., shifting from data-driven to plugin-based) breaks every installed project. The shape needs to be right from v0.1.
Decision
1. The catalog is data, not a plugin registry
// packages/mcp-catalog/src/catalog.ts
export const MCP_CATALOG: MCPDef[] = [
{ id: 'context7' /* ... */ },
{ id: 'beads-mcp' /* ... */ },
// ...
];
The catalog ships as a hard-coded TypeScript const array. There is no plugin discovery, no runtime registration, no dynamic loading. The catalog at runtime equals the catalog at build time.
Adding an MCP is a single-file edit (one entry in the array) plus a PR. The PR is the curation gate.
2. Catalog data is in TypeScript, not YAML or JSON
The catalog file is a .ts module exporting a typed const. Not catalog.yaml, not catalog.json.
Reasons:
- Type checking on every entry at build time — wrong fields fail CI
- Inline comments explaining triggers, security decisions
- Multi-line strings for install commands without YAML escaping pain
- Code-aware tooling (Find references, refactor, jump-to-definition)
- Single source of truth with the
MCPDeftype definition
3. Triggers use a restricted predicate grammar
triggers: ['always'];
triggers: ['stack.hasFrontend', 'detect.githubRemote'];
triggers: ['beadsEnabled'];
triggers: ['never'];
The full grammar is small (~14 forms; see docs/specs/mcp-catalog.md) and the evaluator is ~50 lines of pseudocode in spec Appendix A.
Triggers are NOT a JavaScript expression. There is no eval, no template, no function reference. Just a string against a closed set of patterns.
4. Triggers compose with OR semantics; no AND, no negation, no boolean operators
// OR across array — suggest if any trigger matches
triggers: ['stack.has(react)', 'detect.githubRemote'];
// NOT supported:
// triggers: ['stack.has(react) && !stack.has(vue)']
// triggers: ['or(a, and(b, not(c)))']
If you need an AND condition, you split into two catalog entries. If you can't express the requirement without negation, you reconsider the trigger.
5. Per-entry role-scoping via suggestedFor
Each catalog entry names the roles for which the MCP is appropriate by default:
{
id: 'playwright',
suggestedFor: ['tester-reviewer', 'frontend-browser-tester'],
// ...
}
The CLI scopes the MCP to exactly those roles on install. Users can override per-install via --roles. The default is never "all roles."
6. securitySensitive: true is a hard, non-bypassable gate
When set, the flag forces:
- Not auto-suggested — excluded from
aidokit initMCP suggestion list even if triggers match - Explicit confirmation on
aido mcp add— even with--yes, the user must confirm separately - Disclosure of capability — the confirmation prompt names what the MCP can do
- Documented in
purpose— the catalog's purpose field mentions the elevated capability - Stack packs MUST NOT suggest sensitive MCPs — per
stack-pack-contract.md§12.3 - Audit log entry with
userConfirmed: true—state.jsonrecords the confirmation
--yes overrides routine prompts but cannot override security-sensitive confirmations. This is a CLI invariant.
7. Entries are never removed; only deprecated
Once published, a catalog entry stays in the catalog indefinitely. To retire an MCP:
{
id: 'old-mcp',
deprecated: true,
replacedBy: 'new-mcp',
// ... rest of entry unchanged
}
The CLI excludes deprecated entries from suggestion lists; aidokit doctor warns projects still using them.
8. Per-adapter install specs unified in a single entry
{
id: 'context7',
install: {
'claude-code': { command: 'claude mcp add context7 -- npx -y @upstash/context7-mcp', ... },
'codex': { configBlock: { /* TOML snippet */ }, ... },
},
// ...
}
One entry. One id. One trigger set. One security flag. Multiple install methods, keyed by adapter name. We do NOT split into per-adapter catalogs.
9. Custom (non-catalog) MCPs are always treated as security-sensitive
aido mcp add <id> --custom-url <url> is the escape hatch for MCPs not in the catalog. Custom MCPs:
- Are treated as
securitySensitive: trueregardless of actual risk - Require explicit user confirmation including capability disclosure
- Are flagged as
source: 'custom'instate.json - Cannot be referenced by stack packs (stack packs use catalog ids only)
- May trigger an
aidokit doctorwarning depending on configuration
10. The catalog evolves via PR against @aidokit/mcp-catalog, not via runtime
To add an entry: open a PR adding a single object to MCP_CATALOG. Reviewers check the entry against the checklist in docs/specs/mcp-catalog.md Appendix B. On merge, the entry ships in the next minor release of @aidokit/mcp-catalog.
This is slower than runtime registration. It's also a quality gate.
Consequences
Positive
- Curation as a quality signal. Every catalog entry has been reviewed for triggers, role-scoping, and security flag accuracy. Users can trust that suggestions are sensible.
- Type safety on every entry. Wrong shape, missing fields, or invalid trigger strings fail at TypeScript compile time. CI catches catalog bugs before publish.
- Trigger evaluator is ~50 lines and trivially auditable. A reviewer can read the entire trigger semantics in five minutes. No corner-case logic hidden in plugin code.
- OR-only semantics force simple entries. Pack authors and catalog reviewers can't construct complex conditions. If a condition can't be expressed simply, the entry needs splitting.
- Per-entry role-scoping prevents capability creep. No "default to all roles" anywhere in the system. Every MCP install scopes to the right roles by construction.
- Hard security gates are unbypassable.
--yesis for routine convenience; security-sensitive MCPs always require explicit confirmation. The audit log records every confirmation. - No removal preserves user trust. A user who installed
old-mcptwo years ago can still inspect what they installed; theirstate.jsonreferences remain valid. - Unified per-entry install specs avoid fragmentation. A new adapter adds its install spec to existing entries via PR; the entry's identity stays stable.
- Stack pack ↔ catalog interaction is unambiguous. Stack packs reference ids. The catalog defines the ids. Single direction, no cycles.
- Custom MCP escape hatch exists but has friction. Power users can install anything; the friction (
--custom-url, security confirmation) discourages casual use of untrusted MCPs.
Negative
- PR friction for adding entries. A community member who wants to contribute an MCP must open a PR and wait for review. Faster paths (runtime registration) are blocked. Mitigated: PR review is a small cost compared to ecosystem fragmentation if anyone could register anything.
- Catalog ships with
aidokitCLI version. Users onaidokitv0.1 can't see MCPs added in v0.3 catalog without upgrading. Mitigated: catalog updates ship in@aidokit/mcp-catalogpatches; updating one package is cheap. - Restricted trigger grammar may not cover edge cases. A future case might want a condition the grammar can't express. Mitigated: split into multiple entries (one trigger per entry) covers most cases.
never-triggered entries clutter the catalog. Filesystem MCP exists in the catalog but never auto-suggests. Mitigated:aido mcp suggest --verboseshows evennever-triggered entries;aido mcp addaccepts them by id.- Catalog size grows over time. With no removal, the catalog accumulates entries indefinitely. Mitigated: deprecated entries excluded from suggestion lists; no runtime cost.
Neutral
- Custom MCP flow exists but is rarely used. Power users have an escape; most users stay on catalog entries. Both are valid.
- Catalog updates require
aidokitusers to update their@aidokit/mcp-catalog. Standard npm dep update; pnpm makes this fast.
Alternatives considered
Alternative 1: Plugin-based catalog (runtime registration)
Adapters, stack packs, or third-party packages could register MCPs at runtime via a plugin API.
Pros
- Maximally open ecosystem — anyone can add an MCP
- No PR bottleneck
- Decentralized
Cons
- Zero curation — bad entries proliferate
- Trigger evaluation becomes plugin code (security risk; complexity creep)
- Conflicting entries (two plugins both register
github) require runtime conflict resolution - Stack pack ↔ catalog interaction becomes circular (packs register MCPs that other packs reference)
- Trust signals collapse — users can't tell which entries are curated
- Discovery breaks — users can't browse what's available without running detection
Reason rejected: the catalog's primary value is curation. Removing curation removes the value.
Alternative 2: Full JavaScript expressions for triggers
triggers: [(ctx) => ctx.stack.includes('react') && !ctx.stack.includes('vue')];
Pros
- Maximum expressiveness
- No restriction on what conditions can express
- Familiar to developers
Cons
- Trigger evaluation becomes code execution — security concerns
- Cannot safely evaluate triggers from a catalog the user hasn't audited
- Defeats the entire data-driven model
- Pack authors will write triggers that depend on undocumented
ctxfields, then break when those fields change - Static analysis (linting, code search) cannot inspect what a function-based trigger actually checks
Reason rejected: the restricted grammar is the safety story. Function triggers make the catalog as dangerous as a plugin system.
Alternative 3: AND semantics or boolean expressions (DSL)
A small DSL: "stack.has(react) && detect.githubRemote".
Pros
- More expressive than OR-only
- Still inspectable (it's a string, not a function)
Cons
- Parser complexity (precedence, parens, AST)
- Test surface explodes (every boolean combination needs a test)
- Catalog reviewers must learn DSL semantics
- Hard cases push toward "just one more operator" until the DSL becomes a programming language
- OR-only forces atomic entries, which are easier to reason about
Reason rejected: the grammar should stay tiny. If you need AND, split the entry. The test surface and reviewer cognitive load aren't worth the gain.
Alternative 4: Per-adapter separate catalogs
Each adapter ships its own MCP_CATALOG with adapter-specific entries.
Pros
- Adapters own their MCP integration completely
- No cross-adapter coordination needed
Cons
- The same MCP (Context7) has different entries in different catalogs — divergence guaranteed
- Stack packs can't reference MCPs portably (which catalog?)
- Users see different MCPs depending on which adapter they pick — confusing
- Trigger evaluation differs across adapters
- Security flags can diverge — an MCP marked sensitive in one adapter's catalog but not another's
Reason rejected: one MCP = one entry = one set of decisions about it. The install method varies by adapter; the identity does not.
Alternative 5: Allowing entry removal
Catalog entries can be removed cleanly via deprecation + version bump.
Pros
- Catalog stays smaller over time
- Cleaner UX for browsing
- No "zombie entries" cluttering the catalog
Cons
- Breaks user trust: "I installed
old-mcplast year; now mystate.jsonreferences a nonexistent id" - Forces every user to migrate on every removal
- Requires a removal-migration script per removed entry
- Versioning becomes more complex (removed-in-X.Y, replaced-by, etc.)
- Encourages catalog churn
Reason rejected: the catalog is a permanent record. Users' state.json files outlive any single catalog version. Stability matters more than tidiness.
Alternative 6: YAML/JSON catalog instead of TypeScript
- id: context7
triggers: [always]
install:
claude-code:
command: claude mcp add context7 -- npx -y @upstash/context7-mcp
Pros
- Slightly easier for non-TS users to read
- More portable across languages
Cons
- No type checking on entries — wrong fields ship without warning
- Multi-line strings (TOML install blocks for Codex) become escape-hell
- No inline comments at the language level
- Worse tooling (no jump-to-definition, no refactor)
- Loses the single-source-of-truth pairing of type + data
Reason rejected: TypeScript's type checking on every entry is the safety net. YAML/JSON doesn't pay rent.
Alternative 7: No security-sensitive flag (treat all MCPs equally)
Drop the flag; rely on per-MCP descriptions and user judgment.
Pros
- Simpler model
- Less code in the CLI
Cons
- Users have no programmatic signal which MCPs are risky
--yeswould bypass confirmation for filesystem MCPs alongside docs MCPs — terrible default- Stack packs would have no rule to follow ("don't auto-suggest sensitive MCPs")
- Audit log loses the
userConfirmeddistinction - All MCPs become "click through" — security fatigue
Reason rejected: the flag exists specifically to gate --yes automation. Without it, automation becomes unsafe.
Alternative 8: Stack packs can define MCPs inline (decentralized)
Stack packs ship their own MCP definitions; the catalog becomes optional.
Pros
- Decentralized — packs don't need catalog updates
- Faster iteration for niche stack-specific MCPs
Cons
- Ten packs invent ten different "Django MCP" entries with subtly different install commands
- Security flags diverge across packs
- No curation
- Catalog becomes one source among many — users can't trust the catalog as authoritative
- Same problem as Alternative 1, scoped to stack packs
Reason rejected: ADR 0006 already rejects this from the stack-pack side. This ADR rejects it from the catalog side.
Implementation notes
Catalog data file
// packages/mcp-catalog/src/catalog.ts
import type { MCPDef } from '@aidokit/core';
export const MCP_CATALOG: MCPDef[] = [
{
id: 'context7',
name: 'Context7',
purpose: 'Up-to-date library documentation lookup for agents.',
suggestedFor: ['researcher', 'architect'],
triggers: ['always'],
securitySensitive: false,
install: {
'claude-code': {
command: 'claude mcp add context7 -- npx -y @upstash/context7-mcp',
requiresNetwork: true,
minCliVersion: '2.1.32',
},
codex: {
configBlock: {
target: '~/.codex/config.toml',
content:
'[mcp_servers.context7]\ncommand = "npx"\nargs = ["-y", "@upstash/context7-mcp"]',
mode: 'merge',
},
requiresNetwork: true,
},
},
homepage: 'https://github.com/upstash/context7',
tags: ['docs', 'research', 'libraries'],
},
// ... more entries
] as const;
The as const enforces literal types; TypeScript catches typos in trigger strings at compile time (since the type union is finite).
Adding a new entry — workflow
- Author opens PR against
@aidokit/mcp-catalog/src/catalog.tsadding one object - PR checklist (per
mcp-catalog.mdAppendix B): - [ ] Run the MCP locally; verify it works
- [ ] Identify
suggestedForroles (no "all roles") - [ ] Decide triggers honestly
- [ ] Mark
securitySensitiveaccurately - [ ] Install specs for at least one adapter
- [ ] Trigger evaluation tests pass
- [ ] Documented in
mcp-catalog.md§11 - Reviewer (core maintainer or designated catalog steward) approves
- Merge → catalog releases in next minor version
Trigger evaluator (~50 lines)
Located at packages/mcp-catalog/src/triggers.ts:
import type { ProjectContext } from '@aidokit/core';
import { FRONTEND_STACKS, BACKEND_STACKS } from './stack-sets.js';
export function evaluateTrigger(trigger: string, ctx: ProjectContext): boolean {
// Universal
if (trigger === 'always') return true;
if (trigger === 'never') return false;
// Context flags
if (trigger === 'beadsEnabled') return ctx.beadsEnabled;
if (trigger === 'brownfield') return ctx.brownfield;
// Stack predicates
if (trigger === 'stack.hasFrontend')
return ctx.selectedStackPacks.some((id) => FRONTEND_STACKS.has(id));
if (trigger === 'stack.hasBackend')
return ctx.selectedStackPacks.some((id) => BACKEND_STACKS.has(id));
let m = trigger.match(/^stack\.has\((.+)\)$/);
if (m) return ctx.selectedStackPacks.includes(m[1]);
m = trigger.match(/^stack\.language\((.+)\)$/);
if (m) return ctx.stack.languages.includes(m[1]);
// Remote detection
if (trigger === 'detect.githubRemote') return ctx.gitRemote?.includes('github.com') ?? false;
if (trigger === 'detect.gitlabRemote') return ctx.gitRemote?.includes('gitlab.com') ?? false;
if (trigger === 'detect.bitbucketRemote')
return ctx.gitRemote?.includes('bitbucket.org') ?? false;
// OS
if (trigger === 'os.darwin') return ctx.os === 'darwin';
if (trigger === 'os.linux') return ctx.os === 'linux';
if (trigger === 'os.win32') return ctx.os === 'win32';
// Conformance
if (trigger.startsWith('conformance.')) {
return ctx.conformance === trigger.split('.')[1];
}
// Unknown trigger — fail closed
console.warn(`Unknown trigger: ${trigger}`);
return false;
}
export function shouldSuggest(mcp: MCPDef, ctx: ProjectContext): boolean {
if (mcp.deprecated) return false;
if (mcp.securitySensitive) return false;
return mcp.triggers.some((t) => evaluateTrigger(t, ctx));
}
Note: shouldSuggest returns false for securitySensitive MCPs even when triggers match. The flag is checked first.
Per-adapter install resolution
export function resolveInstallSpec(mcp: MCPDef, adapterName: string): MCPInstallSpec {
const spec = mcp.install[adapterName];
if (!spec) {
throw new AidoError(
'MCP_NO_INSTALL_FOR_ADAPTER',
`MCP '${mcp.id}' has no install spec for adapter '${adapterName}'`,
{ mcpId: mcp.id, adapterName },
'Suggest opening an issue against @aidokit/mcp-catalog.',
);
}
return spec;
}
Custom MCP flow
async function addCustomMcp(id: string, url: string, ctx: ProjectContext) {
// Always require explicit confirmation, regardless of --yes
const confirmed = await prompt({
message: `Install custom MCP '${id}' from ${url}? This MCP is not in the catalog and is treated as security-sensitive.`,
type: 'confirm',
bypassYes: true, // <-- key: --yes does NOT bypass this
});
if (!confirmed) {
throw new AidoError('USER_CANCELLED', 'Custom MCP installation declined');
}
// Treat as a synthetic catalog entry
const synthMcp: MCPDef = {
id,
name: id,
purpose: '(custom)',
suggestedFor: [],
triggers: ['never'],
securitySensitive: true,
install: {
[ctx.adapterName]: { command: `<derived-from-url>`, requiresNetwork: true },
},
};
// Call adapter's installMCP with the synth entry
const cmds = adapter.installMCP(synthMcp, [], ctx);
await runCommands(cmds);
// Record in state.json with custom flag
recordInstall({ ...synthMcp, source: 'custom', userConfirmed: true });
}
Validation tests for the catalog
Each catalog entry MUST be covered by:
- Schema test: entry matches
MCPDeftype - Trigger test: at least one trigger evaluates predictably (per test fixtures)
- Install test (per adapter): the install spec produces a runnable command/config block
- Security test: if
securitySensitive: true, it's excluded fromshouldSuggest
These run as part of pnpm test in @aidokit/mcp-catalog. CI fails if any catalog entry fails its tests.
Validation
This decision is validated when:
- [ ] The v0.1 catalog (Context7, Beads MCP, Playwright) ships and works on Claude Code (Phase 7)
- [ ] Adding a new MCP requires only a single-file edit + PR (no other coordination needed)
- [ ] Trigger evaluator passes 100% of test cases in
mcp-catalog.mdAppendix A.2 - [ ]
securitySensitive: trueflag successfully blocks auto-suggestion inaidokit initforfilesystemMCP (Phase 7) - [ ]
--yesdoes NOT bypass confirmation for the filesystem MCP (verified in integration test) - [ ] At least one community-contributed MCP entry is merged via PR by v1.0 (signal that the contribution flow works)
- [ ] Two adapters (Claude Code, Codex) share the same catalog without per-adapter divergence (v1.0)
- [ ] No entry has been removed from the catalog between v0.1 and v1.0 (deprecation only)
References
docs/specs/mcp-catalog.md— the catalog itself (entries, full schema)docs/specs/adapter-contract.md§7.8 —installMCPmethod consuming catalog install specsdocs/specs/stack-pack-contract.md§12 — Stack-pack MCP suggestion rulesdocs/specs/cli-reference.md§7.6 —aidokit mcpcommand surfaceARCHITECTURE.md§12 — MCP catalog model in system context- ADR 0001 — Runtime + language (Node.js + TypeScript)
- ADR 0005 — Adapter shape
- ADR 0006 — Stack-pack shape
External
- Model Context Protocol — https://modelcontextprotocol.io
- "Curation vs Decentralization in Package Ecosystems" — various
- "Fail Closed by Default" — security engineering principle