Skip to main content
Back to Blog
tutorialJanuary 4, 202514 min min read

Building MCP Servers for Claude: A Complete Guide

Learn how to extend Claude's capabilities by building custom MCP (Model Context Protocol) servers that provide tools, resources, and prompts.

Robert Fridzema

Robert Fridzema

Fullstack Developer

Building MCP Servers for Claude: A Complete Guide

The Model Context Protocol (MCP) lets you extend Claude's capabilities with custom tools, data sources, and prompts. Instead of copying data into prompts manually, MCP servers provide Claude with direct access to your systems. Here's how to build one.

What is MCP?

MCP is a protocol that allows AI assistants like Claude to:

  • Use tools - Execute functions and return results
  • Access resources - Read files, databases, APIs
  • Use prompts - Pre-defined prompt templates
┌─────────────────┐     MCP Protocol     ┌─────────────────┐
│     Claude      │◄───────────────────►│   MCP Server    │
│   (AI Client)   │                      │  (Your Code)    │
└─────────────────┘                      └─────────────────┘
                                                │
                                                ▼
                                         ┌─────────────────┐
                                         │  Your Systems   │
                                         │  - Databases    │
                                         │  - APIs         │
                                         │  - Files        │
                                         └─────────────────┘

When to Build an MCP Server

Good use cases:

  • Documentation access - Let Claude search your docs
  • Database queries - Safe, read-only data access
  • API integration - Connect Claude to your services
  • Code generation - Provide context-aware templates
  • Workflow automation - Multi-step processes

Project Setup

mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}
// package.json
{
  "name": "my-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts"
  }
}

Basic Server Structure

// src/index.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'

// Create server instance
const server = new Server(
  {
    name: 'my-mcp-server',
    version: '1.0.0',
  },
  {
    capabilities: {
      tools: {},
      resources: {},
    },
  }
)

// Start the server
async function main() {
  const transport = new StdioServerTransport()
  await server.connect(transport)
  console.error('MCP Server running on stdio')
}

main().catch(console.error)

Adding Tools

Tools let Claude execute functions. Define them with clear schemas:

// src/tools/search.ts
import { z } from 'zod'

// Define input schema
export const SearchInputSchema = z.object({
  query: z.string().describe('The search query'),
  limit: z.number().optional().default(10).describe('Max results to return'),
})

export type SearchInput = z.infer<typeof SearchInputSchema>

// Implement the tool
export async function searchDocs(input: SearchInput): Promise<string> {
  const { query, limit } = input

  // Your search logic here
  const results = await performSearch(query, limit)

  return JSON.stringify(results, null, 2)
}

Register tools with the server:

// src/index.ts
import { searchDocs, SearchInputSchema } from './tools/search.js'

// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: 'search_docs',
        description: 'Search the documentation for relevant content',
        inputSchema: {
          type: 'object',
          properties: {
            query: {
              type: 'string',
              description: 'The search query',
            },
            limit: {
              type: 'number',
              description: 'Max results to return',
              default: 10,
            },
          },
          required: ['query'],
        },
      },
      {
        name: 'get_component',
        description: 'Get details about a UI component',
        inputSchema: {
          type: 'object',
          properties: {
            name: {
              type: 'string',
              description: 'Component name (e.g., "Button", "Modal")',
            },
          },
          required: ['name'],
        },
      },
    ],
  }
})

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

  switch (name) {
    case 'search_docs': {
      const input = SearchInputSchema.parse(args)
      const result = await searchDocs(input)
      return { content: [{ type: 'text', text: result }] }
    }

    case 'get_component': {
      const { name: componentName } = args as { name: string }
      const result = await getComponent(componentName)
      return { content: [{ type: 'text', text: result }] }
    }

    default:
      throw new Error(`Unknown tool: ${name}`)
  }
})

Adding Resources

Resources provide data that Claude can read:

// List available resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
  return {
    resources: [
      {
        uri: 'docs://components/button',
        name: 'Button Component',
        description: 'Documentation for the Button component',
        mimeType: 'text/markdown',
      },
      {
        uri: 'docs://components/modal',
        name: 'Modal Component',
        description: 'Documentation for the Modal component',
        mimeType: 'text/markdown',
      },
      {
        uri: 'config://theme',
        name: 'Theme Configuration',
        description: 'Current theme settings',
        mimeType: 'application/json',
      },
    ],
  }
})

// Read resource content
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const { uri } = request.params

  if (uri.startsWith('docs://components/')) {
    const componentName = uri.replace('docs://components/', '')
    const content = await loadComponentDocs(componentName)
    return {
      contents: [
        {
          uri,
          mimeType: 'text/markdown',
          text: content,
        },
      ],
    }
  }

  if (uri === 'config://theme') {
    const theme = await loadThemeConfig()
    return {
      contents: [
        {
          uri,
          mimeType: 'application/json',
          text: JSON.stringify(theme, null, 2),
        },
      ],
    }
  }

  throw new Error(`Unknown resource: ${uri}`)
})

Real-World Example: Documentation Server

Here's a complete example that serves documentation with search:

// src/index.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import Fuse from 'fuse.js'
import { readFile, readdir } from 'fs/promises'
import { join } from 'path'

interface DocEntry {
  path: string
  title: string
  content: string
}

class DocsServer {
  private docs: DocEntry[] = []
  private fuse: Fuse<DocEntry> | null = null

  async initialize(docsPath: string) {
    // Load all markdown files
    const files = await this.findMarkdownFiles(docsPath)

    for (const file of files) {
      const content = await readFile(file, 'utf-8')
      const title = this.extractTitle(content)
      this.docs.push({ path: file, title, content })
    }

    // Initialize search index
    this.fuse = new Fuse(this.docs, {
      keys: ['title', 'content'],
      threshold: 0.3,
      includeScore: true,
    })

    console.error(`Loaded ${this.docs.length} documents`)
  }

  search(query: string, limit: number = 10): DocEntry[] {
    if (!this.fuse) return []
    return this.fuse
      .search(query)
      .slice(0, limit)
      .map((r) => r.item)
  }

  getDoc(path: string): DocEntry | undefined {
    return this.docs.find((d) => d.path === path)
  }

  listDocs(): DocEntry[] {
    return this.docs
  }

  private async findMarkdownFiles(dir: string): Promise<string[]> {
    const files: string[] = []
    const entries = await readdir(dir, { withFileTypes: true })

    for (const entry of entries) {
      const fullPath = join(dir, entry.name)
      if (entry.isDirectory()) {
        files.push(...(await this.findMarkdownFiles(fullPath)))
      } else if (entry.name.endsWith('.md')) {
        files.push(fullPath)
      }
    }

    return files
  }

  private extractTitle(content: string): string {
    const match = content.match(/^#\s+(.+)$/m)
    return match ? match[1] : 'Untitled'
  }
}

// Main
async function main() {
  const docsPath = process.env.DOCS_PATH || './docs'
  const docsServer = new DocsServer()
  await docsServer.initialize(docsPath)

  const server = new Server(
    { name: 'docs-server', version: '1.0.0' },
    { capabilities: { tools: {}, resources: {} } }
  )

  // 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', default: 5 },
          },
          required: ['query'],
        },
      },
    ],
  }))

  server.setRequestHandler(CallToolRequestSchema, async (request) => {
    const { name, arguments: args } = request.params

    if (name === 'search_docs') {
      const { query, limit = 5 } = args as { query: string; limit?: number }
      const results = docsServer.search(query, limit)

      const text = results
        .map((r) => `## ${r.title}\n\n${r.content.slice(0, 500)}...`)
        .join('\n\n---\n\n')

      return { content: [{ type: 'text', text }] }
    }

    throw new Error(`Unknown tool: ${name}`)
  })

  // Resources
  server.setRequestHandler(ListResourcesRequestSchema, async () => ({
    resources: docsServer.listDocs().map((doc) => ({
      uri: `docs://${doc.path}`,
      name: doc.title,
      mimeType: 'text/markdown',
    })),
  }))

  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
    const path = request.params.uri.replace('docs://', '')
    const doc = docsServer.getDoc(path)

    if (!doc) throw new Error(`Document not found: ${path}`)

    return {
      contents: [{ uri: request.params.uri, mimeType: 'text/markdown', text: doc.content }],
    }
  })

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

main().catch(console.error)

Configuring Claude Desktop

Add your server to Claude Desktop's configuration:

// ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)
// %APPDATA%\Claude\claude_desktop_config.json (Windows)
{
  "mcpServers": {
    "docs": {
      "command": "node",
      "args": ["/path/to/my-mcp-server/dist/index.js"],
      "env": {
        "DOCS_PATH": "/path/to/documentation"
      }
    }
  }
}

Restart Claude Desktop to load the server.

Testing Your Server

Test tools manually with the MCP Inspector:

npx @modelcontextprotocol/inspector node dist/index.js

This opens a web UI where you can:

  • List and call tools
  • Browse and read resources
  • View server logs

Error Handling

Always handle errors gracefully:

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  try {
    const { name, arguments: args } = request.params

    // Validate inputs
    if (!args || typeof args !== 'object') {
      throw new Error('Invalid arguments')
    }

    // Tool logic...
    const result = await executeTool(name, args)

    return { content: [{ type: 'text', text: result }] }
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error'

    return {
      content: [{ type: 'text', text: `Error: ${message}` }],
      isError: true,
    }
  }
})

Security Considerations

  1. Validate all inputs - Never trust data from the client
  2. Limit capabilities - Only expose what's necessary
  3. Use read-only access - Avoid write operations when possible
  4. Sanitize outputs - Don't leak sensitive information
  5. Rate limit - Prevent abuse of expensive operations
// Example: Read-only database access
const ALLOWED_TABLES = ['products', 'categories', 'docs']

async function queryDatabase(table: string, conditions: object) {
  if (!ALLOWED_TABLES.includes(table)) {
    throw new Error(`Access to table '${table}' is not allowed`)
  }

  // Use parameterized queries
  return db.select().from(table).where(conditions).limit(100)
}

Deployment Options

Local Development

npm run dev  # Uses tsx watch for hot reload

Production (systemd)

[Unit]
Description=My MCP Server
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/node /opt/mcp-server/dist/index.js
Restart=on-failure
Environment=DOCS_PATH=/var/docs

[Install]
WantedBy=multi-user.target

Docker

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY dist ./dist
CMD ["node", "dist/index.js"]

Key Takeaways

  1. Start with tools - They're the most useful capability
  2. Use clear descriptions - Claude uses them to decide when to call tools
  3. Validate everything - Input validation prevents crashes and security issues
  4. Test with Inspector - Manual testing before Claude integration
  5. Keep it focused - One server per domain/purpose

MCP servers unlock Claude's potential by connecting it to your systems. Start simple, iterate based on what's useful, and you'll build something genuinely helpful.


Building AI tools? Let's talk - I've built several MCP servers and happy to share more patterns.

#Claude #MCP #TypeScript #AI Tools #Automation
Share: