Skip to main content
Glama
README.md
# Nexus — a remote MCP server for heterogeneous enterprise systems

A remote MCP server (streamable HTTP, OAuth 2.1) exposing three deliberately
different backends through one tool surface, built to answer one question from
experience rather than from reading:

> **How is an MCP server different from a REST API wrapper?**

The short answer is three things, in increasing order of how few people can say
them: discovery happens at runtime rather than build time; the consumer is a
*model*, which inverts the design constraints; and the protocol has primitives —
elicitation, and the multi-round-trip pattern behind it — that a wrapper cannot
express at all.

> **Status: milestone 2 of 4.** All three systems are live, all three tool
> surfaces are built from one operations table, and the measurement harness runs.
> **The accuracy numbers are not yet measured** — that needs an
> `ANTHROPIC_API_KEY`, and this README will say so rather than quote a guess.
> The token-overhead half *is* measured, below. Elicitation and deployment are
> milestones 3 and 4.

---

## The claim this project is organised around

**The tool surface is part of the prompt.**

A REST API is designed around resources and CRUD, for a developer who reads
documentation, can retry, and can ask a colleague. A model reads tool
descriptions *at inference time* and gets one shot. That changes everything
downstream:

| For a developer | For a model |
|---|---|
| Endpoint-shaped tools are fine — compose them | Every extra tool is context spent and a chance to mis-select |
| Docs are reference material | **Descriptions are prompt engineering** |
| `500` is debuggable | An error must teach a *retry strategy* |
| Response size is bandwidth | **Response size is context budget** |
| Pagination is `?page=2` | Page numbers invite the model to invent `page=7` |

Every design decision below follows from the right-hand column.

---

## The three systems — heterogeneity is the design principle

Three backends chosen because they **fail in three different ways**. This is
what makes the project a design study rather than a wrapping exercise.

| System | What it is | The failure mode it forces |
|---|---|---|
| **Structured** | Postgres — orders, customers, products, shipments, returns. 420 seeded orders over 14 months. | Unbounded scans, exfiltration queries, the accidental cross join |
| **External SaaS** | **The real GitHub REST API**, per-user credentials, live | Two different rate limits, Link-header pagination, flakiness you didn't simulate |
| **Unstructured** | 30 documents / 360 chunks of the MCP specification, embedded with `text-embedding-3-small` into pgvector | Retrieval, not lookup — the answer is a ranked passage, not a record |

The Postgres data is synthetic, and that is stated rather than hidden: there is
no public transactional orders database to point at. The other two are real.

GitHub earns its place by being *actually external*. The client distinguishes
GitHub's two rate limits, which is the bug most wrappers ship:

- the **primary** limit is a quota — `x-ratelimit-remaining` counts down, and you
  can plan around it from headers you already have;
- the **secondary** limit is abuse detection — it fires on burst rate, returns
  **403 not 429**, and appears in no counter beforehand.

A retry loop that treats the second like the first hammers the endpoint until it
is blocked outright.

## Architecture

```
  MCP client (Claude Code / Cursor / your own)
        │  streamable HTTP + Bearer token
        ▼
  ┌─────────────────────────────────────────────┐
  │ Express                                     │
  │  requireBearerAuth ──► verifyAccessToken    │   OAuth 2.1 resource server
  │  mcpAuthMetadataRouter (RFC 9728)           │   (never an auth server)
  ├─────────────────────────────────────────────┤
  │ createMcpHandler(buildServer)               │
  │   └─ ONE McpServer per request,             │
  │      registering only the tools this        │   ◄── per-caller tool visibility
  │      caller's scopes permit                 │
  ├─────────────────────────────────────────────┤
  │ tools/  find_orders  get_order_detail       │   task-shaped
  │         query_metrics                       │   the deliberate escape hatch
  ├─────────────────────────────────────────────┤
  │ contracts/  errors (retryable + hint +      │
  │             alternatives), paging (caps,    │
  │             total_matched, signed cursors)  │
  ├─────────────────────────────────────────────┤
  │ data/   SET LOCAL ROLE ─► nexus_reader      │
  │         allowlisted api.* views only        │
  │         EXPLAIN cost gate ─► refuse         │
  └─────────────────────────────────────────────┘
        │
        ▼  Postgres 16 + pgvector
     ops.*  base tables — no tool-facing role has any privilege here
     api.*  the published views
```

---

## The three questions this is built to answer well

### 1. "Why not just give the model raw SQL?"

The answer is not "never". It is a stated trade-off with controls, in order of
how much each actually buys:

1. **A least-privileged role is the only real boundary.** Every query runs inside
   a `BEGIN READ ONLY` with `SET LOCAL ROLE nexus_reader`, a role with *no
   privilege on the `ops` schema at all*. `SET LOCAL`, not `SET`, so a pooled
   connection can never be handed to the next request still wearing the previous
   caller's role.
2. **Published views, not base tables.** `api.*` is what a model may see, decided
   column by column — `v_customers` deliberately has no `email`.
3. **A mandatory outer LIMIT**, wrapped rather than parsed.
4. **A statement timeout**, transaction-local.
5. **`EXPLAIN` cost gating.** The query is planned, the planner's own cost
   estimate is read, and anything above the threshold is refused *before it
   executes*. In practice this catches the accidental cross join far more often
   than anything adversarial — which is exactly why it earns its place.

Everything except (1) is a usability layer: it turns a permission error the
model cannot act on into an instruction it can. Verified, not asserted —
`tests/sqlGuard.test.ts` asks Postgres directly, bypassing every check in our
own code:

```
✓ nexus_reader cannot read a base table even by direct query      (42501)
✓ nexus_reader cannot execute the write function                  (42501)
✓ nexus_writer still cannot read a base table directly            (42501)
✓ the transaction is read-only, so even a privileged statement cannot mutate
✓ refuses an unbounded cross join before executing it   (cost 7.1e9 > 5e4)
```

### 2. "How does auth work?"

**The model is not the principal, the user is.**

Nexus is an OAuth 2.1 **resource server** and never an authorization server. An
unauthenticated request gets the full challenge chain:

```console
$ curl -i -X POST localhost:8080/mcp -d '...'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token",
                  resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource/mcp"
```

Token verification validates the **audience** (RFC 8707) — a token minted for
another API is not a token for this one, however valid its signature — and always
populates `expiresAt`, because `requireBearerAuth` treats an unset expiry as an
invalid token.

**Tool visibility is filtered per caller**, which the 2026-07-28 spec explicitly
permits: the tool set *"MAY vary by the authorization presented on the request …
since credentials are per-request input, not connection state."* This is not a
filter applied to a fixed list — `createMcpHandler`'s per-request factory
receives the verified `authInfo` and **registers a different tool set**, so a
tool the caller cannot use never enters the model's context at all:

```console
# orders:read only                 # orders:read + metrics:read
- find_orders                      - find_orders
- get_order_detail                 - get_order_detail
                                   - query_metrics
```

It is a real boundary, not a display convenience — calling a hidden tool by name
returns `Tool query_metrics not found`, because it was never registered.

The list is served `cacheScope: "private"` (a shared cache holding one user's
tool list and serving it to another would leak a capability listing across a
security boundary) and in deterministic order, which the spec asks for so
clients can cache and LLM prompt caches hit.

### 3. "What happens when the model gets it wrong?"

Two layers: prevention through fewer, better-differentiated tools, and
**recovery** through errors that carry a strategy. Every error answers three
questions — *is retrying useful, what should change, what else could work* — and
is returned as a **tool execution error** (`isError: true`), never a JSON-RPC
protocol error, because the spec says clients pass the former to the model for
self-correction and that the latter is "less likely to result in successful
recovery".

The counterpart failure is a *successful empty* result, which a model reads as
"no such records exist" when the truth is nearly always "your filter was wrong":

```console
$ find_orders("Northwind orders in 2019")
{ "results": [], "total_matched": 0,
  "hint": "matching customers exist (Northwind Traders), so another filter
           excluded them; 40 order(s) match everything except the date range
           — the window is probably wrong" }
```

---

## The measurement

The same capability set, built at three granularities from one operations table,
crossed with two description qualities. `npm run surfaces`:

| surface | tools | capabilities | tuned defs | weak defs |
|---|---|---|---|---|
| `endpoint` | **81** | 33 | 38,266 chars | 37,394 chars |
| `grouped` | **16** | 33 | 15,845 chars | 10,237 chars |
| `task` | **7** | 33 | 9,255 chars | 3,865 chars |

Three things about this table are load-bearing:

**All three reach exactly the same 33 capabilities.** `tests/surfaces.test.ts`
fails if they diverge. Comparing selection accuracy across surfaces that can do
different things would measure scope, not granularity.

**The endpoint surface has 81 tools for 33 capabilities**, and that redundancy is
the point rather than padding. Real endpoint-shaped servers accumulate
`get_order`, `lookup_order_by_id`, `list_orders`, `search_orders_by_customer`,
`list_cancelled_orders` — each added the day someone needed it, each reading
plausibly to a model facing a question it half-matches. A human disambiguates
near-duplicates by reading docs; a model guesses. **That is the pathology being
measured.**

**Scoring is by capability, not tool name.** "Which EMEA orders were cancelled?"
is `find_orders` on one surface and any of three near-synonyms on another.
Grading against a fixed name would score surfaces against each other's
vocabulary instead of against user intent.

### The confound, and what's done about it

If the 81-tool surface got auto-generated stubs while the 7 got tuned prose, the
experiment would measure *granularity plus writing quality* and report it as
granularity. One interviewer question — *"were the descriptions equally
good?"* — and the chart is worthless. Two defences:

1. Both generated surfaces compose their descriptions from the **same
   per-operation fragments** the task tools are written against, so no surface is
   handicapped by neglect.
2. Description quality is a **second axis**, not an afterthought — which is why
   this is a 2×2 rather than a line.

### What is measured, and what is not

Token overhead is measured and reported above: **4.1× fewer characters of tool
definitions** from `endpoint/tuned` to `task/tuned`, paid on every request before
the user has said anything.

Note what the table already shows: at *tuned* quality the endpoint surface costs
only 2.3% more than at *weak*, because 81 short descriptions add up to about what
7 rich ones do. The description axis costs almost nothing at high tool counts and
a great deal at low ones — which is not what I expected going in, and is the kind
of thing that only shows up when you build the 2×2 instead of the line.

**Selection accuracy is not yet measured.** It needs a model to act as the
client:

```bash
export ANTHROPIC_API_KEY=...
npm run eval                 # 6 configs x 51 requests x 3 trials ≈ 918 calls
npm run eval:report
```

Until that runs, there is no accuracy claim here. `npm run eval -- --dry-run`
produces the token-overhead half with no key.

## Task-shaped, not endpoint-shaped

`find_orders(query, limit, cursor)` absorbs what an endpoint-shaped surface
would spread across `list_orders(status, customer_id, from_date, to_date, page,
per_page)` + `get_customer` + a date conversion the model performs in its head:

```console
$ find_orders("cancelled EMEA orders since March")
{ "total_matched": 4,
  "interpreted_filters": {
    "statuses": ["cancelled"], "region": "EMEA",
    "date_range": { "from": "2026-03-01T00:00:00.000Z",
                    "to":   "2026-08-02T00:00:00.000Z",
                    "understood_as": "since march 2026" } } }
```

**The complexity did not disappear — it moved from the model's reasoning into
server code, where it is testable.** `tests/dates.test.ts` pins down 32 cases
the model would otherwise get silently wrong: that "in March" asked in August
2026 means March 2026 and not 2027, that "since March" and "in March" are
different ranges, that upper bounds are exclusive.

And it is returned as `interpreted_filters`, so a misparse is *visible in the
result* rather than producing a plausible wrong answer.

**A cut worth defending:** there is no `get_customer`. Customers are only ever
reached through orders in this domain, so folding the customer fields into
`get_order_detail` removed a tool without removing a capability.

**`query_metrics` is a deliberate exception** — the one endpoint-shaped escape
hatch, because a closed set of task tools cannot answer aggregate questions
nobody anticipated. Being the most capable tool makes it the most dangerous, so
it carries every control above. It also carries the largest description on the
surface (it has to publish the schema), which is the honest cost of an escape
hatch: paid in tokens on every single request.

---

## Running it

```bash
npm install
cp .env.example .env
docker compose up -d
npm run migrate && npm run seed
npm run corpus:ingest            # corpus/ is committed; --dry-run to chunk only
npm run link:github -- u-ana     # per-user GitHub credential (uses `gh auth token`)
npm start                        # http://localhost:8080/mcp
```

Neither API key is required to run the server. Without `OPENAI_API_KEY` the
document store indexes lexically and **says so** in every result
(`backend: "lexical"`) — degrading visibly matters, because retrieval that
quietly gets worse is indistinguishable from a corpus that lacks the answer.
Without `ANTHROPIC_API_KEY`, `search_documents` returns passages verbatim
instead of condensing them.

### What the embeddings actually bought

The corpus is indexed with `text-embedding-3-small` (1536 dims, 360 chunks,
HNSW over cosine distance). The model name is pinned in `ops.index_meta` as well
as in config, so changing `EMBED_MODEL` without re-ingesting fails loudly instead
of silently comparing vectors from two different encoders.

Both backends were run against the same paraphrased questions — queries that
deliberately avoid the corpus's own vocabulary. The honest summary is **lexical
finds the right document; vector finds the right section**:

| query | lexical (OR top-up) | vector |
|---|---|---|
| "stop a client replaying elicitation state as another user" | `client__elicitation` → *Request Sensitive Data*, *Phishing* | `client__elicitation` → **Statefulness**, **Security Considerations** |
| "can a shared cache store the tool list" | — | `server__utilities__caching` → **Choosing a Cache Scope** |

Strict keyword search (`websearch_to_tsquery`, which ANDs every term) returns
**zero rows** for all three paraphrases — nine content words never co-occur in one
chunk. That is why the lexical path ANDs first for precision and then tops up
from an OR query for recall, rather than doing either alone.

End to end, with condensation:

```console
$ search_documents("what must a server do to requestState to stop one user
                    replaying another user's state?")

backend: vector | condensed: true | passages: 6

  To prevent one user from replaying another user's state, a server MUST
  include the authenticated principal inside the integrity-protected
  `requestState` payload and verify it on receipt [2] … [complementary
  measures: a short TTL, and an identifier for the originating request]

  [1] basic__patterns__mrtr — Security Considerations
  [2] basic__patterns__mrtr — Server Requirements (Basic Workflow)
  [3] client__elicitation  — Statefulness
```

Note where the condensation happens: **before** the six passages cross into the
conversation. That is the whole point — the context saving is only real if the
raw text never arrives.

`DEV_AUTH=true` (the default in `.env.example`) accepts unsigned
`dev.<base64url({sub,name,scopes})>` tokens so the whole surface — including the
per-scope filtering that is most of the point — is exercisable before an Entra
tenant exists. `assertDeployable` refuses to start a non-localhost server with
it on.

```bash
# mint a token and call the server
TOK=$(node -e "console.log('dev.'+Buffer.from(JSON.stringify({
  sub:'u-ana', name:'Ana', scopes:['orders:read','metrics:read']})).toString('base64url'))")

curl -s -X POST localhost:8080/mcp \
  -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'Mcp-Method: tools/list' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{
       "io.modelcontextprotocol/protocolVersion":"2026-07-28",
       "io.modelcontextprotocol/clientInfo":{"name":"curl","version":"1"},
       "io.modelcontextprotocol/clientCapabilities":{}}}}'
```

> `Mcp-Method` and `Mcp-Name` are **required** headers on 2026-07-28 streamable
> HTTP POSTs, so gateways can route without parsing the body. `tools/call`
> without `Mcp-Name` is rejected as a malformed message.

### Tests

```bash
npm test        # 94 tests
npm run typecheck
```

The suites drive a real `Client` against the real handler **in-process** —
`handler.fetch` is the same function the Express route calls, so no port, no
socket and no mock transport is involved. The database-backed suites skip with a
warning when no container is running, so a fresh checkout stays green; CI
provides the service so the skip never hides a regression there.

---

## Stack

| Concern | Choice |
|---|---|
| Protocol | MCP **2026-07-28**, with 2025-era clients served from the same endpoint (`legacy: 'stateless'`) |
| SDK | `@modelcontextprotocol/{server,client,core,express,node}` v2 (ESM-only) |
| Runtime | Node 22+, TypeScript strict, zod v4 |
| Data | Postgres 16 + pgvector |
| Auth | OAuth 2.1 + PKCE, Microsoft Entra ID (a free tenant needs no paid subscription) |

> **On the spec revision.** `2026-07-28` is the largest MCP revision since
> launch: the protocol went **stateless** (no `initialize`, no session id), and
> server-initiated requests were replaced by **Multi Round-Trip Requests**.
> Two consequences this project is built around — sampling is now **deprecated**
> (so it is not implemented here; `search_documents` will condense via the
> provider API directly, which is the spec's own recommended migration), and an
> elicitation pause is no longer session state but a signed, principal-bound,
> TTL'd `requestState` blob the client echoes back on retry.

---

## Roadmap

| | |
|---|---|
| **1. Postgres, transport, auth** | ✅ streamable HTTP, OAuth resource server, per-caller tool visibility, constrained SQL with EXPLAIN gating |
| **2. GitHub + documents + the surfaces** | ✅ real rate-limited GitHub with per-user credentials, 360-chunk spec corpus, three surfaces from one operations table, harness runs — **accuracy pending a key** |
| **3. Elicitation, contracts, CI** | MRTR elicitation on the write tools with a signed `requestState`, `update_order_status` (the 8th tool), robustness suite, CI gates |
| **4. Deploy** | Azure Container Apps, and the same server demonstrated in Claude Code, Cursor and a custom client with zero per-client integration code |

Whatever the accuracy numbers say when they run — including a smaller effect than
expected — they get reported. A measured modest result is worth more than an
impressive guess, and an interviewer can tell the difference instantly.

---

## Related

Sibling project: [`bank-support-agent`](../bank-support-agent) — a guarded
LangGraph loop with a durable human-approval gate. Deliberately a different
story at a different layer: approval there is an orchestrator-level
`interrupt()` with a Postgres checkpoint; approval here will be protocol-level
elicitation that needs no orchestrator. Same requirement, two layers of the
stack, and the comparison is the point of having built both.