When to Build an MCP Server
Most Claude Code plugins are instruction plugins — Markdown files that teach Claude how to perform tasks. MCP server plugins are fundamentally different. They provide runtime tools that Claude can call as functions during a session.
Build an MCP server when:
- You need to access external APIs with authentication at runtime
- You need persistent state across tool invocations (in-memory storage, database connections)
- You need to perform operations that Markdown instructions cannot express (binary data processing, cryptographic operations, real-time data feeds)
- You want to expose a reusable tool interface that multiple skills and agents can call
Use an instruction plugin instead when:
- The task can be accomplished by reading/writing files and running shell commands
- The knowledge is primarily “how to do X” rather than “execute X”
- You do not need runtime state or external API connections
MCP server plugins make up roughly 2% of the Tons of Skills ecosystem. They require TypeScript development, a build step, and more careful testing than instruction plugins.
Step 1: Project Setup
Directory Structure
MCP server plugins live in plugins/mcp/ and follow this structure:
plugins/mcp/my-mcp-tool/
├── .claude-plugin/
│ └── plugin.json
├── .mcp.json # MCP server configuration
├── src/
│ ├── index.ts # Entry point and tool definitions
│ ├── types.ts # TypeScript type definitions
│ └── storage.ts # Optional: persistent storage
├── dist/
│ └── index.js # Built output (executable)
├── package.json
├── tsconfig.json
├── README.md
└── LICENSE
Initialize the Project
mkdir -p plugins/mcp/my-mcp-tool/.claude-plugin
mkdir -p plugins/mcp/my-mcp-tool/src
cd plugins/mcp/my-mcp-tool
# Initialize package.json
cat > package.json << 'EOF'
{
"name": "@plugins/my-mcp-tool",
"version": "1.0.0",
"description": "An MCP server providing custom tools for Claude Code",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc && chmod +x dist/index.js",
"dev": "tsc --watch"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0"
},
"devDependencies": {
"typescript": "^5.5.0",
"@types/node": "^22.0.0"
}
}
EOF
# Install dependencies
pnpm install
TypeScript Configuration
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Plugin Manifest
Create .claude-plugin/plugin.json:
{
"name": "my-mcp-tool",
"version": "1.0.0",
"description": "Custom MCP server providing specialized tools for data analysis and transformation",
"author": "Your Name <you@example.com>",
"repository": "https://github.com/username/my-mcp-tool",
"license": "MIT",
"keywords": ["mcp", "tools", "data-analysis"]
}
Step 2: Implement MCP Tools
The Entry Point
Create src/index.ts. This is the main file that defines your MCP server and its tools:
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ErrorCode,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
// Create the MCP server
const server = new Server(
{
name: 'my-mcp-tool',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
// Define available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'analyze_json',
description:
'Analyze a JSON object and return statistics about its structure: key count, depth, types, and size',
inputSchema: {
type: 'object' as const,
properties: {
json_string: {
type: 'string',
description: 'The JSON string to analyze',
},
},
required: ['json_string'],
},
},
{
name: 'transform_csv',
description:
'Transform CSV data: filter rows, select columns, sort, and compute aggregates',
inputSchema: {
type: 'object' as const,
properties: {
csv_data: {
type: 'string',
description: 'Raw CSV data as a string',
},
columns: {
type: 'array',
items: { type: 'string' },
description: 'Columns to include in output (empty = all)',
},
sort_by: {
type: 'string',
description: 'Column name to sort by',
},
filter: {
type: 'string',
description:
'Filter expression (e.g., "age > 30")',
},
},
required: ['csv_data'],
},
},
],
};
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case 'analyze_json': {
try {
const data = JSON.parse(args?.json_string as string);
const analysis = analyzeJsonStructure(data);
return {
content: [
{
type: 'text',
text: JSON.stringify(analysis, null, 2),
},
],
};
} catch (error) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid JSON: ${(error as Error).message}`
);
}
}
case 'transform_csv': {
try {
const result = transformCsv(
args?.csv_data as string,
args?.columns as string[] | undefined,
args?.sort_by as string | undefined,
args?.filter as string | undefined
);
return {
content: [
{
type: 'text',
text: result,
},
],
};
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Transform failed: ${(error as Error).message}`
);
}
}
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${name}`
);
}
});
// Tool implementation functions
function analyzeJsonStructure(data: unknown, depth = 0): Record<string, unknown> {
const type = Array.isArray(data) ? 'array' : typeof data;
const result: Record<string, unknown> = {
type,
depth,
};
if (type === 'object' && data !== null) {
const keys = Object.keys(data as Record<string, unknown>);
result.keyCount = keys.length;
result.keys = keys;
result.children = {};
for (const key of keys) {
(result.children as Record<string, unknown>)[key] = analyzeJsonStructure(
(data as Record<string, unknown>)[key],
depth + 1
);
}
} else if (type === 'array') {
const arr = data as unknown[];
result.length = arr.length;
if (arr.length > 0) {
result.firstElementType = typeof arr[0];
}
} else if (type === 'string') {
result.length = (data as string).length;
}
return result;
}
function transformCsv(
csvData: string,
columns?: string[],
sortBy?: string,
filter?: string
): string {
const lines = csvData.trim().split('\n');
if (lines.length === 0) return '';
const headers = lines[0].split(',').map((h) => h.trim());
let rows = lines.slice(1).map((line) =>
line.split(',').map((cell) => cell.trim())
);
// Apply filter (simple column > value comparison)
if (filter) {
const match = filter.match(/(\w+)\s*(>|<|=|>=|<=|!=)\s*(.+)/);
if (match) {
const [, col, op, val] = match;
const colIdx = headers.indexOf(col);
if (colIdx >= 0) {
rows = rows.filter((row) => {
const cellVal = parseFloat(row[colIdx]) || row[colIdx];
const filterVal = parseFloat(val) || val;
switch (op) {
case '>': return cellVal > filterVal;
case '<': return cellVal < filterVal;
case '=': return cellVal === filterVal;
case '>=': return cellVal >= filterVal;
case '<=': return cellVal <= filterVal;
case '!=': return cellVal !== filterVal;
default: return true;
}
});
}
}
}
// Apply sort
if (sortBy) {
const sortIdx = headers.indexOf(sortBy);
if (sortIdx >= 0) {
rows.sort((a, b) => {
const aVal = parseFloat(a[sortIdx]) || a[sortIdx];
const bVal = parseFloat(b[sortIdx]) || b[sortIdx];
if (aVal < bVal) return -1;
if (aVal > bVal) return 1;
return 0;
});
}
}
// Select columns
let selectedHeaders = headers;
let selectedRows = rows;
if (columns && columns.length > 0) {
const indices = columns
.map((c) => headers.indexOf(c))
.filter((i) => i >= 0);
selectedHeaders = indices.map((i) => headers[i]);
selectedRows = rows.map((row) => indices.map((i) => row[i]));
}
// Rebuild CSV
const output = [selectedHeaders.join(',')];
for (const row of selectedRows) {
output.push(row.join(','));
}
return output.join('\n');
}
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('MCP server running on stdio');
}
main().catch(console.error);
Type Definitions
Create src/types.ts for shared types:
export interface AnalysisResult {
type: string;
depth: number;
keyCount?: number;
keys?: string[];
length?: number;
children?: Record<string, AnalysisResult>;
}
export interface TransformOptions {
columns?: string[];
sortBy?: string;
filter?: string;
}
Step 3: Build and Make Executable
MCP servers must be executable files. The build process compiles TypeScript to JavaScript and sets the executable bit.
Build Script
The shebang line (#!/usr/bin/env node) at the top of src/index.ts tells the OS to run the file with Node.js. After compilation, make the output executable:
cd plugins/mcp/my-mcp-tool
# Build
pnpm build
# Verify the shebang is present in dist/index.js
head -1 dist/index.js
# Should output: #!/usr/bin/env node
# Verify it is executable
ls -la dist/index.js
# Should show -rwxr-xr-x permissions
If the shebang is stripped during compilation, add it manually to your build script in package.json:
{
"scripts": {
"build": "tsc && echo '#!/usr/bin/env node' | cat - dist/index.js > dist/tmp.js && mv dist/tmp.js dist/index.js && chmod +x dist/index.js"
}
}
Verify the Build
# Test that the server starts without errors
echo '{}' | node dist/index.js 2>&1 | head -5
# The server should start and wait for stdin input
# Press Ctrl+C to stop
Step 4: Create the MCP Configuration
The .mcp.json file tells Claude Code how to start your MCP server. Create it in the plugin root:
{
"mcpServers": {
"my-mcp-tool": {
"command": "node",
"args": ["dist/index.js"],
"cwd": "."
}
}
}
Configuration Fields
| Field | Purpose | Example |
|---|---|---|
command | The executable to run | "node" |
args | Arguments passed to the command | ["dist/index.js"] |
cwd | Working directory (relative to plugin root) | "." |
env | Environment variables | { "API_KEY": "..." } |
Environment Variables
If your MCP server needs API keys or configuration, reference environment variables:
{
"mcpServers": {
"my-mcp-tool": {
"command": "node",
"args": ["dist/index.js"],
"env": {
"API_BASE_URL": "${MY_API_URL}",
"API_KEY": "${MY_API_KEY}"
}
}
}
}
Users set these variables in their shell environment before starting Claude Code.
Step 5: Add Optional Components
Instruction Files
MCP plugins can also include commands, skills, and agents alongside the server. A skill might tell Claude when and how to use your MCP tools:
plugins/mcp/my-mcp-tool/
├── .mcp.json
├── src/
├── dist/
├── commands/
│ └── analyze-data.md # Command that uses MCP tools
└── skills/
└── data-analysis/
└── SKILL.md # Skill that auto-activates for data tasks
The skill can reference MCP tools in its instructions:
## Instructions
1. When the user provides data for analysis, use the `analyze_json`
MCP tool for JSON data and the `transform_csv` MCP tool for CSV data
2. Present the results in a readable format
Persistent Storage
If your MCP server needs to store state between invocations, use ${CLAUDE_PLUGIN_DATA} for the storage path. This directory survives plugin updates and reinstalls.
Create src/storage.ts:
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
export class Storage {
private dataDir: string;
constructor() {
this.dataDir = process.env.CLAUDE_PLUGIN_DATA || './data';
if (!existsSync(this.dataDir)) {
mkdirSync(this.dataDir, { recursive: true });
}
}
get(key: string): unknown | null {
const filePath = join(this.dataDir, `${key}.json`);
if (!existsSync(filePath)) return null;
return JSON.parse(readFileSync(filePath, 'utf-8'));
}
set(key: string, value: unknown): void {
const filePath = join(this.dataDir, `${key}.json`);
writeFileSync(filePath, JSON.stringify(value, null, 2));
}
}
Step 6: Testing
Unit Testing
Add Vitest for unit tests:
pnpm add -D vitest
Create a test file:
// src/index.test.ts
import { describe, it, expect } from 'vitest';
describe('analyzeJsonStructure', () => {
it('analyzes a flat object', () => {
const data = { name: 'test', age: 30 };
const result = analyzeJsonStructure(data);
expect(result.type).toBe('object');
expect(result.keyCount).toBe(2);
expect(result.keys).toContain('name');
});
it('analyzes nested objects', () => {
const data = { user: { name: 'test', address: { city: 'NYC' } } };
const result = analyzeJsonStructure(data);
expect(result.depth).toBe(0);
});
});
describe('transformCsv', () => {
const csv = 'name,age,city\nAlice,30,NYC\nBob,25,LA\nCarol,35,Chicago';
it('selects specific columns', () => {
const result = transformCsv(csv, ['name', 'city']);
expect(result).toContain('name,city');
expect(result).not.toContain('age');
});
it('filters rows', () => {
const result = transformCsv(csv, undefined, undefined, 'age > 28');
expect(result).toContain('Alice');
expect(result).toContain('Carol');
expect(result).not.toContain('Bob');
});
it('sorts by column', () => {
const result = transformCsv(csv, undefined, 'age');
const lines = result.split('\n');
expect(lines[1]).toContain('Bob'); // age 25
expect(lines[3]).toContain('Carol'); // age 35
});
});
Integration Testing
Test the full MCP server by sending JSON-RPC messages via stdin:
# List available tools
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/index.js 2>/dev/null
# Call a tool
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"analyze_json","arguments":{"json_string":"{\"key\":\"value\"}"}}}' | node dist/index.js 2>/dev/null
Validation
Run the plugin validator:
python3 scripts/validate-skills-schema.py --enterprise --verbose \
plugins/mcp/my-mcp-tool/
Common Patterns
Error Handling
Always use McpError with appropriate error codes:
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
// Invalid input from the user
throw new McpError(ErrorCode.InvalidParams, 'Missing required field: name');
// Tool not found
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
// Internal server error
throw new McpError(ErrorCode.InternalError, `Database connection failed`);
Rate Limiting
If your MCP server calls external APIs, implement rate limiting:
class RateLimiter {
private timestamps: number[] = [];
private maxRequests: number;
private windowMs: number;
constructor(maxRequests: number, windowMs: number) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
}
async acquire(): Promise<void> {
const now = Date.now();
this.timestamps = this.timestamps.filter(
(t) => now - t < this.windowMs
);
if (this.timestamps.length >= this.maxRequests) {
const waitTime = this.timestamps[0] + this.windowMs - now;
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
this.timestamps.push(Date.now());
}
}
Logging
Write diagnostic logs to stderr (not stdout, which is the MCP transport):
console.error('[my-mcp-tool] Starting server...');
console.error(`[my-mcp-tool] Tool called: ${name}`);
Checklist Before Publishing
Before submitting your MCP server plugin, verify:
-
dist/index.jshas the#!/usr/bin/env nodeshebang on line 1 -
dist/index.jshas executable permissions (chmod +x) -
.mcp.jsonis present with valid server configuration -
plugin.jsoncontains only allowed fields - All tools have descriptive names and complete input schemas
- Error cases return
McpErrorwith appropriate codes - Unit tests pass (
pnpm test) - Integration test via stdin/stdout works
-
README.mddocuments all available tools - No secrets or API keys in the source code
- Enterprise validation passes at B grade or higher
Next Steps
- Build a complete plugin with commands, skills, and your MCP server
- Publish to the marketplace
- Explore existing MCP plugins on the marketplace for patterns and inspiration