Talki Academy

MCP in Practice: 5 MCP Servers to Deploy Today

Talki AcademyApril 3, 202628 min read
MCPTypeScriptPythonProduction AITutorial

From Theory to Practice

You've read the theory on MCP. You understand the Host-Client-Server architecture. You know what resources, tools, and prompts are. Now, you want code that works.

This article gives you 5 production-ready MCP servers, with complete code, security patterns, deployment configurations, and common errors to avoid. Each example is tested with Claude 4.5 Sonnet and Claude Desktop (April 2026).

⚠️ Prerequisites: Node.js 20+ or Python 3.11+, MCP SDK installed, a Claude API key to test. If you're new to MCP, read the theory guide first — this article assumes you know the basics.

What You'll Learn

  • How to secure a filesystem server to prevent unauthorized access
  • How to manage a PostgreSQL connection pool with robust error handling
  • How to wrap an external REST API with authentication
  • How to build a custom tool (web scraper) from scratch
  • How to combine multiple tools in a multi-tool MCP server
  • How to debug the most frequent MCP errors

Server 1: Secure Filesystem MCP

The filesystem server exposes your local files to Claude. Without precautions, this is a massive security vulnerability. This server implements 3 critical rules: directory whitelist, path validation, and read-only by default.

Complete Code (TypeScript)

// filesystem-server.ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import * as fs from "fs/promises"; import * as path from "path"; // SECURITY CONFIGURATION const ALLOWED_DIRS = [ "/Users/username/Documents/notes", "/Users/username/Projects" ]; // Path validation function function isPathAllowed(filePath: string): boolean { const normalized = path.resolve(filePath); return ALLOWED_DIRS.some(dir => normalized.startsWith(path.resolve(dir))); } const server = new McpServer({ name: "filesystem-secure", version: "1.0.0" }); // RESOURCE: List files in a directory server.resource( "fs://list", "List of files in authorized directories", async () => { const allFiles: string[] = []; for (const dir of ALLOWED_DIRS) { try { const files = await fs.readdir(dir); allFiles.push(...files.map(f => path.join(dir, f))); } catch (err) { console.error(`Error reading ${dir}:`, err); } } return { contents: [{ uri: "fs://list", mimeType: "application/json", text: JSON.stringify(allFiles, null, 2) }] }; } ); // TOOL: Read a file (read-only) server.tool( "read_file", "Reads the content of a text file", { path: z.string().describe("Absolute path of the file to read") }, async ({ path: filePath }) => { // Security validation if (!isPathAllowed(filePath)) { return { content: [{ type: "text", text: `Error: access denied. Allowed directories: ${ALLOWED_DIRS.join(", ")}` }], isError: true }; } try { const content = await fs.readFile(filePath, "utf-8"); const stats = await fs.stat(filePath); return { content: [{ type: "text", text: `File: ${filePath} Size: ${stats.size} bytes Modified: ${stats.mtime.toISOString()} Content: ${content}` }] }; } catch (err: any) { return { content: [{ type: "text", text: `Error reading file: ${err.message}` }], isError: true }; } } ); // TOOL: Search in files (grep-like) server.tool( "search_files", "Search for a pattern in all authorized files", { pattern: z.string().describe("Regular expression to search for"), caseInsensitive: z.boolean().optional().describe("Ignore case") }, async ({ pattern, caseInsensitive = false }) => { const results: Array<{ file: string; matches: string[] }> = []; const regex = new RegExp(pattern, caseInsensitive ? "gi" : "g"); for (const dir of ALLOWED_DIRS) { try { const files = await fs.readdir(dir); for (const file of files) { const filePath = path.join(dir, file); const stat = await fs.stat(filePath); if (stat.isFile()) { try { const content = await fs.readFile(filePath, "utf-8"); const matches = content.match(regex); if (matches && matches.length > 0) { results.push({ file: filePath, matches: matches.slice(0, 5) // Limit to 5 matches per file }); } } catch { // Ignore binary files } } } } catch (err) { console.error(`Error scanning ${dir}:`, err); } } if (results.length === 0) { return { content: [{ type: "text", text: `No results for pattern "${pattern}"` }] }; } return { content: [{ type: "text", text: `Found ${results.length} file(s) matching "${pattern}": ${results.map(r => `${r.file}: ${r.matches.length} match(es)`).join("\n")}` }] }; } ); // Server startup async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("✅ Secure filesystem MCP server started"); console.error(`📁 Allowed directories: ${ALLOWED_DIRS.join(", ")}`); } main().catch(console.error);

Claude Desktop Configuration

// ~/Library/Application Support/Claude/claude_desktop_config.json { "mcpServers": { "filesystem": { "command": "node", "args": ["/absolute/path/to/filesystem-server/dist/index.js"], "env": { "NODE_ENV": "production" } } } }

Testing the Server

Once configured and Claude Desktop restarted:

// In Claude Desktop, type: "Read the file /Users/username/Documents/notes/2026-01-15.md" // Claude will automatically call the read_file tool // Expected result: file content with metadata "Search all files containing the word 'MCP'" // Claude will call search_files // Result: list of files + number of matches

Critical Security Points

  • Strict whitelist: only directories in ALLOWED_DIRS are accessible
  • Path validation: path.resolve() prevents path traversal attacks (../../etc/passwd)
  • Read-only by default: no write tools exposed (add write_file only if necessary)
  • Error handling: filesystem errors don't crash the server
  • Result limiting: search_files limits to 5 matches/file to avoid overload
💡 Pro tip: For enterprise filesystem servers, store ALLOWED_DIRS in environment variables and add per-user permissions (via OAuth).

Server 2: PostgreSQL MCP with Connection Pooling

This server connects Claude to your PostgreSQL database. It implements connection pooling, prepared queries, query timeout, and a table whitelist.

Installing Dependencies

npm install @modelcontextprotocol/sdk zod pg npm install -D @types/pg # Environment variables (.env) DATABASE_URL=postgresql://user:password@localhost:5432/dbname ALLOWED_TABLES=users,orders,products QUERY_TIMEOUT_MS=5000

Complete Code (TypeScript)

// postgres-server.ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { Pool } from "pg"; // Configuration const DATABASE_URL = process.env.DATABASE_URL!; const ALLOWED_TABLES = process.env.ALLOWED_TABLES?.split(",") || []; const QUERY_TIMEOUT = parseInt(process.env.QUERY_TIMEOUT_MS || "5000"); if (!DATABASE_URL) { throw new Error("DATABASE_URL missing in .env"); } if (ALLOWED_TABLES.length === 0) { throw new Error("ALLOWED_TABLES empty — no accessible tables"); } // PostgreSQL connection pool const pool = new Pool({ connectionString: DATABASE_URL, max: 10, // Max 10 simultaneous connections idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000, statement_timeout: QUERY_TIMEOUT }); // SQL security validation function isTableAllowed(tableName: string): boolean { return ALLOWED_TABLES.includes(tableName); } const server = new McpServer({ name: "postgres-server", version: "1.0.0" }); // RESOURCE: Database schema server.resource( "db://schema", "Schema of authorized PostgreSQL tables", async () => { const client = await pool.connect(); try { const schemaQuery = ` SELECT table_name, column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = ANY($1) ORDER BY table_name, ordinal_position `; const result = await client.query(schemaQuery, [ALLOWED_TABLES]); return { contents: [{ uri: "db://schema", mimeType: "application/json", text: JSON.stringify(result.rows, null, 2) }] }; } finally { client.release(); } } ); // TOOL: Execute a SELECT query server.tool( "query_database", "Execute a SELECT query in natural language", { query: z.string().describe("SQL SELECT query (read-only)"), params: z.array(z.any()).optional().describe("Prepared query parameters") }, async ({ query, params = [] }) => { // Validation: only SELECT allowed const normalizedQuery = query.trim().toUpperCase(); if (!normalizedQuery.startsWith("SELECT")) { return { content: [{ type: "text", text: "Error: only SELECT queries are allowed" }], isError: true }; } // Check that queried tables are in whitelist const tablesInQuery = ALLOWED_TABLES.filter(table => query.toLowerCase().includes(table.toLowerCase()) ); if (tablesInQuery.length === 0) { return { content: [{ type: "text", text: `Error: no allowed table detected. Available tables: ${ALLOWED_TABLES.join(", ")}` }], isError: true }; } const client = await pool.connect(); try { const startTime = Date.now(); const result = await client.query(query, params); const duration = Date.now() - startTime; if (result.rows.length === 0) { return { content: [{ type: "text", text: "Query executed successfully — 0 results" }] }; } return { content: [{ type: "text", text: `Query executed in ${duration}ms — ${result.rows.length} result(s): ${JSON.stringify(result.rows, null, 2)}` }] }; } catch (err: any) { return { content: [{ type: "text", text: `SQL error: ${err.message}` }], isError: true }; } finally { client.release(); } } ); // TOOL: Table statistics server.tool( "table_stats", "Returns row count and size of a table", { tableName: z.string().describe("Table name") }, async ({ tableName }) => { if (!isTableAllowed(tableName)) { return { content: [{ type: "text", text: `Table "${tableName}" not allowed. Available tables: ${ALLOWED_TABLES.join(", ")}` }], isError: true }; } const client = await pool.connect(); try { const countQuery = `SELECT COUNT(*) FROM ${tableName}`; const sizeQuery = ` SELECT pg_size_pretty(pg_total_relation_size($1)) AS size `; const [countResult, sizeResult] = await Promise.all([ client.query(countQuery), client.query(sizeQuery, [tableName]) ]); const count = countResult.rows[0].count; const size = sizeResult.rows[0].size; return { content: [{ type: "text", text: `Table: ${tableName} Row count: ${count} Total size: ${size}` }] }; } finally { client.release(); } } ); // Server startup async function main() { // Test connection on startup try { const client = await pool.connect(); await client.query("SELECT NOW()"); client.release(); console.error("✅ PostgreSQL connection OK"); } catch (err) { console.error("❌ PostgreSQL connection error:", err); process.exit(1); } const transport = new StdioServerTransport(); await server.connect(transport); console.error("✅ PostgreSQL MCP server started"); console.error(`📊 Allowed tables: ${ALLOWED_TABLES.join(", ")}`); } // Cleanup on shutdown process.on("SIGINT", async () => { await pool.end(); process.exit(0); }); main().catch(console.error);

Claude Desktop Configuration

{ "mcpServers": { "postgres": { "command": "node", "args": ["/path/to/postgres-server/dist/index.js"], "env": { "DATABASE_URL": "postgresql://user:password@localhost:5432/dbname", "ALLOWED_TABLES": "users,orders,products", "QUERY_TIMEOUT_MS": "5000" } } } }

Usage Example

// In Claude Desktop: "How many users registered since January 2026?" // Claude generates and executes: // SELECT COUNT(*) FROM users WHERE created_at >= '2026-01-01' "What's the average order amount per customer?" // Claude generates: // SELECT AVG(total_amount) FROM orders

PostgreSQL Security

  • Prepared queries: all parameters use $1, $2... to prevent SQL injection
  • SELECT only: strict validation, no INSERT/UPDATE/DELETE/DROP
  • Table whitelist: only tables in ALLOWED_TABLES are accessible
  • Timeout: long queries are cancelled after 5s by default
  • Connection pooling: max 10 connections, auto-release after use
  • Secure credentials: DATABASE_URL in env vars, never hardcoded
⚠️ In production: create a dedicated read-only PostgreSQL user with GRANT SELECT ON tables TO mcp_readonly_user. Never reuse your admin user.

Server 3: REST API MCP with Authentication

This server wraps an external REST API (example: GitHub API) and handles authentication, rate limiting, and HTTP errors.

Installation

npm install @modelcontextprotocol/sdk zod node-fetch # .env GITHUB_TOKEN=ghp_your_token_here GITHUB_API_BASE=https://api.github.com

Complete Code (TypeScript)

// github-api-server.ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import fetch from "node-fetch"; const GITHUB_TOKEN = process.env.GITHUB_TOKEN!; const GITHUB_API_BASE = process.env.GITHUB_API_BASE || "https://api.github.com"; if (!GITHUB_TOKEN) { throw new Error("GITHUB_TOKEN missing in .env"); } // Helper: GitHub API call with auth async function githubAPI(endpoint: string, options: any = {}) { const url = `${GITHUB_API_BASE}${endpoint}`; const headers = { "Authorization": `Bearer ${GITHUB_TOKEN}`, "Accept": "application/vnd.github+json", "User-Agent": "MCP-Server/1.0", ...options.headers }; const response = await fetch(url, { ...options, headers }); // Rate limiting handling if (response.status === 403) { const rateLimitReset = response.headers.get("x-ratelimit-reset"); if (rateLimitReset) { const resetTime = new Date(parseInt(rateLimitReset) * 1000); throw new Error(`Rate limit reached. Reset at ${resetTime.toISOString()}`); } } if (!response.ok) { const errorBody = await response.text(); throw new Error(`GitHub API error ${response.status}: ${errorBody}`); } return await response.json(); } const server = new McpServer({ name: "github-api", version: "1.0.0" }); // TOOL: List a user's repos server.tool( "list_repos", "List public repositories of a GitHub user", { username: z.string().describe("GitHub username") }, async ({ username }) => { try { const repos = await githubAPI(`/users/${username}/repos`); if (repos.length === 0) { return { content: [{ type: "text", text: `No public repositories for @${username}` }] }; } const repoList = repos.map((r: any) => `- ${r.name} (${r.stargazers_count} ⭐) — ${r.description || "No description"}` ).join("\n"); return { content: [{ type: "text", text: `Repositories of @${username}: ${repoList}` }] }; } catch (err: any) { return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true }; } } ); // TOOL: Get repo issues server.tool( "list_issues", "List open issues of a repository", { owner: z.string().describe("Repository owner"), repo: z.string().describe("Repository name"), state: z.enum(["open", "closed", "all"]).optional().describe("Issue state") }, async ({ owner, repo, state = "open" }) => { try { const issues = await githubAPI(`/repos/${owner}/${repo}/issues?state=${state}`); if (issues.length === 0) { return { content: [{ type: "text", text: `No ${state} issues in ${owner}/${repo}` }] }; } const issueList = issues.map((i: any) => `#${i.number} — ${i.title} (by @${i.user.login})` ).join("\n"); return { content: [{ type: "text", text: `${state} issues in ${owner}/${repo}: ${issueList}` }] }; } catch (err: any) { return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true }; } } ); // TOOL: Create an issue (write operation) server.tool( "create_issue", "Create a new issue in a repository", { owner: z.string().describe("Repository owner"), repo: z.string().describe("Repository name"), title: z.string().describe("Issue title"), body: z.string().optional().describe("Issue description") }, async ({ owner, repo, title, body = "" }) => { try { const issue = await githubAPI(`/repos/${owner}/${repo}/issues`, { method: "POST", body: JSON.stringify({ title, body }) }); return { content: [{ type: "text", text: `✅ Issue created: #${issue.number} — ${issue.title} URL: ${issue.html_url}` }] }; } catch (err: any) { return { content: [{ type: "text", text: `Error creating issue: ${err.message}` }], isError: true }; } } ); // Startup async function main() { // Test connection on startup try { await githubAPI("/user"); console.error("✅ GitHub authentication OK"); } catch (err) { console.error("❌ GitHub auth error:", err); process.exit(1); } const transport = new StdioServerTransport(); await server.connect(transport); console.error("✅ GitHub API MCP server started"); } main().catch(console.error);

Claude Desktop Configuration

{ "mcpServers": { "github": { "command": "node", "args": ["/path/to/github-api-server/dist/index.js"], "env": { "GITHUB_TOKEN": "ghp_your_personal_token" } } } }

Usage

// In Claude Desktop: "List repos of @anthropics" // Claude calls list_repos({ username: "anthropics" }) "What are the open issues in anthropics/anthropic-sdk-python?" // Claude calls list_issues({ owner: "anthropics", repo: "anthropic-sdk-python" }) "Create an issue in my test/demo repo with title 'Bug in README'" // Claude calls create_issue with correct parameters

REST API Key Points

  • Centralized authentication: token injected in all calls via headers
  • Rate limiting: detection of x-ratelimit-reset header with explicit error
  • HTTP error handling: non-2xx status codes are caught and logged
  • User-Agent: required for GitHub API (otherwise 403)
  • Write operations opt-in: create_issue is a separate tool, not exposed by default
💡 Adaptable to any API: replace GitHub with Notion, Stripe, Jira, etc. The apiCall() + auth handling + error handling pattern works everywhere.

Server 4: Custom Web Scraper MCP

This custom server scrapes a web page and extracts structured content. Ideal for transforming non-API sites into data sources for Claude.

Installation

npm install @modelcontextprotocol/sdk zod cheerio node-fetch

Complete Code (TypeScript)

// web-scraper-server.ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import fetch from "node-fetch"; import * as cheerio from "cheerio"; const server = new McpServer({ name: "web-scraper", version: "1.0.0" }); // TOOL: Scrape a web page server.tool( "scrape_page", "Fetch and parse HTML content from a web page", { url: z.string().url().describe("URL of the page to scrape"), selector: z.string().optional().describe("Optional CSS selector to extract specific part") }, async ({ url, selector }) => { try { const response = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (compatible; MCP-Scraper/1.0)" } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const html = await response.text(); const $ = cheerio.load(html); // If selector provided, extract only that part if (selector) { const selected = $(selector); if (selected.length === 0) { return { content: [{ type: "text", text: `No element found for selector "${selector}"` }] }; } const extractedText = selected.text().trim(); return { content: [{ type: "text", text: `Content extracted from ${url} (selector: ${selector}): ${extractedText}` }] }; } // Otherwise, extract main text (body) const title = $("title").text().trim(); const bodyText = $("body").text().trim().replace(/\s+/g, " "); return { content: [{ type: "text", text: `Title: ${title} Content: ${bodyText.slice(0, 2000)}...` }] }; } catch (err: any) { return { content: [{ type: "text", text: `Scraping error: ${err.message}` }], isError: true }; } } ); // TOOL: Extract all links from a page server.tool( "extract_links", "Extract all <a> links from a web page", { url: z.string().url().describe("Page URL"), filterPattern: z.string().optional().describe("Regex to filter links (ex: '.pdf$')") }, async ({ url, filterPattern }) => { try { const response = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (compatible; MCP-Scraper/1.0)" } }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const html = await response.text(); const $ = cheerio.load(html); const links: Array<{ text: string; href: string }> = []; $("a").each((_, el) => { const href = $(el).attr("href"); const text = $(el).text().trim(); if (href) { // Convert relative links to absolute const absoluteUrl = new URL(href, url).href; // Filter if pattern provided if (filterPattern) { const regex = new RegExp(filterPattern); if (regex.test(absoluteUrl)) { links.push({ text, href: absoluteUrl }); } } else { links.push({ text, href: absoluteUrl }); } } }); if (links.length === 0) { return { content: [{ type: "text", text: "No links found" }] }; } const linkList = links.map(l => `- ${l.text || "(no text)"}: ${l.href}`).join("\n"); return { content: [{ type: "text", text: `Found ${links.length} link(s) in ${url}: ${linkList}` }] }; } catch (err: any) { return { content: [{ type: "text", text: `Link extraction error: ${err.message}` }], isError: true }; } } ); // TOOL: Extract an HTML table server.tool( "extract_table", "Extract an HTML table and return it as JSON", { url: z.string().url().describe("Page URL"), tableIndex: z.number().optional().describe("Table index (0 = first table)") }, async ({ url, tableIndex = 0 }) => { try { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); const html = await response.text(); const $ = cheerio.load(html); const tables = $("table"); if (tables.length === 0) { return { content: [{ type: "text", text: "No table found in page" }] }; } if (tableIndex >= tables.length) { return { content: [{ type: "text", text: `Index ${tableIndex} invalid — only ${tables.length} table(s) found` }] }; } const table = $(tables[tableIndex]); const rows: string[][] = []; table.find("tr").each((_, row) => { const cells: string[] = []; $(row).find("th, td").each((_, cell) => { cells.push($(cell).text().trim()); }); if (cells.length > 0) { rows.push(cells); } }); return { content: [{ type: "text", text: `Table extracted from ${url}: ${JSON.stringify(rows, null, 2)}` }] }; } catch (err: any) { return { content: [{ type: "text", text: `Table extraction error: ${err.message}` }], isError: true }; } } ); // Startup async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("✅ Web Scraper MCP server started"); } main().catch(console.error);

Claude Desktop Configuration

{ "mcpServers": { "scraper": { "command": "node", "args": ["/path/to/web-scraper-server/dist/index.js"] } } }

Usage Examples

// In Claude Desktop: "Fetch content from https://news.ycombinator.com/" // Claude calls scrape_page({ url: "..." }) "Extract all PDF links from https://example.com/documentation" // Claude calls extract_links({ url: "...", filterPattern: "\.pdf$" }) "Extract the first table from https://example.com/pricing" // Claude calls extract_table({ url: "...", tableIndex: 0 })

Legal and Ethical Considerations

⚠️ Scraping caution:
  • Respect the target site's robots.txt file
  • Don't abuse: limit request frequency (rate limiting)
  • Some sites prohibit scraping in their ToS
  • Always prefer an official API if available

Server 5: Combined Multi-Tool (Filesystem + PostgreSQL + REST API)

This server combines the 3 previous patterns into a single MCP server. Ideal for avoiding managing multiple processes and centralizing access to your business tools.

Complete Code (TypeScript)

// multi-tool-server.ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import * as fs from "fs/promises"; import * as path from "path"; import { Pool } from "pg"; import fetch from "node-fetch"; // Configuration const ALLOWED_DIRS = process.env.ALLOWED_DIRS?.split(",") || []; const DATABASE_URL = process.env.DATABASE_URL; const GITHUB_TOKEN = process.env.GITHUB_TOKEN; const server = new McpServer({ name: "multi-tool-server", version: "1.0.0" }); // ========== FILESYSTEM TOOLS ========== if (ALLOWED_DIRS.length > 0) { function isPathAllowed(filePath: string): boolean { const normalized = path.resolve(filePath); return ALLOWED_DIRS.some(dir => normalized.startsWith(path.resolve(dir))); } server.tool( "read_file", "Read a text file", { path: z.string().describe("File path") }, async ({ path: filePath }) => { if (!isPathAllowed(filePath)) { return { content: [{ type: "text", text: `Access denied: ${filePath}` }], isError: true }; } try { const content = await fs.readFile(filePath, "utf-8"); return { content: [{ type: "text", text: content }] }; } catch (err: any) { return { content: [{ type: "text", text: `Read error: ${err.message}` }], isError: true }; } } ); console.error(`✅ Filesystem tools enabled (${ALLOWED_DIRS.length} directories)`); } // ========== POSTGRESQL TOOLS ========== let pool: Pool | null = null; if (DATABASE_URL) { pool = new Pool({ connectionString: DATABASE_URL, max: 5 }); server.tool( "query_database", "Execute a SELECT query", { query: z.string().describe("SQL SELECT query") }, async ({ query }) => { if (!query.trim().toUpperCase().startsWith("SELECT")) { return { content: [{ type: "text", text: "Only SELECT queries allowed" }], isError: true }; } try { const result = await pool!.query(query); return { content: [{ type: "text", text: `${result.rows.length} result(s):\n${JSON.stringify(result.rows, null, 2)}` }] }; } catch (err: any) { return { content: [{ type: "text", text: `SQL error: ${err.message}` }], isError: true }; } } ); console.error("✅ PostgreSQL tools enabled"); } // ========== GITHUB API TOOLS ========== if (GITHUB_TOKEN) { async function githubAPI(endpoint: string, options: any = {}) { const url = `https://api.github.com${endpoint}`; const headers = { "Authorization": `Bearer ${GITHUB_TOKEN}`, "Accept": "application/vnd.github+json", ...options.headers }; const response = await fetch(url, { ...options, headers }); if (!response.ok) { throw new Error(`GitHub API error ${response.status}`); } return await response.json(); } server.tool( "list_github_repos", "List a GitHub user's repos", { username: z.string().describe("Username") }, async ({ username }) => { try { const repos = await githubAPI(`/users/${username}/repos`); const repoList = repos.map((r: any) => `- ${r.name} (${r.stargazers_count} ⭐)`).join("\n"); return { content: [{ type: "text", text: repoList }] }; } catch (err: any) { return { content: [{ type: "text", text: `GitHub error: ${err.message}` }], isError: true }; } } ); console.error("✅ GitHub API tools enabled"); } // ========== STARTUP ========== async function main() { // Test connections on startup if (pool) { try { await pool.query("SELECT NOW()"); } catch (err) { console.error("❌ PostgreSQL connection error:", err); process.exit(1); } } if (GITHUB_TOKEN) { try { await githubAPI("/user"); } catch (err) { console.error("❌ GitHub auth error:", err); process.exit(1); } } const transport = new StdioServerTransport(); await server.connect(transport); console.error("✅ Multi-Tool MCP server started"); } process.on("SIGINT", async () => { if (pool) await pool.end(); process.exit(0); }); main().catch(console.error);

Claude Desktop Configuration

{ "mcpServers": { "multi-tool": { "command": "node", "args": ["/path/to/multi-tool-server/dist/index.js"], "env": { "ALLOWED_DIRS": "/Users/username/Documents,/Users/username/Projects", "DATABASE_URL": "postgresql://user:password@localhost:5432/dbname", "GITHUB_TOKEN": "ghp_your_token" } } } }

Multi-Tool Server Advantages

  • Single process: less memory, simpler to manage
  • Centralized configuration: all env vars in one place
  • Conditional activation: tools only added if credentials provided
  • Unified logs: all tools log to same stderr stream

Disadvantages

  • If one tool crashes, entire server restarts
  • Harder to debug (interleaved logs)
  • More complex code to maintain
💡 Recommendation: start with separate servers (one per tool). Consolidate into multi-tool only if you have >5 servers and management becomes heavy.

Troubleshooting & Common Errors

Error 1: "Server not responding" in Claude Desktop

Symptom: MCP server appears disconnected in Claude Desktop.

Causes:

  • Path to script in claude_desktop_config.json is incorrect
  • Script not executable (chmod +x missing for Python)
  • Node.js or Python not in PATH
  • Server crashes on startup (code error)

Solution:

# 1. Verify script starts manually node /path/to/server.js # Or for Python: python3 /path/to/server.py # 2. Check Claude Desktop logs # macOS: ~/Library/Logs/Claude/ # Windows: %APPDATA%\Claude\logs\ # 3. Test with MCP inspector npx @modelcontextprotocol/inspector node server.js

Error 2: "Tool call failed" or "isError: true"

Symptom: Claude calls tool but receives error.

Causes:

  • Zod validation fails (wrong parameter type)
  • Uncaught exception in tool handler
  • Timeout (database too slow, external API down)

Solution:

// Always wrap external calls in try/catch server.tool("my_tool", "...", schema, async (params) => { try { const result = await externalAPI.call(params); return { content: [{ type: "text", text: result }] }; } catch (err: any) { // Log error to stderr (visible in Claude Desktop logs) console.error("Error my_tool:", err); return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true // Important: signals to Claude it's an error }; } });

Error 3: "Cannot read property 'xyz' of undefined"

Symptom: server crash with Node.js stack trace.

Cause: accessing non-existent property (e.g. result.rows[0].name when rows is empty).

Solution:

// Always check existence before accessing const result = await db.query("SELECT ..."); if (result.rows.length === 0) { return { content: [{ type: "text", text: "No results" }] }; } const firstRow = result.rows[0]; // Now firstRow exists, safe to access firstRow.name

Error 4: "Rate limit exceeded" (GitHub, OpenAI, etc.)

Symptom: external API returns 429 or 403.

Solution:

// Detect rate limit and inform Claude if (response.status === 429) { const retryAfter = response.headers.get("retry-after"); // in seconds const message = retryAfter ? `Rate limit reached. Retry in ${retryAfter}s.` : "Rate limit reached. Retry later."; return { content: [{ type: "text", text: message }], isError: true }; }

Error 5: MCP server consuming too much memory

Symptom: Node.js/Python process using several GB of RAM.

Causes:

  • Connection pooling misconfigured (too many open connections)
  • Cache not cleared (memory accumulation)
  • Memory leak in code (event listeners not removed)

Solution:

// PostgreSQL: limit pool const pool = new Pool({ max: 5, // Max 5 connections (not 100!) idleTimeoutMillis: 30000 // Close idle connections after 30s }); // Always release clients const client = await pool.connect(); try { await client.query("..."); } finally { client.release(); // CRITICAL: never forget }

Debug mode: verbose logs

To enable MCP SDK debug logs:

// In claude_desktop_config.json, add: { "mcpServers": { "my-server": { "command": "node", "args": ["/path/to/server.js"], "env": { "DEBUG": "mcp:*" // Enable all MCP logs } } } } // Or on command line: DEBUG=mcp:* node server.js

FAQ

What's the difference between stdio and HTTP for an MCP server?

stdio (standard input/output) launches the MCP server as a local process connected to the client via pipes. Ideal for Claude Desktop and IDEs. HTTP with SSE (Server-Sent Events) allows deploying a remote MCP server accessible via network. Recommendation: stdio for dev and local usage, HTTP/SSE for shared enterprise servers.

How to secure an MCP server in production?

5 rules: (1) Never expose credentials hardcoded — use environment variables or secrets managers, (2) Validate all inputs with Zod or Pydantic, (3) Limit tool permissions (read-only by default, write on explicit opt-in), (4) Log all tool calls for audit, (5) For HTTP: enable HTTPS, API key authentication, and rate limiting.

Can I combine multiple MCP servers in Claude Desktop?

Yes. Claude Desktop allows loading multiple MCP servers simultaneously. Example: a filesystem server for your notes, a PostgreSQL server for customer data, and a GitHub server for repos. Claude will have access to all tools and resources exposed by the 3 servers. Note: each server is an independent process, manage memory accordingly.

How to debug an MCP server that's not responding?

Debug workflow: (1) Check Claude Desktop logs (Developer menu > Show logs), (2) Test the server via command line: `node server.js` should start without error, (3) Verify transport: stdio must log to stderr (not stdout), (4) Use MCP inspector: `npx @modelcontextprotocol/inspector node server.js`, (5) Enable verbose mode in SDK: `DEBUG=mcp:* node server.js`.

Which language to choose for an MCP server: TypeScript or Python?

TypeScript: better typing with Zod, native async, perfect for REST APIs and Node.js integrations. Python: ideal for data science, ML, sysadmin scripts, and if your team masters Python. Both SDKs are feature-complete. Recommendation: TypeScript for business and web servers, Python for data and automation.

Want to Go Further?

These 5 MCP servers cover 80% of enterprise needs: file access, databases, REST APIs, scraping, and multi-tool combinations. To master MCP from A to Z with practical exercises and production deployment, check out our training program.