Skip to content

Commit 2cbc16c

Browse files
authored
Merge pull request #668 from atesgoral/fix/instrumentation-data-per-request
fix: isolate instrumentation data per request
2 parents 529dd8b + 2121369 commit 2cbc16c

5 files changed

Lines changed: 246 additions & 40 deletions

File tree

‎docs/_server/configuration.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ If no exception reporter is configured, a default no-op reporter is used that si
8484
The `around_request` hook wraps request handling, allowing you to execute code before and after each request.
8585
This is useful for Application Performance Monitoring (APM) tracing, logging, or other observability needs.
8686

87+
The SDK keeps separate instrumentation data for each request. Both hooks receive that request's hash.
88+
89+
`add_instrumentation_data` is deprecated. Update the data hash passed to `around_request` instead.
90+
The helper retains shared-record behavior and is not safe for concurrent calls.
91+
8792
The hook receives a `data` hash and a `request_handler` block. You must call `request_handler.call` to execute the request:
8893

8994
**Signature:**

‎lib/mcp/instrumentation.rb‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@ module Instrumentation
55
def instrument_call(method, server_context: {}, exception_already_reported: nil, &block)
66
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
77
begin
8-
@instrumentation_data = {}
9-
add_instrumentation_data(method: method)
8+
instrumentation_data = {}
9+
@instrumentation_data = instrumentation_data
10+
instrumentation_data.merge!(method: method)
1011
# `self.` is required: the `server_context:` keyword above shadows the
1112
# reader, and the value we want here is the user-defined hash passed to
1213
# `Server.new`, not the per-call reporter context.
1314
if configuration.instrument_server_context? && respond_to?(:server_context)
14-
add_instrumentation_data(server_context: self.server_context)
15+
instrumentation_data.merge!(server_context: self.server_context)
1516
end
1617

17-
result = configuration.around_request.call(@instrumentation_data, &block)
18+
result = configuration.around_request.call(instrumentation_data) { block.call(instrumentation_data) }
1819

1920
result
2021
rescue => e
@@ -28,22 +29,26 @@ def instrument_call(method, server_context: {}, exception_already_reported: nil,
2829
end
2930

3031
unless already_reported
31-
add_instrumentation_data(error: :internal_error) unless @instrumentation_data.key?(:error)
32+
instrumentation_data.merge!(error: :internal_error) unless instrumentation_data.key?(:error)
3233
configuration.exception_reporter.call(e, server_context)
3334
end
3435

3536
raise
3637
ensure
3738
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
38-
add_instrumentation_data(duration: end_time - start_time)
39+
instrumentation_data.merge!(duration: end_time - start_time)
3940

4041
# Backward compatibility: `instrumentation_callback` is soft-deprecated
4142
# in favor of `around_request`, but existing callers still expect it
4243
# to fire after every request until it is removed in a future version.
43-
configuration.instrumentation_callback.call(@instrumentation_data)
44+
configuration.instrumentation_callback.call(instrumentation_data)
4445
end
4546
end
4647

48+
# @deprecated Use the data hash passed to the {#instrument_call} block or
49+
# `around_request` instead. `add_instrumentation_data` uses a shared record
50+
# and is not safe for concurrent calls.
51+
# @see #instrument_call
4752
def add_instrumentation_data(**kwargs)
4853
@instrumentation_data.merge!(kwargs)
4954
end

‎lib/mcp/server.rb‎

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -733,9 +733,9 @@ def handle_request(request, method, session: nil, related_request_id: nil, auth_
733733

734734
handler = @handlers[method]
735735
unless handler
736-
instrument_call("unsupported_method", server_context: { request: request }) do
736+
instrument_call("unsupported_method", server_context: { request: request }) do |instrumentation_data|
737737
client = session&.client || @client
738-
add_instrumentation_data(client: client) if client
738+
instrumentation_data.merge!(client: client) if client
739739
end
740740
return
741741
end
@@ -798,7 +798,7 @@ def handle_request(request, method, session: nil, related_request_id: nil, auth_
798798
method,
799799
server_context: { request: request },
800800
exception_already_reported: ->(e) { reported_exception.equal?(e) },
801-
) do
801+
) do |instrumentation_data|
802802
refuse_non_object_meta!(params)
803803
envelope = lift_request_envelope(params, method: method, session: session)
804804

@@ -817,7 +817,7 @@ def handle_request(request, method, session: nil, related_request_id: nil, auth_
817817
when Methods::INITIALIZE
818818
init(params, session: session)
819819
when Methods::RESOURCES_READ
820-
contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
820+
contents = read_resource_contents(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
821821

822822
# An SEP-2322 `input_required` result must not be wrapped as `contents` or stamped with SEP-2549 cache hints.
823823
contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
@@ -827,9 +827,9 @@ def handle_request(request, method, session: nil, related_request_id: nil, auth_
827827

828828
subscription_result(handler_result)
829829
when Methods::TOOLS_CALL
830-
call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
830+
call_tool(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
831831
when Methods::PROMPTS_GET
832-
get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
832+
get_prompt(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
833833
when Methods::COMPLETION_COMPLETE
834834
complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
835835
when Methods::LOGGING_SET_LEVEL
@@ -838,10 +838,10 @@ def handle_request(request, method, session: nil, related_request_id: nil, auth_
838838
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
839839
end
840840
client = session&.client || @client
841-
add_instrumentation_data(client: client) if client
841+
instrumentation_data.merge!(client: client) if client
842842

843843
if cancellation&.cancelled?
844-
add_instrumentation_data(cancelled: true, cancellation_reason: cancellation.reason)
844+
instrumentation_data.merge!(cancelled: true, cancellation_reason: cancellation.reason)
845845
next JsonRpcHandler::NO_RESPONSE
846846
end
847847

@@ -851,14 +851,15 @@ def handle_request(request, method, session: nil, related_request_id: nil, auth_
851851
result = if envelope.nil? && @input_required_legacy_shim && session
852852
run_legacy_input_required_shim(
853853
result,
854+
instrumentation_data: instrumentation_data,
854855
method: method,
855856
params: params,
856857
session: session,
857858
related_request_id: related_request_id,
858859
cancellation: cancellation,
859860
)
860861
else
861-
serialize_input_required_result(result, envelope: envelope, request: params, method: method)
862+
serialize_input_required_result(result, instrumentation_data: instrumentation_data, envelope: envelope, request: params, method: method)
862863
end
863864
end
864865

@@ -884,25 +885,25 @@ def handle_request(request, method, session: nil, related_request_id: nil, auth_
884885

885886
result
886887
rescue CancelledError => e
887-
add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
888+
instrumentation_data.merge!(cancelled: true, cancellation_reason: e.reason)
888889
next JsonRpcHandler::NO_RESPONSE
889890
rescue OAuth::InsufficientScopeError => e
890891
# A handler's `require_scopes!` failure is a client authorization error, not a server fault:
891892
# surface it as an invalid-request JSON-RPC error whose message names the missing scopes.
892893
# HTTP-level 403 challenges remain the transport `required_scopes` gate's job.
893894
report_exception(e, { request: request })
894-
add_instrumentation_data(error: :insufficient_scope)
895+
instrumentation_data.merge!(error: :insufficient_scope)
895896
converted = RequestHandlerError.new(e.message, request, error_type: :invalid_request, original_error: e)
896897
reported_exception = converted
897898
raise converted
898899
rescue RequestHandlerError => e
899900
report_exception(e.original_error || e, { request: request })
900-
add_instrumentation_data(error: e.error_type)
901+
instrumentation_data.merge!(error: e.error_type)
901902
reported_exception = e
902903
raise e
903904
rescue => e
904905
report_exception(e, { request: request })
905-
add_instrumentation_data(error: :internal_error)
906+
instrumentation_data.merge!(error: :internal_error)
906907
wrapped = RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e)
907908
reported_exception = wrapped
908909
raise wrapped
@@ -998,7 +999,7 @@ def lift_request_envelope(params, method:, session:)
998999
# a final result. The capability gate enforces the SEP-2575 rule that servers MUST NOT rely on
9991000
# (or embed requests for) capabilities the client did not declare, and reports every missing capability at
10001001
# once so the client sees the full set.
1001-
def serialize_input_required_result(result, envelope:, request:, method:)
1002+
def serialize_input_required_result(result, instrumentation_data:, envelope:, request:, method:)
10021003
if envelope.nil?
10031004
raise RequestHandlerError.new(
10041005
"input_required results require the 2026-07-28 stateless lifecycle (SEP-2322)",
@@ -1010,7 +1011,7 @@ def serialize_input_required_result(result, envelope:, request:, method:)
10101011
missing = result.missing_client_capabilities(envelope.client_capabilities)
10111012
raise MissingRequiredClientCapabilityError.new(missing, request) unless missing.empty?
10121013

1013-
add_instrumentation_data(input_required: true)
1014+
instrumentation_data.merge!(input_required: true)
10141015
serialized = result.to_h
10151016

10161017
if @request_state_security && serialized[:requestState]
@@ -1039,7 +1040,7 @@ def serialize_input_required_result(result, envelope:, request:, method:)
10391040
# re-runs with `inputResponses`/`requestState` merged into the original params - the same deterministic replay
10401041
# contract the modern client driver follows. The `requestState` round-trips in-process as the raw value
10411042
# the handler wrote; `RequestStateSecurity` sealing is wire hardening and does not apply.
1042-
def run_legacy_input_required_shim(result, method:, params:, session:, related_request_id:, cancellation:)
1043+
def run_legacy_input_required_shim(result, instrumentation_data:, method:, params:, session:, related_request_id:, cancellation:)
10431044
rounds = 0
10441045

10451046
loop do
@@ -1071,6 +1072,7 @@ def run_legacy_input_required_shim(result, method:, params:, session:, related_r
10711072
result = redispatch_mrtr_method(
10721073
method,
10731074
retry_params,
1075+
instrumentation_data: instrumentation_data,
10741076
session: session,
10751077
related_request_id: related_request_id,
10761078
cancellation: cancellation,
@@ -1104,14 +1106,14 @@ def legacy_leg_params(entry)
11041106

11051107
# Re-runs the handler of one of the three MRTR-capable methods for
11061108
# the legacy shim. Legacy wire, so no envelope is threaded.
1107-
def redispatch_mrtr_method(method, params, session:, related_request_id:, cancellation:)
1109+
def redispatch_mrtr_method(method, params, instrumentation_data:, session:, related_request_id:, cancellation:)
11081110
case method
11091111
when Methods::TOOLS_CALL
1110-
call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
1112+
call_tool(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation)
11111113
when Methods::PROMPTS_GET
1112-
get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
1114+
get_prompt(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation)
11131115
when Methods::RESOURCES_READ
1114-
contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
1116+
contents = read_resource_contents(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation)
11151117
contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
11161118
else
11171119
raise RequestHandlerError.new(
@@ -1394,21 +1396,21 @@ def list_tools(request)
13941396
apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
13951397
end
13961398

1397-
def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1399+
def call_tool(request, instrumentation_data:, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
13981400
tool_name = request[:name]
13991401

14001402
tool = tools[tool_name]
14011403
unless tool
1402-
add_instrumentation_data(tool_name: tool_name, error: :tool_not_found)
1404+
instrumentation_data.merge!(tool_name: tool_name, error: :tool_not_found)
14031405

14041406
raise RequestHandlerError.new("Tool not found: #{tool_name}", request, error_type: :invalid_params)
14051407
end
14061408

14071409
arguments = request[:arguments] || {}
1408-
add_instrumentation_data(tool_name: tool_name, tool_arguments: arguments)
1410+
instrumentation_data.merge!(tool_name: tool_name, tool_arguments: arguments)
14091411

14101412
if tool.input_schema&.missing_required_arguments?(arguments)
1411-
add_instrumentation_data(error: :missing_required_arguments)
1413+
instrumentation_data.merge!(error: :missing_required_arguments)
14121414

14131415
missing = tool.input_schema.missing_required_arguments(arguments).join(", ")
14141416
return error_tool_response("Missing required arguments: #{missing}")
@@ -1418,7 +1420,7 @@ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil,
14181420
begin
14191421
tool.input_schema.validate_arguments(arguments)
14201422
rescue Tool::InputSchema::ValidationError => e
1421-
add_instrumentation_data(error: :invalid_schema)
1423+
instrumentation_data.merge!(error: :invalid_schema)
14221424

14231425
return error_tool_response(e.message)
14241426
end
@@ -1473,11 +1475,11 @@ def list_prompts(request)
14731475
apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
14741476
end
14751477

1476-
def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1478+
def get_prompt(request, instrumentation_data:, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
14771479
prompt_name = request[:name]
14781480
prompt = @prompts[prompt_name]
14791481
unless prompt
1480-
add_instrumentation_data(error: :prompt_not_found)
1482+
instrumentation_data.merge!(error: :prompt_not_found)
14811483
# The explicit `error_code` maps an unknown prompt to Invalid Params (-32602) rather than
14821484
# the default Internal Error (-32603), matching the `tools/call`, `resources/read`, and
14831485
# `completion/complete` siblings for the same not-found condition, while `error_type:
@@ -1490,7 +1492,7 @@ def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil
14901492
)
14911493
end
14921494

1493-
add_instrumentation_data(prompt_name: prompt_name)
1495+
instrumentation_data.merge!(prompt_name: prompt_name)
14941496

14951497
prompt_args = request[:arguments]
14961498
prompt.validate_arguments!(prompt_args)
@@ -1766,9 +1768,9 @@ def skills_extension_keys(extensions)
17661768
# Default `resources/read` handler: routes to class-based resources and resource templates.
17671769
# Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered,
17681770
# unknown URIs keep the historical no-op `[]` response instead of raising.
1769-
def read_resource(request, server_context: nil)
1771+
def read_resource(request, instrumentation_data:, server_context: nil)
17701772
uri = request[:uri]
1771-
add_instrumentation_data(resource_uri: uri)
1773+
instrumentation_data.merge!(resource_uri: uri)
17721774

17731775
# Files of directory-backed skills are looked up by exact string equality in a map built at construction,
17741776
# so request data never reaches the filesystem; a miss falls through to the routing below.
@@ -1798,7 +1800,7 @@ def read_resource(request, server_context: nil)
17981800
params = template.match_uri(uri)
17991801
next unless params
18001802

1801-
add_instrumentation_data(resource_template: template.uri_template)
1803+
instrumentation_data.merge!(resource_template: template.uri_template)
18021804
return normalize_resource_contents(call_template_contents(template, params, server_context))
18031805
end
18041806

@@ -1884,9 +1886,22 @@ def complete(params, session: nil, related_request_id: nil, cancellation: nil, e
18841886
# Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
18851887
# pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
18861888
# `server_context.raise_if_cancelled!`.
1887-
def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1889+
def read_resource_contents(request, instrumentation_data:, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1890+
handler = @handlers[Methods::RESOURCES_READ]
1891+
if handler == method(:read_resource)
1892+
server_context = build_server_context(
1893+
request: request,
1894+
session: session,
1895+
related_request_id: related_request_id,
1896+
cancellation: cancellation,
1897+
envelope: envelope,
1898+
auth_info: auth_info,
1899+
)
1900+
return read_resource(request, instrumentation_data: instrumentation_data, server_context: server_context)
1901+
end
1902+
18881903
dispatch_optional_context_handler(
1889-
@handlers[Methods::RESOURCES_READ],
1904+
handler,
18901905
request,
18911906
session: session,
18921907
related_request_id: related_request_id,

0 commit comments

Comments
 (0)