mcp-connector-pattern
README.md
# mcp-connector-pattern
A small, complete reference MCP server for a fictional bike shop, **Northwind
Cycles**. It exists to show *how* we build MCP servers, not to sell bikes:
customers, inventory, orders, and outbound messages, backed by an in-memory
fake upstream API. Read this in five minutes to see the shape of the pattern;
read the source (it's short) to see it's real code, not a sketch.
## What this demonstrates
**1. One tool registry, two transports.** [`src/server.ts`](src/server.ts)
exports a single `createServer()` factory. [`src/transports/stdio.ts`](src/transports/stdio.ts)
and [`src/transports/http.ts`](src/transports/http.ts) both call it and hand
the result to a different SDK `Transport`. `MCP_TRANSPORT=stdio` or
`MCP_TRANSPORT=http` picks which one runs -- nothing about the tools changes.
[`tests/dual-transport.test.ts`](tests/dual-transport.test.ts) proves this by
spawning the real stdio process and a real HTTP server side by side and
asserting they list the identical tool set.
**2. Tool descriptions are the real interface.** Every tool description in
[`src/tools/`](src/tools/) spells out what it does, what it doesn't do, and
an example call -- see `draft_order` or `confirm_order` for the clearest
case. A vague description ("place an order") gives a model no way to tell
"draft it" from "commit it, charge the card, ship it" apart, and it will
still answer *confidently* with the wrong tool -- you find out from a wrong
result, not an exception. Precise descriptions are the cheapest fix
available, and they're free at runtime.
**3. Structured, not prose, responses.** Every tool declares an
`outputSchema` and returns matching `structuredContent` alongside a short
text summary (see [`src/lib/result.ts`](src/lib/result.ts)). A caller -- model
or code -- reads `result.structuredContent.order.status`, it doesn't parse a
sentence.
**4. Read/write separated, with a human-approval seam.** This is the load-bearing
idea. `draft_order` and `draft_customer_message` ([`src/tools/orders.ts`](src/tools/orders.ts),
[`src/tools/messages.ts`](src/tools/messages.ts)) only ever write to our own
draft state -- no stock is touched, nothing is sent. `confirm_order` and
`send_customer_message` are the *only* code paths that reach the outside
world (decrementing stock, dispatching a message), and each requires the
exact draft id from the step before. There is no single call that goes from
"customer wants 2 bikes" to "stock decremented" -- a human has to be in that
gap. [`tests/approval-seam.test.ts`](tests/approval-seam.test.ts) asserts
this directly: it drafts an order, checks inventory hasn't moved, confirms
it, and only then checks the stock changed.
**5. Every side-effect tool reports exactly what it changed.**
`confirm_order` returns `stockChanges: [{ unitId, before, after }]` for every
unit it touched; `send_customer_message` returns the `sentAt` timestamp. A
model relaying "done!" to a user is only as honest as what the tool actually
handed back -- so the tool hands back specifics, not a boolean.
**6. One audit line per call.** [`src/lib/audit.ts`](src/lib/audit.ts) wraps
every handler and writes `[audit] {tool, actor, args, ok, durationMs}` to
**stderr** on every call, success or failure. Never stdout -- on the stdio
transport stdout *is* the JSON-RPC channel, and one stray log line there
corrupts every message after it. `no-console` is enforced by lint in `src/`
(`console.error` only) so this can't regress silently.
**7. Secrets stay out of the repo.** [`.env.example`](.env.example) documents
every variable; `.env` is gitignored. The HTTP transport requires
`Authorization: Bearer <token>` on every request
([`src/lib/auth.ts`](src/lib/auth.ts), constant-time compare) and refuses to
start without `MCP_BEARER_TOKEN` set. [`src/lib/redact.ts`](src/lib/redact.ts)
masks anything shaped like a token/secret/password before it reaches a log
line -- see the audit output in the run below, where the bearer token shows
as `cu***23`.
**8. Registering as a custom connector** -- see below.
## What this deliberately does *not* do
Scope is the point. This is a pattern, not a starter kit:
- No database -- state is an in-memory array (`src/upstream/`) that resets
every restart. Swap that module for a real API client; nothing in
`src/tools/` has to change.
- No OAuth -- the HTTP transport uses one shared bearer token, not per-user
auth. Fine for a demo or an internal tool; a multi-tenant product needs
real auth in front of `/mcp`.
- No Docker, no CI, no MCP resources or prompts. Just the tool layer, two
transports, and tests that prove both actually run.
## Run it
```bash
npm install
MCP_TRANSPORT=stdio npm run start:stdio
```
That's the whole stdio path. In a second terminal, the same registry over HTTP:
```bash
cp .env.example .env # edit MCP_BEARER_TOKEN to a real random value
npm run start:http
```
```bash
curl -s -X POST http://localhost:8787/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Authorization: Bearer <your MCP_BEARER_TOKEN>' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
Requests without a valid bearer token get `401`; try the same `curl` without
the `Authorization` header to see it.
## Register as a custom connector
Both Claude.ai and Claude Desktop can add an MCP server as a **custom
connector** under Settings -> Connectors -> Add custom connector:
1. Run the server with `MCP_TRANSPORT=http` (above). For a connector reachable
from claude.ai (not just localhost), put a public HTTPS URL in front of
it -- a tunnel like `ngrok http 8787` is enough for a demo; a real
deployment needs a real host and TLS.
2. In "Add custom connector", set the URL to `https://<your-host>/mcp`.
3. If your client supports a custom header for the connector, set
`Authorization: Bearer <your MCP_BEARER_TOKEN>`. Clients that only support
OAuth will need a real OAuth flow in front of `/mcp` instead of the bearer
check here -- out of scope for this demo, in scope for a production build.
4. Save. The client calls `initialize`, then `tools/list`; you should see
all ten Northwind Cycles tools with their descriptions.
For a local-only client (Claude Desktop, or any stdio-based MCP host), point
it at `MCP_TRANSPORT=stdio node --import tsx src/index.ts` from this
directory instead -- no network, no token, same tools.
## Project layout
```
src/
server.ts single tool-registry factory (point 1)
index.ts entry point, picks a transport from MCP_TRANSPORT
transports/
stdio.ts StdioServerTransport
http.ts StreamableHTTPServerTransport + bearer auth
tools/
customers.ts read-only
inventory.ts read-only
orders.ts read/write split + approval seam (points 4, 5)
messages.ts read/write split + approval seam (points 4, 5)
lib/
audit.ts per-call audit logging to stderr (point 6)
redact.ts secret masking for log lines (point 7)
auth.ts bearer token check (point 7)
result.ts structuredContent + text summary helper (point 3)
upstream/
db.ts in-memory fake upstream data
client.ts async client over db.ts (swap this for a real API)
tests/
approval-seam.test.ts proves point 4
dual-transport.test.ts proves point 1
scripts/
smoke-stdio.ts manual end-to-end check over stdio
smoke-http.ts manual end-to-end check over HTTP
```
## Gates
```bash
npm run lint # eslint, zero warnings
npm run typecheck # tsc --noEmit, strict
npm test # vitest
```
## Stack
TypeScript, Node 22, ESM, `@modelcontextprotocol/sdk` 1.30, Zod 3, Vitest.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues