# Deciding Who Can Call What

> How a FrontMCP tool finds out who is calling, refuses calls it shouldn't make with an error the model can act on, and declares who may use it with authorities.

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

[Authentication](https://frontmcp.dev/learn/authenticating-clients) tells your server who is calling. Authorization decides what they may do: anyone may search tickets, only support agents may close them, only admins may delete them. FrontMCP gives you two places to decide. A tool can check the caller inside `execute()`, and a tool can declare who may use it with `authorities`, so FrontMCP refuses the call, and hides the tool, before your code runs.

**You will learn**
- How to read who is calling inside `execute()`
- How to refuse a call with an error the model can act on
- How to declare who may call a tool with `authorities`
- How to hide tools from callers who can't use them
- Why `visibility: "hidden"` doesn't protect a tool

## A tool anyone can call

This `close_ticket` does its job. But in the Playground you're an anonymous caller, and it closed the ticket anyway:

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

const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }],
]);

@Tool({
  name: "close_ticket",
  description: "Close a support ticket.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    ticket.status = "closed";
    return { id, closed: true };
  }
}
```

Putting the server behind [a shared key or an identity provider](https://frontmcp.dev/learn/authenticating-clients) keeps strangers out, but everyone who gets in can still call every tool. A customer who signs in to check on their own ticket could close anyone's. The tool has to know who is calling, and decide.

## Reading who's calling

Inside `execute()`, `this.auth` describes the caller FrontMCP authenticated for this request:

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

@Tool({
  name: "whoami",
  description: "Say who the server thinks is calling.",
  inputSchema: {},
  annotations: { readOnlyHint: true },
})
export class WhoAmI extends ToolContext {
  async execute() {
    const { user, isAnonymous, scopes, roles } = this.auth;
    return { caller: user.sub, anonymous: isAnonymous, scopes, roles };
  }
}
```

```ts whoami.test.ts
import { test, expect } from "@frontmcp/testing";
import { create } from "@frontmcp/sdk";
import { WhoAmI } from "./whoami.tool";

test("the Playground's caller is anonymous", async ({ mcp }) => {
  const me = (await mcp.tools.call("whoami", {})).json();
  expect(me).toMatchObject({ caller: expect.stringMatching(/^anon:/), anonymous: true, scopes: ["anonymous"], roles: [] });
});

test("a signed-in agent has their id and roles", async () => {
  // create() runs the tool in-process, as the user you name, with the claims you give it.
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [WhoAmI] });
  const result = await server.callTool("whoami", {}, { authContext: { user: { sub: "nour", roles: ["agent"] } } });
  await server.dispose();
  expect(result.structuredContent).toMatchObject({ caller: "nour", anonymous: false, roles: ["agent"] });
});
```

What each field holds depends on the server's [auth mode](https://frontmcp.dev/learn/authenticating-clients#choosing-a-mode):

| Field | `public` | `static` | `transparent`, with a token |
| --- | --- | --- | --- |
| `user.sub` | `anon:` and a new id per request | `static:` and 12 hex characters | The token's `sub` |
| `isAnonymous` | `true` | `false` | `false` |
| `scopes` | The mode's `anonymousScopes`, `["anonymous"]` by default | The mode's `scopes`, `["static"]` by default | The token's `scope` claim, split on spaces, or its `scp` claim |
| `roles` | `[]` | `[]` | The token's `roles` claim |
| `claims` | `{ sub, iss: "public", name: "Anonymous", scope }` | `{ sub, iss: "static", name: "Static token", scope }` | Every claim in the token, such as `name` or `roles` |

`hasScope()`, `hasRole()` and `hasPermission()` check those lists for you. `claims` holds what the token said, and nothing else: FrontMCP verified the token's signature, but a `roles` claim means what your identity provider meant by it. If your provider keeps roles in another claim, the `claimsMapping` [further down](#declaring-who-may-call-a-tool) tells `this.auth` where to look.

`this.auth` is built from `this.context.authInfo`, the raw result of authentication: `clientId`, `scopes`, the token's claims in `user`, and `token`, the token itself. It's the same for every client, whatever MCP version it speaks. (Before 1.8.7, a client that kept a session, which means every version before 2026-07-28, saw `this.auth.scopes` empty and `hasScope()` always `false`, and a tool had to read `this.context.authInfo.scopes`.)

## Refusing a call the model can act on

When the caller isn't allowed to do something, stop with `this.fail()` and a `PublicMcpError` whose message tells the model what happened and what to do instead. Support agents sign in with tokens that carry the `tickets:write` scope; nobody else has it:

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

const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }],
]);

@Tool({
  name: "close_ticket",
  description: "Close a support ticket. Only signed-in support agents can close tickets.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (!this.auth.hasScope("tickets:write")) {
      this.fail(
        new PublicMcpError(
          "Only signed-in support agents can close tickets. Tell the user to sign in as an agent. Don't retry this call until they have.",
          "FORBIDDEN",
        ),
      );
    }
    const ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    ticket.status = "closed";
    return { id, closed: true };
  }
}
```

The result is an ordinary tool error, with `isError: true`. Three details make it useful:

1. **The message is written for the model.** It says what the rule is and what to do next. "Forbidden" alone would leave the model guessing, and likely retrying.
2. **`PublicMcpError` keeps it readable in production.** The message reaches the model word for word. A plain `Error` would become "Internal FrontMCP error" with an error ID.
3. **The second argument is a code.** It arrives as `_meta.code: "FORBIDDEN"`, so a client or a test can tell a refusal from other failures without parsing the text. (There's also a third argument, an HTTP status, but a tool error always goes out with HTTP `200`.)

Check before you change anything, so a refused call has no effects. And put the rule in the description too ("Only signed-in support agents can close tickets"), so a model can tell the user before it tries.

## Declaring who may call a tool

A check in `execute()` only runs once the tool is called. The tool is still listed for everyone, and in a server with many tools it's easy to forget the check in one of them. `authorities` puts the rule on the tool itself. FrontMCP checks it before `execute()` runs, and leaves the tool out of `tools/list` for callers who wouldn't pass. Rules are usually named once, as profiles, in `@FrontMcp`:

```ts main.ts active
import { App, FrontMcp } from "@frontmcp/sdk";
import { CloseTicket, DeleteTicket, SearchTickets } from "./tickets.tools";

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  authorities: {
    // Where to find the caller's roles: the token's `roles` claim.
    claimsMapping: { roles: "roles" },
    profiles: {
      agent: { roles: { any: ["agent", "admin"] } },
      admin: { roles: { any: ["admin"] } },
    },
  },
};

@FrontMcp(config)
export default class Server {}
```

```ts tickets.tools.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "open" }],
]);

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title.",
  inputSchema: { query: z.string().min(1) },
  annotations: { readOnlyHint: true },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    const q = query.toLowerCase();
    return { tickets: [...tickets.values()].filter((t) => t.title.toLowerCase().includes(q)) };
  }
}

@Tool({
  name: "close_ticket",
  description: "Close a support ticket.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  authorities: "agent",
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    ticket.status = "closed";
    return { id, closed: true };
  }
}

@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
  authorities: "admin",
})
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: tickets.delete(id) };
  }
}
```

```ts authorities.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

test("an anonymous caller only sees search_tickets", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t) => t.name);
  expect(names).toEqual(["search_tickets"]);
});

test("calling close_ticket by name is refused before execute() runs", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("AUTHORITY_DENIED");

  const found = await mcp.tools.call("search_tickets", { query: "log in" });
  expect(found.json().tickets[0]).toMatchObject({ id: "T-1", status: "open" });
});

test("an agent sees close_ticket and can call it", async () => {
  // createDirect() runs the server in-process, as the user you name.
  const server = await FrontMcpInstance.createDirect(config);
  const asNour = { authContext: { user: { sub: "nour", roles: ["agent"] } } };
  const { tools } = await server.listTools(asNour);
  const result = await server.callTool("close_ticket", { id: "T-2" }, asNour);
  await server.dispose();
  expect(tools.map((t) => t.name).sort()).toEqual(["close_ticket", "search_tickets"]);
  expect(result.structuredContent).toEqual({ id: "T-2", closed: true });
});
```

Open the **Capabilities** tab: an anonymous caller has no roles, so only `search_tickets` is listed. Calling `close_ticket` by name is refused anyway. The last test calls the server in-process as the agent `nour`, whose claims include `roles: ["agent"]`: `close_ticket` appears and works, and `delete_ticket` stays hidden. [Running FrontMCP Anywhere](https://frontmcp.dev/learn/running-frontmcp-anywhere) covers `createDirect()`.

The pieces:

- **`authorities` on a tool** names the rule: a profile like `"agent"`, a list of profiles that must all pass, like `["agent", "billing"]`, or a rule written in place.
- **`profiles` in `@FrontMcp({ authorities })`** names reusable rules. `roles: { any: [...] }` passes if the caller has at least one of the roles; `all` requires every one.
- **`claimsMapping`** tells FrontMCP where the caller's roles are in the token's claims. `roles: "roles"` reads the `roles` claim, and a path like `"realm_access.roles"` reads a nested one. `permissions` works the same way, and `this.auth.roles` follows the same mapping. When roles have to be worked out rather than read, `claimsResolver` takes a function instead, which gets `this.context.authInfo` and returns `{ roles, permissions, claims }`; rules and `this.auth` both use what it returns.

A tool with `authorities` on a server without an `authorities` option doesn't start: FrontMCP refuses, rather than leave the tool open by accident.

Rules can check more than roles:

| Rule | Passes when |
| --- | --- |
| `{ roles: { any: ["agent"] } }` | The caller has at least one of the roles. `all` requires all of them. |
| `{ permissions: { all: ["tickets:export"] } }` | The caller has every permission. `any` requires one. |
| `{ attributes: { conditions: [{ path: "input.id", op: "startsWith", value: "T-" }] } }` | Each condition holds. Paths start with `user.`, `claims.`, `input.` (the call's arguments) or `env.` (the server's environment variables). |
| `{ guards: [(ctx) => ...] }` | Every function returns `true`. Anything else refuses, `undefined` included, and a string becomes the reason. |
| `{ anyOf: [ruleA, ruleB] }` | At least one of the rules passes. `allOf` and `not` also exist. |

[Authorities](https://frontmcp.dev/reference/auth/authorities) has every rule form and operator, and the settings that look like they protect a tool but don't.

> **Pitfall: An authorities refusal is written for developers**
When `authorities` refuses a call, the result has `_meta.code: "AUTHORITY_DENIED"`, so a client or a test can tell it from other failures. Its text, in production too, is the rule that failed: `Access denied to Tool "help-desk:close_ticket": profile:agent: roles.any: user has none of 'agent', 'admin'`. That tells the model the call was refused, but not what to do next, and it tells the caller which roles would have let them in. Since the tool isn't in the caller's `tools/list`, a model rarely calls it. When a caller can see a tool but may not be allowed to use it on some tickets, check in `execute()` and fail with a message, as in the previous section. You can't add a friendlier message to an `authorities` refusal from `execute()`: for a refused call, `execute()` never runs.

### Scopes as permissions

Roles are one way to describe callers. Scopes are another: a token for a support agent carries `tickets:write`, and an anonymous caller in [transparent mode](https://frontmcp.dev/learn/authenticating-clients#letting-anonymous-callers-in-with-less) gets the `anonymousScopes` you set. Point `claimsMapping.permissions` at the `scope` claim, and FrontMCP splits it on spaces into permissions that rules can require:

```ts main.ts
@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  auth: {
    mode: "transparent",
    provider: "https://auth.example.com",
    allowAnonymous: true,
    anonymousScopes: ["tickets:read"],
  },
  authorities: {
    claimsMapping: { roles: "roles", permissions: "scope" },
    profiles: { reader: { permissions: { all: ["tickets:read"] } } },
  },
})
export default class Server {}
```

The last challenge on this page uses this setup.

> **Note**
A rule that reads `input.` can only be checked when there's a call. `tools/list` has no arguments to check it against, so FrontMCP counts the rule as failed there, and the tool disappears from `tools/list` for every caller, including the ones who could call it.

## Hiding a tool isn't protecting it

`visibility: "hidden"` looks like a way to keep a tool away from callers. It isn't:

```ts purge.tools.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "purge_closed_tickets",
  description: "Delete every closed ticket.",
  inputSchema: {},
  annotations: { destructiveHint: true },
  visibility: "hidden",
})
export class PurgeClosedTickets extends ToolContext {
  async execute() {
    return { purged: 12 };
  }
}

@Tool({
  name: "rebuild_search_index",
  description: "Rebuild the ticket search index.",
  inputSchema: {},
  visibility: "internal",
})
export class RebuildSearchIndex extends ToolContext {
  async execute() {
    return { rebuilt: true };
  }
}

@Tool({ name: "get_ticket", description: "Get one ticket by id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}
```

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

test("neither tool is listed", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t) => t.name);
  expect(names).toEqual(["get_ticket"]);
});

test("a hidden tool still runs for anyone who knows its name", async ({ mcp }) => {
  const result = await mcp.tools.call("purge_closed_tickets", {});
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({ purged: 12 });
});

test("an internal tool can't be called by clients", async ({ mcp }) => {
  const result = await mcp.tools.call("rebuild_search_index", {});
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("TOOL_NOT_FOUND");
});
```

Both tools are missing from the Capabilities tab, but the **Tests** tab shows the difference. Anyone who knows the name of a hidden tool can call it, and names aren't secrets: they're in your code, your docs and old conversations. Hidden is for tools the model doesn't need to see, such as one your own client code calls by name; it keeps them out of the list, nothing more. An internal tool isn't reachable by clients at all; a call gets `Tool "rebuild_search_index" not found`.

| | In `tools/list` | Callable by clients |
| --- | --- | --- |
| No option (`visibility: "public"`) | Yes | Yes |
| `visibility: "hidden"` | No, for everyone | Yes, by anyone who knows the name |
| `visibility: "internal"` | No | No |
| `authorities: …` | Only for callers the rule lets through | Only by callers the rule lets through |

To keep a tool from a caller, use `authorities` or a check in `execute()`. Visibility only decides what a list shows.

## Recap

- `this.auth` holds the caller: `user.sub`, `isAnonymous`, `scopes`, `roles` and the token's `claims`, with checks like `hasScope()` and `hasRole()`.
- To refuse a call, `this.fail(new PublicMcpError(message, "FORBIDDEN"))` before changing anything, with a message that tells the model what to do next.
- `authorities` on a tool, with profiles and a `claimsMapping` in `@FrontMcp({ authorities })`, refuses calls before `execute()` and hides the tool from callers who can't use it.
- An `authorities` refusal has the code `AUTHORITY_DENIED` and a message written for developers, so use `execute()` checks where the model needs an explanation.
- `visibility: "hidden"` only hides a tool from `tools/list`; anyone can still call it. `internal` tools can't be called by clients.
- Every property and method of `this.auth`, per auth mode and entry point, is in the [`this.auth` reference](https://frontmcp.dev/reference/sdk/auth).

## Try some challenges

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

### Challenge: Refuse anonymous callers
`reopen_ticket` reopens a closed ticket for anyone. Make it refuse anonymous callers with a tool error whose code is `FORBIDDEN` and whose message tells the model to ask the user to sign in. A refused call must leave the ticket closed.

```ts tickets.tools.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }],
]);

@Tool({ name: "get_ticket", description: "Get one support ticket by id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}

@Tool({
  name: "reopen_ticket",
  description: "Reopen a closed support ticket.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
})
export class ReopenTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    ticket.status = "open";
    return { id, reopened: true };
  }
}
```

```ts tickets.tools.ts solution
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }],
]);

@Tool({ name: "get_ticket", description: "Get one support ticket by id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}

@Tool({
  name: "reopen_ticket",
  description: "Reopen a closed support ticket. Only signed-in users can reopen tickets.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
})
export class ReopenTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (this.auth.isAnonymous) {
      this.fail(new PublicMcpError("Only signed-in users can reopen tickets. Ask the user to sign in, then try again.", "FORBIDDEN"));
    }
    const ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    ticket.status = "open";
    return { id, reopened: true };
  }
}
```

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

test("an anonymous call is refused with code `FORBIDDEN`", async ({ mcp }) => {
  const result = await mcp.tools.call("reopen_ticket", { id: "T-2" });
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("FORBIDDEN");
});

test("the message tells the model to have the user sign in", async ({ mcp }) => {
  const result = await mcp.tools.call("reopen_ticket", { id: "T-2" });
  expect(result.text()).toMatch(/sign in/i);
});

test("a refused call leaves the ticket closed", async ({ mcp }) => {
  await mcp.tools.call("reopen_ticket", { id: "T-2" });
  const ticket = await mcp.tools.call("get_ticket", { id: "T-2" });
  expect(ticket.json().status).toBe("closed");
});
```

**Hint:**
`this.auth.isAnonymous` is `true` for a caller without credentials. Check it before you touch the ticket.

**Solution:**
The check comes first, so a refused call changes nothing, and `this.fail()` with a `PublicMcpError` sends the message word for word with `_meta.code: "FORBIDDEN"`. The description now states the rule too, so a model can mention it before calling. With a shared key or a token, `isAnonymous` would be `false`, and the call would go through.

### Challenge: Really keep the delete tool from callers
Someone hid `delete_ticket` with `visibility: "hidden"`, but anyone who knows its name can still delete tickets. The server already has an `admin` profile. Make `delete_ticket` both invisible to and uncallable by anyone who isn't an admin.

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

@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
  visibility: "hidden",
})
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: tickets.delete(id) };
  }
}
```

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

@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good. Admins only.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { destructiveHint: true },
  authorities: "admin",
})
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: tickets.delete(id) };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { DeleteTicket } from "./delete-ticket.tool";
import { GetTicket } from "./get-ticket.tool";

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

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

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

@Tool({ name: "get_ticket", description: "Get one support ticket by id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}
```

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

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

test("`delete_ticket` isn't listed for an anonymous caller", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t) => t.name);
  expect(names).not.toContain("delete_ticket");
});

test("calling `delete_ticket` by name is refused", async ({ mcp }) => {
  const result = await mcp.tools.call("delete_ticket", { id: "T-1" });
  expect(result).toBeError();
});

test("the ticket is still there afterwards", async ({ mcp }) => {
  await mcp.tools.call("delete_ticket", { id: "T-1" });
  expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeSuccessful();
});
```

**Hint:**
`visibility` only changes what `tools/list` shows. The option that checks the caller on every call names a profile.

**Solution:**
`authorities: "admin"` makes FrontMCP check the `admin` profile on every call, before `execute()` runs, and leave the tool out of `tools/list` for callers who fail it. `visibility: "hidden"` has to go too: with it, the tool would be hidden even from admins. The description says "Admins only", for the admins who do see it.

### Challenge: Require a scope to export
Anonymous callers on this server get the scope `tickets:read`, and `main.ts` turns scopes into permissions. `search_tickets` requires `tickets:read`. Make `export_tickets` require the permission `tickets:export`, so that anonymous callers can neither see nor call it.

```ts tickets.tools.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

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" },
];

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title.",
  inputSchema: { query: z.string().min(1) },
  authorities: "reader",
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    const q = query.toLowerCase();
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(q)) };
  }
}

@Tool({
  name: "export_tickets",
  description: "Export every support ticket, with customer details, as CSV.",
  inputSchema: {},
})
export class ExportTickets extends ToolContext {
  async execute() {
    return { csv: ["id,title,status", ...tickets.map((t) => `${t.id},${t.title},${t.status}`)].join("\n") };
  }
}
```

```ts tickets.tools.ts solution
import { Tool, ToolContext, z } from "@frontmcp/sdk";

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" },
];

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title.",
  inputSchema: { query: z.string().min(1) },
  authorities: "reader",
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    const q = query.toLowerCase();
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(q)) };
  }
}

@Tool({
  name: "export_tickets",
  description: "Export every support ticket, with customer details, as CSV. Needs the tickets:export scope.",
  inputSchema: {},
  authorities: { permissions: { all: ["tickets:export"] } },
})
export class ExportTickets extends ToolContext {
  async execute() {
    return { csv: ["id,title,status", ...tickets.map((t) => `${t.id},${t.title},${t.status}`)].join("\n") };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { ExportTickets, SearchTickets } from "./tickets.tools";

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  auth: {
    mode: "transparent",
    provider: "https://auth.example.com",
    allowAnonymous: true,
    anonymousScopes: ["tickets:read"],
  },
  authorities: {
    claimsMapping: { roles: "roles", permissions: "scope" },
    profiles: { reader: { permissions: { all: ["tickets:read"] } } },
  },
})
export default class Server {}
```

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

test("anonymous callers don't see `export_tickets`", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t) => t.name);
  expect(names).not.toContain("export_tickets");
});

test("calling `export_tickets` by name is refused", async ({ mcp }) => {
  expect(await mcp.tools.call("export_tickets", {})).toBeError();
});

test("anonymous callers can still search", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t) => t.name);
  expect(names).toContain("search_tickets");
  const result = await mcp.tools.call("search_tickets", { query: "log" });
  expect(result).toBeSuccessful();
  expect(result.json().tickets).toHaveLength(2);
});
```

**Hint:**
`search_tickets` uses a profile from `main.ts`. For one tool, you can also write the rule in place: `{ permissions: { all: [...] } }`.

**Solution:**
`authorities: { permissions: { all: ["tickets:export"] } }` requires the permission that `main.ts` builds from a `tickets:export` scope. Anonymous callers only have `tickets:read`, so FrontMCP leaves the tool out of their `tools/list` and refuses it by name. An agent whose token has `scope: "tickets:read tickets:export"` sees and calls both tools. If more tools need the same rule, name it as a profile next to `reader`.
