MCP is a lot more complex than AI influencers would have you believe. Sure, if by “understanding MCP” you mean putting together a JSON file that points to the server, then I guess you’re all set. And if you’re working on nuclear power plants, do let me know where, I’d rather not spend my holidays nearby.
What you’ll get from this article
- I’ll explain the architecture and protocols used by MCP clients.
- Then I’ll give you the complete code of an HTTP MCP client and server. The client can connect to any HTTP MCP server and list tools. It won’t use an LLM as YOU will be the LLM.^^
I. The two types of MCP clients
There are 2 types of MCP servers… Ideally, your client should be able to deal with both types.
1. STDIO clients
I’ll try to keep this short: DO NOT DO THAT
For some unknown, stupid reason, Anthropic pushed for a strange server communication (transport) protocol: STDIO
This doesn’t rely on any HTTP socket bound to your localhost to interact, but instead uses the f*cking filesystem…
We have built client/server software using localhost for 40 years. But hey… Let’s use the FS instead this time. This way our servers can’t be exposed on the internet, and no clients can connect except the ones on the same machines! YEAY
Very future-proof!
For a client to use this stupid implementation, you first have to set the filepath of your server in the global MCP config.
So your mcp.json config should look like this:
{
"servers": {
"todoist": {
"url": "https://mcp.pipedream.net/aabc9cef-a206-545f-8094-1b9fgdf09203/todoist"
},
"todo_local": {
"command": "uv",
"args": [
"run",
"--with",
"mcp[cli]",
"mcp",
"run",
"/path/to/your/server.py"
]
}
}
}
This config tells the MCP client to look at the file /path/to/your/server.py
which contains the source code of your server. By reading its content, it will discover the different tools that can be “served”.
When the client starts, its first task is to boot the server. YES. You read that right. The client starts the server… It makes total sense! (No, it doesn’t)
When the client exits, it must stop the server process that was forked during boot.
To summarize: DON’T WRITE STDIO SERVERS
As for the client, you can use a software bridge to port the stupid STDIO transport to HTTP (better). For example, mcp-proxy does exactly that. But you can find many more on Google. This way, you only have to care about one transport mechanism and not two.
2. HTTP clients
Obviously, HTTP clients deal with HTTP servers. It’s way better than STDIO as you will be able to expose servers on the internet, allowing remote clients to connect from anywhere.
This is what the future will look like. We’ll all have our own LLM connecting to remote MCP servers and take actions on our behalf. Like ordering food, booking flights, etc.
As if things weren’t complicated enough, I also have to specify the exact HTTP protocol used by these servers: SSE
Never heard of SSE?
It’s Server-Sent Events, meaning that the server can push messages to the clients without the client having to request anything. It’s like an HTTP socket but unidirectional (server => client only).
What’s dumb, IMHO, is that your client makes a POST request to get the list of available tools, and the server responds using SSE. So it’s kind of weird to use a protocol designed to push data to the client proactively… only when it’s explicitly asked for data.
I may see a point in the future where MCP servers will send some data to the client out of the blue, and the client could react to it. But as of today, it feels very strange to ask for data and retrieve it using SSE.
How it works
Also, the SSE protocol specifies that each message should end with a double \n and start with data:.
So, for example:
data: message1\n
\n
data: message2\n
\n
So, what happens if the server sends some unknown unstructured data containing any number of consecutive line endings?
It will go BOOM is what will happen.
You could send the message as a base64 string to fix this. But this would imply that the client can recognise base64 and decode it. Something that isn’t going to happen anytime soon unless it is specified in Anthropic’s MCP spec. So we’ll see… Just keep in mind that the server can’t send random stuff, as it might break the client's ability to read messages.
II. Creating a demo server
To create our client, we first need a basic server implementing the MCP spec. This way we’ll have the ability to learn what are the correct JSON responses.
You can follow this good tutorial. It’s using NodeJS, but it’s very easy to understand and use.
I’ll add everything here for posterity:
# Install the MCP framework globaly (there's a CLI)
npm i -g mcp-framework
# Create the boilerplate code of an MCP weather server
mcp create weather-http-server --http --port 1337 --cors
cd weather-http-server
# Add a tool to the server
mcp add tool weather
Update src/tools/WeatherTool.ts with this content:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
interface WeatherInput {
city: string;
}
class WeatherTool extends MCPTool<WeatherInput> {
name = "weather";
description = "Get weather information for a city";
schema = {
city: {
type: z.string(),
description: "City name to get weather for",
},
};
async execute({ city }: WeatherInput) {
// In a real scenario, this would call a weather API
// For now, we return this sample data
return {
city,
temperature: 22,
condition: "Sunny",
humidity: 45,
};
}
}
export default WeatherTool;
# Build the project
npm run build
# Start the server
npm start
# Your HTTP MCP server is now running at http://localhost:1337/mcp
You can try to connect with existing clients like Claude or Copilot. Simply set the above server URL in the MCP global config file, and you should be good to go.
III. Building an MCP client
Now that I’ve stopped ranting about what’s dumb in MCP and we have a working server, let’s write the client.
But because I want to iterate rapidly, I will not include any LLM in this piece of code. This means that doing the tool calling and parsing will be done manually. DON’T PANIC. The longest methods are not the ones that interest us the most. It’s only parsing.
#!/usr/bin/env python3
"""
Simple MCP (Model Context Protocol) HTTP Client
Connects to remote MCP servers via Streamable HTTP transport and provides a basic chat interface.
"""
import json
import requests
import uuid
import time
from typing import Dict, List, Any, Optional
from dataclasses import dataclass
from urllib.parse import urljoin
@dataclass
class Tool:
name: str
description: str
input_schema: Dict[str, Any]
class MCPClient:
def __init__(self, server_url: str):
self.server_url = server_url.rstrip('/')
self.session = requests.Session()
self.tools: Dict[str, Tool] = {}
self.initialized = False
self.session_id: Optional[str] = None
def initialize(self) -> bool:
"""Initialize connection with the MCP server"""
try:
# Initialize the protocol
init_result = self._make_request("initialize", {
"protocolVersion": "2025-03-26",
"capabilities": {
"tools": {}
},
"clientInfo": {
"name": "yacana-mcp-client",
"version": "0.1.0"
}
})
server_info = init_result.get('serverInfo', {})
print(f"Connected to MCP server: {server_info.get('name', 'Unknown')} v{server_info.get('version', 'Unknown')}")
# List available tools
tools_result = self._make_request("tools/list")
tools = tools_result.get("tools", [])
for tool_info in tools:
tool = Tool(
name=tool_info["name"],
description=tool_info["description"],
input_schema=tool_info["inputSchema"]
)
self.tools[tool.name] = tool
print(f"Available tool: {tool.name} - {tool.description}")
if not tools:
print("No tools available on this server")
self.initialized = True
return True
except Exception as e:
print(f"Failed to initialize MCP client: {e}")
return False
def _make_request(self, method: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
"""Make a JSON-RPC request to the MCP server using Streamable HTTP transport"""
request_id = str(uuid.uuid4())
payload = {
"jsonrpc": "2.0",
"id": request_id,
"method": method
}
if params:
payload["params"] = params
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream"
}
# Add session ID header if we have one
if self.session_id:
headers["Mcp-Session-Id"] = self.session_id
try:
response = self.session.post(
self.server_url,
json=payload,
headers=headers,
timeout=30
)
response.raise_for_status()
# Check if server returned a session ID during initialization
if method == "initialize" and "Mcp-Session-Id" in response.headers:
self.session_id = response.headers["Mcp-Session-Id"]
print(f"Server assigned session ID: {self.session_id}")
# Handle different response types
content_type = response.headers.get('content-type', '').lower()
if 'application/json' in content_type:
result = response.json()
if "error" in result:
raise Exception(f"MCP Error: {result['error']}")
return result.get("result", {})
elif 'text/event-stream' in content_type:
# Handle SSE response - for simplicity, we'll read the first JSON response
# In a production client, you'd want to properly handle the SSE stream
return self._handle_sse_response(response)
else:
# If no specific content type, try to parse as JSON
try:
result = response.json()
if "error" in result:
raise Exception(f"MCP Error: {result['error']}")
return result.get("result", {})
except:
raise Exception(f"Unexpected response format: {response.text}")
except requests.exceptions.HTTPError as e:
if e.response.status_code in [404, 405]:
print("This server is too old and doesn't fully support SSE but probably a mix of HTTP+SEE. This is not supported by this client...")
raise Exception(f"HTTP Error {e.response.status_code}: {e.response.text}")
def _handle_sse_response(self, response) -> Dict[str, Any]:
"""Handle Server-Sent Events response (simplified implementation)"""
# This is a simplified SSE parser - in production you'd want a proper SSE client
lines = response.text.split('\n')
for line in lines:
line = line.strip()
if line.startswith('data: '):
try:
data = json.loads(line[6:]) # Remove 'data: ' prefix
if "result" in data:
return data["result"]
elif "error" in data:
raise Exception(f"MCP Error: {data['error']}")
except json.JSONDecodeError:
continue
raise Exception("No valid JSON-RPC response found in SSE stream")
def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Call a tool on the MCP server"""
if not self.initialized:
raise Exception("Client not initialized")
if tool_name not in self.tools:
raise Exception(f"Tool '{tool_name}' not available. Available tools: {list(self.tools.keys())}")
result = self._make_request("tools/call", {
"name": tool_name,
"arguments": arguments
})
return result
def get_available_tools(self) -> List[str]:
"""Get list of available tool names"""
return list(self.tools.keys())
def get_tool_info(self, tool_name: str) -> Optional[Tool]:
"""Get information about a specific tool"""
return self.tools.get(tool_name)
def disconnect(self):
"""Explicitly disconnect from the server"""
if self.session_id:
try:
headers = {"Mcp-Session-Id": self.session_id}
self.session.delete(self.server_url, headers=headers, timeout=5)
except:
pass # Ignore errors during cleanup
self.session.close()
def detect_tool_calls(message: str) -> List[Dict[str, Any]]:
"""
Detect tool calls in the format: @tool_name({"param1": "value1", ...})
Returns a list of dicts: {"name": ..., "arguments": ...}
"""
import re
import json
tool_calls = []
pattern = r'@([\w-]+)\((\{.*?\})\)'
matches = re.finditer(pattern, message)
for match in matches:
tool_name = match.group(1)
args_str = match.group(2)
try:
arguments = json.loads(args_str)
if not isinstance(arguments, dict):
raise ValueError("Arguments must be a JSON object")
except Exception as e:
print(f"Failed to parse arguments for @{tool_name}: {e}")
raise Exception("Invalid param. Retry.")
tool_calls.append({
"name": tool_name,
"arguments": arguments
})
return tool_calls
def main():
"""Main chat loop"""
print("=== Yacana MCP Client ===")
# Get server URL from user
server_url = input("Enter MCP server URL (e.g., https://mcp.deepwiki.com/mcp): ").strip()
if not server_url:
server_url = "https://mcp.deepwiki.com/mcp"
# Initialize client
client = MCPClient(server_url)
try:
if not client.initialize():
print("Failed to connect to MCP server. Exiting.")
return
print("\n=== Chat Loop Started ===")
print("Type your messages below. Use @tool_name(arg1=\"value1\") to call tools.")
print("Type 'quit' to exit, 'tools' to list available tools.\n")
while True:
try:
user_input = input("\n> ").strip()
if user_input.lower() in ['quit', 'exit']:
break
if user_input.lower() == 'tools':
print("\nAvailable tools:")
if not client.get_available_tools():
print(" No tools available")
else:
for tool_name in client.get_available_tools():
tool = client.get_tool_info(tool_name)
print(f" - {tool_name}: {tool.description}")
# Show required parameters
schema = tool.input_schema
if 'properties' in schema:
required = schema.get('required', [])
props = schema['properties']
params_info = []
for k, v in props.items():
param_type = v.get('type', 'any')
required_marker = "*" if k in required else ""
params_info.append(f"{k}({param_type}){required_marker}")
print(f" Parameters: {', '.join(params_info)}")
continue
if not user_input:
continue
# Detect tool calls in the message
try:
tool_calls = detect_tool_calls(user_input)
except Exception as e:
continue
if tool_calls:
print("\nDetected tool calls:")
for call in tool_calls:
tool_name = call["name"]
arguments = call["arguments"]
print(f" Calling {tool_name} with {arguments}")
try:
result = client.call_tool(tool_name, arguments)
print(f" Result: {json.dumps(result, indent=2)}")
except Exception as e:
print(f" Error calling {tool_name}: {e}")
else:
# In a real implementation, you would send this to your LLM
# and let the LLM decide whether to call tools
print("Assistant: I received your message. In a full implementation, this would be processed by an LLM that could decide to call tools.")
print("For now, use @tool_name({\"param_name\": \"value\"}) syntax to test tool calls.")
except KeyboardInterrupt:
break
except Exception as e:
print(f"Error: {e}")
finally:
client.disconnect()
print("\nGoodbye!")
if __name__ == "__main__":
main()
DON’T PANIC!
You’ll be okay! I know it feels like a loooot of code. But most of it is to perform the task of the LLM programmatically.
Let me explain, and you’ll be fine. I promised.
Starting from the top:
- init(): The constructor takes the server URL as input. initialize(): This method will try to connect to the server and send greetings. Upon success, it will request available tools from the server.
- _make_request(): This method is used to interact with the server. It’s used to do the initial connection and list available tools. Also, we use request to store the session token that identifies our client.
- _handle_sse_response(): This method will parse the response from the server. The SSE protocol starts with the bytes “data: ” so we strip this from the string. Also, the message delimiters are \n\n so we can go through it line by line. Each line should be a different message.
- call_tool(): This method will ask the MCP server to call a tool by its name and with the parameters you gave using the client’s CLI. detect_tool_calls(): This function should be entirely replaced by classic LLM “function calling”. For this demo, I parse the tool name and its arguments programmatically by following the convention@tool_name({"arg_name": "value1"}). However, this should be replaced by a “function call” as specified by OpenAI spec’s.
- main(): The main chat loop. I hope you survived. Open the code in your favorite Python IDE, and I’m sure it will look less scary!
IV. Using the client
1. Connecting to our local demo server
Now that we have a client and a server. Let’s connect them!
python3 client.py
=== Demo MCP Client ===
Enter MCP server URL (e.g., https://mcp.deepwiki.com/mcp): http://localhost:1337/mcp
Server assigned session ID: 03da4b6c-124a-4274-8e25-8b53e41a5e37
Connected to MCP server: weather-http-server v0.0.1
Available tool: example_tool - An example tool that processes messages
Available tool: weather - Get weather information for a city
=== Chat Loop Started ===
Type your messages below. Use @tool_name(arg1="value1") to call tools.
Type 'quit' to exit, 'tools' to list available tools.
> @weather({"city": "paris"})
Detected tool calls:
Calling weather with {'city': 'Paris'}
Result: {
"content": [
{
"type": "text",
"text": "{\"city\":\"Paris\",\"temperature\":22,\"condition\":\"Sunny\",\"humidity\":45}"
}
]
}
> ^C
Goodbye!
In the above output, we connected to http://localhost:1337/mcp
which is our local server.
We can see that the server sent information about the available tools. We ask for the weather tool with the argument “Paris”: @weather({"city": "Paris"}) . Then the server answers with the fake response, which should be added to the LLM context if we had one.
2. Connecting to a real remote MCP server
Do you know DeepWiki? It’s a tool that scans GitHub repositories and allows you to chat with an LLM about the repo’s content. I hate to say this because it was made by the guys who made the Devin crap. But this time it works quite well…
They have a fully working MCP server with 3 tools. Let’s try to connect to it.
python3 client.py
=== Yacana MCP Client ===
Enter MCP server URL (e.g., https://mcp.deepwiki.com/mcp): https://mcp.deepwiki.com/mcp
Server assigned session ID: 68d8083a519da262c385212ad6aeb08c91733479d484af2ad7065fa9614678e2
Connected to MCP server: DeepWiki v0.0.1
Available tool: read_wiki_structure - Get a list of documentation topics for a GitHub repository
Available tool: read_wiki_contents - View documentation about a GitHub repository
Available tool: ask_question - Ask any question about a GitHub repository
=== Chat Loop Started ===
Type your messages below. Use @tool_name(arg1="value1") to call tools.
Type 'quit' to exit, 'tools' to list available tools.
> tools
Available tools:
- read_wiki_structure: Get a list of documentation topics for a GitHub repository
Parameters: repoName(string)*
- read_wiki_contents: View documentation about a GitHub repository
Parameters: repoName(string)*
- ask_question: Ask any question about a GitHub repository
Parameters: repoName(string)*, question(string)*
> @ask_question({"repoName": "rememberSoftwares/yacana", "question": "What is yacana?"})
Detected tool calls:
Calling ask_question with {'repoName': 'rememberSoftwares/yacana', 'question': 'What is Yacana?'}
Result: {
"content": [
{
"type": "text",
"text": "Yacana is an open-source, task-driven multi-agent framework designed to simplify the creation of LLM-powered applications. It focuses on chaining tasks together rather than solely on agents, offering a flexible and intuitive API for both beginners and advanced AI users. \n\n## Core Concepts\n\nThe Yacana framework revolves around two primary classes: `Agent` and `Task`.\n\n### Agent\nAn `Agent` in Yacana represents an LLM (Large Language Model) instance. You can create agents using either `OllamaAgent` for local models or `OpenAiAgent` for OpenAI-compatible APIs. \n\n* **`OllamaAgent`**: Used for interacting with Ollama inference servers. It requires a `name` and `model_name`, and can optionally take a `system_prompt` and `endpoint`. \n* **`OpenAiAgent`**: Used for interacting with OpenAI-compatible APIs. It also requires a `name` and `model_name`, along with an `api_token` and `endpoint`. \n\nAgents maintain their own conversation history, allowing for contextual interactions across multiple tasks. \n\n### Task\nA `Task` is the fundamental execution unit in Yacana. It encapsulates a prompt and is executed by an `Agent`. \n\n* To create a task, you provide a `prompt` (the instruction for the LLM) and an `agent` instance. \n* The `solve()` method of a `Task` executes the prompt using the assigned agent and returns a `GenericMessage` object, which contains the LLM's response in its `content` attribute. \n\n## Key Features\n\nYacana offers several features to enhance LLM interactions:\n\n* **Task Chaining**: Tasks can be linked together by reusing the same agent instance, allowing for multi-turn conversations where each task builds upon the agent's history. \n* **Tool Calling**: Yacana enables LLMs to invoke Python functions (tools) to perform specific actions. This is a core strength, allowing even small open-source models to use tools effectively. \n * Tools are defined using the `Tool` class, specifying a `tool_name`, `function_description`, and a `function_ref` (the Python function to be called). \n * Unlike other frameworks, tools are made available at the `Task` level, not permanently assigned to agents, reducing \"noise\" and improving relevance. \n* **Structured Output**: This feature allows you to define a Pydantic model for the LLM's response, ensuring type-safe JSON outputs. \n* **Streaming Responses**: Yacana supports token-by-token processing of LLM responses, useful for real-time user feedback. \n* **Multi-Agent Collaboration**: The framework supports orchestrating conversations between multiple agents, enabling complex problem-solving scenarios. \n\n## Installation\nYou can install Yacana using pip: `pip install yacana` \n\n## Example Usage\n\nHere's a basic example demonstrating agent creation and task solving:\n```
python\nfrom yacana import OllamaAgent, Task\n\n# Create agent\nagent = OllamaAgent(\n \"Research Assistant\", \n \"llama3.1:8b\",\n system_prompt=\"You are a helpful research assistant\"\n) \n\n# Execute tasks with chaining\nTask(\"What is machine learning?\", agent).solve() \nclarification = Task(\"Explain that in simpler terms\", agent).solve().content \n\nprint(f\"Simplified explanation: {clarification}\") \n\n# Check conversation history\nagent.history.pretty_print() \n
```\n\n## Notes\nYacana was initially designed for Ollama but now supports any OpenAI-compatible endpoints. The framework is open-source under the MIT license. \n\nWiki pages you might want to explore:\n- [Quick Start Guide (rememberSoftwares/yacana)](/wiki/rememberSoftwares/yacana#2.1)\n- [Advanced Features (rememberSoftwares/yacana)](/wiki/rememberSoftwares/yacana#6)\n\nView this search on DeepWiki: https://deepwiki.com/search/what-is-yacana_e1a73f0f-7ad2-4c53-8028-9513cca5c37e\n"
}
]
}
> ^C
Goodbye!
In the above output, we connected to https://mcp.deepwiki.com/mcp
. During initialization, we retrieve and print the list of available tools.
We use the tools command to print information about the tools parameters. We then call the ask_question tool with two parameters: repoName and question. The MCP server answers our question.
V. Conclusion
MCP is not that simple, and the protocol will evolve. I guess that the STDIO transport will slowly die. Hopefully.
For authentication, you can use the headers member variable to deal with different authorisation flows.
In the meantime, if you want to have fun with LLMs locally or otherwise, go try Yacana!
It’s an agent framework that focuses on simplicity with powerful features. It will soon have MCP support. ;-)
I’ll update this article when I get deeper into this.
HF
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.