@Job

@Job declares a job: a piece of work with validated input that a client starts by name, with the built-in execute_job tool. A job can run while the client waits, or in the background while the client does something else, and FrontMCP can retry it when it fails. Jobs are also the steps of a @Workflow.

@Job(options)
class MyJob extends JobContext {
  async execute(input) { /* ... */ }
}

Reference

@Job(options)

Apply @Job to a class that extends JobContext, and list the class in an app's jobs array. FrontMCP then adds the tools that run jobs to the server; you don't write a tool for each job.

count-open.job.ts
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() },
  retry: { maxAttempts: 3, backoffMs: 500 },
})
export class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    this.log(`Counting open tickets for ${customer}`);
    const open = await countOpenTickets(customer);
    return { customer, open };
  }
}
help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CountOpenTickets } from "./count-open.job";

@App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets] })
export class HelpDeskApp {}

See more examples below.

Options

Required:

OptionTypeDescription
namestringWhat clients pass to execute_job, and what a workflow step's jobName refers to. Unique within the server. If you also set id, the id replaces it.
inputSchemaobject of Zod typesOne Zod type per input field, like { customer: z.string() }. execute_job checks the input against it before execute() runs.
outputSchemaobject of Zod types, or any output schema a tool acceptsThe shape of the result. TypeScript checks execute()'s return type against it, and FrontMCP checks the result when the job runs: fields it doesn't declare are dropped, and a result that doesn't match fails the run with INVALID_OUTPUT, without a retry. See Troubleshooting. (Changed in 1.9.2: before, the result wasn't checked at run time.)

Optional:

OptionTypeDefaultDescription
descriptionstringWhat the job does. list_jobs shows it.
idstringnameA stable identifier. When set, it replaces name: clients, list_jobs and workflow steps all use the id.
retryJobRetryConfignone: one attemptRun execute() again when it throws. See retry.
timeoutnumber300000 (5 minutes)How long the job may run, in milliseconds, as a workflow step. execute_job doesn't enforce it.
tagsstring[][]Labels to filter list_jobs by.
labelsRecord<string, string>{}Key-value labels to filter list_jobs by.
hideFromDiscoverybooleanfalseLeave the job out of list_jobs. It still runs by name.
permissionsJobPermission[]none: anyone may run itWho may run and list the job. See permissions.

retry

FieldTypeDefaultDescription
maxAttemptsnumber3Attempts in all, counting the first.
backoffMsnumber1000How long to wait before the second attempt, in milliseconds.
backoffMultipliernumber2Each wait is this many times longer than the one before.
maxBackoffMsnumber60000The longest any one wait can be.

The wait after attempt n is backoffMs × backoffMultiplier^(n−1), capped at maxBackoffMs. The defaults apply as soon as you set retry: retry: {} means three attempts, with waits of 1 and 2 seconds.

A job without retry runs once when a client calls execute_job, but three times, with the same waits, when it's a workflow step.

permissions

Each entry is a rule for one action:

FieldTypeDescription
action"execute" | "list" | "delete" | "create" | "read" | "update"What the rule guards. FrontMCP 1.9.4 checks execute (when execute_job starts a run, again before each attempt, and for a workflow step that runs the job), list (list_jobs) and delete (remove_job). The other three are accepted and never checked.
rolesstring[]The caller needs at least one of these, in their roles claim.
scopesstring[]The caller needs at least one of these, in their token's scope claim.
custom(authInfo) => boolean | Promise<boolean>Your own check. It receives the caller's authInfo, with the token's claims in authInfo.user. For execute, it runs when the run starts and again before each attempt: four times for a job that runs three times. (Changed in 1.9.4: before, once per run.)

A rule passes when each of its fields passes, and the caller needs every rule for the action. list_jobs leaves out jobs the caller can't list or can't run. A caller who isn't allowed to run a job gets the same error as for a job that doesn't exist, so they can't find out which jobs exist by guessing names. When the check before a later attempt refuses the caller, as a custom check can, the run fails at once, without a retry.

execute(input)

FrontMCP calls execute() once the input passes validation, and again for each retry.

  • input: the validated input. Fields with a .default() are filled in, and fields that aren't in inputSchema are dropped. The same object is this.input.
  • Returns the job's result: result in what execute_job returns, and outputs when the job is a workflow step, where later steps read it. Return an object.

To fail, throw, or call this.fail(). A failed attempt is retried if the job has retry, unless the result failed outputSchema.

JobContext

Inside execute(), this is the JobContext:

MemberDescription
this.log(message)Adds a line to the run's logs, prefixed with the time: [2026-09-27T09:00:00.000Z] message. Only the lines of the attempt that succeeded are kept, and a failed run's logs is empty, but every line also goes to the server's log.
this.inputThe validated input.
this.get(token), this.tryGet(token)Providers, from the job's app and from the server. See Using a provider. (Changed in 1.9.2: before, a job only saw the server's.)
this.fail(error)Ends the attempt with error. The message and code of a PublicMcpError reach the client as they are.
this.authThe caller who started the run, as in tools: user.sub, isAnonymous, roles, scopes.
this.callTool(name, args)Calls a tool of the server as the caller who started the run, on the "job" surface: a tool whose availableWhen.surface leaves out "job" answers Tool "…" not found. See this.callTool().
this.context, this.fetch()The request's context, as in a tool. When execute_job runs the job while the client waits, it's the caller's; in the background, it's a copy with the same session, caller and trace, and a request id of its own. this.fetch() sends the tracing headers, traceparent and x-request-id. See Calling another service from a job. Context classes compares every member. (Changed in 1.9.2: before, this.context threw in a job, and this.fetch() sent no tracing headers.)
this.workerEnvOn a Cloudflare Worker, the bindings of the request that ran execute_job, when it runs the job while the client waits. On a server from create(), the workerEnv of the executeJob() call, or else of the server, in the background too. undefined elsewhere. (Since 1.9; before, always undefined. Changed in 1.9.4: before, a server from create() had none.)
this.metadataThe job's options.
this.attemptThe attempt number, from 1: 2 on the first retry. See Retrying a job that fails. (Changed in 1.9.2: before, it was always 1.)
this.progress(progress, total?, message?)Meant to send a progress notification to the session that started the run. For the Playground's caller it sends nothing and returns false.
this.respond(value)Ends the attempt with value as the job's result, as returning it does, and value is checked against outputSchema the same way. (Changed in 1.9.2: before, value replaced the whole execute_job response.)

job(options)(handler)

The function form takes the same options. handler(input, ctx) receives the validated input and the JobContext. List the result in jobs like a class.

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

export const countOpenTickets = job({
  name: "count-open-tickets",
  inputSchema: { customer: z.string() },
  outputSchema: { customer: z.string(), open: z.number() },
})(async ({ customer }, ctx) => {
  return { customer, open: ctx.get(TicketStore).openFor(customer).length };
});

ctx.log() and ctx.progress() work in a handler. fail() is protected, so TypeScript rejects ctx.fail() with error TS2445: throw a PublicMcpError instead, which fails the attempt the same way. (Changed in 1.9.1: before, log() and progress() were protected too.)

JobHook

Each attempt of a job runs the jobs:execute-job flow: a run execute_job starts, inline or in the background, each retry, and each workflow step. JobHook hooks it, as ToolHook hooks tool calls, and FlowCtxOf<"jobs:execute-job"> types the hook's ctx. @frontmcp/sdk also exports the flow's class as the type ExecuteJobFlow. A hook runs once per attempt: three times for a job that succeeds on its third. (New in 1.9.4: before, jobs ran through no flow.)

StageGroupWhat it does
parseInputfirstSets job, attempt, runId, workflow, authInfo and input.
checkJobAuthorizationfirstChecks the job's execute permissions against the caller. A refusal fails the run with JOB_NOT_AUTHORIZED, without a retry.
validateInputfirstParses input with inputSchema into parsedInput.
createJobContextfirstBuilds the job's instance, the JobContext, as jobContext.
executeexecuteRuns execute().
validateOutputexecuteParses the result with outputSchema, and sets answer: { result, logs }.
updateRunStatealwaysRecords the attempt on the run: completed, retrying or failed.
finalizealwaysAnswers with answer.

ctx.state has:

FieldWhat it is
jobThe job: name, and metadata, its options.
attemptThe attempt number, from 1.
runIdThe run's id. undefined for a workflow step, whose workflow run keeps the record.
workflowFor a workflow step, { name, stepId, runId }: the workflow, the step's id and the workflow run's id. undefined otherwise.
authInfoThe caller who started the run.
input, parsedInputThe job's input as given, and once inputSchema has parsed it. A hook before validateInput can replace input.
jobContextThe job's instance, from createJobContext on.
answer{ result, logs }, from validateOutput on.
flowErrorIn the always stages, the error that failed the attempt.
  • Where hooks go. A hook on a plugin or a provider of an app runs for that app's jobs; on @FrontMcp, for every job. On a @Job class, it runs for that job's attempts only: as an instance method from createJobContext on, with the job's context as this, or as a static method on any stage, with the class as this. A hook for another flow on a @Job class stops the server from starting. See Hooking every attempt of a job.
  • A hook that throws fails the attempt, and the job's retry applies, unless the error is a JobNotAuthorizedError.
  • ctx.respond({ result, logs }) answers for the job, from a cache say: the stages that haven't run are skipped, execute() included, apart from the always group, and the run completes with result and logs. result isn't checked against outputSchema.

Built-in tools

When any app declares a job or a workflow, FrontMCP adds these tools to the server:

ToolIn tools/listArgumentsReturns
execute_jobYesname, input? (an object, default {}), background? (default false)Without background: waits for the job, then { runId, state, result, logs }. With background: true: { runId, state: "pending" } at once.
get_job_statusYesrunId{ runId, jobName, state, result?, error?, startedAt, completedAt?, attempt, logs }. Times are milliseconds since 1970. It also reads a workflow's run, whose result holds the step results. Only the caller who started the run can read it: see who can read a run.
list_jobsNoquery?, tags?, labels?{ jobs: [{ name, description?, tags, labels, inputSchema? }], count }. query matches the name or description; tags needs any one of them; labels needs all of them.
remove_jobNonameRemoves a job registered at run time with register_job. For a @Job class it fails with Job "…" is not a dynamic job and cannot be removed.

execute_workflow and get_workflow_status come with them; see @Workflow. Tools that aren't in tools/list can still be called by name.

A fifth job tool, register_job, registers a job from a script sent by the client. It exists only on servers that set jobs: { enabled: true, allowDynamicRegistration: true }, and isn't in tools/list there either. It runs code a client sent you, so leave it off unless you know you need it.

@FrontMcp({ jobs })

The server's jobs option controls the subsystem. You don't need it to use jobs.

OptionTypeDefaultDescription
enabledbooleanon when an app declares jobs or workflowsRequired when you set jobs. false leaves the tools out even when apps declare jobs; true adds them even when none do.
store{ redis?, keyPrefix? }memoryWhere runs are kept. By default, in the server's memory: they're lost on restart, and each server process has its own. redis keeps them in Redis for a day. It takes the server's redis fields, host, port, password, db and tls, or a url, and the fields beside a url only fill in what it leaves out: one that contradicts it stops the server at startup. (Changed in 1.9.3: before, only host and port reached Redis, and password, db and tls were dropped, those in a url too.) keyPrefix defaults to "mcp:jobs:".
allowDynamicRegistrationbooleanfalseAdds register_job and register_workflow.

The Playground always uses the memory store: Redis needs Node. The redis fields were checked in Node against a Redis that needs a password. Without the right password, the server doesn't start: NOAUTH Authentication required. when there's none, WRONGPASS invalid username-password pair when it's wrong. A field that contradicts the url stops it with a ZodError that names the field, like redis password contradicts redis.url.

Run states

StateMeaning
pendingCreated, not started yet. A background call returns this.
runningAn attempt is running.
retryingAn attempt failed, and the job is waiting before the next one.
completedresult holds the result.
failedEvery attempt failed. error holds the last error's message and name.

Who can read a run

A run belongs to the user who started it, identified by their sub. get_job_status answers only that user; everyone else gets Run "<id>" not found, as if the run didn't exist.

An anonymous caller on protocol 2026-07-28 gets a new identity on every request (anon: and a random id), so it can never read its own runs. That includes the Playground. To let clients poll background runs, authenticate them. The examples below poll as a signed-in user, in-process, with FrontMcpInstance.createDirect().

Caveats

  • The class must extend JobContext, and like @Tool, @Job checks when TypeScript compiles that execute()'s parameter matches inputSchema and its return type matches outputSchema.
  • Hooks run once per attempt. A JobHook sees every retry and every workflow step as an attempt of its own. Since 1.9.4: before, jobs ran through no flow, so a hook on the execute_job tool's call missed retries, background attempts and workflow steps, and a hook on a @Job class stopped the server from starting.
  • A result that doesn't match outputSchema isn't retried: the run fails at once, whatever retry says.
  • timeout is only enforced for workflow steps. A job started with execute_job runs until it finishes.
  • Bad input isn't an INVALID_INPUT error. execute_job fails with TOOL_EXECUTION_ERROR and the Zod issues (in production, only "Internal FrontMCP error"), and a job with retry retries it, waits included.
  • Runs live in memory unless you configure a Redis store: restarting the server loses them, and several processes each have their own.

Usage

Running a job and reading its result

Call execute_job with the job's name and its input. It waits for the job and returns the run: its id, its state, the result, and the lines the job logged.

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

const tickets = [
  { id: "T-1", customer: "Ada", status: "open" },
  { id: "T-2", customer: "Grace", status: "closed" },
  { id: "T-3", customer: "Ada", status: "open" },
];

@Job({
  name: "count-open-tickets",
  description: "Count a customer's open tickets",
  inputSchema: { customer: z.string().min(1) },
  outputSchema: { customer: z.string(), open: z.number() },
})
export class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    this.log(`Counting open tickets for ${customer}`);
    const open = tickets.filter((t) => t.customer === customer && t.status === "open").length;
    return { customer, open };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Open the Tests tab. The app declares no tools, yet the server has four: FrontMCP added them for the job.

Failing

A job fails when execute() throws, and execute_job returns an isError result. What the client reads depends on how the job failed:

Failures

Example 1 of 3

Bad input

Input that doesn't match inputSchema fails with TOOL_EXECUTION_ERROR, and the text lists each Zod issue with its path. In production, the client only gets "Internal FrontMCP error".

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

@Job({
  name: "count-open-tickets",
  inputSchema: { customer: z.string().min(1) },
  outputSchema: { customer: z.string(), open: z.number() },
})
export class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    return { customer, open: 2 };
  }
}

@App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets] })
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Retrying a job that fails

With retry, a failed attempt is tried again after a wait. Here the mail server is busy two times out of three, so the job succeeds on its third attempt, after waiting 100 and then 200 milliseconds:

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

@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}`);
    sendEmail(agent, `${ticket} needs you`);
    return { sent: true };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

If the last attempt fails too, the call fails with that attempt's error. this.attempt says which attempt is running. Only retry work that's safe to repeat: here, a second email is better than none, but a job that charges a card needs to know whether the first attempt went through.

Running a job in the background

With background: true, execute_job returns a runId at once, and the job runs after the call has returned. The caller that started it polls get_job_status with the runId until the run is completed or failed.

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

@Job({
  name: "export-tickets",
  description: "Export a customer's tickets as CSV",
  inputSchema: { customer: z.string() },
  outputSchema: { customer: z.string(), csv: z.string() },
})
export class ExportTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    this.log(`Exporting tickets for ${customer}`);
    await new Promise((resolve) => setTimeout(resolve, 100)); // stands in for a slow query
    return { customer, csv: "id,title\nT-1,Cannot log in\nT-3,Login link expired" };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Without the TC39 AsyncContext, FrontMCP's browser build serves one request at a time and runs a background job before the next one, so there the first poll already finds the run completed. The Playground provides a minimal AsyncContext, so here the run is still running at first, as on Node.

The Call tab shows the pending run. Calling get_job_status with its runId from the Call tab fails with "not found": the Playground's caller is anonymous, and nobody can read an anonymous caller's runs, not even that caller. The last test signs in as nour, who can, and sam, who can't. Running Jobs in the Background walks through polling.

Using a provider

A job gets providers with this.get(), from its app and from the server, as the app's tools do. Both get the same instance, so a tool and a job share data:

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

@Job({
  name: "count-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: this.get(TicketStore).openFor(customer).length };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A provider in @FrontMcp({ providers }) works too, and is shared with every app. A provider on another app isn't available to the job: see Troubleshooting. (Changed in 1.9.2: before, a job only saw the server's providers.)

Calling another service from a job

this.fetch() works in a job as in a tool, and sends the request's tracing headers, so the other service's logs tie its request to the call that started the job. A background run keeps the caller's trace:

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

@Job({
  name: "sync-customer",
  description: "Copy a customer's plan from the CRM",
  inputSchema: { customerId: z.string() },
  outputSchema: { customerId: z.string(), plan: z.string() },
})
export class SyncCustomer extends JobContext {
  async execute({ customerId }: { customerId: string }) {
    const response = await this.fetch(`https://crm.example/customers/${customerId}`);
    const { plan } = await response.json();
    return { customerId, plan };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

In the background, this.context is a copy of the starting call's: the same session, caller and trace, and a request id of its own, since the call it came from has already returned. (Changed in 1.9.2: before, a job had no request context, this.context threw, and this.fetch() sent no tracing headers.)

Restricting who can run a job

permissions limits a job to callers with a role, a scope, or anything a function can check. Anyone else gets the same error as for a job that doesn't exist, and list_jobs doesn't show them the job:

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

@Job({
  name: "export-invoices",
  description: "Export a month's invoices",
  inputSchema: { month: z.string().regex(/^\d{4}-\d\d$/) },
  outputSchema: { month: z.string(), invoices: z.number() },
  permissions: [{ action: "execute", roles: ["billing"] }],
})
export class ExportInvoices extends JobContext {
  async execute({ month }: { month: string }) {
    return { month, invoices: 42 };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Hooking every attempt of a job

A JobHook runs for each attempt of a job, so a plugin can audit, time or cache job runs the way it hooks tool calls. ctx.state.attempt tells the attempts apart, and ctx.state.flowError is set in the always stages when the attempt failed.

Job hooks

Example 1 of 3

Seeing each attempt

The plugin records how each attempt ended. The mail server is busy two times out of three, so one call makes three attempts:

Open
import { DynamicPlugin, FlowCtxOf, JobHook, Plugin } from "@frontmcp/sdk";

export const attempts: string[] = [];

@Plugin({ name: "attempt-log" })
export class AttemptLogPlugin extends DynamicPlugin<object> {
  @JobHook.Did("finalize")
  async record(ctx: FlowCtxOf<"jobs:execute-job">) {
    const { job, attempt, flowError } = ctx.state;
    attempts.push(`${job?.name} attempt ${attempt}: ${flowError ? flowError.message : "completed"}`);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A plugin or a provider on an app hooks that app's jobs only. To hook every job a server runs, register the plugin on @FrontMcp. Hooks covers Will, Did, Around and Stage, which work on JobHook as on every flow.

Writing a job as a function

For a small job that doesn't log, the function form is shorter. The handler gets the JobContext as its second argument, for ctx.get() and ctx.auth.

Open
import { App, job, z } from "@frontmcp/sdk";

export const shout = job({
  name: "shout",
  inputSchema: { text: z.string() },
  outputSchema: { text: z.string() },
})(async ({ text }) => {
  return { text: text.toUpperCase() };
});

@App({ id: "help-desk", name: "Help Desk", jobs: [shout] })
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.


Troubleshooting

Job or workflow "…" not found or not permitted

execute_job found no job by that name that the caller may run. Check, in order:

  1. The name is spelled as in @Job({ name }). If the job also sets id, use the id.
  2. The class is in an app's jobs array, and the app is in @FrontMcp({ apps }).
  3. The job's permissions let this caller run it. The error is the same on purpose, so a caller can't tell a job they may not run from one that doesn't exist. Call list_jobs as that caller to see what they can run.

Run "…" not found

get_job_status only answers the caller who started the run, and says "not found" to everyone else. An anonymous caller on protocol 2026-07-28, like the Playground's, can't read its own runs either, because each of its requests has a new identity. See who can read a run. Also check that the runId is from this server since its last restart: runs live in memory by default.

Provider "…" is not available in a job

A job resolves providers from its own app and from the server, so a provider registered on another app is missing:

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

@Provider({ name: "TicketStore" })
export class TicketStore {
  openFor(customer: string) {
    return customer === "Ada" ? ["T-1", "T-3"] : [];
  }
}

@Job({
  name: "count-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: this.get(TicketStore).openFor(customer).length };
  }
}

@App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets] })
export class HelpDeskApp {}

// 🚩 TicketStore is on another app
@App({ id: "tickets", name: "Tickets", providers: [TicketStore] })
export class TicketsApp {}

@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp, TicketsApp] })
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Register the provider on the job's app, or on the server with @FrontMcp({ providers }) to share it between apps, as in Using a provider. Before 1.9.2, a job didn't see its own app's providers either, so servers written for 1.9.1 often have them on @FrontMcp, where they still work.

Tool "execute_job" execution failed: [ followed by a list

The input didn't match the job's inputSchema. Each entry in the list has the field's path and a message. See Bad input. If the job has retry, the bad input was retried too, so the call took as long as every attempt and wait.

A job runs past its timeout

execute_job doesn't enforce timeout; only a workflow step does. A job started with execute_job runs until execute() returns:

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

@Job({ name: "export-tickets", inputSchema: {}, outputSchema: { rows: z.number() }, timeout: 50 })
export class ExportTickets extends JobContext {
  async execute() {
    await new Promise((resolve) => setTimeout(resolve, 150));
    return { rows: 3 };
  }
}

@App({ id: "help-desk", name: "Help Desk", jobs: [ExportTickets] })
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

If a job must stop, give the slow work inside execute() its own deadline.

Tool output validation failed (output does not match outputSchema at …)

The job returned something outputSchema doesn't describe, and the text names the first field that doesn't match. execute_job fails with INVALID_OUTPUT, a background run fails with InvalidOutputError in its error, and the job isn't retried, even with retry. Fields the schema doesn't declare are dropped instead:

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

export let attempts = 0;

// Stands in for rows from a database you don't control: "open" comes back as text.
const rows: Record<string, Record<string, unknown>> = {
  Ada: { open: "2", internalNote: "VIP" },
  Grace: { open: 0, internalNote: "Prefers email" },
};

@Job({ name: "count-open-tickets", inputSchema: { customer: z.string() }, outputSchema: { open: z.number() }, retry: { maxAttempts: 3, backoffMs: 10 } })
export class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    attempts += 1;
    return rows[customer] as { open: number };
  }
}

@App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets] })
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

TypeScript catches most mismatches when execute() returns a typed value. For data you don't control, build the result from the fields you mean to send, or parse it with the schema yourself, z.object({ open: z.number() }).parse(row), and fail with a PublicMcpError when it doesn't parse. (Changed in 1.9.2: before, FrontMCP didn't check the result, and sent it as it was.)

Job "…" declares hooks that would never run

The server doesn't start, with InvalidHookFlowError: a @Job class has an instance method hook on a stage before createJobContext, where the job's instance doesn't exist yet. The message names the method and the stage, and says to declare it as a static method. Make it static, and read what it needs from its ctx argument instead of this, as On the job class does. Job "…" has hooks for unsupported flows means a hook on the class is for another flow, like a ToolHook: move it to a plugin or a provider.

TypeScript says Unable to resolve signature of class decorator

@Job checks the class against its options, like @Tool. Error TS1238 on the decorator line says which check failed:

  • 'execute() parameter error': "Parameter type does not match the input schema.": execute()'s parameter type disagrees with inputSchema.
  • 'execute() return type error': "The method's return type is not assignable to the expected output schema type.": execute() returns something outputSchema doesn't describe.
  • 'Job class error': "Class must extend JobContext": the class doesn't extend JobContext.

A @Job without outputSchema doesn't compile either, and fails when the class is loaded with a Zod issue at the path outputSchema.