# Reading the Request

> What a tool can learn about the call it's handling beyond its input, like the request id, the trace, the caller and the client, and which of those a client can simply make up.

Source: https://frontmcp.dev/learn/reading-the-request

Most tools need nothing but their input. Now and then a tool needs to know about the call itself: which request this is, so its logs and errors can be found later; who is calling, so it can decide what they may do; which client is on the other end. FrontMCP keeps all of that on `this`, next to the helpers you already use. This lesson covers what's there in FrontMCP 1.8, what it's for, and which parts you can't trust.

**You will learn**
- What `this.context` and `this.auth` tell a tool about the call and the caller
- How to tag logs and error messages with the request id
- Where the caller's identity should come from
- Why arguments, client info and headers are claims, not facts

## What a tool knows about the call

`execute()` receives the arguments. Everything else about the call is on `this`. This tool returns the parts you'll use most. You wouldn't ship it, but call it a few times and watch what changes:

```ts describe-call.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({
  name: "describe_call",
  description: "Describe the request this call arrived in. For debugging.",
  inputSchema: {},
})
export class DescribeCall extends ToolContext {
  async execute() {
    return {
      requestId: this.context.requestId,
      traceId: this.context.traceContext.traceId,
      caller: this.auth.user.sub,
      client: this.clientInfo ?? null,
      platform: this.platform,
    };
  }
}
```

`requestId` and `traceId` are new on every call, and here so is the caller: the Playground has no authentication, so each request comes from a new anonymous caller, `anon:` and a random id. `client` is the Playground's own description of itself, which it sends with every request. Here is what `this` offers:

| On `this` | What it is |
| --- | --- |
| `this.context.requestId` | A UUID that FrontMCP gives each request. It isn't the JSON-RPC `id`, and the client never sees it unless you put it in a result. |
| `this.context.traceContext` | The request's W3C trace context: `traceId`, `parentId`, and `raw`, the value of a `traceparent` header. A request without one starts a new trace. |
| `this.contextLogger` | A logger whose lines carry the request id and the trace id. |
| `this.auth` | The caller, as your server's authentication established it: `user.sub` says who, `isAnonymous` whether they signed in, `scopes` and `roles` what they were granted, and `claims` what their token said, with checks like `hasRole()`. See [Authorizing Calls](https://frontmcp.dev/learn/authorizing-calls). |
| `this.context.authInfo` | The raw result of authentication that `this.auth` is built from, including `token`, the caller's token itself. |
| `this.clientInfo` and `this.platform` | The client's name and version, and the AI platform FrontMCP guesses from the name, like `"claude"` or `"cursor"`. |
| `this.context.metadata` | Parts of the HTTP request: `userAgent`, `clientIp`, and `customHeaders`, which holds every `x-frontmcp-*` header. |

Not all of them are filled in for every call. The deep dive below shows what FrontMCP 1.9.3 fills in for each way of running a server.

**Deep dive: What each way of running a server fills in**
What's on `this` depends on how the request reached FrontMCP:

| | Node HTTP server (`frontmcp dev`, a Node build) | `createFetchHandler()` (Workers, this Playground) | In-process (`connect()`, `create()`) |
| --- | --- | --- | --- |
| `traceContext` | Read from the request's `traceparent` header, or new | Read from the request's `traceparent` header, or new | New, unless a `create()` call's `metadata` continues a trace with `x-frontmcp-trace-id` |
| `metadata` | User agent, client IP, `x-frontmcp-*` headers | User agent, `x-frontmcp-*` headers, and the client IP when the runtime gives it (Bun, Deno, Cloudflare) or `FRONTMCP_TRUST_PROXY=1` is set | Empty with `connect()`; with `create()`, what each call's `metadata` option passes |
| `clientInfo`, `platform` | From `initialize`, or from each 2026-07-28 request's `_meta` | From each request's `_meta` | From `connect()`'s `clientInfo` option; not set with `create()` |
| The caller, in `this.auth` | From your auth configuration | From your auth configuration | Whatever the calling code passes: the user and their scopes |

Clients before MCP 2026-07-28 open a session with an `initialize` request that includes their `clientInfo`, and FrontMCP keeps it for the rest of the session. MCP 2026-07-28 has no sessions: a client sends its info in every request's `_meta`, as `io.modelcontextprotocol/clientInfo`, and FrontMCP reads it from there. The Playground calls itself `frontmcp.dev playground`, which isn't a platform FrontMCP recognizes, so `this.platform` is `"generic-mcp"`. The tests below send a request to a fetch handler with headers of their own, connect with FrontMCP's in-process client, `connect()`, which sends `initialize`, and call the tool with `create()`, which has no client at all (see [Running FrontMCP Anywhere](https://frontmcp.dev/learn/running-frontmcp-anywhere)):

```ts help-desk.app.ts active
import { App, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "describe_request", description: "Which client sent this request, and its trace and headers. For debugging.", inputSchema: {} })
export class DescribeRequest extends ToolContext {
  async execute() {
    return {
      client: this.clientInfo ?? null,
      platform: this.platform,
      traceparent: this.context.traceContext.raw,
      headers: this.context.metadata.customHeaders,
    };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [DescribeRequest] })
export class HelpDesk {}
```

```ts request.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance, connect, create } from "@frontmcp/sdk";
import { DescribeRequest, HelpDesk } from "./help-desk.app";

const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] };

test("the Playground names itself in every request", async ({ mcp }) => {
  const result = await mcp.tools.call("describe_request", {});
  expect(result.json()).toMatchObject({ client: { name: "frontmcp.dev playground", version: "1.0.0" }, platform: "generic-mcp" });
});

test("behind createFetchHandler(), the request's trace and headers reach the tool", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": "describe_request",
        traceparent,
        "x-frontmcp-desk": "berlin",
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/call",
        params: {
          name: "describe_request",
          arguments: {},
          _meta: {
            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
            "io.modelcontextprotocol/clientInfo": { name: "cursor", version: "1.4.0" },
          },
        },
      }),
    }),
  );
  const { result } = await response.json();
  expect(result.structuredContent).toEqual({
    client: { name: "cursor", version: "1.4.0" },
    platform: "cursor",
    traceparent,
    headers: { "x-frontmcp-desk": "berlin" },
  });
});

test("connect() sends the clientInfo you give it, and no headers", async () => {
  const client = await connect(config, { clientInfo: { name: "cursor", version: "1.4.0" } });
  const result = await client.callTool("describe_request", {});
  await client.close();
  expect(result).toMatchObject({ structuredContent: { client: { name: "cursor", version: "1.4.0" }, platform: "cursor", headers: {} } });
});

test("create() has no client, and passes the metadata each call gives it", async () => {
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [DescribeRequest] });
  const result = await server.callTool("describe_request", {}, { metadata: { customHeaders: { "x-frontmcp-desk": "berlin" } } });
  await server.dispose();
  expect(result.structuredContent).toMatchObject({ client: null, platform: "unknown", headers: { "x-frontmcp-desk": "berlin" } });
});
```

Either way it's the client's own description of itself, fine for choosing a format and useless as proof.

## Tagging logs and errors with the request id

When a tool fails for a reason that isn't the model's fault, like a database that's having a bad day, the model can only tell the user something went wrong. This tool does exactly that:

```ts reopen-ticket.tool.ts active
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { reopen } from "./store";

@Tool({
  name: "reopen_ticket",
  description: "Reopen a closed support ticket.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class ReopenTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    try {
      return await reopen(id);
    } catch {
      this.fail(new PublicMcpError("Something went wrong. Please try again later."));
    }
  }
}
```

```ts store.ts
const tickets = new Map([
  ["T-1", { id: "T-1", status: "closed" }],
  ["T-2", { id: "T-2", status: "closed" }],
]);

export async function reopen(id: string) {
  // T-2 stands in for a database that's having a bad day.
  if (id === "T-2") throw new Error("SQLITE_BUSY: database is locked (tickets.db)");
  const ticket = tickets.get(id);
  if (!ticket) throw new Error(`no row for ${id}`);
  ticket.status = "open";
  return ticket;
}
```

It's safe: the database error stays hidden. But when the user writes to support, "the assistant said something went wrong", nobody can find out which call failed or why. FrontMCP does log the failure, as a warning with an error id, but that line holds only the message the tool wrote: the database error is gone. And the message the model got has nothing for the user to quote.

Log the real error with `this.contextLogger`, and give the user a reference to quote:

```ts reopen-ticket.tool.ts active
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { reopen } from "./store";

@Tool({
  name: "reopen_ticket",
  description: "Reopen a closed support ticket.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class ReopenTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    try {
      return await reopen(id);
    } catch (err) {
      this.contextLogger.error(`reopen ${id} failed: ${String(err)}`);
      this.fail(
        new PublicMcpError(
          `The ticket system couldn't reopen ${id} just now. Try again in a minute. ` +
            `If it keeps failing, tell the user to give support this reference: ${this.context.requestId}`,
          "STORE_UNAVAILABLE",
        ),
      );
    }
  }
}
```

```ts store.ts
const tickets = new Map([
  ["T-1", { id: "T-1", status: "closed" }],
  ["T-2", { id: "T-2", status: "closed" }],
]);

export async function reopen(id: string) {
  // T-2 stands in for a database that's having a bad day.
  if (id === "T-2") throw new Error("SQLITE_BUSY: database is locked (tickets.db)");
  const ticket = tickets.get(id);
  if (!ticket) throw new Error(`no row for ${id}`);
  ticket.status = "open";
  return ticket;
}
```

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

test("the error gives a reference to quote", async ({ mcp }) => {
  const result = await mcp.tools.call("reopen_ticket", { id: "T-2" });
  expect(result.raw._meta?.code).toBe("STORE_UNAVAILABLE");
  expect(result.text()).toMatch(/reference: [0-9a-f-]{36}$/);
});

test("the database error stays out of the message", async ({ mcp }) => {
  const result = await mcp.tools.call("reopen_ticket", { id: "T-2" });
  expect(result.text()).not.toContain("SQLITE_BUSY");
});
```

Open the **Logs** tab. After the time, the error line shows two ids in brackets, like `[[1f0c2a9e:5d1b4c7e]]`: the first eight characters of the request id and of the trace id. The message the model got ends with the full request id, so support can search the logs for its first eight characters and find the real error. The same id also reaches other services: [`this.fetch()`](https://frontmcp.dev/learn/calling-other-services) sends it upstream as `x-request-id`.

## Who is calling

`this.auth` describes the caller: `user.sub` says who they are, `isAnonymous` whether they signed in, `scopes` and `roles` what they were granted, and `claims` what their token said. It's filled in from what your server's authentication checked, usually a token the client sent, so it's the one part of the request a tool can base a decision on. [Authenticating Clients](https://frontmcp.dev/learn/authenticating-clients) covers where it comes from, and [Authorizing Calls](https://frontmcp.dev/learn/authorizing-calls) what each field holds in each auth mode.

This tool lists the tickets assigned to whoever is calling. It has no argument for "who", because the caller doesn't get to choose:

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

const assigned: Record<string, string[]> = { nour: ["T-1", "T-3"], sam: ["T-2"] };

@Tool({
  name: "my_tickets",
  description: "List the support tickets assigned to the signed-in agent.",
  inputSchema: {},
  annotations: { readOnlyHint: true },
})
export class MyTickets extends ToolContext {
  async execute() {
    if (this.auth.isAnonymous) {
      this.fail(new PublicMcpError("Only signed-in agents have tickets. Ask the user to sign in.", "SIGN_IN_REQUIRED"));
    }
    const agent = this.auth.user.sub;
    return { agent, tickets: assigned[agent] ?? [] };
  }
}
```

```ts my-tickets.test.ts
import { test, expect } from "@frontmcp/testing";
import { create } from "@frontmcp/sdk";
import { MyTickets } from "./my-tickets.tool";

test("anonymous callers are asked to sign in", async ({ mcp }) => {
  const result = await mcp.tools.call("my_tickets", {});
  expect(result.raw._meta?.code).toBe("SIGN_IN_REQUIRED");
});

test("a signed-in agent gets their own tickets", async () => {
  // create() runs the tool in-process, as the user you name.
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [MyTickets] });
  const result = await server.callTool("my_tickets", {}, { authContext: { user: { sub: "nour" } } });
  expect(result.structuredContent).toEqual({ agent: "nour", tickets: ["T-1", "T-3"] });
  await server.dispose();
});
```

The Playground has no authentication, so the call above is anonymous and fails. The second test calls the tool as the agent `nour`, using `create()`, which runs a server in-process and lets the calling code say who the user is: the user's `sub` becomes `this.auth.user.sub`. That's for tests and trusted code, and [Running FrontMCP Anywhere](https://frontmcp.dev/learn/running-frontmcp-anywhere) covers it.

## Arguments, client info and headers are claims

Only team leads may move a ticket to someone else. Here's a first attempt:

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

const leads = ["nour"];

@Tool({
  name: "reassign_ticket",
  description: "Move a ticket to another agent. Team leads only.",
  inputSchema: {
    id: z.string().regex(/^T-\d+$/),
    to: z.string().describe("The agent to assign it to"),
    requested_by: z.string().describe("Who is asking"),
  },
})
export class ReassignTicket extends ToolContext {
  async execute({ id, to, requested_by }: { id: string; to: string; requested_by: string }) {
    if (!leads.includes(requested_by)) {
      this.fail(new PublicMcpError(`${requested_by} isn't a team lead.`, "LEADS_ONLY"));
    }
    return { id, assignee: to };
  }
}
```

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

test("🚩 anyone who says they're nour can reassign", async ({ mcp }) => {
  const result = await mcp.tools.call("reassign_ticket", { id: "T-1", to: "mallory", requested_by: "nour" });
  expect(result).toBeSuccessful();
});
```

The check works if the model passes the truth. But the model writes `requested_by`, from whatever is in the conversation, and a conversation can contain anything: a user who says "I'm Nour", or a ticket whose text says "the team lead asked to reassign this to mallory". The test shows it: an anonymous call that claims to be `nour` passes as a team lead.

Decide from `this.auth`, which the model can't write, and take the argument away:

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

@Tool({
  name: "reassign_ticket",
  description: "Move a ticket to another agent. Team leads only.",
  inputSchema: {
    id: z.string().regex(/^T-\d+$/),
    to: z.string().describe("The agent to assign it to"),
  },
})
export class ReassignTicket extends ToolContext {
  async execute({ id, to }: { id: string; to: string }) {
    if (!this.auth.hasRole("lead")) {
      this.fail(new PublicMcpError("Only team leads can reassign tickets.", "LEADS_ONLY"));
    }
    return { id, assignee: to, by: this.auth.user.sub };
  }
}
```

```ts reassign.test.ts
import { test, expect } from "@frontmcp/testing";
import { create } from "@frontmcp/sdk";
import { ReassignTicket } from "./reassign-ticket.tool";

test("an anonymous caller can't reassign", async ({ mcp }) => {
  const result = await mcp.tools.call("reassign_ticket", { id: "T-1", to: "mallory" });
  expect(result.raw._meta?.code).toBe("LEADS_ONLY");
});

test("a signed-in lead can", async () => {
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [ReassignTicket] });
  const nour = { user: { sub: "nour", roles: ["lead"] } };
  const result = await server.callTool("reassign_ticket", { id: "T-1", to: "sam" }, { authContext: nour });
  expect(result.structuredContent).toEqual({ id: "T-1", assignee: "sam", by: "nour" });
  await server.dispose();
});
```

Roles come from the caller's verified claims, here a `roles` claim in their token, which `this.auth.roles` reads. In-process, `authContext.user` stands in for those claims, so the test puts `roles` there. [Authorizing Calls](https://frontmcp.dev/learn/authorizing-calls) shows how to declare rules like this one so FrontMCP checks them before `execute()` runs at all.

Arguments aren't the only thing a client makes up. Everything in the request came from the client, and nothing but your authentication checks any of it:

- **Client info.** A client names itself in `clientInfo`, and FrontMCP guesses `this.platform` from that name. Any client can say it's `claude-ai`.
- **Headers.** `this.context.metadata` holds the `User-Agent` and every `x-frontmcp-*` header exactly as the client sent them. If you let [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch) pass the `x-frontmcp-*` ones on to a service, don't let that service trust them either.
- **`_meta`.** Trace ids, progress tokens and client capabilities travel in the request's `_meta`, written by the client.

Use these for what they're good for: formatting a result for a client, logging, joining up a trace. Base access decisions only on the caller your authentication established, in `this.auth`.

## Recap

- Everything about a call beyond its arguments is on `this`: `this.context` for the request, `this.auth` for the caller, `this.clientInfo` for the client.
- `this.context.requestId` is new for every request. Put it in log lines with `this.contextLogger`, and in errors a person may have to report.
- Take the caller's identity and claims from `this.auth`, never from an argument. The model writes the arguments.
- Client info, headers and `_meta` are whatever the client sent. Use them for formatting, logging and tracing, not for access.
- How much of the request reaches `this` depends on how the server runs: in-process calls have no headers and a new trace, and `create()` has no client info.
- Every member of `this.context` and `this.auth`, per entry point, is in the [`this.context`](https://frontmcp.dev/reference/sdk/context) and [`this.auth`](https://frontmcp.dev/reference/sdk/auth) references.

## Try some challenges

### Challenge: Give support a reference
`export_tickets` fails when the storage bucket rejects the upload, and its error passes the storage error straight to the model, access key and all. Keep the storage error in the logs, and give the model a message with a reference support can search for: this call's request id. Keep the code `EXPORT_FAILED`.

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

@Tool({
  name: "export_tickets",
  description: "Export every support ticket to a CSV file and return a link to it.",
  inputSchema: {},
})
export class ExportTickets extends ToolContext {
  async execute() {
    try {
      return { url: await upload("tickets.csv", "id,title\nT-1,Cannot log in\n") };
    } catch (err) {
      this.fail(new PublicMcpError(`Export failed: ${String(err)}`, "EXPORT_FAILED"));
    }
  }
}
```

```ts storage.ts
export async function upload(name: string, csv: string): Promise<string> {
  // Stands in for a bucket whose credentials were just rotated.
  throw new Error(`AccessDenied: key AKIA4EXAMPLE7Q is not allowed to write ${name} (${csv.length} bytes) to desk-exports`);
}
```

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

@Tool({
  name: "export_tickets",
  description: "Export every support ticket to a CSV file and return a link to it.",
  inputSchema: {},
})
export class ExportTickets extends ToolContext {
  async execute() {
    try {
      return { url: await upload("tickets.csv", "id,title\nT-1,Cannot log in\n") };
    } catch (err) {
      this.contextLogger.error(`export failed: ${String(err)}`);
      this.fail(
        new PublicMcpError(
          `The export couldn't be saved. Tell the user to try again later, and to give support this reference: ${this.context.requestId}`,
          "EXPORT_FAILED",
        ),
      );
    }
  }
}
```

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

const uuid = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/;

test("the error keeps the code `EXPORT_FAILED`", async ({ mcp }) => {
  const result = await mcp.tools.call("export_tickets", {});
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("EXPORT_FAILED");
});

test("the storage error stays out of the message", async ({ mcp }) => {
  const text = (await mcp.tools.call("export_tickets", {})).text() ?? "";
  expect(text).not.toContain("AccessDenied");
  expect(text).not.toContain("AKIA");
});

test("the message includes the request id as a reference", async ({ mcp }) => {
  const first = (await mcp.tools.call("export_tickets", {})).text() ?? "";
  const second = (await mcp.tools.call("export_tickets", {})).text() ?? "";
  expect(first).toMatch(uuid);
  expect(first.match(uuid)?.[0]).not.toBe(second.match(uuid)?.[0]);
});
```

**Hint:**
`this.contextLogger` writes a log line tagged with the request, and `this.context.requestId` is the id to put in the message.

**Solution:**
The storage error now goes to the log, in a line tagged with the first eight characters of the request id, and the model gets a message it can pass on with the full id. Each call has its own id, so support can find exactly the failure a user reports. The model never sees the key, which would reach it word for word in production too: a `PublicMcpError`'s message is shown as is.

### Challenge: Record who closed the ticket
`close_ticket` asks the model who is closing the ticket, so the audit trail says whatever the model wrote. Take the agent from the signed-in caller instead: remove `closed_by` from the input, record the caller's `user.sub`, and refuse anonymous callers with the code `SIGN_IN_REQUIRED`.

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

@Tool({
  name: "close_ticket",
  description: "Close a support ticket.",
  inputSchema: {
    id: z.string().regex(/^T-\d+$/),
    closed_by: z.string().describe("The agent closing the ticket"),
  },
})
export class CloseTicket extends ToolContext {
  async execute({ id, closed_by }: { id: string; closed_by: string }) {
    return { id, status: "closed", closed_by };
  }
}
```

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

@Tool({
  name: "close_ticket",
  description: "Close a support ticket.",
  inputSchema: { id: z.string().regex(/^T-\d+$/) },
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (this.auth.isAnonymous) {
      this.fail(new PublicMcpError("Only signed-in agents can close tickets. Ask the user to sign in.", "SIGN_IN_REQUIRED"));
    }
    return { id, status: "closed", closed_by: this.auth.user.sub };
  }
}
```

```ts close-ticket.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { create } from "@frontmcp/sdk";
import { CloseTicket } from "./close-ticket.tool";

test("`closed_by` isn't an argument any more", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "close_ticket");
  expect(Object.keys(tool?.inputSchema.properties ?? {})).toEqual(["id"]);
});

test("anonymous callers get `SIGN_IN_REQUIRED`", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("SIGN_IN_REQUIRED");
});

test("a signed-in agent is recorded as the closer", async () => {
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [CloseTicket] });
  try {
    const result = await server.callTool("close_ticket", { id: "T-1", closed_by: "mallory" }, { authContext: { user: { sub: "sam" } } });
    expect(result.structuredContent).toEqual({ id: "T-1", status: "closed", closed_by: "sam" });
  } finally {
    await server.dispose();
  }
});
```

**Hint:**
`this.auth.user.sub` is the caller, and `this.auth.isAnonymous` says whether they signed in. Remember that `this.fail()` ends the call, so nothing after it runs.

**Solution:**
The model can no longer name the closer, because there's nothing to fill in: FrontMCP drops `closed_by` if a client sends it anyway, as the last test does. The agent comes from `this.auth`, which the model can't touch, and an anonymous call fails before anything is recorded.

### Challenge: Don't unlock notes by client name
`customer_notes` returns internal notes only to the help desk's own console, which it recognizes by `clientInfo.name`. Any client can use that name. Show the notes to callers whose token has the `agent` role instead, whatever client they use, and refuse everyone else with the code `AGENTS_ONLY`.

```ts help-desk.app.ts
import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const notes: Record<string, string> = { "C-881": "Legacy plan. Don't offer a refund." };

@Tool({
  name: "customer_notes",
  description: "Internal notes about a customer. Help desk staff only.",
  inputSchema: { customer: z.string().describe("Customer id, like C-881") },
})
export class CustomerNotes extends ToolContext {
  async execute({ customer }: { customer: string }) {
    if (this.clientInfo?.name !== "desk-console") {
      this.fail(new PublicMcpError("Internal notes are only available in the help desk console.", "AGENTS_ONLY"));
    }
    return { customer, notes: notes[customer] ?? "" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [CustomerNotes] })
export class HelpDesk {}
```

```ts help-desk.app.ts solution
import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const notes: Record<string, string> = { "C-881": "Legacy plan. Don't offer a refund." };

@Tool({
  name: "customer_notes",
  description: "Internal notes about a customer. Help desk staff only.",
  inputSchema: { customer: z.string().describe("Customer id, like C-881") },
})
export class CustomerNotes extends ToolContext {
  async execute({ customer }: { customer: string }) {
    if (!this.auth.hasRole("agent")) {
      this.fail(new PublicMcpError("Internal notes are only available to help desk agents.", "AGENTS_ONLY"));
    }
    return { customer, notes: notes[customer] ?? "" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [CustomerNotes] })
export class HelpDesk {}
```

```ts notes.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { connect, create } from "@frontmcp/sdk";
import { CustomerNotes, HelpDesk } from "./help-desk.app";

const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] };

test("a client that calls itself desk-console gets no notes", async () => {
  const client = await connect(config, { clientInfo: { name: "desk-console", version: "1.0.0" } });
  const result = await client.callTool("customer_notes", { customer: "C-881" });
  await client.close();
  expect(result).toMatchObject({ isError: true, _meta: { code: "AGENTS_ONLY" } });
});

test("a caller with the `agent` role gets the notes", async () => {
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [CustomerNotes] });
  const sam = { user: { sub: "sam", roles: ["agent"] } };
  const result = await server.callTool("customer_notes", { customer: "C-881" }, { authContext: sam });
  await server.dispose();
  expect(result.structuredContent).toEqual({ customer: "C-881", notes: "Legacy plan. Don't offer a refund." });
});

test("anonymous callers get `AGENTS_ONLY`", async ({ mcp }) => {
  const result = await mcp.tools.call("customer_notes", { customer: "C-881" });
  expect(result.raw._meta?.code).toBe("AGENTS_ONLY");
});
```

**Hint:**
The tests use FrontMCP's in-process entry points, which let them choose the client's name and the signed-in user. Only one of those is checked by anything. The caller's roles are in `this.auth.roles`, as in `reassign_ticket` above.

**Solution:**
The first test connects with the console's name and no user, and the starter gave it the notes. A name is just a string the client sends. The solution checks the caller's claims instead, so the notes follow the signed-in agent wherever they work, and a client that only borrows the console's name gets nothing.
