Remote servers

App.remote() connects your server to another MCP server over HTTP and serves that server's tools, resources and prompts as one of your apps, under a namespace. Clients see one server; when they call a remote tool, FrontMCP forwards the call and passes the answer back. Use it to put several MCP servers behind one endpoint, or to offer a third-party server's tools next to your own without asking every client to connect to it.

App.remote(url, options?)

Reference

App.remote(url, options?)

List the result in @FrontMcp({ apps }), next to your own apps.

main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [
    HelpDeskApp,
    App.remote("https://status.example/mcp", {
      namespace: "status",
      transportOptions: {
        headers: { Authorization: `Bearer ${process.env.STATUS_TOKEN}` },
        timeout: 10_000,
      },
    }),
  ],
})
export default class Server {}

See more examples below.

Parameters

ParameterTypeDescription
urlstringThe remote server's MCP endpoint, with its scheme and path, like https://status.example/mcp. App.remote() throws URI must have a valid scheme (e.g., file://, https://, custom://) for a URL without a scheme.
optionsRemoteUrlAppOptionsOptional. See below.

Options

OptionTypeDefaultDescription
namespacestringnamePrefix for the remote's tool, resource and prompt names: status turns get_status into status:get_status.
namestringThe URL's host up to the first dotThe app's name, and the default namespace. https://status.example/mcp gives status; http://localhost:3099/ gives localhost, and http://127.0.0.1:3099/ gives 127. A name with spaces keeps them in the prefix. The app's id, which error messages use, is the name with spaces turned into -, or, when you set namespace and no name, the namespace. Two remote apps with one id stop the server from starting, with DuplicateAppIdError. (Changed in 1.9.3: before, the id always came from the name.)
descriptionstringWhat the app is for, for your own tooling.
transportOptionsRemoteTransportOptionsHow to reach the remote. See below.
cacheTTLnumber60000Milliseconds FrontMCP keeps the remote's list of tools, resources and prompts before it asks again. See picking up changes.
refreshIntervalnumber0 (off)Ask the remote for its lists every this many milliseconds, in the background. See picking up changes.
standaloneboolean | "includeInParent"falseServe the app on its own path, as for @App. The path is the app's id: /status for https://status.example/mcp.
remoteAuthRemoteAuthConfigCredentials for the remote. { mode: "static", credentials } sends the same ones with every request; { mode: "forward" } sends each caller's own token with that caller's calls, and needs a server in transparent mode; { mode: "mapped", mapper } sends the credentials mapper returns for each caller; { mode: "oauth" } sends nothing. See authenticating.
filterAppFilterConfigServe everythingWhich of the remote's tools, resources and prompts to serve, by the remote's own names, with * wildcards: { exclude: { tools: ["delete_*"] } }, or { default: "exclude", include: { tools: ["get_*"] } }. An entry left out isn't listed, and calling it gives Tool "…" not found. See serving part of a remote.

transportOptions:

FieldDefaultWhat it does
headersSent with every request to the remote, the first one included. remoteAuth credentials replace a header of the same name.
protocolVersion"legacy"Which MCP revision to speak to the remote. "legacy": a session, opened with initialize, as below. "2026-07-28": each request on its own, after a server/discover; the server doesn't start if the remote doesn't answer it. "auto": try server/discover, and fall back to "legacy" when the remote doesn't answer it. See a 2026-07-28 remote. Since 1.9.0.
timeout30000Milliseconds a tool call may take. After that the try fails with REMOTE_TIMEOUT_ERROR, and is retried; the remote isn't told to stop. It doesn't apply to connecting, reading resources or getting prompts, which wait as long as the remote takes.
fallbackToSSEtrueWhen the first connection attempt fails, try the older HTTP+SSE transport, with a GET, before giving up. That's why a failed connection reports an SSE error.
retryAttempts2How many times to try a tool call again after a failure that may pass: a network failure, a timeout, or HTTP 408, 425, 429 or a 5xx other than 501 and 505. 0 tries once. See retries.
retryDelayMs1000Milliseconds before the first retry. Each further retry waits twice as long as the one before, up to 10 seconds, give or take a tenth. A 2026-07-28 remote's Retry-After header replaces the wait, up to 10 seconds.

Returns

A plain object that describes the app, not a class: { name, urlType: "url", url, namespace, standalone, transportOptions, … }, with an id taken from namespace when you set one and no name. You can write it yourself instead, to set an id of your own:

apps: [{ id: "status-page", name: "status", urlType: "url", url: "https://status.example/mcp", standalone: false }]

Nothing is fetched until the server starts.

How a remote app works

  1. When the server starts, FrontMCP connects: by default initialize (as client frontmcp-gateway 1.0.0, protocol version 2025-11-25), notifications/initialized, a GET for an event stream, then tools/list, resources/list, resources/templates/list and prompts/list, each followed page by page until the remote sends no nextCursor (below). If the remote can't be reached or refuses, the server doesn't start. Connecting has no time limit.
  2. On the first request that lists or calls anything, FrontMCP asks for the lists again and registers the remote's tools, resources and prompts in the app. It asks again whenever they're older than cacheTTL.
  3. Each call is forwarded under the remote's own name: status:get_status reaches the remote as get_status, with the client's arguments. A resource read is forwarded with the same URI, a prompt with its arguments.
  4. Every 30 seconds FrontMCP lists the remote's tools to check it's still there. The server's /readyz has a remote:<app id> check for it, which is degraded, and so still ready, until the first check has run.

By default FrontMCP talks to the remote with the protocol before 2026-07-28, even when the remote supports 2026-07-28; transportOptions.protocolVersion changes that. All clients share this one connection: the remote sees one client, with the headers you configured, never your users.

What clients see

On your serverNotes
Tools<namespace>:<name>Description, inputSchema and annotations as the remote lists them; a tool without a description gets Remote tool: <name>. title and outputSchema are dropped. Results, structuredContent and isError included, come back as the remote sent them.
Resources<namespace>:<name>, same URIWithout a description: Remote resource: <name>.
Resource templates<namespace>:<name>, same URI templateBefore 1.9.0 each template was listed twice, as <app id>:<namespace>:<name>.
Prompts<namespace>:<name>Arguments as the remote lists them. FrontMCP checks required arguments before it forwards.

Clients see your server's name and version, never the remote's. FrontMCP doesn't check a remote tool's arguments against its inputSchema: the remote does, and its error comes back as it sent it.

When the remote server fails

  • A tool call that fails comes back as an isError result with _meta.code REMOTE_TOOL_EXECUTION_ERROR: Remote tool "get_status" on "status" failed: …, with the underlying error after the colon. It's kept in production.
  • Retries. A call that fails in a way that may pass is tried 3 times in all, about 1 and then 2 seconds apart; retryAttempts and retryDelayMs change both. That's a network failure (fetch failed, a refused or reset connection), a try that ran out of timeout, or an HTTP answer of 408, 425, 429, or 5xx other than 501 and 505, whatever its body says. Any other status, like 400 or 404, is the answer at once. A 2026-07-28 remote's Retry-After header sets the wait, up to 10 seconds. See retries. (Changed in 1.9.3: before, only network failures were retried, so a 503 wasn't, nor a timeout.)
  • A call that takes longer than timeout comes back as REMOTE_TIMEOUT_ERROR, Remote operation "get_status" on "status" timed out after 30000ms, after its retries. The remote isn't told to stop, so it may run the call once per try. See limiting how long a call can take.
  • A remote that answers 401 to a tool call, a resource read or a prompt fails it with RemoteAuthError, Authentication failed for remote server "status": the remote server refused the credentials (HTTP 401) (REMOTE_AUTH_ERROR), whatever the remoteAuth mode, and isn't retried. Since 1.9.3; before, it was a REMOTE_TOOL_EXECUTION_ERROR.
  • The circuit breaker. After 5 failed attempts within a minute, retries and timeouts included, FrontMCP stops forwarding calls to that remote for 30 seconds. Every call to any of its tools fails at once with Circuit breaker is open for status. Retry in 30s (TOOL_EXECUTION_ERROR, and "Internal FrontMCP error" in production). Other remote apps aren't affected.
  • Resource reads and prompts aren't retried or counted by the breaker. A failure is JSON-RPC error -32603 with data.code REMOTE_RESOURCE_READ_ERROR or REMOTE_PROMPT_GET_ERROR, like Remote resource "status://services/api" on "status" read failed: …, or -32602 with REMOTE_RESOURCE_NOT_FOUND, Resource "…" not found on remote server "status", when the remote's message says "not found".
  • A tool the remote no longer has is dropped from your server when FrontMCP next reads the remote's list, once cacheTTL has passed, and a call then fails as for any unknown tool, TOOL_NOT_FOUND. Until then, the call is forwarded and fails the way the remote answers it. See picking up changes.

Caveats

  • The remote must be up when your server starts, or your server doesn't start: createFetchHandler() rejects with the connection error, as createDirect() does. On an edge runtime, like Cloudflare Workers, the server starts on the first request instead. Until it starts, every request gets 503 {"error":"server_unavailable","code":"SERVER_START_FAILED"} with a Retry-After header, and FrontMCP tries again only after that delay, which grows from a second to a minute. Changed in 1.8.4: before, createFetchHandler() always waited for the first request. Changed in 1.8.5: on an edge runtime, that request used to throw, and the next one tried again at once.
  • With transportOptions.headers or remoteAuth: { mode: "static" }, everyone who calls a remote tool reaches the remote with the same credentials. To pass each caller's own token on, the server must be in transparent mode, with remoteAuth: { mode: "forward" }; to send credentials you keep for each caller, use remoteAuth: { mode: "mapped" }.
  • refreshInterval is read from the first remote app only, and applies to every remote app on the server.
  • Remote apps have no plugins, adapters or providers of their own.
  • To forward one tool, resource or prompt instead of the whole server, use Tool.remote(), Resource.remote() or Prompt.remote() in an app's lists. Agent.remote(), Skill.remote() and Job.remote() stop the server from starting.
  • urlType: "worker", in a hand-written app object, isn't implemented: the server fails to start with McpClientService.createTransport[worker]() is not implemented.
  • This page is about your server calling another one. How callers sign in to your server through another identity provider is auth: { mode: "remote" }, covered in Remote and proxied auth.

Usage

Mounting a remote server

Here the help desk's own tool and the status server's tool are served side by side. The status server's tool gets the status: prefix, and a call to it is forwarded under its own name:

Open
import "./status.example";
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "closed" };
  }
}

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp, App.remote("https://status.example/mcp", { namespace: "status" })],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Without namespace, the prefix is the app's name, which comes from the URL: status here too. Set it anyway, so the names don't change if the URL does. Local tools don't get a prefix unless their names clash with another app's, as described in @App.

What clients see

Everything the remote offers is served under the namespace. The remote's tool metadata is passed on, except title and outputSchema; resource URIs are kept; and results come back as the remote sent them, errors included:

Open
import "./status.example";
import { App, FrontMcp } from "@frontmcp/sdk";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [App.remote("https://status.example/mcp")],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A remote with more than one page of tools

A FrontMCP server pages tools/list past 40 tools, and other servers may page any of their lists. FrontMCP follows each list's nextCursor to the end, for tools, resources, resource templates and prompts, and serves everything it got. Before 1.8.6 it read only the first page, so a remote with more than 40 tools had only 40 proxied, with no error. Here the remote answers two items to a page:

Open
import "./status.example";
import { App, FrontMcp } from "@frontmcp/sdk";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [App.remote("https://status.example/mcp", { namespace: "status" })],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Talking to a 2026-07-28 server

By default FrontMCP opens a session with the remote, as clients did before protocol 2026-07-28. With transportOptions.protocolVersion: "2026-07-28", it asks server/discover instead, and sends every request on its own, with the 2026-07-28 headers and _meta, and no session to keep. Since FrontMCP 1.9.0; before, protocolVersion was dropped from the options:

Open
import "./status.example";
import { App, FrontMcp } from "@frontmcp/sdk";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { protocolVersion: "2026-07-28" } })],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A remote that only speaks the older protocol doesn't answer server/discover, so with "2026-07-28" your server doesn't start: Method not found: server/discover. "auto" tries server/discover and, when it isn't answered, opens a session as "legacy" does, which suits a remote you don't control; that was checked in Node.

Authenticating to the remote server

Give the remote its credentials with remoteAuth: { mode: "static", credentials }. FrontMCP sends them with every request to the remote, starting with initialize, and doesn't follow redirects on those requests, so a redirect can't carry them to another host. Read the secret from the environment, not from your code:

Open
import "./status.example";
import { App, FrontMcp } from "@frontmcp/sdk";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [
    App.remote("https://status.example/mcp", {
      namespace: "status",
      remoteAuth: { mode: "static", credentials: { type: "bearer", value: process.env.STATUS_TOKEN! } },
    }),
  ],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The credentials are between your server and the remote. Callers still authenticate to your server with its own auth, as the last test shows, and call remote tools like any other.

credentials.typeSent as
"bearer"Authorization: Bearer <value>
"basic"Authorization: Basic <value>. The value is sent as it is, so encode user:password in Base64 yourself.
"apiKey"<headerName>: <value>, with headerName defaulting to X-API-Key.

transportOptions.headers reaches the remote the same way, for any header you need. When both set the same header, remoteAuth wins. remoteAuth: { mode: "oauth" } is accepted, but sends nothing: against a remote that needs credentials, the server doesn't start.

Passing each caller's token on

When the remote trusts the same identity provider as your server, it can see who is calling. remoteAuth: { mode: "forward" } sends each tool call with the caller's own token, as Authorization: Bearer <token>. Only a server in transparent mode receives its callers' tokens from the identity provider; any other server refuses to start with forward, since the token it holds was minted for itself. Starting up, FrontMCP connects to the remote and lists its tools without credentials, so the remote has to answer those without a token:

Open
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { calls } from "./status.example";
import { JWKS, NOUR, SAM } from "./tokens";

const app = App.remote("https://status.example/mcp", { namespace: "status", remoteAuth: { mode: "forward" } });
const config = {
  info: { name: "help-desk", version: "1.0.0" },
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    allowAnonymous: true,
    providerConfig: { jwks: JWKS },
  },
  apps: [app],
};

/** Calls a remote tool over HTTP, as a client signed in with `token` would. */
async function callAs(token: string | undefined, name: string) {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": name,
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  return (await response.json()).result.content[0].text;
}

test("each call carries its caller's token", async () => {
  expect(await callAs(NOUR, "status:whoami")).toBe(`Bearer ${NOUR}`);
  expect(await callAs(SAM, "status:whoami")).toBe(`Bearer ${SAM}`);
});

test("an anonymous caller's call carries none", async () => {
  expect(await callAs(undefined, "status:whoami")).toBe("no token");
});

test("connecting and listing carry none either", async () => {
  await callAs(NOUR, "status:whoami");
  expect(calls.filter((c) => c.method !== "tools/call").every((c) => c.authorization === null)).toBe(true);
});

test("a server that isn't transparent refuses to start", async () => {
  await expect(FrontMcpInstance.createFetchHandler({ info: config.info, apps: [app] })).rejects.toThrow(
    "remoteAuth: { mode: 'forward' } on status would send the remote a token this server minted or holds itself",
  );
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

tokenClaim: "<claim>" forwards that claim of the caller's token instead of the whole token, and headerName sends it in another header. A caller without a token, like an anonymous one, reaches the remote with none, and FrontMCP logs No gateway auth token to forward for status.

Credentials for each caller

When the remote has credentials of its own for each of your callers, like an API key per customer, remoteAuth: { mode: "mapped", mapper } picks them. FrontMCP calls mapper with the caller's auth info, { token, clientId, scopes, … }, for each tool call, resource read and prompt request, and sends the credentials it returns, of any credentials.type above. It works whatever your server's auth mode. A mapper that throws fails the call with RemoteAuthError, and so does a remote that answers 401. Since 1.9.3; before, App.remote() rejected mapped. Here Nour has a key, Sam's key has been revoked, and an anonymous caller has none:

Open
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { calls } from "./status.example";
import { JWKS, NOUR, SAM } from "./tokens";

const statusKeys: Record<string, string> = { nour: "status-key-nour", sam: "status-key-revoked" };

const app = App.remote("https://status.example/mcp", {
  namespace: "status",
  remoteAuth: {
    mode: "mapped",
    mapper: (authInfo) => {
      const key = statusKeys[authInfo?.clientId ?? ""];
      if (!key) throw new Error(`no status key for ${authInfo?.clientId}`);
      return { type: "apiKey", value: key };
    },
  },
});

/** Calls a remote tool over HTTP, as a client signed in with `token` would. */
async function callAs(token: string | undefined) {
  const handler = await FrontMcpInstance.createFetchHandler({
    info: { name: "help-desk", version: "1.0.0" },
    auth: { mode: "transparent", provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", allowAnonymous: true, providerConfig: { jwks: JWKS } },
    apps: [app],
  });
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": "status:get_status",
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "status:get_status", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  return (await response.json()).result;
}

test("each call carries the key the mapper picked for its caller", async () => {
  expect((await callAs(NOUR)).content[0].text).toBe("all systems up");
  expect(calls.at(-1)).toEqual({ method: "tools/call", apiKey: "status-key-nour" });
});

test("a mapper that throws fails the call with RemoteAuthError", async () => {
  const result = await callAs(undefined);
  expect(result.isError).toBe(true);
  expect(result.content[0].text).toMatch(/^Authentication failed for remote server "status": Auth mapping failed: no status key for anon:/);
  expect(result._meta.code).toBe("REMOTE_AUTH_ERROR");
});

test("so does a key the remote refuses", async () => {
  const result = await callAs(SAM);
  expect(result.isError).toBe(true);
  expect(result.content[0].text).toBe('Authentication failed for remote server "status": the remote server refused the credentials (HTTP 401)');
});

test("connecting and listing carry no key", async () => {
  await callAs(NOUR);
  expect(calls.filter((c) => c.method !== "tools/call").every((c) => c.apiKey === null)).toBe(true);
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The client gets an isError result with _meta.code REMOTE_AUTH_ERROR, and its message is kept in production. Like forward, mapped sends nothing while connecting and listing, so the remote has to answer those without credentials.

Limiting how long a call can take

transportOptions.timeout caps each try of a tool call. A try that runs out is retried, and when the last one runs out too, the client gets REMOTE_TIMEOUT_ERROR. The remote isn't told to stop, so it carries on with every try, and a slow tool runs once per try:

Open
import "./status.example";
import { App, FrontMcp } from "@frontmcp/sdk";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { timeout: 100, retryDelayMs: 50 } })],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

For a tool that mustn't run twice, like one that sends a message or charges a card, set retryAttempts: 0 on its remote app, as the second test does, or make the remote ignore a repeat. Each timed-out try counts as a failure for the circuit breaker. The timeout only covers tool calls: a resource read or a prompt waits as long as the remote takes. (Changed in 1.9.3: before, a call that ran out of timeout wasn't retried, so the remote ran it once.)

Retrying calls that fail on the way

A tool call that fails in a way that may pass is tried again: by default twice more, 1 and then 2 seconds apart. That's a network failure, like fetch failed or a refused or reset connection, a try that ran out of timeout, or an HTTP answer of 408, 425, 429, or 5xx other than 501 and 505. FrontMCP goes by the status, not by the body. retryAttempts sets how many more times, and retryDelayMs the first wait. Here the connection drops twice, and the third try gets through:

Open
import "./status.example";
import { App, FrontMcp } from "@frontmcp/sdk";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { retryAttempts: 2, retryDelayMs: 50 } })],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The remote answers each failure with Retry-After: 1. On a 2026-07-28 remote, that header replaces the wait, up to 10 seconds, as the last test shows: the retry waits a second, not 50 ms. On the default "legacy" session, FrontMCP doesn't read it, and waits retryDelayMs. Each failed try counts toward the circuit breaker, so one call that fails three times counts three times. A 401 isn't retried: it fails with RemoteAuthError.

Changed in 1.9.3: before, FrontMCP looked for words like fetch failed or 503 in the error's message, which carries the remote's response body, not its status, so a 503 wasn't retried, nor a call that ran out of timeout, and Retry-After wasn't read. A call that timed out or got a 5xx may have run on the remote already, so a retry can run it twice: give a remote whose tools mustn't run twice retryAttempts: 0.

Picking up changes on the remote server

FrontMCP keeps the remote's lists for cacheTTL milliseconds, a minute by default. The next request after that asks the remote again, and replaces the remote's tools, resources and prompts with what it lists now: new tools appear, and tools the remote removed disappear. Before 1.9.0, removed tools stayed listed, and calling them failed with REMOTE_TOOL_NOT_FOUND:

Open
import "./status.example";
import { App, FrontMcp } from "@frontmcp/sdk";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [App.remote("https://status.example/mcp", { namespace: "status", cacheTTL: 200 })],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A shorter cacheTTL finds changes sooner, at the cost of four list requests to the remote each time it runs out.

refreshInterval asks the remote for its lists in the background instead, every so many milliseconds. It notices a change only when the number of tools, resources or prompts changes: then the next request registers the new ones, as above. A tool renamed on the remote, with the count unchanged, isn't picked up until cacheTTL runs out. Only the first remote app's refreshInterval counts, for every remote app on the server. That was checked in Node; the examples here don't use it, because it keeps asking for as long as the server runs.

Serving part of a remote server

filter picks which of the remote's tools, resources and prompts your server serves. Patterns match the remote's own names, before the namespace, and * matches any run of characters. Here clients get the status server's read tools, but not the one that deletes incidents:

Open
import "./status.example";
import { App, FrontMcp } from "@frontmcp/sdk";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [
    App.remote("https://status.example/mcp", {
      namespace: "status",
      filter: { exclude: { tools: ["delete_*"] } },
    }),
  ],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

To serve only what you list, start from nothing: filter: { default: "exclude", include: { tools: ["get_*"] } }. include and exclude take tools, resources and prompts, each a list of patterns. The remote itself still has the tools: filter only decides what your server lists and forwards.

Forwarding one tool

Tool.remote(url, name) takes a single tool from a remote server and puts it in one of your apps, next to your own tools. It keeps the remote's name, with no namespace, and the rest of the remote's tools aren't served. Resource.remote() and Prompt.remote() do the same for a resource or a prompt. Since FrontMCP 1.9.1; before, every list rejected what they return.

Open
import "./status.example";
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "closed" };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [CloseTicket, Tool.remote("https://status.example/mcp", "get_status")],
})
class HelpDeskApp {}

@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] })
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Tool.remote() takes transportOptions and metadata; entries with the same URL and options share one connection. Agent.remote(), Skill.remote() and Job.remote() exist too, but there's nothing to load them: a server that lists one doesn't start, with ExternalEntryNotSupportedError.


Troubleshooting

Failed to connect to remote MCP server "status" at …: SSE error: Non-200 status code (401)

Your server couldn't connect to the remote, so it didn't start. FrontMCP first tried the Streamable HTTP transport, then the older SSE one, and this is the SSE attempt's error. The status says why:

  • 401 or 403: the remote wants credentials, or refused the ones it got. Send them with remoteAuth: { mode: "static", credentials } or transportOptions.headers; remoteAuth: { mode: "oauth" }, forward and mapped send nothing while connecting. See authenticating.
  • 404: the URL's path is wrong. Use the remote's MCP endpoint, like /mcp, not its home page.
  • fetch failed, ECONNREFUSED: the remote isn't running, or the host or port is wrong. The remote must be up before your server starts.

Failed to connect to remote MCP server "status" at …: Method not found: server/discover

The app has transportOptions.protocolVersion: "2026-07-28", and the remote doesn't speak that revision. Use "auto", which falls back to a session, or leave protocolVersion out. See a 2026-07-28 remote.

Remote operation "…" on "status" timed out after 30000ms

The remote took longer than transportOptions.timeout to answer a tool call, on every try. Raise the timeout for a remote that's slow on purpose, or find out why it's slow. The remote keeps working on each try after FrontMCP gives up, so it may have run the call 3 times: see limiting how long a call can take.

Remote tool "…" on "status" failed: …

The remote couldn't be reached during the call, or answered with a protocol or HTTP error. The text after the colon is the underlying error: fetch failed for a network failure, Streamable HTTP error: Error POSTing to endpoint: … with the remote's response body for an HTTP error, or HTTP 503: … from a 2026-07-28 remote. A network failure, a 408, 425, 429 or a 5xx other than 501 and 505 comes after 3 tries; any other status after one. A tool that ran and failed on the remote isn't this: its own isError result comes back as it is.

Circuit breaker is open for status. Retry in 30s

Calls to this remote failed 5 times within a minute, so FrontMCP stopped forwarding them for 30 seconds. Every tool of the remote fails like this until then, even one that works; in production the client sees "Internal FrontMCP error" with TOOL_EXECUTION_ERROR. Fix what made the calls fail, and wait. Each try counts, so a call that is retried and fails, like the HTTP 500 here, counts 3 times, and the second one opens the circuit. FrontMCP checks the breaker when a call starts, so that second call still makes its 3 tries:

Open
import "./status.example";
import { App, FrontMcp } from "@frontmcp/sdk";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { retryDelayMs: 50 } })],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Tool "status:get_status" not found for a tool that worked

The remote stopped offering it: since 1.9.0, FrontMCP drops a tool the remote removed when it next reads the remote's list. Check the remote's own tools/list. Before 1.9.0 the tool stayed listed, and calls failed with REMOTE_TOOL_NOT_FOUND, Tool "get_status" not found on remote server "status", until a restart. See picking up changes.

Tool names start with localhost: or 127:

The default namespace is the app's name, taken from the URL's host up to the first dot. Set namespace (or name) in the options.

Authentication failed for remote server "status": …

A RemoteAuthError (REMOTE_AUTH_ERROR). After the colon:

  • the remote server refused the credentials (HTTP 401): the remote answered a tool call, resource read or prompt request with 401. Check the credentials your remoteAuth sends, or the caller's token with forward.
  • Auth mapping failed: …: your remoteAuth: { mode: "mapped" } mapper threw, with its message after the colon. See credentials for each caller.

A remote that refuses the connection while your server starts is a RemoteConnectionError instead, and the server doesn't start, as above. The error classes FrontMCP throws for remote apps are listed in error classes.

Two apps share the id "status"

The whole message is Two apps share the id "status": give each App.esm() / App.remote() its own `name` or `namespace`. Two of the server's App.remote() or App.esm() apps have one id, so the server doesn't start (DuplicateAppIdError). An app's id is its name, or its namespace when it has no name, else the URL's host up to the first dot: https://mcp.github.com and https://mcp.linear.app are both mcp. Give each app its own namespace:

Open
import "./status.example";
import { App, FrontMcp } from "@frontmcp/sdk";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [
    App.remote("https://status.example/mcp", { namespace: "status" }),
    App.remote("https://status.example/v2/mcp", { namespace: "status_v2" }),
  ],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Since 1.9.3. Before, the id always came from the name, and the server started.