From b75a4ed6fd4fd7e39fdcf0172415e75d4aee2dc1 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Wed, 7 Oct 2026 14:18:10 -0300 Subject: [PATCH 1/3] test(mcp): cover instrument() when rack is missing Failing spec: install_extensions! raises LoadError from the mcp gem's autoloaded StreamableHTTPTransport when rack is absent, crashing stdio servers at boot. Fix follows. --- spec/posthog/mcp/instrument_spec.rb | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/spec/posthog/mcp/instrument_spec.rb b/spec/posthog/mcp/instrument_spec.rb index 9598ecb..1d3a3ea 100644 --- a/spec/posthog/mcp/instrument_spec.rb +++ b/spec/posthog/mcp/instrument_spec.rb @@ -148,6 +148,30 @@ def initialize_request(id = 1, version = '2025-06-18') handle.capture('custom') end + context 'when the rack gem is not installed (stdio-only server)' do + let(:logger_messages) { [] } + + before do + allow(Kernel).to receive(:warn) + PostHog::MCP.instance_variable_set(:@extensions_installed, false) + allow(MCP::Server::Transports::StreamableHTTPTransport).to receive(:prepend) + .and_raise(LoadError, "The 'rack' gem is required to use the StreamableHTTPTransport.") + end + + after { PostHog::MCP.instance_variable_set(:@extensions_installed, false) } + + it 'skips the HTTP extension, still instruments the server, and logs it' do + handle = described_class.instrument(server, client, logger: ->(message) { logger_messages << message }) + expect(handle).to be_a(PostHog::MCP::Analytics) + expect(described_class.tracking_data(server)).not_to be_nil + expect(logger_messages).to include(a_string_including('rack')) + + server.handle(initialize_request) + server.handle(rpc(2, 'tools/call', { name: 'echo', arguments: { message: 'hi' } })) + expect(drain_events(client).map { |e| e[:event] }).to eq(%w[$mcp_initialize $mcp_tool_call]) + end + 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) From 6b7fe59c4d0afa2884de2636b4f523001dcd504a Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Wed, 7 Oct 2026 14:19:49 -0300 Subject: [PATCH 2/3] fix(mcp): don't crash stdio servers when rack is missing What: PostHog::MCP.instrument installed the Streamable HTTP transport extension unconditionally. The mcp gem autoloads that transport and raises LoadError without rack, which instrument did not rescue (it only rescues StandardError), so stdio-only servers crashed at boot. Why: rack is only needed for HTTP. A missing rack now skips only the transport extension and logs through Log.debug; the server extension and stdio capture are unaffected. Tested: bundle exec rspec (1254 examples, 0 failures); bundle exec rubocop clean. New spec fails on the previous commit's code with the LoadError and passes now. --- .changeset/mcp-optional-rack.md | 5 +++++ lib/posthog/mcp.rb | 19 ++++++++++++++----- spec/posthog/mcp/instrument_spec.rb | 6 ++---- 3 files changed, 21 insertions(+), 9 deletions(-) create mode 100644 .changeset/mcp-optional-rack.md 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..0ac1c57 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) @@ -155,6 +155,7 @@ def experimental_notice!(options = nil) # @api private def reset_for_tests! @experimental_notice_shown = false + @extensions_installed = false end private @@ -193,15 +194,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 1d3a3ea..e86d64a 100644 --- a/spec/posthog/mcp/instrument_spec.rb +++ b/spec/posthog/mcp/instrument_spec.rb @@ -153,15 +153,13 @@ def initialize_request(id = 1, version = '2025-06-18') before do allow(Kernel).to receive(:warn) - PostHog::MCP.instance_variable_set(:@extensions_installed, false) allow(MCP::Server::Transports::StreamableHTTPTransport).to receive(:prepend) .and_raise(LoadError, "The 'rack' gem is required to use the StreamableHTTPTransport.") end - after { PostHog::MCP.instance_variable_set(:@extensions_installed, false) } - it 'skips the HTTP extension, still instruments the server, and logs it' do - handle = described_class.instrument(server, client, logger: ->(message) { logger_messages << message }) + handle = described_class.instrument(server, client, capture_model: false, enable_conversation_id: false, + logger: ->(message) { logger_messages << message }) expect(handle).to be_a(PostHog::MCP::Analytics) expect(described_class.tracking_data(server)).not_to be_nil expect(logger_messages).to include(a_string_including('rack')) From e15c26db4790d27c90e5b5c62c319eaff66e2fa6 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Wed, 7 Oct 2026 14:23:02 -0300 Subject: [PATCH 3/3] test(mcp): exercise the real rack LoadError in a subprocess Interrogate gate: both reviewers noted the in-process spec stubbed prepend, not the autoload that raises. Replaced with a subprocess spec that blocks require 'rack' before loading mcp; it fails on main with the gem's LoadError and passes here. Dropped the now-unneeded @extensions_installed reset in reset_for_tests!. Tested: bundle exec rspec (1254 examples, 0 failures); bundle exec rubocop clean. --- lib/posthog/mcp.rb | 1 - spec/posthog/mcp/instrument_spec.rb | 48 +++++++++++++++++------------ 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/lib/posthog/mcp.rb b/lib/posthog/mcp.rb index 0ac1c57..cb0c533 100644 --- a/lib/posthog/mcp.rb +++ b/lib/posthog/mcp.rb @@ -155,7 +155,6 @@ def experimental_notice!(options = nil) # @api private def reset_for_tests! @experimental_notice_shown = false - @extensions_installed = false end private diff --git a/spec/posthog/mcp/instrument_spec.rb b/spec/posthog/mcp/instrument_spec.rb index e86d64a..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,26 +149,35 @@ def initialize_request(id = 1, version = '2025-06-18') handle.capture('custom') end - context 'when the rack gem is not installed (stdio-only server)' do - let(:logger_messages) { [] } + 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' - before do - allow(Kernel).to receive(:warn) - allow(MCP::Server::Transports::StreamableHTTPTransport).to receive(:prepend) - .and_raise(LoadError, "The 'rack' gem is required to use the StreamableHTTPTransport.") - end - - it 'skips the HTTP extension, still instruments the server, and logs it' do - handle = described_class.instrument(server, client, capture_model: false, enable_conversation_id: false, - logger: ->(message) { logger_messages << message }) - expect(handle).to be_a(PostHog::MCP::Analytics) - expect(described_class.tracking_data(server)).not_to be_nil - expect(logger_messages).to include(a_string_including('rack')) - - server.handle(initialize_request) - server.handle(rpc(2, 'tools/call', { name: 'echo', arguments: { message: 'hi' } })) - expect(drain_events(client).map { |e| e[:event] }).to eq(%w[$mcp_initialize $mcp_tool_call]) - end + 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