# Loading apps from npm (ESM)

> App.esm() loads a package's tools, resources and prompts from npm when the server starts, and runs them in your server's process.

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

`App.esm()` adds an app whose tools, resources and prompts come from an npm package. When the server starts, FrontMCP asks the npm registry which version matches the range you gave, downloads that version as a JavaScript bundle from esm.sh, and runs it in your server's process. The package's tools then sit next to your own, under a namespace. A team can publish tools once and add them to any server without rebuilding it. The flip side is that the package's code runs with your server's permissions, so load only packages you trust.

```ts
App.esm(specifier, options?)
```

---

## Reference

### `App.esm(specifier, 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.esm("@acme/status-tools@^1.0.0", { namespace: "status" }),
  ],
})
export default class Server {}
```

[See more examples below.](#usage)

#### Parameters

| Parameter | Type | Description |
| --- | --- | --- |
| `specifier` | `string` | A package name, optionally followed by `@` and a version: an exact version (`1.2.0`), a semver range (`^1.0.0`, `~1.2.0`, `>=1.0.0 <2.0.0`) or a dist-tag (`next`). Without a version, `latest`. Names follow npm's rules, lowercase and optionally scoped; anything else, a URL included, makes `App.esm()` throw `Invalid package specifier: "…"`. See [choosing a version](#choosing-a-version). |
| `options` | `EsmAppOptions` | Optional. See below. |

#### Options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `namespace` | `string` | `name` | Prefix for every tool, resource and prompt name: `status` turns `get_status` into `status:get_status`. Set it. The default is the package name, which gives names like `@acme/status-tools:get_status`. |
| `name` | `string` | The package name | The app's name in logs, and the default namespace. The app's id is the `name` you set, else the `namespace`, else the package name. Two apps with one id stop the server from starting, with `DuplicateAppIdError`, so give every `App.esm()` of one package its own `namespace`: see [choosing a version](#choosing-a-version). |
| `description` | `string` | | What the app is for, for your own tooling. |
| `loader` | `PackageLoader` | The server's `loader` | Where to get this package from, and with what token: the same fields as [the server's `loader`](#the-loader). It **replaces** the server's loader for this package; the two aren't merged, so a `token` set on the server isn't sent. |
| `cacheTTL` | `number` | `86400000` (24 hours) | Milliseconds a downloaded bundle is reused before it's downloaded again. Must be more than `0`. See [caching](#caching). |
| `autoUpdate` | `{ enabled: boolean; intervalMs?: number }` | Off | Check the registry every `intervalMs` (default `300000`, 5 minutes) for a newer version in the range, and load it without a restart. See [updating without a restart](#updating-without-a-restart). |
| `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 `/` and the `namespace`, like `/status`, unless you set a `name`, which takes its place. With neither, it's the package name: `/@acme/status-tools`. |
| `filter` | `AppFilterConfig` | Load everything | Which of the package's tools, resources and prompts to load, by their names without the namespace. `*` matches any run of characters. `{ exclude: { tools: ["delete_*"] } }` leaves those out; `{ default: "exclude", include: { tools: ["get_*"] } }` loads only those. An entry left out isn't listed, and calling it gives `Tool "…" not found`. See [loading part of a package](#loading-part-of-a-package). |
| `importMap` | `Record<string, string>` | | Imports in the bundle to point elsewhere: `{ "<specifier>": "<target>" }`. A key ending in `/` covers every specifier that starts with it. FrontMCP asks the bundle server to leave those packages out of the bundle, rewrites the bundle's imports, and caches the result apart from a bundle loaded with another map. See [rewriting a package's imports](#rewriting-a-packages-imports). |

#### Returns

A plain object that describes the app, not a class: `{ name, urlType: "esm", url: specifier, namespace, description, standalone, filter }`, with an `id` taken from `namespace` when you set one and no `name`, plus `packageConfig: { loader, autoUpdate, cacheTTL, importMap }` with the ones you set. Nothing is fetched until the server starts.

#### The loader

`@FrontMcp({ loader })` says where every `App.esm()` package comes from, unless the app sets its own `loader`:

```ts main.ts
@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  loader: {
    registryUrl: "https://npm.acme.example",
    url: "https://esm.acme.example",
    tokenEnvVar: "ACME_NPM_TOKEN",
  },
  apps: [App.esm("@acme/internal-tools@^1.0.0", { namespace: "internal" })],
})
export default class Server {}
```

| Field | Default | What it does |
| --- | --- | --- |
| `url` | | One server for both jobs: versions are resolved at `<url>/<name>` and bundles downloaded from `<url>/<name>@<version>?bundle`, so it must answer both, as a self-hosted esm.sh in front of a registry would. |
| `registryUrl` | `url`, else `https://registry.npmjs.org` | Where versions are resolved. Bundles still come from `url`, or `https://esm.sh` without one. |
| `token` | | Sent as `Authorization: Bearer <token>` to the registry. It isn't sent when the bundle is downloaded. |
| `tokenEnvVar` | | The name of an environment variable that holds the token, read when the package loads. If the variable isn't set, the server doesn't start. When both are set, `token` wins. |

`url` and `registryUrl` must be full URLs, like `https://npm.acme.example`; the server doesn't start otherwise (`Invalid URL`). See [using a private registry](#using-a-private-registry).

#### How a package is loaded

When the server starts, for each `App.esm()` app, FrontMCP:

1. **Resolves the version.** It sends `GET <registry>/<name>` (the `/` of a scoped name as `%2f`) and picks one version from the answer: for `latest` or another dist-tag, the version the tag points to; for a range, the highest published version that satisfies it. A word that isn't one of the package's dist-tags, like a misspelled `stabel`, isn't an error: FrontMCP loads the highest published version, prereleases included. It gives up after 15 seconds.
2. **Checks its cache** for that exact `name@version`. See [caching](#caching).
3. **Downloads the bundle** on a miss: `GET <bundles>/<name>@<version>?bundle`, from esm.sh unless the loader says otherwise, with `&external=<packages>` for the packages in `importMap`. It gives up after 30 seconds. Neither time limit can be changed.
4. **Runs the bundle** with a dynamic `import()`. A CommonJS bundle (`module.exports = …`) works too.
5. **Reads the package's exports** (see [what a package exports](#what-a-package-exports)) and registers its tools, resources and prompts in the app, under the namespace: all of them, or the ones `filter` lets through.

If any step fails, the server doesn't start, with one of the [errors below](#errors). The registry is asked on every start, even when the bundle is cached, so a server that loads packages can't start while the registry is unreachable.

#### What a package exports

FrontMCP looks for, in this order:

1. A default export with string `name` and `version` fields: a **manifest**, `{ name, version, description?, tools?, resources?, prompts? }`. It's the clearest form.
2. A default export that's an object of arrays (`{ tools, resources, prompts }`), or of `@Tool`, `@Resource` and `@Prompt` classes, or is one such class.
3. Named exports, the same three ways: `export const name`, `version` and `tools`; `export const tools = […]` alone; or exported decorated classes.

Anything else, a default-exported `@App` or `@FrontMcp` class included, stops the server with `Invalid manifest in ESM package "…": ESM module does not export a valid FrontMcpPackageManifest.`

A tool, resource or prompt can be a decorated class, which works like one in your own code, or a plain object:

| Entry | Fields | Notes |
| --- | --- | --- |
| Tool | `name`, `execute(input)`, and optionally `description`, `inputSchema` (a JSON Schema object) | `execute()` should return a `CallToolResult`, `{ content: [...] }`, which is sent as it is. A string comes back as its text. Any other object comes back as the text `[object Object]`. The arguments aren't checked against `inputSchema`: `execute()` gets what the client sent. Without a description, the model sees `ESM tool: <name>`. |
| Resource | `name`, `uri`, `read()`, and optionally `description`, `mimeType` | `read()` returns a `ReadResourceResult`, `{ contents: [...] }`. The URI isn't namespaced. Without a description: `ESM resource: <name>`. |
| Prompt | `name`, `execute(args)`, and optionally `description`, `arguments` | `execute()` returns a `GetPromptResult`, `{ messages: [...] }`. Required `arguments` are checked before it runs. Without a description: `ESM prompt: <name>`. |

An entry without a `name` or without its function, or a resource whose `uri` has no scheme, is skipped without a word. A manifest can also list `skills`, `agents`, `jobs`, `workflows` and `providers`; FrontMCP 1.9.3 accepts them and loads none of them, and the server logs a warning that names the kinds it skipped: `ESM app status: App.esm() loads only tools, resources and prompts; the package's skills, agents are not loaded`. (Changed in 1.9.3: before, it skipped them without a word.)

#### Caching

In Node, FrontMCP keeps each downloaded bundle in memory and on disk, one folder per `name@version` holding the bundle and a `meta.json`. The folder is `node_modules/.cache/frontmcp-esm/` in the working directory when it has a `node_modules` folder, and `~/.frontmcp/esm-cache/` otherwise. A later start with the same version reads the bundle from disk instead of downloading it, until it's `cacheTTL` old. In a browser, the cache is memory only, so each start downloads again.

The cache key is only `name@version`: a bundle cached from one loader is reused when another loader resolves the same version. The version itself is always resolved with the registry, as above.

#### Updating without a restart

With `autoUpdate: { enabled: true }`, FrontMCP asks the registry every `intervalMs` which version the range resolves to now. When it's newer than the loaded one, FrontMCP downloads it and swaps the app's tools, resources and prompts for the new version's: tools that the new version dropped disappear, new ones appear, and calls reach the new code. A version outside the range, like `2.0.0` for `^1.0.0`, is never loaded. Clients connected with a session, over the protocol versions before 2026-07-28, get `notifications/tools/list_changed`; 2026-07-28 clients see the change the next time they list.

A failed check is logged and tried again at the next interval. A failed download is logged too, and the old version keeps serving, but FrontMCP doesn't try that version again: it's loaded when a newer one is published, or at the next restart.

```ts main.ts
App.esm("@acme/status-tools@^1.0.0", {
  namespace: "status",
  autoUpdate: { enabled: true, intervalMs: 10 * 60_000 },
})
```

This was checked in Node against a stand-in registry: publishing `1.1.0` replaced `1.0.0`'s tools within one interval, and publishing `2.0.0` changed nothing. The examples on this page don't use it, because the check keeps running for as long as the server does.

#### Running someone else's code

A package loaded with `App.esm()` is code running inside your server. In Node it can read your environment variables, including secrets, open files and make network requests, with your server's permissions. So:

- **Load only packages you trust**, from publishers you trust. The code FrontMCP runs is esm.sh's build of the package, not the tarball on npm, so you're trusting esm.sh too, or whoever serves your `loader.url`.
- **Pin exact versions** for code you don't control. With a range, a new release is loaded at the next restart, without a review or a deploy on your side. With `autoUpdate`, it's loaded without a restart either.
- FrontMCP doesn't check the bundle against the registry's integrity hash, or at all.
- A private registry or mirror, with [`loader`](#the-loader), lets you decide which versions exist.

#### Errors

A package that can't be loaded stops the server with one of these classes, all exported by `@frontmcp/sdk`. Each keeps the cause's message after its own, and the cause itself in `originalError` (except the manifest and specifier errors).

| Class | `code` | When | Message |
| --- | --- | --- | --- |
| `EsmInvalidSpecifierError` | `ESM_INVALID_SPECIFIER` | The specifier isn't a package name. Thrown by `App.esm()` itself. | `Invalid package specifier: "…"` |
| `EsmRegistryAuthError` | `ESM_REGISTRY_AUTH_ERROR` | The registry answered `401` or `403`. | `Authentication failed for npm registry: Registry returned 401 for "…"` |
| `EsmVersionResolutionError` | `ESM_VERSION_RESOLUTION_ERROR` | Any other failure while resolving the version: an unknown package, an unreachable registry, a range nothing satisfies, an unset `tokenEnvVar`. | `Failed to resolve version for "<name>@<range>": <cause>` |
| `EsmPackageLoadError` | `ESM_PACKAGE_LOAD_ERROR` | The bundle couldn't be downloaded or run. | `Failed to load ESM package "<name>"@<version>: <cause>` |
| `EsmManifestInvalidError` | `ESM_MANIFEST_INVALID` | The bundle ran, but exports nothing FrontMCP can use. | `Invalid manifest in ESM package "…": ESM module does not export a valid FrontMcpPackageManifest. …` |
| `EsmCacheError` | `ESM_CACHE_ERROR` | A cached bundle couldn't be read or written. | `ESM cache <operation> failed for "…": <cause>` |

[When a package can't be loaded](#when-a-package-cant-be-loaded) and [using a private registry](#using-a-private-registry) show all but the cache error. `Tool.esm()` and the other single-entry loaders wrap them in [`ExternalEntryLoadError`](#loading-one-tool-from-a-package).

#### Caveats

- Every package loads at startup. If one can't be loaded, the server doesn't start: [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) rejects with the loader's 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.
- ESM apps have tools, resources and prompts only: no plugins, adapters or providers of their own, and the package's skills, agents, jobs and workflows aren't loaded. The server logs a warning when a manifest has them.
- To load one tool, resource or prompt instead of the whole package, use [`Tool.esm()`](#loading-one-tool-from-a-package), `Resource.esm()` or `Prompt.esm()` in an app's lists. `Agent.esm()`, `Skill.esm()` and `Job.esm()` stop the server from starting.

---

## Usage

> **Note**
The examples on this page don't reach the npm registry or esm.sh: each has a `npm.example.ts` file that plays both. It answers FrontMCP's requests in-process, and records them so tests can check them; `publish()` puts a package version "on npm". Everything else is the real loader. You won't need the file in a real server.

In the Playground, bundles are cached in memory only, and a package can only import what an [`importMap`](#rewriting-a-packages-imports) points at, which can't be the Playground's own `@frontmcp/sdk`, so packages here don't use decorated classes. `autoUpdate` isn't shown, because its checks never stop. Those were checked in Node.

### Loading a package

`packages.ts` publishes three versions of `@acme/status-tools`. The server asks for `^1.0.0`, so FrontMCP resolves it to `1.2.0`, the highest 1.x, downloads that version's bundle, and lists its tool under the `status` namespace.

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

@FrontMcp({
  info: { name: "support", version: "1.0.0" },
  apps: [App.esm("@acme/status-tools@^1.0.0", { namespace: "status" })],
})
export default class Server {}
```

```ts packages.ts
import { publish } from "./npm.example";

// What esm.sh serves for each version: the package's code, as one module.
const bundle = (version: string) => `
export default {
  name: "@acme/status-tools",
  version: "${version}",
  tools: [
    {
      name: "get_status",
      description: "Current status of one service",
      inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"] },
      execute: async ({ service }) => ({
        content: [{ type: "text", text: service + " is up (served by ${version})" }],
      }),
    },
  ],
};`;

publish("@acme/status-tools", "1.0.0", bundle("1.0.0"));
publish("@acme/status-tools", "1.2.0", bundle("1.2.0"));
publish("@acme/status-tools", "2.0.0", bundle("2.0.0"));
```

```ts npm.example.ts
// Stands in for the npm registry and esm.sh, which the Playground doesn't reach.
// It answers FrontMCP's requests to them in-process, and passes everything else
// to the real fetch. A real server doesn't need this file.

type Package = { versions: Record<string, string | undefined>; tags: Record<string, string> };
const packages: Record<string, Package> = {};

/** Every request FrontMCP sent to the registry or esm.sh, oldest first. */
export const requests: { url: string; authorization: string | null }[] = [];

/** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */
export function publish(name: string, version: string, bundle?: string, tag = "latest") {
  const pkg = (packages[name] ??= { versions: {}, tags: {} });
  pkg.versions[version] = bundle;
  pkg.tags[tag] = version;
}

const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" });

function registry(url: URL) {
  const name = decodeURIComponent(url.pathname.slice(1));
  const pkg = packages[name];
  if (!pkg) return notFound();
  const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }]));
  return Response.json({ name, "dist-tags": pkg.tags, versions });
}

function esmSh(url: URL) {
  const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? [];
  const bundle = packages[name]?.versions[version];
  return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound();
}

const hosts: Record<string, (url: URL) => Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh };
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));
  const answer = hosts[url.host];
  if (!answer) return realFetch(input, init);
  requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") });
  return answer(url);
};
```

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

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

test("^1.0.0 was resolved with the registry, and 1.2.0 was loaded", async ({ mcp }) => {
  const result = await mcp.tools.call("status:get_status", { service: "api" });
  expect(result.text()).toBe("api is up (served by 1.2.0)");
  expect(requests[0].url).toBe("https://registry.npmjs.org/@acme%2fstatus-tools");
  // Then the bundle, from esm.sh, unless Node's disk cache already had it
  for (const { url } of requests.slice(1)) expect(url).toBe("https://esm.sh/@acme/status-tools@1.2.0?bundle");
});
```

The call works like a call to any of your tools; the model sees `status:get_status` and doesn't know where it came from. In Node, the test usually finds only the registry request: the Playground's first start already put `1.2.0` in the [disk cache](#caching).

### Choosing a version

The part after the last `@` picks the version. Here the same package is loaded five times, with five specifiers, each under its own namespace; `2.0.0-beta.1` is published under the `next` dist-tag:

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

@FrontMcp({
  info: { name: "support", version: "1.0.0" },
  apps: [
    App.esm("@acme/deploy-tools@^1.0.0", { namespace: "stable" }), // the highest 1.x
    App.esm("@acme/deploy-tools@1.0.0", { namespace: "pinned" }),  // exactly 1.0.0
    App.esm("@acme/deploy-tools@next", { namespace: "beta" }),     // the "next" dist-tag
    App.esm("@acme/deploy-tools", { namespace: "latest" }),        // the "latest" dist-tag
    App.esm("@acme/deploy-tools@stable", { namespace: "typo" }),   // 🚩 no such dist-tag
  ],
})
export default class Server {}
```

```ts packages.ts
import { publish } from "./npm.example";

const bundle = (version: string) => `
export default {
  name: "@acme/deploy-tools",
  version: "${version}",
  tools: [
    {
      name: "version",
      description: "Which version of @acme/deploy-tools is loaded",
      execute: async () => ({ content: [{ type: "text", text: "${version}" }] }),
    },
  ],
};`;

publish("@acme/deploy-tools", "1.0.0", bundle("1.0.0"));
publish("@acme/deploy-tools", "1.2.0", bundle("1.2.0"));
publish("@acme/deploy-tools", "2.0.0-beta.1", bundle("2.0.0-beta.1"), "next");
```

```ts npm.example.ts
// Stands in for the npm registry and esm.sh, which the Playground doesn't reach.
// It answers FrontMCP's requests to them in-process, and passes everything else
// to the real fetch. A real server doesn't need this file.

type Package = { versions: Record<string, string | undefined>; tags: Record<string, string> };
const packages: Record<string, Package> = {};

/** Every request FrontMCP sent to the registry or esm.sh, oldest first. */
export const requests: { url: string; authorization: string | null }[] = [];

/** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */
export function publish(name: string, version: string, bundle?: string, tag = "latest") {
  const pkg = (packages[name] ??= { versions: {}, tags: {} });
  pkg.versions[version] = bundle;
  pkg.tags[tag] = version;
}

const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" });

function registry(url: URL) {
  const name = decodeURIComponent(url.pathname.slice(1));
  const pkg = packages[name];
  if (!pkg) return notFound();
  const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }]));
  return Response.json({ name, "dist-tags": pkg.tags, versions });
}

function esmSh(url: URL) {
  const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? [];
  const bundle = packages[name]?.versions[version];
  return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound();
}

const hosts: Record<string, (url: URL) => Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh };
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));
  const answer = hosts[url.host];
  if (!answer) return realFetch(input, init);
  requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") });
  return answer(url);
};
```

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

test("every app is listed", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["beta:version", "latest:version", "pinned:version", "stable:version", "typo:version"]);
});

test("each specifier loads its own version", async ({ mcp }) => {
  const loaded = async (namespace: string) => (await mcp.tools.call(`${namespace}:version`, {})).text();
  expect(await loaded("stable")).toBe("1.2.0");
  expect(await loaded("pinned")).toBe("1.0.0");
  expect(await loaded("beta")).toBe("2.0.0-beta.1");
  expect(await loaded("latest")).toBe("1.2.0");
});

test("a dist-tag the package doesn't have loads the highest version", async ({ mcp }) => {
  expect((await mcp.tools.call("typo:version", {})).text()).toBe("2.0.0-beta.1");
});

test("without namespaces, two apps of one package share an id, and the server doesn't start", async () => {
  const apps = [App.esm("@acme/deploy-tools@^1.0.0"), App.esm("@acme/deploy-tools@1.0.0")];
  await expect(FrontMcpInstance.createDirect({ info: { name: "support", version: "1.0.0" }, apps })).rejects.toThrow(
    'Two apps share the id "acme-deploy-tools": give each App.esm() / App.remote() its own `name` or `namespace`.',
  );
});
```

Each app takes its id from its namespace. Without them, the five apps would share one id, made from the package name, and the server doesn't start, with `DuplicateAppIdError`, as the last test shows. Give every `App.esm()` of the same package a `namespace` of its own. (Changed in 1.9.3: before, an app without a `name` took its id from the package name, even with a namespace, and the server started; `tools/list` then left out some of their tools, different ones from start to start.)

A range never picks a prerelease like `2.0.0-beta.1`; a dist-tag or an exact version does. So does a dist-tag the package doesn't have: `stable` isn't one here, and instead of failing, FrontMCP loads the highest version it can find, the beta. Check dist-tags carefully. The version is resolved again at every start, so with a range, the next release is loaded at the next restart. Pin an exact version when that matters, or [check for updates while running](#updating-without-a-restart).

### Writing a package FrontMCP can load

A package that FrontMCP can load exports a manifest: its `name` and `version`, and arrays of `tools`, `resources` and `prompts`. Plain objects need no dependency on FrontMCP. Publish the package the way you publish any other; FrontMCP loads it through esm.sh, as an ES module or CommonJS.

<Examples title="Export forms">

#### Example: Default export
```ts packages.ts active
import { publish } from "./npm.example";

// The package's src/index.js, as esm.sh serves it
publish("@acme/incident-tools", "1.0.0", `
const incidents = [
  { id: "INC-7", service: "api", title: "Elevated error rate" },
  { id: "INC-8", service: "billing", title: "Invoices delayed" },
];

export default {
  name: "@acme/incident-tools",
  version: "1.0.0",
  tools: [
    {
      name: "open_incidents",
      description: "List open incidents, optionally for one service",
      inputSchema: { type: "object", properties: { service: { type: "string" } } },
      execute: async ({ service }) => {
        const open = incidents.filter((i) => !service || i.service === service);
        return { content: [{ type: "text", text: JSON.stringify(open) }] };
      },
    },
  ],
  resources: [
    {
      name: "incidents",
      uri: "incidents://open",
      mimeType: "application/json",
      read: async () => ({
        contents: [{ uri: "incidents://open", mimeType: "application/json", text: JSON.stringify(incidents) }],
      }),
    },
  ],
  prompts: [
    {
      name: "incident_summary",
      description: "Summarize the open incidents for a status page",
      arguments: [{ name: "audience", description: "Who will read it", required: true }],
      execute: async ({ audience }) => ({
        messages: [{ role: "user", content: { type: "text", text: "Read incidents://open and summarize it for " + audience + "." } }],
      }),
    },
  ],
};
`);
```

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

@FrontMcp({
  info: { name: "support", version: "1.0.0" },
  apps: [App.esm("@acme/incident-tools@^1.0.0", { namespace: "incidents" })],
})
export default class Server {}
```

```ts npm.example.ts
// Stands in for the npm registry and esm.sh, which the Playground doesn't reach.
// It answers FrontMCP's requests to them in-process, and passes everything else
// to the real fetch. A real server doesn't need this file.

type Package = { versions: Record<string, string | undefined>; tags: Record<string, string> };
const packages: Record<string, Package> = {};

/** Every request FrontMCP sent to the registry or esm.sh, oldest first. */
export const requests: { url: string; authorization: string | null }[] = [];

/** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */
export function publish(name: string, version: string, bundle?: string, tag = "latest") {
  const pkg = (packages[name] ??= { versions: {}, tags: {} });
  pkg.versions[version] = bundle;
  pkg.tags[tag] = version;
}

const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" });

function registry(url: URL) {
  const name = decodeURIComponent(url.pathname.slice(1));
  const pkg = packages[name];
  if (!pkg) return notFound();
  const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }]));
  return Response.json({ name, "dist-tags": pkg.tags, versions });
}

function esmSh(url: URL) {
  const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? [];
  const bundle = packages[name]?.versions[version];
  return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound();
}

const hosts: Record<string, (url: URL) => Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh };
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));
  const answer = hosts[url.host];
  if (!answer) return realFetch(input, init);
  requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") });
  return answer(url);
};
```

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

test("tools, resources and prompts are namespaced; URIs aren't", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["incidents:open_incidents"]);
  expect(await mcp.resources.list()).toEqual([
    { name: "incidents:incidents", uri: "incidents://open", mimeType: "application/json", description: "ESM resource: incidents" },
  ]);
  expect((await mcp.prompts.list()).map((p) => p.name)).toEqual(["incidents:incident_summary"]);
  expect((await mcp.resources.read("incidents://open")).json()).toHaveLength(2);
});

test("arguments aren't checked against a JSON Schema inputSchema", async ({ mcp }) => {
  const result = await mcp.tools.call("incidents:open_incidents", { service: 42 });
  expect(result).toBeSuccessful();
  expect(result.text()).toBe("[]");
});

test("required prompt arguments are checked", async ({ mcp }) => {
  const result = await mcp.prompts.get("incidents:incident_summary", {});
  expect(result.error?.message).toBe("Missing required argument: audience");
});
```

#### Example: Named exports
Without a default export, FrontMCP reads the module's named exports. `name` and `version` are optional here.

```ts packages.ts active
import { publish } from "./npm.example";

publish("@acme/incident-lite", "1.0.0", `
export const tools = [
  {
    name: "open_incidents",
    description: "List open incidents",
    execute: async () => ({ content: [{ type: "text", text: "INC-7: Elevated error rate" }] }),
  },
];
`);
```

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

@FrontMcp({
  info: { name: "support", version: "1.0.0" },
  apps: [App.esm("@acme/incident-lite@1.0.0", { namespace: "incidents" })],
})
export default class Server {}
```

```ts npm.example.ts
// Stands in for the npm registry and esm.sh, which the Playground doesn't reach.
// It answers FrontMCP's requests to them in-process, and passes everything else
// to the real fetch. A real server doesn't need this file.

type Package = { versions: Record<string, string | undefined>; tags: Record<string, string> };
const packages: Record<string, Package> = {};

/** Every request FrontMCP sent to the registry or esm.sh, oldest first. */
export const requests: { url: string; authorization: string | null }[] = [];

/** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */
export function publish(name: string, version: string, bundle?: string, tag = "latest") {
  const pkg = (packages[name] ??= { versions: {}, tags: {} });
  pkg.versions[version] = bundle;
  pkg.tags[tag] = version;
}

const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" });

function registry(url: URL) {
  const name = decodeURIComponent(url.pathname.slice(1));
  const pkg = packages[name];
  if (!pkg) return notFound();
  const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }]));
  return Response.json({ name, "dist-tags": pkg.tags, versions });
}

function esmSh(url: URL) {
  const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? [];
  const bundle = packages[name]?.versions[version];
  return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound();
}

const hosts: Record<string, (url: URL) => Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh };
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));
  const answer = hosts[url.host];
  if (!answer) return realFetch(input, init);
  requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") });
  return answer(url);
};
```

A package can also export `@Tool`, `@Resource` and `@Prompt` classes, in a manifest's arrays or as exports of their own. FrontMCP registers them like the classes in your own apps, with their input validated and their results shaped as usual. That was checked in Node, with the bundle importing the server's own `@frontmcp/sdk`; a Playground bundle can't import the Playground's `@frontmcp/sdk`, so the examples here use plain objects.

### Using a private registry

Point `loader` at your registry and give it a token. Prefer `tokenEnvVar`, which names an environment variable, over `token`, so the token stays out of your code. Here the registry is `npm.acme.example` and the bundles come from `esm.acme.example`, a self-hosted esm.sh:

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

@FrontMcp({
  info: { name: "support", version: "1.0.0" },
  loader: {
    registryUrl: "https://npm.acme.example",
    url: "https://esm.acme.example",
    tokenEnvVar: "ACME_NPM_TOKEN",
  },
  apps: [App.esm("@acme/internal-tools@^1.0.0", { namespace: "internal" })],
})
export default class Server {}
```

```ts npm.example.ts
// Stands in for Acme's private registry (npm.acme.example), which only answers
// with the right token, and its esm.sh (esm.acme.example). A real server doesn't
// need this file.

// Your deployment would set this. Here the stand-in does.
process.env.ACME_NPM_TOKEN = "npm_acme_read_only";

const bundle = `
export default {
  name: "@acme/internal-tools",
  version: "1.0.0",
  tools: [{ name: "whoami", description: "Which registry this came from", execute: async () => ({ content: [{ type: "text", text: "Acme's private registry" }] }) }],
};`;

/** Every request FrontMCP sent to the registry or esm.sh, oldest first. */
export const requests: { url: string; authorization: string | null }[] = [];

function registry(url: URL, authorization: string | null) {
  if (authorization !== "Bearer npm_acme_read_only") return new Response("", { status: 401, statusText: "Unauthorized" });
  if (url.pathname !== "/@acme%2finternal-tools") return new Response("", { status: 404, statusText: "Not Found" });
  return Response.json({ name: "@acme/internal-tools", "dist-tags": { latest: "1.0.0" }, versions: { "1.0.0": {} } });
}

function esmSh(url: URL) {
  if (url.pathname !== "/@acme/internal-tools@1.0.0") return new Response("", { status: 404, statusText: "Not Found" });
  return new Response(bundle, { headers: { "content-type": "application/javascript" } });
}

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 !== "npm.acme.example" && url.host !== "esm.acme.example") return realFetch(input, init);
  const authorization = new Headers(init?.headers).get("authorization");
  requests.push({ url: url.href, authorization });
  return url.host === "npm.acme.example" ? registry(url, authorization) : esmSh(url);
};
```

```ts private.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, EsmRegistryAuthError, FrontMcpInstance } from "@frontmcp/sdk";
import { requests } from "./npm.example";

test("the token goes to the registry, and not to the bundle server", async ({ mcp }) => {
  expect((await mcp.tools.call("internal:whoami", {})).text()).toBe("Acme's private registry");
  expect(requests[0]).toEqual({ url: "https://npm.acme.example/@acme%2finternal-tools", authorization: "Bearer npm_acme_read_only" });
  for (const request of requests.slice(1)) {
    expect(request).toEqual({ url: "https://esm.acme.example/@acme/internal-tools@1.0.0?bundle", authorization: null });
  }
});

test("a wrong token stops the server from starting", async () => {
  const starting = FrontMcpInstance.createFetchHandler({
    info: { name: "support", version: "1.0.0" },
    loader: { registryUrl: "https://npm.acme.example", url: "https://esm.acme.example", token: "npm_expired" },
    apps: [App.esm("@acme/internal-tools@^1.0.0", { namespace: "internal" })],
  });
  const error = await starting.catch((e) => e);
  expect(error).toBeInstanceOf(EsmRegistryAuthError);
  expect(error.message).toBe('Authentication failed for npm registry: Registry returned 401 for "@acme/internal-tools"');
});
```

The token only goes to the registry. The bundle server gets no credentials from FrontMCP, so it has to be able to fetch the package by itself: the public esm.sh can't build a package from a private registry, which is why this example has its own. To use a registry for one package only, give that `App.esm()` its own `loader`; it replaces the server's, token included.

### Loading part of a package

`filter` picks which of a package's tools, resources and prompts the server loads. Patterns match the names the package gives them, before the namespace, and `*` matches any run of characters. Here the server takes the status tools of `@acme/status-admin`, but not the one that deletes incidents:

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

@FrontMcp({
  info: { name: "support", version: "1.0.0" },
  apps: [
    App.esm("@acme/status-admin@1.0.0", {
      namespace: "status",
      filter: { exclude: { tools: ["delete_*"] } },
    }),
  ],
})
export default class Server {}
```

```ts packages.ts
import { publish } from "./npm.example";

publish("@acme/status-admin", "1.0.0", `
const tool = (name) => ({ name, description: "The " + name + " tool", execute: async () => ({ content: [{ type: "text", text: name + " ran" }] }) });
export default { name: "@acme/status-admin", version: "1.0.0", tools: [tool("get_status"), tool("delete_incident")] };
`);
```

```ts npm.example.ts
// Stands in for the npm registry and esm.sh, which the Playground doesn't reach.
// It answers FrontMCP's requests to them in-process, and passes everything else
// to the real fetch. A real server doesn't need this file.

type Package = { versions: Record<string, string | undefined>; tags: Record<string, string> };
const packages: Record<string, Package> = {};

/** Every request FrontMCP sent to the registry or esm.sh, oldest first. */
export const requests: { url: string; authorization: string | null }[] = [];

/** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */
export function publish(name: string, version: string, bundle?: string, tag = "latest") {
  const pkg = (packages[name] ??= { versions: {}, tags: {} });
  pkg.versions[version] = bundle;
  pkg.tags[tag] = version;
}

const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" });

function registry(url: URL) {
  const name = decodeURIComponent(url.pathname.slice(1));
  const pkg = packages[name];
  if (!pkg) return notFound();
  const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }]));
  return Response.json({ name, "dist-tags": pkg.tags, versions });
}

function esmSh(url: URL) {
  const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? [];
  const bundle = packages[name]?.versions[version];
  return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound();
}

const hosts: Record<string, (url: URL) => Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh };
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));
  const answer = hosts[url.host];
  if (!answer) return realFetch(input, init);
  requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") });
  return answer(url);
};
```

```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 callable", 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 rest of the package works as usual", async ({ mcp }) => {
  expect((await mcp.tools.call("status:get_status", {})).text()).toBe("get_status ran");
});
```

To load 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.

### Rewriting a package's imports

A bundle can import packages that its bundle server can't build in, like a private dependency, or that your server should provide. `importMap` names them: FrontMCP asks for the bundle without them (`&external=…`), and rewrites the bundle's imports to the targets you give. A target is anything `import()` accepts where the server runs. Here `@acme/greeter` imports `@acme/greetings`, and the server supplies it as a `data:` module:

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

const greetings = "export const greet = (name) => 'Hello, ' + name + '!';";

@FrontMcp({
  info: { name: "support", version: "1.0.0" },
  apps: [
    App.esm("@acme/greeter@1.0.0", {
      namespace: "greet",
      importMap: { "@acme/greetings": `data:text/javascript,${encodeURIComponent(greetings)}` },
    }),
  ],
})
export default class Server {}
```

```ts packages.ts
import { publish } from "./npm.example";

publish("@acme/greeter", "1.0.0", `
import { greet } from "@acme/greetings";
export default {
  name: "@acme/greeter",
  version: "1.0.0",
  tools: [
    {
      name: "hello",
      description: "Greet someone",
      inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] },
      execute: async ({ name }) => ({ content: [{ type: "text", text: greet(name) }] }),
    },
  ],
};
`);
```

```ts npm.example.ts
// Stands in for the npm registry and esm.sh, which the Playground doesn't reach.
// It answers FrontMCP's requests to them in-process, and passes everything else
// to the real fetch. A real server doesn't need this file.

type Package = { versions: Record<string, string | undefined>; tags: Record<string, string> };
const packages: Record<string, Package> = {};

/** Every request FrontMCP sent to the registry or esm.sh, oldest first. */
export const requests: { url: string; authorization: string | null }[] = [];

/** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */
export function publish(name: string, version: string, bundle?: string, tag = "latest") {
  const pkg = (packages[name] ??= { versions: {}, tags: {} });
  pkg.versions[version] = bundle;
  pkg.tags[tag] = version;
}

const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" });

function registry(url: URL) {
  const name = decodeURIComponent(url.pathname.slice(1));
  const pkg = packages[name];
  if (!pkg) return notFound();
  const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }]));
  return Response.json({ name, "dist-tags": pkg.tags, versions });
}

function esmSh(url: URL) {
  const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? [];
  const bundle = packages[name]?.versions[version];
  return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound();
}

const hosts: Record<string, (url: URL) => Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh };
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));
  const answer = hosts[url.host];
  if (!answer) return realFetch(input, init);
  requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") });
  return answer(url);
};
```

```ts greeter.test.ts
import { test, expect } from "@frontmcp/testing";
import { requests } from "./npm.example";

test("the package's import reaches the module the server supplied", async ({ mcp }) => {
  expect((await mcp.tools.call("greet:hello", { name: "Ana" })).text()).toBe("Hello, Ana!");
});

test("the bundle is asked for without that package", async ({ mcp }) => {
  await mcp.tools.list();
  // Unless Node's disk cache already had it
  for (const { url } of requests.filter((r) => r.url.startsWith("https://esm.sh/"))) {
    expect(url).toBe("https://esm.sh/@acme/greeter@1.0.0?bundle&external=@acme/greetings");
  }
});
```

A key ending in `/`, like `"@acme/util/"`, rewrites every import that starts with it, keeping the rest of the path. The rewritten bundle is cached under its own key, so changing the map downloads the bundle again.

### Loading one tool from a package

`Tool.esm(specifier, name)` loads a single tool from a package into one of your apps, next to your own tools. It keeps its own name, with no namespace, and the package's other tools aren't loaded. `Resource.esm()` and `Prompt.esm()` do the same for a resource or a prompt. A package specifier string in an app's `tools`, like `tools: ["@acme/status-admin@1.0.0"]`, loads every tool of the package, also without a namespace. Both load when the server starts, like `App.esm()`. Since FrontMCP 1.9.1; before, every list rejected what `Tool.esm()` returns, and a specifier string listed a placeholder that couldn't be called.

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

@Tool({ name: "ping", description: "Check the server is up", inputSchema: {} })
class Ping extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

@App({ id: "support", name: "Support", tools: [Ping, Tool.esm("@acme/status-admin@1.0.0", "get_status")] })
class SupportApp {}

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

```ts packages.ts
import { publish } from "./npm.example";

publish("@acme/status-admin", "1.0.0", `
const tool = (name) => ({ name, description: "The " + name + " tool", execute: async () => ({ content: [{ type: "text", text: name + " ran" }] }) });
export default { name: "@acme/status-admin", version: "1.0.0", tools: [tool("get_status"), tool("delete_incident")] };
`);
```

```ts npm.example.ts
// Stands in for the npm registry and esm.sh, which the Playground doesn't reach.
// It answers FrontMCP's requests to them in-process, and passes everything else
// to the real fetch. A real server doesn't need this file.

type Package = { versions: Record<string, string | undefined>; tags: Record<string, string> };
const packages: Record<string, Package> = {};

/** Every request FrontMCP sent to the registry or esm.sh, oldest first. */
export const requests: { url: string; authorization: string | null }[] = [];

/** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */
export function publish(name: string, version: string, bundle?: string, tag = "latest") {
  const pkg = (packages[name] ??= { versions: {}, tags: {} });
  pkg.versions[version] = bundle;
  pkg.tags[tag] = version;
}

const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" });

function registry(url: URL) {
  const name = decodeURIComponent(url.pathname.slice(1));
  const pkg = packages[name];
  if (!pkg) return notFound();
  const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }]));
  return Response.json({ name, "dist-tags": pkg.tags, versions });
}

function esmSh(url: URL) {
  const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? [];
  const bundle = packages[name]?.versions[version];
  return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound();
}

const hosts: Record<string, (url: URL) => Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh };
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));
  const answer = hosts[url.host];
  if (!answer) return realFetch(input, init);
  requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") });
  return answer(url);
};
```

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

const toolNames = async (tools: unknown[]) => {
  @App({ id: "desk", name: "Desk", tools: tools as any })
  class Desk {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "support", version: "1.0.0" }, apps: [Desk] });
  try {
    return (await server.listTools()).tools.map((t) => t.name);
  } finally {
    await server.dispose();
  }
};

test("Tool.esm() loads one tool, under its own name", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["get_status", "ping"]);
  expect((await mcp.tools.call("get_status", {})).text()).toBe("get_status ran");
});

test("a specifier string loads every tool of the package", async () => {
  expect(await toolNames(["@acme/status-admin@1.0.0"])).toEqual(["delete_incident", "get_status"]);
});

test("`metadata` renames it or changes its description", async () => {
  expect(await toolNames([Tool.esm("@acme/status-admin@1.0.0", "get_status", { metadata: { name: "service_status" } })])).toEqual(["service_status"]);
});

test("a name the package doesn't have stops the server from starting", async () => {
  await expect(toolNames([Tool.esm("@acme/status-admin@1.0.0", "get_stauts")])).rejects.toThrow(
    'Tool "get_stauts" was not found in @acme/status-admin@1.0.0 (tools there: get_status, delete_incident)',
  );
});
```

`Tool.esm()` takes `loader`, `cacheTTL` and `metadata`; entries from the same package and options share one download. `Agent.esm()`, `Skill.esm()` and `Job.esm()` exist too, but there's nothing to load them: a server that lists one doesn't start, with `ExternalEntryNotSupportedError`. A package that can't be loaded stops the server with `ExternalEntryLoadError`, whose message ends with the loader's own, [below](#when-a-package-cant-be-loaded).

### When a package can't be loaded

The server doesn't start, and the error says what failed: `createFetchHandler()` rejects with it. Its class tells the step apart, and its message ends with the cause, which `originalError` holds too. Each test here starts a server of its own with a package that can't load:

```ts failures.test.ts active
import { test, expect } from "@frontmcp/testing";
import {
  App,
  EsmInvalidSpecifierError,
  EsmManifestInvalidError,
  EsmPackageLoadError,
  EsmVersionResolutionError,
  FrontMcpInstance,
} from "@frontmcp/sdk";
import "./npm.example";
import { publish } from "./npm.example";

publish("@acme/uptime-tools", "1.0.0", "export default { name: '@acme/uptime-tools', version: '1.0.0', tools: [] };");
publish("@acme/uptime-tools", "1.2.0", "export default { name: '@acme/uptime-tools', version: '1.2.0', tools: [] };");
publish("@acme/half-published", "1.0.0"); // in the registry, but esm.sh can't serve it
publish("@acme/not-a-manifest", "1.0.0", "export const hello = 'world';");

/** Starts a server with one ESM app. It rejects when the package can't be loaded. */
async function start(app: unknown) {
  await FrontMcpInstance.createFetchHandler({ info: { name: "support", version: "1.0.0" }, apps: [app] } as any);
}

/** The error a server with this app fails to start with. */
const failure = (app: unknown) => start(app).then(() => undefined, (e) => e);

test("a package the registry doesn't know", async () => {
  const error = await failure(App.esm("@acme/stauts-tools@^1.0.0"));
  expect(error).toBeInstanceOf(EsmVersionResolutionError);
  expect(error.message).toBe(
    'Failed to resolve version for "@acme/stauts-tools@^1.0.0": Package "@acme/stauts-tools" not found in registry at https://registry.npmjs.org',
  );
});

test("a registry that can't be reached", async () => {
  const error = await failure(App.esm("@acme/uptime-tools@^1.0.0", { loader: { registryUrl: "https://npm.down.example" } }));
  expect(error).toBeInstanceOf(EsmVersionResolutionError);
  expect(error.originalError.message).toBe('Failed to fetch package info for "@acme/uptime-tools": fetch failed');
});

test("a range no published version satisfies", async () => {
  const error = await failure(App.esm("@acme/uptime-tools@^3.0.0"));
  expect(error).toBeInstanceOf(EsmVersionResolutionError);
  expect(error.originalError.message).toBe('No version of "@acme/uptime-tools" satisfies range "^3.0.0". Available versions: 1.0.0, 1.2.0');
});

test("a version esm.sh can't serve", async () => {
  const error = await failure(App.esm("@acme/half-published@1.0.0"));
  expect(error).toBeInstanceOf(EsmPackageLoadError);
  expect(error.message).toBe('Failed to load ESM package "@acme/half-published"@1.0.0: esm.sh returned 404 for "@acme/half-published@1.0.0": Not Found');
});

test("a module without a manifest", async () => {
  const error = await failure(App.esm("@acme/not-a-manifest@1.0.0"));
  expect(error).toBeInstanceOf(EsmManifestInvalidError);
  expect(error.message).toContain('Invalid manifest in ESM package "@acme/not-a-manifest": ESM module does not export a valid FrontMcpPackageManifest.');
});

test("a token variable that isn't set", async () => {
  const error = await failure(App.esm("@acme/uptime-tools@^1.0.0", { loader: { tokenEnvVar: "ACME_TOKEN_NOT_SET" } }));
  expect(error).toBeInstanceOf(EsmVersionResolutionError);
  expect(error.originalError.message).toBe('Environment variable "ACME_TOKEN_NOT_SET" is not set. Required for private npm registry authentication.');
});

test("a malformed specifier throws before the server exists", () => {
  expect(() => App.esm("Acme/Status-Tools")).toThrow(EsmInvalidSpecifierError);
  expect(() => App.esm("Acme/Status-Tools")).toThrow('Invalid package specifier: "Acme/Status-Tools"');
});
```

```ts npm.example.ts
// Stands in for the npm registry and esm.sh, which the Playground doesn't reach.
// It answers FrontMCP's requests to them in-process, and passes everything else
// to the real fetch. A real server doesn't need this file.

type Package = { versions: Record<string, string | undefined>; tags: Record<string, string> };
const packages: Record<string, Package> = {};

/** Every request FrontMCP sent to the registry or esm.sh, oldest first. */
export const requests: { url: string; authorization: string | null }[] = [];

/** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */
export function publish(name: string, version: string, bundle?: string, tag = "latest") {
  const pkg = (packages[name] ??= { versions: {}, tags: {} });
  pkg.versions[version] = bundle;
  pkg.tags[tag] = version;
}

const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" });

function registry(url: URL) {
  const name = decodeURIComponent(url.pathname.slice(1));
  const pkg = packages[name];
  if (!pkg) return notFound();
  const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }]));
  return Response.json({ name, "dist-tags": pkg.tags, versions });
}

function esmSh(url: URL) {
  const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? [];
  const bundle = packages[name]?.versions[version];
  return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound();
}

// A registry that's down: every request fails the way fetch() does when it can't connect
const down = (): Response => {
  throw new TypeError("fetch failed");
};

const hosts: Record<string, (url: URL) => Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh, "npm.down.example": down };
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));
  const answer = hosts[url.host];
  if (!answer) return realFetch(input, init);
  requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") });
  return answer(url);
};
```

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

@Tool({ name: "ping", description: "Check the server is up", inputSchema: {} })
class Ping extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

@App({ id: "support", name: "Support", tools: [Ping] })
class SupportApp {}

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

What each one means, and what to do, is under [Troubleshooting](#troubleshooting).

---

## Troubleshooting

### `Failed to resolve version for "…": Package "…" not found in registry at …`

The registry answered `404` for the name. Check the spelling and the scope. For a private package, check that `registryUrl` points at the registry that has it: without one, FrontMCP asks `https://registry.npmjs.org`.

### `Failed to resolve version for "…": Failed to fetch package info for "…": fetch failed`

FrontMCP couldn't reach the registry at all: it's down, the host is wrong, or the network doesn't let the server out. FrontMCP asks the registry at every start, so this stops the server even when the bundle is in the cache. The text after the colon is the runtime's own error, like `Failed to fetch` in browsers.

### `Failed to resolve version for "…": Timeout resolving version for "…" after 15000ms`

The registry didn't answer within 15 seconds. The bundle download has its own limit, 30 seconds: `Failed to load ESM package "…"@…: Timeout fetching ESM bundle for "…@…" after 30000ms`. Neither can be changed. (Checked in Node against a stand-in that never answers.)

### `Failed to resolve version for "…": No version of "…" satisfies range "…"`

The package exists, but no published version is in the range. The message lists up to five published versions. Widen the range, publish a matching version, or use a dist-tag. A range never matches a prerelease; use its exact version or its dist-tag.

### `Failed to load ESM package "…"@…: esm.sh returned 404 for "…@…": Not Found`

The registry has the version, but the bundle URL answered `404`, or another status. Check that the bundle server can reach the package, and that `loader.url` points at a server that serves bundles. The message says "esm.sh" even when `loader.url` points somewhere else. With `url` set and no `registryUrl`, both requests go to `url`, so a plain npm registry there resolves versions and then fails here.

### `Invalid manifest in ESM package "…": ESM module does not export a valid FrontMcpPackageManifest`

The bundle loaded, but FrontMCP found nothing it could use in its exports: no `name` and `version`, no `tools`, `resources` or `prompts` array, and no decorated classes. A default-exported `@App` or `@FrontMcp` class isn't recognized either. Export a manifest, as in [writing a package](#writing-a-package-frontmcp-can-load).

If the server starts but some tools are missing, those entries were skipped, or [`filter`](#loading-part-of-a-package) left them out: every plain-object tool needs a `name` and an `execute` function, every resource a `name`, a `uri` with a scheme and a `read` function.

### `Failed to resolve version for "…": Environment variable "…" is not set. Required for private npm registry authentication.`

`tokenEnvVar` names a variable that isn't set in the server's environment. Set it where the server runs; FrontMCP reads it when the package loads.

### `Authentication failed for npm registry: Registry returned 401 for "…"`

The registry refused the token, or got none (`401` or `403`). Check the token and that it can read the package. FrontMCP sends it as `Authorization: Bearer <token>`, only to the registry; an app's own `loader` replaces the server's, so its token must be set there too. Other statuses fail the version step instead, as `Failed to resolve version for "…": Registry returned <status> for "…": <status text>`.

### `Invalid package specifier: "…"`

`App.esm()` throws this as soon as it's called, so the server module doesn't load. The specifier must be an npm package name, lowercase, optionally scoped, optionally followed by `@` and a version. URLs aren't accepted. An empty string gives `Package specifier cannot be empty`.

### Tool names start with `@acme/status-tools:`

That's the default namespace, the package name. Set `namespace` (or `name`) in the options: `App.esm("@acme/status-tools@^1.0.0", { namespace: "status" })`.

### A tool's result is the text `[object Object]`

A plain-object tool's `execute()` returned an object that isn't a `CallToolResult`. Only `{ content: [...] }` is sent as it is; wrap other data in a text block:

```ts packages.ts active
import { publish } from "./npm.example";

publish("@acme/status-counts", "1.0.0", `
export default {
  name: "@acme/status-counts",
  version: "1.0.0",
  tools: [
    {
      name: "counts_raw",
      description: "Count open incidents",
      // 🚩 A plain object: the client gets "[object Object]"
      execute: async () => ({ open: 2, resolved: 14 }),
    },
    {
      name: "counts",
      description: "Count open incidents",
      // ✅ A CallToolResult
      execute: async () => ({ content: [{ type: "text", text: JSON.stringify({ open: 2, resolved: 14 }) }] }),
    },
  ],
};
`);
```

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

@FrontMcp({
  info: { name: "support", version: "1.0.0" },
  apps: [App.esm("@acme/status-counts@1.0.0", { namespace: "status" })],
})
export default class Server {}
```

```ts npm.example.ts
// Stands in for the npm registry and esm.sh, which the Playground doesn't reach.
// It answers FrontMCP's requests to them in-process, and passes everything else
// to the real fetch. A real server doesn't need this file.

type Package = { versions: Record<string, string | undefined>; tags: Record<string, string> };
const packages: Record<string, Package> = {};

/** Every request FrontMCP sent to the registry or esm.sh, oldest first. */
export const requests: { url: string; authorization: string | null }[] = [];

/** Publishes `name@version`. `bundle` is its code; without one, esm.sh can't serve it. */
export function publish(name: string, version: string, bundle?: string, tag = "latest") {
  const pkg = (packages[name] ??= { versions: {}, tags: {} });
  pkg.versions[version] = bundle;
  pkg.tags[tag] = version;
}

const notFound = () => new Response("Not Found", { status: 404, statusText: "Not Found" });

function registry(url: URL) {
  const name = decodeURIComponent(url.pathname.slice(1));
  const pkg = packages[name];
  if (!pkg) return notFound();
  const versions = Object.fromEntries(Object.keys(pkg.versions).map((version) => [version, { name, version }]));
  return Response.json({ name, "dist-tags": pkg.tags, versions });
}

function esmSh(url: URL) {
  const [, name, version] = /^\/(.+)@([^@]+)$/.exec(decodeURIComponent(url.pathname)) ?? [];
  const bundle = packages[name]?.versions[version];
  return bundle ? new Response(bundle, { headers: { "content-type": "application/javascript" } }) : notFound();
}

const hosts: Record<string, (url: URL) => Response> = { "registry.npmjs.org": registry, "esm.sh": esmSh };
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));
  const answer = hosts[url.host];
  if (!answer) return realFetch(input, init);
  requests.push({ url: url.href, authorization: new Headers(init?.headers).get("authorization") });
  return answer(url);
};
```

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

test("a plain object arrives as [object Object]", async ({ mcp }) => {
  expect((await mcp.tools.call("status:counts_raw", {})).text()).toBe("[object Object]");
});

test("a CallToolResult arrives as it is", async ({ mcp }) => {
  expect((await mcp.tools.call("status:counts", {})).json()).toEqual({ open: 2, resolved: 14 });
});
```

A string is fine too: it arrives as the text. A decorated `@Tool` class in a package returns values the usual way.
