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-serverHow 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.
@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);
}
}Is T-1 still open?
get_ticketread-onlyDone{ "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.
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.
@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);
}
}Invoices won't export. Log a bug, it's urgent.
create_ticketRejected{ "title": "Bug", "priority": "urgent" }Invalid tool inputtitleToo small: expected string to have >=5 characterspriorityInvalid option: expected one of "low"|"normal"|"high"
create_ticketDone{ "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.
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.
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.
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.
@App({
id: "desk",
name: "Desk",
adapters: [
OpenapiAdapter.init({
name: "desk",
baseUrl: "https://desk.example/v1",
spec: deskOpenApiSpec,
}),
],
})
export class DeskApp {}closeTicketClose a support ticketcreateTicketOpen a support ticketgetTicketGet one support ticket by its idlistTicketsList support tickets, newest first
getTicketDone{ "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.
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.
@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.
@FrontMcp({
info: { name: "Support", version: "1.0.0" },
apps: [
HelpDeskApp,
App.esm("@acme/status-tools@^1.0.0", { namespace: "status" }),
App.remote("https://billing.example/mcp", { namespace: "billing" }),
],
})
export default class Server {}create_ticketHelpDeskAppOpen a new support ticketget_ticketHelpDeskAppGet one support ticket by its id. Returns the ticket's title and status.status:get_statusApp.esm · npm 1.2.0Current status of one servicebilling:list_invoicesApp.remote · billing.exampleList a customer's open invoices
status:get_statusDone{ "service": "api" }Resultapi is up (served by 1.2.0)
Real FrontMCP output: ^1.0.0 became 1.2.0, the newest 1.x on the registry, when the server started, and the billing tools come from the billing team's own MCP server. The model sees one list.
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.
@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 {}get_sla_reportDone{ "day": "2026-10-08" }Result{ "day": "2026-10-08", "tickets": 42, "breaches": 3, "slowestReplyMinutes": 95, "build": 1 }get_sla_reportcache: hitDone{ "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.
- CacheAnswers repeated calls from a store instead of running the tool again.
- RememberAn encrypted memory your tools read and write with this.remember.
- ApprovalHolds a tool's call until the user approves it.
- Feature flagsTurns tools, resources and prompts on and off from your flag service.
- CodeCallLets the model search your tools and run short scripts that call them.
- Skilled OpenAPIServes a large REST API as skills behind a few tools.
- WebMCPOffers a web page's tools to the agents in the user's browser.
- DashboardA web page and an MCP endpoint that show what your server has.
@Plugin({ name: "release-freeze" })
export class ReleaseFreezePlugin extends DynamicPlugin<object> {
@ToolHook.Will("execute")
async refuseChanges(ctx: FlowCtxOf<"tools:call-tool">) {
const readOnly = ctx.state.tool?.metadata.annotations?.readOnlyHint;
if (readOnly || process.env.RELEASE_FREEZE !== "on") return;
const text = "Changes are frozen until the release ships.";
ctx.respond({ content: [{ type: "text", text }], isError: true });
}
@ToolHook.Did("execute")
async markAudited(ctx: FlowCtxOf<"tools:call-tool">) {
ctx.state.set("resultMeta", { audited: true });
}
}Is T-1 still open? Close it, it's fixed.
get_ticketread-onlyauditedDone{ "id": "T-1" }Result{ "id": "T-1", "title": "Cannot log in", "status": "open" }close_ticketRejected{ "id": "T-1" }ErrorChanges are frozen until the release ships.
T-1 is still open. Changes are frozen until the release ships, so I couldn't close it yet.
Both calls are real FrontMCP output, with RELEASE_FREEZE=on: the hook answered close_ticket before its execute() ran, and marked the read it let through.
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.
@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 };
}
}execute_jobDone{ "name": "notify-agent", "input": {"agent":"nour","ticket":"T-1"} }Result{ "state": "completed", "result": {"sent":true} }LogsAttempt 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.
@Workflow({
name: "nightly-report",
description: "Find a day's SLA breaches, write the report and email the lead",
steps: [
{ id: "collect", jobName: "collect-tickets" },
{
id: "breaches",
jobName: "find-sla-breaches",
dependsOn: ["collect"],
input: (steps) => steps.get("collect").outputs,
},
{
id: "report",
jobName: "write-report",
dependsOn: ["breaches"],
input: (steps) => steps.get("breaches").outputs,
},
{
id: "notify",
jobName: "notify-lead",
dependsOn: ["report"],
condition: (steps) => steps.get("breaches").outputs.count !== 0,
input: (steps) => steps.get("report").outputs,
},
],
})
export class NightlyReport {}execute_workflowcompletedcollectcompleted{"day":"2026-10-08","tickets":[{"id":"T-1","replyMinutes":30},{"id":"T-2","replyMinutes":95}]}breachescompleted{"day":"2026-10-08","count":1,"breaches":["T-2"]}reportcompleted{"report":"2026-10-08: 1 SLA breach (T-2)"}notifycompleted{"sent":true}
Real FrontMCP output: each step got the outputs of the step before it, and notify ran because a ticket broke its SLA.
@Agent({
name: "triage",
description: "Triage a new support ticket. Pass the ticket's id.",
systemInstructions:
"Read the ticket with get_ticket, then set its priority with set_priority: " +
"high if the customer can't work, normal otherwise.",
inputSchema: { ticketId: z.string().describe("A ticket id, like T-1") },
tools: [GetTicket, SetPriority],
llm: { provider: "openai", model: "gpt-5", apiKey: { env: "OPENAI_API_KEY" } },
execution: { maxIterations: 5 },
})
export class Triage extends AgentContext {}invoke_triageTriage a new support ticket. Pass the ticket's id.
- The client calls
invoke_triageonce, withticketId. - The agent asks its own model what to do, offering
get_ticketandset_priority. - It runs the tools the model asks for and sends back their results, until the model answers.
- The client gets the model's answer as the call's result.
The tools/list above is real: clients see the agent as one tool, and only the agent's model sees get_ticket and set_priority.
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.
2024-11-052025-03-262025-06-182025-11-252026-07-28stateless
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": {}
}
}
}
}
}{
"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.
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.
$ 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
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.
Latest news
- ReleaseFrontMCP 1.10.2: Reliable Browser Builds October 2026
- BlogYour MCP Codebase Just Hit 50 Files. Here's How to Stop the Chaos. February 25, 2026
- BlogBeyond Text: How to Render Rich UI Widgets from Your MCP Tools December 19, 2025
- BlogZero to Production: Deploy Your MCP Server to Vercel in 5 Minutes December 12, 2025
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.