FrontMCPThe TypeScript framework for MCP servers

Typed tools with Zod schemas, dependency injection, authentication, testing, agents and jobs, and one build for Node, Vercel, AWS Lambda and Cloudflare Workers.

npx frontmcp create my-mcp-server

How FrontMCP compares with the official MCP SDK and other TypeScript frameworks

Write tools as typed classes

A tool is a class: a name, a description, a Zod schema for its arguments, and an execute() method. FrontMCP lists it for every MCP client, and runs it when a model calls it.

get-ticket.tool.ts
@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id. Returns the ticket's title and status.",
  inputSchema: {
    id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1"),
  },
  annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return this.get(Tickets).find(id);
  }
}
Any MCP client

Is T-1 still open?

Tool callget_ticketread-onlyDone
Arguments
{ "id": "T-1" }
Result
{ "id": "T-1", "title": "Cannot log in", "status": "open" }

Yes. T-1, “Cannot log in”, is still open.

You write the class. FrontMCP handles the Model Context Protocol, validation, transport and auth.

Write your first tool

The schema is the contract

The model reads your Zod schema as JSON Schema, and FrontMCP checks every call against it before execute() runs. A bad call comes back as an error the model can read and correct.

create-ticket.tool.ts
@Tool({
  name: "create_ticket",
  description: "Open a new support ticket",
  inputSchema: {
    title: z.string().min(5).describe("One-line summary of the problem"),
    priority: z.enum(["low", "normal", "high"]).default("normal"),
  },
})
export class CreateTicket extends ToolContext {
  async execute(input: { title: string; priority: Priority }) {
    return this.get(Tickets).create(input);
  }
}
Any MCP client

Invoices won't export. Log a bug, it's urgent.

Tool callcreate_ticketRejected
Arguments
{ "title": "Bug", "priority": "urgent" }
Invalid tool input
  • titleToo small: expected string to have >=5 characters
  • priorityInvalid option: expected one of "low"|"normal"|"high"
Tool callcreate_ticketDone
Arguments
{ "title": "Invoices will not export", "priority": "high" }
Result
{ "id": "T-2", "title": "Invoices will not export", "priority": "high", "status": "open" }

Done: T-2, “Invoices will not export”, is open as high priority.

Both calls are real FrontMCP output: the first is rejected before your code runs, and the model fixes its arguments and tries again.

Learn how schemas work

Run the real SDK in your browser

This is FrontMCP 1.10.2 running in a Web Worker on this page. Call a tool, change the code and call it again. The Wire tab shows every message.

Open
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
  { id: "T-3", title: "Login link expired", status: "open" },
];

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title",
  inputSchema: { query: z.string().min(2).describe("Words to look for") },
  annotations: { readOnlyHint: true },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    const q = query.toLowerCase();
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(q)) };
  }
}

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Every lesson and reference page on this site works the same way, and every example on it runs as a test against the version it documents.

Open the Playground

Any REST API, as tools

Give the OpenAPI adapter a spec, and every operation becomes a tool: named by its operationId, with an input schema built from its parameters and body. Calls are checked against the spec before the adapter calls your API.

desk.app.ts
@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://desk.example/v1",
      spec: deskOpenApiSpec,
    }),
  ],
})
export class DeskApp {}
Any MCP client
tools/list4 tools
  • closeTicketClose a support ticket
  • createTicketOpen a support ticket
  • getTicketGet one support ticket by its id
  • listTicketsList support tickets, newest first
Tool callgetTicketDone
Arguments
{ "id": "T-1" }
Result
{ "status": 200, "ok": true, "data": {"id":"T-1","title":"Cannot log in","status":"open"} }

Four operations in the Desk API's spec, four tools. The call is real FrontMCP output: the adapter sent the API a GET request and returned its answer.

Wrap an OpenAPI service

Many apps, one server

Group tools, resources and prompts into apps, and serve them from one endpoint. An app can be your own code, a package FrontMCP loads from npm when the server starts, or another MCP server.

main.ts
@FrontMcp({
  info: { name: "Support", version: "1.0.0" },
  apps: [HelpDeskApp, BillingApp],
  http: { port: 3000 },
})
export default class Server {}

Providers give every tool the same database client, API wrapper or cache, through dependency injection.

Group capabilities into apps

Extend every call

Plugins add what belongs around your tools: a cache, a memory, an approval step, feature flags. They're built on hooks, which you can write too: code that runs before, after or around any stage of a request.

reports.app.ts
@Tool({
  name: "get_sla_report",
  description: "The SLA report for one day",
  inputSchema: { day: z.string().describe("A day, like 2026-10-08") },
  cache: { ttl: 300 },
})
export class GetSlaReport extends ToolContext {
  async execute({ day }: { day: string }) {
    return this.get(Reports).build(day);
  }
}

@App({
  id: "reports",
  name: "Reports",
  tools: [GetSlaReport],
  providers: [Reports],
  plugins: [CachePlugin.init({ type: "memory", keyByIdentity: false })],
})
export class ReportsApp {}
Any MCP client
Tool callget_sla_reportDone
Arguments
{ "day": "2026-10-08" }
Result
{ "day": "2026-10-08", "tickets": 42, "breaches": 3, "slowestReplyMinutes": 95, "build": 1 }
Tool callget_sla_reportcache: hitDone
Arguments
{ "day": "2026-10-08" }
Result
{ "day": "2026-10-08", "tickets": 42, "breaches": 3, "slowestReplyMinutes": 95, "build": 1 }

Real FrontMCP output: the second call was answered from the cache, so execute() didn't run again and build is still 1.

Use the official plugins

Work that outlives a single call

Jobs run in the background, with retries and a record of every run. Workflows run jobs as steps, in order. Agents run a model of their own with your tools, so a client can hand over a whole task.

notify-agent.job.ts
@Job({
  name: "notify-agent",
  description: "Email a support agent about a ticket",
  inputSchema: { agent: z.string(), ticket: z.string() },
  outputSchema: { sent: z.boolean() },
  retry: { maxAttempts: 3, backoffMs: 100 },
})
export class NotifyAgent extends JobContext {
  async execute({ agent, ticket }: { agent: string; ticket: string }) {
    this.log(`Attempt ${this.attempt}: emailing ${agent} about ${ticket}`);
    await this.get(Mail).send(agent, `${ticket} needs you`);
    return { sent: true };
  }
}
Any MCP client
Tool callexecute_jobDone
Arguments
{ "name": "notify-agent", "input": {"agent":"nour","ticket":"T-1"} }
Result
{ "state": "completed", "result": {"sent":true} }
Logs
Attempt 3: emailing nour about T-1

Real FrontMCP output: the mail server was busy twice, so the job ran again after a wait, and the third attempt sent the email.

Write your first job

Current with the protocol

FrontMCP serves every MCP revision from 2024-11-05 to 2026-07-28 on the same endpoint. Clients that speak the new stateless revision get it, and existing clients keep working unchanged.

  1. 2024-11-05
  2. 2025-03-26
  3. 2025-06-18
  4. 2025-11-25
  5. 2026-07-28stateless
→ Request
POST /
content-type: application/json
accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_ticket
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "get_ticket",
    "arguments": {
      "id": "T-1"
    },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": {
        "name": "frontmcp.dev playground",
        "version": "1.0.0"
      },
      "io.modelcontextprotocol/clientCapabilities": {
        "elicitation": {
          "form": {}
        }
      }
    }
  }
}
← Response
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\"id\":\"T-1\",\"title\":\"Cannot log in\",\"status\":\"open\"}"
      }
    ],
    "structuredContent": {
      "id": "T-1",
      "title": "Cannot log in",
      "status": "open"
    },
    "resultType": "complete",
    "_meta": {
      "io.modelcontextprotocol/serverInfo": {
        "name": "Support",
        "version": "1.0.0"
      }
    }
  }
}

A real request and response, from the get_ticket tool above running on FrontMCP 1.10.2.

See what clients discover

Deploy where you already run

One command scaffolds a server with TypeScript, hot reload, the MCP Inspector and deployment config for the platform you choose.

Terminal
$ npx frontmcp create my-mcp-server --target cloudflare
  • Node.jsA Dockerfile and Compose file, with Redis--target node
  • VercelServerless functions, with a vercel.json--target vercel
  • AWS LambdaAn AWS SAM template--target lambda
  • Cloudflare WorkersA Worker, with a wrangler.toml--target cloudflare

Install FrontMCP

Keep up with every release

Each release's notes say what changed. This site documents FrontMCP 1.10.2, and every example on it is tested against that version.

The blog covers the bigger pieces in depth, from monorepos to tool UIs and deployment.

Start building with FrontMCP

The Quick Start covers the 80% of FrontMCP you'll use every day, and every example in it runs in your browser.