mcp 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de660064625f4c983293603731aad08da2dc60fe6efdc92a79c11501182610e4
4
- data.tar.gz: 663b210aee91776875b90c06457651ff33fe2c125826ee188a8143bf5569e5e9
3
+ metadata.gz: 149cbf6f8809235111648ffae6163abb1994cb5c2a4bd2240eb696f17197e421
4
+ data.tar.gz: 7c526f405b013d868032122484079ce715ef8db9629d78dc5c8635fccaf2fc6f
5
5
  SHA512:
6
- metadata.gz: 77e28efcff07e26bf49e1509b988f137008ae19391dd86e4ebb31be7d63fdfdcb740a4d49840b4b28b319f3ccd757e8034207eedfed72e2b33b3b464ebd4ff2a
7
- data.tar.gz: 3d83677fe719a766f73b2e91fe6e8c5109bbbb7143ef5c674a154226d0f517fb28ed3b8cce6bbac37136770892258bde3267963000f8aa43d77faaa2a83a1083
6
+ metadata.gz: b7dcdebe8faa024fe784a894ae6383d2bcd812f41a3a492c75429034ed8f062a8da49963a94a159ce2af25928cccfbb92cb629be98ead1d751d54389e3dfc9a3
7
+ data.tar.gz: 9c6f1fb122e3d636da0a7585fe38bd3ea567ba98c2899d91ef87136b69e8d0c93007c3f031915b44c835d16bbcd1465875decc459e5a34aada517541b91d369f
@@ -0,0 +1,29 @@
1
+ name: Conformance Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ concurrency:
10
+ group: conformance-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ jobs:
17
+ server-conformance:
18
+ runs-on: ubuntu-latest
19
+ continue-on-error: true
20
+ steps:
21
+ - uses: actions/checkout@v6
22
+ - uses: ruby/setup-ruby@v1
23
+ with:
24
+ ruby-version: '4.0' # Specify the latest supported Ruby version.
25
+ bundler-cache: true
26
+ - uses: actions/setup-node@v4
27
+ with:
28
+ node-version: '24' # Specify the latest Node.js version.
29
+ - run: bundle exec rake conformance
data/.gitignore CHANGED
@@ -1,4 +1,5 @@
1
1
  .ruby-version
2
+ /*.gem
2
3
  /.bundle/
3
4
  /.yardoc
4
5
  /_yardoc/
data/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-03-03
11
+
12
+ ### Added
13
+
14
+ - `Content::EmbeddedResource` class for embedded resource content type (#244)
15
+ - `Content::Audio` class for audio content type (#243)
16
+ - `$ref` support in `Tool::Schema` for protocol version 2025-11-25 (#242)
17
+ - MCP conformance test suite (#248)
18
+
19
+ ### Fixed
20
+
21
+ - Handle `Errno::ECONNRESET` in SSE stream operations (#249)
22
+ - Fix default handler return values to comply with MCP spec (#247)
23
+ - Fix `Prompt#validate_arguments!` crash when arguments are `nil` (#246)
24
+ - Return 202 Accepted for SSE responses per MCP spec (#245)
25
+ - Fix `Content::Image#to_h` to return `mimeType` (camelCase) per MCP spec (#241)
26
+
27
+ ## [0.7.1] - 2026-02-21
28
+
29
+ ### Fixed
30
+
31
+ - Fix `Resource::Contents#to_h` to use correct property names per MCP spec (#235)
32
+ - Return JSON-RPC protocol errors for unknown tool calls (#231)
33
+ - Fix `logging/setLevel` to return empty hash per MCP specification (#230)
34
+
10
35
  ## [0.7.0] - 2026-02-14
11
36
 
12
37
  ### Added
@@ -140,4 +165,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
140
165
 
141
166
  ## [0.1.0] - 2025-05-30
142
167
 
143
- Initial release
168
+ Initial release in collaboration with Shopify
data/README.md CHANGED
@@ -1028,6 +1028,12 @@ The client provides a wrapper class for tools returned by the server:
1028
1028
 
1029
1029
  This class provides easy access to tool properties like name, description, input schema, and output schema.
1030
1030
 
1031
+ ## Conformance Testing
1032
+
1033
+ The `conformance/` directory contains a test server and runner that validate the SDK against the MCP specification using [`@modelcontextprotocol/conformance`](https://github.com/modelcontextprotocol/conformance).
1034
+
1035
+ See [conformance/README.md](conformance/README.md) for usage instructions.
1036
+
1031
1037
  ## Documentation
1032
1038
 
1033
1039
  - [SDK API documentation](https://rubydoc.info/gems/mcp)
data/Rakefile CHANGED
@@ -14,4 +14,43 @@ require "rubocop/rake_task"
14
14
 
15
15
  RuboCop::RakeTask.new
16
16
 
17
- task default: [:test, :rubocop]
17
+ task default: [:rubocop, :test, :conformance]
18
+
19
+ desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)"
20
+ task :conformance do |t|
21
+ next unless npx_available?(t.name)
22
+
23
+ require_relative "conformance/runner"
24
+
25
+ options = {}
26
+ options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
27
+ options[:scenario] = ENV["SCENARIO"] if ENV["SCENARIO"]
28
+ options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"]
29
+ options[:verbose] = true if ENV["VERBOSE"]
30
+
31
+ Conformance::Runner.new(**options).run
32
+ end
33
+
34
+ desc "List available conformance scenarios"
35
+ task :conformance_list do |t|
36
+ next unless npx_available?(t.name)
37
+
38
+ system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server")
39
+ end
40
+
41
+ desc "Start the conformance server (PORT)"
42
+ task :conformance_server do
43
+ require_relative "conformance/server"
44
+
45
+ options = {}
46
+ options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
47
+
48
+ Conformance::Server.new(**options).start
49
+ end
50
+
51
+ def npx_available?(task_name)
52
+ return true if system("which", "npx", out: File::NULL, err: File::NULL)
53
+
54
+ warn("Skipping #{task_name}: npx is not installed. Install Node.js to run this task: https://nodejs.org/")
55
+ false
56
+ end
data/SECURITY.md ADDED
@@ -0,0 +1,21 @@
1
+ # Security Policy
2
+
3
+ Thank you for helping keep the Model Context Protocol and its ecosystem secure.
4
+
5
+ ## Reporting Security Issues
6
+
7
+ If you discover a security vulnerability in this repository, please report it through
8
+ the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability)
9
+ for this repository.
10
+
11
+ Please **do not** report security vulnerabilities through public GitHub issues, discussions,
12
+ or pull requests.
13
+
14
+ ## What to Include
15
+
16
+ To help us triage and respond quickly, please include:
17
+
18
+ - A description of the vulnerability
19
+ - Steps to reproduce the issue
20
+ - The potential impact
21
+ - Any suggested fixes (optional)
@@ -0,0 +1,103 @@
1
+ # MCP Conformance Tests
2
+
3
+ Validates the Ruby SDK's conformance to the MCP specification using [`@modelcontextprotocol/conformance`](https://github.com/modelcontextprotocol/conformance).
4
+
5
+ ## Prerequisites
6
+
7
+ - Node.js (for `npx`)
8
+ - `bundle install` completed
9
+
10
+ ## Running the Tests
11
+
12
+ ### Run all scenarios
13
+
14
+ ```bash
15
+ bundle exec rake conformance
16
+ ```
17
+
18
+ Starts the conformance server, runs all active scenarios against it, prints a pass/fail
19
+ summary for each scenario, and exits with a non-zero status code if any unexpected failures
20
+ are detected. Scenarios listed in `expected_failures.yml` are allowed to fail without
21
+ affecting the exit code.
22
+
23
+ ### Environment variables
24
+
25
+ | Variable | Description | Default |
26
+ |----------------|--------------------------------------|---------|
27
+ | `PORT` | Server port | `9292` |
28
+ | `SCENARIO` | Run a single scenario by name | (all) |
29
+ | `SPEC_VERSION` | Filter scenarios by spec version | (all) |
30
+ | `VERBOSE` | Show raw JSON output when set | (off) |
31
+
32
+ ```bash
33
+ # Run a single scenario
34
+ bundle exec rake conformance SCENARIO=ping
35
+
36
+ # Use a different port with verbose output
37
+ bundle exec rake conformance PORT=3000 VERBOSE=1
38
+
39
+ # Start the server on a specific port
40
+ bundle exec rake conformance_server PORT=3000
41
+ ```
42
+
43
+ ### Start the server and test separately
44
+
45
+ ```bash
46
+ # Terminal 1: start the server
47
+ bundle exec rake conformance_server
48
+
49
+ # Terminal 2: run all scenarios
50
+ npx @modelcontextprotocol/conformance server --url http://localhost:9292/mcp
51
+
52
+ # Terminal 2: run a single scenario
53
+ npx @modelcontextprotocol/conformance server --url http://localhost:9292/mcp --scenario ping
54
+ ```
55
+
56
+ Keeps the server alive between test runs, which avoids the startup overhead when iterating
57
+ on a single scenario. Stop the server with Ctrl+C when done.
58
+
59
+ ### List available scenarios
60
+
61
+ ```bash
62
+ bundle exec rake conformance_list
63
+ ```
64
+
65
+ Prints all scenario names that can be passed to `SCENARIO`.
66
+
67
+ ## SDK Tier Report
68
+
69
+ The [MCP SDK Tier system](https://modelcontextprotocol.io/community/sdk-tiers) requires SDK
70
+ maintainers to self-assess and report results to the SDK Working Group via
71
+ [modelcontextprotocol/modelcontextprotocol issues](https://github.com/modelcontextprotocol/modelcontextprotocol/issues).
72
+
73
+ To generate a full tier assessment report, use the `/mcp-sdk-tier-audit` slash command from
74
+ the [modelcontextprotocol/conformance](https://github.com/modelcontextprotocol/conformance)
75
+ repository with the conformance server running:
76
+
77
+ ```bash
78
+ # Terminal 1 (this repository): start the conformance server
79
+ bundle exec rake conformance_server
80
+
81
+ # Terminal 2 (conformance repository): run the tier audit skill as a slash command in Claude Code
82
+ /mcp-sdk-tier-audit /path/to/modelcontextprotocol/ruby-sdk http://localhost:9292/mcp
83
+ ```
84
+
85
+ The skill evaluates conformance pass rate, issue label taxonomy, triage metrics, documentation
86
+ coverage, and policy compliance, then produces a markdown report suitable for tier advancement
87
+ submissions.
88
+
89
+ ## File Structure
90
+
91
+ ```
92
+ conformance/
93
+ server.rb # Conformance server (Rack + Puma, default port 9292)
94
+ runner.rb # Starts the server, runs npx conformance, exits with result code
95
+ expected_failures.yml # Baseline of known-failing scenarios
96
+ README.md # This file
97
+ ```
98
+
99
+ ## Known Limitations
100
+
101
+ Known-failing scenarios are registered in `conformance/expected_failures.yml`. They are allowed to
102
+ fail without affecting the exit code and are tracked to catch regressions.
103
+ These are shown in the output of `bundle exec rake conformance`.
@@ -0,0 +1,9 @@
1
+ server:
2
+ # TODO: Server-to-client requests (sampling/createMessage, elicitation/create) are not implemented.
3
+ # `Transport#send_request` does not exist in the current SDK.
4
+ - tools-call-sampling
5
+ - tools-call-elicitation
6
+ - elicitation-sep1034-defaults
7
+ - elicitation-sep1330-enums
8
+ # TODO: The SDK does not extract `_meta.progressToken` from tool call requests or deliver `notifications/progress` to tools.
9
+ - tools-call-with-progress
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Starts the conformance server and runs `npx @modelcontextprotocol/conformance` against it.
4
+ require "English"
5
+ require "net/http"
6
+ require_relative "server"
7
+
8
+ module Conformance
9
+ class Runner
10
+ # Timeout for waiting for the Puma server to start.
11
+ SERVER_START_TIMEOUT = 20
12
+ SERVER_POLL_INTERVAL = 0.5
13
+ SERVER_HEALTH_CHECK_RETRIES = (SERVER_START_TIMEOUT / SERVER_POLL_INTERVAL).to_i
14
+
15
+ def initialize(port: Server::DEFAULT_PORT, scenario: nil, spec_version: nil, verbose: false)
16
+ @port = port
17
+ @scenario = scenario
18
+ @spec_version = spec_version
19
+ @verbose = verbose
20
+ end
21
+
22
+ def run
23
+ command = build_command
24
+ server_pid = start_server
25
+
26
+ run_conformance(command, server_pid: server_pid)
27
+ end
28
+
29
+ private
30
+
31
+ def build_command
32
+ expected_failures_yml = File.expand_path("expected_failures.yml", __dir__)
33
+
34
+ npx_command = ["npx", "--yes", "@modelcontextprotocol/conformance", "server", "--url", "http://localhost:#{@port}/mcp"]
35
+ npx_command += ["--scenario", @scenario] if @scenario
36
+ npx_command += ["--spec-version", @spec_version] if @spec_version
37
+ npx_command += ["--verbose"] if @verbose
38
+ npx_command += ["--expected-failures", expected_failures_yml]
39
+ npx_command
40
+ end
41
+
42
+ def start_server
43
+ puts "Starting conformance server on port #{@port}..."
44
+
45
+ server_pid = fork do
46
+ Conformance::Server.new(port: @port).start
47
+ end
48
+
49
+ health_url = URI("http://localhost:#{@port}/health")
50
+ ready = false
51
+ SERVER_HEALTH_CHECK_RETRIES.times do
52
+ begin
53
+ response = Net::HTTP.get_response(health_url)
54
+ if response.code == "200"
55
+ ready = true
56
+ break
57
+ end
58
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Net::ReadTimeout
59
+ # not ready yet
60
+ end
61
+ sleep(SERVER_POLL_INTERVAL)
62
+ end
63
+
64
+ unless ready
65
+ warn("ERROR: Conformance server did not start within #{SERVER_START_TIMEOUT} seconds")
66
+ terminate_server(server_pid)
67
+ exit(1)
68
+ end
69
+
70
+ puts "Server ready. Running conformance tests..."
71
+
72
+ server_pid
73
+ end
74
+
75
+ def run_conformance(command, server_pid:)
76
+ puts "Command: #{command.join(" ")}\n\n"
77
+
78
+ conformance_exit_code = nil
79
+ begin
80
+ system(*command)
81
+ conformance_exit_code = $CHILD_STATUS.exitstatus
82
+ ensure
83
+ terminate_server(server_pid)
84
+ end
85
+
86
+ exit(conformance_exit_code || 1)
87
+ end
88
+
89
+ def terminate_server(pid)
90
+ Process.kill("TERM", pid)
91
+ rescue Errno::ESRCH
92
+ # process already exited
93
+ ensure
94
+ begin
95
+ Process.wait(pid)
96
+ rescue Errno::ECHILD
97
+ # already reaped
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,547 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rackup"
4
+ require "json"
5
+ require "uri"
6
+ require_relative "../lib/mcp"
7
+
8
+ module Conformance
9
+ # 1x1 red PNG pixel (matches TypeScript SDK and Python SDK)
10
+ BASE64_1X1_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
11
+
12
+ # Minimal WAV file (matches TypeScript SDK and Python SDK)
13
+ BASE64_MINIMAL_WAV = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA="
14
+
15
+ module Tools
16
+ class TestSimpleText < MCP::Tool
17
+ tool_name "test_simple_text"
18
+ description "A tool that returns simple text content"
19
+
20
+ class << self
21
+ def call(**_args)
22
+ MCP::Tool::Response.new([MCP::Content::Text.new("This is a simple text response for testing.").to_h])
23
+ end
24
+ end
25
+ end
26
+
27
+ class TestImageContent < MCP::Tool
28
+ tool_name "test_image_content"
29
+ description "A tool that returns image content"
30
+
31
+ class << self
32
+ def call(**_args)
33
+ MCP::Tool::Response.new([MCP::Content::Image.new(BASE64_1X1_PNG, "image/png").to_h])
34
+ end
35
+ end
36
+ end
37
+
38
+ class TestAudioContent < MCP::Tool
39
+ tool_name "test_audio_content"
40
+ description "A tool that returns audio content"
41
+
42
+ class << self
43
+ def call(**_args)
44
+ MCP::Tool::Response.new([MCP::Content::Audio.new(BASE64_MINIMAL_WAV, "audio/wav").to_h])
45
+ end
46
+ end
47
+ end
48
+
49
+ class TestEmbeddedResource < MCP::Tool
50
+ tool_name "test_embedded_resource"
51
+ description "A tool that returns embedded resource content"
52
+
53
+ class << self
54
+ def call(**_args)
55
+ text_contents = MCP::Resource::TextContents.new(
56
+ uri: "test://embedded-resource",
57
+ mime_type: "text/plain",
58
+ text: "This is an embedded resource content.",
59
+ )
60
+ MCP::Tool::Response.new([MCP::Content::EmbeddedResource.new(text_contents).to_h])
61
+ end
62
+ end
63
+ end
64
+
65
+ class TestMultipleContentTypes < MCP::Tool
66
+ tool_name "test_multiple_content_types"
67
+ description "A tool that returns multiple content types"
68
+
69
+ class << self
70
+ def call(**_args)
71
+ MCP::Tool::Response.new([
72
+ MCP::Content::Text.new("Multiple content types test:").to_h,
73
+ MCP::Content::Image.new(BASE64_1X1_PNG, "image/png").to_h,
74
+ MCP::Content::EmbeddedResource.new(
75
+ MCP::Resource::TextContents.new(
76
+ uri: "test://mixed-content-resource",
77
+ mime_type: "application/json",
78
+ text: '{"test":"data","value":123}',
79
+ ),
80
+ ).to_h,
81
+ ])
82
+ end
83
+ end
84
+ end
85
+
86
+ class TestErrorHandling < MCP::Tool
87
+ tool_name "test_error_handling"
88
+ description "A tool that intentionally returns an error for testing"
89
+
90
+ class << self
91
+ def call(**_args)
92
+ MCP::Tool::Response.new(
93
+ [MCP::Content::Text.new("This tool intentionally returns an error for testing").to_h],
94
+ error: true,
95
+ )
96
+ end
97
+ end
98
+ end
99
+
100
+ class JsonSchema202012Tool < MCP::Tool
101
+ tool_name "json_schema_2020_12_tool"
102
+ description "Tool with JSON Schema 2020-12 features"
103
+ input_schema(
104
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
105
+ "$defs": {
106
+ address: {
107
+ type: "object",
108
+ properties: {
109
+ street: { type: "string" },
110
+ city: { type: "string" },
111
+ },
112
+ },
113
+ },
114
+ properties: {
115
+ name: { type: "string" },
116
+ address: { "$ref": "#/$defs/address" },
117
+ },
118
+ additionalProperties: false,
119
+ )
120
+
121
+ class << self
122
+ def call(**_args)
123
+ MCP::Tool::Response.new([MCP::Content::Text.new("Processed with JSON Schema 2020-12").to_h])
124
+ end
125
+ end
126
+ end
127
+
128
+ class TestToolWithLogging < MCP::Tool
129
+ tool_name "test_tool_with_logging"
130
+ description "A tool that sends log messages during execution"
131
+
132
+ class << self
133
+ def call(server_context:, **_args)
134
+ server_context.notify_log_message(data: "Tool execution started", level: "info", logger: "test_logger")
135
+ sleep(0.05) # Required by the conformance test to verify clients handle interleaved notifications (same as TypeScript SDK).
136
+ server_context.notify_log_message(data: "Tool processing data", level: "info", logger: "test_logger")
137
+ sleep(0.05) # Same as above.
138
+ server_context.notify_log_message(data: "Tool execution completed", level: "info", logger: "test_logger")
139
+ MCP::Tool::Response.new([MCP::Content::Text.new("Logging complete (3 messages sent)").to_h])
140
+ end
141
+ end
142
+ end
143
+
144
+ # test_tool_with_progress: the actual progress dispatch is in `tools_call_handler`
145
+ class TestToolWithProgress < MCP::Tool
146
+ tool_name "test_tool_with_progress"
147
+ description "A tool that reports progress notifications"
148
+
149
+ class << self
150
+ def call(**_args)
151
+ MCP::Tool::Response.new([MCP::Content::Text.new("Progress complete").to_h])
152
+ end
153
+ end
154
+ end
155
+
156
+ # TODO: Implement when `Transport` supports server-to-client requests.
157
+ class TestSampling < MCP::Tool
158
+ tool_name "test_sampling"
159
+ description "A tool that requests LLM sampling from the client"
160
+ input_schema(
161
+ properties: { prompt: { type: "string" } },
162
+ required: ["prompt"],
163
+ )
164
+
165
+ class << self
166
+ def call(prompt:)
167
+ MCP::Tool::Response.new(
168
+ [MCP::Content::Text.new("Sampling not supported in this SDK version").to_h],
169
+ error: true,
170
+ )
171
+ end
172
+ end
173
+ end
174
+
175
+ # TODO: Implement when `Transport` supports server-to-client requests.
176
+ class TestElicitation < MCP::Tool
177
+ tool_name "test_elicitation"
178
+ description "A tool that requests user input from the client"
179
+ input_schema(
180
+ properties: { message: { type: "string" } },
181
+ required: ["message"],
182
+ )
183
+
184
+ class << self
185
+ def call(message:)
186
+ MCP::Tool::Response.new(
187
+ [MCP::Content::Text.new("Elicitation not supported in this SDK version").to_h],
188
+ error: true,
189
+ )
190
+ end
191
+ end
192
+ end
193
+
194
+ # TODO: Implement when `Transport` supports server-to-client requests.
195
+ class TestElicitationSep1034Defaults < MCP::Tool
196
+ tool_name "test_elicitation_sep1034_defaults"
197
+ description "A tool that tests elicitation with default values"
198
+
199
+ class << self
200
+ def call(**_args)
201
+ MCP::Tool::Response.new(
202
+ [MCP::Content::Text.new("Elicitation not supported in this SDK version").to_h],
203
+ error: true,
204
+ )
205
+ end
206
+ end
207
+ end
208
+
209
+ # TODO: Implement when `Transport` supports server-to-client requests.
210
+ class TestElicitationSep1330Enums < MCP::Tool
211
+ tool_name "test_elicitation_sep1330_enums"
212
+ description "A tool that tests elicitation with enum schemas"
213
+
214
+ class << self
215
+ def call(**_args)
216
+ MCP::Tool::Response.new(
217
+ [MCP::Content::Text.new("Elicitation not supported in this SDK version").to_h],
218
+ error: true,
219
+ )
220
+ end
221
+ end
222
+ end
223
+
224
+ class TestReconnection < MCP::Tool
225
+ tool_name "test_reconnection"
226
+ description "A tool that triggers SSE stream closure to test client reconnection behavior"
227
+
228
+ class << self
229
+ def call(**_args)
230
+ MCP::Tool::Response.new([MCP::Content::Text.new("Reconnection test completed").to_h])
231
+ end
232
+ end
233
+ end
234
+ end
235
+
236
+ module Prompts
237
+ class TestSimplePrompt < MCP::Prompt
238
+ prompt_name "test_simple_prompt"
239
+ description "A simple prompt for testing with no arguments"
240
+
241
+ class << self
242
+ def template(_args, server_context: nil)
243
+ MCP::Prompt::Result.new(
244
+ messages: [
245
+ MCP::Prompt::Message.new(
246
+ role: "user",
247
+ content: MCP::Content::Text.new("This is a simple prompt for testing."),
248
+ ),
249
+ ],
250
+ )
251
+ end
252
+ end
253
+ end
254
+
255
+ class TestPromptWithArguments < MCP::Prompt
256
+ prompt_name "test_prompt_with_arguments"
257
+ description "A prompt with required arguments for testing"
258
+ arguments [
259
+ MCP::Prompt::Argument.new(name: "arg1", description: "First test argument", required: true),
260
+ MCP::Prompt::Argument.new(name: "arg2", description: "Second test argument", required: true),
261
+ ]
262
+
263
+ class << self
264
+ def template(args, server_context: nil)
265
+ arg1 = args.dig(:arg1) || args.dig("arg1") || ""
266
+ arg2 = args.dig(:arg2) || args.dig("arg2") || ""
267
+ MCP::Prompt::Result.new(
268
+ messages: [
269
+ MCP::Prompt::Message.new(
270
+ role: "user",
271
+ content: MCP::Content::Text.new("Prompt with arguments: arg1='#{arg1}', arg2='#{arg2}'"),
272
+ ),
273
+ ],
274
+ )
275
+ end
276
+ end
277
+ end
278
+
279
+ class TestPromptWithEmbeddedResource < MCP::Prompt
280
+ prompt_name "test_prompt_with_embedded_resource"
281
+ description "A prompt with an embedded resource for testing"
282
+ arguments [
283
+ MCP::Prompt::Argument.new(name: "resourceUri", description: "URI of the resource to embed", required: true),
284
+ ]
285
+
286
+ class << self
287
+ def template(args, server_context: nil)
288
+ resource_uri = args.dig(:resourceUri) || args.dig("resourceUri") || "test://example-resource"
289
+ MCP::Prompt::Result.new(
290
+ messages: [
291
+ MCP::Prompt::Message.new(
292
+ role: "user",
293
+ content: MCP::Content::EmbeddedResource.new(
294
+ MCP::Resource::TextContents.new(
295
+ uri: resource_uri,
296
+ mime_type: "text/plain",
297
+ text: "Embedded resource content for testing.",
298
+ ),
299
+ ),
300
+ ),
301
+ MCP::Prompt::Message.new(
302
+ role: "user",
303
+ content: MCP::Content::Text.new("Please process the embedded resource above."),
304
+ ),
305
+ ],
306
+ )
307
+ end
308
+ end
309
+ end
310
+
311
+ class TestPromptWithImage < MCP::Prompt
312
+ prompt_name "test_prompt_with_image"
313
+ description "A prompt with image content for testing"
314
+
315
+ class << self
316
+ def template(_args, server_context: nil)
317
+ MCP::Prompt::Result.new(
318
+ messages: [
319
+ MCP::Prompt::Message.new(
320
+ role: "user",
321
+ content: MCP::Content::Image.new(BASE64_1X1_PNG, "image/png"),
322
+ ),
323
+ MCP::Prompt::Message.new(
324
+ role: "user",
325
+ content: MCP::Content::Text.new("Please analyze the image above."),
326
+ ),
327
+ ],
328
+ )
329
+ end
330
+ end
331
+ end
332
+ end
333
+
334
+ class Server
335
+ DEFAULT_PORT = 9292
336
+
337
+ class DnsRebindingProtection
338
+ LOCALHOST_PATTERNS = /\A(localhost|127\.0\.0\.1|\[::1\]|::1)(:\d+)?\z/i.freeze
339
+
340
+ def initialize(app)
341
+ @app = app
342
+ end
343
+
344
+ def call(env)
345
+ host = env["HTTP_HOST"] || env["SERVER_NAME"] || ""
346
+
347
+ unless localhost?(host)
348
+ return [
349
+ 403,
350
+ { "Content-Type" => "application/json" },
351
+ [{ error: "Forbidden: DNS rebinding protection - invalid Host header '#{host}'" }.to_json],
352
+ ]
353
+ end
354
+
355
+ origin = env["HTTP_ORIGIN"]
356
+ if origin && !origin.empty?
357
+ begin
358
+ origin_host = URI.parse(origin).host.to_s
359
+ unless localhost?(origin_host)
360
+ return [
361
+ 403,
362
+ { "Content-Type" => "application/json" },
363
+ [{ error: "Forbidden: DNS rebinding protection - invalid Origin '#{origin}'" }.to_json],
364
+ ]
365
+ end
366
+ rescue URI::InvalidURIError
367
+ return [
368
+ 403,
369
+ { "Content-Type" => "application/json" },
370
+ [{ error: "Forbidden: invalid Origin header" }.to_json],
371
+ ]
372
+ end
373
+ end
374
+
375
+ @app.call(env)
376
+ end
377
+
378
+ private
379
+
380
+ def localhost?(host)
381
+ host.empty? || host.match?(LOCALHOST_PATTERNS)
382
+ end
383
+ end
384
+
385
+ def initialize(port: DEFAULT_PORT)
386
+ @port = port
387
+ end
388
+
389
+ def start
390
+ server = build_server
391
+ transport = build_transport(server)
392
+ configure_handlers(server)
393
+ rack_app = build_rack_app(transport)
394
+
395
+ puts <<~MESSAGE
396
+ MCP Conformance Server starting on http://localhost:#{@port}/mcp
397
+ Use Ctrl-C to stop
398
+ MESSAGE
399
+
400
+ Rackup::Handler.get("puma").run(rack_app, Port: @port, Host: "localhost", Silent: true)
401
+ end
402
+
403
+ private
404
+
405
+ def build_server
406
+ MCP::Server.new(
407
+ name: "ruby-sdk-conformance-server",
408
+ version: MCP::VERSION,
409
+ tools: [
410
+ Tools::TestSimpleText,
411
+ Tools::TestImageContent,
412
+ Tools::TestAudioContent,
413
+ Tools::TestEmbeddedResource,
414
+ Tools::TestMultipleContentTypes,
415
+ Tools::TestErrorHandling,
416
+ Tools::JsonSchema202012Tool,
417
+ Tools::TestToolWithLogging,
418
+ Tools::TestToolWithProgress,
419
+ Tools::TestSampling,
420
+ Tools::TestElicitation,
421
+ Tools::TestElicitationSep1034Defaults,
422
+ Tools::TestElicitationSep1330Enums,
423
+ Tools::TestReconnection,
424
+ ],
425
+ prompts: [
426
+ Prompts::TestSimplePrompt,
427
+ Prompts::TestPromptWithArguments,
428
+ Prompts::TestPromptWithEmbeddedResource,
429
+ Prompts::TestPromptWithImage,
430
+ ],
431
+ resources: resources,
432
+ resource_templates: resource_templates,
433
+ capabilities: {
434
+ tools: { listChanged: true },
435
+ prompts: { listChanged: true },
436
+ resources: { listChanged: true, subscribe: true },
437
+ logging: {},
438
+ completions: {},
439
+ },
440
+ )
441
+ end
442
+
443
+ def resources
444
+ [
445
+ MCP::Resource.new(
446
+ uri: "test://static-text",
447
+ name: "static-text",
448
+ description: "A static text resource for testing",
449
+ mime_type: "text/plain",
450
+ ),
451
+ MCP::Resource.new(
452
+ uri: "test://static-binary",
453
+ name: "static-binary",
454
+ description: "A static binary (PNG) resource for testing",
455
+ mime_type: "image/png",
456
+ ),
457
+ MCP::Resource.new(
458
+ uri: "test://watched-resource",
459
+ name: "watched-resource",
460
+ description: "A resource for subscription testing",
461
+ mime_type: "text/plain",
462
+ ),
463
+ ]
464
+ end
465
+
466
+ def resource_templates
467
+ [
468
+ MCP::ResourceTemplate.new(
469
+ uri_template: "test://template/{id}/data",
470
+ name: "template-resource",
471
+ description: "A parameterized resource template for testing",
472
+ mime_type: "application/json",
473
+ ),
474
+ ]
475
+ end
476
+
477
+ def build_transport(server)
478
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
479
+ server.transport = transport
480
+ transport
481
+ end
482
+
483
+ def configure_handlers(server)
484
+ server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug")
485
+ server.server_context = server
486
+
487
+ configure_resources_read_handler(server)
488
+ end
489
+
490
+ def configure_resources_read_handler(server)
491
+ server.resources_read_handler do |params|
492
+ uri = params[:uri].to_s
493
+
494
+ case uri
495
+ when "test://static-text"
496
+ [
497
+ MCP::Resource::TextContents.new(
498
+ text: "This is the content of the static text resource.",
499
+ uri: uri,
500
+ mime_type: "text/plain",
501
+ ).to_h,
502
+ ]
503
+ when "test://static-binary"
504
+ [
505
+ MCP::Resource::BlobContents.new(
506
+ data: BASE64_1X1_PNG,
507
+ uri: uri,
508
+ mime_type: "image/png",
509
+ ).to_h,
510
+ ]
511
+ when %r{\Atest://template/(.+)/data\z}
512
+ id = Regexp.last_match(1)
513
+ content = { id: id, templateTest: true, data: "Data for ID: #{id}" }.to_json
514
+
515
+ [
516
+ MCP::Resource::TextContents.new(
517
+ text: content,
518
+ uri: uri,
519
+ mime_type: "application/json",
520
+ ).to_h,
521
+ ]
522
+ else
523
+ []
524
+ end
525
+ end
526
+ end
527
+
528
+ def build_rack_app(transport)
529
+ mcp_app = proc do |env|
530
+ request = Rack::Request.new(env)
531
+
532
+ if request.path_info == "/health"
533
+ [200, { "Content-Type" => "application/json" }, ['{"status":"ok"}']]
534
+ elsif request.path_info == "/mcp" || request.path_info == "/"
535
+ transport.handle_request(request)
536
+ else
537
+ [404, { "Content-Type" => "application/json" }, ['{"error":"Not found"}']]
538
+ end
539
+ end
540
+
541
+ Rack::Builder.new do
542
+ use(DnsRebindingProtection)
543
+ run(mcp_app)
544
+ end
545
+ end
546
+ end
547
+ end
data/lib/mcp/content.rb CHANGED
@@ -25,7 +25,34 @@ module MCP
25
25
  end
26
26
 
27
27
  def to_h
28
- { data: data, mime_type: mime_type, annotations: annotations, type: "image" }.compact
28
+ { data: data, mimeType: mime_type, annotations: annotations, type: "image" }.compact
29
+ end
30
+ end
31
+
32
+ class Audio
33
+ attr_reader :data, :mime_type, :annotations
34
+
35
+ def initialize(data, mime_type, annotations: nil)
36
+ @data = data
37
+ @mime_type = mime_type
38
+ @annotations = annotations
39
+ end
40
+
41
+ def to_h
42
+ { data: data, mimeType: mime_type, annotations: annotations, type: "audio" }.compact
43
+ end
44
+ end
45
+
46
+ class EmbeddedResource
47
+ attr_reader :resource, :annotations
48
+
49
+ def initialize(resource, annotations: nil)
50
+ @resource = resource
51
+ @annotations = annotations
52
+ end
53
+
54
+ def to_h
55
+ { resource: resource.to_h, annotations: annotations, type: "resource" }.compact
29
56
  end
30
57
  end
31
58
  end
data/lib/mcp/prompt.rb CHANGED
@@ -21,7 +21,7 @@ module MCP
21
21
  title: title_value,
22
22
  description: description_value,
23
23
  icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
24
- arguments: arguments_value&.map(&:to_h),
24
+ arguments: arguments_value.empty? ? nil : arguments_value.map(&:to_h),
25
25
  _meta: meta_value,
26
26
  }.compact
27
27
  end
@@ -32,7 +32,7 @@ module MCP
32
32
  subclass.instance_variable_set(:@title_value, nil)
33
33
  subclass.instance_variable_set(:@description_value, nil)
34
34
  subclass.instance_variable_set(:@icons_value, nil)
35
- subclass.instance_variable_set(:@arguments_value, nil)
35
+ subclass.instance_variable_set(:@arguments_value, [])
36
36
  subclass.instance_variable_set(:@meta_value, nil)
37
37
  end
38
38
 
@@ -76,7 +76,7 @@ module MCP
76
76
  if value == NOT_SET
77
77
  @arguments_value
78
78
  else
79
- @arguments_value = value
79
+ @arguments_value = Array(value)
80
80
  end
81
81
  end
82
82
 
@@ -103,6 +103,7 @@ module MCP
103
103
  end
104
104
 
105
105
  def validate_arguments!(args)
106
+ args ||= {}
106
107
  missing = required_args - args.keys
107
108
  return if missing.empty?
108
109
 
@@ -11,7 +11,7 @@ module MCP
11
11
  end
12
12
 
13
13
  def to_h
14
- { uri: uri, mime_type: mime_type }.compact
14
+ { uri: uri, mimeType: mime_type }.compact
15
15
  end
16
16
  end
17
17
 
@@ -37,7 +37,7 @@ module MCP
37
37
  end
38
38
 
39
39
  def to_h
40
- super.merge(data: data)
40
+ super.merge(blob: data)
41
41
  end
42
42
  end
43
43
  end
@@ -19,6 +19,7 @@ module MCP
19
19
 
20
20
  REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze
21
21
  REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
22
+ STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
22
23
 
23
24
  def handle_request(request)
24
25
  case request.env["REQUEST_METHOD"]
@@ -58,7 +59,7 @@ module MCP
58
59
  begin
59
60
  send_to_stream(session[:stream], notification)
60
61
  true
61
- rescue IOError, Errno::EPIPE => e
62
+ rescue *STREAM_WRITE_ERRORS => e
62
63
  MCP.configuration.exception_reporter.call(
63
64
  e,
64
65
  { session_id: session_id, error: "Failed to send notification" },
@@ -77,7 +78,7 @@ module MCP
77
78
  begin
78
79
  send_to_stream(session[:stream], notification)
79
80
  sent_count += 1
80
- rescue IOError, Errno::EPIPE => e
81
+ rescue *STREAM_WRITE_ERRORS => e
81
82
  MCP.configuration.exception_reporter.call(
82
83
  e,
83
84
  { session_id: sid, error: "Failed to send notification" },
@@ -288,8 +289,8 @@ module MCP
288
289
  def send_response_to_stream(stream, response, session_id)
289
290
  message = JSON.parse(response)
290
291
  send_to_stream(stream, message)
291
- [200, { "Content-Type" => "application/json" }, [{ accepted: true }.to_json]]
292
- rescue IOError, Errno::EPIPE => e
292
+ handle_accepted
293
+ rescue *STREAM_WRITE_ERRORS => e
293
294
  MCP.configuration.exception_reporter.call(
294
295
  e,
295
296
  { session_id: session_id, error: "Stream closed during response" },
@@ -366,7 +367,7 @@ module MCP
366
367
  send_ping_to_stream(@sessions[session_id][:stream])
367
368
  end
368
369
  end
369
- rescue IOError, Errno::EPIPE => e
370
+ rescue *STREAM_WRITE_ERRORS => e
370
371
  MCP.configuration.exception_reporter.call(
371
372
  e,
372
373
  { session_id: session_id, error: "Stream closed" },
data/lib/mcp/server.rb CHANGED
@@ -99,9 +99,9 @@ module MCP
99
99
  Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
100
100
 
101
101
  # No op handlers for currently unsupported methods
102
- Methods::RESOURCES_SUBSCRIBE => ->(_) {},
103
- Methods::RESOURCES_UNSUBSCRIBE => ->(_) {},
104
- Methods::COMPLETION_COMPLETE => ->(_) {},
102
+ Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
103
+ Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
104
+ Methods::COMPLETION_COMPLETE => ->(_) { { completion: { values: [], hasMore: false } } },
105
105
  Methods::ELICITATION_CREATE => ->(_) {},
106
106
  }
107
107
  @transport = transport
@@ -219,6 +219,14 @@ module MCP
219
219
  message = "Error occurred in server_info. `description` is not supported in protocol version 2025-06-18 or earlier"
220
220
  raise ArgumentError, message
221
221
  end
222
+
223
+ tools_with_ref = @tools.each_with_object([]) do |(tool_name, tool), names|
224
+ names << tool_name if schema_contains_ref?(tool.input_schema_value.to_h)
225
+ end
226
+ unless tools_with_ref.empty?
227
+ message = "Error occurred in #{tools_with_ref.join(", ")}. `$ref` in input schemas is supported by protocol version 2025-11-25 or higher"
228
+ raise ArgumentError, message
229
+ end
222
230
  end
223
231
 
224
232
  if @configuration.protocol_version <= "2025-03-26"
@@ -259,6 +267,17 @@ module MCP
259
267
  raise ToolNotUnique, duplicated_tool_names unless duplicated_tool_names.empty?
260
268
  end
261
269
 
270
+ def schema_contains_ref?(schema)
271
+ case schema
272
+ when Hash
273
+ schema.any? { |key, value| key.to_s == "$ref" || schema_contains_ref?(value) }
274
+ when Array
275
+ schema.any? { |element| schema_contains_ref?(element) }
276
+ else
277
+ false
278
+ end
279
+ end
280
+
262
281
  def handle_request(request, method)
263
282
  handler = @handlers[method]
264
283
  unless handler
@@ -362,6 +381,8 @@ module MCP
362
381
  end
363
382
 
364
383
  @logging_message_notification = logging_message_notification
384
+
385
+ {}
365
386
  end
366
387
 
367
388
  def list_tools(request)
@@ -375,7 +396,7 @@ module MCP
375
396
  unless tool
376
397
  add_instrumentation_data(tool_name: tool_name, error: :tool_not_found)
377
398
 
378
- return error_tool_response("Tool not found: #{tool_name}")
399
+ raise RequestHandlerError.new("Tool not found: #{tool_name}", request, error_type: :invalid_params)
379
400
  end
380
401
 
381
402
  arguments = request[:arguments] || {}
@@ -399,6 +420,8 @@ module MCP
399
420
  end
400
421
 
401
422
  call_tool_with_args(tool, arguments)
423
+ rescue RequestHandlerError
424
+ raise
402
425
  rescue => e
403
426
  report_exception(e, request: request)
404
427
 
@@ -31,10 +31,6 @@ module MCP
31
31
  case schema
32
32
  when Hash
33
33
  schema.each_with_object({}) do |(key, value), result|
34
- if key.casecmp?("$ref")
35
- raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool schemas"
36
- end
37
-
38
34
  result[yield(key)] = deep_transform_keys(value, &block)
39
35
  end
40
36
  when Array
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -33,6 +33,7 @@ files:
33
33
  - ".gitattributes"
34
34
  - ".github/dependabot.yml"
35
35
  - ".github/workflows/ci.yml"
36
+ - ".github/workflows/conformance.yml"
36
37
  - ".github/workflows/release.yml"
37
38
  - ".gitignore"
38
39
  - ".rubocop.yml"
@@ -44,10 +45,15 @@ files:
44
45
  - README.md
45
46
  - RELEASE.md
46
47
  - Rakefile
48
+ - SECURITY.md
47
49
  - bin/console
48
50
  - bin/generate-gh-pages.sh
49
51
  - bin/rake
50
52
  - bin/setup
53
+ - conformance/README.md
54
+ - conformance/expected_failures.yml
55
+ - conformance/runner.rb
56
+ - conformance/server.rb
51
57
  - dev.yml
52
58
  - docs/_config.yml
53
59
  - docs/index.md
@@ -98,7 +104,7 @@ licenses:
98
104
  - Apache-2.0
99
105
  metadata:
100
106
  allowed_push_host: https://rubygems.org
101
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.7.0
107
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.8.0
102
108
  homepage_uri: https://github.com/modelcontextprotocol/ruby-sdk
103
109
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
104
110
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues