Skip to main content
Glama
equityquotient

eq-mcp-gateway

Official
README.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.