@vurb/swarm 3.8.2
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 +315 -0
- package/dist/NamespaceRewriter.d.ts +45 -0
- package/dist/NamespaceRewriter.d.ts.map +1 -0
- package/dist/NamespaceRewriter.js +70 -0
- package/dist/NamespaceRewriter.js.map +1 -0
- package/dist/ReturnTripInjector.d.ts +62 -0
- package/dist/ReturnTripInjector.d.ts.map +1 -0
- package/dist/ReturnTripInjector.js +120 -0
- package/dist/ReturnTripInjector.js.map +1 -0
- package/dist/SwarmGateway.d.ts +146 -0
- package/dist/SwarmGateway.d.ts.map +1 -0
- package/dist/SwarmGateway.js +347 -0
- package/dist/SwarmGateway.js.map +1 -0
- package/dist/UpstreamMcpClient.d.ts +85 -0
- package/dist/UpstreamMcpClient.d.ts.map +1 -0
- package/dist/UpstreamMcpClient.js +266 -0
- package/dist/UpstreamMcpClient.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { HandoffPayload, HandoffStateStore, ToolResponse } from '@vurb/core';
|
|
2
|
+
import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
export interface SwarmGatewayConfig {
|
|
4
|
+
/**
|
|
5
|
+
* Domain → upstream URI registry.
|
|
6
|
+
* @example `{ finance: 'http://finance-agent:8081', devops: 'http://devops-agent:8082' }`
|
|
7
|
+
*/
|
|
8
|
+
registry: Record<string, string>;
|
|
9
|
+
/** HMAC secret shared with all upstream micro-servers. */
|
|
10
|
+
delegationSecret: string;
|
|
11
|
+
/** Store for Claim-Check pattern (state > 2 KB). Defaults to InMemory. */
|
|
12
|
+
stateStore?: HandoffStateStore;
|
|
13
|
+
/** Upstream connection timeout in ms (default: 5 000). */
|
|
14
|
+
connectTimeoutMs?: number;
|
|
15
|
+
/** Tunnel idle timeout in ms (default: 300 000 = 5 min). */
|
|
16
|
+
idleTimeoutMs?: number;
|
|
17
|
+
/** Delegation token TTL in seconds (default: 60). */
|
|
18
|
+
tokenTtlSeconds?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Upstream transport selection.
|
|
21
|
+
* - `'auto'` (default): SSE on Node.js, HTTP on edge runtimes
|
|
22
|
+
* - `'sse'`: Always use SSE (persistent connection)
|
|
23
|
+
* - `'http'`: Always use Streamable HTTP (stateless, edge-compatible)
|
|
24
|
+
*/
|
|
25
|
+
upstreamTransport?: 'auto' | 'sse' | 'http';
|
|
26
|
+
/**
|
|
27
|
+
* Name used for the return-trip tool.
|
|
28
|
+
* Default: `'gateway'` → tool name `'gateway.return_to_triage'`
|
|
29
|
+
*/
|
|
30
|
+
gatewayName?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Maximum number of concurrent active handoff sessions.
|
|
33
|
+
* Excess activations are rejected with `REGISTRY_SESSION_LIMIT_EXCEEDED`.
|
|
34
|
+
* Default: 100.
|
|
35
|
+
*/
|
|
36
|
+
maxSessions?: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* SwarmGateway — B2BUA for multi-agent MCP orchestration.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```typescript
|
|
43
|
+
* import { SwarmGateway } from '@vurb/swarm';
|
|
44
|
+
*
|
|
45
|
+
* const gateway = new SwarmGateway({
|
|
46
|
+
* registry: {
|
|
47
|
+
* finance: 'http://finance-agent:8081',
|
|
48
|
+
* devops: 'http://devops-agent:8082',
|
|
49
|
+
* },
|
|
50
|
+
* delegationSecret: process.env.VURB_DELEGATION_SECRET!,
|
|
51
|
+
* });
|
|
52
|
+
*
|
|
53
|
+
* // Pass to attachToServer:
|
|
54
|
+
* registry.attachToServer(server, { swarmGateway: gateway });
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export declare class SwarmGateway {
|
|
58
|
+
private readonly _config;
|
|
59
|
+
private readonly _sessions;
|
|
60
|
+
private readonly _rewriter;
|
|
61
|
+
private readonly _closingSessions;
|
|
62
|
+
constructor(config: SwarmGatewayConfig);
|
|
63
|
+
/**
|
|
64
|
+
* Activate a handoff tunnel to the upstream server described by `payload`.
|
|
65
|
+
*
|
|
66
|
+
* Called by `ServerAttachment` after detecting a `HandoffResponse` from a
|
|
67
|
+
* tool handler. Connects asynchronously — does not block the ACK to the LLM.
|
|
68
|
+
*
|
|
69
|
+
* **Concurrent safety**: If an activation is already in progress or active
|
|
70
|
+
* for `sessionId`, the previous session is disposed before the new one starts.
|
|
71
|
+
*
|
|
72
|
+
* @param payload - Handoff payload produced by `f.handoff()`
|
|
73
|
+
* @param sessionId - MCP session ID (used to key the active tunnel)
|
|
74
|
+
* @param signal - AbortSignal tied to the parent MCP connection
|
|
75
|
+
* @throws If the target is not found in the registry
|
|
76
|
+
* @throws If the session limit is exceeded
|
|
77
|
+
*/
|
|
78
|
+
activateHandoff(payload: HandoffPayload, sessionId: string, signal: AbortSignal): Promise<void>;
|
|
79
|
+
/**
|
|
80
|
+
* Proxy the tools/list for an active session.
|
|
81
|
+
*
|
|
82
|
+
* Returns `null` for non-existent sessions.
|
|
83
|
+
* Returns only the return-trip escape tool if the upstream is unreachable.
|
|
84
|
+
* Returns an empty list while the session is still connecting.
|
|
85
|
+
*/
|
|
86
|
+
proxyToolsList(sessionId: string): Promise<McpTool[] | null>;
|
|
87
|
+
/**
|
|
88
|
+
* Proxy a tools/call for an active session.
|
|
89
|
+
*
|
|
90
|
+
* Strips the namespace prefix before forwarding to the upstream.
|
|
91
|
+
*
|
|
92
|
+
* @returns The upstream's ToolResponse, or `null` if no active tunnel.
|
|
93
|
+
*/
|
|
94
|
+
proxyToolsCall(sessionId: string, name: string, args: Record<string, unknown>, signal: AbortSignal): Promise<ToolResponse | null>;
|
|
95
|
+
/**
|
|
96
|
+
* Close the tunnel for `sessionId` and restore the gateway's tool list.
|
|
97
|
+
*
|
|
98
|
+
* Called when the LLM invokes the `gateway.return_to_triage` tool.
|
|
99
|
+
* `ServerAttachment` emits `notifications/tools/list_changed` after this.
|
|
100
|
+
*/
|
|
101
|
+
returnToGateway(sessionId: string): Promise<void>;
|
|
102
|
+
/**
|
|
103
|
+
* `true` if there is a tracked tunnel (connecting or active) for the given session.
|
|
104
|
+
*
|
|
105
|
+
* Returning `true` during `'connecting'` ensures `ServerAttachment` routes
|
|
106
|
+
* tools/call through `proxyToolsCall`, which already returns the correct
|
|
107
|
+
* `HANDOFF_CONNECTING` error for in-progress tunnels — preventing gateway-local
|
|
108
|
+
* execution from silently bypassing the handoff.
|
|
109
|
+
*/
|
|
110
|
+
hasActiveHandoff(sessionId: string): boolean;
|
|
111
|
+
/** `true` if an activation is in progress for the given session. */
|
|
112
|
+
isConnecting(sessionId: string): boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Total number of tracked sessions (connecting + active).
|
|
115
|
+
*
|
|
116
|
+
* exposed for integration testing and observability.
|
|
117
|
+
* Allows tests to assert session lifecycle transitions deterministically
|
|
118
|
+
* without accessing private state via casting.
|
|
119
|
+
*/
|
|
120
|
+
get sessionCount(): number;
|
|
121
|
+
/**
|
|
122
|
+
* Number of sessions currently in the `'connecting'` state.
|
|
123
|
+
*
|
|
124
|
+
* useful for load-shedding checks and integration tests.
|
|
125
|
+
*/
|
|
126
|
+
get connectingCount(): number;
|
|
127
|
+
/**
|
|
128
|
+
* Dispose all active sessions and release all resources.
|
|
129
|
+
*
|
|
130
|
+
* Call this when shutting down the gateway to cleanly close
|
|
131
|
+
* all upstream connections, idle timers, and AbortController listeners.
|
|
132
|
+
*/
|
|
133
|
+
dispose(): Promise<void>;
|
|
134
|
+
private _closeSession;
|
|
135
|
+
/**
|
|
136
|
+
* Extract the domain key from a target URI.
|
|
137
|
+
*
|
|
138
|
+
* `'mcp://finance-agent.internal:8080'` → `'finance'` (registry lookup)
|
|
139
|
+
* `'finance'` → `'finance'` (direct key)
|
|
140
|
+
*
|
|
141
|
+
* @throws If the target does not resolve to any entry in the registry.
|
|
142
|
+
* Configure all targets explicitly in `SwarmGatewayConfig.registry`.
|
|
143
|
+
*/
|
|
144
|
+
private _resolveDomain;
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=SwarmGateway.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SwarmGateway.d.ts","sourceRoot":"","sources":["../src/SwarmGateway.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,cAAc,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAIlF,OAAO,KAAK,EAAE,IAAI,IAAI,OAAO,EAAE,MAAM,oCAAoC,CAAC;AAM1E,MAAM,WAAW,kBAAkB;IAC/B;;;OAGG;IACH,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,0DAA0D;IAC1D,gBAAgB,EAAE,MAAM,CAAC;IACzB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,0DAA0D;IAC1D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4DAA4D;IAC5D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,qDAAqD;IACrD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,CAAC;IAC5C;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACxB;AAkBD;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,YAAY;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA+B;IACvD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAmC;IAC7D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA2B;IAErD,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqB;gBAE1C,MAAM,EAAE,kBAAkB;IAiCtC;;;;;;;;;;;;;;OAcG;IACG,eAAe,CACjB,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,WAAW,GACpB,OAAO,CAAC,IAAI,CAAC;IAiEhB;;;;;;OAMG;IACG,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC;IAsBlE;;;;;;OAMG;IACG,cAAc,CAChB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,MAAM,EAAE,WAAW,GACpB,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IA6C/B;;;;;OAKG;IACG,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvD;;;;;;;OAOG;IACH,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAI5C,oEAAoE;IACpE,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAIxC;;;;;;OAMG;IACH,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED;;;;OAIG;IACH,IAAI,eAAe,IAAI,MAAM,CAM5B;IAED;;;;;OAKG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;YAOhB,aAAa;IAsB3B;;;;;;;;OAQG;IACH,OAAO,CAAC,cAAc;CAsCzB"}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Federated Handoff Protocol — SwarmGateway (B2BUA)
|
|
3
|
+
*
|
|
4
|
+
* The central orchestrator implementing the Back-to-Back User Agent pattern:
|
|
5
|
+
*
|
|
6
|
+
* - **External face (UAS)**: MCP server for the LLM client (Claude/Cursor)
|
|
7
|
+
* - **Internal face (UAC)**: MCP client for upstream micro-servers
|
|
8
|
+
*
|
|
9
|
+
* Lifecycle per session:
|
|
10
|
+
* 1. `activateHandoff()` — opens tunnel to upstream, mints delegation token
|
|
11
|
+
* 2. `proxyToolsList()` — returns prefixed upstream tools + return-trip tool
|
|
12
|
+
* 3. `proxyToolsCall()` — strips prefix, forwards to upstream
|
|
13
|
+
* 4. `returnToGateway()` — closes tunnel, session returns to gateway tools
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
18
|
+
import { mintDelegationToken, InMemoryHandoffStateStore, toolError, } from '@vurb/core';
|
|
19
|
+
import { UpstreamMcpClient } from './UpstreamMcpClient.js';
|
|
20
|
+
import { NamespaceRewriter, NamespaceError } from './NamespaceRewriter.js';
|
|
21
|
+
import { injectReturnTripTool } from './ReturnTripInjector.js';
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// SwarmGateway
|
|
24
|
+
// ============================================================================
|
|
25
|
+
/**
|
|
26
|
+
* SwarmGateway — B2BUA for multi-agent MCP orchestration.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* import { SwarmGateway } from '@vurb/swarm';
|
|
31
|
+
*
|
|
32
|
+
* const gateway = new SwarmGateway({
|
|
33
|
+
* registry: {
|
|
34
|
+
* finance: 'http://finance-agent:8081',
|
|
35
|
+
* devops: 'http://devops-agent:8082',
|
|
36
|
+
* },
|
|
37
|
+
* delegationSecret: process.env.VURB_DELEGATION_SECRET!,
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* // Pass to attachToServer:
|
|
41
|
+
* registry.attachToServer(server, { swarmGateway: gateway });
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export class SwarmGateway {
|
|
45
|
+
_config;
|
|
46
|
+
_sessions = new Map();
|
|
47
|
+
_rewriter = new NamespaceRewriter();
|
|
48
|
+
// tracks sessions currently being closed to prevent concurrent double-dispose
|
|
49
|
+
_closingSessions = new Set();
|
|
50
|
+
constructor(config) {
|
|
51
|
+
// validate registry — reject entries with empty URIs before they
|
|
52
|
+
// cause a silent `TypeError: Invalid URL` deep in _resolveTransport at runtime.
|
|
53
|
+
// An empty-string URI is always a configuration error, not a runtime edge case.
|
|
54
|
+
const emptyUriKeys = Object.entries(config.registry)
|
|
55
|
+
.filter(([, uri]) => !uri)
|
|
56
|
+
.map(([key]) => JSON.stringify(key));
|
|
57
|
+
if (emptyUriKeys.length > 0) {
|
|
58
|
+
throw Object.assign(new Error(`[vurb/swarm] Registry entries with empty URIs: ${emptyUriKeys.join(', ')}. ` +
|
|
59
|
+
'All registry values must be non-empty URI strings.'), { code: 'REGISTRY_INVALID_URI' });
|
|
60
|
+
}
|
|
61
|
+
this._config = {
|
|
62
|
+
stateStore: config.stateStore ?? new InMemoryHandoffStateStore(),
|
|
63
|
+
connectTimeoutMs: config.connectTimeoutMs ?? 5_000,
|
|
64
|
+
idleTimeoutMs: config.idleTimeoutMs ?? 300_000,
|
|
65
|
+
tokenTtlSeconds: config.tokenTtlSeconds ?? 60,
|
|
66
|
+
upstreamTransport: config.upstreamTransport ?? 'auto',
|
|
67
|
+
gatewayName: config.gatewayName ?? 'gateway',
|
|
68
|
+
// enforce a default session limit to prevent unbounded growth
|
|
69
|
+
maxSessions: config.maxSessions ?? 100,
|
|
70
|
+
registry: config.registry,
|
|
71
|
+
delegationSecret: config.delegationSecret,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// ── Public API ───────────────────────────────────────────
|
|
75
|
+
/**
|
|
76
|
+
* Activate a handoff tunnel to the upstream server described by `payload`.
|
|
77
|
+
*
|
|
78
|
+
* Called by `ServerAttachment` after detecting a `HandoffResponse` from a
|
|
79
|
+
* tool handler. Connects asynchronously — does not block the ACK to the LLM.
|
|
80
|
+
*
|
|
81
|
+
* **Concurrent safety**: If an activation is already in progress or active
|
|
82
|
+
* for `sessionId`, the previous session is disposed before the new one starts.
|
|
83
|
+
*
|
|
84
|
+
* @param payload - Handoff payload produced by `f.handoff()`
|
|
85
|
+
* @param sessionId - MCP session ID (used to key the active tunnel)
|
|
86
|
+
* @param signal - AbortSignal tied to the parent MCP connection
|
|
87
|
+
* @throws If the target is not found in the registry
|
|
88
|
+
* @throws If the session limit is exceeded
|
|
89
|
+
*/
|
|
90
|
+
async activateHandoff(payload, sessionId, signal) {
|
|
91
|
+
// dispose any pre-existing session for this ID before starting
|
|
92
|
+
await this._closeSession(sessionId);
|
|
93
|
+
// count ALL sessions (connecting + active) to prevent bypass attacks
|
|
94
|
+
// where an attacker opens maxSessions 'connecting' tunnels and then opens more.
|
|
95
|
+
if (this._sessions.size >= this._config.maxSessions) {
|
|
96
|
+
throw Object.assign(new Error(`[vurb/swarm] Session limit of ${this._config.maxSessions} reached. ` +
|
|
97
|
+
'Close existing sessions before activating more.'), { code: 'SESSION_LIMIT_EXCEEDED' });
|
|
98
|
+
}
|
|
99
|
+
// Mark the slot as 'connecting' immediately to prevent double-activation
|
|
100
|
+
this._sessions.set(sessionId, { status: 'connecting' });
|
|
101
|
+
// track the client so dispose() can be called if connect() fails
|
|
102
|
+
let client;
|
|
103
|
+
try {
|
|
104
|
+
// _resolveDomain throws on unknown targets
|
|
105
|
+
const domain = this._resolveDomain(payload.target);
|
|
106
|
+
const upstreamUri = this._config.registry[domain];
|
|
107
|
+
const traceparent = generateTraceparent();
|
|
108
|
+
const token = await mintDelegationToken(domain, this._config.tokenTtlSeconds, this._config.delegationSecret,
|
|
109
|
+
// use the configured gateway name as the token issuer (iss claim)
|
|
110
|
+
// so that tokens from different gateway instances are distinguishable in audit logs.
|
|
111
|
+
this._config.gatewayName, payload.carryOverState, this._config.stateStore, traceparent);
|
|
112
|
+
client = new UpstreamMcpClient(upstreamUri, {
|
|
113
|
+
connectTimeoutMs: this._config.connectTimeoutMs,
|
|
114
|
+
idleTimeoutMs: this._config.idleTimeoutMs,
|
|
115
|
+
delegationToken: token,
|
|
116
|
+
traceparent,
|
|
117
|
+
transport: this._config.upstreamTransport,
|
|
118
|
+
}, signal);
|
|
119
|
+
// update session with client reference BEFORE awaiting connect().
|
|
120
|
+
// This lets _closeSession dispose the client even during the in-flight connect,
|
|
121
|
+
// preventing dispose() called on the gateway from leaving zombie connections.
|
|
122
|
+
this._sessions.set(sessionId, { status: 'connecting', client });
|
|
123
|
+
await client.connect();
|
|
124
|
+
this._sessions.set(sessionId, { status: 'active', client, domain, traceparent });
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
this._sessions.delete(sessionId);
|
|
128
|
+
// Dispose the client if it was created, even if _closeSession already did so
|
|
129
|
+
// concurrently (e.g. parentSignal abort racing with the in-flight connect).
|
|
130
|
+
// UpstreamMcpClient.dispose() is fully idempotent — safe to call multiple times.
|
|
131
|
+
await client?.dispose();
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Proxy the tools/list for an active session.
|
|
137
|
+
*
|
|
138
|
+
* Returns `null` for non-existent sessions.
|
|
139
|
+
* Returns only the return-trip escape tool if the upstream is unreachable.
|
|
140
|
+
* Returns an empty list while the session is still connecting.
|
|
141
|
+
*/
|
|
142
|
+
async proxyToolsList(sessionId) {
|
|
143
|
+
const session = this._sessions.get(sessionId);
|
|
144
|
+
// distinguish 'no session' from 'still connecting'
|
|
145
|
+
if (!session)
|
|
146
|
+
return null;
|
|
147
|
+
// While connecting, report no tools yet (caller should retry)
|
|
148
|
+
if (session.status !== 'active')
|
|
149
|
+
return [];
|
|
150
|
+
// only wrap the network call in try/catch — not the pure transform
|
|
151
|
+
// functions. `rewriteList` and `injectReturnTripTool` are pure and should never
|
|
152
|
+
// throw. Catching them silently would hide programming errors as degraded UX.
|
|
153
|
+
let raw;
|
|
154
|
+
try {
|
|
155
|
+
raw = await session.client.listTools();
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// upstream went away — return only the escape hatch
|
|
159
|
+
return injectReturnTripTool([], this._config.gatewayName);
|
|
160
|
+
}
|
|
161
|
+
const prefixed = this._rewriter.rewriteList(raw, session.domain);
|
|
162
|
+
return injectReturnTripTool(prefixed, this._config.gatewayName);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Proxy a tools/call for an active session.
|
|
166
|
+
*
|
|
167
|
+
* Strips the namespace prefix before forwarding to the upstream.
|
|
168
|
+
*
|
|
169
|
+
* @returns The upstream's ToolResponse, or `null` if no active tunnel.
|
|
170
|
+
*/
|
|
171
|
+
async proxyToolsCall(sessionId, name, args, signal) {
|
|
172
|
+
const session = this._sessions.get(sessionId);
|
|
173
|
+
// No session at all — caller serves gateway-level tools
|
|
174
|
+
if (!session)
|
|
175
|
+
return null;
|
|
176
|
+
// when session is still connecting, return a descriptive error
|
|
177
|
+
// instead of null. Returning null causes ServerAttachment to look up the
|
|
178
|
+
// tool in the gateway's own list, which fails with a generic 'tool not found'.
|
|
179
|
+
if (session.status !== 'active') {
|
|
180
|
+
return toolError('HANDOFF_CONNECTING', {
|
|
181
|
+
message: 'The upstream specialist is still connecting. Please retry in a moment.',
|
|
182
|
+
suggestion: 'Wait 1-2 seconds and retry the same tool call.',
|
|
183
|
+
retryAfter: 2,
|
|
184
|
+
severity: 'warning',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const strippedName = this._rewriter.stripPrefix(name, session.domain);
|
|
189
|
+
return await session.client.callTool(strippedName, args, signal);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
// instanceof instead of duck-typing .name
|
|
193
|
+
if (err instanceof NamespaceError) {
|
|
194
|
+
return toolError('HANDOFF_NAMESPACE_MISMATCH', {
|
|
195
|
+
message: `Tool "${name}" does not match the active upstream domain "${session.domain}".`,
|
|
196
|
+
suggestion: 'Re-fetch the tools/list and retry with a valid tool name.',
|
|
197
|
+
severity: 'error',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
// log non-NamespaceError errors for observability before masking them.
|
|
201
|
+
// The LLM must not see internal error details (security + UX), but errors must
|
|
202
|
+
// surface somewhere so bugs are diagnosable in production.
|
|
203
|
+
console.warn(`[vurb/swarm] proxyToolsCall unexpected error for tool "${name}" in domain "${session.domain}":`, err);
|
|
204
|
+
return toolError('HANDOFF_UPSTREAM_UNAVAILABLE', {
|
|
205
|
+
message: `The upstream specialist "${session.domain}" is temporarily unavailable.`,
|
|
206
|
+
suggestion: 'Inform the user and retry in 30 seconds, or call gateway.return_to_triage.',
|
|
207
|
+
retryAfter: 30,
|
|
208
|
+
severity: 'error',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Close the tunnel for `sessionId` and restore the gateway's tool list.
|
|
214
|
+
*
|
|
215
|
+
* Called when the LLM invokes the `gateway.return_to_triage` tool.
|
|
216
|
+
* `ServerAttachment` emits `notifications/tools/list_changed` after this.
|
|
217
|
+
*/
|
|
218
|
+
async returnToGateway(sessionId) {
|
|
219
|
+
await this._closeSession(sessionId);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* `true` if there is a tracked tunnel (connecting or active) for the given session.
|
|
223
|
+
*
|
|
224
|
+
* Returning `true` during `'connecting'` ensures `ServerAttachment` routes
|
|
225
|
+
* tools/call through `proxyToolsCall`, which already returns the correct
|
|
226
|
+
* `HANDOFF_CONNECTING` error for in-progress tunnels — preventing gateway-local
|
|
227
|
+
* execution from silently bypassing the handoff.
|
|
228
|
+
*/
|
|
229
|
+
hasActiveHandoff(sessionId) {
|
|
230
|
+
return this._sessions.has(sessionId);
|
|
231
|
+
}
|
|
232
|
+
/** `true` if an activation is in progress for the given session. */
|
|
233
|
+
isConnecting(sessionId) {
|
|
234
|
+
return this._sessions.get(sessionId)?.status === 'connecting';
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Total number of tracked sessions (connecting + active).
|
|
238
|
+
*
|
|
239
|
+
* exposed for integration testing and observability.
|
|
240
|
+
* Allows tests to assert session lifecycle transitions deterministically
|
|
241
|
+
* without accessing private state via casting.
|
|
242
|
+
*/
|
|
243
|
+
get sessionCount() {
|
|
244
|
+
return this._sessions.size;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Number of sessions currently in the `'connecting'` state.
|
|
248
|
+
*
|
|
249
|
+
* useful for load-shedding checks and integration tests.
|
|
250
|
+
*/
|
|
251
|
+
get connectingCount() {
|
|
252
|
+
let count = 0;
|
|
253
|
+
for (const s of this._sessions.values()) {
|
|
254
|
+
if (s.status === 'connecting')
|
|
255
|
+
count++;
|
|
256
|
+
}
|
|
257
|
+
return count;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Dispose all active sessions and release all resources.
|
|
261
|
+
*
|
|
262
|
+
* Call this when shutting down the gateway to cleanly close
|
|
263
|
+
* all upstream connections, idle timers, and AbortController listeners.
|
|
264
|
+
*/
|
|
265
|
+
async dispose() {
|
|
266
|
+
const sessionIds = [...this._sessions.keys()];
|
|
267
|
+
await Promise.allSettled(sessionIds.map(id => this._closeSession(id)));
|
|
268
|
+
}
|
|
269
|
+
// ── Private ─────────────────────────────────────────────
|
|
270
|
+
async _closeSession(sessionId) {
|
|
271
|
+
// prevent concurrent double-dispose (e.g. abort signal + returnToGateway racing).
|
|
272
|
+
// If already closing, bail out immediately — dispose() is idempotent in UpstreamMcpClient
|
|
273
|
+
// but calling it concurrently can cause duplicate timers and listener leaks.
|
|
274
|
+
if (this._closingSessions.has(sessionId))
|
|
275
|
+
return;
|
|
276
|
+
const session = this._sessions.get(sessionId);
|
|
277
|
+
if (!session)
|
|
278
|
+
return;
|
|
279
|
+
this._closingSessions.add(sessionId);
|
|
280
|
+
this._sessions.delete(sessionId);
|
|
281
|
+
try {
|
|
282
|
+
// also dispose 'connecting' sessions if client was already created.
|
|
283
|
+
// This aborts the in-flight connect() and cleans up the parentSignal listener.
|
|
284
|
+
// Sessions where client hasn't been set yet (first ~few ms of activateHandoff)
|
|
285
|
+
// will simply be deleted from the map — activateHandoff's catch handles cleanup.
|
|
286
|
+
if (session.status === 'active' || (session.status === 'connecting' && session.client)) {
|
|
287
|
+
await session.client.dispose();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
this._closingSessions.delete(sessionId);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Extract the domain key from a target URI.
|
|
296
|
+
*
|
|
297
|
+
* `'mcp://finance-agent.internal:8080'` → `'finance'` (registry lookup)
|
|
298
|
+
* `'finance'` → `'finance'` (direct key)
|
|
299
|
+
*
|
|
300
|
+
* @throws If the target does not resolve to any entry in the registry.
|
|
301
|
+
* Configure all targets explicitly in `SwarmGatewayConfig.registry`.
|
|
302
|
+
*/
|
|
303
|
+
_resolveDomain(target) {
|
|
304
|
+
// reject empty-string targets before the registry lookup.
|
|
305
|
+
// An empty target is always a caller error; if the registry happened to
|
|
306
|
+
// contain a '' key, we would silently accept it and produce confusing logs.
|
|
307
|
+
if (!target) {
|
|
308
|
+
throw Object.assign(new Error('[vurb/swarm] Handoff target must not be an empty string.'), { code: 'REGISTRY_LOOKUP_FAILED' });
|
|
309
|
+
}
|
|
310
|
+
// use Object.hasOwn instead of truthiness — an empty string value
|
|
311
|
+
// is a config error, not a valid reason to skip the lookup and throw a confusing error
|
|
312
|
+
if (Object.hasOwn(this._config.registry, target))
|
|
313
|
+
return target;
|
|
314
|
+
// Try to extract subdomain from mcp:// or mcps:// URI hostname
|
|
315
|
+
try {
|
|
316
|
+
const url = new URL(target
|
|
317
|
+
.replace(/^mcps:\/\//, 'https://') // handle secure mcp scheme
|
|
318
|
+
.replace(/^mcp:\/\//, 'http://'));
|
|
319
|
+
const subdomain = url.hostname.split('.')[0] ?? '';
|
|
320
|
+
if (subdomain && Object.hasOwn(this._config.registry, subdomain))
|
|
321
|
+
return subdomain;
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// ignore URL parse error
|
|
325
|
+
}
|
|
326
|
+
// sanitize the target before including it in the error message.
|
|
327
|
+
// A verbatim target can contain control characters, ANSI escapes, or adversarial
|
|
328
|
+
// payloads that pollute logs or trip structured error parsers.
|
|
329
|
+
const safeTarget = String(target).replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200);
|
|
330
|
+
throw Object.assign(new Error(`[vurb/swarm] Unknown handoff target "${safeTarget}". ` +
|
|
331
|
+
'All targets must be registered in SwarmGatewayConfig.registry.'), { code: 'REGISTRY_LOOKUP_FAILED' });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// ============================================================================
|
|
335
|
+
// W3C traceparent
|
|
336
|
+
// ============================================================================
|
|
337
|
+
/**
|
|
338
|
+
* Generate a W3C Trace Context `traceparent` header value.
|
|
339
|
+
* Format: `00-{32 hex}-{16 hex}-01`
|
|
340
|
+
* Uses `crypto.randomUUID()` — no external dependencies.
|
|
341
|
+
*/
|
|
342
|
+
function generateTraceparent() {
|
|
343
|
+
const traceId = randomUUID().replace(/-/g, '');
|
|
344
|
+
const spanId = randomUUID().replace(/-/g, '').slice(0, 16);
|
|
345
|
+
return `00-${traceId}-${spanId}-01`;
|
|
346
|
+
}
|
|
347
|
+
//# sourceMappingURL=SwarmGateway.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SwarmGateway.js","sourceRoot":"","sources":["../src/SwarmGateway.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import type { ToolResponse } from '@vurb/core';
|
|
3
|
+
/** Progress notification forwarded from the upstream to the gateway client. */
|
|
4
|
+
export interface ProgressNotification {
|
|
5
|
+
method: string;
|
|
6
|
+
params: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
/** Callback used by SwarmGateway to relay progress upstream → LLM client. */
|
|
9
|
+
export type ProgressForwarder = (notification: ProgressNotification) => void;
|
|
10
|
+
export interface UpstreamMcpClientConfig {
|
|
11
|
+
/** Milliseconds to wait for the initial connection (default: 5 000). */
|
|
12
|
+
connectTimeoutMs: number;
|
|
13
|
+
/** Milliseconds of inactivity before the tunnel is closed (default: 300 000). */
|
|
14
|
+
idleTimeoutMs: number;
|
|
15
|
+
/** Delegation token to send via `x-vurb-delegation` header. */
|
|
16
|
+
delegationToken: string;
|
|
17
|
+
/** W3C traceparent to propagate (optional). */
|
|
18
|
+
traceparent?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Transport override.
|
|
21
|
+
* - `'auto'` (default): SSE on Node.js, HTTP on edge runtimes
|
|
22
|
+
* - `'sse'`: always SSE (persistent)
|
|
23
|
+
* - `'http'`: always Streamable HTTP (stateless, edge-compatible)
|
|
24
|
+
*/
|
|
25
|
+
transport?: 'auto' | 'sse' | 'http';
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Outbound MCP client for the SwarmGateway B2BUA face.
|
|
29
|
+
*
|
|
30
|
+
* One instance per active handoff session. Closed via `dispose()` or
|
|
31
|
+
* automatically when the idle timeout or AbortSignal fires.
|
|
32
|
+
*/
|
|
33
|
+
export declare class UpstreamMcpClient {
|
|
34
|
+
private readonly _targetUri;
|
|
35
|
+
private readonly _config;
|
|
36
|
+
private readonly _abortController;
|
|
37
|
+
private readonly _onParentAbort;
|
|
38
|
+
private readonly _parentSignal;
|
|
39
|
+
private _client;
|
|
40
|
+
private _idleTimer;
|
|
41
|
+
private _progressForwarder;
|
|
42
|
+
private _activeCalls;
|
|
43
|
+
private _disposed;
|
|
44
|
+
constructor(_targetUri: string, _config: UpstreamMcpClientConfig, parentSignal: AbortSignal);
|
|
45
|
+
/** Register a forwarder for upstream progress/logging notifications. */
|
|
46
|
+
setProgressForwarder(forwarder: ProgressForwarder): void;
|
|
47
|
+
/**
|
|
48
|
+
* Connect to the upstream server.
|
|
49
|
+
*
|
|
50
|
+
* @throws `Error` with `code: 'UPSTREAM_CONNECT_TIMEOUT'` if unreachable within `connectTimeoutMs`
|
|
51
|
+
*/
|
|
52
|
+
connect(): Promise<void>;
|
|
53
|
+
/** List all tools exposed by the upstream server. */
|
|
54
|
+
listTools(): Promise<McpTool[]>;
|
|
55
|
+
/**
|
|
56
|
+
* Call a tool on the upstream server.
|
|
57
|
+
* @param name - Tool name (without namespace prefix)
|
|
58
|
+
* @param args - Tool arguments
|
|
59
|
+
* @param signal - AbortSignal from the parent tools/call request
|
|
60
|
+
*/
|
|
61
|
+
callTool(name: string, args: Record<string, unknown>, signal: AbortSignal): Promise<ToolResponse>;
|
|
62
|
+
/** Close the connection and clear all timers. */
|
|
63
|
+
dispose(): Promise<void>;
|
|
64
|
+
private _assertConnected;
|
|
65
|
+
/**
|
|
66
|
+
* Build the MCP transport for the given headers and connect-phase abort signal.
|
|
67
|
+
*
|
|
68
|
+
* The `connectSignal` is only used for the initial TCP/SSE handshake.
|
|
69
|
+
* It is NOT the same as the session lifetime signal (`_abortController`).
|
|
70
|
+
*
|
|
71
|
+
* `mcps://` (secure MCP) is mapped to `https://`.
|
|
72
|
+
*/
|
|
73
|
+
private _resolveTransport;
|
|
74
|
+
/**
|
|
75
|
+
* Register notification handlers with correctly typed wrappers.
|
|
76
|
+
*
|
|
77
|
+
* The MCP SDK's `setNotificationHandler` expects a Zod schema as the first argument.
|
|
78
|
+
* We create a minimal compliant object that satisfies the runtime duck-type without
|
|
79
|
+
* importing Zod directly (the SDK validates at transport level, not via our schema).
|
|
80
|
+
* The `as never` cast is retained on the schema only — all handler types are explicit.
|
|
81
|
+
*/
|
|
82
|
+
private _wireNotifications;
|
|
83
|
+
private _resetIdleTimer;
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=UpstreamMcpClient.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"UpstreamMcpClient.d.ts","sourceRoot":"","sources":["../src/UpstreamMcpClient.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,IAAI,IAAI,OAAO,EAAE,MAAM,oCAAoC,CAAC;AAE1E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C,+EAA+E;AAC/E,MAAM,WAAW,oBAAoB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED,6EAA6E;AAC7E,MAAM,MAAM,iBAAiB,GAAG,CAAC,YAAY,EAAE,oBAAoB,KAAK,IAAI,CAAC;AAM7E,MAAM,WAAW,uBAAuB;IACpC,wEAAwE;IACxE,gBAAgB,EAAE,MAAM,CAAC;IACzB,iFAAiF;IACjF,aAAa,EAAE,MAAM,CAAC;IACtB,+DAA+D;IAC/D,eAAe,EAAE,MAAM,CAAC;IACxB,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,CAAC;CACvC;AAED;;;;;GAKG;AACH,qBAAa,iBAAiB;IAetB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAf5B,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAkB;IAGnD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAa;IAC5C,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAc;IAC5C,OAAO,CAAC,OAAO,CAAqB;IACpC,OAAO,CAAC,UAAU,CAA4C;IAC9D,OAAO,CAAC,kBAAkB,CAAgC;IAE1D,OAAO,CAAC,YAAY,CAAK;IAEzB,OAAO,CAAC,SAAS,CAAS;gBAGL,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,uBAAuB,EACjD,YAAY,EAAE,WAAW;IAY7B,wEAAwE;IACxE,oBAAoB,CAAC,SAAS,EAAE,iBAAiB,GAAG,IAAI;IAIxD;;;;OAIG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IA2D9B,qDAAqD;IAC/C,SAAS,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;IAuBrC;;;;;OAKG;IACG,QAAQ,CACV,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,MAAM,EAAE,WAAW,GACpB,OAAO,CAAC,YAAY,CAAC;IAwDxB,iDAAiD;IAC3C,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAoB9B,OAAO,CAAC,gBAAgB;IAMxB;;;;;;;OAOG;IACH,OAAO,CAAC,iBAAiB;IAmBzB;;;;;;;OAOG;IACH,OAAO,CAAC,kBAAkB;IAkC1B,OAAO,CAAC,eAAe;CAS1B"}
|