Build a Secure MCP Server
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
Concepts You'll Practice
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:
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.
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:
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:
{
"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
- Build the MCP server with search and read tools
- Secure it against path traversal
- Audit for at least 3 additional vulnerabilities
- Document what you found and how you fixed it
3. Try It Yourself
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
5. Check the Solution
Reveal the complete solution
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
*/ 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
Call search_docs with query='API' and limit=5Returns array with api-reference.mdPath traversal blocked
read_doc rejects path traversal attempts
Call read_doc with path='../../../etc/passwd'Returns error, not file contentsLimit is capped
search_docs caps limit at 10
Call search_docs with query='test' and limit=100Returns at most 10 resultsValid doc read works
read_doc returns content for valid paths
Call read_doc with path='guides/getting-started.md'Returns document content