Spanly Docs
TypeScript SDK

Examples

Recipes for common TypeScript SDK integrations.

Claude Desktop / Cursor / Windsurf

These IDEs and chat clients launch your MCP server as a child process over stdio. The SDK works unchanged – just wire it into the server before connect().

import { SpanlyClient } from '@spanly/sdk';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

const mcpServer = new McpServer({ name: 'my-server', version: '1.0.0' });
// … register tools, prompts, resources …

new SpanlyClient({ apiKey: process.env.SPANLY_API_KEY }).monitor(mcpServer);

await mcpServer.connect(new StdioServerTransport());

Pass the API key through the host config:

{
  "mcpServers": {
    "my-server": {
      "command": "node",
      "args": ["./dist/server.js"],
      "env": {
        "SPANLY_API_KEY": "spanly_us_xxxxxxxxxxxxxxxxxxxxxx"
      }
    }
  }
}

Multi-tenant tagging

When the same MCP server handles requests from many tenants, attach the tenant id per request so the dashboard can slice by it.

import { AsyncLocalStorage } from 'node:async_hooks';

const tenantStore = new AsyncLocalStorage<{ tenantId: string }>();

app.use((req, _res, next) => {
  const tenantId = extractTenantFromAuth(req);
  tenantStore.run({ tenantId }, next);
});

spanly.monitor(mcpServer, {
  onCollect: (_direction, context) => {
    const scope = tenantStore.getStore();
    if (scope) {
      context.environmentId = scope.tenantId;
    }
    return null; // unchanged – onCollect can return the packet or null
  },
});

The dashboard's filter bar picks up environmentId automatically.

Filtering sensitive tools

Drop a packet entirely by returning null from onCollect:

spanly.monitor(mcpServer, {
  onCollect: (_direction, _context, packet) => {
    if (packet.method === 'tools/call' &&
        packet.params?.name === 'internal-debug-tool') {
      return null; // never sent to Spanly
    }
    return packet;
  },
});

You can also redact fields in place:

spanly.monitor(mcpServer, {
  onCollect: (_direction, _context, packet) => {
    if (packet.method === 'tools/call' && packet.params?.arguments?.apiKey) {
      packet.params.arguments.apiKey = '[REDACTED]';
    }
    return packet;
  },
});

Tests – flush before assertions

import { SpanlyClient } from '@spanly/sdk';

test('captures a tool call', async () => {
  const spanly = new SpanlyClient({ apiKey: process.env.SPANLY_API_KEY });
  spanly.monitor(server);

  await callTool(server, 'echo', { input: 'hi' });
  await spanly.flush();

  // assertions against the dashboard / capture mock here
});

For unit tests against the capture layer, pass a fake endpoint to the client and run a local HTTP server that records the payloads.

Correlate with OpenTelemetry / Sentry

SpanlyPacketContext is plain JSON, so any trace id you have in scope can ride along:

import { trace } from '@opentelemetry/api';

spanly.monitor(mcpServer, {
  onCollect: (_direction, context) => {
    const span = trace.getActiveSpan();
    if (span) {
      const { traceId, spanId } = span.spanContext();
      context.traceId = traceId;
      context.spanId = spanId;
    }
    return null;
  },
});

Click into a request in the Spanly dashboard and the traceId field is linkable straight to your APM tool's trace view.

On this page