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:

  1. 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.

  2. Curation — every catalog entry has been vetted for triggers, role-scoping, and security implications. The catalog is a quality gate.

  3. 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.

  4. 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:

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:

--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:

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

Negative

Neutral


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

Cons

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

Cons

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

Cons

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

Cons

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

Cons

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

Cons

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

Cons

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

Cons

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

  1. Author opens PR against @aidokit/mcp-catalog/src/catalog.ts adding one object
  2. PR checklist (per mcp-catalog.md Appendix B):
  3. [ ] Run the MCP locally; verify it works
  4. [ ] Identify suggestedFor roles (no "all roles")
  5. [ ] Decide triggers honestly
  6. [ ] Mark securitySensitive accurately
  7. [ ] Install specs for at least one adapter
  8. [ ] Trigger evaluation tests pass
  9. [ ] Documented in mcp-catalog.md §11
  10. Reviewer (core maintainer or designated catalog steward) approves
  11. 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:

  1. Schema test: entry matches MCPDef type
  2. Trigger test: at least one trigger evaluates predictably (per test fixtures)
  3. Install test (per adapter): the install spec produces a runnable command/config block
  4. Security test: if securitySensitive: true, it's excluded from shouldSuggest

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:


References

External