Running Jobs in the Background

IntermediateMCP 2026-07-28

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:

Open
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:

Open
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:

Open
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.

FieldMeaning
runId, jobNameWhich run, of which job.
stateWhere the run is; see below.
resultWhat execute() returned, once the run has completed.
error{ message, name } of the error, once the run has failed.
startedAt, completedAtMilliseconds since 1970. completedAt is set when the run completes or fails.
attemptCounts attempts, when the job retries.
logsThe this.log() lines, once the run has completed. Until then it's empty.

A run's state is one of:

stateWhen
"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:

Open
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:

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() },
  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:

FieldDefaultMeaning
maxAttempts3How many times to run the job in all, counting the first.
backoffMs1000How long to wait before the first retry, in milliseconds.
backoffMultiplier2What each wait is multiplied by for the next one.
maxBackoffMs60000The 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 match inputSchema is retried as well. Keep maxAttempts low 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 match outputSchema.
  • Logs are per attempt. A run keeps the logs of its last attempt only.
  • this.attempt is the attempt number, from 1, as the second test's log line shows. (Changed in 1.9.2: before, it was always 1.)
  • Hooks see every attempt. Each attempt runs the jobs:execute-job flow, so a plugin's JobHook sees 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:

Open
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.

  • error has the last attempt's message, as thrown, and name, the error's class.
  • logs is 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.
  • attempt is 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:

Open
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:

main.ts
@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: true is required once you write a jobs option. jobs: { enabled: false } turns jobs off: FrontMCP adds none of the job tools, even with jobs registered.
  • It needs the ioredis package. If FrontMCP can't load it, it logs Failed to create Redis job state store, falling back to memory and carries on in memory.
  • It connects the way the server's redis option does: with host, port, password, db and tls, or with a url such as redis://:<password>@redis.internal:6379. Beside a url, 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, only host and port reached 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, result or error, attempt, times and logs.
  • A run goes pending, running, and completed or failed, with retrying between 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's redis option, 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.

Open
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.