MCP Gateway
Enables authorized Energi-Up staff to query KLIP data in natural language through Claude, strictly read-only, without opening the application.
README
MCP Gateway — Phase 1 (KLIP, read-only)
A single secure service that lets authorized Energi-Up staff query KLIP in natural language through Claude, without opening the application. Strictly read-only.
Deployment target (PRD Q3, now closed): <gateway-hostname> -> <gateway-public-ip>
(ECS-MCP, ap-southeast-5). Note the hostname is mcp-gw, not the mcp.example.com
the v0.9 documents assumed; the PRD/TSD should be updated to match.
Documents: PRD v0.9 · TSD v0.9 · Implementation guide · Design review · Deployment runbook
1. Pinned versions (T-1)
The MCP spec revision and SDK version are pinned here. Where the SDK's own docs disagree with the design documents, the SDK wins — it defines the exact API.
| Component | Pinned | Notes |
|---|---|---|
@modelcontextprotocol/sdk |
1.30.0 (exact, no caret) | Published 27 Jul 2026. Supplies the OAuth AS, the Streamable HTTP transport and requireBearerAuth. |
| MCP protocol revision | 2025-11-25 implemented; wire shape compatible with 2025-06-18 clients |
Revision 2026-07-28 (draft) removed protocol-level sessions, the GET stream and Last-Event-ID. This server is stateless, so it is forward-compatible with that change and answers 405 to legacy GET/DELETE on /mcp. |
| Node.js | 22 LTS (node:22-slim) |
|
| TypeScript | 5.9.3, strict + exactOptionalPropertyTypes |
|
| express | 5.2.1 | Raised from the TSD's 4.x: the SDK depends on express@^5.2.1, and mounting an express-5 router into an express-4 app mixes path-to-regexp majors. |
| zod | 4.4.3 | Raised from the TSD's 3.x: the SDK's type definitions target zod 4. Note .default() now supplies the output type. |
| jose | 6.2.9 | RS256 gateway tokens. |
@node-rs/argon2 |
2.1.0 | Substituted for argon2: prebuilt binaries, so no node-gyp toolchain in node:22-slim. |
| axios | 1.19.0 | KLIP client, behind the method guard. |
| pg | 8.23.0 | |
| PostgreSQL | 16 (container) | |
| nginx | nginx.org mainline | Config in deploy/nginx/mcp.conf. |
2. Layout
src/
core/ config, logger, db, audit, cache, rateLimit, semaphore, migrate, errors
adapters/klip/ routes(APPENDIX A) · fields(APPENDIX A) · client(guard) · session · paginate · normalize
tools/klip/ 9 tool definitions + shared parameter plumbing
mcp/ server, envelope, runner
auth/ keys, hub(OIDC RP), users, clients, tokens, provider, loginPage
http/ app, consent(Hub + break-glass), health, origin, clientIp
migrations/ idempotent SQL (001 schema, 002 Hub OIDC)
deploy/ nginx config, backup sidecar
test/ 120 tests + mock KLIP and mock Hub fixtures
Layering rule (T-3): tools → adapters → core. Nothing imports http/ except the
entrypoint. All business normalization lives in adapters/klip/normalize.ts, which
imports nothing, so it is unit-testable in isolation.
3. Authentication — Downstream Hub OIDC
Pilot users sign in with Downstream Hub (OIDC). The gateway remains the
authorization server that Claude talks to; the Hub is one step inside its own
/authorize flow.
We deliberately do not use the SDK's ProxyOAuthServerProvider. Proxying would
hand Claude a Hub token, which would break the RFC 8707 audience binding, give
Claude broader Hub scopes than klip:read, and move token issuance out of our
control so the S8 kill switch could no longer invalidate live sessions.
Claude ──/authorize──▶ gateway ──302──▶ Downstream Hub ──302──▶ /authorize/hub/callback
│ │
│ validate id_token (sig/iss/aud/nonce) │
│ check the pilot ALLOWLIST │
◀──────────────────────────────────────────────┘
└──302 code──▶ Claude ──/token──▶ gateway token (klip:read)
Authentication is not authorization. The Hub proves who someone is; the
users table decides whether they may use the connector. Phase 1 uses one shared
KLIP service account, so every admitted user can read everything MCP_READONLY
can read (review H8) — pilot membership is the data-access control. A Hub account
that is not on the list gets a 403, and the <= 15 cap is enforced by user:add.
Two PKCE exchanges run in the flow; do not conflate them. Claude's own
code_challenge protects the Claude→gateway leg (handled by the SDK); a separate
verifier the gateway keeps server-side protects the gateway→Hub leg.
Which Hub client? The gateway's own — not KLIP's
Register a new OIDC client for the MCP Gateway. Do not reuse KLIP's Hub registration, even though KLIP is already registered in the testing DWS Hub.
The Hub only touches one of the two trust boundaries:
| Boundary | Credential | Hub involved? |
|---|---|---|
| Claude → gateway (which human is asking) | gateway's own Hub client + the pilot allowlist | yes |
| gateway → KLIP (reading data) | svc-mcp against KLIP's /api/auth/login |
no — PRD §7 excludes it explicitly |
Reusing KLIP's client breaks the first boundary in a concrete way. The gateway
validates the ID token's aud against its own HUB_CLIENT_ID; sharing KLIP's
client id means an ID token minted during a KLIP login would be accepted by the
gateway, which is the confused-deputy shape. Separate clients are what make the
two relying parties distinguishable. It also keeps the redirect-URI allowlists,
client secrets, rotation schedules, Hub SSO audit entries, and disable switches
independent — turning the connector's Hub client off must not take KLIP login down.
None of this needs any change on the KLIP side. K1–K4 are unaffected.
Two Hub instances, two registrations
Register the gateway separately in each Hub and pair them with the matching KLIP:
| Stage | KLIP_ENV | HUB_ISSUER | Client |
|---|---|---|---|
| 4–6 (build, staging UAT) | staging |
testing DWS Hub | gateway client in the testing Hub |
| 7+ (production cutover) | production |
production Hub | a separate gateway client in the production Hub |
The pairing is enforced at boot, because getting it wrong in one direction is dangerous rather than untidy:
KLIP_ENV=production+ a testingHUB_ISSUER→ the gateway refuses to start. Anyone able to create a test-Hub account would otherwise reach real commercial data.KLIP_ENV=staging+ the productionHUB_ISSUER→ warns and continues.
hub:check prints the pairing it detected, so the cutover is verifiable:
pairing: KLIP staging <-> Hub testing
What DWS Hub requires (it is not vanilla OIDC)
Per Docs/SSO-TARGET-APP-INTEGRATION.md. Four of these differ from the defaults an
OIDC client library assumes, and three would fail outright:
| DWS Hub | |
|---|---|
| Client type | public, PKCE S256 — token_endpoint_auth_methods_supported: ["none"], no client secret exists |
| Discovery | /api/sso/.well-known/openid-configuration — not the RFC 8414 path from the issuer |
| Token body | JSON; form-encoded returns unsupported_grant_type |
| Scopes | openid profile email only — no groups claim, so HUB_REQUIRED_GROUP is unusable |
redirect_uri |
mandatory in the token request and byte-exact |
Hence HUB_DISCOVERY_URL is configured explicitly, HUB_CLIENT_SECRET is optional,
and HUB_TOKEN_BODY defaults to json with a one-shot fallback to form on
unsupported_grant_type (logging which worked, so it can be pinned).
Setting it up
- Register the gateway as an OIDC client in the Hub with redirect URI
<PUBLIC_URL>/authorize/hub/callback. It is a public client — do not ask for a secret. - Put
HUB_ISSUER,HUB_DISCOVERY_URLandHUB_CLIENT_IDin/opt/mcp/.env. - Verify before any pilot user tries:
This prints the redirect URI to register, runs discovery, and warns if the Hub does not advertise S256 PKCE. The gateway also probes discovery at boot and reports it indocker compose exec -T gateway node dist/cli.js hub:check/healthzashub_oidc. - Add pilot users (no passwords — the Hub authenticates them):
docker compose exec -T gateway node dist/cli.js user:add someone@example.com "Their Name"
The break-glass account
Exactly one local password account is permitted, for use when the Hub is down or misconfigured:
docker compose exec -T gateway node dist/cli.js user:add-break-glass it-emergency@example.com
It is hidden behind a disclosure on the login page, forces a password change at
first use, and every sign-in through it is audited with break_glass: true at high
severity. A Hub-authenticated user cannot use the password path at all, so turning
the Hub off is not a way to fall back to a password nobody set.
Set BREAK_GLASS_ENABLED=false once the Hub path is proven in production to remove
the password surface entirely.
Optional group gate — not available on DWS Hub
HUB_REQUIRED_GROUP adds a second check against a groups claim. DWS Hub does not
issue one (it advertises only openid profile email), so setting it would refuse
every user. hub:check warns if you do. The pilot allowlist in the users table
remains the authorization control.
IdP-initiated login
The Hub can push a user straight to a callback from its dashboard tile. That cannot work for a connector: the callback exists to complete an authorization request Claude started, so arriving without one leaves nothing to issue a code against. The gateway detects it and says "start from Claude instead" rather than failing as "sign-in expired".
4. Quick start (local)
npm ci
docker run -d --name mcpgw-devdb -e POSTGRES_DB=gateway -e POSTGRES_USER=gateway \
-e POSTGRES_PASSWORD=devpassword -p 127.0.0.1:55432:5432 postgres:16-alpine
cp .env.example .env.dev # then edit: PUBLIC_URL=http://localhost:8787, DATABASE_URL=...55432...
npx tsx test/fixtures/mockKlip.ts 5099 & # mock KLIP, behaves like the real one
set -a; . ./.env.dev; set +a
npm run migrate
npx tsx src/index.ts
Create a pilot user (works with a TTY or piped input):
printf 'a-strong-password\na-strong-password\n' | npx tsx src/cli.ts user:add you@example.com "Your Name"
5. Tests
npm test
| Suite | Covers |
|---|---|
normalize.spec.ts (33) |
The Incoterm × status × null matrix, kg→MT, rounding order, negative outstanding, WIB timestamps |
guard.spec.ts (9) |
Exhaustive method/path table for T-6, traversal and origin escapes |
envelope.spec.ts (12) |
T-5 envelope, truncation next_step, injection-payload defusing |
truncation.spec.ts (4) |
A bounded fetch publishes totals_partial, never totals |
integration.spec.ts (23) |
All 9 tools against mock KLIP; no non-GET ever reaches KLIP; 401 re-login; AUTH_DEGRADED; typed errors; unit discipline |
resource.spec.ts (6) |
RFC 8707 audience binding: only this server's canonical resource is accepted |
audit.spec.ts (8) |
S5 redaction — aggressive on strings, inert on numbers |
hub.spec.ts (20) |
Hub OIDC against a mock provider: discovery issuer mismatch, foreign signing key, wrong issuer/audience, expired token, missing and replayed nonce, no-email claim |
hubGroupGate.spec.ts (5) |
HUB_REQUIRED_GROUP admission, including near-miss group names |
hubPairing.spec.ts (5) |
Production KLIP behind a testing Hub refuses to boot; the normal pairings do not |
hubTokenAuth.spec.ts (7) |
Token-endpoint auth method chosen from discovery, including post-only and public-client Hubs |
hubDws.spec.ts (16) |
DWS Hub modelled exactly: /api/sso discovery, public client, JSON-only token body, no groups scope, plus the encoding fallback both ways |
The mock Hub is a working mini-OIDC provider — real discovery document, real JWKS, real RS256 ID tokens, and PKCE verification on the authorization code — with every knob needed to forge a bad token, because the negative cases are the point.
The mock KLIP fixture deliberately reproduces the real system's quirks: kilograms
labelled MT, mixed-language statuses, an incoterm outside the standard four, null
quantities, an over-delivered contract, a limit silently clamped to 100, and a
contract remark carrying a prompt-injection payload.
6. Deploy
Full host-specific procedure, with the real IP, hostname and security-group table: deploy/RUNBOOK.md.
# on ECS-MCP
cd /opt/mcp && git pull
docker compose build gateway && docker compose up -d
curl -fsS http://127.0.0.1:8787/healthz
Admin CLI — note this runs inside the container, because the host installs Docker only and has no Node.js:
docker compose exec -T gateway node dist/cli.js user:list
docker compose exec -T gateway node dist/cli.js audit:summary --days 7
docker compose exec -T gateway node dist/cli.js audit:export --from 2026-08-01 --to 2026-09-01 --out /tmp/audit.csv
docker compose exec -T gateway node dist/cli.js routes:verify # probes KLIP, reports Appendix A gaps
Kill switch (S8) — target under 5 minutes:
docker compose exec -T gateway node dist/cli.js tokens:revoke-all --reason "incident 2026-xx"
docker compose stop gateway
Break-glass if the app container is unhealthy:
docker compose exec -T db psql -U gateway -d gateway -c "UPDATE oauth_tokens SET revoked_at=now() WHERE revoked_at IS NULL;"
7. Appendix A is a hard gate
src/adapters/klip/routes.ts and src/adapters/klip/fields.ts hold every KLIP path,
query-parameter name, page-size ceiling, response field name and enum value the
adapter depends on. Every entry is currently unverified.
The gate is executable, not clerical: with KLIP_ENV=production the process
refuses to start while any route is unverified. Run routes:verify against
staging, record the results, set verified: true per route and enums.verified = true.
Two fields matter more than the rest:
maxLimit— the largestlimitKLIP actually accepts. If it silently clamps to 100, aKLIP_PAGE_SIZEof 1000 turns one page into ten and breaks the latency target.enums.*— the canonical status and incoterm values. Anything unmapped is excluded from totals with a data-quality note, never defaulted.
8. Deviations from TSD v0.9
Each one comes from the design review and is commented at its call site.
| # | Change | Why |
|---|---|---|
| B3 | Origin rejected only when present and invalid | The spec requires 403 only for a present-and-invalid Origin. Claude calls the connector server-to-server and may send none; rejecting absent would 403 every tool call. |
| B4 | aud = <PUBLIC_URL>/mcp, not the bare hostname |
RFC 8707 audience binding. Added authorization_servers + resource to the PRM document, scope on the 401 challenge, and RFC 9207 iss. |
| B5 | Stateless transport; identity from the token per request | Revision 2026-07-28 removed protocol sessions. T-4 is satisfied by construction — there is no session to hijack. |
| B6 | Kill switch runs docker compose exec |
The host has no Node.js, so cd /opt/mcp && node cli.js could never work. |
| B7 | nginx is the anti-abuse floor; per-user limit keyed on the OAuth sub |
All traffic arrives from Anthropic's shared egress range, so IP-keyed limiting puts the whole pilot in one bucket. Added limit_req_status 429 (the default is 503). |
| H1 | OAuth built on the SDK's mcpAuthRouter + OAuthServerProvider |
The SDK ships the AS, including revocation and default rate limiting. Only storage and user authentication are ours. |
| H2 | Downstream Hub OIDC is the login path, with one break-glass local account | Brought forward from Phase 2. The Hub authenticates; the users table stays the pilot allowlist. ProxyOAuthServerProvider was rejected on purpose — see §3. |
| H3 | nginx allowlist scaffolding for Anthropic's 160.79.104.0/21 |
Anthropic publishes stable egress ranges and recommends allowlisting; /authorize stays open to corporate egress because it runs in the user's browser. Commented out until Stage 6 confirms the real source addresses. |
| H4 | Truncated results publish totals_partial; aggregation in integer kg; unmapped enums excluded; negative outstanding preserved |
Four distinct paths to a confidently wrong number. |
| H5 | Short TTL cache; pages 2..N fetched concurrently; page size clamped to maxLimit |
Ten sequential round-trips cannot meet P95 ≤ 5 s. |
| H6 | Added a 9th tool, klip_reference, plus a typed UNKNOWN_FILTER_VALUE |
Without it a mistyped plant name returns an empty set that reads as "nothing is outstanding". |
| H7 | environment and source derived from KLIP_ENV |
The specified hardcoded "KLIP production" string would have made every staging UAT answer claim to be production. |
| H9 | audit_events is monthly range-partitioned; added audit:export; X-Forwarded-For honoured |
Retention by partition drop is the only thing the append-only trigger permits; U5's export had no implementation; client IPs would all have logged as 127.0.0.1. |
| H10 | Backup sidecar, container healthcheck, /healthz detail restricted to internal callers |
Specified in the TSD but never implemented in the guide, so it would not have existed at go-live. |
| — | Unknown tool parameters rejected via z.strictObject |
PRD 8.1 requires rejection; a plain z.object silently ignores extras. |
| — | Tools return structuredContent against an outputSchema |
Typed data rather than JSON embedded in prose — fewer transcription errors, which is what M1 measures. |
9. Still outstanding
- Appendix A reconciliation (P1) — blocks production, enforced at startup.
- KLIP-side K1–K4 — the
MCP_READONLYrole,svc-mcpaccount, security-group rule. - Off-host backup sync and a tested restore — the sidecar writes locally only.
- Real corporate egress CIDRs in
deploy/nginx/mcp.conf, then enable the two commentedreturn 403lines. - Load test at the 30-concurrent-user capacity NFR.
- Hub client registration — the gateway's own, in the testing DWS Hub first:
HUB_ISSUER, client id and secret, with redirect URI<PUBLIC_URL>/authorize/hub/callbackregistered Hub-side. Confirm the Hub's email and groups claim names, then runhub:check. A second, separate registration in the production Hub is needed at Stage 7. - Decide whether to set
HUB_REQUIRED_GROUP, and whether to turnBREAK_GLASS_ENABLED=falseafter the Hub path is proven in production.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。