Spanly Docs

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.js

If 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.

On this page