Running FrontMCP Anywhere

Advanced

So far, @FrontMcp and the frontmcp CLI have decided how your server runs: frontmcp dev starts an HTTP server, and frontmcp build packages the server for a target like Node or Cloudflare Workers. Underneath are a few functions you can call yourself, for when you need to own a part FrontMCP usually owns: the HTTP layer, or the client. Most servers never need them.

You will learn

  • What @FrontMcp does for you, and which entry points sit under it
  • How to serve MCP from any runtime that speaks Request and Response
  • How to call your tools in-process with connect() and createDirect()
  • What changes when you skip the transport: identity, errors and formatting

What @FrontMcp does for you

@FrontMcp({ info, apps }) records your server's configuration and, on Node, starts an HTTP server as soon as the decorated class is defined. The CLI's serverless builds switch that off and wrap the same configuration in the entry point their target needs. When you need something else, these are the pieces to build from:

You want toUseYou get
Serve MCP over HTTP from Node@FrontMcp, with frontmcp dev and frontmcp buildA running server
Serve MCP from a runtime that hands you a web Request: Cloudflare Workers, Deno, Bun, or a route in another frameworkFrontMcpInstance.createFetchHandler(config)(request: Request) => Promise<Response>
Call your tools from your own code, through an MCP clientconnect(config, options), or connectClaude(), connectOpenAI(), connectLangChain(), connectVercelAI()A DirectClient
Call your tools from your own code, without a clientFrontMcpInstance.createDirect(config), or create() for a config without appsA DirectMcpServer

FrontMcpInstance.runStdio() and runUnixSocket() serve local clients over stdio or a Unix socket. They need Node and aren't covered here.

All of them take the configuration you'd give @FrontMcp. Keep it in its own module, and decorate a class with it only in the file the CLI runs, because importing a decorated class starts a server:

config.ts
import { HelpDesk } from "./help-desk.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { entryPath: "/mcp" },
};
main.ts
import "reflect-metadata";
import { FrontMcp } from "@frontmcp/sdk";
import { config } from "./config";

@FrontMcp(config)
export default class HelpDeskServer {}

A worker, a script or an agent imports config.ts and never touches main.ts.

Serving MCP from any runtime

FrontMcpInstance.createFetchHandler(config) returns a function that takes a web Request and returns a Response, and knows nothing else about where it runs. This is a complete Cloudflare Worker, and the test sends it the same requests Cloudflare would:

Open
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./config";

const handler = FrontMcpInstance.createFetchHandler(config);

export default {
  async fetch(request: Request): Promise<Response> {
    return (await handler)(request);
  },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

createFetchHandler() takes the same configuration as @FrontMcp. The handler serves MCP at http.entryPath, or at / if you don't set one, answers /healthz and /readyz everywhere, and returns 404 for anything else. It returns a promise of the handler, and builds the server when you call it, so a server that can't start fails there. On an edge isolate like a Worker, which can't build a server while the module loads, it builds on the first request instead: see createFetchHandler().

This site's Playground runs on the same function. It builds a fetch handler from each example and sends it requests from a Web Worker in your browser, and the Wire tab shows those requests. In a project, deploy the Worker like any other; FrontMCP's CLI also has a Cloudflare build target that writes this entry file for you. Any other runtime or framework that gives you a Request and takes a Response back can call handler(request) the same way.

Behind createFetchHandler(), your tools see the request much as they would behind FrontMCP's own server: a traceparent header continues the client's trace, and this.context.metadata has the user agent and the x-frontmcp-* headers. A deep dive in Reading the Request tests it.

Calling your tools through a client: connect()

Sometimes your own code is the MCP client: an agent loop that calls a model's API directly, a command-line tool, a job. connect(config, options) builds the server in your process and connects a client to it in memory. It goes through the same handshake and requests as a client over HTTP, without a network.

The connectClaude(), connectOpenAI(), connectLangChain() and connectVercelAI() variants also convert tools and results to the shape each API or library expects:

Open
import { test, expect } from "@frontmcp/testing";
import { ToolCallError, connectClaude } from "@frontmcp/sdk";
import { config } from "./config";

test("tools come in the shape Claude's Messages API takes", async () => {
  const client = await connectClaude(config);
  const tools = await client.listTools();
  await client.close();
  expect(tools).toEqual([
    {
      name: "get_ticket",
      description: "Get one support ticket by its id.",
      input_schema: expect.objectContaining({ type: "object", required: ["id"] }),
    },
  ]);
});

test("results come back as content blocks", async () => {
  const client = await connectClaude(config);
  const result = await client.callTool("get_ticket", { id: "T-1" });
  await client.close();
  expect(result).toEqual([{ type: "text", text: '{"id":"T-1","title":"Cannot log in","status":"open"}' }]);
});

test("a failed call rejects with a ToolCallError", async () => {
  const client = await connectClaude(config);
  const error = await client.callTool("get_ticket", { id: "T-9" }).catch((err: unknown) => err);
  await client.close();
  expect(error).toBeInstanceOf(ToolCallError);
  expect((error as ToolCallError).message).toBe("There's no ticket T-9.");
  expect((error as ToolCallError).result.content).toEqual([{ type: "text", text: "There's no ticket T-9." }]);
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

listTools() returns tools with input_schema, ready to pass to Claude, and callTool() returns the result's content blocks, ready for a tool_result. Claude's format has no place for MCP's isError, so a failed call doesn't return at all: it rejects with a ToolCallError, whose message is the tool's error text and whose result is the full MCP result. An agent that doesn't catch it stops at the first failed call, when the model could have tried another id. The second challenge catches it and sends the failure back as a tool_result with is_error: true. Plain connect() returns the full MCP result instead, isError included, for code that wants to convert it itself.

connect() also takes who the client is and who the user is:

const client = await connect(config, {
  clientInfo: { name: "desk-agent", version: "1.0.0" },
  authToken: userToken,
  session: { user: { sub: "nour" }, scopes: ["tickets:write"] },
});

clientInfo becomes this.clientInfo in your tools, session.user becomes the caller in this.auth, session.scopes the caller's scopes, so this.auth.hasScope("tickets:write") is true (since 1.9.3), and authToken becomes the request's token, which this.fetch() keeps to itself. (In FrontMCP 1.9.4, connect() leaves this.context.authInfo.clientId empty, so read the caller from this.auth, as this course's tools do.) Nothing checks the user, the scopes or the token: in-process, your code is the authentication, so only pass a user your own code has verified.

Calling your tools without a client: createDirect()

A script or a job often doesn't need a client at all, just a way to run a tool. FrontMcpInstance.createDirect(config) returns a DirectMcpServer with listTools(), callTool(), listResources(), readResource(), listPrompts(), getPrompt() and dispose(). create() does the same from a flat configuration with tools and providers at the top level and no @App, which is what the tests in Reading the Request use.

Its results are plain MCP results, and each call can say who the user is with authContext: authContext.user stands in for the claims of a verified token, so its sub becomes this.auth.user.sub and its roles become this.auth.roles. Here a nightly job closes tickets as the user nightly-cleanup:

Open
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance, connect } from "@frontmcp/sdk";
import { config } from "./config";

const asJob = { authContext: { user: { sub: "nightly-cleanup" } } };

test("a job can call tools as a named user", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  const result = await server.callTool("close_ticket", { id: "T-1" }, asJob);
  await server.dispose();
  expect(result.structuredContent).toEqual({ id: "T-1", status: "closed", closed_by: "nightly-cleanup" });
});

test("a failed call throws, instead of returning isError", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  await expect(server.callTool("close_ticket", { id: "T-9" }, asJob)).rejects.toThrow("There's no ticket T-9.");
  await expect(server.callTool("close_ticket", { id: 9 }, asJob)).rejects.toThrow("Invalid tool input");
  await server.dispose();
});

test("🚩 without authContext, the caller is `direct`, not anonymous", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  const result = await server.callTool("close_ticket", { id: "T-2" });
  await server.dispose();
  expect(result.structuredContent).toEqual({ id: "T-2", status: "closed", closed_by: "direct" });
});

test("one server, several users: each call runs as the user it names", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  const ana = await server.callTool("close_ticket", { id: "T-1" }, { authContext: { user: { sub: "ana" } } });
  const bo = await server.callTool("close_ticket", { id: "T-2" }, { authContext: { user: { sub: "bo" } } });
  const job = await server.callTool("close_ticket", { id: "T-1" }, asJob);
  await server.dispose();
  expect(ana.structuredContent).toMatchObject({ closed_by: "ana" });
  expect(bo.structuredContent).toMatchObject({ closed_by: "bo" });
  expect(job.structuredContent).toMatchObject({ closed_by: "nightly-cleanup" });
});

test("connect() takes the user from `session.user`", async () => {
  const client = await connect(config, { session: { user: { sub: "nightly-cleanup" } } });
  const result = await client.callTool("close_ticket", { id: "T-2" });
  await client.close();
  expect(result).toMatchObject({ structuredContent: { closed_by: "nightly-cleanup" } });
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Two things work differently from a client, and the tests show both:

  • Failures are exceptions. Over MCP, a tool that fails returns a result with isError: true. callTool() here rejects instead, with the PublicMcpError the tool failed with, code and all, or an InvalidInputError for bad arguments. Wrap calls in try when one failure shouldn't stop the job.
  • No authContext isn't anonymous. Without it, the caller is a signed-in user called direct: this.auth.user.sub is "direct" and isAnonymous is false, so a tool's check for anonymous callers lets the call through. Always name the user, even for jobs.

One server can serve many users. Each call runs as the user its authContext names, and gets a this.context of its own, with its own requestId and authInfo. The fourth test closes tickets as Ana, Bo and the job on one server, and each ticket records the right user. (In FrontMCP 1.8.0, calls without authContext.sessionId shared one context, so Bo's call could run as Ana. On 1.8.0, give each user their own sessionId.)

A server you build this way inside a Cloudflare Worker, in a Durable Object or a queue consumer, gets no request from a fetch handler, so it doesn't see the Worker's env by itself. Pass it as workerEnv, in createDirect()'s configuration, on one call, or in connect()'s options, and your tools read it as this.workerEnv (since 1.9.4). Passing a Worker's bindings shows it.

Recap

  • @FrontMcp records your configuration and starts a server on import. Keep the configuration in its own module, so other entry points can use it without starting one.
  • FrontMcpInstance.createFetchHandler(config) turns a server into (request) => Promise<Response>, for Workers and any other web-standard runtime. It serves http.entryPath, /healthz and /readyz.
  • connect() connects an MCP client in memory. The platform variants convert tools and results for an LLM API, and reject a failed call with a ToolCallError that carries the result.
  • createDirect() and create() run tools with no client: results are MCP results, and failures are thrown.
  • In-process callers say who the user is and nothing checks it. Pass only users your code has verified, and always pass one.

Try some challenges

Challenge 1 of 3

Serve MCP at /mcp

The help desk's MCP clients are set up with https://desk.example.com/mcp, and this worker answers every one of their requests with a 404. Open the Tests tab, then fix the worker so that MCP requests to /mcp work.

Open
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./config";

const handler = FrontMcpInstance.createFetchHandler(config);

export default {
  async fetch(request: Request): Promise<Response> {
    return (await handler)(request);
  },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.