Post

Agentic AI - Components

Agentic AI - Components

MCP

The MCP protocol is a common language that lets AI apps and external tools communicate smoothly. Just like a browser uses HTTP to talk to websites, the MCP protocol lets an AI—such as ChatGPT, VS Code, or your own web app—connect to small helper services (MCP servers) that provide capabilities like reading files, running queries, or calling APIs. It standardizes how the AI discovers these tools, sends requests, and receives results, making everything work together reliably and safely without custom integrations.

MCP Server

An MCP server is like a plugin or helper service your AI can talk to. If you imagine ChatGPT as a “brain,” it can think and reason—but it can’t directly read your files, query your database, call your APIs, or update your systems. It needs “hands” to interact with the real world. MCP servers provide those hands by exposing safe, controlled tools the AI can use. For example, a File MCP can read or write files, a SQL MCP can run database queries, a GitHub MCP can create issues, and a custom MCP can do anything you code. Each server offers a specific set of capabilities, and the AI chooses which tools to use based on the task.

Core Components

ComponentWhat It IsWhy It’s Core
ToolsActions the AI can callCore for doing things
ResourcesData or files the client can access (read-only or read/write)Core for giving AI access to state
PromptsServer-provided prompt templates or structured instructionsCore for giving AIs context-aware or workflow-specific prompts
CapabilitiesMetadata describing tools, resources, prompts, streaming, etc.Core handshake between client ↔ server
TransportWebSocket or stdioNeeded for communication
Protocol HandlersHandles messages like initialize, call_tool, read_resource, etc.Required for compliance
Execution EngineRuns actual business logicWhere server work happens
SecurityToken validation, access rulesEnsures safe execution
Logging & ErrorsStandard notifications & errorsRequired for observability

As you can see its a complex protocol and there are lots of moving pieces that you need to know/care about. Imaging trying to implement that yourself for your MCP server. To get an idea, i asked chatgpt to do this for me and it generated this code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import sys
import json
from typing import Any, Dict, Callable

# ---- 1. Define tools by hand ----
def add(a: int, b: int) -> int:
    return a + b

TOOLS: Dict[str, Callable[..., Any]] = {
    "add": add,
}

def list_tools():
    # Normally you'd return proper MCP tool schemas
    return [
        {
            "name": "add",
            "description": "Add two integers",
            "input_schema": {
                "type": "object",
                "properties": {
                    "a": {"type": "integer"},
                    "b": {"type": "integer"},
                },
                "required": ["a", "b"],
            },
        }
    ]

# ---- 2. Core MCP message loop over stdio ----
def handle_message(msg: Dict[str, Any]) -> Dict[str, Any]:
    method = msg.get("method")
    msg_id = msg.get("id")

    # Very simplified MCP-like behavior:
    if method == "initialize":
        return {
            "id": msg_id,
            "result": {
                "protocolVersion": "2024-11-01",
                "serverInfo": {"name": "demo-server", "version": "0.1"},
                "capabilities": {
                    "tools": True,
                    "resources": False,
                    "prompts": False,
                },
            },
        }

    if method == "tools/list":
        return {
            "id": msg_id,
            "result": {"tools": list_tools()},
        }

    if method == "tools/call":
        params = msg.get("params", {})
        name = params.get("name")
        args = params.get("arguments", {})

        if name not in TOOLS:
            return {
                "id": msg_id,
                "error": {
                    "code": 400,
                    "message": f"Unknown tool: {name}",
                },
            }

        try:
            result = TOOLS[name](**args)
            return {
                "id": msg_id,
                "result": {"content": result},
            }
        except Exception as e:
            return {
                "id": msg_id,
                "error": {
                    "code": 500,
                    "message": str(e),
                },
            }

    # Default: method not supported
    return {
        "id": msg_id,
        "error": {"code": 400, "message": f"Unsupported method: {method}"},
    }

def main():
    for line in sys.stdin:
        if not line.strip():
            continue
        msg = json.loads(line)
        response = handle_message(msg)
        sys.stdout.write(json.dumps(response) + "\n")
        sys.stdout.flush()

if __name__ == "__main__":
    main()

More importantly, though it did with this warning :)

If you try to build an MCP server entirely by hand, you quickly become responsible for everything the protocol requires—handling all method names, message IDs, error codes, tool schemas, argument validation, resources, prompts, streaming, logs, session state, and authentication. It’s manageable for a tiny toy example, but as soon as you scale beyond a simple demo, the amount of protocol plumbing and edge-case handling becomes overwhelming and slows down real development.

Now compare above to using the FastMCP 2.0 tool

1
2
3
4
5
6
7
8
9
from fastmcp import FastMCP
mcp = FastMCP("Demo Server")
@mcp.tool
def add(a: int, b: int) -> int:
    """Add two numbers"""
    return a + b
if __name__ == "__main__":
    # stdio or WebSocket/HTTP depending on how you run it
    mcp.run()

That’s it - FastMCP handles all the gruntwork so no suprises, if you are starting out - start with FastMCP :)

Synthea MCP Server

  graph TD
    A[Server]
    subgraph B[MCP Resources]
        B1[data_dictionary: Data Dictionary of all the tables in the database.]
        B2[data://tables: List of available tables in the database.]
    end
    subgraph C[MCP Tools]
        C1[health_check: Health check for the Synthea MCP Server]
        C2[get_user_info: Get information about the authenticated user]
        C4[execute_sql_query: Execute a SQL query against the Synthea database]
    end
    A --> B
    A --> C
    D[🔒 Security]
    B -- Secured --> D
    C -- Secured --> D

Tracking MCP Server

  graph TD
    A[Air Quality Tracking MCP Server]
    subgraph B[MCP Resources]
        B1[tracking_data_dictionary: Data Dictionary for Air Quality Tracking datasets including CO, O3, PM2.5, and SO2 measurements.]
    end
    subgraph C[Tools]
        C1[get_air_quality_data: Load air quality data from CSV files and filter by specified parameters.]
        C2[list_available_datasets: List all available air quality datasets in the tracking directory.]
        C3[health_check: Health check for the Air Quality Tracking MCP Server.]
    end
    A --> B
    A --> C
    D[🔒 Security]
    B -- Secured --> D
    C -- Secured --> D

Consumer

The client side flow is somewhat along this line

 flowchart TD
    A[Client - chat.html]
    B[Quart App - app.py]
    C[Authentication - Azure AD]
    D[Session Established]
    E[User Request: /chat]
    F[Tool/Resource Discovery]
    G[Synthea MCP Server]
    H[Tracking MCP Server]

    A --> B
    B --> C
    C --> D
    D --> E
    E --> F
    F --> G
    F --> H

I am using the FastMCP client (link) as well so that i am less worried about the low level MCP protocol stuff. Things that i need to circle back to later are

Security for Multi-Servers :

FastMCP supports these kind of configurations

1
2
3
4
5
6
7
8
9
10
11
12
13
def get_mcp_config():
    return {
        "mcpServers": {
            "synthea": {
                "url": "https://xxxxx.net/mcp",
                "transport": "http",
            },
            "tracking": {
                "url": "https://yyyyyynet/mcp",
                "transport": "http",
            }
        }
    }

I was not able to get OAuth to work soely based on configuration. The only way i got it to work when the MCP server was secured with OAuth was handling it myself something like this

1
2
3
4
5
6
server_name = "synthea" if response["name"] and response["name"].startswith("synthea") else "tracking"
    configs = get_mcp_config()
    server_url = configs["mcpServers"][server_name]["url"]

    async with Client(server_url, auth=BearerAuth(token=access_token)) as client:
        # Use the full resource URI directly
This post is licensed under CC BY 4.0 by the author.