@cyanmycelium/mcp-broker-provider 0.1.1 → 0.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/README.md +153 -7
- package/dist/index.d.ts +404 -8
- package/dist/index.js +578 -31
- package/dist/index.js.map +1 -1
- package/dist/protocol/index.d.ts +34 -3
- package/dist/protocol/index.js +13 -3
- package/dist/protocol/index.js.map +1 -1
- package/package.json +3 -3
- package/src/broker.client.ts +407 -0
- package/src/direct.transport.ts +184 -5
- package/src/index.ts +32 -2
- package/src/multiplex.transport.ts +293 -24
- package/src/protocol/envelope.ts +47 -3
- package/src/transport.support.ts +300 -0
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ A multiplexed tunnel socket carries traffic for several providers at once, so ev
|
|
|
36
36
|
|
|
37
37
|
Beyond the envelope it also covers:
|
|
38
38
|
|
|
39
|
-
- `notifications/register`, sent as soon as the tunnel opens to claim a provider slot. Without it the broker only learns a provider name on its first real message, and an MCP client connecting in between is told the provider is not connected.
|
|
39
|
+
- `notifications/register`, sent as soon as the tunnel opens to claim a provider slot. Without it the broker only learns a provider name on its first real message, and an MCP client connecting in between is told the provider is not connected. It optionally carries `params: { aggregate: true }`, which also joins the broker's `_all` slot, see [Joining the `_all` aggregate slot](#joining-the-_all-aggregate-slot).
|
|
40
40
|
- The tunnel error codes: `-32001` when the provider's credentials do not allow publishing on the requested slot, `-32000` when the slot is unavailable.
|
|
41
41
|
|
|
42
42
|
When the broker refuses a slot, the transport surfaces it through `onError` rather than forwarding it. An id-less error frame handed to an MCP server would be classified as an unknown notification and dropped without a word, so the publisher would never learn it was refused.
|
|
@@ -45,10 +45,12 @@ Malformed frames decode to `undefined` rather than throwing: a tunnel socket is
|
|
|
45
45
|
|
|
46
46
|
## Transports
|
|
47
47
|
|
|
48
|
-
| Transport | Use case |
|
|
49
|
-
|
|
50
|
-
| `MultiplexTransport` | Several servers published by one application. A single socket carries them all, keyed by slot name |
|
|
51
|
-
| `DirectTransport` |
|
|
48
|
+
| Transport | Endpoint it speaks to | Framing | Reconnects | Use case |
|
|
49
|
+
|---|---|---|---|---|
|
|
50
|
+
| `MultiplexTransport` | the shared multiplex base, `ws://<broker>/providers` | envelopes `{ provider, payload }` | yes, on the shared socket | Several servers published by one application. A single socket carries them all, keyed by slot name |
|
|
51
|
+
| `DirectTransport` | a slot-scoped path, `ws://<broker>/provider/<name>` | plain JSON-RPC frames | **no** | One server, one socket |
|
|
52
|
+
|
|
53
|
+
**The transport and the path are a pair, not a preference.** The broker decides framing from the endpoint the socket landed on, so a mismatch does not fail the handshake: the socket opens, looks healthy, and every frame is dropped on one side or the other with nothing logged by the broker. Both transports warn on the console when they spot the mismatch at connect time. And `ws://<broker>/providers/<name>`, the natural-looking blend of the two, is neither: the broker accepts it as a *client* connection on a slot of that name.
|
|
52
54
|
|
|
53
55
|
```ts
|
|
54
56
|
import { McpServerBuilder } from "@cyanmycelium/mcp-core/server";
|
|
@@ -63,13 +65,157 @@ const server = new McpServerBuilder()
|
|
|
63
65
|
await server.start();
|
|
64
66
|
```
|
|
65
67
|
|
|
66
|
-
Transports created for the same tunnel URL share one WebSocket, whichever order they are opened in. Reconnection is handled by that shared socket, with exponential back-off and jitter
|
|
68
|
+
Transports created for the same tunnel URL share one WebSocket, whichever order they are opened in. Reconnection is handled by that shared socket, with exponential back-off and jitter, and individual transports never reconnect on their own. `DirectTransport` does not reconnect at all: when its socket closes it stays closed, and the application decides whether to call `connect()` again.
|
|
69
|
+
|
|
70
|
+
`server.start()` resolving means the transport reported itself open, not that the broker accepted the slot. A refusal arrives afterwards, and reaches you as an `onError` on the transport and a line on the console.
|
|
67
71
|
|
|
68
72
|
`@cyanmycelium/mcp-core` is a peer dependency: the transports import its `IMessageTransport` type and nothing else at runtime, so your application keeps a single copy of it.
|
|
69
73
|
|
|
74
|
+
## Joining the `_all` aggregate slot
|
|
75
|
+
|
|
76
|
+
The broker publishes an aggregate slot, `_all`, which exposes every opted-in provider's tools and prompts through one MCP connection. Membership is opt-in, because `_all` is a confidentiality boundary: a provider that does not ask for it stays reachable only on its own slot.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { DirectTransport, MultiplexTransport } from "@cyanmycelium/mcp-broker-provider";
|
|
80
|
+
|
|
81
|
+
// One socket per server
|
|
82
|
+
const direct = new DirectTransport("ws://localhost:3000/provider/scene-1", { aggregate: true });
|
|
83
|
+
|
|
84
|
+
// Or on the shared tunnel
|
|
85
|
+
const shared = MultiplexTransport.create("scene-1", "ws://localhost:3000/providers", { aggregate: true });
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Either form sends the registration notification with `params: { aggregate: true }` as its first frame:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{ "jsonrpc": "2.0", "method": "notifications/register", "params": { "aggregate": true } }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`DirectTransport` sends it verbatim, since the slot-scoped path carries plain JSON-RPC and the broker already knows the slot name from the URL. `MultiplexTransport` sends it inside the usual envelope, as `{ "provider": "scene-1", "payload": { ... } }`.
|
|
95
|
+
|
|
96
|
+
**Wire the message handler before you connect.** The broker runs `initialize` against a newly aggregated provider immediately, and a provider that does not answer is dropped from `_all` silently. Handing the transport to an MCP server does this for you, since the server assigns `onMessage` before calling `connect()`. Assigning it yourself, after connecting, loses the handshake and the provider never appears in the aggregate.
|
|
97
|
+
|
|
98
|
+
## Letting the broker decide (broker 1.5.0)
|
|
99
|
+
|
|
100
|
+
A provider that serves its own kind of resource can declare an authorization
|
|
101
|
+
domain and ask the broker for decisions, instead of carrying a policy of its
|
|
102
|
+
own. Both transports expose the broker's methods as `transport.broker`:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { DirectTransport, callerReferenceOf } from "@cyanmycelium/mcp-broker-provider";
|
|
106
|
+
|
|
107
|
+
const transport = new DirectTransport("ws://broker:3000/provider/scada");
|
|
108
|
+
// ... start the MCP server on it, then:
|
|
109
|
+
await transport.broker.declare({
|
|
110
|
+
version: "2026-10-01.1",
|
|
111
|
+
domain: "scada",
|
|
112
|
+
namespace: { resource: "/production/site1" },
|
|
113
|
+
capabilities: ["scada.observe", "scada.control"],
|
|
114
|
+
resources: [
|
|
115
|
+
{
|
|
116
|
+
resource: "uns://production/site1/line1/motor01/speed_sp",
|
|
117
|
+
resourcePath: "/production/site1/line1/motor01/speed_sp",
|
|
118
|
+
limits: { minValue: 0, maxValue: 1500 },
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
resultsRequired: ["scada.control"],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// In a tool handler (mcp-core 1.4.0 hands the adapter the request's _meta):
|
|
125
|
+
const caller = callerReferenceOf(request?.meta);
|
|
126
|
+
const { decisions } = await transport.broker.authorize({
|
|
127
|
+
principal: { type: "caller-ref", ref: caller!.ref },
|
|
128
|
+
checks: [{ capability: "scada.control", resource: "uns://production/site1/line1/motor01/speed_sp", resourcePath: "/production/site1/line1/motor01/speed_sp" }],
|
|
129
|
+
});
|
|
130
|
+
// decisions[0]: { effect: "allow-with-constraints", allowed: false, obligations: { constraints: { minValue: 0, maxValue: 1500 } }, ... }
|
|
131
|
+
|
|
132
|
+
// Once the write is done, or refused by a constraint:
|
|
133
|
+
transport.broker.reportResult({ decisionId: decisions[0].decisionId, result: "success", nativeStatus: "Good" });
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- `effect` is `allow`, `deny` or `allow-with-constraints`. The last one comes
|
|
137
|
+
with the resource's declared limits in `obligations.constraints`; apply them
|
|
138
|
+
right before executing. `allowed` is `true` only for a plain `allow`, so code
|
|
139
|
+
that reads `allowed` alone refuses a constrained allow instead of ignoring
|
|
140
|
+
its limits.
|
|
141
|
+
- `reportResult()` is a notification: nothing comes back. The broker writes the
|
|
142
|
+
outcome next to the decision in its audit, and `broker_diagnose` reports
|
|
143
|
+
decisions of `resultsRequired` capabilities still unreported.
|
|
144
|
+
|
|
145
|
+
- The provider needs its own identity on the broker (an entry in the security
|
|
146
|
+
file's `providers` table), which means presenting a secret:
|
|
147
|
+
`new DirectTransport(url, { secret })`, sent as `X-Provider-Token`. Node 22
|
|
148
|
+
and later can; a browser cannot, so this is for Node and native providers.
|
|
149
|
+
- To test all of this without an authorization server, start the broker with
|
|
150
|
+
`startTestBroker()` from `@cyanmycelium/mcp-broker/testing`
|
|
151
|
+
([guide](../broker/docs/testing.md)).
|
|
152
|
+
- The broker's answers are taken off the socket before the MCP server sees
|
|
153
|
+
them. A refused request rejects with `BrokerRequestError` (`code`, `data`).
|
|
154
|
+
- There is no timeout by default: a broker from 1.4.1 on answers at once. Set
|
|
155
|
+
`brokerRequestTimeoutMs` only to talk to an older one, which drops methods it
|
|
156
|
+
does not know.
|
|
157
|
+
|
|
158
|
+
## Distributed traces (broker 1.5.0)
|
|
159
|
+
|
|
160
|
+
Every addressed request arriving from broker 1.5 carries W3C Trace Context in
|
|
161
|
+
`params._meta.traceparent`. Read it from the request metadata, create a SERVER
|
|
162
|
+
span, then propagate a child context when this provider calls another slot:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import {
|
|
166
|
+
childTraceparent,
|
|
167
|
+
traceparentOf,
|
|
168
|
+
withTraceparent,
|
|
169
|
+
} from "@cyanmycelium/mcp-broker-provider";
|
|
170
|
+
|
|
171
|
+
const incoming = traceparentOf(request?.meta);
|
|
172
|
+
const downstreamMeta = incoming
|
|
173
|
+
? withTraceparent(undefined, childTraceparent(incoming, clientSpanId))
|
|
174
|
+
: undefined;
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Completed spans are validated before emission:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const emitted = transport.broker.span({
|
|
181
|
+
traceId,
|
|
182
|
+
spanId,
|
|
183
|
+
parentSpanId,
|
|
184
|
+
name: "modbus.read",
|
|
185
|
+
kind: 3,
|
|
186
|
+
startTimeUnixNano,
|
|
187
|
+
endTimeUnixNano,
|
|
188
|
+
attributes: { "modbus.unit_id": 1 },
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The call is a notification and never waits for an answer. The broker consumes
|
|
193
|
+
`broker/telemetry`, sends it through its bounded exporter queue, and never
|
|
194
|
+
broadcasts it to MCP clients. It returns `false` and drops the span when the
|
|
195
|
+
provider link is down, so telemetry never occupies the transport queue ahead
|
|
196
|
+
of MCP control traffic. See [the complete telemetry contract](../../../docs/telemetry.md).
|
|
197
|
+
|
|
198
|
+
## Diagnostics
|
|
199
|
+
|
|
200
|
+
This stack used to fail quietly. The transports now name what went wrong, on the console, because a browser-hosted provider has nowhere else to report:
|
|
201
|
+
|
|
202
|
+
- A frame written before the socket is open is queued (64 frames, oldest dropped first with a warning) and flushed on open, rather than discarded. That window covers the whole reconnect back-off, up to 30 seconds.
|
|
203
|
+
- A close with a code other than `1000` is reported through `onError` with the code and the broker's own reason, before `onClose`. A `1008` is a policy refusal: the slot is already connected, is reserved, or provider authentication rejected it.
|
|
204
|
+
- An incoming frame that is not an envelope, or one for a slot this socket does not publish, is logged with the likely cause and the fix. Repeats are sampled (the first in full, then one in fifty) so a mismatched tunnel cannot flood the console.
|
|
205
|
+
|
|
206
|
+
## In a browser
|
|
207
|
+
|
|
208
|
+
The transports are written for the browser, but the npm path needs a bundler: neither this package nor `@cyanmycelium/mcp-core` ships a UMD build or declares a `browser` field, so a bare `<script>` tag will not load them. Any bundler works; there is nothing to configure beyond resolving the two packages.
|
|
209
|
+
|
|
210
|
+
Import only the isomorphic entry points. `@cyanmycelium/mcp-core/server` and `@cyanmycelium/mcp-core/client` run in a browser; `@cyanmycelium/mcp-core/node` does not, and pulling it in is the usual cause of a build that fails on Node built-ins.
|
|
211
|
+
|
|
212
|
+
If you would rather not add a build step, the broker ships a dependency-free ES module that speaks the same tunnel, `web/js/lib/broker-tunnel.js` (inside the `@cyanmycelium/mcp-broker` package, at `node/packages/broker/web` in the repository). It is the zero-build alternative, not a replacement: it carries no MCP server implementation.
|
|
213
|
+
|
|
214
|
+
One limitation worth knowing before you deploy: if the broker is configured with provider authentication, a browser-hosted provider cannot connect. The broker reads its credential from the `X-Provider-Token` or `Authorization` header of the upgrade request, and the browser `WebSocket` constructor cannot set headers, so the handshake is refused with a 401 the page sees only as a generic error. A browser provider needs provider auth off, or an authenticating reverse proxy in front of the broker.
|
|
215
|
+
|
|
70
216
|
## Status
|
|
71
217
|
|
|
72
|
-
|
|
218
|
+
This package is the only home of the tunnel transports. They also shipped in `@cyanmycelium/mcp-core@0.4.x`, were removed there in `0.5.0`, and are absent from `0.7.x` and `1.x`, so migrate those imports here before upgrading `mcp-core`.
|
|
73
219
|
|
|
74
220
|
The protocol is shared with the broker, and later with the consumer side. Import it through the `./protocol` subpath rather than the package root, so it can move to a package of its own one day without touching your call sites.
|
|
75
221
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,279 @@
|
|
|
1
|
-
export { TUNNEL_REGISTER_METHOD, TunnelEnvelope, TunnelError, TunnelErrorCode, TunnelErrorCodes, decodeEnvelope, encodeEnvelope, encodeEnvelopeMessage, encodeErrorEnvelope, encodeRegisterEnvelope, envelopeFrame, tunnelErrorOf } from './protocol/index.js';
|
|
1
|
+
export { ITunnelRegisterOptions, TUNNEL_REGISTER_METHOD, TunnelEnvelope, TunnelError, TunnelErrorCode, TunnelErrorCodes, decodeEnvelope, encodeEnvelope, encodeEnvelopeMessage, encodeErrorEnvelope, encodeRegisterEnvelope, encodeRegisterFrame, envelopeFrame, tunnelErrorOf } from './protocol/index.js';
|
|
2
2
|
import { IMessageTransport } from '@cyanmycelium/mcp-core';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Talks to the broker itself, over the provider's own socket: declaring an
|
|
6
|
+
* authorization domain, and asking for decisions.
|
|
7
|
+
*
|
|
8
|
+
* Requires broker 1.5.0 or later. An older broker does not know these methods:
|
|
9
|
+
* from 1.4.1 it refuses them at once with `-32601`; 1.4.0 and earlier drop
|
|
10
|
+
* them and never answer, which is why {@link IBrokerClientOptions.requestTimeoutMs}
|
|
11
|
+
* exists, off by default.
|
|
12
|
+
*/
|
|
13
|
+
/** The `params._meta` key under which the broker passes the caller reference with each request. */
|
|
14
|
+
declare const CALLER_META_KEY = "io.cyanmycelium/caller";
|
|
15
|
+
/** W3C Trace Context carrier used by MCP requests. */
|
|
16
|
+
declare const TRACEPARENT_META_KEY = "traceparent";
|
|
17
|
+
/** Provider-to-broker telemetry notification. */
|
|
18
|
+
declare const TELEMETRY_NOTIFICATION_METHOD = "broker/telemetry";
|
|
19
|
+
/** Provider-to-broker notification reporting what happened after a decision. */
|
|
20
|
+
declare const AUDIT_RESULT_NOTIFICATION_METHOD = "broker/audit/result";
|
|
21
|
+
interface ITraceParent {
|
|
22
|
+
readonly version: "00";
|
|
23
|
+
readonly traceId: string;
|
|
24
|
+
readonly parentId: string;
|
|
25
|
+
readonly traceFlags: string;
|
|
26
|
+
}
|
|
27
|
+
type TelemetryAttributeValue = string | number | boolean;
|
|
28
|
+
interface ITelemetryEvent {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly timeUnixNano: string;
|
|
31
|
+
readonly attributes?: Readonly<Record<string, TelemetryAttributeValue>>;
|
|
32
|
+
}
|
|
33
|
+
interface ITelemetrySpan {
|
|
34
|
+
readonly traceId: string;
|
|
35
|
+
readonly spanId: string;
|
|
36
|
+
readonly parentSpanId?: string;
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly kind?: number;
|
|
39
|
+
readonly startTimeUnixNano: string;
|
|
40
|
+
readonly endTimeUnixNano: string;
|
|
41
|
+
readonly attributes?: Readonly<Record<string, TelemetryAttributeValue>>;
|
|
42
|
+
readonly events?: readonly ITelemetryEvent[];
|
|
43
|
+
readonly status?: {
|
|
44
|
+
readonly code: number;
|
|
45
|
+
readonly message?: string;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Parses the W3C version 00 traceparent representation used in MCP metadata. */
|
|
49
|
+
declare function parseTraceparent(value: unknown): ITraceParent | undefined;
|
|
50
|
+
declare function formatTraceparent(context: ITraceParent): string;
|
|
51
|
+
/** Reads `params._meta.traceparent`, returning `undefined` when it is malformed. */
|
|
52
|
+
declare function traceparentOf(meta: Readonly<Record<string, unknown>> | undefined): ITraceParent | undefined;
|
|
53
|
+
/** Returns a metadata copy carrying the supplied validated trace context. */
|
|
54
|
+
declare function withTraceparent(meta: Readonly<Record<string, unknown>> | undefined, context: ITraceParent): Readonly<Record<string, unknown>>;
|
|
55
|
+
/** Continues a trace using the caller's span as the new W3C parent id. */
|
|
56
|
+
declare function childTraceparent(parent: ITraceParent, spanId: string): ITraceParent;
|
|
57
|
+
/** What the broker hands a declaring provider with each request, under {@link CALLER_META_KEY}. */
|
|
58
|
+
interface ICallerReference {
|
|
59
|
+
/** Opaque. Valid on this slot, while the request it came with is pending. */
|
|
60
|
+
readonly ref: string;
|
|
61
|
+
/** Ties the decisions and the eventual report to the client request. */
|
|
62
|
+
readonly correlationId: string;
|
|
63
|
+
/** W3C trace id carried by the MCP request. */
|
|
64
|
+
readonly traceId?: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Reads the caller reference out of a request's `params._meta`, or
|
|
68
|
+
* `undefined` when there is none (the broker only adds it once a declaration
|
|
69
|
+
* was accepted). With mcp-core 1.4.0, pass `request?.meta` from the context
|
|
70
|
+
* an adapter receives.
|
|
71
|
+
*/
|
|
72
|
+
declare function callerReferenceOf(meta: Readonly<Record<string, unknown>> | undefined): ICallerReference | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* Engineering limits of one resource. They hold for every caller, and the
|
|
75
|
+
* broker returns them with each allow on that resource as
|
|
76
|
+
* `obligations.constraints`, for the provider to apply.
|
|
77
|
+
*/
|
|
78
|
+
interface IResourceLimits {
|
|
79
|
+
readonly minValue?: number;
|
|
80
|
+
readonly maxValue?: number;
|
|
81
|
+
readonly allowedValues?: readonly (string | number | boolean | null)[];
|
|
82
|
+
readonly destinations?: readonly string[];
|
|
83
|
+
}
|
|
84
|
+
/** One resource of a declaration: the provider's own identifier, and the path the broker evaluates. */
|
|
85
|
+
interface IDeclaredResource {
|
|
86
|
+
readonly resource: string;
|
|
87
|
+
readonly resourcePath: string;
|
|
88
|
+
readonly effect?: string;
|
|
89
|
+
readonly limits?: IResourceLimits;
|
|
90
|
+
}
|
|
91
|
+
/** `broker/authorization/declare` parameters. Describes; grants nothing. */
|
|
92
|
+
interface IAuthorizationDeclaration {
|
|
93
|
+
readonly version: string;
|
|
94
|
+
readonly domain: string;
|
|
95
|
+
readonly namespace: {
|
|
96
|
+
readonly resource: string;
|
|
97
|
+
};
|
|
98
|
+
readonly capabilities: readonly string[];
|
|
99
|
+
readonly resources?: readonly IDeclaredResource[];
|
|
100
|
+
readonly protects?: readonly string[];
|
|
101
|
+
/**
|
|
102
|
+
* Declared capabilities whose allowed decisions this provider promises to
|
|
103
|
+
* report with {@link BrokerClient.reportResult}. One not reported in time
|
|
104
|
+
* shows up in `broker_diagnose`.
|
|
105
|
+
*/
|
|
106
|
+
readonly resultsRequired?: readonly string[];
|
|
107
|
+
}
|
|
108
|
+
interface IDeclarationAccepted {
|
|
109
|
+
readonly accepted: true;
|
|
110
|
+
readonly version: string;
|
|
111
|
+
readonly policyVersion: string;
|
|
112
|
+
}
|
|
113
|
+
/** One question: may (the caller) do `capability` on this resource? */
|
|
114
|
+
interface IAuthorizationCheck {
|
|
115
|
+
readonly capability: string;
|
|
116
|
+
readonly resource: string;
|
|
117
|
+
readonly resourcePath: string;
|
|
118
|
+
readonly attributes?: Readonly<Record<string, unknown>>;
|
|
119
|
+
}
|
|
120
|
+
/** `broker/authorize` parameters. */
|
|
121
|
+
interface IAuthorizationQuery {
|
|
122
|
+
/** On whose behalf: the caller of a pending request, or the provider itself. Never an identity. */
|
|
123
|
+
readonly principal: {
|
|
124
|
+
readonly type: "caller-ref";
|
|
125
|
+
readonly ref: string;
|
|
126
|
+
} | {
|
|
127
|
+
readonly type: "provider";
|
|
128
|
+
};
|
|
129
|
+
/** Only for `{ type: "provider" }`; a caller reference carries its own. */
|
|
130
|
+
readonly correlationId?: string;
|
|
131
|
+
/** Optional W3C trace id for provider-initiated work. */
|
|
132
|
+
readonly traceId?: string;
|
|
133
|
+
readonly checks: readonly IAuthorizationCheck[];
|
|
134
|
+
}
|
|
135
|
+
/** What an allow comes with. */
|
|
136
|
+
interface IAuthorizationObligations {
|
|
137
|
+
/** The declared limits of the resource. Apply them right before executing. */
|
|
138
|
+
readonly constraints?: IResourceLimits;
|
|
139
|
+
}
|
|
140
|
+
interface IAuthorizationDecision {
|
|
141
|
+
readonly decisionId: string;
|
|
142
|
+
/** `allow-with-constraints`: allowed, within `obligations`. */
|
|
143
|
+
readonly effect: "allow" | "deny" | "allow-with-constraints";
|
|
144
|
+
/**
|
|
145
|
+
* `true` only for an unconditional `allow`. A provider that reads only
|
|
146
|
+
* this field therefore refuses a constrained allow rather than ignoring
|
|
147
|
+
* its constraints; read `effect` to apply them.
|
|
148
|
+
*/
|
|
149
|
+
readonly allowed: boolean;
|
|
150
|
+
readonly reason: string;
|
|
151
|
+
readonly policies?: readonly string[];
|
|
152
|
+
readonly obligations?: IAuthorizationObligations;
|
|
153
|
+
}
|
|
154
|
+
/** `broker/audit/result` parameters: the outcome of what a decision allowed or refused. */
|
|
155
|
+
interface IAuditResult {
|
|
156
|
+
/** The `decisionId` the broker returned. */
|
|
157
|
+
readonly decisionId: string;
|
|
158
|
+
readonly result: "success" | "failure" | "refused";
|
|
159
|
+
/** The protocol's own status (`Good`, an exception code, ...). */
|
|
160
|
+
readonly nativeStatus?: string;
|
|
161
|
+
/** The provider's error code, when it failed or refused. */
|
|
162
|
+
readonly errorCode?: string;
|
|
163
|
+
}
|
|
164
|
+
interface IAuthorizationAnswer {
|
|
165
|
+
readonly policyVersion: string;
|
|
166
|
+
/** One per check, in the same order. */
|
|
167
|
+
readonly decisions: readonly IAuthorizationDecision[];
|
|
168
|
+
}
|
|
169
|
+
/** The broker refused a request, or never answered it. */
|
|
170
|
+
declare class BrokerRequestError extends Error {
|
|
171
|
+
/** JSON-RPC error code; `undefined` for a timeout or a closed socket. */
|
|
172
|
+
readonly code?: number | undefined;
|
|
173
|
+
/** The error's `data`, e.g. `{ errors: [...] }` for a refused declaration. */
|
|
174
|
+
readonly data?: unknown | undefined;
|
|
175
|
+
constructor(message: string,
|
|
176
|
+
/** JSON-RPC error code; `undefined` for a timeout or a closed socket. */
|
|
177
|
+
code?: number | undefined,
|
|
178
|
+
/** The error's `data`, e.g. `{ errors: [...] }` for a refused declaration. */
|
|
179
|
+
data?: unknown | undefined);
|
|
180
|
+
}
|
|
181
|
+
interface IBrokerClientOptions {
|
|
182
|
+
/**
|
|
183
|
+
* Rejects a request the broker did not answer within this many ms. Off by
|
|
184
|
+
* default: a broker from 1.4.1 on answers every request at once, refusals
|
|
185
|
+
* included, so waiting is never the normal path. Set it only to talk to an
|
|
186
|
+
* older broker, which drops what it does not know.
|
|
187
|
+
*/
|
|
188
|
+
readonly requestTimeoutMs?: number;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The `broker/*` methods of one provider slot. Reached as `transport.broker`
|
|
192
|
+
* on {@link DirectTransport} and {@link MultiplexTransport}; the transport
|
|
193
|
+
* routes the broker's answers here before anything reaches the MCP server.
|
|
194
|
+
*/
|
|
195
|
+
declare class BrokerClient {
|
|
196
|
+
private readonly _write;
|
|
197
|
+
private readonly _writeTelemetry;
|
|
198
|
+
private readonly _timeoutMs;
|
|
199
|
+
private readonly _waiting;
|
|
200
|
+
private _next;
|
|
201
|
+
constructor(write: (frame: string) => void, options?: IBrokerClientOptions, writeTelemetry?: (frame: string) => boolean);
|
|
202
|
+
/**
|
|
203
|
+
* Declares this provider's authorization domain. Resolves when the broker
|
|
204
|
+
* accepted it; rejects with a {@link BrokerRequestError} whose `data.errors`
|
|
205
|
+
* lists every problem otherwise. Until it resolves, serve nothing that
|
|
206
|
+
* needs a decision.
|
|
207
|
+
*/
|
|
208
|
+
declare(declaration: IAuthorizationDeclaration): Promise<IDeclarationAccepted>;
|
|
209
|
+
/** Asks for one decision per check. */
|
|
210
|
+
authorize(query: IAuthorizationQuery): Promise<IAuthorizationAnswer>;
|
|
211
|
+
/**
|
|
212
|
+
* Reports what happened after a decision, so the broker's audit shows the
|
|
213
|
+
* outcome next to the decision. A notification: nothing comes back, and a
|
|
214
|
+
* report the broker cannot match is counted on its side.
|
|
215
|
+
*/
|
|
216
|
+
reportResult(report: IAuditResult): void;
|
|
217
|
+
/** Emits one complete provider span, or returns false when the link is down. */
|
|
218
|
+
span(span: ITelemetrySpan): boolean;
|
|
219
|
+
/** Number of requests still waiting for the broker. */
|
|
220
|
+
get pendingCount(): number;
|
|
221
|
+
/**
|
|
222
|
+
* Consumes a frame when it answers one of this client's requests. Returns
|
|
223
|
+
* `true` when it did, and the frame must not reach the MCP server.
|
|
224
|
+
* @internal Called by the transports.
|
|
225
|
+
*/
|
|
226
|
+
handleIncoming(frame: string): boolean;
|
|
227
|
+
/**
|
|
228
|
+
* Fails every waiting request: the socket they went out on is gone, and a
|
|
229
|
+
* reconnected one will not carry their answers.
|
|
230
|
+
* @internal Called by the transports.
|
|
231
|
+
*/
|
|
232
|
+
rejectAll(reason: string): void;
|
|
233
|
+
private _request;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Options accepted by {@link DirectTransport}. */
|
|
237
|
+
interface IDirectTransportOptions {
|
|
238
|
+
/**
|
|
239
|
+
* Join the broker's `_all` aggregate slot as well as this provider's own
|
|
240
|
+
* slot, by sending the registration notification
|
|
241
|
+
* `{"jsonrpc":"2.0","method":"notifications/register","params":{"aggregate":true}}`
|
|
242
|
+
* as the first frame on the socket.
|
|
243
|
+
*
|
|
244
|
+
* Opt-in on purpose: `_all` exposes this provider's tools and prompts to
|
|
245
|
+
* every client of the aggregate slot, so a provider that does not ask for it
|
|
246
|
+
* stays reachable only on its own slot.
|
|
247
|
+
*
|
|
248
|
+
* ORDERING: the broker runs `initialize` against a newly aggregated provider
|
|
249
|
+
* immediately, and drops it from `_all` without a word if the handshake times
|
|
250
|
+
* out. The frame therefore goes out on `open`, never from the constructor, so
|
|
251
|
+
* assign `onMessage` (or hand this transport to an MCP server, which assigns
|
|
252
|
+
* it for you) *before* calling {@link DirectTransport.connect}. Connecting
|
|
253
|
+
* first and wiring the handler afterwards loses the broker's `initialize` and
|
|
254
|
+
* the provider silently never appears in `_all`.
|
|
255
|
+
*/
|
|
256
|
+
aggregate?: boolean;
|
|
257
|
+
/**
|
|
258
|
+
* Rejects a `broker.declare()` / `broker.authorize()` the broker did not
|
|
259
|
+
* answer within this many ms. Off by default: a broker from 1.4.1 on answers
|
|
260
|
+
* at once. Only for an older broker, which drops methods it does not know.
|
|
261
|
+
*/
|
|
262
|
+
brokerRequestTimeoutMs?: number;
|
|
263
|
+
/**
|
|
264
|
+
* The provider secret, sent as the `X-Provider-Token` header of the
|
|
265
|
+
* WebSocket handshake. Required by a broker that authenticates providers
|
|
266
|
+
* (`providerSecret`, or the security file's `providers` table, where it is
|
|
267
|
+
* what gives this provider its own identity).
|
|
268
|
+
*
|
|
269
|
+
* **Node only.** Node's `WebSocket` (22 and later) accepts handshake
|
|
270
|
+
* headers; a browser's does not, and a browser provider cannot
|
|
271
|
+
* authenticate (terminate provider auth in a reverse proxy instead).
|
|
272
|
+
*/
|
|
273
|
+
secret?: string;
|
|
274
|
+
/** Extra handshake headers, Node only, like {@link secret}. */
|
|
275
|
+
headers?: Readonly<Record<string, string>>;
|
|
276
|
+
}
|
|
4
277
|
/**
|
|
5
278
|
* 1:1 WebSocket transport, wraps a single `WebSocket` connection to a broker
|
|
6
279
|
* provider slot, typically `ws://<broker>/provider/<name>`.
|
|
@@ -9,16 +282,32 @@ import { IMessageTransport } from '@cyanmycelium/mcp-core';
|
|
|
9
282
|
* through the same broker, prefer {@link MultiplexTransport}, which shares a
|
|
10
283
|
* single socket between them.
|
|
11
284
|
*
|
|
285
|
+
* This transport does **not** reconnect: when the socket closes, it stays
|
|
286
|
+
* closed until the application calls {@link connect} again. Only
|
|
287
|
+
* {@link MultiplexTransport}'s shared socket reconnects on its own.
|
|
288
|
+
*
|
|
12
289
|
* Call {@link connect} after setting the event callbacks to open the socket.
|
|
13
290
|
*/
|
|
14
291
|
declare class DirectTransport implements IMessageTransport {
|
|
15
292
|
private readonly _wsUrl;
|
|
293
|
+
private readonly _aggregate;
|
|
294
|
+
private readonly _headers;
|
|
295
|
+
private readonly _pending;
|
|
296
|
+
/** Throttles the "wrote to a closed transport" line, which repeats per frame. */
|
|
297
|
+
private readonly _afterCloseNotice;
|
|
16
298
|
private _ws;
|
|
299
|
+
private _closed;
|
|
17
300
|
onMessage: ((data: string) => void) | null;
|
|
18
301
|
onOpen: (() => void) | null;
|
|
19
302
|
onClose: (() => void) | null;
|
|
20
303
|
onError: ((error: Error) => void) | null;
|
|
21
|
-
|
|
304
|
+
/**
|
|
305
|
+
* The broker's own methods for this slot: declaring an authorization
|
|
306
|
+
* domain, asking for decisions. Its answers are taken off the socket
|
|
307
|
+
* before {@link onMessage} sees anything.
|
|
308
|
+
*/
|
|
309
|
+
readonly broker: BrokerClient;
|
|
310
|
+
constructor(wsUrl: string, options?: IDirectTransportOptions);
|
|
22
311
|
get isOpen(): boolean;
|
|
23
312
|
/**
|
|
24
313
|
* Opens the WebSocket connection and wires its events to the transport
|
|
@@ -27,6 +316,23 @@ declare class DirectTransport implements IMessageTransport {
|
|
|
27
316
|
connect(): void;
|
|
28
317
|
send(data: string): void;
|
|
29
318
|
close(): void;
|
|
319
|
+
/**
|
|
320
|
+
* Sends the registration notification when the caller asked for a specific
|
|
321
|
+
* aggregate membership. The slot name is not in the frame: on the
|
|
322
|
+
* slot-scoped path the broker takes it from the URL at connect time.
|
|
323
|
+
*/
|
|
324
|
+
private _sendRegistration;
|
|
325
|
+
/** Writes out everything queued while the socket was connecting. */
|
|
326
|
+
private _flush;
|
|
327
|
+
/**
|
|
328
|
+
* Builds the message for a close worth reporting, or `undefined` when there
|
|
329
|
+
* is nothing to say.
|
|
330
|
+
*
|
|
331
|
+
* A code of 1000 is a normal close and stays silent. An environment that
|
|
332
|
+
* calls `onclose` with no event at all leaves nothing to distinguish a
|
|
333
|
+
* refusal from a clean shutdown, so that stays silent too.
|
|
334
|
+
*/
|
|
335
|
+
private _closeError;
|
|
30
336
|
}
|
|
31
337
|
|
|
32
338
|
/**
|
|
@@ -41,27 +347,100 @@ declare class MultiplexSocket {
|
|
|
41
347
|
/** Per-URL cache so all transports targeting the same tunnel share one socket. */
|
|
42
348
|
private static readonly _instances;
|
|
43
349
|
private readonly _wsUrl;
|
|
350
|
+
/** Cache key: the URL, and the handshake headers, since two secrets are two identities and need two sockets. */
|
|
351
|
+
private readonly _key;
|
|
352
|
+
private readonly _headers;
|
|
44
353
|
private readonly _transports;
|
|
354
|
+
/** Aggregate opt-in per slot, absent when the caller did not express one. */
|
|
355
|
+
private readonly _aggregates;
|
|
356
|
+
/** Frames written while the socket is connecting, reconnecting or backing off. */
|
|
357
|
+
private readonly _pending;
|
|
45
358
|
private _ws;
|
|
46
359
|
private _reconnectAttempts;
|
|
360
|
+
private _reconnectTimer;
|
|
47
361
|
private _stopped;
|
|
362
|
+
/** Set once the last transport left and this instance gave up its URL. */
|
|
363
|
+
private _dead;
|
|
364
|
+
/** The URL guard is about the URL, not the socket, so it is said once. */
|
|
365
|
+
private _pathWarned;
|
|
366
|
+
/** Throttles the "wrote to a closed tunnel" line, which repeats per frame. */
|
|
367
|
+
private readonly _afterStopNotice;
|
|
48
368
|
private constructor();
|
|
49
|
-
/** Returns (or creates) the singleton socket for a given tunnel URL. */
|
|
50
|
-
static getOrCreate(wsUrl: string): MultiplexSocket;
|
|
369
|
+
/** Returns (or creates) the singleton socket for a given tunnel URL and handshake headers. */
|
|
370
|
+
static getOrCreate(wsUrl: string, headers?: Record<string, string>): MultiplexSocket;
|
|
51
371
|
get isOpen(): boolean;
|
|
52
|
-
register(name: string, transport: MultiplexTransport): void;
|
|
372
|
+
register(name: string, transport: MultiplexTransport, aggregate?: boolean): void;
|
|
53
373
|
unregister(name: string): void;
|
|
374
|
+
/**
|
|
375
|
+
* Brings a torn-down instance back into service when a transport that
|
|
376
|
+
* captured it is reactivated.
|
|
377
|
+
*
|
|
378
|
+
* Reclaims the URL when nothing else holds it. When another instance already
|
|
379
|
+
* does, the two would race for the same slot names, so this says exactly that
|
|
380
|
+
* rather than letting the loser fail with a refusal nobody reads.
|
|
381
|
+
*/
|
|
382
|
+
private _revive;
|
|
54
383
|
/**
|
|
55
384
|
* Claims the slot for `name` so the broker eagerly creates its provider
|
|
56
385
|
* state before any MCP client connects. Without it the broker only learns
|
|
57
386
|
* about a provider on its first real message, and a client connecting in
|
|
58
387
|
* between is told the provider is not connected.
|
|
388
|
+
*
|
|
389
|
+
* Carries the aggregate opt-in when the caller expressed one, which is why
|
|
390
|
+
* it must go out before any traffic: the broker runs `initialize` against a
|
|
391
|
+
* newly aggregated provider straight away.
|
|
59
392
|
*/
|
|
60
393
|
private _announceProvider;
|
|
61
394
|
send(provider: string, data: string): void;
|
|
395
|
+
/** Sends low-priority telemetry only while the shared link is open. */
|
|
396
|
+
sendTelemetry(provider: string, data: string): boolean;
|
|
62
397
|
private _connect;
|
|
398
|
+
/** Writes out everything queued while the socket was down. */
|
|
399
|
+
private _flush;
|
|
63
400
|
private _routeIncoming;
|
|
64
401
|
private _scheduleReconnect;
|
|
402
|
+
/** Disarms a pending reconnect, so nothing opens a socket behind our back. */
|
|
403
|
+
private _cancelReconnect;
|
|
404
|
+
}
|
|
405
|
+
/** Options accepted by {@link MultiplexTransport.create}. */
|
|
406
|
+
interface IMultiplexTransportOptions {
|
|
407
|
+
/**
|
|
408
|
+
* Join the broker's `_all` aggregate slot as well as this provider's own
|
|
409
|
+
* slot, by carrying `params: { aggregate: true }` on the registration
|
|
410
|
+
* notification the shared socket sends when it claims the slot.
|
|
411
|
+
*
|
|
412
|
+
* Opt-in on purpose: `_all` exposes this provider's tools and prompts to
|
|
413
|
+
* every client of the aggregate slot, so a provider that does not ask for it
|
|
414
|
+
* stays reachable only on its own slot.
|
|
415
|
+
*
|
|
416
|
+
* ORDERING: the broker runs `initialize` against a newly aggregated provider
|
|
417
|
+
* immediately, and drops it from `_all` without a word if the handshake times
|
|
418
|
+
* out. The registration goes out from {@link MultiplexTransport.connect}, so
|
|
419
|
+
* assign `onMessage` (or hand this transport to an MCP server, which assigns
|
|
420
|
+
* it for you) *before* connecting. Connecting first and wiring the handler
|
|
421
|
+
* afterwards loses the broker's `initialize`, and the provider silently never
|
|
422
|
+
* appears in `_all`.
|
|
423
|
+
*/
|
|
424
|
+
aggregate?: boolean;
|
|
425
|
+
/**
|
|
426
|
+
* Rejects a `broker.declare()` / `broker.authorize()` the broker did not
|
|
427
|
+
* answer within this many ms. Off by default: a broker from 1.4.1 on answers
|
|
428
|
+
* at once. Only for an older broker, which drops methods it does not know.
|
|
429
|
+
*/
|
|
430
|
+
brokerRequestTimeoutMs?: number;
|
|
431
|
+
/**
|
|
432
|
+
* The provider secret, sent as the `X-Provider-Token` header of the
|
|
433
|
+
* WebSocket handshake. Required by a broker that authenticates providers
|
|
434
|
+
* (`providerSecret`, or the security file's `providers` table, where it is
|
|
435
|
+
* what gives this provider its own identity).
|
|
436
|
+
*
|
|
437
|
+
* **Node only.** Node's `WebSocket` (22 and later) accepts handshake
|
|
438
|
+
* headers; a browser's does not, and a browser provider cannot
|
|
439
|
+
* authenticate (terminate provider auth in a reverse proxy instead).
|
|
440
|
+
*/
|
|
441
|
+
secret?: string;
|
|
442
|
+
/** Extra handshake headers, Node only, like {@link secret}. */
|
|
443
|
+
headers?: Readonly<Record<string, string>>;
|
|
65
444
|
}
|
|
66
445
|
/**
|
|
67
446
|
* A transport that multiplexes multiple MCP servers over a single shared
|
|
@@ -77,19 +456,36 @@ declare class MultiplexSocket {
|
|
|
77
456
|
declare class MultiplexTransport implements IMessageTransport {
|
|
78
457
|
private readonly _name;
|
|
79
458
|
private readonly _socket;
|
|
459
|
+
private readonly _aggregate;
|
|
80
460
|
private _registered;
|
|
81
461
|
onMessage: ((data: string) => void) | null;
|
|
82
462
|
onOpen: (() => void) | null;
|
|
83
463
|
onClose: (() => void) | null;
|
|
84
464
|
onError: ((error: Error) => void) | null;
|
|
85
|
-
|
|
465
|
+
/**
|
|
466
|
+
* The broker's own methods for this slot: declaring an authorization
|
|
467
|
+
* domain, asking for decisions. Its answers are taken off the socket
|
|
468
|
+
* before {@link onMessage} sees anything.
|
|
469
|
+
*/
|
|
470
|
+
readonly broker: BrokerClient;
|
|
471
|
+
constructor(name: string, socket: MultiplexSocket, options?: IMultiplexTransportOptions);
|
|
472
|
+
/**
|
|
473
|
+
* One frame from the broker for this slot: the broker's answer to one of
|
|
474
|
+
* {@link broker}'s requests, or MCP traffic for the server.
|
|
475
|
+
* @internal Called by the shared socket.
|
|
476
|
+
*/
|
|
477
|
+
_receive(frame: string): void;
|
|
86
478
|
/**
|
|
87
479
|
* Convenience factory: creates a {@link MultiplexTransport} backed by a
|
|
88
480
|
* shared {@link MultiplexSocket} for the given tunnel URL.
|
|
89
481
|
*
|
|
90
482
|
* Transports targeting the same `wsUrl` automatically share one WebSocket.
|
|
483
|
+
*
|
|
484
|
+
* @param wsUrl The broker's shared multiplex base, `ws://<broker>/providers`.
|
|
485
|
+
* A slot-scoped `/provider/<name>` URL belongs to
|
|
486
|
+
* {@link DirectTransport} and is warned about on connect.
|
|
91
487
|
*/
|
|
92
|
-
static create(name: string, wsUrl: string): MultiplexTransport;
|
|
488
|
+
static create(name: string, wsUrl: string, options?: IMultiplexTransportOptions): MultiplexTransport;
|
|
93
489
|
get isOpen(): boolean;
|
|
94
490
|
/**
|
|
95
491
|
* Registers this transport with the shared socket.
|
|
@@ -109,4 +505,4 @@ declare class MultiplexTransport implements IMessageTransport {
|
|
|
109
505
|
close(): void;
|
|
110
506
|
}
|
|
111
507
|
|
|
112
|
-
export { DirectTransport, MultiplexTransport };
|
|
508
|
+
export { AUDIT_RESULT_NOTIFICATION_METHOD, BrokerClient, BrokerRequestError, CALLER_META_KEY, DirectTransport, type IAuditResult, type IAuthorizationAnswer, type IAuthorizationCheck, type IAuthorizationDecision, type IAuthorizationDeclaration, type IAuthorizationObligations, type IAuthorizationQuery, type IBrokerClientOptions, type ICallerReference, type IDeclarationAccepted, type IDeclaredResource, type IDirectTransportOptions, type IMultiplexTransportOptions, type IResourceLimits, type ITelemetryEvent, type ITelemetrySpan, type ITraceParent, MultiplexTransport, TELEMETRY_NOTIFICATION_METHOD, TRACEPARENT_META_KEY, type TelemetryAttributeValue, callerReferenceOf, childTraceparent, formatTraceparent, parseTraceparent, traceparentOf, withTraceparent };
|