Sharing notes on the Model Context Protocol (MCP) and its role in AI model interactions and build a sample MCP server to see how LLM-base agent tools interact with the server.

How Model Context Protocol (MCP) Works


The Model Context Protocol (MCP) is an open standard that enables seamless integration between AI assistants and external tools or data sources. Since major AI service provider adopt the MCP standards, the protocol allow us to create a single MCP server that works with any MCP-compatible host that is AI tools.

TL;DR

Don’t have time to read the full article? Here’s a quick conversational summary that covers the key points:

How It Works

MCP uses JSON-RPC for communication and provides a standardized way to expose tools, manage permissions, and handle errors - making it easier to give AI assistants the ability to perform real-world tasks while maintaining security and control.

Here is the high-level call flow of how the Model Context Protocol works, including the key components and their interactions:

External ToolAI AgentFile SystemMCP Server(Filesystem Tools)MCP Client(Protocol Handler)MCP Host(AI Agent Core)Chat InterfaceFile SystemMCP Server(Filesystem Tools)MCP Client(Protocol Handler)MCP Host(AI Agent Core)Chat InterfaceInitialization Phase (happens once per connection)Ready for normal operations(Authorization flow would happen here if required)Operation Phase - User asks AI to read a fileWithin AI ApplicationCross-process boundary(stdio/SSE)Within AI ApplicationConnection remains open for more operationsUserinitialize request(protocol version, capabilities)1initialize response(capabilities, server info)2initialized notification3"What's in my config.json file?"4Process user message5Parse request & identifytool needed (file read)6callTool("read_file", {...})7tools/call JSON-RPC Request8Validate request &check permissions9fs.readFile("config.json")10File contents11JSON-RPC Response12Tool result: file contents13Analyze JSON structureGenerate human-friendly response14"Your config.json contains:• Theme: dark mode• Language: English• Debug: disabled"15💬 "Your config.json contains:• Theme: dark mode• Language: English• Debug: disabled"16User

  1. Initialize Request (MCP Client → MCP Server)

    • Client initiates connection with protocol version and capabilities
    • Includes client info (name, version) and supported features
    • This happens once when the connection is established
    {
      "jsonrpc": "2.0",
      "method": "initialize",
      "params": {
        "protocolVersion": "2024-11-05",
        "capabilities": {
          "roots": { "listChanged": true },
          "sampling": {}
        },
        "clientInfo": {
          "name": "AIAgent",
          "version": "1.0.0"
        }
      }
    }
  2. Initialize Response (MCP Server → MCP Client)

    • Server responds with its capabilities and supported tools
    • Includes server info and available operations
    • Protocol version negotiation happens here
    {
      "jsonrpc": "2.0",
      "result": {
        "protocolVersion": "2024-11-05",
        "capabilities": {
          "tools": { "listChanged": true }
        },
        "serverInfo": {
          "name": "FileSystemServer",
          "version": "1.0.0"
        }
      }
    }
  3. Initialized Notification (MCP Client → MCP Server)

    • Client confirms it’s ready for normal operations
    • Server can now accept tool calls and other requests
    • No response expected for this notification
  4. User Input (User → Chat Interface)

    • User types a natural language question in the chat interface
    • Example: “What’s in my config.json file?”
    • This starts the actual operation phase
  5. Chat UI Forward (Chat Interface → MCP Host)

    • The chat interface forwards the message to the AI agent core
    • The UI component passes the raw user input to the AI processing engine
    • Chat interface is activated to handle the request
  6. Intent Recognition (MCP Host → MCP Host)

    • AI agent analyzes the request using its language model
    • Determines that it needs to read a file from the filesystem
    • Identifies the appropriate tool to use (file read operation)
  7. Internal Tool Call (MCP Host → MCP Client)

    • Host invokes the MCP client with the tool name and parameters
    • Passes: callTool("read_file", { "path": "config.json" })
    • This is an internal call within the AI application
  8. JSON-RPC Request (MCP Client → MCP Server)

    • Client formats the request according to JSON-RPC 2.0 specification
    • Sends the request across process boundary via stdio or SSE
    • This is the critical boundary between the AI application and external tools
    {
      "jsonrpc": "2.0",
      "method": "tools/call",
      "params": {
        "name": "read_file",
        "arguments": {
          "path": "config.json"
        }
      },
      "id": 1
    }
  9. Request Validation (MCP Server → MCP Server)

    • MCP server validates the incoming JSON-RPC request format
    • Checks if the requested tool exists and is available
    • Verifies permissions for the requested operation
  10. File System Access (MCP Server → File System)

    • Server executes the actual file read operation
    • Calls system API: fs.readFile("config.json")
    • This is where the tool interacts with actual system resources
  11. File Contents Retrieved (File System → MCP Server)

    • Operating system returns the file contents to the MCP server
    • Data is read from disk and passed back to the server
    • Example: JSON configuration data with theme and language settings
    {
      "theme": "dark",
      "language": "en",
      "debug": false
    }
  12. JSON-RPC Response (MCP Server → MCP Client)

    • Server packages the file contents in JSON-RPC response format
    • Includes the result wrapped in proper JSON-RPC structure
    • Response crosses back over the process boundary
    {
      "jsonrpc": "2.0",
      "result": {
        "content": "{\n  \"theme\": \"dark\",\n  \"language\": \"en\",\n  \"debug\": false\n}"
      },
      "id": 1
    }
  13. Result Delivery (MCP Client → MCP Host)

    • Client unwraps the JSON-RPC response and extracts the result
    • Passes the tool result (file contents) back to the host
    • Error handling occurs here if the operation failed
  14. Data Processing (MCP Host → MCP Host)

    • AI analyzes the received JSON data structure
    • Interprets the configuration settings
    • Generates a human-friendly summary of the contents
  15. Response Generation (MCP Host → Chat Interface)

    • Host formats the analysis into a user-friendly message
    • Includes bullet points summarizing the configuration
    • Prepares the response for display
  16. User Display (Chat Interface → User)

    • Chat interface renders the final response to the user
    • Shows formatted message with configuration details
    • Completes the request-response cycle

How does AI Agent Know What MCP Tool to Use?

When an AI agent receives a user request, it needs to determine which MCP tools can help accomplish the task. This happens through a capability discovery process:

Tool Discovery and Registration

  1. Initial Connection: When an MCP client connects to a server, it sends an initialization request
  2. Capability Advertisement: The server responds with its capabilities, including:
    • Available tools and their descriptions
    • Required and optional parameters for each tool
    • Input/output schemas
  3. Dynamic Updates: Servers can notify clients when tools are added or removed

AI Decision Making

The AI agent uses its language understanding to:

  • Parse the user’s intent from natural language
  • Match the intent to available tool descriptions
  • Select the most appropriate tool(s) for the task
  • Extract necessary parameters from the user’s request

For example, when a user asks “What’s in my config.json?”, the AI:

  • Recognizes this requires file reading capability
  • Finds the read_file tool in its available tools
  • Extracts the filename parameter (“config.json”)
  • Calls the appropriate MCP server with the correct parameters

MCP Client Features

1. Filesystem Roots

The Model Context Protocol provides a standardized way for clients to expose filesystem “roots” to servers. Roots define the boundaries of where servers can operate within the filesystem:

  • Access Control: Roots establish which directories servers can access
  • Security Boundaries: Prevents servers from accessing sensitive system areas
  • Dynamic Updates: Clients can notify servers when available roots change
  • Multiple Roots: Supports multiple root directories for different purposes

Example roots configuration:

{
  "roots": [
    { "path": "/home/user/documents", "name": "Documents" },
    { "path": "/home/user/projects", "name": "Projects" }
  ]
}

2. Sampling (LLM Access)

MCP enables servers to request LLM sampling (completions) from language models via clients:

  • No API Keys Required: Servers don’t need their own model API keys
  • Client Control: Clients maintain control over model selection and permissions
  • Multi-modal Support: Supports text, audio, and image-based interactions
  • Context Inclusion: Can include MCP server context in prompts

This allows MCP servers to leverage AI capabilities while keeping sensitive credentials secure on the client side.

3. Elicitation (User Input Requests)

New in June 2024, the elicitation feature allows servers to request additional information from users:

  • Structured Requests: Servers define JSON schemas for expected responses
  • Validation: Client validates user input against the schema
  • User Control: Users maintain control over what information they share
  • Dynamic Workflows: Enables interactive, multi-step operations

Example elicitation request:

{
  "prompt": "Please select the database to connect to:",
  "schema": {
    "type": "object",
    "properties": {
      "database": {
        "type": "string",
        "enum": ["production", "staging", "development"]
      }
    },
    "required": ["database"]
  }
}

MCP Server Features

MCP servers provide the fundamental building blocks for adding context to language models. These primitives enable rich interactions between clients, servers, and language models through three core capabilities:

1. Prompts

Pre-defined templates or instructions that guide language model interactions:

  • Reusable Templates: Servers can expose prompt templates for common tasks
  • Dynamic Arguments: Prompts can accept parameters to customize their behavior
  • Context Integration: Automatically include relevant context from resources
  • Structured Output: Guide models to produce consistent, formatted responses

Example prompt definition:

{
  "name": "analyze-code",
  "description": "Analyze code for potential improvements",
  "arguments": [
    {
      "name": "language",
      "description": "Programming language",
      "required": true
    },
    {
      "name": "focus",
      "description": "Area to focus on (performance, security, readability)",
      "required": false
    }
  ]
}

2. Resources

Structured data or content that provides additional context to the model:

  • Static Resources: Configuration files, documentation, schemas
  • Dynamic Resources: Live data feeds, API responses, database queries
  • Subscribable: Clients can subscribe to resource changes
  • Metadata Rich: Include MIME types, descriptions, and timestamps

Example resource types:

{
  "resources": [
    {
      "uri": "file:///config/app.json",
      "name": "Application Config",
      "mimeType": "application/json"
    },
    {
      "uri": "db://users/schema",
      "name": "User Database Schema",
      "mimeType": "application/sql"
    },
    {
      "uri": "api://weather/current",
      "name": "Current Weather",
      "mimeType": "application/json"
    }
  ]
}

3. Tools

Executable functions that allow models to perform actions or retrieve information:

  • Action Execution: Perform operations like file I/O, API calls, calculations
  • Information Retrieval: Query databases, search documents, fetch data
  • System Integration: Interact with external services and systems
  • Parameter Validation: Define required and optional parameters with schemas

Example tool definition:

{
  "name": "search_database",
  "description": "Search the user database",
  "inputSchema": {
    "type": "object",
    "properties": {
      "query": {
        "type": "string",
        "description": "Search query"
      },
      "filters": {
        "type": "object",
        "description": "Optional filters"
      },
      "limit": {
        "type": "number",
        "description": "Maximum results",
        "default": 10
      }
    },
    "required": ["query"]
  }
}

Control Hierarchy

The MCP primitives follow a clear control hierarchy:

MCP Server

Prompts

Resources

Tools

Guide Interactions

Template Responses

Provide Context

Supply Data

Execute Actions

Retrieve Information

AI Model

Enhanced Response

This hierarchy enables:

  1. Prompts control how the AI interprets and responds to requests
  2. Resources provide the necessary context and data
  3. Tools enable the AI to take actions and gather information
  4. Together, they create a complete system for enhanced AI capabilities

Capability Advertisement

During initialization, servers advertise their available primitives:

{
  "capabilities": {
    "prompts": {
      "listChanged": true  // Supports dynamic prompt updates
    },
    "resources": {
      "subscribe": true,   // Supports resource subscriptions
      "listChanged": true  // Supports dynamic resource updates
    },
    "tools": {
      "listChanged": true  // Supports dynamic tool updates
    }
  }
}

This allows clients to discover and utilize server capabilities dynamically, enabling flexible and extensible integrations.

Building an MCP Server: Filesystem Config Reader

Let’s build the MCP server from our example - a filesystem tools server that can read configuration files. We’ll use the official @modelcontextprotocol/sdk for TypeScript.

Project Setup

First, create a new TypeScript project using Bun:

mkdir mcp-filesystem-server
cd mcp-filesystem-server
bun init

When prompted, accept the defaults. This creates a tsconfig.json and basic project structure.

Install the MCP SDK:

bun add @modelcontextprotocol/sdk

Implementation

Create filesystem-server.ts in the project root with a complete implementation that includes both tools and resources:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
  Tool,
  Resource,
} from "@modelcontextprotocol/sdk/types.js";
import fs from "fs/promises";
import path from "path";

// Define our tools
const tools: Tool[] = [
  {
    name: "read_file",
    description: "Read the contents of a file",
    inputSchema: {
      type: "object",
      properties: {
        path: {
          type: "string",
          description: "The path to the file to read",
        },
      },
      required: ["path"],
    },
  },
  {
    name: "list_directory",
    description: "List contents of a directory",
    inputSchema: {
      type: "object",
      properties: {
        path: {
          type: "string",
          description: "The directory path to list",
        },
      },
      required: ["path"],
    },
  },
];

// Define available resources
const resources: Resource[] = [
  {
    uri: "file:///config.json",
    name: "Application Configuration",
    description: "Main configuration file for the application",
    mimeType: "application/json",
  },
  {
    uri: "file:///package.json",
    name: "Package Configuration",
    description: "Node.js package configuration",
    mimeType: "application/json",
  },
];

// Create the MCP server with all capabilities
const server = new Server(
  {
    name: "filesystem-server",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
      resources: {},
    },
  }
);

// Handle tool listing
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools,
  };
});

// Handle resource listing
server.setRequestHandler(ListResourcesRequestSchema, async () => {
  return { 
    resources,
  };
});

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

  switch (name) {
    case "read_file": {
      const filePath = args.path as string;
      
      try {
        // Validate path is within allowed bounds
        const resolvedPath = path.resolve(filePath);
        
        // Read the file
        const content = await fs.readFile(resolvedPath, "utf-8");
        
        // Detect if it's JSON and parse it
        let result: any = content;
        if (filePath.endsWith(".json")) {
          try {
            result = JSON.parse(content);
          } catch {
            // If JSON parsing fails, return raw content
          }
        }
        
        return {
          content: [
            {
              type: "text",
              text: typeof result === "object" 
                ? JSON.stringify(result, null, 2) 
                : result,
            },
          ],
        };
      } catch (error) {
        throw new Error(`Failed to read file: ${error.message}`);
      }
    }
    
    case "list_directory": {
      const dirPath = args.path as string;
      
      try {
        const resolvedPath = path.resolve(dirPath);
        const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
        
        const listing = entries.map((entry) => ({
          name: entry.name,
          type: entry.isDirectory() ? "directory" : "file",
        }));
        
        return {
          content: [
            {
              type: "text",
              text: JSON.stringify(listing, null, 2),
            },
          ],
        };
      } catch (error) {
        throw new Error(`Failed to list directory: ${error.message}`);
      }
    }
    
    default:
      throw new Error(`Unknown tool: ${name}`);
  }
});

// Handle resource reading
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const { uri } = request.params;
  
  // Extract the file path from the URI
  const filePath = uri.replace("file:///", "");
  
  // Check if this is one of our defined resources
  const resource = resources.find(r => r.uri === uri);
  if (!resource) {
    throw new Error(`Unknown resource: ${uri}`);
  }
  
  try {
    const content = await fs.readFile(filePath, "utf-8");
    return {
      contents: [
        {
          uri,
          mimeType: resource.mimeType,
          text: content,
        },
      ],
    };
  } catch (error) {
    throw new Error(`Failed to read resource: ${error.message}`);
  }
});

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

main().catch((error) => {
  console.error("Server error:", error);
  process.exit(1);
});

Running the Server

Add scripts to package.json:

{
  "scripts": {
    "start": "bun run filesystem-server.ts",
    "dev": "bun --watch filesystem-server.ts"
  }
}

The server runs over stdio, so it needs to be launched by an MCP client. For testing, you can use the MCP Inspector:

# Install MCP Inspector globally with Bun
bunx @modelcontextprotocol/inspector bun filesystem-server.ts

Client Configuration

To use this server with Claude Desktop, add it to your configuration:

macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json

{
  "mcpServers": {
    "filesystem": {
      "command": "bun",
      "args": ["run", "/path/to/your/filesystem-server.ts"]
    }
  }
}

Or if you prefer to use the Bun binary directly:

{
  "mcpServers": {
    "filesystem": {
      "command": "/path/to/bun",
      "args": ["/path/to/your/filesystem-server.ts"]
    }
  }
}

Security Considerations

When building filesystem MCP servers, always:

  1. Validate Paths: Ensure paths don’t escape allowed directories
  2. Check Permissions: Verify the server has appropriate read permissions
  3. Limit Scope: Consider restricting to specific directories
  4. Handle Errors: Gracefully handle missing files and permissions errors
  5. Log Access: Keep audit logs of file access for security

This example demonstrates the core concepts of building an MCP server that matches our sequence diagram - handling initialization, exposing tools, and processing file read requests.

Appendix - Authorization

MCP supports OAuth 2.1-based authorization for HTTP transports, allowing secure access to protected resources. This is optional and only applies when:

  • Using HTTP-based transport (not stdio)
  • The MCP server requires authentication
  • The server implements OAuth 2.0 Protected Resource Metadata

Here’s how the authorization flow works:

Protected ServicesAI AgentAuthorization ServerMCP Server(Resource Server)MCP ClientBrowserUserAuthorization ServerMCP Server(Resource Server)MCP ClientBrowserUserInitial request without authenticationDiscovery phaseDynamic client registration (if supported)OAuth 2.1 Authorization Code Flow with PKCEAuthenticated MCP communicationToken remains valid for subsequent requestsMCP request (no token)1HTTP 401 UnauthorizedWWW-Authenticate header2GET /.well-known/oauth-protected-resource3Resource metadata with auth server URL4GET /.well-known/oauth-authorization-server5Authorization server metadata6POST /register (client registration)7Client credentials8Open authorization URLwith code_challenge & resource9Authorization request10Login/consent page11Approve access12Redirect with authorization code13Authorization code callback14Token requestwith code_verifier & resource15Access token (+ refresh token)16MCP request with Bearer token17MCP response18

Key security features of the authorization flow:

  1. PKCE (Proof Key for Code Exchange): Prevents authorization code interception
  2. Resource Indicators: Tokens are bound to specific MCP servers
  3. Dynamic Registration: Clients can register automatically without manual setup
  4. Token Validation: Servers verify tokens are intended for them specifically

References