2025-06-03 15:22:33 +01:00
|
|
|
# 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 <text>. " \
|
|
|
|
"If <text> 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 <formula> or <cask> 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 <formula> or <cask>.",
|
|
|
|
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 <cask> or <formula> " \
|
|
|
|
"are specified, upgrade only the given <cask> or <formula> kegs (unless they are pinned).",
|
|
|
|
command: "brew upgrade",
|
|
|
|
inputSchema: { type: "object", properties: FORMULA_OR_CASK_PROPERTIES },
|
|
|
|
},
|
|
|
|
uninstall: {
|
|
|
|
name: "uninstall",
|
|
|
|
description: "Uninstall a <formula> or <cask>.",
|
|
|
|
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 <formula> is provided, summarise the paths within its current keg. " \
|
|
|
|
"If <cask> 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: {} },
|
|
|
|
},
|
2025-08-22 15:51:14 +01:00
|
|
|
typecheck: {
|
|
|
|
name: "typecheck",
|
|
|
|
description: "Check for typechecking errors using Sorbet.",
|
|
|
|
command: "brew typecheck",
|
|
|
|
inputSchema: { type: "object", properties: {} },
|
|
|
|
},
|
|
|
|
style: {
|
|
|
|
name: "style",
|
|
|
|
description: "Check formulae or files for conformance to Homebrew style guidelines.",
|
|
|
|
command: "brew style",
|
|
|
|
inputSchema: {
|
|
|
|
type: "object",
|
|
|
|
properties: {
|
|
|
|
fix: {
|
|
|
|
type: "boolean",
|
|
|
|
description: "Fix style violations automatically using RuboCop's auto-correct feature",
|
|
|
|
},
|
|
|
|
files: {
|
|
|
|
type: "string",
|
|
|
|
description: "Specific files to check (space-separated)",
|
|
|
|
},
|
|
|
|
changed: {
|
|
|
|
type: "boolean",
|
|
|
|
description: "Only check files that were changed from the `main` branch",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
tests: {
|
|
|
|
name: "tests",
|
|
|
|
description: "Run Homebrew's unit and integration tests.",
|
|
|
|
command: "brew tests",
|
|
|
|
inputSchema: {
|
|
|
|
type: "object",
|
|
|
|
properties: {
|
|
|
|
only: {
|
|
|
|
type: "string",
|
|
|
|
description: "Specific tests to run (comma-seperated) e.g. for `<file>_spec.rb` pass `<file>`. " \
|
|
|
|
"Appending `:<line_number>` will start at a specific line",
|
|
|
|
},
|
|
|
|
fail_fast: {
|
|
|
|
type: "boolean",
|
|
|
|
description: "Exit early on the first failing test",
|
|
|
|
},
|
|
|
|
changed: {
|
|
|
|
type: "boolean",
|
|
|
|
description: "Only runs tests on files that were changed from the `main` branch",
|
|
|
|
},
|
|
|
|
online: {
|
|
|
|
type: "boolean",
|
|
|
|
description: "Run online tests",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
2025-06-03 15:22:33 +01:00
|
|
|
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>.",
|
|
|
|
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
|
2025-06-23 16:50:39 +01:00
|
|
|
break if @stdin.eof?
|
|
|
|
|
2025-06-03 15:22:33 +01:00
|
|
|
@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"
|
2025-08-22 15:51:14 +01:00
|
|
|
respond_to_tools_call(id, request)
|
2025-06-03 15:22:33 +01:00
|
|
|
else
|
|
|
|
respond_error(id, "Method not found")
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2025-08-22 15:51:14 +01:00
|
|
|
sig { params(id: Integer, request: T::Hash[String, T.untyped]).returns(T.nilable(T::Hash[Symbol, T.anything])) }
|
|
|
|
def respond_to_tools_call(id, request)
|
|
|
|
tool_name = request["params"]["name"].to_sym
|
|
|
|
tool = TOOLS.fetch tool_name do
|
|
|
|
return respond_error(id, "Unknown tool")
|
|
|
|
end
|
|
|
|
|
|
|
|
require "open3"
|
|
|
|
|
|
|
|
command_args = tool_command_arguments(tool_name, request["params"]["arguments"])
|
|
|
|
progress_token = request["params"]["_meta"]&.fetch("progressToken", nil)
|
|
|
|
brew_command = T.cast(tool.fetch(:command), String)
|
|
|
|
.delete_prefix("brew ")
|
|
|
|
buffer_size = 4096 # 4KB
|
|
|
|
progress = T.let(0, Integer)
|
|
|
|
done = T.let(false, T::Boolean)
|
|
|
|
new_output = T.let(false, T::Boolean)
|
|
|
|
output = +""
|
|
|
|
|
|
|
|
text = Open3.popen2e(HOMEBREW_BREW_FILE, brew_command, *command_args) do |stdin, io, _wait|
|
|
|
|
stdin.close
|
|
|
|
|
|
|
|
reader = Thread.new do
|
|
|
|
loop do
|
|
|
|
output << io.readpartial(buffer_size)
|
|
|
|
progress += 1
|
|
|
|
new_output = true
|
|
|
|
end
|
|
|
|
rescue EOFError
|
|
|
|
nil
|
|
|
|
ensure
|
|
|
|
done = true
|
|
|
|
end
|
|
|
|
|
|
|
|
until done
|
|
|
|
break unless progress_token
|
|
|
|
|
|
|
|
sleep 1
|
|
|
|
next unless new_output
|
|
|
|
|
|
|
|
response = {
|
|
|
|
jsonrpc: JSON_RPC_VERSION,
|
|
|
|
method: "notifications/progress",
|
|
|
|
params: { progressToken: progress_token, progress: },
|
|
|
|
}
|
|
|
|
progress_output = JSON.dump(response).strip
|
|
|
|
@stdout.puts(progress_output)
|
|
|
|
@stdout.flush
|
|
|
|
|
|
|
|
new_output = false
|
|
|
|
end
|
|
|
|
|
|
|
|
reader.join
|
|
|
|
|
|
|
|
output
|
|
|
|
end
|
|
|
|
|
|
|
|
respond_result(id, { content: [{ type: "text", text: }] })
|
|
|
|
end
|
|
|
|
|
|
|
|
sig { params(tool_name: Symbol, arguments: T::Hash[String, T.untyped]).returns(T::Array[String]) }
|
|
|
|
def tool_command_arguments(tool_name, arguments)
|
|
|
|
require "shellwords"
|
|
|
|
|
|
|
|
case tool_name
|
|
|
|
when :style
|
|
|
|
style_args = []
|
|
|
|
style_args << "--fix" if arguments["fix"]
|
2025-08-23 20:40:20 +01:00
|
|
|
style_args << "--changed" if arguments["changed"]
|
2025-08-22 15:51:14 +01:00
|
|
|
file_arguments = arguments.fetch("files", "").strip.split
|
|
|
|
style_args.concat(file_arguments) unless file_arguments.empty?
|
|
|
|
style_args
|
|
|
|
when :tests
|
|
|
|
tests_args = []
|
|
|
|
only_arguments = arguments.fetch("only", "").strip
|
|
|
|
tests_args << "--only=#{only_arguments}" unless only_arguments.empty?
|
|
|
|
tests_args << "--fail-fast" if arguments["fail_fast"]
|
|
|
|
tests_args << "--changed" if arguments["changed"]
|
|
|
|
tests_args << "--online" if arguments["online"]
|
|
|
|
tests_args
|
|
|
|
when :search
|
|
|
|
[arguments["text_or_regex"]]
|
|
|
|
when :help
|
|
|
|
[arguments["command"]]
|
|
|
|
else
|
|
|
|
[arguments["formula_or_cask"]]
|
|
|
|
end.compact
|
|
|
|
.reject(&:empty?)
|
|
|
|
.map { |arg| Shellwords.escape(arg) }
|
|
|
|
end
|
|
|
|
|
2025-06-03 15:22:33 +01:00
|
|
|
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
|