Examples
Recipes for common TypeScript SDK integrations.
Claude Desktop / Cursor / Windsurf
These hosts launch your MCP server as a child process over stdio. The TypeScript SDK is HTTP middleware, so it does not apply to a stdio server; wrap it with the Spanly CLI instead:
npx -y @spanly/spanly run -- node ./dist/server.jsIf your server also exposes an HTTP transport for other clients, mount
spanly() on that HTTP surface as usual; the two integration methods
are independent.
Multi-tenant attribution
When the same MCP server handles requests from many tenants, attach
the tenant id to captured traffic with identity.resolve. The
resolved user's accountId is exactly the tenant-scoped field the
dashboard's filter bar picks up automatically:
import express from 'express';
import { spanly } from '@spanly/sdk';
const app = express();
app.use(
spanly({
apiKey: process.env.SPANLY_API_KEY,
identity: {
resolve: ({ headers }) => {
const tenant = extractTenantFromAuth(headers['authorization']);
return tenant ? { id: tenant.userId, accountId: tenant.orgId } : null;
},
},
}),
);resolve runs concurrently with request handling and never blocks it;
a packet emitted before resolution settles simply goes out without a
user, and the batcher backfills the rest of that session downstream.
What the middleware does not filter
There is no hook to drop or rewrite an individual captured packet: the middleware is a byte-copy tee, not an interception point for your JSON-RPC payloads. If you need to keep certain tools or paths out of Spanly entirely, scope capture at the transport level instead:
app.use(
spanly({
apiKey: process.env.SPANLY_API_KEY,
paths: ['/mcp/public'], // /mcp/internal is never inspected
}),
);This is coarser than per-packet filtering: it excludes a whole path prefix, not a specific tool call. Credential-bearing headers are always redacted automatically (see the privacy model); there is nothing to configure there.
Tests: capture locally with ingestUrl
There is no client-side queue to flush on a fixed schedule; each
packet is posted to ingest as soon as its request or response leg
finishes. For deterministic tests, construct CaptureEngine directly
(bypassing the HTTP binding) and point ingestUrl at a local server
that records the payloads:
import { createServer } from 'node:http';
import { CaptureEngine } from '@spanly/sdk';
test('captures a tool call', async () => {
const received: unknown[] = [];
const capture = createServer((req, res) => {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
received.push(JSON.parse(body));
res.writeHead(200).end(JSON.stringify({ success: true }));
});
}).listen(0);
const { port } = capture.address() as { port: number };
const engine = new CaptureEngine({
apiKey: 'spanly_us_test',
ingestUrl: () => `http://127.0.0.1:${port}`,
});
const txn = engine.beginTransaction({
method: 'POST',
path: '/mcp',
headers: { 'content-type': 'application/json' },
});
txn.onRequestBody(
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call' }),
);
txn.onResponseStart(200, { 'content-type': 'application/json' });
txn.onResponseBody(JSON.stringify({ jsonrpc: '2.0', id: 1, result: {} }));
txn.onTransactionEnd();
await engine.flush();
expect(received.length).toBeGreaterThan(0);
capture.close();
});Use onError in tests to fail loudly when delivery breaks instead of
silently losing packets.
Correlate with your APM (Datadog, Sentry, …)
If your infrastructure propagates a W3C traceparent value, on an
HTTP header or embedded in params._meta.traceparent on the JSON-RPC
message, it survives untouched in the captured packet: neither is
stripped or rewritten before the packet leaves your process. Pick your
APM provider in Settings, Integrations and the request detail view
links straight to the corresponding trace in your APM. See
Trace context propagation.