# frozen_string_literal: true require "test_helper" require "mcp/instrumentation" module MCP class InstrumentationTest < ActiveSupport::TestCase class Subject include Instrumentation attr_reader :instrumentation_data_received, :configuration def initialize @configuration = MCP::Configuration.new @configuration.instrumentation_callback = ->(data) { @instrumentation_data_received = data } end def instrumented_method instrument_call("instrumented_method") do # nothing to do end end def instrumented_method_with_additional_data instrument_call("instrumented_method_with_additional_data") do add_instrumentation_data(additional_data: "test") end end def instrumented_method_with_server_context(context) instrument_call("instrumented_method_with_server_context", server_context: context) do # nothing to do end end def instrumented_method_that_raises_with_error_set instrument_call("instrumented_method_that_raises_with_error_set") do add_instrumentation_data(error: :custom_error) raise "block error" end end end # Mirrors `MCP::Server`, which exposes the user-defined hash through # `attr_accessor :server_context`. class SubjectWithServerContext < Subject attr_accessor :server_context def initialize(server_context) super() @server_context = server_context end end test "#instrument_call omits the user-defined server_context by default" do subject = SubjectWithServerContext.new({ user_id: 42 }) subject.instrumented_method refute_includes subject.instrumentation_data_received.keys, :server_context end test "#instrument_call exposes the user-defined server_context when instrument_server_context is enabled" do subject = SubjectWithServerContext.new({ user_id: 42 }) subject.configuration.instrument_server_context = true subject.instrumented_method assert_equal({ user_id: 42 }, subject.instrumentation_data_received[:server_context]) end test "#instrument_call exposes the user-defined server_context to around_request" do subject = SubjectWithServerContext.new({ user_id: 42 }) subject.configuration.instrument_server_context = true seen = nil subject.configuration.around_request = ->(data, &handler) { seen = data[:server_context] handler.call } subject.instrumented_method assert_equal({ user_id: 42 }, seen) end test "#instrument_call reports the user-defined server_context as nil when the host does not set one" do subject = SubjectWithServerContext.new(nil) subject.configuration.instrument_server_context = true subject.instrumented_method assert_includes subject.instrumentation_data_received.keys, :server_context assert_nil subject.instrumentation_data_received[:server_context] end test "#instrument_call does not confuse the reporter context with the user-defined server_context" do # `instrument_call(server_context:)` is the exception-reporter context, a # different value from the user-defined hash. Enabling the flag must # surface the latter, not the former. subject = SubjectWithServerContext.new({ user_id: 42 }) subject.configuration.instrument_server_context = true subject.instrumented_method_with_server_context({ request: "reporter-context" }) assert_equal({ user_id: 42 }, subject.instrumentation_data_received[:server_context]) end test "#instrument_call skips server_context for hosts without the reader even when enabled" do subject = Subject.new subject.configuration.instrument_server_context = true subject.instrumented_method refute_includes subject.instrumentation_data_received.keys, :server_context end test "#instrument_call adds the method name to the instrumentation data" do subject = Subject.new subject.instrumented_method assert_equal({ method: "instrumented_method" }, subject.instrumentation_data_received.tap do |data| data.delete(:duration) end) end test "#instrument_call exposes data added via add_instrumentation_data" do subject = Subject.new subject.instrumented_method_with_additional_data assert_equal( { method: "instrumented_method_with_additional_data", additional_data: "test" }, subject.instrumentation_data_received.tap { |data| data.delete(:duration) }, ) end test "#instrument_call invokes around_request wrapping execution" do call_log = [] subject = Subject.new subject.configuration.around_request = ->(_data, &request_handler) { call_log << :before request_handler.call call_log << :after } subject.instrumented_method assert_equal([:before, :after], call_log) end test "#instrument_call around_request receives method before request_handler.call" do received_method = nil subject = Subject.new subject.configuration.around_request = ->(data, &request_handler) { received_method = data[:method] request_handler.call } subject.instrumented_method assert_equal("instrumented_method", received_method) end test "#instrument_call around_request data is populated after request_handler.call" do data_after_call = nil subject = Subject.new subject.configuration.around_request = ->(data, &request_handler) { request_handler.call data_after_call = data.dup } subject.instrumented_method_with_additional_data assert_equal("instrumented_method_with_additional_data", data_after_call[:method]) assert_equal("test", data_after_call[:additional_data]) end test "#instrument_call default around_request is pass-through" do subject = Subject.new subject.instrumented_method_with_additional_data assert_equal( { method: "instrumented_method_with_additional_data", additional_data: "test" }, subject.instrumentation_data_received.tap { |data| data.delete(:duration) }, ) end test "#instrument_call reports exception and sets error when around_request raises before request_handler.call" do reported_exception = nil reported_context = nil subject = Subject.new subject.configuration.exception_reporter = ->(e, server_context) { reported_exception = e reported_context = server_context } subject.configuration.around_request = ->(_data, &_request_handler) { raise "boom before" } error = assert_raises(RuntimeError) { subject.instrumented_method } assert_equal("boom before", error.message) assert_same(error, reported_exception) assert_equal({}, reported_context) assert_equal(:internal_error, subject.instrumentation_data_received[:error]) assert(subject.instrumentation_data_received.key?(:duration)) end test "#instrument_call reports exception and sets error when around_request raises after request_handler.call" do reported_exception = nil subject = Subject.new subject.configuration.exception_reporter = ->(e, _server_context) { reported_exception = e } subject.configuration.around_request = ->(_data, &request_handler) { request_handler.call raise "boom after" } error = assert_raises(RuntimeError) { subject.instrumented_method } assert_equal("boom after", error.message) assert_same(error, reported_exception) assert_equal(:internal_error, subject.instrumentation_data_received[:error]) end test "#instrument_call preserves block's custom error tag and reports the exception" do report_count = 0 subject = Subject.new subject.configuration.exception_reporter = ->(_e, _server_context) { report_count += 1 } assert_raises(RuntimeError) { subject.instrumented_method_that_raises_with_error_set } assert_equal(1, report_count) assert_equal(:custom_error, subject.instrumentation_data_received[:error]) end test "#instrument_call skips reporting when exception_already_reported returns true" do reported = [] subject = Subject.new subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } error = StandardError.new("inner failure") subject.define_singleton_method(:instrumented_with_skip) do instrument_call("instrumented_with_skip", exception_already_reported: ->(e) { error.equal?(e) }) do raise error end end assert_raises(StandardError) { subject.instrumented_with_skip } assert_equal([], reported) end test "#instrument_call reports when exception_already_reported returns false for a new exception" do reported = [] subject = Subject.new subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } target = StandardError.new("inner failure") subject.configuration.around_request = ->(_data, &request_handler) do request_handler.call ensure raise "ensure boom" end subject.define_singleton_method(:instrumented_with_skip) do instrument_call("instrumented_with_skip", exception_already_reported: ->(e) { target.equal?(e) }) do raise target end end assert_raises(RuntimeError) { subject.instrumented_with_skip } assert_equal(["ensure boom"], reported) end test "#instrument_call falls back to reporting when exception_already_reported itself raises" do reported = [] subject = Subject.new subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } subject.define_singleton_method(:instrumented_with_broken_predicate) do instrument_call( "instrumented_with_broken_predicate", exception_already_reported: ->(_e) { raise "predicate blew up" }, ) do raise "original failure" end end error = assert_raises(RuntimeError) { subject.instrumented_with_broken_predicate } assert_equal("original failure", error.message) assert_equal(["original failure"], reported) end test "#instrument_call falls back to reporting when exception_already_reported raises a non-StandardError" do reported = [] subject = Subject.new subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } subject.define_singleton_method(:instrumented_with_system_exit_predicate) do instrument_call( "instrumented_with_system_exit_predicate", exception_already_reported: ->(_e) { raise SystemExit, 9 }, ) do raise "original failure" end end error = assert_raises(RuntimeError) { subject.instrumented_with_system_exit_predicate } assert_equal("original failure", error.message) assert_equal(["original failure"], reported) end test "#instrument_call normalizes non-boolean truthy return values from exception_already_reported" do reported = [] subject = Subject.new subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } subject.define_singleton_method(:instrumented_with_truthy_predicate) do instrument_call( "instrumented_with_truthy_predicate", exception_already_reported: ->(_e) { "non-boolean truthy" }, ) do raise "original failure" end end assert_raises(RuntimeError) { subject.instrumented_with_truthy_predicate } assert_equal([], reported) end test "#instrument_call reports when exception_already_reported returns nil" do reported = [] subject = Subject.new subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } subject.define_singleton_method(:instrumented_with_nil_predicate) do instrument_call( "instrumented_with_nil_predicate", exception_already_reported: ->(_e) { nil }, ) do raise "original failure" end end assert_raises(RuntimeError) { subject.instrumented_with_nil_predicate } assert_equal(["original failure"], reported) end test "#instrument_call does not carry reported state across invocations when the same exception is reused" do reported = [] subject = Subject.new subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } shared_error = RuntimeError.new("reused") subject.configuration.around_request = ->(_data, &_request_handler) { raise shared_error } 2.times do assert_raises(RuntimeError) { subject.instrumented_method } end assert_equal(["reused", "reused"], reported) end test "#instrument_call reports frozen exceptions without mutating them" do reported = [] subject = Subject.new subject.configuration.exception_reporter = ->(e, _server_context) { reported << e } frozen_error = RuntimeError.new("frozen").freeze subject.configuration.around_request = ->(_data, &_request_handler) { raise frozen_error } error = assert_raises(RuntimeError) { subject.instrumented_method } assert_same(frozen_error, error) assert_equal([frozen_error], reported) assert_predicate(frozen_error, :frozen?) end test "#instrument_call nested calls sharing an exception_already_reported predicate report only once" do reported = [] subject = Subject.new subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } reported_exception = nil predicate = ->(e) { reported_exception.equal?(e) } subject.define_singleton_method(:outer) do instrument_call("outer", exception_already_reported: predicate) { inner } end subject.define_singleton_method(:inner) do instrument_call("inner", exception_already_reported: predicate) do raise "nested boom" rescue => e configuration.exception_reporter.call(e, {}) reported_exception = e raise end end assert_raises(RuntimeError) { subject.outer } assert_equal(["nested boom"], reported) end test "#instrument_call keeps reported state isolated across concurrent threads on a shared subject" do subject = Subject.new reported = Queue.new subject.configuration.exception_reporter = ->(e, _server_context) { reported << e.message } threads = 10.times.map do |i| Thread.new do subject.instrument_call("threaded_#{i}") { raise "thread #{i}" } rescue RuntimeError # Swallow the intentional raise so the thread finishes cleanly. # The test verifies side effects via the shared `reported` queue. end end threads.each(&:join) messages = [] messages << reported.pop until reported.empty? assert_equal(10.times.map { |i| "thread #{i}" }, messages.sort) end test "#instrument_call reports each invocation when the same exception instance is raised from multiple threads" do subject = Subject.new reported = Queue.new subject.configuration.exception_reporter = ->(e, _server_context) { reported << e } shared_error = RuntimeError.new("shared across threads") start_gate = Queue.new threads = 10.times.map do Thread.new do start_gate.pop begin subject.instrument_call("shared_error_method") { raise shared_error } rescue RuntimeError # Swallow the intentional raise so the thread finishes cleanly. # The test verifies side effects via the shared `reported` queue. end end end 10.times { start_gate << :go } threads.each(&:join) collected = [] collected << reported.pop until reported.empty? assert_equal(10, collected.size) assert(collected.all? { |e| e.equal?(shared_error) }) end test "#instrument_call forwards server_context to exception_reporter on around_request failure" do reported_context = nil subject = Subject.new subject.configuration.exception_reporter = ->(_e, server_context) { reported_context = server_context } subject.configuration.around_request = ->(_data, &_request_handler) { raise "boom" } assert_raises(RuntimeError) do subject.instrumented_method_with_server_context(request: { id: 42 }) end assert_equal({ request: { id: 42 } }, reported_context) end test "#instrument_call resets the instrumentation data between calls" do subject = Subject.new subject.instrumented_method_with_additional_data assert_equal( { method: "instrumented_method_with_additional_data", additional_data: "test" }, subject.instrumentation_data_received.tap { |data| data.delete(:duration) }, ) subject.instrumented_method assert_equal({ method: "instrumented_method" }, subject.instrumentation_data_received.tap do |data| data.delete(:duration) end) end end end