MCP Nexus
Allows task and project management via Todoist API through the MCP Nexus middleware.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@MCP Nexusbrowse available services"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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"}) → resultAgents 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.
Related MCP server: mcptool
Quick Start
Prerequisites
Node.js 22+
Install & Run
# 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 --verboseVerify It's Running
# Health check
curl http://localhost:8050/healthConfiguration
Create a mcp-nexus.yaml file:
port: 8050
auth:
enabled: false # Set to true in production; tokens come from the environment
allowedOrigins: # Optional — restrict CORS to these origins when auth is on
- https://openwebui.local
clients: # Optional — per-client policy (see Gateway)
lyra:
deny: ["ebay__ebay_issue_refund"]
confirmDestructive: true
connectors:
httpReuseIdleTimeoutSeconds: 300 # Reap idle upstream HTTP sessions after N seconds
recoveryIntervalSeconds: 30 # Probe failed sources every N seconds (0 = disabled)
maxResponseBytes: 33554432 # Abandon an upstream response larger than this
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-foldersConfig Reference
The schema is strict: a key it does not know — misspelt, or retired like type and
projections — stops startup with the path of the key, rather than being dropped.
Field | Description |
| HTTP port for the MCP endpoint (default: 8050) |
| Require |
| The shared token, authenticating as client |
| Globs over namespaced tool names ( |
| Globs; matching tools are hidden and refused. Wins over |
| Globs; matching tools need a confirmed second call |
| Also confirm every tool its service annotates |
| Optional list of origins allowed via CORS when auth is enabled. If omitted, the request |
| Idle timeout before a cached upstream HTTP session is reaped (default: 300) |
| Interval (seconds) for background recovery probes of failed sources. 0 = disabled (default: 30) |
| An upstream response larger than this is abandoned as it arrives, and the call returns an error (default: 33554432) |
| Search strategy: |
| Max results returned by |
| Embedding provider: |
| Model name (provider-specific; defaults vary by provider) |
| Base URL for |
| Name of env var containing the API key (required for |
| Batch size for embedding generation at index time (default: 32) |
| Where to cache the downloaded model ( |
| Cosine similarity a tool must reach to be returned at all (default: 0.25, calibrated for all-MiniLM-L6-v2) |
| Unique identifier for the source (used in namespaced tool names) |
|
|
| Upstream MCP server URL (required for HTTP transport) |
| Executable to spawn (required for stdio transport) |
| Optional glob patterns to curate which tools are indexed |
| Optional array of non-prefixed tool names to surface directly in |
| Pass arguments a tool's schema does not declare through to it. Off by default: they are refused (see MCP Tools) |
| Directory artefacts are written under. Omit the whole |
| Delete run directories older than this, at startup and daily (default: 14; 0 = never) |
| How long a label keeps resolving to the same run directory (default: 180) |
| 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.
Under semantic and hybrid search each hit carries its similarity to the query (cosine, to two places), and the response carries minSimilarity. A broad query can match a hundred tools, so totalMatches alone cannot say whether the top hit is a close match or merely the best of a poor set; the similarity can.
A client with a policy (see Gateway) searches only the tools it may call: the rest are left out before ranking, so they neither appear nor count.
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.
A query word also matches through two kinds of variant, at half weight so the exact word still wins a tie: a plural matches its singular (tasks finds task), and the read verbs list, get, fetch, retrieve, show and read match each other. Services name their read tools with different verbs — ms365's list-todo-tasks, Todoist's todoist_task_get — and without this "list my tasks" was evidence for ms365's tools alone.
search:
type: lexical
maxResults: 20Semantic
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.
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-cacheEmbedding Providers
Provider | Description | Config |
| Local model via Transformers.js (all-MiniLM-L6-v2, 384d) | No external dependencies. Downloads model on first run. |
| Local Ollama instance (nomic-embed-text, 768d) | Requires |
| Any OpenAI-compatible API (text-embedding-3-small, 1536d) | Requires |
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.
search:
type: hybrid
maxResults: 5
semantic:
provider: built-in
model: Xenova/all-MiniLM-L6-v2Ranking 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 reachesminSimilarity, 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-caseorcamelCase— is pinned first withpinned: true.A service's own "use X instead" is followed. When a matching tool's description says to use another of the service's tools instead — ms365's
list-calendar-eventspoints toget-calendar-viewfor anything in a date range — that tool is placed directly after it, withmatched: suggested(unless it matched on its own) andsuggestedBynaming the tool that pointed to it. After, not before: such pointers are often conditional, so the caller is shown both and reads both descriptions.Every hit says how it matched:
matchedissemantic,lexical,bothorsuggested, andsimilaritygives the semantic closeness. 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, and /health reports it for whoever runs the nexus: search.semantic is unavailable when the provider never started, failing when the latest query that needed it failed, with the error and lastFallbackAt. Either makes the overall status degraded.
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.
# 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-nexusOr use the provided Dockerfile directly:
docker build -t mcp-nexus .MCP Tools
The nexus exposes these tools to connected AI agents:
Tool | What it does |
| List all available upstream services with descriptions and tool counts |
| List all tools for a specific service (namespaced names) |
| Search for tools by keyword (lexical) or natural language (semantic); every hit carries a compact schema |
| Get input schemas and inferred response shapes for one or more tools |
| 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 |
| 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.
Unknown arguments are refused. Most upstream schemas leave additionalProperties
unset, which JSON Schema reads as "anything goes", and the services then ignore keys
they don't know. A misspelt filter — due_date for due_before — reached Todoist,
was dropped there, and every task came back with nothing to say the filter never
applied. call_tool now refuses any top-level argument the tool's schema does not
declare, whatever additionalProperties says, and suggests the declared one it was
probably meant to be. A schema that declares a map (additionalProperties as a
schema, or patternProperties) or no properties at all is left alone, and a service
whose tools genuinely take extra keys sets sources[].allowUnknownArguments.
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.
select. To trim fields as well, give call_tool a select array of dotted
paths. [*] maps over an array, and the original nesting is preserved:
{
"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.
A tool that returns plain text rather than JSON — Todoist's task tools all do — has
nothing to select from. The text comes back as it was, with a note that select was
ignored, rather than as an error: the call has already been made, and refusing it
afterwards only made the caller make it again.
Trimming happens only when the caller asks. There used to be a per-tool default trim
in the config (projections); it was removed because the caller could not see it, so
a field it had cut read as a field the service never returned. select and
artefacts between them cover what it was for.
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
select trims 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:
{
"toolName": "ebay__ebay_get_orders",
"parameters": { "limit": 25, "offset": 50 },
"artefacts": "ebay-weekly-review"
}{
"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.
select still applies. The context argument for trimming disappears, but the
reason to keep buyer addresses out of a file is not that they are expensive. With
both, the file holds only the selected paths, 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.
Gateway
Every caller is a named client, every call is logged under that name, and a client
can be limited in what it may call. None of it is on until configured: a deployment
with only the shared token behaves as before, with its calls logged as default.
Clients. MCP_NEXUS_AUTH_TOKEN is the shared token and authenticates as
default. Each MCP_NEXUS_TOKEN_<NAME> gives a client its own token —
MCP_NEXUS_TOKEN_LYRA is client lyra. Tokens live in the environment only. Two
clients with the same token are refused at startup, since neither could then be told
apart. A session belongs to the client that opened it: a request carrying another
client's token with that session's ID is refused with 403.
Policy, per client under auth.clients, is globs over namespaced tool names, so
ebay__* names a service and ms365__send-mail one tool. allow, when set, is the
whole of what the client may use; deny removes from it and wins. It is enforced in
call_tool, the only route to an upstream, and also shapes what the client is shown:
denied tools are left out of browse_services counts, browse_tools, search_tools,
get_schemas, preloaded tools and the service roster. (sources[].filter is
different: it shapes the index for every client, and is not a control.)
Confirmation. A tool matching the client's confirm patterns, or — with
confirmDestructive: true — one its service annotates destructiveHint: true, is
refused on the first call with a confirm token. Repeating the identical call with
that token as confirm goes through. The token is bound to the client, the tool and
the exact parameters, is spent on use, and expires after five minutes, so it cannot
be replayed against another record or by another client. The refusal tells the agent
to describe the call to the user and wait for their go-ahead; the token proves the
call was repeated, not that a person agreed, which is why the call log records each
refusal and each confirmed call.
Only an explicit destructiveHint: true counts. MCP defaults the hint to true for any
tool not marked read-only, so reading an absent annotation as destructive would gate
every unannotated tool — all of Todoist and eBay, refunds included. Those are named in
confirm instead. (At the time of writing ms365 marks 102 of its 178 tools
destructive, including creating calendar events; Google Workspace 18; Plex 1.)
The confirm argument is advertised only to clients whose policy can ask for it.
Call log. Every call_tool, refused or not, writes one JSON line tagged
[nexus][calls]:
2026-10-01T11:14:38.618Z [nexus][calls] [INFO] {"client":"lyra","tool":"todoist__todoist_task_get","outcome":"ok","ms":70,"args":["filter","limit"]}outcome is ok, tool_error, transport_error, invalid_arguments, not_found,
denied, confirmation_required or artefact_error. Arguments are logged by name
only — their values carry message bodies, addresses and IDs. docker logs mcp-nexus | grep '\[calls\]' gives the log alone.
Architecture
AI Agent ──Streamable HTTP──▶ mcp-nexus ──HTTP/stdio──▶ todoist, outlook, ...
│
In-memory index
Session managementTransport: MCP Streamable HTTP (2025-11-05)
Auth: Optional bearer tokens, one per client, with optional CORS origin allowlist (see Gateway)
Health:
GET /healthendpoint for monitoring (Uptime Kuma, etc.), including whether search is running as configuredHTTP connection reuse: keep-alive sessions per source, reaped after an idle timeout
Response cap: an upstream response over
connectors.maxResponseBytesis abandoned as it arrives rather than buffered whole. A stdio source cannot be resynchronised mid-line, so its process is restarted on the next callShutdown stops taking requests, waits up to eight seconds for those in flight to finish their responses, and only then closes sessions and stops stdio children
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
gateway.ts Client tokens, per-client policy, confirmation tokens, the call log
response.ts JSON payload resolution, minification, `select`, shape inference
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)
limits.ts The upstream response size cap both transports enforce
search/
index.ts SearchEngine — strategy dispatch, fallback, status for /health
types.ts Search config, result, and provider interfaces
lexical-search.ts Keyword matching (word-prefix scoring, stopwords dropped, variants)
semantic-search.ts Embedding similarity search
hybrid-search.ts Reciprocal-rank fusion of the two, exact-name pinning
redirects.ts A service's own "use X instead" pointers between its tools
providers/
builtin.ts Transformers.js (all-MiniLM-L6-v2, local)
ollama.ts Ollama embedding API (nomic-embed-text)
openai.ts OpenAI-compatible embedding API
test/
pure.test.ts The pure modules: namespacing, globs, select, shapes, compact schemas
gateway.test.ts Config, authentication, policy, confirmation, validation, search
call-tool.test.ts call_tool end to end: a real server and client, a stub upstream
stub-upstream.mjs The stub: a stdio MCP server with one tool per behaviour under testScripts
Command | Description |
| Run with hot reload via |
| Run without watch |
| Compile TypeScript to |
| Unit tests, and |
| Build Docker image |
| Run Docker container |
| Search relevance eval against |
| Refresh |
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
Related MCP Connectors
The OpenRouter for tools. One MCP connection gives any AI agent 254 hosted tools, pay per call.
One MCP endpoint for Claude, GPT & Gemini: 100+ tools + no-code connectors + agent workers.
MCP server for your apps' tools and custom tools, plus hosted AI agents and approval-gated workflows
The Remote MCP server acts as a standardized bridge between LLM applications (like Claude, ChatGPT, and Cursor) and external services, enabling AI agents to access external tools and resources. Its primary capability is providing a centralized search tool to discover other MCP servers and their respective tools. Unlike local implementations, it runs remotely with OAuth authentication and permission controls for security.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceAn MCP aggregator that consolidates multiple MCP servers behind a single interface with just 3 tools (search, get details, execute), reducing context pollution for AI agents by avoiding direct exposure of numerous tool schemas.18 npm2MIT
- AlicenseNot gradedqualityNot gradedmaintenanceA drop-in MCP proxy that aggregates multiple backend servers into two meta-tools for efficient tool discovery and execution. It enables AI clients to access hundreds of tools while minimizing context window usage through searchable indexing.1 npm-
- AlicenseNot gradedqualityDmaintenanceEnterprise-grade dynamic MCP proxy that eliminates token bloat by lazy-loading tool schemas based on semantic intent, enabling efficient orchestration of multiple backend tools from a single endpoint.MIT
- AlicenseNot gradedqualityCmaintenanceA proxy MCP server that manages multiple upstream MCP servers by grouping them and loading tool schemas on-demand, reducing LLM context usage.5,258 npm51MIT