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:
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();
}Options
| Option | Type | Description |
|---|---|---|
info | { name, version, … } | Required. As on @FrontMcp. |
tools, resources, prompts | arrays | The entries to serve: classes, or tool(), resource() and prompt() results. |
providers, plugins, adapters | arrays | As on @App. |
agents, skills, authProviders | arrays | As on @App. |
jobDefinitions, workflowDefinitions | arrays | The app's jobs and workflows. Note the names: @App calls them jobs and workflows. |
jobs | { enabled, store? } | The jobs system itself, as on @FrontMcp. |
auth | AuthOptions | Set 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 option | As on @FrontMcp: fetch, authorities, instructions, ui, output, throttle, elicitation, pagination, logging, redis and the rest, except http and splitByApp. See below. | |
appName | string | The name of the app create() builds around your entries. Defaults to info.name. |
cacheKey | string | Return the same server for every create() with this key, until it's disposed. See below. |
machineId | string | Replaces 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. |
workerEnv | Record<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:
| Method | Returns |
|---|---|
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. |
ready | A 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:
| Option | Description |
|---|---|
authContext | Who 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: {} }. |
workerEnv | Bindings 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.
| Field | Becomes |
|---|---|
user | The 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". |
scopes | this.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. |
token | this.context.authInfo.token, which this.fetch() sends to the origins in fetch.forwardCallerTokenTo. |
sessionId | this.context.sessionId. By default each user gets their own, derived from the server's. |
extra | this.context.authInfo.extra. |
Two things differ from a real caller:
- Without
authContext, the caller is signed in, asdirect.this.auth.user.subis"direct"andthis.auth.isAnonymousisfalse, so a check that refuses anonymous callers lets the call through. Always pass a user. - Scopes come only from
authContext.scopes. A token'sscopeclaim becomes the caller's scopes over HTTP, butuser.scopeisn't read here: withoutscopes,this.auth.scopesis[].
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 this | In-process |
|---|---|
this.auth | The authContext user, or direct. |
this.context.traceContext | A new trace for every call, unless metadata names one with x-frontmcp-trace-id. |
this.context.metadata | The call's metadata, or { customHeaders: {} } without it. |
this.workerEnv | The call's workerEnv, else the server's, else undefined. |
this.clientInfo, this.platform | undefined 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:
| Failure | Rejects with |
|---|---|
The tool failed with this.fail(error) or threw a PublicMcpError | That error, code included. |
| Invalid arguments | InvalidInputError, code INVALID_INPUT, message Invalid tool input. getPublicMessage() lists the problems. |
| No such tool, resource or prompt | ToolNotFoundError (TOOL_NOT_FOUND), ResourceNotFoundError (RESOURCE_NOT_FOUND) or PromptNotFoundError (PROMPT_NOT_FOUND). |
| The server was disposed | InternalMcpError: 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()dropshttpandsplitByApp, 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
authContextmeans a signed-in user calleddirect, not an anonymous caller. machineIdhas no effect in a CommonJS project (seen with 1.9.4): set the id withsetMachineIdOverride()before the server starts instead (Machine ids).- Scopes come from
authContext.scopesonly, never from the user's claims. list_jobsandlist_workflowsaren't inlistTools(), butlistJobs()andlistWorkflows()call them anyway. A job's run belongs to the user who started it: check its status with the sameauthContext, or it'sRun "…" 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:
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:
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:
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:
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:
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():
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:
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:
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.
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:
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.