Quick Start: build an MCP server in TypeScript

Welcome to FrontMCP. This page covers the concepts you'll use most days when building an MCP server. Every example runs the real FrontMCP SDK in your browser: change the code and the server restarts with your edits.

You will learn

  • How to define a tool, and what an AI model sees when it looks at one
  • How schemas check input before your code runs
  • How to return structured results
  • How to expose data with resources and reusable prompts
  • How apps and a server fit together
  • How to share a service between tools with a provider

Creating a tool

An MCP server gives AI clients, like Claude or Cursor, a set of capabilities. The most common kind is a tool: a function the model can decide to call when it needs to act or look something up.

In FrontMCP, a tool is a class decorated with @Tool that extends ToolContext and implements execute():

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

@Tool({
  name: "add",
  description: "Adds two numbers together",
  inputSchema: { a: z.number(), b: z.number() },
})
export class AddTool extends ToolContext {
  async execute(input: { a: number; b: number }) {
    return input.a + input.b;
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The panel on the right is an MCP client talking to your server:

  • Capabilities shows what tools/list returns. That card is everything the model knows about your tool, so the name and description are the model's only documentation.
  • Call sends tools/call. The page already sent { "a": 2, "b": 3 }, and the result came back as { "value": 5 }.
  • Wire shows each JSON-RPC request and response exactly as a client would send and receive them.

Try changing description and watch the Capabilities card change.

Checking input with schemas

inputSchema is written with Zod, re-exported from @frontmcp/sdk as z. FrontMCP turns it into the JSON Schema the model reads, and checks every call against it before execute() runs.

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

@Tool({
  name: "greet",
  description: "Greets someone by name",
  inputSchema: {
    name: z.string().min(1).describe("Who to greet"),
    style: z.enum(["casual", "formal"]).default("casual"),
  },
})
export class GreetTool extends ToolContext {
  async execute({ name, style }: { name: string; style: "casual" | "formal" }) {
    return style === "formal" ? `Good day, ${name}.` : `Hi ${name}!`;
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Clear the name field and call it again. FrontMCP rejects the call with Invalid tool input and a list of what's wrong, and your execute() never runs. The model gets that message back and can correct itself.

.describe() text shows up next to the field in the Capabilities tab. Models use it the same way you'd use a parameter comment.

Returning structured results

Whatever execute() returns is sent back to the client. Plain values, like the number and string above, arrive wrapped as { "value": ... }. When the result has a shape, declare it with outputSchema, so clients know what fields to expect before they call:

Open
import { 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" },
] as const;

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in the title",
  inputSchema: { query: z.string().min(1) },
  outputSchema: z.object({
    tickets: z.array(z.object({ id: z.string(), title: z.string(), status: z.enum(["open", "closed"]) })),
  }),
  annotations: { readOnlyHint: true },
})
export class SearchTicketsTool extends ToolContext {
  async execute({ query }: { query: string }) {
    const q = query.toLowerCase();
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(q)) };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The result arrives as structuredContent, and FrontMCP also includes it as text for clients that only read text. The readOnlyHint annotation tells clients this tool doesn't change anything. Clients can use hints like this to decide how carefully to confirm a call with the user.

Exposing data with resources and prompts

Tools are for actions the model decides to take. MCP has two other kinds of capability:

  • A resource is data the client application can read and attach to a conversation, addressed by a URI. @ResourceTemplate covers a whole family of URIs, like ticket://{id}.
  • A prompt is a reusable message template the user picks, often from a slash menu.
Open
import { Prompt, PromptContext, Resource, ResourceContext, ResourceTemplate } from "@frontmcp/sdk";

@Resource({ name: "sla", uri: "policy://sla", mimeType: "text/plain", description: "How fast we reply" })
export class SlaPolicy extends ResourceContext {
  async execute(uri: string) {
    return { contents: [{ uri, mimeType: "text/plain", text: "Reply to every ticket within 4 business hours." }] };
  }
}

@ResourceTemplate({ name: "ticket", uriTemplate: "ticket://{id}", mimeType: "application/json", description: "One ticket by id" })
export class TicketResource extends ResourceContext<{ id: string }> {
  async execute(uri: string, params: { id: string }) {
    const ticket = { id: params.id, title: "Cannot log in", status: "open" };
    return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] };
  }
}

@Prompt({
  name: "triage",
  description: "Triage a ticket",
  arguments: [{ name: "id", description: "Ticket id", required: true }],
})
export class TriagePrompt extends PromptContext {
  async execute(args: Record<string, string>) {
    return {
      messages: [{ role: "user" as const, content: { type: "text" as const, text: `Read ticket://${args.id}, then suggest a priority and a next step.` } }],
    };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Use the Request menu in the Call tab to read ticket://{id} with any id, or to get the triage prompt.

Grouping capabilities into an app and a server

So far the Playground has wrapped your exports in a server for you. In a real project you write that wiring yourself, in two layers:

  • An app (@App) groups related tools, resources, prompts and providers. Apps can have their own auth and settings.
  • The server (@FrontMcp) is the entry point. It lists your apps and holds server-wide settings such as info and http.
Open
import { FrontMcp } from "@frontmcp/sdk";
import { CalcApp } from "./calc.app";

@FrontMcp({
  info: { name: "Hello MCP", version: "0.1.0" },
  apps: [CalcApp],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

npx frontmcp create generates this same structure, with the tool in a tools/ folder. Open the Wire tab: the _meta.serverInfo in each response now reads Hello MCP, from info in main.ts.

Sharing a service with a provider

Tools often need the same thing: a database client, an API wrapper, a cache. Put it in a provider (@Provider), register it on the app, and get it inside any tool with this.get():

Open
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";

@Tool({ name: "count_open_tickets", description: "How many tickets are still open", inputSchema: {} })
export class CountOpenTickets extends ToolContext {
  async execute() {
    return this.get(TicketStore).open().length;
  }
}

@Tool({
  name: "close_ticket",
  description: "Close a ticket by id",
  inputSchema: { id: z.string() },
  annotations: { destructiveHint: true, idempotentHint: true },
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return this.get(TicketStore).close(id);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Call close_ticket with T-1, then call count_open_tickets again: the count drops from 2 to 1. Both tools share one TicketStore, because ProviderScope.GLOBAL creates one instance for the whole server. ProviderScope.CONTEXT would create one per request instead.

Next steps

You now know the pieces you'll use most: tools, schemas, structured results, resources, prompts, apps, servers and providers.