# Environment awareness

> Offer a tool, resource or prompt only on some runtimes, platforms or deployments with availableWhen, and branch on where the server runs with this.runtimeContext.

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

The same server can run on a laptop, in a Docker container, on a serverless platform or inside another program. FrontMCP works out where it's running the first time something asks, and uses the answer in two places: `availableWhen`, which takes an entry out of the server where it can't work, and `this.runtimeContext`, which lets code inside a call branch on it.

```ts
@Tool({ name: "export_tickets", availableWhen: { runtime: ["node", "bun"], env: ["production"] } })
class ExportTickets extends ToolContext {
  async execute() {
    this.runtimeContext; // { os, platform, runtime, deployment, provider, target, env }
    this.isEnv("production");
  }
}
```

---

## Reference

### `availableWhen`

An option of [`@Tool`](https://frontmcp.dev/reference/sdk/tool), [`@Resource`](https://frontmcp.dev/reference/sdk/resource), [`@ResourceTemplate`](https://frontmcp.dev/reference/sdk/resource-template), [`@Prompt`](https://frontmcp.dev/reference/sdk/prompt) and [`@Skill`](https://frontmcp.dev/reference/sdk/skill). It takes an object of arrays; each array lists the values a field may have.

```ts export.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({
  name: "export_tickets",
  description: "Write every open ticket to a CSV file on the server's disk",
  inputSchema: {},
  availableWhen: { runtime: ["node", "bun"], deployment: ["standalone"] },
})
export class ExportTickets extends ToolContext {
  async execute() {
    return { path: "/var/exports/tickets.csv" };
  }
}
```

[See more examples below.](#usage)

#### Fields

| Field | Matches | Values FrontMCP detects |
| --- | --- | --- |
| `os` | The operating system, as Node's `process.platform` names it. | `"darwin"`, `"linux"`, `"win32"`, `"freebsd"`, …, and `"browser"` in FrontMCP's browser build |
| `platform` | The same as `os`. Deprecated: use `os`. | |
| `runtime` | The JavaScript runtime. | `"node"`, `"bun"`, `"deno"`, `"edge"`, and `"browser"` in FrontMCP's browser build |
| `deployment` | How the server is deployed. | `"standalone"`, `"serverless"`, `"distributed"` |
| `provider` | The hosting provider. | `"bare"`, `"docker"`, `"vercel"`, `"lambda"`, `"cloudflare"`, `"netlify"`, `"azure"`, `"gcp"`, `"fly"`, `"render"`, `"railway"` |
| `target` | The build target: what `frontmcp build --target` made, or `FRONTMCP_BUILD_TARGET`. | `"unknown"`, `"node"`, `"distributed"`, `"cli"`, `"vercel"`, `"lambda"`, `"cloudflare"`, `"browser"`, `"sdk"`, `"mcpb"` |
| `env` | `NODE_ENV`. | `"development"` when it isn't set, or whatever it's set to: `"production"`, `"test"`, … |
| `surface` | Where the call comes from, not the machine: `"mcp"`, `"cli"`, `"agent"`, `"job"`, `"http-trigger"` or `"webmcp"`. See [Surfaces](#surfaces). | `"mcp"` for MCP clients and [`createDirect()`](https://frontmcp.dev/reference/sdk/create); `"cli"` for the commands of a server built with `frontmcp build --target cli`; `"agent"` for an [agent](https://frontmcp.dev/reference/sdk/agent)'s calls; `"job"` for a [job](https://frontmcp.dev/reference/sdk/job)'s `this.callTool()`; `"http-trigger"` for a [channel](https://frontmcp.dev/reference/sdk/channel)'s `this.callTool()` while it handles a webhook; `"webmcp"` for an agent in the user's browser, through [`@frontmcp/plugin-webmcp`](https://frontmcp.dev/reference/plugins/webmcp). |

How [each value is detected](#how-frontmcp-detects-the-environment) is below. The types also list `"browser"` for `deployment`, but FrontMCP never reports it: its browser build says `"standalone"`, as [in the Playground](#in-the-playground).

#### Matching

- Every field you set must match. `{ runtime: ["node"], env: ["production"] }` needs both.
- Within a field, any one value matches. `runtime: ["node", "bun"]` matches either.
- A field you leave out matches everything. No `availableWhen` means available everywhere.
- An empty array matches nothing: the entry is never available.
- Values aren't checked. A misspelled one, like `runtime: ["nodejs"]`, never matches, so the entry is silently never available. A misspelled *field* fails at startup with [`Unrecognized key`](#unrecognized-key-in-availablewhen).

#### What happens where it doesn't match

| Entry | Listed? | Usable? |
| --- | --- | --- |
| `@Tool` | No: left out of `tools/list`. | No: a call fails with [`ENTRY_UNAVAILABLE`](#tool--is-not-available-in-the-current-environment), from a client or from [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool). |
| `@Resource`, `@ResourceTemplate` | No: left out of `resources/list` and `resources/templates/list`. | No: `resources/read` fails with [`-32003`](#resource--is-not-available-in-the-current-environment). |
| `@Prompt` | No: left out of `prompts/list`. | No: `prompts/get` fails with `-32003`. |
| `@Skill` | No: left out of `skills/list`, `skills/search` and the `skill://` resources. | No: `skills/load` can't find it. See [`@Skill`](https://frontmcp.dev/reference/sdk/skill). |
| `@Agent` | No: its `invoke_<name>` tool is left out of `tools/list`. | No: a call fails with `ENTRY_UNAVAILABLE`, as for a tool. |
| `@Channel` | No: the channel isn't registered, so a two-way channel's `channel-reply` tool isn't listed either. | No. FrontMCP logs `Channel "…" is not available in this runtime; skipped` at `verbose`. |
| `@Job`, `@Workflow` | Not an option. | |

Before FrontMCP 1.8.3, a resource or prompt that didn't match was only left out of its list and could still be read by its URI or name, and `availableWhen` on an `@Agent` was ignored. Before 1.8.4, a skill that didn't match was still listed in `skills/list` and `skills/search`.

Two tools may share a name when their `availableWhen` never match at the same time. Clients see the one that matches: see [one tool, a version per platform](#one-tool-a-version-per-platform).

#### Surfaces

`surface` is about who is calling, not where the server runs. An entry whose `surface` leaves out the caller's surface is treated as if it didn't exist: it's left out of every list, and a call, read or get answers as for an unknown name: `Tool "…" not found`, `Resource not found: …`, `Prompt not found: …`. `invoke_<agent>` tools work the same way, and so do skills in `skills/list` and `skills/load`. [Keeping a tool from MCP clients](#keeping-a-tool-from-mcp-clients) shows it.

| Caller | Surface |
| --- | --- |
| An MCP client, over HTTP or stdio, and [`createDirect()`](https://frontmcp.dev/reference/sdk/create) | `"mcp"` |
| The commands of a server built with `frontmcp build --target cli` | `"cli"` |
| An [agent](https://frontmcp.dev/reference/sdk/agent): the tools its model is offered and calls, and `this.callTool()` in the agent's own code | `"agent"` |
| A [job](https://frontmcp.dev/reference/sdk/job)'s `this.callTool()` | `"job"` |
| [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool) in a tool, resource or prompt | None: `surface` isn't checked, except `surface: []`, which nothing matches. |
| A [channel](https://frontmcp.dev/reference/sdk/channel)'s `this.callTool()` while it handles a request to its webhook `source` | `"http-trigger"` |
| An agent in the user's browser, calling a tool of a server that runs in the page, through [`@frontmcp/plugin-webmcp`](https://frontmcp.dev/reference/plugins/webmcp) | `"webmcp"`: `["webmcp"]` offers a tool only to those agents, `["mcp"]` keeps it from them. See [`server.registerTool()`](https://frontmcp.dev/reference/sdk/register-tool). |

So `surface: ["agent"]` hides a tool from every MCP client while an agent can still call it, and `surface: ["mcp"]` keeps a tool from agents and jobs: an agent's model isn't offered it, and a job's `this.callTool()` fails with `Tool "…" not found`, as does a webhook channel's. [Keeping a tool from agents and jobs](#keeping-a-tool-from-agents-and-jobs) shows it. Webhook routes are served by the Node server only: through `createFetchHandler()`, and so in the Playground, the webhook's path answers `404`, which is why `"http-trigger"` was checked in Node. `"webmcp"`, new in 1.9.0, is the surface of calls from an agent in the user's browser through [`@frontmcp/plugin-webmcp`](https://frontmcp.dev/reference/plugins/webmcp). Changed in 1.8.5: webhook sources weren't served, so no call carried `"http-trigger"`. Changed in 1.8.4: agents and jobs had no surface, so `surface` didn't keep a tool from them. Before FrontMCP 1.8.3, `surface` wasn't checked at all, and only `surface: []` had an effect.

### How FrontMCP detects the environment

The first time anything asks (usually when the server builds its tool list), FrontMCP detects each field and keeps the result for the life of the process, except `env`, which it reads from `NODE_ENV` every time. Changing any other environment variable afterwards changes nothing; changing `NODE_ENV` changes `env` from then on: see [Changing `NODE_ENV` while the server runs](#changing-node_env-while-the-server-runs).

| Field | How it's detected, first match wins |
| --- | --- |
| `os`, `platform` | `process.platform`. |
| `runtime` | `"bun"` or `"deno"` when their global exists; `"edge"` when the global scope looks like an edge runtime, or when both `EDGE_RUNTIME` and `VERCEL_ENV` are set; otherwise `"node"`. FrontMCP's browser build, which a bundler picks with the `browser` condition, always says `"browser"`. |
| `deployment` | `FRONTMCP_DEPLOYMENT_MODE` when it's `"serverless"` or `"distributed"`; `"serverless"` when any of `VERCEL`, `NETLIFY`, `CF_PAGES`, `AWS_LAMBDA_FUNCTION_NAME`, `AZURE_FUNCTIONS_ENVIRONMENT`, `K_SERVICE`, `RAILWAY_ENVIRONMENT`, `RENDER` or `FLY_APP_NAME` is set; otherwise `"standalone"`. |
| `provider` | `FRONTMCP_PROVIDER`, when it's one of the known names (anything else is ignored); then `VERCEL` → `"vercel"`, `AWS_LAMBDA_FUNCTION_NAME` → `"lambda"`, `CF_PAGES` → `"cloudflare"`, `NETLIFY` → `"netlify"`, `AZURE_FUNCTIONS_ENVIRONMENT` → `"azure"`, `K_SERVICE` → `"gcp"`, `FLY_APP_NAME` → `"fly"`, `RENDER` → `"render"`, `RAILWAY_ENVIRONMENT` → `"railway"`; a `/.dockerenv` file → `"docker"`; otherwise `"bare"`. |
| `target` | The target a bundle from `frontmcp build --target` was built for, which it sets when it starts (since 1.9.0); else `FRONTMCP_BUILD_TARGET`, when it's one of the known names; otherwise `"unknown"`, as for a server started with `frontmcp dev` or `tsx`. |
| `env` | `NODE_ENV`, or `"development"` when it isn't set, each time it's read. (Before 1.8.7 it was read once, like the rest.) |

`frontmcp build` does set `FRONTMCP_DEPLOYMENT_MODE` in the bundles it makes: `"serverless"` for the Vercel, Lambda and Cloudflare targets, `"distributed"` for the distributed target. `FRONTMCP_SERVERLESS=1`, which makes `@FrontMcp` [serve a handler instead of listening](https://frontmcp.dev/reference/sdk/frontmcp-instance), doesn't change `deployment`.

When any entry has `availableWhen`, each registry logs a summary at startup, at `info` level, and each entry it filtered at `verbose`:

```text
[ToolRegistry] availability: 5 total, 4 with availableWhen constraint, 3 available, 1 filtered [ctx: platform=darwin, runtime=node, deployment=standalone, env=development]
```

#### In the Playground

A Playground on this site runs FrontMCP's browser build in your browser (since FrontMCP 1.9, the SDK ships one), and that build doesn't detect anything: it always says `{ os: "browser", platform: "browser", runtime: "browser", deployment: "standalone", provider: "bare", target: "browser" }`. Its `env` follows `NODE_ENV`, as in Node: the page's `process.env.NODE_ENV` when there is one, else the value a bundler built in, else `"development"`. Error details follow the same value. The Playground sets `NODE_ENV` to `"development"`. (Before 1.9.2 the browser build's `env` was always `"production"`, while its errors were formatted for development.) When the same examples run in Node, as they do in this site's tests and under `frontmcp test`, `os` is your operating system, `runtime` is `"node"` and `target` is `"unknown"`. So the examples on this page restrict by fields that are the same in both, like `deployment` or `env`, or show both answers. Before 1.9, the Playground's build said `runtime: "node"` and `target: "unknown"`.

### `this.runtimeContext` and the checks

Every [context class](https://frontmcp.dev/reference/sdk/contexts) has them: tools, resources, prompts, agents, jobs and channels.

| Member | Returns | Description |
| --- | --- | --- |
| `this.runtimeContext` | `RuntimeContext` | The detected environment: `{ os, platform, runtime, deployment, provider, target, env }`. The same object for every call. |
| `this.isEnv(env)` | `boolean` | `runtimeContext.env === env`. |
| `this.isRuntime(runtime)` | `boolean` | `runtimeContext.runtime === runtime`. |
| `this.isDeployment(deployment)` | `boolean` | `runtimeContext.deployment === deployment`. |
| `this.isPlatform(os)` | `boolean` | `runtimeContext.platform === os`. |

There's no `isProvider()` or `isTarget()`: compare `this.runtimeContext.provider` and `.target`.

### Runtime modes

How a server is started is a separate thing from where it runs, and `runtimeContext` doesn't tell the ways apart:

| Mode | Started by | What `runtimeContext` says |
| --- | --- | --- |
| HTTP server | `@FrontMcp` (the default `serve: true`) or [`FrontMcpInstance.bootstrap()`](https://frontmcp.dev/reference/sdk/frontmcp-instance) | `deployment: "standalone"`, or `"distributed"` with `FRONTMCP_DEPLOYMENT_MODE=distributed`. |
| stdio | `FRONTMCP_STDIO=1`, or `FrontMcpInstance.runStdio()` | The same as for HTTP. |
| Request handler | `FRONTMCP_SERVERLESS=1`, `FrontMcpInstance.createHandler()` or [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) | `"serverless"` only when a platform variable or `FRONTMCP_DEPLOYMENT_MODE` says so. |
| In your process | [`create()`, `createDirect()`](https://frontmcp.dev/reference/sdk/create) or [`connect()`](https://frontmcp.dev/reference/sdk/connect) | The process's environment, like any other mode. |

If a tool needs to know the mode, set a variable of your own where you start the server and read it with [`ConfigPlugin`](https://frontmcp.dev/reference/server/config-files#configplugin).

#### Caveats

- Everything but `env` is detected once per process, so in one process every server sees the same `os`, `runtime`, `deployment`, `provider` and `target`. `env` follows `NODE_ENV`: change it while a server runs, and `this.runtimeContext.env` and the entries `availableWhen: { env }` allows change with it.
- `availableWhen` is about the machine and, with `surface`, the kind of caller, never about who the caller is. To decide which users may call something, use [`authorities`](https://frontmcp.dev/learn/authorizing-calls).
- The `ENTRY_UNAVAILABLE` message, and the `-32003` error of a resource or prompt, tell any client the server's OS, runtime, deployment, provider, target and `NODE_ENV`, in production too. If that's more than callers should know, leave the entry out of the server where it can't run.

---

## Usage

### Offering a tool only where it works

A tool that needs something only some environments have, like a disk that keeps its files or a Node API, says so with `availableWhen`. Where it doesn't match, the model never sees it, so it never tries it. Here `export_tickets` writes to the disk of a long-running server; `upload_tickets` is for serverless deployments, whose functions don't keep files from one call to the next:

```ts tickets.tools.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({
  name: "export_tickets",
  description: "Write every open ticket to a CSV file on the server's disk",
  inputSchema: {},
  availableWhen: { deployment: ["standalone"] },
})
export class ExportTickets extends ToolContext {
  async execute() {
    return { path: "/var/exports/tickets.csv", rows: 42 };
  }
}

@Tool({
  name: "upload_tickets",
  description: "Upload every open ticket as a CSV file to object storage",
  inputSchema: {},
  availableWhen: { deployment: ["serverless"] },
})
export class UploadTickets extends ToolContext {
  async execute() {
    return { url: "https://files.desk.example/tickets.csv", rows: 42 };
  }
}
```

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

test("only the tool for this deployment is listed", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools).toContainTool("export_tickets");
  expect(tools).not.toContainTool("upload_tickets");
});

test("calling the other one fails", async ({ mcp }) => {
  const result = await mcp.tools.call("upload_tickets", {});
  expect(result).toBeError("ENTRY_UNAVAILABLE");
});
```

The same works for [resources](https://frontmcp.dev/reference/sdk/resource) and [prompts](https://frontmcp.dev/reference/sdk/prompt), which then don't appear in their lists, and [can't be read](#resource--is-not-available-in-the-current-environment) by a client that knows their URI or name.

### One tool, a version per platform

When the same capability needs different code on different platforms, write one class per platform with the same `name` and a different `availableWhen`. The model sees a single `export_tickets` tool, and calls reach whichever version matches. Here the server runtimes write to disk, and the runtimes without a file system, the browser and edge runtimes, upload instead:

```ts export.disk.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({
  name: "export_tickets",
  description: "Export every open ticket as a CSV file and return where it is",
  inputSchema: {},
  availableWhen: { runtime: ["node", "bun", "deno"] },
})
export class ExportTicketsToDisk extends ToolContext {
  async execute() {
    return { location: "/var/exports/tickets.csv" };
  }
}
```

```ts export.storage.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({
  name: "export_tickets",
  description: "Export every open ticket as a CSV file and return where it is",
  inputSchema: {},
  availableWhen: { runtime: ["browser", "edge"] },
})
export class ExportTicketsToStorage extends ToolContext {
  async execute() {
    return { location: "https://files.desk.example/tickets.csv" };
  }
}
```

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

// The Playground in your browser runs FrontMCP's browser build, whose runtime is "browser". In Node it's "node".
const inBrowser = typeof WorkerGlobalScope !== "undefined";

test("clients see one export_tickets", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools.filter((t) => t.name === "export_tickets")).toHaveLength(1);
});

test("the version for this runtime runs", async ({ mcp }) => {
  const result = await mcp.tools.call("export_tickets", {});
  const location = inBrowser ? "https://files.desk.example/tickets.csv" : "/var/exports/tickets.csv";
  expect(result.json()).toEqual({ location });
});
```

In your browser the Call tab shows the upload's location; run the same server in Node and the disk version answers.

Keep the `name`, `description` and `inputSchema` the same in every version, so the model's view of the tool doesn't depend on where the server runs. Make sure the `availableWhen` of the versions don't overlap: when two match, clients see and call only the first one registered, and FrontMCP reports no conflict.

### Tools for development only

`env` restricts a tool to some values of `NODE_ENV`. A tool that resets demo data should never reach production:

```ts dev-tools.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({
  name: "reset_demo_data",
  description: "Delete every ticket and load the demo tickets again",
  inputSchema: {},
  availableWhen: { env: ["development", "test"] },
})
export class ResetDemoData extends ToolContext {
  async execute() {
    return { tickets: 3 };
  }
}

@Tool({ name: "where", description: "Where the server is running", inputSchema: {} })
export class Where extends ToolContext {
  async execute() {
    return { env: this.runtimeContext.env };
  }
}
```

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

test("reset_demo_data is listed outside production", async ({ mcp }) => {
  expect((await mcp.tools.call("where", {})).json().env).not.toBe("production");
  expect(await mcp.tools.list()).toContainTool("reset_demo_data");
});
```

The Playground runs as `development`, so the Call tab shows `"env": "development"` and the tools list has `reset_demo_data`. Start the server with `NODE_ENV=production` and it's gone; [Changing `NODE_ENV` while the server runs](#changing-node_env-while-the-server-runs) tests both.

### Keeping a tool from MCP clients

`surface` restricts who an entry is offered to. A tool for operators, meant for the commands of the server's CLI build, shouldn't reach the model: with `surface: ["cli"]`, MCP clients don't see it, and a call by its name fails as if it didn't exist. A tool's own code isn't a surface, so a tool that calls it with `this.callTool()` still runs it:

```ts tickets.tools.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "purge_closed_tickets",
  description: "Delete every closed ticket for good",
  inputSchema: {},
  availableWhen: { surface: ["cli"] },
})
export class PurgeClosedTickets extends ToolContext {
  async execute() {
    return { purged: 12 };
  }
}

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

```ts cleanup.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "clean_up", description: "Tidy the help desk", inputSchema: {} })
export class CleanUp extends ToolContext {
  async execute() {
    const purge = await this.callTool("purge_closed_tickets", {}); // 🚩 not checked against surface
    return { purge: purge.structuredContent };
  }
}
```

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

test("MCP clients don't see the tool", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools).toContainTool("close_ticket");
  expect(tools).not.toContainTool("purge_closed_tickets");
});

test("calling it by name fails as for an unknown tool", async ({ mcp }) => {
  const result = await mcp.tools.call("purge_closed_tickets", {});
  expect(result).toBeError("TOOL_NOT_FOUND");
  expect(result.text()).toBe('Tool "purge_closed_tickets" not found');
});

test("this.callTool() isn't checked, so a tool that calls it lets clients run it anyway", async ({ mcp }) => {
  expect((await mcp.tools.call("clean_up", {})).json()).toEqual({ purge: { purged: 12 } });
});
```

`surface` decides what clients are offered, not what your code may do. Keep tools that call a restricted one restricted too, and to decide which users may call something, use [`authorities`](https://frontmcp.dev/learn/authorizing-calls).

### Keeping a tool from agents and jobs

`surface` works the other way too. A tool that only a person should run, through their MCP client, gets `surface: ["mcp"]`: an [agent](https://frontmcp.dev/reference/sdk/agent)'s model isn't offered it even when it's in the agent's `tools`, and a [job](https://frontmcp.dev/reference/sdk/job) can't call it. Here the triage agent may read tickets but not delete them, and neither may the spam job. `get_ticket` reports the surface it was called on, with `getCallSurface()`:

```ts tickets.tools.ts active
import { Tool, ToolContext, getCallSurface, z } from "@frontmcp/sdk";

@Tool({ name: "get_ticket", description: "Get a support ticket by its id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Charged twice", calledFrom: getCallSurface() };
  }
}

@Tool({
  name: "delete_ticket",
  description: "Delete a support ticket for good",
  inputSchema: { id: z.string() },
  availableWhen: { surface: ["mcp"] }, // people, through their MCP client
})
export class DeleteTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, deleted: true };
  }
}
```

```ts help-desk.app.ts
import { Agent, AgentContext, App, Job, JobContext, z } from "@frontmcp/sdk";
import { model } from "./model.example";
import { DeleteTicket, GetTicket } from "./tickets.tools";

@Agent({
  name: "triage",
  description: "Triage a support ticket. Pass the ticket id.",
  inputSchema: { ticketId: z.string() },
  tools: [GetTicket, DeleteTicket],
  llm: { adapter: model },
})
class Triage extends AgentContext {}

@Job({
  name: "purge-spam",
  description: "Delete a ticket reported as spam",
  inputSchema: { id: z.string() },
  outputSchema: { calledFrom: z.string(), deleted: z.boolean(), error: z.string().optional() },
})
class PurgeSpam extends JobContext {
  async execute({ id }: { id: string }) {
    const ticket = await this.callTool("get_ticket", { id });
    const { calledFrom } = ticket.structuredContent as { calledFrom: string };
    try {
      await this.callTool("delete_ticket", { id });
      return { calledFrom, deleted: true };
    } catch (error) {
      return { calledFrom, deleted: false, error: (error as Error).message };
    }
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, DeleteTicket], agents: [Triage], jobs: [PurgeSpam] })
export class HelpDeskApp {}
```

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach. It reads the
// ticket, then tries to delete it, and answers with what the second call
// returned. A real server doesn't need this file.
import type { AgentCompletion, AgentLlmAdapter } from "@frontmcp/sdk";

/** The names of the tools FrontMCP offered the model, one list per request. */
export const offered: string[][] = [];

export const model: AgentLlmAdapter = {
  async completion(prompt, tools) {
    offered.push((tools ?? []).map((tool) => tool.name));
    const results = prompt.messages.filter((m) => m.role === "tool").map((m) => m.content ?? "");
    if (results.length === 0) return callTool("get_ticket");
    if (results.length === 1) return callTool("delete_ticket");
    return { content: results.join("\n"), finishReason: "stop" };
  },
};

function callTool(name: string): AgentCompletion {
  return { content: null, finishReason: "tool_calls", toolCalls: [{ id: `call_${name}`, name, arguments: { id: "T-1" } }] };
}
```

```ts surfaces.test.ts
import { test, expect } from "@frontmcp/testing";
import { offered } from "./model.example";

test("the agent's model isn't offered delete_ticket", async ({ mcp }) => {
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(offered.at(-1)).toEqual(["get_ticket"]);
});

test("the agent calls on the agent surface, and a call to delete_ticket isn't found", async ({ mcp }) => {
  const { response } = (await mcp.tools.call("invoke_triage", { ticketId: "T-1" })).json();
  const [read, deleted] = response.split("\n");
  expect(JSON.parse(read)).toEqual({ id: "T-1", title: "Charged twice", calledFrom: "agent" });
  expect(JSON.parse(deleted)).toEqual({ error: 'Tool "delete_ticket" not found in agent "triage". Available tools: [get_ticket]' });
});

test("a job calls on the job surface, and can't call delete_ticket", async ({ mcp }) => {
  const run = await mcp.tools.call("execute_job", { name: "purge-spam", input: { id: "T-1" } });
  expect(run.json()).toMatchObject({
    state: "completed",
    result: { calledFrom: "job", deleted: false, error: 'Tool "delete_ticket" not found' },
  });
});

test("an MCP client can still delete", async ({ mcp }) => {
  expect((await mcp.tools.call("get_ticket", { id: "T-1" })).json().calledFrom).toBe("mcp");
  expect((await mcp.tools.call("delete_ticket", { id: "T-1" })).json()).toEqual({ id: "T-1", deleted: true });
});
```

An agent's model only learns about the tools it's offered, so it never sees `delete_ticket`; the Call tab shows what it got when it asked for it anyway. `this.callTool()` in the agent's own code is on `"agent"` too, so it can't reach `delete_ticket` either.

### Offering an agent or a channel only where it works

`availableWhen` works on an [`@Agent`](https://frontmcp.dev/reference/sdk/agent) as on a tool: its `invoke_<name>` tool is left out of `tools/list` where it doesn't match, and a call fails with `ENTRY_UNAVAILABLE`. On a [`@Channel`](https://frontmcp.dev/reference/sdk/channel), the channel isn't registered at all, so a two-way channel's `channel-reply` tool is gone too:

```ts main.ts active
import { Agent, AgentContext, App, Channel, ChannelContext, FrontMcp, z, type ChannelNotification } from "@frontmcp/sdk";

@Agent({
  name: "triage",
  description: "Sort a support ticket into a queue",
  inputSchema: { ticket: z.string() },
  availableWhen: { runtime: ["deno"] },
  llm: { adapter: { async completion() { return { content: "billing", finishReason: "stop" }; } } },
})
class Triage extends AgentContext {}

@Channel({
  name: "deploys",
  description: "Deploys of the help desk, as they happen",
  source: { type: "app-event", event: "deploy" },
  twoWay: true,
  availableWhen: { runtime: ["deno"] },
})
class DeploysChannel extends ChannelContext {
  async onEvent(payload: unknown): Promise<ChannelNotification> {
    return { content: `deployed ${(payload as { version: string }).version}` };
  }
  async onReply() {}
}

@App({ id: "help-desk", name: "Help Desk", agents: [Triage], channels: [DeploysChannel] })
class HelpDeskApp {}

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

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

test("the agent's tool isn't listed where the agent doesn't match", async ({ mcp }) => {
  expect(await mcp.tools.list()).not.toContainTool("invoke_triage");
});

test("and calling it fails", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticket: "I was charged twice." });
  expect(result).toBeError("ENTRY_UNAVAILABLE");
  expect(result.text()).toMatch(/^Tool "invoke_triage" is not available in the current environment/);
});

test("the channel isn't registered, so there's no channel-reply tool", async ({ mcp }) => {
  expect(await mcp.tools.list()).not.toContainTool("channel-reply");
});
```

Remove `availableWhen` from the channel, and `channel-reply` is listed. Before 1.9.2, `availableWhen` on a channel was ignored; before 1.8.3, on an agent too.

### Branching inside a call

When most of a tool works everywhere and only one step differs, check `this.runtimeContext` inside `execute()` instead of writing two tools. Here the tool keeps its cache in memory on a single long-running server, and in a shared store when the server runs as short-lived functions that don't share memory:

```ts cache-status.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "cache_status", description: "Where search results are cached", inputSchema: {} })
export class CacheStatus extends ToolContext {
  async execute() {
    const { runtime, deployment, provider } = this.runtimeContext;
    const cache = this.isDeployment("serverless") ? "shared store" : "memory";
    return { cache, runtime, deployment, provider };
  }
}
```

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

test("a standalone server caches in memory", async ({ mcp }) => {
  const result = await mcp.tools.call("cache_status", {});
  expect(result.json()).toMatchObject({ cache: "memory", deployment: "standalone" });
});
```

Resources and prompts have the same members. [Context classes](https://frontmcp.dev/reference/sdk/contexts#checking-where-the-server-runs) has an example that adds debugging details outside production.

### Setting the environment for a deployment

Detection covers the common platforms. Where it can't tell, set the variables yourself, before the server starts, for example in a Dockerfile:

```bash title="Dockerfile"
ENV NODE_ENV=production
ENV FRONTMCP_PROVIDER=fly
ENV FRONTMCP_DEPLOYMENT_MODE=distributed
ENV FRONTMCP_BUILD_TARGET=node
```

These can't be shown in a Playground: it runs in one process, whose environment is fixed. [Configuration files](https://frontmcp.dev/reference/server/config-files#environment-variables) lists every environment variable FrontMCP reads.

---

## Troubleshooting

### `Tool "…" is not available in the current environment`

A client called a tool whose `availableWhen` doesn't match where the server runs. The result is a tool error with the code `ENTRY_UNAVAILABLE`, and the message says what the tool requires, what the server has, and which fields didn't match:

```ts run-on-deno.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "run_on_deno", description: "Only works on Deno", inputSchema: {}, availableWhen: { runtime: ["deno"] } })
export class RunOnDeno extends ToolContext {
  async execute() {
    return { ok: true };
  }
}
```

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

test("the error names the fields that didn't match", async ({ mcp }) => {
  const result = await mcp.tools.call("run_on_deno", {});
  expect(result).toBeError("ENTRY_UNAVAILABLE");
  expect(result.text()).toMatch(/^Tool "run_on_deno" is not available in the current environment \(requires: \{"runtime":\["deno"\]\}\) \(current: \{"os":/);
  // "browser" in the Playground in your browser, "node" in Node
  expect(result.text()).toMatch(/"runtime":"(browser|node)".*"surface":"mcp"\}\) \(missing axes: runtime\)$/);
});
```

The client had the tool's name from an older list, or guessed it; the model won't see it in `tools/list`. If the tool should run here, fix its `availableWhen`. A tool called with [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool) fails the same way, by throwing an `EntryUnavailableError`.

### A tool is missing, and nothing says why

Its `availableWhen` doesn't match, most often because of a misspelled value (`"nodejs"`, `"prod"`), an empty array, or a `surface` without `"mcp"`; none is an error. Look for the startup line `[ToolRegistry] availability: … filtered`, and set the log level to `Verbose` to see `filtered: "<name>"` with the constraint and the detected value. Compare with what `this.runtimeContext` returns in a tool that has no `availableWhen`.

### `Tool "…" not found` for a tool that has `surface`

Since FrontMCP 1.8.3, `surface` is checked. A tool whose `surface` leaves out `"mcp"` isn't offered to MCP clients or to `createDirect()`, and a call to it fails with `TOOL_NOT_FOUND`, as for a tool that doesn't exist; a resource or prompt answers `Resource not found: …` or `Prompt not found: …`. Before 1.8.3 such a tool was listed and callable. Add `"mcp"` to its `surface` if clients should reach it, or remove `surface`.

Since 1.8.4, agents and jobs are surfaces too. An agent whose tool's `surface` leaves out `"agent"` isn't offered that tool, and when its model calls it anyway, the model gets `Tool "…" not found in agent "…"`; a job's `this.callTool()` of a tool without `"job"` throws `Tool "…" not found`. Add the caller's surface, or remove `surface`. See [Surfaces](#surfaces), [Keeping a tool from MCP clients](#keeping-a-tool-from-mcp-clients) and [Keeping a tool from agents and jobs](#keeping-a-tool-from-agents-and-jobs).

### `Resource "…" is not available in the current environment`

A client read a resource, or got a prompt, whose `availableWhen` doesn't match where the server runs. Both are left out of their lists, and since FrontMCP 1.8.3 reading or getting them fails too, with JSON-RPC error `-32003` and the same kind of message as [for a tool](#tool--is-not-available-in-the-current-environment). Before 1.8.3 they could still be read by a client that knew the URI or the name:

```ts deno-only.ts active
import { Prompt, PromptContext, Resource, ResourceContext } from "@frontmcp/sdk";

@Resource({ name: "deno-config", uri: "desk://deno-config", mimeType: "application/json", availableWhen: { runtime: ["deno"] } })
export class DenoConfig extends ResourceContext {
  async execute(uri: string) {
    return { contents: [{ uri, text: JSON.stringify({ permissions: ["--allow-net"] }) }] };
  }
}

@Prompt({ name: "deno-review", description: "Review a Deno module", arguments: [], availableWhen: { runtime: ["deno"] } })
export class DenoReview extends PromptContext {
  async execute() {
    return { messages: [{ role: "user" as const, content: { type: "text" as const, text: "Review this Deno module." } }] };
  }
}
```

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

test("the resource isn't listed, and reading it fails", async ({ mcp }) => {
  expect(await mcp.resources.list()).not.toContainResource("desk://deno-config");
  const content = await mcp.resources.read("desk://deno-config");
  expect(content).toBeError(-32003);
  expect(content.error?.message).toMatch(/^Resource "desk:\/\/deno-config" is not available in the current environment \(requires: \{"runtime":\["deno"\]\}\)/);
});

test("the prompt isn't listed, and getting it fails", async ({ mcp }) => {
  const prompts = await mcp.prompts.list();
  expect(prompts.map((p) => p.name)).not.toContain("deno-review");
  const prompt = await mcp.prompts.get("deno-review");
  expect(prompt).toBeError(-32003);
  expect(prompt.error?.message).toMatch(/^Prompt "deno-review" is not available in the current environment \(requires: \{"runtime":\["deno"\]\}\)/);
});
```

The client had the URI or the name from elsewhere; the model won't see either in a list. If the resource or prompt should work here, fix its `availableWhen`.

### `Unrecognized key` in `availableWhen`

The server fails to start with a Zod error: `Unrecognized key: "environment"`, path `availableWhen`. The fields are `os`, `platform`, `runtime`, `deployment`, `provider`, `target`, `env` and `surface`:

```ts
// 🚩 Not a field
availableWhen: { environment: ["production"] }

// ✅
availableWhen: { env: ["production"] }
```

### `target` is `"unknown"`

The server didn't start from a bundle that `frontmcp build --target …` made: `frontmcp dev` and `tsx` start your source. (The [Playground](#in-the-playground) says `"browser"`.) A bundle sets its target when it starts, since FrontMCP 1.9.0; `node dist/node/<name>.bundle.js` reports `target: "node"`. Elsewhere, set `FRONTMCP_BUILD_TARGET` in the environment, or restrict by `deployment` or `provider`, which are detected. Before 1.9.0, `frontmcp build` didn't pass the target to the bundle, so it was always `"unknown"`.

### Changing `NODE_ENV` while the server runs

`env` is read from `NODE_ENV` each time, so a tool that's `availableWhen: { env: ["production"] }` appears, and one that's `["development"]` disappears, as soon as `NODE_ENV` becomes `production`, and `this.runtimeContext.env` says so too. That is useful in a test and surprising in a server: set `NODE_ENV` before the server starts, in the shell, the Dockerfile or the platform's settings, and leave it. A `.env` file only works if something loads it before FrontMCP starts, such as `frontmcp dev`: see [Configuration files](https://frontmcp.dev/reference/server/config-files#env-files). Every other field is detected once, and changing its variable afterwards does nothing. Before 1.8.7 `env` was fixed too, so `NODE_ENV` set after the first read changed nothing. The Playground reads its own `NODE_ENV` the same way, so the tests pass in your browser and in Node.

```ts tickets.tools.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "reset_demo_data", description: "Delete every ticket and load the demo tickets again", inputSchema: {}, availableWhen: { env: ["development"] } })
export class ResetDemoData extends ToolContext {
  async execute() {
    return { tickets: 3 };
  }
}

@Tool({ name: "export_audit_log", description: "Write the audit log for the auditors", inputSchema: {}, availableWhen: { env: ["production"] } })
export class ExportAuditLog extends ToolContext {
  async execute() {
    return { rows: 120 };
  }
}

@Tool({ name: "where", description: "Where the server is running", inputSchema: {} })
export class Where extends ToolContext {
  async execute() {
    return { env: this.runtimeContext.env };
  }
}
```

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

async function withNodeEnv<T>(value: string, run: () => Promise<T>) {
  const before = process.env.NODE_ENV;
  process.env.NODE_ENV = value;
  try {
    return await run();
  } finally {
    if (before === undefined) delete process.env.NODE_ENV;
    else process.env.NODE_ENV = before;
  }
}

test("the tools on offer follow NODE_ENV", async ({ mcp }) => {
  const names = async () => (await mcp.tools.list()).map((tool) => tool.name);
  expect(await withNodeEnv("development", names)).toEqual(["reset_demo_data", "where"]);
  expect(await withNodeEnv("production", names)).toEqual(["export_audit_log", "where"]);
});

test("this.runtimeContext.env follows it too", async ({ mcp }) => {
  const env = async () => (await mcp.tools.call("where", {})).json().env;
  expect(await withNodeEnv("test", env)).toBe("test");
  expect(await withNodeEnv("production", env)).toBe("production");
});

test("a tool that the current NODE_ENV doesn't allow can't be called", async ({ mcp }) => {
  const result = await withNodeEnv("production", () => mcp.tools.call("reset_demo_data", {}));
  expect(result).toBeError("ENTRY_UNAVAILABLE");
});
```
