iTranslated by AI
Implementing a Minimal MCP Host/Client/Server from Scratch
In the past 1-2 months, many explanations of MCP have been released, and I think there's no need to explain what MCP is, so I'll skip that. If you have no idea what it is, reading Easy Introduction to MCP would be good.
While there are already many articles/slides about "What is MCP?" and practical explanations/introductions on how to use it, if you want to learn about the internals of MCP, you would likely end up reading the official Specification.
However, there is a slight gap between these introductory explanations and the Specification. For someone like me who lacks deep comprehension, it's not possible to just read the specification and say "Okay, I understand it now."
So this time, I implemented MCP's Host/Client/Server from scratch, hoping to bridge the gap between introductions and the Specification. I used Ruby for the implementation.
Overall Picture

I will implement the three components mentioned above: Host, MCP Client, and MCP Server. To give a brief and rough explanation of each role:
- The Host manages the interaction between user input and the LLM, showing the results to the user.
- The MCP Client sends requests to the MCP Server and returns the results to the Host.
- The MCP Server receives requests from the MCP Client, performs specific processing, and returns the result to the Client.
Cline or Claude Desktop corresponds to the Host, which has an internal MCP Client, while things like Figma MCP or Firecrawl correspond to the MCP Server.
Transport Layer
Communication between the MCP Client and MCP Server uses JSON-RPC as the message format. Recommended communication methods are stdio (standard input/output) or Streamable HTTP (though other unique communication methods seem to be allowed). I only implemented stdio this time because it simplifies the implementation.
Specifically, "using stdio" means launching the MCP Server process using something like popen in C, obtaining the standard input/output of that process, and interacting by reading from and writing to it. In Ruby, it looks like this:
require 'open3'
# Start the server
# Once started, you can obtain the standard input/output of that server process
@stdio, @stdout, @stderr, @wait_thr = Open3.popen3(`ruby hoge_server.rb`)
@stdio.puts(request_message) # Send request to hoge_server
response = @stdout.read # Receive response from hoge_server
puts response
@stdout.close # Close standard output
Implementation
First, about what we are implementing this time. I'm assuming we'll create a CLI that, when a message is sent to the LLM as shown below, appropriately executes the MCP server, receives the result, and returns a message to the user.

The repository is below.
As for the MCP server, I implemented one with a function to roll a dice. It's a simple one that returns a random value based on the specified number of sides on the dice.
Based on the message entered by the user, the LLM reads the number of sides and has the MCP server execute with it as an argument.
Host Implementation
Host for implementation includes the following three things:
- Receiving user input
- Sending a message to the LLM and receiving the result
- Returning the result to the user
We use $stdin.gets to receive user input and $stdout.puts to output to the user.
In the phase of sending a message to the LLM and receiving the result, we are doing two additional things.
The first is to combine "user input" and "the functions (Tools) implemented by the MCP server" to have the LLM think about what to do next.
The second is to receive the response from the LLM, perform an action based on that response (such as executing an MCP server Tool), and send everything including those results back to the LLM to generate the final result.
In short, there are two rounds of communication with the LLM for a single user input. Incidentally, while it's more complex, coding agents do something similar in a loop: creating a System Prompt, getting Feedback, updating the System Prompt, and sending it back to the LLM.
The specific implementation is below.
The process of combining user input and the MCP server's Tools is here. It has the MCP Client execute list_tools (to get a list of functions implemented by the MCP Server) and sends those results along with the basic message to Claude.
messages = [
{
'role': 'user',
'content': query
}
]
response = @client.list_tools
available_tools = response[:tools].map do |tool|
{
name: tool[:name],
description: tool[:description],
input_schema: tool[:input_schema]
}
end
response = @anthropic_client.messages(
parameters: {
model: 'claude-3-7-sonnet-20250219',
system: 'Respond only in Japanese.',
messages: messages,
max_tokens: 1000,
tools: available_tools
}
)
As for the second part, it's shown below. Since multiple contents are returned as the initial LLM response, we process them according to each content type.
The case where the type is tool_use is the process for executing the MCP server's Tool. Using the name (Tool name) and arguments (input) returned from the LLM, the MCP server's Tool is executed via call_tool.
response['content'].each do |content|
if content['type'] == 'text'
final_text << content['text']
assistant_message_content << content
elsif content['type'] == 'tool_use'
tool_name = content['name']
tool_args = content['input']
result = @client.call_tool(name: tool_name, args: tool_args)
final_text.push("[Calling tool #{tool_name} with args #{tool_args}]")
assistant_message_content.push(content)
messages.push({
'role': 'assistant',
'content': assistant_message_content
})
messages.push({
'role': 'user',
'content': [
{
'type': 'tool_result',
'tool_use_id': content['id'],
'content': result.to_s
}
]
})
response = @anthropic_client.messages(
parameters: {
model: 'claude-3-7-sonnet-20250219',
max_tokens: 1000,
messages: messages,
tools: available_tools
}
)
final_text.push(response['content'][0]['text'])
end
end
We will implement list_tools and call_tool later.
Note that for the Anthropic API client, since it's not necessary for the explanation of this implementation, please forgive me for using a gem.
Client Implementation
In the MCP Client, messages are exchanged according to JSON-RPC. For more information on JSON-RPC, you should read this. In short, it is a protocol for exchanging messages using JSON objects that have member fields such as jsonrpc/method/params/id. For now, you can get an idea by reading the 7 Examples.
The following diagram makes the Client implementation easy to understand.

As a minimum implementation, you just need to implement the three phases in this diagram: Initialization Phase, Operation Phase, and Shutdown Phase.
Initialization Phase
In the Initialization Phase, the Client sends two messages: an initialized request and an initialized notification.
The initialized request is a message to request initialization of the Client from the MCP Server. A minimal message looks like this:
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"clientInfo": {
"name": "ExampleClient",
"version": "1.0.0"
}
}
}
The initialized notification is a message that notifies the MCP Server that the Client's initialization is complete. A minimal message looks like this:
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
The specific implementation is below.
Extracting just the implementation of the Initialization Phase looks like this:
# Initialize request/initialize response
response = send_request({
jsonrpc: '2.0',
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
clientInfo: {
name: 'MCP Client',
version: '1.0.0'
}
},
id: SecureRandom.uuid
})
# initialized notification
@stdin.puts(JSON.generate({
jsonrpc: '2.0',
method: 'notifications/initialized'
}))
Both the Client and Server are not supposed to send any messages other than PING/LOGGING until this Initialization Phase is complete.
By the way, regarding the initialize request, you can specify something called capabilities in the params. This is an optional protocol that must be explicitly exchanged between the client and server during the initialization phase.
The list of capabilities is as follows. I haven't implemented them this time, but they're here for reference.

Operation Phase
In the Operation Phase, messages are exchanged between the Client and Server according to the rules of their respective capability negotiation.
Since we want to use the Tool capabilities list_tools and call_tool this time, we will send messages to execute those Tools.
It's faster to see the implementation, so both are shown below.
def list_tools
return raise 'Server is not running' if @pid.nil?
send_request({
jsonrpc: '2.0',
method: 'tools/list',
params: {},
id: SecureRandom.uuid
})
end
def call_tool(name:, args: {})
send_request({
jsonrpc: '2.0',
method: 'tools/call',
params: { name: name, args: args },
id: SecureRandom.uuid
})
end
The methods are specified as tools/list and tools/call, respectively. I've made it possible to specify params as needed. The MCP Server side will execute processing tailored to each request upon receiving these messages.
Shutdown Phase
In the Shutdown Phase, the Client simply closes stdio and sends a TERM signal to the Server.
def close
return if @pid.nil?
@stdin.close
@stdout.close
@stderr.close
Process.kill('TERM', @pid)
@wait_thr.value
rescue IOError, Errno::ESRCH
ensure
@pid = nil
end
That's it for the MCP Client. Next is the MCP Server implementation.
Server Implementation
As for the Server implementation, it's just a matter of implementing how to return responses to the Client implementation we just looked at.
I'll provide the specific implementation first.
Initialization Phase
In the Initialization Phase, the Server receives an initialize request and initialized notification from the Client and executes processing according to each request.
For an initialize request, it returns a response according to the method included in the request, as follows:
case request['method']
when 'initialize'
{
jsonrpc: '2.0',
id: 1,
result: {
protocolVersion: '2025-03-26',
serverInfo: {
name: 'MCP Client',
version: '1.0.0'
},
instructions: 'Optional instructions for the client'
}
}
end
Regarding the initialized notification, there is no need to return anything, so we ensure nothing is returned.
Operation Phase
In the Operation Phase, it receives list_tools and call_tool from the Client and executes processing according to each request.
list_tools
For list_tools, it is necessary to return a list of tools implemented by the MCP Server as message objects containing name, description, and input_schema, as shown below.
when 'tools/list'
tools = @tools.map do |name, tool|
{
name:,
description: tool[:description],
input_schema: tool[:input_schema]
}
end
{
jsonrpc: '2.0',
id: request['id'],
result: {
tools: tools
}
}
end
You might wonder where @tools came from, but this is created during the phase where the MCP Server's Tools are registered.
Specifically, for example, the repository mentioned earlier has an implementation for registering an MCP Server Tool in mcp/dice/server.rb, where a Tool is registered with the key dice to @tools.
require_relative '../server'
@server = MCP::Server.new
@server.register_tool(
name: 'dice',
description: 'Roll a dice',
input_schema: {
type: 'object',
properties: {
sides: {
type: 'integer',
description: 'The number of sides on the dice'
}
},
required: ['sides']
},
handler: proc do |args|
rand(1..args['sides'])
end
)
@server.run
From the perspective of someone just using an MCP Server in a coding agent, etc., you would only need to implement the mcp/dice/server.rb part and register it with the agent. Most people who have implemented an MCP Server probably used an existing SDK, so they didn't need to implement these Client/Server processes; defining the Tool was enough. Inside the SDK, it's actually doing something like this (I haven't read the official MCP SDK implementation, but it's likely something like this).
call_tool
For call_tool, it just executes the handler for the tool implemented by the MCP Server and returns the result. You can see that the handler registered in the previous mcp/dice/server.rb is executed directly.
when 'tools/call'
tool_name = request['params']['name']
tool_args = request['params']['args']
tool = @tools[tool_name]
result = tool[:handler].call(tool_args)
{
jsonrpc: '2.0',
id: request['id'],
result: {
content: result
}
}
end
That's all for the implementation of call_tool.
Summary
This concludes the simple implementation of an MCP Host, Client, and Server.
While I omitted them this time, considering practical use, error handling and timeout processing would be necessary. Additionally, using Streamable HTTP (though I'm not yet sure how it differs from traditional SSE...) in the Transport Layer would make it even more convenient. Furthermore, I haven't implemented detailed Capability Negotiations other than for Tools, but since there are quite a few types, implementing them yourself seems tedious. For practical purposes, it's probably best to use the official SDK.
By the way, Authorization has been added to the latest 2025-03-26 Specification. Since the discussion around MCP execution and security is a relatively hot topic, and the specifications themselves have already been formulated, it looks like there will be a trend of each client implementing them.
Incidentally, this attempt is not new at all; in fact, a repository called funwarioisii/mcp-rb already exists and served as a great reference. It includes implementations for things like pagination for list_tools and has an abstracted Transport Layer, making the implementation quite clean, so please check it out as well.
My implementation this time was created by reading the Specification and stripping down the features from that repository to keep it simple enough to focus on learning. While it's not suitable for practical use at all, I think it serves as a good reference for learning what MCP is at an implementation level.
Discussion