Skip to content

Commit 14e02da

Browse files
committed
Expose the user-defined server_context in instrumentation data
APM integrations need to tag a span with the subject of the request, but the user-defined `server_context` hash passed to `Server.new` never reaches `around_request` or `instrumentation_callback`. The exception reporter's `server_context` argument is a different value — it describes where a failure occurred (`{ request: ... }`, `{ notification: ... }`), not who made the call — so there is currently no way to read the request subject from a hook. Add `Configuration#instrument_server_context`, off by default. When enabled, `instrument_call` puts the host's `server_context` into the instrumentation data before invoking `around_request`, so it is readable both before and after `request_handler.call`. The flag is opt-in rather than unconditional because the hash is application-supplied and may hold values a tracing backend should not receive. Hosts that do not expose a `server_context` reader are unaffected. `instrument_call` reads it through `self.server_context`: the existing `server_context:` keyword shadows the reader, and the value wanted here is the user-defined hash, not the reporter context. Follows the same shape as the earlier additions of `tool_arguments` (#218) and `client` (#221) to this hash.
1 parent 9a62b9a commit 14e02da

5 files changed

Lines changed: 158 additions & 3 deletions

File tree

README.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,8 @@ server = MCP::Server.new(
346346
```
347347

348348
This hash is then passed as the `server_context` keyword argument to tool and prompt calls.
349-
Note that exception and instrumentation callbacks do not receive this user-defined hash.
349+
Note that the exception reporter does not receive this user-defined hash, and instrumentation
350+
callbacks omit it unless you opt in with `instrument_server_context`.
350351
See the relevant sections below for the arguments they receive.
351352

352353
#### Request-specific `_meta` Parameter
@@ -460,10 +461,31 @@ around_request = ->(data, &request_handler) { request_handler.call }
460461

461462
**`data` availability by timing:**
462463

463-
- Before `request_handler.call`: `method`
464+
- Before `request_handler.call`: `method`, and `server_context` when `instrument_server_context` is enabled
464465
- After `request_handler.call`: `tool_name`, `tool_arguments`, `prompt_name`, `resource_uri`, `error`, `client`
465466
- Not available inside `around_request`: `duration` (added after `around_request` returns)
466467

468+
**Exposing the user-defined `server_context` (opt in):**
469+
470+
`data` omits the user-defined `server_context` by default, because that hash is
471+
application-supplied and may hold values a tracing backend should not receive.
472+
Enable it when you need to tag spans with the request's subject:
473+
474+
```ruby
475+
MCP.configure do |config|
476+
config.instrument_server_context = true
477+
478+
config.around_request = ->(data, &request_handler) {
479+
Sentry.set_user(id: data.dig(:server_context, :user_id))
480+
request_handler.call
481+
}
482+
end
483+
```
484+
485+
`data[:server_context]` is the hash passed to `Server.new``nil` when the host
486+
set none. It is not the exception reporter's context argument, which describes
487+
where a failure occurred rather than who made the request.
488+
467489
> [!NOTE]
468490
> `tool_name`, `prompt_name` and `resource_uri` may only be populated for the corresponding request methods
469491
> (`tools/call`, `prompts/get`, `resources/read`), and may not be set depending on how the request is handled
@@ -515,6 +537,8 @@ It receives a hash with the following possible keys:
515537
- `error`: (String, optional) Error code if a lookup failed
516538
- `duration`: (Float) Duration of the call in seconds
517539
- `client`: (Hash, optional) Client information with `name` and `version` keys, from the initialize request
540+
- `server_context`: (Any, optional) The user-defined hash passed to `Server.new`, present only when
541+
`instrument_server_context` is enabled
518542
519543
**Signature:**
520544

lib/mcp/configuration.rb

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def modern_protocol_version?(version)
3333
attr_writer :instrumentation_callback
3434

3535
def initialize(exception_reporter: nil, around_request: nil, instrumentation_callback: nil, protocol_version: nil,
36-
validate_tool_call_arguments: true, validate_tool_call_results: false)
36+
validate_tool_call_arguments: true, validate_tool_call_results: false, instrument_server_context: false)
3737
@exception_reporter = exception_reporter
3838
@around_request = around_request
3939
@instrumentation_callback = instrumentation_callback
@@ -43,9 +43,11 @@ def initialize(exception_reporter: nil, around_request: nil, instrumentation_cal
4343
end
4444
validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments)
4545
validate_value_of_validate_tool_call_results!(validate_tool_call_results)
46+
validate_value_of_instrument_server_context!(instrument_server_context)
4647

4748
@validate_tool_call_arguments = validate_tool_call_arguments
4849
@validate_tool_call_results = validate_tool_call_results
50+
@instrument_server_context = instrument_server_context
4951
end
5052

5153
def protocol_version=(protocol_version)
@@ -60,6 +62,16 @@ def validate_tool_call_arguments=(validate_tool_call_arguments)
6062
@validate_tool_call_arguments = validate_tool_call_arguments
6163
end
6264

65+
# Opt in to exposing the user-defined `server_context` in the
66+
# `around_request` / `instrumentation_callback` data hash. Off by default:
67+
# the hash is application-supplied and may hold values a tracing backend
68+
# should not receive, so surfacing it has to be a deliberate choice.
69+
def instrument_server_context=(instrument_server_context)
70+
validate_value_of_instrument_server_context!(instrument_server_context)
71+
72+
@instrument_server_context = instrument_server_context
73+
end
74+
6375
def validate_tool_call_results=(validate_tool_call_results)
6476
validate_value_of_validate_tool_call_results!(validate_tool_call_results)
6577

@@ -107,6 +119,10 @@ def instrumentation_callback?
107119
attr_reader :validate_tool_call_arguments
108120
attr_reader :validate_tool_call_results
109121

122+
def instrument_server_context?
123+
!!@instrument_server_context
124+
end
125+
110126
def validate_tool_call_arguments?
111127
!!@validate_tool_call_arguments
112128
end
@@ -152,6 +168,7 @@ def merge(other)
152168
protocol_version: protocol_version,
153169
validate_tool_call_arguments: validate_tool_call_arguments,
154170
validate_tool_call_results: validate_tool_call_results,
171+
instrument_server_context: other.instrument_server_context?,
155172
)
156173
end
157174

@@ -176,6 +193,12 @@ def validate_value_of_validate_tool_call_results!(validate_tool_call_results)
176193
end
177194
end
178195

196+
def validate_value_of_instrument_server_context!(instrument_server_context)
197+
unless instrument_server_context.is_a?(TrueClass) || instrument_server_context.is_a?(FalseClass)
198+
raise ArgumentError, "instrument_server_context must be a boolean"
199+
end
200+
end
201+
179202
def default_exception_reporter
180203
@default_exception_reporter ||= ->(exception, server_context) {}
181204
end

lib/mcp/instrumentation.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ def instrument_call(method, server_context: {}, exception_already_reported: nil,
77
begin
88
@instrumentation_data = {}
99
add_instrumentation_data(method: method)
10+
# `self.` is required: the `server_context:` keyword above shadows the
11+
# reader, and the value we want here is the user-defined hash passed to
12+
# `Server.new`, not the per-call reporter context.
13+
if configuration.instrument_server_context? && respond_to?(:server_context)
14+
add_instrumentation_data(server_context: self.server_context)
15+
end
1016

1117
result = configuration.around_request.call(@instrumentation_data, &block)
1218

test/mcp/configuration_test.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,35 @@ class ConfigurationTest < ActiveSupport::TestCase
145145
refute merged.validate_tool_call_arguments
146146
end
147147

148+
test "defaults instrument_server_context to false" do
149+
config = Configuration.new
150+
151+
refute_predicate config, :instrument_server_context?
152+
end
153+
154+
test "accepts instrument_server_context in the constructor" do
155+
config = Configuration.new(instrument_server_context: true)
156+
157+
assert_predicate config, :instrument_server_context?
158+
end
159+
160+
test "raises ArgumentError when instrument_server_context is not a boolean value" do
161+
config = Configuration.new
162+
163+
exception = assert_raises(ArgumentError) do
164+
config.instrument_server_context = "true"
165+
end
166+
assert_equal("instrument_server_context must be a boolean", exception.message)
167+
end
168+
169+
test "merge carries instrument_server_context from the other configuration" do
170+
base = Configuration.new(instrument_server_context: true)
171+
other = Configuration.new(instrument_server_context: false)
172+
173+
refute_predicate base.merge(other), :instrument_server_context?
174+
assert_predicate other.merge(base), :instrument_server_context?
175+
end
176+
148177
test "defaults validate_tool_call_results to false" do
149178
config = Configuration.new
150179
refute config.validate_tool_call_results

test/mcp/instrumentation_test.rb

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,79 @@ def instrumented_method_that_raises_with_error_set
4040
end
4141
end
4242

43+
# Mirrors `MCP::Server`, which exposes the user-defined hash through
44+
# `attr_accessor :server_context`.
45+
class SubjectWithServerContext < Subject
46+
attr_accessor :server_context
47+
48+
def initialize(server_context)
49+
super()
50+
@server_context = server_context
51+
end
52+
end
53+
54+
test "#instrument_call omits the user-defined server_context by default" do
55+
subject = SubjectWithServerContext.new({ user_id: 42 })
56+
57+
subject.instrumented_method
58+
59+
refute_includes subject.instrumentation_data_received.keys, :server_context
60+
end
61+
62+
test "#instrument_call exposes the user-defined server_context when instrument_server_context is enabled" do
63+
subject = SubjectWithServerContext.new({ user_id: 42 })
64+
subject.configuration.instrument_server_context = true
65+
66+
subject.instrumented_method
67+
68+
assert_equal({ user_id: 42 }, subject.instrumentation_data_received[:server_context])
69+
end
70+
71+
test "#instrument_call exposes the user-defined server_context to around_request" do
72+
subject = SubjectWithServerContext.new({ user_id: 42 })
73+
subject.configuration.instrument_server_context = true
74+
seen = nil
75+
subject.configuration.around_request = ->(data, &handler) {
76+
seen = data[:server_context]
77+
handler.call
78+
}
79+
80+
subject.instrumented_method
81+
82+
assert_equal({ user_id: 42 }, seen)
83+
end
84+
85+
test "#instrument_call reports the user-defined server_context as nil when the host does not set one" do
86+
subject = SubjectWithServerContext.new(nil)
87+
subject.configuration.instrument_server_context = true
88+
89+
subject.instrumented_method
90+
91+
assert_includes subject.instrumentation_data_received.keys, :server_context
92+
assert_nil subject.instrumentation_data_received[:server_context]
93+
end
94+
95+
test "#instrument_call does not confuse the reporter context with the user-defined server_context" do
96+
# `instrument_call(server_context:)` is the exception-reporter context, a
97+
# different value from the user-defined hash. Enabling the flag must
98+
# surface the latter, not the former.
99+
subject = SubjectWithServerContext.new({ user_id: 42 })
100+
subject.configuration.instrument_server_context = true
101+
102+
subject.instrumented_method_with_server_context({ request: "reporter-context" })
103+
104+
assert_equal({ user_id: 42 }, subject.instrumentation_data_received[:server_context])
105+
end
106+
107+
test "#instrument_call skips server_context for hosts without the reader even when enabled" do
108+
subject = Subject.new
109+
subject.configuration.instrument_server_context = true
110+
111+
subject.instrumented_method
112+
113+
refute_includes subject.instrumentation_data_received.keys, :server_context
114+
end
115+
43116
test "#instrument_call adds the method name to the instrumentation data" do
44117
subject = Subject.new
45118

0 commit comments

Comments
 (0)