仅实现 2025-11-25 及更早的握手式 MCP 规范,不支持无状态的 2026-07-28 规范;HTTP Streaming 默认监听 8080 端口、路径为 /mcp,同时另起 /sse;自定义 Hono 路由默认公开,需自行加鉴权中间件
基于官方 SDK 的 TypeScript 框架,帮你快速搭建带会话管理的 MCP 服务器
用 TypeScript 快速搭建 MCP 服务器的框架,内置 SSE 传输支持,省去从零处理协议细节的麻烦。
核心能力
- 用 addTool 配合 zod 声明式定义工具与参数
- 内置 HTTP Streaming 并兼容 SSE 传输
- 支持 OpenAPI 转 MCP 与自定义 HTTP 路由
- 提供认证、会话 ID 与请求 ID 追踪
- 内置内存传输,单测无需占用端口
容易踩的坑
- v4.26.0最新正式版2026-11-11 · 2026-11-11 00:44 UTC
- v4.25.12026-11-11 · 2026-11-11 00:40 UTC
查看更新说明
4.25.1 (2026-11-11)
Bug Fixes
- auth: build OAuth proxy URLs for a baseUrl with a path or a trailing slash (#520) (ebba3a2)
- keep the last duplicate prompt in a registration batch (#516) (a6d5426)
- keep tools/list working when a tool schema has no JSON Schema form (#519) (ab6b2b3)
- openapi: leave out a null query, header or cookie value (#514) (a1ef273)
- v4.25.02026-11-09 · 2026-11-09 06:23 UTC
查看更新说明
4.25.0 (2026-11-09)
Bug Fixes
- auth: pass an upstream OAuth error body through instead of failing the token schema (#510) (db3c685)
- cli: pass --watch and the file to tsx in fastmcp dev (#511) (db1417a)
- edge: replay a resumed stream while the standalone GET stream is open (#505) (358c46e)
- openapi: preserve optional request body requirements (#507) (8cb5d11)
Features
- v4.24.52026-11-06 · 2026-11-06 22:13 UTC
- v4.24.42026-11-06 · 2026-11-06 22:06 UTC
用户点评
登录用户的真实使用记录,可按版本和硬件筛选还没有人点评。第一个说出真实体验的人最有价值。
FastMCP
A TypeScript framework for building MCP servers capable of handling client sessions.
[!IMPORTANT]
FastMCP implements the legacy, handshake-based MCP revisions (
2025-11-25and earlier). It does not support the current specification,2026-07-28, which made the protocol stateless — noinitializehandshake and noMcp-Session-Id. For a framework targeting the current spec, use ViteMCP.
Features
- Simple Tool, Resource, Prompt definition
- OpenAPI to MCP conversion
- Authentication
- Passing headers through context
- Session ID and Request ID tracking
- Sessions
- Image content
- Audio content
- Embedded
- Logging
- Error handling
- HTTP Streaming (with SSE compatibility)
- HTTPS Support for secure connections
- Custom HTTP routes for REST APIs, webhooks, and admin interfaces
- Edge Runtime Support for Cloudflare Workers, Deno Deploy, and more
- Stateless mode for serverless deployments
- CORS (enabled by default)
- Progress notifications
- Streaming output
- Typed server events
- Prompt argument auto-completion
- Sampling
- Elicitation
- Configurable ping behavior
- Health-check endpoint
- Roots
- In-memory transport for unit testing without binding a port
- CLI for testing and debugging
When to use FastMCP over the official SDK?
FastMCP is built on top of the official SDK.
The official SDK provides foundational blocks for building MCPs, but leaves many implementation details to you:
- Initiating and configuring all the server components
- Handling of connections
- Handling of tools
- Handling of responses
- Handling of resources
- Adding prompts, resources, resource templates
- Embedding resources, image and audio content blocks
FastMCP eliminates this complexity by providing an opinionated framework that:
- Handles all the boilerplate automatically
- Provides simple, intuitive APIs for common tasks
- Includes built-in best practices and error handling
- Lets you focus on your MCP's core functionality
When to choose FastMCP: You want to build MCP servers quickly without dealing with low-level implementation details.
When to use the official SDK: You need maximum control or have specific architectural requirements. In this case, we encourage referencing FastMCP's implementation to avoid common pitfalls.
Installation
npm install fastmcp
Quickstart
[!NOTE]
There are many real-world examples of using FastMCP in the wild. See the Showcase for examples.
import { FastMCP } from "fastmcp";
import { z } from "zod"; // Or any validation library that supports Standard Schema
const server = new FastMCP({
name: "My Server",
version: "1.0.0",
});
server.addTool({
name: "add",
description: "Add two numbers",
parameters: z.object({
a: z.number(),
b: z.number(),
}),
execute: async (args) => {
return String(args.a + args.b);
},
});
server.start({
transportType: "stdio",
});
That's it! You have a working MCP server.
You can test the server in terminal with:
git clone https://github.com/punkpeye/fastmcp.git
cd fastmcp
pnpm install
pnpm build
# Test the addition server example using CLI:
npx fastmcp dev src/examples/addition.ts
# Test the addition server example using MCP Inspector:
npx fastmcp inspect src/examples/addition.ts
If you are looking for a boilerplate repository to build your own MCP server, check out fastmcp-boilerplate.
Remote Server Options
FastMCP supports multiple transport options for remote communication, allowing an MCP hosted on a remote machine to be accessed over the network.
HTTP Streaming
HTTP streaming provides a more efficient alternative to SSE in environments that support it, with potentially better performance for larger payloads.
You can run the server with HTTP streaming support:
server.start({
transportType: "httpStream",
httpStream: {
port: 8080,
},
});
This will start the server and listen for HTTP streaming connections on http://localhost:8080/mcp.
Note: You can also customize the endpoint path using the
httpStream.endpointoption (default is/mcp).
Note: To serve HTTP streaming and built-in OAuth routes under an issuer path, set
httpStream.basePath(for example,/issuer1). This exposes authorization server metadata at/.well-known/oauth-authorization-server/issuer1per RFC 8414.
Note: This also starts an SSE server on
http://localhost:8080/sse.
You can connect to these servers using the appropriate client transport.
For HTTP streaming connections:
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const client = new Client(
{
name: "example-client",
version: "1.0.0",
},
{
capabilities: {},
},
);
const transport = new StreamableHTTPClientTransport(
new URL(`http://localhost:8080/mcp`),
);
await client.connect(transport);
For SSE connections:
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
const client = new Client(
{
name: "example-client",
version: "1.0.0",
},
{
capabilities: {},
},
);
const transport = new SSEClientTransport(new URL(`http://localhost:8080/sse`));
await client.connect(transport);
HTTPS Support
FastMCP supports HTTPS for secure connections by providing SSL certificate options:
server.start({
transportType: "httpStream",
httpStream: {
port: 8443,
sslCert: "./path/to/cert.pem",
sslKey: "./path/to/key.pem",
sslCa: "./path/to/ca.pem", // Optional: for client certificate authentication
},
});
This will start the server with HTTPS on https://localhost:8443/mcp.
SSL Options:
sslCert- Path to SSL certificate filesslKey- Path to SSL private key filesslCa- (Optional) Path to CA certificate for mutual TLS authentication
For testing, you can generate self-signed certificates:
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"
For production, obtain certificates from a trusted CA like Let's Encrypt.
See the https-server example for a complete demonstration.
CORS Configuration
By default, FastMCP enables CORS with a standard set of allowed headers. You can customize the CORS behavior by passing a cors option:
server.start({
transportType: "httpStream",
httpStream: {
port: 8080,
cors: {
origin: "http://localhost:3000",
allowedHeaders: [
"Content-Type",
"Authorization",
"Accept",
"Mcp-Session-Id",
"Mcp-Protocol-Version",
"Last-Event-Id",
"X-Custom-Header",
],
credentials: true,
},
},
});
The cors option accepts:
true(default) - enable CORS with default settingsfalse- disable CORS entirely- An object with these fields:
origin- a string, array of strings, or a function(origin: string) => booleanallowedHeaders- a string or array of stringsmethods- array of allowed HTTP methodsexposedHeaders- array of headers to exposecredentials- boolean to allow credentialsmaxAge- preflight cache duration in seconds
The CorsOptions type is exported from fastmcp for convenience.
Custom HTTP Routes
FastMCP allows you to add custom HTTP routes alongside MCP endpoints, enabling you to build comprehensive HTTP services that include REST APIs, webhooks, admin interfaces, and more - all within the same server process.
const app = server.getApp();
// Add REST API endpoints with Hono's native API
app.get("/api/users", async (c) => {
return c.json({ users: [] });
});
// Handle path parameters
app.get("/api/users/:id", async (c) => {
return c.json({
userId: c.req.param("id"),
query: c.req.query(), // Access query parameters
});
});
// Handle POST requests with body parsing
app.post("/api/users", async (c) => {
const body = await c.req.json();
return c.json({ created: body }, 201);
});
// Serve HTML content
app.get("/admin", async (c) => {
return c.html("<html><body><h1>Admin Panel</h1></body></html>");
});
// Handle webhooks
app.post("/webhook/github", async (c) => {
const payload = await c.req.json();
const event = c.req.header("x-github-event");
// Process webhook...
return c.json({ received: true });
});
Custom routes use the underlying Hono app returned by server.getApp() and support:
- Hono's HTTP methods:
get,post,put,delete,patch,options, and more - Path parameters (
:param) and wildcards (*) - Query string parsing
- JSON, text, form, and other body helpers from
c.req - Custom status codes and headers
- Middleware and route groups through Hono
Routes are matched in the order they are registered, allowing you to define specific routes before catch-all patterns.
Public and Protected Routes
Custom Hono routes are public unless you add your own route middleware or authentication checks. For protected custom routes, put your auth logic in a reusable helper and call it from both FastMCP's authenticate option and your Hono route handlers:
import type { IncomingMessage } from "node:http";
import type { Context } from "hono";
import { FastMCP } from "fastmcp";
async function authenticateRequest(request: IncomingMessage) {
const apiKey = request.headers["x-api-key"];
return apiKey === "123" ? { userId: "123" } : undefined;
}
const server = new FastMCP({
name: "My Server",
version: "1.0.0",
authenticate: authenticateRequest,
});
const app = server.getApp();
async function requireAuth(c: Context) {
const auth = await authenticateRequest(c.env.incoming);
if (!auth) {
return c.json({ error: "Authentication required" }, 401);
}
return auth;
}
// Public route - no authentication required
app.get("/.well-known/openid-configuration", async (c) => {
return c.json({
issuer: "https://example.com",
authorization_endpoint: "https://example.com/auth",
token_endpoint: "https://example.com/token",
});
});
// Private route - requires authentication
app.get("/api/users", async (c) => {
const auth = await requireAuth(c);
if (auth instanceof Response) {
return auth;
}
return c.json({ users: [] });
});
// Public static files
app.get("/public/*", async (c) => {
return c.text(`File: ${c.req.path}`);
});
Public routes are perfect for:
- OAuth discovery endpoints (
.well-known/*) - Health checks and status pages
- Static assets and documentation
- Webhook endpoints from external services
- Public APIs that don't require user authentication
See the custom-routes example for a complete demonstration.
Edge Runtime Support
FastMCP supports edge runtimes like Cloudflare Workers, enabling deployment of MCP servers to the edge with minimal latency worldwide.
Choosing Between FastMCP and EdgeFastMCP
| Use Case | Class | Import |
|---|---|---|
| Node.js, Express, Bun | FastMCP | import { FastMCP } from "fastmcp" |
| Cloudflare Workers, Deno Deploy | EdgeFastMCP | import { EdgeFastMCP } from "fastmcp/edge" |
| Feature | FastMCP | EdgeFastMCP |
|---|---|---|
| Runtime | Node.js | Edge (V8 isolates) |
| Start method | server.start({ port }) | export default server |
| Transport | stdio, httpStream, SSE | HTTP Streamable only |
| Sessions | Stateful or stateless | Stateless only |
| File system | Yes | No |
| OAuth/Authentication | Built-in authenticate option | Use Hono middleware (built-in planned) |
| Custom routes | server.getApp() | server.getApp() |
Note: Built-in authentication for EdgeFastMCP is planned for a future release. Both FastMCP and EdgeFastMCP use Hono internally, so there's no technical barrier—EdgeFastMCP was simply written before OAuth was added to FastMCP. PRs are welcome to add an
authenticateoption that accepts webRequestinstead of Node.jshttp.IncomingMessage.In the meantime, use Hono middleware:
const app = server.getApp(); app.use("/api/*", async (c, next) => { if (c.req.header("authorization") !== "Bearer secret") { return c.json({ error: "Unauthorized" }, 401); } await next(); });
Cloudflare Workers
To deploy FastMCP to Cloudflare Workers, use the EdgeFastMCP class from the /edge subpath:
import { EdgeFastMCP } from "fastmcp/edge";
import { z } from "zod";
const server = new EdgeFastMCP({
name: "My Edge Server",
version: "1.0.0",
description: "MCP server running on Cloudflare Workers",
});
// Add tools, resources, prompts as usual
server.addTool({
name: "greet",
description: "Greet someone",
parameters: z.object({
name: z.string(),
}),
execute: async ({ name }) => {
return `Hello, ${name}! Served from the edge.`;
},
});
// Export the server as the default (required for Cloudflare Workers)
export default server;
Edge Runtime Differences
When running on edge runtimes:
- Stateless by default: Each request is handled independently
- No filesystem access: Use fetch APIs for external data
- V8 Isolates: Fast cold starts and efficient resource usage
- Global deployment: Automatic distribution to edge locations
Custom Routes on Edge
You can access the underlying Hono app to add custom HTTP routes:
const app = server.getApp();
// Add a landing page
app.get("/", (c) => c.html("<h1>Welcome to my MCP server</h1>"));
// Add REST API endpoints
app.get("/api/status", (c) => c.json({ status: "ok" }));
Deployment
Configure your wrangler.toml:
name = "my-mcp-server"
main = "src/index.ts"
compatibility_date = "2024-01-01"
Deploy with:
wrangler deploy
See the edge-cloudflare-worker example for a complete demonstration.
Stateless Mode
FastMCP supports stateless operation for HTTP streaming, where each request is handled independently without maintaining persistent sessions. This is ideal for serverless environments, load-balanced deployments, or when session state isn't required.
In stateless mode:
- No sessions are tracked on the server
- Each request creates a temporary session that's discarded after the response
- Reduced memory usage and better scalability
- Perfect for stateless deployment environments
You can enable stateless mode by adding the stateless: true option:
server.start({
transportType: "httpStream",
httpStream: {
port: 8080,
stateless: true,
},
});
Note: Stateless mode is only available with HTTP streaming transport. Features that depend on persistent sessions (like session-specific state) will not be available in stateless mode.
You can also enable stateless mode using CLI arguments or environment variables:
# Via CLI argument
npx tsx src/server.ts --transport http-stream --port 8080 --stateless true
# Via environment variable
FASTMCP_TRANSPORT=httpStream FASTMCP_STATELESS=true npx tsx src/server.ts
The /ready health check endpoint will indicate when the server is running in stateless mode:
{
"mode": "stateless",
"ready": 1,
"status": "ready",
"total": 1
}
Core Concepts
Tools
Tools in MCP allow servers to expose executable functions that can be invoked by clients and used by LLMs to perform actions.
FastMCP uses the Standard Schema specification for defining tool parameters. This allows you to use your preferred schema validation library (like Zod, ArkType, or Valibot) as long as it implements the spec.
Zod Example:
import { z } from "zod";
server.addTool({
name: "fetch-zod",
description: "Fetch the content of a url (using Zod)",
parameters: z.object({
url: z.string(),
}),
execute: async (args) => {
return await fetchWebpageContent(args.url);
},
});
ArkType Example:
import { type } from "arktype";
server.addTool({
name: "fetch-arktype",
description: "Fetch the content of a url (using ArkType)",
parameters: type({
url: "string",
}),
execute: async (args) => {
return await fetchWebpageContent(args.url);
},
});
Valibot Example:
Valibot requires the peer dependency @valibot/to-json-schema.
import * as v from "valibot";
server.addTool({
name: "fetch-valibot",
description: "Fetch the content of a url (using Valibot)",
parameters: v.object({
url: v.string(),
}),
execute: async (args) => {
return await fetchWebpageContent(args.url);
},
});
Plain JSON Schema Example:
If you already have a JSON Schema — from an OpenAPI document, a config file, or
another server — jsonSchemaAdapter wraps it so it can be used directly, with
no schema library in between.
It requires the peer dependency ajv, which does the validation, plus
ajv-formats if your schema uses format keywords such as email or uri.
Both are imported the first time a tool is called, so servers that don't use
this pay nothing for it.
npm install ajv ajv-formats
import { jsonSchemaAdapter } from "fastmcp";
server.addTool({
name: "fetch-json-schema",
description: "Fetch the content of a url (using plain JSON Schema)",
parameters: jsonSchemaAdapter({
type: "object",
properties: {
url: { type: "string", format: "uri" },
},
required: ["url"],
}),
execute: async (args) => {
const { url } = args as { url: string };
return await fetchWebpageContent(url);
},
});
Works for outputSchema too. Note that FastMCP advertises input schemas with
additionalProperties: false, whatever your schema said — the same treatment
Zod and Valibot schemas get. The exception is an object made only of
additional properties: a dictionary (no properties and an
additionalProperties schema, like z.record()) keeps its value schema, and a
free-form argument ({ type: "object" }, or additionalProperties: true)
stays open. Output schemas keep whatever additional-properties rule your schema
declared.
Unlike the schema libraries above, a plain JSON Schema carries no TypeScript
types, so execute receives unknown arguments. Cast or narrow them yourself.
Tools Without Parameters
When creating tools that don't require parameters, you have two options:
-
Omit the parameters property entirely:
server.addTool({ name: "sayHello", description: "Say hello", // No parameters property execute: async () => { return "Hello, world!"; }, }); -
Explicitly define empty parameters:
import { z } from "zod"; server.addTool({ name: "sayHello", description: "Say hello", parameters: z.object({}), // Empty object execute: async () => { return "Hello, world!"; }, });
[!NOTE]
Both approaches are fully compatible with all MCP clients, including Cursor. FastMCP automatically generates the proper schema in both cases.
Structured Tool Output
Tools can declare an outputSchema and return structured data. FastMCP exposes that value as MCP structuredContent, while also returning a JSON text fallback for clients that only render text content.
server.addTool({
name: "get-weather",
description: "Get weather for a city",
parameters: z.object({
city: z.string(),
}),
outputSchema: z.object({
temperature: z.number(),
humidity: z.number(),
}),
execute: async ({ city }) => {
const weather = await getWeather(city);
return {
temperature: weather.temperature,
humidity: weather.humidity,
};
},
});
You can also return explicit text content and structured content together:
server.addTool({
name: "get-weather",
description: "Get weather for a city",
parameters: z.object({
city: z.string(),
}),
outputSchema: z.object({
temperature: z.number(),
humidity: z.number(),
}),
execute: async ({ city }) => {
const weather = await getWeather(city);
return {
content: [
{
type: "text",
text: `${city}: ${weather.temperature}F`,
},
],
structuredContent: {
temperature: weather.temperature,
humidity: weather.humidity,
},
};
},
});
When outputSchema is provided, FastMCP validates structuredContent before sending the tool result. Invalid structured output is returned to the client as a tool error instead of silently violating the advertised schema.
Tool Authorization
You can control which tools are available to authenticated users by adding an optional canAccess function to a tool's definition. This function receives the authentication context and should return true if the user is allowed to access the tool.
server.addTool({
name: "admin-tool",
description: "An admin-only tool",
canAccess: (auth) => auth?.role === "admin",
execute: async () => "Welcome, admin!",
});
A server without an authenticate function has no auth to check, so every
session sees every tool. With one, a session that still ends up without auth —
on stdio, where the server carries on when authenticate throws or returns
nothing — sees none of the tools that have canAccess.
Scope challenges
To keep a tool listed but require OAuth scopes to call it, list them in
scopes. FastMCP reads the scopes a session was granted from auth.scopes, an
array or a Set, the same way requireScopes() does.
server.addTool({
name: "write-file",
description: "Writes a file",
scopes: ["files:write"],
execute: async () => "Written",
});
A call without those scopes never runs. Over HTTP it gets MCP's
scope challenge,
a 403 with:
WWW-Authenticate: Bearer error="insufficient_scope", scope="files:read files:write", resource_metadata="…"
scope lists the scopes the session already has plus the ones the tool needs,
so the client can get a token with all of them and retry. resource_metadata
is included when oauth.enabled is set. On stdio and other transports without
HTTP, the call fails with a JSON-RPC error instead: code -32003, with
data.error set to "insufficient_scope".
To hide the tool from sessions without the scopes, use
canAccess: requireScopes(...) instead. Like canAccess, scopes is not
enforced on a server without an authenticate function.
Returning a string
execute can return a string:
server.addTool({
name: "download",
description: "Download a file",
parameters: z.object({
url: z.string(),
}),
execute: async (args) => {
return "Hello, world!";
},
});
The latter is equivalent to:
server.addTool({
name: "download",
description: "Download a file",
parameters: z.object({
url: z.string(),
}),
execute: async (args) => {
return {
content: [
{
type: "text",
text: "Hello, world!",
},
],
};
},
});
Returning a list
If you want to return a list of messages, you can return an object with a content property:
server.addTool({
name: "download",
description: "Download a file",
parameters: z.object({
url: z.string(),
}),
execute: async (args) => {
return {
content: [
{ type: "text", text: "First message" },
{ type: "text", text: "Second message" },
],
};
},
});
Returning an image
Use the imageContent to create a content object for an image:
import { imageContent } from "fastmcp";
server.addTool({
name: "download",
description: "Download a file",
parameters: z.object({
url: z.string(),
}),
execute: async (args) => {
return imageContent({
url: "https://example.com/image.png",
});
// or...
// return imageContent({
// path: "/path/to/image.png",
// });
// or...
// return imageContent({
// buffer: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", "base64"),
// });
// or...
// return {
// content: [
// await imageContent(...)
// ],
// };
},
});
The imageContent function takes the following options:
url: The URL of the image.timeoutMs: Optional timeout for a URL download in milliseconds (defaults to 30 seconds).path: The path to the image file.buffer: The image data as a buffer.
Only one of url, path, or buffer must be specified.
The above example is equivalent to:
server.addTool({
name: "download",
description: "Download a file",
parameters: z.object({
url: z.string(),
}),
execute: async (args) => {
return {
content: [
{
type: "image",
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
mimeType: "image/png",
},
],
};
},
});
Configurable Ping Behavior
FastMCP includes a configurable ping mechanism to maintain connection health. The ping behavior can be customized through server options:
const server = new FastMCP({
name: "My Server",
version: "1.0.0",
ping: {
// Explicitly enable or disable pings (defaults vary by transport)
enabled: true,
// Configure ping interval in milliseconds (default: 5000ms)
intervalMs: 10000,
// Set log level for ping-related messages (default: 'debug')
logLevel: "debug",
},
});
By default, ping behavior is optimized for each transport type:
- Enabled for SSE and HTTP streaming connections (which benefit from keep-alive)
- Disabled for
stdioconnections (where pings are typically unnecessary)
This configurable approach helps reduce log verbosity and optimize performance for different usage scenarios.
Keeping Long Tool Calls Alive (streamKeepalive)
Pings travel on the standalone server-to-client stream, so they never keep an
individual tool call's connection open — and in stateless mode that stream does
not exist at all. A tool that runs for minutes without producing output leaves
its response connection silent, and an idle-connection timeout in front of the
server (AWS ALB defaults to 60 seconds) closes it before the result is written.
This applies to both session modes. A stateful deployment keeps its standalone stream warm with pings while the connection carrying the tool call goes idle and is closed anyway; stateless has no standalone stream to begin with.
streamKeepalive writes periodically to the in-flight tool call's own response
stream, which is the connection that would otherwise be closed. It is opt-in:
const server = new FastMCP({
name: "My Server",
version: "1.0.0",
streamKeepalive: {
// Opt in; disabled by default
enabled: true,
// Keep comfortably below the shortest idle timeout on the path
intervalMs: 20000,
},
});
Each keepalive is a notifications/message (level configurable via logLevel,
default debug) tagged with the logger fastmcp-keepalive, related to the tool
call being served. Keepalives start when a tool begins executing and stop when
the request stops waiting for it — on success, error, timeout, or client
cancellation — so idle connections stay quiet. Because the messages are related
to the request, this works in stateful and stateless mode alike, and needs no
client support beyond the standard logging notification.
Limits worth knowing:
- It has no effect with
httpStream.enableJsonResponse, which buffers a single JSON reply instead of streaming, so there is no open stream to write to. - It only covers tool calls. Other long-running requests (
resources/read,prompts/get) still write nothing until they finish. - On
stdiothere is no proxy to keep alive, so enabling it there only adds notification traffic.
Cancelling Long Tool Calls (context.signal)
Every execute receives an AbortSignal that fires once its result can no
longer reach anyone. Forward it to whatever does the real work so the work stops
with the call instead of outliving it:
server.addTool({
name: "fetch_report",
parameters: z.object({ url: z.string() }),
timeoutMs: 30000,
execute: async (args, { signal }) => {
const response = await fetch(args.url, { signal });
return await response.text();
},
});
It aborts on any of three events:
- The client cancelled the call — it sent
notifications/cancelled, which is what the MCP SDK emits when a caller aborts its own request. - The session ended — the transport closed, or the session was closed explicitly. No cancellation notification is involved here.
timeoutMselapsed —signal.reasonis the sameUserErrorthe caller receives, so a tool can tell a timeout apart from a cancellation.
The signal is never aborted after a call completes normally, so attaching cleanup to it is safe.
Limits worth knowing:
- Nothing is killed for you. FastMCP stops waiting for the tool, but the
promise
executereturned keeps running until it settles. A tool that ignores the signal still runs to completion — it just does so with nowhere to report. - An HTTP client that vanishes mid-request is not detected. The MCP SDK only
aborts a request's own signal for an explicit
notifications/cancelled, andStreamableHTTPServerTransportdoes not treat an abandoned response stream as a session close. A caller that hangs up without terminating its session (DELETE) leaves the tool running until it finishes or times out. SettimeoutMson anything expensive rather than relying on disconnect detection. loaddoes not get one. Resources, resource templates, and prompts receive a smaller context withoutsignal.
Health-check Endpoint
When you run FastMCP with the httpStream transport you can optionally expose a
simple HTTP endpoint that returns a plain-text response useful for load-balancer
or container orchestration liveness checks.
Enable (or customise) the endpoint via the health key in the server options:
const server = new FastMCP({
name: "My Server",
version: "1.0.0",
health: {
// Enable / disable (default: true)
enabled: true,
// Body returned by the endpoint (default: 'ok')
message: "healthy",
// Path that should respond (default: '/health')
path: "/healthz",
// HTTP status code to return (default: 200)
status: 200,
},
});
await server.start({
transportType: "httpStream",
httpStream: { port: 8080 },
});
Now a request to http://localhost:8080/healthz will return:
HTTP/1.1 200 OK
content-type: text/plain
healthy
The endpoint is ignored when the server is started with the stdio transport.
Roots Management
FastMCP supports Roots - Feature that allows clients to provide a set of filesystem-like root locations that can be listed and dynamically updated. The Roots feature can be configured or disabled in server options:
const server = new FastMCP({
name: "My Server",
version: "1.0.0",
roots: {
// Set to false to explicitly disable roots support
enabled: false,
// By default, roots support is enabled (true)
},
});
This provides the following benefits:
- Better compatibility with different clients that may not support Roots
- Reduced error logs when connecting to clients that don't implement roots capability
- More explicit control over MCP server capabilities
- Graceful degradation when roots functionality isn't available
You can listen for root changes in your server:
server.on("connect", (event) => {
const session = event.session;
// Access the current roots
console.log("Initial roots:", session.roots);
// Listen for changes to the roots
session.on("rootsChanged", (event) => {
console.log("Roots changed:", event.roots);
});
});
When a client doesn't support roots or when roots functionality is explicitly disabled, these operations will gracefully handle the situation without throwing errors.
Returning an audio
Use the audioContent to create a content object for an audio:
import { audioContent } from "fastmcp";
server.addTool({
name: "download",
description: "Download a file",
parameters: z.object({
url: z.string(),
}),
execute: async (args) => {
return audioContent({
url: "https://example.com/audio.mp3",
});
// or...
// return audioContent({
// path: "/path/to/audio.mp3",
// });
// or...
// return audioContent({
// buffer: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", "base64"),
// });
// or...
// return {
// content: [
// await audioContent(...)
// ],
// };
},
});
The audioContent function takes the following options:
url: The URL of the audio.timeoutMs: Optional timeout for a URL download in milliseconds (defaults to 30 seconds).path: The path to the audio file.buffer: The audio data as a buffer.
Only one of url, path, or buffer must be specified.
The above example is equivalent to:
server.addTool({
name: "download",
description: "Download a file",
parameters: z.object({
url: z.string(),
}),
execute: async (args) => {
return {
content: [
{
type: "audio",
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
mimeType: "audio/mpeg",
},
],
};
},
});
Return combination type
You can combine various types in this way and send them back to AI
server.addTool({
name: "download",
description: "Download a file",
parameters: z.object({
url: z.string(),
}),
execute: async (args) => {
return {
content: [
{
type: "text",
text: "Hello, world!",
},
{
type: "image",
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
mimeType: "image/png",
},
{
type: "audio",
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
mimeType: "audio/mpeg",
},
],
};
},
// or...
// execute: async (args) => {
// const imgContent = await imageContent({
// url: "https://example.com/image.png",
// });
// const audContent = await audioContent({
// url: "https://example.com/audio.mp3",
// });
// return {
// content: [
// {
// type: "text",
// text: "Hello, world!",
// },
// imgContent,
// audContent,
// ],
// };
// },
});
Custom Logger
FastMCP allows you to provide a custom logger implementation to control how the server logs messages. This is useful for integrating with existing logging infrastructure or customizing log formatting.
import { FastMCP, Logger } from "fastmcp";
class CustomLogger implements Logger {
debug(...args: unknown[]): void {
console.log("[DEBUG]", new Date().toISOString(), ...args);
}
error(...args: unknown[]): void {
console.error("[ERROR]", new Date().toISOString(), ...args);
}
info(...args: unknown[]): void {
console.info("[INFO]", new Date().toISOString(), ...args);
}
log(...args: unknown[]): void {
console.log("[LOG]", new Date().toISOString(), ...args);
}
warn(...args: unknown[]): void {
console.warn("[WARN]", new Date().toISOString(), ...args);
}
}
const server = new FastMCP({
name: "My Server",
version: "1.0.0",
logger: new CustomLogger(),
});
See src/examples/custom-logger.ts for examples with Winston, Pino, and file-based logging.
Logging
Tools can log messages to the client using the log object in the context object:
server.addTool({
name: "download",
description: "Download a file",
parameters: z.object({
url: z.string(),
}),
execute: async (args, { log }) => {
log.info("Downloading file...", {
url,
});
// ...
log.info("Downloaded file");
return "done";
},
});
The log object has the following methods:
debug(message: string, data?: SerializableValue)error(message: string, data?: SerializableValue)info(message: string, data?: SerializableValue)warn(message: string, data?: SerializableValue)
A client can raise the minimum level it wants with the logging/setLevel
request, and FastMCP then drops anything less severe instead of sending it — so
after logging/setLevel with error, log.debug and log.info reach nobody.
Until a client asks, every level is sent. The level in force is readable as
session.loggingLevel.
Each message travels on the stream of the request that logged it, so logging
works in stateless mode too. With httpStream.enableJsonResponse, which
answers each request with a single JSON body, messages logged while a request
runs are dropped.
Errors
The errors that are meant to be shown to the user should be thrown as UserError instances:
import { UserError } from "fastmcp";
server.addTool({
name: "download",
description: "Download a file",
parameters: z.object({
url: z.string(),
}),
execute: async (args) => {
if (args.url.startsWith("https://example.com")) {
throw new UserError("This URL is not allowed");
}
return "done";
},
});
Progress
Tools can report progress by calling reportProgress in the context object:
server.addTool({
name: "download",
description: "Download a file",
parameters: z.object({
url: z.string(),
}),
execute: async (args, { reportProgress }) => {
await reportProgress({
progress: 0,
total: 100,
});
// ...
await reportProgress({
progress: 100,
total: 100,
});
return "done";
},
});
reportProgress accepts an optional human-readable message alongside the numeric fields, which clients can display next to the progress indicator:
await reportProgress({
progress: 40,
total: 100,
message: "Downloading chunk 4 of 10…",
});
Progress notifications are only emitted when the client opts in by supplying a progressToken on the tool call; otherwise reportProgress is a no-op. Because notifications/progress is part of the MCP specification (the message field since revision 2025-03-26), this is the portable way to send incremental updates during a long-running tool call — see Streaming Output below for the difference.
Streaming Output
FastMCP can stream partial results from tools while they're still executing, enabling responsive UIs and real-time feedback. This is particularly useful for:
- Long-running operations that generate content incrementally
- Progressive generation of text, images, or other media
- Operations where users benefit from seeing immediate partial results
[!IMPORTANT]
streamContentis a FastMCP extension, not part of the MCP specification. It emits anotifications/tool/streamContentnotification, which the MCP specification does not define — as of revision2025-11-25there is no standard mechanism for streaming tool output (SEP-2998 is the in-progress proposal to add one).Clients discard notifications they have no handler registered for, silently and without error. A client only sees streamed content if it registers a handler for the method (or sets a
fallbackNotificationHandler), and no client is known to render it as tool output — MCP Inspector, for example, logs it in its notifications pane via a fallback handler, but the tool result itself still shows only whatexecutereturned. Streaming is therefore mainly useful when you also control the client — see Consuming streamed content below. If you need incremental updates that work on any client, usereportProgresswith amessageinstead.
To stream from a tool, use the streamContent method:
server.addTool({
name: "generateText",
description: "Generate text incrementally",
parameters: z.object({
prompt: z.string(),
}),
annotations: {
streamingHint: true, // Advisory only; see below
readOnlyHint: true,
},
execute: async (args, { streamContent }) => {
// Send initial content immediately
await streamContent({ type: "text", text: "Starting generation...\n" });
// Simulate incremental content generation
const words = "The quick brown fox jumps over the lazy dog.".split(" ");
for (const word of words) {
await streamContent({ type: "text", text: word + " " });
await new Promise((resolve) => setTimeout(resolve, 300)); // Simulate delay
}
// Always return a final result. Returning nothing sends an empty tool
// result, so clients that ignore the streamed notifications see no output
// at all.
return "The quick brown fox jumps over the lazy dog.";
},
});
[!WARNING] Returning
undefinedfromexecuteproduces a tool result with emptycontent. If you stream everything and return nothing, the tool call resolves to an empty result with no indication that anything was lost — including on clients that do log the notification. Return the complete result as well, and treat streamed content purely as a progressive-rendering enhancement.
The streamingHint annotation is advisory metadata. It is forwarded verbatim to clients in tools/list, but it does not enable or gate streamContent, and FastMCP itself never reads it. No client is known to act on it today, though SEP-2998 proposes standardizing the same annotation name.
Consuming streamed content
A client sees these notifications only if it registers a handler for the method (or sets a fallbackNotificationHandler):
import { z } from "zod";
const StreamContentNotificationSchema = z.object({
method: z.literal("notifications/tool/streamContent"),
params: z.object({
content: z.array(z.any()),
toolName: z.string(),
}),
});
client.setNotificationHandler(
StreamContentNotificationSchema,
(notification) => {
const { content, toolName } = notification.params;
// Render the partial content however you like.
},
);
Note that notifications carry only toolName, not a request or progress token, so concurrent calls to the same tool on one session cannot be told apart.
Streaming works with all content types (text, image, audio) and can be combined with progress reporting:
server.addTool({
name: "processData",
description: "Process data with streaming updates",
parameters: z.object({
datasetSize: z.number(),
}),
annotations: {
streamingHint: true,
},
execute: async (args, { streamContent, reportProgress }) => {
const total = args.datasetSize;
for (let i = 0; i < total; i++) {
// Standard progress notification: reaches every spec-compliant client
await reportProgress({
progress: i,
total,
message: `Processed ${i} of ${total} items`,
});
// Richer partial content: only reaches clients that opt in
if (i % 10 === 0) {
await streamContent({
type: "text",
text: `Processed ${i} of ${total} items...\n`,
});
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
return "Processing complete!";
},
});
Elicitation
Tools can request additional information from the user mid-execution via elicitation, using the elicit method in the context object. The client must advertise the matching elicitation capability mode — elicitation: { form: {} } for form requests (the default) and/or elicitation: { url: {} } for url requests.
server.addTool({
name: "delete-file",
description: "Delete a file",
parameters: z.object({
path: z.string(),
}),
execute: async (args, { elicit }) => {
const response = await elicit({
message: `Are you sure you want to delete ${args.path}?`,
requestedSchema: {
type: "object",
properties: {
confirmed: {
type: "boolean",
},
},
required: ["confirmed"],
},
});
if (response.action !== "accept" || !response.content?.confirmed) {
return "Deletion cancelled.";
}
// ...
return `Deleted ${args.path}`;
},
});
The response action is "accept", "decline", or "cancel"; on accept, content holds the user's answers matching requestedSchema. Elicitation is also available outside of tools via session.requestElicitation.
Tool Annotations
As of the MCP Specification (2025-03-26), tools can include annotations that provide richer context and control by adding metadata about a tool's behavior:
server.addTool({
name: "fetch-content",
description: "Fetch content from a URL",
parameters: z.object({
url: z.string(),
}),
annotations: {
title: "Web Content Fetcher", // Human-readable title for UI display
readOnlyHint: true, // Tool doesn't modify its environment
openWorldHint: true, // Tool interacts with external entities
},
execute: async (args) => {
return await fetchWebpageContent(args.url);
},
});
The available annotations are:
| Annotation | Type | Default | Description |
|---|---|---|---|
title | string | - | A human-readable title for the tool, useful for UI display |
readOnlyHint | boolean | false | If true, indicates the tool does not modify its environment |
destructiveHint | boolean | true | If true, the tool may perform destructive updates (only meaningful when readOnlyHint is false) |
idempotentHint | boolean | false | If true, calling the tool repeatedly with the same arguments has no additional effect (only meaningful when readOnlyHint is false) |
openWorldHint | boolean | true | If true, the tool may interact with an "open world" of external entities |
These annotations help clients and LLMs better understand how to use the tools and what to expect when calling them.
Resources
Resources represent any kind of data that an MCP server wants to make available to clients. This can include:
- File contents
- Screenshots and images
- Log files
- And more
Each resource is identified by a unique URI and can contain either text or binary data.
server.addResource({
uri: "file:///logs/app.log",
name: "Application Logs",
mimeType: "text/plain",
async load() {
return {
text: await readLogFile(),
};
},
});
[!NOTE]
loadcan return multiple resources. This could be used, for example, to return a list of files inside a directory when the directory is read.async load() { return [ { text: "First file content", }, { text: "Second file content", }, ]; }
You can also return binary contents in load:
async load() {
return {
blob: 'base64-encoded-data'
};
}
load also receives auth (the value returned by your authenticate function, if any) and a context object as its second and third arguments. context mirrors the client, log, session, and sessionId fields available to tool.execute (see Session ID and Request ID Tracking); reportProgress and streamContent are not included since they are tied to a tool call's progress token:
server.addResource({
uri: "file:///logs/app.log",
name: "Application Logs",
mimeType: "text/plain",
async load(auth, context) {
context.log.info("loading application logs", { requestedBy: auth?.userId });
return {
text: await readLogFile(),
};
},
});
Subscribing to resource updates
Clients can subscribe to a resource with the MCP resources/subscribe method to be notified whenever its contents change. FastMCP advertises the subscribe capability automatically for any server that exposes resources, tracks each client's subscriptions, and lets you emit an update with sendResourceUpdated:
server.addResource({
uri: "file:///logs/app.log",
name: "Application Logs",
mimeType: "text/plain",
async load() {
return { text: await readLogFile() };
},
});
// Whenever the underlying data changes, notify subscribed clients:
await server.sendResourceUpdated("file:///logs/app.log");
sendResourceUpdated only notifies clients that have subscribed to the given URI, so it is safe to call whenever your data changes. FastMCP also advertises the listChanged capability for tools, resources and prompts and emits notifications/tools/list_changed / notifications/resources/list_changed / notifications/prompts/list_changed automatically when you add or remove tools, resources, resource templates, or prompts at runtime.
One session cannot be shown a kind of primitive it never negotiated. MCP settles capabilities during initialize and they hold for the life of the session, so a client that connected while the server had no resources at all will not see resources added later — FastMCP logs a warning naming the capability and leaves that session alone. Register one of each kind you intend to add before start(), or have the client reconnect; sessions that connect afterwards see everything.
Resource templates
You can also define resource templates:
server.addResourceTemplate({
uriTemplate: "file:///logs/{name}.log",
name: "Application Logs",
mimeType: "text/plain",
arguments: [
{
name: "name",
description: "Name of the log",
required: true,
},
],
async load({ name }) {
return {
text: `Example log content for ${name}`,
};
},
});
Like plain resources, load also receives auth and context as its second and third arguments (see Resources).
A {name} expression matches within one path segment, as in RFC 6570, so file:///logs/a/b.log does not match the template above. Use {+name} for a value that spans segments, e.g. file:///{+path}. Values arrive percent-decoded (file:///logs/..%2Fsecret.log gives name: "../secret"), so validate them before using them as paths.
Resource template argument auto-completion
Provide complete functions for resource template arguments to enable automatic completion:
server.addResourceTemplate({
uriTemplate: "file:///logs/{name}.log",
name: "Application Logs",
mimeType: "text/plain",
arguments: [
{
name: "name",
description: "Name of the log",
required: true,
complete: async (value) => {
if (value === "Example") {
return {
values: ["Example Log"],
};
}
return {
values: [],
};
},
},
],
async load({ name }) {
return {
text: `Example log content for ${name}`,
};
},
});
Embedded Resources
FastMCP provides a convenient embedded() method that simplifies including resources in tool responses. This feature reduces code duplication and makes it easier to reference resources from within tools.
Basic Usage
server.addTool({
name: "get_user_data",
description: "Retrieve user information",
parameters: z.object({
userId: z.string(),
}),
execute: async (args) => {
return {
content: [
{
type: "resource",
resource: await server.embedded(`user://profile/${args.userId}`),
},
],
};
},
});
Working with Resource Templates
The embedded() method works seamlessly with resource templates:
// Define a resource template
server.addResourceTemplate({
uriTemplate: "docs://project/{section}",
name: "Project Documentation",
mimeType: "text/markdown",
arguments: [
{
name: "section",
required: true,
},
],
async load(args) {
const docs = {
"getting-started": "# Getting Started\n\nWelcome to our project!",
"api-reference": "# API Reference\n\nAuthentication is required.",
};
return {
text: docs[args.section] || "Documentation not found",
};
},
});
// Use embedded resources in a tool
server.addTool({
name: "get_documentation",
description: "Retrieve project documentation",
parameters: z.object({
section: z.enum(["getting-started", "api-reference"]),
}),
execute: async (args) => {
return {
content: [
{
type: "resource",
resource: await server.embedded(`docs://project/${args.section}`),
},
],
};
},
});
Working with Direct Resources
It also works with directly defined resources:
// Define a direct resource
server.addResource({
uri: "system://status",
name: "System Status",
mimeType: "text/plain",
async load() {
return {
text: "System operational",
};
},
});
// Use in a tool
server.addTool({
name: "get_system_status",
description: "Get current system status",
parameters: z.object({}),
execute: async () => {
return {
content: [
{
type: "resource",
resource: await server.embedded("system://status"),
},
],
};
},
});
Prompts
Prompts enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs. They provide a powerful way to standardize and share common LLM interactions.
server.addPrompt({
name: "git-commit",
description: "Generate a Git commit message",
arguments: [
{
name: "changes",
description: "Git diff or description of changes",
required: true,
},
],
load: async (args) => {
return `Generate a concise but descriptive commit message for these changes:\n\n${args.changes}`;
},
});
Like resources, load also receives auth and context as its second and third arguments (see Resources):
server.addPrompt({
name: "git-commit",
description: "Generate a Git commit message",
arguments: [
{
name: "changes",
description: "Git diff or description of changes",
required: true,
},
],
load: async (args, auth, context) => {
context.log.debug("generating git commit prompt", { user: auth?.userId });
return `Generate a concise but descriptive commit message for these changes:\n\n${args.changes}`;
},
});
Prompt argument auto-completion
Prompts can provide auto-completion for their arguments:
server.addPrompt({
name: "countryPoem",
description: "Writes a poem about a country",
load: async ({ name }) => {
return `Hello, ${name}!`;
},
arguments: [
{
name: "name",
description: "Name of the country",
required: true,
complete: async (value) => {
if (value === "Germ") {
return {
values: ["Germany"],
};
}
return {
values: [],
};
},
},
],
});
Prompt argument auto-completion using enum
If you provide an enum array for an argument, the server will automatically provide completions for the argument.
server.addPrompt({
name: "countryPoem",
description: "Writes a poem about a country",
load: async ({ name }) => {
return `Hello, ${name}!`;
},
arguments: [
{
name: "name",
description: "Name of the country",
required: true,
enum: ["Germany", "France", "Italy"],
},
],
});
OpenAPI
fromOpenAPI() (from fastmcp/openapi) converts an OpenAPI 3.x document into a FastMCP server, one tool per operation — handling external $refs (multi-file specs), relative servers[0].url resolution, and paramet