@vurb/swarm
v3.14.7
Published
Federated Handoff Protocol for Vurb — SwarmGateway B2BUA, multi-agent orchestration, zero-trust delegation, and upstream MCP client tunneling.
Downloads
973
Maintainers
Readme
MCP Multi-Agent Orchestration for Vurb.ts — the Model Context Protocol framework for building production MCP server networks.
@vurb/swarmlets a single gateway MCP server dynamically hand off an LLM session to a specialist upstream MCP micro-server — and bring it back — without the LLM ever losing context or the conversation thread.
The gateway acts as a Back-to-Back User Agent (B2BUA):
LLM (Claude / Cursor / Copilot)
│ MCP (tools/list, tools/call)
▼
┌──────────────────┐
│ SwarmGateway │ ← you run this (the "triage" server)
│ (B2BUA / UAS) │
└────────┬─────────┘
│ FHP tunnel (x-vurb-delegation + traceparent)
▼
┌──────────────────┐
│ Upstream server │ ← specialist micro-server (finance, devops, hr…)
│ (UAC target) │
└──────────────────┘The LLM sees one coherent conversation. Internally, the gateway:
- Detects a
HandoffResponsefrom one of your tools. - Mints a short-lived HMAC-SHA256 delegation token carrying the carry-over context.
- Opens an MCP tunnel to the upstream micro-server.
- Proxies all
tools/listandtools/callthrough that tunnel, with namespace prefixing. - Injects a
gateway.return_to_triageescape tool so the LLM can come back when done. - On return, cleanly closes the tunnel and restores the gateway's original tools.
Installation
npm install @vurb/swarm @vurb/coreQuick start
1. Gateway server
import { ToolRegistry } from '@vurb/core';
import { SwarmGateway } from '@vurb/swarm';
const gateway = new SwarmGateway({
registry: {
finance: 'http://finance-agent:8081',
devops: 'http://devops-agent:8082',
},
delegationSecret: process.env.VURB_DELEGATION_SECRET!,
});
const registry = new ToolRegistry<AppContext>();
// A triage tool that decides which specialist to call
registry.define('triage')
.action('route', z.object({ intent: z.string() }), async ({ intent }, f) => {
if (intent.includes('invoice'))
return f.handoff('finance', {
reason: 'Routing to finance specialist.',
carryOverState: { originalIntent: intent },
});
return f.text('I can help with that directly.');
});
registry.attachToServer(server, {
contextFactory: createContext,
swarmGateway: gateway,
});2. Upstream specialist server
The upstream is a regular Vurb server that uses requireGatewayClearance middleware:
import { ToolRegistry } from '@vurb/core';
import { requireGatewayClearance } from '@vurb/core';
// Attach the zero-trust middleware — rejects any request without a valid token
app.use('/mcp', requireGatewayClearance({
secret: process.env.VURB_DELEGATION_SECRET!,
}));
const registry = new ToolRegistry<FinanceContext>();
registry.define('invoices')
.action('list', z.object({ status: z.string().optional() }), listInvoices)
.action('refund', z.object({ invoiceId: z.string() }), refundInvoice);
// The LLM calls these as: finance.invoices_list, finance.invoices_refundHow the FHP works
Activation flow
LLM calls triage.route → HandoffResponse detected by ServerAttachment
→ SwarmGateway.activateHandoff()
→ mintDelegationToken(domain, ttl, secret, carryOverState)
→ UpstreamMcpClient.connect() (async, non-blocking)
→ LLM receives: HANDOFF_CONNECTING (tools reloading…)
→ notifications/tools/list_changed emitted
→ LLM calls tools/list → SwarmGateway.proxyToolsList()
→ upstream tools prefixed as finance.*
→ gateway.return_to_triage injectedToken lifecycle
| Phase | What happens |
|---|---|
| mintDelegationToken | HMAC-SHA256 signed payload: iss, sub, iat, exp, tid, optional traceparent |
| State > 2 KB | Claim-Check: state stored in HandoffStateStore, only UUID key in token |
| requireGatewayClearance | Verifies HMAC, checks expiry, hydrates carry-over state one-shot |
| Replay or expired | → EXPIRED_DELEGATION_TOKEN — explicit rejection, no silent failure |
Namespace isolation
Every tool from the upstream is automatically prefixed with its domain:
upstream: listInvoices → gateway exposes: finance.listInvoices
upstream: refund → gateway exposes: finance.refundThe gateway strips the prefix before forwarding. If a call arrives with a mismatched prefix: HANDOFF_NAMESPACE_MISMATCH.
Return trip
The LLM always sees gateway.return_to_triage in the upstream tools list. Calling it:
- Closes the upstream tunnel.
- Notifies the gateway to emit
notifications/tools/list_changed. - LLM re-fetches tools and sees the original gateway tools again.
The summary provided by the LLM is anti-IPI sanitised before being returned:
- HTML-escaped
<,>,& [SYSTEM]/[SISTEMA]patterns blocked- Hard-truncated at 2000 characters
- Wrapped in
<upstream_report source="finance" trusted="false">XML envelope
Configuration
const gateway = new SwarmGateway({
// Required
registry: {
finance: 'http://finance-agent:8081',
devops: 'http://devops-agent:8082',
},
delegationSecret: process.env.VURB_DELEGATION_SECRET!,
// Optional
stateStore: myRedisStore, // custom HandoffStateStore (default: in-memory)
connectTimeoutMs: 5_000, // upstream connection timeout (default: 5 s)
idleTimeoutMs: 300_000, // idle tunnel timeout (default: 5 min)
tokenTtlSeconds: 60, // delegation token TTL (default: 60 s)
upstreamTransport: 'auto', // 'auto' | 'sse' | 'http' (default: 'auto')
gatewayName: 'gateway', // prefix for return_to_triage (default: 'gateway')
maxSessions: 100, // concurrent session limit (default: 100)
});upstreamTransport
| Value | Transport | Use when |
|---|---|---|
| 'auto' | SSE on Node.js, HTTP on edge | Default — works everywhere |
| 'sse' | SSE (persistent connection) | Long-running sessions, streaming |
| 'http' | Streamable HTTP (stateless) | Cloudflare Workers, Vercel Edge |
Custom state store
For Claim-Check tokens (carry-over state > 2 KB) the in-memory default is not suitable for distributed deployments. Implement HandoffStateStore:
import type { HandoffStateStore } from '@vurb/core';
const redisStore: HandoffStateStore = {
async set(id, state, ttlSeconds) {
await redis.set(`vurb:state:${id}`, JSON.stringify(state), { EX: ttlSeconds });
},
// Atomic: read + delete in one operation — prevents replay under concurrency
async getAndDelete(id) {
const raw = await redis.getdel(`vurb:state:${id}`);
return raw ? JSON.parse(raw) : undefined;
},
};
const gateway = new SwarmGateway({
registry: { finance: '...' },
delegationSecret: process.env.VURB_DELEGATION_SECRET!,
stateStore: redisStore,
});Important: External stores must use a native atomic
getAndDelete(e.g. RedisGETDEL) to enforce the one-shot guarantee under high concurrency. Separateget+deleteoperations have a race window where two simultaneous verifications of the same token can both succeed.
Security properties
| Property | How it's enforced |
|---|---|
| Zero-trust upstream | Every request carries a short-lived HMAC-SHA256 token |
| One-shot state | Claim-Check state is atomically deleted on first read |
| Replay protection | Expired or consumed state_id → EXPIRED_DELEGATION_TOKEN |
| Session isolation | Each session has its own UpstreamMcpClient instance |
| Session limit | maxSessions prevents resource exhaustion |
| Zombie prevention | Idle timeout + AbortSignal cascade close orphan tunnels |
| IPI mitigation | Return summaries sanitised + wrapped in trusted="false" XML |
| Namespace enforcement | Prefix mismatch → HANDOFF_NAMESPACE_MISMATCH, never silently routed |
| Distributed tracing | W3C traceparent generated per handoff, propagated to upstream |
Distributed tracing
Every handoff generates a W3C traceparent (00-{traceId}-{spanId}-01) that is:
- Embedded in the delegation token as a claim.
- Sent to the upstream via the
traceparentHTTP header. - Accessible on the upstream via
ctx.traceparent(fromrequireGatewayClearance).
This allows you to correlate gateway ↔ upstream spans in any OpenTelemetry-compatible backend.
Lifecycle & cleanup
// Graceful shutdown — closes all active tunnels
await gateway.dispose();
// Inspection (useful in tests and monitoring)
gateway.sessionCount; // total sessions (connecting + active)
gateway.connectingCount; // sessions still establishing connection
gateway.hasActiveHandoff(sessionId);
gateway.isConnecting(sessionId);Target resolution
The target in f.handoff(target, ...) supports two formats:
// Direct registry key (recommended)
f.handoff('finance', { reason: '...' })
// MCP URI (hostname subdomain is matched against registry)
f.handoff('mcp://finance-agent.internal:8080', { reason: '...' })
f.handoff('mcps://finance-agent.internal', { reason: '...' }) // secureError codes
| Code | When |
|---|---|
| HANDOFF_CONNECTING | Upstream is still establishing — retry |
| HANDOFF_UPSTREAM_UNAVAILABLE | Upstream dropped mid-session |
| HANDOFF_NAMESPACE_MISMATCH | Tool prefix doesn't match active domain |
| SESSION_LIMIT_EXCEEDED | maxSessions cap reached |
| REGISTRY_LOOKUP_FAILED | Unknown target in registry |
| REGISTRY_INVALID_URI | Registry entry has empty URI |
| UPSTREAM_CONNECT_TIMEOUT | Upstream didn't respond within connectTimeoutMs |
| EXPIRED_DELEGATION_TOKEN | Token expired or Claim-Check state already consumed |
Package layout
| File | Responsibility |
|---|---|
| SwarmGateway.ts | B2BUA orchestrator — session lifecycle, proxy routing |
| UpstreamMcpClient.ts | Outbound MCP client (SSE/HTTP), idle timer, signal cascade |
| NamespaceRewriter.ts | Tool name prefix/unprefix, NamespaceError |
| ReturnTripInjector.ts | gateway.return_to_triage injection + anti-IPI sanitiser |
License
Apache-2.0 © Vinkius
