Part III · Week 5

Protocols & Standards

MCP, A2A, and the open standards that define how agents communicate with tools and each other in 2026

Chapter 10
MCP — Model Context Protocol

Model Context Protocol (MCP) is the universal standard for connecting LLM agents to external tools, data sources, and services. Released by Anthropic in late 2024, it has become the standard for agent tool integrations. For the official specifications, see the Model Context Protocol Documentation.

The problem MCP solves

Before MCP, every AI company building tools faced an N×M integration problem. Stripe wanted to expose their API to Claude, GPT-4o, Gemini, and Llama — that was 4 custom integrations to build, test, and maintain. Every time a new LLM appeared, they had to build another one. And every LLM provider had to document their own tool format, leading to a fragmented ecosystem where nothing was compatible.

Before MCP vs After MCP
Tangled integrations where every LLM host needs a custom integration for every tool.
Claude GPT-4o Gemini Stripe GitHub Jira
USB-C style standard interface. Every client and tool integrates via the central MCP model.
Claude GPT-4o Gemini MCP Host / Protocol Stripe GitHub Jira

MCP architecture — three components

MCP Host
The AI application that wants to use tools — Claude, your custom agent, Cursor, VS Code Copilot. Manages the lifecycle of MCP connections and presents tool capabilities to the LLM.
MCP Client
The protocol library embedded in the host that speaks JSON-RPC 2.0 to MCP servers. Usually the SDK you import. Handles connection, tool discovery, and result passing.
MCP Server
A process that exposes tools, resources, and prompts over the MCP protocol. You build this to give agents access to your data or service. Can run as stdio (local process) or HTTP with SSE (remote service).

What MCP servers expose

PrimitiveWhat it isExampleWho controls
ToolsFunctions the agent can call — search, write, computesearch_documents(), create_ticket()Server — agent must call explicitly
ResourcesRead-only data the agent can browse and readdatabase://schema, file://README.mdAgent — reads as context
PromptsPre-built prompt templates the agent can invoke"summarise this PR", "explain this error"Server — reusable instructions

Building a production MCP server — Python (FastMCP)

Python — FastMCP server
from mcp.server.fastmcp import FastMCP
from pymongo import MongoClient
from datetime import datetime
import os

# Any agent (Claude, GPT-4o, Llama) can now query your MongoDB
mcp = FastMCP("zetwerk-inventory-server")
db  = MongoClient(os.getenv("MONGO_URI"))["production"]

# ── TOOLS (agent can call these) ──────────────────────────────

@mcp.tool()
async def search_inventory(
    product_name: str,
    min_stock: int = 0,
    category: str = None
) -> list[dict]:
    """
    Search the product inventory by name and optional filters.
    Returns products with stock levels, pricing, and supplier info.
    Use when an agent needs to check product availability.
    Args:
        product_name: Full or partial product name to search
        min_stock: Minimum stock quantity (default 0 = include out-of-stock)
        category: Optional category filter (e.g. 'fasteners', 'steel')
    """
    query = {"name": {"$regex": product_name, "$options": "i"}}
    if min_stock: query["stock"] = {"$gte": min_stock}
    if category:  query["category"] = category
    return list(db.inventory.find(query, {"_id": 0}).limit(20))

@mcp.tool()
async def get_supplier_quotes(product_id: str) -> list[dict]:
    """Get all supplier quotes for a specific product ID, sorted by price."""
    return list(db.quotes.find(
        {"product_id": product_id},
        {"_id": 0}
    ).sort("unit_price", 1))

@mcp.tool()
async def create_purchase_order(
    supplier_id: str,
    product_id: str,
    quantity: int,
    requested_by: str
) -> dict:
    """
    Create a purchase order. REQUIRES human approval — do not call autonomously.
    This creates a real order in the production system.
    """
    order = {
        "supplier_id": supplier_id, "product_id": product_id,
        "quantity": quantity, "requested_by": requested_by,
        "status": "pending_approval", "created_at": datetime.utcnow()
    }
    result = db.purchase_orders.insert_one(order)
    return {"order_id": str(result.inserted_id), "status": "pending_approval"}

# ── RESOURCES (agent can read these for context) ──────────────

@mcp.resource("inventory://schema")
async def get_schema() -> str:
    """Database schema — agent reads this to understand data structure"""
    return """
    Collections:
    - inventory: {product_id, name, category, stock, unit, min_reorder_qty}
    - quotes: {product_id, supplier_id, unit_price, lead_time_days, valid_until}
    - suppliers: {supplier_id, name, rating, location, payment_terms}
    - purchase_orders: {order_id, supplier_id, product_id, quantity, status}
    """

if __name__ == "__main__":
    mcp.run(transport="stdio")  # Local process mode
    # Or: mcp.run(transport="sse", port=8080)  # Remote HTTP mode

Building a production MCP server — TypeScript

TypeScript — MCP SDK server
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { MongoClient } from "mongodb";

const server = new Server(
  { name: "postgres-agent-server", version: "1.0.0" },
  { capabilities: { tools: {}, resources: {} } }
);

server.setRequestHandler("tools/list", async () => ({
  tools: [{
    name: "run_query",
    description: "Execute a read-only SQL query on the analytics database. SELECT only — no mutations.",
    inputSchema: {
      type: "object",
      properties: {
        sql: { type: "string", description: "SELECT query to run" },
        limit: { type: "number", default: 100 }
      },
      required: ["sql"]
    }
  }]
}));

server.setRequestHandler("tools/call", async (request) => {
  const { name, arguments: args } = request.params;
  if (name === "run_query") {
    // Safety: reject any non-SELECT query
    if (!args.sql.trim().toUpperCase().startsWith("SELECT"))
      throw new Error("Only SELECT queries are allowed");
    const rows = await pgClient.query(args.sql + ` LIMIT ${args.limit || 100}`);
    return { content: [{ type: "text", text: JSON.stringify(rows.rows, null, 2) }] };
  }
});

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

MCP servers you will commonly see

CompanyMCP serverWhat agents can do
AtlassianJira + Confluence MCPCreate tickets, search docs, update sprint boards
GitHubGitHub MCPSearch repos, read files, create PRs, manage issues
StripeStripe MCPQuery payments, check subscriptions, create refunds
MongoDBMongoDB Atlas MCPQuery collections, run aggregations, check indexes
SnowflakeSnowflake MCPRun SQL analytics, explore schemas, export results
SlackSlack MCPSend messages, search history, manage channels
LinearLinear MCPCreate issues, update status, query roadmap
NotionNotion MCPRead/write pages, search workspace, update databases
Google DriveDrive MCPSearch files, read documents, create spreadsheets
FigmaFigma MCPRead design tokens, extract component specs

MCP authentication — OAuth 2.1 for agents

Authentication is only the first layer. Production MCP servers also need per-client consent, exact redirect URI validation, scope minimisation, token audience validation, rate limits, and audit logs. Avoid token passthrough: an MCP server should not blindly accept a user token and forward it downstream without validating audience, issuer, scopes, expiry, and intended resource.

Python — OAuth 2.1 in MCP server
from mcp.server.fastmcp import FastMCP
from mcp.server.auth import OAuthProvider
import jwt

mcp = FastMCP("secure-data-server")

def verify_token(token: str) -> dict:
    """Verify JWT from OAuth 2.1 flow"""
    try:
        payload = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])
        return {"user_id": payload["sub"], "scopes": payload.get("scope", []).split()}
    except jwt.InvalidTokenError:
        raise PermissionError("Invalid or expired token")

@mcp.tool()
async def get_user_data(user_id: str, ctx: Context) -> dict:
    """Get user account data — requires 'read:users' scope"""
    claims = verify_token(ctx.auth_token)
    if "read:users" not in claims["scopes"]:
        raise PermissionError("Missing required scope: read:users")
    return db.users.find_one({"id": user_id})

Chapter 11
A2A — Agent-to-Agent Protocol

While MCP connects agents to tools, A2A connects agents to other agents. Released by Google in April 2025 and now developed through the Linux Foundation A2A project, A2A defines how agents discover each other, exchange messages, manage tasks, and coordinate across different frameworks and organisations — even across company boundaries.

Why A2A exists

A complex business workflow might need a researcher agent (built on LangGraph by Company A) to delegate a financial analysis subtask to a specialist agent (built on CrewAI by Company B) and a legal review to another (built on AutoGen by Company C). Without a standard, these agents cannot communicate. With A2A, any agent can discover and delegate to any other — framework and vendor agnostic.

A2A — Cross-framework Agent Collaboration
User query: "Prepare a full investment memo for Acme Corp"
👑 Orchestrator Agent
LangGraph / Owner
Receives goal, orchestrates subtasks, and merges final outputs.
🔍 Research Agent (CrewAI)
"Find all public info" → returns company_overview
💰 Financial Agent (OpenAI SDK)
"Analyse valuation" → returns risk_score & model
⚖️ Legal Agent (Custom Python)
"Check filings" → returns compliance_status

Core A2A concepts

Agent Card
A JSON document served at /.well-known/agent.json that describes an agent's capabilities, supported input/output types, authentication requirements, and pricing. Agents discover each other by reading Agent Cards. Like a business card + API specification combined.
Example: {"name":"financial-analyst-agent","capabilities":["dcf_valuation","ratio_analysis","risk_scoring"],"input_types":["text","json"],"auth":"oauth2"}
Task
The unit of delegated work. A Task has: a unique ID, input (the instructions), a lifecycle (submitted → working → input-required → completed / failed / canceled), and an output (Artifact). Client agents submit Tasks; server agents execute them.
Artifact
The output of a completed Task. Can be text, structured JSON, a file, an image, or any content type the server agent declares it can produce. Artifacts are typed and versioned.
Push notifications
For long-running tasks, the server agent sends status updates to a webhook URL provided by the client agent — rather than requiring the client to poll. Essential for tasks that take minutes or hours.

A2A task lifecycle

A2A Task State Machine

The lifecycle of a delegated task across agent frameworks.

1. Submitted
Task registered on server.
2. Working
Agent executes loop.
3. Input Required
Pause for clarification.
4. Completed
Artifact returned.

Implementing A2A — sending a task

Python — A2A client (sending a task)
import httpx, uuid, asyncio

class A2AClient:
    """Send tasks to remote A2A-compatible agents"""

    def __init__(self, agent_url: str, auth_token: str):
        self.agent_url = agent_url.rstrip("/")
        self.headers = {"Authorization": f"Bearer {auth_token}"}

    async def discover(self) -> dict:
        """Read the agent's capabilities from its Agent Card"""
        async with httpx.AsyncClient() as client:
            r = await client.get(f"{self.agent_url}/.well-known/agent.json")
            return r.json()

    async def send_task(
        self,
        message: str,
        webhook_url: str = None
    ) -> dict:
        """Submit a task to the remote agent"""
        task_id = str(uuid.uuid4())
        payload = {
            "id": task_id,
            "message": {"role": "user", "parts": [{"text": message}]},
            "webhookUrl": webhook_url
        }
        async with httpx.AsyncClient() as client:
            r = await client.post(
                f"{self.agent_url}/tasks/send",
                json=payload, headers=self.headers
            )
            return r.json()

    async def wait_for_completion(self, task_id: str, timeout: int = 300) -> dict:
        """Poll until task completes (use webhooks in production instead)"""
        async with httpx.AsyncClient() as client:
            for _ in range(timeout // 5):
                r = await client.get(
                    f"{self.agent_url}/tasks/{task_id}",
                    headers=self.headers
                )
                task = r.json()
                if task["status"]["state"] in ("completed", "failed"):
                    return task
                await asyncio.sleep(5)
        raise TimeoutError(f"Task {task_id} did not complete in {timeout}s")

# Usage — orchestrator delegates to specialist agents
async def run_investment_research(company: str):
    research_client  = A2AClient("https://research.agentco.com",  token)
    financial_client = A2AClient("https://finance.agentco.com",   token)

    # Discover capabilities first
    research_card  = await research_client.discover()
    financial_card = await financial_client.discover()

    # Run both in parallel
    research_task, financial_task = await asyncio.gather(
        research_client.send_task(f"Research {company} — find all public information"),
        financial_client.send_task(f"Analyse {company} financials and estimate valuation")
    )
    # Wait for both
    research_result, financial_result = await asyncio.gather(
        research_client.wait_for_completion(research_task["id"]),
        financial_client.wait_for_completion(financial_task["id"])
    )
    return {"research": research_result, "financials": financial_result}

Chapter 12
Tool Ecosystems & Integration Patterns

Beyond MCP servers, agents integrate with hundreds of existing services, APIs, and data sources. This chapter covers how to think about tool ecosystems, which integrations matter most, and the patterns for building them reliably.

The tool integration landscape

CategoryKey servicesIntegration methodNotes
Code & DevGitHub, GitLab, Jira, Linear, SentryMCP servers (all official)Most complete ecosystem
CommunicationSlack, Teams, Gmail, OutlookMCP servers (Slack, Gmail official)Needs careful write permissions
Data & AnalyticsSnowflake, BigQuery, Databricks, RedshiftMCP + NL-to-SQLAlways read-only for agents
CRM & SalesSalesforce, HubSpot, PipedriveREST API tools or MCPRich data, complex auth
DocumentsNotion, Confluence, Google DocsMCP (Notion official, Confluence Atlassian)RAG over docs, write with approval
FinanceStripe, Plaid, QuickBooks, XeroREST API tools (strict read-only for safety)Never write-access without HITL
WebTavily, Firecrawl, Browserbase, PlaywrightDirect Python/JS toolsWeb search and browsing
AI servicesReplicate, Fal.ai, ElevenLabsREST API toolsImage/audio/video generation

Tool design patterns

Read-first pattern
Design all agents with read-only tools by default. Require explicit human approval before any write, create, update, or delete operation. Makes agents safe to iterate on — worst case is wasted API calls, not corrupted data.
Idempotent tools
Design tools so calling them twice produces the same result as calling once. Use upsert instead of insert. Include an idempotency key on all write operations. Critical for agents that may retry on perceived failures.
Structured error returns
Never raise exceptions from tools — return structured error dicts instead. The agent needs to reason about errors and try alternatives, which requires a structured observation, not a crashed process.
Always return: {"success": false, "error": "Rate limit exceeded", "retry_after": 60} instead of raising RateLimitError.
Tool result truncation
Large tool results consume context window tokens rapidly. Always truncate results to a maximum size (e.g. 8,000 characters) and include a note that content was truncated. Let the agent request a more specific query to get the full content.
· · ·