Loading apps from npm (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.
App.esm(specifier, options?)
Reference
App.esm(specifier, options?)
List the result in @FrontMcp({ apps }), next to your own apps.
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 {}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. |
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. |
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. 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. |
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. |
standalone | boolean | "includeInParent" | false | Serve the app on its own path, as for @App. 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. |
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. |
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:
@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.
How a package is loaded
When the server starts, for each App.esm() app, FrontMCP:
- Resolves the version. It sends
GET <registry>/<name>(the/of a scoped name as%2f) and picks one version from the answer: forlatestor 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 misspelledstabel, isn't an error: FrontMCP loads the highest published version, prereleases included. It gives up after 15 seconds. - Checks its cache for that exact
name@version. See caching. - 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 inimportMap. It gives up after 30 seconds. Neither time limit can be changed. - Runs the bundle with a dynamic
import(). A CommonJS bundle (module.exports = …) works too. - Reads the package's exports (see what a package exports) and registers its tools, resources and prompts in the app, under the namespace: all of them, or the ones
filterlets through.
If any step fails, the server doesn't start, with one of the errors below. 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:
- A default export with string
nameandversionfields: a manifest,{ name, version, description?, tools?, resources?, prompts? }. It's the clearest form. - A default export that's an object of arrays (
{ tools, resources, prompts }), or of@Tool,@Resourceand@Promptclasses, or is one such class. - Named exports, the same three ways:
export const name,versionandtools;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.
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, 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 and using a private registry show all but the cache error. Tool.esm() and the other single-entry loaders wrap them in ExternalEntryLoadError.
Caveats
- Every package loads at startup. If one can't be loaded, the server doesn't start:
createFetchHandler()rejects with the loader's error, ascreateDirect()does. On an edge runtime, like Cloudflare Workers, the server starts on the first request instead. Until it starts, every request gets503{"error":"server_unavailable","code":"SERVER_START_FAILED"}with aRetry-Afterheader, 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(),Resource.esm()orPrompt.esm()in an app's lists.Agent.esm(),Skill.esm()andJob.esm()stop the server from starting.
Usage
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.
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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.
Export forms
Example 1 of 2
Default export
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 + "." } }],
}),
},
],
};
`);Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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 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:
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"');
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
What each one means, and what to do, is under 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.
If the server starts but some tools are missing, those entries were skipped, or filter 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:
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 }) }] }),
},
],
};
`);Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
A string is fine too: it arrives as the text. A decorated @Tool class in a package returns values the usual way.