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.
This commit is contained in:
parent
9a62b9ae4f
commit
14e02da475
5 changed files with 158 additions and 3 deletions
28
README.md
28
README.md
|
|
@ -346,7 +346,8 @@ server = MCP::Server.new(
|
|||
```
|
||||
|
||||
This hash is then passed as the `server_context` keyword argument to tool and prompt calls.
|
||||
Note that exception and instrumentation callbacks do not receive this user-defined hash.
|
||||
Note that the exception reporter does not receive this user-defined hash, and instrumentation
|
||||
callbacks omit it unless you opt in with `instrument_server_context`.
|
||||
See the relevant sections below for the arguments they receive.
|
||||
|
||||
#### Request-specific `_meta` Parameter
|
||||
|
|
@ -460,10 +461,31 @@ around_request = ->(data, &request_handler) { request_handler.call }
|
|||
|
||||
**`data` availability by timing:**
|
||||
|
||||
- Before `request_handler.call`: `method`
|
||||
- Before `request_handler.call`: `method`, and `server_context` when `instrument_server_context` is enabled
|
||||
- After `request_handler.call`: `tool_name`, `tool_arguments`, `prompt_name`, `resource_uri`, `error`, `client`
|
||||
- Not available inside `around_request`: `duration` (added after `around_request` returns)
|
||||
|
||||
**Exposing the user-defined `server_context` (opt in):**
|
||||
|
||||
`data` omits the user-defined `server_context` by default, because that hash is
|
||||
application-supplied and may hold values a tracing backend should not receive.
|
||||
Enable it when you need to tag spans with the request's subject:
|
||||
|
||||
```ruby
|
||||
MCP.configure do |config|
|
||||
config.instrument_server_context = true
|
||||
|
||||
config.around_request = ->(data, &request_handler) {
|
||||
Sentry.set_user(id: data.dig(:server_context, :user_id))
|
||||
request_handler.call
|
||||
}
|
||||
end
|
||||
```
|
||||
|
||||
`data[:server_context]` is the hash passed to `Server.new` — `nil` when the host
|
||||
set none. It is not the exception reporter's context argument, which describes
|
||||
where a failure occurred rather than who made the request.
|
||||
|
||||
> [!NOTE]
|
||||
> `tool_name`, `prompt_name` and `resource_uri` may only be populated for the corresponding request methods
|
||||
> (`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:
|
|||
- `error`: (String, optional) Error code if a lookup failed
|
||||
- `duration`: (Float) Duration of the call in seconds
|
||||
- `client`: (Hash, optional) Client information with `name` and `version` keys, from the initialize request
|
||||
- `server_context`: (Any, optional) The user-defined hash passed to `Server.new`, present only when
|
||||
`instrument_server_context` is enabled
|
||||
|
||||
**Signature:**
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ module MCP
|
|||
attr_writer :instrumentation_callback
|
||||
|
||||
def initialize(exception_reporter: nil, around_request: nil, instrumentation_callback: nil, protocol_version: nil,
|
||||
validate_tool_call_arguments: true, validate_tool_call_results: false)
|
||||
validate_tool_call_arguments: true, validate_tool_call_results: false, instrument_server_context: false)
|
||||
@exception_reporter = exception_reporter
|
||||
@around_request = around_request
|
||||
@instrumentation_callback = instrumentation_callback
|
||||
|
|
@ -43,9 +43,11 @@ module MCP
|
|||
end
|
||||
validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments)
|
||||
validate_value_of_validate_tool_call_results!(validate_tool_call_results)
|
||||
validate_value_of_instrument_server_context!(instrument_server_context)
|
||||
|
||||
@validate_tool_call_arguments = validate_tool_call_arguments
|
||||
@validate_tool_call_results = validate_tool_call_results
|
||||
@instrument_server_context = instrument_server_context
|
||||
end
|
||||
|
||||
def protocol_version=(protocol_version)
|
||||
|
|
@ -60,6 +62,16 @@ module MCP
|
|||
@validate_tool_call_arguments = validate_tool_call_arguments
|
||||
end
|
||||
|
||||
# Opt in to exposing the user-defined `server_context` in the
|
||||
# `around_request` / `instrumentation_callback` data hash. Off by default:
|
||||
# the hash is application-supplied and may hold values a tracing backend
|
||||
# should not receive, so surfacing it has to be a deliberate choice.
|
||||
def instrument_server_context=(instrument_server_context)
|
||||
validate_value_of_instrument_server_context!(instrument_server_context)
|
||||
|
||||
@instrument_server_context = instrument_server_context
|
||||
end
|
||||
|
||||
def validate_tool_call_results=(validate_tool_call_results)
|
||||
validate_value_of_validate_tool_call_results!(validate_tool_call_results)
|
||||
|
||||
|
|
@ -107,6 +119,10 @@ module MCP
|
|||
attr_reader :validate_tool_call_arguments
|
||||
attr_reader :validate_tool_call_results
|
||||
|
||||
def instrument_server_context?
|
||||
!!@instrument_server_context
|
||||
end
|
||||
|
||||
def validate_tool_call_arguments?
|
||||
!!@validate_tool_call_arguments
|
||||
end
|
||||
|
|
@ -152,6 +168,7 @@ module MCP
|
|||
protocol_version: protocol_version,
|
||||
validate_tool_call_arguments: validate_tool_call_arguments,
|
||||
validate_tool_call_results: validate_tool_call_results,
|
||||
instrument_server_context: other.instrument_server_context?,
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -176,6 +193,12 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
def validate_value_of_instrument_server_context!(instrument_server_context)
|
||||
unless instrument_server_context.is_a?(TrueClass) || instrument_server_context.is_a?(FalseClass)
|
||||
raise ArgumentError, "instrument_server_context must be a boolean"
|
||||
end
|
||||
end
|
||||
|
||||
def default_exception_reporter
|
||||
@default_exception_reporter ||= ->(exception, server_context) {}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ module MCP
|
|||
begin
|
||||
@instrumentation_data = {}
|
||||
add_instrumentation_data(method: method)
|
||||
# `self.` is required: the `server_context:` keyword above shadows the
|
||||
# reader, and the value we want here is the user-defined hash passed to
|
||||
# `Server.new`, not the per-call reporter context.
|
||||
if configuration.instrument_server_context? && respond_to?(:server_context)
|
||||
add_instrumentation_data(server_context: self.server_context)
|
||||
end
|
||||
|
||||
result = configuration.around_request.call(@instrumentation_data, &block)
|
||||
|
||||
|
|
|
|||
|
|
@ -145,6 +145,35 @@ module MCP
|
|||
refute merged.validate_tool_call_arguments
|
||||
end
|
||||
|
||||
test "defaults instrument_server_context to false" do
|
||||
config = Configuration.new
|
||||
|
||||
refute_predicate config, :instrument_server_context?
|
||||
end
|
||||
|
||||
test "accepts instrument_server_context in the constructor" do
|
||||
config = Configuration.new(instrument_server_context: true)
|
||||
|
||||
assert_predicate config, :instrument_server_context?
|
||||
end
|
||||
|
||||
test "raises ArgumentError when instrument_server_context is not a boolean value" do
|
||||
config = Configuration.new
|
||||
|
||||
exception = assert_raises(ArgumentError) do
|
||||
config.instrument_server_context = "true"
|
||||
end
|
||||
assert_equal("instrument_server_context must be a boolean", exception.message)
|
||||
end
|
||||
|
||||
test "merge carries instrument_server_context from the other configuration" do
|
||||
base = Configuration.new(instrument_server_context: true)
|
||||
other = Configuration.new(instrument_server_context: false)
|
||||
|
||||
refute_predicate base.merge(other), :instrument_server_context?
|
||||
assert_predicate other.merge(base), :instrument_server_context?
|
||||
end
|
||||
|
||||
test "defaults validate_tool_call_results to false" do
|
||||
config = Configuration.new
|
||||
refute config.validate_tool_call_results
|
||||
|
|
|
|||
|
|
@ -40,6 +40,79 @@ module MCP
|
|||
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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue