brew/Library/Homebrew/test/mcp_server_spec.rb
Mike McQuaid f9471f9591
Add brew mcp-server: a MCP server for Homebrew.
Add a new `brew mcp-server` command for a Model Context Protocol (MCP)
server for Homebrew. This integrates with AI/LLM tools like Claude,
Claude Code and Cursor.

It currently supports the calls needed/used by the MCP Inspector and
Cursor (where I've tested it).

It provides as `tools` the subcommands output by `brew help` but should
be fairly straightforward to add more in future.

It is implemented in a slightly strange way (a standalone Ruby command
called from a shell command) as MCP servers need a faster startup time
than a normal Homebrew Ruby command allows and fail if they don't get
it.

There are a few Ruby libraries available but, given how relatively
simplistic the implementation is, it didn't feel worthwhile to use and
vendor them.
2025-06-03 15:22:33 +01:00

250 lines
7.7 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
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
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