create() and createDirect()

create() and FrontMcpInstance.createDirect() build a server inside your process and hand you its tools, resources and prompts as plain async methods. There's no transport, no client and no protocol in between: callTool() runs the same checks, hooks and execute() as a call from a client, and returns the result, or throws the error. Use them in tests, scripts, scheduled jobs, and in code that embeds a server. create() takes a flat configuration with tools at the top level; createDirect() takes the one you give @FrontMcp.

const server = await create(config)
const server = await FrontMcpInstance.createDirect(config)

const result = await server.callTool(name, args?, { authContext? })
await server.dispose()

Reference

create(config)

Pass your server's name and its entries. create() puts them in one app and builds a server around it:

nightly.ts
import { create } from "@frontmcp/sdk";
import { CloseTicket } from "./close-ticket.tool";

const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [CloseTicket] });
try {
  const result = await server.callTool("close_ticket", { id: "T-1" }, { authContext: { user: { sub: "nightly-cleanup" } } });
  console.log(result.structuredContent);
} finally {
  await server.dispose();
}

See more examples below.

Options

OptionTypeDescription
info{ name, version, … }Required. As on @FrontMcp.
tools, resources, promptsarraysThe entries to serve: classes, or tool(), resource() and prompt() results.
providers, plugins, adaptersarraysAs on @App.
agents, skills, authProvidersarraysAs on @App.
jobDefinitions, workflowDefinitionsarraysThe app's jobs and workflows. Note the names: @App calls them jobs and workflows.
jobs{ enabled, store? }The jobs system itself, as on @FrontMcp.
authAuthOptionsSet on the app. In-process calls skip authentication, so even with mode: "static" a call without a token goes through, as direct.
Every other @FrontMcp optionAs on @FrontMcp: fetch, authorities, instructions, ui, output, throttle, elicitation, pagination, logging, redis and the rest, except http and splitByApp. See below.
appNamestringThe name of the app create() builds around your entries. Defaults to info.name.
cacheKeystringReturn the same server for every create() with this key, until it's disposed. See below.
machineIdstringReplaces FrontMCP's machine id, which session ids derive from, for the whole process, until this server is disposed. Keeps sessions stored in Redis valid across restarts.
workerEnvRecord<string, unknown>Platform bindings, like a Cloudflare Worker's env, that every call and every client from server.connect() hands to the code it runs, as this.workerEnv. A call's or a client's own workerEnv replaces it. See below. New in 1.9.4.

http and splitByApp are dropped: a server in your process has no HTTP endpoint, and create() builds one app. TypeScript rejects them in an object literal. (Changed in 1.9.3: before, create() also dropped fetch, authorities, instructions, ui and most other server options, without an error. Changed in 1.9.2: before, it dropped output and throttle too.)

FrontMcpInstance.createDirect(config)

Takes the configuration you give @FrontMcp, apps and all, and honours every option except http:

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

const server = await FrontMcpInstance.createDirect(config);
const billing = await FrontMcpInstance.createDirect(config, { app: "billing" });

workerEnv goes in the same object, as for create(): createDirect({ ...config, workerEnv: env }).

It needs apps: a create()-style configuration fails with Invalid input: expected array, received undefined at apps. It needs the configuration object, not the decorated class; pass getDecoratorConfig(Server) if all you have is the class.

It serves the server's main endpoint: every app that isn't standalone: true, or with splitByApp: true, the first app. The second argument, { app }, serves the endpoint an app has of its own instead: each app's with splitByApp, a standalone app's otherwise. An app without one rejects with ScopeConfigurationError, No endpoint serves app "…" on its own, naming the apps that have one. connect() takes the same option. New in 1.9.3: before, with splitByApp, only the first app could be served in-process.

The DirectMcpServer

Both return a DirectMcpServer, built and ready:

MethodReturns
listTools(options?){ tools }: every tool, however many pages tools/list splits them into. { paginate: true } returns one page and its nextCursor, and { cursor } the page after it. See below.
callTool(name, args?, options?)The tool's MCP result: content, and structuredContent for object results. Throws when the call fails.
listResources(options?){ resources }, every page; paginate and cursor as for listTools().
listResourceTemplates(options?){ resourceTemplates }, the same.
readResource(uri, options?){ contents }.
listPrompts(options?){ prompts }, the same.
getPrompt(name, args?, options?){ description, messages }.
listJobs(options?), listWorkflows(options?)The result of the list_jobs or list_workflows tool: the list is JSON in content[0].text.
executeJob(name, input?, options?), executeWorkflow(name, input?, options?)The execute_job or execute_workflow result. structuredContent has runId, state and, once done, result. With options.background: true, the run goes on in the background.
getJobStatus(runId, options?), getWorkflowStatus(runId, options?)The run's status, in structuredContent.
connect(sessionIdOrOptions?)A DirectClient connected to this server, for code that wants an MCP client. Closing it leaves the server running, for its other clients too; only dispose() ends it. (Changed in 1.9.3: before, closing one disposed part of the server, so registerTool() failed and the other clients stopped getting notifications.)
registerTool(definition)Adds a tool while the server runs, and returns a function that removes it. New in 1.9: see registerTool().
dispose()Releases the server. See below.
readyA promise that has already resolved.

The job and workflow methods call the built-in tools that run jobs, so they return tool results, not parsed objects.

Call options

Every method takes options last:

OptionDescription
authContextWho is calling. See below.
metadata{ userAgent?, clientIp?, customHeaders? }, which becomes this.context.metadata, as an HTTP request's headers would. Only x-frontmcp-* headers are kept in customHeaders, and clientIp only if it's an IP address. An x-frontmcp-trace-id header with a 32-character hex trace id continues that trace. New in 1.9.2: before, tools always saw { customHeaders: {} }.
workerEnvBindings for this call only, read as this.workerEnv by the tool, resource, prompt, job or agent it runs. They replace the server's workerEnv rather than adding to it. New in 1.9.4.

Who is calling

authContext stands in for what authentication would have established. Nothing checks it: in-process, your code is the authentication, so pass only a user your code has verified.

FieldBecomes
userThe caller's claims. user.sub is this.auth.user.sub; roles, name and email are read as from a token, so this.auth.hasRole() works. The claims also get iss: "direct".
scopesthis.auth.scopes and this.context.authInfo.scopes, so this.auth.hasScope() works. A scope claim in user isn't read. New in 1.9.2: before, in-process calls had no scopes.
tokenthis.context.authInfo.token, which this.fetch() sends to the origins in fetch.forwardCallerTokenTo.
sessionIdthis.context.sessionId. By default each user gets their own, derived from the server's.
extrathis.context.authInfo.extra.

Two things differ from a real caller:

  • Without authContext, the caller is signed in, as direct. this.auth.user.sub is "direct" and this.auth.isAnonymous is false, so a check that refuses anonymous callers lets the call through. Always pass a user.
  • Scopes come only from authContext.scopes. A token's scope claim becomes the caller's scopes over HTTP, but user.scope isn't read here: without scopes, this.auth.scopes is [].

Each call gets a this.context of its own, with its own requestId and caller, so one server can serve many users at once.

What a tool sees

On thisIn-process
this.authThe authContext user, or direct.
this.context.traceContextA new trace for every call, unless metadata names one with x-frontmcp-trace-id.
this.context.metadataThe call's metadata, or { customHeaders: {} } without it.
this.workerEnvThe call's workerEnv, else the server's, else undefined.
this.clientInfo, this.platformundefined and "unknown": there's no client.
this.elicit()Can't ask anyone. With elicitation turned on, the call returns FrontMCP's fallback result, "This tool requires user input to continue", instead of the answer.
this.progress(), this.notify()Send nothing, and return false.

Errors

A failed call rejects instead of returning a result with isError:

FailureRejects with
The tool failed with this.fail(error) or threw a PublicMcpErrorThat error, code included.
Invalid argumentsInvalidInputError, code INVALID_INPUT, message Invalid tool input. getPublicMessage() lists the problems.
No such tool, resource or promptToolNotFoundError (TOOL_NOT_FOUND), ResourceNotFoundError (RESOURCE_NOT_FOUND) or PromptNotFoundError (PROMPT_NOT_FOUND).
The server was disposedInternalMcpError: DirectMcpServer has been disposed.

Wrap calls in try when one failure shouldn't stop your code.

Disposing

dispose() shuts the whole server down: every endpoint is disposed, those it doesn't serve included, and their onDispose callbacks run. Every call after it rejects with DirectMcpServer has been disposed, and a second dispose() does nothing. (Changed in 1.9.3: before, it disposed only the endpoint it served.) It also removes the server from the cacheKey cache and undoes machineId. Call it when you're done, in a finally, so a failed call doesn't leave a server behind. clearCreateCache() empties the cacheKey cache without disposing anything.

Caveats

  • create() drops http and splitByApp, and takes every other server option.
  • Failures throw. Code that expects MCP results, with isError, gets exceptions instead. For MCP results, use a client.
  • No authContext means a signed-in user called direct, not an anonymous caller.
  • machineId has no effect in a CommonJS project (seen with 1.9.4): set the id with setMachineIdOverride() before the server starts instead (Machine ids).
  • Scopes come from authContext.scopes only, never from the user's claims.
  • list_jobs and list_workflows aren't in listTools(), but listJobs() and listWorkflows() call them anyway. A job's run belongs to the user who started it: check its status with the same authContext, or it's Run "…" not found.

Usage

Calling a tool as a signed-in user

A tool that decides from this.auth needs a caller. Name one in authContext, with the claims your tool reads:

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

@Tool({
  name: "reassign_ticket",
  description: "Move a ticket to another agent. Team leads only.",
  inputSchema: { id: z.string(), to: z.string() },
})
export class ReassignTicket extends ToolContext {
  async execute({ id, to }: { id: string; to: string }) {
    if (!this.auth.hasRole("lead")) this.fail(new PublicMcpError("Only team leads can reassign tickets.", "LEADS_ONLY"));
    return { id, assignee: to, by: this.auth.user.sub };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

What a tool sees in-process

This tool reports what it knows about its call. The tests show the direct user FrontMCP fills in without authContext, what authContext and metadata pass through, and what's missing because there's no client:

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

@Tool({ name: "describe_call", description: "Describe the caller and the request. For debugging.", inputSchema: {} })
export class DescribeCall extends ToolContext {
  async execute() {
    return {
      user: this.auth.user.sub,
      anonymous: this.auth.isAnonymous,
      roles: this.auth.roles,
      scopes: this.auth.scopes,
      canWrite: this.auth.hasScope("tickets:write"),
      token: this.context.authInfo.token ?? null,
      extra: this.context.authInfo.extra ?? null,
      metadata: this.context.metadata,
      traceId: this.context.traceContext.traceId,
      client: this.clientInfo ?? null,
      platform: this.platform,
      progressSent: await this.progress(1, 2),
    };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The Playground's own call goes through its client, so there the caller is an anonymous anon: user and the client is the Playground.

Handling failures

A failed call rejects with the error the tool failed with, so its code tells you why. Give each call its own try, so one failure doesn't stop the rest:

Open
import { create } from "@frontmcp/sdk";
import { CloseTicket } from "./close-ticket.tool";

export async function closeAll(ids: string[]) {
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [CloseTicket] });
  const closed: string[] = [];
  const failed: { id: string; code: string }[] = [];
  try {
    for (const id of ids) {
      try {
        await server.callTool("close_ticket", { id }, { authContext: { user: { sub: "nightly-cleanup" } } });
        closed.push(id);
      } catch (err) {
        failed.push({ id, code: (err as { code?: string }).code ?? "UNKNOWN" });
      }
    }
  } finally {
    await server.dispose();
  }
  return { closed, failed };
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Reading resources and prompts

Resources and prompts work the same way. Results are what a client would get:

Open
import { Prompt, PromptContext, Resource, ResourceContext } from "@frontmcp/sdk";

@Resource({ uri: "tickets://open", name: "open-tickets", description: "Open tickets", mimeType: "application/json" })
export class OpenTickets extends ResourceContext {
  async execute() {
    return { tickets: [{ id: "T-1", title: "Cannot log in" }] };
  }
}

@Prompt({ name: "triage", description: "Triage a ticket", arguments: [{ name: "id", required: true }] })
export class Triage extends PromptContext {
  async execute({ id }: { id: string }) {
    return { messages: [{ role: "user" as const, content: { type: "text" as const, text: `Triage ticket ${id}. Say how urgent it is.` } }] };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Keeping the server's options

create() takes the server options @FrontMcp takes. Here the server lets results contain NaN, with output: { allowNonFinite: true }, limits each tool to one call a minute with throttle, and defines the admin profile that purge_closed's authorities: "admin" names. (A client would see the NaN as null, as the text block shows. In-process, structuredContent is the object itself, never serialized, so it's still NaN.) createDirect() takes the same options, with the entries in an app:

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

@Tool({ name: "average_reply_hours", description: "Average hours to first reply this week", inputSchema: {} })
export class AverageReplyHours extends ToolContext {
  async execute() {
    const replies: number[] = [];
    return { hours: replies.reduce((a, b) => a + b, 0) / replies.length }; // 0 / 0 is NaN
  }
}

// Stops a server without `authorities` from starting, so it's made in the tests, not served by the Playground.
export const purgeClosed = () => {
  @Tool({ name: "purge_closed", description: "Delete closed tickets, for admins", inputSchema: {}, authorities: "admin" })
  class PurgeClosed extends ToolContext {
    async execute() {
      return { purged: 31 };
    }
  }
  return PurgeClosed;
};

export const info = { name: "help-desk", version: "1.0.0" };
export const output = { allowNonFinite: true };
export const throttle = { enabled: true, defaultRateLimit: { maxRequests: 1, windowMs: 60_000 } };
export const authorities = { profiles: { admin: { roles: { any: ["admin"] } } } };

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Listing more than one page of tools

tools/list returns 40 tools at a time once a server has more than 40, with a nextCursor for the rest (see pagination). listTools() follows nextCursor to the end and returns every tool. To page through the list as a client does, pass { paginate: true } for the first page and { cursor } for each one after. A DirectClient from server.connect() reads every page too, with its listTools(), listResources(), listResourceTemplates() and listPrompts():

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

function numberedTool(n: number) {
  @Tool({ name: `tool_${n}`, description: `Tool number ${n}`, inputSchema: {} })
  class NumberedTool extends ToolContext {
    async execute() {
      return { n };
    }
  }
  return NumberedTool;
}

export const numbered = (count: number) => Array.from({ length: count }, (_, i) => numberedTool(i + 1));

@Tool({ name: "ping", description: "Check that the server is up", inputSchema: {} })
export class Ping extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Changed in 1.9: before, listTools() returned only the first page, and its nextCursor couldn't be passed back. New in 1.8.7: listResources(), listResourceTemplates() and listPrompts() of a DirectClient follow every page too. Before, they returned one; listTools() already followed them. The server's pagination option covers tools only, and 45 resources or 45 prompts come back in one list, so with your own entries this changes nothing you can see.

Running jobs

With jobDefinitions, the server has jobs, and the job methods run them. A run belongs to the user who started it, so ask for its status as the same user:

Open
import { Job, JobContext, z } from "@frontmcp/sdk";

@Job({
  name: "count-open-tickets",
  description: "Count a customer's open tickets",
  inputSchema: { customer: z.string() },
  outputSchema: { customer: z.string(), open: z.number() },
})
export class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    return { customer, open: customer === "acme" ? 3 : 0 };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Passing a Worker's bindings

On Cloudflare, a server you build with create() in a Durable Object, a queue consumer or a route of your own gets no request from createFetchHandler(), so nothing hands its tools the Worker's env. Pass it as workerEnv: on create() or createDirect() for every call, on one call for that call, or on server.connect() for one client. Tools, resources, prompts, jobs and agents read it as this.workerEnv. Here a stand-in KV namespace plays the Worker's:

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

type Env = { DESK?: string; TICKETS?: { get(key: string): Promise<string | null> } };

@Tool({ name: "get_ticket", description: "Get one support ticket from the desk's KV namespace", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const env = this.workerEnv as Env | undefined;
    return { id, title: env?.TICKETS ? await env.TICKETS.get(id) : null, desk: env?.DESK ?? null };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A call's or a client's bindings replace the server's: they aren't merged, so the Lisbon call has no TICKETS. They live in that request's context only, and FrontMCP never copies them into process.env. A job that executeJob() runs reads them too, in the background as well. connect() takes workerEnv the same way. New in 1.9.4: before, this.workerEnv was undefined in every in-process call.

Reusing one server

Building a server takes a moment. When many parts of a process need the same one, like each test in a file, give them a cacheKey: every create() with that key gets the same server until it's disposed.

Open
import { create } from "@frontmcp/sdk";
import { Ping } from "./ping.tool";

export function helpDesk() {
  return create({ info: { name: "help-desk", version: "1.0.0" }, tools: [Ping], cacheKey: "help-desk" });
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The key is the only thing compared: a second create() with the same key and different tools, or a different workerEnv, still gets the first server, with the first one's workerEnv. Pass bindings that change from call to call with each call's workerEnv.


Troubleshooting

DirectMcpServer has been disposed

Something called the server after dispose(). Often a shared server with a cacheKey was disposed by one part of the code while another still used it; dispose a shared server once, when everything is done with it.

Invalid input: expected array, received undefined

The error's path is apps. You passed a create()-style configuration, with tools at the top level, to createDirect() or connect(), which need apps. Either call create(), or put the tools in an @App and list it in apps.

A tool that refuses anonymous callers lets my script through

Without authContext, the caller is a signed-in user called direct. Pass the user the script acts for: { authContext: { user: { sub: "nightly-cleanup" } } }.

this.auth.hasScope() is always false

The call passed no authContext.scopes. In-process, a caller's scopes come only from there: a scope claim in authContext.user isn't read. Pass { authContext: { user: { sub: "nour" }, scopes: ["tickets:write"] } }, as in What a tool sees in-process.

Authorities configuration required

The full message starts Authorities configuration required: Tool "purge_closed" declare 'authorities' metadata but authorities enforcement is not fully configured. A tool has authorities, and the server has no authorities option. Pass the profiles it names, as in Keeping the server's options. Before 1.9.3, create() dropped authorities, so a server built with it always failed this way.

Run "…" not found

getJobStatus() was called as a different user from the one that started the run, or without authContext after starting it with one. Pass the same authContext to both.

A tool that asks the user returns "This tool requires user input to continue"

In-process there's no client to show a form, so this.elicit() can't ask. With elicitation: { enabled: true }, the call returns FrontMCP's fallback result instead, meant for clients without forms, and never gets an answer:

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

@Tool({ name: "close_ticket", description: "Close a ticket once the user confirms", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
    return { id, status: answer.status === "accept" && answer.content?.confirm ? "closed" : "open" };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The fallback can still be answered in-process: call sendElicitationResult with the elicitId from the result's _meta.elicitationPending, as the same user, and FrontMCP runs the tool again as that user, with this.auth (since 1.8.4). this.elicit() shows it with createDirect().

Test tools that ask the user through the @frontmcp/testing client, which answers with mcp.onElicitation(), or split the question from the work: a tool that takes the confirmation as an argument can be called in-process.