# Grouping Capabilities into Apps

> How @App groups tools, resources, prompts and providers, how a @FrontMcp server composes several apps, what happens when their names collide, and how apps share a provider.

Source: https://frontmcp.dev/learn/grouping-capabilities-into-apps

As a server grows, its tools, resources and prompts fall into areas: tickets, billing, a knowledge base. An **app** (`@App`) is one of those areas, with the capabilities that belong to it and the providers they use. A **server** (`@FrontMcp`) puts several apps behind one endpoint. Clients never see the apps, only one list of everything, so splitting a server into apps is mostly about organising your code. It does change two things, and this lesson covers both: what happens when names collide, and which providers each app can reach.

**You will learn**
- What an `@App` groups, and how a `@FrontMcp` server lists apps
- What clients see when a server has several apps
- What FrontMCP does when two apps use the same name
- Why a provider registered on one app isn't available to another, and how to share it
- When it's worth splitting a server into apps

## One app

Here is the help desk from the previous lessons, laid out as a small project: a server in `main.ts`, and one app with its tools, resource, prompt and provider in its own folder.

```ts main.ts active
import { FrontMcp } from "@frontmcp/sdk";
import { TicketsApp } from "./apps/tickets/tickets.app";

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [TicketsApp],
})
export default class HelpDeskServer {}
```

```ts apps/tickets/tickets.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket, SearchTickets } from "./tools";
import { TicketResource } from "./ticket.resource";
import { TriageTicket } from "./triage.prompt";
import { TicketStore } from "./ticket-store";

@App({
  id: "tickets",
  name: "Tickets",
  description: "Search, read and triage support tickets",
  tools: [SearchTickets, GetTicket],
  resources: [TicketResource],
  prompts: [TriageTicket],
  providers: [TicketStore],
})
export class TicketsApp {}
```

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

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title",
  inputSchema: { query: z.string().min(2) },
  annotations: { readOnlyHint: true },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: this.get(TicketStore).search(query) };
  }
}

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = this.get(TicketStore).get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}
```

```ts apps/tickets/ticket.resource.ts
import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";

@ResourceTemplate({
  name: "ticket",
  uriTemplate: "tickets://{id}",
  mimeType: "application/json",
  description: "One support ticket",
})
export class TicketResource extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    const ticket = this.get(TicketStore).get(id);
    if (!ticket) throw new ResourceNotFoundError(uri);
    return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] };
  }
}
```

```ts apps/tickets/triage.prompt.ts
import { Prompt, PromptContext, type GetPromptResult } from "@frontmcp/sdk";

@Prompt({
  name: "triage_ticket",
  title: "Triage a ticket",
  description: "Suggest a priority and an owning team for one ticket",
  arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }],
})
export class TriageTicket extends PromptContext {
  async execute({ id }: { id: string }): Promise<GetPromptResult> {
    return {
      messages: [
        { role: "user", content: { type: "text", text: `Read ticket ${id} with get_ticket, then suggest a priority and an owning team.` } },
      ],
    };
  }
}
```

```ts apps/tickets/ticket-store.ts
import { Provider, ProviderScope } from "@frontmcp/sdk";

type Ticket = { id: string; title: string; status: "open" | "closed" };

@Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL })
export class TicketStore {
  private tickets: Ticket[] = [
    { 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" },
    { id: "T-4", title: "Charged twice in March", status: "open" },
  ];
  search(query: string) {
    return this.tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase()));
  }
  get(id: string) {
    return this.tickets.find((t) => t.id === id);
  }
  close(id: string) {
    const ticket = this.get(id);
    if (ticket) ticket.status = "closed";
    return ticket;
  }
}
```

Two decorators hold it together:

- **`@App`** lists what belongs to one area: its `tools`, `resources`, `prompts`, and the `providers` they get with `this.get()`. `id` is a short, stable identifier that FrontMCP uses to tell apps apart, and `name` and `description` are for people reading your code and logs. A capability that isn't listed in an app doesn't exist as far as clients are concerned.
- **`@FrontMcp`** is the server. It lists `apps` and holds server-wide settings like `info`, which clients see as the server's name and version.

Open the **Capabilities** tab. It shows two tools, a template and a prompt, and nothing about an app called Tickets. MCP has no idea of apps: `tools/list`, `resources/templates/list` and `prompts/list` each return one flat list. (The [`@App`](https://frontmcp.dev/reference/sdk/app) and [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp) references list every option.)

## A server with several apps

Billing is a different area, with its own data and its own tools, so it gets its own app. The server lists both:

```ts main.ts active
import { FrontMcp } from "@frontmcp/sdk";
import { TicketsApp } from "./apps/tickets/tickets.app";
import { BillingApp } from "./apps/billing/billing.app";

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [TicketsApp, BillingApp],
})
export default class HelpDeskServer {}
```

```ts apps/billing/billing.app.ts
import { App } from "@frontmcp/sdk";
import { GetInvoice, RefundInvoice } from "./tools";

@App({
  id: "billing",
  name: "Billing",
  description: "Look up and refund invoices",
  tools: [GetInvoice, RefundInvoice],
})
export class BillingApp {}
```

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

const invoices = [
  { id: "INV-7", customer: "Acme Corp", total: 1200, status: "paid", ticket: "T-4" },
  { id: "INV-8", customer: "Globex", total: 300, status: "paid", ticket: null },
];

@Tool({
  name: "get_invoice",
  description: "Get one invoice by its id, like INV-7",
  inputSchema: { id: z.string() },
  annotations: { readOnlyHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const invoice = invoices.find((i) => i.id === id);
    if (!invoice) this.fail(new PublicMcpError(`There's no invoice ${id}.`));
    return invoice;
  }
}

@Tool({
  name: "refund_invoice",
  description: "Refund an invoice in full",
  inputSchema: { id: z.string() },
})
export class RefundInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const invoice = invoices.find((i) => i.id === id);
    if (!invoice) this.fail(new PublicMcpError(`There's no invoice ${id}.`));
    invoice.status = "refunded";
    return invoice;
  }
}
```

```ts apps/tickets/tickets.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket, SearchTickets } from "./tools";
import { TicketStore } from "./ticket-store";

@App({
  id: "tickets",
  name: "Tickets",
  description: "Search and read support tickets",
  tools: [SearchTickets, GetTicket],
  providers: [TicketStore],
})
export class TicketsApp {}
```

```ts apps/tickets/tools.ts hidden
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title",
  inputSchema: { query: z.string().min(2) },
  annotations: { readOnlyHint: true },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: this.get(TicketStore).search(query) };
  }
}

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = this.get(TicketStore).get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}
```

```ts apps/tickets/ticket-store.ts hidden
import { Provider, ProviderScope } from "@frontmcp/sdk";

type Ticket = { id: string; title: string; status: "open" | "closed" };

@Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL })
export class TicketStore {
  private tickets: Ticket[] = [
    { 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" },
    { id: "T-4", title: "Charged twice in March", status: "open" },
  ];
  search(query: string) {
    return this.tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase()));
  }
  get(id: string) {
    return this.tickets.find((t) => t.id === id);
  }
  close(id: string) {
    const ticket = this.get(id);
    if (ticket) ticket.status = "closed";
    return ticket;
  }
}
```

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

test("clients see one list, with every tool under its own name", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t: { name: string }) => t.name);
  expect(names).toEqual(["get_invoice", "get_ticket", "refund_invoice", "search_tickets"]);
});

test("each tool still works from its own app", async ({ mcp }) => {
  expect((await mcp.tools.call("get_invoice", { id: "INV-7" })).json()).toMatchObject({ total: 1200 });
  expect((await mcp.tools.call("get_ticket", { id: "T-4" })).json()).toMatchObject({ status: "open" });
});
```

The **Tests** tab shows what a client sees: four tools in one list, each under the name it was declared with. Nothing tells the client which app a tool came from. For a client, this server is no different from one app with four tools.

## When two apps use the same name

Each app is written on its own, often by a different person, so it's easy for two of them to pick the same name. Say both teams called their search tool `search`:

```ts main.ts active
import { FrontMcp } from "@frontmcp/sdk";
import { TicketsApp } from "./apps/tickets.app";
import { BillingApp } from "./apps/billing.app";

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [TicketsApp, BillingApp],
})
export default class HelpDeskServer {}
```

```ts apps/tickets.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in" },
  { id: "T-3", title: "Login link expired" },
];

@Tool({ name: "search", description: "Search support tickets by title", inputSchema: { query: z.string() } })
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) };
  }
}

@App({ id: "tickets", name: "Tickets", tools: [SearchTickets] })
export class TicketsApp {}
```

```ts apps/billing.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

const invoices = [
  { id: "INV-7", customer: "Acme Corp" },
  { id: "INV-8", customer: "Globex" },
];

@Tool({ name: "search", description: "Search invoices by customer name", inputSchema: { query: z.string() } })
export class SearchInvoices extends ToolContext {
  async execute({ query }: { query: string }) {
    return { invoices: invoices.filter((i) => i.customer.toLowerCase().includes(query.toLowerCase())) };
  }
}

@App({ id: "billing", name: "Billing", tools: [SearchInvoices] })
export class BillingApp {}
```

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

test("both tools are renamed to `<app id>:search`", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t: { name: string }) => t.name);
  expect(names).toEqual(["billing:search", "tickets:search"]);
});

test("neither has a title to tell them apart", async ({ mcp }) => {
  const titles = (await mcp.tools.list()).map((t: { title?: string }) => t.title);
  expect(titles).toEqual([undefined, undefined]);
});

test("each can be called by its new name", async ({ mcp }) => {
  expect((await mcp.tools.call("billing:search", { query: "acme" })).json()).toHaveProperty("invoices");
  expect((await mcp.tools.call("tickets:search", { query: "log" })).json()).toHaveProperty("tickets");
});

test("plain `search` still works, and reaches the first app in `apps`", async ({ mcp }) => {
  expect((await mcp.tools.call("search", { query: "log" })).json()).toHaveProperty("tickets");
});
```

FrontMCP doesn't refuse to start. When a tool name appears in more than one app, it renames **every** tool with that name to `<app id>:<name>`, and logs a warning about the conflict (look in the **Logs** tab). Names that don't collide are left alone. Open the **Tests** tab to see the result.

That keeps each tool reachable, but it isn't a good place to stay:

- **The names depend on what else is installed.** `search` was called `search` until the billing app arrived. Anything that referred to it by name, like a prompt that says "call search", now means something else.
- **The old name still answers.** A client that calls plain `search` reaches the tool in the first app listed in `apps`, here the ticket search, with no error to say it was ambiguous.
- **The model has only the prefixes and the descriptions to tell them apart.** Neither tool declared a `title`, so `tools/list` has none for them, and an app id like `billing` is a word you picked for your code, not for the model.

The fix is to give tools names that are unique across the whole server, and that say what they search: `search_tickets` and `search_invoices`. Treat the prefixes as a warning, not as a way to namespace tools.

> **Pitfall: Resource URIs are never renamed**
FrontMCP can't rename a URI, because the URI is the address clients read. If two apps both serve `policy://sla`, the first app in `apps` serves it, and the other app's resource is neither listed nor read. FrontMCP logs a warning that names both (look in the **Logs** tab), and starts anyway:

```ts main.ts active
import { FrontMcp } from "@frontmcp/sdk";
import { TicketsApp, BillingApp } from "./apps";

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [TicketsApp, BillingApp],
})
export default class HelpDeskServer {}
```

```ts apps.ts
import { App, Resource, ResourceContext } from "@frontmcp/sdk";

@Resource({ name: "sla", uri: "policy://sla", mimeType: "text/markdown" })
export class TicketsSla extends ResourceContext {
  async execute(uri: string) {
    return "# Support SLA\n\nFirst reply within 4 business hours.";
  }
}

@Resource({ name: "sla", uri: "policy://sla", mimeType: "text/markdown" })
export class BillingSla extends ResourceContext {
  async execute(uri: string) {
    return "# Billing SLA\n\nRefunds are processed within 5 business days.";
  }
}

@App({ id: "tickets", name: "Tickets", resources: [TicketsSla] })
export class TicketsApp {}

@App({ id: "billing", name: "Billing", resources: [BillingSla] })
export class BillingApp {}
```

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

test("only the first app's resource is listed", async ({ mcp }) => {
  const listed = (await mcp.resources.list()).map((r: { name: string; uri: string }) => [r.name, r.uri]);
  expect(listed).toEqual([["sla", "policy://sla"]]);
});

test("reading the URI returns the first app's SLA, never the billing one", async ({ mcp }) => {
  expect((await mcp.resources.read("policy://sla")).text()).toContain("Support SLA");
});
```

Give each app its own URI scheme, like `tickets://sla` and `billing://sla`, so their addresses can't meet.

## Sharing a provider between apps

When an invoice is refunded, the ticket that asked for the refund should close. So the billing app's `refund_invoice` needs the `TicketStore`, which belongs to the tickets app:

```ts apps/billing/tools.ts active
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "../tickets/ticket-store";

const invoices = [
  { id: "INV-7", customer: "Acme Corp", total: 1200, status: "paid", ticket: "T-4" },
  { id: "INV-8", customer: "Globex", total: 300, status: "paid", ticket: null },
];

@Tool({
  name: "refund_invoice",
  description: "Refund an invoice in full, and close the ticket that asked for it",
  inputSchema: { id: z.string() },
})
export class RefundInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const invoice = invoices.find((i) => i.id === id);
    if (!invoice) this.fail(new PublicMcpError(`There's no invoice ${id}.`));
    invoice.status = "refunded";
    if (invoice.ticket) this.get(TicketStore).close(invoice.ticket);
    return invoice;
  }
}
```

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { TicketsApp } from "./apps/tickets/tickets.app";
import { BillingApp } from "./apps/billing/billing.app";

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [TicketsApp, BillingApp],
})
export default class HelpDeskServer {}
```

```ts apps/billing/billing.app.ts
import { App } from "@frontmcp/sdk";
import { RefundInvoice } from "./tools";

@App({ id: "billing", name: "Billing", tools: [RefundInvoice] })
export class BillingApp {}
```

```ts apps/tickets/tickets.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket } from "./tools";
import { TicketStore } from "./ticket-store";

@App({ id: "tickets", name: "Tickets", tools: [GetTicket], providers: [TicketStore] })
export class TicketsApp {}
```

```ts apps/tickets/tools.ts hidden
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = this.get(TicketStore).get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}
```

```ts apps/tickets/ticket-store.ts
import { Provider, ProviderScope } from "@frontmcp/sdk";

type Ticket = { id: string; title: string; status: "open" | "closed" };

@Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL })
export class TicketStore {
  private tickets: Ticket[] = [
    { 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" },
    { id: "T-4", title: "Charged twice in March", status: "open" },
  ];
  get(id: string) {
    return this.tickets.find((t) => t.id === id);
  }
  close(id: string) {
    const ticket = this.get(id);
    if (ticket) ticket.status = "closed";
    return ticket;
  }
}
```

The call fails with `Provider "TicketStore" is not available: not found in local or parent registries`. Importing the class isn't enough. A provider registered on an app is **private to that app**: FrontMCP looks for it in the calling tool's app, then in the server, and `TicketStore` is in neither place for a billing tool.

Move it up to the server. Providers listed in `@FrontMcp({ providers })` are available to every app, and with `ProviderScope.GLOBAL` there's one instance for all of them:

```ts main.ts active
import { FrontMcp } from "@frontmcp/sdk";
import { TicketsApp } from "./apps/tickets/tickets.app";
import { BillingApp } from "./apps/billing/billing.app";
import { TicketStore } from "./shared/ticket-store";

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [TicketsApp, BillingApp],
  providers: [TicketStore],
})
export default class HelpDeskServer {}
```

```ts apps/tickets/tickets.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket } from "./tools";

@App({ id: "tickets", name: "Tickets", tools: [GetTicket] })
export class TicketsApp {}
```

```ts apps/billing/billing.app.ts
import { App } from "@frontmcp/sdk";
import { RefundInvoice } from "./tools";

@App({ id: "billing", name: "Billing", tools: [RefundInvoice] })
export class BillingApp {}
```

```ts apps/billing/tools.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "../../shared/ticket-store";

const invoices = [
  { id: "INV-7", customer: "Acme Corp", total: 1200, status: "paid", ticket: "T-4" },
  { id: "INV-8", customer: "Globex", total: 300, status: "paid", ticket: null },
];

@Tool({
  name: "refund_invoice",
  description: "Refund an invoice in full, and close the ticket that asked for it",
  inputSchema: { id: z.string() },
})
export class RefundInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const invoice = invoices.find((i) => i.id === id);
    if (!invoice) this.fail(new PublicMcpError(`There's no invoice ${id}.`));
    invoice.status = "refunded";
    if (invoice.ticket) this.get(TicketStore).close(invoice.ticket);
    return invoice;
  }
}
```

```ts apps/tickets/tools.ts hidden
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "../../shared/ticket-store";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { readOnlyHint: true },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = this.get(TicketStore).get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}
```

```ts shared/ticket-store.ts
import { Provider, ProviderScope } from "@frontmcp/sdk";

type Ticket = { id: string; title: string; status: "open" | "closed" };

@Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL })
export class TicketStore {
  private tickets: Ticket[] = [
    { 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" },
    { id: "T-4", title: "Charged twice in March", status: "open" },
  ];
  get(id: string) {
    return this.tickets.find((t) => t.id === id);
  }
  close(id: string) {
    const ticket = this.get(id);
    if (ticket) ticket.status = "closed";
    return ticket;
  }
}
```

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

test("refunding INV-7 closes T-4, which the tickets app then sees", async ({ mcp }) => {
  expect((await mcp.tools.call("get_ticket", { id: "T-4" })).json()).toMatchObject({ status: "open" });
  const refund = await mcp.tools.call("refund_invoice", { id: "INV-7" });
  expect(refund).toBeSuccessful();
  expect((await mcp.tools.call("get_ticket", { id: "T-4" })).json()).toMatchObject({ status: "closed" });
});
```

The store moved to `shared/`, `TicketsApp` no longer lists it, and the server does. Both apps' tools get it with the same `this.get(TicketStore)`, and the **Tests** tab shows a refund in one app closing a ticket that the other app then reads as closed.

> **Pitfall: Registering a provider in two apps makes two of them**
It's tempting to fix the error by adding `TicketStore` to `BillingApp`'s `providers` as well. The error goes away, but each app now builds its own `TicketStore`, even with `ProviderScope.GLOBAL`. The refund closes T-4 in billing's copy, and the tickets app keeps saying it's open. The last challenge below starts from exactly this mistake. If apps need to share state, register the provider once, on the server.

## When to split into apps

Apps are mostly for you: clients get the same flat list either way. Splitting changes two things at runtime, both covered above: providers registered on an app are private to it, and names that collide across apps get prefixed. That suggests when to split and when not to:

- **Split along the areas of your domain.** Tickets, billing and a knowledge base each have their own data, their own services and their own words for things. An app per area keeps each one's providers out of the others' reach.
- **Split where ownership splits.** If a different team owns billing, its app is the unit they change, review and test, and it's a class another `@FrontMcp` server could list too.
- **Put what several apps need on the server.** A ticket store that billing also uses, a clock, an HTTP client with your credentials: register it in `@FrontMcp({ providers })` once.
- **Don't split just to organise files.** Folders do that. Two apps that share everything are one app with extra wiring.
- **Keep names unique across the server.** The model sees one list, so `search_tickets` and `search_invoices` are better than two `search` tools in two apps.

## Recap

- `@App` groups the tools, resources, prompts and providers of one area. `@FrontMcp` is the server that lists the apps and holds settings like `info`.
- Clients don't see apps. `tools/list`, `resources/list` and `prompts/list` each return one flat list across all apps.
- When two apps use the same tool name, FrontMCP renames each to `<app id>:<name>` and logs a warning. The bare name still reaches the first app. Pick names that are unique across the server instead.
- Resource URIs are never renamed. When two apps serve the same URI, only the first app's resource is listed and read. Give each app its own scheme.
- A provider registered on an app is private to that app. Register providers that several apps need on the server, once.

## Try some challenges

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

### Challenge: Register the billing app
The billing app is written, but nobody can call `get_invoice`. Add the app to the server, and name the server `Help Desk`.

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { TicketsApp } from "./apps/tickets.app";

@FrontMcp({
  info: { name: "Untitled", version: "1.0.0" },
  apps: [TicketsApp],
})
export default class HelpDeskServer {}
```

```ts main.ts solution
import { FrontMcp } from "@frontmcp/sdk";
import { TicketsApp } from "./apps/tickets.app";
import { BillingApp } from "./apps/billing.app";

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [TicketsApp, BillingApp],
})
export default class HelpDeskServer {}
```

```ts apps/tickets.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

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

@App({ id: "tickets", name: "Tickets", tools: [GetTicket] })
export class TicketsApp {}
```

```ts apps/billing.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "get_invoice", description: "Get one invoice by its id, like INV-7", inputSchema: { id: z.string() } })
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, customer: "Acme Corp", total: 1200 };
  }
}

@App({ id: "billing", name: "Billing", tools: [GetInvoice] })
export class BillingApp {}
```

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

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

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

test("the server is called Help Desk", async ({ mcp }) => {
  const response = await mcp.raw.request({ method: "tools/list" });
  expect(response.result._meta["io.modelcontextprotocol/serverInfo"].name).toBe("Help Desk");
});
```

**Hint:**
An app only exists for clients once it's in the server's `apps` array. Import it into `main.ts` first.

**Solution:**
`apps: [TicketsApp, BillingApp]` puts both apps behind the server, and their tools appear in one list. `info.name` is what clients see as the server's name: FrontMCP sends it in the `_meta` of every result.

### Challenge: Fix a name collision
Both apps call their search tool `search`, so clients see `billing:search` and `tickets:search`. Rename them so each has a clear name of its own, `search_tickets` and `search_invoices`, and no tool is prefixed.

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

const tickets = [
  { id: "T-1", title: "Cannot log in" },
  { id: "T-3", title: "Login link expired" },
];

const invoices = [
  { id: "INV-7", customer: "Acme Corp" },
  { id: "INV-8", customer: "Globex" },
];

@Tool({ name: "search", description: "Search support tickets by title", inputSchema: { query: z.string() } })
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) };
  }
}

@Tool({ name: "search", description: "Search invoices by customer name", inputSchema: { query: z.string() } })
export class SearchInvoices extends ToolContext {
  async execute({ query }: { query: string }) {
    return { invoices: invoices.filter((i) => i.customer.toLowerCase().includes(query.toLowerCase())) };
  }
}

@App({ id: "tickets", name: "Tickets", tools: [SearchTickets] })
export class TicketsApp {}

@App({ id: "billing", name: "Billing", tools: [SearchInvoices] })
export class BillingApp {}
```

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

const tickets = [
  { id: "T-1", title: "Cannot log in" },
  { id: "T-3", title: "Login link expired" },
];

const invoices = [
  { id: "INV-7", customer: "Acme Corp" },
  { id: "INV-8", customer: "Globex" },
];

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

@Tool({ name: "search_invoices", description: "Search invoices by customer name", inputSchema: { query: z.string() } })
export class SearchInvoices extends ToolContext {
  async execute({ query }: { query: string }) {
    return { invoices: invoices.filter((i) => i.customer.toLowerCase().includes(query.toLowerCase())) };
  }
}

@App({ id: "tickets", name: "Tickets", tools: [SearchTickets] })
export class TicketsApp {}

@App({ id: "billing", name: "Billing", tools: [SearchInvoices] })
export class BillingApp {}
```

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { TicketsApp, BillingApp } from "./apps";

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [TicketsApp, BillingApp],
})
export default class HelpDeskServer {}
```

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

test("the tools are `search_invoices` and `search_tickets`", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t: { name: string }) => t.name);
  expect(names).toEqual(["search_invoices", "search_tickets"]);
});

test("`search_tickets` finds tickets", async ({ mcp }) => {
  const result = await mcp.tools.call("search_tickets", { query: "log" });
  expect(result).toBeSuccessful();
  expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-1", "T-3"]);
});

test("`search_invoices` finds invoices", async ({ mcp }) => {
  const result = await mcp.tools.call("search_invoices", { query: "globex" });
  expect(result).toBeSuccessful();
  expect(result.json().invoices.map((i: { id: string }) => i.id)).toEqual(["INV-8"]);
});
```

**Hint:**
The prefix only appears while two tools share a name. Change the `name` in each `@Tool`, not the app ids.

**Solution:**
With unique names there's no conflict, so FrontMCP leaves both names as declared. The names are also better for the model: `search_invoices` says what it searches before the model reads a word of the description.

### Challenge: Give each app its own URI scheme
Both apps serve their SLA at `policy://sla`, so the billing SLA is never listed or read. Move them to `tickets://sla` and `billing://sla`.

```ts apps.ts
import { App, Resource, ResourceContext } from "@frontmcp/sdk";

@Resource({ name: "sla", uri: "policy://sla", mimeType: "text/markdown", description: "How fast support replies" })
export class TicketsSla extends ResourceContext {
  async execute(uri: string) {
    return "# Support SLA\n\nFirst reply within 4 business hours.";
  }
}

@Resource({ name: "sla", uri: "policy://sla", mimeType: "text/markdown", description: "How fast refunds are paid" })
export class BillingSla extends ResourceContext {
  async execute(uri: string) {
    return "# Billing SLA\n\nRefunds are processed within 5 business days.";
  }
}

@App({ id: "tickets", name: "Tickets", resources: [TicketsSla] })
export class TicketsApp {}

@App({ id: "billing", name: "Billing", resources: [BillingSla] })
export class BillingApp {}
```

```ts apps.ts solution
import { App, Resource, ResourceContext } from "@frontmcp/sdk";

@Resource({ name: "support-sla", uri: "tickets://sla", mimeType: "text/markdown", description: "How fast support replies" })
export class TicketsSla extends ResourceContext {
  async execute(uri: string) {
    return "# Support SLA\n\nFirst reply within 4 business hours.";
  }
}

@Resource({ name: "billing-sla", uri: "billing://sla", mimeType: "text/markdown", description: "How fast refunds are paid" })
export class BillingSla extends ResourceContext {
  async execute(uri: string) {
    return "# Billing SLA\n\nRefunds are processed within 5 business days.";
  }
}

@App({ id: "tickets", name: "Tickets", resources: [TicketsSla] })
export class TicketsApp {}

@App({ id: "billing", name: "Billing", resources: [BillingSla] })
export class BillingApp {}
```

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { TicketsApp, BillingApp } from "./apps";

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [TicketsApp, BillingApp],
})
export default class HelpDeskServer {}
```

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

test("`tickets://sla` is the support SLA", async ({ mcp }) => {
  expect((await mcp.resources.read("tickets://sla")).text()).toContain("Support SLA");
});

test("`billing://sla` is the billing SLA", async ({ mcp }) => {
  expect((await mcp.resources.read("billing://sla")).text()).toContain("Billing SLA");
});

test("both SLAs are listed", async ({ mcp }) => {
  expect(await mcp.resources.list()).toHaveLength(2);
});
```

**Hint:**
Only the `uri` has to change. While you're there, give the two resources different `name`s too, so they don't get prefixed.

**Solution:**
Each app now owns a scheme, so its URIs can't collide with another app's, and both SLAs can be read. The solution also renames them `support-sla` and `billing-sla`, which keeps the names in `resources/list` as declared.

### Challenge: Share one ticket store
Refunding INV-7 should close ticket T-4, but `get_ticket` keeps saying it's open. `TicketStore` is registered in both apps, so each app has its own copy. Make both apps use the same one.

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { TicketsApp } from "./apps/tickets.app";
import { BillingApp } from "./apps/billing.app";

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [TicketsApp, BillingApp],
})
export default class HelpDeskServer {}
```

```ts main.ts solution
import { FrontMcp } from "@frontmcp/sdk";
import { TicketsApp } from "./apps/tickets.app";
import { BillingApp } from "./apps/billing.app";
import { TicketStore } from "./ticket-store";

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [TicketsApp, BillingApp],
  providers: [TicketStore],
})
export default class HelpDeskServer {}
```

```ts apps/tickets.app.ts
import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "../ticket-store";

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

@App({ id: "tickets", name: "Tickets", tools: [GetTicket], providers: [TicketStore] })
export class TicketsApp {}
```

```ts apps/tickets.app.ts solution
import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "../ticket-store";

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

@App({ id: "tickets", name: "Tickets", tools: [GetTicket] })
export class TicketsApp {}
```

```ts apps/billing.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "../ticket-store";

@Tool({
  name: "refund_invoice",
  description: "Refund an invoice in full, and close the ticket that asked for it",
  inputSchema: { id: z.string() },
})
export class RefundInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = id === "INV-7" ? "T-4" : null;
    if (ticket) this.get(TicketStore).close(ticket);
    return { invoice: id, status: "refunded", closedTicket: ticket };
  }
}

@App({ id: "billing", name: "Billing", tools: [RefundInvoice], providers: [TicketStore] })
export class BillingApp {}
```

```ts apps/billing.app.ts solution
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "../ticket-store";

@Tool({
  name: "refund_invoice",
  description: "Refund an invoice in full, and close the ticket that asked for it",
  inputSchema: { id: z.string() },
})
export class RefundInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = id === "INV-7" ? "T-4" : null;
    if (ticket) this.get(TicketStore).close(ticket);
    return { invoice: id, status: "refunded", closedTicket: ticket };
  }
}

@App({ id: "billing", name: "Billing", tools: [RefundInvoice] })
export class BillingApp {}
```

```ts ticket-store.ts
import { Provider, ProviderScope } from "@frontmcp/sdk";

type Ticket = { id: string; title: string; status: "open" | "closed" };

@Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL })
export class TicketStore {
  private tickets: Ticket[] = [
    { id: "T-1", title: "Cannot log in", status: "open" },
    { id: "T-4", title: "Charged twice in March", status: "open" },
  ];
  get(id: string) {
    return this.tickets.find((t) => t.id === id);
  }
  close(id: string) {
    const ticket = this.get(id);
    if (ticket) ticket.status = "closed";
    return ticket;
  }
}
```

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

test("after refunding INV-7, `get_ticket` says T-4 is closed", async ({ mcp }) => {
  expect(await mcp.tools.call("refund_invoice", { id: "INV-7" })).toBeSuccessful();
  expect((await mcp.tools.call("get_ticket", { id: "T-4" })).json()).toMatchObject({ status: "closed" });
});

test("tickets that weren't refunded stay open", async ({ mcp }) => {
  expect((await mcp.tools.call("get_ticket", { id: "T-1" })).json()).toMatchObject({ status: "open" });
});
```

**Hint:**
Take `TicketStore` out of both apps' `providers`, and list it once in `@FrontMcp`.

**Solution:**
A provider registered on an app belongs to that app, so two registrations meant two stores. Registered once on the server, there's a single `TicketStore`, and both apps' tools reach it through the same `this.get(TicketStore)`.
