Skip to main content
ESM Packages let you dynamically load npm packages at runtime and register their tools, resources, and prompts into your FrontMCP server. Unlike Remote Apps which proxy requests to external MCP servers over HTTP, ESM packages are fetched from a CDN, cached locally, and executed in-process — giving you the same performance as local apps with the flexibility of npm distribution.

Why ESM Packages?

Zero Build Step

Load community or internal packages at runtime without bundling them into your server build

Auto-Updates

Background version polling with semver-aware hot-reload — tools update without restarts

Private Registries

Token-based authentication for private npm registries and custom CDN endpoints

Cross-Platform

Works in Node.js (disk + memory cache) and browser environments (memory-only cache)

Quick Start

import { FrontMcp, App } from '@frontmcp/sdk';

@FrontMcp({
  info: { name: 'My Server', version: '1.0.0' },
  apps: [
    App.esm('@acme/mcp-tools@^1.0.0'),
  ],
})
export default class Server {}
The App.esm() method creates an app entry that loads the npm package @acme/mcp-tools (any version matching ^1.0.0) at server startup. Its tools, resources, and prompts are automatically registered.

How It Works

1

Parse Specifier

App.esm('@acme/tools@^1.0.0') parses the npm package specifier into scope, name, and semver range.
2

Resolve Version

The npm registry is queried to resolve the semver range (e.g., ^1.0.0) to a concrete version (e.g., 1.2.3).
3

Check Cache

The two-tier cache is checked — first in-memory, then disk. On a hit, the cached bundle is used directly.
4

Fetch Bundle

On a cache miss, the bundle is fetched from the esm.sh CDN (or a custom loader URL) and stored in both cache tiers.
5

Evaluate Module

The bundle is evaluated (ESM via native import(), CJS via bridge wrapper) and the default export is extracted.
6

Normalize Manifest

The export is normalized into a FrontMcpPackageManifest — supporting plain objects, decorated classes, or named exports.
7

Register Primitives

Tools, resources, and prompts from the manifest are registered into standard registries with full hook and lifecycle support.

App.esm() API

import { App } from '@frontmcp/sdk';

App.esm(specifier: string, options?: EsmAppOptions): RemoteAppMetadata

Parameters

specifier
string
required
npm package specifier in the format @scope/name@range or name@range. The range defaults to latest if omitted.

Options

name
string
Override the auto-derived app name (defaults to the package’s full name).
namespace
string
Prefix for all tools, resources, and prompts from this package. For example, namespace: 'acme' turns a tool named echo into acme:echo.
description
string
Human-readable description for the app entry.
standalone
boolean | 'includeInParent'
default:"false"
Isolation mode. true creates a separate scope; 'includeInParent' lists it under the parent while keeping isolation.
loader
PackageLoader
Per-app loader override. Takes precedence over the gateway-level loader. See Gateway-Level Loader for fields.
autoUpdate
{ enabled: boolean; intervalMs?: number }
Enable background version polling. When a new version matching the semver range is published, the package is automatically reloaded. Default interval: 300,000ms (5 minutes).
cacheTTL
number
Local cache time-to-live in milliseconds. Default: 86,400,000ms (24 hours).
importMap
Record<string, string>
Import map overrides for ESM resolution. Maps package names to alternative URLs.
filter
AppFilterConfig
Include/exclude filter for selectively importing primitives. See Per-Primitive Loading for details.

Examples

App.esm('@acme/tools@^1.0.0')

Package Specifier Format

PatternExampleDescription
@scope/name@range@acme/tools@^1.0.0Scoped package with semver range
@scope/name@tag@acme/tools@latestScoped package with dist-tag
name@rangemy-tools@~2.0.0Unscoped package with semver range
namemy-toolsUnscoped package, defaults to latest
Supported semver ranges include ^1.0.0, ~1.0.0, >=1.0.0 <2.0.0, exact versions like 1.2.3, and dist-tags like latest, next, beta.

Gateway-Level Loader

Set a default loader at the server level that applies to all npm apps:
@FrontMcp({
  info: { name: 'Gateway', version: '1.0.0' },
  loader: {
    url: 'https://custom-cdn.corp.com',
    tokenEnvVar: 'NPM_TOKEN',
  },
  apps: [
    App.esm('@acme/tools@^1.0.0', { namespace: 'acme' }),
    App.esm('@acme/analytics@^2.0.0', { namespace: 'analytics' }),
    // Both apps use the gateway-level loader config
  ],
})
export default class Server {}
Individual apps can override the gateway loader via the loader option in App.esm().

PackageLoader Fields

FieldTypeDefaultDescription
urlstringhttps://esm.sh (bundles), https://registry.npmjs.org (registry)Base URL for both registry API and bundle fetching
registryUrlstringSame as urlSeparate registry URL for version resolution (if different from bundle URL)
tokenstringBearer token for authentication
tokenEnvVarstringEnvironment variable name containing the bearer token
When url is set but registryUrl is not, both the registry API and bundle downloads use url. When registryUrl is also set, the registry uses registryUrl while bundles use url.

Auto-Update & Hot-Reload

Enable background version polling to automatically reload packages when new versions are published:
App.esm('@acme/tools@^1.0.0', {
  autoUpdate: {
    enabled: true,
    intervalMs: 60000, // Check every 60 seconds
  },
})
When a new version matching the semver range is detected:
  1. The new bundle is fetched and cached
  2. All tools, resources, and prompts from the old version are unregistered
  3. The new manifest is registered
  4. MCP clients receive tools/list_changed and resources/list_changed notifications
In production, use conservative polling intervals (5+ minutes) to avoid excessive registry traffic. The default interval is 5 minutes (300,000ms).

CLI Update

You can also check for and apply updates via the CLI:
# Check all ESM apps for updates
frontmcp package esm-update --all --check-only

# Apply updates for a specific app
frontmcp package esm-update my-esm-app

# Apply all pending updates
frontmcp package esm-update --all

Caching

ESM packages use a two-tier cache for fast startup and offline resilience:
TierEnvironmentPersistenceSpeed
MemoryAllProcess lifetimeInstant
DiskNode.js onlySurvives restartsFast (local I/O)

Cache Locations (Node.js)

  • Project mode: node_modules/.cache/frontmcp-esm/ (relative to project root)
  • CLI mode: ~/.frontmcp/esm-cache/ (user home directory)
Each cached package is stored as a hashed directory containing the bundle file (.mjs or .cjs) and a meta.json with version, etag, and timestamp.

Cache TTL

Configure how long cached bundles remain valid:
App.esm('@acme/tools@^1.0.0', {
  cacheTTL: 3600000, // 1 hour
})
Default TTL is 24 hours (86,400,000ms). After expiry, the next load triggers a fresh fetch.

Browser Mode

In browser environments, only the in-memory cache is used. Bundles are evaluated via Blob + URL.createObjectURL and stored in a Map. See Browser Compatibility for details.

Private Registry Authentication

@FrontMcp({
  loader: {
    tokenEnvVar: 'NPM_TOKEN',
  },
  apps: [
    App.esm('@private/tools@^1.0.0'),
  ],
})
Never commit tokens directly in source code. Use tokenEnvVar to reference environment variables instead.
The Bearer token is sent in the Authorization header for both registry API calls (version resolution) and bundle fetching.

Comparison: ESM vs Remote vs Local Apps

AspectLocal AppsESM PackagesRemote Apps
Declaration@App classApp.esm()App.remote()
ExecutionIn-processIn-processOut-of-process (HTTP)
TransportNoneNoneStreamable HTTP / SSE
CachingN/ATwo-tier (memory + disk)Optional via CachePlugin
AuthN/Anpm registry authremoteAuth config
Hot-ReloadRequires restartVersion pollingN/A
Plugins/AdaptersFull supportNot supportedNot supported
Best ForFirst-party codeCommunity/npm packagesExternal MCP servers

Error Handling

ESM loading can fail at several stages. FrontMCP provides specific error classes for each:
ErrorWhenHTTP
EsmInvalidSpecifierErrorPackage specifier format is invalid400
EsmVersionResolutionErrornpm registry query fails or no matching version500
EsmRegistryAuthErrorPrivate registry authentication fails401
EsmPackageLoadErrorBundle fetch or evaluation fails500
EsmManifestInvalidErrorPackage export doesn’t match manifest contract400
EsmCacheErrorCache read/write operation fails500
See the ESM Errors reference for full details.

Best Practices

Do:
  • Use namespace to avoid naming conflicts between packages
  • Use tokenEnvVar instead of inline tokens for private registries
  • Set autoUpdate with conservative intervals in production (5+ minutes)
  • Use cacheTTL to balance freshness vs. startup speed
  • Test packages locally before deploying to production
Don’t:
  • Commit registry tokens in source code
  • Use very short polling intervals in production (avoids registry rate limits)
  • Load untrusted packages without reviewing their manifest and code
  • Rely on ESM packages for plugins or adapters (use local @App classes instead)

Next Steps

Publishing ESM Packages

Create and publish npm packages loadable by FrontMCP servers

CLI Reference

Use frontmcp package esm-update to manage ESM packages

Apps

Full guide to local and remote app configuration

ESM Errors

Error classes for ESM loading, caching, and authentication