DEV Community

Cover image for MCP: The Protocol That Makes AI Tools Interoperable
Gokulnath P
Gokulnath P

Posted on AI-assisted

MCP: The Protocol That Makes AI Tools Interoperable

Every time we've given an LLM a tool, we have followed the same process — write a JSON schema, write the dispatch logic, handle the result format. It works, but every model has its own format. OpenAI expects one thing, Anthropic another, Ollama another. If you want to share a tool across multiple models or clients, you're writing the same glue code over and over.

That's the problem MCP was built to solve.

What MCP is

Model Context Protocol is an open standard published by Anthropic in late 2024. Think of it like USB-C for AI tools — build a tool once as an MCP server, and any MCP-compatible client can use it without any changes.

It defines three things: how a client discovers what tools are available, how a server exposes those tools in a standard format, and how tool calls and results are exchanged between them.

The architecture is straightforward:

┌─────────────────────────────────┐
│         MCP Client              │
│  (Claude, Cursor, your Python)  │
│                                 │
│  1. connects to server          │
│  2. asks: "what tools exist?"   │
│  3. LLM sees tools in context   │
│  4. sends tool call             │
│  5. receives result             │
└──────────────┬──────────────────┘
               │  JSON-RPC (stdio or HTTP)
               ▼
┌─────────────────────────────────┐
│         MCP Server              │
│  (filesystem, database, API...) │
│                                 │
│  exposes tools, resources,      │
│  and prompts                    │
└─────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The server doesn't need to know which client is connecting. The client doesn't need to know the server's internals. The protocol is the contract between them.

What a server exposes

An MCP server can expose three types of things. Tools are functions the LLM can call — the same concept as Post #2, but now in a standard format. Resources are data the application loads into context, like file contents or database records. Prompts are reusable message templates the user can invoke by name.

For most use cases, tools are what you'll work with.

The ecosystem

There are already hundreds of pre-built MCP servers you can connect to immediately:

  • @modelcontextprotocol/server-filesystem — read and write local files
  • @modelcontextprotocol/server-github — GitHub repos, PRs, issues
  • @modelcontextprotocol/server-postgres — query PostgreSQL
  • @modelcontextprotocol/server-brave-search — web search
  • mcp-server-fetch — fetch and read web URLs

Connect any MCP client to these and the LLM instantly gains those capabilities — no schema writing required.

Two packages to know

pip install fastmcp   # for building MCP servers
pip install mcp       # for connecting to MCP servers as a client
Enter fullscreen mode Exit fullscreen mode

For the filesystem server used in Exercises 1–3, you also need Node:

npm install -g @modelcontextprotocol/server-filesystem
Enter fullscreen mode Exit fullscreen mode

Exercise 1 — Explore an existing MCP server

Connect to the filesystem server and see what tools it exposes:

import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def explore_server():
    server_params = StdioServerParameters(
        command="npx",
        args=["@modelcontextprotocol/server-filesystem", "/tmp"]
    )

    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            tools = await session.list_tools()
            print("Available tools:\n")
            for tool in tools.tools:
                print(f"  {tool.name}")
                print(f"    {tool.description}")
                print()

asyncio.run(explore_server())
Enter fullscreen mode Exit fullscreen mode

You'll see tools like read_file, write_file, list_directory. You didn't write any of these schemas — the server exposed them through the protocol.

Exercise 2 — Call a tool via MCP

Now let's actually invoke a tool:

import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def use_filesystem_tools():
    server_params = StdioServerParameters(
        command="npx",
        args=["@modelcontextprotocol/server-filesystem", "/tmp"]
    )

    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # Write a file via MCP
            print("Writing file via MCP...")
            await session.call_tool(
                "write_file",
                arguments={"path": "/tmp/mcp_test.txt", "content": "Hello from MCP!"}
            )

            # Read it back
            print("Reading file back via MCP...")
            read_result = await session.call_tool(
                "read_file",
                arguments={"path": "/tmp/mcp_test.txt"}
            )
            print(f"Content: {read_result.content[0].text}")

            # List the directory
            print("\nListing /tmp via MCP...")
            list_result = await session.call_tool(
                "list_directory",
                arguments={"path": "/tmp"}
            )
            print(list_result.content[0].text[:300])

asyncio.run(use_filesystem_tools())
Enter fullscreen mode Exit fullscreen mode

Same tool call mechanism as Post #2, but now going through a standard protocol instead of direct function calls.

Exercise 3 — Connect MCP tools to an LLM

Now let's bridge MCP tools into Ollama — the full integration:

import asyncio
import ollama
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client


def mcp_to_ollama_tool_schema(mcp_tool) -> dict:
    return {
        "type": "function",
        "function": {
            "name": mcp_tool.name,
            "description": mcp_tool.description or "",
            "parameters": mcp_tool.input_schema or {"type": "object", "properties": {}}
        }
    }


async def run_llm_with_mcp_tools(user_question: str):
    server_params = StdioServerParameters(
        command="npx",
        args=["@modelcontextprotocol/server-filesystem", "/tmp"]
    )

    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # Discover tools from the MCP server
            mcp_tools_result = await session.list_tools()
            ollama_tools = [mcp_to_ollama_tool_schema(t) for t in mcp_tools_result.tools]

            print(f"Tools discovered: {[t.name for t in mcp_tools_result.tools]}\n")

            messages = [{"role": "user", "content": user_question}]
            response = ollama.chat(model="qwen2.5", messages=messages, tools=ollama_tools)

            if response.message.tool_calls:
                messages.append(response.message)

                for tool_call in response.message.tool_calls:
                    name = tool_call.function.name
                    args = tool_call.function.arguments
                    print(f"  → LLM called: {name}({args})")

                    # Execute via MCP — not a direct function call
                    result = await session.call_tool(name, arguments=args)
                    result_text = result.content[0].text if result.content else "Done."
                    print(f"  ← Result: {result_text[:100]}")

                    messages.append({"role": "tool", "content": result_text})

                final = ollama.chat(model="qwen2.5", messages=messages)
                print(f"\nAnswer: {final.message.content}")
            else:
                print(f"Answer: {response.message.content}")


asyncio.run(run_llm_with_mcp_tools(
    "List what files are in /tmp and tell me how many there are"
))
asyncio.run(run_llm_with_mcp_tools(
    "Create a file called /tmp/hello.txt with the content 'Hello MCP'"
))
Enter fullscreen mode Exit fullscreen mode

The LLM discovered the tools from the server automatically — no hardcoded schemas. If you swap in a different MCP server, the LLM immediately gains whatever tools that server exposes.

Exercise 4 — Build your own MCP server

Tools are just plain Python functions with docstrings — the framework handles everything else:

# my_mcp_server.py
from fastmcp import FastMCP
import datetime

mcp = FastMCP(name="my-tools")


@mcp.tool
def get_current_time(format: str = "full") -> str:
    """Get the current date and time.

    Args:
        format: Output format — 'full', 'date', or 'time'. Defaults to 'full'.
    """
    now = datetime.datetime.now()
    if format == "date":
        return now.strftime("%Y-%m-%d")
    elif format == "time":
        return now.strftime("%H:%M:%S")
    else:
        return now.strftime("%Y-%m-%d %H:%M:%S")


@mcp.tool
def calculate(expression: str) -> str:
    """Perform arithmetic calculations.

    Args:
        expression: A Python math expression e.g. '(4 * 7) + 3'
    """
    try:
        return str(eval(expression))
    except Exception as e:
        return f"Error: {e}"


if __name__ == "__main__":
    mcp.run()
Enter fullscreen mode Exit fullscreen mode

Run it:

python my_mcp_server.py
Enter fullscreen mode Exit fullscreen mode

Connect to it using the same client from Exercise 1 — just swap the server_params:

server_params = StdioServerParameters(
    command="python",
    args=["my_mcp_server.py"]
)
Enter fullscreen mode Exit fullscreen mode

Any MCP-compatible client can now connect to your server. You wrote two Python functions — the protocol, schema generation, and transport are all handled for you.

When to use MCP vs direct tool use

MCP adds a layer of indirection, so it's not always the right choice. Direct tool use (the approach from Post #2) is simpler and fine for quick prototypes with a single model. MCP makes sense when you want to share tools across multiple models or clients, when you're connecting to an existing MCP ecosystem, or when you're building something that needs to be maintainable at scale.

The underlying concepts are identical — MCP is just the standardised version of what you've been building from scratch since Post #2.

Wrapping up

MCP solves the fragmentation problem that comes with building AI tools — instead of writing custom glue code for every model and client combination, you write a server once and any compatible client can use it.

The ecosystem is already large and growing fast. Once you understand the protocol, plugging into existing servers like GitHub, Postgres, or filesystem is straightforward.

In Post #8, the final post in the series, we look at evals and safety — how to measure whether your agents are actually working well, and how to protect them from being manipulated. See you there. 🚀

Top comments (0)