Skip to content

Instantly share code, notes, and snippets.

@maxim
Last active May 25, 2025 18:32
Show Gist options
  • Save maxim/e6d024892031ed526eb270c7baebe6c1 to your computer and use it in GitHub Desktop.
Save maxim/e6d024892031ed526eb270c7baebe6c1 to your computer and use it in GitHub Desktop.
A quick one file stdio MCP server in Ruby
#!/usr/bin/env ruby
require 'json'
require 'shellwords'
module Mcp
Prop = Data.define(:name, :type, :desc, :req) do
def to_h = { type: type, description: desc }
end
class Definition
attr_accessor :name, :desc, :props
def initialize = @props = []
def to_h
{
name:,
description: desc,
inputSchema: {
type: 'object',
properties: props.map { [_1.name, _1.to_h] }.to_h,
required: props.select(&:req).map(&:name)
}
}
end
end
class Tool
class << self
attr_accessor :mcp
def inherited(base) = base.mcp = Definition.new
def name(string) = mcp.name = string
def desc(string) = mcp.desc = string
def arg(*args) = mcp.props << Prop[*args, true]
def opt(*args) = mcp.props << Prop[*args, false]
end
def run = raise 'Override in subclass'
end
class Server
ERROR_TYPES = {
invalid_json: [-32700, 'Invalid JSON'],
invalid_request: [-32600, 'Invalid request'],
method_not_found: [-32601, 'Method not found'],
invalid_params: [-32602, 'Invalid params'],
internal: [-32603, 'Internal error']
}.freeze
def initialize(*tools)
@tools = tools.map { [_1.mcp.name, _1.mcp.to_h] }.to_h
@objs = tools.map(&:new)
end
def run
loop do
input = STDIN.gets
break if input.nil?
request =
begin
JSON.parse(input.strip)
rescue
puts error_for({'id' => nil}, :invalid_json)
STDOUT.flush
next
end
response = handle_request(request)
puts JSON.generate(response)
STDOUT.flush
end
end
private
def handle_request(request)
case request['method']
when 'initialize'
response_for request,
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
serverInfo: { name: 'ruby-mcp-server', version: '1.0.0' }
when 'tools/list'
response_for request, tools: @tools.values
when 'tools/call'
handle_tool_call request
else
error_for(request, :method_not_found)
end
end
def handle_tool_call(request)
name = request.dig('params', 'name')
tool = @objs.find { _1.class.mcp.name == name }
return error_for(request, :invalid_params, "Unknown_tool: #{name}") if !tool
args = request.dig('params', 'arguments')&.transform_keys(&:to_sym)
begin
result = tool.run(**args)
# TODO: Support other content types (ask claude code which ones).
response_for(request, content: [{ type: 'text', text: result.to_s }])
rescue => e
error_for(request, :internal, e.full_message(highlight: false))
end
end
def error_for(request, type, message = ERROR_TYPES[type][1])
code = ERROR_TYPES[type][0]
{ jsonrpc: '2.0', id: request['id'], error: { code:, message: } }
end
def response_for(request, **hash)
{ jsonrpc: '2.0', id: request['id'], result: hash }
end
end
def self.serve(...) = Server.new(...).run
end
# IMPLEMENT YOUR TOOLS HERE:
# ==========================
class Tool1 < Mcp::Tool
name 'my_tool'
desc 'explaination for LLM what it does'
# Use arg for required and opt for optional.
arg :some_required_arg, :string, 'tell llm what this arg is (add example)'
opt :some_optional_arg, :string, 'tell llm what this opt is (add example)'
# Implement this method
def run(some_required_arg:, some_optional_arg: nil)
# 1. do something
# 2. whatever you return will be to_json'ed
# 3. let errors just raise, it's handled by server
# 4. assume args are always strings
end
end
class Tool2 < Mcp::Tool
name 'other_tool'
desc 'explanation'
arg :some_required_arg, :string, 'description'
def run(some_required_arg:) = puts 'something'
end
Mcp.serve(Tool1, Tool2) if __FILE__ == $0
# Place this file somewhere like: `bin/mcp`
# Make it executable: `chmod +x bin/mcp`
# Then add to Claude with: `claude mcp add some-mcp-name bin/mcp`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment