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/sdk2. Set the API key
export SPANLY_API_KEY=spanly_us_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxGet 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.waitUntilon the second handler argument is detected automatically, no extra option needed. -
Vercel: there is no
ctx.waitUntilequivalent, so pass one explicitly:import { waitUntil } from '@vercel/functions'; export const GET = wrapFetchHandler(handleRequest, { apiKey: process.env.SPANLY_API_KEY, waitUntil, }); -
No
waitUntilavailable: delivery falls back to a keepalivefetch, best effort only. -
AWS Lambda (function URLs, Lambda Web Adapter): there is no
waitUntilequivalent, 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()andwrapFetchHandler()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.