@cyanmycelium/mcp-broker 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mcp-broker.example/CONFIGURATION-EN.md +300 -44
- package/.mcp-broker.example/CONFIGURATION-FR.md +313 -44
- package/.mcp-broker.example/README.md +73 -2
- package/.mcp-broker.example/config.json +18 -9
- package/.mcp-broker.example/config.stdio-bridge.json +16 -0
- package/README.md +407 -27
- package/dist/bin.js +215 -20
- package/dist/bin.js.map +1 -1
- package/dist/chunk-BZUZYXVA.js +5955 -0
- package/dist/chunk-BZUZYXVA.js.map +1 -0
- package/dist/grammars/claude/en.json +12 -0
- package/dist/grammars/claude/fr.json +12 -0
- package/dist/grammars/default/en.json +40 -0
- package/dist/grammars/default/fr.json +40 -0
- package/dist/grammars/default/zh.json +40 -0
- package/dist/index.d.ts +991 -25
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/src/auth/index.ts +3 -1
- package/src/auth/provider.auth.ts +126 -8
- package/src/authorization/policy.engine.ts +11 -2
- package/src/authorization/policy.types.ts +25 -1
- package/src/bin.ts +325 -28
- package/src/broker/adapters/broker.adapter.diagnose.ts +45 -0
- package/src/broker/adapters/broker.adapter.guide.ts +108 -0
- package/src/broker/aggregate/aggregate.server.ts +82 -15
- package/src/broker/aggregate/provider.client.session.ts +85 -11
- package/src/broker/behaviors/broker.behavior.diagnose.ts +47 -0
- package/src/broker/behaviors/broker.behavior.guide.ts +79 -0
- package/src/broker/broker.context.ts +65 -0
- package/src/broker/broker.diagnostics.ts +495 -0
- package/src/broker/broker.guides.ts +1029 -0
- package/src/broker/broker.server.ts +23 -7
- package/src/broker/broker.slots.ts +36 -0
- package/src/broker/grammars/claude/en.json +12 -0
- package/src/broker/grammars/claude/fr.json +12 -0
- package/src/broker/grammars/default/en.json +40 -0
- package/src/broker/grammars/default/fr.json +40 -0
- package/src/broker/grammars/default/zh.json +40 -0
- package/src/config.ts +191 -4
- package/src/index.ts +38 -3
- package/src/remote.transports.ts +127 -10
- package/src/remote.upstream.ts +4 -1
- package/src/ws/ws.interfaces.ts +148 -3
- package/src/ws/ws.tunnel.builder.ts +63 -1
- package/src/ws/ws.tunnel.ts +1150 -173
- package/web/README.md +31 -4
- package/dist/chunk-FTDKH2C4.js +0 -3670
- package/dist/chunk-FTDKH2C4.js.map +0 -1
|
@@ -4,12 +4,21 @@
|
|
|
4
4
|
"locale": "fr",
|
|
5
5
|
"brokerName": "broker-eu-west",
|
|
6
6
|
|
|
7
|
+
"allowedOrigins": ["https://app.factory.local", "https://mcp.factory.local"],
|
|
8
|
+
|
|
7
9
|
"paths": {
|
|
8
|
-
"provider":
|
|
9
|
-
"
|
|
10
|
-
"
|
|
10
|
+
"provider": "/provider",
|
|
11
|
+
"providers": "/providers",
|
|
12
|
+
"client": "/",
|
|
13
|
+
"mcp": "/mcp",
|
|
14
|
+
"sse": "/sse",
|
|
15
|
+
"messages": "/messages"
|
|
11
16
|
},
|
|
12
17
|
|
|
18
|
+
"providerHeartbeatIntervalMs": 30000,
|
|
19
|
+
"providerRequestTimeoutMs": 60000,
|
|
20
|
+
"providerTakeover": "liveness",
|
|
21
|
+
|
|
13
22
|
"tls": {
|
|
14
23
|
"cert": "certs/cert.pem",
|
|
15
24
|
"key": "certs/key.pem"
|
|
@@ -23,7 +32,7 @@
|
|
|
23
32
|
},
|
|
24
33
|
|
|
25
34
|
"auth": {
|
|
26
|
-
"enabled":
|
|
35
|
+
"enabled": false,
|
|
27
36
|
"publicBaseUrl": "https://mcp.factory.local",
|
|
28
37
|
"authorizationServers": [
|
|
29
38
|
"https://identity.factory.local"
|
|
@@ -104,15 +113,15 @@
|
|
|
104
113
|
},
|
|
105
114
|
"audit": {
|
|
106
115
|
"logAllowed": false
|
|
107
|
-
}
|
|
108
|
-
"providerSecret": "change-me"
|
|
116
|
+
}
|
|
109
117
|
},
|
|
110
118
|
|
|
111
119
|
"stdioUpstreams": [
|
|
112
120
|
{
|
|
113
|
-
"name":
|
|
114
|
-
"command":
|
|
115
|
-
"args":
|
|
121
|
+
"name": "fs",
|
|
122
|
+
"command": "npx",
|
|
123
|
+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
|
|
124
|
+
"aggregate": true
|
|
116
125
|
}
|
|
117
126
|
],
|
|
118
127
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"port": 3001,
|
|
3
|
+
"host": "127.0.0.1",
|
|
4
|
+
"locale": "en",
|
|
5
|
+
|
|
6
|
+
"stdioProvider": "_all",
|
|
7
|
+
|
|
8
|
+
"stdioUpstreams": [
|
|
9
|
+
{
|
|
10
|
+
"name": "fs",
|
|
11
|
+
"command": "npx",
|
|
12
|
+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
|
|
13
|
+
"aggregate": true
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
package/README.md
CHANGED
|
@@ -10,7 +10,17 @@
|
|
|
10
10
|
|
|
11
11
|
WebSocket-based [Model Context Protocol](https://modelcontextprotocol.io/) broker. Aggregates multiple MCP providers behind a single endpoint, with WebSocket, Streamable HTTP, SSE, and stdio client transports.
|
|
12
12
|
|
|
13
|
-
> This is the Node/TypeScript implementation of the broker. Architecture, wire protocol, and endpoints are documented language-neutrally in [
|
|
13
|
+
> This is the Node/TypeScript implementation of the broker. Architecture, wire protocol, and endpoints are documented language-neutrally in the [repository `docs/` folder](https://github.com/pandaGaume/mcp-broker/tree/main/docs), which is not shipped in the npm package. Every link out of this file is therefore absolute.
|
|
14
|
+
|
|
15
|
+
| I want to | go to |
|
|
16
|
+
|---|---|
|
|
17
|
+
| publish my MCP server into a slot | [the pairing rule](#the-one-rule-that-costs-the-most-time) |
|
|
18
|
+
| wire Claude Desktop or another stdio host | [Claude Desktop integration](#claude-desktop-integration) |
|
|
19
|
+
| reach a slot as a client | [Install and run](#install-and-run), [Browser origins](#browser-origins) |
|
|
20
|
+
| know what `_broker` and `_all` are | [Reserved slots](#reserved-slots-_broker-and-_all) |
|
|
21
|
+
| configure the process | [Configuration](#configuration) |
|
|
22
|
+
| embed the broker in my own process | [Programmatic API](#programmatic-api) |
|
|
23
|
+
| fix something that is already broken | [Troubleshooting](#troubleshooting), or call `broker_diagnose` |
|
|
14
24
|
|
|
15
25
|
## Install and run
|
|
16
26
|
|
|
@@ -28,6 +38,47 @@ The broker starts on `http://localhost:3000` by default.
|
|
|
28
38
|
- Connect your MCP provider to: `ws://localhost:3000/provider/<name>`
|
|
29
39
|
- Point an MCP client at: `http://localhost:3000/<name>/mcp` (Streamable HTTP) or `http://localhost:3000/<name>/sse` (legacy SSE) or `ws://localhost:3000/<name>` (raw WS)
|
|
30
40
|
|
|
41
|
+
### The broker documents itself
|
|
42
|
+
|
|
43
|
+
Once it is running you do not need this README. The reserved `_broker` slot
|
|
44
|
+
serves the integration guide over MCP, and diagnoses the deployment:
|
|
45
|
+
|
|
46
|
+
| call it with | and you get |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `broker_guide({ topic? })`, or read `broker://guide/{topic}` | six Markdown pages (`index`, `publish-provider`, `connect-client`, `host-config`, `deploy`, `troubleshooting`) with **this** broker's effective configuration injected into each |
|
|
49
|
+
| `broker_diagnose({ slot? })` | live state plus the problems the broker can prove, each with `symptom`, `evidence` and a `fix` |
|
|
50
|
+
|
|
51
|
+
Point any MCP client at `http://localhost:3000/_broker/mcp` and call them. From
|
|
52
|
+
a stdio host bridged to `_all` they are named `_broker-broker_guide` and
|
|
53
|
+
`_broker-broker_diagnose`. When something does not work, call `broker_diagnose`
|
|
54
|
+
before reading anything: it does the correlation for you and names the fix.
|
|
55
|
+
|
|
56
|
+
### The one rule that costs the most time
|
|
57
|
+
|
|
58
|
+
A provider's transport and its URL path are a matched pair:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
DirectTransport <-> ws://<host>/provider/<name> plain JSON-RPC frames
|
|
62
|
+
MultiplexTransport <-> ws://<host>/providers envelopes { provider, payload }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`ws://<host>/providers/<name>` is **neither**. The router matches `/providers`
|
|
66
|
+
exactly and `/provider/` as a prefix, so a URL starting with `/providers/` falls
|
|
67
|
+
through to the client branch and is accepted as an MCP *client* on a slot named
|
|
68
|
+
`providers/<name>`. Nothing errors; your slot stays empty.
|
|
69
|
+
|
|
70
|
+
Both mismatches are detected on the first frame and refused with WebSocket close
|
|
71
|
+
code `1008` and a reason naming the correction. Older brokers stay silent, so
|
|
72
|
+
the observable signatures are worth knowing:
|
|
73
|
+
|
|
74
|
+
- **MultiplexTransport on `/provider/<name>`**: `provider_status` reports
|
|
75
|
+
`connected: true`, `transport: "ws"`, `pendingCount` climbing and never
|
|
76
|
+
falling, while the client's `initialize` never resolves.
|
|
77
|
+
- **DirectTransport on `/providers`**: your socket is open and the slot never
|
|
78
|
+
appears in `providers_list` at all.
|
|
79
|
+
|
|
80
|
+
`broker_diagnose` reports the first as `transport-path-mismatch`.
|
|
81
|
+
|
|
31
82
|
## Configuration
|
|
32
83
|
|
|
33
84
|
Two sources, env vars **always win** over the file. The file is the static baseline you ship with the broker; env vars are deploy-specific overrides.
|
|
@@ -53,10 +104,7 @@ Minimal `config.json`:
|
|
|
53
104
|
{
|
|
54
105
|
"port": 3001,
|
|
55
106
|
"locale": "fr",
|
|
56
|
-
"
|
|
57
|
-
"cert": "certs/cert.pem",
|
|
58
|
-
"key": "certs/key.pem"
|
|
59
|
-
},
|
|
107
|
+
"allowedOrigins": ["http://localhost:3001"],
|
|
60
108
|
"www": {
|
|
61
109
|
"open": false,
|
|
62
110
|
"mounts": [{ "urlPrefix": "/", "dir": "www" }]
|
|
@@ -65,42 +113,188 @@ Minimal `config.json`:
|
|
|
65
113
|
{
|
|
66
114
|
"name": "fs",
|
|
67
115
|
"command": "npx",
|
|
68
|
-
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
|
|
116
|
+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
|
|
117
|
+
"aggregate": true
|
|
69
118
|
}
|
|
119
|
+
],
|
|
120
|
+
"mcpServers": [
|
|
121
|
+
{ "name": "geo", "url": "https://geo.example.com/mcp" }
|
|
70
122
|
]
|
|
71
123
|
}
|
|
72
124
|
```
|
|
73
125
|
|
|
74
|
-
|
|
126
|
+
Three ways to give the broker a provider without writing any code:
|
|
127
|
+
`stdioUpstreams[]` spawns a child process, `mcpServers[]` dials out to a remote
|
|
128
|
+
MCP server by URL, and `mcpbBundles[]` verifies and runs a signed `.mcpb`
|
|
129
|
+
bundle. Note that `stdioUpstreams` entries are **not** in `_all` unless you
|
|
130
|
+
write `"aggregate": true`, while the other two are in unless you write
|
|
131
|
+
`"aggregate": false`.
|
|
132
|
+
|
|
133
|
+
Start from the [`.mcp-broker.example/`](.mcp-broker.example/) template, which
|
|
134
|
+
ships with authorization off and every key annotated:
|
|
75
135
|
|
|
76
136
|
```sh
|
|
77
137
|
cp -r node_modules/@cyanmycelium/mcp-broker/.mcp-broker.example .mcp-broker
|
|
78
138
|
```
|
|
79
139
|
|
|
80
|
-
Full reference (every field, defaults, recipes, grammar overrides): **[docs/config.md](docs/config.md)**.
|
|
140
|
+
Full reference (every field, defaults, recipes, grammar overrides): **[docs/config.md](https://github.com/pandaGaume/mcp-broker/blob/main/node/packages/broker/docs/config.md)**.
|
|
81
141
|
|
|
82
142
|
### Option B: Environment variables
|
|
83
143
|
|
|
144
|
+
This table is complete: it lists every `MCP_BROKER_*` variable the CLI reads.
|
|
145
|
+
|
|
84
146
|
| Variable | Default | Notes |
|
|
85
147
|
|---|---|---|
|
|
86
148
|
| `MCP_BROKER_CONFIG` | `./.mcp-broker/config.json` | Path to a JSON config file (see above) |
|
|
87
149
|
| `MCP_BROKER_PORT` | `3000` | TCP port to listen on |
|
|
88
150
|
| `MCP_BROKER_HOST` | `0.0.0.0` | Interface to bind |
|
|
89
|
-
| `
|
|
151
|
+
| `MCP_BROKER_LOCALE` | `en` | BCP-47 tag driving the `_broker` tool descriptions |
|
|
152
|
+
| `MCP_BROKER_ALLOWED_ORIGINS` | (unset) | Comma-separated browser origins allowed on the client endpoints. **Unset means no browser origin passes.** See below |
|
|
153
|
+
| `MCP_BROKER_PROVIDER_PATH` | `/provider` | Prefix for dedicated provider WS connections (`DirectTransport`, plain frames) |
|
|
154
|
+
| `MCP_BROKER_PROVIDERS_PATH` | `/providers` | Exact path for multiplexed provider WS connections (`MultiplexTransport`, envelopes) |
|
|
90
155
|
| `MCP_BROKER_CLIENT_PATH` | `/` | Prefix for raw WS clients |
|
|
91
|
-
| `MCP_BROKER_MCP_PATH` | `/mcp` |
|
|
156
|
+
| `MCP_BROKER_MCP_PATH` | `/mcp` | Per-slot suffix for Streamable HTTP |
|
|
157
|
+
| `MCP_BROKER_SSE_PATH` | `/sse` | Per-slot suffix for the legacy SSE stream |
|
|
158
|
+
| `MCP_BROKER_MESSAGES_PATH` | `/messages` | Per-slot suffix for legacy SSE posts |
|
|
159
|
+
| `MCP_BROKER_PROVIDER_HEARTBEAT_MS` | `30000` | ws-level ping interval on provider sockets. `0` disables |
|
|
160
|
+
| `MCP_BROKER_PROVIDER_REQUEST_TIMEOUT_MS` | `60000` | How long a provider has to answer one request before it is failed. `0` disables |
|
|
161
|
+
| `MCP_BROKER_PROVIDER_TAKEOVER` | `liveness` | `reject`, `liveness` or `always` when a second provider claims an occupied slot |
|
|
92
162
|
| `MCP_BROKER_WWW_DIR` | (unset) | If set, serve this directory at `/` |
|
|
93
163
|
| `MCP_BROKER_BUNDLE_DIR` | (unset) | If set, serve this directory at `/bundle` |
|
|
94
|
-
| `MCP_BROKER_OPEN` | (unset) | `1`
|
|
164
|
+
| `MCP_BROKER_OPEN` | (unset) | `1` opens the broker root on startup; a `/path` or a same-origin absolute URL opens that page. Opens only when a static mount actually covers the resolved path |
|
|
95
165
|
| `MCP_BROKER_TLS_CERT` | (unset) | Path to a PEM certificate. Enables HTTPS/WSS |
|
|
96
166
|
| `MCP_BROKER_TLS_KEY` | (unset) | Path to a PEM private key. Enables HTTPS/WSS |
|
|
97
167
|
| `MCP_BROKER_PROTOCOL` | auto | `http` forces plain, `https` forces TLS, unset auto-detects from cert+key |
|
|
98
|
-
| `MCP_BROKER_STDIO_PROVIDER` | (unset) | When set, bridge stdin/stdout JSON-RPC to this
|
|
168
|
+
| `MCP_BROKER_STDIO_PROVIDER` | (unset) | When set, bridge stdin/stdout JSON-RPC to this slot. **Use `_all`**, see [Claude Desktop integration](#claude-desktop-integration) |
|
|
99
169
|
| `MCP_BROKER_AUTH_ENABLED` | (unset) | `1` to turn on the OAuth 2.1 resource server (requires the three below) |
|
|
100
170
|
| `MCP_BROKER_PUBLIC_BASE_URL` | (unset) | Public origin used to build canonical resource URIs, e.g. `https://mcp.example.com` |
|
|
101
171
|
| `MCP_BROKER_JWKS` | (unset) | Authorization server's JWKS URL, used to verify token signatures |
|
|
102
172
|
| `MCP_BROKER_ISSUER` | (unset) | Expected token issuer (defaults to the sole authorization server) |
|
|
103
|
-
| `MCP_BROKER_PROVIDER_SECRET` | (unset) | Shared secret every provider must present to occupy a slot |
|
|
173
|
+
| `MCP_BROKER_PROVIDER_SECRET` | (unset) | Shared secret every provider must present to occupy a slot. **Not gated by `auth.enabled`**: setting it alone turns provider authentication on |
|
|
174
|
+
|
|
175
|
+
`mcp-broker --help` prints the same list against the running build.
|
|
176
|
+
|
|
177
|
+
### Browser origins
|
|
178
|
+
|
|
179
|
+
Client endpoints (`/<slot>/mcp`, `/<slot>/sse`, `/<slot>/messages`) refuse any
|
|
180
|
+
request carrying an `Origin` header that is not allowed, with `403` and an
|
|
181
|
+
`invalid_origin` body. **The allow list is empty by default**, so out of the box
|
|
182
|
+
no browser origin passes while every non-browser client, which sends no
|
|
183
|
+
`Origin`, passes unchanged. This is the DNS-rebinding protection the MCP
|
|
184
|
+
specification asks for.
|
|
185
|
+
|
|
186
|
+
**A static mount does not exempt the origin it serves.** A page the broker
|
|
187
|
+
itself serves at `http://localhost:3000/` is still a browser origin and must be
|
|
188
|
+
listed:
|
|
189
|
+
|
|
190
|
+
```sh
|
|
191
|
+
MCP_BROKER_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Match the scheme, host and port exactly as the browser sends them. If the broker
|
|
195
|
+
runs with TLS, the origin is `https://`, not `http://`.
|
|
196
|
+
|
|
197
|
+
Scope, precisely: the check covers the three **HTTP** client endpoints. A raw
|
|
198
|
+
WebSocket upgrade (`ws://<host>/<slot>`) and both provider endpoints are not
|
|
199
|
+
origin-checked, so `allowedOrigins` is not a substitute for authentication.
|
|
200
|
+
|
|
201
|
+
Full reference, including the regular-expression form and the
|
|
202
|
+
`withAllowedOrigins` predicate, in [docs/config.md](https://github.com/pandaGaume/mcp-broker/blob/main/node/packages/broker/docs/config.md#allowedorigins).
|
|
203
|
+
|
|
204
|
+
## Reserved slots: `_broker` and `_all`
|
|
205
|
+
|
|
206
|
+
Two slot names are reserved. A provider that tries to claim either is refused
|
|
207
|
+
with `Provider "<name>" is reserved by the broker`.
|
|
208
|
+
|
|
209
|
+
### `_broker`, introspection
|
|
210
|
+
|
|
211
|
+
The broker registers **itself** as a provider under `_broker`, over an
|
|
212
|
+
in-process loopback transport. Reach it through any client transport, e.g.
|
|
213
|
+
`http://localhost:3000/_broker/mcp`. It proxies nothing: it is not a route to
|
|
214
|
+
other slots.
|
|
215
|
+
|
|
216
|
+
| tool | arguments | returns |
|
|
217
|
+
|---|---|---|
|
|
218
|
+
| `broker_info` | none | `{ name, version, uptimeSeconds, host, port, tls, paths }` |
|
|
219
|
+
| `providers_list` | none | every slot known to the broker, connected or not |
|
|
220
|
+
| `provider_status` | `{ name }` | one slot in detail |
|
|
221
|
+
| `broker_guide` | `{ topic? }` | one of the six guide pages; no argument returns the index |
|
|
222
|
+
| `broker_diagnose` | `{ slot? }` | live state plus proven problems, each with `symptom`, `evidence`, `fix` |
|
|
223
|
+
|
|
224
|
+
Matching resources for clients that prefer `resources/read`: `broker://info`,
|
|
225
|
+
`broker://providers`, the template `broker://providers/{name}`, the six
|
|
226
|
+
`broker://guide/<topic>` pages and the template `broker://guide/{topic}`.
|
|
227
|
+
|
|
228
|
+
Each provider entry from `providers_list` / `provider_status`:
|
|
229
|
+
|
|
230
|
+
```json
|
|
231
|
+
{
|
|
232
|
+
"name": "weather",
|
|
233
|
+
"transport": "ws",
|
|
234
|
+
"connected": true,
|
|
235
|
+
"clientCount": 0,
|
|
236
|
+
"sessionCount": 1,
|
|
237
|
+
"pendingCount": 0
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
- `transport` is `ws` (dedicated socket), `ws-multiplex` (shared socket),
|
|
242
|
+
`stdio` (**any** configured upstream: a child process from `stdioUpstreams`,
|
|
243
|
+
a `.mcpb` bundle, *or* a remote URL from `mcpServers`), `loopback`
|
|
244
|
+
(in-process), or `none` (the slot exists but nothing is serving it).
|
|
245
|
+
- `clientCount` counts **raw WebSocket clients only**. A perfectly healthy
|
|
246
|
+
Streamable HTTP client reads as `clientCount: 0, sessionCount: 1`.
|
|
247
|
+
- `pendingCount` is the number of in-flight requests. One that only grows is the
|
|
248
|
+
signature of a provider that is connected and not answering.
|
|
249
|
+
|
|
250
|
+
### `_all`, the aggregate
|
|
251
|
+
|
|
252
|
+
`_all` presents the union of the tools and prompts of every **opted-in**
|
|
253
|
+
provider as one MCP server. It is not a proxy and not automatic.
|
|
254
|
+
|
|
255
|
+
Membership, per provider kind:
|
|
256
|
+
|
|
257
|
+
| provider kind | joins `_all` when |
|
|
258
|
+
|---|---|
|
|
259
|
+
| WebSocket, `DirectTransport` | `new DirectTransport(url, { aggregate: true })` |
|
|
260
|
+
| WebSocket, `MultiplexTransport` | `MultiplexTransport.create(name, url, { aggregate: true })` |
|
|
261
|
+
| `stdioUpstreams[]` | `"aggregate": true` (**omitted means NOT aggregated**) |
|
|
262
|
+
| `mcpServers[]` | by default; `"aggregate": false` opts out |
|
|
263
|
+
| `mcpbBundles[]` | by default; `"aggregate": false` opts out |
|
|
264
|
+
| `_broker` | always, automatically |
|
|
265
|
+
|
|
266
|
+
That default asymmetry between `stdioUpstreams` and the other two is real and it
|
|
267
|
+
is worth re-reading before assuming a slot is in.
|
|
268
|
+
|
|
269
|
+
Names are prefixed with the origin slot, `<slot>-<original>`, and descriptions
|
|
270
|
+
are tagged `[<slot>] <original description>`.
|
|
271
|
+
|
|
272
|
+
> **Never reconstruct a prefixed name.** It is capped at 64 characters, an
|
|
273
|
+
> overlong one is truncated and given a hash suffix, and a collision between two
|
|
274
|
+
> providers is broken with a `-2`, `-3` suffix. The mapping back to
|
|
275
|
+
> `(provider, original)` is a lookup table, not a parse. Call `tools/list` on
|
|
276
|
+
> `_all` and pass the returned string back verbatim in `tools/call`.
|
|
277
|
+
|
|
278
|
+
`_all` implements `initialize`, `ping`, `tools/list`, `tools/call`,
|
|
279
|
+
`prompts/list` and `prompts/get`. **Everything else returns `-32601 Method not
|
|
280
|
+
found`, including `resources/list` and `resources/read`.** For a provider's
|
|
281
|
+
resources, connect to that provider's own slot.
|
|
282
|
+
|
|
283
|
+
It emits `notifications/tools/list_changed` and
|
|
284
|
+
`notifications/prompts/list_changed` when a provider joins, leaves or changes
|
|
285
|
+
its catalog, so a subscribing client sees a provider that arrives mid-session
|
|
286
|
+
without reconnecting. That is what makes `_all` the correct stdio-bridge target.
|
|
287
|
+
|
|
288
|
+
Registration snippets must **install the MCP message handler before announcing
|
|
289
|
+
the slot**. The broker sends `initialize` the moment it accepts an aggregate
|
|
290
|
+
registration; a provider not listening yet fails that handshake, is dropped from
|
|
291
|
+
`_all` and logged, with no retry. The SDK's `aggregate` option already does this
|
|
292
|
+
in the right order.
|
|
293
|
+
|
|
294
|
+
When authorization is enabled, `_all` **filters the catalog** rather than
|
|
295
|
+
rejecting the call: a caller sees only the providers it is scoped for, and a
|
|
296
|
+
tool it may not see answers `-32602 Unknown aggregated tool`, deliberately
|
|
297
|
+
indistinguishable from a name that does not exist.
|
|
104
298
|
|
|
105
299
|
## Authorization (OAuth 2.1)
|
|
106
300
|
|
|
@@ -171,10 +365,22 @@ With this on:
|
|
|
171
365
|
- Structured provider principals can publish only inside their allowed resource
|
|
172
366
|
namespace.
|
|
173
367
|
|
|
174
|
-
|
|
368
|
+
Two things this does **not** do. It does not authenticate the stdio bridge:
|
|
369
|
+
`MCP_BROKER_STDIO_PROVIDER` reaches `_all` with no principal attached, so
|
|
370
|
+
enabling per-provider scopes silently empties an MCP host's tool list. And it
|
|
371
|
+
cannot authenticate a browser-hosted provider: the secret is read from the
|
|
372
|
+
`X-Provider-Token` header or from `Authorization: Bearer`, and the browser
|
|
373
|
+
`WebSocket` constructor cannot set headers, so setting `providerSecret` locks
|
|
374
|
+
every browser provider out permanently with an HTTP 401 that reaches the page as
|
|
375
|
+
a bare `error` event. Run without a provider secret, or terminate provider auth
|
|
376
|
+
in a reverse proxy that injects the header.
|
|
377
|
+
|
|
378
|
+
Full model, flows, scopes, and endpoints:
|
|
379
|
+
**[docs/authorization.md](https://github.com/pandaGaume/mcp-broker/blob/main/docs/authorization.md)**.
|
|
175
380
|
Roles, resource paths, denies, and migration:
|
|
176
|
-
**[
|
|
177
|
-
Config field reference: **[docs/config.md](docs/config.md#auth-oauth-21-authorization)**.
|
|
381
|
+
**[docs/hierarchical-authorization.md](https://github.com/pandaGaume/mcp-broker/blob/main/docs/hierarchical-authorization.md)**.
|
|
382
|
+
Config field reference: **[docs/config.md](https://github.com/pandaGaume/mcp-broker/blob/main/node/packages/broker/docs/config.md#auth-oauth-21-authorization)**.
|
|
383
|
+
(The first two live in the repository and are not shipped in the npm package.)
|
|
178
384
|
|
|
179
385
|
## Programmatic API
|
|
180
386
|
|
|
@@ -186,8 +392,12 @@ const broker = new WsTunnelBuilder()
|
|
|
186
392
|
.withHost("0.0.0.0")
|
|
187
393
|
.withProviderPath("/provider")
|
|
188
394
|
.withMcpPath("/mcp")
|
|
189
|
-
//
|
|
190
|
-
.
|
|
395
|
+
// Required before any browser page can reach a client endpoint.
|
|
396
|
+
.withAllowedOrigins(["http://localhost:5173"])
|
|
397
|
+
// Optional: bridge a local stdio MCP server as a provider. One object, not positional args.
|
|
398
|
+
.withStdioUpstream({ name: "my-server", command: "node", args: ["./my-server.js"], aggregate: true })
|
|
399
|
+
// Optional: front a remote MCP server reached by URL.
|
|
400
|
+
.withRemoteUpstream({ name: "geo", url: "https://geo.example.com/mcp" })
|
|
191
401
|
// Optional: serve a dev harness at /
|
|
192
402
|
.withStaticMount("/", "/abs/path/to/www")
|
|
193
403
|
// Optional: enable the OAuth 2.1 resource server + provider auth
|
|
@@ -214,6 +424,37 @@ const broker = new WsTunnelBuilder()
|
|
|
214
424
|
await broker.start();
|
|
215
425
|
```
|
|
216
426
|
|
|
427
|
+
`start()` **rejects** on a listen failure, with a message naming the port and
|
|
428
|
+
what to do about it. Await it, or handle the rejection: `void broker.start()`
|
|
429
|
+
produces an unhandled rejection.
|
|
430
|
+
|
|
431
|
+
An MCP server living in the same process needs no WebSocket at all:
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
import { LoopbackTransport, McpServerBuilder } from "@cyanmycelium/mcp-core";
|
|
435
|
+
|
|
436
|
+
const [serverEnd, clientEnd] = LoopbackTransport.createPair();
|
|
437
|
+
const server = new McpServerBuilder().withTransport(serverEnd).register(/* behaviors */).build();
|
|
438
|
+
await server.start();
|
|
439
|
+
broker.registerLoopbackProvider("in-process", clientEnd);
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
A loopback slot outranks a WebSocket provider of the same name: the WebSocket
|
|
443
|
+
one is refused with `Provider "<name>" is reserved by the broker`.
|
|
444
|
+
|
|
445
|
+
Provider liveness has three knobs, all also settable from the config file and
|
|
446
|
+
from `MCP_BROKER_*`:
|
|
447
|
+
|
|
448
|
+
| method | default | what it addresses |
|
|
449
|
+
|---|---|---|
|
|
450
|
+
| `withProviderHeartbeat(ms)` | `30000` | a half-open socket holding a slot until TCP keepalive expires, hours later. `0` disables |
|
|
451
|
+
| `withProviderTakeover(mode)` | `"liveness"` | `"reject"` keeps the incumbent always; `"always"` needs provider auth and a matching principal, and degrades to `"liveness"` with a log otherwise |
|
|
452
|
+
| `withProviderRequestTimeout(ms)` | `60000` | a provider that stays connected and never answers, the ordinary state of a throttled background browser tab. `0` disables |
|
|
453
|
+
|
|
454
|
+
A pong is answered by the peer's network stack, so the heartbeat proves the
|
|
455
|
+
*process* is alive, not that it is serving. `withProviderRequestTimeout` is what
|
|
456
|
+
covers the second case.
|
|
457
|
+
|
|
217
458
|
All builder methods are documented inline. The full options interface is
|
|
218
459
|
`IWsTunnelOptions`, also exported. Use `withAuthorizationPolicy` for a
|
|
219
460
|
standalone policy configuration, `withPolicyEngine` for a custom engine, and
|
|
@@ -222,6 +463,44 @@ validator (for example RFC 7662 introspection) or provider authenticator, use
|
|
|
222
463
|
`withAuth(resolvedAuth)` or `withProviderAuth(authenticator)` with your own
|
|
223
464
|
`ITokenValidator` or `IProviderAuthenticator`.
|
|
224
465
|
|
|
466
|
+
`brokerName` is honored by `IWsTunnelOptions` but there is no
|
|
467
|
+
`withBrokerName()`, so the CLI cannot forward the config-file key. Set it
|
|
468
|
+
through `IWsTunnelOptions` directly if you need it.
|
|
469
|
+
|
|
470
|
+
## Runnable samples
|
|
471
|
+
|
|
472
|
+
Five self-contained integration samples live in the repository, outside this
|
|
473
|
+
package: [samples/](https://github.com/pandaGaume/mcp-broker/tree/main/samples).
|
|
474
|
+
Each starts what it needs and proves itself end to end, and
|
|
475
|
+
[samples/index.json](https://github.com/pandaGaume/mcp-broker/blob/main/samples/index.json)
|
|
476
|
+
indexes them for an agent. They are not in this package's `files`, so they are
|
|
477
|
+
on GitHub rather than in `node_modules`.
|
|
478
|
+
|
|
479
|
+
Everything below *does* ship inside the npm package under `web/`, so it is
|
|
480
|
+
available from `node_modules/@cyanmycelium/mcp-broker/web`.
|
|
481
|
+
|
|
482
|
+
| path | what it shows |
|
|
483
|
+
|---|---|
|
|
484
|
+
| [`web/demos/provider-tunnel/`](web/demos/provider-tunnel/) | a browser page hosting an MCP server and tunnelling it into a slot |
|
|
485
|
+
| [`web/demos/broker-explorer/`](web/demos/broker-explorer/) | a browser MCP **client** driving `_broker`, `_all` or any slot |
|
|
486
|
+
| [`web/demos/oauth-lab/`](web/demos/oauth-lab/) | a complete local OAuth 2.1 and policy environment |
|
|
487
|
+
| [`.mcp-broker.example/`](.mcp-broker.example/) | a config template with per-key guides in [English](.mcp-broker.example/CONFIGURATION-EN.md) and [French](.mcp-broker.example/CONFIGURATION-FR.md), plus `config.stdio-bridge.json` |
|
|
488
|
+
|
|
489
|
+
Serve the whole `web/` folder from an installed package in one command:
|
|
490
|
+
|
|
491
|
+
```sh
|
|
492
|
+
MCP_BROKER_WWW_DIR=node_modules/@cyanmycelium/mcp-broker/web \
|
|
493
|
+
MCP_BROKER_ALLOWED_ORIGINS=http://localhost:3000 \
|
|
494
|
+
MCP_BROKER_OPEN=1 \
|
|
495
|
+
npx @cyanmycelium/mcp-broker
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
`MCP_BROKER_ALLOWED_ORIGINS` is there because a page that reaches a slot over
|
|
499
|
+
**HTTP** (`/<slot>/mcp`, `/<slot>/sse`, `/<slot>/messages`) is origin-checked,
|
|
500
|
+
and the broker serving the page does not exempt it. A page that uses only
|
|
501
|
+
WebSocket transports does not need it: WebSocket upgrades carry no origin check
|
|
502
|
+
at all, which is worth knowing in both directions.
|
|
503
|
+
|
|
225
504
|
## Interactive OAuth demo
|
|
226
505
|
|
|
227
506
|
The bundled [OAuth Policy Lab](web/demos/oauth-lab/) runs a complete local
|
|
@@ -239,15 +518,37 @@ and automated smoke test.
|
|
|
239
518
|
|
|
240
519
|
## TLS for local development
|
|
241
520
|
|
|
521
|
+
`gen-cert` is a repository script, not part of the published package: it needs
|
|
522
|
+
`selfsigned`, which is a devDependency. From a checkout of this repo, in
|
|
523
|
+
`node/packages/broker`:
|
|
524
|
+
|
|
242
525
|
```sh
|
|
243
|
-
npm run gen-cert
|
|
244
|
-
# Writes
|
|
526
|
+
npm run gen-cert -- --out .mcp-broker/certs
|
|
527
|
+
# Writes .mcp-broker/certs/cert.pem and .mcp-broker/certs/key.pem
|
|
245
528
|
|
|
246
|
-
|
|
247
|
-
|
|
529
|
+
MCP_BROKER_TLS_CERT=.mcp-broker/certs/cert.pem \
|
|
530
|
+
MCP_BROKER_TLS_KEY=.mcp-broker/certs/key.pem \
|
|
248
531
|
npm start
|
|
249
532
|
```
|
|
250
533
|
|
|
534
|
+
Without `--out` the script writes to `../certs` resolved against the working
|
|
535
|
+
directory, which for an npm script is the package directory. Pass `--out`
|
|
536
|
+
whenever you care where the files land.
|
|
537
|
+
|
|
538
|
+
Outside this repository, `openssl` does the same job:
|
|
539
|
+
|
|
540
|
+
```sh
|
|
541
|
+
mkdir -p .mcp-broker/certs && openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
|
|
542
|
+
-keyout .mcp-broker/certs/key.pem -out .mcp-broker/certs/cert.pem \
|
|
543
|
+
-subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
Setting both a certificate and a key switches the **whole** server to HTTPS and
|
|
547
|
+
WSS at once. There is no mixed mode: providers then connect with `wss://`,
|
|
548
|
+
clients with `https://`, and every entry in `allowedOrigins` must be spelled
|
|
549
|
+
`https://` or it will match nothing. The files are read synchronously when the
|
|
550
|
+
tunnel is built, so a wrong path fails before the port is bound.
|
|
551
|
+
|
|
251
552
|
The generated certificate covers `localhost`, `127.0.0.1`, `::1` for 365 days. Browsers will warn about an untrusted issuer on first visit. Click "Advanced → Proceed". MCP clients (Claude, Inspector) ignore certificate validation by default.
|
|
252
553
|
|
|
253
554
|
## Docker
|
|
@@ -312,21 +613,100 @@ services:
|
|
|
312
613
|
|
|
313
614
|
## Claude Desktop integration
|
|
314
615
|
|
|
315
|
-
The broker can act as a stdio MCP server for Claude Desktop
|
|
616
|
+
The broker can act as a stdio MCP server for Claude Desktop and any other stdio
|
|
617
|
+
MCP host. **Point the bridge at `_all`, not at one of your slots:**
|
|
316
618
|
|
|
317
619
|
```json
|
|
318
620
|
{
|
|
319
621
|
"mcpServers": {
|
|
320
|
-
"broker": {
|
|
622
|
+
"mcp-broker": {
|
|
321
623
|
"command": "npx",
|
|
322
624
|
"args": ["-y", "@cyanmycelium/mcp-broker"],
|
|
323
|
-
"env": {
|
|
625
|
+
"env": {
|
|
626
|
+
"MCP_BROKER_STDIO_PROVIDER": "_all",
|
|
627
|
+
"MCP_BROKER_PORT": "3000",
|
|
628
|
+
"MCP_BROKER_HOST": "127.0.0.1"
|
|
629
|
+
}
|
|
324
630
|
}
|
|
325
631
|
}
|
|
326
632
|
}
|
|
327
633
|
```
|
|
328
634
|
|
|
329
|
-
|
|
635
|
+
### Why `_all` and not your slot
|
|
636
|
+
|
|
637
|
+
This is an ordering problem, and pinning a real slot fails every single time.
|
|
638
|
+
|
|
639
|
+
An MCP host starts its servers when the host application launches. It sends
|
|
640
|
+
`initialize` immediately and treats a failure as a dead server: no retry, no
|
|
641
|
+
backoff, the entry is disabled for the session. Your provider does not exist
|
|
642
|
+
yet. A browser provider needs a human to open a page; a provider in another
|
|
643
|
+
process needs that process to start. So the bridge answers the host's very first
|
|
644
|
+
`initialize` with `-32000 Provider "<slot>" not connected`, and the host gives
|
|
645
|
+
up. **Pinning a real slot never works for a browser-hosted provider at all.**
|
|
646
|
+
|
|
647
|
+
`_all` is registered before the broker resumes stdin, so the slot exists before
|
|
648
|
+
the host's first byte arrives. It answers `initialize` itself. It already
|
|
649
|
+
aggregates `_broker`, so the host always has at least the five introspection
|
|
650
|
+
tools. And it pushes `notifications/tools/list_changed` when a provider joins,
|
|
651
|
+
so a page opened ten minutes into the session appears in the host's tool list
|
|
652
|
+
live, with no reconnect.
|
|
653
|
+
|
|
654
|
+
`_broker` is the fallback if you want introspection only. It is also always up,
|
|
655
|
+
but it will never show anything except the broker's own five tools.
|
|
656
|
+
|
|
657
|
+
Providers still have to **opt in** to `_all` (see
|
|
658
|
+
[Reserved slots](#reserved-slots-_broker-and-_all)). If the host shows only
|
|
659
|
+
`_broker-*` tools, nothing opted in; call `_broker-broker_diagnose` and it will
|
|
660
|
+
say so.
|
|
661
|
+
|
|
662
|
+
### One broker per port
|
|
663
|
+
|
|
664
|
+
Do not add a second host entry that spawns another broker. The second process
|
|
665
|
+
cannot bind the port and dies; the host reports `Connection closed` with no
|
|
666
|
+
cause, and the real `EADDRINUSE` diagnosis is only in the host's
|
|
667
|
+
`mcp-server-<name>.log`. If you want two logical servers visible to the host,
|
|
668
|
+
publish both into the one broker and let `_all` union them.
|
|
669
|
+
|
|
670
|
+
### The bridge is anonymous
|
|
671
|
+
|
|
672
|
+
The stdio bridge forwards frames to `_all` with **no principal attached**. While
|
|
673
|
+
no authorization is configured that is invisible. The moment you enable
|
|
674
|
+
per-provider scopes, or any policy that denies an anonymous subject, the host's
|
|
675
|
+
tool list silently empties: `tools/list` still succeeds and returns
|
|
676
|
+
`tools: []`, and nothing anywhere says why.
|
|
677
|
+
|
|
678
|
+
In this mode all broker logging goes to stderr; stdout is reserved for the JSON-RPC stream the host expects.
|
|
679
|
+
|
|
680
|
+
## Troubleshooting
|
|
681
|
+
|
|
682
|
+
Call `broker_diagnose()` on the `_broker` slot first. It reads the live state
|
|
683
|
+
and names the problems it can prove. This table is the reference behind it, and
|
|
684
|
+
covers the failures the broker cannot see from the inside.
|
|
685
|
+
|
|
686
|
+
| symptom | cause | fix |
|
|
687
|
+
|---|---|---|
|
|
688
|
+
| Client hangs forever on `initialize`, provider shows `connected: true` | `MultiplexTransport` on `/provider/<name>` | move it to `ws://<host>/providers`, or switch it to `DirectTransport` |
|
|
689
|
+
| Provider socket open, slot never appears in `providers_list` | `DirectTransport` on `/providers` | move it to `ws://<host>/provider/<name>`, or switch it to `MultiplexTransport` |
|
|
690
|
+
| Provider on `/providers/<name>`, nothing works | that path is neither endpoint: it is accepted as a **client** on a slot named `providers/<name>` | drop the name for multiplex, or add `/provider/` for a dedicated socket |
|
|
691
|
+
| WebSocket closes `1008 already connected` after a reload | a stale socket still holds the slot | release on `pagehide`; the heartbeat frees a genuinely dead one within one interval |
|
|
692
|
+
| Host reports `Connection closed` with no cause | a second broker could not bind the port | one broker per port; `EADDRINUSE` is in `mcp-server-<name>.log` |
|
|
693
|
+
| `403 invalid_origin` from a page this broker serves | a static mount does not exempt the origin it serves | list that exact origin, scheme and port included |
|
|
694
|
+
| `Provider "<slot>" not connected` at host start | ordering: the host starts before any provider exists | point `MCP_BROKER_STDIO_PROVIDER` at `_all` |
|
|
695
|
+
| `_all` shows only `_broker-*` tools | nothing opted into the aggregate | see [Reserved slots](#reserved-slots-_broker-and-_all); verify with `tools/list` on `_all` |
|
|
696
|
+
| Host's tool list empties after enabling authorization | the stdio bridge is anonymous | grant the anonymous subject, or stop bridging in authorized deployments |
|
|
697
|
+
| Browser provider gets a bare `error` event | provider auth is on; a browser cannot send the header | run without a provider secret, or authenticate in a proxy |
|
|
698
|
+
| `-32601 Method not found` on `_all` | `_all` covers tools and prompts only | use the provider's own slot for resources |
|
|
699
|
+
| `-32602 Unknown aggregated tool` | the prefixed name was reconstructed rather than echoed | re-run `tools/list`, pass the name back verbatim |
|
|
700
|
+
| `did not respond within 60000ms` | the provider stayed connected and never answered | raise `providerRequestTimeoutMs`, or fix the provider |
|
|
701
|
+
| `sessionCount` grows and never falls | Streamable HTTP and SSE sessions do not expire | send `DELETE /<slot>/mcp` when a client is done; restart if it is already large |
|
|
702
|
+
|
|
703
|
+
Reading the console: the broker prints one line per accepted WebSocket upgrade
|
|
704
|
+
naming the path, the role the router assigned (`dedicated-provider`,
|
|
705
|
+
`multiplex-provider` or `client`) and the slot. **A provider URL that came out
|
|
706
|
+
as `role=client` is the mismatch, caught for free.** In stdio mode every log
|
|
707
|
+
goes to stderr, so look in the host's `mcp-server-<name>.log`, never on stdout.
|
|
708
|
+
Do not read silence as success: routed frames and provider replies are not
|
|
709
|
+
logged at all, and `providers_list` plus `broker_diagnose` are the ground truth.
|
|
330
710
|
|
|
331
711
|
## Development
|
|
332
712
|
|
|
@@ -342,7 +722,7 @@ Requires Node 20.11+.
|
|
|
342
722
|
|
|
343
723
|
## Releasing
|
|
344
724
|
|
|
345
|
-
The package is published to npm by [`.github/workflows/release-node.yml`](
|
|
725
|
+
The package is published to npm by [`.github/workflows/release-node.yml`](https://github.com/pandaGaume/mcp-broker/blob/main/.github/workflows/release-node.yml), triggered by tags of the form `node-v*`.
|
|
346
726
|
|
|
347
727
|
```sh
|
|
348
728
|
# from the node/ directory:
|