|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +require "anthropic" |
| 4 | +require "dotenv/load" |
| 5 | +require "json" |
| 6 | +require "mcp" |
| 7 | + |
| 8 | +class MCPClient |
| 9 | + ANTHROPIC_MODEL = "claude-sonnet-4-5" |
| 10 | + |
| 11 | + def initialize |
| 12 | + @mcp_client = nil |
| 13 | + @transport = nil |
| 14 | + @anthropic_client = nil |
| 15 | + end |
| 16 | + |
| 17 | + def connect_to_server(server_script_path) |
| 18 | + command = case File.extname(server_script_path) |
| 19 | + when ".rb" |
| 20 | + "ruby" |
| 21 | + when ".py" |
| 22 | + "python3" |
| 23 | + when ".js" |
| 24 | + "node" |
| 25 | + else |
| 26 | + raise ArgumentError, "Server script must be a .rb, .py, or .js file." |
| 27 | + end |
| 28 | + |
| 29 | + @transport = MCP::Client::Stdio.new(command: command, args: [server_script_path]) |
| 30 | + @mcp_client = MCP::Client.new(transport: @transport) |
| 31 | + |
| 32 | + @tools = @mcp_client.tools |
| 33 | + tool_names = @tools.map(&:name) |
| 34 | + |
| 35 | + puts "\nConnected to server with tools: #{tool_names}" |
| 36 | + end |
| 37 | + |
| 38 | + def chat_loop |
| 39 | + puts <<~MESSAGE |
| 40 | + MCP Client Started! |
| 41 | + Type your queries or 'quit' to exit. |
| 42 | + MESSAGE |
| 43 | + |
| 44 | + loop do |
| 45 | + print "\nQuery: " |
| 46 | + line = $stdin.gets |
| 47 | + break if line.nil? |
| 48 | + |
| 49 | + query = line.chomp.strip |
| 50 | + break if query.downcase == "quit" |
| 51 | + next if query.empty? |
| 52 | + |
| 53 | + begin |
| 54 | + response = process_query(query) |
| 55 | + puts "\n#{response}" |
| 56 | + rescue => e |
| 57 | + puts "\nError: #{e.message}" |
| 58 | + end |
| 59 | + end |
| 60 | + end |
| 61 | + |
| 62 | + def cleanup |
| 63 | + @transport&.close |
| 64 | + end |
| 65 | + |
| 66 | + private |
| 67 | + |
| 68 | + def process_query(query) |
| 69 | + messages = [{ role: "user", content: query }] |
| 70 | + |
| 71 | + available_tools = @tools.map do |tool| |
| 72 | + { name: tool.name, description: tool.description, input_schema: tool.input_schema } |
| 73 | + end |
| 74 | + |
| 75 | + # Initial Claude API call. |
| 76 | + response = chat(messages, tools: available_tools) |
| 77 | + |
| 78 | + # Process response and handle tool calls. |
| 79 | + if response.content.any?(Anthropic::Models::ToolUseBlock) |
| 80 | + assistant_content = response.content.filter_map do |content_block| |
| 81 | + case content_block |
| 82 | + when Anthropic::Models::TextBlock |
| 83 | + { type: "text", text: content_block.text } |
| 84 | + when Anthropic::Models::ToolUseBlock |
| 85 | + { type: "tool_use", id: content_block.id, name: content_block.name, input: content_block.input } |
| 86 | + end |
| 87 | + end |
| 88 | + messages << { role: "assistant", content: assistant_content } |
| 89 | + end |
| 90 | + |
| 91 | + response.content.each_with_object([]) do |content, response_parts| |
| 92 | + case content |
| 93 | + when Anthropic::Models::TextBlock |
| 94 | + response_parts << content.text |
| 95 | + when Anthropic::Models::ToolUseBlock |
| 96 | + tool_name = content.name |
| 97 | + tool_args = content.input |
| 98 | + |
| 99 | + # Find the matching MCP tool object. |
| 100 | + tool = @tools.find { |tool| tool.name == tool_name } |
| 101 | + |
| 102 | + # Execute tool call via MCP. |
| 103 | + result = @mcp_client.call_tool(tool: tool, arguments: tool_args) |
| 104 | + response_parts << "[Calling tool #{tool_name} with args #{tool_args.to_json}]" |
| 105 | + |
| 106 | + tool_result_content = result.dig("result", "content") |
| 107 | + result_text = if tool_result_content.is_a?(Array) |
| 108 | + tool_result_content.filter_map { |content_item| content_item["text"] }.join("\n") |
| 109 | + else |
| 110 | + tool_result_content.to_s |
| 111 | + end |
| 112 | + |
| 113 | + messages << { |
| 114 | + role: "user", |
| 115 | + content: [{ |
| 116 | + type: "tool_result", |
| 117 | + tool_use_id: content.id, |
| 118 | + content: result_text |
| 119 | + }] |
| 120 | + } |
| 121 | + |
| 122 | + # Get next response from Claude. |
| 123 | + response = chat(messages) |
| 124 | + |
| 125 | + response.content.each do |content_block| |
| 126 | + response_parts << content_block.text if content_block.is_a?(Anthropic::Models::TextBlock) |
| 127 | + end |
| 128 | + end |
| 129 | + end.join("\n") |
| 130 | + end |
| 131 | + |
| 132 | + def chat(messages, tools: nil) |
| 133 | + params = { model: ANTHROPIC_MODEL, max_tokens: 1000, messages: messages } |
| 134 | + params[:tools] = tools if tools |
| 135 | + |
| 136 | + anthropic_client.messages.create(**params) |
| 137 | + end |
| 138 | + |
| 139 | + def anthropic_client |
| 140 | + @anthropic_client ||= Anthropic::Client.new(api_key: ENV["ANTHROPIC_API_KEY"]) |
| 141 | + end |
| 142 | +end |
| 143 | + |
| 144 | +if ARGV.empty? |
| 145 | + puts "Usage: ruby client.rb <path_to_server_script>" |
| 146 | + exit 1 |
| 147 | +end |
| 148 | + |
| 149 | +client = MCPClient.new |
| 150 | + |
| 151 | +begin |
| 152 | + client.connect_to_server(ARGV[0]) |
| 153 | + |
| 154 | + api_key = ENV["ANTHROPIC_API_KEY"] |
| 155 | + if api_key.nil? || api_key.empty? |
| 156 | + puts <<~MESSAGE |
| 157 | + No ANTHROPIC_API_KEY found. To query these tools with Claude, set your API key: |
| 158 | + export ANTHROPIC_API_KEY=your-api-key-here |
| 159 | + MESSAGE |
| 160 | + exit |
| 161 | + end |
| 162 | + |
| 163 | + client.chat_loop |
| 164 | +rescue => e |
| 165 | + puts "Error: #{e.message}" |
| 166 | + exit 1 |
| 167 | +ensure |
| 168 | + client.cleanup |
| 169 | +end |
0 commit comments