Skip to main content
This guide walks you through creating an npm package that FrontMCP servers can load at runtime via App.esm(). By the end, you’ll have a published package with tools, resources, and prompts that any FrontMCP server can consume.

Prerequisites


The Package Manifest Contract

Every ESM package must export a FrontMcpPackageManifest — an object declaring what the package provides:
interface FrontMcpPackageManifest {
  name: string;          // Package name (should match npm name)
  version: string;       // Package version (should match npm version)
  description?: string;  // Optional description
  tools?: unknown[];     // Tool classes or plain tool objects
  prompts?: unknown[];   // Prompt classes or plain prompt objects
  resources?: unknown[]; // Resource classes or plain resource objects
  skills?: unknown[];    // Skill definitions
  agents?: unknown[];    // Agent classes or plain agent objects
  jobs?: unknown[];      // Job classes or plain job objects
  workflows?: unknown[]; // Workflow classes or plain workflow objects
  providers?: unknown[]; // Shared providers for DI
}

Step 1: Scaffold the Package

my-mcp-tools/
├── package.json
├── tsconfig.json
├── src/
│   ├── index.ts          # Default export: FrontMcpPackageManifest
│   └── tools/
│       ├── echo.ts
│       └── add.ts

Step 2: Create Tools

FrontMCP ESM packages support two styles for defining tools:
// src/tools/echo.ts
export const echoTool = {
  name: 'echo',
  description: 'Echoes the input message back',
  inputSchema: {
    type: 'object',
    properties: {
      message: { type: 'string', description: 'Message to echo' },
    },
    required: ['message'],
  },
  execute: async (input: { message: string }) => ({
    content: [{ type: 'text', text: input.message }],
  }),
};

Plain Object Contract

When using plain objects, each tool must have:
FieldTypeRequiredDescription
namestringYesTool name
descriptionstringNoHuman-readable description
inputSchemaobjectNoJSON Schema for input validation
executefunctionYes(input) => Promise<CallToolResult>
The execute function receives the parsed input and must return a CallToolResult with a content array.

Step 3: Create Resources & Prompts

export const statusResource = {
  name: 'status',
  description: 'Server status endpoint',
  uri: 'my-tools://status',
  mimeType: 'application/json',
  read: async () => ({
    contents: [{
      uri: 'my-tools://status',
      text: JSON.stringify({ status: 'ok' }),
    }],
  }),
};

Resource Contract

FieldTypeRequiredDescription
namestringYesResource name
descriptionstringNoHuman-readable description
uristringYesResource URI (e.g., my-tools://status)
mimeTypestringNoMIME type of the resource content
readfunctionYes() => Promise<ReadResourceResult>

Prompt Contract

FieldTypeRequiredDescription
namestringYesPrompt name
descriptionstringNoHuman-readable description
argumentsarrayNoArray of { name, description?, required? }
executefunctionYes(args) => Promise<GetPromptResult>

Step 4: Export the Manifest

The package must export a FrontMcpPackageManifest. Three export formats are supported:
// src/index.ts
import { echoTool } from './tools/echo';
import { addTool } from './tools/add';
import { statusResource } from './resources/status';

export default {
  name: '@acme/mcp-tools',
  version: '1.0.0',
  description: 'ACME MCP tools for task management',
  tools: [echoTool, addTool],
  resources: [statusResource],
};
The default export format is recommended for clarity and compatibility. FrontMCP’s manifest normalizer tries the default export first, then falls back to named exports.

Step 5: Configure package.json

{
  "name": "@acme/mcp-tools",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  },
  "files": ["dist"],
  "peerDependencies": {
    "@frontmcp/sdk": ">=1.0.0",
    "zod": ">=3.0.0"
  },
  "devDependencies": {
    "@frontmcp/sdk": "^1.0.0",
    "zod": "^3.23.0",
    "tsup": "^8.0.0",
    "typescript": "^5.0.0"
  },
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts",
    "prepublishOnly": "npm run build"
  }
}
Declare @frontmcp/sdk and zod as peer dependencies, not regular dependencies. The consuming server provides these at runtime. This keeps bundle sizes small and avoids version conflicts.

Step 6: Build & Publish

# Build the package
npm run build

# Publish to npm
npm publish --access public

# Or publish to a private registry
npm publish --registry https://npm.pkg.github.com

Step 7: Consume with App.esm()

Once published, any FrontMCP server can load your package:
import { FrontMcp, App } from '@frontmcp/sdk';

@FrontMcp({
  info: { name: 'My Server', version: '1.0.0' },
  apps: [
    App.esm('@acme/mcp-tools@^1.0.0', {
      namespace: 'acme',
    }),
  ],
})
export default class Server {}
The server will discover acme:echo, acme:add, and acme:status at startup.

Manifest Normalization

FrontMCP’s normalizeEsmExport() function tries three paths in order:
  1. Default export as manifest — Checks if module.default has name and version fields. If so, validates against the Zod schema.
  2. Default export as decorated class — Checks if module.default is a class with frontmcp:type reflect-metadata. If so, extracts the @FrontMcp configuration.
  3. Named exports — Scans the module for named exports matching manifest primitive keys (tools, prompts, resources, etc.) and assembles them into a manifest.
If none of these paths produce a valid manifest, an EsmManifestInvalidError is thrown.

Best Practices

Tools loaded from ESM packages are often namespaced (e.g., acme:echo). Include clear descriptions so AI models understand what each tool does without seeing the source code.
@frontmcp/sdk and zod should be peer dependencies. Bundling them causes version conflicts and inflates package size.
Use tsup or a similar tool to produce both .mjs and .cjs outputs. FrontMCP handles both formats, but dual output maximizes compatibility.
ESM consumers use semver ranges (^1.0.0, ~2.1.0). Breaking changes should bump the major version. New features bump minor. Bug fixes bump patch.
Set up a local ESM server or use App.esm() with a file path during development to verify your manifest is correct before publishing.

Troubleshooting

Verify your default export has name, version, and a tools array. Check that each tool object has at least name and execute fields. Enable debug logging on the server to see manifest normalization output.
Check that the package is published and accessible. For private registries, verify the token or tokenEnvVar is set correctly. Try npm view @your/package versions to confirm the version exists.
Ensure your package doesn’t import Node.js-only modules (fs, crypto, path) at the top level if it needs to work in browsers. Use dynamic imports for platform-specific code.
In browser environments, avoid any file system operations. The ESM cache is memory-only. Modules are evaluated via Blob URLs, so ensure your code doesn’t rely on __filename or __dirname.

ESM Packages Reference

Full reference for App.esm(), caching, auth, and hot-reload

ESM Errors

Error classes for loading, caching, and authentication