Building Dynamic AI Systems with MCP

Building Dynamic AI Systems with MCP

MCP gives a model a standardized bridge to live data and executable functions — the basis for a modular, tool-augmented system rather than a closed text generator. The best way to internalize that architecture is to build a miniature of it. This reference implements the protocol’s core ideas in plain Python: the data structures, a server, a client, and the async patterns that hold it together.

The code below is a teaching model, deliberately built from first principles to expose the moving parts. It is not the production wire protocol — for that, and for the roles this mirrors, see MCP Foundations and Architecture.

The three data structures

Structured information flows between an AI system and its environment through three types:

  • Resource — external data or a document (a file, a database record).
  • Tool — an executable capability the model can invoke (an API call, an analysis function).
  • Message — one unit of communication; a sequence of them forms the interaction history.
from dataclasses import dataclass, asdict
from typing import Dict, List, Any, Optional, Callable
from datetime import datetime

@dataclass
class Resource:
   uri: str
   name: str
   description: str
   mime_type: str
   content: Any = None

@dataclass
class Tool:
   name: str
   description: str
   parameters: Dict[str, Any]
   handler: Optional[Callable] = None

@dataclass
class Message:
   role: str
   content: str
   timestamp: str = None
   def __post_init__(self):
       if not self.timestamp:
           self.timestamp = datetime.now().isoformat()

Architecture

Two components carry the system: a server that registers and executes resources and tools, and a client that connects to servers, queries them, and keeps a running context of every interaction. That local context is what makes the exchange stateful — the client remembers what it fetched and called.

The server

import asyncio

class MCPServer:
   def __init__(self, name: str):
       self.name = name
       self.resources: Dict[str, Resource] = {}
       self.tools: Dict[str, Tool] = {}
       self.capabilities = {"resources": True, "tools": True, "prompts": True, "logging": True}

   def register_resource(self, resource: Resource) -> None:
       self.resources[resource.uri] = resource

   def register_tool(self, tool: Tool) -> None:
       self.tools[tool.name] = tool

   async def get_resource(self, uri: str) -> Optional[Resource]:
       await asyncio.sleep(0.1) # Simulate I/O
       return self.resources.get(uri)

   async def execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
       if tool_name not in self.tools:
           raise ValueError(f"Tool '{tool_name}' not found")
       tool = self.tools[tool_name]
       if tool.handler:
           return await tool.handler(**arguments)
       return {"status": "executed", "tool": tool_name, "args": arguments}

   def list_resources(self) -> List[Dict[str, str]]:
       return [{"uri": r.uri, "name": r.name, "description": r.description} for r in self.resources.values()]

   def list_tools(self) -> List[Dict[str, Any]]:
       return [{"name": t.name, "description": t.description, "parameters": t.parameters} for t in self.tools.values()]

The list_resources and list_tools methods are the discovery surface — the equivalent of resources/list and tools/list on a real server.

The client

class MCPClient:
   def __init__(self, client_id: str):
       self.client_id = client_id
       self.connected_servers: Dict[str, MCPServer] = {}
       self.context: List[Message] = []

   def connect_server(self, server: MCPServer) -> None:
       self.connected_servers[server.name] = server

   async def query_resources(self, server_name: str) -> List[Dict[str, str]]:
       if server_name not in self.connected_servers:
           raise ValueError(f"Not connected to server: {server_name}")
       return self.connected_servers[server_name].list_resources()

   async def fetch_resource(self, server_name: str, uri: str) -> Optional[Resource]:
       server = self.connected_servers[server_name]
       resource = await server.get_resource(uri)
       if resource:
           self.add_to_context(Message(role="system", content=f"Fetched resource: {resource.name}"))
       return resource

   async def call_tool(self, server_name: str, tool_name: str, **kwargs) -> Any:
       server = self.connected_servers[server_name]
       result = await server.execute_tool(tool_name, kwargs)
       self.add_to_context(Message(role="system", content=f"Tool '{tool_name}' executed"))
       return result

   def add_to_context(self, message: Message) -> None:
       self.context.append(message)

   def get_context(self) -> List[Dict[str, Any]]:
       return [asdict(msg) for msg in self.context]

Every fetch and call appends a Message, so get_context() yields a complete, replayable record of the session.

Example tool handlers

Async functions stand in for real external operations and get registered as tools:

import random

async def analyze_sentiment(text: str) -> Dict[str, Any]:
   await asyncio.sleep(0.2)
   sentiments = ["positive", "negative", "neutral"]
   return {"text": text, "sentiment": random.choice(sentiments), "confidence": round(random.uniform(0.7, 0.99), 2)}

async def summarize_text(text: str, max_length: int = 100) -> Dict[str, str]:
   await asyncio.sleep(0.15)
   summary = text[:max_length] + "..." if len(text) > max_length else text
   return {"original_length": len(text), "summary": summary}

async def search_knowledge(query: str, top_k: int = 3) -> List[Dict[str, Any]]:
   await asyncio.sleep(0.25)
   mock_results = [{"title": f"Result {i+1} for '{query}'", "score": round(random.uniform(0.5, 1.0), 2)} for i in range(top_k)]
   return sorted(mock_results, key=lambda x: x["score"], reverse=True)

Putting it together

A full run exercises the protocol end to end:

  1. Initialize an MCPServer.
  2. Register resources (documents) and tools (analyze_sentiment and friends).
  3. Connect an MCPClient to the server.
  4. Interact — list resources, fetch a specific one, and call tools with arguments.
  5. Inspect context — the client’s history now holds a stateful record of everything that happened.

Why the design holds up

  • Modularity — resources and tools plug in independently, so the system extends without rewrites.
  • External context — resources feed structured, outside data into the model on demand.
  • Dynamic action — tools let the model do things, not just describe them.
  • Async by default — the async core overlaps I/O, which is what makes the pattern scale to systems talking to many APIs and data sources at once.
This entry was posted in . Bookmark the permalink.