Spanly Docs

Quickstart

Instrument a TypeScript MCP server with @spanly/sdk in one line.

This walks through mounting the Spanly middleware in front of a TypeScript MCP server that speaks HTTP. spanly() inspects requests under /mcp and /sse by default, tees the request and response bytes, and never sends a response, mutates a header, or delays the app.

1. Install

npm install @spanly/sdk

2. Set the API key

export SPANLY_API_KEY=spanly_us_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Get a key by signing in at spanly.com, opening your project, and going to Settings → API keys. The region is encoded in the key prefix (spanly_us_ / spanly_eu_) and auto-detected, so there is nothing else to configure.

3. Mount the middleware

import express from "express";import { spanly } from "@spanly/sdk";const app = express();app.use(spanly({ apiKey: process.env.SPANLY_API_KEY }));// app.use("/mcp", mcpRouter);

That's it. Traffic on /mcp and /sse is now reported to your Spanly project. spanly() works whether it is mounted before or after a JSON body parser like express.json(), and also works with Koa or a bare http.createServer handler.

Full example: Express

import express from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { spanly } from '@spanly/sdk';

const mcpServer = new McpServer({
  name: 'demo-http-server',
  version: '1.0.0',
});

mcpServer.registerTool('ping', {}, async () => ({
  content: [{ type: 'text', text: 'pong' }],
}));

// Stateless mode: one shared transport serves every request.
const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: undefined,
});
await mcpServer.connect(transport);

const app = express();
app.use(spanly({ apiKey: process.env.SPANLY_API_KEY }));
app.use(express.json());
app.post('/mcp', (req, res) => {
  transport.handleRequest(req, res, req.body);
});
app.listen(3000);

Fetch and Hono

If your MCP server runs behind a Fetch-shaped handler instead of Node's http module, wrap it with wrapFetchHandler instead:

import { Hono } from 'hono';
import { wrapFetchHandler } from '@spanly/sdk';

const app = new Hono();
// … register /mcp routes on app …

export default {
  fetch: wrapFetchHandler(app.fetch.bind(app), {
    apiKey: process.env.SPANLY_API_KEY,
  }),
};

wrapFetchHandler wraps any Fetch-shaped handler (req, ctx?) => Response: Hono, Next.js route handlers, Deno, or a Cloudflare Workers fetch export. The response body is teed with ReadableStream.tee(), so the caller always gets byte-identical status, headers, and body.

Serverless delivery

Capture packets are posted after the response is already built, so a short-lived runtime needs to keep the process alive long enough for that post to land:

  • Cloudflare Workers: a function-valued ctx.waitUntil on the second handler argument is detected automatically, no extra option needed.

  • Vercel: there is no ctx.waitUntil equivalent, so pass one explicitly:

    import { waitUntil } from '@vercel/functions';
    
    export const GET = wrapFetchHandler(handleRequest, {
      apiKey: process.env.SPANLY_API_KEY,
      waitUntil,
    });
  • No waitUntil available: delivery falls back to a keepalive fetch, best effort only.

  • AWS Lambda (function URLs, Lambda Web Adapter): there is no waitUntil equivalent, and a keepalive fetch can be frozen mid-flight once the invocation ends. Until a Spanly Lambda extension exists, put the Spanly CLI in front of a Lambda-hosted MCP server instead.

Next steps

  • The API reference covers every option spanly() and wrapFetchHandler() accept.
  • Examples show end-user attribution and a local test harness.
  • If you'd rather not change code, the CLI wraps the same server with the same capture behavior.
  • Nothing showing up in the dashboard? See the troubleshooting guide.

On this page