├── .ruby-version ├── .rspec ├── bin ├── setup ├── console ├── rspec └── bundler_mcp ├── docs ├── get_gem_details.png └── list_project_gems.png ├── lib ├── bundler_mcp │ ├── version.rb │ ├── tool_collection.rb │ ├── environment_checker.rb │ ├── resource_collection.rb │ ├── gem_resource.rb │ ├── server.rb │ └── tools │ │ ├── list_project_gems.rb │ │ └── get_gem_details.rb └── bundler_mcp.rb ├── .gitignore ├── Gemfile ├── Rakefile ├── exe └── bundler_mcp ├── CHANGELOG.md ├── .rubocop.yml ├── spec ├── lib │ ├── resource_collection_spec.rb │ ├── environment_checker_spec.rb │ ├── tools │ │ ├── list_project_gems_spec.rb │ │ └── get_gem_details_spec.rb │ ├── gem_resource_spec.rb │ └── server_spec.rb ├── spec_helper.rb ├── integration │ ├── listing_project_gems_spec.rb │ └── getting_gem_details_spec.rb └── support │ └── integration_spec_helper.rb ├── LICENSE.txt ├── .github └── workflows │ └── main.yml ├── bundler_mcp.gemspec ├── Gemfile.lock └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.2 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | -------------------------------------------------------------------------------- /docs/get_gem_details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subelsky/bundler_mcp/HEAD/docs/get_gem_details.png -------------------------------------------------------------------------------- /docs/list_project_gems.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subelsky/bundler_mcp/HEAD/docs/list_project_gems.png -------------------------------------------------------------------------------- /lib/bundler_mcp/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module BundlerMCP 4 | VERSION = "0.2.1.1" 5 | end 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /log/ 10 | .rspec_status 11 | .cursor/ -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "bundler_mcp" 6 | require "irb" 7 | 8 | IRB.start(__FILE__) 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | gem "debug" 8 | gem "irb" 9 | gem "rake", "~> 13.0" 10 | gem "rspec", "~> 3.0" 11 | gem "rubocop", "~> 1.21" 12 | gem "rubocop-rspec", "~> 3.6" 13 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | require "rubocop/rake_task" 9 | 10 | RuboCop::RakeTask.new 11 | 12 | task default: %i[spec rubocop] 13 | -------------------------------------------------------------------------------- /exe/bundler_mcp: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "logger" 5 | require_relative "../lib/bundler_mcp/server" 6 | 7 | # Set this up by running `bundle binstubs bundler_mcp`; 8 | # your client will then able to run `bin/bundler_mcp` 9 | # in the correct Bundler environment 10 | 11 | logfile_path = ENV.fetch("BUNDLER_MCP_LOG_FILE", nil) 12 | logger = Logger.new(logfile_path || File::NULL) 13 | 14 | BundlerMCP::Server.run(logger:) 15 | -------------------------------------------------------------------------------- /lib/bundler_mcp.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "bundler_mcp/version" 4 | 5 | # Namespace for BundlerMCP code 6 | module BundlerMCP 7 | # Base error class for BundlerMCP 8 | Error = Class.new(StandardError) 9 | 10 | # Raised when Bundler cannot find a Gemfile 11 | class GemfileNotFound < Error 12 | def initialize(msg = DEFAULT_MESSAGE) 13 | super 14 | end 15 | 16 | DEFAULT_MESSAGE = "Bundler cannot find a Gemfile; try setting BUNDLE_GEMFILE" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 4 | 5 | ## [0.2.1] - 2025-06-03 6 | 7 | - Add GitHub Action for trusted publishing to RubyGems 8 | 9 | ## [0.2.0] - 2025-05-18 10 | 11 | - Fix `BUNDLER_MCP_LOG_FILE` environment variable name bug 12 | - Use case-insensitive matching to look up gem details 13 | - Explicitly require `pathname` in files that use it 14 | 15 | ## [0.1.0] - 2025-05-18 16 | 17 | - Initial release 18 | - Add `list_project_gems` tool 19 | - Add `get_gem_details` tool 20 | -------------------------------------------------------------------------------- /lib/bundler_mcp/tool_collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pathname" 4 | 5 | Pathname(__dir__).glob("tools/*.rb").each do |tool| 6 | require tool 7 | end 8 | 9 | module BundlerMCP 10 | # Contains all tools that can be used by the caller 11 | class ToolCollection 12 | # @yield [Tool] each MCP tool 13 | # @yieldparam tool [Tool] a tool in the collection 14 | def self.each(&) 15 | TOOLS.each(&) 16 | end 17 | 18 | TOOLS = BundlerMCP::Tools.constants.map { |c| BundlerMCP::Tools.const_get(c) } 19 | private_constant :TOOLS 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/bundler_mcp/environment_checker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler" 4 | require "bundler_mcp" 5 | 6 | module BundlerMCP 7 | # Responsible for checking the environment to make sure Bundler can find gems 8 | class EnvironmentChecker 9 | # Check for a Gemfile and raise an error if not found 10 | # @return [Pathname] The path to the Gemfile 11 | # @raise [GemfileNotFound] 12 | # If Bundler cannot find a Gemfile; can be avoided by setting BUNDLE_GEMFILE 13 | def self.check! 14 | raise GemfileNotFound unless Bundler.default_gemfile.exist? 15 | 16 | Bundler.default_gemfile 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - rubocop-rspec 3 | 4 | AllCops: 5 | TargetRubyVersion: 3.2 6 | NewCops: enable 7 | 8 | Metrics/BlockLength: 9 | Exclude: 10 | - 'spec/**/*' 11 | - 'bundler_mcp.gemspec' 12 | 13 | RSpec/DescribeClass: 14 | Exclude: 15 | - 'spec/integration/**/*' 16 | 17 | RSpec/ExampleLength: 18 | Max: 20 19 | 20 | RSpec/MultipleExpectations: 21 | Exclude: 22 | - 'spec/integration/**/*' 23 | 24 | RSpec/SpecFilePathFormat: 25 | Enabled: false 26 | 27 | Style/FrozenStringLiteralComment: 28 | Exclude: 29 | - 'exe/*' 30 | 31 | Style/StringLiterals: 32 | EnforcedStyle: double_quotes 33 | 34 | Style/StringLiteralsInInterpolation: 35 | EnforcedStyle: double_quotes 36 | -------------------------------------------------------------------------------- /spec/lib/resource_collection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "bundler_mcp/gem_resource" 5 | require "bundler_mcp/resource_collection" 6 | 7 | RSpec.describe BundlerMCP::ResourceCollection do 8 | let(:gem) do 9 | Bundler.load.specs.find { |s| s.name == "bundler_mcp" } 10 | end 11 | 12 | describe "#each" do 13 | it "yields each gem" do 14 | expect do |b| 15 | described_class.instance.each(&b) 16 | end 17 | .to yield_control 18 | .exactly(55) 19 | .times 20 | end 21 | 22 | it "yields GemResource instances" do 23 | expect(described_class.instance).to all be_a(BundlerMCP::GemResource) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler_mcp" 4 | require "debug" 5 | 6 | Dir[File.expand_path("support/**/*.rb", __dir__)].each do |file| 7 | require file 8 | end 9 | 10 | RSpec.configure do |config| 11 | # Enable flags like --only-failures and --next-failure 12 | config.example_status_persistence_file_path = ".rspec_status" 13 | 14 | # Disable RSpec exposing methods globally on `Module` and `main` 15 | config.disable_monkey_patching! 16 | 17 | config.expect_with :rspec do |c| 18 | c.syntax = :expect 19 | end 20 | 21 | config.define_derived_metadata(file_path: %r{spec/integration/}) do |metadata| 22 | metadata[:integration] = true 23 | end 24 | 25 | config.include IntegrationSpecHelper, integration: true 26 | end 27 | -------------------------------------------------------------------------------- /spec/integration/listing_project_gems_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "json" 5 | require "timeout" 6 | 7 | RSpec.describe "Listing gems" do 8 | around do |example| 9 | setup_server 10 | example.run 11 | teardown_server 12 | end 13 | 14 | it "returns a list of gems via MCP protocol" do 15 | gem_list = request("list_project_gems") 16 | 17 | # this is a little brittle, but this number should be stable between CI and local development environments; 18 | expect(gem_list.size).to eq(55) 19 | expect(gem_list).to all include(:name, :version, :description, :full_gem_path) 20 | 21 | bundler_mcp = gem_list.find do |gem| 22 | gem.fetch(:name) == "bundler_mcp" 23 | end 24 | 25 | expect(bundler_mcp).to include(version: BundlerMCP::VERSION) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rspec-core", "rspec") 28 | -------------------------------------------------------------------------------- /spec/lib/environment_checker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "bundler_mcp/environment_checker" 5 | 6 | RSpec.describe BundlerMCP::EnvironmentChecker do 7 | describe ".check" do 8 | context "when Gemfile exists" do 9 | it "returns the Gemfile path" do 10 | expect(described_class.check!).to eq(Bundler.default_gemfile) 11 | end 12 | end 13 | 14 | context "when Gemfile does not exist" do 15 | before do 16 | allow(Bundler) 17 | .to receive_message_chain(:default_gemfile, :exist?) # rubocop:disable RSpec/MessageChain 18 | .and_return(false) 19 | end 20 | 21 | it "raises error" do 22 | expect do 23 | described_class.check! 24 | end.to raise_error(BundlerMCP::GemfileNotFound) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /bin/bundler_mcp: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundler_mcp' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("bundler_mcp", "bundler_mcp") 28 | -------------------------------------------------------------------------------- /lib/bundler_mcp/resource_collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "gem_resource" 4 | require "singleton" 5 | 6 | module BundlerMCP 7 | # Represents a collection of GemResource objects defining all currently bundled gems 8 | # @see GemResource 9 | class ResourceCollection 10 | include Singleton 11 | include Enumerable 12 | 13 | def initialize 14 | @resources = [] 15 | 16 | Gem.loaded_specs.each_value do |spec| 17 | # Returns most gems as Bundler::StubSpecification, which does not expose 18 | # many gem details, so we convert to Gem::Specification 19 | spec = Gem::Specification.find_by_name(spec.name) 20 | resources << GemResource.new(spec) 21 | end 22 | end 23 | 24 | # Iterate over all GemResource objects in the collection 25 | # @yield [GemResource] each GemResource object in the collection 26 | def each(&) 27 | resources.each(&) 28 | end 29 | 30 | private 31 | 32 | attr_reader :resources 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/integration/getting_gem_details_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "json" 5 | require "timeout" 6 | 7 | RSpec.describe "Fetching a gem" do 8 | around do |example| 9 | setup_server 10 | example.run 11 | teardown_server 12 | end 13 | 14 | it "returns gem details via MCP protocol" do 15 | gem_details = request("get_gem_details", name: "bundler_mcp") 16 | 17 | expect(gem_details).to include( 18 | name: "bundler_mcp", 19 | version: BundlerMCP::VERSION, 20 | description: be_a(String), 21 | full_gem_path: end_with("bundler_mcp"), 22 | lib_path: end_with("bundler_mcp/lib"), 23 | top_level_documentation_paths: include(end_with("bundler_mcp/README.md")), 24 | source_files: include(end_with("bundler_mcp/lib/bundler_mcp.rb")) 25 | ) 26 | end 27 | 28 | it "returns an error for non-existent gems" do 29 | response = request("get_gem_details", name: "non_existent_gem_123") 30 | expect(response).to include(error: "We could not find 'non_existent_gem_123' among the project's bundled gems") 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/lib/tools/list_project_gems_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler_mcp/tools/list_project_gems" 4 | require "bundler_mcp/gem_resource" 5 | 6 | RSpec.describe BundlerMCP::Tools::ListProjectGems do 7 | subject(:tool) { described_class.new(gem_resources) } 8 | 9 | let(:gem_resources) do 10 | [ 11 | instance_double(BundlerMCP::GemResource, 12 | to_h: { name: "rspec", version: "3.12.0" }), 13 | instance_double(BundlerMCP::GemResource, 14 | to_h: { name: "rails", version: "7.1.0" }) 15 | ] 16 | end 17 | 18 | describe ".name" do 19 | it "returns the tool name" do 20 | expect(described_class.name).to eq("list_project_gems") 21 | end 22 | end 23 | 24 | describe "#call" do 25 | it "returns a JSON array of gem details" do 26 | result = JSON.parse(tool.call, symbolize_names: true) 27 | expect(result).to eq([ 28 | { name: "rspec", version: "3.12.0" }, 29 | { name: "rails", version: "7.1.0" } 30 | ]) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Mike Subelsky 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/lib/tools/get_gem_details_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler_mcp/tools/get_gem_details" 4 | require "bundler_mcp/gem_resource" 5 | 6 | RSpec.describe BundlerMCP::Tools::GetGemDetails do 7 | subject(:tool) { described_class.new([gem_resource]) } 8 | 9 | let(:gem_resource) do 10 | instance_double(BundlerMCP::GemResource, 11 | name: "rspec", 12 | to_h: { name: "rspec", version: "3.12.0" }) 13 | end 14 | 15 | describe ".name" do 16 | it "returns the tool name" do 17 | expect(described_class.name).to eq("get_gem_details") 18 | end 19 | end 20 | 21 | describe "#call" do 22 | it "returns gem details" do 23 | response = tool.call(name: "rspec") 24 | result = JSON.parse(response, symbolize_names: true) 25 | 26 | expect(result).to eq(name: "rspec", version: "3.12.0") 27 | end 28 | 29 | context "when gem doesn't exist" do 30 | it "returns error message" do 31 | response = tool.call(name: "nonexistent") 32 | result = JSON.parse(response, symbolize_names: true) 33 | 34 | expect(result).to eq(error: "We could not find 'nonexistent' among the project's bundled gems") 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/lib/gem_resource_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler_mcp/gem_resource" 4 | require "rubygems" 5 | 6 | RSpec.describe BundlerMCP::GemResource do 7 | subject(:resource) { described_class.new(gem_spec) } 8 | 9 | let(:gem_spec) { Gem::Specification.find_by_name("bundler_mcp") } 10 | 11 | describe "#to_h" do 12 | it "returns basic gem details" do 13 | aggregate_failures do 14 | expect(resource.to_h).to include( 15 | name: "bundler_mcp", 16 | version: BundlerMCP::VERSION, 17 | description: gem_spec.description, 18 | full_gem_path: gem_spec.full_gem_path, 19 | lib_path: File.join(gem_spec.full_gem_path, "lib") 20 | ) 21 | end 22 | end 23 | 24 | it "includes top-level documentation paths" do 25 | expect(resource.to_h.fetch(:top_level_documentation_paths)).to include( 26 | File.join(gem_spec.full_gem_path, "README.md") 27 | ) 28 | end 29 | 30 | context "when include_source_files is true" do 31 | it "includes source file paths" do 32 | expect(resource.to_h(include_source_files: true).fetch(:source_files)).to include( 33 | File.join(gem_spec.full_gem_path, "lib/bundler_mcp/gem_resource.rb") 34 | ) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/bundler_mcp/gem_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | require "pathname" 5 | 6 | module BundlerMCP 7 | # Represents a Ruby gem and its associated data 8 | # @see https://docs.ruby-lang.org/en/2.5.0/Gem/Specification.html 9 | class GemResource 10 | extend Forwardable 11 | 12 | def_delegators :gem, :name, :description, :full_gem_path 13 | 14 | # @param gem [Gem::Specification] 15 | def initialize(gem) 16 | @gem = gem 17 | 18 | @version = gem.version.to_s 19 | @base_path = Pathname(@gem.full_gem_path) 20 | @doc_paths = @base_path.glob("{README,CHANGELOG}*").map!(&:to_s) 21 | @lib_path = @base_path.join("lib").to_s 22 | end 23 | 24 | # @return [Hash] A hash containing the gem's details 25 | def to_h(include_source_files: false) 26 | base_hash = { name:, 27 | version:, 28 | description:, 29 | full_gem_path:, 30 | lib_path:, 31 | top_level_documentation_paths: doc_paths } 32 | 33 | base_hash[:source_files] = @base_path.glob("**/*.{rb,c,rake}").map!(&:to_s) if include_source_files 34 | 35 | base_hash 36 | end 37 | 38 | private 39 | 40 | attr_reader :gem, :version, :base_path, :doc_paths, :lib_path 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/bundler_mcp/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler" 4 | require "fast_mcp" 5 | require "json" 6 | require "pathname" 7 | require "logger" 8 | require_relative "version" 9 | require_relative "resource_collection" 10 | require_relative "tool_collection" 11 | require_relative "environment_checker" 12 | 13 | module BundlerMCP 14 | # Main server class for BundlerMCP 15 | # @see https://github.com/yjacquin/fast-mcp/blob/main/examples/server_with_stdio_transport.rb 16 | class Server 17 | # Convenience method to start the server 18 | # @return [void] 19 | def self.run(**args) 20 | new(**args).run 21 | end 22 | 23 | # Initialize the server 24 | # @return [void] 25 | def initialize(logger: Logger.new(File::NULL)) 26 | @logger = logger 27 | 28 | @server = FastMcp::Server.new( 29 | name: "bundler-gem-documentation", 30 | version: VERSION 31 | ) 32 | end 33 | 34 | # Start the MCP server 35 | # @return [void] 36 | def run 37 | gemfile_path = EnvironmentChecker.check! 38 | logger.info "Starting BundlerMCP server with Gemfile: #{gemfile_path}" 39 | 40 | ToolCollection.each do |tool| 41 | server.register_tool(tool) 42 | end 43 | 44 | server.start 45 | rescue StandardError => e 46 | logger.error e.message 47 | raise e 48 | end 49 | 50 | private 51 | 52 | attr_reader :logger, :server 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/lib/server_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "bundler_mcp/server" 5 | 6 | RSpec.describe BundlerMCP::Server do 7 | before do 8 | allow(BundlerMCP::EnvironmentChecker).to receive(:check!) 9 | allow(FastMcp::Server).to receive(:new).and_return(mcp_server) 10 | end 11 | 12 | let(:mcp_server) do 13 | instance_double(FastMcp::Server, 14 | register_tool: nil, 15 | start: nil) 16 | end 17 | 18 | describe ".run" do 19 | it "checks the environment" do 20 | described_class.run 21 | expect(BundlerMCP::EnvironmentChecker).to have_received(:check!) 22 | end 23 | 24 | it "registers all tools" do 25 | described_class.run 26 | 27 | expect(mcp_server) 28 | .to have_received(:register_tool) 29 | .with(BundlerMCP::Tools::ListProjectGems) 30 | .with(BundlerMCP::Tools::GetGemDetails) 31 | end 32 | 33 | it "starts the server" do 34 | described_class.run 35 | expect(mcp_server).to have_received(:start) 36 | end 37 | 38 | context "when an error occurs" do 39 | let(:error) { BundlerMCP::GemfileNotFound } 40 | 41 | before do 42 | allow(BundlerMCP::EnvironmentChecker) 43 | .to receive(:check!) 44 | .and_raise(error) 45 | end 46 | 47 | it "re-raises the error" do 48 | expect do 49 | described_class.run 50 | end.to raise_error(error) 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: .ruby-version 21 | bundler-cache: true 22 | 23 | - name: Lint code for consistent style 24 | run: bundle exec rubocop -f github --fail-level error -c ./.rubocop.yml 25 | 26 | build: 27 | runs-on: ubuntu-latest 28 | name: Ruby ${{ matrix.ruby }} 29 | strategy: 30 | matrix: 31 | ruby: 32 | - '3.4.2' 33 | 34 | steps: 35 | - uses: actions/checkout@v4 36 | 37 | - name: Set up Ruby 38 | uses: ruby/setup-ruby@v1 39 | with: 40 | ruby-version: ${{ matrix.ruby }} 41 | bundler-cache: true 42 | 43 | - name: Run tests 44 | run: bundle exec rspec 45 | 46 | push: 47 | name: Release to Rubygems 48 | runs-on: ubuntu-latest 49 | 50 | if: github.ref == 'refs/heads/main' 51 | needs: [lint, build] 52 | 53 | permissions: 54 | contents: write 55 | id-token: write 56 | 57 | environment: production 58 | 59 | steps: 60 | - uses: actions/checkout@v4 61 | 62 | - name: Set up Ruby 63 | uses: ruby/setup-ruby@v1 64 | with: 65 | bundler-cache: true 66 | ruby-version: ruby 67 | 68 | - name: Release to Rubygems 69 | uses: rubygems/release-gem@v1 70 | -------------------------------------------------------------------------------- /lib/bundler_mcp/tools/list_project_gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "mcp/tool" 4 | 5 | module BundlerMCP 6 | module Tools 7 | # Informs the client of all bundled Ruby gems with their versions, descriptions, installation paths, 8 | # documentation, and (optionally) source code locations 9 | # @see https://github.com/yjacquin/fast-mcp/blob/main/docs/tools.md 10 | class ListProjectGems < FastMcp::Tool 11 | description <<~DESC 12 | Lists **all** Ruby Gems declared in this project's Gemfile/Gemfile.lock, returning for each gem: 13 | name, version, short description, install path, and top-level docs path. 14 | 15 | ‣ Use this tool whenever you need to know about the project's gem dependencies and how they work. 16 | ‣ This tool reads local files only, so the data is authoritative for the current workspace 17 | and never relies on the Internet. 18 | DESC 19 | 20 | # @return [String] Tool name exposed by FastMCP to clients 21 | def self.name 22 | "list_project_gems" 23 | end 24 | 25 | # @param collection [ResourceCollection] contains installed gems 26 | def initialize(collection = ResourceCollection.instance) 27 | @resource_collection = collection 28 | super() 29 | end 30 | 31 | # Invoke the tool to list all installed gems 32 | # @return [Array] An array of hashes containing gem details 33 | def call 34 | data = resource_collection.map(&:to_h) 35 | JSON.generate(data) 36 | end 37 | 38 | private 39 | 40 | attr_reader :resource_collection 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /bundler_mcp.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/bundler_mcp/version" 4 | 5 | EXCLUDE_FILES = %w[ 6 | bin/ test/ spec/ features/ .git .github .cursor .cursorignore 7 | .rspec .rubocop.yml example.mcp.json Gemfile 8 | ].freeze 9 | 10 | Gem::Specification.new do |spec| 11 | spec.name = "bundler_mcp" 12 | spec.version = BundlerMCP::VERSION 13 | spec.authors = ["Mike Subelsky"] 14 | spec.email = ["12020+subelsky@users.noreply.github.com"] 15 | 16 | spec.summary = "MCP server for searching Ruby bundled gem documentation and metadata" 17 | 18 | spec.description = <<~DESC 19 | A Model Context Protocol (MCP) server that enables AI agents to query information 20 | about gems in a Ruby project's Gemfile, including source code and metadata. 21 | DESC 22 | 23 | spec.homepage = "https://github.com/subelsky/bundler_mcp" 24 | spec.license = "MIT" 25 | spec.required_ruby_version = ">= 3.2" 26 | 27 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 28 | 29 | spec.metadata["homepage_uri"] = spec.homepage 30 | spec.metadata["source_code_uri"] = spec.homepage 31 | spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" 32 | spec.metadata["rubygems_mfa_required"] = "true" 33 | spec.metadata["keywords"] = "mcp gems bundler" 34 | 35 | gemspec = File.basename(__FILE__) 36 | 37 | spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| 38 | ls.readlines("\x0", chomp: true).reject do |f| 39 | (f == gemspec) || f.start_with?(*EXCLUDE_FILES) 40 | end 41 | end 42 | 43 | spec.bindir = "exe" 44 | spec.executables = ["bundler_mcp"] 45 | 46 | spec.require_paths = ["lib"] 47 | 48 | spec.add_dependency "bundler", "~> 2.6" 49 | spec.add_dependency "fast-mcp", "~> 1.4" 50 | end 51 | -------------------------------------------------------------------------------- /lib/bundler_mcp/tools/get_gem_details.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "fast_mcp" 4 | 5 | module BundlerMCP 6 | module Tools 7 | # Retrieve details about a specific bundled Ruby gem 8 | # @see ResourceCollection 9 | class GetGemDetails < FastMcp::Tool 10 | description <<~DESC 11 | Returns detailed information about one **Ruby Gem** that is installed in the current project. 12 | 13 | ‣ Use this tool when you need to know the version, summary, or source code location for a gem that is installed in the current project. 14 | ‣ Do **not** use it for gems that are *not* part of this project. 15 | 16 | The data comes directly from Gemfile.lock and the local installation, so it is always up-to-date and requires **no Internet access**. 17 | DESC 18 | 19 | arguments do 20 | required(:name).filled(:string).description("The name of the gem to fetch") 21 | end 22 | 23 | # @return [String] Tool name exposed by FastMCP to clients 24 | def self.name = "get_gem_details" 25 | 26 | # @param collection [ResourceCollection] contains installed gems 27 | def initialize(collection = ResourceCollection.instance) 28 | @resource_collection = collection 29 | super() 30 | end 31 | 32 | # @param name [String] The name of the gem to fetch 33 | # @return [Hash] Contains the gem's details, or an error message if the gem is not found 34 | def call(name:) 35 | name = name.to_s.strip 36 | gem_resource = resource_collection.find { |r| r.name.casecmp?(name) } 37 | 38 | data = if gem_resource 39 | gem_resource.to_h(include_source_files: true) 40 | else 41 | { error: "We could not find '#{name}' among the project's bundled gems" } 42 | end 43 | 44 | JSON.generate(data) 45 | end 46 | 47 | private 48 | 49 | attr_reader :resource_collection 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/support/integration_spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable Metrics/AbcSize,Metrics/MethodLength 4 | 5 | module IntegrationSpecHelper 6 | attr_accessor :read_io, :write_io, :child_pid 7 | 8 | # Forks so that we can test the server's behavior in isolation using STDIN/STDOUT. 9 | # We use IO.pipe to create a pair of pipes, one for reading and one for writing. 10 | def setup_server 11 | input_read, input_write = IO.pipe 12 | output_read, output_write = IO.pipe 13 | 14 | self.child_pid = fork do 15 | # Child process - will run the server 16 | input_write.close 17 | output_read.close 18 | 19 | # Redirect stdin/stdout to our pipes 20 | $stdin.reopen(input_read) 21 | $stdout.reopen(output_write) 22 | 23 | # Close unused pipe ends 24 | input_read.close 25 | output_write.close 26 | 27 | # Run the server 28 | server_executable = File.expand_path("../../exe/bundler_mcp", __dir__) 29 | load server_executable 30 | end 31 | 32 | # Parent process - will run the tests 33 | input_read.close 34 | output_write.close 35 | 36 | self.read_io = output_read 37 | self.write_io = input_write 38 | 39 | [read_io, write_io] 40 | end 41 | 42 | # Closes the pipes and kills the child process. 43 | def teardown_server 44 | write_io&.close 45 | read_io&.close 46 | 47 | return unless child_pid 48 | 49 | Process.kill("TERM", child_pid) 50 | Process.wait(child_pid) 51 | end 52 | 53 | # Sends a request to the server and returns the response. 54 | # @param method [String] The name of the tool to call 55 | # @param arguments [Hash] Tool-specific arguments to pass as part of the tool call 56 | # @return [Hash] The response from the server 57 | def request(method, **arguments) 58 | request = RPC_ARGUMENTS.merge( 59 | params: { 60 | name: method, 61 | arguments: arguments 62 | } 63 | ).to_json 64 | 65 | write_io.puts(request) 66 | 67 | response = JSON.parse(read_io.gets, symbolize_names: true) 68 | text = response.dig(:result, :content, 0, :text) 69 | 70 | JSON.parse(text, symbolize_names: true) 71 | end 72 | 73 | RPC_ARGUMENTS = { 74 | jsonrpc: "2.0", 75 | method: "tools/call" 76 | }.freeze 77 | 78 | private_constant :RPC_ARGUMENTS 79 | end 80 | 81 | # rubocop:enable Metrics/AbcSize,Metrics/MethodLength 82 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | bundler_mcp (0.2.1.1) 5 | bundler (~> 2.6) 6 | fast-mcp (~> 1.4) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | ast (2.4.2) 12 | base64 (0.2.0) 13 | bigdecimal (3.1.9) 14 | concurrent-ruby (1.3.5) 15 | date (3.4.1) 16 | debug (1.10.0) 17 | irb (~> 1.10) 18 | reline (>= 0.3.8) 19 | diff-lcs (1.5.1) 20 | dry-configurable (1.3.0) 21 | dry-core (~> 1.1) 22 | zeitwerk (~> 2.6) 23 | dry-core (1.1.0) 24 | concurrent-ruby (~> 1.0) 25 | logger 26 | zeitwerk (~> 2.6) 27 | dry-inflector (1.2.0) 28 | dry-initializer (3.2.0) 29 | dry-logic (1.6.0) 30 | bigdecimal 31 | concurrent-ruby (~> 1.0) 32 | dry-core (~> 1.1) 33 | zeitwerk (~> 2.6) 34 | dry-schema (1.14.1) 35 | concurrent-ruby (~> 1.0) 36 | dry-configurable (~> 1.0, >= 1.0.1) 37 | dry-core (~> 1.1) 38 | dry-initializer (~> 3.2) 39 | dry-logic (~> 1.5) 40 | dry-types (~> 1.8) 41 | zeitwerk (~> 2.6) 42 | dry-types (1.8.2) 43 | bigdecimal (~> 3.0) 44 | concurrent-ruby (~> 1.0) 45 | dry-core (~> 1.0) 46 | dry-inflector (~> 1.0) 47 | dry-logic (~> 1.4) 48 | zeitwerk (~> 2.6) 49 | fast-mcp (1.4.0) 50 | base64 51 | dry-schema (~> 1.14) 52 | json (~> 2.0) 53 | mime-types (~> 3.4) 54 | rack (~> 3.1) 55 | io-console (0.8.0) 56 | irb (1.15.2) 57 | pp (>= 0.6.0) 58 | rdoc (>= 4.0.0) 59 | reline (>= 0.4.2) 60 | json (2.12.0) 61 | language_server-protocol (3.17.0.4) 62 | lint_roller (1.1.0) 63 | logger (1.7.0) 64 | mime-types (3.7.0) 65 | logger 66 | mime-types-data (~> 3.2025, >= 3.2025.0507) 67 | mime-types-data (3.2025.0514) 68 | parallel (1.26.3) 69 | parser (3.3.8.0) 70 | ast (~> 2.4.1) 71 | racc 72 | pp (0.6.2) 73 | prettyprint 74 | prettyprint (0.2.0) 75 | prism (1.4.0) 76 | psych (5.2.3) 77 | date 78 | stringio 79 | racc (1.8.1) 80 | rack (3.1.16) 81 | rainbow (3.1.1) 82 | rake (13.2.1) 83 | rdoc (6.13.1) 84 | psych (>= 4.0.0) 85 | regexp_parser (2.10.0) 86 | reline (0.6.1) 87 | io-console (~> 0.5) 88 | rspec (3.13.0) 89 | rspec-core (~> 3.13.0) 90 | rspec-expectations (~> 3.13.0) 91 | rspec-mocks (~> 3.13.0) 92 | rspec-core (3.13.3) 93 | rspec-support (~> 3.13.0) 94 | rspec-expectations (3.13.3) 95 | diff-lcs (>= 1.2.0, < 2.0) 96 | rspec-support (~> 3.13.0) 97 | rspec-mocks (3.13.2) 98 | diff-lcs (>= 1.2.0, < 2.0) 99 | rspec-support (~> 3.13.0) 100 | rspec-support (3.13.2) 101 | rubocop (1.75.3) 102 | json (~> 2.3) 103 | language_server-protocol (~> 3.17.0.2) 104 | lint_roller (~> 1.1.0) 105 | parallel (~> 1.10) 106 | parser (>= 3.3.0.2) 107 | rainbow (>= 2.2.2, < 4.0) 108 | regexp_parser (>= 2.9.3, < 3.0) 109 | rubocop-ast (>= 1.44.0, < 2.0) 110 | ruby-progressbar (~> 1.7) 111 | unicode-display_width (>= 2.4.0, < 4.0) 112 | rubocop-ast (1.44.1) 113 | parser (>= 3.3.7.2) 114 | prism (~> 1.4) 115 | rubocop-rspec (3.6.0) 116 | lint_roller (~> 1.1) 117 | rubocop (~> 1.72, >= 1.72.1) 118 | ruby-progressbar (1.13.0) 119 | stringio (3.1.6) 120 | unicode-display_width (3.1.4) 121 | unicode-emoji (~> 4.0, >= 4.0.4) 122 | unicode-emoji (4.0.4) 123 | zeitwerk (2.7.2) 124 | 125 | PLATFORMS 126 | arm64-darwin-24 127 | ruby 128 | 129 | DEPENDENCIES 130 | bundler_mcp! 131 | debug 132 | irb 133 | rake (~> 13.0) 134 | rspec (~> 3.0) 135 | rubocop (~> 1.21) 136 | rubocop-rspec (~> 3.6) 137 | 138 | BUNDLED WITH 139 | 2.6.8 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BundlerMCP 2 | 3 | A Model Context Protocol (MCP) server enabling AI agents to query information about dependencies in a Ruby project's `Gemfile`. Built with [fast-mcp](https://github.com/yjacquin/fast-mcp). 4 | 5 | [![CI](https://github.com/subelsky/bundler_mcp/actions/workflows/main.yml/badge.svg)](https://github.com/subelsky/bundler_mcp/actions/workflows/main.yml) 6 | [![Gem Version](https://badge.fury.io/rb/bundler_mcp.svg)](https://badge.fury.io/rb/bundler_mcp) 7 | 8 | ## Installation 9 | 10 | Install the gem and add to the application's Gemfile by executing: 11 | 12 | ```bash 13 | bundle add bundler_mcp --group=development 14 | ``` 15 | 16 | ## Usage 17 | 18 | 1. Generate the binstub: 19 | 20 | ```bash 21 | bundle binstubs bundler_mcp 22 | ``` 23 | 24 | 2. Configure your client to execute the binstub. Here are examples that work for Claude and Cursor: 25 | 26 | ### Basic Example (mcp.json) 27 | 28 | ```json 29 | { 30 | "mcpServers": { 31 | "bundler-mcp": { 32 | "command": "/Users/mike/my_project/bin/bundler_mcp" 33 | } 34 | } 35 | } 36 | ``` 37 | 38 | ### Example with logging and explicit Gemfile 39 | 40 | ```json 41 | { 42 | "mcpServers": { 43 | "bundler-mcp": { 44 | "command": "/Users/mike/my_project/bin/bundler_mcp", 45 | 46 | "env": { 47 | "BUNDLER_MCP_LOG_FILE": "/Users/mike/my_project/log/mcp.log", 48 | "BUNDLE_GEMFILE": "/Users/mike/my_project/subdir/Gemfile" 49 | } 50 | } 51 | } 52 | } 53 | ``` 54 | 55 | ### Documentation 56 | 57 | [Available on RubyDoc](https://www.rubydoc.info/gems/bundler_mcp/) 58 | 59 | ### Available Tools 60 | 61 | The server provides two tools for AI agents: 62 | 63 | #### list_project_gems 64 | 65 | Lists all bundled Ruby gems with their: 66 | 67 | - Versions 68 | - Descriptions 69 | - Installation paths 70 | - Top-level documentation locations (e.g. `README` and `CHANGELOG`) 71 | 72 | ![list_project_gems tool](/docs/list_project_gems.png) 73 | 74 | #### get_gem_details 75 | 76 | Retrieves detailed information about a specific gem, including: 77 | 78 | - Version 79 | - Description 80 | - Installation path 81 | - Top-level documentation locations 82 | - Source code file locations 83 | 84 | ![get_gem_details tool](/docs/get_gem_details.png) 85 | 86 | ## Environment Variables 87 | 88 | - `BUNDLE_GEMFILE`: Used by Bundler to locate your Gemfile. If you use the binstub method described in the [Usage](#usage) section, this is usually not needed. 89 | - `BUNDLER_MCP_LOG_FILE`: Path to log file. Useful for troubleshooting (defaults to no logging) 90 | 91 | ## Development 92 | 93 | After checking out the repo, run `bin/setup` to install dependencies and `bundle exec rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 94 | 95 | ### Testing with the MCP Inspector 96 | 97 | You can test the server directly using the [MCP inspector](https://modelcontextprotocol.io/docs/tools/inspector): 98 | 99 | ```bash 100 | # Basic usage 101 | npx @modelcontextprotocol/inspector ./bin/bundler_mcp 102 | 103 | # With logging enabled 104 | BUNDLER_MCP_LOG_FILE=/tmp/log/mcp.log npx @modelcontextprotocol/inspector ./bin/bundler_mcp 105 | 106 | # With custom Gemfile 107 | BUNDLE_GEMFILE=./other/Gemfile npx @modelcontextprotocol/inspector ./bin/bundler_mcp 108 | ``` 109 | 110 | ### Release Process 111 | 112 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version: 113 | 114 | 1. Update the version number in `version.rb` 115 | 2. Run `bundle exec rake release` 116 | 117 | This will: 118 | 119 | - Create a git tag for the version 120 | - Push git commits and the created tag 121 | - Push the `.gem` file to [rubygems.org](https://rubygems.org) 122 | 123 | ## Contributing 124 | 125 | Bug reports and pull requests are welcome on GitHub at https://github.com/subelsky/bundler_mcp. 126 | 127 | ## License 128 | 129 | Open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 130 | 131 | ## Author 132 | 133 | [Mike Subelsky](https://subelsky.com) 134 | --------------------------------------------------------------------------------