skills/mcp-server-design/SKILL.md
Use when designing, building, or debugging a Model Context Protocol (MCP) server in Node/TypeScript. Triggers: stdio JSON-RPC handshake, tool descriptions as discovery surface, lazy startup vs eager catalog loading, telemetry placement, schema design for tool inputs (zod), tool naming conventions for discoverability, error handling that does not leak stack traces, MCP client compatibility (Claude Desktop, Claude Code, Cursor), local resource fetching, secrets and env var handling, distributing as npm + claude mcp add. NOT for MCP client implementation, MCP HTTP transport (different surface), Anthropic Agent SDK building, or non-MCP plugin systems.
npx skillsauth add curiositech/windags-skills mcp-server-designInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
The Model Context Protocol is a JSON-RPC 2.0 dialect over stdio (most commonly). The protocol is small; the design space is in what tools you expose, how you describe them, and how the server starts up. The descriptions are the discovery surface — a tool the client doesn't understand from its description never gets called.
claude mcp add foo -- npx -y foo-mcp).client → server initialize { protocolVersion, capabilities, clientInfo }
server → client result { protocolVersion, capabilities, serverInfo }
client → server notifications/initialized (no response)
client → server tools/list
server → client result { tools: [...] }
client → server tools/call { name, arguments }
server → client result { content: [...], isError? }
The high-level SDK (@modelcontextprotocol/sdk) handles framing. You declare tools; the SDK serializes the schema and dispatches calls.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const server = new McpServer({ name: 'mything', version: '1.0.0' });
server.tool(
'mything_search',
'Search the catalog. Returns ranked candidates with descriptions only ' +
'(no full bodies). Pair with mything_load to fetch the chosen item. ' +
'Local-only, no API keys.',
{
query: z.string().describe('Natural-language search query'),
limit: z.number().int().min(1).max(50).optional().default(10).describe('Max results (default 10)'),
},
async ({ query, limit }) => {
const results = await search(query, limit ?? 10);
return { content: [{ type: 'text', text: JSON.stringify({ query, results }, null, 2) }] };
},
);
await server.connect(new StdioServerTransport());
The model picks tools based on the description. Two rules:
A discoverable description includes:
Servers should start fast. The MCP client times out the handshake; long-loading models or large indices belong behind the first tool call.
let _catalog: Promise<Catalog> | null = null;
function ensureCatalog() {
if (!_catalog) _catalog = loadCatalog();
return _catalog;
}
server.tool('mything_search', '...', schema, async (args) => {
const cat = await ensureCatalog();
// ... use cat ...
});
Print a status line to stderr at startup so logs in the client are interpretable:
console.error(`[mything] ${count} items, telemetry=${mode}, version=${VERSION}`);
stdout is reserved for JSON-RPC frames. Anything you accidentally log there breaks the client.
const SearchInput = {
query: z.string().min(1).max(500).describe('Natural-language query'),
limit: z.number().int().min(1).max(50).optional().default(10),
category: z.enum(['frontend', 'backend', 'devops']).optional(),
};
Constraints (min, max, enum) become tool-input metadata the client surfaces. The model uses them to decide which arguments to fill.
return {
content: [
{ type: 'text', text: 'Markdown body here' },
{ type: 'text', text: 'Structured JSON:\n```json\n' + JSON.stringify(data) + '\n```' },
],
};
// On error:
return {
content: [{ type: 'text', text: JSON.stringify({ error: msg }, null, 2) }],
isError: true,
};
JSON inside a text block is a workable convention; the model can parse it. Avoid throwing — return isError: true instead so the client gets a typed error.
Servers run in the user's environment. process.env works, but:
const apiKey = process.env.MY_API_KEY;
if (!apiKey) {
console.error('MY_API_KEY required. Set in your shell or in claude mcp config.');
process.exit(2);
}
If a tool reads a file by path:
const root = path.resolve(skillsDir, skillId);
const requested = path.resolve(root, filePath.replace(/^\//, ''));
if (!requested.startsWith(root + path.sep)) {
return { content: [{ type: 'text', text: 'invalid path' }], isError: true };
}
This is the #1 vulnerability in file-reading tools. Test with ../../../../etc/passwd.
If you record analytics, do it fire-and-forget at the top of each tool handler:
server.tool('mything_search', '...', schema, async (args) => {
recordEvent({ tool: 'mything_search', taskText: args.query });
// ... work ...
});
Never block on telemetry. Wrap in a 1.5s AbortController. Default to anonymous. Document WINDAGS_TELEMETRY=off (or your equivalent) prominently.
Three install patterns:
# 1. npm global
npm install -g mything-mcp
claude mcp add mything -- mything-mcp
# 2. npx (no install)
claude mcp add mything -- npx -y mything-mcp@latest
# 3. From a plugin marketplace
claude plugin marketplace add yourorg/your-skills
claude plugin install your-skills
For (2), include "bin": { "mything-mcp": "./index.js" } in package.json with a shebang on index.js. For (3), the plugin manifest declares the MCP server alongside skills/commands.
// scripts/handshake-test.mjs
import { spawn } from 'node:child_process';
const proc = spawn('node', ['./index.js']);
function send(msg) { proc.stdin.write(JSON.stringify(msg) + '\n'); }
send({ jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: { tools: {} }, clientInfo: { name: 't', version: '1' } }});
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
proc.stdout.on('data', (b) => {
for (const line of b.toString().split('\n')) {
if (!line.trim()) continue;
const msg = JSON.parse(line);
if (msg.id === 2) {
console.log('tools:', msg.result.tools.map((t) => t.name).join(','));
process.exit(0);
}
}
});
setTimeout(() => { console.error('timeout'); process.exit(1); }, 30_000);
Run this in CI on every commit. It catches startup regressions before they reach users.
Symptom: Client says "invalid JSON-RPC frame" or "unexpected character".
Diagnosis: Anything written to stdout after connect corrupts the frame stream.
Fix: All non-protocol output → stderr. Audit console.log and any logger that defaults to stdout.
Symptom: Client times out the handshake; tool list never appears. Diagnosis: Loading a 100MB index synchronously at boot. Fix: Lazy-load behind first tool call. Print a one-liner status to stderr; do real work later.
Symptom: Model doesn't invoke the tool even when the query matches its purpose. Diagnosis: Description starts with "This tool will..." instead of the capability. Fix: Lead with the verb. "Find skills relevant to a task." Then implementation details.
Symptom: Reading a file by skill_id + file_path lets the user escape the skill dir.
Diagnosis: No startsWith check after path resolution.
Fix: path.resolve both, then startsWith(root + path.sep). Test with ../../../etc/passwd.
Symptom: A failed call returns "invalid auth: Bearer sk_live_..." to the client.
Diagnosis: Error formatting includes the auth header verbatim.
Fix: Sanitize errors at the tool boundary. Return generic messages; log the detailed error to stderr.
Symptom: Model wastes turns trying to figure out which arguments to pass. Diagnosis: One tool with 12 optional args covering 3 unrelated capabilities. Fix: Split into 3 tools with focused schemas. Cross-reference each in their descriptions.
connect.../../../etc/passwd) returns an error.min/max/enum constraints.isError: true, not thrown exceptions.data-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.