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

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.
250 lines
7.7 KiB
Ruby
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
|