Running Jobs in the Background
Run inline, a job holds its call open until it's done, and a model waits the whole time. With background: true, execute_job answers at once with a run id, the job keeps working on the server, and get_job_status reports on it later. This lesson starts jobs that way, retries the ones that fail now and then, and shows who can read a run afterwards. In FrontMCP 1.9.4, under MCP 2026-07-28, that rules out anonymous callers, so the lesson also shows how to get results to them another way.
You will learn
- How to start a job in the background, and read its run with
get_job_status - What states a run goes through, and what a failed run looks like
- How to retry a flaky job with backoff, and what gets retried
- Who can read a run, and how to hand results to anonymous callers
- Where runs are stored, and how to keep them in Redis
A job that takes a while
Exporting the ticket database reads it a page at a time, and each page takes a fifth of a second. Run inline, the call takes as long as the export. The call in this example starts it in the background instead. Open the Tests tab:
import { Job, JobContext, z } from "@frontmcp/sdk";
import { readPage } from "./database";
@Job({
name: "export-tickets",
description: "Export every support ticket as CSV. Reads the database a page at a time, so it takes a while.",
inputSchema: {},
outputSchema: { rows: z.number(), csv: z.string() },
})
export class ExportTickets extends JobContext {
async execute() {
const rows: string[] = [];
for (let page = 1; page <= 5; page++) {
rows.push(...(await readPage(page)));
this.log(`Read page ${page} of 5`);
}
return { rows: rows.length, csv: ["id,title,status", ...rows].join("\n") };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
With background: true, execute_job returns { runId, state: "pending" } and nothing else: no result, no logs. The job carries on after the call has returned. A client reads the run later by passing that runId to get_job_status.
Who can read a run
Copy the runId from the Call tab, and call get_job_status with it. It fails with Run "…" not found, although the run is right there. Open the Tests tab:
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";
const asNour = { authContext: { user: { sub: "nour" } } };
const asSam = { authContext: { user: { sub: "sam" } } };
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
test("an anonymous caller can't read the run it started", async ({ mcp }) => {
const { runId } = (await mcp.tools.call("execute_job", { name: "export-tickets", background: true })).json();
const status = await mcp.tools.call("get_job_status", { runId });
expect(status).toBeError("INVALID_INPUT");
expect(status.text()).toBe(`Run "${runId}" not found`);
});
test("a signed-in caller can read their own run, and nobody else can", async () => {
// createDirect() calls the server in-process, as the user you name.
const server = await FrontMcpInstance.createDirect(config);
const started = await server.callTool("execute_job", { name: "export-tickets", background: true }, asNour);
const { runId } = started.structuredContent as { runId: string };
const mine = await server.callTool("get_job_status", { runId }, asNour);
await expect(server.callTool("get_job_status", { runId }, asSam)).rejects.toThrow(`Run "${runId}" not found`);
await wait(1_200);
await server.dispose();
expect(mine.structuredContent).toMatchObject({ runId, jobName: "export-tickets", state: "running" });
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
A run holds the job's input and result, which can be anybody's data, so FrontMCP only lets the caller who started a run read it. It records the caller's sub with the run, and get_job_status answers only a caller with the same sub. Anyone else gets Run "…" not found with code INVALID_INPUT, exactly what they'd get for a run that doesn't exist, so a run id that leaks tells a stranger nothing.
That's where anonymous callers lose out. Under MCP 2026-07-28 each anonymous request is a new caller, with a new anon: id, so the request that asks for the status is never the one that started the run. The Playground's calls are anonymous, which is why yours failed. The second test calls the server in-process with createDirect(), as the signed-in user nour: she can read her run while it works, and sam can't.
What a run looks like over time
For the rest of this lesson, the tests read runs as nour. get_job_status returns the run's record:
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";
const asNour = { authContext: { user: { sub: "nour" } } };
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
test("a run is `running` with no logs, then `completed` with all of them", async () => {
const server = await FrontMcpInstance.createDirect(config);
const status = async (runId: string) =>
(await server.callTool("get_job_status", { runId }, asNour)).structuredContent as any;
const started = await server.callTool("execute_job", { name: "export-tickets", background: true }, asNour);
const { runId } = started.structuredContent as { runId: string };
await wait(500); // two pages in
const during = await status(runId);
await wait(800);
const after = await status(runId);
await server.dispose();
expect(during).toEqual({ runId, jobName: "export-tickets", state: "running", startedAt: expect.any(Number), attempt: 1, logs: [] });
expect(after).toMatchObject({ state: "completed", result: { rows: 5 }, attempt: 1, completedAt: expect.any(Number) });
expect(after.logs).toHaveLength(5);
expect(after.logs[4]).toMatch(/\] Read page 5 of 5$/);
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
| Field | Meaning |
|---|---|
runId, jobName | Which run, of which job. |
state | Where the run is; see below. |
result | What execute() returned, once the run has completed. |
error | { message, name } of the error, once the run has failed. |
startedAt, completedAt | Milliseconds since 1970. completedAt is set when the run completes or fails. |
attempt | Counts attempts, when the job retries. |
logs | The this.log() lines, once the run has completed. Until then it's empty. |
A run's state is one of:
state | When |
|---|---|
"pending" | Created, not started yet. It's what execute_job returns for a background run. |
"running" | The job is working. |
"retrying" | An attempt failed, and the run is waiting to try again. |
"completed" | The job returned. result is set. |
"failed" | The last attempt threw. error is set. |
The empty logs in during matter: in 1.9.4 a client can't watch a run's progress. FrontMCP saves the logs when the run ends, and only if it succeeds. A client learns how far a run got from state alone, so poll it every few seconds, not in a tight loop.
Deep diveWhat about this.progress()?Show detailsHide details
JobContext has this.progress(progress, total, message), as tools do. For a tool it sends a progress notification on the call's stream. For a job it looks for the caller's session, and under 2026-07-28 there isn't one, so it sends nothing and returns false:
import { App, FrontMcp, Job, JobContext, z } from "@frontmcp/sdk";
@Job({ name: "export-tickets", inputSchema: {}, outputSchema: { delivered: z.array(z.boolean()) } })
class ExportTickets extends JobContext {
async execute() {
const delivered: boolean[] = [];
for (let page = 1; page <= 3; page++) {
delivered.push(await this.progress(page, 3, `Read page ${page} of 3`));
}
return { delivered };
}
}
@App({ id: "help-desk", name: "Help Desk", jobs: [ExportTickets] })
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.
If a user should see how far a long export got, have the job write its progress somewhere a tool can read, the same way as results for anonymous callers.
Retrying a flaky job
The CRM the help desk syncs with drops requests now and then: here, the first two tries for each customer fail. retry tells FrontMCP to run the job again when it throws, waiting a little longer each time:
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() },
retry: { maxAttempts: 3, backoffMs: 200 },
})
export class SyncCustomer extends JobContext {
async execute({ customerId }: { customerId: string }) {
this.log(`Attempt ${this.attempt}: asking the CRM for ${customerId}`);
const customer = await crm.find(customerId);
if (!customer) throw new PublicMcpError(`There's no customer ${customerId} in the CRM.`);
return { customerId, plan: customer.plan };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The first try fails, FrontMCP waits 200 ms, the second fails, it waits 400 ms, and the third works. Take retry out and run the tests again: the job runs once, and the call fails with the CRM's first timeout. retry takes:
| Field | Default | Meaning |
|---|---|---|
maxAttempts | 3 | How many times to run the job in all, counting the first. |
backoffMs | 1000 | How long to wait before the first retry, in milliseconds. |
backoffMultiplier | 2 | What each wait is multiplied by for the next one. |
maxBackoffMs | 60000 | The longest FrontMCP waits between two attempts. |
The defaults apply once retry is set, so retry: {} means three attempts, waiting 1 and then 2 seconds. Without retry, a job runs once. An inline call waits through every retry, so keep maxAttempts and the waits small for jobs a model runs inline, and run slow-to-retry jobs in the background.
A few things about retries in 1.9.4 are easy to trip over:
- Almost everything is retried. The last test asks for customer
C-404, which the CRM doesn't have. Retrying can't fix that, but the job throws, so FrontMCP tries three times before the caller hears about it. Input that doesn't matchinputSchemais retried as well. KeepmaxAttemptslow when a job can fail for reasons that won't go away. The one failure that isn't retried is a result that doesn't matchoutputSchema. - Logs are per attempt. A run keeps the logs of its last attempt only.
this.attemptis the attempt number, from 1, as the second test's log line shows. (Changed in 1.9.2: before, it was always1.)- Hooks see every attempt. Each attempt runs the
jobs:execute-jobflow, so a plugin'sJobHooksees each retry and its error, while the caller only hears about the last one. (New in 1.9.4.)
What a failed run looks like
When the last attempt fails, the run's state becomes "failed", with the error:
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";
const asNour = { authContext: { user: { sub: "nour" } } };
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
test("a failed run keeps the error, and none of the logs", async () => {
const server = await FrontMcpInstance.createDirect(config);
const started = await server.callTool("execute_job", { name: "sync-customer", input: { customerId: "C-404" }, background: true }, asNour);
const { runId } = started.structuredContent as { runId: string };
await wait(400);
const status = (await server.callTool("get_job_status", { runId }, asNour)).structuredContent;
await server.dispose();
expect(status).toEqual({
runId,
jobName: "sync-customer",
state: "failed",
error: { message: "There's no customer C-404 in the CRM.", name: "PublicMcpError" },
startedAt: expect.any(Number),
completedAt: expect.any(Number),
attempt: 3,
logs: [],
});
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
errorhas the last attempt'smessage, as thrown, andname, the error's class.logsis empty: in 1.9.4 a failed run keeps none of its logs.this.log()also writes each line to the server's own log, tagged[job:sync-customer], so that's where to look when a run fails. In the Playground, it's the Logs tab.attemptis the number of attempts made. (Changed in 1.9.2: before, it was one less.)
Handing results to anonymous callers
A job can share state with tools, as tools share it with each other: through a provider or a module. So when callers can't read runs, the job can put its result where a tool can read it. The nightly report job saves each day's report, and get_daily_report returns it:
import { App, FrontMcp } from "@frontmcp/sdk";
import { BuildDailyReport, GetDailyReport, ReportStore } from "./reports";
@App({
id: "help-desk",
name: "Help Desk",
tools: [GetDailyReport],
jobs: [BuildDailyReport],
providers: [ReportStore], // one store, for the job and the tool
})
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.
Press Call with get_daily_report and { "day": "2026-09-26" } a moment after the example starts: the report is there. The job and the tool see the same ReportStore because it's registered once, on their app. The report is keyed by day, not by run, because a job doesn't know its run's id. Anyone can read it, too, so keep this for results that every caller may see.
A job finds providers on its own app and on the server, as a tool does. (Changed in 1.9.2: before, a job only found the server's.) A provider on another app isn't there for it: the third challenge starts from that mistake.
Where runs are kept
By default FrontMCP keeps runs in memory, in the server process. That's what the Playground uses, and it's fine on one machine, with two limits: runs are lost when the server restarts, and a server behind a load balancer only knows the runs it started itself, so get_job_status fails whenever the request lands on another instance. For those, keep runs in Redis:
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
jobs: {
enabled: true,
store: {
redis: { provider: "redis", host: "redis.internal", port: 6379, password: process.env.REDIS_PASSWORD },
keyPrefix: "help-desk:jobs:", // the default is "mcp:jobs:"
},
},
})
export default class Server {}The Playground can't connect to Redis, so this part is for a real server. It was checked on one, against a Redis that needs a password. What 1.9.4 does with it:
enabled: trueis required once you write ajobsoption.jobs: { enabled: false }turns jobs off: FrontMCP adds none of the job tools, even with jobs registered.- It needs the
ioredispackage. If FrontMCP can't load it, it logsFailed to create Redis job state store, falling back to memoryand carries on in memory. - It connects the way the server's
redisoption does: withhost,port,password,dbandtls, or with aurlsuch asredis://:<password>@redis.internal:6379. Beside aurl, those fields only fill in what it leaves out, and one that contradicts it stops the server at startup. So does a missing password (NOAUTH Authentication required.) or a wrong one (WRONGPASS invalid username-password pair). (Changed in 1.9.3: before, onlyhostandportreached Redis, so a Redis that needs a password or TLS couldn't hold runs.) - Runs in Redis expire after 24 hours. Runs in memory stay until the server restarts. The sets FrontMCP keeps beside the runs,
<keyPrefix>idx:jobId:<job>and<keyPrefix>idx:sessionId:<session>, never expire, and keep every run's id, so they grow for as long as the server starts runs.
Recap
execute_job { name, input, background: true }returns{ runId, state: "pending" }at once, and the job keeps running.get_job_status { runId }returns the run:state,resultorerror,attempt, times andlogs.- A run goes
pending,running, andcompletedorfailed, withretryingbetween attempts. Logs appear only when a run completes; a failed run keeps none. - Only the caller who started a run can read it. Under 2026-07-28 an anonymous caller is new on every request, so it can never read its runs: have callers sign in, or have the job save its result where a tool can read it.
retry: { maxAttempts, backoffMs, backoffMultiplier, maxBackoffMs }runs a failed job again, waiting longer each time. Every error is retried, and each attempt starts from the top, so make retried jobs safe to run twice.- A job finds providers on its own app and on the server, so a job and a tool can share one.
- Runs are in memory by default.
jobs: { enabled: true, store: { redis } }keeps them in Redis, connected like the server'sredisoption, password and TLS included.
Try some challenges
Each challenge runs hidden checks against your code. Edit the code, then press Check.
Challenge 1 of 3
Retry the CRM sync
The CRM drops the first two requests for each customer, so sync-customer fails every first call. Make FrontMCP try up to 3 times, waiting 100 ms before the first retry and 200 ms before the second.
import { Job, JobContext, 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() },
outputSchema: { customerId: z.string(), plan: z.string() },
})
export class SyncCustomer extends JobContext {
async execute({ customerId }: { customerId: string }) {
const customer = await crm.find(customerId);
return { customerId, plan: customer.plan };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.