mirror of
https://github.com/Homebrew/brew.git
synced 2025-07-14 16:09:03 +08:00

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
|