Meridian Bank MCP Server
by saivarmadpr
README.md
# Meridian Bank MCP Server
A production-grade remote **Model Context Protocol (MCP)** server for a fictional digital
bank ("Meridian"). It exposes the tools an AI ops/support copilot would use to service
customers, spanning the full risk spectrum from read-only lookups to money movement and
destructive admin actions.
It is a realistic customer artifact built to validate [LLM Shield](https://github.com/sundi133/llm-shield)'s
guardrails + agentic IDP. It stands on its own as a real OAuth 2.1-protected MCP server.
- **Transport:** Streamable HTTP (`POST /mcp`), stateless.
- **Auth:** OAuth 2.1 — the server is a **resource server** (RFC 9728 protected-resource
metadata, RFC 8707 audience-bound tokens) with a **co-located minimal authorization
server** (RFC 8414 metadata, PKCE S256, authorization_code + refresh_token +
client_credentials, Ed25519-signed JWTs). Conforms to the MCP 2025-11-25 auth spec.
- **Storage:** PostgreSQL via Drizzle ORM, seeded with a realistic dataset.
- **SDK:** `@modelcontextprotocol/sdk` v1.x (TypeScript).
See [`docs/specs`](docs/specs) and [`docs/plans`](docs/plans) for the design and plan.
---
## Tool catalog
| Tool | Risk | Required scope |
|---|---|---|
| `search_customers`, `get_customer`, `list_accounts`, `get_account`, `get_balance`, `list_transactions`, `get_transaction`, `list_cards` | read (PII in output) | `banking:read` |
| `freeze_card`, `unfreeze_card`, `freeze_account`, `unfreeze_account`, `create_payee`, `open_dispute` | reversible write | `banking:write` |
| `create_transfer`, `initiate_ach_payment` | money movement | `payments:write` |
| `initiate_wire` | money movement | `payments:write` **and** `wire:write` |
| `reverse_transaction`, `close_account`, `adjust_balance` | destructive / admin | `admin:write` |
Money movement is atomic and idempotent (row-locked source, unique `idempotency_key`,
no negative balances). Every mutation writes an `audit_log` row. Insufficient scope is
returned as an **MCP tool error** (`isError`), not an HTTP 403, because Streamable HTTP
multiplexes all tool calls over one `POST /mcp`.
---
## Run locally
Prerequisites: Node 22+, Docker (for Postgres).
```bash
# 1. Postgres
docker run -d --name meridian-pg -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=meridian -p 5433:5432 postgres:16
# 2. Env
cp .env.example .env.local # edit if needed; SEED_ON_BOOT=true for demo data
# 3. Install + run (migrates, seeds, listens on :8080)
npm install
npm run dev # or: npm run build && npm start
```
Scripts: `npm run build`, `npm start`, `npm run dev`, `npm test`, `npm run db:generate`,
`npm run db:migrate`, `npm run db:seed`.
### Dev operator credentials (seeded; NOT for production)
| Username | Password | Role |
|---|---|---|
| `alice.admin` | `meridian-dev-1` | admin |
| `bob.ops` | `meridian-dev-1` | ops |
| `carol.support` | `meridian-dev-1` | support |
---
## Try it
**Machine-to-machine (client_credentials):**
```bash
B=http://localhost:8080
TOKEN=$(curl -s -X POST $B/token \
-d grant_type=client_credentials \
-d client_id=meridian-copilot -d client_secret=dev-client-secret-change-me \
-d 'scope=banking:read payments:write' -d resource=$B/mcp | jq -r .access_token)
# discovery
curl -s $B/.well-known/oauth-protected-resource/mcp | jq
curl -s $B/.well-known/oauth-authorization-server | jq
# unauthorized → 401 + WWW-Authenticate
curl -i -X POST $B/mcp -H 'content-type: application/json' -d '{}'
```
**Full end-to-end smoke (MCP handshake, scope enforcement, transfer, idempotency):**
```bash
OAUTH_CLIENT_SECRET=dev-client-secret-change-me npx tsx scripts/mcp-smoke.ts
npx tsx scripts/authcode-smoke.ts # browser-style authorization_code + PKCE
```
**MCP Inspector** (interactive): point it at `http://localhost:8080/mcp` (Streamable HTTP).
It will run OAuth discovery; complete the operator login/consent with a dev credential above.
---
## Deploy to Railway
The server is deploy-ready (Dockerfile + `railway.json`, `/healthz` health check).
```bash
railway login
railway init # or link an existing project
railway add -d postgres # provision managed Postgres
# In the service Variables, set:
# DATABASE_URL = ${{Postgres.DATABASE_URL}}
# PUBLIC_URL = https://<your-service>.up.railway.app (set after the domain exists)
# AUTH_SIGNING_KEY = <a stable Ed25519 private JWK> (so tokens survive restarts)
# COOKIE_SECRET, OAUTH_CLIENT_SECRET = <secrets>
# SEED_ON_BOOT = true (first deploy, to seed demo data)
railway up # build + deploy from the Dockerfile
railway domain # generate the public URL
```
The MCP endpoint is then `https://<your-service>.up.railway.app/mcp`. Set `PUBLIC_URL` to
that origin so the token audience and metadata are correct, then redeploy.
> Note: `railway.com/mcp` is Railway's own site; your server lives at your Railway-assigned
> domain (or a custom domain you attach), not on `railway.com` itself.
---
## Putting LLM Shield in front (later)
Meridian is designed to sit behind Shield's MCP proxy (a Policy Enforcement Point). When
that phase starts:
- Isolate Meridian on the network so Shield's proxy is the only path to it.
- `AUTH_ISSUER` is env-driven, so Shield's OAuth authorization server can replace the
co-located AS without code changes.
---
## Configuration
See [`.env.example`](.env.example) for all variables. Key ones: `PUBLIC_URL` (drives the
token audience `PUBLIC_URL/mcp`), `AUTH_ISSUER`, `AUTH_SIGNING_KEY`, `DATABASE_URL`,
`OAUTH_CLIENT_*`, `SEED_ON_BOOT`.
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues