MCP Gateway
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@MCP GatewayShow the last 5 anomaly alerts for plant A12"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
MCP Gateway — KLIP and Jetty Planning, read-only
One secure service that lets authorized Energi-Up staff query KLIP and the Jetty Planning System (JPS) in natural language through Claude, without opening either application. Strictly read-only toward both.
Live: https://mcp-gw.energi-up.com (SrvMCP, docker compose, nginx-fronted).
Both upstreams are on production as of 14–16 Sep 2026.
Documents: PRD v0.9 · TSD v0.9 · Implementation guide · Design review · Deployment runbook
1. What it exposes
27 tools. Every one read-only toward its upstream; the four that write touch only the gateway's own Postgres.
Family | Tools |
KLIP (13) |
|
JPS (5) |
|
DWS Hub (5, admins only) |
|
Gateway-local (4) |
|
The JPS family is empty when JPS is unconfigured. Advertising a tool that always fails is worse than not advertising it: a model that can see it will reach for it, and "no JPS connection" arrives as a tool error rather than as an honest absence.
DWS Hub production support — admins only
The dwshub_* family reads what the DWS Hub's Sentinel agent has already produced: bug and
JavaScript-error analyses (severity, root cause, proposed fix), per-application health reports, and the
bug reports and error groups behind them. Milo's production-support agent uses it for the daily summary.
Source: the Hub's read-only service API (
/api/service/v1), with a service key the Hub accepts on those GET routes only (DWSHUB_BASE_URL,DWSHUB_SERVICE_KEY). Not the Hub OIDC login (HUB_*). Empty when unconfigured, like JPS. Plaintexthttpis refused at boot in production.Admins only. Sentinel's output is admin-only in the Hub, so the gateway does not widen it to every pilot user: the tools are advertised only to callers with
is_admin(read from the database on every request), and the runner refuses them for anyone else, audited asNOT_ADMIN.Never starts work in the Hub. No tool can start an analysis, change a report or spend the Hub's Anthropic budget. A proposed code patch stays in the Hub (
has_patchsays one exists).People are pseudonyms. The Hub replaces every email with a stable pseudonym before answering.
The two upstreams answer different questions
Both say vessel and shipment, so routing is on the discriminator, not the noun. KLIP is commercial fulfilment across plants — contracts, outstanding, incoterms, performance, oil loss, trucking, quality, price. JPS is what physically happens at a port — berths, vessels alongside, NOR and laytime, cargo operations, cast-off, shore tank stock.
There is no join key. Measured 11 Sep 2026: of 85 JPS shipment plans, 0 carry an
externalReference; of 87 shipping instructions, 0 carry a KLIP-shaped STO number.
jetty_vessel_crosscheck therefore shows both systems side by side, matched on
vessel name, with every candidate carrying its similarity score and nothing merged. The
two genuinely disagree — on 11 Sep JPS had 4 vessels alongside while KLIP showed 12 as
berthed or unloading, overlapping on 2.
One field would fix it: JPS plans already have an empty externalReference. Populate it
with the KLIP STO and the join becomes exact. voyage_no exists on both sides and is
the nearer-term candidate.
Related MCP server: Snowflake MCP Server
2. Pinned versions (T-1)
Where the SDK's docs disagree with the design documents, the SDK wins.
Component | Pinned | Notes |
| 1.30.0 (exact) | OAuth AS, Streamable HTTP, |
MCP protocol revision | 2025-11-25; wire-compatible with | Server is stateless, so forward-compatible with |
Node.js | 22 LTS ( | |
TypeScript | 5.9.3, | |
express | 5.2.1 | SDK depends on |
zod | 4.4.3 | SDK types target zod 4. |
jose | 6.2.9 | RS256 gateway tokens, Hub ID-token verification, admin session. |
| 2.1.0 | Prebuilt binaries — no |
axios | 1.19.0 | Both upstream clients, behind their method guards. |
pg / PostgreSQL | 8.23.0 / 16 | Managed ApsaraDB over the VPC, not a container. |
3. Layout
src/
core/ config, logger, db, audit, cache, rateLimit, semaphore, migrate,
errors, knowledge, gaps
adapters/klip/ routes · fields · client(guard) · session · paginate · normalize
adapters/jetty/ routes · client(GET-only) · session(cookie login)
tools/klip/ 13 read-only tools + shared parameter plumbing
tools/jetty/ 5 read-only tools
tools/knowledge/ 4 gateway-local tools (memory + gap log)
mcp/ server, envelope, runner
auth/ keys, hub(OIDC RP), users, clients, tokens, provider,
loginPage, adminSession, adminPage
http/ app, consent(Hub + break-glass), admin(pilot UI), health,
origin, clientIp
migrations/ 001 schema · 002 Hub OIDC · 003 knowledge · 004-008,010 curated
knowledge · 009 admin flag · 011 capability gaps
deploy/ nginx config, backup sidecar
test/ 38 suites, 454 tests, with mock KLIP, mock JPS, mock Hub and mock DWS Hub APILayering rule (T-3): tools → adapters → core. Nothing imports http/ except the
entrypoint and the consent router. All KLIP normalization lives in
adapters/klip/normalize.ts, which imports nothing.
4. Read-only, twice over
Boundary | How it is enforced |
KLIP |
|
JPS |
|
Credentials | KLIP: a read-only service account. JPS production: a view-only role, created 16 Sep before the connector was pointed at it. JPS staging still holds Full Access, so there the client guard is the only layer. |
JPS deserves the belt and braces: it is the operational system of record, and its API can approve a plan, sign off an operation and record a cast-off that marks a vessel SAILED.
5. Verification is executable, not clerical
Three independent checks, each added after a defect it would have caught.
routes:verify-fields — probes KLIP and checks every mapped field against 50
live rows (10 ids for the detail route), separating ABSENT from sparse. routes:verify
alone proves a path resolves and prints twelve key names, which catches a 404 and
almost nothing else. This found the payments map matching nothing for due date, paid
date and amount, so every payment reported as unpaid, none overdue and all amounts
excluded — without one error.
jetty:verify — the same for JPS, plus a check of the 21 fields jetty_at_berth
reads and a flag for any route an order of magnitude slower than its recorded timing.
This found production returning summaries as an object keyed by operation id where
staging returned an array: for...of threw not iterable, so the tool failed outright
while passing ten tests against a staging-shaped fixture.
fieldCoverage.spec.ts — fails the build when a mapped field reaches no tool. Found
39, including shortage (the tool reported how late a shipment was while silently
holding how much of it failed to arrive) and the eight berthing/start milestones. Fields
deliberately not surfaced live in an allowlist with a written reason each, and two
further tests keep that list honest.
Appendix A remains a hard gate. With KLIP_ENV=production the process refuses to
start while any route is unverified. All 15 KLIP routes were verified against production
on 14 Sep; all 15 JPS routes on 16 Sep (JPS has no boot gate — jetty:verify is the
check, and nothing will remind you).
6. Where numbers come from
The standing rule: the source system owns the figure. Report it, never publish a rival beside it. Four consequences visible in the code:
Units are measured, not assumed. JPS labels only
observedDensityKgM3, sojetty_tank_farmderives what the volume and mass columns are in fromvolume × densityover the rows it fetched — near 1 means kilograms, near 1000 means tonnes, anything else is reported UNKNOWN rather than rounded to the nearer of two answers a thousand apart.klip_price_summarydoes the same for whetherunit_priceis per kilogram (it is — confirmed from KLIP's own SQL, and still measured).Currencies are never mixed. Production carries IDR and US$; currency is part of every price group key.
Aggregates say who computed them.
jetty_berth_productivityreplicates the JPS Management Dashboard's own arithmetic — read fromManagementDashboard.jsx, because JPS serves no KPI endpoint and derives everything in the browser. That means copying a dedup by vessel+berthing-time, outlier guards that discard a wait longer than the berth stay, and a throughput that sums the undeduplicated rows while the medians use the deduplicated ones. Any of those guessed differently produces a plausible number that does not match the screen.Empty is not zero. A null milestone means unrecorded, not that the event did not happen; a tank with no reading is excluded from a total and counted.
Reading the upstream source
Both systems are checked out locally — JPS at D:\Claude\Jetty Planning, KLIP at
D:\Cursor\Logistic SAP. Reading them has overturned something every time:
Assumption | What the source said |
Dashboard KPIs come from an endpoint | They are computed in the browser from |
|
|
The plan-ETA window was "unverified" | Confirmed: |
|
|
| A different question: contract minus what is assigned to STOs, clamped at zero. Surfaced as |
7. Authentication — Downstream Hub OIDC
Pilot users sign in with Downstream Hub. The gateway remains the authorization
server Claude talks to; the Hub is one step inside its own /authorize flow.
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)We deliberately do not use ProxyOAuthServerProvider: proxying would hand Claude a
Hub token, breaking the RFC 8707 audience binding and moving token issuance out of our
control so the S8 kill switch could no longer invalidate live sessions.
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 it can — pilot membership is the
data-access control, capped at 15.
Register the gateway's own Hub client — never reuse KLIP's. The gateway validates
aud against its own HUB_CLIENT_ID; sharing KLIP's id would mean an ID token minted
during a KLIP login is accepted by the gateway, which is the confused-deputy shape.
What DWS Hub requires (it is not vanilla OIDC)
DWS Hub | |
Client type | public, PKCE S256, |
Discovery |
|
Token body | JSON; form-encoded returns |
Scopes |
|
Nonce | sent and never echoed back. See below. |
HUB_REQUIRE_NONCE defaults to true and production runs it false. OIDC Core
3.1.3.7 says a nonce that was sent must come back; DWS Hub accepts the parameter and
drops it. An absent nonce is configurable; a wrong one stays fatal either way,
is logged at error, and a test asserts the flag cannot widen into skipping the check.
What still guards the flow: the authorization code arrives over the back channel, PKCE
S256 binds it to this client, and state is single-use.
Environment pairing, enforced at boot
KLIP_ENV=production+ a testingHUB_ISSUER→ refuses to start. Anyone able to create a test-Hub account would otherwise reach real commercial data.KLIP_ENV=staging+ the production Hub → warns and continues.
hub:check prints the pairing, the discovery result and the exact redirect URI to
register. Note it infers "production Hub" from the absence of test/staging/uat/dev in
the hostname, so dwshub.kpndomain.com reads as production either way — confirm the
instance by hand.
Local apps (Milo) — loopback redirects
A local app such as Milo receives its authorization code on http://127.0.0.1:<port>
(RFC 8252). That is accepted only for clients an admin pre-registers from a shell
with ALLOW_LOOPBACK_REDIRECTS=true — dynamic registration at /register never accepts
a loopback redirect, whatever the flag says. Only the literal 127.0.0.1 counts, never
localhost. The person still signs in with the Hub and must be on the pilot list.
docker compose exec -T gateway node dist/cli.js client:add "Milo – Atlas" http://127.0.0.1:4242/api/gateway/callbackThree operational consequences of a local app, none of them visible in its code:
It shares its user's rate limit.
RATE_LIMIT_USERis keyed on the OAuth subject, 30 calls per 5 minutes. Agents signing in as a person share that bucket with each other and with the person's own Claude — an agent loop can 429 the human.It calls the public endpoint from the corporate network, the egress path that has been intermittently blocked. Token refresh and every
/mcpcall depend on it.It conflicts with the planned
/mcpsource-address allowlist indeploy/nginx/mcp.conf, which admits only Anthropic's egress. See the comment there.
There is no per-client off-switch yet. Turning the flag off blocks new sign-ins only;
cutting off one app today means user:disable on its person or the kill switch.
The break-glass account
Exactly one local password account, for when the Hub is down or misconfigured. Forces a password change at first use; every sign-in is audited at high severity. Keep it on a separate address from any person's pilot account — sharing one makes break-glass use indistinguishable from everyday login, and the alarm stops meaning anything.
user:clear-break-glass demotes an account back to Hub-only and removes the password
hash; enable refuses to restore a break-glass account while another is active.
8. The admin UI — /admin
Server-rendered, no JavaScript, no external assets. Three checks stand between the internet and it:
Check | Question |
Downstream Hub | who are you |
pilot list | may you use the connector |
| may you change who else can |
The third is granted only by user:grant-admin from a shell. There is deliberately
no route that sets it, and a test asserts no such route exists — a public page that can
promote its own callers is a self-service door, and this host takes automated /admin
and formLogin probes continuously.
The session is a 30-minute signed JWT with no table behind it, and is_admin is re-read
from the database on every request, so revoking takes effect on the next click. CSRF
is bound to the session and echoed in hidden fields rather than carried in a second
cookie.
It shows the pilot list (add / disable / enable, disabling also revokes live tokens) and the capability gap log — see below.
nginx must proxy
/adminand must not add its own CSP there. Two CSP headers are intersected by the browser, and the server-leveldefault-src 'none'cancels the app'sstyle-src 'unsafe-inline', which renders as "the CSS didn't load".
9. Knowledge base and the gap log
Knowledge base (003–008, 010): the connector's own provider-independent memory, so a
fact established once reaches every future conversation. Three channels — the MCP
instructions preamble (verified and pinned entries, ~2 KB budget, skip-not-truncate
so an overrun silently drops an entry), klip_knowledge_search, and the save/feedback
write path. An AI-saved entry is proposed until two distinct users vote it helpful.
Entries that enumerate current capability go stale every time code ships. That happened three times with the jetty tool list, the last time telling a user the connector could not reach the tank farm hours after the tool shipped. The pinned entry now carries the durable half — which system owns which question — and says read the tool list, which is generated from code and cannot drift.
Capability gap log (011): what the connector was asked for and could not answer.
Three ways in — klip_report_gap called by a model that has to say "cannot reach that"
(the only one the gateway cannot detect itself, because when no tool covers a question
nothing is called and nothing fails), an empty knowledge search, and a tool throwing
capability-unavailable. Ranked on /admin, most asked first, with up to three of the
actual questions.
It stores question text, deliberately. Resolving a gap keeps the count and deletes the questions; text past 90 days is cleared by the housekeeping sweep regardless. Recording is best-effort and swallows its own failures — a gap is a note about a failure that already happened, and the log must not become a second one.
10. 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 # PUBLIC_URL=http://localhost:8787, DATABASE_URL=...55432...
npx tsx test/fixtures/mockKlip.ts 5099 &
set -a; . ./.env.dev; set +a
npm run migrate
npx tsx src/index.ts11. Tests
npm test # 38 suites, 454 testsHighlights rather than an inventory — the full list is the directory:
Suite | Covers |
| Incoterm × status × null matrix, kg→MT, rounding order, negative outstanding, WIB |
| Exhaustive method/path tables: no non-GET reaches either upstream |
| Every mapped field reaches a tool, or is allowlisted with a reason |
| All KLIP tools against mock KLIP, including the prompt-injection drill |
| DWS Hub modelled exactly, plus forged/expired/foreign-key/wrong-audience tokens and the nonce cases |
| The four ways into |
| Exactly one active break-glass account, both directions |
| RFC1918 by parsing, not string prefixes |
| The retention promise as implemented |
| Units inferred as kg, tonnes, or honestly UNKNOWN |
| Why an 80% name threshold alone is unsafe |
The mocks reproduce the real systems' quirks on purpose: kilograms labelled MT, mixed-
language statuses, a limit silently clamped to 100, JPS's bare-array and keyed-object
envelopes, a login that returns its token only in a cookie, and a contract carrying a
prompt-injection payload.
12. Deploy
Full procedure: deploy/RUNBOOK.md.
cd /opt/mcp && git pull
docker compose up -d --build --force-recreate gateway
docker compose exec -T gateway node dist/cli.js migrate
curl -fsS http://127.0.0.1:8787/healthz | python3 -m json.tool
.envis read at container CREATE time.docker restartreuses the old values, and plainup -ddoes not reliably notice an.envedit — it reports "Running" and leaves it. Use--force-recreate, then verify withdocker compose exec -T gateway printenv <VAR>.
Adding a tool needs a client reconnect. Claude caches the tool list above the conversation, so a new chat alone will not see it.
Admin CLI runs inside the container (the host has Docker only, no Node):
docker compose exec -T gateway node dist/cli.js user:list
docker compose exec -T gateway node dist/cli.js user:grant-admin someone@energi-up.com
docker compose exec -T gateway node dist/cli.js audit:summary --days 7
docker compose exec -T gateway node dist/cli.js routes:verify-fields # KLIP
docker compose exec -T gateway node dist/cli.js jetty:verify # JPSUse docker compose run --rm gateway … instead when the container is crash-looping and
cannot be exec'd into — which is how a failed boot gate presents.
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 gateway13. Still outstanding
Verify production figures against the KLIP UI. The payment map, the contract- quantity merge, the enum records and eighteen milestones all changed against data they had never seen. The standing rule is that the connector and the page never disagree; that has not been checked since.
Open with the KLIP team:
contract_qtyrepeating identically on every STO row (the 4× summing trap);is_delayedfalse on every row while the page marks shipments Late;sla_days/sfal_qty/sfbd_qtyempty everywhere; a negativeavgLogCycle; trucking summary 344,277 vs cards 346,777. Each is now answerable from the KLIP source rather than by round trip.JPS staging still runs on
JPS Full Access— production is view-only, staging is not, so there the GET-only client is the only layer.Off-host backup sync and a tested restore — the sidecar writes locally only.
Corporate egress CIDRs in
deploy/nginx/mcp.conf, then enable the guard on/admin(a better home for it than/authorize, which must work wherever a pilot user opens Claude).Load test at the 30-concurrent-user NFR.
Decide whether to set
BREAK_GLASS_ENABLED=falsenow the Hub path is proven.oilLossreturns its whole dataset unpaginated — 4,092 rows on 16 Sep, an order of magnitude past staging. The cache is what keeps it inside the latency target.
This server cannot be deployed
Maintenance
Related MCP Connectors
Query your org's data in natural language — read-only MCP access to SQL, NoSQL, files & warehouses.
Query your warehouse or a CSV with Claude/ChatGPT over MCP, governed by table-level ACL + audit.
Query 40 databases from Claude, ChatGPT, or Cursor — on any device. Read-only, encrypted, audited.
Give Claude only the Google Drive files you choose. Every action logged.
Related MCP Servers
- AlicenseAqualityCmaintenanceEnables read-only querying of the gong-nl-db Postgres database through natural language via Claude Desktop.9Academic Free v1.1
- AlicenseNot gradedqualityDmaintenanceEnables natural language queries against Snowflake Gold-layer tables through Claude Desktop, allowing users to ask business questions in plain English without SQL knowledge.MIT
- FlicenseNot gradedqualityCmaintenanceEnables natural language interaction with Kintone data via Claude, allowing listing apps, field definitions, querying and modifying records.-
- FlicenseAqualityCmaintenanceEnables querying AgroClimate company data via natural language through ChatGPT or Claude.7-