diff --git a/.changeset/mcp-optional-rack.md b/.changeset/mcp-optional-rack.md new file mode 100644 index 0000000..499a365 --- /dev/null +++ b/.changeset/mcp-optional-rack.md @@ -0,0 +1,5 @@ +--- +'posthog-ruby': patch +--- + +Fix `PostHog::MCP.instrument` raising `LoadError` at boot on stdio-only MCP servers that don't have the `rack` gem installed. A missing `rack` now only skips the Streamable HTTP transport extension. diff --git a/lib/posthog/mcp.rb b/lib/posthog/mcp.rb index d682966..cb0c533 100644 --- a/lib/posthog/mcp.rb +++ b/lib/posthog/mcp.rb @@ -84,7 +84,7 @@ def instrument(server, client = nil, options: nil, **kwargs) sink = resolved_client ? Sink.new(resolved_client) : nil data = TrackingData.new(options: opts, sink: sink, server_name: safe_call(server, :name), server_version: safe_call(server, :version)) - install_extensions! + install_extensions!(opts) server.instance_variable_set(:@__posthog_mcp, data) register_missing_capability_tool(server, data) Analytics.new(server) @@ -193,15 +193,23 @@ def register_missing_capability_tool(server, data) Log.warn(data.options, "Warning: could not register the #{name} tool - #{e.class}: #{e.message}") end - def install_extensions! + def install_extensions!(options) return if @extensions_installed ::MCP::Server.prepend(ServerExtension) - if defined?(::MCP::Server::Transports::StreamableHTTPTransport) - ::MCP::Server::Transports::StreamableHTTPTransport.prepend(TransportExtension) - end + install_transport_extension(options) @extensions_installed = true end + + # The `mcp` gem autoloads its Streamable HTTP transport and that file raises + # LoadError without `rack`, which a stdio-only server legitimately lacks. + def install_transport_extension(options) + return unless defined?(::MCP::Server::Transports::StreamableHTTPTransport) + + ::MCP::Server::Transports::StreamableHTTPTransport.prepend(TransportExtension) + rescue LoadError => e + Log.debug(options, "Skipping the Streamable HTTP transport extension - #{e.message}") + end end end end diff --git a/spec/posthog/mcp/instrument_spec.rb b/spec/posthog/mcp/instrument_spec.rb index 9598ecb..4b2f24a 100644 --- a/spec/posthog/mcp/instrument_spec.rb +++ b/spec/posthog/mcp/instrument_spec.rb @@ -3,6 +3,7 @@ # rubocop:disable Layout/LineLength require_relative 'spec_helper' +require 'open3' class PostHogMcpSpecEchoTool < MCP::Tool tool_name 'echo' @@ -148,6 +149,37 @@ def initialize_request(id = 1, version = '2025-06-18') handle.capture('custom') end + it 'instruments a stdio-only server when the rack gem cannot be loaded' do + script = <<~RUBY + module Kernel + alias_method :require_without_rack_block, :require + def require(name) + raise LoadError, 'cannot load such file -- rack' if name == 'rack' + + require_without_rack_block(name) + end + end + require 'posthog/mcp' + class EchoTool < MCP::Tool + tool_name 'echo' + input_schema(properties: { message: { type: 'string' } }) + def self.call(message: nil, **) = MCP::Tool::Response.new([{ type: 'text', text: message }]) + end + client = PostHog::Client.new(api_key: 'phc_test', test_mode: true) + server = MCP::Server.new(name: 'stdio', version: '1.0.0', tools: [EchoTool]) + messages = [] + handle = PostHog::MCP.instrument(server, client, logger: ->(m) { messages << m }) + server.handle({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: { message: 'hi' } } }) + events = [] + events << client.dequeue_last_message while client.queued_messages.positive? + puts [handle.class, events.map { |e| e[:event] }.include?('$mcp_tool_call'), messages.grep(/rack/).size].inspect + RUBY + + stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-Ilib', '-e', script) + expect(status.success?).to be(true), stderr + expect(stdout.lines.last).to eq("[PostHog::MCP::Analytics, true, 1]\n") + end + it 'falls back to the posthog-rails facade client when present' do allow(Kernel).to receive(:warn) facade_client = PostHog::Client.new(api_key: 'phc_facade', test_mode: true)