Skip to main content

How to Package an MCP Server as an .mcpb File

Short answer

An .mcpb file (MCP Bundle) is a zip archive with your local MCP server inside and a manifest.json that tells the app how to start it and what settings to ask for. Write the server, describe it in manifest.json, run mcpb validate and then mcpb pack with the official MCPB CLI from npm. To install it, double-click the file in Claude Desktop, or go to Settings > Extensions > Advanced settings > Install Extension... and pick the file.

Tested withMCP spec 2026-07-28MCPB CLI (@anthropic-ai/mcpb) 2.1.2MCPB manifest spec 0.3TypeScript SDK (@modelcontextprotocol/server) 2.3.1MCP Inspector 2.11.0Node.js 22Checked 20 Oct 2026

What you'll have at the end​

A notes-stats.mcpb file with two tools, note_stats and search_notes, that counts and searches the Markdown notes in a folder you choose. This is the output of packing the bundle:

...
Archive Details
name: notes-stats
version: 1.0.0
filename: notes-stats-1.0.0.mcpb
package size: 1.4MB
unpacked size: 5.9MB
total files: 454

The package is 1.4 MB because it includes the MCP SDK the server runs on. Your own server code is about 3 KB of that.

Before you start

  • Node.js 20 or later for building and packing. The MCP TypeScript SDK requires Node.js 20 or later.
  • Node.js 22.19 or later if you want to run the MCP Inspector step, as in Test with MCP Inspector.
  • A terminal and npm.
  • Claude Desktop on macOS or Windows, for the install step. Claude Desktop runs on macOS and Windows.
  • Basic familiarity with TypeScript. The server is about 100 lines.

Packaging needs a server that already works over stdio. If you haven't written one yet, start with How to Build an MCP Server. This guide uses a small server written for the package, so you don't need to have finished that guide first.

1. Set up the project​

Create a folder called notes-stats and the files this step describes. The folder is the bundle: everything in it that isn't excluded ends up in the .mcpb file.

mkdir -p notes-stats/src
cd notes-stats

Create package.json. The SDK and Zod are runtime dependencies. TypeScript is a development dependency that you'll remove before packing.

package.json
{
"name": "notes-stats",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc"
},
"dependencies": {
"@modelcontextprotocol/server": "^2.3.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^7.0.2"
}
}

Create tsconfig.json. It builds src/ into server/, which is where the manifest expects the entry point.

tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"types": ["node"],
"outDir": "./server",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

Create .mcpbignore. It lists files the MCPB CLI leaves out of the bundle. The source and the TypeScript config aren't needed at runtime.

.mcpbignore
src/
tsconfig.json

2. Write the server​

The host starts the server with the NOTES_DIR environment variable set to the folder the user picked. Your server reads that variable rather than a hard-coded path.

src/index.ts
#!/usr/bin/env node
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";

// The host fills NOTES_DIR from the notes_dir setting in manifest.json.
function notesDirectory(): string {
const dir = process.env.NOTES_DIR;
if (!dir || !statSync(dir, { throwIfNoEntry: false })?.isDirectory()) {
console.error(`NOTES_DIR must be an existing folder, got: ${dir ?? "(unset)"}`);
process.exit(1);
}
return dir;
}

const NOTES_DIR = notesDirectory();

interface Note {
name: string;
text: string;
}

function loadNotes(): Note[] {
return readdirSync(NOTES_DIR)
.filter((name) => name.endsWith(".md"))
.map((name) => ({ name, text: readFileSync(join(NOTES_DIR, name), "utf8") }));
}

function countWords(text: string): number {
return text.split(/\s+/).filter(Boolean).length;
}

function text(value: string) {
return { content: [{ type: "text" as const, text: value }] };
}

function failure(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return { content: [{ type: "text" as const, text: `Could not read notes: ${message}` }], isError: true };
}

// serveStdio calls this whenever it needs a server instance,
// so it must return a new McpServer every time.
function createServer() {
const server = new McpServer({ name: "notes-stats", version: "1.0.0" });

server.registerTool(
"note_stats",
{
description:
"Count the Markdown notes in the user's notes folder and their total word count. Use this when the user asks how many notes they have or how much they have written.",
inputSchema: z.object({}),
},
async () => {
try {
const notes = loadNotes();
const words = notes.reduce((sum, note) => sum + countWords(note.text), 0);
return text(`${notes.length} notes, ${words} words in total.`);
} catch (error) {
return failure(error);
}
},
);

server.registerTool(
"search_notes",
{
description:
"List the names of notes that contain every word in the query, ignoring case. Use this when the user asks which note mentions something.",
inputSchema: z.object({
query: z.string().min(1).describe("One or more words to look for, case-insensitive"),
}),
},
async ({ query }) => {
try {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
const matches = loadNotes()
.filter((note) => {
const haystack = note.text.toLowerCase();
return words.every((word) => haystack.includes(word));
})
.map((note) => note.name);
if (matches.length === 0) return text(`No notes contain '${query}'.`);
return text(matches.join("\n"));
} catch (error) {
return failure(error);
}
},
);

return server;
}

// Logs go to stderr. Anything written to stdout corrupts the stdio transport.
serveStdio(createServer, {
onerror: (error) => console.error("notes-stats server error:", error),
});
console.error("notes-stats MCP server running on stdio");

Two details matter for packaging. The server writes its logs to stderr, because anything on stdout corrupts the stdio connection. And it exits with a clear message when NOTES_DIR is missing, which is what you'll see in the logs if a user skips the setting.

The tool descriptions tell the model when to call each tool. They're the same text you'll put in the manifest, so the install screen and the model describe the server in the same way.

3. Write manifest.json​

The manifest is the file that makes the package an MCPB. Create manifest.json in the root of the folder:

manifest.json
{
"manifest_version": "0.3",
"name": "notes-stats",
"display_name": "Notes Stats",
"version": "1.0.0",
"description": "Count and search the Markdown notes in a folder.",
"author": {
"name": "Acme",
"email": "you@example.com"
},
"license": "MIT",
"server": {
"type": "node",
"entry_point": "server/index.js",
"mcp_config": {
"command": "node",
"args": ["${__dirname}/server/index.js"],
"env": {
"NOTES_DIR": "${user_config.notes_dir}"
}
}
},
"tools": [
{
"name": "note_stats",
"description": "Count the Markdown notes in the notes folder and their total word count"
},
{
"name": "search_notes",
"description": "List the names of notes that contain every word in the query"
}
],
"user_config": {
"notes_dir": {
"type": "directory",
"title": "Notes folder",
"description": "Folder that contains your Markdown (.md) notes. Only files directly inside it are read.",
"default": "${HOME}/Documents",
"required": true
}
},
"compatibility": {
"platforms": ["darwin", "win32"],
"runtimes": {
"node": ">=20.0.0"
}
}
}

The fields you'll change most often:

FieldWhat it does
manifest_versionThe version of the manifest spec this file follows. This guide uses 0.3, the version the spec lists as current. Version 0.4 adds the uv server type for Python.
nameMachine-readable name, used by the CLI. Use lowercase letters, numbers and hyphens.
server.typeHow the server runs: node, python or binary in spec 0.3.
server.entry_pointThe file the host runs, relative to the bundle root.
server.mcp_configThe command and arguments the host uses to start the server.
${__dirname}Inside mcp_config, replaced with the folder where the host installed the bundle.
${user_config.notes_dir}Inside mcp_config, replaced with the value the user chose in the install settings.
user_configSettings the host asks for during install. Claude Desktop builds a settings form from this block.
compatibilityplatforms lists where the bundle runs (darwin is macOS, win32 is Windows). runtimes.node sets the Node.js version.

The tools list describes what the server offers. Keep it in step with the tools the server actually registers in src/index.ts.

notes_dir has type directory, so the install screen shows a folder picker. ${HOME} in the default value is replaced with the user's home folder, and the spec also documents ${DESKTOP} and ${DOCUMENTS}.

4. Install the CLI and validate the manifest​

Install the MCPB CLI globally. It's published on npm as @anthropic-ai/mcpb:

npm install -g @anthropic-ai/mcpb

Install the dependencies, build the server, and then remove the development dependencies so they don't go into the bundle:

npm install
npm run build
npm prune --omit=dev

Check the manifest against the spec:

mcpb validate manifest.json

A valid manifest prints Manifest schema validation passes!. If it doesn't, the CLI names the field and the problem. See Common problems below for the errors you're most likely to see.

5. Pack the bundle​

Run pack from the folder that contains manifest.json:

mcpb pack . notes-stats.mcpb

pack validates the manifest, leaves out the files .mcpbignore lists and the usual development files, and writes a ZIP archive. If you leave out the output name, the CLI names the file after the folder.

To see what's inside, print the archive contents and details with info:

mcpb info notes-stats.mcpb

To look at the bundle as files, unpack it into a folder:

mcpb unpack notes-stats.mcpb unpacked

Check that unpacked/manifest.json and unpacked/server/index.js are there, and that src/ isn't. If you see your source files, check .mcpbignore.

6. Test the unpacked server​

Claude Desktop starts the server with the command from mcp_config, with ${__dirname} and the user settings filled in. You can do the same by hand, which avoids installing a bundle you haven't tested. First, make a folder of notes to test with:

mkdir -p ~/notes-test
printf 'Buy milk and eggs\nSee the MCP docs\n' > ~/notes-test/groceries.md
printf 'Meeting notes about the MCP launch\n' > ~/notes-test/work.md

Then list the tools with the MCP Inspector, pointing NOTES_DIR at that folder:

npx @modelcontextprotocol/inspector --cli -e NOTES_DIR=$HOME/notes-test node unpacked/server/index.js --protocol-era modern --method tools/list

Run the same command with --protocol-era legacy, which checks the older handshake that some apps still use. Both runs should list note_stats and search_notes.

Then call note_stats:

npx @modelcontextprotocol/inspector --cli -e NOTES_DIR=$HOME/notes-test node unpacked/server/index.js --protocol-era modern --method tools/call --tool-name note_stats

With the two files above, the result is:

{
"content": [
{
"type": "text",
"text": "2 notes, 14 words in total."
}
]
}

The -e NOTES_DIR=... flag sets the environment variable the same way mcp_config sets it from notes_dir when the bundle runs in Claude Desktop. That's the part of the manifest that's easiest to get wrong, so it's worth testing here.

7. Install it in Claude Desktop​

Claude Desktop accepts a .mcpb file in three ways: double-click it, drag it into the Claude Desktop window, or open Settings > Extensions > Advanced settings and choose Install Extension.... Each way opens an install screen that shows the extension's details and permissions, and asks for the settings in user_config.

  1. Open notes-stats.mcpb with one of those methods.
  2. On the install screen, choose your notes folder for Notes folder. Use a folder that contains .md files.
  3. Finish the install.
  4. If the tools don't show up, quit Claude Desktop and reopen it. The help center suggests a restart to refresh the extension registry.

To check the tools, click the + button under the chat box and choose Connectors. Then start a new chat and ask "How many notes do I have?". Claude can then call note_stats to answer.

Each user installs the file on their own machine. To share your bundle, send the .mcpb file to the people who need it. Claude's docs say the extension directory no longer accepts .mcpb submissions.

8. Release a new version​

Change version in manifest.json, make the change in src/, then rebuild, prune, validate and pack again:

npm run build && npm prune --omit=dev
mcpb validate manifest.json
mcpb pack . notes-stats.mcpb

Each user installs the new file. The help center says privately shared extensions need the updated .mcpb installed by hand, so tell your users when a new version is out.

Common problems​

ERROR: Manifest validation failed: with server.type: Invalid enum value. You set a server type the spec doesn't allow in manifest_version 0.3. The error reads Expected 'python' | 'node' | 'binary', received 'ruby'. Set server.type to node, python or binary. If you want a uv server, change manifest_version to 0.4 and use "type": "uv".

ERROR: Manifest validation failed: with server.mcp_config: Required. The server block has no mcp_config. Add the command and args that start the server, as in step 3. The field is required in both spec versions.

NOTES_DIR must be an existing folder, got: (unset) in the extension logs. The server started without the notes_dir setting. Open Settings > Extensions, select the extension, and choose a folder. The same message appears when the folder was moved or deleted, and then it shows the path that no longer exists.

The bundle is much bigger than you expected. You probably packed node_modules with development dependencies in it. Run npm prune --omit=dev before mcpb pack, and check .mcpbignore for the source folder.

Claude Desktop doesn't show the extension's tools. Quit and reopen Claude Desktop, then check the extension's settings for a required field that's still empty. For log messages and fixes, see Fix MCP connection errors.

FAQ​

Do I have to use the MCPB CLI?

No. An .mcpb file is a ZIP archive with a manifest.json at its root, so you could build the archive yourself. The CLI validates the manifest and packs the archive for you, which is why this guide uses it.

Does my user need Node.js installed to run a Node bundle?

No. Claude Desktop includes Node.js on macOS and Windows, so a bundle with "type": "node" runs without a separate install. Claude's docs recommend Node.js for this reason.

How do I keep an API key out of the bundle?

Don't put it in the bundle. Add a user_config entry with "sensitive": true and reference it in mcp_config with ${user_config.your_key}. Claude Desktop stores sensitive values in the operating system's secure storage.

Is a .mcpb file the same as a remote MCP server?

No. A .mcpb bundle runs on the user's machine over stdio, so it can reach local files and tools. A remote server runs on a web host and is reached over HTTP. See Local vs remote MCP servers for how to choose.

What is the difference between manifest versions 0.3 and 0.4?

Version 0.4 adds the uv server type, which runs a Python server from a pyproject.toml file, so the host installs the dependencies. Version 0.3 is the version the spec lists as current, so this guide uses it.