# Limiting and Timing Out Calls

> How to cap how often a FrontMCP tool runs, how many copies run at once, and how long a call may take, with rateLimit, concurrency and timeout, and what callers get back when a limit trips.

Source: https://frontmcp.dev/learn/limiting-calls

A model can call a tool as often as it likes, and nothing in MCP stops it from calling one in a loop. For most tools that's fine. For an export that reads the whole database, a merge that must never run twice at once, or a lookup that can hang on a slow service, it isn't. Three `@Tool` options set limits: `rateLimit` caps how often a tool runs, `concurrency` caps how many calls run at once, and `timeout` caps how long one call may take.

**You will learn**
- How to cap how often a tool runs, and what a caller gets back when a limit trips
- Whose calls a limit counts, and what anonymous callers share
- How to stop two runs of a tool overlapping
- What a timeout stops, and how to let the work stop with it
- How to choose limits for expensive and destructive tools

## Capping how often a tool runs

Exporting tickets reads every ticket in the database. The help desk wants at most two exports an hour, so the tool sets `rateLimit`. Open the **Tests** tab:

```ts export-tickets.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";
import { tickets } from "./store";

@Tool({
  name: "export_tickets",
  description: "Export every support ticket as CSV. Reads the whole ticket database, so at most 2 exports an hour.",
  inputSchema: {},
  annotations: { readOnlyHint: true },
  rateLimit: { maxRequests: 2, windowMs: 60 * 60_000 },
})
export class ExportTickets extends ToolContext {
  async execute() {
    const rows = tickets.map((t) => `${t.id},${t.title},${t.status}`);
    return { csv: ["id,title,status", ...rows].join("\n") };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { ExportTickets } from "./export-tickets.tool";

@App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets] })
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", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
  { id: "T-3", title: "Login link expired", status: "open" },
];
```

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

test("the third export in an hour is refused", async ({ mcp }) => {
  expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful();
  expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful();

  const third = await mcp.tools.call("export_tickets", {});
  expect(third).toBeError();
  expect(third.raw._meta?.code).toBe("RATE_LIMIT_EXCEEDED");
  expect(third.text()).toMatch(/^Rate limit exceeded\. Retry after \d+ seconds$/);
});
```

The export above ran once when the example started. Press **Call** in the Call tab twice more: the second call works, the third is refused. `rateLimit` takes:

| Field | Default | Meaning |
| --- | --- | --- |
| `maxRequests` | required | How many calls a window allows. |
| `windowMs` | `60_000` | The window's length, in milliseconds. |
| `partitionBy` | `"global"` | Whose calls share a count. See [below](#whose-calls-a-limit-counts). |

## What the caller gets back

A call over the limit doesn't reach `execute()`. The client gets an ordinary tool error:

```json
{
  "content": [{ "type": "text", "text": "Rate limit exceeded. Retry after 2274 seconds" }],
  "isError": true,
  "_meta": { "errorId": "err_…", "code": "RATE_LIMIT_EXCEEDED" }
}
```

- **The retry hint is in the text, for the model.** The HTTP status is still `200` and there's no `Retry-After` header, so the model is the one that reads when to try again. The message survives in production too.
- **The wait is until the window ends.** Windows line up with the clock, not with the first call: an hour-long window runs from one full hour (in UTC) to the next, and a minute-long one from one full minute to the next. The number is the time left in the current window, so it can be anything from a second up to the whole `windowMs`. Refused at 10:40 UTC by an hourly limit, a caller is told to retry in about 1200 seconds.

Say the limit in the description as well, as `export_tickets` does ("at most 2 exports an hour"). A model that knows the rule can plan around it, or tell the user, instead of finding out from an error.

## Whose calls a limit counts

`partitionBy` decides which calls share a count:

| `partitionBy` | One count per | Under MCP 2026-07-28 |
| --- | --- | --- |
| `"global"` | The whole server | Every caller shares one limit. |
| `"userId"` | Signed-in caller, by `authInfo.clientId` | Each caller with a key or token has a count of their own. All anonymous callers share one. |
| `"ip"` | Client IP address | Works on a real server. The Playground has no IP, so there it behaves like `"userId"`. |
| `"session"` | MCP session | There are no sessions, so it behaves like `"userId"`. |
| `(ctx) => string` | Whatever key you return | `ctx` has `sessionId`, plus `userId` for a signed-in caller and `clientIp` when there is one. |

`"userId"` is the natural choice for "two exports per person", and for signed-in callers it does exactly that. Anonymous callers have no user id, so they all share one count:

```ts export-tickets.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({
  name: "export_tickets",
  description:
    "Export every support ticket as CSV. At most 2 exports an hour per signed-in caller, and 2 an hour for all anonymous callers together.",
  inputSchema: {},
  annotations: { readOnlyHint: true },
  rateLimit: { maxRequests: 2, windowMs: 60 * 60_000, partitionBy: "userId" },
})
export class ExportTickets extends ToolContext {
  async execute() {
    return { csv: "id,title,status\nT-1,Cannot log in,open" };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { ExportTickets } from "./export-tickets.tool";

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

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

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

test("anonymous callers share one count", async ({ mcp }) => {
  expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful();
  expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful();

  const third = await mcp.tools.call("export_tickets", {});
  expect(third.raw._meta?.code).toBe("RATE_LIMIT_EXCEEDED");
});
```

Each of these calls arrives as a [new anonymous caller](https://frontmcp.dev/learn/authenticating-clients#a-server-anyone-can-call) with a new id, but FrontMCP doesn't count anonymous callers by those ids, so the third one is refused like the third call from a single person. That's the safe choice: nobody gets around a limit by not signing in. It also means one busy anonymous client can use up the exports for every other anonymous client. If that matters, have callers sign in, so each gets a count of their own, or, on a real server, count anonymous callers by address with a function: `partitionBy: (ctx) => ctx.userId ?? ctx.clientIp ?? "anonymous"`.

Counts are kept in the server's memory unless you say otherwise, so each instance of a server counts on its own: two instances behind a load balancer let through four exports an hour, not two. With `throttle.storage` on one Redis, the instances share a count: five exports sent to two instances in turn let two through. [Running Several Instances](https://frontmcp.dev/learn/running-several-instances#what-else-lives-in-one-instances-memory) sets that up. If that Redis can't be reached, a production server doesn't start, unless `throttle.storage` has `fallback: "memory"`, which starts it with each instance counting on its own. When Redis goes away while the server runs, a call to a tool with a limit fails with `GUARD_STORAGE_UNAVAILABLE`, or, with the fallback, is counted in the instance's memory until Redis answers again: [the guard reference](https://frontmcp.dev/reference/sdk/guard#calls-fail-with-guard_storage_unavailable-while-the-server-runs) shows what a caller gets, and [what happens at startup](https://frontmcp.dev/reference/sdk/guard#the-server-doesnt-start-throttlestorage-redis-is-unavailable).

## One at a time

Merging two customer records moves all their tickets from one record to the other. Two merges running at once can move the same tickets twice. `concurrency` caps how many calls of a tool run at the same moment:

```ts merge-customers.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

export const merges: string[] = [];

@Tool({
  name: "merge_customers",
  description:
    "Merge one customer record into another, moving all its tickets. One merge runs at a time; if another is running, wait a minute and try again.",
  inputSchema: { from: z.string().describe("Customer to merge away, like C-7"), into: z.string().describe("Customer to keep") },
  annotations: { destructiveHint: true },
  concurrency: { maxConcurrent: 1 },
})
export class MergeCustomers extends ToolContext {
  async execute({ from, into }: { from: string; into: string }) {
    await new Promise((resolve) => setTimeout(resolve, 300)); // moving tickets takes a while
    merges.push(`${from} → ${into}`);
    return { merged: from, into };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { MergeCustomers } from "./merge-customers.tool";

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

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

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

test("a second merge while one is running is refused", async ({ mcp }) => {
  const [first, second] = await Promise.all([
    mcp.tools.call("merge_customers", { from: "C-7", into: "C-2" }),
    mcp.tools.call("merge_customers", { from: "C-8", into: "C-2" }),
  ]);
  expect(first).toBeSuccessful();
  expect(second).toBeError();
  expect(second.raw._meta?.code).toBe("CONCURRENCY_LIMIT");
  expect(second.text()).toBe('Concurrency limit reached for "merge_customers" (max: 1)');
});

test("once it's done, the next merge runs", async ({ mcp }) => {
  expect(await mcp.tools.call("merge_customers", { from: "C-8", into: "C-2" })).toBeSuccessful();
});
```

The slot is taken when a call starts and given back when its `execute()` ends, whether it succeeded or failed. `concurrency` takes `maxConcurrent`, `partitionBy` (the same choices as for `rateLimit`, `"global"` by default), and `queueTimeoutMs`. By default, a call that finds no free slot is refused at once. With a queue, it waits for a slot, and is only refused if none frees up in time:

```ts
concurrency: { maxConcurrent: 1, queueTimeoutMs: 5_000 },
```

A queued call that runs out of time gets `Queue timeout for "merge_customers" after waiting 5000ms for a concurrency slot`. For a tool a model is likely to call twice in a row, a short queue is kinder than a refusal.

Every limit answers the same way: an `isError` tool result, over HTTP `200`, with a code of its own in `_meta.code` and a message that is kept in production too:

| Limit | `_meta.code` | Message |
| --- | --- | --- |
| `rateLimit` | `RATE_LIMIT_EXCEEDED` | `Rate limit exceeded. Retry after 1200 seconds` |
| `concurrency` | `CONCURRENCY_LIMIT` | `Concurrency limit reached for "merge_customers" (max: 1)` |
| `concurrency` with a queue | `QUEUE_TIMEOUT` | `Queue timeout for "merge_customers" after waiting 5000ms for a concurrency slot` |
| `timeout` | `EXECUTION_TIMEOUT` | `Execution of "lookup_customer" timed out after 500ms` |

So the model can tell "busy, try again soon" from "broken". It still helps to put the rule in the tool's description, as `merge_customers` does, so the model knows what to do about it.

## Deadlines

A tool that calls another service waits as long as that service takes. `timeout: { executeMs }` puts a limit on it. Customer `C-9` sends the CRM into a slow path:

```ts lookup-customer.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

// Stands in for a CRM that is sometimes very slow to answer.
async function crmLookup(id: string) {
  await new Promise((resolve) => setTimeout(resolve, id === "C-9" ? 2_000 : 50));
  return { id, name: id === "C-9" ? "Globex" : "Initech", plan: "business" };
}

@Tool({
  name: "lookup_customer",
  description: "Look up a customer in the CRM by id. Gives up after half a second if the CRM is slow.",
  inputSchema: { id: z.string().describe("Customer id, like C-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
  timeout: { executeMs: 500 },
})
export class LookupCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return crmLookup(id);
  }
}
```

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

test("a slow lookup fails after half a second", async ({ mcp }) => {
  const result = await mcp.tools.call("lookup_customer", { id: "C-9" });
  expect(result.raw._meta?.code).toBe("EXECUTION_TIMEOUT");
  expect(result.durationMs).toBeLessThan(1_000);
});

test("a fast one answers", async ({ mcp }) => {
  expect(await mcp.tools.call("lookup_customer", { id: "C-1" })).toBeSuccessful();
});
```

After half a second the call fails with `Execution of "lookup_customer" timed out after 500ms`. Try `C-1`: it answers in time.

## A timeout ends the call, not the work

When a call times out, FrontMCP stops waiting for `execute()` and answers the client. `execute()` itself carries on unless it's written to stop (the [next section](#stopping-the-work) shows how), side effects and all. Its `concurrency` slot stays taken until it really ends, so runs never overlap, and a call that arrives in the meantime is refused:

```ts merge-customers.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const merges: string[] = [];
let running = 0;
let mostAtOnce = 0;

@Tool({
  name: "merge_customers",
  description: "Merge one customer record into another, moving all its tickets. One merge runs at a time.",
  inputSchema: { from: z.string(), into: z.string() },
  annotations: { destructiveHint: true },
  concurrency: { maxConcurrent: 1 },
  timeout: { executeMs: 200 },
})
export class MergeCustomers extends ToolContext {
  async execute({ from, into }: { from: string; into: string }) {
    running++;
    mostAtOnce = Math.max(mostAtOnce, running);
    await new Promise((resolve) => setTimeout(resolve, 500)); // takes longer than the timeout
    merges.push(`${from} → ${into}`);
    running--;
    return { merged: from, into };
  }
}

@Tool({ name: "merge_log", description: "List finished merges.", inputSchema: {} })
export class MergeLog extends ToolContext {
  async execute() {
    return { merges, mostAtOnce };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { MergeCustomers, MergeLog } from "./merge-customers.tool";

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

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

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

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("the merge still happens after its call times out", async ({ mcp }) => {
  const result = await mcp.tools.call("merge_customers", { from: "C-7", into: "C-2" });
  expect(result.raw._meta?.code).toBe("EXECUTION_TIMEOUT");

  await wait(500);
  const log = (await mcp.tools.call("merge_log", {})).json();
  expect(log.merges).toContain("C-7 → C-2");
});

test("a merge sent while a timed-out one is still running is refused", async ({ mcp }) => {
  await mcp.tools.call("merge_customers", { from: "C-8", into: "C-2" }); // times out, keeps running
  const second = await mcp.tools.call("merge_customers", { from: "C-9", into: "C-2" });
  expect(second.raw._meta?.code).toBe("CONCURRENCY_LIMIT");

  await wait(600);
  const log = (await mcp.tools.call("merge_log", {})).json();
  expect(log.merges).toContain("C-8 → C-2");
  expect(log.merges).not.toContain("C-9 → C-2");
  expect(log.mostAtOnce).toBe(1);
});
```

For a tool that changes things, a timeout shorter than the work is worse than none. The model is told the merge failed while it's still going, then that merges are busy, and it may well run the same merge again once the first one is done. Give such tools no timeout, or one comfortably longer than the work can take, and make them safe to repeat, for example by checking whether the merge already happened before starting it.

## Stopping the work

A timeout also aborts `this.signal`, the call's [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Work that listens to it stops with the call. `fetch()` and most clients for databases and other services take a `signal` option, and a loop can call `this.signal?.throwIfAborted()` between steps. (The type says `signal` may be missing, hence the `?.`.) This export reads the ticket database a page at a time, and a slow database makes it take longer than its timeout:

```ts export-tickets.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";

export const pagesRead: number[] = [];
export const stops: string[] = [];

// Stands in for a slow database: 10 pages, a tenth of a second each.
async function readPage(page: number) {
  await new Promise((resolve) => setTimeout(resolve, 100));
  pagesRead.push(page);
  return [`T-${page},Ticket ${page},open`];
}

@Tool({
  name: "export_tickets",
  description: "Export every support ticket as CSV. Gives up after 350 ms.",
  inputSchema: {},
  annotations: { readOnlyHint: true },
  timeout: { executeMs: 350 },
})
export class ExportTickets extends ToolContext {
  async execute() {
    const rows: string[] = [];
    try {
      for (let page = 1; page <= 10; page++) {
        this.signal?.throwIfAborted(); // ✅ stop between pages once the call has timed out
        rows.push(...(await readPage(page)));
      }
    } catch (error) {
      stops.push(String(this.signal?.reason?.message ?? error));
      throw error;
    }
    return { csv: ["id,title,status", ...rows].join("\n") };
  }
}

@Tool({ name: "export_stats", description: "How many database pages exports have read, and why they stopped.", inputSchema: {} })
export class ExportStats extends ToolContext {
  async execute() {
    return { pagesRead: pagesRead.length, stops };
  }
}
```

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

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("the export stops reading when its call times out", async ({ mcp }) => {
  const result = await mcp.tools.call("export_tickets", {});
  expect(result.raw._meta?.code).toBe("EXECUTION_TIMEOUT");

  await wait(1_200); // long enough to read all 10 pages, had it kept going
  const stats = (await mcp.tools.call("export_stats", {})).json();
  expect(stats.pagesRead).toBeLessThan(10);
  expect(stats.stops).toContain('Execution of "export_tickets" timed out after 350ms');
});
```

Take out the `throwIfAborted()` line and run the test again: the call still times out after 350 ms, but the export goes on to read all ten pages that nobody will see. `this.signal.reason` is the same timeout error the client got. Stopping is right for work that only reads. For work that changes things, stopping halfway can be worse than finishing, which is why the merge above doesn't listen to the signal.

## Turning limits off

The limits in `@Tool` are enforced by FrontMCP's guard, which starts by itself when a tool declares `rateLimit` or `concurrency`. `@FrontMcp`'s `throttle` option configures the guard, and `throttle: { enabled: false }` turns it off: every `rateLimit` and `concurrency` on the server is ignored. That can be what you want for a local test run that calls the same tool many times:

```ts main.ts active
import { App, FrontMcp } from "@frontmcp/sdk";
import { ExportTickets } from "./export-tickets.tool";

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  throttle: { enabled: false }, // 🚩 for local testing only
})
export default class Server {}
```

```ts export-tickets.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";
import { tickets } from "./store";

@Tool({
  name: "export_tickets",
  description: "Export every support ticket as CSV. Reads the whole ticket database, so at most 2 exports an hour.",
  inputSchema: {},
  annotations: { readOnlyHint: true },
  rateLimit: { maxRequests: 2, windowMs: 60 * 60_000 },
})
export class ExportTickets extends ToolContext {
  async execute() {
    const rows = tickets.map((t) => `${t.id},${t.title},${t.status}`);
    return { csv: ["id,title,status", ...rows].join("\n") };
  }
}
```

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

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

test("with the guard off, five exports in a row all succeed", async ({ mcp }) => {
  for (let i = 0; i < 5; i++) {
    expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful();
  }
});
```

The description still says "at most 2 exports an hour", and nothing enforces it, so keep `enabled: false` out of anything a real client can reach. `timeout` is the one limit it doesn't turn off.

## Limits for every tool

`throttle` can also set defaults, for tools that don't set their own. Unlike a tool's own limits, `throttle` needs `enabled: true`:

```ts main.ts
@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  throttle: {
    enabled: true,
    defaultRateLimit: { maxRequests: 60, windowMs: 60_000, partitionBy: "ip" },
    defaultConcurrency: { maxConcurrent: 5 },
    defaultTimeout: { executeMs: 30_000 },
  },
})
export default class Server {}
```

A tool's own `rateLimit`, `concurrency` or `timeout` replaces the default. The [`@FrontMcp` reference](https://frontmcp.dev/reference/sdk/frontmcp#setting-defaults-for-every-tool) shows `defaultRateLimit` running.

> **Note**
Two more `throttle` options apply to the server as a whole. `globalConcurrency: { maxConcurrent }` caps how many tool calls run at once across every tool; a call over it is refused with `Concurrency limit reached for "global"`. `global: { maxRequests, windowMs }` is one rate limit for every request the server gets. It's checked when a request arrives, before any tool runs, so a request over it gets HTTP `429` with a `Retry-After` header and JSON-RPC error `-32029`, not a tool error.

## Choosing limits

| Kind of tool | Example | Limits |
| --- | --- | --- |
| Cheap and read-only | `search_tickets` | Usually none of its own. A server-wide `defaultRateLimit` if strangers can reach the server. |
| Expensive to run | `export_tickets` | A `rateLimit` per caller (`"userId"`), and a `timeout` whose `this.signal` stops the work. |
| Waits on another service | `lookup_customer` | A `timeout` shorter than the client is willing to wait, so the model gets an error it can explain, with `this.signal` passed on to the request. |
| Changes shared state | `merge_customers` | `concurrency: { maxConcurrent: 1 }`, with a short queue. No timeout shorter than the work. |
| Sends email or spends money | `email_customer`, `refund_order` | A low `rateLimit` per caller. A model stuck in a loop shouldn't be able to send a hundred emails. A limit doesn't ask anyone: to have the user agree first, add [approval](https://frontmcp.dev/learn/asking-for-approval). |

Whatever you choose, write it in the description. Limits the model knows about are limits it can work with.

## Recap

- `rateLimit`, `concurrency` and `timeout` on `@Tool` work as soon as a tool sets them. `throttle` in `@FrontMcp` adds defaults for every tool, and `throttle: { enabled: false }` turns rate limits and concurrency off.
- A call over a limit gets an `isError` result, over HTTP `200`, with its own `_meta.code` (`RATE_LIMIT_EXCEEDED`, `CONCURRENCY_LIMIT`, `QUEUE_TIMEOUT`, `EXECUTION_TIMEOUT`) and a message kept in production. "Retry after N seconds" counts down to the end of a clock-aligned window.
- `partitionBy` picks whose calls share a count. With `"userId"`, each signed-in caller has a count of their own, and all anonymous callers share one.
- `concurrency: { maxConcurrent, queueTimeoutMs }` stops calls overlapping, and a timed-out call keeps its slot until `execute()` really ends.
- `timeout: { executeMs }` ends the call and aborts `this.signal`. Pass the signal on so read-only work stops too, and don't give tools that change things a timeout shorter than their work.
- Every guard option, and what each error looks like to a caller, is in the [Guard options reference](https://frontmcp.dev/reference/sdk/guard).

## Try some challenges

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

### Challenge: Cap the export
Nothing stops a model from exporting the ticket database over and over. Allow at most 3 exports an hour, for the whole server.

```ts main.ts
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({
  name: "export_tickets",
  description: "Export every support ticket as CSV.",
  inputSchema: {},
  annotations: { readOnlyHint: true },
})
class ExportTickets extends ToolContext {
  async execute() {
    return { csv: "id,title,status\nT-1,Cannot log in,open\nT-2,Invoice total is wrong,closed" };
  }
}

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

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

```ts main.ts solution
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({
  name: "export_tickets",
  description: "Export every support ticket as CSV. At most 3 exports an hour.",
  inputSchema: {},
  annotations: { readOnlyHint: true },
  rateLimit: { maxRequests: 3, windowMs: 60 * 60_000 },
})
class ExportTickets extends ToolContext {
  async execute() {
    return { csv: "id,title,status\nT-1,Cannot log in,open\nT-2,Invoice total is wrong,closed" };
  }
}

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

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

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

test("three exports in an hour work, and the fourth is refused", async ({ mcp }) => {
  for (let i = 0; i < 3; i++) {
    expect(await mcp.tools.call("export_tickets", {})).toBeSuccessful();
  }
  const fourth = await mcp.tools.call("export_tickets", {});
  expect(fourth).toBeError();
  expect(fourth.raw._meta?.code).toBe("RATE_LIMIT_EXCEEDED");
});

test("the refusal tells the model when to retry", async ({ mcp }) => {
  const result = await mcp.tools.call("export_tickets", {});
  expect(result.text()).toMatch(/Retry after \d+ seconds/);
});
```

**Hint:**
One option on `@Tool` does it. `windowMs` is in milliseconds.

**Solution:**
`rateLimit: { maxRequests: 3, windowMs: 60 * 60_000 }` counts calls per hour, and the default `partitionBy: "global"` shares that count across every caller. The server needs no change: a tool that declares a limit is enough to start the guard. The description now says the rule, for the model.

### Challenge: Let the second merge wait its turn
`merge_customers` runs one merge at a time, and refuses a second merge that arrives while one is running. A merge takes a fraction of a second, so make a second merge wait for the first one instead, for up to 5 seconds. Merges must still never run at the same time.

```ts merge-customers.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const merges: string[] = [];
let running = 0;
let mostAtOnce = 0;

@Tool({
  name: "merge_customers",
  description: "Merge one customer record into another, moving all its tickets. One merge runs at a time.",
  inputSchema: { from: z.string(), into: z.string() },
  annotations: { destructiveHint: true },
  concurrency: { maxConcurrent: 1 },
})
export class MergeCustomers extends ToolContext {
  async execute({ from, into }: { from: string; into: string }) {
    running++;
    mostAtOnce = Math.max(mostAtOnce, running);
    await new Promise((resolve) => setTimeout(resolve, 300));
    merges.push(`${from} → ${into}`);
    running--;
    return { merged: from, into };
  }
}

@Tool({ name: "merge_log", description: "List finished merges.", inputSchema: {} })
export class MergeLog extends ToolContext {
  async execute() {
    return { merges, mostAtOnce };
  }
}
```

```ts merge-customers.tool.ts solution
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const merges: string[] = [];
let running = 0;
let mostAtOnce = 0;

@Tool({
  name: "merge_customers",
  description: "Merge one customer record into another, moving all its tickets. One merge runs at a time; a second merge waits up to 5 seconds for its turn.",
  inputSchema: { from: z.string(), into: z.string() },
  annotations: { destructiveHint: true },
  concurrency: { maxConcurrent: 1, queueTimeoutMs: 5_000 },
})
export class MergeCustomers extends ToolContext {
  async execute({ from, into }: { from: string; into: string }) {
    running++;
    mostAtOnce = Math.max(mostAtOnce, running);
    await new Promise((resolve) => setTimeout(resolve, 300));
    merges.push(`${from} → ${into}`);
    running--;
    return { merged: from, into };
  }
}

@Tool({ name: "merge_log", description: "List finished merges.", inputSchema: {} })
export class MergeLog extends ToolContext {
  async execute() {
    return { merges, mostAtOnce };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { MergeCustomers, MergeLog } from "./merge-customers.tool";

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

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

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

test("two merges started together both succeed", async ({ mcp }) => {
  const results = await Promise.all([
    mcp.tools.call("merge_customers", { from: "C-7", into: "C-2" }),
    mcp.tools.call("merge_customers", { from: "C-8", into: "C-2" }),
  ]);
  for (const result of results) expect(result).toBeSuccessful();
});

test("they ran one after the other", async ({ mcp }) => {
  const log = (await mcp.tools.call("merge_log", {})).json();
  expect(log.merges).toHaveLength(2);
  expect(log.mostAtOnce).toBe(1);
});
```

**Hint:**
`concurrency` has an option for how long a call may wait for a free slot. Its default, `0`, means not at all.

**Solution:**
`queueTimeoutMs: 5_000` makes a call that finds the slot taken wait for it, for up to 5 seconds, instead of failing at once. `maxConcurrent: 1` still holds, so the second merge starts when the first ends: both succeed, and `mostAtOnce` stays `1`. If a merge ever took longer than 5 seconds, the waiting call would fail with `QUEUE_TIMEOUT`, so the description says how long it waits.

### Challenge: Give up on a slow CRM
`lookup_customer` waits as long as the CRM takes, and for `C-9` that's two seconds. Make the tool give up after 500 milliseconds, so the model hears back quickly. Lookups that answer in time must still work.

```ts lookup-customer.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

async function crmLookup(id: string) {
  await new Promise((resolve) => setTimeout(resolve, id === "C-9" ? 2_000 : 50));
  return { id, name: id === "C-9" ? "Globex" : "Initech", plan: "business" };
}

@Tool({
  name: "lookup_customer",
  description: "Look up a customer in the CRM by id.",
  inputSchema: { id: z.string().describe("Customer id, like C-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class LookupCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return crmLookup(id);
  }
}
```

```ts lookup-customer.tool.ts solution
import { Tool, ToolContext, z } from "@frontmcp/sdk";

async function crmLookup(id: string) {
  await new Promise((resolve) => setTimeout(resolve, id === "C-9" ? 2_000 : 50));
  return { id, name: id === "C-9" ? "Globex" : "Initech", plan: "business" };
}

@Tool({
  name: "lookup_customer",
  description: "Look up a customer in the CRM by id. Gives up after half a second if the CRM is slow; if it does, try again later.",
  inputSchema: { id: z.string().describe("Customer id, like C-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
  timeout: { executeMs: 500 },
})
export class LookupCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return crmLookup(id);
  }
}
```

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

test("a fast lookup still works", async ({ mcp }) => {
  const result = await mcp.tools.call("lookup_customer", { id: "C-1" });
  expect(result).toBeSuccessful();
  expect(result.json().name).toBe("Initech");
});

test("a slow lookup fails within a second", async ({ mcp }) => {
  const result = await mcp.tools.call("lookup_customer", { id: "C-9" });
  expect(result).toBeError();
  expect(result.text()).toContain("timed out");
  expect(result.durationMs).toBeLessThan(1_000);
});
```

**Hint:**
One of the three `@Tool` limits is about time. Its value is in milliseconds.

**Solution:**
`timeout: { executeMs: 500 }` makes FrontMCP answer with `Execution of "lookup_customer" timed out after 500ms` once half a second has passed. The lookup itself keeps running until the CRM answers, because it doesn't listen to `this.signal`; the next challenge fixes that. The description tells the model what a failure means and what to do about it.

### Challenge: Cancel the CRM request
`lookup_customer` gives up after half a second, but its request to the CRM doesn't: for `C-9` it keeps a CRM connection busy for two whole seconds, for an answer nobody reads. Make the request stop when the call times out. `crmLookup` takes a `signal`, like `fetch()` does.

```ts lookup-customer.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { crmLookup } from "./crm";

@Tool({
  name: "lookup_customer",
  description: "Look up a customer in the CRM by id. Gives up after half a second if the CRM is slow; if it does, try again later.",
  inputSchema: { id: z.string().describe("Customer id, like C-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
  timeout: { executeMs: 500 },
})
export class LookupCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return crmLookup(id);
  }
}
```

```ts lookup-customer.tool.ts solution
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { crmLookup } from "./crm";

@Tool({
  name: "lookup_customer",
  description: "Look up a customer in the CRM by id. Gives up after half a second if the CRM is slow; if it does, try again later.",
  inputSchema: { id: z.string().describe("Customer id, like C-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
  timeout: { executeMs: 500 },
})
export class LookupCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return crmLookup(id, { signal: this.signal });
  }
}
```

```ts crm.ts
import { Tool, ToolContext } from "@frontmcp/sdk";

// Stands in for the CRM's client library, and counts what happens to its requests.
const requests = { answered: 0, cancelled: 0 };

export async function crmLookup(id: string, options: { signal?: AbortSignal } = {}) {
  await new Promise<void>((resolve, reject) => {
    const timer = setTimeout(resolve, id === "C-9" ? 2_000 : 50);
    options.signal?.addEventListener("abort", () => {
      clearTimeout(timer);
      requests.cancelled += 1;
      reject(options.signal?.reason);
    });
  });
  requests.answered += 1;
  return { id, name: id === "C-9" ? "Globex" : "Initech", plan: "business" };
}

@Tool({ name: "crm_requests", description: "How many CRM requests were answered and cancelled.", inputSchema: {} })
export class CrmRequests extends ToolContext {
  async execute() {
    return { ...requests };
  }
}
```

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

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("a fast lookup still works", async ({ mcp }) => {
  const result = await mcp.tools.call("lookup_customer", { id: "C-1" });
  expect(result).toBeSuccessful();
  expect(result.json().name).toBe("Initech");
});

test("a slow lookup still times out", async ({ mcp }) => {
  const result = await mcp.tools.call("lookup_customer", { id: "C-9" });
  expect(result.raw._meta?.code).toBe("EXECUTION_TIMEOUT");
});

test("its CRM request is cancelled", async ({ mcp }) => {
  await wait(100);
  expect((await mcp.tools.call("crm_requests", {})).json().cancelled).toBe(1);
});
```

**Hint:**
When a call times out, FrontMCP aborts the call's `AbortSignal`. Where does a tool find it?

**Solution:**
`this.signal` is aborted when the call times out, and `crmLookup(id, { signal: this.signal })` hands it to the CRM client, which drops the request as soon as that happens. The model hears back after half a second either way; the difference is that the CRM stops working on an answer nobody will read. Pass `this.signal` on the same way to `fetch()` and to any client that takes one.
