# Migrating from FastMCP for TypeScript

> Move a FastMCP for TypeScript server to FrontMCP one client contract at a time: tools, errors, logs, resources, prompts, auth and transports.

Source: https://frontmcp.dev/learn/migrating-from-fastmcp

[Checked against FrontMCP 1.10.10](https://frontmcp.dev/versions#how-pages-are-checked) on October 25, 2026

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](https://frontmcp.dev/compare/fastmcp-ts) 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

<a id="before-you-start"></a>

## 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](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md?plain=1#L5-L7)). 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](https://frontmcp.dev/compare/fastmcp-typescript#how-this-was-checked)). FrontMCP serves every revision from 2024-11-05 to 2026-07-28 over Streamable HTTP and stdio ([Protocol versions](https://frontmcp.dev/reference/server/protocol-versions#revisions-over-stdio)), 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](https://frontmcp.dev/compare/fastmcp-typescript#when-to-choose-fastmcp) lists reasons to stay. For 2026-07-28 with FastMCP's API, its README points to ViteMCP, by the same author ([FrontMCP vs ViteMCP](https://frontmcp.dev/compare/vitemcp)).

A FrontMCP project needs Node 24 or later and TypeScript's decorator settings; `frontmcp init` adds them to an existing project ([Installation](https://frontmcp.dev/learn/installation#adding-frontmcp-to-an-existing-project)). 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:

1. [Record what clients see now](https://frontmcp.dev/learn/migrating-from-fastmcp#record-what-clients-see-now)
2. [Port the tools](https://frontmcp.dev/learn/migrating-from-fastmcp#port-the-tools)
3. [Port errors, logs and progress](https://frontmcp.dev/learn/migrating-from-fastmcp#port-errors-logs-and-progress)
4. [Port resources, templates and prompts](https://frontmcp.dev/learn/migrating-from-fastmcp#port-resources-templates-and-prompts)
5. [Move state out of the session](https://frontmcp.dev/learn/migrating-from-fastmcp#move-state-out-of-the-session)
6. [Choose auth](https://frontmcp.dev/learn/migrating-from-fastmcp#choose-auth)
7. [Serve the same endpoint, then switch clients](https://frontmcp.dev/learn/migrating-from-fastmcp#serve-the-same-endpoint-then-switch-clients)

<a id="what-maps-to-what"></a>

## What maps to what

| FastMCP 4.23.4 | FrontMCP | Watch for |
| --- | --- | --- |
| `new FastMCP({ name, version })` ([Quickstart](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#quickstart)) | `@FrontMcp({ info: { name, version }, apps })`, with an [`@App`](https://frontmcp.dev/reference/sdk/app) that lists the tools, resources and prompts ([`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp)) | |
| `server.addTool({ name, description, parameters, execute })` ([Tools](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#tools)) | A [`@Tool`](https://frontmcp.dev/reference/sdk/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](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#returning-a-string)) | Return `{ content: [{ type: "text", text }] }` | A returned string arrives as `{"value": …}` ([Port the tools](https://frontmcp.dev/learn/migrating-from-fastmcp#port-the-tools)) |
| `outputSchema` ([Structured Tool Output](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#structured-tool-output)) | `outputSchema` ([Shaping Tool Results](https://frontmcp.dev/learn/shaping-tool-results#declaring-the-shape-with-outputschema)) | |
| `throw new UserError(message)` ([Errors](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#errors)) | `this.fail(new PublicMcpError(message))`, or `throw` it ([`this.fail`](https://frontmcp.dev/reference/sdk/fail)) | A `UserError`'s `extras`, which FastMCP sends as `structuredContent` ([`FastMCP.ts`](https://github.com/punkpeye/fastmcp/blob/v4.23.4/src/FastMCP.ts#L3050-L3056)), have no counterpart on this site |
| `log.info(message, data)` and `debug`, `warn`, `error` ([Logging](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#logging)) | `this.notify({ message, context: data }, level)` ([`this.notify`](https://frontmcp.dev/reference/sdk/notify)) | FrontMCP sends nothing until the client asks for log messages |
| `reportProgress({ progress, total, message })` ([Progress](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#progress)) | `this.progress(progress, total, message)` ([`this.progress`](https://frontmcp.dev/reference/sdk/progress)) | |
| `streamContent()` ([Streaming Output](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#streaming-output)) | None | FastMCP's own extension; it isn't part of MCP |
| `elicit({ message, requestedSchema })` ([Elicitation](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#elicitation)) | `this.elicit(message, zodSchema)`, with `elicitation: { enabled: true }` on the server ([`this.elicit`](https://frontmcp.dev/reference/sdk/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](https://frontmcp.dev/reference/sdk/elicit#keeping-side-effects-after-the-question)) |
| `session`: what `authenticate` returned ([Custom Authentication](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#custom-authentication)) | [`this.auth`](https://frontmcp.dev/reference/sdk/auth): `user.sub`, `scopes`, `roles`, `claims` | [Choose auth](https://frontmcp.dev/learn/migrating-from-fastmcp#choose-auth) |
| `sessionId` ([Session ID and Request ID Tracking](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#session-id-and-request-id-tracking)) | `this.context.sessionId` ([`this.context`](https://frontmcp.dev/reference/sdk/context)) | A new id on every 2026-07-28 request ([Move state out of the session](https://frontmcp.dev/learn/migrating-from-fastmcp#move-state-out-of-the-session)) |
| `requestId`, from the client's request ([Session ID and Request ID Tracking](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#session-id-and-request-id-tracking)) | None. `this.context.requestId` is an id FrontMCP makes, not the JSON-RPC `id` ([`this.context`](https://frontmcp.dev/reference/sdk/context#properties)) | |
| `signal` ([Cancelling Long Tool Calls](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#cancelling-long-tool-calls-contextsignal)) | [`this.signal`](https://frontmcp.dev/reference/sdk/contexts#thissignal) | |
| `server.addResource({ uri, name, mimeType, load })` ([Resources](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#resources)) | A [`@Resource`](https://frontmcp.dev/reference/sdk/resource) class with `execute()` | `load()`'s `{ text }` or `{ blob }` works unchanged |
| `server.addResourceTemplate({ uriTemplate, arguments, load })` ([Resource templates](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#resource-templates)) | A [`@ResourceTemplate`](https://frontmcp.dev/reference/sdk/resource-template) class | Parameters come from the URI's placeholders; there's no `arguments` list |
| `complete` on a template argument ([auto-completion](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#resource-template-argument-auto-completion)) | A [completer](https://frontmcp.dev/reference/sdk/resource-template#completing-parameters) | |
| `server.addPrompt({ name, arguments, load })` ([Prompts](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#prompts)) | A [`@Prompt`](https://frontmcp.dev/reference/sdk/prompt) class with `execute(args)` | |
| `complete` or `enum` on a prompt argument ([auto-completion](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#prompt-argument-auto-completion)) | None on this site | |
| `authenticate(request)` checking an API key ([Custom Authentication](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#custom-authentication)) | `auth: { mode: "static" }` ([Static keys](https://frontmcp.dev/reference/auth/tokens#static-keys)) | Your code gets an id per key, not an object you return |
| `auth: new GoogleProvider(…)`, `GitHubProvider`, `AzureProvider` ([OAuth with Pre-configured Providers](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#oauth-with-pre-configured-providers)) | `auth: { mode: "remote" }` with an OpenID Connect provider you configure ([Remote and proxied auth](https://frontmcp.dev/reference/auth/remote)) | GitHub [can't be used](https://frontmcp.dev/reference/auth/remote#pointing-frontmcp-at-your-providers-endpoints) |
| `canAccess`, `requireScopes()`, `requireRole()` ([Tool Authorization](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#tool-authorization)) | `authorities` on the tool ([Authorities](https://frontmcp.dev/reference/auth/authorities#authorities-on-an-entry)), or `this.auth.hasScope()` in `execute()` | |
| `server.start({ transportType: "httpStream", httpStream: { port } })` ([HTTP Streaming](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#http-streaming)) | `http: { port, entryPath: "/mcp" }` on `@FrontMcp` ([`http`](https://frontmcp.dev/reference/sdk/frontmcp#http)) | FrontMCP serves the root path unless you set `entryPath` |
| `httpStream: { stateless: true }` ([Stateless Mode](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#stateless-mode)) | `transport: { protocol: "stateless-api" }` ([`transport`](https://frontmcp.dev/reference/sdk/frontmcp#transport)) | Only clients before 2026-07-28 have sessions to turn off |
| `server.start({ transportType: "stdio" })` ([Quickstart](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#quickstart)) | `FRONTMCP_STDIO=1` ([Starting the server](https://frontmcp.dev/reference/sdk/frontmcp#starting-the-server)) | |
| `server.connect()` with `InMemoryTransport`, in tests ([Unit testing](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#unit-testing-with-an-in-memory-transport)) | `@frontmcp/testing` ([Testing](https://frontmcp.dev/reference/testing)) | |
| `fastmcp dev`, `fastmcp inspect` ([Running Your Server](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#running-your-server)) | `frontmcp dev`, `frontmcp inspector` ([CLI](https://frontmcp.dev/reference/cli)) | |
| `EdgeFastMCP` ([Edge Runtime Support](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#edge-runtime-support)) | [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), or a [Cloudflare Workers build](https://frontmcp.dev/reference/deployment/cloudflare-workers) | |

<a id="record-what-clients-see-now"></a>

## 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](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#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`:

```ts 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):

```json
{
  "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.

<a id="port-the-tools"></a>

## Port the tools

FastMCP's [Quickstart](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#quickstart), which answered over stdio as recorded above (the recording drove the same server, exported instead of started):

```ts server.ts
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](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#returning-a-string)), so `execute()` returns that block, and the client gets the same answer. The **Tests** tab checks it against the recording.

Initial call configuration:

```js
{ tool: "add", arguments: { a: 2, b: 3 } }
```

```ts main.ts active
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 {}
```

```ts add.test.ts
import { test, expect } from "@frontmcp/testing";

test("2 + 3 is the text 5, with no structuredContent, as in the recording", async ({ mcp }) => {
  const result = await mcp.tools.call("add", { a: 2, b: 3 });
  expect(result.raw.content).toEqual([{ type: "text", text: "5" }]);
  expect(result.raw.structuredContent).toBeUndefined();
});

test("tools/list has the same name, description and inputs", async ({ mcp }) => {
  const add = (await mcp.tools.list()).find((tool) => tool.name === "add");
  expect(add?.description).toBe("Add two numbers");
  expect(add?.inputSchema).toEqual({
    type: "object",
    properties: { a: { type: "number" }, b: { type: "number" } },
    required: ["a", "b"],
    $schema: "https://json-schema.org/draft/2020-12/schema",
  });
});

test("a string where a number belongs is an INVALID_INPUT result, not a -32602 error", async ({ mcp }) => {
  const result = await mcp.tools.call("add", { a: "2", b: 3 });
  expect(result.error).toBeUndefined();
  expect(result).toBeError("INVALID_INPUT");
});

test("the server still says it is My Server 1.0.0", async ({ mcp }) => {
  const result = await mcp.tools.call("add", { a: 2, b: 3 });
  expect(result.raw._meta?.["io.modelcontextprotocol/serverInfo"]).toEqual({ name: "My Server", version: "1.0.0" });
});
```

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 added `additionalProperties: false`, whatever the schema said ([Tools](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md?plain=1#L634-L640)). The properties and `required` are the same.
- **Invalid input.** FrontMCP answers with a tool result, `isError: true` and `_meta.code` `INVALID_INPUT`, which the model reads and can correct ([Your First Tool](https://frontmcp.dev/learn/your-first-tool#what-happens-when-a-tool-is-called)). FastMCP answered with a JSON-RPC error, `-32602` ([`FastMCP.ts`](https://github.com/punkpeye/fastmcp/blob/v4.23.4/src/FastMCP.ts#L2810-L2817)). 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](https://frontmcp.dev/learn/schemas-are-contracts)).

> **Pitfall: A returned string isn't sent as it is**
>
> `return String(a + b)` runs in FrontMCP too, but the client gets `{"value":"5"}` as the text, and the same object as `structuredContent` ([Shaping Tool Results](https://frontmcp.dev/learn/shaping-tool-results#what-comes-back-from-toolscall)). Return the content block to keep the text. When the clients can take a change, return an object, like `{ sum: 5 }`, which arrives as `structuredContent` with a JSON copy as text ([FrontMCP vs FastMCP](https://frontmcp.dev/compare/fastmcp-typescript#in-frontmcp)); make that change as a step of its own, after the move.

**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.

<a id="port-errors-logs-and-progress"></a>

## 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:

```ts download.ts
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.

Initial call configuration:

```js
{ tool: "download", arguments: { url: "https://files.example.org/report.pdf" } }
```

```ts download.tool.ts active
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" }] };
  }
}
```

```ts download.test.ts
import { test, expect } from "@frontmcp/testing";

test("a blocked URL fails with the UserError's message, word for word", async ({ mcp }) => {
  const result = await mcp.tools.call("download", { url: "https://example.com/report.pdf" });
  expect(result).toBeError("PUBLIC_ERROR");
  expect(result.raw.content).toEqual([{ type: "text", text: "This URL is not allowed" }]);
});

test("the log message carries FastMCP's data", async ({ mcp }) => {
  await mcp.tools.call("download", { url: "https://files.example.org/report.pdf" });
  const logs = mcp.notifications.collect().received.filter((n) => n.method === "notifications/message");
  expect(logs.map((n) => n.params)).toEqual([
    { level: "info", logger: "download", data: { message: "Downloading file...", context: { url: "https://files.example.org/report.pdf" } } },
  ]);
});

test("progress goes from 0 to 100", async ({ mcp }) => {
  await mcp.tools.call("download", { url: "https://files.example.org/report.pdf" });
  const updates = mcp.notifications.collectProgress().all;
  expect(updates.map((update) => [update.progress, update.total])).toEqual([[0, 100], [100, 100]]);
});

test("an allowed URL answers done", async ({ mcp }) => {
  const result = await mcp.tools.call("download", { url: "https://files.example.org/report.pdf" });
  expect(result.text()).toBe("done");
});
```

- **`UserError` becomes `PublicMcpError`.** Its message is the whole text of an `isError` result in both, and FrontMCP adds a code in `_meta.code`. An error that isn't public is different: FastMCP sent `Tool 'download' execution failed:` and the message ([`FastMCP.ts`](https://github.com/punkpeye/fastmcp/blob/v4.23.4/src/FastMCP.ts#L3058-L3067)); FrontMCP sends `Tool "download" execution failed:`, the message and the stack in development, and hides all of it in production ([Failing vs throwing](https://frontmcp.dev/reference/sdk/fail#failing-vs-throwing)).
- **`log` becomes `this.notify()`.** FastMCP sent `{ message, context }` as the log message's `data` ([`FastMCP.ts`](https://github.com/punkpeye/fastmcp/blob/v4.23.4/src/FastMCP.ts#L2035-L2058)); `this.notify()` sends the object you pass, so the same object keeps the shape, and FrontMCP adds the tool's name as `logger`. FastMCP's `log.warn` sent the level `"warning"` ([`FastMCP.ts`](https://github.com/punkpeye/fastmcp/blob/v4.23.4/src/FastMCP.ts#L1929-L1931)), which is the name `this.notify()` takes.
- **FrontMCP's log messages are opt-in.** FastMCP sent every level until the client set one with `logging/setLevel` ([Logging](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md?plain=1#L1283-L1286)). FrontMCP sends nothing until the client asks: with `logging/setLevel` before 2026-07-28, and with a level in each request's `_meta` under 2026-07-28 ([Asking for log messages](https://frontmcp.dev/reference/sdk/notify#asking-for-log-messages)). The Playground's test client asks for `debug` on 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](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md?plain=1#L1351), [`this.progress`](https://frontmcp.dev/reference/sdk/progress)).

**Before you move on:** the recorded failures have the same text, and the clients that show logs or progress still get them.

<a id="port-resources-templates-and-prompts"></a>

## 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):

```ts logs.ts
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.

Initial call configuration:

```js
{ resource: "file:///logs/server.log" }
```

```ts logs.resource.ts active
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}` };
  }
}
```

```ts git-commit.prompt.ts
import { Prompt, PromptContext } from "@frontmcp/sdk";

@Prompt({
  name: "git-commit",
  description: "Generate a Git commit message",
  arguments: [{ name: "changes", description: "Git diff or description of changes", required: true }],
})
export class GitCommit extends PromptContext {
  async execute(args: Record<string, string>) {
    return `Generate a concise but descriptive commit message for these changes:\n\n${args.changes}`;
  }
}
```

```ts logs.test.ts
import { test, expect } from "@frontmcp/testing";

test("resources/list and resources/templates/list match the recording", async ({ mcp }) => {
  expect(await mcp.resources.list()).toEqual([{ name: "Application Logs", uri: "file:///logs/app.log", mimeType: "text/plain" }]);
  expect(await mcp.resources.listTemplates()).toEqual([{ name: "Application Logs", uriTemplate: "file:///logs/{name}.log", mimeType: "text/plain" }]);
});

test("both reads give the recorded contents", async ({ mcp }) => {
  expect((await mcp.resources.read("file:///logs/app.log")).raw.contents).toEqual([
    { uri: "file:///logs/app.log", mimeType: "text/plain", text: "Started on port 8080" },
  ]);
  expect((await mcp.resources.read("file:///logs/server.log")).raw.contents).toEqual([
    { uri: "file:///logs/server.log", mimeType: "text/plain", text: "Example log content for server" },
  ]);
});

test("the prompt lists the same argument and gives the same message", async ({ mcp }) => {
  const gitCommit = (await mcp.prompts.list()).find((prompt) => prompt.name === "git-commit");
  expect(gitCommit?.arguments).toEqual([{ name: "changes", description: "Git diff or description of changes", required: true }]);
  const result = await mcp.prompts.get("git-commit", { changes: "Fix a typo in the README" });
  expect(result.messages).toEqual([
    { role: "user", content: { type: "text", text: "Generate a concise but descriptive commit message for these changes:\n\nFix a typo in the README" } },
  ]);
});
```

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](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md?plain=1#L1656), [FrontMCP](https://frontmcp.dev/reference/sdk/resource-template#matching-paths-with-slashes)). 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.

<a id="move-state-out-of-the-session"></a>

## 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](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#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`](https://frontmcp.dev/reference/sdk/context#thiscontextsessionid-is-different-on-every-call)), so the count starts again on every call:

Initial call configuration:

```js
{ tool: "increment_counter", arguments: {} }
```

```ts counter.tool.ts active
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}` }] };
  }
}
```

```ts counter.test.ts
import { test, expect } from "@frontmcp/testing";

test("each call is a new session, so the count never passes 1", async ({ mcp }) => {
  const first = await mcp.tools.call("increment_counter", {});
  const second = await mcp.tools.call("increment_counter", {});
  expect(first.text()).toMatch(/^Counter for session anon:.+: 1$/);
  expect(second.text()).toMatch(/: 1$/);
  expect(second.text()).not.toBe(first.text());
});
```

Give the state a handle of its own that the model passes back, as the 2026-07-28 changelog suggests ([MCP Went Stateless](https://frontmcp.dev/blog/mcp-went-stateless#what-it-means-for-a-server)). One tool starts a counter and returns its id; the other takes the id:

Initial call configuration:

```js
{ tool: "start_counter", arguments: {} }
```

```ts counter.tool.ts active
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 };
  }
}
```

```ts counter.test.ts
import { test, expect } from "@frontmcp/testing";

test("the count goes on across calls, without a session", async ({ mcp }) => {
  const { counterId } = (await mcp.tools.call("start_counter", {})).json() as { counterId: string };
  await mcp.tools.call("increment_counter", { counterId });
  const second = await mcp.tools.call("increment_counter", { counterId });
  expect(second.json()).toEqual({ counterId, counter: 2 });
});

test("an unknown counterId is refused with a message the model can act on", async ({ mcp }) => {
  const result = await mcp.tools.call("increment_counter", { counterId: "c-404" });
  expect(result).toBeError("PUBLIC_ERROR");
  expect(result.text()).toBe("There's no counter c-404. Start one with start_counter.");
});
```

The `Map` lives in one process. With several instances, keep the state where every instance can read it ([Running Several Instances](https://frontmcp.dev/learn/running-several-instances)). State that belongs to a signed-in user can be keyed by `this.auth.user.sub` instead, once an [auth mode](https://frontmcp.dev/learn/migrating-from-fastmcp#choose-auth) 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.

<a id="choose-auth"></a>

## Choose auth

A FrontMCP server is `public` by default: every request gets in. Choose a mode before you move a protected server ([Auth modes](https://frontmcp.dev/reference/auth/modes)).

- **An API key.** FastMCP's README checks an `x-api-key` header in `authenticate()` ([Custom Authentication](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#custom-authentication)). FrontMCP's `static` mode checks the same header, without code, with `header: "x-api-key"` and `scheme: ""` ([Static keys in a header of your own](https://frontmcp.dev/reference/auth/tokens#static-keys-in-a-header-of-your-own)). What tools see changes: FastMCP passed whatever `authenticate()` returned as `session`; in `static` mode, `this.auth.user.sub` is `static:` and 12 hex characters for each key, and every key gets the mode's `scopes` ([What each auth mode fills in](https://frontmcp.dev/reference/sdk/auth#what-each-auth-mode-fills-in)).
- **Roles and scopes per user.** Accept JWTs from your identity provider in `transparent` mode ([Tokens](https://frontmcp.dev/reference/auth/tokens#verifying-jwts-from-your-identity-provider)), and replace `canAccess` with `authorities` on the tool ([Authorities](https://frontmcp.dev/reference/auth/authorities#authorities-on-an-entry)).
- **Sign-in with a provider.** FastMCP's OAuth proxy has presets for Google, GitHub and Azure. FrontMCP's `remote` mode takes an OpenID Connect provider you configure; GitHub, whose users have no `sub`, [can't be used](https://frontmcp.dev/reference/auth/remote#pointing-frontmcp-at-your-providers-endpoints). `local` mode makes FrontMCP the sign-in page, and you check the user in `authenticate` ([Local auth](https://frontmcp.dev/reference/auth/local#checking-users-yourself)).

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](https://frontmcp.dev/reference/auth/authorities#hiding-versus-refusing)).

<a id="serve-the-same-endpoint-then-switch-clients"></a>

## 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 `/mcp` unless you set `httpStream.endpoint` ([HTTP Streaming](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md?plain=1#L141-L143)). FrontMCP serves the root path unless you set `http.entryPath`, so set `entryPath: "/mcp"` and the same `port` ([`http`](https://frontmcp.dev/reference/sdk/frontmcp#http)).
- **SSE.** FastMCP also starts an SSE endpoint at `/sse` ([HTTP Streaming](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md?plain=1#L147)). FrontMCP's Node server serves the older HTTP+SSE transport by default ([`transport`](https://frontmcp.dev/reference/sdk/frontmcp#transport)) at `<entryPath>/sse`, not at `/sse`, and wants posts to its message endpoint to accept JSON or an event stream ([Choosing a transport](https://frontmcp.dev/reference/deployment/mcp-clients#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=1` is set ([Starting the server](https://frontmcp.dev/reference/sdk/frontmcp#starting-the-server), [Over stdio](https://frontmcp.dev/reference/deployment/mcp-clients#over-stdio)).
- **Health checks.** FastMCP answers `/health` with the text `ok` ([Health-check Endpoint](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#health-check-endpoint)). FrontMCP answers `/health` and `/healthz` with JSON ([Health and metrics](https://frontmcp.dev/reference/deployment/health-and-metrics)), so a probe that reads the body needs changing.
- **The address it listens on.** FrontMCP listens on `127.0.0.1` by default, which a published container port can't reach; set `FRONTMCP_BIND_ADDRESS=all` there ([Node](https://frontmcp.dev/reference/deployment/node)).

Then switch clients the way [Migrating from the TypeScript SDK](https://frontmcp.dev/learn/migrating-from-the-typescript-sdk#verify-the-endpoint-before-switching-clients) 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.

<a id="recap"></a>

## Recap

- Record what FastMCP answers first, with `server.connect()` and the SDK's in-memory client, and turn each answer into a `@frontmcp/testing` test.
- `addTool`, `addResource`, `addResourceTemplate` and `addPrompt` become `@Tool`, `@Resource`, `@ResourceTemplate` and `@Prompt` classes, listed in an `@App` on a `@FrontMcp` server.
- A tool that returned a string returns `{ content: [{ type: "text", text }] }` to keep its text.
- `UserError` becomes `PublicMcpError`; `log`, `reportProgress` and `elicit` become `this.notify()`, `this.progress()` and `this.elicit()`. Log messages are opt-in in FrontMCP.
- Invalid input is an `INVALID_INPUT` tool result in FrontMCP, not a `-32602` error.
- 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.

<a id="try-some-challenges"></a>

## Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press **Check**.

### Challenge: 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.

Initial call configuration:

```js
{ tool: "greet", arguments: { name: "Ada" } }
```

```ts greet.tool.ts
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}!`;
  }
}
```

**Solution file (alternative to the starter):**

```ts greet.tool.ts solution
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 { content: [{ type: "text", text: `Hello, ${name}!` }] };
  }
}
```

**Hidden fixture/test file (runs with the example):**

```ts greet.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("the text is `Hello, Ada!` and nothing else", async ({ mcp }) => {
  const result = await mcp.tools.call("greet", { name: "Ada" });
  expect(result.raw.content).toEqual([{ type: "text", text: "Hello, Ada!" }]);
});

test("there's no `structuredContent`", async ({ mcp }) => {
  const result = await mcp.tools.call("greet", { name: "Ada" });
  expect(result.raw.structuredContent).toBeUndefined();
});
```

**Hint:**

Return the content block FastMCP built from the string.

**Solution:**

A string is wrapped as `{ "value": … }`; a result with a `content` array is sent as it is, so the text is the greeting and there's no `structuredContent`.

### Challenge: 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.

Initial call configuration:

```js
{ tool: "download", arguments: { url: "https://example.com/report.pdf" } }
```

```ts download.tool.ts
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" }] };
  }
}
```

**Solution file (alternative to the starter):**

```ts download.tool.ts solution
import { PublicMcpError, 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")) {
      this.fail(new PublicMcpError("This URL is not allowed"));
    }
    return { content: [{ type: "text", text: "done" }] };
  }
}
```

**Hidden fixture/test file (runs with the example):**

```ts download.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("a blocked URL fails with `This URL is not allowed` as the whole text", async ({ mcp }) => {
  const result = await mcp.tools.call("download", { url: "https://example.com/report.pdf" });
  expect(result).toBeError();
  expect(result.raw.content).toEqual([{ type: "text", text: "This URL is not allowed" }]);
});

test("the error is public, so production keeps its message", async ({ mcp }) => {
  const result = await mcp.tools.call("download", { url: "https://example.com/report.pdf" });
  expect(result).toBeError("PUBLIC_ERROR");
});

test("another URL still answers `done`", async ({ mcp }) => {
  const result = await mcp.tools.call("download", { url: "https://files.example.org/report.pdf" });
  expect(result.text()).toBe("done");
});
```

**Hint:**

FrontMCP's public error class is exported from `@frontmcp/sdk`.

**Solution:**

A plain `Error` is a server failure: in development its text starts with `Tool "download" execution failed:` and includes the stack, and production hides it. `PublicMcpError` is meant for the model, so its message is the whole text everywhere, with `PUBLIC_ERROR` in `_meta.code`.

### Challenge: 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`.

Initial call configuration:

```js
{ resource: "file:///logs/server.log" }
```

```ts logs.resource.ts
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}` };
  }
}
```

**Solution file (alternative to the starter):**

```ts logs.resource.ts solution
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}` };
  }
}
```

**Hidden fixture/test file (runs with the example):**

```ts logs.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("`file:///logs/2026/10/app.log` reads the log named `2026/10/app`", async ({ mcp }) => {
  const result = await mcp.resources.read("file:///logs/2026/10/app.log");
  expect(result.text()).toBe("Example log content for 2026/10/app");
});

test("`file:///logs/server.log` still reads the log named `server`", async ({ mcp }) => {
  const result = await mcp.resources.read("file:///logs/server.log");
  expect(result.text()).toBe("Example log content for server");
});
```

**Hint:**

Only the placeholder in `uriTemplate` changes.

**Solution:**

`{name}` stops at the first `/`, in FrontMCP as in FastMCP. `{+name}` takes the rest of the path up to `.log`, slashes included.
