
Setup (The Easy Part)
Install the MCPB CLI and pray it works:
npm install -g @anthropic-ai/mcpb
This gives you the `mcpb` command which sometimes works and sometimes throws cryptic errors. When it works, it handles manifest validation and ZIP creation. When it doesn't, you'll be digging through GitHub issues.
Building a File Browser That Won't Crash (Much)
Here's a file browser extension that took me 3 tries to get right because file paths are hell:
1. Initialize the Project (Pray mcpb Works)
mcpb init file-browser-extension
cd file-browser-extension
This creates the standard layout (if the CLI doesn't shit itself):
file-browser-extension/
├── manifest.json # Where things break first
├── server/ # Your actual code
│ └── index.js
├── package.json # npm dependency nightmare
└── README.md # Nobody reads this
2. Fight with manifest.json (The Fun Part)
The manifest.json uses spec version 0.2, which means half the examples online are wrong. This file will break in creative ways:
{
"manifest_version": "0.2",
"name": "file-browser",
"display_name": "Safe File Browser",
"version": "1.0.0",
"description": "Browse and read local files safely with directory restrictions",
"author": {
"name": "Your Name",
"email": "you@example.com"
},
"server": {
"type": "node",
"entry_point": "server/index.js",
"mcp_config": {
"command": "node",
"args": ["${__dirname}/server/index.js"],
"env": {
"ALLOWED_PATHS": "${user_config.allowed_paths}"
}
}
},
"tools": [
{
"name": "list_directory",
"description": "List files and directories in a path"
},
{
"name": "read_file",
"description": "Read the contents of a text file"
}
],
"user_config": {
"allowed_paths": {
"type": "directory",
"title": "Allowed Directories",
"description": "Directories this extension can access",
"multiple": true,
"required": true,
"default": ["${HOME}/Desktop", "${HOME}/Documents"]
}
},
"compatibility": {
"claude_desktop": ">=1.0.0",
"platforms": ["darwin", "win32", "linux"],
"runtimes": {
"node": ">=16.0.0"
}
}
}
3. Implement the MCP Server

The server/index.js implements the MCP protocol. This is where your extension does the actual work:
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import fs from 'fs/promises';
import path from 'path';
// Get allowed paths from environment (set by user config)
const allowedPaths = process.env.ALLOWED_PATHS?.split(path.delimiter) || [];
class FileExplorerServer {
constructor() {
this.server = new Server({
name: "file-browser",
version: "1.0.0"
}, {
capabilities: {
tools: {}
}
});
this.setupToolHandlers();
}
isPathAllowed(targetPath) {
const resolved = path.resolve(targetPath);
return allowedPaths.some(allowedPath => {
const allowedResolved = path.resolve(allowedPath);
return resolved.startsWith(allowedResolved);
});
}
setupToolHandlers() {
this.server.setRequestHandler('tools/list', async () => {
return {
tools: [
{
name: "list_directory",
description: "List files and directories in a path",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Directory path to list"
}
},
required: ["path"]
}
},
{
name: "read_file",
description: "Read contents of a text file",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "File path to read"
}
},
required: ["path"]
}
}
]
};
});
this.server.setRequestHandler('tools/call', async (request) => {
const { name, arguments: args } = request.params;
if (!this.isPathAllowed(args.path)) {
return {
content: [
{
type: "text",
text: `Access denied: ${args.path} is not in allowed directories`
}
]
};
}
switch (name) {
case "list_directory":
try {
const entries = await fs.readdir(args.path, { withFileTypes: true });
const listing = entries.map(entry => ({
name: entry.name,
type: entry.isDirectory() ? 'directory' : 'file',
path: path.join(args.path, entry.name)
}));
return {
content: [
{
type: "text",
text: `Directory listing for ${args.path}:
${JSON.stringify(listing, null, 2)}`
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error reading directory: ${error.message}`
}
]
};
}
case "read_file":
try {
const content = await fs.readFile(args.path, 'utf8');
return {
content: [
{
type: "text",
text: `Contents of ${args.path}:
${content}`
}
]
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error reading file: ${error.message}`
}
]
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
});
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
}
}
const server = new FileExplorerServer();
server.run().catch(console.error);
Packaging and Distribution
4. Package the Extension

mcpb pack .
This creates file-browser-1.0.0.mcpb
with proper ZIP structure and validates the manifest. The CLI handles file permissions and cross-platform compatibility.
5. Local Testing
Install by dragging file-browser-1.0.0.mcpb
into Claude Desktop. No CLI command needed - just drag and drop the file.
Claude Desktop will prompt for directory permissions and store choices in the system keychain. Test by asking Claude to list files in your Desktop directory.
Shit That Will Break (And How I Learned)
mcpb pack
fails with "Invalid manifest.json" - You probably have a trailing comma in your JSON. I always forget JSON isn't JavaScript. Spent way too long on this exact issue.
Extension installs but tools don't show up - Your manifest `tools` array doesn't match what your server actually implements. Claude Desktop just silently ignores the mismatch with no error message. Super helpful.
Works on Mac, crashes on Windows - You hardcoded forward slashes in file paths. Windows uses backslashes and will break your stuff. Always use `path.join()` or Windows users will be pissed.
Cannot resolve module
errors - The import paths are specific: @modelcontextprotocol/sdk/server/index.js
, not the shortened version. Yeah, it's verbose.
User config variables are undefined - ${user_config.allowed_paths}
comes back as a string, not an array. You have to split it yourself. The docs are unclear about this.
"Extension failed to start" with no useful error - Debugging nightmare. I ended up adding `console.error()` statements everywhere. They show up in Claude's dev console if you hit Cmd+Shift+I.
Works locally, breaks when packaged - Usually means you forgot `npm install --production` or some dependency is doing weird dynamic imports that break when bundled.
Permission denied on Linux - mcpb pack
doesn't preserve execute permissions. Had to run `chmod +x server/index.js` before packaging to fix it.
Always test with `node server/index.js` first. If your MCP server doesn't work standalone, the extension is fucked too.
6. Publishing Options
- GitHub Releases: Upload .mcpb files as release artifacts
- Direct Distribution: Share .mcpb files like any other installer
- Enterprise Distribution: Deploy through existing software distribution systems
- Community: Share on forums and developer communities
Advanced Manifest Features

The manifest.json supports sophisticated configuration options:
User Configuration Types:
"user_config": {
"api_key": {
"type": "string",
"title": "API Key",
"sensitive": true,
"required": true
},
"max_results": {
"type": "number",
"title": "Maximum Results",
"min": 1,
"max": 100,
"default": 10
},
"output_directory": {
"type": "directory",
"title": "Output Directory",
"required": true
},
"enable_logging": {
"type": "boolean",
"title": "Enable Debug Logging",
"default": false
}
}
Compatibility Constraints:
"compatibility": {
"claude_desktop": ">=1.2.0",
"platforms": ["darwin", "win32"],
"runtimes": {
"node": ">=18.0.0"
}
}
This development workflow transforms complex MCP server setup into standard software development. The key is remembering that debugging is 90% of the work - plan accordingly.