Apps, discovery and splitting

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 and @App document each option.

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

Reference

What goes in apps

EntryWhat it isSee
An @App classYour own tools, resources, prompts and providers.@App
app({ ... })The same, built from a plain object.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
App.remote(url, options?)Another MCP server, whose tools, resources and prompts are served as an app.Remote servers

Anything else fails at startup with @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); and with splitByApp, the first app is the one runStdio() serves, and createDirect() and connect() without an app (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, and the skill:// resources when it has skills. They belong to the server, not to an app.

Composition patterns

GoalHow
Split one product into areas, like tickets and billingOne app per area, all on the main endpoint, with names that are unique across the server.
Share a service, like a database client, between appsRegister it on @FrontMcp({ providers }). An app's own providers are private to it.
Run the same behavior for every app, like auditingA plugin on @FrontMcp({ plugins }). A plugin on one app hooks that app's calls, but every app's lists.
Use one app's tool from anotherthis.callTool("<app id>:<name>").
Different defaults for one appApp-level settings like output override the server's.
Add another MCP server's toolsApp.remote().
Reuse an app published to npmApp.esm().
Give one app its own URL and its own authstandalone: true on that app. See Endpoints.
One URL per product or tenantsplitByApp: 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.
  • pagination 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.

ClashListed asWhat a request reaches
Two apps' tools named searchdesk:search, billing:searchEach prefixed name reaches its app. Plain search still works, and reaches the first app in apps.
Two apps' prompts named summarizedesk:summarize, billing:summarizeEach prefixed name reaches its app. Plain summarize still works, and reaches the first app.
Two apps' resources named sla, at different URIsdesk:sla, billing:slaEach URI reaches its app. Only the names change.
Two apps' resources at the same URIOnly the first app's, under its own nameThe first app's. The other app's resource is neither listed nor read.
Two apps' templates with the same uriTemplateOnly the first app's, under its own nameThe 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:

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 shows each row.

Endpoints: standalone and splitByApp

An endpoint (FrontMCP calls it a 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:

SettingEndpointsWhere each app is served
Neither (the default)One, rootEvery app, at the entry path: /, or http.entryPath.
@App({ standalone: true })root, plus one per standalone appThe standalone app only at <entryPath>/<id>. The other apps at the entry path.
@App({ standalone: "includeInParent" })root, plus one per such appThe app at both.
@FrontMcp({ splitByApp: true })One per app, in the order of appsEach 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).

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 checks the same layout with createForGraph(), and the troubleshooting example calls a standalone app's path through createFetchHandler().

What each endpoint gets

SettingOn the main endpointOn 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.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 @FrontMcpAs 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(), which is what importing a @FrontMcp class starts, and createHandler()) and createFetchHandler() serve every endpoint. The in-process entry points serve one:

Entry pointServes
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(), connect()The main endpoint, as runStdio() does. Given { app }, the endpoint that app has of its own.

FrontMcpInstance.getPrimaryScope() 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.

What server/discover returns

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

FieldWhat it holds
supportedVersionsThe protocol versions FrontMCP speaks (Protocol versions): 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05.
capabilitiesWhat the endpoint offers. See the next table.
instructions@FrontMcp({ instructions }), when set, with the skill catalog appended (as set by skillsConfig.injectInstructions) and, with channels, a line about them. See 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, cacheScopeHow 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 or a hook on skills:filter. Changed in 1.8.4: a caller signed in with a static key used to get "public".
CapabilityWhen 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.
extensionsAlways io.modelcontextprotocol/tasks; also io.modelcontextprotocol/skills when there are skills.
experimentalio.modelcontextprotocol/skills when there are skills, and claude/channel when there are channels (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.
  • @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.

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.

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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(), 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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 on an endpoint, and a plugin on @FrontMcp({ plugins }) hooks every app's requests.

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":

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

With bootstrap(), each of these paths answers MCP requests; the table in Endpoints 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:

Open
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);
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

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; Auth for one app has the whole table. The usual fix is to give the app its own endpoint, where its auth applies:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

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.

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).