Skip to main content
Glama

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.


Related MCP server: Relay

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:

$ 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:

# 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":

$ 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:

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:

$ 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

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__elicitationRequest Sensitive Data, Phishing

client__elicitationStatefulness, Security Considerations

"can a shared cache store the tool list"

server__utilities__cachingChoosing 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:

$ 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.

# 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

npm test        # 94 tests
npm run typecheck

The suites drive a real Client against the real handler in-processhandler.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.


Sibling project: 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.

A
license - permissive license
Not graded
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables AI agents to query live schema, lineage, and query-context across data warehouses, dbt projects, orchestration systems, and BI tools via MCP tools.
    Apache 2.0
  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables AI agents to access a unified catalog of tools from various APIs (OpenAPI, GraphQL, MCP, Google Discovery) through the MCP protocol.
    MIT
  • F
    license
    Not graded
    quality
    C
    maintenance
    Enables an LLM to dynamically discover and call tools across multiple MCP servers (file, GitHub, SQL, Python execution) with authentication, rate limiting, and observability, supporting parallel execution and secure deployment.

View all related MCP servers

Related MCP Connectors

  • Shared, governed long-term memory for AI agents across tools and sessions via MCP and REST.

  • Hosted MCP endpoint with realistic fake data for prototyping agents. 12 tools, no setup.

  • Free public MCP for AI agents — 193 tools, 44 workflows. No API key.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/indraxneel/nexus-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server