Your First Job

IntermediateMCP 2026-07-28

Every tool so far has done its work inside one call and answered. A job is work you hand to FrontMCP instead: a named piece of work with an input schema and an output schema, which FrontMCP runs when a client asks, records as a run, and can retry, run in the background, or chain with other jobs. This lesson writes one, runs it the way a client would, and looks at everything that comes back, including what happens when the input is wrong or the job fails.

You will learn

  • How to write a job with @Job and JobContext, and register it on an app
  • Which tools FrontMCP adds for jobs, and how a client runs one with execute_job
  • What a run's result holds: runId, state, result and logs
  • What the caller gets back when the input is wrong or the job throws
  • When to write a job, and when a tool is the better choice

Writing a job

The help desk promises to answer high-priority tickets within 4 hours and normal ones within a day. Finding the open tickets that broke that promise reads every open ticket, it should run every night whether or not anyone is chatting with a model, and later in this chapter it becomes one step of a nightly report. That's work for a job. Open the Tests tab:

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

const SLA_HOURS = { high: 4, normal: 24 };

@Job({
  name: "find-sla-breaches",
  description: "Find the open tickets of one priority that have waited longer than their SLA allows.",
  inputSchema: {
    priority: z.enum(["high", "normal"]).describe("Which tickets to check"),
  },
  outputSchema: {
    priority: z.enum(["high", "normal"]),
    breaches: z.array(z.object({ id: z.string(), hoursOpen: z.number() })),
  },
})
export class FindSlaBreaches extends JobContext {
  async execute({ priority }: { priority: "high" | "normal" }) {
    const open = tickets.filter((t) => t.status === "open" && t.priority === priority);
    this.log(`Checking ${open.length} open ${priority}-priority tickets`);

    const breaches = open
      .filter((t) => t.hoursOpen > SLA_HOURS[priority])
      .map((t) => ({ id: t.id, hoursOpen: t.hoursOpen }));
    this.log(`Found ${breaches.length} past the ${SLA_HOURS[priority]}-hour SLA`);

    return { priority, breaches };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A job looks a lot like a tool:

  • @Job({ name, description, inputSchema, outputSchema }) describes it. name is what a client asks for. inputSchema and outputSchema are Zod shapes, as on a tool, and both are required: a job without an outputSchema fails as soon as its class is decorated.
  • JobContext is the base class, and execute(input) does the work. It gets the input already checked against inputSchema, and what it returns is the job's result.
  • this.log(message) adds a line to the run's logs, with the time in front.
  • jobs: [FindSlaBreaches] on @App registers it. That's all it takes: FrontMCP turns jobs on for any server with an app that has jobs or workflows.

How a client runs a job

The job isn't a tool, and it doesn't show up in tools/list. Open the Capabilities tab of the example above: registering one job added four tools of FrontMCP's own, and the call in the Call tab went through the first of them.

ToolWhat it does
execute_jobRuns the job called name with input, and returns the run. background: true starts it and returns at once, which the next lesson covers.
get_job_statusReads a run by its runId.
execute_workflowRuns a workflow, a chain of jobs. See Chaining Jobs into Workflows.
get_workflow_statusReads a workflow's run.

All four appear as soon as one app has a job, even if there are no workflows. FrontMCP also adds list_jobs, list_workflows, remove_job and remove_workflow, but leaves them out of tools/list. They still answer when a client calls them by name.

That leaves a model with a problem. It sees execute_job, described as "Execute a registered job by name", and nothing that says which names exist or what input they take. Tell it, in the server's instructions:

Open
import { App, FrontMcp } from "@frontmcp/sdk";
import { FindSlaBreaches } from "./find-sla-breaches.job";

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  instructions:
    "Help desk jobs, run with execute_job. " +
    'find-sla-breaches, input { priority: "high" | "normal" }: lists open tickets that have waited longer than their SLA.',
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Clients read instructions from server/discover and usually add them to the model's context. list_jobs returns each job's name, description and input schema, but a model only calls a tool it has heard of, so don't rely on it.

From here on, examples that don't need a server of their own leave out main.ts. When an example exports jobs but no app, the Playground registers them in one for you, as it does tools.

What comes back

execute_job runs the job and answers when it's finished, with the run as structuredContent and as JSON text:

{
  "runId": "5b0e3c1e-8a47-4c1e-9d0a-1f6c2e7b9a30",
  "state": "completed",
  "result": { "priority": "high", "breaches": [{ "id": "T-1", "hoursOpen": 6 }] },
  "logs": [
    "[2026-09-27T09:00:00.123Z] Checking 2 open high-priority tickets",
    "[2026-09-27T09:00:00.124Z] Found 1 past the 4-hour SLA"
  ]
}
  • runId names this run, and it's new every time. It's what get_job_status takes.
  • state is "completed". A run that fails doesn't come back as a run at all, but as an error, below.
  • result is what execute() returned, checked against outputSchema.
  • logs are the this.log() lines, each starting with the time it was written in ISO format.

When the input is wrong

execute_job's own input schema takes any object as input. FrontMCP checks it against the job's inputSchema when it runs the job, and a mismatch fails the call. Here the model asked for a priority that doesn't exist, and a job name that doesn't either:

Open
import { test, expect } from "@frontmcp/testing";

test("input that doesn't match the job's schema fails the call", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "urgent" } });
  expect(result).toBeError("TOOL_EXECUTION_ERROR");
  expect(result.text()).toMatch(/^Tool "execute_job" execution failed: \[/);
  expect(result.text()).toContain('"path": [\n      "priority"\n    ]');
});

test("leaving out `input` is the same as sending {}", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "find-sla-breaches" });
  expect(result).toBeError("TOOL_EXECUTION_ERROR");
  expect(result.text()).toContain("expected one of");
});

test("an unknown job name is refused", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "find-breaches", input: { priority: "high" } });
  expect(result).toBeError("JOB_NOT_AUTHORIZED");
  expect(result.text()).toBe('Job or workflow "find-breaches" not found or not permitted');
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A tool with the same schema would refuse "urgent" with INVALID_INPUT and Zod's message. A job's input is checked later, inside execute_job, so the call fails with TOOL_EXECUTION_ERROR: Tool "execute_job" execution failed: and then Zod's list of problems. That's the development message, which is what the Playground shows. In production, FrontMCP hides the message of an error like this, and the model only gets Internal FrontMCP error. Please contact support with error ID: …, with nothing to say what to fix.

An unknown name gets JOB_NOT_AUTHORIZED: Job or workflow "find-breaches" not found or not permitted. A job the caller isn't allowed to run, because of the job's permissions, gets exactly the same answer, so a caller can't find out which jobs exist by guessing. This message is kept in production.

So the model has to get the name and the input right the first time. Say both in instructions, as in the previous section, with the allowed values spelled out.

When a job fails

A job that throws fails the call. What the caller reads depends on what you throw, just as it does for a tool. This job copies a customer's plan from the CRM:

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

@Job({
  name: "sync-customer",
  description: "Copy a customer's plan from the CRM into the help desk.",
  inputSchema: { customerId: z.string().describe("Customer id, like C-1") },
  outputSchema: { customerId: z.string(), plan: z.string() },
})
export class SyncCustomer extends JobContext {
  async execute({ customerId }: { customerId: string }) {
    const customer = await crm.find(customerId);
    if (!customer) {
      // ✅ A message the model can act on, kept in production
      throw new PublicMcpError(`There's no customer ${customerId} in the CRM. Check the id with the user.`);
    }
    return { customerId, plan: customer.plan };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

  • PublicMcpError, thrown or passed to this.fail(), fails the call with your message, in production too, and code PUBLIC_ERROR unless you pass it a code of your own. Use it when the model can do something about the failure, like asking the user for the right id.
  • Any other error fails it with TOOL_EXECUTION_ERROR. In development the text is Tool "execute_job" execution failed: with the error's message and stack; in production it's Internal FrontMCP error. Please contact support with error ID: …, which keeps the address of your database out of the model's context.

Either way, the caller gets an error, not a run with state: "failed". Failed runs are recorded too, and the next lesson shows how to read one.

A job as a function

A job with no state of its own can be a function. job({...}) takes the same options as @Job and returns a function that takes the handler; register what it returns, as you would a class:

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

const FormatTicketId = job({
  name: "format-ticket-id",
  description: "Turn a ticket number into the id customers see, like T-0042.",
  inputSchema: { number: z.number().int().positive() },
  outputSchema: { id: z.string() },
})(({ number }, ctx) => {
  const id = `T-${String(number).padStart(4, "0")}`;
  ctx.log(`Formatted ${number} as ${id}`);
  return { id };
});

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

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The handler gets the job's context as its second argument, so ctx.log() writes to the run's logs, as this.log() does in a class. (Changed in 1.9.1: before, log() was protected, so ctx.log() didn't compile.)

Jobs or tools?

A job can do anything a tool can, so which should you write?

ToolJob
How the model finds itIn tools/list, with its own description and schemaBy a name you have to tell it, through execute_job
When it runsWhen called, and the answer comes back in the same callWhen execute_job is called, in the call or in the background
If it failsThe call failsThe call fails, or FrontMCP retries it first
AfterwardsNothing is keptA run with an id, a state and logs
In a workflowNoYes, as a step

If the model should call it and use the answer right away, like searching tickets or closing one, write a tool: it gets a proper description and schema in tools/list, and errors the model can read. Write a job for work that takes a while, that should be retried when it fails, that should keep going after the call returns, or that is one step of something bigger. A nightly report is all four.

Recap

  • A job is a class that extends JobContext, decorated with @Job({ name, inputSchema, outputSchema }), or a function made with job({...}). Register it with @App({ jobs: [...] }).
  • Jobs aren't in tools/list. FrontMCP adds execute_job, get_job_status, execute_workflow and get_workflow_status, and the model has to be told which job names exist, for example in instructions.
  • execute_job { name, input } runs the job and returns { runId, state: "completed", result, logs }. this.log() lines start with the time.
  • A result that doesn't match outputSchema fails with INVALID_OUTPUT, and fields it doesn't declare are dropped. Input that doesn't match inputSchema fails with TOOL_EXECUTION_ERROR, whose message production hides. An unknown name is JOB_NOT_AUTHORIZED.
  • Throw PublicMcpError for failures the model can act on. Anything else becomes Internal FrontMCP error in production.

Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press Check.

Challenge 1 of 3

Register the job

count-open-tickets is written, but execute_job can't find it, and it doesn't log anything yet. Register it, and make it log how many open tickets it counted, like Counted 2 open tickets for Acme.

Open
import { App, FrontMcp, Job, JobContext, Tool, ToolContext, z } from "@frontmcp/sdk";
import { tickets } from "./store";

@Job({
  name: "count-open-tickets",
  description: "Count a customer's open tickets.",
  inputSchema: { customer: z.string().describe("Customer name, like Acme") },
  outputSchema: { customer: z.string(), open: z.number() },
})
class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    const open = tickets.filter((t) => t.customer === customer && t.status === "open").length;
    return { customer, open };
  }
}

@Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } })
class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) };
  }
}

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

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.