eq-mcp-gateway
OfficialREADME.md
# eq-mcp-gateway
Remote MCP gateway for Equity Quotient. It owns **protocol and identity**; services stay REST.
Built as a general connector entrypoint rather than an adapter for one service — `eq-data`
or Rosa's web-search tools are plausible second and third connections, and adding one is a
tool module plus an entry in `SERVICE_CONNECTIONS`, not a change to the transport or the auth flow.
## Design invariants
These are the things not to break; each exists for a reason that already bit us.
1. **Forward the caller's own token. Never hold a service credential.**
Organization scoping in the service keys off whichever user's token arrives. A shared
credential would flatten every org boundary at once, silently, for every user. The
user's Auth0 access token rides on the OAuth grant (`props`) and is forwarded on every
tool call.
2. **Composition stays in the service.** This Worker does not orchestrate multi-step
queries. If a tool needs resolve-then-fetch or fetch-then-rank, whichever service can
reach the data exposes it as one endpoint. Composing here pulls query semantics into a
second codebase and turns one round trip into several.
3. **Everything is read-only,** and every tool says so via `annotations.readOnlyHint`.
Both directories check this at review.
4. **Tool names and shapes are the client contract.** Hosting, runtime and platform can
change silently. Names, parameters and return shapes cannot, once published — they
become an API consumed by people you cannot contact.
## Two surfaces, one tool set
| Route | Auth | Who |
| --- | --- | --- |
| `/mcp` | OAuth 2.1 — the Worker is the authorization server | Claude, ChatGPT, anything that cannot be handed a token out of band |
| `/internal/mcp` | Raw bearer pass-through | eq-app, which already holds the user's Auth0 token |
Separate paths rather than one route with two auth modes, because this is also the seam
the narrower connector envelope needs later: same tools, different entitlements, decided
by which door the request arrived through.
## How the OAuth flow works
Auth0 does not support dynamic client registration. MCP clients require it. That mismatch
is the entire reason this layer exists.
```
MCP client gateway (Worker) Auth0
│ register ──────────────▶ /oauth/register
│ authorize ─────────────▶ /authorize ───────────────▶ /authorize
│ (user signs in)
│ /callback ◀──────────────── code
│ exchange ─────────────────▶ /oauth/token
│ ◀──────────── redirect + gateway code
│ token ─────────────────▶ /oauth/token
│ tools/call ────────────▶ /mcp ──── forwards the user's Auth0 token ──▶ service
```
The gateway issues its own tokens to MCP clients and stores the upstream Auth0 access
token on the grant. The service therefore still sees the person, not the gateway.
## Run locally
```bash
npm install
cp .dev.vars.example .dev.vars # add AUTH0_CLIENT_SECRET
npm run dev
```
Before the full flow works you need, once:
1. **An Auth0 application** for the gateway (regular web app), with
`https://<gateway-host>/callback` and `http://localhost:8787/callback` as allowed
callback URLs. Put its client id and the user-mgmt API identifier in `wrangler.jsonc`,
the secret in `.dev.vars`.
2. **A KV namespace**: `npx wrangler kv namespace create OAUTH_KV`, then paste the id into
`wrangler.jsonc`. Local dev uses a local namespace automatically.
`USER_MGMT_BASE_URL` and `EQ_APP_BASE_URL` are origins only — the route prefixes belong to
the REST client, not to configuration. Both default to the deployed dev hosts in
`wrangler.jsonc`, so tool calls work without anything running locally; override either in
`.dev.vars` to point at a local service. Listing tools needs neither.
### Verified probes
All of these were run against `wrangler dev`:
```bash
curl -s localhost:8787/health
curl -s localhost:8787/.well-known/oauth-authorization-server # issuer, endpoints, S256
# 401 carrying the discovery pointer public clients need
curl -i -X POST localhost:8787/mcp -H 'content-type: application/json' -d '{}'
# dynamic client registration — the capability Auth0 lacks
curl -s -X POST localhost:8787/oauth/register -H 'content-type: application/json' \
-d '{"client_name":"Probe","redirect_uris":["https://example.com/cb"],
"token_endpoint_auth_method":"none","grant_types":["authorization_code"],
"response_types":["code"]}'
# first-party surface
curl -s -X POST localhost:8787/internal/mcp \
-H 'authorization: Bearer <JWT>' -H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```
The MCP endpoint serves **both protocol generations**: the 2025-era `initialize` handshake,
and the stateless 2026-07-28 revision, which has no handshake at all and carries a `_meta`
envelope on every request. Both return the same tools.
The `/authorize` → Auth0 → `/callback` round trip is verified too, against the
`eq-development` tenant: a real sign-in through to a tool call returning real segment data.
## The tool set
Three modules, one per source of truth. `src/tools/toolkit.ts` holds what they share —
result shaping, the read-only annotation, and the `traced()` wrapper that logs every call.
| Module | Served by | Tools |
| --- | --- | --- |
| `identity.ts` | the caller's own token, no round trip | `my_organization` |
| `platform.ts` | eq-user-mgmt-service, one tool per REST endpoint | `dashboard_setup`, `browse_places`, `place_details`, `browse_place_sets`, `place_set_details` |
| `analytics.ts` | eq-user-mgmt-service and eq-app | `available_data`, `metric_values`, `find_place`, `find_region_in_state`, `rank_places` |
Two of those reach eq-app rather than eq-user-mgmt-service, because only eq-app can serve
them: `rank_places` needs the nationwide insight values *and* PlanetScale geography
names, and `available_data` needs the dashboard-tree-to-insight rules that live where the
dashboards are rendered. Both are `/api/tools/*` routes there, bearer-authed, sharing one
implementation with Rosa's own code.
`available_data` is the one tool with no counterpart in Rosa: eq-app pastes the
organization's whole KPI table into Rosa's system prompt, so it never has to ask.
Each tool declares a `title` as well as a name — a plain sentence ("Find a county or city
inside a state") alongside the identifier the model calls. Both are user-visible: clients
show the title where they have room, and fall back to the name in compact surfaces like the
tool-search step, which is why the names read as words rather than as functions. Change a
title freely; a name is covered by invariant 4.
## Pending
**Consent screen** — `/authorize` currently redirects straight to Auth0. Auth0
authenticates the person but does not tell them *which MCP client* is being granted
access. Directory review expects explicit permission, so this is required before listing.
**`webSearch`**, the one Rosa tool not ported. It is the only one that reaches outside the
platform, and an MCP client generally has its own.
**Authorized regions.** eq-app pastes a table of the org's region ids into Rosa's prompt;
nothing exposes them over HTTP, so a connector resolves geography from scratch through
`find_place`.
**Per-client tool allowlists**, so the public set and Rosa's set can differ. The set is
decided when a client connects, not per message.
**The narrower external envelope** — a connector session should not reach as far as a
first-party one. Enforced in the service on a token scope, never here.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues