Skip to main content
Glama
README.md
# CatalogMCP

[![CI](https://github.com/Surferdude-AF/CatalogMCP/actions/workflows/ci.yml/badge.svg)](https://github.com/Surferdude-AF/CatalogMCP/actions/workflows/ci.yml)

Setting up a digital product catalog today is either slow (click through a UI for
every attribute and rule) or expensive (script it against an API, which turns labor
cost into engineering cost for every future change). This is a spike proving a third
option: an agent talking to a governed [MCP](https://modelcontextprotocol.io) tool
surface can replace both. A catalog admin describes what they want in plain
language — *"add a Weight attribute in kg, required for all Industrial-line
products"* — and an agent executes it through tools that preview, log, and gate
every change. Setup becomes a conversation instead of a UI marathon or an
engineering ticket.

## Demo

šŸŽ„ [**Watch the recorded demo**](https://github.com/Surferdude-AF/CatalogMCP/releases/download/v0.1.0/catalog-mcp-demo.mp4)
— screen recording of the exact run described below.

The scripted client in [`demo/client.ts`](demo/client.ts) drives this server through
a real Claude tool-use loop (Anthropic Messages API, no mocking) and walks through:

1. Browsing the empty catalog.
2. A natural-language request to set up the catalog from an attached file of
   products and their attribute values — including the schema (attributes, a
   required-for-industrial-line rule) the import depends on.
3. A dry-run preview surfacing two rows that fail validation, a back-and-forth to
   fix them, then a real commit of all five products.
4. Reading the audit log the commit produced.
5. Browsing the catalog again, now populated — closing the loop the same way it
   opened, via the agent, not a human refreshing a UI.
6. A destructive request — delete the `material` attribute — held for confirmation
   with a data-aware blast-radius disclosure (5 affected products, by name), then
   confirmed.
7. A closing beat: the same request attempted against a read-only instance, cleanly
   refused.

The full text transcript of that same run is in
[`demo/transcript.txt`](demo/transcript.txt) — reproducible yourself by running
`npm run demo`.

## What makes this MCP (not just an API)

[MCP](https://modelcontextprotocol.io) is a wire protocol — JSON-RPC 2.0 messages
over a transport (stdio here; HTTP/SSE elsewhere) — not a coding convention. A
small, fixed set of method names is standardized by the spec itself, so any
compliant client (Claude Desktop, this repo's own `demo/client.ts`, any other agent
framework) can talk to this server without knowing anything about catalogs,
attributes, or rules ahead of time.

### The protocol methods

| Method | Direction | Purpose |
|---|---|---|
| `initialize` | client → server | Handshake: exchange protocol versions, negotiate capabilities |
| `notifications/initialized` | client → server | Client confirms it's ready |
| `tools/list` | client → server | **Discovery** — "what can you do?" Returns every tool's name, description, and JSON Schema input shape |
| `tools/call` | client → server | **Invocation** — "do this one, with these arguments" |

That's the entire surface an agent needs to know in advance. There's no
`POST /create_attribute` endpoint — an agent calls `tools/call` with
`{"name": "create_attribute", "arguments": {...}}`. A trimmed real exchange with
this server:

```
// → client
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05", ...}}

// ← server
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"catalog-mcp","version":"0.1.0"}}}

// → client
{"jsonrpc":"2.0","id":2,"method":"tools/list"}

// ← server — every tool below, as name + description + JSON Schema
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"list_attributes", ...}, ...]}}
```

### How it works in this codebase

We never wrote a `tools/list` or `tools/call` handler. Every call to
`server.registerTool(name, config, handler)` in [`src/tools/`](src/tools/) appends
to an internal registry inside `@modelcontextprotocol/sdk`'s `McpServer`. The SDK
answers `tools/list` by dumping that registry (converting our Zod schemas to JSON
Schema along the way) and answers `tools/call` by looking the requested name up in
the same registry and invoking its handler — none of that is our code. It's also
why the governance layer ([`src/governance/governed-registration.ts`](src/governance/governed-registration.ts))
wraps `registerTool` itself rather than hooking into some HTTP middleware layer:
that single call is the only seam every tool invocation is guaranteed to pass
through.

### The tools

12 tools, grouped by what they operate on. Every mutating tool accepts
`dryRun: true` for a no-persist preview; full argument shapes are in
[`src/tools/`](src/tools/).

**Schema** (catalog structure — attributes, rules)

| Tool | Args | Mutating? |
|---|---|---|
| `list_attributes` | — | no |
| `get_attribute` | `name` | no |
| `create_attribute` | `name, type, enumValues?, unit?, dryRun?` | yes |
| `update_attribute` | `name, type?, enumValues?, unit?, dryRun?` | yes |
| `delete_attribute` | `name, confirmToken?` | yes — destructive, two-phase |
| `list_rules` | — | no |
| `create_rule` | `attributeName, productLines, dryRun?` | yes |

**Content** (actual products)

| Tool | Args | Mutating? |
|---|---|---|
| `list_products` | — | no |
| `get_product` | `sku` | no |
| `import_products` | `rows, dryRun?` | yes — bulk, upsert, partial-commit |
| `set_attribute_value` | `sku, attributeName, value, dryRun?` | yes |

**Governance**

| Tool | Args | Mutating? |
|---|---|---|
| `list_audit_log` | — | no |

## Why the guardrails aren't optional

Nobody hands an ungoverned agent write access to a live product catalog. The
preview/audit/approval layer isn't a safety feature bolted on afterward — it's the
precondition that makes "just describe what you want" viable for something you can't
afford to get wrong. Concretely, this server never lets a mutation happen blind:

- **Dry-run preview** — every mutating tool accepts `dryRun: true` and returns the
  exact outcome without persisting anything.
- **Audit log** — every tool call is recorded (timestamp, tool, args, actor,
  outcome) and readable via its own tool, not just claimed to exist.
- **Two-phase confirm on destructive ops** — deleting an attribute first returns a
  blast-radius disclosure (a count *and* a sample of affected products) and a
  token; only a second call with that token executes, and if the catalog changed
  in between, the stale token is refused rather than honored.
- **Policy mode** — the server can run fully read-only, refusing every mutating
  tool at the gate, for a locked-down deployment.

See [`CONTEXT.md`](CONTEXT.md) for the domain glossary and [`CLAUDE.md`](CLAUDE.md)
for the full scope, tool surface, and explicit non-goals.

## Setup

```
npm install
npm test     # domain, tool, and governance tests, plus the 6-step demo acceptance test
npm run dev  # start the MCP server over stdio (add --read-only to lock it down)
npm run demo # run the scripted Claude-driven demo client (needs ANTHROPIC_API_KEY)
```