Skip to main content
Glama
codespriha

mcp-router-local

by codespriha
README.md
# mcp-router-local

**Zero-dependency local Model Context Protocol (MCP) router.** Aggregate multiple MCP
servers behind a single endpoint so an LLM client (Claude Desktop, Claude Code, Cursor,
or any JSON-RPC 2.0 over stdio/SSE client) connects to **one** router instead of
managing many individual server processes.

Built on native Node.js modules only — `child_process`, `readline`, `fs`, `path`, `http`.
No runtime dependencies, no third-party parsers, nothing to install but the router itself.

## Features

- **Single endpoint aggregation** — one stdio connection (or one local SSE endpoint)
  that fronts every configured sub-server.
- **Dynamic config discovery** — reads `mcp-router.config.json` from the current working
  directory: executable paths, arguments, env vars, and per-server behavior.
- **Protocol aggregation** — spawns every sub-server on startup, performs the MCP
  handshake, and merges `tools/list`, `resources/list`, `resources/templates/list`, and
  `prompts/list` into one unified capability manifest.
- **Intelligent routing** — `tools/call`, `resources/read`, and `prompts/get` are
  intercepted, matched to the owning sub-server, forwarded, and the response is safely
  returned to the client. Tool-name collisions across servers are auto-namespaced.
- **Process safety** — a crashing sub-server is logged to a local debug file (never
  stdout, so the JSON-RPC stream stays clean) and restarted with capped exponential
  backoff. On exit, all children are terminated gracefully (SIGTERM → SIGKILL).

## Requirements

- Node.js ≥ 18 (ESM)

## Install

```bash
npm install -g mcp-router-local   # global CLI: mcp-router
# or run without installing:
npx mcp-router-local
```

## Quick start

Create `mcp-router.config.json` in the directory you'll run the router from:

```json
{
  "servers": [
    {
      "id": "filesystem",
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
    },
    {
      "id": "db",
      "command": "python",
      "args": ["db_mcp_server.py"],
      "env": { "DATABASE_URL": "postgres://localhost/app" }
    }
  ]
}
```

Then start the router:

```bash
mcp-router
```

Point your LLM client at it. For a client that spawns servers over stdio, configure the
command `mcp-router` (with `--config <path>` if needed). For example, Claude Code:

```json
{
  "mcpServers": {
    "all-my-servers": {
      "command": "mcp-router",
      "args": ["--config", "/absolute/path/to/mcp-router.config.json"]
    }
  }
}
```

> The router announces itself as a *single* server exposing the union of every
> sub-server's tools, resources, and prompts. From the client's perspective there is
> only one server.

## Configuration reference

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string` | `mcp-router-local` | Server name sent in the `initialize` handshake. |
| `version` | `string` | `0.1.0` | Server version sent in the handshake. |
| `transport.type` | `"stdio" \| "sse"` | `"stdio"` | Client-facing transport. |
| `transport.host` | `string` | `127.0.0.1` | SSE bind host. |
| `transport.port` | `number` | `8765` | SSE bind port. |
| `servers[]` | `array` | — | **Required.** At least one sub-server entry. |
| `timeouts.initialize` | `number` (ms) | `15000` | Handshake timeout per sub-server. |
| `timeouts.list` | `number` (ms) | `15000` | `tools/list` / `resources/list` / `prompts/list` timeout. |
| `timeouts.call` | `number` (ms) | `60000` | `tools/call` timeout. |
| `timeouts.general` | `number` (ms) | `30000` | `resources/read` / `prompts/get` timeout. |
| `debug.enabled` | `boolean` | `false` | Mirror warnings/errors to stderr. |
| `debug.logFile` | `string` | `./mcp-router.debug.log` | Diagnostic log path (resolved next to the config file). |

### Server entry (`servers[]`)

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` | — | **Required.** Unique id used for routing and logs. |
| `command` | `string` | — | **Required.** Executable to spawn. |
| `args` | `string[]` | `[]` | Arguments passed to the executable. |
| `cwd` | `string` | inherit | Working directory for the child process. |
| `env` | `object` | inherit | Extra env vars merged over the router's environment. |
| `autoRestart` | `boolean` | `true` | Restart the child after a crash. |
| `maxRestarts` | `number` | `5` | Restart attempts before giving up. |

## Transports

### stdio (default)

`mcp-router` reads line-delimited JSON-RPC from stdin and writes responses to stdout.
All diagnostics go to the debug file (and optionally stderr) — stdout carries **only**
JSON-RPC, so your client never sees log noise.

### SSE

Set `transport.type` to `"sse"`, then:

- `GET http://host:port/sse` — opens the event stream (sends `event: endpoint`).
- `POST http://host:port/message` — accepts JSON-RPC bodies, returns `202`, and pushes
  responses over the open stream. Requests received before a client connects are queued
  and drained on connect.

## Handling tool-name collisions

If two sub-servers expose a tool with the same name, the router keeps the first server's
name untouched and renames the later one to `<serverId>__<toolName>`. A warning is
written to the debug log:

```
Tool "shared" exists on both "alpha" and "beta"; exposed as "beta__shared" to avoid collision
```

Call the renamed tool exactly as exposed; the router strips the prefix and forwards the
original name to the owning sub-server.

## Crash handling & restart backoff

When a sub-server exits unexpectedly:

1. A clean error is written to the debug file (stderr too if `debug.enabled`).
2. The router attempts a restart with exponential backoff: `1s, 2s, 4s, 8s, 15s…`
   (capped at 15s), up to `maxRestarts`.
3. On each restart the router re-runs the handshake, re-gathers capabilities, and
   rebuilds its routing indexes.
4. A server that stays alive for 2 minutes resets its restart counter.

## Demo

A self-contained demo ships in `demo/` — no real MCP servers needed:

```bash
npm run build
node demo/run.mjs
```

It starts the router against two built-in mock servers (`alpha` and `beta`), runs an
`initialize` handshake, lists the merged tools, and calls tools on both — including the
collision-renamed `beta__shared` — then tears everything down. Open
`demo/mcp-router.config.json` to see the shape it uses.

## Development

```bash
npm install
npm run type-check   # strict TypeScript
npm run build        # tsup -> dist/index.js + dist/index.d.ts
```

## License

MIT