Skip to content

Build a Secure MCP Server

Build intermediate 45 min typescript
Sources verified Dec 23

Create a Model Context Protocol server that exposes safe, read-only tools to AI assistants. Learn MCP architecture and identify security vulnerabilities.

1. Understand the Scenario

Your team wants to connect Claude Code to an internal knowledge base. You'll build an MCP server that provides read-only access to documentation, then audit it for prompt injection vulnerabilities.

Learning Objectives

  • Understand MCP server architecture (transports, tools, resources)
  • Implement tool schemas with proper validation
  • Identify prompt injection attack vectors in MCP
  • Apply 'Least Agency' security principles

2. Follow the Instructions

What is MCP?

Model Context Protocol (MCP) is an open standard for connecting AI assistants to external data sources and tools. It provides:

  • Tools — Functions the AI can call (search, create, read)
  • Resources — Data the AI can access (files, databases, APIs)
  • Prompts — Templates for common operations

MCP servers run as separate processes that AI assistants connect to via stdin/stdout or HTTP.

MCP Architecture

┌────────────────────┐     stdio/HTTP     ┌─────────────────┐
│   Claude Code      │ ◄────────────────► │   MCP Server    │
│   Cursor           │                    │   (Your Code)   │
│   Any MCP Client   │                    │                 │
└────────────────────┘                    └────────┬────────┘
                                                   │
                                                   ▼
                                          ┌─────────────────┐
                                          │  Data Sources   │
                                          │  • Files        │
                                          │  • APIs         │
                                          │  • Databases    │
                                          └─────────────────┘

Step 1: Project Setup

Create a new Node.js project with the MCP SDK:

setup.sh
mkdir my-docs-server && cd my-docs-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init

Step 2: Define Your Tool Schema

MCP tools need clear schemas. This helps the AI understand what the tool does and what parameters it accepts.

step2_schema.ts
import { z } from 'zod';

// Schema for searching documentation
const SearchDocsSchema = z.object({
  query: z.string().describe('Search query for documentation'),
  limit: z.number().min(1).max(10).default(5).describe('Max results to return')
});

// Schema for reading a specific document
const ReadDocSchema = z.object({
  path: z.string().describe('Document path (e.g., "guides/getting-started")')
});

// What makes a good schema?
// ✅ Clear descriptions - helps the AI know when to use the tool
// ✅ Validation - prevents bad inputs (path traversal, etc.)
// ✅ Defaults - reduces required parameters
// ✅ Bounds - limit: max 10 prevents resource exhaustion

Step 3: Implement the Server

Use the MCP SDK to create a server with tools:

step3_server.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import * as path from 'path';
import * as fs from 'fs/promises';

// Create the server
const server = new Server(
  { name: 'docs-server', version: '1.0.0' },
  { capabilities: { tools: {} } }
);

// Allowed docs directory (security: whitelist paths)
const DOCS_ROOT = path.resolve('./docs');

// Validate path is within allowed directory
function sanitizePath(userPath: string): string | null {
  const resolved = path.resolve(DOCS_ROOT, userPath);
  // Security: ensure path doesn't escape docs root
  if (!resolved.startsWith(DOCS_ROOT)) {
    return null; // Path traversal attempt blocked
  }
  return resolved;
}

// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: 'search_docs',
      description: 'Search documentation by keyword',
      inputSchema: {
        type: 'object',
        properties: {
          query: { type: 'string', description: 'Search query' },
          limit: { type: 'number', description: 'Max results (1-10)', default: 5 }
        },
        required: ['query']
      }
    },
    {
      name: 'read_doc',
      description: 'Read a specific document by path',
      inputSchema: {
        type: 'object',
        properties: {
          path: { type: 'string', description: 'Document path' }
        },
        required: ['path']
      }
    }
  ]
}));

// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  switch (name) {
    case 'search_docs': {
      const { query, limit = 5 } = args as { query: string; limit?: number };
      // Simple search implementation
      const results = await searchDocs(query, Math.min(limit, 10));
      return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
    }

    case 'read_doc': {
      const { path: docPath } = args as { path: string };
      const safePath = sanitizePath(docPath);
      
      if (!safePath) {
        return { content: [{ type: 'text', text: 'Error: Invalid path' }], isError: true };
      }

      try {
        const content = await fs.readFile(safePath, 'utf-8');
        return { content: [{ type: 'text', text: content }] };
      } catch (e) {
        return { content: [{ type: 'text', text: 'Error: Document not found' }], isError: true };
      }
    }

    default:
      return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
  }
});

// Start server
const transport = new StdioServerTransport();
server.connect(transport);

Step 4: Configure Claude Code

Add your server to Claude Code's MCP configuration:

claude_code_config.json
{
  "mcpServers": {
    "docs": {
      "command": "npx",
      "args": ["tsx", "/path/to/my-docs-server/server.ts"]
    }
  }
}

Step 5: Security Audit

Now audit your server for vulnerabilities. MCP servers are high-value attack targets because they have direct access to your systems.

Common MCP Attack Vectors

Attack How It Works Mitigation
Path Traversal ../../etc/passwd in path param Whitelist directories, resolve + check prefix
Tool Poisoning Malicious server returns instructions Only install trusted servers
Prompt Injection User content tricks AI into bad tool calls Validate all tool inputs
Resource Exhaustion limit: 999999 Cap parameters, rate limit

Your Task

  1. Build the MCP server with search and read tools
  2. Secure it against path traversal
  3. Audit for at least 3 additional vulnerabilities
  4. Document what you found and how you fixed it

3. Try It Yourself

exercise_starter.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import * as path from 'path';
import * as fs from 'fs/promises';

// Mock docs for testing
const MOCK_DOCS: Record<string, string> = {
  'guides/getting-started.md': '# Getting Started\n\nWelcome to our docs...',
  'guides/api-reference.md': '# API Reference\n\n## Endpoints...',
  'guides/security.md': '# Security Best Practices\n\n1. Validate inputs...'
};

// TODO: Create server with name 'docs-server' and tools capability
const server = new Server(
  { name: 'docs-server', version: '1.0.0' },
  { capabilities: {} }  // Add tools capability
);

// TODO: Implement sanitizePath to prevent directory traversal
function sanitizePath(userPath: string): string | null {
  // SECURITY: This is intentionally vulnerable
  // Fix it to prevent path traversal attacks like '../../../etc/passwd'
  return `./docs/${userPath}`;
}

// TODO: Implement search_docs tool
// - Accept: query (string), limit (number, default 5, max 10)
// - Return: array of { path, snippet } matches

// TODO: Implement read_doc tool  
// - Accept: path (string)
// - Return: document content or error
// - Use sanitizePath to validate

// TODO: Set up request handlers for ListToolsRequestSchema and CallToolRequestSchema

// Start server
const transport = new StdioServerTransport();
server.connect(transport);

console.error('Docs MCP server started');

This typescript exercise requires local setup. Copy the code to your IDE to run.

4. Get Help (If Needed)

Reveal progressive hints
Hint 1: Use path.resolve() to normalize paths, then check if the result starts with your allowed directory.
Hint 2: Don't forget to handle edge cases: empty strings, null bytes (\0), and URLs that look like paths.
Hint 3: For search, implement basic string matching. In production, use a proper search library like MiniSearch or connect to Elasticsearch.

5. Check the Solution

Reveal the complete solution
exercise_solution.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import * as path from 'path';
import * as fs from 'fs/promises';

// Mock docs for testing (in production, use real files)
const MOCK_DOCS: Record<string, string> = {
  'guides/getting-started.md': '# Getting Started\n\nWelcome to our documentation! This guide will help you get up and running quickly.',
  'guides/api-reference.md': '# API Reference\n\n## Endpoints\n\n- GET /users - List users\n- POST /users - Create user',
  'guides/security.md': '# Security Best Practices\n\n1. Always validate user inputs\n2. Use parameterized queries\n3. Implement rate limiting',
  'tutorials/first-app.md': '# Build Your First App\n\nIn this tutorial, you will build a simple REST API...'
};

const DOCS_ROOT = path.resolve('./docs');

// Create server with tools capability
const server = new Server(
  { name: 'docs-server', version: '1.0.0' },
  { capabilities: { tools: {} } }
);

/**
 * Sanitize path to prevent directory traversal attacks.
 * Returns null if path is invalid or attempts to escape docs root.
 */
function sanitizePath(userPath: string): string | null {
  // Remove any null bytes (common bypass technique)
  let cleaned = userPath.replace(/\0/g, '');
  
  // SECURITY: Strip leading slashes and Windows drive letters
  // This prevents absolute paths like /etc/passwd from bypassing DOCS_ROOT
  cleaned = cleaned.replace(/^(\.\.\/|\.\.\\)+/, ''); // Remove leading ../
  cleaned = cleaned.replace(/^[/\\]+/, '');              // Remove leading slashes
  cleaned = cleaned.replace(/^[A-Za-z]:[/\\]/, '');      // Remove Windows drive (C:\)
  
  // Normalize the path to resolve remaining .. and . components
  const resolved = path.resolve(DOCS_ROOT, cleaned);
  
  // Security: ensure the resolved path starts with our docs root
  // This is the final check after all sanitization
  if (!resolved.startsWith(DOCS_ROOT + path.sep) && resolved !== DOCS_ROOT) {
    console.error(`Path traversal blocked: ${userPath} -> ${resolved}`);
    return null;
  }
  
  return resolved;
}

/**
 * Search mock docs for a query string.
 * In production, use a proper search index.
 */
function searchDocs(query: string, limit: number): { path: string; snippet: string }[] {
  const queryLower = query.toLowerCase();
  const results: { path: string; snippet: string; score: number }[] = [];
  
  for (const [docPath, content] of Object.entries(MOCK_DOCS)) {
    const contentLower = content.toLowerCase();
    if (contentLower.includes(queryLower)) {
      // Find the snippet around the match
      const index = contentLower.indexOf(queryLower);
      const start = Math.max(0, index - 50);
      const end = Math.min(content.length, index + query.length + 50);
      const snippet = content.slice(start, end);
      
      results.push({
        path: docPath,
        snippet: (start > 0 ? '...' : '') + snippet + (end < content.length ? '...' : ''),
        score: content.split(new RegExp(query, 'gi')).length - 1 // Count matches
      });
    }
  }
  
  // Sort by score descending and limit results
  return results
    .sort((a, b) => b.score - a.score)
    .slice(0, limit)
    .map(({ path, snippet }) => ({ path, snippet }));
}

// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: 'search_docs',
      description: 'Search documentation by keyword. Returns matching document paths and snippets.',
      inputSchema: {
        type: 'object',
        properties: {
          query: { 
            type: 'string', 
            description: 'Search query (e.g., "authentication", "API")' 
          },
          limit: { 
            type: 'number', 
            description: 'Maximum results to return (1-10, default 5)',
            minimum: 1,
            maximum: 10,
            default: 5
          }
        },
        required: ['query']
      }
    },
    {
      name: 'read_doc',
      description: 'Read the full content of a specific document by path.',
      inputSchema: {
        type: 'object',
        properties: {
          path: { 
            type: 'string', 
            description: 'Document path from search results (e.g., "guides/getting-started.md")' 
          }
        },
        required: ['path']
      }
    }
  ]
}));

// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  switch (name) {
    case 'search_docs': {
      const { query, limit = 5 } = args as { query: string; limit?: number };
      
      // Validate query
      if (!query || typeof query !== 'string') {
        return { 
          content: [{ type: 'text', text: 'Error: query must be a non-empty string' }], 
          isError: true 
        };
      }
      
      // Cap limit for resource protection
      const safeLimit = Math.min(Math.max(1, limit || 5), 10);
      
      const results = searchDocs(query, safeLimit);
      
      if (results.length === 0) {
        return { 
          content: [{ type: 'text', text: `No documents found matching "${query}"` }] 
        };
      }
      
      return { 
        content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] 
      };
    }

    case 'read_doc': {
      const { path: docPath } = args as { path: string };
      
      // Validate path exists
      if (!docPath || typeof docPath !== 'string') {
        return { 
          content: [{ type: 'text', text: 'Error: path must be a non-empty string' }], 
          isError: true 
        };
      }
      
      // Check mock docs first (for testing)
      if (MOCK_DOCS[docPath]) {
        return { 
          content: [{ type: 'text', text: MOCK_DOCS[docPath] }] 
        };
      }
      
      // Try reading from filesystem with path sanitization
      const safePath = sanitizePath(docPath);
      
      if (!safePath) {
        return { 
          content: [{ type: 'text', text: 'Error: Invalid document path' }], 
          isError: true 
        };
      }

      try {
        const content = await fs.readFile(safePath, 'utf-8');
        return { content: [{ type: 'text', text: content }] };
      } catch (e) {
        return { 
          content: [{ type: 'text', text: `Error: Document not found at path "${docPath}"` }], 
          isError: true 
        };
      }
    }

    default:
      return { 
        content: [{ type: 'text', text: `Unknown tool: ${name}` }], 
        isError: true 
      };
  }
});

// Start server
const transport = new StdioServerTransport();
server.connect(transport);

console.error('Docs MCP server started');

/* Security audit checklist:
 * ✅ Path traversal: sanitizePath blocks ../../../etc/passwd
 * ✅ Resource exhaustion: limit capped at 10
 * ✅ Input validation: query and path type-checked
 * ✅ Null byte injection: cleaned in sanitizePath
 * ✅ Read-only: no write/delete tools exposed
 * ✅ Error messages: don't leak sensitive info
 */
L28: Always resolve paths and check they stay within allowed directory
L30: Remove null bytes which can bypass path validation
L33: Strip leading slashes to prevent absolute paths like /etc/passwd from bypassing DOCS_ROOT
L37: Remove Windows drive letters (C:\) for cross-platform security
L43: The key security check: resolved path must start with DOCS_ROOT
L104: Cap limit to prevent resource exhaustion attacks
L140: Validate input types - don't assume AI sends correct types

Common Mistakes

Using user path directly without validation

Why it's wrong: Path traversal attacks can read any file on the system (../../../etc/passwd)

How to fix: Use path.resolve() then verify the result starts with your allowed directory

No limit on search results

Why it's wrong: Resource exhaustion: malicious input could return gigabytes of data

How to fix: Always cap numeric parameters (limit, page size, etc.)

Exposing write operations

Why it's wrong: Prompt injection could trick the AI into modifying/deleting files

How to fix: Start with read-only tools. Add write operations only when necessary, with strict validation.

Detailed error messages

Why it's wrong: Error messages like 'File not found at /home/user/secrets/...' leak information

How to fix: Use generic error messages like 'Document not found'

Test Cases

Basic search works

search_docs returns relevant results

Input: Call search_docs with query='API' and limit=5
Expected: Returns array with api-reference.md

Path traversal blocked

read_doc rejects path traversal attempts

Input: Call read_doc with path='../../../etc/passwd'
Expected: Returns error, not file contents

Limit is capped

search_docs caps limit at 10

Input: Call search_docs with query='test' and limit=100
Expected: Returns at most 10 results

Valid doc read works

read_doc returns content for valid paths

Input: Call read_doc with path='guides/getting-started.md'
Expected: Returns document content

Sources

Tempered AI Forged Through Practice, Not Hype

Keyboard Shortcuts

j
Next page
k
Previous page
h
Section home
/
Search
?
Show shortcuts
m
Toggle sidebar
Esc
Close modal
Shift+R
Reset all progress
? Keyboard shortcuts