# Apps, discovery and splitting

> How a FrontMCP server is built from apps, what clients see and discover, what happens when two apps use the same name, and how standalone apps and splitByApp give apps endpoints and auth of their own.

Source: https://frontmcp.dev/reference/server/apps

A FrontMCP server is a list of apps. By default every app is served on one MCP endpoint, and clients see one server: every app's tools in one `tools/list`, every app's resources in one `resources/list`. This page covers how apps combine, what happens when two of them use the same name, what a client learns from `server/discover`, and how `standalone` and `splitByApp` give apps endpoints of their own, each with its own auth. [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp) and [`@App`](https://frontmcp.dev/reference/sdk/app) document each option.

```ts
@FrontMcp({ info, apps: [HelpDeskApp, BillingApp], instructions?, splitByApp? })
@App({ id, name, standalone?, auth?, tools, resources, prompts })
```

---

## Reference

### What goes in `apps`

| Entry | What it is | See |
| --- | --- | --- |
| An `@App` class | Your own tools, resources, prompts and providers. | [`@App`](https://frontmcp.dev/reference/sdk/app) |
| `app({ ... })` | The same, built from a plain object. | [Building an app without a decorator](https://frontmcp.dev/reference/sdk/app#building-an-app-without-a-decorator) |
| `App.esm(package, options?)` | An app loaded from an npm package when the server starts, and run in your process. | [Loading apps from npm](https://frontmcp.dev/reference/server/esm) |
| `App.remote(url, options?)` | Another MCP server, whose tools, resources and prompts are served as an app. | [Remote servers](https://frontmcp.dev/reference/server/remote) |

Anything else fails at startup with [`@FrontMcp invalid metadata for "apps"`](https://frontmcp.dev/reference/sdk/frontmcp#frontmcp-invalid-metadata-for-apps).

The order of `apps` matters in two places: a clashing tool or prompt name without its prefix, and a resource URI or URI template that two apps both declare, reach the **first** app ([name clashes](#name-clashes)); and with `splitByApp`, the first app is the one `runStdio()` serves, and `createDirect()` and `connect()` without an `app` ([which entry points serve every endpoint](#which-entry-points-serve-every-endpoint)).

Besides your apps' entries, clients see entries FrontMCP adds for features you turn on, like the job tools (`execute_job`, …) when an app has [jobs](https://frontmcp.dev/reference/sdk/job), and the `skill://` resources when it has [skills](https://frontmcp.dev/reference/sdk/skill). They belong to the server, not to an app.

### Composition patterns

| Goal | How |
| --- | --- |
| Split one product into areas, like tickets and billing | One app per area, all on the main endpoint, with names that are unique across the server. |
| Share a service, like a database client, between apps | Register it on `@FrontMcp({ providers })`. An app's own providers are [private to it](https://frontmcp.dev/reference/sdk/app#keeping-providers-private-to-an-app). |
| Run the same behavior for every app, like auditing | A plugin on `@FrontMcp({ plugins })`. A plugin on one app hooks that app's calls, but [every app's lists](https://frontmcp.dev/reference/sdk/hooks#caveats). |
| Use one app's tool from another | [`this.callTool("<app id>:<name>")`](#calling-another-apps-tool). |
| Different defaults for one app | App-level settings like [`output`](https://frontmcp.dev/reference/sdk/app#overriding-server-defaults-for-one-app) override the server's. |
| Add another MCP server's tools | [`App.remote()`](https://frontmcp.dev/reference/server/remote). |
| Reuse an app published to npm | [`App.esm()`](https://frontmcp.dev/reference/server/esm). |
| Give one app its own URL and its own auth | `standalone: true` on that app. See [Endpoints](#endpoints-standalone-and-splitbyapp). |
| One URL per product or tenant | `splitByApp: true`. |

### What clients see

Clients see one list per kind for each endpoint, merged from every app served there. Nothing in a list says which app an entry comes from, unless its name was prefixed because of a clash.

- Under protocol 2026-07-28, each list is **sorted by name**, whatever the order of `apps` or of each app's `tools`.
- `server/discover` describes the endpoint as a whole: see [what `server/discover` returns](#what-serverdiscover-returns).
- [`pagination`](https://frontmcp.dev/reference/sdk/frontmcp#paging-long-tool-lists) splits a long `tools/list` into pages across all the apps.

#### Name clashes

Clients find tools, prompts, resources and templates by name, so names on one endpoint have to differ. When two apps use the same `name`, FrontMCP keeps both entries and prefixes each name with its app's id, as `<id>:<name>`. The id is the app's `id`, or, when it has none, its `name` with spaces replaced by `-` (`"Billing Team"` becomes `Billing-Team:search`). Entries whose names don't clash keep them.

| Clash | Listed as | What a request reaches |
| --- | --- | --- |
| Two apps' tools named `search` | `desk:search`, `billing:search` | Each prefixed name reaches its app. Plain `search` still works, and reaches the first app in `apps`. |
| Two apps' prompts named `summarize` | `desk:summarize`, `billing:summarize` | Each prefixed name reaches its app. Plain `summarize` still works, and reaches the first app. |
| Two apps' resources named `sla`, at different URIs | `desk:sla`, `billing:sla` | Each URI reaches its app. Only the names change. |
| Two apps' resources at the same URI | Only the **first** app's, under its own name | The first app's. The other app's resource is neither listed nor read. |
| Two apps' templates with the same `uriTemplate` | Only the **first** app's, under its own name | The first app's. |

A URI can't be renamed, because it's the address clients read, so for a shared URI or URI template FrontMCP keeps the first app's entry and logs a warning that names both:

```text
Resource URI "support://status" is registered by both "scope:root/app:desk:status" and "scope:root/app:billing:status"; "scope:root/app:desk:status" serves it and the other is not listed or read. Give each a distinct URI.
```

A clash is decided when the server starts, so adding an app can rename or hide entries that clients already use. Names and URIs that are unique across the server, like `search_tickets`, `search_invoices` and `desk://status`, avoid all of it. [Handling name clashes](#handling-name-clashes) shows each row.

### Endpoints: `standalone` and `splitByApp`

An **endpoint** (FrontMCP calls it a [scope](https://frontmcp.dev/reference/sdk/scope)) is one MCP URL with its own list of entries, its own auth and its own copy of the server's providers. A server has one by default. Two options add more:

| Setting | Endpoints | Where each app is served |
| --- | --- | --- |
| Neither (the default) | One, `root` | Every app, at the entry path: `/`, or [`http.entryPath`](https://frontmcp.dev/reference/sdk/frontmcp#http). |
| `@App({ standalone: true })` | `root`, plus one per standalone app | The standalone app only at `<entryPath>/<id>`. The other apps at the entry path. |
| `@App({ standalone: "includeInParent" })` | `root`, plus one per such app | The app at both. |
| `@FrontMcp({ splitByApp: true })` | One per app, in the order of `apps` | Each app at `<entryPath>/<id>`. Nothing at the entry path itself: it answers 404. |

With `splitByApp`, `standalone: true` changes nothing, and `standalone: "includeInParent"` stops the server from starting ([`standalone: includeInParent is not supported for splitByApp scope`](#standalone-includeinparent-is-not-supported-for-splitbyapp-scope)).

```ts main.ts
@App({ id: "desk", name: "Help Desk", tools: [SearchTickets] })
class HelpDeskApp {}

@App({ id: "billing", name: "Billing", tools: [GetInvoice], standalone: true, auth: { mode: "static", tokens: [process.env.BILLING_KEY!] } })
class BillingApp {}

@App({ id: "status", name: "Status", tools: [GetStatus], standalone: "includeInParent" })
class StatusApp {}

@FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp, StatusApp], http: { entryPath: "/mcp" } })
export default class Server {}

// POST /mcp         → search_tickets, get_status (public)
// POST /mcp/billing → get_invoice (needs the billing key)
// POST /mcp/status  → get_status (public)
// POST /mcp/desk    → 404: only standalone apps get a path
```

The Playground's client only talks to the entry path, so the other paths are checked in tests: [Giving an app its own endpoint](#giving-an-app-its-own-endpoint) checks the same layout with `createForGraph()`, and [the troubleshooting example](#app-level-auth-is-not-enforced-on-the-shared-endpoint) calls a standalone app's path through `createFetchHandler()`.

#### What each endpoint gets

| Setting | On the main endpoint | On an app's own endpoint |
| --- | --- | --- |
| `auth` | `@FrontMcp({ auth })`, for every app on it. A public, static or transparent server with an app here that has its own `auth` refuses to start; a local or remote one checks the app's grant per call only with `incrementalAuth`. See [Auth for one app](https://frontmcp.dev/reference/auth/modes#auth-for-one-app). | The app's `auth`, or, if it has none, the server's. `splitByApp` with a server-level `auth` is allowed, and gives it to every app without one. |
| `@FrontMcp({ providers })` | One instance, shared by the apps on it. | A **separate** instance per endpoint: state in a server provider isn't shared between endpoints. |
| `instructions` and the rest of `@FrontMcp` | As set. | The server's. |

A client that connects to one endpoint sees nothing of the others: not their tools, not their instructions.

#### Which entry points serve every endpoint

The Node HTTP server ([`FrontMcpInstance.bootstrap()`](https://frontmcp.dev/reference/sdk/frontmcp-instance), which is what importing a `@FrontMcp` class starts, and `createHandler()`) and [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) serve every endpoint. The in-process entry points serve **one**:

| Entry point | Serves |
| --- | --- |
| `bootstrap()`, `createHandler()`, `createFetchHandler()` | Every endpoint, at its path. |
| `runStdio()` | The main endpoint: every app that isn't `standalone: true`. With `splitByApp`, or when every app is standalone, the first endpoint. |
| [`createDirect()`](https://frontmcp.dev/reference/sdk/create), [`connect()`](https://frontmcp.dev/reference/sdk/connect) | The main endpoint, as `runStdio()` does. Given `{ app }`, the endpoint that app has of its own. |

[`FrontMcpInstance.getPrimaryScope()`](https://frontmcp.dev/reference/sdk/frontmcp-instance) returns the main endpoint, and `getAppScope(app)` an app's own. A standalone app's endpoint, and with `splitByApp` every app's but the first, can be reached in-process only with `{ app }`. See [Some apps' tools aren't listed](#some-apps-tools-arent-listed).

> **Note**
Changed in 1.9.3: `createDirect()` and `connect()` take `{ app }`, and `createFetchHandler()` serves nothing at the entry path with `splitByApp`, as the Node server. Before, the in-process entry points reached the main endpoint only, and `createFetchHandler()` also served the first endpoint at the entry path when nothing else was there.

Changed in 1.9.2: `createFetchHandler()` serves every endpoint. Before, it served only the main one, like the in-process entry points.

Changed in 1.8.3: the one-endpoint entry points used to serve the first endpoint, which is a standalone app's whenever there is one, because endpoints of standalone apps are created before the main one. A server with a standalone app served that app alone, and none of the others.

### What `server/discover` returns

A 2026-07-28 client asks the endpoint what it is with `server/discover`:

| Field | What it holds |
| --- | --- |
| `supportedVersions` | The protocol versions FrontMCP speaks ([Protocol versions](https://frontmcp.dev/reference/server/protocol-versions)): `2026-07-28`, `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`. |
| `capabilities` | What the endpoint offers. See the next table. |
| `instructions` | `@FrontMcp({ instructions })`, when set, with the skill catalog appended (as set by [`skillsConfig.injectInstructions`](https://frontmcp.dev/reference/sdk/skill)) and, with [channels](https://frontmcp.dev/reference/sdk/channel), a line about them. See [giving the model instructions](https://frontmcp.dev/reference/sdk/frontmcp#giving-the-model-instructions). (Changed in 1.9.3: before, it was `instructions` alone.) |
| `_meta["io.modelcontextprotocol/serverInfo"]` | `info`: `name`, `version`, and `title`, `websiteUrl` and `icons` when set. Every response carries it. |
| `ttlMs`, `cacheScope` | How long, and how widely, a client may cache the answer: 300000 ms (five minutes), and `"public"` for an anonymous caller. `cacheScope` is `"private"` when the answer could differ per caller: for a caller that sent a token, a static key included, and when the server has [`authorities`](https://frontmcp.dev/learn/authorizing-calls) or a hook on `skills:filter`. Changed in 1.8.4: a caller signed in with a static key used to get `"public"`. |

| Capability | When it's there |
| --- | --- |
| `tools: { listChanged: true }` | Always. |
| `resources: { subscribe, listChanged }` | Always. Both are `true` when the endpoint has any resource (skills count), `false` otherwise. |
| `prompts: { listChanged }` | Always. `true` when the endpoint has any prompt. |
| `completions: {}` | When the endpoint has any prompt or resource. |
| `logging: {}` | Always. |
| `extensions` | Always `io.modelcontextprotocol/tasks`; also `io.modelcontextprotocol/skills` when there are skills. |
| `experimental` | `io.modelcontextprotocol/skills` when there are skills, and `claude/channel` when there are [channels](https://frontmcp.dev/reference/sdk/channel) (since 1.9.3). |

The capabilities describe the whole endpoint, so they can't tell a client which app has what.

#### `initialize`, for older clients

Clients on protocol versions before 2026-07-28 open a session with `initialize` instead. Its result has the same `capabilities` (plus `tasks` when a tool sets `execution.taskSupport`), the same `instructions`, and `info` as its `serverInfo`: `name`, `version`, and `title`, `websiteUrl` and `icons` when set. Without `info.title`, `serverInfo.title` is the name. This was checked on the Node server and through `createFetchHandler()`; the Playground speaks 2026-07-28 only.

Changed in 1.9.3: before, `serverInfo.title` was always the name, and `websiteUrl` and `icons` weren't sent; `initialize` through `createFetchHandler()` carried no `instructions`.

#### Caveats

- The Playground and every example on this site send their requests to the entry path, `/`, of a `createFetchHandler()`, which is the main endpoint. An example with a standalone app shows the other apps' tools, not the standalone app's; its tests can reach the app's own path with a handler of their own, as in [the troubleshooting example below](#app-level-auth-is-not-enforced-on-the-shared-endpoint).
- `@FrontMcp({ tools, resources })` are served on every endpoint, next to the apps' entries, since FrontMCP 1.9.1; before, they weren't served anywhere. A server-level tool keeps its plain name unless an app's tool has it too: then they're listed as `server:<name>` and `<app id>:<name>`. See [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp).

---

## Usage

### Seeing what a client discovers

Two apps on one endpoint: the client gets one sorted `tools/list`, and `server/discover` describes both apps together. The help desk app has a resource, so the endpoint offers `resources` and `completions` for both.

```ts main.ts active
import { FrontMcp } from "@frontmcp/sdk";
import { BillingApp } from "./billing.app";
import { HelpDeskApp } from "./help-desk.app";

@FrontMcp({
  info: { name: "support", title: "Support", version: "2.1.0" },
  apps: [HelpDeskApp, BillingApp],
  instructions: "Help desk and billing tools. Find the customer's ticket before you look at their invoices.",
})
export default class Server {}
```

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

@Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } })
class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: [{ id: "T-1", title: `Cannot log in (matched "${query}")` }] };
  }
}

@Resource({ name: "open-tickets", uri: "desk://open-tickets", mimeType: "application/json" })
class OpenTickets extends ResourceContext {
  async execute() {
    return { count: 12 };
  }
}

@App({ id: "desk", name: "Help Desk", tools: [SearchTickets], resources: [OpenTickets] })
export class HelpDeskApp {}
```

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

@Tool({ name: "get_invoice", description: "Get an invoice by id", inputSchema: { id: z.string() } })
class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, total: 120, currency: "EUR" };
  }
}

@Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { id: z.string() } })
class RefundInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, refunded: true };
  }
}

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

```ts discover.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { BillingApp } from "./billing.app";
import { HelpDeskApp } from "./help-desk.app";

test("one tools/list, sorted by name", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t) => t.name);
  expect(names).toEqual(["get_invoice", "refund_invoice", "search_tickets"]);
});

test("server/discover describes the endpoint as a whole", async ({ mcp }) => {
  const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" })) as any;
  expect(result.supportedVersions[0]).toBe("2026-07-28");
  expect(result.instructions).toContain("Find the customer's ticket");
  expect(result.capabilities).toEqual({
    tools: { listChanged: true },
    resources: { subscribe: true, listChanged: true },
    prompts: { listChanged: false },
    completions: {},
    logging: {},
    extensions: { "io.modelcontextprotocol/tasks": {} },
  });
  expect(result._meta["io.modelcontextprotocol/serverInfo"]).toEqual({ name: "support", title: "Support", version: "2.1.0" });
  expect([result.ttlMs, result.cacheScope]).toEqual([300000, "public"]);
});

async function cacheScope(config: Parameters<typeof FrontMcpInstance.createFetchHandler>[0], headers: Record<string, string> = {}) {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(
    new Request("https://support.example.com/", {
      method: "POST",
      headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "server/discover", ...headers },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "server/discover", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  return (await response.json()).result.cacheScope;
}

test("with authorities, the answer is cached per caller", async () => {
  const authorities = { profiles: { admin: { roles: { any: ["admin"] } } } };
  expect(await cacheScope({ info: { name: "support", version: "2.1.0" }, apps: [HelpDeskApp, BillingApp], authorities })).toBe("private");
});

test("a caller signed in with a static key gets a private answer too", async () => {
  const auth = { mode: "static" as const, tokens: ["support-key"] };
  const config = { info: { name: "support", version: "2.1.0" }, apps: [HelpDeskApp, BillingApp], auth };
  expect(await cacheScope(config, { authorization: "Bearer support-key" })).toBe("private");
});
```

Remove `OpenTickets` from the help desk app and run the tests again: `resources` turns to `{ subscribe: false, listChanged: false }`, and `completions` disappears.

### Handling name clashes

Both apps have a `search` tool, a `summarize` prompt, an `sla` resource at a URI of their own, a `status` resource at the same URI and a `ticket` template with the same URI template. The tests show what a client sees and what each name reaches, and the **Logs** tab shows the warnings:

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

@FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [DeskApp, BillingApp] })
export default class Server {}
```

```ts apps.ts
import { App, Prompt, PromptContext, Resource, ResourceContext, ResourceTemplate, Tool, ToolContext, z, type GetPromptResult } from "@frontmcp/sdk";

function appEntries(app: string) {
  @Tool({ name: "search", description: `Search ${app}`, inputSchema: { query: z.string() } })
  class Search extends ToolContext {
    async execute({ query }: { query: string }) {
      return { app, query };
    }
  }

  @Prompt({ name: "summarize", description: `Summarize for ${app}` })
  class Summarize extends PromptContext {
    async execute(): Promise<GetPromptResult> {
      return { messages: [{ role: "user", content: { type: "text", text: `Summarize the ${app} queue.` } }] };
    }
  }

  @Resource({ name: "sla", uri: `${app}://sla`, mimeType: "text/plain" })
  class Sla extends ResourceContext {
    async execute() {
      return `${app} replies within a day`;
    }
  }

  @Resource({ name: "status", uri: "support://status", mimeType: "application/json" })
  class Status extends ResourceContext {
    async execute() {
      return { app, healthy: true };
    }
  }

  @ResourceTemplate({ name: "ticket", uriTemplate: "support://tickets/{id}", mimeType: "application/json" })
  class Ticket extends ResourceContext<{ id: string }> {
    async execute(uri: string, { id }: { id: string }) {
      return { app, id };
    }
  }

  return { tools: [Search], prompts: [Summarize], resources: [Sla, Status, Ticket] };
}

@App({ id: "desk", name: "Help Desk", ...appEntries("desk") })
export class DeskApp {}

@App({ id: "billing", name: "Billing", ...appEntries("billing") })
export class BillingApp {}
```

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

test("clashing tools are renamed; the plain name reaches the first app", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["billing:search", "desk:search"]);
  expect((await mcp.tools.call("billing:search", { query: "x" })).json()).toEqual({ app: "billing", query: "x" });
  expect((await mcp.tools.call("search", { query: "x" })).json()).toEqual({ app: "desk", query: "x" });
});

test("clashing prompts are renamed too; each name reaches its app", async ({ mcp }) => {
  expect((await mcp.prompts.list()).map((p) => p.name)).toEqual(["billing:summarize", "desk:summarize"]);
  const text = async (name: string) => (await mcp.prompts.get(name, {})).messages[0].content;
  expect(await text("billing:summarize")).toEqual({ type: "text", text: "Summarize the billing queue." });
  expect(await text("summarize")).toEqual({ type: "text", text: "Summarize the desk queue." });
});

test("resources with one name at different URIs: renamed, each URI reaches its app", async ({ mcp }) => {
  const listed = (await mcp.resources.list()).map((r) => [r.name, r.uri]);
  expect(listed).toContainEqual(["billing:sla", "billing://sla"]);
  expect(listed).toContainEqual(["desk:sla", "desk://sla"]);
  expect((await mcp.resources.read("billing://sla")).text()).toBe("billing replies within a day");
});

test("two resources at one URI: only the first app's is listed and read", async ({ mcp }) => {
  const atStatus = (await mcp.resources.list()).filter((r) => r.uri === "support://status");
  expect(atStatus.map((r) => r.name)).toEqual(["status"]);
  expect((await mcp.resources.read("support://status")).json()).toEqual({ app: "desk", healthy: true });
});

test("two templates with one URI template: only the first app's is listed and read", async ({ mcp }) => {
  const listed = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "resources/templates/list", params: {} });
  expect((listed.result as any).resourceTemplates.map((t: { name: string }) => t.name)).toEqual(["ticket"]);
  expect((await mcp.resources.read("support://tickets/T-1")).json()).toEqual({ app: "desk", id: "T-1" });
});
```

Give entries names and URIs that are unique across the server, like `search_tickets`, `summarize_queue` and `desk://status`, and none of this happens.

### Calling another app's tool

Apps don't share providers, but a tool can call any tool on its endpoint with [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool), by its plain name or as `<app id>:<name>`. The call goes through the other tool's validation and hooks, as the same caller. Here the help desk app looks up an invoice without knowing how billing stores them:

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

const tickets: Record<string, { title: string; invoice: string }> = {
  "T-1": { title: "Invoice total is wrong", invoice: "INV-7" },
};

@Tool({ name: "ticket_with_invoice", description: "Get a ticket and the invoice it's about", inputSchema: { id: z.string() } })
class TicketWithInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets[id];
    const invoice = await this.callTool("billing:get_invoice", { id: ticket.invoice });
    return { id, title: ticket.title, invoice: invoice.structuredContent };
  }
}

@App({ id: "desk", name: "Help Desk", tools: [TicketWithInvoice] })
export class HelpDeskApp {}
```

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

@Tool({ name: "get_invoice", description: "Get an invoice by id", inputSchema: { id: z.string().regex(/^INV-\d+$/) } })
class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, total: 120, currency: "EUR" };
  }
}

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

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { BillingApp } from "./billing.app";
import { HelpDeskApp } from "./help-desk.app";

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

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

test("the help desk reads the invoice through billing's tool", async ({ mcp }) => {
  expect((await mcp.tools.call("ticket_with_invoice", { id: "T-1" })).json()).toEqual({
    id: "T-1",
    title: "Invoice total is wrong",
    invoice: { id: "INV-7", total: 120, currency: "EUR" },
  });
});
```

The prefixed name keeps working if another app adds its own `get_invoice` later; the plain name would then reach whichever app comes first in `apps`. The other ways to share across apps: a provider on `@FrontMcp({ providers })` is [shared by every app](https://frontmcp.dev/reference/sdk/app#keeping-providers-private-to-an-app) on an endpoint, and a plugin on `@FrontMcp({ plugins })` [hooks every app's requests](https://frontmcp.dev/reference/sdk/plugin).

### Giving an app its own endpoint

`FrontMcpInstance.createForGraph()` builds a server without serving it, `getScopes()` lists its endpoints, and `getPrimaryScope()` returns the main one. The tests check a standalone billing app with its own auth, a status app served in both places, and the same apps with `splitByApp`, all under `http.entryPath: "/mcp"`:

```ts endpoints.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { BillingApp, HelpDeskApp, StatusApp, Usage } from "./apps";

const base = { info: { name: "support", version: "1.0.0" }, http: { entryPath: "/mcp" }, instructions: "Support tools." };

async function endpoints(config: Parameters<typeof FrontMcpInstance.createForGraph>[0]) {
  const instance = await FrontMcpInstance.createForGraph(config);
  return instance.getScopes().map((scope) => ({
    path: scope.fullPath,
    auth: scope.metadata.auth?.mode ?? "public",
    tools: scope.tools.getTools().map((tool) => tool.metadata.name),
  }));
}

test("standalone apps get their own endpoint, listed before the main one", async () => {
  expect(await endpoints({ ...base, apps: [HelpDeskApp, BillingApp, StatusApp] })).toEqual([
    { path: "/mcp/billing", auth: "static", tools: ["get_invoice"] },
    { path: "/mcp/status", auth: "public", tools: ["get_status"] },
    { path: "/mcp", auth: "public", tools: ["search_tickets", "get_status"] },
  ]);
});

test("getPrimaryScope() is the main endpoint, which the one-endpoint entry points serve", async () => {
  const instance = await FrontMcpInstance.createForGraph({ ...base, apps: [HelpDeskApp, BillingApp] });
  expect(instance.getScopes()[0].fullPath).toBe("/mcp/billing");
  expect(instance.getPrimaryScope()?.fullPath).toBe("/mcp");
  // No main endpoint to serve: the first one
  const split = await FrontMcpInstance.createForGraph({ ...base, apps: [HelpDeskApp, BillingApp], splitByApp: true });
  expect(split.getPrimaryScope()?.fullPath).toBe("/mcp/desk");
  const allStandalone = await FrontMcpInstance.createForGraph({ ...base, apps: [BillingApp] });
  expect(allStandalone.getPrimaryScope()?.fullPath).toBe("/mcp/billing");
});

test("splitByApp: one endpoint per app; the server's auth for apps without their own", async () => {
  const server = { ...base, auth: { mode: "static" as const, tokens: ["support-key"] }, splitByApp: true };
  expect(await endpoints({ ...server, apps: [HelpDeskApp, BillingApp] })).toEqual([
    { path: "/mcp/desk", auth: "static", tools: ["search_tickets"] }, // the server's auth
    { path: "/mcp/billing", auth: "static", tools: ["get_invoice"] }, // its own
  ]);
});

test("every endpoint gets the server's instructions, and its own copy of the server's providers", async () => {
  const instance = await FrontMcpInstance.createForGraph({ ...base, providers: [Usage], apps: [HelpDeskApp, BillingApp] });
  const [billing, main] = instance.getScopes();
  expect([billing.metadata.instructions, main.metadata.instructions]).toEqual(["Support tools.", "Support tools."]);
  expect(billing.providers.get(Usage)).not.toBe(main.providers.get(Usage));
});

test("includeInParent can't be combined with splitByApp", async () => {
  await expect(FrontMcpInstance.createForGraph({ ...base, apps: [StatusApp], splitByApp: true })).rejects.toThrow(
    "standalone: includeInParent is not supported for splitByApp scope",
  );
});
```

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

@Provider({ name: "Usage" })
export class Usage {
  calls = 0;
}

@Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } })
class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: [], query };
  }
}

@Tool({ name: "get_invoice", description: "Get an invoice by id", inputSchema: { id: z.string() } })
class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, total: 120 };
  }
}

@Tool({ name: "get_status", description: "Is the support system up?", inputSchema: {} })
class GetStatus extends ToolContext {
  async execute() {
    return { up: true };
  }
}

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

@App({ id: "billing", name: "Billing", tools: [GetInvoice], standalone: true, auth: { mode: "static", tokens: ["billing-key"] } })
export class BillingApp {}

@App({ id: "status", name: "Status", tools: [GetStatus], standalone: "includeInParent" })
export class StatusApp {}
```

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

// The Playground serves one endpoint, so it runs the help desk alone. The tests build the layouts.
@FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [HelpDeskApp] })
export default class Server {}
```

With `bootstrap()`, each of these paths answers MCP requests; the table in [Endpoints](#endpoints-standalone-and-splitbyapp) shows what a client at each one gets.

---

## Troubleshooting

### Some apps' tools aren't listed

The server has a standalone app, or uses `splitByApp`, and it's served by an entry point that serves only one endpoint: `runStdio()`, `createDirect()` or `connect()`. These serve the main endpoint, so a standalone app's tools aren't there; with `splitByApp`, only the first app's are. Give `createDirect()` or `connect()` the `app` whose endpoint you want. `createFetchHandler()` serves every endpoint, each at its path:

```ts one-endpoint.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { BillingApp, HelpDeskApp } from "./apps";

const info = { name: "support", version: "1.0.0" };

async function directToolNames(config: Parameters<typeof FrontMcpInstance.createDirect>[0], endpoint?: { app: string }) {
  const server = await FrontMcpInstance.createDirect(config, endpoint);
  const { tools } = await server.listTools();
  await server.dispose();
  return tools.map((tool: { name: string }) => tool.name);
}

async function toolNames(handler: (request: Request) => Promise<Response>, path: string) {
  const response = await handler(
    new Request(`https://support.example.com${path}`, {
      method: "POST",
      headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list" },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  if (!response.ok) return response.status;
  return (await response.json()).result.tools.map((tool: { name: string }) => tool.name);
}

test("createDirect() with a standalone app: the other apps, or the standalone one with `app`", async () => {
  expect(await directToolNames({ info, apps: [HelpDeskApp, BillingApp] })).toEqual(["search_tickets"]);
  expect(await directToolNames({ info, apps: [HelpDeskApp, BillingApp] }, { app: "billing" })).toEqual(["get_invoice"]);
});

test("createDirect() with splitByApp: the first app, or the one `app` names", async () => {
  expect(await directToolNames({ info, apps: [HelpDeskApp, BillingApp], splitByApp: true })).toEqual(["search_tickets"]);
  expect(await directToolNames({ info, apps: [HelpDeskApp, BillingApp], splitByApp: true }, { app: "billing" })).toEqual(["get_invoice"]);
});

test("createFetchHandler() serves the standalone app at its own path", async () => {
  const handler = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDeskApp, BillingApp] });
  expect(await toolNames(handler, "/")).toEqual(["search_tickets"]);
  expect(await toolNames(handler, "/billing")).toEqual(["get_invoice"]);
  expect(await toolNames(handler, "/desk")).toBe(404);
});

test("createFetchHandler() with splitByApp: each app at its path, and nothing at the root", async () => {
  const handler = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDeskApp, BillingApp], splitByApp: true });
  expect(await toolNames(handler, "/desk")).toEqual(["search_tickets"]);
  expect(await toolNames(handler, "/billing")).toEqual(["get_invoice"]);
  expect(await toolNames(handler, "/")).toBe(404);
});
```

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

@Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } })
class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: [], query };
  }
}

@Tool({ name: "get_invoice", description: "Get an invoice by id", inputSchema: { id: z.string() } })
class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, total: 120 };
  }
}

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

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

`runStdio()` has no such option: for it, don't mark apps `standalone` and don't use `splitByApp`, or build one configuration per app that needs an endpoint of its own. `createFetchHandler()` (Cloudflare Workers, Deno, Bun) and the Node server started by `bootstrap()` serve every endpoint at its path. (Changed in 1.9.3: before, `createDirect()` and `connect()` had no `app` option either.)

### `App-level auth is not enforced on the shared endpoint`

An app sets its own `auth`, but it's served on the main endpoint, where the server's `auth` applies to every app. A public or static server can't check the app's `auth` there, so instead of serving its tools to anyone, it refuses to start:

```text
Invalid auth configuration: App-level auth is not enforced on the shared endpoint of a server in public mode, so the tools of billing would be served without it. Serve the app on its own endpoint with standalone: true or splitByApp: true, or run the server in local or remote mode with incrementalAuth enabled, which checks each tool call against the apps the caller has authorized (without incrementalAuth, local and remote mode do not check app grants per tool call).
```

A static server says `in static mode`, unless the app is `static` too, with the same header and scheme, and lists every one of the server's `tokens`. A transparent server refuses to start too, with `Parent uses transparent mode but apps have their own auth providers`. A local or remote server starts, and checks a call against the apps the caller authorized only with [`incrementalAuth`](https://frontmcp.dev/reference/auth/progressive); [Auth for one app](https://frontmcp.dev/reference/auth/modes#auth-for-one-app) has the whole table. The usual fix is to give the app its own endpoint, where its `auth` applies:

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

@Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } })
class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: [], query };
  }
}

@Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { id: z.string() } })
export class RefundInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, refunded: true };
  }
}

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

// ✅ Its own endpoint, at /billing, where its auth applies
@App({ id: "billing", name: "Billing", tools: [RefundInvoice], standalone: true, auth: { mode: "static", tokens: ["billing-key"] } })
export class BillingApp {}

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

```ts auth.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { BillingApp, HelpDeskApp, RefundInvoice } from "./main";

const info = { name: "support", version: "1.0.0" };

test("on the shared endpoint of a public server, an app's own auth stops the server from starting", async () => {
  // 🚩 Not standalone
  @App({ id: "billing", name: "Billing", tools: [RefundInvoice], auth: { mode: "static", tokens: ["billing-key"] } })
  class SharedBillingApp {}
  await expect(FrontMcpInstance.createFetchHandler({ info, apps: [HelpDeskApp, SharedBillingApp] })).rejects.toThrow(
    "App-level auth is not enforced on the shared endpoint of a server in public mode, so the tools of billing would be served without it.",
  );
});

async function toolNames(handler: (request: Request) => Promise<Response>, path: string, token?: string) {
  const response = await handler(
    new Request(`https://support.example.com${path}`, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/list",
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  if (!response.ok) return response.status;
  return (await response.json()).result.tools.map((tool: { name: string }) => tool.name);
}

test("standalone, the app's auth guards its own endpoint", async () => {
  const handler = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDeskApp, BillingApp] });
  expect(await toolNames(handler, "/")).toEqual(["search_tickets"]);
  expect(await toolNames(handler, "/billing")).toBe(401);
  expect(await toolNames(handler, "/billing", "billing-key")).toEqual(["refund_invoice"]);
});
```

To protect every app the same way, put the `auth` on `@FrontMcp` instead. To let some callers use some tools on one endpoint, keep the server's `auth` and check callers per tool with [`authorities`](https://frontmcp.dev/learn/authorizing-calls).

### `Resource URI "…" is registered by both …`

Two apps declare a resource with the same URI, or a template with the same `uriTemplate`. Only the first one in `apps` is listed and read; the other app's resource can't be reached, and a read of the URI returns the first app's content. A URI can only mean one thing: give each app its own scheme or path, like `desk://status` and `billing://status`. See [Handling name clashes](#handling-name-clashes).

### `standalone: includeInParent is not supported for splitByApp scope`

The server uses `splitByApp: true`, and an app sets `standalone: "includeInParent"`. With `splitByApp`, every app already has its own endpoint and there's no main one to include it in. Remove `standalone` from the app.

### `404` or `Cannot POST /` with `splitByApp`

With `splitByApp: true`, the Node server and `createFetchHandler()` serve nothing at the entry path itself: each app is at `<entryPath>/<id>`, like `/mcp/billing`, and the 404's `entryPaths` lists them. Point the client at the app's path. (Changed in 1.9.3: before, `createFetchHandler()` served the first app at the entry path as well.) The same goes for a standalone app, which isn't on the main endpoint; and a non-standalone app has no path of its own (`/mcp/desk` is a 404).
