Skip to content

Instantly share code, notes, and snippets.

@maxim
Last active May 25, 2025 18:32
Show Gist options
  • Select an option

  • Save maxim/e6d024892031ed526eb270c7baebe6c1 to your computer and use it in GitHub Desktop.

Select an option

Save maxim/e6d024892031ed526eb270c7baebe6c1 to your computer and use it in GitHub Desktop.

Revisions

  1. maxim revised this gist May 25, 2025. No changes.
  2. maxim created this gist May 25, 2025.
    153 changes: 153 additions & 0 deletions mcp.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,153 @@
    #!/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`