MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Model Context Protocol (MCP): Connecting AI to Everything

Explore Anthropic's MCP: tool definitions, server implementation, and AI tool integration.

AIMCPAnthropicTools

By MinhVo

Introduction

The Model Context Protocol (MCP) represents a paradigm shift in how AI models interact with external systems. Before MCP, connecting an AI assistant to your database, file system, or API required custom integration code for each model provider. If you wanted your AI to query PostgreSQL, you'd write a custom plugin for Claude, a different one for ChatGPT, and yet another for Gemini. MCP eliminates this fragmentation by providing a universal protocol that any AI application can use to connect to any tool or data source.

Anthropic released MCP in late 2024 as an open specification, and it has rapidly gained adoption across the AI ecosystem. The protocol defines how tools are described, how resources are accessed, and how prompts are templated—all through a standardized JSON-RPC interface. This means a single MCP server can work with Claude Desktop, Cursor, Continue, or any other MCP-compatible client. The result is a thriving ecosystem of reusable integrations that benefit the entire AI community.

MCP protocol architecture

Understanding MCP: Core Concepts

MCP defines a client-server architecture where the AI application (client) communicates with integration servers through a standardized protocol. The client initiates connections, discovers capabilities, and invokes tools. The server advertises its tools, resources, and prompts, then handles requests from the client.

The protocol uses JSON-RPC 2.0 as its wire format, with two transport mechanisms: stdio (standard input/output) for local servers and HTTP with Server-Sent Events (SSE) for remote servers. Stdio transport is simpler—the client spawns the MCP server as a subprocess and communicates through stdin/stdout. HTTP/SSE transport enables remote servers that can be shared across multiple clients.

Three primary primitives form the MCP model: Tools, Resources, and Prompts. Tools are functions that perform actions—creating records, sending messages, querying APIs. Resources are read-only data sources that provide context—file contents, database schemas, documentation. Prompts are reusable templates that guide the AI's behavior for specific tasks. Each primitive serves a distinct purpose in the AI integration landscape.

The capability negotiation during initialization is a critical design choice. When a client connects to a server, they exchange capability declarations. The server declares which primitives it supports (tools, resources, prompts), and the client declares what it can handle. This negotiation ensures forward compatibility—servers can add new capabilities without breaking existing clients.

Tool annotations provide rich metadata about each tool's behavior. Annotations indicate whether a tool is read-only or destructive, whether it's idempotent, and whether it requires user confirmation. This metadata helps AI clients make informed decisions about when to use each tool and whether to ask for user approval before executing dangerous operations.

Architecture and Design Patterns

MCP Server Architecture

A well-structured MCP server separates concerns across multiple layers:

// server.ts - Main server setup
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { ToolRegistry } from './tools/registry.js';
import { ResourceRegistry } from './resources/registry.js';
 
export function createServer(): Server {
    const server = new Server(
        { name: 'my-integration-server', version: '1.0.0' },
        {
            capabilities: {
                tools: {},
                resources: {},
                prompts: {},
            },
        }
    );
 
    // Register all tools
    const toolRegistry = new ToolRegistry(server);
    toolRegistry.register(new DatabaseTools());
    toolRegistry.register(new FileSystemTools());
    toolRegistry.register(new ApiTools());
 
    // Register all resources
    const resourceRegistry = new ResourceRegistry(server);
    resourceRegistry.register(new SchemaResource());
    resourceRegistry.register(new DocsResource());
 
    return server;
}
 
async function main() {
    const server = createServer();
    const transport = new StdioServerTransport();
    await server.connect(transport);
    console.error('MCP server started on stdio');
}
 
main().catch(console.error);

Tool Definition Patterns

Well-designed tools follow consistent patterns that maximize AI effectiveness:

// tools/database.ts
import { z } from 'zod';
 
const QuerySchema = z.object({
    sql: z.string().describe('SQL query to execute. Must be a SELECT statement.'),
    params: z.array(z.any()).optional().describe('Parameterized query values'),
    limit: z.number().min(1).max(1000).default(100).describe('Maximum rows to return'),
});
 
export class DatabaseTools {
    getTools() {
        return [
            {
                name: 'query_database',
                description: `Execute a read-only SQL query against the database.
                    Use this to retrieve data, check records, or analyze information.
                    Only SELECT statements are allowed. Results are limited to 1000 rows.
                    Use parameterized queries ($1, $2, etc.) for values to prevent SQL injection.`,
                inputSchema: QuerySchema,
                annotations: {
                    readOnlyHint: true,
                    idempotentHint: true,
                    openWorldHint: false,
                },
            },
            {
                name: 'describe_table',
                description: `Get the schema of a database table including columns, types, and constraints.
                    Use this before writing queries to understand the data structure.`,
                inputSchema: z.object({
                    table: z.string().describe('Table name to describe'),
                    schema: z.string().default('public').describe('Database schema name'),
                }),
                annotations: {
                    readOnlyHint: true,
                    idempotentHint: true,
                },
            },
        ];
    }
 
    async handleQuery(args: z.infer<typeof QuerySchema>) {
        const { sql, params, limit } = args;
 
        // Security: Only allow SELECT statements
        if (!sql.trim().toUpperCase().startsWith('SELECT')) {
            throw new Error('Only SELECT statements are allowed');
        }
 
        // Add LIMIT if not present
        const limitedSql = sql.includes('LIMIT')
            ? sql
            : `${sql} LIMIT ${limit}`;
 
        const result = await this.pool.query(limitedSql, params);
 
        return {
            content: [{
                type: 'text',
                text: JSON.stringify({
                    rows: result.rows,
                    rowCount: result.rowCount,
                    fields: result.fields.map(f => ({
                        name: f.name,
                        type: f.dataTypeID,
                    })),
                }, null, 2),
            }],
        };
    }
}

Resource Patterns

Resources provide read-only context that the AI can reference:

// resources/schema.ts
export class SchemaResource {
    async listResources() {
        const tables = await this.getTables();
        return tables.map(table => ({
            uri: `db://schema/${table.name}`,
            name: `Schema: ${table.name}`,
            description: `Database schema for the ${table.name} table`,
            mimeType: 'application/json',
        }));
    }
 
    async readResource(uri: string) {
        const tableName = uri.replace('db://schema/', '');
        const schema = await this.getTableSchema(tableName);
 
        return {
            contents: [{
                uri,
                mimeType: 'application/json',
                text: JSON.stringify({
                    table: tableName,
                    columns: schema.columns.map(col => ({
                        name: col.name,
                        type: col.type,
                        nullable: col.nullable,
                        default: col.default,
                        description: col.comment,
                    })),
                    indexes: schema.indexes,
                    foreignKeys: schema.foreignKeys,
                    sampleData: await this.getSampleData(tableName, 3),
                }, null, 2),
            }],
        };
    }
}

Step-by-Step Implementation

Creating an MCP Server from Scratch

# Initialize project
mkdir my-mcp-server && cd my-mcp-server
npm init -y
 
# Install dependencies
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
 
# Configure TypeScript
cat > tsconfig.json << 'EOF'
{
    "compilerOptions": {
        "target": "ES2022",
        "module": "Node16",
        "moduleResolution": "Node16",
        "outDir": "./dist",
        "rootDir": "./src",
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "declaration": true
    },
    "include": ["src/**/*"]
}
EOF

Implementing a GitHub Integration MCP Server

// src/index.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { Octokit } from '@octokit/rest';
import { z } from 'zod';
 
const server = new Server(
    { name: 'github-mcp', version: '1.0.0' },
    { capabilities: { tools: {}, resources: {} } }
);
 
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
 
// Tool definitions
const ListReposSchema = z.object({
    owner: z.string().describe('GitHub username or organization'),
    type: z.enum(['all', 'owner', 'public', 'private', 'member']).default('all'),
    sort: z.enum(['created', 'updated', 'pushed', 'full_name']).default('updated'),
    per_page: z.number().min(1).max(100).default(30),
});
 
const GetFileSchema = z.object({
    owner: z.string(),
    repo: z.string(),
    path: z.string().describe('File path relative to repository root'),
    ref: z.string().optional().describe('Branch, tag, or commit SHA'),
});
 
const CreateIssueSchema = z.object({
    owner: z.string(),
    repo: z.string(),
    title: z.string(),
    body: z.string().optional(),
    labels: z.array(z.string()).optional(),
    assignees: z.array(z.string()).optional(),
});
 
server.setRequestHandler(ListToolsRequestSchema, async () => ({
    tools: [
        {
            name: 'list_repositories',
            description: 'List repositories for a GitHub user or organization',
            inputSchema: ListReposSchema,
            annotations: { readOnlyHint: true },
        },
        {
            name: 'get_file_contents',
            description: 'Get the contents of a file from a GitHub repository',
            inputSchema: GetFileSchema,
            annotations: { readOnlyHint: true },
        },
        {
            name: 'create_issue',
            description: 'Create a new issue in a GitHub repository',
            inputSchema: CreateIssueSchema,
            annotations: { readOnlyHint: false },
        },
        {
            name: 'search_code',
            description: 'Search for code across GitHub repositories',
            inputSchema: z.object({
                query: z.string().describe('Search query using GitHub code search syntax'),
                per_page: z.number().min(1).max(100).default(30),
            }),
            annotations: { readOnlyHint: true },
        },
    ],
}));
 
server.setRequestHandler(CallToolRequestSchema, async (request) => {
    const { name, arguments: args } = request.params;
 
    switch (name) {
        case 'list_repositories': {
            const { owner, type, sort, per_page } = ListReposSchema.parse(args);
            const { data } = await octokit.repos.listForUser({
                username: owner, type, sort, per_page,
            });
            return {
                content: [{
                    type: 'text',
                    text: JSON.stringify(data.map(r => ({
                        name: r.name,
                        description: r.description,
                        language: r.language,
                        stars: r.stargazers_count,
                        updated: r.updated_at,
                        url: r.html_url,
                    })), null, 2),
                }],
            };
        }
 
        case 'get_file_contents': {
            const { owner, repo, path, ref } = GetFileSchema.parse(args);
            const { data } = await octokit.repos.getContent({
                owner, repo, path, ref,
            });
            if ('content' in data) {
                return {
                    content: [{
                        type: 'text',
                        text: Buffer.from(data.content, 'base64').toString('utf-8'),
                    }],
                };
            }
            throw new Error('Path is a directory, not a file');
        }
 
        case 'create_issue': {
            const { owner, repo, title, body, labels, assignees } = CreateIssueSchema.parse(args);
            const { data } = await octokit.issues.create({
                owner, repo, title, body, labels, assignees,
            });
            return {
                content: [{
                    type: 'text',
                    text: JSON.stringify({
                        number: data.number,
                        url: data.html_url,
                        title: data.title,
                        state: data.state,
                    }, null, 2),
                }],
            };
        }
 
        case 'search_code': {
            const { query, per_page } = args as any;
            const { data } = await octokit.search.code({ q: query, per_page });
            return {
                content: [{
                    type: 'text',
                    text: JSON.stringify(data.items.map(item => ({
                        name: item.name,
                        path: item.path,
                        repository: item.repository.full_name,
                        url: item.html_url,
                    })), null, 2),
                }],
            };
        }
 
        default:
            throw new Error(`Unknown tool: ${name}`);
    }
});
 
async function main() {
    const transport = new StdioServerTransport();
    await server.connect(transport);
}
 
main().catch(console.error);

Configuring MCP Clients

Claude Desktop configuration (~/.config/claude/claude_desktop_config.json):

{
    "mcpServers": {
        "github": {
            "command": "node",
            "args": ["/path/to/github-mcp/dist/index.js"],
            "env": {
                "GITHUB_TOKEN": "ghp_xxxxxxxxxxxx"
            }
        },
        "filesystem": {
            "command": "npx",
            "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/documents"]
        },
        "postgres": {
            "command": "npx",
            "args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"]
        }
    }
}

MCP integration ecosystem

Real-World Use Cases and Case Studies

Use Case 1: Development Assistant

An MCP-powered development assistant can read code from repositories, query databases for schema information, search documentation, create issues, and trigger CI/CD pipelines—all through standardized MCP tools. Developers interact conversationally: "Find all TODO comments in the auth module and create issues for them" translates to multiple MCP tool calls orchestrated by the AI.

Use Case 2: Data Analysis Platform

Business analysts use MCP-connected AI to query databases, generate visualizations, and produce reports. The AI understands the database schema through MCP resources, constructs queries through MCP tools, and presents results in natural language. This democratizes data access without requiring SQL knowledge.

Use Case 3: Customer Support Automation

MCP servers integrate with CRM systems, knowledge bases, and ticketing platforms. Support agents ask the AI to "look up customer order history and check shipping status," which triggers MCP calls to the order service and shipping API. The AI synthesizes responses from multiple systems into a coherent answer.

Best Practices for Production

  1. Validate all inputs strictly: Use Zod or JSON Schema to validate every tool input. Reject invalid inputs with clear error messages that help the AI correct its request.

  2. Implement rate limiting: AI models can call tools rapidly. Implement rate limiting to prevent overwhelming downstream services. Return clear rate limit messages.

  3. Use read-only defaults: Tools that modify data should require explicit confirmation. Implement dry-run modes and return previews before making changes.

  4. Provide rich error messages: Error messages should explain what went wrong, why, and how to fix it. "Query failed" is useless; "Table 'users' doesn't exist. Available tables: customers, accounts" is actionable.

  5. Log all tool calls: Log every tool invocation with parameters, results, and timing. This audit trail is essential for debugging and security monitoring.

  6. Implement authentication securely: Use environment variables for secrets. Implement token refresh for long-running servers. Never log authentication credentials.

  7. Version your MCP server: Include version numbers in the server configuration. Use semantic versioning to communicate breaking changes.

  8. Test with MCP Inspector: Use the MCP Inspector tool to test your server interactively before integrating with AI clients.

Common Pitfalls and Solutions

PitfallImpactSolution
Vague tool descriptionsModel misuses tools or avoids themWrite specific descriptions with usage examples
No input validationSecurity vulnerabilities and errorsValidate all inputs with Zod before processing
Exposing sensitive dataSecurity breachesFilter sensitive fields; implement data masking
Too many toolsModel confusionGroup related tools; provide 5-15 well-designed tools max
No error handlingServer crashes on unexpected inputsWrap handlers in try/catch with descriptive errors
Synchronous blockingServer becomes unresponsiveUse async/await; implement timeouts for all I/O

Performance Optimization

Cache frequently accessed resources to reduce latency:

class ResourceCache {
    private cache = new Map<string, { data: any; expiry: number }>();
 
    async get(key: string, fetcher: () => Promise<any>, ttlMs: number = 60000) {
        const cached = this.cache.get(key);
        if (cached && cached.expiry > Date.now()) return cached.data;
 
        const data = await fetcher();
        this.cache.set(key, { data, expiry: Date.now() + ttlMs });
        return data;
    }
}

Use connection pooling for database connections and HTTP keep-alive for API clients. Batch similar requests when possible.

Comparison with Alternatives

FeatureMCPFunction CallingCustom PluginsLangChain Tools
StandardizationOpen protocolPer-providerPer-platformFramework-specific
Model AgnosticYesNoNoYes
DiscoveryBuilt-inManualManualManual
Resource AccessNativeVia toolsVariesVia tools
EcosystemGrowing rapidlyPer-providerPlatform-specificLarge
ComplexityMediumLowMediumMedium

MCP provides the most standardized approach. Function calling is simpler but vendor-locked. Custom plugins are platform-specific. LangChain tools work within the LangChain ecosystem but aren't universally compatible.

Advanced Patterns and Techniques

MCP Server Composition

Chain multiple MCP servers together for complex integrations:

class CompositeServer {
    private servers: Map<string, MCPServer> = new Map();
 
    async registerServer(name: string, server: MCPServer) {
        this.servers.set(name, server);
        // Aggregate tools from all servers
        for (const tool of await server.listTools()) {
            this.registerTool({ ...tool, name: `${name}_${tool.name}` });
        }
    }
 
    async callTool(name: string, args: any) {
        const [serverName, toolName] = name.split('_', 2);
        const server = this.servers.get(serverName);
        if (!server) throw new Error(`Unknown server: ${serverName}`);
        return server.callTool(toolName, args);
    }
}

Dynamic Tool Loading

Load tools based on user permissions or context:

class DynamicToolServer {
    async listTools(request: ListToolsRequest) {
        const user = await this.authenticateUser(request);
        const allTools = await this.getAllTools();
 
        // Filter tools based on user permissions
        return allTools.filter(tool =>
            this.hasPermission(user, tool.name)
        );
    }
}

Testing Strategies

Test MCP servers with the built-in test utilities:

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
 
describe('MCP Server', () => {
    let client: Client;
 
    beforeEach(async () => {
        const server = createServer();
        const [clientTransport, serverTransport] = InMemoryTransport.createPair();
        await server.connect(serverTransport);
        client = new Client({ name: 'test', version: '1.0.0' });
        await client.connect(clientTransport);
    });
 
    it('should list tools', async () => {
        const { tools } = await client.listTools();
        expect(tools.length).toBeGreaterThan(0);
    });
 
    it('should execute tool', async () => {
        const result = await client.callTool({
            name: 'list_repositories',
            arguments: { owner: 'octocat' },
        });
        expect(result.content[0].type).toBe('text');
    });
 
    it('should list resources', async () => {
        const { resources } = await client.listResources();
        expect(resources.length).toBeGreaterThan(0);
    });
});

Future Outlook

MCP is evolving rapidly with new features like streaming tool responses, bidirectional notifications, and resource subscriptions. The protocol is being adopted beyond Anthropic—OpenAI, Google, and open-source projects are implementing support. The MCP server ecosystem is growing with pre-built servers for hundreds of services. Standardization through the MCP specification ensures long-term compatibility across AI platforms.

Conclusion

The Model Context Protocol standardizes how AI models connect to external systems, eliminating the fragmentation of custom integrations. By implementing MCP servers, you give AI assistants the ability to interact with your specific infrastructure through a universal protocol. The result is a thriving ecosystem of reusable integrations that benefit the entire AI community.

Key takeaways: design tools with clear descriptions and strict validation, implement proper authentication and rate limiting, and cache frequently accessed resources. Start with the MCP SDK, test with MCP Inspector, and share your servers with the community. MCP is the bridge between AI capabilities and real-world systems.

For further reading, consult the MCP specification at modelcontextprotocol.io, the Anthropic MCP documentation, and the growing collection of community MCP servers.


MCP connecting AI