Hands-On Deep Dive Into Model Context Protocol

Now, let’s dive into MCP and why it matters.

Meet our guest

Antropic Model Context Protocol (MCP)

Reference: Model Context Protocol (MCP)

Only a couple of months ago Anthropic introduced the Model Context Protocol (MCP). While it’s still in early development, it shows immense promise in shaping how we interact with Large Language Models.

To put it simply, it defines how an LLM will communicate with external tools and resources

What are the benefits? Well, following a standard protocol will speed up the adoption of new capabilities and improve reusability

Let’s explore what makes it special and why it matters.

Protocols: How the Internet works

Protocols, protocols are everywhere meme

Why are protocols such a big deal? Well, because this is how our modern digital world runs.

Every time you open your browser, send a message, or make a payment, you’re using protocols without even realizing it. Think about it: HTTP, WebSocket, TCP/UDP, SMTP – these are the “hidden engines that make the internet work“. Even if you don’t recognize these letters, you use them constantly.

EP80: Explaining 8 Popular Network Protocols in 1 Diagram

Reference: EP80: Explaining 8 Popular Network Protocols in 1 Diagram

The internet works through a standard model called OSI (Open Systems Interconnection), which defines seven layers of communication:

The layers of the OSI model

Reference: What are the layers of the OSI model?

From the physical layer, where we have wires and binary data, all the way up to the application layer – it’s pure magic that it all works together. As application developers, we spend 99% of our time in the application layer, working with protocols like HTTP. But this sophisticated system wasn’t built overnight – it was established decades ago by brilliant minds who created protocols like TCP.

RFC (Request For Comments) for TCP (RFC: 793) and HTTP (RFC: 2616)

Reference: RFC (Request For Comments) for TCP (RFC: 793) and HTTP (RFC: 2616)

Why Do We Need Another Protocol?

From time to time, companies introduce new protocols to solve specific problems. Take Signal messenger, for example.

Signal Protocol, Signal Docs

Reference: Signal Protocol, Signal Docs

When they needed top-notch security, they created their own protocol in Rust – fast, secure, and open for inspection by anyone. Similarly, Microsoft developed the Language Server Protocol (LSP) when building VS Code to enhance extensibility and create a network effect around their IDE.

Language Server Protocol

Reference: Language Server Protocol

As of now, we’re in a state of chaos with LLMs and the agents built on top of them. There’s no standard way to work with LLM tools, no standardized method to connect agents, and no standard connectors or integrations for large-scale enterprise adoption.

  • Some developers define tools as functions
  • Others implement them as web servers
  • Many build custom integrations

So, the question is: Do we need a single protocol for LLMs? Yes – it would simplify a lot!

Enter MCP: The Protocol LLMs Have Been Waiting For

At its core, MCP (Model Context Protocol) is surprisingly simple – it’s a standard way for LLMs to work with tools. A close analogy is OpenAPI: you can define REST endpoints however you want, as long as you describe your API in a standard way. This enables anyone to connect using any programming language.

It has 2 main components:

  1. Client: The application interacting with the LLM
  2. Server: The provider of tools and capabilities

Let’s break them down across three levels of complexity.

Level One: Official Client + Official Server (No Code)

Level One: Official client + Official server atchitecture

The simplest approach is using the official client + official server, which means we don’t have to write any code at all

Let’s take Claude for Desktop as our client (it offers the best MCP support since it comes from Anthropic) and MotherDuck as our server. MotherDuck helps to manage DuckDB, a popular local database that’s rapidly gaining traction.

Here’s what’s fascinating – you don’t need to write any code to connect them. All you need is the right configuration.

Update your claude_desktop_config.json file with this config:

{
    "mcpServers": {
      "mcp-server-motherduck": {
        "command": "uvx",
        "args": [
          "mcp-server-motherduck"
        ],
        "env": {
          "HOME": "/tmp/mcp-duckdb"
        }
      }
    }
  }

Restart the Claude app, and you should see the following three tools:

Claude MCP configuration

Now, you can chat with any DuckDB instance from your desktop app. How do I use this example? I typically use it to understand Hugging Face datasets – querying over 150K+ datasets directly from Hugging Face using DuckDB. My prompt:

read ibm-research/nestful dataset from HF 

- describe each column
- provide statistics about the dataset
- Explain what kind of tasks LLM can be trained on it

use read_parquet('hf://datasets/ibm-research/nestful@~parquet/default/train/.parquet') to read it directly from HF

You can see that I give it a hint about some DuckDB functionality that Claude might not be aware of.

DuckDB functionality

It does its job quite well, even handling restarts after errors. For more information about using MCP, check this out: For Claude Desktop Users.

We did all of this without writing any code! But what if (1) the capabilities we need aren’t implemented yet, or (2) we want more granular control over the tools?

For this, let’s move on to Level Two!

Level Two: Official Client + Custom Server (Some Code)

Level Two: Official Client + Custom Server architecture

What if you want to create your own MCP server because what you need simply doesn’t exist or isn’t good enough? If you’ve ever used FastAPI, you’ll feel right at home. Instead of REST endpoints (POST, GET, DELETE), you expose the following:

  • Tools
  • Resources
  • Prompts

Here, I created a simple server to perform just two actions: create a GitHub issue and list GitHub issues:

from mcp.server.fastmcp import FastMCP
from github import Github
import os
import json

mcp = FastMCP("github", dependencies=["PyGithub"])

GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
g = Github(GITHUB_TOKEN)

@mcp.tool()
def create_issue(title: str, body: str = "", labels: str = "", assignees: str = "", repo_name: str = os.getenv("REPO_NAME", "kyryl-opens-ml/mcp-webinar")) -> str:
    """
    Create a new GitHub issue in the specified repository.

    Parameters:
      - title (str): The title of the issue.
      - body (str): The content/description of the issue.
      - labels (str): Comma-separated list of labels to add to the issue.
      - assignees (str): Comma-separated list of GitHub usernames to assign the issue to.
      - repo_name (str): The repository name where the issue should be created.

    Returns:
      A string confirming the issue creation with a URL to the issue.
    """
    repo = g.get_repo(repo_name)
    # Convert comma-separated strings into lists (if provided)
    label_list = [label.strip() for label in labels.split(",")] if labels else []
    assignee_list = [assignee.strip() for assignee in assignees.split(",")] if assignees else []
    
    issue = repo.create_issue(
        title=title,
        body=body,
        labels=label_list,
        assignees=assignee_list
    )
    return f"Issue created: {issue.html_url}"

@mcp.tool()
def list_issues(repo_name: str = os.getenv("REPO_NAME", "kyryl-opens-ml/mcp-webinar")) -> str:
    """
    Retrieve and return all issues from the specified GitHub repository.

    Parameters:
      - repo_name (str): The repository name from which to list issues.

    Returns:
      A JSON-formatted string containing a list of issues with details such as number, title, state, assignees, and URL.
    """
    repo = g.get_repo(repo_name)
    # Get all issues (both open and closed) from the repository
    issues = repo.get_issues(state="all")
    issues_list = []
    for issue in issues:
        issues_list.append({
            "number": issue.number,
            "title": issue.title,
            "state": issue.state,
            "assignees": [assignee.login for assignee in issue.assignees],
            "url": issue.html_url,
        })
    return json.dumps(issues_list, indent=2)

if __name__ == "__main__":
    mcp.run(transport='stdio')

And now, I can create and retrieve GitHub issues from Claude. It’s just a toy example, but I use it in my daily routine to define tasks. Don’t forget to update the Claude config!

{
  "mcpServers": {
    "mcp-server-motherduck": {
      "command": "uvx",
      "args": [
        "mcp-server-motherduck"
      ],
      "env": {
        "HOME": "/tmp/mcp-duckdb"
      }
    },
    "github": {
      "command": "uv",
      "args": [
        "run",
        "--with",
        "PyGithub",
        "--with",
        "mcp[cli]",
        "mcp",
        "run",
        "/Users/kyryl/Projects/KOML/MCP/mcp-webinar/scr/server_github.py"
      ]
    }
  }
}

For more information about custom servers for MCP, check this out: For Server Developers.

But what if I don’t want to use Anthropic’s LLMs- no problem at all! This is the beauty of a protocol – it doesn’t lock you into a specific tool, framework, or LLM.

If you want to use a different LLM, it’s time to move on to Level 3.

Level Three: Custom Everything (Only Code)

Level Three: Custom Everything architecture

For maximum flexibility, you can implement both the client and the server. The main issue with Levels 1 and 2 was Claude – I like this LLM, but what if I want to use ChatGPT, Gemini, or any other open LLMs?

Well, the good news is that MCP allows you to do this. The bad news? It’s hard – you need to implement everything from scratch.

import asyncio
from typing import Optional
from contextlib import AsyncExitStack

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

from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv()

class MCPClient:
    def __init__(self):
        self.session: Optional[ClientSession] = None
        self.exit_stack = AsyncExitStack()
        self.anthropic = Anthropic()

    async def connect_to_server(self, server_script_path: str):
        """Connect to an MCP server
        
        Args:
            server_script_path: Path to the server script (.py or .js)
        """
        is_python = server_script_path.endswith('.py')
        is_js = server_script_path.endswith('.js')
        if not (is_python or is_js):
            raise ValueError("Server script must be a .py or .js file")
            
        command = "python" if is_python else "node"
        server_params = StdioServerParameters(
            command=command,
            args=[server_script_path],
            env=None
        )
        
        stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
        self.stdio, self.write = stdio_transport
        self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write))
        
        await self.session.initialize()
        
        # List available tools
        response = await self.session.list_tools()
        tools = response.tools
        print("\nConnected to server with tools:", [tool.name for tool in tools])

    async def process_query(self, query: str) -> str:
        """Process a query using Claude and available tools"""
        messages = [
            {
                "role": "user",
                "content": query
            }
        ]

        response = await self.session.list_tools()
        available_tools = [{ 
            "name": tool.name,
            "description": tool.description,
            "input_schema": tool.inputSchema
        } for tool in response.tools]

        # Initial Claude API call
        response = self.anthropic.messages.create(
            model="claude-3-5-sonnet-20241022",
            max_tokens=1000,
            messages=messages,
            tools=available_tools
        )

        # Process response and handle tool calls
        tool_results = []
        final_text = []

        for content in response.content:
            if content.type == 'text':
                final_text.append(content.text)
            elif content.type == 'tool_use':
                tool_name = content.name
                tool_args = content.input
                
                # Execute tool call
                result = await self.session.call_tool(tool_name, tool_args)
                tool_results.append({"call": tool_name, "result": result})
                final_text.append(f"[Calling tool {tool_name} with args {tool_args}]")

                # Continue conversation with tool results
                if hasattr(content, 'text') and content.text:
                    messages.append({
                    "role": "assistant",
                    "content": content.text
                    })
                messages.append({
                    "role": "user", 
                    "content": result.content
                })

                # Get next response from Claude
                response = self.anthropic.messages.create(
                    model="claude-3-5-sonnet-20241022",
                    max_tokens=1000,
                    messages=messages,
                )

                final_text.append(response.content[0].text)

        return "\n".join(final_text)


    async def chat_loop(self):
        """Run an interactive chat loop"""
        print("\nMCP Client Started!")
        print("Type your queries or 'q' to exit.")
        
        while True:
            try:
                query = input("\nQuery: ").strip()
                
                if query.lower() == 'q':
                    break
                    
                response = await self.process_query(query)
                print("\n" + response)
                    
            except Exception as e:
                print(f"\nError: {str(e)}")

    async def cleanup(self):
        """Clean up resources"""
        await self.exit_stack.aclose()


async def main():
    if len(sys.argv) < 2:
        print("Usage: python client.py <path_to_server_script>")
        sys.exit(1)
    
    print("Starting MCP Client...")
    client = MCPClient()
    try:
        print("Connecting to server...")
        await client.connect_to_server(sys.argv[1])
        print("Starting chat loop...")
        await client.chat_loop()
    finally:
        print("Cleaning up...")
        await client.cleanup()  

if __name__ == "__main__":
    import sys
    asyncio.run(main())

uv run ./src/client_custom.py ./src/server_github.py

This is a very basic implementation of a client and server. For LLMs like OpenAI or Google Gemini, check out this example: How to use Anthropic MCP Server with open LLMs, OpenAI or Google Gemini.

Debugging and Core Components

Debugging MCP implementations can be challenging, especially on the client side. However, the server side is much easier thanks to the MCP Inspector tool.

MCP Inspector GitHub

Reference: MCP Inspector GitHub

The Inspector provides a visual way to:

  • Test your tools interactively
  • View available resources
  • Monitor tool execution

And if you ask me to summarize MCP in one picture, it would be this:

MCP architecture in one picture

Core components are:

  • Host – The main application (Claude, course IDEs, REPL, etc.).
  • Clients – Components managed by the Host.
  • LLM – The main decision driver; can be any model.

Claude Desktop App combines all three into a cohesive offering.

  • Servers – Provide tools, resources, prompts, etc.

These are the main ways to add capabilities to your application.

Adoption

So what? Why should we care? And how many people & companies have adopted MCP?

Well – it’s slow! Anthropic created a reference server, and there are some official implementations, but I find them very basic for now.

Model Context Protocol servers GitHub

Reference: Model Context Protocol servers GitHub

What about open-source clients?

GitHub issues - MCP support

References: GitHub issues 1, 2, 3

Big open-source projects have corresponding issues to add MCP support – but none of them officially support it yet, and conversations are in progress.

As of early 2025, there are about 700 MCP servers implemented, with an open registry to check which tools have corresponding servers.

Open-Source MCP servers

Reference: Open-Source MCP servers

What about client adoption?

Example Clients

Reference: Example Clients

It looks like coding environments are the main consumers of MCP – which makes sense at such an early stage.

And of course, big competitors of Anthropic wouldn’t want to support MCP unless it really becomes the default standard.

Any Gaps? YES!

There are many gaps and paper cuts.

  1. Any LLMs: Sometimes it’s great, sometimes it’s not.
  2. Local only: SSE for HTTP compatibility, but no examples.
  3. Authentication / Debugging / Observability / Distribution are missing.
  4. Adoption (Servers): Most packages are not very active.
  5. Adoption (Clients): OpenAI, Google, etc – no supports.
  6. Paper cuts: uv, restart required each time, async API only.

But this is perfectly normal for an early-stage protocol. Think about HTTP in 1990 – it was probably just as annoying and hard to use. Yet, it became the backbone of the web.

But it looks like the team has plans to address most of these in H1 2025:

MCP Roadmap

Reference: Roadmap

The Future of MCP

What’s my main take?

It’s 1990, and we’re looking at one of the HTTP RFCs.

Whether MCP becomes the standard or simply inspires the next iteration (like Mesos did before Kubernetes), it’s worth keeping an eye on. It might just be the HTTP of the LLM era.

If you’re working with LLMs in production and want to go beyond protocols to real-world deployment, join my “Machine Learning in Production”.

2 thoughts on “Hands-On Deep Dive Into Model Context Protocol”

  1. Pingback: Fine-Tune LLM Agent for Tool Use with Hugging Face

  2. Pingback: Build a Self-Healing Kubernetes Agent with LibreChat & MCP | Step-by-Step Guide

Leave a Reply

Scroll to Top

Discover more from Kyryl Opens ML

Subscribe now to keep reading and get access to the full archive.

Continue reading