Skip to content

mcp-patterns

Core MCP SDK patterns for TypeScript/Node.js server implementation, tool registration, and resource handling

From plugin
vexjoy-agent
413198 skills198 agents10 commands86 hooks
Install
$ npx -y skills add notque/vexjoy-agent --agent claude-code

How it fires

How this agent gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.

Context preview

The summary Claude sees to decide when to auto-load this agent.

Core MCP SDK patterns for TypeScript/Node.js server implementation, tool registration, and resource handling

Agent definition

mcp-patterns.md
description: Core MCP SDK patterns for TypeScript/Node.js server implementation, tool registration, and resource handling

MCP Server Development Patterns

> **Scope**: TypeScript/Node.js MCP via `@modelcontextprotocol/sdk` 0.5.0+.

Pattern Table

| Pattern | Version | Use When | Avoid When | |---------|---------|----------|------------| | `StdioServerTransport` | 0.5.0+ | Claude Desktop integration | HTTP API needed | | `SSEServerTransport` | 0.5.0+ | Web-based clients, multiple connections | Single-process use | | `server.setRequestHandler()` | 0.5.0+ | All request handling | Direct protocol manipulation | | `ListResourcesRequestSchema` | 0.5.0+ | Exposing documents/files | Structured data with tools | | `CallToolRequestSchema` | 0.5.0+ | Search, filtering, actions | Read-only resource access |

---

Correct Patterns

Tool Registration with Input Schema

Explicit JSON Schema for client-side validation and LLM parameter guidance.

import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';

const SearchSchema = z.object({
  query: z.string().min(1).describe('Search terms'),
  limit: z.number().int().min(1).max(100).default(10).describe('Max results'),
  tags: z.array(z.string()).optional().describe('Filter by tags'),
});

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name !== 'search_docs') {
    throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
  }

  // Validate with Zod before processing
  const args = SearchSchema.parse(request.params.arguments ?? {});
  const results = await searchIndex(args.query, args.limit, args.tags);

  return {
    content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
  };
});

**Why**: Without schema, malformed arguments cause cryptic errors instead of `InvalidParams`.

---

Resource URI Design with Custom Scheme

`docs://` scheme — never expose filesystem paths.

function pathToUri(docsRoot: string, filePath: string): string {
  const relative = path.relative(docsRoot, filePath);
  // docs://guides/api-reference.md — portable, no host path leakage
  return `docs://${relative.replace(path.sep, '/')}`;
}

function uriToPath(docsRoot: string, uri: string): string {
  if (!uri.startsWith('docs://')) {
    throw new McpError(ErrorCode.InvalidParams, `Invalid URI scheme: ${uri}`);
  }
  const relative = uri.slice('docs://'.length);
  // Prevent path traversal
  const resolved = path.resolve(docsRoot, relative);
  if (!resolved.startsWith(docsRoot)) {
    throw new McpError(ErrorCode.InvalidParams, 'Path traversal not allowed');
  }
  return resolved;
}

**Why**: `file:///` exposes filesystem layout. `docs://` is portable.

---

Startup Indexing with Background Continuation

Serve partial results for large corpora while indexing continues.

class DocsServer {
  private indexReady = false;
  private indexingPromise: Promise<void> | null = null;

  async start(): Promise<void> {
    // Begin indexing, but don't await completion — serve partial results
    this.indexingPromise = this.indexDocs();

    // Wait up to 10s for initial batch, then proceed
    await Promise.race([
      this.indexingPromise,
      new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
    ]);
    this.indexReady = true;

    const transport = new StdioServerTransport();
    await this.server.connect(transport);
  }

  private async indexDocs(): Promise<void> {
    const files = await glob('**/*.md', { cwd: this.docsPath });
    // Process in batches of 50 to avoid memory spikes
    for (let i = 0; i < files.length; i += 50) {
      const batch = files.slice(i, i + 50);
      await Promise.all(batch.map((f) => this.parseAndCache(f)));
    }
  }
}

**Why**: Full indexing before connect causes client timeouts. Batch processing prevents heap exhaustion.

---

Pattern Catalog

Use Async File I/O in Request Handlers

**Detection**:

grep -rn 'readFileSync\|existsSync\|readdirSync' --include="*.ts" src/
rg 'Sync\(' --type ts src/

**Signal**:

server.setRequestHandler(ReadResourceRequestSchema, (request) => {
  // NOT async — blocks the entire Node.js event loop
  const content = fs.readFileSync(uriToPath(request.params.uri), 'utf-8');
  return { contents: [{ uri: request.params.uri, text: content }] };
});

**Why this matters**: `readFileSync` blocks the event loop. While one client is reading a large file, all other clients are frozen. Under concurrent load this causes cascading timeouts — the MCP client kills the connection thinking the server hung.

**Preferred action**:

server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const filePath = uriToPath(this.docsRoot, request.params.uri);
  const content = await fs.promises.readFile(filePath, 'utf-8');
  return { contents: [{ uri: request.params.uri, text: content }] };
});

---

Index Once at Startup and Serve from Cache

**Detection**:

grep -rn 'indexDocs\|parseDoc\|WalkDir' --include="*.ts" src/
# Flag if found inside a setRequestHandler callback

**Signal**:

server.setRequestHandler(ListResourcesRequestSchema, async () => {
  // Re-parses all markdown files on every list call — seconds of delay
  const docs = await this.indexDocs();
  return { resources: docs.map((d) => ({ uri: d.uri, name: d.metadata.title })) };
});

**Why this matters**: Claude calls `resources/list` repeatedly during a session. Re-parsing 1000 files takes 3-10 seconds each time. The LLM context window fills with timeout errors before any content arrives.

**Preferred action**: Index once at startup, serve from in-memory Map, use mtime-based cache invalidation:

private docsIndex = new Map<string, ParsedDoc>();

// Called once at startup
async indexDocs(): Promise<void> {
  const files = await glob('**/*.md', { cwd: this.docsPath });
Read more
Ships withvexjoy-agent

Essays and writing behind this toolkit live at vexjoy.com. AI agents skip steps. "Looks correct" replaces running tests. "Trivial change" replaces verification.

Get the whole plugin, auto-invoked