Skip to content

Commit f9471f9

Browse files
committed
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.
1 parent 89739ac commit f9471f9

File tree

12 files changed

+625
-1
lines changed

12 files changed

+625
-1
lines changed

Library/Homebrew/brew.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,11 @@ case "$1" in
600600
homebrew-version
601601
exit 0
602602
;;
603+
mcp-server)
604+
source "${HOMEBREW_LIBRARY}/Homebrew/cmd/mcp-server.sh"
605+
homebrew-mcp-server "$@"
606+
exit 0
607+
;;
603608
esac
604609

605610
# TODO: bump version when new macOS is released or announced and update references in:

Library/Homebrew/cmd/mcp-server.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# typed: strong
2+
# frozen_string_literal: true
3+
4+
require "abstract_command"
5+
require "shell_command"
6+
7+
module Homebrew
8+
module Cmd
9+
class McpServerCmd < AbstractCommand
10+
# This is a shell command as MCP servers need a faster startup time
11+
# than a normal Homebrew Ruby command allows.
12+
include ShellCommand
13+
14+
cmd_args do
15+
description <<~EOS
16+
Starts the Homebrew MCP (Model Context Protocol) server.
17+
EOS
18+
switch "-d", "--debug", description: "Enable debug logging to stderr."
19+
switch "--ping", description: "Start the server, act as if receiving a ping and then exit.", hidden: true
20+
end
21+
end
22+
end
23+
end

Library/Homebrew/cmd/mcp-server.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Documentation defined in Library/Homebrew/cmd/mcp-server.rb
2+
3+
# This is a shell command as MCP servers need a faster startup time
4+
# than a normal Homebrew Ruby command allows.
5+
6+
# HOMEBREW_LIBRARY is set by brew.sh
7+
# HOMEBREW_BREW_FILE is set by extend/ENV/super.rb
8+
# shellcheck disable=SC2154
9+
homebrew-mcp-server() {
10+
source "${HOMEBREW_LIBRARY}/Homebrew/utils/ruby.sh"
11+
setup-ruby-path
12+
export HOMEBREW_VERSION
13+
"${HOMEBREW_RUBY_PATH}" "-r${HOMEBREW_LIBRARY}/Homebrew/mcp_server.rb" -e "Homebrew::McpServer.new.run" "$@"
14+
}

Library/Homebrew/mcp_server.rb

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
# This is a standalone Ruby script as MCP servers need a faster startup time
5+
# than a normal Homebrew Ruby command allows.
6+
require_relative "standalone"
7+
require "json"
8+
require "stringio"
9+
10+
module Homebrew
11+
# Provides a Model Context Protocol (MCP) server for Homebrew.
12+
# See https://modelcontextprotocol.io/introduction for more information.
13+
#
14+
# https://modelcontextprotocol.io/docs/tools/inspector is useful for testing.
15+
class McpServer
16+
HOMEBREW_BREW_FILE = T.let(ENV.fetch("HOMEBREW_BREW_FILE").freeze, String)
17+
HOMEBREW_VERSION = T.let(ENV.fetch("HOMEBREW_VERSION").freeze, String)
18+
JSON_RPC_VERSION = T.let("2.0", String)
19+
MCP_PROTOCOL_VERSION = T.let("2025-03-26", String)
20+
ERROR_CODE = T.let(-32601, Integer)
21+
22+
SERVER_INFO = T.let({
23+
name: "brew-mcp-server",
24+
version: HOMEBREW_VERSION,
25+
}.freeze, T::Hash[Symbol, String])
26+
27+
FORMULA_OR_CASK_PROPERTIES = T.let({
28+
formula_or_cask: {
29+
type: "string",
30+
description: "Formula or cask name",
31+
},
32+
}.freeze, T::Hash[Symbol, T.anything])
33+
34+
# NOTE: Cursor (as of June 2025) will only query/use a maximum of 40 tools.
35+
TOOLS = T.let({
36+
search: {
37+
name: "search",
38+
description: "Perform a substring search of cask tokens and formula names for <text>. " \
39+
"If <text> is flanked by slashes, it is interpreted as a regular expression.",
40+
command: "brew search",
41+
inputSchema: {
42+
type: "object",
43+
properties: {
44+
text_or_regex: {
45+
type: "string",
46+
description: "Text or regex to search for",
47+
},
48+
},
49+
},
50+
required: ["text_or_regex"],
51+
},
52+
info: {
53+
name: "info",
54+
description: "Display brief statistics for your Homebrew installation. " \
55+
"If a <formula> or <cask> is provided, show summary of information about it.",
56+
command: "brew info",
57+
inputSchema: { type: "object", properties: FORMULA_OR_CASK_PROPERTIES },
58+
},
59+
install: {
60+
name: "install",
61+
description: "Install a <formula> or <cask>.",
62+
command: "brew install",
63+
inputSchema: { type: "object", properties: FORMULA_OR_CASK_PROPERTIES },
64+
required: ["formula_or_cask"],
65+
},
66+
update: {
67+
name: "update",
68+
description: "Fetch the newest version of Homebrew and all formulae from GitHub using `git` and " \
69+
"perform any necessary migrations.",
70+
command: "brew update",
71+
inputSchema: { type: "object", properties: {} },
72+
},
73+
upgrade: {
74+
name: "upgrade",
75+
description: "Upgrade outdated casks and outdated, unpinned formulae using the same options they were " \
76+
"originally installed with, plus any appended brew formula options. If <cask> or <formula> " \
77+
"are specified, upgrade only the given <cask> or <formula> kegs (unless they are pinned).",
78+
command: "brew upgrade",
79+
inputSchema: { type: "object", properties: FORMULA_OR_CASK_PROPERTIES },
80+
},
81+
uninstall: {
82+
name: "uninstall",
83+
description: "Uninstall a <formula> or <cask>.",
84+
command: "brew uninstall",
85+
inputSchema: { type: "object", properties: FORMULA_OR_CASK_PROPERTIES },
86+
required: ["formula_or_cask"],
87+
},
88+
list: {
89+
name: "list",
90+
description: "List all installed formulae and casks. " \
91+
"If <formula> is provided, summarise the paths within its current keg. " \
92+
"If <cask> is provided, list its artifacts.",
93+
command: "brew list",
94+
inputSchema: { type: "object", properties: FORMULA_OR_CASK_PROPERTIES },
95+
},
96+
config: {
97+
name: "config",
98+
description: "Show Homebrew and system configuration info useful for debugging. " \
99+
"If you file a bug report, you will be required to provide this information.",
100+
command: "brew config",
101+
inputSchema: { type: "object", properties: {} },
102+
},
103+
doctor: {
104+
name: "doctor",
105+
description: "Check your system for potential problems. Will exit with a non-zero status " \
106+
"if any potential problems are found. " \
107+
"Please note that these warnings are just used to help the Homebrew maintainers " \
108+
"with debugging if you file an issue. If everything you use Homebrew for " \
109+
"is working fine: please don't worry or file an issue; just ignore this.",
110+
command: "brew doctor",
111+
inputSchema: { type: "object", properties: {} },
112+
},
113+
commands: {
114+
name: "commands",
115+
description: "Show lists of built-in and external commands.",
116+
command: "brew commands",
117+
inputSchema: { type: "object", properties: {} },
118+
},
119+
help: {
120+
name: "help",
121+
description: "Outputs the usage instructions for `brew` <command>.",
122+
command: "brew help",
123+
inputSchema: {
124+
type: "object",
125+
properties: {
126+
command: {
127+
type: "string",
128+
description: "Command to get help for",
129+
},
130+
},
131+
},
132+
},
133+
}.freeze, T::Hash[Symbol, T::Hash[Symbol, T.anything]])
134+
135+
sig { params(stdin: T.any(IO, StringIO), stdout: T.any(IO, StringIO), stderr: T.any(IO, StringIO)).void }
136+
def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr)
137+
@debug_logging = T.let(ARGV.include?("--debug") || ARGV.include?("-d"), T::Boolean)
138+
@ping_switch = T.let(ARGV.include?("--ping"), T::Boolean)
139+
@stdin = T.let(stdin, T.any(IO, StringIO))
140+
@stdout = T.let(stdout, T.any(IO, StringIO))
141+
@stderr = T.let(stderr, T.any(IO, StringIO))
142+
end
143+
144+
sig { returns(T::Boolean) }
145+
def debug_logging? = @debug_logging
146+
147+
sig { returns(T::Boolean) }
148+
def ping_switch? = @ping_switch
149+
150+
sig { void }
151+
def run
152+
@stderr.puts "==> Started Homebrew MCP server..."
153+
154+
loop do
155+
input = if ping_switch?
156+
{ jsonrpc: JSON_RPC_VERSION, id: 1, method: "ping" }.to_json
157+
else
158+
@stdin.gets
159+
end
160+
next if input.nil? || input.strip.empty?
161+
162+
request = JSON.parse(input)
163+
debug("Request: #{JSON.pretty_generate(request)}")
164+
165+
response = handle_request(request)
166+
if response.nil?
167+
debug("Response: nil")
168+
next
169+
end
170+
171+
debug("Response: #{JSON.pretty_generate(response)}")
172+
output = JSON.dump(response).strip
173+
@stdout.puts(output)
174+
@stdout.flush
175+
176+
break if ping_switch?
177+
end
178+
rescue Interrupt
179+
exit 0
180+
rescue => e
181+
log("Error: #{e.message}")
182+
exit 1
183+
end
184+
185+
sig { params(text: String).void }
186+
def debug(text)
187+
return unless debug_logging?
188+
189+
log(text)
190+
end
191+
192+
sig { params(text: String).void }
193+
def log(text)
194+
@stderr.puts(text)
195+
@stderr.flush
196+
end
197+
198+
sig { params(request: T::Hash[String, T.untyped]).returns(T.nilable(T::Hash[Symbol, T.anything])) }
199+
def handle_request(request)
200+
id = request["id"]
201+
return if id.nil?
202+
203+
case request["method"]
204+
when "initialize"
205+
respond_result(id, {
206+
protocolVersion: MCP_PROTOCOL_VERSION,
207+
capabilities: {
208+
tools: { listChanged: false },
209+
prompts: {},
210+
resources: {},
211+
logging: {},
212+
roots: {},
213+
},
214+
serverInfo: SERVER_INFO,
215+
})
216+
when "resources/list"
217+
respond_result(id, { resources: [] })
218+
when "resources/templates/list"
219+
respond_result(id, { resourceTemplates: [] })
220+
when "prompts/list"
221+
respond_result(id, { prompts: [] })
222+
when "ping"
223+
respond_result(id)
224+
when "get_server_info"
225+
respond_result(id, SERVER_INFO)
226+
when "logging/setLevel"
227+
@debug_logging = request["params"]["level"] == "debug"
228+
respond_result(id)
229+
when "notifications/initialized", "notifications/cancelled"
230+
respond_result
231+
when "tools/list"
232+
respond_result(id, { tools: TOOLS.values })
233+
when "tools/call"
234+
if (tool = TOOLS.fetch(request["params"]["name"].to_sym, nil))
235+
require "shellwords"
236+
237+
arguments = request["params"]["arguments"]
238+
argument = arguments.fetch("formula_or_cask", "")
239+
argument = arguments.fetch("text_or_regex", "") if argument.strip.empty?
240+
argument = arguments.fetch("command", "") if argument.strip.empty?
241+
argument = nil if argument.strip.empty?
242+
brew_command = T.cast(tool.fetch(:command), String)
243+
.delete_prefix("brew ")
244+
full_command = [HOMEBREW_BREW_FILE, brew_command, argument].compact
245+
.map { |arg| Shellwords.escape(arg) }
246+
.join(" ")
247+
output = `#{full_command} 2>&1`.strip
248+
respond_result(id, { content: [{ type: "text", text: output }] })
249+
else
250+
respond_error(id, "Unknown tool")
251+
end
252+
else
253+
respond_error(id, "Method not found")
254+
end
255+
end
256+
257+
sig {
258+
params(id: T.nilable(Integer),
259+
result: T::Hash[Symbol, T.anything]).returns(T.nilable(T::Hash[Symbol, T.anything]))
260+
}
261+
def respond_result(id = nil, result = {})
262+
return if id.nil?
263+
264+
{ jsonrpc: JSON_RPC_VERSION, id:, result: }
265+
end
266+
267+
sig { params(id: T.nilable(Integer), message: String).returns(T::Hash[Symbol, T.anything]) }
268+
def respond_error(id, message)
269+
{ jsonrpc: JSON_RPC_VERSION, id:, error: { code: ERROR_CODE, message: } }
270+
end
271+
end
272+
end
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe "brew mcp-server", type: :system do
4+
it "starts the MCP server", :integration_test do
5+
# This is the easiest way to handle a newline here.
6+
# rubocop:disable Style/StringConcatenation
7+
expect { brew_sh "mcp-server", "--ping" }
8+
.to output("==> Started Homebrew MCP server...\n").to_stderr
9+
.and output('{"jsonrpc":"2.0","id":1,"result":{}}' + "\n").to_stdout
10+
.and be_a_success
11+
# rubocop:enable Style/StringConcatenation
12+
end
13+
end

0 commit comments

Comments
 (0)