mcp 0.7.1 → 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 +4 -4
- data/.github/workflows/conformance.yml +29 -0
- data/.gitignore +1 -0
- data/CHANGELOG.md +17 -0
- data/README.md +6 -0
- data/Rakefile +40 -1
- data/conformance/README.md +103 -0
- data/conformance/expected_failures.yml +9 -0
- data/conformance/runner.rb +101 -0
- data/conformance/server.rb +547 -0
- data/lib/mcp/content.rb +28 -1
- data/lib/mcp/prompt.rb +4 -3
- data/lib/mcp/server/transports/streamable_http_transport.rb +6 -5
- data/lib/mcp/server.rb +22 -3
- data/lib/mcp/tool/schema.rb +0 -4
- data/lib/mcp/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 149cbf6f8809235111648ffae6163abb1994cb5c2a4bd2240eb696f17197e421
|
|
4
|
+
data.tar.gz: 7c526f405b013d868032122484079ce715ef8db9629d78dc5c8635fccaf2fc6f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ 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
|
+
|
|
10
27
|
## [0.7.1] - 2026-02-21
|
|
11
28
|
|
|
12
29
|
### Fixed
|
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, :
|
|
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
|
|
@@ -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,
|
|
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
|
|
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,
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
292
|
-
rescue
|
|
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
|
|
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
|
data/lib/mcp/tool/schema.rb
CHANGED
|
@@ -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
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.
|
|
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"
|
|
@@ -49,6 +50,10 @@ files:
|
|
|
49
50
|
- bin/generate-gh-pages.sh
|
|
50
51
|
- bin/rake
|
|
51
52
|
- bin/setup
|
|
53
|
+
- conformance/README.md
|
|
54
|
+
- conformance/expected_failures.yml
|
|
55
|
+
- conformance/runner.rb
|
|
56
|
+
- conformance/server.rb
|
|
52
57
|
- dev.yml
|
|
53
58
|
- docs/_config.yml
|
|
54
59
|
- docs/index.md
|
|
@@ -99,7 +104,7 @@ licenses:
|
|
|
99
104
|
- Apache-2.0
|
|
100
105
|
metadata:
|
|
101
106
|
allowed_push_host: https://rubygems.org
|
|
102
|
-
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.
|
|
107
|
+
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.8.0
|
|
103
108
|
homepage_uri: https://github.com/modelcontextprotocol/ruby-sdk
|
|
104
109
|
source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
|
|
105
110
|
bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
|