MCP Nexus
by Aidan-Kay
README.md
# MCP Nexus
> Browse-first MCP middleware — an LLM-friendly nexus for discovering and invoking MCP tools across multiple services.
AI agents use this as a single MCP endpoint to **browse**, **inspect**, and **call** tools from many upstream MCP servers — without flooding their context with every tool schema upfront.
## How It Works
Instead of connecting every MCP server directly (and loading all their tool schemas at session start), agents connect to **one** nexus server and discover tools on demand:
```
browse_services → [{id: "todoist", name: "Todoist"}, {id: "outlook", ...}]
browse_tools("todoist") → ["todoist__get-task", "todoist__create-task", ...]
search_tools("send email") → [{name: "outlook__search-emails", serviceId: "outlook", inputSchema: {...}}, ...]
get_schemas(["todoist__get-task"]) → [full input schema] (for browsed tools, or a hit with inputSchemaTrimmed)
call_tool("todoist__get-task", {id: "123"}) → result
```
Agents can either **browse** (list services → list tools) or **search** (find tools by keyword or semantic similarity across all services at once).
The service roster itself costs no tool call at all: the nexus renders it into the `instructions` field of the MCP `initialize` response, which clients inject into the model's system prompt. An agent knows which services exist, what each covers, and how many tools each carries before it makes a single request — `browse_services` remains for clients that ignore `instructions`, and for checking live availability.
Instructions are sent once per session and cannot be revised afterwards, so only durable facts go into them. Availability shifts as the recovery poller re-probes failed sources, which is why status stays in `browse_services` and `index`.
## Quick Start
### Prerequisites
- Node.js 22+
### Install & Run
```bash
# Install dependencies
npm install
# Copy a config (or create your own)
cp mcp-nexus.example.yaml mcp-nexus.yaml
# Start in dev mode (with hot reload)
npm run dev
# Or with a custom config path and verbose logging
npx tsx src/index.ts --config ./mcp-nexus.example.yaml --verbose
```
### Verify It's Running
```bash
# Health check
curl http://localhost:8050/health
```
## Configuration
Create a `mcp-nexus.yaml` file:
```yaml
port: 8050
auth:
enabled: false # Set to true and provide a token in production
token: ""
allowedOrigins: # Optional — restrict CORS to these origins when auth is on
- https://openwebui.local
connectors:
httpReuseIdleTimeoutSeconds: 300 # Reap idle upstream HTTP sessions after N seconds
recoveryIntervalSeconds: 30 # Probe failed sources every N seconds (0 = disabled)
search:
type: lexical # "lexical" (keyword matching) or "semantic" (embedding-based)
maxResults: 20
# semantic: # Uncomment to enable semantic search
# provider: built-in # "built-in" (local), "ollama", or "openai-compatible"
# model: Xenova/all-MiniLM-L6-v2
# batchSize: 32
# # For ollama: provider: ollama, baseUrl: http://ollama:11434, model: nomic-embed-text
# # For openai-compatible: provider: openai-compatible, baseUrl: https://api.openai.com, model: text-embedding-3-small, apiKeyEnv: OPENAI_API_KEY
sources:
- id: todoist
name: Todoist
description: Task and project management
transport: http
url: http://todoist-mcp:8081/mcp
filter: ["*"] # Glob patterns — only index matching tools
- id: outlook
name: Outlook
description: Email and calendar
transport: stdio
command: npx
args: ["-y", "@softeria/ms-365-mcp-server"]
env:
API_KEY: "your-key"
preloadedTools:
- search-emails
- list-folders
```
### Config Reference
| Field | Description |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `port` | HTTP port for the MCP endpoint (default: 8050) |
| `auth.enabled` | Require `Authorization: Bearer <token>` header |
| `auth.token` | Static bearer token (override via `MCP_NEXUS_AUTH_TOKEN` env var) |
| `auth.allowedOrigins` | Optional list of origins allowed via CORS when auth is enabled. If omitted, the request `Origin` is reflected back |
| `connectors.httpReuseIdleTimeoutSeconds` | Idle timeout before a cached upstream HTTP session is reaped (default: 300) |
| `connectors.recoveryIntervalSeconds` | Interval (seconds) for background recovery probes of failed sources. 0 = disabled (default: 30) |
| `search.type` | Search strategy: `"lexical"` (keyword matching, default), `"semantic"` (embedding-based similarity) or `"hybrid"` (both, fused) |
| `search.maxResults` | Max results returned by `search_tools` (default: 20) |
| `search.semantic.provider` | Embedding provider: `"built-in"` (local model), `"ollama"`, or `"openai-compatible"` (required if type is semantic) |
| `search.semantic.model` | Model name (provider-specific; defaults vary by provider) |
| `search.semantic.baseUrl` | Base URL for `ollama` or `openai-compatible` providers (required for those providers) |
| `search.semantic.apiKeyEnv` | Name of env var containing the API key (required for `openai-compatible`) |
| `search.semantic.batchSize` | Batch size for embedding generation at index time (default: 32) |
| `search.semantic.modelCachePath` | Where to cache the downloaded model (`built-in` provider only) |
| `search.semantic.minSimilarity` | Cosine similarity a tool must reach to be returned at all (default: 0.25, calibrated for all-MiniLM-L6-v2) |
| `sources[].id` | Unique identifier for the source (used in namespaced tool names) |
| `sources[].transport` | `"http"` for Streamable HTTP, `"stdio"` for subprocess |
| `sources[].url` | Upstream MCP server URL (required for HTTP transport) |
| `sources[].command` | Executable to spawn (required for stdio transport) |
| `sources[].filter` | Optional glob patterns to curate which tools are indexed |
| `sources[].preloadedTools` | Optional array of non-prefixed tool names to surface directly in `tools/list` (e.g. `[\"search-emails\"]`) |
| `sources[].projections` | Optional default response projections, keyed by non-prefixed tool name (see [Response Shaping](#response-shaping)) |
| `artefacts.root` | Directory artefacts are written under. Omit the whole `artefacts` block to disable the feature (see [Artefacts](#artefacts)) |
| `artefacts.retentionDays` | Delete run directories older than this, at startup and daily (default: 14; 0 = never) |
| `artefacts.runIdleMinutes` | How long a label keeps resolving to the same run directory (default: 180) |
| `artefacts.maxBytes` | Refuse to write a single artefact larger than this (default: 33554432) |
## Search
The `search_tools` tool lets agents find tools by query instead of browsing every service. Every hit carries its description, `responseShape` (once the tool has been called) and a **compact** input schema: each top-level argument is kept whole when it is simple — scalars, enums, arrays and maps of scalars — and a structured one (a nested object, an array of objects, a `$ref`) is cut to its type and description and named in `inputSchemaTrimmed`. A hit without `inputSchemaTrimmed` has its complete schema and can go straight to `call_tool`; one with it needs `get_schemas` first. This keeps a search over Graph-backed tools, whose schemas embed whole message and event entities, from costing thousands of tokens.
`totalMatches` counts real matches rather than tools scanned. Under lexical search that is every tool matching a query word. Under semantic search every tool has *some* similarity, so only those at or above `search.semantic.minSimilarity` are counted or returned: a search may come back with fewer than `maxResults` hits, and a query nothing resembles comes back empty rather than padded with weak guesses.
Three strategies are available, configured at startup via `search.type`:
### Lexical (default)
Keyword matching against tool names and descriptions. Fast, no dependencies. Best for queries like `"send email"` or `"ebay orders"` — concise terms that appear in the tool metadata.
Tool names and descriptions are split into words (on punctuation and camelCase), and a query word matches any word it is a prefix of — `email` finds `emails`, but `an` does not match `manage`. Common filler words (`a`, `an`, `the`, `to`, `my`, …) are dropped from the query unless it contains nothing else.
```yaml
search:
type: lexical
maxResults: 20
```
### Semantic
Embedding-based similarity search. Understands natural-language intent like `"I want to send an email"` or `"find tools for managing my inbox"`. Requires an embedding provider.
```yaml
search:
type: semantic
maxResults: 20
semantic:
provider: built-in # local model, no external dependencies
model: Xenova/all-MiniLM-L6-v2
batchSize: 32
modelCachePath: /app/data/model-cache
```
#### Embedding Providers
| Provider | Description | Config |
| ------------------- | --------------------------------------------------------- | ------------------------------------------------------- |
| `built-in` | Local model via Transformers.js (all-MiniLM-L6-v2, 384d) | No external dependencies. Downloads model on first run. |
| `ollama` | Local Ollama instance (nomic-embed-text, 768d) | Requires `baseUrl` (e.g. `http://localhost:11434`) |
| `openai-compatible` | Any OpenAI-compatible API (text-embedding-3-small, 1536d) | Requires `baseUrl`, `apiKeyEnv`, and `model` |
### Hybrid
Semantic and lexical ranking fused, for a corpus where neither is enough alone: semantic understands intent but can miss a tool whose description never uses the query's words, and lexical finds exact names but matches filler. Takes the same `semantic` block as semantic search.
```yaml
search:
type: hybrid
maxResults: 5
semantic:
provider: built-in
model: Xenova/all-MiniLM-L6-v2
```
- **Ranking** is reciprocal-rank fusion: each tool scores `1/(60 + semantic rank) + 1/(60 + lexical rank)`. Both arms rank every tool in scope, so a strong name match is never shut out by a weak semantic one.
- **A match** — what is returned and counted in `totalMatches` — is a tool whose similarity reaches `minSimilarity`, *or* whose name contains at least two of the query's meaningful words (one, for a one-word query). Words of the service's own name don't count towards that, so "todoist" in a query does not make every Todoist tool a match. A description match alone helps a tool's rank but does not make it a match.
- **An exact tool name** — namespaced or bare, in any of `snake_case`, `kebab-case` or `camelCase` — is pinned first with `pinned: true`.
- **Every hit says how it matched**: `matched` is `semantic`, `lexical` or `both`. No fused score is shown: it measures how far the two rankings agreed, which reads as a confidence and is not one.
If the semantic provider fails at query time (e.g. Ollama is down), semantic and hybrid search **fall back to lexical** automatically. The response includes `strategy` and `fellBackToLexical` fields so the agent can tell what happened.
## Docker
CI builds the image from each commit on `main` and publishes it as
`ghcr.io/aidan-kay/mcp-nexus`, tagged `latest` and with the commit SHA. A local build
copies the working tree, so it builds what is on disk, uncommitted changes included.
```bash
# Build
npm run docker:build
# Run
docker run -d \
--name mcp-nexus \
-p 8050:8050 \
-v ./mcp-nexus.yaml:/app/mcp-nexus.yaml \
-e MCP_NEXUS_AUTH_TOKEN=your-token \
mcp-nexus
```
Or use the provided Dockerfile directly:
```bash
docker build -t mcp-nexus .
```
## MCP Tools
The nexus exposes these tools to connected AI agents:
| Tool | What it does |
| ----------------- | ---------------------------------------------------------------------- |
| `browse_services` | List all available upstream services with descriptions and tool counts |
| `browse_tools` | List all tools for a specific service (namespaced names) |
| `search_tools` | Search for tools by keyword (lexical) or natural language (semantic); every hit carries a compact schema |
| `get_schemas` | Get input schemas and inferred response shapes for one or more tools |
| `call_tool` | Call a tool on an upstream service, optionally trimming the response or writing it to a file. Arguments are checked against the tool's input schema first; a mismatch is refused with the problems and the schema |
| `index` | Diagnostic — shows index summary, source availability, and error info |
Additionally, any tools listed under `preloadedTools` on a source will appear directly in the `tools/list` response alongside the built-in nexus tools — no browsing needed.
## Response Shaping
Upstream tools routinely return far more than an agent needs — every field of every
record, often pretty-printed. That width lands directly in the agent's context, so
the nexus trims it on the way through.
**Always applied.** Responses are forwarded as the upstream service's own content
blocks, with each JSON block re-serialised compactly. This is lossless — nothing is
dropped, and blocks that aren't JSON (prose errors, images, embedded resources) pass
through untouched. On live eBay responses this alone removes 38–48% of the bytes.
**Projections.** To trim fields as well, give `call_tool` a `select` array of dotted
paths. `[*]` maps over an array, and the original nesting is preserved:
```jsonc
{
"toolName": "ebay__ebay_get_inventory_items",
"parameters": { "limit": 25 },
"select": ["total", "inventoryItems[*].sku", "inventoryItems[*].product.title"]
}
```
A path matching nothing is returned as an **error**, not as absent data, so a typo
can't be mistaken for a field the service doesn't return. The error carries the
tool's response shape so the caller can correct itself.
For tools that are *always* too wide, set a default under `sources[].projections`
instead — keyed by the tool's own name, applied to every call, and overridden by an
explicit `select`. A configured projection whose paths have drifted out of date warns
and skips them rather than failing, since the caller didn't write it.
**Discovering paths.** Most services declare no `outputSchema`, so `get_schemas`
reports a `responseShape` instead: the leaf paths and types of what the tool last
returned, learned from calls as they pass through. Its size is fixed regardless of
how many records came back. If a tool hasn't been called yet, make one small call
first (most take a `limit` or `pageSize`) and the shape will be recorded.
## Artefacts
Projections trim a response; artefacts remove it from the conversation altogether.
When a result is only ever going to be aggregated by code — a full orders feed, a
whole catalogue — passing an `artefacts` label writes it to a file and returns a
receipt instead:
```jsonc
{
"toolName": "ebay__ebay_get_orders",
"parameters": { "limit": 25, "offset": 50 },
"artefacts": "ebay-weekly-review"
}
```
```jsonc
{
"artefact": {
"run": "20260824-161204-ebay-weekly-review",
"dir": "/data/artefacts/20260824-161204-ebay-weekly-review",
"path": "/data/artefacts/20260824-161204-ebay-weekly-review/ebay_get_orders-a3f1c92b.json",
"bytes": 18206,
"records": 25,
"recordPath": "orders",
"shape": ["total: number", "orders[*].lineItems[*].legacyItemId: string", "…"]
}
}
```
The agent then runs code against `dir`. Nothing about the payload enters its context.
This requires a code executor that can see the same absolute path — mount one
volume into both containers at the same location. Read-only on the executor's side
is the cleanest arrangement: artefacts are the nexus's output and its input.
**`artefacts` is a label, not a path.** The caller names the task; the nexus builds
the directory name from a timestamp and the label reduced to `[a-z0-9-]`, and
resolves it strictly under `root`. Every call sharing a label lands in one
directory until it has been idle for `runIdleMinutes`, so a multi-call pull needs no
coordination — and last week's run can never be read as this week's.
**Filenames are derived from the tool and a digest of its arguments**, so a retried
page overwrites itself instead of leaving a duplicate for the aggregation to
double-count.
**`records` is the count of the largest top-level array**, reported per file so a
caller can check that pages sum to the expected total without opening anything. A
page past the end of a feed reports `0` rather than going missing.
**Projections still apply.** The context argument for trimming disappears, but the
reason to keep buyer addresses out of a response is not that they are expensive.
`select` still overrides a configured projection, and `shape` describes what is
actually in the file — not the wider upstream response, which `get_schemas` still
reports in full.
**Errors are never written to a file.** Transport failures, upstream tool errors and
unmatched `select` paths all come back inline, as they do without a label. If the
*write* fails, the call returns an error naming the path and errno — the payload is
deliberately not returned instead, since dumping a whole feed into the context is
the failure the caller was avoiding.
Preloaded tools take no `artefacts` argument (or `select`), since they are dispatched
with the upstream schema verbatim. A tool wide enough to want either should be
reached through `call_tool`.
## Architecture
```
AI Agent ──Streamable HTTP──▶ mcp-nexus ──HTTP/stdio──▶ todoist, outlook, ...
│
In-memory index
Session management
```
- **Transport**: MCP Streamable HTTP (2025-11-05)
- **Auth**: Optional bearer token, with optional CORS origin allowlist
- **Health**: `GET /health` endpoint for monitoring (Uptime Kuma, etc.)
- **HTTP connection reuse**: keep-alive sessions per source, reaped after an idle timeout
## Project Structure
```
src/
index.ts Entry point with CLI args
config.ts YAML loader with Zod validation
types.ts Shared types and interfaces
logger.ts Structured logger
namespace.ts Tool name namespacing (<sourceId>__<toolName>)
glob-utils.ts Glob pattern matching for tool filtering
indexer.ts Startup index — fetches tools/list from all sources
artefacts.ts Run directories, artefact writing, retention
recovery.ts Background recovery probes for failed sources
validation.ts call_tool argument checking against the upstream input schema
compact-schema.ts Compact input schemas for search results
nexus-server.ts MCP server — tool definitions and request handling
sources/
http-source.ts HTTP transport client (Streamable HTTP)
stdio-source.ts Stdio transport client (subprocess, JSON-RPC)
search/
index.ts SearchEngine — strategy dispatch + fallback
types.ts Search config, result, and provider interfaces
lexical-search.ts Keyword matching (word-prefix scoring, stopwords dropped)
semantic-search.ts Embedding similarity search
hybrid-search.ts Reciprocal-rank fusion of the two, exact-name pinning
providers/
builtin.ts Transformers.js (all-MiniLM-L6-v2, local)
ollama.ts Ollama embedding API (nomic-embed-text)
openai.ts OpenAI-compatible embedding API
```
## Scripts
| Command | Description |
| ---------------------- | ----------------------------------- |
| `npm run dev` | Run with hot reload via `tsx watch` |
| `npm start` | Run without watch |
| `npm run build` | Compile TypeScript to `dist/` |
| `npm run docker:build` | Build Docker image |
| `npm run docker:run` | Run Docker container |
| `npm run eval` | Search relevance eval against `eval/baseline.json` |
| `npm run eval:snapshot`| Refresh `eval/tools.json` from a running nexus (`NEXUS_URL`, `NEXUS_TOKEN`) |
## Relevance eval
`eval/` measures search ranking offline, so a change to it is judged by numbers rather than a few hand-run queries. `eval/tools.json` is a snapshot of the live corpus (names, descriptions and parameter names only); `eval/queries.json` lists queries with every acceptable tool, including queries nothing should match. `npm run eval` scores hit@5, MRR and the share of nonsense queries returning nothing for each strategy, and fails on any regression from `eval/baseline.json`. CI runs it on every push.
A deliberate improvement is recorded with `npm run eval -- --update`, in the same commit as the change. Refreshing the corpus with `npm run eval:snapshot` invalidates the baseline, so re-baseline in the same commit.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues