# Remote servers

> App.remote() mounts another MCP server as an app, so its tools, resources and prompts appear on your server and calls are forwarded to it.

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

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

```ts
App.remote(url, options?)
```

---

## Reference

### `App.remote(url, options?)`

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

```ts 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.](#usage)

#### Parameters

| Parameter | Type | Description |
| --- | --- | --- |
| `url` | `string` | The 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. |
| `options` | `RemoteUrlAppOptions` | Optional. See below. |

#### Options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `namespace` | `string` | `name` | Prefix for the remote's tool, resource and prompt names: `status` turns `get_status` into `status:get_status`. |
| `name` | `string` | The URL's host up to the first dot | The 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`](#two-apps-share-the-id-status). (Changed in 1.9.3: before, the id always came from the name.) |
| `description` | `string` | | What the app is for, for your own tooling. |
| `transportOptions` | `RemoteTransportOptions` | | How to reach the remote. See below. |
| `cacheTTL` | `number` | `60000` | Milliseconds FrontMCP keeps the remote's list of tools, resources and prompts before it asks again. See [picking up changes](#picking-up-changes-on-the-remote-server). |
| `refreshInterval` | `number` | `0` (off) | Ask the remote for its lists every this many milliseconds, in the background. See [picking up changes](#picking-up-changes-on-the-remote-server). |
| `standalone` | `boolean \| "includeInParent"` | `false` | Serve the app on its own path, as for [`@App`](https://frontmcp.dev/reference/sdk/app#standalone-apps). The path is the app's id: `/status` for `https://status.example/mcp`. |
| `remoteAuth` | `RemoteAuthConfig` | | Credentials 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](#authenticating-to-the-remote-server). |
| `filter` | `AppFilterConfig` | Serve everything | Which 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](#serving-part-of-a-remote-server). |

`transportOptions`:

| Field | Default | What it does |
| --- | --- | --- |
| `headers` | | Sent 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](#talking-to-a-2026-07-28-server). Since 1.9.0. |
| `timeout` | `30000` | Milliseconds 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. |
| `fallbackToSSE` | `true` | When 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`. |
| `retryAttempts` | `2` | How 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](#retrying-calls-that-fail-on-the-way). |
| `retryDelayMs` | `1000` | Milliseconds 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:

```ts
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](#a-remote-with-more-than-one-page-of-tools)). 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`](https://frontmcp.dev/reference/server/observability#health) 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`](#talking-to-a-2026-07-28-server) 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 server | Notes |
| --- | --- | --- |
| 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 URI | Without a description: `Remote resource: <name>`. |
| Resource templates | `<namespace>:<name>`, same URI template | Before 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](#retrying-calls-that-fail-on-the-way). (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](#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](#picking-up-changes-on-the-remote-server).

#### Caveats

- The remote must be up when your server starts, or your server doesn't start: [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) 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" }`](#passing-each-callers-token-on); to send credentials you keep for each caller, use [`remoteAuth: { mode: "mapped" }`](#credentials-for-each-caller).
- `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()`](#forwarding-one-tool), `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](https://frontmcp.dev/reference/auth/remote).

---

## Usage

> **Note**
The examples on this page don't use the network: each has a `status.example.ts` file that plays an MCP server at `https://status.example/mcp`. It answers FrontMCP's requests in-process, with just enough of the protocol, and records them so tests can check them. The remote could be any MCP server; you won't need the file in a real server.

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

```ts main.ts active
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 {}
```

```ts status.example.ts
// Stands in for an MCP server at https://status.example/mcp, which the Playground
// can't reach. It answers FrontMCP's requests in-process, with just enough of the
// protocol, and passes everything else to the real fetch. A real server doesn't
// need this file: the remote can be any MCP server.

const services: Record<string, boolean> = { api: true, billing: false };

const tools = [
  {
    name: "get_status",
    description: "Current status of one service",
    inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"] },
  },
];

/** Every JSON-RPC message the remote server received, oldest first. */
export const received: { method: string; params?: any }[] = [];

function handle(method: string, params: any) {
  switch (method) {
    case "initialize":
      return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } };
    case "tools/list":
      return { tools };
    case "tools/call": {
      const status = { service: params.arguments.service, up: services[params.arguments.service] ?? false };
      return { content: [{ type: "text", text: JSON.stringify(status) }], structuredContent: status };
    }
  }
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "status.example") return realFetch(input, init);
  if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream
  const message = JSON.parse(String(init.body));
  received.push({ method: message.method, params: message.params });
  if (message.id === undefined) return new Response(null, { status: 202 }); // a notification
  const result = handle(message.method, message.params);
  const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } };
  return Response.json({ jsonrpc: "2.0", id: message.id, ...reply });
};
```

```ts remote.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { received } from "./status.example";

test("the remote's tools are listed next to the local ones, prefixed", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["close_ticket", "status:get_status"]);
});

test("/readyz has a check for the remote, which is ready before its first check has run", async () => {
  const handler = await FrontMcpInstance.createFetchHandler({
    info: { name: "help-desk", version: "1.0.0" },
    apps: [App.remote("https://status.example/mcp", { namespace: "status" })],
    health: { includeDetails: true },
  });
  const response = await handler(new Request("https://desk.example.com/readyz"));
  expect(response.status).toBe(200);
  expect(["degraded", "healthy"]).toContain((await response.json()).probes["remote:status"].status);
});

test("FrontMCP connected with initialize, then forwards calls under the remote's own name", async ({ mcp }) => {
  const result = await mcp.tools.call("status:get_status", { service: "billing" });
  expect(result.json()).toEqual({ service: "billing", up: false });
  expect(received.slice(0, 2).map((m) => m.method)).toEqual(["initialize", "notifications/initialized"]);
  expect(received[0].params).toMatchObject({ protocolVersion: "2025-11-25", clientInfo: { name: "frontmcp-gateway", version: "1.0.0" } });
  expect(received.at(-1)).toEqual({ method: "tools/call", params: { name: "get_status", arguments: { service: "billing" } } });
});
```

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`](https://frontmcp.dev/reference/sdk/app#when-two-apps-use-the-same-name).

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

```ts main.ts active
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 {}
```

```ts status.example.ts
// Stands in for an MCP server at https://status.example/mcp, which the Playground
// can't reach. It answers FrontMCP's requests in-process, with just enough of the
// protocol, and passes everything else to the real fetch. A real server doesn't
// need this file: the remote can be any MCP server.

const services: Record<string, boolean> = { api: true, billing: false };
const text = (value: string) => ({ content: [{ type: "text", text: value }] });

const tools = [
  {
    name: "get_status",
    title: "Service status",
    description: "Current status of one service",
    inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"] },
    outputSchema: { type: "object", properties: { service: { type: "string" }, up: { type: "boolean" } } },
    annotations: { readOnlyHint: true },
  },
  { name: "list_services", inputSchema: { type: "object", properties: {} } },
];
const resources = [{ name: "incidents", uri: "status://incidents", mimeType: "application/json" }];
const resourceTemplates = [{ name: "service", uriTemplate: "status://services/{name}", mimeType: "application/json" }];
const prompts = [{ name: "status_report", description: "Write a status report", arguments: [{ name: "audience", required: true }] }];

/** Every JSON-RPC message the remote server received, oldest first. */
export const received: { method: string; params?: any }[] = [];

function handle(method: string, params: any) {
  switch (method) {
    case "initialize":
      return { protocolVersion: params.protocolVersion, capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: "status", version: "3.1.0" } };
    case "tools/list":
      return { tools };
    case "resources/list":
      return { resources };
    case "resources/templates/list":
      return { resourceTemplates };
    case "prompts/list":
      return { prompts };
    case "tools/call": {
      if (params.name === "list_services") return text(Object.keys(services).join(", "));
      const { service } = params.arguments;
      if (!(service in services)) return { ...text(`No service called ${service}`), isError: true };
      const status = { service, up: services[service] };
      return { ...text(JSON.stringify(status)), structuredContent: status };
    }
    case "resources/read": {
      const name = params.uri.replace("status://services/", "");
      const body = params.uri === "status://incidents" ? [] : { service: name, up: services[name] ?? false };
      return { contents: [{ uri: params.uri, mimeType: "application/json", text: JSON.stringify(body) }] };
    }
    case "prompts/get":
      return { messages: [{ role: "user", content: { type: "text", text: `Write a status report for ${params.arguments.audience}.` } }] };
  }
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "status.example") return realFetch(input, init);
  if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream
  const message = JSON.parse(String(init.body));
  received.push({ method: message.method, params: message.params });
  if (message.id === undefined) return new Response(null, { status: 202 }); // a notification
  const result = handle(message.method, message.params);
  const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } };
  return Response.json({ jsonrpc: "2.0", id: message.id, ...reply });
};
```

```ts clients.test.ts
import { test, expect } from "@frontmcp/testing";
import { received } from "./status.example";

test("tools keep their description, schema and annotations, not title or outputSchema", async ({ mcp }) => {
  const [getStatus, listServices] = await mcp.tools.list();
  expect(getStatus).toEqual({
    name: "status:get_status",
    description: "Current status of one service",
    inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"] },
    annotations: { readOnlyHint: true },
  });
  expect(listServices.description).toBe("Remote tool: list_services");
});

test("results and errors come back as the remote sent them", async ({ mcp }) => {
  expect((await mcp.tools.call("status:get_status", { service: "api" })).raw.structuredContent).toEqual({ service: "api", up: true });
  const missing = await mcp.tools.call("status:get_status", { service: "search" });
  expect(missing).toBeError();
  expect(missing.text()).toBe("No service called search");
});

test("arguments are checked by the remote, not by FrontMCP", async ({ mcp }) => {
  await mcp.tools.call("status:get_status", { service: 42 });
  expect(received.at(-1)).toEqual({ method: "tools/call", params: { name: "get_status", arguments: { service: 42 } } });
});

test("resources keep their URIs; templates are prefixed like the rest", async ({ mcp }) => {
  expect(await mcp.resources.list()).toEqual([
    { name: "status:incidents", uri: "status://incidents", mimeType: "application/json", description: "Remote resource: incidents" },
  ]);
  const templates = await mcp.resources.listTemplates();
  expect(templates.map((t) => t.name)).toEqual(["status:service"]);
  expect((await mcp.resources.read("status://services/billing")).json()).toEqual({ service: "billing", up: false });
});

test("prompts are prefixed, and required arguments are checked first", async ({ mcp }) => {
  expect((await mcp.prompts.list()).map((p) => p.name)).toEqual(["status:status_report"]);
  const prompt = await mcp.prompts.get("status:status_report", { audience: "customers" });
  expect(prompt.messages[0].content).toEqual({ type: "text", text: "Write a status report for customers." });
  expect((await mcp.prompts.get("status:status_report", {})).error?.message).toBe("Missing required argument: audience");
});
```

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

```ts main.ts active
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 {}
```

```ts status.example.ts
// Plays an MCP server at https://status.example/mcp that answers two items to a page.

const names = ["check_api", "check_billing", "check_search", "check_mail", "check_chat"];
const tools = names.map((name) => ({ name, description: `Status of ${name.replace("check_", "")}`, inputSchema: { type: "object", properties: {} } }));
const resources = ["incidents", "maintenance", "history"].map((name) => ({ name, uri: `status://${name}`, mimeType: "application/json" }));
const PAGE_SIZE = 2;

/** Every JSON-RPC message the remote server received, oldest first. */
export const received: { method: string; params?: any }[] = [];

function page<Item>(items: Item[], key: string, cursor?: string) {
  const start = Number(cursor ?? 0);
  const end = start + PAGE_SIZE;
  return { [key]: items.slice(start, end), ...(end < items.length ? { nextCursor: String(end) } : {}) };
}

function handle(method: string, params: any) {
  switch (method) {
    case "initialize":
      return { protocolVersion: params.protocolVersion, capabilities: { tools: {}, resources: {} }, serverInfo: { name: "status", version: "3.1.0" } };
    case "tools/list":
      return page(tools, "tools", params?.cursor);
    case "resources/list":
      return page(resources, "resources", params?.cursor);
    case "resources/templates/list":
      return { resourceTemplates: [] };
    case "prompts/list":
      return { prompts: [] };
    case "tools/call":
      return { content: [{ type: "text", text: JSON.stringify({ tool: params.name }) }] };
  }
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "status.example") return realFetch(input, init);
  if (init?.method !== "POST") return new Response(null, { status: 405 });
  const message = JSON.parse(String(init.body));
  received.push({ method: message.method, params: message.params });
  if (message.id === undefined) return new Response(null, { status: 202 });
  const result = handle(message.method, message.params);
  const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } };
  return Response.json({ jsonrpc: "2.0", id: message.id, ...reply });
};
```

```ts paging.test.ts
import { test, expect } from "@frontmcp/testing";
import { received } from "./status.example";

test("every page of the remote's tools is served, not only the first", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((tool) => tool.name);
  expect(names).toHaveLength(5);
  expect(names).toContain("status:check_chat");
});

test("FrontMCP asked for each page with the cursor the remote gave", async ({ mcp }) => {
  await mcp.tools.list();
  const cursors = received.filter((message) => message.method === "tools/list").map((message) => message.params?.cursor);
  expect(cursors.slice(0, 3)).toEqual([undefined, "2", "4"]);
});

test("resources are paged the same way", async ({ mcp }) => {
  const uris = (await mcp.resources.list()).map((resource) => resource.uri);
  expect(uris).toHaveLength(3);
  expect(uris).toContain("status://history");
});

test("a tool from the last page can be called", async ({ mcp }) => {
  expect((await mcp.tools.call("status:check_chat")).json()).toEqual({ tool: "check_chat" });
});
```

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

```ts main.ts active
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 {}
```

```ts status.example.ts
// Stands in for an MCP server at https://status.example/mcp that speaks protocol
// 2026-07-28 only. It answers FrontMCP's requests in-process, with just enough of
// the protocol, and passes everything else to the real fetch. A real server
// doesn't need this file.

const services: Record<string, boolean> = { api: true, billing: false };

const tools = [
  {
    name: "get_status",
    description: "Current status of one service",
    inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"] },
  },
];

/** Every request the remote server received, oldest first: its method, its MCP headers and its params. */
export const received: { method: string; headers: Record<string, string>; params?: any }[] = [];

function handle(method: string, params: any) {
  switch (method) {
    case "server/discover":
      return { supportedVersions: ["2026-07-28"], capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } };
    case "tools/list":
      return { tools };
    case "resources/list":
      return { resources: [] };
    case "resources/templates/list":
      return { resourceTemplates: [] };
    case "prompts/list":
      return { prompts: [] };
    case "tools/call": {
      const status = { service: params.arguments.service, up: services[params.arguments.service] ?? false };
      return { content: [{ type: "text", text: JSON.stringify(status) }], structuredContent: status };
    }
  }
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "status.example") return realFetch(input, init);
  if (init?.method !== "POST") return new Response(null, { status: 405 });
  const headers = Object.fromEntries([...new Headers(init.headers)].filter(([name]) => name.startsWith("mcp-")));
  const message = JSON.parse(String(init.body));
  received.push({ method: message.method, headers, params: message.params });
  const result = handle(message.method, message.params);
  const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } };
  return Response.json({ jsonrpc: "2.0", id: message.id, ...reply });
};
```

```ts stateless.test.ts
import { test, expect } from "@frontmcp/testing";
import { received } from "./status.example";

test("FrontMCP discovers the remote instead of opening a session", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["status:get_status"]);
  expect(received[0].method).toBe("server/discover");
  expect(received.map((r) => r.method)).not.toContain("initialize");
  expect(received[0].params._meta).toEqual({
    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
    "io.modelcontextprotocol/clientInfo": { name: "frontmcp-gateway", version: "1.0.0" },
    "io.modelcontextprotocol/clientCapabilities": {},
  });
});

test("each call carries the 2026-07-28 headers, and no session", async ({ mcp }) => {
  expect((await mcp.tools.call("status:get_status", { service: "billing" })).json()).toEqual({ service: "billing", up: false });
  expect(received.at(-1)).toMatchObject({
    method: "tools/call",
    headers: { "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "get_status" },
  });
  expect(received.every((r) => !("mcp-session-id" in r.headers))).toBe(true);
});
```

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`](#failed-to-connect-to-remote-mcp-server-status-at--method-not-found-serverdiscover). `"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:

```ts main.ts active
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 {}
```

```ts status.example.ts
// Stands in for an MCP server at https://status.example/mcp that only answers
// requests with the right token. It answers FrontMCP's requests in-process, and
// passes everything else to the real fetch. A real server doesn't need this file.

// Your deployment would set this. Here the stand-in does.
process.env.STATUS_TOKEN = "status-key-1";

/** The Authorization header and redirect mode of every request the remote server received, oldest first. */
export const received: { authorization: string | null; redirect: RequestRedirect | undefined }[] = [];

function handle(method: string, params: any) {
  switch (method) {
    case "initialize":
      return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } };
    case "tools/list":
      return { tools: [{ name: "get_status", description: "Current status of one service", inputSchema: { type: "object", properties: { service: { type: "string" } } } }] };
    case "tools/call":
      return { content: [{ type: "text", text: `${params.arguments.service} is up` }] };
  }
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "status.example") return realFetch(input, init);
  const authorization = new Headers(init?.headers).get("authorization");
  received.push({ authorization, redirect: init?.redirect });
  if (authorization !== "Bearer status-key-1") return new Response("Unauthorized", { status: 401 });
  if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream
  const message = JSON.parse(String(init.body));
  if (message.id === undefined) return new Response(null, { status: 202 }); // a notification
  const result = handle(message.method, message.params);
  const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } };
  return Response.json({ jsonrpc: "2.0", id: message.id, ...reply });
};
```

```ts auth.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { received } from "./status.example";

test("every request carries the credentials, and doesn't follow redirects", async ({ mcp }) => {
  expect((await mcp.tools.call("status:get_status", { service: "api" })).text()).toBe("api is up");
  expect(new Set(received.map((r) => r.authorization))).toEqual(new Set(["Bearer status-key-1"]));
  expect(new Set(received.map((r) => r.redirect))).toEqual(new Set(["manual"]));
});

test("transportOptions.headers works too", async () => {
  const server = await FrontMcpInstance.createDirect({
    info: { name: "help-desk", version: "1.0.0" },
    apps: [App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { headers: { Authorization: "Bearer status-key-1" } } })],
  });
  const result = await server.callTool("status:get_status", { service: "db" });
  await server.dispose();
  expect(result.content).toEqual([{ type: "text", text: "db is up" }]);
});

test("without credentials, the server can't start", async () => {
  const starting = FrontMcpInstance.createFetchHandler({
    info: { name: "help-desk", version: "1.0.0" },
    apps: [App.remote("https://status.example/mcp", { namespace: "status" })],
  });
  await expect(starting).rejects.toThrow(
    'Failed to connect to remote MCP server "status" at https://status.example/mcp: SSE error: Non-200 status code (401)',
  );
});

test("callers still need your server's own credentials", async () => {
  const handler = await FrontMcpInstance.createFetchHandler({
    info: { name: "help-desk", version: "1.0.0" },
    auth: { mode: "static", tokens: ["desk-key-1"] },
    apps: [App.remote("https://status.example/mcp", { remoteAuth: { mode: "static", credentials: { type: "bearer", value: "status-key-1" } } })],
  });
  const response = await handler(new Request("http://localhost/", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }));
  expect(response.status).toBe(401);
});
```

The credentials are between your server and the remote. Callers still authenticate to your server with its own [`auth`](https://frontmcp.dev/reference/sdk/auth), as the last test shows, and call remote tools like any other.

| `credentials.type` | Sent 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](https://frontmcp.dev/reference/auth/modes) 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:

```ts forward.test.ts active
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",
  );
});
```

```ts status.example.ts
// Stands in for an MCP server at https://status.example/mcp whose whoami tool
// answers with the Authorization header it received. It answers FrontMCP's requests
// in-process, and passes everything else to the real fetch. A real server doesn't
// need this file.

/** The method and Authorization header of every request the remote server received. */
export const calls: { method: string; authorization: string | null }[] = [];

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "status.example") return realFetch(input, init);
  if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream
  const message = JSON.parse(String(init.body));
  const authorization = new Headers(init?.headers).get("authorization");
  calls.push({ method: message.method, authorization });
  if (message.id === undefined) return new Response(null, { status: 202 }); // a notification
  const result =
    message.method === "initialize"
      ? { protocolVersion: message.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } }
      : message.method === "tools/list"
        ? { tools: [{ name: "whoami", description: "Who the remote thinks is calling", inputSchema: { type: "object", properties: {} } }] }
        : message.method === "tools/call"
          ? { content: [{ type: "text", text: authorization ?? "no token" }] }
          : undefined;
  const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } };
  return Response.json({ jsonrpc: "2.0", id: message.id, ...reply });
};
```

```ts tokens.ts
// Access tokens from auth.example.com, signed ahead of time with the key in JWKS.
// nour is an agent and sam a customer, both at acme.
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsIm5hbWUiOiJOb3VyIEhhZGRhZCIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJhY21lIiwic2l0ZXMiOlsiYmVybGluIiwibGlzYm9uIl0sImh0dHBzOi8vZGVzay5leGFtcGxlLmNvbS9yb2xlcyI6WyJsZWFkIl19.u6qb8pAPftFUuehhbk66UgD3jymiUiJ8_sSD5D_-Tb3COSuxJXFcSrSmquWh1z_5awaC9QUrYtVYptr6XBxnrA";
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwibmFtZSI6IlNhbSBPcnRpeiIsInNjb3BlIjoidGlja2V0czpyZWFkIiwidGVuYW50IjoiYWNtZSIsInNpdGVzIjpbImJlcmxpbiJdfQ.qv5lgslG_X822NsMQ8Hdy39WO3Zrzq1JORDhGABpgNoQ1QIIcFQlQ_S6qKag2AOKZB5z20s4YA_eMxOc9a-N-A";
// The provider's public key. Usually FrontMCP fetches it; here it's inline so the example runs offline.
export const JWKS = { keys: [{"kty":"EC","x":"E5CNMEh_im_-kUoq-qbsGQa7NQBkf3vt8hxAFPH9OGk","y":"HCve2s_TG0bffFc8xEokraDFtq2SWxIE6z2GJfAY52g","crv":"P-256","kid":"desk-3","alg":"ES256","use":"sig"}] };
```

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

// The Playground has no sign-in, so the tests start the transparent server themselves.
@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status" })] })
export default class Server {}
```

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

```ts mapped.test.ts active
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);
});
```

```ts status.example.ts
// Stands in for an MCP server at https://status.example/mcp that runs tools only
// for a valid API key. It answers FrontMCP's requests in-process, and passes
// everything else to the real fetch. A real server doesn't need this file.

/** The method and X-API-Key header of every request the remote server received. */
export const calls: { method: string; apiKey: string | null }[] = [];

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "status.example") return realFetch(input, init);
  if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream
  const message = JSON.parse(String(init.body));
  const apiKey = new Headers(init?.headers).get("x-api-key");
  calls.push({ method: message.method, apiKey });
  if (message.id === undefined) return new Response(null, { status: 202 }); // a notification
  if (message.method === "tools/call" && apiKey !== "status-key-nour") return new Response("invalid API key", { status: 401 });
  const result =
    message.method === "initialize"
      ? { protocolVersion: message.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } }
      : message.method === "tools/list"
        ? { tools: [{ name: "get_status", description: "Current status of every service", inputSchema: { type: "object", properties: {} } }] }
        : message.method === "tools/call"
          ? { content: [{ type: "text", text: "all systems up" }] }
          : undefined;
  const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } };
  return Response.json({ jsonrpc: "2.0", id: message.id, ...reply });
};
```

```ts tokens.ts
// Access tokens from auth.example.com, signed ahead of time with the key in JWKS.
// nour is an agent and sam a customer, both at acme.
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsIm5hbWUiOiJOb3VyIEhhZGRhZCIsInJvbGVzIjpbImFnZW50Il0sInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJ0ZW5hbnQiOiJhY21lIiwic2l0ZXMiOlsiYmVybGluIiwibGlzYm9uIl0sImh0dHBzOi8vZGVzay5leGFtcGxlLmNvbS9yb2xlcyI6WyJsZWFkIl19.u6qb8pAPftFUuehhbk66UgD3jymiUiJ8_sSD5D_-Tb3COSuxJXFcSrSmquWh1z_5awaC9QUrYtVYptr6XBxnrA";
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwibmFtZSI6IlNhbSBPcnRpeiIsInNjb3BlIjoidGlja2V0czpyZWFkIiwidGVuYW50IjoiYWNtZSIsInNpdGVzIjpbImJlcmxpbiJdfQ.qv5lgslG_X822NsMQ8Hdy39WO3Zrzq1JORDhGABpgNoQ1QIIcFQlQ_S6qKag2AOKZB5z20s4YA_eMxOc9a-N-A";
// The provider's public key. Usually FrontMCP fetches it; here it's inline so the example runs offline.
export const JWKS = { keys: [{"kty":"EC","x":"E5CNMEh_im_-kUoq-qbsGQa7NQBkf3vt8hxAFPH9OGk","y":"HCve2s_TG0bffFc8xEokraDFtq2SWxIE6z2GJfAY52g","crv":"P-256","kid":"desk-3","alg":"ES256","use":"sig"}] };
```

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

// The Playground has no sign-in, so the tests start the server themselves.
@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [App.remote("https://status.example/mcp", { namespace: "status" })] })
export default class Server {}
```

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](#retrying-calls-that-fail-on-the-way), 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:

```ts main.ts active
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 {}
```

```ts status.example.ts
// Stands in for an MCP server at https://status.example/mcp whose uptime report
// takes 300 ms. It answers FrontMCP's requests in-process, and passes everything
// else to the real fetch. A real server doesn't need this file.

/** Reports the remote server finished, even after FrontMCP stopped waiting. */
export const finished: string[] = [];

async function handle(method: string, params: any) {
  switch (method) {
    case "initialize":
      return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } };
    case "tools/list":
      return { tools: [{ name: "uptime_report", description: "Uptime of every service over 90 days", inputSchema: { type: "object", properties: {} } }] };
    case "tools/call":
      await new Promise((resolve) => setTimeout(resolve, 300));
      finished.push(params.name);
      return { content: [{ type: "text", text: "api 99.95%, billing 99.9%" }] };
  }
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "status.example") return realFetch(input, init);
  if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream
  const message = JSON.parse(String(init.body));
  if (message.id === undefined) return new Response(null, { status: 202 }); // a notification
  const result = await handle(message.method, message.params);
  const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } };
  return Response.json({ jsonrpc: "2.0", id: message.id, ...reply });
};
```

```ts timeout.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { finished } from "./status.example";

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("each of the 3 tries times out, and the remote finishes all 3", async ({ mcp }) => {
  finished.length = 0;
  const result = await mcp.tools.call("status:uptime_report", {});
  expect(result).toBeError("REMOTE_TIMEOUT_ERROR");
  expect(result.text()).toBe('Remote operation "uptime_report" on "status" timed out after 100ms');
  await wait(300);
  expect(finished).toEqual(["uptime_report", "uptime_report", "uptime_report"]);
});

test("with retryAttempts: 0, it runs once", async () => {
  const server = await FrontMcpInstance.createDirect({
    info: { name: "help-desk", version: "1.0.0" },
    apps: [App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { timeout: 100, retryAttempts: 0 } })],
  });
  finished.length = 0;
  await expect(server.callTool("status:uptime_report", {})).rejects.toThrow('Remote operation "uptime_report" on "status" timed out after 100ms');
  await wait(300);
  await server.dispose();
  expect(finished).toEqual(["uptime_report"]);
});
```

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](#circuit-breaker-is-open-for-status-retry-in-30s). 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`](#limiting-how-long-a-call-can-take), 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:

```ts main.ts active
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 {}
```

```ts status.example.ts
// Stands in for an MCP server at https://status.example/mcp whose tool calls fail
// the way `failures` says, then succeed. It answers FrontMCP's requests in-process,
// and passes everything else to the real fetch. A real server doesn't need this file.

/** How the next tool calls fail, one entry each: "drop" for the connection, or an HTTP status. Tests set it. */
export const failures: ("drop" | number)[] = ["drop", "drop"];

/** How many tool calls reached the remote, the failed ones included. */
export const attempts = { count: 0 };

function handle(method: string, params: any) {
  switch (method) {
    case "initialize":
      return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } };
    case "server/discover":
      return { supportedVersions: ["2026-07-28"], capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } };
    case "tools/list":
      return { tools: [{ name: "get_status", description: "Current status of every service", inputSchema: { type: "object", properties: {} } }] };
    case "tools/call":
      return { content: [{ type: "text", text: "all systems up" }] };
  }
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "status.example") return realFetch(input, init);
  if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream
  const message = JSON.parse(String(init.body));
  if (message.id === undefined) return new Response(null, { status: 202 }); // a notification
  if (message.method === "tools/call") {
    attempts.count++;
    const failure = failures.shift();
    if (failure === "drop") throw new TypeError("fetch failed");
    if (failure) return new Response("try again later", { status: failure, headers: { "Retry-After": "1" } });
  }
  const result = handle(message.method, message.params);
  const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } };
  return Response.json({ jsonrpc: "2.0", id: message.id, ...reply });
};
```

```ts retry.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { attempts, failures } from "./status.example";

/** Calls the tool once, after the remote's tool calls are set to fail as `plan` says. */
async function callFailing(mcp: any, plan: ("drop" | number)[]) {
  failures.splice(0, failures.length, ...plan);
  attempts.count = 0;
  return mcp.tools.call("status:get_status", {});
}

test("two dropped connections, then the third try answers", async ({ mcp }) => {
  expect((await callFailing(mcp, ["drop", "drop"])).text()).toBe("all systems up");
  expect(attempts.count).toBe(3);
});

test("a 503 and a 429 are tried again too, without reading Retry-After", async ({ mcp }) => {
  const started = Date.now();
  expect((await callFailing(mcp, [503, 429])).text()).toBe("all systems up");
  expect(attempts.count).toBe(3);
  expect(Date.now() - started).toBeLessThan(1000);
});

test("a 400 is the answer at once", async ({ mcp }) => {
  const result = await callFailing(mcp, [400]);
  expect(result).toBeError("REMOTE_TOOL_EXECUTION_ERROR");
  expect(result.text()).toBe('Remote tool "get_status" on "status" failed: Streamable HTTP error: Error POSTing to endpoint: try again later');
  expect(attempts.count).toBe(1);
});

test("with retryAttempts: 0, the first failure is the answer", async () => {
  const server = await FrontMcpInstance.createDirect({
    info: { name: "help-desk", version: "1.0.0" },
    apps: [App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { retryAttempts: 0 } })],
  });
  failures.splice(0, failures.length, "drop");
  attempts.count = 0;
  await expect(server.callTool("status:get_status", {})).rejects.toThrow('Remote tool "get_status" on "status" failed: fetch failed');
  await server.dispose();
  expect(attempts.count).toBe(1);
});

test("a 2026-07-28 remote's Retry-After sets the wait", async () => {
  const server = await FrontMcpInstance.createDirect({
    info: { name: "help-desk", version: "1.0.0" },
    apps: [App.remote("https://status.example/mcp", { namespace: "status", transportOptions: { protocolVersion: "2026-07-28", retryDelayMs: 50 } })],
  });
  failures.splice(0, failures.length, 503);
  const started = Date.now();
  const result = await server.callTool("status:get_status", {});
  await server.dispose();
  expect(result.content).toEqual([{ type: "text", text: "all systems up" }]);
  expect(Date.now() - started).toBeGreaterThanOrEqual(1000);
});
```

The remote answers each failure with `Retry-After: 1`. On a [2026-07-28 remote](#talking-to-a-2026-07-28-server), 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](#circuit-breaker-is-open-for-status-retry-in-30s), so one call that fails three times counts three times. A `401` isn't retried: it fails with [`RemoteAuthError`](#credentials-for-each-caller).

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

```ts main.ts active
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 {}
```

```ts status.example.ts
// Stands in for an MCP server at https://status.example/mcp whose tools change
// while it runs. It answers FrontMCP's requests in-process, and passes everything
// else to the real fetch. A real server doesn't need this file.

const tool = (name: string) => ({ name, description: `The ${name} tool`, inputSchema: { type: "object", properties: {} } });

/** The remote's tools, right now. Tests change it. */
export const tools = [tool("get_status")];
export const addTool = (name: string) => tools.push(tool(name));
export const removeTool = (name: string) => tools.splice(tools.findIndex((t) => t.name === name), 1);

function handle(method: string, params: any) {
  switch (method) {
    case "initialize":
      return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } };
    case "tools/list":
      return { tools };
    case "tools/call":
      return { content: [{ type: "text", text: `${params.name} ran` }] };
  }
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "status.example") return realFetch(input, init);
  if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream
  const message = JSON.parse(String(init.body));
  if (message.id === undefined) return new Response(null, { status: 202 }); // a notification
  const result = handle(message.method, message.params);
  const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } };
  return Response.json({ jsonrpc: "2.0", id: message.id, ...reply });
};
```

```ts changes.test.ts
import { test, expect } from "@frontmcp/testing";
import { addTool, removeTool } from "./status.example";

const names = async (mcp: any) => (await mcp.tools.list()).map((t: { name: string }) => t.name);
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("a new remote tool appears once cacheTTL has passed", async ({ mcp }) => {
  expect(await names(mcp)).toEqual(["status:get_status"]);
  addTool("list_incidents");
  expect(await names(mcp)).toEqual(["status:get_status"]);
  await wait(250);
  expect(await names(mcp)).toEqual(["status:get_status", "status:list_incidents"]);
  expect((await mcp.tools.call("status:list_incidents", {})).text()).toBe("list_incidents ran");
});

test("a removed remote tool is dropped once cacheTTL has passed", async ({ mcp }) => {
  removeTool("get_status");
  await wait(250);
  expect(await names(mcp)).not.toContain("status:get_status");
  const result = await mcp.tools.call("status:get_status", {});
  expect(result).toBeError("TOOL_NOT_FOUND");
  expect(result.text()).toBe('Tool "status:get_status" not found');
});
```

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:

```ts main.ts active
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 {}
```

```ts status.example.ts
// Stands in for an MCP server at https://status.example/mcp. It answers FrontMCP's
// requests in-process, and passes everything else to the real fetch. A real server
// doesn't need this file.

const tools = ["get_status", "delete_incident"].map((name) => ({ name, description: `The ${name} tool`, inputSchema: { type: "object", properties: {} } }));

function handle(method: string, params: any) {
  switch (method) {
    case "initialize":
      return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } };
    case "tools/list":
      return { tools };
    case "tools/call":
      return { content: [{ type: "text", text: `${params.name} ran` }] };
  }
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "status.example") return realFetch(input, init);
  if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream
  const message = JSON.parse(String(init.body));
  if (message.id === undefined) return new Response(null, { status: 202 }); // a notification
  const result = handle(message.method, message.params);
  const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } };
  return Response.json({ jsonrpc: "2.0", id: message.id, ...reply });
};
```

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

test("the excluded tool isn't listed", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["status:get_status"]);
});

test("or forwarded", async ({ mcp }) => {
  const result = await mcp.tools.call("status:delete_incident", {});
  expect(result).toBeError();
  expect(result.text()).toBe('Tool "status:delete_incident" not found');
});

test("the remote's other tools work as usual", async ({ mcp }) => {
  expect((await mcp.tools.call("status:get_status", {})).text()).toBe("get_status ran");
});
```

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.

```ts main.ts active
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 {}
```

```ts status.example.ts
// Stands in for an MCP server at https://status.example/mcp, which the Playground
// can't reach. It answers FrontMCP's requests in-process, with just enough of the
// protocol, and passes everything else to the real fetch. A real server doesn't
// need this file: the remote can be any MCP server.

const services: Record<string, boolean> = { api: true, billing: false };

const tools = [
  {
    name: "get_status",
    description: "Current status of one service",
    inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"] },
  },
  { name: "delete_incident", description: "Delete an incident", inputSchema: { type: "object", properties: { id: { type: "string" } } } },
];

/** Every JSON-RPC message the remote server received, oldest first. */
export const received: { method: string; params?: any }[] = [];

function handle(method: string, params: any) {
  switch (method) {
    case "initialize":
      return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } };
    case "tools/list":
      return { tools };
    case "tools/call": {
      const status = { service: params.arguments.service, up: services[params.arguments.service] ?? false };
      return { content: [{ type: "text", text: JSON.stringify(status) }], structuredContent: status };
    }
  }
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "status.example") return realFetch(input, init);
  if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream
  const message = JSON.parse(String(init.body));
  received.push({ method: message.method, params: message.params });
  if (message.id === undefined) return new Response(null, { status: 202 }); // a notification
  const result = handle(message.method, message.params);
  const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } };
  return Response.json({ jsonrpc: "2.0", id: message.id, ...reply });
};
```

```ts per-entry.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Tool } from "@frontmcp/sdk";
import { received } from "./status.example";

test("only that tool is served, under its own name", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["close_ticket", "get_status"]);
});

test("calls are forwarded to the remote", async ({ mcp }) => {
  expect((await mcp.tools.call("get_status", { service: "api" })).json()).toEqual({ service: "api", up: true });
  expect(received.at(-1)).toEqual({ method: "tools/call", params: { name: "get_status", arguments: { service: "api" } } });
});

test("`metadata` renames it or changes its description", async () => {
  @App({ id: "desk", name: "Desk", tools: [Tool.remote("https://status.example/mcp", "get_status", { metadata: { name: "service_status" } })] })
  class Desk {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] });
  expect((await server.listTools()).tools.map((t) => t.name)).toEqual(["service_status"]);
  await server.dispose();
});

test("a name the remote doesn't have stops the server from starting", async () => {
  @App({ id: "desk", name: "Desk", tools: [Tool.remote("https://status.example/mcp", "get_stauts")] })
  class Desk {}
  await expect(FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] })).rejects.toThrow(
    'Tool "get_stauts" was not found in https://status.example/mcp (tools there: get_status, delete_incident)',
  );
});
```

`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](#authenticating-to-the-remote-server).
- **`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](#talking-to-a-2026-07-28-server).

### `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](#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](#retrying-calls-that-fail-on-the-way) 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:

```ts main.ts active
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 {}
```

```ts status.example.ts
// Stands in for an MCP server at https://status.example/mcp whose flaky_report
// tool answers HTTP 500. It answers FrontMCP's requests in-process, and passes
// everything else to the real fetch. A real server doesn't need this file.

const tools = ["get_status", "flaky_report"].map((name) => ({ name, description: `The ${name} tool`, inputSchema: { type: "object", properties: {} } }));

function handle(method: string, params: any) {
  switch (method) {
    case "initialize":
      return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } };
    case "tools/list":
      return { tools };
    case "tools/call":
      return { content: [{ type: "text", text: "all systems up" }] };
  }
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "status.example") return realFetch(input, init);
  if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream
  const message = JSON.parse(String(init.body));
  if (message.id === undefined) return new Response(null, { status: 202 }); // a notification
  if (message.params?.name === "flaky_report") return new Response("database unavailable", { status: 500 });
  const result = handle(message.method, message.params);
  const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } };
  return Response.json({ jsonrpc: "2.0", id: message.id, ...reply });
};
```

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

test("two failed calls, 3 tries each, open the circuit for the whole remote", async ({ mcp }) => {
  for (let i = 0; i < 2; i++) {
    const result = await mcp.tools.call("status:flaky_report", {});
    expect(result).toBeError("REMOTE_TOOL_EXECUTION_ERROR");
    expect(result.text()).toBe('Remote tool "flaky_report" on "status" failed: Streamable HTTP error: Error POSTing to endpoint: database unavailable');
  }
  const blocked = await mcp.tools.call("status:get_status", {});
  expect(blocked).toBeError("TOOL_EXECUTION_ERROR");
  expect(blocked.text()).toMatch(/Circuit breaker is open for status\. Retry in \d+s/);
});
```

### `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](#picking-up-changes-on-the-remote-server).

### 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](#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](#failed-to-connect-to-remote-mcp-server-status-at--sse-error-non-200-status-code-401). The error classes FrontMCP throws for remote apps are listed in [error classes](https://frontmcp.dev/reference/sdk/error-classes#remote-apps).

### `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`:

```ts main.ts active
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 {}
```

```ts status.example.ts
// Stands in for an MCP server at https://status.example with an endpoint at /mcp
// and one at /v2/mcp. It answers FrontMCP's requests in-process, and passes
// everything else to the real fetch. A real server doesn't need this file.

function handle(method: string, params: any) {
  switch (method) {
    case "initialize":
      return { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "status", version: "3.1.0" } };
    case "tools/list":
      return { tools: [{ name: "get_status", description: "Current status of every service", inputSchema: { type: "object", properties: {} } }] };
    case "tools/call":
      return { content: [{ type: "text", text: "all systems up" }] };
  }
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "status.example") return realFetch(input, init);
  if (init?.method !== "POST") return new Response(null, { status: 405 }); // no event stream
  const message = JSON.parse(String(init.body));
  if (message.id === undefined) return new Response(null, { status: 202 }); // a notification
  const result = handle(message.method, message.params);
  const reply = result ? { result } : { error: { code: -32601, message: `Method not found: ${message.method}` } };
  return Response.json({ jsonrpc: "2.0", id: message.id, ...reply });
};
```

```ts ids.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";

const info = { name: "help-desk", version: "1.0.0" };

test("with a namespace each, both are served", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name).sort()).toEqual(["status:get_status", "status_v2:get_status"]);
});

test("without, both take their id from the host, and the server doesn't start", async () => {
  const apps = [App.remote("https://status.example/mcp"), App.remote("https://status.example/v2/mcp")];
  await expect(FrontMcpInstance.createDirect({ info, apps })).rejects.toThrow(
    'Two apps share the id "status": give each App.esm() / App.remote() its own `name` or `namespace`.',
  );
});

test("a namespace gives the app its id", () => {
  expect(App.remote("https://status.example/v2/mcp", { namespace: "status_v2" })).toMatchObject({ id: "status_v2", name: "status" });
});
```

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