diff --git a/Library/Homebrew/brew.sh b/Library/Homebrew/brew.sh index 38a4058b34..537ee55a4e 100644 --- a/Library/Homebrew/brew.sh +++ b/Library/Homebrew/brew.sh @@ -600,6 +600,11 @@ case "$1" in homebrew-version exit 0 ;; + mcp-server) + source "${HOMEBREW_LIBRARY}/Homebrew/cmd/mcp-server.sh" + homebrew-mcp-server "$@" + exit 0 + ;; esac # TODO: bump version when new macOS is released or announced and update references in: diff --git a/Library/Homebrew/cmd/mcp-server.rb b/Library/Homebrew/cmd/mcp-server.rb new file mode 100644 index 0000000000..9a31e2a3e4 --- /dev/null +++ b/Library/Homebrew/cmd/mcp-server.rb @@ -0,0 +1,23 @@ +# typed: strong +# frozen_string_literal: true + +require "abstract_command" +require "shell_command" + +module Homebrew + module Cmd + class McpServerCmd < AbstractCommand + # This is a shell command as MCP servers need a faster startup time + # than a normal Homebrew Ruby command allows. + include ShellCommand + + cmd_args do + description <<~EOS + Starts the Homebrew MCP (Model Context Protocol) server. + EOS + switch "-d", "--debug", description: "Enable debug logging to stderr." + switch "--ping", description: "Start the server, act as if receiving a ping and then exit.", hidden: true + end + end + end +end diff --git a/Library/Homebrew/cmd/mcp-server.sh b/Library/Homebrew/cmd/mcp-server.sh new file mode 100644 index 0000000000..692eec5ed4 --- /dev/null +++ b/Library/Homebrew/cmd/mcp-server.sh @@ -0,0 +1,14 @@ +# Documentation defined in Library/Homebrew/cmd/mcp-server.rb + +# This is a shell command as MCP servers need a faster startup time +# than a normal Homebrew Ruby command allows. + +# HOMEBREW_LIBRARY is set by brew.sh +# HOMEBREW_BREW_FILE is set by extend/ENV/super.rb +# shellcheck disable=SC2154 +homebrew-mcp-server() { + source "${HOMEBREW_LIBRARY}/Homebrew/utils/ruby.sh" + setup-ruby-path + export HOMEBREW_VERSION + "${HOMEBREW_RUBY_PATH}" "-r${HOMEBREW_LIBRARY}/Homebrew/mcp_server.rb" -e "Homebrew::McpServer.new.run" "$@" +} diff --git a/Library/Homebrew/mcp_server.rb b/Library/Homebrew/mcp_server.rb new file mode 100644 index 0000000000..2ce1c89e11 --- /dev/null +++ b/Library/Homebrew/mcp_server.rb @@ -0,0 +1,272 @@ +# typed: strict +# frozen_string_literal: true + +# This is a standalone Ruby script as MCP servers need a faster startup time +# than a normal Homebrew Ruby command allows. +require_relative "standalone" +require "json" +require "stringio" + +module Homebrew + # Provides a Model Context Protocol (MCP) server for Homebrew. + # See https://modelcontextprotocol.io/introduction for more information. + # + # https://modelcontextprotocol.io/docs/tools/inspector is useful for testing. + class McpServer + HOMEBREW_BREW_FILE = T.let(ENV.fetch("HOMEBREW_BREW_FILE").freeze, String) + HOMEBREW_VERSION = T.let(ENV.fetch("HOMEBREW_VERSION").freeze, String) + JSON_RPC_VERSION = T.let("2.0", String) + MCP_PROTOCOL_VERSION = T.let("2025-03-26", String) + ERROR_CODE = T.let(-32601, Integer) + + SERVER_INFO = T.let({ + name: "brew-mcp-server", + version: HOMEBREW_VERSION, + }.freeze, T::Hash[Symbol, String]) + + FORMULA_OR_CASK_PROPERTIES = T.let({ + formula_or_cask: { + type: "string", + description: "Formula or cask name", + }, + }.freeze, T::Hash[Symbol, T.anything]) + + # NOTE: Cursor (as of June 2025) will only query/use a maximum of 40 tools. + TOOLS = T.let({ + search: { + name: "search", + description: "Perform a substring search of cask tokens and formula names for . " \ + "If is flanked by slashes, it is interpreted as a regular expression.", + command: "brew search", + inputSchema: { + type: "object", + properties: { + text_or_regex: { + type: "string", + description: "Text or regex to search for", + }, + }, + }, + required: ["text_or_regex"], + }, + info: { + name: "info", + description: "Display brief statistics for your Homebrew installation. " \ + "If a or is provided, show summary of information about it.", + command: "brew info", + inputSchema: { type: "object", properties: FORMULA_OR_CASK_PROPERTIES }, + }, + install: { + name: "install", + description: "Install a or .", + command: "brew install", + inputSchema: { type: "object", properties: FORMULA_OR_CASK_PROPERTIES }, + required: ["formula_or_cask"], + }, + update: { + name: "update", + description: "Fetch the newest version of Homebrew and all formulae from GitHub using `git` and " \ + "perform any necessary migrations.", + command: "brew update", + inputSchema: { type: "object", properties: {} }, + }, + upgrade: { + name: "upgrade", + description: "Upgrade outdated casks and outdated, unpinned formulae using the same options they were " \ + "originally installed with, plus any appended brew formula options. If or " \ + "are specified, upgrade only the given or kegs (unless they are pinned).", + command: "brew upgrade", + inputSchema: { type: "object", properties: FORMULA_OR_CASK_PROPERTIES }, + }, + uninstall: { + name: "uninstall", + description: "Uninstall a or .", + command: "brew uninstall", + inputSchema: { type: "object", properties: FORMULA_OR_CASK_PROPERTIES }, + required: ["formula_or_cask"], + }, + list: { + name: "list", + description: "List all installed formulae and casks. " \ + "If is provided, summarise the paths within its current keg. " \ + "If is provided, list its artifacts.", + command: "brew list", + inputSchema: { type: "object", properties: FORMULA_OR_CASK_PROPERTIES }, + }, + config: { + name: "config", + description: "Show Homebrew and system configuration info useful for debugging. " \ + "If you file a bug report, you will be required to provide this information.", + command: "brew config", + inputSchema: { type: "object", properties: {} }, + }, + doctor: { + name: "doctor", + description: "Check your system for potential problems. Will exit with a non-zero status " \ + "if any potential problems are found. " \ + "Please note that these warnings are just used to help the Homebrew maintainers " \ + "with debugging if you file an issue. If everything you use Homebrew for " \ + "is working fine: please don't worry or file an issue; just ignore this.", + command: "brew doctor", + inputSchema: { type: "object", properties: {} }, + }, + commands: { + name: "commands", + description: "Show lists of built-in and external commands.", + command: "brew commands", + inputSchema: { type: "object", properties: {} }, + }, + help: { + name: "help", + description: "Outputs the usage instructions for `brew` .", + command: "brew help", + inputSchema: { + type: "object", + properties: { + command: { + type: "string", + description: "Command to get help for", + }, + }, + }, + }, + }.freeze, T::Hash[Symbol, T::Hash[Symbol, T.anything]]) + + sig { params(stdin: T.any(IO, StringIO), stdout: T.any(IO, StringIO), stderr: T.any(IO, StringIO)).void } + def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr) + @debug_logging = T.let(ARGV.include?("--debug") || ARGV.include?("-d"), T::Boolean) + @ping_switch = T.let(ARGV.include?("--ping"), T::Boolean) + @stdin = T.let(stdin, T.any(IO, StringIO)) + @stdout = T.let(stdout, T.any(IO, StringIO)) + @stderr = T.let(stderr, T.any(IO, StringIO)) + end + + sig { returns(T::Boolean) } + def debug_logging? = @debug_logging + + sig { returns(T::Boolean) } + def ping_switch? = @ping_switch + + sig { void } + def run + @stderr.puts "==> Started Homebrew MCP server..." + + loop do + input = if ping_switch? + { jsonrpc: JSON_RPC_VERSION, id: 1, method: "ping" }.to_json + else + @stdin.gets + end + next if input.nil? || input.strip.empty? + + request = JSON.parse(input) + debug("Request: #{JSON.pretty_generate(request)}") + + response = handle_request(request) + if response.nil? + debug("Response: nil") + next + end + + debug("Response: #{JSON.pretty_generate(response)}") + output = JSON.dump(response).strip + @stdout.puts(output) + @stdout.flush + + break if ping_switch? + end + rescue Interrupt + exit 0 + rescue => e + log("Error: #{e.message}") + exit 1 + end + + sig { params(text: String).void } + def debug(text) + return unless debug_logging? + + log(text) + end + + sig { params(text: String).void } + def log(text) + @stderr.puts(text) + @stderr.flush + end + + sig { params(request: T::Hash[String, T.untyped]).returns(T.nilable(T::Hash[Symbol, T.anything])) } + def handle_request(request) + id = request["id"] + return if id.nil? + + case request["method"] + when "initialize" + respond_result(id, { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { + tools: { listChanged: false }, + prompts: {}, + resources: {}, + logging: {}, + roots: {}, + }, + serverInfo: SERVER_INFO, + }) + when "resources/list" + respond_result(id, { resources: [] }) + when "resources/templates/list" + respond_result(id, { resourceTemplates: [] }) + when "prompts/list" + respond_result(id, { prompts: [] }) + when "ping" + respond_result(id) + when "get_server_info" + respond_result(id, SERVER_INFO) + when "logging/setLevel" + @debug_logging = request["params"]["level"] == "debug" + respond_result(id) + when "notifications/initialized", "notifications/cancelled" + respond_result + when "tools/list" + respond_result(id, { tools: TOOLS.values }) + when "tools/call" + if (tool = TOOLS.fetch(request["params"]["name"].to_sym, nil)) + require "shellwords" + + arguments = request["params"]["arguments"] + argument = arguments.fetch("formula_or_cask", "") + argument = arguments.fetch("text_or_regex", "") if argument.strip.empty? + argument = arguments.fetch("command", "") if argument.strip.empty? + argument = nil if argument.strip.empty? + brew_command = T.cast(tool.fetch(:command), String) + .delete_prefix("brew ") + full_command = [HOMEBREW_BREW_FILE, brew_command, argument].compact + .map { |arg| Shellwords.escape(arg) } + .join(" ") + output = `#{full_command} 2>&1`.strip + respond_result(id, { content: [{ type: "text", text: output }] }) + else + respond_error(id, "Unknown tool") + end + else + respond_error(id, "Method not found") + end + end + + sig { + params(id: T.nilable(Integer), + result: T::Hash[Symbol, T.anything]).returns(T.nilable(T::Hash[Symbol, T.anything])) + } + def respond_result(id = nil, result = {}) + return if id.nil? + + { jsonrpc: JSON_RPC_VERSION, id:, result: } + end + + sig { params(id: T.nilable(Integer), message: String).returns(T::Hash[Symbol, T.anything]) } + def respond_error(id, message) + { jsonrpc: JSON_RPC_VERSION, id:, error: { code: ERROR_CODE, message: } } + end + end +end diff --git a/Library/Homebrew/test/cmd/mcp-server_spec.rb b/Library/Homebrew/test/cmd/mcp-server_spec.rb new file mode 100644 index 0000000000..9a4ef1116f --- /dev/null +++ b/Library/Homebrew/test/cmd/mcp-server_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +RSpec.describe "brew mcp-server", type: :system do + it "starts the MCP server", :integration_test do + # This is the easiest way to handle a newline here. + # rubocop:disable Style/StringConcatenation + expect { brew_sh "mcp-server", "--ping" } + .to output("==> Started Homebrew MCP server...\n").to_stderr + .and output('{"jsonrpc":"2.0","id":1,"result":{}}' + "\n").to_stdout + .and be_a_success + # rubocop:enable Style/StringConcatenation + end +end diff --git a/Library/Homebrew/test/mcp_server_spec.rb b/Library/Homebrew/test/mcp_server_spec.rb new file mode 100644 index 0000000000..98f31469cf --- /dev/null +++ b/Library/Homebrew/test/mcp_server_spec.rb @@ -0,0 +1,249 @@ +# 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 diff --git a/completions/bash/brew b/completions/bash/brew index 7e00bdfa81..b85bd4746c 100644 --- a/completions/bash/brew +++ b/completions/bash/brew @@ -1797,6 +1797,22 @@ _brew_ls() { __brew_complete_installed_casks } +_brew_mcp_server() { + local cur="${COMP_WORDS[COMP_CWORD]}" + case "${cur}" in + -*) + __brewcomp " + --debug + --help + --quiet + --verbose + " + return + ;; + *) ;; + esac +} + _brew_migrate() { local cur="${COMP_WORDS[COMP_CWORD]}" case "${cur}" in @@ -3191,6 +3207,7 @@ _brew() { ln) _brew_ln ;; log) _brew_log ;; ls) _brew_ls ;; + mcp-server) _brew_mcp_server ;; migrate) _brew_migrate ;; missing) _brew_missing ;; nodenv-sync) _brew_nodenv_sync ;; diff --git a/completions/fish/brew.fish b/completions/fish/brew.fish index 0457b6dd6a..be001f007b 100644 --- a/completions/fish/brew.fish +++ b/completions/fish/brew.fish @@ -1226,6 +1226,13 @@ __fish_brew_complete_arg 'ls; and not __fish_seen_argument -l cask -l casks' -a __fish_brew_complete_arg 'ls; and not __fish_seen_argument -l formula -l formulae' -a '(__fish_brew_suggest_casks_installed)' +__fish_brew_complete_cmd 'mcp-server' 'Starts the Homebrew MCP (Model Context Protocol) server' +__fish_brew_complete_arg 'mcp-server' -l debug -d 'Enable debug logging to stderr' +__fish_brew_complete_arg 'mcp-server' -l help -d 'Show this message' +__fish_brew_complete_arg 'mcp-server' -l quiet -d 'Make some output more quiet' +__fish_brew_complete_arg 'mcp-server' -l verbose -d 'Make some output more verbose' + + __fish_brew_complete_cmd 'migrate' 'Migrate renamed packages to new names, where formula are old names of packages' __fish_brew_complete_arg 'migrate' -l cask -d 'Only migrate casks' __fish_brew_complete_arg 'migrate' -l debug -d 'Display any debugging information' diff --git a/completions/internal_commands_list.txt b/completions/internal_commands_list.txt index b0e7542097..dbdc755da3 100644 --- a/completions/internal_commands_list.txt +++ b/completions/internal_commands_list.txt @@ -68,6 +68,7 @@ livecheck ln log ls +mcp-server migrate missing nodenv-sync diff --git a/completions/zsh/_brew b/completions/zsh/_brew index 8d1d4e3da9..977501c4dd 100644 --- a/completions/zsh/_brew +++ b/completions/zsh/_brew @@ -191,6 +191,7 @@ __brew_internal_commands() { 'list:List all installed formulae and casks' 'livecheck:Check for newer versions of formulae and/or casks from upstream' 'log:Show the `git log` for formula or cask, or show the log for the Homebrew repository if no formula or cask is provided' + 'mcp-server:Starts the Homebrew MCP (Model Context Protocol) server' 'migrate:Migrate renamed packages to new names, where formula are old names of packages' 'missing:Check the given formula kegs for missing dependencies' 'nodenv-sync:Create symlinks for Homebrew'\''s installed NodeJS versions in `~/.nodenv/versions`' @@ -1511,6 +1512,15 @@ _brew_ls() { '*:installed_cask:__brew_installed_casks' } +# brew mcp-server +_brew_mcp_server() { + _arguments \ + '--debug[Enable debug logging to stderr]' \ + '--help[Show this message]' \ + '--quiet[Make some output more quiet]' \ + '--verbose[Make some output more verbose]' +} + # brew migrate _brew_migrate() { _arguments \ diff --git a/docs/Manpage.md b/docs/Manpage.md index 861f6d0e92..da7ddfc8d6 100644 --- a/docs/Manpage.md +++ b/docs/Manpage.md @@ -1046,6 +1046,14 @@ repository if no formula or cask is provided. : Treat all named arguments as casks. +### `mcp-server` \[`--debug`\] + +Starts the Homebrew MCP (Model Context Protocol) server. + +`-d`, `--debug` + +: Enable debug logging to stderr. + ### `migrate` \[*`options`*\] *`installed_formula`*\|*`installed_cask`* \[...\] Migrate renamed packages to new names, where *`formula`* are old names of diff --git a/manpages/brew.1 b/manpages/brew.1 index 1d84a042df..a67133433c 100644 --- a/manpages/brew.1 +++ b/manpages/brew.1 @@ -1,5 +1,5 @@ .\" generated by kramdown -.TH "BREW" "1" "May 2025" "Homebrew" +.TH "BREW" "1" "June 2025" "Homebrew" .SH NAME brew \- The Missing Package Manager for macOS (or Linux) .SH "SYNOPSIS" @@ -656,6 +656,11 @@ Treat all named arguments as formulae\. .TP \fB\-\-cask\fP Treat all named arguments as casks\. +.SS "\fBmcp\-server\fP \fR[\fB\-\-debug\fP]" +Starts the Homebrew MCP (Model Context Protocol) server\. +.TP +\fB\-d\fP, \fB\-\-debug\fP +Enable debug logging to stderr\. .SS "\fBmigrate\fP \fR[\fIoptions\fP] \fIinstalled_formula\fP|\fIinstalled_cask\fP \fR[\.\.\.]" Migrate renamed packages to new names, where \fIformula\fP are old names of packages\. .TP