@spfn/mcp
@spfn/mcp mounts an OAuth-protected Model Context Protocol endpoint in an SPFN
application. The official MCP TypeScript SDK handles protocol negotiation, JSON-RPC,
Streamable HTTP, schema validation, and legacy compatibility. This package supplies the
SPFN route adapter and application-facing tool, resource, prompt, and authentication hooks.
The package is currently beta and requires Node.js 20 or newer. It pins the MCP v2 beta
while the 2026-07-28 protocol release is being finalized. Its public SPFN API does not
expose SDK-specific server or transport objects, so the SDK can be upgraded without
rewriting application routes.
Migrating from 0.1
Version 0.2 is a deliberate breaking redesign of the hand-written protocol stack in
0.1. The SPFN router mount remains the same, but its configuration needs these changes:
- Remove
signer,session, andprotocolVersion. Protocol negotiation and legacy transport compatibility now belong to the official SDK. - Extend
McpAuthwith application claims such asuserIdorgrantId; those fields are no longer prescribed by the package. - Change
resolveContext(auth, session)toresolveContext(auth, request). Userequest.requestIdfor log and usage correlation. It is not a durable session ID. - Change
onToolCallconsumers fromevent.sessionIdtoevent.requestId. - Move skill definitions and grant, catalog, billing, and rate-limit policy into the
consuming application. The old
./skills,./server/*, and./shared/*internals are not exported by0.2.
Applications can keep their existing defineRouter(...).packages([mcpRouter]) mount and
their existing listTools filtering boundary. If application state must survive multiple
tool calls, pass an explicit application-owned handle as a tool argument.
Install
pnpm add @spfn/mcp @spfn/core
Create a route
import { createMcpRoute, McpError } from '@spfn/mcp/server';
import type { McpAuth, McpTool } from '@spfn/mcp';
type Auth = McpAuth & {
userId: number;
};
type Context = {
userId: number;
};
const tools: McpTool<Context>[] = [{
name: 'greet',
title: 'Greet user',
description: 'Create a greeting for the current user.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string' },
},
required: ['name'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
},
handler: async (args, ctx) => ({
greeting: `Hello ${String(args.name)}`,
userId: ctx.userId,
}),
}];
export const mcpRouter = createMcpRoute<Auth, Context>({
appUrl: 'https://app.example.com',
serverInfo: {
name: 'example-app',
version: '1.0.0',
},
validateToken: async (token, resource) => {
const claims = await verifyAccessToken(token, resource);
return {
clientId: claims.clientId,
scopes: claims.scopes,
expiresAt: claims.expiresAt,
userId: claims.userId,
};
},
resolveContext: async auth => {
const user = await findUser(auth.userId);
if (!user) {
throw new McpError(-32002, 'User not found', 403);
}
return { userId: user.id };
},
listTools: () => tools,
});
Register the package router with the application router:
import { defineRouter } from '@spfn/core/route';
import { mcpRouter } from './mcp';
export const appRouter = defineRouter({
// Application routes
}).packages([mcpRouter]);
The adapter registers POST, GET, and DELETE handlers at /mcp and skips SPFN's
session middleware. validateToken is therefore the authentication boundary and must
validate the token audience against the supplied resource value.
Request context
resolveContext receives the validated application auth value and request metadata:
resolveContext: async (auth, request) => ({
userId: auth.userId,
protocolEra: request.era, // "legacy" or "modern"
requestId: request.requestId, // correlation ID, not a protocol session ID
});
The modern MCP protocol is request-scoped and does not use Mcp-Session-Id. Applications
that need durable conversational state should expose an explicit application session or
handle as a tool argument instead of relying on transport state. Legacy 2025 clients are
served through the official SDK's stateless compatibility path.
Tool results and errors
Plain handler values are returned as text and, for objects, as structured content. A handler
may also return a valid MCP CallToolResult; the adapter recognizes it without changing it.
Only McpError messages are exposed to MCP clients. Other exceptions become
Tool execution failed. Use onError for internal reporting and keep secrets out of error
objects and logs.
onError: async event => {
await reportMcpFailure(event.operation, event.name);
},
onToolCall: async event => {
await recordUsage(event.toolName, event.ok, event.requestId);
},
Host and Origin validation
Remote deployments should validate Host and Origin at the reverse proxy or configure allowlists in the adapter:
security: {
allowedHosts: ['app.example.com'],
allowedOrigins: ['app.example.com'],
},
Allowlist values are hostnames without a scheme or port.
Scope
This package intentionally does not provide an application catalog, grants, billing,
rate limits, a skill registry, or tool discovery policy. Resolve those in application code
and return only the tools the authenticated context may use from listTools.