 818662352c
			
		
	
	
		818662352c
		
			
		
	
	
	
	
		
			
			We need to handle when `stdin` is closed but there's no interrupt signal. Without this, the server will be stuck an in infinite busy loop.
		
			
				
	
	
		
			254 lines
		
	
	
		
			7.8 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
			
		
		
	
	
			254 lines
		
	
	
		
			7.8 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
| # frozen_string_literal: true
 | |
| 
 | |
| require "mcp_server"
 | |
| require "stringio"
 | |
| require "timeout"
 | |
| 
 | |
| RSpec.describe Homebrew::McpServer do
 | |
|   let(:stdin) { StringIO.new }
 | |
|   let(:stdout) { StringIO.new }
 | |
|   let(:stderr) { StringIO.new }
 | |
|   let(:server) { described_class.new(stdin:, stdout:, stderr:) }
 | |
|   let(:jsonrpc) { Homebrew::McpServer::JSON_RPC_VERSION }
 | |
|   let(:id) { Random.rand(1000) }
 | |
|   let(:code) { Homebrew::McpServer::ERROR_CODE }
 | |
| 
 | |
|   describe "#initialize" do
 | |
|     it "sets debug_logging to false by default" do
 | |
|       expect(server.debug_logging?).to be(false)
 | |
|     end
 | |
| 
 | |
|     it "sets debug_logging to true if --debug is in ARGV" do
 | |
|       stub_const("ARGV", ["--debug"])
 | |
|       expect(server.debug_logging?).to be(true)
 | |
|     end
 | |
| 
 | |
|     it "sets debug_logging to true if -d is in ARGV" do
 | |
|       stub_const("ARGV", ["-d"])
 | |
|       expect(server.debug_logging?).to be(true)
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   describe "#debug and #log" do
 | |
|     it "logs debug output when debug_logging is true" do
 | |
|       stub_const("ARGV", ["--debug"])
 | |
|       server.debug("foo")
 | |
|       expect(stderr.string).to include("foo")
 | |
|     end
 | |
| 
 | |
|     it "does not log debug output when debug_logging is false" do
 | |
|       server.debug("foo")
 | |
|       expect(stderr.string).to eq("")
 | |
|     end
 | |
| 
 | |
|     it "logs to stderr" do
 | |
|       server.log("bar")
 | |
|       expect(stderr.string).to include("bar")
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   describe "#handle_request" do
 | |
|     it "responds to initialize method" do
 | |
|       request = { "id" => id, "method" => "initialize" }
 | |
|       result = server.handle_request(request)
 | |
|       expect(result).to eq({
 | |
|         jsonrpc:,
 | |
|         id:,
 | |
|         result:  {
 | |
|           protocolVersion: Homebrew::McpServer::MCP_PROTOCOL_VERSION,
 | |
|           capabilities:    {
 | |
|             tools:     { listChanged: false },
 | |
|             prompts:   {},
 | |
|             resources: {},
 | |
|             logging:   {},
 | |
|             roots:     {},
 | |
|           },
 | |
|           serverInfo:      Homebrew::McpServer::SERVER_INFO,
 | |
|         },
 | |
|       })
 | |
|     end
 | |
| 
 | |
|     it "responds to resources/list" do
 | |
|       request = { "id" => id, "method" => "resources/list" }
 | |
|       result = server.handle_request(request)
 | |
|       expect(result).to eq({ jsonrpc:, id:, result: { resources: [] } })
 | |
|     end
 | |
| 
 | |
|     it "responds to resources/templates/list" do
 | |
|       request = { "id" => id, "method" => "resources/templates/list" }
 | |
|       result = server.handle_request(request)
 | |
|       expect(result).to eq({ jsonrpc:, id:, result: { resourceTemplates: [] } })
 | |
|     end
 | |
| 
 | |
|     it "responds to prompts/list" do
 | |
|       request = { "id" => id, "method" => "prompts/list" }
 | |
|       result = server.handle_request(request)
 | |
|       expect(result).to eq({ jsonrpc:, id:, result: { prompts: [] } })
 | |
|     end
 | |
| 
 | |
|     it "responds to ping" do
 | |
|       request = { "id" => id, "method" => "ping" }
 | |
|       result = server.handle_request(request)
 | |
|       expect(result).to eq({ jsonrpc:, id:, result: {} })
 | |
|     end
 | |
| 
 | |
|     it "responds to get_server_info" do
 | |
|       request = { "id" => id, "method" => "get_server_info" }
 | |
|       result = server.handle_request(request)
 | |
|       expect(result).to eq({ jsonrpc:, id:, result: Homebrew::McpServer::SERVER_INFO })
 | |
|     end
 | |
| 
 | |
|     it "responds to logging/setLevel with debug" do
 | |
|       request = { "id" => id, "method" => "logging/setLevel", "params" => { "level" => "debug" } }
 | |
|       result = server.handle_request(request)
 | |
|       expect(server.debug_logging?).to be(true)
 | |
|       expect(result).to eq({ jsonrpc:, id:, result: {} })
 | |
|     end
 | |
| 
 | |
|     it "responds to logging/setLevel with non-debug" do
 | |
|       request = { "id" => id, "method" => "logging/setLevel", "params" => { "level" => "info" } }
 | |
|       result = server.handle_request(request)
 | |
|       expect(server.debug_logging?).to be(false)
 | |
|       expect(result).to eq({ jsonrpc:, id:, result: {} })
 | |
|     end
 | |
| 
 | |
|     it "responds to notifications/initialized" do
 | |
|       request = { "id" => id, "method" => "notifications/initialized" }
 | |
|       expect(server.handle_request(request)).to be_nil
 | |
|     end
 | |
| 
 | |
|     it "responds to notifications/cancelled" do
 | |
|       request = { "id" => id, "method" => "notifications/cancelled" }
 | |
|       expect(server.handle_request(request)).to be_nil
 | |
|     end
 | |
| 
 | |
|     it "responds to tools/list" do
 | |
|       request = { "id" => id, "method" => "tools/list" }
 | |
|       result = server.handle_request(request)
 | |
|       expect(result[:result][:tools]).to match_array(Homebrew::McpServer::TOOLS.values)
 | |
|     end
 | |
| 
 | |
|     Homebrew::McpServer::TOOLS.each do |tool_name, tool_definition|
 | |
|       it "responds to tools/call for #{tool_name}" do
 | |
|         allow(server).to receive(:`).and_return("output for #{tool_name}")
 | |
|         arguments = {}
 | |
|         Array(tool_definition[:required]).each do |required_key|
 | |
|           arguments[required_key] = "dummy"
 | |
|         end
 | |
|         request = {
 | |
|           "id"     => id,
 | |
|           "method" => "tools/call",
 | |
|           "params" => {
 | |
|             "name"      => tool_name.to_s,
 | |
|             "arguments" => arguments,
 | |
|           },
 | |
|         }
 | |
|         result = server.handle_request(request)
 | |
|         expect(result).to eq({
 | |
|           jsonrpc: jsonrpc,
 | |
|           id:      id,
 | |
|           result:  { content: [{ type: "text", text: "output for #{tool_name}" }] },
 | |
|         })
 | |
|       end
 | |
|     end
 | |
| 
 | |
|     it "responds to tools/call for unknown tool" do
 | |
|       request = { "id" => id, "method" => "tools/call", "params" => { "name" => "not_a_tool", "arguments" => {} } }
 | |
|       result = server.handle_request(request)
 | |
|       expect(result).to eq({ jsonrpc:, id:, error: { message: "Unknown tool", code: } })
 | |
|     end
 | |
| 
 | |
|     it "responds with error for unknown method" do
 | |
|       request = { "id" => id, "method" => "not_a_method" }
 | |
|       result = server.handle_request(request)
 | |
|       expect(result).to eq({ jsonrpc:, id:, error: { message: "Method not found", code: } })
 | |
|     end
 | |
| 
 | |
|     it "returns nil if id is nil" do
 | |
|       request = { "method" => "initialize" }
 | |
|       expect(server.handle_request(request)).to be_nil
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   describe "#respond_result" do
 | |
|     it "returns nil if id is nil" do
 | |
|       expect(server.send(:respond_result, nil, {})).to be_nil
 | |
|     end
 | |
| 
 | |
|     it "returns a result hash if id is present" do
 | |
|       result = server.respond_result(id, { foo: "bar" })
 | |
|       expect(result).to eq({ jsonrpc:, id:, result: { foo: "bar" } })
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   describe "#respond_error" do
 | |
|     it "returns an error hash" do
 | |
|       result = server.respond_error(id, "fail")
 | |
|       expect(result).to eq({ jsonrpc:, id:, error: { message: "fail", code: } })
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   describe "#run" do
 | |
|     let(:sleep_time) { 0.001 }
 | |
| 
 | |
|     it "runs the loop and exits cleanly on interrupt" do
 | |
|       stub_const("ARGV", ["--debug"])
 | |
|       stdin.puts({ id:, method: "ping" }.to_json)
 | |
|       stdin.rewind
 | |
|       server_thread = Thread.new do
 | |
|         server.run
 | |
|       rescue SystemExit
 | |
|         # expected, do nothing
 | |
|       end
 | |
| 
 | |
|       response_hash_string = "Response: {"
 | |
|       sleep(sleep_time)
 | |
|       server_thread.raise(Interrupt)
 | |
|       server_thread.join
 | |
| 
 | |
|       expect(stderr.string).to include(response_hash_string)
 | |
|     end
 | |
| 
 | |
|     it "runs the loop and logs 'Response: nil' when handle_request returns nil" do
 | |
|       stub_const("ARGV", ["--debug"])
 | |
|       stdin.puts({ id:, method: "notifications/initialized" }.to_json)
 | |
|       stdin.rewind
 | |
|       server_thread = Thread.new do
 | |
|         server.run
 | |
|       rescue SystemExit
 | |
|         # expected, do nothing
 | |
|       end
 | |
| 
 | |
|       response_nil_string = "Response: nil"
 | |
|       sleep(sleep_time)
 | |
|       server_thread.raise(Interrupt)
 | |
|       server_thread.join
 | |
| 
 | |
|       expect(stderr.string).to include(response_nil_string)
 | |
|     end
 | |
| 
 | |
|     it "exits on Interrupt" do
 | |
|       stdin.puts
 | |
|       stdin.rewind
 | |
|       allow(stdin).to receive(:gets).and_raise(Interrupt)
 | |
|       expect do
 | |
|         server.run
 | |
|       rescue
 | |
|         SystemExit
 | |
|       end.to raise_error(SystemExit)
 | |
|     end
 | |
| 
 | |
|     it "exits on error" do
 | |
|       stdin.puts
 | |
|       stdin.rewind
 | |
|       allow(stdin).to receive(:gets).and_raise(StandardError, "fail")
 | |
|       expect do
 | |
|         server.run
 | |
|       rescue
 | |
|         SystemExit
 | |
|       end.to raise_error(SystemExit)
 | |
|       expect(stderr.string).to match(/Error: fail/)
 | |
|     end
 | |
|   end
 | |
| end
 |