# Your First Job

> How to write a FrontMCP job with @Job and JobContext, register it on an app, run it with the execute_job tool, and read the result, logs and errors that come back.

Source: https://frontmcp.dev/learn/your-first-job

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:

```ts find-sla-breaches.job.ts active
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 };
  }
}
```

```ts main.ts
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],
})
export default class Server {}
```

```ts store.ts
export const tickets = [
  { id: "T-1", title: "Cannot log in", priority: "high", status: "open", hoursOpen: 6 },
  { id: "T-2", title: "Invoice total is wrong", priority: "normal", status: "open", hoursOpen: 30 },
  { id: "T-3", title: "Login link expired", priority: "high", status: "open", hoursOpen: 2 },
  { id: "T-4", title: "Refund not received", priority: "normal", status: "closed", hoursOpen: 50 },
];
```

```ts sla.test.ts
import { test, expect } from "@frontmcp/testing";

test("finds the high-priority ticket past its SLA", async ({ mcp }) => {
  const run = await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "high" } });
  expect(run).toBeSuccessful();
  expect(run.json()).toMatchObject({
    runId: expect.any(String),
    state: "completed",
    result: { priority: "high", breaches: [{ id: "T-1", hoursOpen: 6 }] },
  });
});

test("each log line starts with the time it was written", async ({ mcp }) => {
  const run = (await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "normal" } })).json();
  expect(run.logs).toEqual([
    expect.stringMatching(/^\[\d{4}-\d\d-\d\dT[\d:.]+Z\] Checking 1 open normal-priority tickets$/),
    expect.stringMatching(/\] Found 1 past the 24-hour SLA$/),
  ]);
});

test("every run gets its own id", async ({ mcp }) => {
  const first = (await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "high" } })).json();
  const second = (await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "high" } })).json();
  expect(second.runId).not.toBe(first.runId);
});
```

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.

| Tool | What it does |
| --- | --- |
| `execute_job` | Runs the job called `name` with `input`, and returns the run. `background: true` starts it and returns at once, which the [next lesson](https://frontmcp.dev/learn/running-jobs-in-the-background) covers. |
| `get_job_status` | Reads a run by its `runId`. |
| `execute_workflow` | Runs a workflow, a chain of jobs. See [Chaining Jobs into Workflows](https://frontmcp.dev/learn/chaining-jobs-into-workflows). |
| `get_workflow_status` | Reads 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`](https://frontmcp.dev/reference/sdk/frontmcp#giving-the-model-instructions):

```ts main.ts active
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 {}
```

```ts find-sla-breaches.job.ts
import { Job, JobContext, z } from "@frontmcp/sdk";

@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" }) {
    return { priority, breaches: priority === "high" ? [{ id: "T-1", hoursOpen: 6 }] : [] };
  }
}
```

```ts discovery.test.ts
import { test, expect } from "@frontmcp/testing";

test("tools/list has FrontMCP's four job tools, not the job", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t) => t.name).sort();
  expect(names).toEqual(["execute_job", "execute_workflow", "get_job_status", "get_workflow_status"]);
});

test("the instructions name the job and its input", async ({ mcp }) => {
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" });
  expect(response.result.instructions).toContain('find-sla-breaches, input { priority: "high" | "normal" }');
});

test("list_jobs isn't listed, but answers when called", async ({ mcp }) => {
  const result = await mcp.tools.call("list_jobs", {});
  expect(result.json()).toMatchObject({ count: 1, jobs: [{ name: "find-sla-breaches" }] });
});
```

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:

```json
{
  "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](#when-a-job-fails).
- **`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.

> **Pitfall: Outside data has to match the output schema**
`@Job`'s types make TypeScript check that `execute()` returns what `outputSchema` describes, and FrontMCP checks the result again when the job runs. That matters when the data comes from somewhere TypeScript can't see, like a response from another service:

```ts find-sla-breaches.job.ts active
import { Job, JobContext, z } from "@frontmcp/sdk";

// Stands in for a reporting service. It renamed two fields in its last release, but
// only for high-priority tickets, and it sends each customer's email address along.
async function fetchBreaches(priority: string): Promise<any> {
  if (priority === "high") return [{ ticket: "T-1", hours: 6 }];
  return [{ id: "T-2", hoursOpen: 30, customerEmail: "ap@globex.example" }];
}

@Job({
  name: "find-sla-breaches",
  inputSchema: { priority: z.enum(["high", "normal"]) },
  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" }) {
    return { priority, breaches: await fetchBreaches(priority) }; // 🚩 not always what outputSchema says
  }
}
```

```ts output.test.ts
import { test, expect } from "@frontmcp/testing";

test("a result that doesn't match outputSchema fails the call", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "high" } });
  expect(result).toBeError("INVALID_OUTPUT");
  expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at breaches.0.id)");
});

test("a field outputSchema doesn't declare is dropped", async ({ mcp }) => {
  const run = (await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "normal" } })).json();
  expect(run.result.breaches).toEqual([{ id: "T-2", hoursOpen: 30 }]);
});
```

A result that doesn't match fails the call with `INVALID_OUTPUT`, and the message names the first field that's wrong. That tells the model the job broke, but not what it could do about it. Fields that `outputSchema` doesn't declare are dropped, so the customer's email never reaches the client. Parse outside data yourself, for example with the same Zod schema, and fail with a [`PublicMcpError`](#when-a-job-fails) that says what went wrong. (Changed in 1.9.2: before, FrontMCP didn't check the result at run time, and sent it as it was.)

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

```ts find-sla-breaches.job.ts
import { Job, JobContext, z } from "@frontmcp/sdk";

@Job({
  name: "find-sla-breaches",
  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" }) {
    return { priority, breaches: [] };
  }
}
```

```ts input.test.ts active
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');
});
```

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`](https://frontmcp.dev/reference/sdk/job#restricting-who-can-run-a-job), 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](#how-a-client-runs-a-job), 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](https://frontmcp.dev/learn/calling-other-services). This job copies a customer's plan from the CRM:

```ts sync-customer.job.ts active
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 };
  }
}
```

```ts crm.ts
// Stands in for the CRM's client. C-500 makes it fail the way a service does when it's down.
export const crm = {
  async find(id: string) {
    if (id === "C-500") throw new Error("connect ECONNREFUSED 10.0.4.7:5432");
    return id === "C-1" ? { id, plan: "business" } : undefined;
  },
};
```

```ts failures.test.ts
import { test, expect } from "@frontmcp/testing";

test("a PublicMcpError reaches the caller word for word", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-404" } });
  expect(result).toBeError("PUBLIC_ERROR");
  expect(result.text()).toBe("There's no customer C-404 in the CRM. Check the id with the user.");
});

test("any other error is a TOOL_EXECUTION_ERROR", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-500" } });
  expect(result).toBeError("TOOL_EXECUTION_ERROR");
  expect(result.text()).toMatch(/^Tool "execute_job" execution failed: connect ECONNREFUSED/);
});

test("a customer the CRM knows is synced", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-1" } });
  expect(result.json().result).toEqual({ customerId: "C-1", plan: "business" });
});
```

- **`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](https://frontmcp.dev/learn/running-jobs-in-the-background#what-a-failed-run-looks-like) 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:

```ts main.ts active
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 {}
```

```ts format.test.ts
import { test, expect } from "@frontmcp/testing";

test("a function-style job runs like a class", async ({ mcp }) => {
  const run = (await mcp.tools.call("execute_job", { name: "format-ticket-id", input: { number: 42 } })).json();
  expect(run).toMatchObject({ state: "completed", result: { id: "T-0042" } });
  expect(run.logs).toEqual([expect.stringMatching(/\] Formatted 42 as T-0042$/)]);
});
```

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?

| | Tool | Job |
| --- | --- | --- |
| How the model finds it | In `tools/list`, with its own description and schema | By a name you have to tell it, through `execute_job` |
| When it runs | When called, and the answer comes back in the same call | When `execute_job` is called, in the call or [in the background](https://frontmcp.dev/learn/running-jobs-in-the-background) |
| If it fails | The call fails | The call fails, or FrontMCP [retries](https://frontmcp.dev/learn/running-jobs-in-the-background#retrying-a-flaky-job) it first |
| Afterwards | Nothing is kept | A run with an id, a state and logs |
| In a workflow | No | Yes, as a [step](https://frontmcp.dev/learn/chaining-jobs-into-workflows) |

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: 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`.

```ts main.ts active
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 {}
```

```ts main.ts solution
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;
    this.log(`Counted ${open} open tickets for ${customer}`);
    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], jobs: [CountOpenTickets] })
class HelpDeskApp {}

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

```ts store.ts
export const tickets = [
  { id: "T-1", title: "Cannot log in", customer: "Acme", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", customer: "Globex", status: "open" },
  { id: "T-3", title: "Login link expired", customer: "Acme", status: "open" },
  { id: "T-4", title: "Refund not received", customer: "Acme", status: "closed" },
];
```

```ts count.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("`execute_job` is listed", async ({ mcp }) => {
  expect(await mcp.tools.list()).toContainTool("execute_job");
});

test("`count-open-tickets` counts Acme's open tickets", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Acme" } });
  expect(result).toBeSuccessful();
  expect(result.json()).toMatchObject({ state: "completed", result: { customer: "Acme", open: 2 } });
});

test("it logs the count", async ({ mcp }) => {
  const run = (await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Globex" } })).json();
  expect(run.logs).toEqual([expect.stringMatching(/\] Counted 1 open tickets for Globex$/)]);
});
```

**Hint:**
`@App` has a list for jobs next to `tools`. Inside `execute()`, a job writes to its logs with a method it inherits from `JobContext`.

**Solution:**
`jobs: [CountOpenTickets]` on `@App` registers the job, and with it FrontMCP adds `execute_job` and the other job tools. `this.log()` adds a line to the run's `logs`, and FrontMCP puts the time in front of it.

### Challenge: Say which customer is missing
`sync-customer` throws a plain `Error` when the CRM doesn't know the customer. In production, the model would only read "Internal FrontMCP error". Make the call fail with a message the model can act on, one that names the customer id, in production too.

```ts sync-customer.job.ts active
import { Job, JobContext, z } from "@frontmcp/sdk";

const crm = new Map([["C-1", { plan: "business" }]]);

@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 = crm.get(customerId);
    if (!customer) throw new Error(`customer lookup returned null for ${customerId}`);
    return { customerId, plan: customer.plan };
  }
}
```

```ts sync-customer.job.ts solution
import { Job, JobContext, PublicMcpError, z } from "@frontmcp/sdk";

const crm = new Map([["C-1", { plan: "business" }]]);

@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 = crm.get(customerId);
    if (!customer) throw new PublicMcpError(`There's no customer ${customerId} in the CRM. Check the id with the user.`);
    return { customerId, plan: customer.plan };
  }
}
```

```ts sync.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("an unknown customer fails with `PUBLIC_ERROR`", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-9" } });
  expect(result).toBeError("PUBLIC_ERROR");
});

test("the message names the customer", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-9" } });
  expect(result.text()).toContain("C-9");
  expect(result.text()).not.toContain("execution failed");
});

test("a known customer still syncs", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-1" } });
  expect(result.json().result).toEqual({ customerId: "C-1", plan: "business" });
});
```

**Hint:**
FrontMCP keeps the message of one kind of error in production. It's the same one tools use.

**Solution:**
A `PublicMcpError` fails `execute_job` with code `PUBLIC_ERROR` and exactly your message, in development and production alike. Any other error becomes `TOOL_EXECUTION_ERROR`, whose message production replaces with "Internal FrontMCP error". The new message also tells the model what to do next: check the id with the user.

### Challenge: Tell the model which jobs exist
This server has two jobs, but a model connected to it only sees `execute_job` and three other tools, with nothing that names the jobs. Add `instructions` that say to run jobs with `execute_job`, and name both jobs with the input each one takes.

```ts main.ts active
import { App, FrontMcp } from "@frontmcp/sdk";
import { CountOpenTickets, FindSlaBreaches } from "./jobs";

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

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

```ts main.ts solution
import { App, FrontMcp } from "@frontmcp/sdk";
import { CountOpenTickets, FindSlaBreaches } from "./jobs";

@App({ id: "help-desk", name: "Help Desk", jobs: [FindSlaBreaches, CountOpenTickets] })
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 past their SLA. ' +
    "count-open-tickets, input { customer: string }: counts a customer's open tickets.",
})
export default class Server {}
```

```ts jobs.ts
import { Job, JobContext, z } from "@frontmcp/sdk";

@Job({
  name: "find-sla-breaches",
  inputSchema: { priority: z.enum(["high", "normal"]) },
  outputSchema: { breaches: z.array(z.string()) },
})
export class FindSlaBreaches extends JobContext {
  async execute({ priority }: { priority: "high" | "normal" }) {
    return { breaches: priority === "high" ? ["T-1"] : [] };
  }
}

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

```ts instructions.test.ts hidden
import { test, expect } from "@frontmcp/testing";

async function instructions(mcp: any): Promise<string> {
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" });
  return response.result.instructions ?? "";
}

test("the instructions say to use `execute_job`", async ({ mcp }) => {
  expect(await instructions(mcp)).toContain("execute_job");
});

test("they name `find-sla-breaches` and its `priority`", async ({ mcp }) => {
  const text = await instructions(mcp);
  expect(text).toContain("find-sla-breaches");
  expect(text).toContain("priority");
});

test("they name `count-open-tickets` and its `customer`", async ({ mcp }) => {
  const text = await instructions(mcp);
  expect(text).toContain("count-open-tickets");
  expect(text).toContain("customer");
});
```

**Hint:**
`@FrontMcp` has an option for text that describes the server as a whole. Clients read it from `server/discover`.

**Solution:**
`instructions` is sent with `server/discover`, and clients usually add it to the model's context. It's the one place a model reads about jobs before it calls anything: `tools/list` only shows FrontMCP's generic job tools, and `list_jobs` is hidden from it. Spelling out the allowed values, like `"high" | "normal"`, matters because a job's input errors are hidden in production.
