MCP Server Development
mcp-server-development
Expert guidance for building MCP (Model Context Protocol) servers using the TypeScript SDK. Use when developing MCP servers, implementing tools/resources/prompts, or working with the @modelcontextprotocol/sdk package. Covers server initialization, request handlers, Zod schemas, error handling, an...
SKILL.md
Full skill instructions
This skill provides comprehensive guidance for building robust MCP servers, with specific focus on the unity-mcp-server architecture and patterns.
Core Philosophy
MCP servers bridge AI assistants to external systems. They must be:
- Reliable: Handle errors gracefully, never crash unexpectedly
- Discoverable: Tools should have clear, self-documenting schemas
- Performant: Minimize latency, especially for stdio transport
- Protocol-compliant: Follow JSON-RPC 2.0 and MCP spec exactly
Architecture Patterns
Handler-Based Design
ALWAYS use a handler class per tool. Each handler encapsulates:
- Input validation (Zod schema)
- Business logic execution
- Error handling and response formatting
// Pattern: One handler per tool
export class SystemPingToolHandler extends BaseToolHandler {
constructor(unityConnection) {
super({
name: 'system_ping',
description: 'Check Unity Editor connectivity',
inputSchema: { type: 'object', properties: {} }
});
this.unityConnection = unityConnection;
}
async execute(params) {
const result = await this.unityConnection.send({ command: 'ping' });
return { status: 'ok', ...result };
}
}
BaseToolHandler Contract
All handlers MUST:
- Call
super()with tool metadata - Implement
execute(params)method - Return plain objects (framework handles JSON-RPC wrapping)
- Throw errors with descriptive messages
Input Validation with Zod
ALWAYS validate inputs before processing:
import { z } from 'zod';
const inputSchema = z.object({
name: z.string().min(1).describe('GameObject name'),
primitiveType: z.enum(['cube', 'sphere', 'cylinder']).optional()
});
validate(input) {
return inputSchema.parse(input);
}
Transport Layer
Content-Length Framing (Standard)
MCP uses LSP-style Content-Length framing for stdio:
Content-Length: 123\r\n
\r\n
{"jsonrpc":"2.0","id":1,"method":"tools/call"...}
ALWAYS use Content-Length for output. NEVER mix framing formats in a session.
Hybrid Input (Compatibility)
Accept both Content-Length and NDJSON input for client compatibility:
- Claude Desktop: Content-Length
- Some CLI tools: NDJSON (newline-delimited)
Error Handling
Structured Errors
Use MCP error codes from the spec:
const McpError = {
ParseError: -32700,
InvalidRequest: -32600,
MethodNotFound: -32601,
InvalidParams: -32602,
InternalError: -32603
};
throw new Error(JSON.stringify({
code: McpError.InvalidParams,
message: 'primitiveType must be one of: cube, sphere, cylinder'
}));
Error Response Format
{
jsonrpc: '2.0',
id: requestId,
error: {
code: -32602,
message: 'Invalid params',
data: { field: 'name', issue: 'required' }
}
}
Unity-Specific Patterns
Command Protocol
Unity communication uses a simple request/response pattern:
const result = await this.unityConnection.send({
command: 'gameobject_create',
params: { name: 'Cube', primitiveType: 'cube' }
});
Workspace Root Resolution
ALWAYS include workspaceRoot in commands requiring file paths:
execute(params) {
return this.unityConnection.send({
command: 'screenshot_capture',
workspaceRoot: config.workspaceRoot,
...params
});
}
Testing Patterns
TDD for Handlers
- Contract test first: Define expected input/output schema
- Mock Unity connection: Isolate handler logic
- Test error paths: Invalid input, connection failures
- Test happy path last: After contracts are verified
describe('CreateGameObjectHandler', () => {
it('validates primitiveType enum', async () => {
const handler = new CreateGameObjectHandler(mockConnection);
await assert.rejects(
() => handler.handle({ name: 'Cube', primitiveType: 'invalid' }),
/primitiveType must be one of/
);
});
});
Integration Testing
Test full request/response cycle including JSON-RPC framing:
const stdin = new PassThrough();
const stdout = new PassThrough();
const transport = new HybridStdioServerTransport(stdin, stdout);
stdin.write('Content-Length: 50\r\n\r\n{"jsonrpc":"2.0","id":1,"method":"ping"}');
// Assert stdout contains Content-Length response
Common Mistakes
Mixing framing formats:
- NEVER: Output NDJSON after receiving Content-Length input
- ALWAYS: Output Content-Length regardless of input format
Swallowing errors:
- NEVER:
catch (e) { return null; } - ALWAYS: Propagate errors with context
Missing validation:
- NEVER: Trust raw input from clients
- ALWAYS: Validate with Zod before processing
Blocking stdio:
- NEVER: Synchronous operations on transport
- ALWAYS: Use async/await throughout
Handler Registration
Register all handlers in a central index:
// src/handlers/index.js
export function createHandlers(unityConnection) {
return [
new SystemPingToolHandler(unityConnection),
new CreateGameObjectHandler(unityConnection),
new ScreenshotHandler(unityConnection),
// ...
];
}
Remember
- Content-Length always: Output framing must be consistent
- Validate everything: Never trust client input
- One handler, one tool: Keep handlers focused
- Test error paths: Most bugs hide in error handling
- Protocol compliance: Follow JSON-RPC 2.0 exactly
