Skip to content

Commit 0645351

Browse files
committed
Internal Release 0.3.0
Add configurable exception reporter Avoid leaking tool exceptions by sending them to a configurable exception reporter Add optional context to pass to tools Add basic instrumentation data/labels
1 parent f5a3567 commit 0645351

File tree

13 files changed

+359
-39
lines changed

13 files changed

+359
-39
lines changed

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
model_context_protocol (0.2.5)
4+
model_context_protocol (0.3.0)
55
json_rpc_handler (~> 0.1)
66

77
GEM

README.md

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,50 @@ module ModelContextProtocol
4747
server = ModelContextProtocol::Server.new(
4848
name: "my_server",
4949
tools: [someTool, anotherTool],
50-
prompts: [myPrompt]
50+
prompts: [myPrompt],
51+
context: nil,
5152
)
5253
render(json: server.handle(request.body.read).to_h)
5354
end
5455
end
5556
end
5657
```
5758

59+
## Configuration
60+
61+
The gem can be configured using the `ModelContextProtocol.configure` block:
62+
63+
```ruby
64+
ModelContextProtocol.configure do |config|
65+
config.exception_reporter = ->(exception, context) do
66+
# Your exception reporting logic here
67+
# For example with Bugsnag:
68+
Bugsnag.notify(exception) do |report|
69+
report.add_metadata(:model_context_protocol, context)
70+
end
71+
end
72+
73+
config.instrumentation_callback = -> (data) { puts "Got instrumentation data #{data.inspect}" }
74+
end
75+
```
76+
77+
### Exception Reporting
78+
79+
The exception reporter receives two arguments:
80+
- `exception`: The Ruby exception object that was raised
81+
- `context`: A hash containing contextual information about where the error occurred
82+
83+
The context hash includes:
84+
- For tool calls: `{ tool_name: "name", arguments: { ... } }`
85+
- For general request handling: `{ request: { ... } }`
86+
87+
When an exception occurs:
88+
1. The exception is reported via the configured reporter
89+
2. For tool calls, a generic error response is returned to the client: `{ error: "Internal error occurred", is_error: true }`
90+
3. For other requests, the exception is re-raised after reporting
91+
92+
If no exception reporter is configured, a default no-op reporter is used that silently ignores exceptions.
93+
5894
## Tools
5995

6096
MCP spec includes [Tools](https://modelcontextprotocol.io/docs/concepts/tools) which provide functionality to LLM apps.
@@ -65,11 +101,10 @@ This gem provides a `ModelContextProtocol::Tool` class that can be used to creat
65101

66102
```ruby
67103
class MyTool < ModelContextProtocol::Tool
68-
tool_name "my_tool"
69-
tool_description "This tool performs specific functionality..."
70-
tool_input_schema [{ type: "text", name: "message" }]
104+
description "This tool performs specific functionality..."
105+
input_schema [{ type: "text", name: "message" }]
71106

72-
def call(message)
107+
def call(message, context:)
73108
Tool::Response.new([{ type: "text", content: "OK" }])
74109
end
75110
end
@@ -80,11 +115,14 @@ tool = MyTool.new
80115
2. By using the `ModelContextProtocol::Tool.define` method with a block:
81116

82117
```ruby
83-
tool = ModelContextProtocol::Tool.define(name: "my_tool", description: "This tool performs specific functionality...") do |args|
118+
tool = ModelContextProtocol::Tool.define(name: "my_tool", description: "This tool performs specific functionality...") do |args, context|
84119
Tool::Response.new([{ type: "text", content: "OK" }])
85120
end
86121
```
87122

123+
The context parameter is the context passed into the server and can be used to pass per request information,
124+
e.g. around authentication state.
125+
88126
## Prompts
89127

90128
MCP spec includes [Prompts](https://modelcontextprotocol.io/docs/concepts/prompts), which enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs.
@@ -167,7 +205,8 @@ Register prompts with the MCP server:
167205
```ruby
168206
server = ModelContextProtocol::Server.new(
169207
name: "my_server",
170-
prompts: [MyPrompt.new]
208+
prompts: [MyPrompt.new],
209+
context: nil,
171210
)
172211
```
173212

@@ -176,6 +215,28 @@ The server will handle prompt listing and execution through the MCP protocol met
176215
- `prompts/list` - Lists all registered prompts and their schemas
177216
- `prompts/get` - Retrieves and executes a specific prompt with arguments
178217

218+
### Instrumentation
219+
220+
The server allows registering a callback to receive information about instrumentation.
221+
To register a handler pass a proc/lambda to as `instrumentation_callback` into the server constructor.
222+
223+
```ruby
224+
ModelContextProtocol.configure do |config|
225+
config.instrumentation_callback = -> (data) { puts "Got instrumentation data #{data.inspect}" }
226+
end
227+
```
228+
229+
The data contains the following keys:
230+
`method`: the metod called, e.g. `ping`, `tools/list`, `tools/call` etc
231+
`tool_name`: the name of the tool called
232+
`prompt_name`: the name of the prompt called
233+
`resource_uri`: the uri of the resource called
234+
`error`: if looking up tools/prompts etc failed, e.g. `tool_not_found`
235+
`duration`: the duration of the call in seconds
236+
237+
`tool_name`, `prompt_name` and `resource_uri` are only populated if a matching handler is registered.
238+
This is to avoid potential issues with metric cardinality
239+
179240
## Releases
180241

181242
TODO

lib/model_context_protocol.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,19 @@
88
require "model_context_protocol/resource"
99
require "model_context_protocol/prompt"
1010
require "model_context_protocol/version"
11+
require "model_context_protocol/configuration"
1112

1213
module ModelContextProtocol
14+
class << self
15+
def configure
16+
yield(configuration)
17+
end
18+
19+
def configuration
20+
@configuration ||= Configuration.new
21+
end
22+
end
23+
1324
class Annotations
1425
attr_reader :audience, :priority
1526

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
module ModelContextProtocol
4+
class Configuration
5+
attr_accessor :exception_reporter
6+
attr_accessor :instrumentation_callback
7+
8+
def initialize
9+
@exception_reporter = ->(exception, context) {} # Default no-op reporter
10+
@instrumentation_callback = ->(data) {} # Default no-op callback
11+
end
12+
end
13+
end
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
module ModelContextProtocol
4+
module Instrumentation
5+
def instrument_call(method, &block)
6+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
7+
@instrumentation_data = {}
8+
add_instrumentation_data(method:)
9+
10+
result = yield block
11+
12+
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
13+
add_instrumentation_data(duration: end_time - start_time)
14+
15+
ModelContextProtocol.configuration.instrumentation_callback.call(@instrumentation_data)
16+
17+
result
18+
end
19+
20+
def add_instrumentation_data(**kwargs)
21+
@instrumentation_data.merge!(kwargs)
22+
end
23+
end
24+
end

lib/model_context_protocol/server.rb

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
# frozen_string_literal: true
22

33
require "json_rpc_handler"
4+
require_relative "instrumentation"
45

56
module ModelContextProtocol
67
class Server
8+
class RequestHandlerError < StandardError
9+
def initialize(message, request)
10+
super(message)
11+
@request = request
12+
end
13+
end
14+
715
PROTOCOL_VERSION = "2024-11-05"
816

9-
attr_accessor :name, :tools, :prompts, :resources
17+
include Instrumentation
18+
19+
attr_accessor :name, :tools, :prompts, :resources, :context
1020

11-
def initialize(name: "model_context_protocol", tools: [], prompts: [], resources: [])
21+
def initialize(name: "model_context_protocol", tools: [], prompts: [], resources: [], context: nil)
1222
@name = name
1323
@tools = tools.to_h { |t| [t.name, t] }
1424
@prompts = prompts.to_h { |p| [p.name, p] }
1525
@resources = resources
1626
@resource_index = resources.index_by(&:uri)
17-
27+
@context = context
1828
@handlers = {
1929
"resources/list" => method(:list_resources),
2030
"resources/read" => method(:read_resource),
@@ -29,18 +39,21 @@ def initialize(name: "model_context_protocol", tools: [], prompts: [], resources
2939

3040
def handle(request)
3141
JsonRpcHandler.handle(request) do |method|
32-
handler = case method
33-
when "tools/list"
34-
->(params) { { tools: @handlers["tools/list"].call(params) } }
35-
when "prompts/list"
36-
->(params) { { prompts: @handlers["prompts/list"].call(params) } }
37-
when "resources/list"
38-
->(params) { { resources: @handlers["resources/list"].call(params) } }
39-
else
40-
@handlers[method]
42+
instrument_call(method) do
43+
case method
44+
when "tools/list"
45+
->(params) { { tools: @handlers["tools/list"].call(params) } }
46+
when "prompts/list"
47+
->(params) { { prompts: @handlers["prompts/list"].call(params) } }
48+
when "resources/list"
49+
->(params) { { resources: @handlers["resources/list"].call(params) } }
50+
else
51+
@handlers[method]
52+
end
53+
rescue => e
54+
report_exception(e, { request: request })
55+
raise RequestHandlerError.new("Internal error handling #{request[:method]} request", request)
4156
end
42-
43-
handler
4457
end
4558
end
4659

@@ -86,6 +99,7 @@ def server_info
8699
end
87100

88101
def init(request)
102+
add_instrumentation_data(method: "initialize")
89103
{
90104
protocolVersion: PROTOCOL_VERSION,
91105
capabilities: capabilities,
@@ -94,47 +108,76 @@ def init(request)
94108
end
95109

96110
def list_tools(request)
111+
add_instrumentation_data(method: "tools/list")
97112
@tools.map { |_, tool| tool.to_h }
98113
end
99114

100115
def call_tool(request)
116+
add_instrumentation_data(method: "tools/call")
101117
tool_name = request[:name]
102118
tool = tools[tool_name]
103-
raise "Tool not found #{tool_name}" unless tool
119+
unless tool
120+
add_instrumentation_data(error: :tool_not_found)
121+
raise "Tool not found #{tool_name}"
122+
end
123+
124+
add_instrumentation_data(tool_name:)
104125

105-
result = tool.call(**request[:arguments])
106-
result.to_h
126+
begin
127+
result = tool.call(**request[:arguments], context:)
128+
result.to_h
129+
rescue => e
130+
report_exception(e, { tool_name: tool_name, arguments: request[:arguments] })
131+
add_instrumentation_data(error: :internal_error)
132+
raise RequestHandlerError.new("Internal error calling tool #{tool_name}", request)
133+
end
107134
end
108135

109136
def list_prompts(request)
137+
add_instrumentation_data(method: "prompts/list")
110138
@prompts.map { |_, prompt| prompt.to_h }
111139
end
112140

113141
def get_prompt(request)
142+
add_instrumentation_data(method: "prompts/get")
114143
prompt_name = request[:name]
115144
prompt = @prompts[prompt_name]
145+
unless prompt
146+
add_instrumentation_data(error: :prompt_not_found)
147+
raise "Prompt not found #{prompt_name}"
148+
end
116149

117-
raise "Prompt not found #{prompt_name}" unless prompt
150+
add_instrumentation_data(prompt_name:)
118151

119152
prompt_args = request[:arguments]
120153
prompt.validate_arguments!(prompt_args)
121154

122-
result = prompt.template(prompt_args)
123-
124-
result.to_h
155+
prompt.template(prompt_args).to_h
125156
end
126157

127158
def list_resources(request)
159+
add_instrumentation_data(method: "resources/list")
160+
128161
@resources.map(&:to_h)
129162
end
130163

131164
def read_resource(request)
165+
add_instrumentation_data(method: "resources/read")
132166
resource_uri = request[:uri]
133167

134168
resource = @resource_index[resource_uri]
135-
raise "Resource not found #{resource_uri}" unless resource
169+
unless resource
170+
add_instrumentation_data(error: :resource_not_found)
171+
raise "Resource not found #{resource_uri}"
172+
end
173+
174+
add_instrumentation_data(resource_uri:)
136175

137176
resource.to_h
138177
end
178+
179+
def report_exception(exception, context = {})
180+
ModelContextProtocol.configuration.exception_reporter.call(exception, context)
181+
end
139182
end
140183
end

lib/model_context_protocol/tool.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ def input_schema(value)
4444

4545
def define(name: nil, description: nil, input_schema: nil, &block)
4646
new(name:, description:, input_schema:).tap do |tool|
47-
tool.define_singleton_method(:call) do |*args|
48-
instance_exec(*args, &block)
47+
tool.define_singleton_method(:call) do |*args, context:|
48+
instance_exec(*args, context:, &block)
4949
end
5050
end
5151
end
@@ -59,7 +59,7 @@ def initialize(name: nil, description: nil, input_schema: nil)
5959
@input_schema = input_schema || self.class.input_schema_value
6060
end
6161

62-
def call(*args)
62+
def call(*args, context:)
6363
raise NotImplementedError, "Subclasses must implement call"
6464
end
6565

lib/model_context_protocol/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
module ModelContextProtocol
4-
VERSION = "0.2.5"
4+
VERSION = "0.3.0"
55
end

0 commit comments

Comments
 (0)