Migrating from FastMCP for TypeScript
Checked against FrontMCP 1.10.10 on
This guide moves a server written with FastMCP for TypeScript, punkpeye's fastmcp package, to FrontMCP without changing what its clients see: tool names, input schemas, result text, error messages, resource URIs and prompt messages. Most FastMCP calls have a FrontMCP counterpart. A few FrontMCP defaults differ in ways a client notices, and some FastMCP features have no counterpart. Move one contract at a time, and compare it with what FastMCP sent before you move the next.
Prefect's @prefecthq/fastmcp-ts is a different library; FrontMCP vs Prefect's FastMCP TypeScript covers it.
You will learn
- Which FrontMCP construct replaces each FastMCP one, and which have no counterpart
- How to port a tool and keep the result its clients already read
- Where FrontMCP answers differently: invalid input, log messages and session state
- An order to migrate in, and what to check before each next step
Before you start
FastMCP 4.23.4's README says it implements the handshake-based MCP revisions, 2025-11-25 and earlier, and doesn't support 2026-07-28 (README). When this site checked it on 2026-10-20, its server answered 2026-07-28 requests with 400 over HTTP and -32601 over stdio (How this was checked). FrontMCP serves every revision from 2024-11-05 to 2026-07-28 over Streamable HTTP and stdio (Protocol versions), so clients that start with initialize keep working after the move.
Moving isn't the only option. If your clients speak 2025-11-25 or earlier and you don't need what FrontMCP adds, FrontMCP vs FastMCP for TypeScript lists reasons to stay. For 2026-07-28 with FastMCP's API, its README points to ViteMCP, by the same author (FrontMCP vs ViteMCP).
A FrontMCP project needs Node 24 or later and TypeScript's decorator settings; frontmcp init adds them to an existing project (Installation). The FastMCP code on this page ran on 2026-10-25 with fastmcp 4.23.4, zod 4.6.7, the official SDK 1.32.2 that fastmcp installs, and tsx. The Playgrounds run only FrontMCP, with a client on MCP 2026-07-28.
The sections follow the order to migrate in, and each ends with what to check before the next:
- Record what clients see now
- Port the tools
- Port errors, logs and progress
- Port resources, templates and prompts
- Move state out of the session
- Choose auth
- Serve the same endpoint, then switch clients
What maps to what
| FastMCP 4.23.4 | FrontMCP | Watch for |
|---|---|---|
new FastMCP({ name, version }) (Quickstart) | @FrontMcp({ info: { name, version }, apps }), with an @App that lists the tools, resources and prompts (@FrontMcp) | |
server.addTool({ name, description, parameters, execute }) (Tools) | A @Tool class. inputSchema takes the z.object()'s .shape; execute() gets the arguments | Zod 4 only: ArkType, Valibot and jsonSchemaAdapter() schemas have to be rewritten in Zod |
execute returns a string (Returning a string) | Return { content: [{ type: "text", text }] } | A returned string arrives as {"value": …} (Port the tools) |
outputSchema (Structured Tool Output) | outputSchema (Shaping Tool Results) | |
throw new UserError(message) (Errors) | this.fail(new PublicMcpError(message)), or throw it (this.fail) | A UserError's extras, which FastMCP sends as structuredContent (FastMCP.ts), have no counterpart on this site |
log.info(message, data) and debug, warn, error (Logging) | this.notify({ message, context: data }, level) (this.notify) | FrontMCP sends nothing until the client asks for log messages |
reportProgress({ progress, total, message }) (Progress) | this.progress(progress, total, message) (this.progress) | |
streamContent() (Streaming Output) | None | FastMCP's own extension; it isn't part of MCP |
elicit({ message, requestedSchema }) (Elicitation) | this.elicit(message, zodSchema), with elicitation: { enabled: true } on the server (this.elicit) | The answer's status is FastMCP's action. Under 2026-07-28 the tool runs again from the top when the answer arrives (side effects) |
session: what authenticate returned (Custom Authentication) | this.auth: user.sub, scopes, roles, claims | Choose auth |
sessionId (Session ID and Request ID Tracking) | this.context.sessionId (this.context) | A new id on every 2026-07-28 request (Move state out of the session) |
requestId, from the client's request (Session ID and Request ID Tracking) | None. this.context.requestId is an id FrontMCP makes, not the JSON-RPC id (this.context) | |
signal (Cancelling Long Tool Calls) | this.signal | |
server.addResource({ uri, name, mimeType, load }) (Resources) | A @Resource class with execute() | load()'s { text } or { blob } works unchanged |
server.addResourceTemplate({ uriTemplate, arguments, load }) (Resource templates) | A @ResourceTemplate class | Parameters come from the URI's placeholders; there's no arguments list |
complete on a template argument (auto-completion) | A completer | |
server.addPrompt({ name, arguments, load }) (Prompts) | A @Prompt class with execute(args) | |
complete or enum on a prompt argument (auto-completion) | None on this site | |
authenticate(request) checking an API key (Custom Authentication) | auth: { mode: "static" } (Static keys) | Your code gets an id per key, not an object you return |
auth: new GoogleProvider(…), GitHubProvider, AzureProvider (OAuth with Pre-configured Providers) | auth: { mode: "remote" } with an OpenID Connect provider you configure (Remote and proxied auth) | GitHub can't be used |
canAccess, requireScopes(), requireRole() (Tool Authorization) | authorities on the tool (Authorities), or this.auth.hasScope() in execute() | |
server.start({ transportType: "httpStream", httpStream: { port } }) (HTTP Streaming) | http: { port, entryPath: "/mcp" } on @FrontMcp (http) | FrontMCP serves the root path unless you set entryPath |
httpStream: { stateless: true } (Stateless Mode) | transport: { protocol: "stateless-api" } (transport) | Only clients before 2026-07-28 have sessions to turn off |
server.start({ transportType: "stdio" }) (Quickstart) | FRONTMCP_STDIO=1 (Starting the server) | |
server.connect() with InMemoryTransport, in tests (Unit testing) | @frontmcp/testing (Testing) | |
fastmcp dev, fastmcp inspect (Running Your Server) | frontmcp dev, frontmcp inspector (CLI) | |
EdgeFastMCP (Edge Runtime Support) | createFetchHandler(), or a Cloudflare Workers build |
Record what clients see now
Save what the FastMCP server answers before you change any code: the FrontMCP tests below check these answers. FastMCP's server.connect() attaches the server to a transport you create, so a script can drive it in-process with the official SDK's client (Unit testing with an in-memory transport). Export the FastMCP instance from a module that doesn't call start(), then run this with npx tsx record-contract.ts:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { server } from "./server.js";
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "contract-recorder", version: "1.0.0" });
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
const contract = {
tools: await client.listTools(),
add: await client.callTool({ name: "add", arguments: { a: 2, b: 3 } }),
addWithAString: await client.callTool({ name: "add", arguments: { a: "2", b: 3 } }).catch((error) => ({ code: error.code, message: error.message })),
};
console.log(JSON.stringify(contract, null, 2));
await client.close();For FastMCP's Quickstart server, with its one tool, add, it printed this (the error message is shortened):
{
"tools": {
"tools": [
{
"name": "add",
"description": "Add two numbers",
"inputSchema": {
"type": "object",
"properties": { "a": { "type": "number" }, "b": { "type": "number" } },
"required": ["a", "b"],
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false
}
}
]
},
"add": { "content": [{ "type": "text", "text": "5" }] },
"addWithAString": { "code": -32602, "message": "MCP error -32602: … Tool 'add' parameter validation failed: a: Invalid input: expected number, received string. …" }
}
Add a call for every tool, resource and prompt your clients use, with listResources(), listResourceTemplates(), readResource(), listPrompts() and getPrompt(), and a failing call for each tool.
Before you move on: the recording has every name and URI your clients use, and a successful and a failing answer for each tool.
Port the tools
FastMCP's Quickstart, which answered over stdio as recorded above (the recording drove the same server, exported instead of started):
import { FastMCP } from "fastmcp";
import { z } from "zod";
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",
});In FrontMCP the same server is three declarations: the tool, an app that lists it, and the server that lists the app. FastMCP sends a returned string as one text block (Returning a string), so execute() returns that block, and the client gets the same answer. The Tests tab checks it against the recording.
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({
name: "add",
description: "Add two numbers",
inputSchema: { a: z.number(), b: z.number() },
})
export class Add extends ToolContext {
async execute({ a, b }: { a: number; b: number }) {
return { content: [{ type: "text", text: String(a + b) }] };
}
}
@App({ id: "calc", name: "Calculator", tools: [Add] })
export class CalcApp {}
@FrontMcp({ info: { name: "My Server", version: "1.0.0" }, apps: [CalcApp] })
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
A 2026-07-28 result also carries resultType and the server's name in _meta, which the last test reads. Two answers differ from the recording, and the tests show both:
- The input schema's keywords. FrontMCP names JSON Schema draft 2020-12 and leaves out
additionalProperties. FastMCP named draft-07 and addedadditionalProperties: false, whatever the schema said (Tools). The properties andrequiredare the same. - Invalid input. FrontMCP answers with a tool result,
isError: trueand_meta.codeINVALID_INPUT, which the model reads and can correct (Your First Tool). FastMCP answered with a JSON-RPC error,-32602(FastMCP.ts). Check any client code or test that looks for-32602.
FastMCP's parameters was a z.object(); FrontMCP's inputSchema takes its fields. Keep the object and pass its .shape, as the next section does. Schemas in ArkType, Valibot or plain JSON Schema have to be rewritten in Zod 4, which is the only schema library FrontMCP takes (Schemas Are Contracts).
Before you move on: for each tool, the tools/list entry and the recorded calls give the same answers, apart from the two differences above.
Port errors, logs and progress
FastMCP's README shows a download tool three times: to log, to fail with a UserError, and to report progress. In one tool, as it ran with fastmcp 4.23.4:
import { UserError } from "fastmcp";
import { z } from "zod";
import { server } from "./server.js";
server.addTool({
name: "download",
description: "Download a file",
parameters: z.object({
url: z.string(),
}),
execute: async (args, { log, reportProgress }) => {
log.info("Downloading file...", { url: args.url });
await reportProgress({ progress: 0, total: 100 });
if (args.url.startsWith("https://example.com")) {
throw new UserError("This URL is not allowed");
}
await reportProgress({ progress: 100, total: 100 });
return "done";
},
});The helpers FastMCP passes in the second argument are methods of this in FrontMCP. The Call tab streams the log message and the progress above the result.
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
const downloadParameters = z.object({ url: z.string() });
@Tool({ name: "download", description: "Download a file", inputSchema: downloadParameters.shape })
export class Download extends ToolContext {
async execute({ url }: z.infer<typeof downloadParameters>) {
await this.notify({ message: "Downloading file...", context: { url } });
await this.progress(0, 100);
if (url.startsWith("https://example.com")) {
this.fail(new PublicMcpError("This URL is not allowed"));
}
await this.progress(100, 100);
return { content: [{ type: "text", text: "done" }] };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
UserErrorbecomesPublicMcpError. Its message is the whole text of anisErrorresult in both, and FrontMCP adds a code in_meta.code. An error that isn't public is different: FastMCP sentTool 'download' execution failed:and the message (FastMCP.ts); FrontMCP sendsTool "download" execution failed:, the message and the stack in development, and hides all of it in production (Failing vs throwing).logbecomesthis.notify(). FastMCP sent{ message, context }as the log message'sdata(FastMCP.ts);this.notify()sends the object you pass, so the same object keeps the shape, and FrontMCP adds the tool's name aslogger. FastMCP'slog.warnsent the level"warning"(FastMCP.ts), which is the namethis.notify()takes.- FrontMCP's log messages are opt-in. FastMCP sent every level until the client set one with
logging/setLevel(Logging). FrontMCP sends nothing until the client asks: withlogging/setLevelbefore 2026-07-28, and with a level in each request's_metaunder 2026-07-28 (Asking for log messages). The Playground's test client asks fordebugon every call, which is why the test sees the message. Anything the model needs belongs in the result. - Progress is the same. Both send it only for a request with a
progressToken(FastMCP,this.progress).
Before you move on: the recorded failures have the same text, and the clients that show logs or progress still get them.
Port resources, templates and prompts
FastMCP's README examples for a resource, a resource template and a prompt, as they ran with fastmcp 4.23.4 (the log's text is ours):
import { server } from "./server.js";
server.addResource({
uri: "file:///logs/app.log",
name: "Application Logs",
mimeType: "text/plain",
async load() {
return { text: "Started on port 8080" };
},
});
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}` };
},
});
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}`;
},
});In FrontMCP each is a class, and load()'s body moves into execute() as it is. The tests compare the lists and the reads with what FastMCP answered.
import { Resource, ResourceContext, ResourceTemplate } from "@frontmcp/sdk";
@Resource({ uri: "file:///logs/app.log", name: "Application Logs", mimeType: "text/plain" })
export class AppLog extends ResourceContext {
async execute() {
return { text: "Started on port 8080" };
}
}
@ResourceTemplate({ uriTemplate: "file:///logs/{name}.log", name: "Application Logs", mimeType: "text/plain" })
export class NamedLog extends ResourceContext<{ name: string }> {
async execute(uri: string, { name }: { name: string }) {
return { text: `Example log content for ${name}` };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
A template has no arguments list in FrontMCP: its parameters are the placeholders in uriTemplate, and execute() gets them as its second argument. FastMCP didn't send the list in resources/templates/list either. In both, {name} matches within one path segment, so file:///logs/2026/10/app.log doesn't match this template (FastMCP, FrontMCP). The last challenge ports a template that matches across segments.
Before you move on: the resource and prompt lists, and a read of every URI and template your clients use, match the recording.
Move state out of the session
FastMCP's README keeps per-session state in a Map keyed by context.sessionId, which comes from the Mcp-Session-Id header (Session ID and Request ID Tracking). Ported as it is, it works for clients on 2025-11-25 and earlier over HTTP, which still get a session from FrontMCP. A client on 2026-07-28 has none: this.context.sessionId is a new anon: id on every request (this.context), so the count starts again on every call:
import { Tool, ToolContext } from "@frontmcp/sdk";
const sessionCounters = new Map<string, number>();
// 🚩 Keyed by a session that 2026-07-28 clients don't have
@Tool({ name: "increment_counter", description: "Increment a per-session counter", inputSchema: {} })
export class IncrementCounter extends ToolContext {
async execute() {
const sessionId = this.context.sessionId;
const counter = (sessionCounters.get(sessionId) ?? 0) + 1;
sessionCounters.set(sessionId, counter);
return { content: [{ type: "text", text: `Counter for session ${sessionId}: ${counter}` }] };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Give the state a handle of its own that the model passes back, as the 2026-07-28 changelog suggests (MCP Went Stateless). One tool starts a counter and returns its id; the other takes the id:
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
const counters = new Map<string, number>();
// ✅ Keyed by an id the model passes to the next call
@Tool({ name: "start_counter", description: "Start a counter. Pass its counterId to increment_counter.", inputSchema: {} })
export class StartCounter extends ToolContext {
async execute() {
const counterId = crypto.randomUUID();
counters.set(counterId, 0);
return { counterId };
}
}
@Tool({
name: "increment_counter",
description: "Increment a counter that start_counter returned",
inputSchema: { counterId: z.string().describe("The counterId from start_counter") },
})
export class IncrementCounter extends ToolContext {
async execute({ counterId }: { counterId: string }) {
const counter = counters.get(counterId);
if (counter === undefined) this.fail(new PublicMcpError(`There's no counter ${counterId}. Start one with start_counter.`));
counters.set(counterId, counter + 1);
return { counterId, counter: counter + 1 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The Map lives in one process. With several instances, keep the state where every instance can read it (Running Several Instances). State that belongs to a signed-in user can be keyed by this.auth.user.sub instead, once an auth mode identifies users.
Before you move on: call each tool that keeps state twice, as a 2026-07-28 client, and check that the second call sees the first.
Choose auth
A FrontMCP server is public by default: every request gets in. Choose a mode before you move a protected server (Auth modes).
- An API key. FastMCP's README checks an
x-api-keyheader inauthenticate()(Custom Authentication). FrontMCP'sstaticmode checks the same header, without code, withheader: "x-api-key"andscheme: ""(Static keys in a header of your own). What tools see changes: FastMCP passed whateverauthenticate()returned assession; instaticmode,this.auth.user.subisstatic:and 12 hex characters for each key, and every key gets the mode'sscopes(What each auth mode fills in). - Roles and scopes per user. Accept JWTs from your identity provider in
transparentmode (Tokens), and replacecanAccesswithauthoritieson the tool (Authorities). - Sign-in with a provider. FastMCP's OAuth proxy has presets for Google, GitHub and Azure. FrontMCP's
remotemode takes an OpenID Connect provider you configure; GitHub, whose users have nosub, can't be used.localmode makes FrontMCP the sign-in page, and you check the user inauthenticate(Local auth).
The Playground's client can't send credentials, so none of this runs on this page; each linked reference page runs its mode.
Before you move on: against a running server, a request without a key and one with a wrong key are refused, a request with a key gets through, and a tool a caller may not use is refused when called by name, not only left out of tools/list (Hiding versus refusing).
Serve the same endpoint, then switch clients
Clients reach the server at a URL, or start it with a command. Keep the URL, and update the command:
- The HTTP path. FastMCP serves
/mcpunless you sethttpStream.endpoint(HTTP Streaming). FrontMCP serves the root path unless you sethttp.entryPath, so setentryPath: "/mcp"and the sameport(http). - SSE. FastMCP also starts an SSE endpoint at
/sse(HTTP Streaming). FrontMCP's Node server serves the older HTTP+SSE transport by default (transport) at<entryPath>/sse, not at/sse, and wants posts to its message endpoint to accept JSON or an event stream (Choosing a transport). Point an SSE client at the new path and try it before you switch it. - stdio. A client that starts the server as a command needs the new command. FrontMCP connects over stdio when
FRONTMCP_STDIO=1is set (Starting the server, Over stdio). - Health checks. FastMCP answers
/healthwith the textok(Health-check Endpoint). FrontMCP answers/healthand/healthzwith JSON (Health and metrics), so a probe that reads the body needs changing. - The address it listens on. FrontMCP listens on
127.0.0.1by default, which a published container port can't reach; setFRONTMCP_BIND_ADDRESS=allthere (Node).
Then switch clients the way Migrating from the TypeScript SDK describes: keep FastMCP serving while FrontMCP runs in preview, call the exact endpoint as a client that starts with initialize and as one on 2026-07-28, move a small group of clients first, and keep the FastMCP deployment until you no longer need to roll back.
Before you move on: every client reaches the server at its old URL or with its new command, and the recorded calls give the same answers there as in the tests.
Recap
- Record what FastMCP answers first, with
server.connect()and the SDK's in-memory client, and turn each answer into a@frontmcp/testingtest. addTool,addResource,addResourceTemplateandaddPromptbecome@Tool,@Resource,@ResourceTemplateand@Promptclasses, listed in an@Appon a@FrontMcpserver.- A tool that returned a string returns
{ content: [{ type: "text", text }] }to keep its text. UserErrorbecomesPublicMcpError;log,reportProgressandelicitbecomethis.notify(),this.progress()andthis.elicit(). Log messages are opt-in in FrontMCP.- Invalid input is an
INVALID_INPUTtool result in FrontMCP, not a-32602error. - State keyed by the session starts over for 2026-07-28 clients: key it by a handle or the signed-in user.
- Choose an auth mode, set
entryPath: "/mcp", and keep FastMCP serving until clients have moved.
Try some challenges
Each challenge runs hidden checks against your code. Edit the code, then press Check.
Read all challenge instructions
Keep a tool's text
This challenge is open below.
Port a UserError
FastMCP had UserError, and this port throws a plain Error in its place. Make a blocked URL fail with This URL is not allowed as the whole text, the way UserError did, in development and in production.
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "download", description: "Download a file", inputSchema: { url: z.string() } })
export class Download extends ToolContext {
async execute({ url }: { url: string }) {
if (url.startsWith("https://example.com")) {
throw new Error("This URL is not allowed");
}
return { content: [{ type: "text", text: "done" }] };
}
}Read logs in subfolders
The logs moved into dated folders, like file:///logs/2026/10/app.log, and the template should read them with name as 2026/10/app. FastMCP's README uses {+name} for a value that spans segments; do the same here, without breaking file:///logs/server.log.
import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk";
@ResourceTemplate({ uriTemplate: "file:///logs/{name}.log", name: "Application Logs", mimeType: "text/plain" })
export class NamedLog extends ResourceContext<{ name: string }> {
async execute(uri: string, { name }: { name: string }) {
return { text: `Example log content for ${name}` };
}
}Challenge 1 of 3
Keep a tool's text
This FastMCP tool returned `Hello, ${name}!` as a string, and was ported as it was. Clients read the text, so make greet send Hello, Ada! as the whole text, with no structuredContent, as FastMCP did.
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "greet", description: "Greet someone by name", inputSchema: { name: z.string() } })
export class Greet extends ToolContext {
async execute({ name }: { name: string }) {
return `Hello, ${name}!`;
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.