Skip to content

Commit 7761f93

Browse files
committed
Test example code
This adds sanity tests to ensure that example code in the `examples/` directory and `README.md` isn't outright wrong. These are not intended to serve as unit tests for the example functionality, just as smoke tests to catch things like API changes that require updating examples. Including a sanity test ensures that the example code isn't outright wrong. This is not intended to serve as unit tests for the example functionality. Some minor changes are included which facilitate this work: - Use `console` instead of `bash` codeblock language `console` highlights `$ ` prefixed lines differently from the following lines, which clearly distinguishes between commands and input/output. - Set `file_fixture_path` This allows us to use `ActiveSupport::TestCase#file_fixture`. - Add `ReadmeTestHelper` This helper provides utilities for extracting code snippets from `README.md`. Some of these tests revealed that either the examples were busted, or even bugs in the implementation. - Test README per-server configuration example This test revealed that the `define_` helper methods were failing to ensure the server supports the type of capability they were defining. - Test README protocol version examples This revealed that the `protocol_version` accessors weren't available on the `Server` at all, and the examples were incorrect. - Test README tool definition examples This revealed that the `define` example doesn't work, and the fix is unclear.
1 parent 04710ac commit 7761f93

22 files changed

+718
-18
lines changed

.rubocop.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@ inherit_gem:
44
plugins:
55
- rubocop-minitest
66
- rubocop-rake
7+
8+
Security/Eval:
9+
Exclude:
10+
- test/fixtures/files/code_snippet_wrappers/**/*.rb # We must often resort to eval to access local variable

README.md

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ gem 'mcp'
1212

1313
And then execute:
1414

15-
```bash
15+
```console
1616
$ bundle install
1717
```
1818

1919
Or install it yourself as:
2020

21-
```bash
21+
```console
2222
$ gem install mcp
2323
```
2424

@@ -63,6 +63,7 @@ requests.
6363

6464
You can use the `Server#handle_json` method to handle requests.
6565

66+
<!-- SNIPPET ID: rails_controller -->
6667
```ruby
6768
class ApplicationController < ActionController::Base
6869

@@ -83,6 +84,7 @@ end
8384

8485
If you want to build a local command-line application, you can use the stdio transport:
8586

87+
<!-- SNIPPET ID: stdio_transport -->
8688
```ruby
8789
#!/usr/bin/env ruby
8890
require "mcp"
@@ -121,7 +123,8 @@ transport.open
121123

122124
You can run this script and then type in requests to the server at the command line.
123125

124-
```bash
126+
<!-- SNIPPET ID: running_stdio_server -->
127+
```console
125128
$ ./examples/stdio_server.rb
126129
{"jsonrpc":"2.0","id":"1","method":"ping"}
127130
{"jsonrpc":"2.0","id":"2","method":"tools/list"}
@@ -131,6 +134,7 @@ $ ./examples/stdio_server.rb
131134

132135
The gem can be configured using the `MCP.configure` block:
133136

137+
<!-- SNIPPET ID: configuration -->
134138
```ruby
135139
MCP.configure do |config|
136140
config.exception_reporter = ->(exception, server_context) {
@@ -151,6 +155,7 @@ or by creating an explicit configuration and passing it into the server.
151155
This is useful for systems where an application hosts more than one MCP server but
152156
they might require different instrumentation callbacks.
153157

158+
<!-- SNIPPET ID: per_server_configuration -->
154159
```ruby
155160
configuration = MCP::Configuration.new
156161
configuration.exception_reporter = ->(exception, server_context) {
@@ -183,6 +188,8 @@ server_context: { [String, Symbol] => Any }
183188
```
184189

185190
**Example:**
191+
192+
<!-- SNIPPET ID: server_context -->
186193
```ruby
187194
server = MCP::Server.new(
188195
name: "my_server",
@@ -224,6 +231,7 @@ instrumentation_callback = ->(data) { ... }
224231
```
225232

226233
**Example:**
234+
<!-- SNIPPET ID: instrumentation_callback -->
227235
```ruby
228236
config.instrumentation_callback = ->(data) {
229237
puts "Instrumentation: #{data.inspect}"
@@ -232,16 +240,22 @@ config.instrumentation_callback = ->(data) {
232240

233241
### Server Protocol Version
234242

235-
The server's protocol version can be overridden using the `protocol_version` class method:
243+
The server's protocol version can be overridden via the `Configuration#protocol_version` method:
236244

245+
<!-- SNIPPET ID: set_server_protocol_version -->
237246
```ruby
238-
MCP::Server.protocol_version = "2024-11-05"
247+
MCP.configure do |config|
248+
config.protocol_version = "2024-11-05"
249+
end
239250
```
240251

241252
This will make all new server instances use the specified protocol version instead of the default version. The protocol version can be reset to the default by setting it to `nil`:
242253

254+
<!-- SNIPPET ID: unset_server_protocol_version -->
243255
```ruby
244-
MCP::Server.protocol_version = nil
256+
MCP.configure do |config|
257+
config.protocol_version = nil
258+
end
245259
```
246260

247261
Be sure to check the [MCP spec](https://spec.modelcontextprotocol.io/specification/2024-11-05/) for the protocol version to understand the supported features for the version being set.
@@ -274,6 +288,7 @@ This gem provides a `MCP::Tool` class that can be used to create tools in two wa
274288

275289
1. As a class definition:
276290

291+
<!-- SNIPPET ID: tool_class_definition -->
277292
```ruby
278293
class MyTool < MCP::Tool
279294
description "This tool performs specific functionality..."
@@ -301,6 +316,7 @@ tool = MyTool
301316

302317
2. By using the `MCP::Tool.define` method with a block:
303318

319+
<!-- SNIPPET ID: tool_definition_with_block -->
304320
```ruby
305321
tool = MCP::Tool.define(
306322
name: "my_tool",
@@ -337,12 +353,13 @@ The `MCP::Prompt` class provides two ways to create prompts:
337353

338354
1. As a class definition with metadata:
339355

356+
<!-- SNIPPET ID: prompt_class_definition -->
340357
```ruby
341358
class MyPrompt < MCP::Prompt
342359
prompt_name "my_prompt" # Optional - defaults to underscored class name
343360
description "This prompt performs specific functionality..."
344361
arguments [
345-
Prompt::Argument.new(
362+
MCP::Prompt::Argument.new(
346363
name: "message",
347364
description: "Input message",
348365
required: true
@@ -351,16 +368,16 @@ class MyPrompt < MCP::Prompt
351368

352369
class << self
353370
def template(args, server_context:)
354-
Prompt::Result.new(
371+
MCP::Prompt::Result.new(
355372
description: "Response description",
356373
messages: [
357-
Prompt::Message.new(
374+
MCP::Prompt::Message.new(
358375
role: "user",
359-
content: Content::Text.new("User message")
376+
content: MCP::Content::Text.new("User message")
360377
),
361-
Prompt::Message.new(
378+
MCP::Prompt::Message.new(
362379
role: "assistant",
363-
content: Content::Text.new(args["message"])
380+
content: MCP::Content::Text.new(args[:message])
364381
)
365382
]
366383
)
@@ -373,28 +390,29 @@ prompt = MyPrompt
373390

374391
2. Using the `MCP::Prompt.define` method:
375392

393+
<!-- SNIPPET ID: prompt_definition_with_block -->
376394
```ruby
377395
prompt = MCP::Prompt.define(
378396
name: "my_prompt",
379397
description: "This prompt performs specific functionality...",
380398
arguments: [
381-
Prompt::Argument.new(
399+
MCP::Prompt::Argument.new(
382400
name: "message",
383401
description: "Input message",
384402
required: true
385403
)
386404
]
387405
) do |args, server_context:|
388-
Prompt::Result.new(
406+
MCP::Prompt::Result.new(
389407
description: "Response description",
390408
messages: [
391-
Prompt::Message.new(
409+
MCP::Prompt::Message.new(
392410
role: "user",
393-
content: Content::Text.new("User message")
411+
content: MCP::Content::Text.new("User message")
394412
),
395-
Prompt::Message.new(
413+
MCP::Prompt::Message.new(
396414
role: "assistant",
397-
content: Content::Text.new(args["message"])
415+
content: MCP::Content::Text.new(args[:message])
398416
)
399417
]
400418
)
@@ -415,6 +433,7 @@ e.g. around authentication state or user preferences.
415433

416434
Register prompts with the MCP server:
417435

436+
<!-- SNIPPET ID: prompts_usage -->
418437
```ruby
419438
server = MCP::Server.new(
420439
name: "my_server",
@@ -433,6 +452,7 @@ The server will handle prompt listing and execution through the MCP protocol met
433452
The server allows registering a callback to receive information about instrumentation.
434453
To register a handler pass a proc/lambda to as `instrumentation_callback` into the server constructor.
435454

455+
<!-- SNIPPET ID: prompts_instrumentation_callback -->
436456
```ruby
437457
MCP.configure do |config|
438458
config.instrumentation_callback = ->(data) {
@@ -458,6 +478,7 @@ MCP spec includes [Resources](https://modelcontextprotocol.io/docs/concepts/reso
458478

459479
The `MCP::Resource` class provides a way to register resources with the server.
460480

481+
<!-- SNIPPET ID: resources -->
461482
```ruby
462483
resource = MCP::Resource.new(
463484
uri: "https://example.com/my_resource",
@@ -474,6 +495,7 @@ server = MCP::Server.new(
474495

475496
The server must register a handler for the `resources/read` method to retrieve a resource dynamically.
476497

498+
<!-- SNIPPET ID: resources_read_handler -->
477499
```ruby
478500
server.resources_read_handler do |params|
479501
[{

lib/mcp/server.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,13 @@ def handle_json(request)
8787
end
8888

8989
def define_tool(name: nil, description: nil, input_schema: nil, annotations: nil, &block)
90+
@capabilities.support_tools
9091
tool = Tool.define(name:, description:, input_schema:, annotations:, &block)
9192
@tools[tool.name_value] = tool
9293
end
9394

9495
def define_prompt(name: nil, description: nil, arguments: [], &block)
96+
@capabilities.support_prompts
9597
prompt = Prompt.define(name:, description:, arguments:, &block)
9698
@prompts[prompt.name_value] = prompt
9799
end
@@ -102,6 +104,7 @@ def resources_list_handler(&block)
102104
end
103105

104106
def resources_read_handler(&block)
107+
@capabilities.support_resources
105108
@handlers[Methods::RESOURCES_READ] = block
106109
end
107110

@@ -116,6 +119,7 @@ def tools_list_handler(&block)
116119
end
117120

118121
def tools_call_handler(&block)
122+
@capabilities.support_tools
119123
@handlers[Methods::TOOLS_CALL] = block
120124
end
121125

@@ -125,6 +129,7 @@ def prompts_list_handler(&block)
125129
end
126130

127131
def prompts_get_handler(&block)
132+
@capabilities.support_prompts
128133
@handlers[Methods::PROMPTS_GET] = block
129134
end
130135

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# frozen_string_literal: true
2+
3+
require "mcp"
4+
5+
# Stub Bugsnag
6+
class Bugsnag
7+
class Report
8+
attr_reader :metadata
9+
10+
def initialize
11+
@metadata = {}
12+
end
13+
14+
def add_metadata(key, value)
15+
@metadata[key] = value
16+
end
17+
end
18+
19+
class << self
20+
def notify(exception)
21+
report = Report.new
22+
yield report
23+
puts "Bugsnag notified of #{exception.inspect} with metadata #{report.metadata.inspect}"
24+
end
25+
end
26+
end
27+
28+
require_relative "code_snippet"
29+
30+
puts MCP::Server.new(
31+
tools: [
32+
MCP::Tool.define(name: "error_tool") { raise "boom" },
33+
],
34+
).handle_json(
35+
{
36+
jsonrpc: "2.0",
37+
id: "1",
38+
method: "tools/call",
39+
params: { name: "error_tool", arguments: {} },
40+
}.to_json,
41+
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
require "mcp"
4+
5+
MCP.configure do |config|
6+
eval(File.read("code_snippet.rb"), binding)
7+
8+
config.instrumentation_callback.call({ example: "data" })
9+
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# frozen_string_literal: true
2+
3+
require "mcp"
4+
5+
# Minimally mock Bugsnag for the test
6+
module Bugsnag
7+
class Report
8+
attr_reader :metadata
9+
10+
def initialize
11+
@metadata = {}
12+
end
13+
14+
def add_metadata(key, value)
15+
@metadata[key] = value
16+
end
17+
end
18+
19+
class << self
20+
def notify(exception)
21+
report = Report.new
22+
yield report
23+
puts "Bugsnag notified of #{exception.inspect} with metadata #{report.metadata.inspect}"
24+
end
25+
end
26+
end
27+
28+
b = binding
29+
eval(File.read("code_snippet.rb"), b)
30+
server = b.local_variable_get(:server)
31+
32+
server.define_tool(name: "error_tool") { raise "boom" }
33+
34+
puts server.handle_json({
35+
jsonrpc: "2.0",
36+
id: "1",
37+
method: "tools/call",
38+
params: { name: "error_tool", arguments: {} },
39+
}.to_json)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
require "mcp"
4+
5+
require_relative "code_snippet"
6+
7+
b = binding
8+
eval(File.read("code_snippet.rb"), b)
9+
prompt = b.local_variable_get(:prompt)
10+
11+
server = MCP::Server.new(prompts: [prompt])
12+
13+
[
14+
{ jsonrpc: "2.0", id: "1", method: "prompts/list" },
15+
{ jsonrpc: "2.0", id: "2", method: "prompts/get", params: { name: "my_prompt", arguments: { message: "Test message" } } },
16+
].each { |request| puts server.handle_json(request.to_json) }
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
prompt_class_definition.rb
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# frozen_string_literal: true
2+
3+
require "mcp"
4+
require_relative "code_snippet"
5+
6+
puts MCP::Server.new.handle_json({ jsonrpc: "2.0", id: "1", method: "ping" }.to_json)

0 commit comments

Comments
 (0)