├── .github └── workflows │ └── main.yml ├── .gitignore ├── .reek.yml ├── .rspec ├── .solargraph.yml ├── .standard.yml ├── .tool-versions ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── model_context_protocol.rb └── model_context_protocol │ ├── server.rb │ ├── server │ ├── completion.rb │ ├── configuration.rb │ ├── prompt.rb │ ├── registry.rb │ ├── resource.rb │ ├── resource_template.rb │ ├── router.rb │ ├── stdio_transport.rb │ └── tool.rb │ └── version.rb ├── model-context-protocol-rb.gemspec ├── spec ├── lib │ ├── model_context_protocol │ │ ├── server │ │ │ ├── completion_spec.rb │ │ │ ├── configuration_spec.rb │ │ │ ├── prompt_spec.rb │ │ │ ├── registry_spec.rb │ │ │ ├── resource_spec.rb │ │ │ ├── resource_template_spec.rb │ │ │ ├── router_spec.rb │ │ │ ├── stdio_transport_spec.rb │ │ │ └── tool_spec.rb │ │ └── server_spec.rb │ └── model_context_protocol_spec.rb ├── spec_helper.rb └── support │ ├── completions │ ├── test_completion.rb │ └── test_resource_template_completion.rb │ ├── prompts │ └── test_prompt.rb │ ├── resource_templates │ └── test_resource_template.rb │ ├── resources │ ├── test_binary_resource.rb │ └── test_resource.rb │ ├── test_invalid_class.rb │ └── tools │ ├── test_tool_with_image_response.rb │ ├── test_tool_with_image_response_default_mime_type.rb │ ├── test_tool_with_resource_response.rb │ ├── test_tool_with_resource_response_default_mime_type.rb │ ├── test_tool_with_text_response.rb │ └── test_tool_with_tool_error_response.rb └── tasks ├── mcp.rake └── templates └── dev.erb /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | standard: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | ruby-version: ['3.2.4'] 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Ruby 17 | uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ${{ matrix.ruby-version }} 20 | bundler-cache: true 21 | - name: Run standard 22 | run: bundle exec standardrb --format github 23 | 24 | reek: 25 | runs-on: ubuntu-latest 26 | strategy: 27 | matrix: 28 | ruby-version: ['3.2.4'] 29 | steps: 30 | - uses: actions/checkout@v2 31 | - name: Set up Ruby 32 | uses: ruby/setup-ruby@v1 33 | with: 34 | ruby-version: ${{ matrix.ruby-version }} 35 | bundler-cache: true 36 | - name: Run reek 37 | run: bundle exec reek 38 | 39 | rspec: 40 | runs-on: ubuntu-latest 41 | strategy: 42 | matrix: 43 | ruby-version: ['3.3.3', '3.2.4'] 44 | steps: 45 | - uses: actions/checkout@v3 46 | - name: Set up Ruby 47 | uses: ruby/setup-ruby@v1 48 | with: 49 | ruby-version: ${{ matrix.ruby-version }} 50 | bundler-cache: true 51 | - name: Specs 52 | run: bundle exec rspec 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | *.gem 13 | bin/dev 14 | -------------------------------------------------------------------------------- /.reek.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dickdavis/model-context-protocol-rb/f89b644b7fffe66e3f43b93f938cff47cfbc01de/.reek.yml -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.solargraph.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - "**/*.rb" 3 | exclude: 4 | - spec/**/* 5 | - ".bundle/**" 6 | require: [] 7 | domains: [] 8 | require_paths: [] 9 | plugins: 10 | - solargraph-standardrb 11 | reporters: 12 | - standardrb 13 | max_files: 5000 14 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | # For available configuration options, see: 2 | # https://github.com/testdouble/standard 3 | ruby_version: 3.2 4 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.2.4 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [0.3.2] - 2025-05-10 4 | 5 | - Added resource template support. 6 | - Added completion support for prompts and resources. 7 | - Improved metadata definition for prompts, resources, and tools using simple DSL. 8 | 9 | ## [0.3.1] - 2025-04-04 10 | 11 | - Added support for environment variables to MCP servers (thanks @hmk): 12 | - `require_environment_variable` method to specify required environment variables 13 | - `set_environment_variable` method to programmatically set environment variables 14 | - Environment variables accessible within tool/prompt/resource handlers 15 | - Added `respond_with` helper methods to simplify response creation: 16 | - For tools: text, image, resource, and error responses 17 | - For prompts: formatted message responses 18 | - For resources: text and binary responses 19 | - Improved development tooling: 20 | - Generated executable now loads all test classes 21 | - Fixed test support classes for better compatibility with MCP inspector 22 | - Organized test tools, prompts, and resources in dedicated directories 23 | 24 | ## [0.3.0] - 2025-03-11 25 | 26 | - (Breaking) Replaced router initialization in favor of registry initialization during server configuration. The server now relies on the registry for auto-discovery of prompts, resources, and tools; this requires the use of SDK-provided builders to facilitate. 27 | - (Breaking) Implemented the use of `Data` objects across the implementation. As a result, responses from custom handlers must now respond with an object that responds to `serialized`. 28 | - Refactored the implementation to maintain separation of concerns and improve testability/maintainability. 29 | - Improved test coverage. 30 | - Improved development tooling. 31 | 32 | ## [0.2.0] - 2025-01-13 33 | 34 | - Added a basic, synchronous server implementation that routes requests to custom handlers. 35 | 36 | ## [0.1.0] - 2025-01-10 37 | 38 | - Initial release 39 | 40 | [Unreleased]: https://github.com/dickdavis/model-context-protocol-rb/compare/v0.3.2...HEAD 41 | [0.3.1]: https://github.com/dickdavis/model-context-protocol-rb/compare/v0.3.1...v0.3.2 42 | [0.3.1]: https://github.com/dickdavis/model-context-protocol-rb/compare/v0.3.0...v0.3.1 43 | [0.3.0]: https://github.com/dickdavis/model-context-protocol-rb/compare/v0.2.0...v0.3.0 44 | [0.2.0]: https://github.com/dickdavis/model-context-protocol-rb/compare/v0.1.0...v0.2.0 45 | [0.1.0]: https://github.com/dickdavis/model-context-protocol-rb/releases/tag/v0.1.0 46 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | group :development do 8 | gem "rake" 9 | gem "reek" 10 | gem "solargraph" 11 | gem "solargraph-standardrb" 12 | gem "standard" 13 | end 14 | 15 | group :development, :test do 16 | gem "debug" 17 | end 18 | 19 | group :test do 20 | gem "rspec" 21 | end 22 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | model-context-protocol-rb (0.3.2) 5 | addressable (~> 2.8) 6 | json-schema (~> 5.1) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | addressable (2.8.7) 12 | public_suffix (>= 2.0.2, < 7.0) 13 | ast (2.4.2) 14 | backport (1.2.0) 15 | benchmark (0.4.0) 16 | bigdecimal (3.1.9) 17 | concurrent-ruby (1.3.4) 18 | date (3.4.1) 19 | debug (1.10.0) 20 | irb (~> 1.10) 21 | reline (>= 0.3.8) 22 | diff-lcs (1.5.1) 23 | dry-configurable (1.3.0) 24 | dry-core (~> 1.1) 25 | zeitwerk (~> 2.6) 26 | dry-core (1.1.0) 27 | concurrent-ruby (~> 1.0) 28 | logger 29 | zeitwerk (~> 2.6) 30 | dry-inflector (1.2.0) 31 | dry-initializer (3.2.0) 32 | dry-logic (1.6.0) 33 | bigdecimal 34 | concurrent-ruby (~> 1.0) 35 | dry-core (~> 1.1) 36 | zeitwerk (~> 2.6) 37 | dry-schema (1.13.4) 38 | concurrent-ruby (~> 1.0) 39 | dry-configurable (~> 1.0, >= 1.0.1) 40 | dry-core (~> 1.0, < 2) 41 | dry-initializer (~> 3.0) 42 | dry-logic (>= 1.4, < 2) 43 | dry-types (>= 1.7, < 2) 44 | zeitwerk (~> 2.6) 45 | dry-types (1.8.0) 46 | bigdecimal (~> 3.0) 47 | concurrent-ruby (~> 1.0) 48 | dry-core (~> 1.0) 49 | dry-inflector (~> 1.0) 50 | dry-logic (~> 1.4) 51 | zeitwerk (~> 2.6) 52 | io-console (0.8.0) 53 | irb (1.14.3) 54 | rdoc (>= 4.0.0) 55 | reline (>= 0.4.2) 56 | jaro_winkler (1.6.0) 57 | json (2.9.1) 58 | json-schema (5.1.1) 59 | addressable (~> 2.8) 60 | bigdecimal (~> 3.1) 61 | kramdown (2.5.1) 62 | rexml (>= 3.3.9) 63 | kramdown-parser-gfm (1.1.0) 64 | kramdown (~> 2.0) 65 | language_server-protocol (3.17.0.3) 66 | lint_roller (1.1.0) 67 | logger (1.6.5) 68 | nokogiri (1.18.3-arm64-darwin) 69 | racc (~> 1.4) 70 | nokogiri (1.18.3-x86_64-linux-gnu) 71 | racc (~> 1.4) 72 | observer (0.1.2) 73 | ostruct (0.6.1) 74 | parallel (1.26.3) 75 | parser (3.3.6.0) 76 | ast (~> 2.4.1) 77 | racc 78 | psych (5.2.2) 79 | date 80 | stringio 81 | public_suffix (6.0.1) 82 | racc (1.8.1) 83 | rainbow (3.1.1) 84 | rake (13.2.1) 85 | rbs (3.8.1) 86 | logger 87 | rdoc (6.10.0) 88 | psych (>= 4.0.0) 89 | reek (6.3.0) 90 | dry-schema (~> 1.13.0) 91 | parser (~> 3.3.0) 92 | rainbow (>= 2.0, < 4.0) 93 | rexml (~> 3.1) 94 | regexp_parser (2.10.0) 95 | reline (0.6.0) 96 | io-console (~> 0.5) 97 | reverse_markdown (3.0.0) 98 | nokogiri 99 | rexml (3.4.0) 100 | rspec (3.13.0) 101 | rspec-core (~> 3.13.0) 102 | rspec-expectations (~> 3.13.0) 103 | rspec-mocks (~> 3.13.0) 104 | rspec-core (3.13.2) 105 | rspec-support (~> 3.13.0) 106 | rspec-expectations (3.13.3) 107 | diff-lcs (>= 1.2.0, < 2.0) 108 | rspec-support (~> 3.13.0) 109 | rspec-mocks (3.13.2) 110 | diff-lcs (>= 1.2.0, < 2.0) 111 | rspec-support (~> 3.13.0) 112 | rspec-support (3.13.2) 113 | rubocop (1.69.2) 114 | json (~> 2.3) 115 | language_server-protocol (>= 3.17.0) 116 | parallel (~> 1.10) 117 | parser (>= 3.3.0.2) 118 | rainbow (>= 2.2.2, < 4.0) 119 | regexp_parser (>= 2.9.3, < 3.0) 120 | rubocop-ast (>= 1.36.2, < 2.0) 121 | ruby-progressbar (~> 1.7) 122 | unicode-display_width (>= 2.4.0, < 4.0) 123 | rubocop-ast (1.37.0) 124 | parser (>= 3.3.1.0) 125 | rubocop-performance (1.23.1) 126 | rubocop (>= 1.48.1, < 2.0) 127 | rubocop-ast (>= 1.31.1, < 2.0) 128 | ruby-progressbar (1.13.0) 129 | solargraph (0.52.0) 130 | backport (~> 1.2) 131 | benchmark 132 | bundler (~> 2.0) 133 | diff-lcs (~> 1.4) 134 | jaro_winkler (~> 1.6) 135 | kramdown (~> 2.3) 136 | kramdown-parser-gfm (~> 1.1) 137 | logger (~> 1.6) 138 | observer (~> 0.1) 139 | ostruct (~> 0.6) 140 | parser (~> 3.0) 141 | rbs (~> 3.0) 142 | reverse_markdown (>= 2.0, < 4) 143 | rubocop (~> 1.38) 144 | thor (~> 1.0) 145 | tilt (~> 2.0) 146 | yard (~> 0.9, >= 0.9.24) 147 | yard-solargraph (~> 0.1) 148 | solargraph-standardrb (0.0.4) 149 | solargraph (>= 0.39.1) 150 | standard (>= 0.4.1) 151 | standard (1.43.0) 152 | language_server-protocol (~> 3.17.0.2) 153 | lint_roller (~> 1.0) 154 | rubocop (~> 1.69.1) 155 | standard-custom (~> 1.0.0) 156 | standard-performance (~> 1.6) 157 | standard-custom (1.0.2) 158 | lint_roller (~> 1.0) 159 | rubocop (~> 1.50) 160 | standard-performance (1.6.0) 161 | lint_roller (~> 1.1) 162 | rubocop-performance (~> 1.23.0) 163 | stringio (3.1.2) 164 | thor (1.3.2) 165 | tilt (2.6.0) 166 | unicode-display_width (3.1.3) 167 | unicode-emoji (~> 4.0, >= 4.0.4) 168 | unicode-emoji (4.0.4) 169 | yard (0.9.37) 170 | yard-solargraph (0.1.0) 171 | yard (~> 0.9) 172 | zeitwerk (2.7.1) 173 | 174 | PLATFORMS 175 | arm64-darwin-23 176 | arm64-darwin-24 177 | x86_64-linux 178 | 179 | DEPENDENCIES 180 | debug 181 | model-context-protocol-rb! 182 | rake 183 | reek 184 | rspec 185 | solargraph 186 | solargraph-standardrb 187 | standard 188 | 189 | BUNDLED WITH 190 | 2.4.19 191 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Dick Davis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # model-context-protocol-rb 2 | 3 | An implementation of the [Model Context Protocol (MCP)](https://spec.modelcontextprotocol.io/specification/2024-11-05/) in Ruby. 4 | 5 | This SDK is experimental and subject to change. The initial focus is to implement MCP server support with the goal of providing a stable API by version `0.4`. MCP client support will follow. 6 | 7 | You are welcome to contribute. 8 | 9 | TODO's: 10 | 11 | * [Pagination](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/utilities/pagination/) 12 | * [Prompt list changed notifications](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/#list-changed-notification) 13 | * [Resource list changed notifications](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#list-changed-notification) 14 | * [Resource subscriptions](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#subscriptions) 15 | * [Tool list changed notifications](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#list-changed-notification) 16 | 17 | ## Usage 18 | 19 | Include `model_context_protocol` in your project. 20 | 21 | ```ruby 22 | require 'model_context_protocol' 23 | ``` 24 | 25 | ### Building an MCP Server 26 | 27 | Build a simple MCP server by registering your prompts, resources, resource templates, and tools. Then, configure and run the server. 28 | 29 | ```ruby 30 | server = ModelContextProtocol::Server.new do |config| 31 | config.name = "MCP Development Server" 32 | config.version = "1.0.0" 33 | config.enable_log = true 34 | 35 | # Environment Variables - https://modelcontextprotocol.io/docs/tools/debugging#environment-variables 36 | # Require specific environment variables to be set 37 | config.require_environment_variable("API_KEY") 38 | 39 | # Set environment variables programmatically 40 | config.set_environment_variable("DEBUG_MODE", "true") 41 | 42 | config.registry = ModelContextProtocol::Server::Registry.new do 43 | prompts list_changed: true do 44 | register TestPrompt 45 | end 46 | 47 | resources list_changed: true, subscribe: true do 48 | register TestResource 49 | end 50 | 51 | resource_templates do 52 | register TestResourceTemplate 53 | end 54 | 55 | tools list_changed: true do 56 | register TestTool 57 | end 58 | end 59 | end 60 | 61 | server.start 62 | ``` 63 | 64 | Messages from the MCP client will be routed to the appropriate custom handler. This SDK provides several classes that should be used to build your handlers. 65 | 66 | #### Prompts 67 | 68 | The `ModelContextProtocol::Server::Prompt` base class allows subclasses to define a prompt that the MCP client can use. Define the [appropriate metadata](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/) in the `with_metadata` block. 69 | 70 | Define any arguments using the `with_argument` block. You can mark an argument as required, and you can optionally provide the class name of a service object that provides completions. See [Completions](#completions) for more information. 71 | 72 | Then implement the `call` method to build your prompt. Use the `respond_with` instance method to ensure your prompt responds with appropriately formatted response data. 73 | 74 | This is an example prompt that returns a properly formatted response: 75 | 76 | ```ruby 77 | class TestPrompt < ModelContextProtocol::Server::Prompt 78 | with_metadata do 79 | name "test_prompt" 80 | description "A test prompt" 81 | end 82 | 83 | with_argument do 84 | name "message" 85 | description "The thing to do" 86 | required true 87 | completion TestCompletion 88 | end 89 | 90 | with_argument do 91 | name "other" 92 | description "Another thing to do" 93 | required false 94 | end 95 | 96 | def call 97 | messages = [ 98 | { 99 | role: "user", 100 | content: { 101 | type: "text", 102 | text: "Do this: #{params["message"]}" 103 | } 104 | } 105 | ] 106 | 107 | respond_with messages: messages 108 | end 109 | end 110 | ``` 111 | 112 | #### Resources 113 | 114 | The `ModelContextProtocol::Server::Resource` base class allows subclasses to define a resource that the MCP client can use. Define the [appropriate metadata](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/) in the `with_metadata` block. 115 | 116 | Then, implement the `call` method to build your resource. Use the `respond_with` instance method to ensure your resource responds with appropriately formatted response data. 117 | 118 | This is an example resource that returns a text response: 119 | 120 | ```ruby 121 | class TestResource < ModelContextProtocol::Server::Resource 122 | with_metadata do 123 | name "Test Resource" 124 | description "A test resource" 125 | mime_type "text/plain" 126 | uri "resource://test-resource" 127 | end 128 | 129 | def call 130 | respond_with :text, text: "Here's the data" 131 | end 132 | end 133 | ``` 134 | 135 | This is an example resource that returns binary data: 136 | 137 | ```ruby 138 | class TestBinaryResource < ModelContextProtocol::Server::Resource 139 | with_metadata do 140 | name "Project Logo" 141 | description "The logo for the project" 142 | mime_type "image/jpeg" 143 | uri "resource://project-logo" 144 | end 145 | 146 | def call 147 | # In a real implementation, we would retrieve the binary resource 148 | data = "dGVzdA==" 149 | respond_with :binary, blob: data 150 | end 151 | end 152 | ``` 153 | 154 | #### Resource Templates 155 | 156 | The `ModelContextProtocol::Server::ResourceTemplate` base class allows subclasses to define a resource template that the MCP client can use. Define the [appropriate metadata](https://modelcontextprotocol.io/specification/2024-11-05/server/resources#resource-templates) in the `with_metadata` block. 157 | 158 | This is an example resource template that provides a completion for a parameter of the URI template: 159 | 160 | ```ruby 161 | class TestResourceTemplateCompletion < ModelContextProtocol::Server::Completion 162 | def call 163 | hints = { 164 | "name" => ["test-resource", "project-logo"] 165 | } 166 | values = hints[argument_name].grep(/#{argument_value}/) 167 | 168 | respond_with values: 169 | end 170 | end 171 | 172 | class TestResourceTemplate < ModelContextProtocol::Server::ResourceTemplate 173 | with_metadata do 174 | name "Test Resource Template" 175 | description "A test resource template" 176 | mime_type "text/plain" 177 | uri_template "resource://{name}" do 178 | completion :name, TestResourceTemplateCompletion 179 | end 180 | end 181 | end 182 | ``` 183 | 184 | #### Tools 185 | 186 | The `ModelContextProtocol::Server::Tool` base class allows subclasses to define a tool that the MCP client can use. Define the [appropriate metadata](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/) in the `with_metadata` block. 187 | 188 | Then implement the `call` method to build your tool. Use the `respond_with` instance method to ensure your tool responds with appropriately formatted response data. 189 | 190 | This is an example tool that returns a text response: 191 | 192 | ```ruby 193 | class TestToolWithTextResponse < ModelContextProtocol::Server::Tool 194 | with_metadata do 195 | name "double" 196 | description "Doubles the provided number" 197 | input_schema do 198 | { 199 | type: "object", 200 | properties: { 201 | number: { 202 | type: "string" 203 | } 204 | }, 205 | required: ["number"] 206 | } 207 | end 208 | end 209 | 210 | def call 211 | number = params["number"].to_i 212 | result = number * 2 213 | respond_with :text, text: "#{number} doubled is #{result}" 214 | end 215 | end 216 | ``` 217 | 218 | This is an example of a tool that returns an image: 219 | 220 | ```ruby 221 | class TestToolWithImageResponse < ModelContextProtocol::Server::Tool 222 | with_metadata do 223 | name "custom-chart-generator" 224 | description "Generates a chart in various formats" 225 | input_schema do 226 | { 227 | type: "object", 228 | properties: { 229 | chart_type: { 230 | type: "string", 231 | description: "Type of chart (pie, bar, line)" 232 | }, 233 | format: { 234 | type: "string", 235 | description: "Image format (jpg, svg, etc)" 236 | } 237 | }, 238 | required: ["chart_type", "format"] 239 | } 240 | end 241 | end 242 | 243 | def call 244 | # Map format to mime type 245 | mime_type = case params["format"].downcase 246 | when "svg" 247 | "image/svg+xml" 248 | when "jpg", "jpeg" 249 | "image/jpeg" 250 | else 251 | "image/png" 252 | end 253 | 254 | # In a real implementation, we would generate an actual chart 255 | # This is a small valid base64 encoded string (represents "test") 256 | chart_data = "dGVzdA==" 257 | respond_with :image, data: chart_data, mime_type: 258 | end 259 | end 260 | ``` 261 | 262 | If you don't provide a mime type, it will default to `image/png`. 263 | 264 | ```ruby 265 | class TestToolWithImageResponseDefaultMimeType < ModelContextProtocol::Server::Tool 266 | with_metadata do 267 | name "other-custom-chart-generator" 268 | description "Generates a chart" 269 | input_schema do 270 | { 271 | type: "object", 272 | properties: { 273 | chart_type: { 274 | type: "string", 275 | description: "Type of chart (pie, bar, line)" 276 | } 277 | }, 278 | required: ["chart_type"] 279 | } 280 | end 281 | end 282 | 283 | def call 284 | # In a real implementation, we would generate an actual chart 285 | # This is a small valid base64 encoded string (represents "test") 286 | chart_data = "dGVzdA==" 287 | respond_with :image, data: chart_data 288 | end 289 | end 290 | ``` 291 | 292 | This is an example of a tool that returns a resource response: 293 | 294 | ```ruby 295 | class TestToolWithResourceResponse < ModelContextProtocol::Server::Tool 296 | with_metadata do 297 | name "document-finder" 298 | description "Finds a the document with the given title" 299 | input_schema do 300 | { 301 | type: "object", 302 | properties: { 303 | title: { 304 | type: "string", 305 | description: "The title of the document" 306 | } 307 | }, 308 | required: ["title"] 309 | } 310 | end 311 | end 312 | 313 | def call 314 | title = params["title"].downcase 315 | # In a real implementation, we would do a lookup to get the document data 316 | document = "richtextdata" 317 | respond_with :resource, uri: "resource://document/#{title}", text: document, mime_type: "application/rtf" 318 | end 319 | end 320 | ``` 321 | 322 | ### Completions 323 | 324 | The `ModelContextProtocol::Server::Completion` base class allows subclasses to define a completion that the MCP client can use to obtain hints or suggestions for arguments to prompts and resources. 325 | 326 | implement the `call` method to build your completion. Use the `respond_with` instance method to ensure your completion responds with appropriately formatted response data. 327 | 328 | This is an example completion that returns an array of values in the response: 329 | 330 | ```ruby 331 | class TestCompletion < ModelContextProtocol::Server::Completion 332 | def call 333 | hints = { 334 | "message" => ["hello", "world", "foo", "bar"] 335 | } 336 | values = hints[argument_name].grep(/#{argument_value}/) 337 | 338 | respond_with values: 339 | end 340 | end 341 | ``` 342 | 343 | ## Installation 344 | 345 | Add this line to your application's Gemfile: 346 | 347 | ```ruby 348 | gem 'model-context-protocol-rb' 349 | ``` 350 | 351 | And then execute: 352 | 353 | ```bash 354 | bundle 355 | ``` 356 | 357 | Or install it yourself as: 358 | 359 | ```bash 360 | gem install model-context-protocol-rb 361 | ``` 362 | 363 | ## Development 364 | 365 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. 366 | 367 | Generate an executable that you can use for testing: 368 | 369 | ```bash 370 | bundle exec rake mcp:generate_executable 371 | ``` 372 | 373 | This will generate a `bin/dev` executable you can provide to MCP clients. 374 | 375 | You can also run `bin/console` for an interactive prompt that will allow you to experiment. 376 | 377 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 378 | 379 | ## Contributing 380 | 381 | Bug reports and pull requests are welcome on GitHub at https://github.com/dickdavis/model-context-protocol-rb. 382 | 383 | ## License 384 | 385 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 386 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | require "standard/rake" 9 | 10 | Dir.glob("tasks/*.rake").each { |r| load r } 11 | 12 | task default: %i[spec standard] 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "irb" 6 | require "irb/command" 7 | require_relative "../lib/model_context_protocol" 8 | 9 | class ReloadProject < IRB::Command::Base 10 | category "Reload project" 11 | description "Reloads the project files" 12 | help_message <<~HELP 13 | Reloads the project files. 14 | 15 | Usage: rp 16 | HELP 17 | 18 | def execute(_arg) 19 | original_verbosity = $VERBOSE 20 | $VERBOSE = nil 21 | 22 | lib_path = File.expand_path('../../lib', __FILE__) 23 | Dir.glob(File.join(lib_path, '**', '*.rb')).sort.each do |file| 24 | load file 25 | end 26 | 27 | puts "Project reloaded successfully." 28 | ensure 29 | $VERBOSE = original_verbosity 30 | end 31 | end 32 | 33 | IRB::Command.register(:rp, ReloadProject) 34 | 35 | IRB.start(__FILE__) 36 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | -------------------------------------------------------------------------------- /lib/model_context_protocol.rb: -------------------------------------------------------------------------------- 1 | require "addressable/template" 2 | 3 | Dir[File.join(__dir__, "model_context_protocol/", "**", "*.rb")].sort.each { |file| require_relative file } 4 | 5 | ## 6 | # Top-level namespace 7 | module ModelContextProtocol 8 | end 9 | -------------------------------------------------------------------------------- /lib/model_context_protocol/server.rb: -------------------------------------------------------------------------------- 1 | require "logger" 2 | 3 | module ModelContextProtocol 4 | class Server 5 | # Raised when invalid response arguments are provided. 6 | class ResponseArgumentsError < StandardError; end 7 | 8 | # Raised when invalid parameters are provided. 9 | class ParameterValidationError < StandardError; end 10 | 11 | attr_reader :configuration, :router 12 | 13 | def initialize 14 | @configuration = Configuration.new 15 | yield(@configuration) if block_given? 16 | @router = Router.new(configuration:) 17 | map_handlers 18 | end 19 | 20 | def start 21 | configuration.validate! 22 | logdev = configuration.logging_enabled? ? $stderr : File::NULL 23 | StdioTransport.new(logger: Logger.new(logdev), router:).begin 24 | end 25 | 26 | private 27 | 28 | PROTOCOL_VERSION = "2024-11-05".freeze 29 | private_constant :PROTOCOL_VERSION 30 | 31 | InitializeResponse = Data.define(:protocol_version, :capabilities, :server_info) do 32 | def serialized 33 | { 34 | protocolVersion: protocol_version, 35 | capabilities: capabilities, 36 | serverInfo: server_info 37 | } 38 | end 39 | end 40 | 41 | PingResponse = Data.define do 42 | def serialized 43 | {} 44 | end 45 | end 46 | 47 | def map_handlers 48 | router.map("initialize") do |_message| 49 | InitializeResponse[ 50 | protocol_version: PROTOCOL_VERSION, 51 | capabilities: build_capabilities, 52 | server_info: { 53 | name: configuration.name, 54 | version: configuration.version 55 | } 56 | ] 57 | end 58 | 59 | router.map("ping") do 60 | PingResponse[] 61 | end 62 | 63 | router.map("completion/complete") do |message| 64 | type = message["params"]["ref"]["type"] 65 | 66 | completion_source = case type 67 | when "ref/prompt" 68 | name = message["params"]["ref"]["name"] 69 | configuration.registry.find_prompt(name) 70 | when "ref/resource" 71 | uri = message["params"]["ref"]["uri"] 72 | configuration.registry.find_resource_template(uri) 73 | else 74 | raise ModelContextProtocol::Server::ParameterValidationError, "ref/type invalid" 75 | end 76 | 77 | arg_name, arg_value = message["params"]["argument"].values_at("name", "value") 78 | 79 | if completion_source 80 | completion_source.complete_for(arg_name, arg_value) 81 | else 82 | ModelContextProtocol::Server::NullCompletion.call(arg_name, arg_value) 83 | end 84 | end 85 | 86 | router.map("resources/list") do 87 | configuration.registry.resources_data 88 | end 89 | 90 | router.map("resources/read") do |message| 91 | uri = message["params"]["uri"] 92 | resource = configuration.registry.find_resource(uri) 93 | unless resource 94 | raise ModelContextProtocol::Server::ParameterValidationError, "resource not found for #{uri}" 95 | end 96 | 97 | resource.call 98 | end 99 | 100 | router.map("resources/templates/list") do |message| 101 | configuration.registry.resource_templates_data 102 | end 103 | 104 | router.map("prompts/list") do 105 | configuration.registry.prompts_data 106 | end 107 | 108 | router.map("prompts/get") do |message| 109 | configuration.registry.find_prompt(message["params"]["name"]).call(message["params"]["arguments"]) 110 | end 111 | 112 | router.map("tools/list") do 113 | configuration.registry.tools_data 114 | end 115 | 116 | router.map("tools/call") do |message| 117 | configuration.registry.find_tool(message["params"]["name"]).call(message["params"]["arguments"]) 118 | end 119 | end 120 | 121 | def build_capabilities 122 | {}.tap do |capabilities| 123 | capabilities[:completions] = {} 124 | capabilities[:logging] = {} if configuration.logging_enabled? 125 | 126 | registry = configuration.registry 127 | 128 | if registry.prompts_options.any? && !registry.instance_variable_get(:@prompts).empty? 129 | capabilities[:prompts] = { 130 | listChanged: registry.prompts_options[:list_changed] 131 | }.except(:completions).compact 132 | end 133 | 134 | if registry.resources_options.any? && !registry.instance_variable_get(:@resources).empty? 135 | capabilities[:resources] = { 136 | subscribe: registry.resources_options[:subscribe], 137 | listChanged: registry.resources_options[:list_changed] 138 | }.compact 139 | end 140 | 141 | if registry.tools_options.any? && !registry.instance_variable_get(:@tools).empty? 142 | capabilities[:tools] = { 143 | listChanged: registry.tools_options[:list_changed] 144 | }.compact 145 | end 146 | end 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /lib/model_context_protocol/server/completion.rb: -------------------------------------------------------------------------------- 1 | module ModelContextProtocol 2 | class Server::Completion 3 | attr_reader :argument_name, :argument_value 4 | 5 | def initialize(argument_name, argument_value) 6 | @argument_name = argument_name 7 | @argument_value = argument_value 8 | end 9 | 10 | def call 11 | raise NotImplementedError, "Subclasses must implement the call method" 12 | end 13 | 14 | def self.call(...) 15 | new(...).call 16 | end 17 | 18 | private 19 | 20 | Response = Data.define(:values, :total, :hasMore) do 21 | def serialized 22 | {completion: {values:, total:, hasMore:}} 23 | end 24 | end 25 | 26 | def respond_with(values:) 27 | values_to_return = values.take(100) 28 | total = values.size 29 | has_more = values_to_return.size != total 30 | Response[values:, total:, hasMore: has_more] 31 | end 32 | end 33 | 34 | class Server::NullCompletion 35 | Response = Data.define(:values, :total, :hasMore) do 36 | def serialized 37 | {completion: {values:, total:, hasMore:}} 38 | end 39 | end 40 | 41 | def self.call(_argument_name, _argument_value) 42 | Response[values: [], total: 0, hasMore: false] 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/model_context_protocol/server/configuration.rb: -------------------------------------------------------------------------------- 1 | module ModelContextProtocol 2 | class Server::Configuration 3 | # Raised when configured with invalid name. 4 | class InvalidServerNameError < StandardError; end 5 | 6 | # Raised when configured with invalid version. 7 | class InvalidServerVersionError < StandardError; end 8 | 9 | # Raised when configured with invalid registry. 10 | class InvalidRegistryError < StandardError; end 11 | 12 | # Raised when a required environment variable is not set 13 | class MissingRequiredEnvironmentVariable < StandardError; end 14 | 15 | attr_accessor :enable_log, :name, :registry, :version 16 | 17 | def logging_enabled? 18 | enable_log || false 19 | end 20 | 21 | def validate! 22 | raise InvalidServerNameError unless valid_name? 23 | raise InvalidRegistryError unless valid_registry? 24 | raise InvalidServerVersionError unless valid_version? 25 | 26 | validate_environment_variables! 27 | end 28 | 29 | def environment_variables 30 | @environment_variables ||= {} 31 | end 32 | 33 | def environment_variable(key) 34 | environment_variables[key.to_s.upcase] || ENV[key.to_s.upcase] || nil 35 | end 36 | 37 | def require_environment_variable(key) 38 | required_environment_variables << key.to_s.upcase 39 | end 40 | 41 | # Programatically set an environment variable - useful if an alternative 42 | # to environment variables is used for security purposes. Despite being 43 | # more like 'configuration variables', these are called environment variables 44 | # to align with the Model Context Protocol terminology. 45 | # 46 | # see: https://modelcontextprotocol.io/docs/tools/debugging#environment-variables 47 | # 48 | # @param key [String] The key to set the environment variable for 49 | # @param value [String] The value to set the environment variable to 50 | def set_environment_variable(key, value) 51 | environment_variables[key.to_s.upcase] = value 52 | end 53 | 54 | private 55 | 56 | def required_environment_variables 57 | @required_environment_variables ||= [] 58 | end 59 | 60 | def validate_environment_variables! 61 | required_environment_variables.each do |key| 62 | raise MissingRequiredEnvironmentVariable, "#{key} is not set" unless environment_variable(key) 63 | end 64 | end 65 | 66 | def valid_name? 67 | name&.is_a?(String) 68 | end 69 | 70 | def valid_registry? 71 | registry&.is_a?(ModelContextProtocol::Server::Registry) 72 | end 73 | 74 | def valid_version? 75 | version&.is_a?(String) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/model_context_protocol/server/prompt.rb: -------------------------------------------------------------------------------- 1 | module ModelContextProtocol 2 | class Server::Prompt 3 | attr_reader :params 4 | 5 | def initialize(params) 6 | validate!(params) 7 | @params = params 8 | end 9 | 10 | def call 11 | raise NotImplementedError, "Subclasses must implement the call method" 12 | end 13 | 14 | Response = Data.define(:messages, :description) do 15 | def serialized 16 | {description:, messages:} 17 | end 18 | end 19 | private_constant :Response 20 | 21 | private def respond_with(messages:) 22 | Response[messages:, description: self.class.description] 23 | end 24 | 25 | private def validate!(params = {}) 26 | arguments = self.class.arguments || [] 27 | required_args = arguments.select { |arg| arg[:required] }.map { |arg| arg[:name] } 28 | valid_arg_names = arguments.map { |arg| arg[:name] } 29 | 30 | missing_args = required_args - params.keys 31 | unless missing_args.empty? 32 | missing_args_list = missing_args.join(", ") 33 | raise ArgumentError, "Missing required arguments: #{missing_args_list}" 34 | end 35 | 36 | extra_args = params.keys - valid_arg_names 37 | unless extra_args.empty? 38 | extra_args_list = extra_args.join(", ") 39 | raise ArgumentError, "Unexpected arguments: #{extra_args_list}" 40 | end 41 | end 42 | 43 | class << self 44 | attr_reader :name, :description, :arguments 45 | 46 | def with_metadata(&block) 47 | @arguments ||= [] 48 | 49 | metadata_dsl = MetadataDSL.new 50 | metadata_dsl.instance_eval(&block) 51 | 52 | @name = metadata_dsl.name 53 | @description = metadata_dsl.description 54 | end 55 | 56 | def with_argument(&block) 57 | @arguments ||= [] 58 | 59 | argument_dsl = ArgumentDSL.new 60 | argument_dsl.instance_eval(&block) 61 | 62 | @arguments << { 63 | name: argument_dsl.name, 64 | description: argument_dsl.description, 65 | required: argument_dsl.required, 66 | completion: argument_dsl.completion 67 | } 68 | end 69 | 70 | def inherited(subclass) 71 | subclass.instance_variable_set(:@name, @name) 72 | subclass.instance_variable_set(:@description, @description) 73 | subclass.instance_variable_set(:@arguments, @arguments&.dup) 74 | end 75 | 76 | def call(params) 77 | new(params).call 78 | rescue ArgumentError => error 79 | raise ModelContextProtocol::Server::ParameterValidationError, error.message 80 | end 81 | 82 | def metadata 83 | {name: @name, description: @description, arguments: @arguments} 84 | end 85 | 86 | def complete_for(arg_name, value) 87 | arg = @arguments&.find { |a| a[:name] == arg_name.to_s } 88 | completion = (arg && arg[:completion]) ? arg[:completion] : ModelContextProtocol::Server::NullCompletion 89 | completion.call(arg_name.to_s, value) 90 | end 91 | end 92 | 93 | class MetadataDSL 94 | def name(value = nil) 95 | @name = value if value 96 | @name 97 | end 98 | 99 | def description(value = nil) 100 | @description = value if value 101 | @description 102 | end 103 | end 104 | 105 | class ArgumentDSL 106 | def name(value = nil) 107 | @name = value if value 108 | @name 109 | end 110 | 111 | def description(value = nil) 112 | @description = value if value 113 | @description 114 | end 115 | 116 | def required(value = nil) 117 | @required = value unless value.nil? 118 | @required 119 | end 120 | 121 | def completion(klass = nil) 122 | @completion = klass unless klass.nil? 123 | @completion 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/model_context_protocol/server/registry.rb: -------------------------------------------------------------------------------- 1 | module ModelContextProtocol 2 | class Server::Registry 3 | attr_reader :prompts_options, :resources_options, :tools_options 4 | 5 | def self.new(&block) 6 | registry = allocate 7 | registry.send(:initialize) 8 | registry.instance_eval(&block) if block 9 | registry 10 | end 11 | 12 | def initialize 13 | @prompts = [] 14 | @resources = [] 15 | @resource_templates = [] 16 | @tools = [] 17 | @prompts_options = {} 18 | @resources_options = {} 19 | @tools_options = {} 20 | end 21 | 22 | def prompts(options = {}, &block) 23 | @prompts_options = options 24 | instance_eval(&block) if block 25 | end 26 | 27 | def resources(options = {}, &block) 28 | @resources_options = options 29 | instance_eval(&block) if block 30 | end 31 | 32 | def resource_templates(&block) 33 | instance_eval(&block) if block 34 | end 35 | 36 | def tools(options = {}, &block) 37 | @tools_options = options 38 | instance_eval(&block) if block 39 | end 40 | 41 | def register(klass) 42 | metadata = klass.metadata 43 | entry = {klass: klass}.merge(metadata) 44 | 45 | case klass.ancestors 46 | when ->(ancestors) { ancestors.include?(ModelContextProtocol::Server::Prompt) } 47 | @prompts << entry 48 | when ->(ancestors) { ancestors.include?(ModelContextProtocol::Server::Resource) } 49 | @resources << entry 50 | when ->(ancestors) { ancestors.include?(ModelContextProtocol::Server::ResourceTemplate) } 51 | @resource_templates << entry 52 | when ->(ancestors) { ancestors.include?(ModelContextProtocol::Server::Tool) } 53 | @tools << entry 54 | else 55 | raise ArgumentError, "Unknown class type: #{klass}" 56 | end 57 | end 58 | 59 | def find_prompt(name) 60 | find_by_name(@prompts, name) 61 | end 62 | 63 | def find_resource(uri) 64 | entry = @resources.find { |r| r[:uri] == uri } 65 | entry ? entry[:klass] : nil 66 | end 67 | 68 | def find_resource_template(uri) 69 | entry = @resource_templates.find { |r| uri == r[:uriTemplate] } 70 | entry ? entry[:klass] : nil 71 | end 72 | 73 | def find_tool(name) 74 | find_by_name(@tools, name) 75 | end 76 | 77 | def prompts_data 78 | PromptsData[prompts: @prompts.map { |entry| entry.except(:klass) }] 79 | end 80 | 81 | def resources_data 82 | ResourcesData[resources: @resources.map { |entry| entry.except(:klass) }] 83 | end 84 | 85 | def resource_templates_data 86 | ResourceTemplatesData[resource_templates: @resource_templates.map { |entry| entry.except(:klass, :completions) }] 87 | end 88 | 89 | def tools_data 90 | ToolsData[tools: @tools.map { |entry| entry.except(:klass) }] 91 | end 92 | 93 | private 94 | 95 | PromptsData = Data.define(:prompts) do 96 | def serialized 97 | {prompts:} 98 | end 99 | end 100 | 101 | ResourcesData = Data.define(:resources) do 102 | def serialized 103 | {resources:} 104 | end 105 | end 106 | 107 | ResourceTemplatesData = Data.define(:resource_templates) do 108 | def serialized 109 | {resourceTemplates: resource_templates} 110 | end 111 | end 112 | 113 | ToolsData = Data.define(:tools) do 114 | def serialized 115 | {tools:} 116 | end 117 | end 118 | 119 | def find_by_name(collection, name) 120 | entry = collection.find { |item| item[:name] == name } 121 | entry ? entry[:klass] : nil 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/model_context_protocol/server/resource.rb: -------------------------------------------------------------------------------- 1 | module ModelContextProtocol 2 | class Server::Resource 3 | attr_reader :mime_type, :uri 4 | 5 | def initialize 6 | @mime_type = self.class.mime_type 7 | @uri = self.class.uri 8 | end 9 | 10 | def call 11 | raise NotImplementedError, "Subclasses must implement the call method" 12 | end 13 | 14 | TextResponse = Data.define(:resource, :text) do 15 | def serialized 16 | {contents: [{mimeType: resource.mime_type, text:, uri: resource.uri}]} 17 | end 18 | end 19 | private_constant :TextResponse 20 | 21 | BinaryResponse = Data.define(:blob, :resource) do 22 | def serialized 23 | {contents: [{blob:, mimeType: resource.mime_type, uri: resource.uri}]} 24 | end 25 | end 26 | private_constant :BinaryResponse 27 | 28 | private def respond_with(type, **options) 29 | case [type, options] 30 | in [:text, {text:}] 31 | TextResponse[resource: self, text:] 32 | in [:binary, {blob:}] 33 | BinaryResponse[blob:, resource: self] 34 | else 35 | raise ModelContextProtocol::Server::ResponseArgumentsError, "Invalid arguments: #{type}, #{options}" 36 | end 37 | end 38 | 39 | class << self 40 | attr_reader :name, :description, :mime_type, :uri 41 | 42 | def with_metadata(&block) 43 | metadata_dsl = MetadataDSL.new 44 | metadata_dsl.instance_eval(&block) 45 | 46 | @name = metadata_dsl.name 47 | @description = metadata_dsl.description 48 | @mime_type = metadata_dsl.mime_type 49 | @uri = metadata_dsl.uri 50 | end 51 | 52 | def inherited(subclass) 53 | subclass.instance_variable_set(:@name, @name) 54 | subclass.instance_variable_set(:@description, @description) 55 | subclass.instance_variable_set(:@mime_type, @mime_type) 56 | subclass.instance_variable_set(:@uri, @uri) 57 | end 58 | 59 | def call 60 | new.call 61 | end 62 | 63 | def metadata 64 | {name: @name, description: @description, mimeType: @mime_type, uri: @uri} 65 | end 66 | end 67 | 68 | class MetadataDSL 69 | def name(value = nil) 70 | @name = value if value 71 | @name 72 | end 73 | 74 | def description(value = nil) 75 | @description = value if value 76 | @description 77 | end 78 | 79 | def mime_type(value = nil) 80 | @mime_type = value if value 81 | @mime_type 82 | end 83 | 84 | def uri(value = nil) 85 | @uri = value if value 86 | @uri 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/model_context_protocol/server/resource_template.rb: -------------------------------------------------------------------------------- 1 | module ModelContextProtocol 2 | class Server::ResourceTemplate 3 | class << self 4 | attr_reader :name, :description, :mime_type, :uri_template, :completions 5 | 6 | def with_metadata(&block) 7 | metadata_dsl = MetadataDSL.new 8 | metadata_dsl.instance_eval(&block) 9 | 10 | @name = metadata_dsl.name 11 | @description = metadata_dsl.description 12 | @mime_type = metadata_dsl.mime_type 13 | @uri_template = metadata_dsl.uri_template 14 | @completions = metadata_dsl.completions 15 | end 16 | 17 | def inherited(subclass) 18 | subclass.instance_variable_set(:@name, @name) 19 | subclass.instance_variable_set(:@description, @description) 20 | subclass.instance_variable_set(:@mime_type, @mime_type) 21 | subclass.instance_variable_set(:@uri_template, @uri_template) 22 | subclass.instance_variable_set(:@completions, @completions&.dup) 23 | end 24 | 25 | def complete_for(param_name, value) 26 | completion = if @completions && @completions[param_name.to_s] 27 | @completions[param_name.to_s] 28 | else 29 | ModelContextProtocol::Server::NullCompletion 30 | end 31 | 32 | completion.call(param_name.to_s, value) 33 | end 34 | 35 | def metadata 36 | { 37 | name: @name, 38 | description: @description, 39 | mimeType: @mime_type, 40 | uriTemplate: @uri_template, 41 | completions: @completions&.transform_keys(&:to_s) 42 | } 43 | end 44 | end 45 | 46 | class MetadataDSL 47 | attr_reader :completions 48 | 49 | def initialize 50 | @completions = {} 51 | end 52 | 53 | def name(value = nil) 54 | @name = value if value 55 | @name 56 | end 57 | 58 | def description(value = nil) 59 | @description = value if value 60 | @description 61 | end 62 | 63 | def mime_type(value = nil) 64 | @mime_type = value if value 65 | @mime_type 66 | end 67 | 68 | def uri_template(value = nil, &block) 69 | @uri_template = value if value 70 | 71 | if block_given? 72 | completion_dsl = CompletionDSL.new 73 | completion_dsl.instance_eval(&block) 74 | @completions = completion_dsl.completions 75 | end 76 | 77 | @uri_template 78 | end 79 | end 80 | 81 | class CompletionDSL 82 | attr_reader :completions 83 | 84 | def initialize 85 | @completions = {} 86 | end 87 | 88 | def completion(param_name, completion_class) 89 | @completions[param_name.to_s] = completion_class 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/model_context_protocol/server/router.rb: -------------------------------------------------------------------------------- 1 | module ModelContextProtocol 2 | class Server::Router 3 | # Raised when an invalid method is provided. 4 | class MethodNotFoundError < StandardError; end 5 | 6 | def initialize(configuration: nil) 7 | @handlers = {} 8 | @configuration = configuration 9 | end 10 | 11 | def map(method, &handler) 12 | @handlers[method] = handler 13 | end 14 | 15 | def route(message) 16 | method = message["method"] 17 | handler = @handlers[method] 18 | raise MethodNotFoundError, "Method not found: #{method}" unless handler 19 | 20 | with_environment(@configuration&.environment_variables) do 21 | handler.call(message) 22 | end 23 | end 24 | 25 | private 26 | 27 | def with_environment(vars) 28 | original = ENV.to_h 29 | vars&.each { |key, value| ENV[key] = value } 30 | yield 31 | ensure 32 | ENV.clear 33 | original.each { |key, value| ENV[key] = value } 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/model_context_protocol/server/stdio_transport.rb: -------------------------------------------------------------------------------- 1 | module ModelContextProtocol 2 | class Server::StdioTransport 3 | Response = Data.define(:id, :result) do 4 | def serialized 5 | {jsonrpc: "2.0", id:, result:} 6 | end 7 | end 8 | 9 | ErrorResponse = Data.define(:id, :error) do 10 | def serialized 11 | {jsonrpc: "2.0", id:, error:} 12 | end 13 | end 14 | 15 | attr_reader :logger, :router 16 | 17 | def initialize(logger:, router:) 18 | @logger = logger 19 | @router = router 20 | end 21 | 22 | def begin 23 | loop do 24 | line = $stdin.gets 25 | break unless line 26 | 27 | begin 28 | message = JSON.parse(line.chomp) 29 | next if message["method"].start_with?("notifications") 30 | 31 | result = router.route(message) 32 | send_message(Response[id: message["id"], result: result.serialized]) 33 | rescue ModelContextProtocol::Server::ParameterValidationError => validation_error 34 | log("Validation error: #{validation_error.message}") 35 | send_message( 36 | ErrorResponse[id: message["id"], error: {code: -32602, message: validation_error.message}] 37 | ) 38 | rescue JSON::ParserError => parser_error 39 | log("Parser error: #{parser_error.message}") 40 | send_message( 41 | ErrorResponse[id: "", error: {code: -32700, message: parser_error.message}] 42 | ) 43 | rescue => error 44 | log("Internal error: #{error.message}") 45 | log(error.backtrace) 46 | send_message( 47 | ErrorResponse[id: message["id"], error: {code: -32603, message: error.message}] 48 | ) 49 | end 50 | end 51 | end 52 | 53 | private 54 | 55 | def log(output, level = :error) 56 | logger.send(level.to_sym, output) 57 | end 58 | 59 | def send_message(message) 60 | message_json = JSON.generate(message.serialized) 61 | $stdout.puts(message_json) 62 | $stdout.flush 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/model_context_protocol/server/tool.rb: -------------------------------------------------------------------------------- 1 | require "json-schema" 2 | 3 | module ModelContextProtocol 4 | class Server::Tool 5 | attr_reader :params 6 | 7 | def initialize(params) 8 | validate!(params) 9 | @params = params 10 | end 11 | 12 | def call 13 | raise NotImplementedError, "Subclasses must implement the call method" 14 | end 15 | 16 | TextResponse = Data.define(:text) do 17 | def serialized 18 | {content: [{type: "text", text:}], isError: false} 19 | end 20 | end 21 | private_constant :TextResponse 22 | 23 | ImageResponse = Data.define(:data, :mime_type) do 24 | def initialize(data:, mime_type: "image/png") 25 | super 26 | end 27 | 28 | def serialized 29 | {content: [{type: "image", data:, mimeType: mime_type}], isError: false} 30 | end 31 | end 32 | private_constant :ImageResponse 33 | 34 | ResourceResponse = Data.define(:uri, :text, :mime_type) do 35 | def initialize(uri:, text:, mime_type: "text/plain") 36 | super 37 | end 38 | 39 | def serialized 40 | {content: [{type: "resource", resource: {uri:, mimeType: mime_type, text:}}], isError: false} 41 | end 42 | end 43 | private_constant :ResourceResponse 44 | 45 | ToolErrorResponse = Data.define(:text) do 46 | def serialized 47 | {content: [{type: "text", text:}], isError: true} 48 | end 49 | end 50 | private_constant :ToolErrorResponse 51 | 52 | private def respond_with(type, **options) 53 | case [type, options] 54 | in [:text, {text:}] 55 | TextResponse[text:] 56 | in [:image, {data:, mime_type:}] 57 | ImageResponse[data:, mime_type:] 58 | in [:image, {data:}] 59 | ImageResponse[data:] 60 | in [:resource, {mime_type:, text:, uri:}] 61 | ResourceResponse[mime_type:, text:, uri:] 62 | in [:resource, {text:, uri:}] 63 | ResourceResponse[text:, uri:] 64 | in [:error, {text:}] 65 | ToolErrorResponse[text:] 66 | else 67 | raise ModelContextProtocol::Server::ResponseArgumentsError, "Invalid arguments: #{type}, #{options}" 68 | end 69 | end 70 | 71 | private def validate!(params) 72 | JSON::Validator.validate!(self.class.input_schema, params) 73 | end 74 | 75 | class << self 76 | attr_reader :name, :description, :input_schema 77 | 78 | def with_metadata(&block) 79 | metadata_dsl = MetadataDSL.new 80 | metadata_dsl.instance_eval(&block) 81 | 82 | @name = metadata_dsl.name 83 | @description = metadata_dsl.description 84 | @input_schema = metadata_dsl.input_schema 85 | end 86 | 87 | def inherited(subclass) 88 | subclass.instance_variable_set(:@name, @name) 89 | subclass.instance_variable_set(:@description, @description) 90 | subclass.instance_variable_set(:@input_schema, @input_schema) 91 | end 92 | 93 | def call(params) 94 | new(params).call 95 | rescue JSON::Schema::ValidationError => validation_error 96 | raise ModelContextProtocol::Server::ParameterValidationError, validation_error.message 97 | rescue ModelContextProtocol::Server::ResponseArgumentsError => response_arguments_error 98 | raise response_arguments_error 99 | rescue => error 100 | ToolErrorResponse[text: error.message] 101 | end 102 | 103 | def metadata 104 | {name: @name, description: @description, inputSchema: @input_schema} 105 | end 106 | end 107 | 108 | class MetadataDSL 109 | def name(value = nil) 110 | @name = value if value 111 | @name 112 | end 113 | 114 | def description(value = nil) 115 | @description = value if value 116 | @description 117 | end 118 | 119 | def input_schema(&block) 120 | @input_schema = instance_eval(&block) if block_given? 121 | @input_schema 122 | end 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /lib/model_context_protocol/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ModelContextProtocol 4 | VERSION = "0.3.2" 5 | end 6 | -------------------------------------------------------------------------------- /model-context-protocol-rb.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/model_context_protocol/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "model-context-protocol-rb" 7 | spec.version = ModelContextProtocol::VERSION 8 | spec.authors = ["Dick Davis"] 9 | spec.email = ["dick@hey.com"] 10 | 11 | spec.summary = "An implementation of the Model Context Protocol (MCP) in Ruby." 12 | spec.homepage = "https://github.com/dickdavis/model-context-protocol-rb" 13 | spec.license = "MIT" 14 | spec.required_ruby_version = ">= 3.2.4" 15 | 16 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 17 | 18 | spec.metadata["homepage_uri"] = spec.homepage 19 | spec.metadata["source_code_uri"] = spec.homepage 20 | spec.metadata["changelog_uri"] = "https://github.com/dickdavis/model-context-protocol-rb/blob/main/CHANGELOG.md" 21 | 22 | spec.files = Dir.chdir(__dir__) do 23 | `git ls-files -z`.split("\x0").reject do |f| 24 | (File.expand_path(f) == __FILE__) || 25 | f.start_with?(*%w[bin/ spec/ .git Gemfile]) 26 | end 27 | end 28 | spec.require_paths = ["lib"] 29 | 30 | spec.add_dependency "json-schema", "~> 5.1" 31 | spec.add_dependency "addressable", "~> 2.8" 32 | end 33 | -------------------------------------------------------------------------------- /spec/lib/model_context_protocol/server/completion_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ModelContextProtocol::Server::Completion do 4 | describe ".call" do 5 | let(:argument_name) { "message" } 6 | let(:argument_value) { "f" } 7 | 8 | it "instantiates the tool with the provided parameters" do 9 | expect(TestCompletion).to receive(:new).with(argument_name, argument_value).and_call_original 10 | TestCompletion.call(argument_name, argument_value) 11 | end 12 | 13 | it "returns the response from the instance's call method" do 14 | response = TestCompletion.call(argument_name, argument_value) 15 | aggregate_failures do 16 | expect(response.values).to eq(["foo"]) 17 | expect(response.total).to eq(1) 18 | expect(response.hasMore).to be_falsey 19 | expect(response.serialized).to eq( 20 | completion: { 21 | values: ["foo"], 22 | total: 1, 23 | hasMore: false 24 | } 25 | ) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/lib/model_context_protocol/server/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ModelContextProtocol::Server::Configuration do 4 | subject(:configuration) { described_class.new } 5 | 6 | let(:registry) { ModelContextProtocol::Server::Registry.new } 7 | 8 | describe "#logging_enabled?" do 9 | context "when enable_log is true" do 10 | before { configuration.enable_log = true } 11 | 12 | it "returns true" do 13 | expect(configuration.logging_enabled?).to be true 14 | end 15 | end 16 | 17 | context "when enable_log is false" do 18 | before { configuration.enable_log = false } 19 | 20 | it "returns false" do 21 | expect(configuration.logging_enabled?).to be false 22 | end 23 | end 24 | 25 | context "when enable_log is nil" do 26 | before { configuration.enable_log = nil } 27 | 28 | it "returns false" do 29 | expect(configuration.logging_enabled?).to be false 30 | end 31 | end 32 | end 33 | 34 | describe "#validate!" do 35 | context "with valid configuration" do 36 | before do 37 | configuration.name = "test-server" 38 | configuration.registry = registry 39 | configuration.version = "1.0.0" 40 | end 41 | 42 | it "does not raise an error" do 43 | expect { configuration.validate! }.not_to raise_error 44 | end 45 | end 46 | 47 | context "with invalid name" do 48 | before do 49 | configuration.name = nil 50 | configuration.registry = registry 51 | configuration.version = "1.0.0" 52 | end 53 | 54 | it "raises InvalidServerNameError" do 55 | expect { configuration.validate! }.to raise_error(described_class::InvalidServerNameError) 56 | end 57 | end 58 | 59 | context "with non-string name" do 60 | before do 61 | configuration.name = 123 62 | configuration.registry = registry 63 | configuration.version = "1.0.0" 64 | end 65 | 66 | it "raises InvalidServerNameError" do 67 | expect { configuration.validate! }.to raise_error(described_class::InvalidServerNameError) 68 | end 69 | end 70 | 71 | context "with invalid registry" do 72 | before do 73 | configuration.name = "test-server" 74 | configuration.registry = nil 75 | configuration.version = "1.0.0" 76 | end 77 | 78 | it "raises InvalidRegistryError" do 79 | expect { configuration.validate! }.to raise_error(described_class::InvalidRegistryError) 80 | end 81 | end 82 | 83 | context "with non-registry object" do 84 | before do 85 | configuration.name = "test-server" 86 | configuration.registry = "not-a-registry" 87 | configuration.version = "1.0.0" 88 | end 89 | 90 | it "raises InvalidRegistryError" do 91 | expect { configuration.validate! }.to raise_error(described_class::InvalidRegistryError) 92 | end 93 | end 94 | 95 | context "with invalid version" do 96 | before do 97 | configuration.name = "test-server" 98 | configuration.registry = registry 99 | configuration.version = nil 100 | end 101 | 102 | it "raises InvalidServerVersionError" do 103 | expect { configuration.validate! }.to raise_error(described_class::InvalidServerVersionError) 104 | end 105 | end 106 | 107 | context "with non-string version" do 108 | before do 109 | configuration.name = "test-server" 110 | configuration.registry = registry 111 | configuration.version = 123 112 | end 113 | 114 | it "raises InvalidServerVersionError" do 115 | expect { configuration.validate! }.to raise_error(described_class::InvalidServerVersionError) 116 | end 117 | end 118 | end 119 | 120 | describe "environment variables" do 121 | context "when requiring environment variables" do 122 | before do 123 | configuration.name = "test-server" 124 | configuration.registry = registry 125 | configuration.version = "1.0.0" 126 | configuration.require_environment_variable("TEST_VAR") 127 | end 128 | 129 | context "when the environment variable is set" do 130 | before { ENV["TEST_VAR"] = "test-value" } 131 | 132 | it "does not raise an error" do 133 | expect { configuration.validate! }.not_to raise_error 134 | end 135 | 136 | it "returns the environment variable value" do 137 | expect(configuration.environment_variable("TEST_VAR")).to eq("test-value") 138 | end 139 | end 140 | 141 | context "when the environment variable is not set" do 142 | before { ENV.delete("TEST_VAR") } 143 | 144 | it "raises MissingRequiredEnvironmentVariable" do 145 | expect { configuration.validate! }.to raise_error(described_class::MissingRequiredEnvironmentVariable) 146 | end 147 | 148 | it "returns nil" do 149 | expect(configuration.environment_variable("TEST_VAR")).to be_nil 150 | end 151 | end 152 | 153 | context "when setting environment variable programmatically" do 154 | before do 155 | configuration.set_environment_variable("TEST_VAR", "programmatic-value") 156 | ENV.delete("TEST_VAR") 157 | end 158 | 159 | it "does not raise an error" do 160 | expect { configuration.validate! }.not_to raise_error 161 | end 162 | 163 | it "returns the programmatically set value" do 164 | expect(configuration.environment_variable("TEST_VAR")).to eq("programmatic-value") 165 | end 166 | end 167 | end 168 | 169 | context "when not requiring environment variables" do 170 | before do 171 | configuration.name = "test-server" 172 | configuration.registry = registry 173 | configuration.version = "1.0.0" 174 | end 175 | 176 | it "does not raise an error when environment variable is not set" do 177 | ENV.delete("TEST_VAR") 178 | expect { configuration.validate! }.not_to raise_error 179 | end 180 | 181 | it "returns nil for unset environment variable" do 182 | expect(configuration.environment_variable("TEST_VAR")).to be_nil 183 | end 184 | end 185 | end 186 | 187 | describe "block initialization" do 188 | it "allows configuration via block" do 189 | server = ModelContextProtocol::Server.new do |config| 190 | config.name = "test-server" 191 | config.registry = registry 192 | config.version = "1.0.0" 193 | config.enable_log = true 194 | end 195 | 196 | config = server.configuration 197 | expect(config.name).to eq("test-server") 198 | expect(config.registry).to eq(registry) 199 | expect(config.version).to eq("1.0.0") 200 | expect(config.enable_log).to be true 201 | end 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /spec/lib/model_context_protocol/server/prompt_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ModelContextProtocol::Server::Prompt do 4 | describe ".call" do 5 | context "when parameter validation fails" do 6 | let(:invalid_params) { {"foo" => "bar"} } 7 | 8 | it "raises a ParameterValidationError" do 9 | expect { 10 | TestPrompt.call(invalid_params) 11 | }.to raise_error(ModelContextProtocol::Server::ParameterValidationError) 12 | end 13 | end 14 | 15 | context "when parameter validation succeeds" do 16 | let(:valid_params) { {"message" => "Hello, world!"} } 17 | 18 | it "instantiates the prompt with the provided parameters" do 19 | expect(TestPrompt).to receive(:new).with(valid_params).and_call_original 20 | TestPrompt.call(valid_params) 21 | end 22 | 23 | it "returns the response from the instance's call method" do 24 | response = TestPrompt.call(valid_params) 25 | aggregate_failures do 26 | expect(response.messages).to eq( 27 | [ 28 | { 29 | role: "user", 30 | content: { 31 | type: "text", 32 | text: "Do this: Hello, world!" 33 | } 34 | } 35 | ] 36 | ) 37 | expect(response.serialized).to eq( 38 | description: "A test prompt", 39 | messages: [ 40 | { 41 | role: "user", 42 | content: { 43 | type: "text", 44 | text: "Do this: Hello, world!" 45 | } 46 | } 47 | ] 48 | ) 49 | end 50 | end 51 | end 52 | end 53 | 54 | describe "#initialize" do 55 | context "when no parameters are provided" do 56 | it "raises an ArgumentError" do 57 | expect { TestPrompt.new }.to raise_error(ArgumentError) 58 | end 59 | end 60 | 61 | context "when invalid parameters are provided" do 62 | it "raises an ArgumentError" do 63 | expect { TestPrompt.new({"foo" => "bar"}) }.to raise_error(ArgumentError) 64 | end 65 | end 66 | 67 | context "when valid parameters are provided" do 68 | it "stores the parameters" do 69 | prompt = TestPrompt.new({"message" => "Hello, world!"}) 70 | expect(prompt.params).to eq({"message" => "Hello, world!"}) 71 | end 72 | 73 | context "when optional parameters are provided" do 74 | it "stores the parameters" do 75 | prompt = TestPrompt.new({"message" => "Hello, world!", "other" => "Other thing"}) 76 | expect(prompt.params).to eq({"message" => "Hello, world!", "other" => "Other thing"}) 77 | end 78 | end 79 | end 80 | end 81 | 82 | describe ".with_metadata" do 83 | it "sets the class metadata" do 84 | aggregate_failures do 85 | expect(TestPrompt.name).to eq("test_prompt") 86 | expect(TestPrompt.description).to eq("A test prompt") 87 | end 88 | end 89 | end 90 | 91 | describe "with_argument" do 92 | it "adds arguments to an array" do 93 | expect(TestPrompt.arguments.size).to eq(2) 94 | end 95 | 96 | it "sets a required argument" do 97 | aggregate_failures do 98 | first_argument = TestPrompt.arguments[0] 99 | expect(first_argument[:name]).to eq("message") 100 | expect(first_argument[:description]).to eq("The thing to do") 101 | expect(first_argument[:required]).to eq(true) 102 | end 103 | end 104 | 105 | it "sets a optional argument" do 106 | aggregate_failures do 107 | second_argument = TestPrompt.arguments[1] 108 | expect(second_argument[:name]).to eq("other") 109 | expect(second_argument[:description]).to eq("Another thing to do") 110 | expect(second_argument[:required]).to eq(false) 111 | end 112 | end 113 | 114 | it "sets an argument with a completion proc" do 115 | first_argument = TestPrompt.arguments[0] 116 | expect(first_argument[:completion]).to be(TestCompletion) 117 | end 118 | 119 | it "sets an argument without a completion proc" do 120 | second_argument = TestPrompt.arguments[1] 121 | expect(second_argument[:completion]).to be_nil 122 | end 123 | end 124 | 125 | describe ".metadata" do 126 | it "returns class metadata" do 127 | metadata = TestPrompt.metadata 128 | expect(metadata[:name]).to eq("test_prompt") 129 | expect(metadata[:description]).to eq("A test prompt") 130 | expect(metadata[:arguments].size).to eq(2) 131 | 132 | first_arg = metadata[:arguments][0] 133 | expect(first_arg[:name]).to eq("message") 134 | expect(first_arg[:description]).to eq("The thing to do") 135 | expect(first_arg[:required]).to eq(true) 136 | expect(first_arg[:completion]).to be(TestCompletion) 137 | 138 | second_arg = metadata[:arguments][1] 139 | expect(second_arg[:name]).to eq("other") 140 | expect(second_arg[:description]).to eq("Another thing to do") 141 | expect(second_arg[:required]).to eq(false) 142 | expect(second_arg[:completion]).to be_nil 143 | end 144 | end 145 | 146 | describe ".complete_for" do 147 | context "when the argument does not exist" do 148 | it "returns nil" do 149 | result = TestPrompt.complete_for("nonexistent_argument", "f") 150 | expect(result).to be_a(ModelContextProtocol::Server::NullCompletion::Response) 151 | end 152 | end 153 | 154 | context "when the argument does not have a completion" do 155 | it "returns nil" do 156 | result = TestPrompt.complete_for("other_message", "f") 157 | expect(result).to be_a(ModelContextProtocol::Server::NullCompletion::Response) 158 | end 159 | end 160 | 161 | context "when the argument has a completion proc" do 162 | it "calls the completion proc with the argument name" do 163 | first_argument_completion = TestPrompt.arguments[0][:completion] 164 | argument_name = "message" 165 | argument_value = "f" 166 | allow(first_argument_completion).to receive(:call).with(argument_name, argument_value).and_call_original 167 | TestPrompt.complete_for(argument_name, argument_value) 168 | expect(first_argument_completion).to have_received(:call).with(argument_name, argument_value) 169 | end 170 | end 171 | 172 | context "when argument name is a symbol" do 173 | it "converts the symbol to a string" do 174 | first_argument_completion = TestPrompt.arguments[0][:completion] 175 | argument_name = "message" 176 | argument_value = "f" 177 | allow(first_argument_completion).to receive(:call).with(argument_name, argument_value).and_call_original 178 | TestPrompt.complete_for(argument_name.to_sym, argument_value) 179 | expect(first_argument_completion).to have_received(:call).with(argument_name, argument_value) 180 | end 181 | end 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /spec/lib/model_context_protocol/server/registry_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ModelContextProtocol::Server::Registry do 4 | describe "#new" do 5 | it "initializes with an empty registry when no block is provided" do 6 | registry = described_class.new 7 | 8 | expect(registry.instance_variable_get(:@prompts)).to be_empty 9 | expect(registry.instance_variable_get(:@resources)).to be_empty 10 | expect(registry.instance_variable_get(:@resource_templates)).to be_empty 11 | expect(registry.instance_variable_get(:@tools)).to be_empty 12 | end 13 | 14 | it "evaluates the block in the context of the registry" do 15 | registry = described_class.new do 16 | prompts list_changed: true 17 | resources list_changed: true, subscribe: true 18 | tools list_changed: true 19 | end 20 | 21 | expect(registry.prompts_options).to eq(list_changed: true) 22 | expect(registry.resources_options).to eq(list_changed: true, subscribe: true) 23 | expect(registry.tools_options).to eq(list_changed: true) 24 | end 25 | end 26 | 27 | describe "#register" do 28 | let(:registry) { described_class.new } 29 | 30 | it "registers a prompt class" do 31 | registry.register(TestPrompt) 32 | prompts = registry.instance_variable_get(:@prompts) 33 | 34 | expect(prompts.size).to eq(1) 35 | expect(prompts.first[:klass]).to eq(TestPrompt) 36 | expect(prompts.first[:name]).to eq("test_prompt") 37 | expect(prompts.first[:description]).to eq("A test prompt") 38 | expect(prompts.first[:arguments]).to be_an(Array) 39 | end 40 | 41 | it "registers a resource class" do 42 | registry.register(TestResource) 43 | resources = registry.instance_variable_get(:@resources) 44 | 45 | expect(resources.size).to eq(1) 46 | expect(resources.first[:klass]).to eq(TestResource) 47 | expect(resources.first[:name]).to eq("Test Resource") 48 | expect(resources.first[:uri]).to eq("resource:///test-resource") 49 | expect(resources.first[:description]).to eq("A test resource") 50 | expect(resources.first[:mimeType]).to eq("text/plain") 51 | end 52 | 53 | it "registers a resource template class" do 54 | registry.register(TestResourceTemplate) 55 | resource_templates = registry.instance_variable_get(:@resource_templates) 56 | 57 | expect(resource_templates.size).to eq(1) 58 | expect(resource_templates.first[:klass]).to eq(TestResourceTemplate) 59 | expect(resource_templates.first[:name]).to eq("Test Resource Template") 60 | expect(resource_templates.first[:uriTemplate]).to eq("resource:///{name}") 61 | expect(resource_templates.first[:description]).to eq("A test resource template") 62 | expect(resource_templates.first[:mimeType]).to eq("text/plain") 63 | end 64 | 65 | it "registers a tool class" do 66 | registry.register(TestToolWithTextResponse) 67 | tools = registry.instance_variable_get(:@tools) 68 | 69 | aggregate_failures do 70 | expect(tools.size).to eq(1) 71 | expect(tools.first[:klass]).to eq(TestToolWithTextResponse) 72 | expect(tools.first[:name]).to eq("double") 73 | expect(tools.first[:description]).to eq("Doubles the provided number") 74 | expect(tools.first[:inputSchema]).to be_a(Hash) 75 | end 76 | end 77 | 78 | it "raises an error for invalid class types" do 79 | expect { registry.register(TestInvalidClass) }.to raise_error(ArgumentError, "Unknown class type: TestInvalidClass") 80 | end 81 | end 82 | 83 | describe "DSL methods" do 84 | it "registers classes within the DSL blocks" do 85 | registry = described_class.new do 86 | prompts list_changed: true do 87 | register TestPrompt 88 | end 89 | 90 | resources list_changed: true, subscribe: true do 91 | register TestResource 92 | end 93 | 94 | resource_templates do 95 | register TestResourceTemplate 96 | end 97 | 98 | tools list_changed: true do 99 | register TestToolWithTextResponse 100 | end 101 | end 102 | 103 | expect(registry.instance_variable_get(:@prompts).size).to eq(1) 104 | expect(registry.instance_variable_get(:@resources).size).to eq(1) 105 | expect(registry.instance_variable_get(:@resource_templates).size).to eq(1) 106 | expect(registry.instance_variable_get(:@tools).size).to eq(1) 107 | expect(registry.prompts_options).to eq(list_changed: true) 108 | expect(registry.resources_options).to eq(list_changed: true, subscribe: true) 109 | expect(registry.tools_options).to eq(list_changed: true) 110 | end 111 | end 112 | 113 | describe "finder methods" do 114 | let(:registry) do 115 | described_class.new do 116 | prompts do 117 | register TestPrompt 118 | end 119 | 120 | resources do 121 | register TestResource 122 | end 123 | 124 | resource_templates do 125 | register TestResourceTemplate 126 | end 127 | 128 | tools do 129 | register TestToolWithTextResponse 130 | end 131 | end 132 | end 133 | 134 | describe "#find_prompt" do 135 | it "returns the prompt class when found" do 136 | expect(registry.find_prompt("test_prompt")).to eq(TestPrompt) 137 | end 138 | 139 | it "returns nil when the prompt is not found" do 140 | expect(registry.find_prompt("nonexistent_prompt")).to be_nil 141 | end 142 | end 143 | 144 | describe "#find_resource" do 145 | it "returns the resource class when found" do 146 | expect(registry.find_resource("resource:///test-resource")).to eq(TestResource) 147 | end 148 | 149 | it "returns nil when the resource is not found" do 150 | expect(registry.find_resource("resource://nonexistent")).to be_nil 151 | end 152 | end 153 | 154 | describe "#find_resource_template" do 155 | it "returns the resource template class when a matching URI is found" do 156 | uri = "resource:///{name}" 157 | expect(registry.find_resource_template(uri)).to eq(TestResourceTemplate) 158 | end 159 | 160 | it "returns nil when no matching template is found" do 161 | uri = "invalid://test-name" 162 | expect(registry.find_resource_template(uri)).to be_nil 163 | end 164 | end 165 | 166 | describe "#find_tool" do 167 | it "returns the tool class when found" do 168 | expect(registry.find_tool("double")).to eq(TestToolWithTextResponse) 169 | end 170 | 171 | it "returns nil when the tool is not found" do 172 | expect(registry.find_tool("nonexistent_tool")).to be_nil 173 | end 174 | end 175 | end 176 | 177 | describe "serialization methods" do 178 | let(:registry) do 179 | described_class.new do 180 | prompts do 181 | register TestPrompt 182 | end 183 | 184 | resources do 185 | register TestResource 186 | end 187 | 188 | resource_templates do 189 | register TestResourceTemplate 190 | end 191 | 192 | tools do 193 | register TestToolWithTextResponse 194 | end 195 | end 196 | end 197 | 198 | describe "#prompts_data" do 199 | it "returns a hash with prompts array without klass references" do 200 | result = registry.prompts_data 201 | 202 | aggregate_failures do 203 | expect(result).to be_a(ModelContextProtocol::Server::Registry::PromptsData) 204 | expect(result.prompts).to be_an(Array) 205 | expect(result.prompts.first).to include( 206 | name: "test_prompt", 207 | description: "A test prompt" 208 | ) 209 | expect(result.prompts.first).not_to have_key(:klass) 210 | end 211 | end 212 | end 213 | 214 | describe "#resources_data" do 215 | it "returns a hash with resources array without klass references" do 216 | result = registry.resources_data 217 | 218 | aggregate_failures do 219 | expect(result).to be_a(ModelContextProtocol::Server::Registry::ResourcesData) 220 | expect(result.resources).to be_an(Array) 221 | expect(result.resources.first).to include( 222 | name: "Test Resource", 223 | uri: "resource:///test-resource", 224 | description: "A test resource", 225 | mimeType: "text/plain" 226 | ) 227 | expect(result.resources.first).not_to have_key(:klass) 228 | end 229 | end 230 | end 231 | 232 | describe "#resource_templates_data" do 233 | it "returns a hash with resource templates array without klass references" do 234 | result = registry.resource_templates_data 235 | 236 | aggregate_failures do 237 | expect(result).to be_a(ModelContextProtocol::Server::Registry::ResourceTemplatesData) 238 | expect(result.resource_templates).to be_an(Array) 239 | expect(result.resource_templates.first).to include( 240 | name: "Test Resource Template", 241 | uriTemplate: "resource:///{name}", 242 | description: "A test resource template", 243 | mimeType: "text/plain" 244 | ) 245 | expect(result.resource_templates.first).not_to have_key(:klass) 246 | end 247 | end 248 | 249 | it "serializes resource templates data properly" do 250 | result = registry.resource_templates_data.serialized 251 | 252 | expect(result).to eq( 253 | resourceTemplates: [ 254 | { 255 | name: "Test Resource Template", 256 | uriTemplate: "resource:///{name}", 257 | description: "A test resource template", 258 | mimeType: "text/plain" 259 | } 260 | ] 261 | ) 262 | end 263 | end 264 | 265 | describe "#tools_data" do 266 | it "returns a hash with tools array without klass references" do 267 | result = registry.tools_data 268 | 269 | aggregate_failures do 270 | expect(result).to be_a(ModelContextProtocol::Server::Registry::ToolsData) 271 | expect(result.tools).to be_an(Array) 272 | expect(result.tools.first).to include( 273 | name: "double", 274 | description: "Doubles the provided number" 275 | ) 276 | expect(result.tools.first).to have_key(:inputSchema) 277 | expect(result.tools.first).not_to have_key(:klass) 278 | end 279 | end 280 | end 281 | end 282 | end 283 | -------------------------------------------------------------------------------- /spec/lib/model_context_protocol/server/resource_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ModelContextProtocol::Server::Resource do 4 | describe ".call" do 5 | it "returns the response from the instance's call method" do 6 | response = TestResource.call 7 | aggregate_failures do 8 | expect(response.text).to eq("Here's the data") 9 | expect(response.serialized).to eq( 10 | contents: [ 11 | { 12 | mimeType: "text/plain", 13 | text: "Here's the data", 14 | uri: "resource:///test-resource" 15 | } 16 | ] 17 | ) 18 | end 19 | end 20 | end 21 | 22 | describe "responses" do 23 | describe "text response" do 24 | it "formats text responses correctly" do 25 | response = TestResource.call 26 | expect(response.serialized).to eq( 27 | contents: [ 28 | { 29 | mimeType: "text/plain", 30 | text: "Here's the data", 31 | uri: "resource:///test-resource" 32 | } 33 | ] 34 | ) 35 | end 36 | end 37 | 38 | describe "binary response" do 39 | it "formats binary responses correctly" do 40 | response = TestBinaryResource.call 41 | 42 | expect(response.serialized).to eq( 43 | contents: [ 44 | { 45 | blob: "dGVzdA==", 46 | mimeType: "image/jpeg", 47 | uri: "resource:///project-logo" 48 | } 49 | ] 50 | ) 51 | end 52 | end 53 | end 54 | 55 | describe "with_metadata" do 56 | it "sets the class metadata" do 57 | aggregate_failures do 58 | expect(TestResource.name).to eq("Test Resource") 59 | expect(TestResource.description).to eq("A test resource") 60 | expect(TestResource.mime_type).to eq("text/plain") 61 | expect(TestResource.uri).to eq("resource:///test-resource") 62 | end 63 | end 64 | end 65 | 66 | describe "metadata" do 67 | it "returns class metadata" do 68 | expect(TestResource.metadata).to eq( 69 | name: "Test Resource", 70 | description: "A test resource", 71 | mimeType: "text/plain", 72 | uri: "resource:///test-resource" 73 | ) 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/lib/model_context_protocol/server/resource_template_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ModelContextProtocol::Server::ResourceTemplate do 4 | describe "with_metadata" do 5 | it "sets the class metadata" do 6 | aggregate_failures do 7 | expect(TestResourceTemplate.name).to eq("Test Resource Template") 8 | expect(TestResourceTemplate.description).to eq("A test resource template") 9 | expect(TestResourceTemplate.mime_type).to eq("text/plain") 10 | expect(TestResourceTemplate.uri_template).to eq("resource:///{name}") 11 | expect(TestResourceTemplate.completions).to eq({"name" => TestResourceTemplateCompletion}) 12 | end 13 | end 14 | end 15 | 16 | describe "metadata" do 17 | it "returns class metadata" do 18 | expect(TestResourceTemplate.metadata).to eq( 19 | name: "Test Resource Template", 20 | description: "A test resource template", 21 | mimeType: "text/plain", 22 | uriTemplate: "resource:///{name}", 23 | completions: {"name" => TestResourceTemplateCompletion} 24 | ) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/lib/model_context_protocol/server/router_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ModelContextProtocol::Server::Router do 4 | subject(:router) { described_class.new } 5 | 6 | describe "#map" do 7 | it "registers a handler for a method" do 8 | router.map("test_method") { |_| "handler result" } 9 | result = router.route({"method" => "test_method"}) 10 | expect(result).to eq("handler result") 11 | end 12 | end 13 | 14 | describe "#route" do 15 | let(:message) { {"method" => "test_method", "params" => {"key" => "value"}} } 16 | 17 | before do 18 | router.map("test_method") { |msg| msg["params"]["key"] } 19 | end 20 | 21 | it "routes the message to the correct handler" do 22 | result = router.route(message) 23 | expect(result).to eq("value") 24 | end 25 | 26 | it "passes the entire message to the handler" do 27 | full_message = nil 28 | router.map("echo_method") { |msg| full_message = msg } 29 | router.route({"method" => "echo_method", "id" => 123}) 30 | expect(full_message).to eq({"method" => "echo_method", "id" => 123}) 31 | end 32 | 33 | context "when the method is not registered" do 34 | let(:unknown_message) { {"method" => "unknown_method"} } 35 | 36 | it "raises MethodNotFoundError" do 37 | expect { router.route(unknown_message) } 38 | .to raise_error(ModelContextProtocol::Server::Router::MethodNotFoundError) 39 | end 40 | 41 | it "includes the method name in the error message" do 42 | expect { router.route(unknown_message) } 43 | .to raise_error(/Method not found: unknown_method/) 44 | end 45 | end 46 | end 47 | 48 | describe "error handling" do 49 | let(:message) { {"method" => "error_method"} } 50 | 51 | before do 52 | router.map("error_method") { |_| raise "Handler error" } 53 | end 54 | 55 | it "allows errors to propagate from handlers" do 56 | expect { router.route(message) }.to raise_error(RuntimeError, "Handler error") 57 | end 58 | end 59 | 60 | describe "multiple handlers" do 61 | before do 62 | router.map("method1") { |_| "result1" } 63 | router.map("method2") { |_| "result2" } 64 | end 65 | 66 | it "routes to the first handler" do 67 | expect(router.route({"method" => "method1"})).to eq("result1") 68 | end 69 | 70 | it "routes to the second handler" do 71 | expect(router.route({"method" => "method2"})).to eq("result2") 72 | end 73 | end 74 | 75 | describe "overwriting handlers" do 76 | it "uses the last registered handler for a method" do 77 | router.map("test_method") { |_| "first handler" } 78 | router.map("test_method") { |_| "second handler" } 79 | 80 | expect(router.route({"method" => "test_method"})).to eq("second handler") 81 | end 82 | end 83 | 84 | describe "handling complex logic" do 85 | it "can perform transformations on the input" do 86 | router.map("transform") do |message| 87 | items = message["params"]["items"] 88 | items.map { |item| item * 2 } 89 | end 90 | 91 | result = router.route({ 92 | "method" => "transform", 93 | "params" => {"items" => [1, 2, 3]} 94 | }) 95 | 96 | expect(result).to eq([2, 4, 6]) 97 | end 98 | 99 | it "can maintain state between calls" do 100 | counter = 0 101 | router.map("counter") { |_| counter += 1 } 102 | 103 | expect(router.route({"method" => "counter"})).to eq(1) 104 | expect(router.route({"method" => "counter"})).to eq(2) 105 | expect(router.route({"method" => "counter"})).to eq(3) 106 | end 107 | end 108 | 109 | describe "environment variable management" do 110 | subject(:router) { described_class.new(configuration: configuration) } 111 | let(:message) { {"method" => "env_test"} } 112 | let(:configuration) { ModelContextProtocol::Server::Configuration.new } 113 | 114 | before do 115 | # Set up some initial environment variables 116 | ENV["EXISTING_VAR"] = "original_value" 117 | ENV["ANOTHER_VAR"] = "another_value" 118 | end 119 | 120 | after do 121 | # Clean up after tests 122 | ENV.delete("EXISTING_VAR") 123 | ENV.delete("ANOTHER_VAR") 124 | ENV.delete("TEST_VAR") 125 | ENV.delete("OVERRIDE_VAR") 126 | end 127 | 128 | it "sets environment variables during handler execution" do 129 | router.map("env_test") do 130 | ENV["TEST_VAR"] 131 | end 132 | 133 | configuration.set_environment_variable("TEST_VAR", "test_value") 134 | result = router.route(message) 135 | 136 | expect(result).to eq("test_value") 137 | end 138 | 139 | it "overrides existing environment variables" do 140 | router.map("env_test") do 141 | ENV["EXISTING_VAR"] 142 | end 143 | 144 | configuration.set_environment_variable("EXISTING_VAR", "new_value") 145 | result = router.route(message) 146 | 147 | expect(result).to eq("new_value") 148 | end 149 | 150 | it "restores original environment variables after handler execution" do 151 | router.map("env_test") do 152 | ENV["EXISTING_VAR"] = "changed_value" 153 | "done" 154 | end 155 | 156 | router.route(message) 157 | 158 | expect(ENV["EXISTING_VAR"]).to eq("original_value") 159 | end 160 | 161 | it "restores environment variables even if handler raises an error" do 162 | router.map("env_test") do 163 | ENV["EXISTING_VAR"] = "changed_value" 164 | raise "Handler error" 165 | end 166 | 167 | expect { router.route(message) }.to raise_error(RuntimeError, "Handler error") 168 | expect(ENV["EXISTING_VAR"]).to eq("original_value") 169 | end 170 | 171 | it "handles multiple environment variables" do 172 | router.map("env_test") do 173 | [ENV["TEST_VAR"], ENV["OVERRIDE_VAR"]] 174 | end 175 | 176 | configuration.set_environment_variable("TEST_VAR", "test_value") 177 | configuration.set_environment_variable("OVERRIDE_VAR", "override_value") 178 | result = router.route(message) 179 | 180 | expect(result).to eq(["test_value", "override_value"]) 181 | end 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /spec/lib/model_context_protocol/server/stdio_transport_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | TestResponse = Data.define(:text) do 4 | def serialized 5 | {text:} 6 | end 7 | end 8 | 9 | RSpec.describe ModelContextProtocol::Server::StdioTransport do 10 | subject(:transport) { described_class.new(logger: logger, router: router) } 11 | 12 | let(:logger) { Logger.new(StringIO.new) } 13 | let(:router) { ModelContextProtocol::Server::Router.new } 14 | 15 | before do 16 | @original_stdin = $stdin 17 | @original_stdout = $stdout 18 | $stdin = StringIO.new 19 | $stdout = StringIO.new 20 | 21 | router.map("test_method") do |_| 22 | TestResponse[text: "foobar"] 23 | end 24 | 25 | router.map("error") do |_| 26 | raise "Something went wrong" 27 | end 28 | 29 | router.map("validation_error") do |_| 30 | raise ModelContextProtocol::Server::ParameterValidationError, "Invalid parameters" 31 | end 32 | end 33 | 34 | after do 35 | $stdin = @original_stdin 36 | $stdout = @original_stdout 37 | end 38 | 39 | describe "#begin" do 40 | context "with a valid request" do 41 | let(:request) { {"jsonrpc" => "2.0", "id" => 1, "method" => "test_method"} } 42 | 43 | before do 44 | $stdin.puts(JSON.generate(request)) 45 | $stdin.rewind 46 | end 47 | 48 | it "processes the request and sends a response" do 49 | begin 50 | transport.begin 51 | rescue EOFError 52 | # Expected to raise EOFError when stdin is exhausted 53 | end 54 | 55 | $stdout.rewind 56 | output = $stdout.read 57 | response_json = JSON.parse(output) 58 | aggregate_failures do 59 | expect(response_json["jsonrpc"]).to eq("2.0") 60 | expect(response_json["id"]).to eq(1) 61 | expect(response_json["result"]).to eq({"text" => "foobar"}) 62 | end 63 | end 64 | end 65 | 66 | context "with a notification" do 67 | before do 68 | $stdin.puts(JSON.generate({"jsonrpc" => "2.0", "method" => "notifications/something"})) 69 | $stdin.puts(JSON.generate({"jsonrpc" => "2.0", "id" => 2, "method" => "test_method"})) 70 | $stdin.rewind 71 | end 72 | 73 | it "does not process notifications" do 74 | begin 75 | transport.begin 76 | rescue EOFError 77 | # Expected 78 | end 79 | 80 | $stdout.rewind 81 | output = $stdout.read 82 | response_lines = output.strip.split("\n") 83 | 84 | aggregate_failures do 85 | expect(response_lines.length).to eq(1) 86 | response_json = JSON.parse(response_lines[0]) 87 | expect(response_json["id"]).to eq(2) 88 | end 89 | end 90 | end 91 | 92 | context "with a JSON parse error" do 93 | before do 94 | $stdin.puts("invalid json") 95 | $stdin.rewind 96 | end 97 | 98 | it "sends an error response" do 99 | allow(logger).to receive(:error) 100 | 101 | begin 102 | transport.begin 103 | rescue EOFError 104 | # Expected 105 | end 106 | 107 | $stdout.rewind 108 | output = $stdout.read 109 | lines = output.strip.split("\n") 110 | 111 | aggregate_failures do 112 | expect(lines.length).to eq(1) 113 | error_response = JSON.parse(lines[0]) 114 | expect(error_response).to include("error") 115 | expect(error_response["error"]["code"]).to eq(-32700) 116 | expect(error_response["error"]["message"]).to include("unexpected token") 117 | expect(logger).to have_received(:error).with(/Parser error/) 118 | end 119 | end 120 | end 121 | 122 | context "with a parameter validation error" do 123 | let(:request) { {"jsonrpc" => "2.0", "id" => 2, "method" => "validation_error"} } 124 | 125 | before do 126 | $stdin.puts(JSON.generate(request)) 127 | $stdin.rewind 128 | end 129 | 130 | it "sends a validation error response" do 131 | allow(logger).to receive(:error) 132 | 133 | begin 134 | transport.begin 135 | rescue EOFError 136 | # Expected 137 | end 138 | 139 | $stdout.rewind 140 | output = $stdout.read 141 | response_json = JSON.parse(output) 142 | 143 | aggregate_failures do 144 | expect(response_json).to include("error") 145 | expect(response_json["error"]["code"]).to eq(-32602) 146 | expect(response_json["error"]["message"]).to eq("Invalid parameters") 147 | expect(logger).to have_received(:error).with(/Validation error: Invalid parameters/) 148 | end 149 | end 150 | end 151 | 152 | context "with an internal error" do 153 | let(:request) { {"jsonrpc" => "2.0", "id" => 3, "method" => "error"} } 154 | 155 | before do 156 | $stdin.puts(JSON.generate(request)) 157 | $stdin.rewind 158 | end 159 | 160 | it "sends an internal error response" do 161 | allow(logger).to receive(:error) 162 | 163 | begin 164 | transport.begin 165 | rescue EOFError 166 | # Expected 167 | end 168 | 169 | $stdout.rewind 170 | output = $stdout.read 171 | response_json = JSON.parse(output) 172 | 173 | aggregate_failures do 174 | expect(response_json).to include("error") 175 | expect(response_json["error"]["code"]).to eq(-32603) 176 | expect(response_json["error"]["message"]).to eq("Something went wrong") 177 | expect(logger).to have_received(:error).with(/Internal error: Something went wrong/) 178 | expect(logger).to have_received(:error).with(kind_of(Array)) # backtrace 179 | end 180 | end 181 | end 182 | 183 | context "with multiple requests" do 184 | before do 185 | router.map("method1") do |message| 186 | TestResponse[text: "method1 response"] 187 | end 188 | 189 | router.map("method2") do |message| 190 | TestResponse[text: "method2 response"] 191 | end 192 | 193 | $stdin.puts(JSON.generate({"jsonrpc" => "2.0", "id" => 1, "method" => "method1"})) 194 | $stdin.puts(JSON.generate({"jsonrpc" => "2.0", "id" => 2, "method" => "method2"})) 195 | $stdin.rewind 196 | end 197 | 198 | it "processes all requests in sequence" do 199 | begin 200 | transport.begin 201 | rescue EOFError 202 | # Expected 203 | end 204 | 205 | $stdout.rewind 206 | output = $stdout.read 207 | responses = output.strip.split("\n").map { |line| JSON.parse(line) } 208 | 209 | aggregate_failures do 210 | expect(responses.length).to eq(2) 211 | expect(responses[0]["id"]).to eq(1) 212 | expect(responses[0]["result"]).to eq({"text" => "method1 response"}) 213 | expect(responses[1]["id"]).to eq(2) 214 | expect(responses[1]["result"]).to eq({"text" => "method2 response"}) 215 | end 216 | end 217 | end 218 | end 219 | 220 | describe "Response class" do 221 | it "creates a properly formatted JSON-RPC response" do 222 | response = described_class::Response.new(id: 123, result: {data: "value"}) 223 | 224 | expect(response.serialized).to eq( 225 | { 226 | jsonrpc: "2.0", 227 | id: 123, 228 | result: {data: "value"} 229 | } 230 | ) 231 | end 232 | end 233 | 234 | describe "ErrorResponse class" do 235 | it "creates a properly formatted JSON-RPC error response" do 236 | error_response = described_class::ErrorResponse.new( 237 | id: 456, 238 | error: {code: -32603, message: "Test error"} 239 | ) 240 | 241 | expect(error_response.serialized).to eq( 242 | { 243 | jsonrpc: "2.0", 244 | id: 456, 245 | error: {code: -32603, message: "Test error"} 246 | } 247 | ) 248 | end 249 | end 250 | end 251 | -------------------------------------------------------------------------------- /spec/lib/model_context_protocol/server/tool_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ModelContextProtocol::Server::Tool do 4 | describe ".call" do 5 | context "when input schema validation fails" do 6 | let(:invalid_params) { {"foo" => "bar"} } 7 | 8 | it "raises a ParameterValidationError" do 9 | expect { 10 | TestToolWithTextResponse.call(invalid_params) 11 | }.to raise_error(ModelContextProtocol::Server::ParameterValidationError) 12 | end 13 | end 14 | 15 | context "when input schema validation succeeds" do 16 | let(:valid_params) { {"number" => "21"} } 17 | 18 | it "instantiates the tool with the provided parameters" do 19 | expect(TestToolWithTextResponse).to receive(:new).with(valid_params).and_call_original 20 | TestToolWithTextResponse.call(valid_params) 21 | end 22 | 23 | it "returns the response from the instance's call method" do 24 | response = TestToolWithTextResponse.call(valid_params) 25 | aggregate_failures do 26 | expect(response.text).to eq("21 doubled is 42") 27 | expect(response.serialized).to eq( 28 | content: [ 29 | { 30 | type: "text", 31 | text: "21 doubled is 42" 32 | } 33 | ], 34 | isError: false 35 | ) 36 | end 37 | end 38 | 39 | context "when an unexpected error occurs" do 40 | before do 41 | allow_any_instance_of(TestToolWithTextResponse).to receive(:call).and_raise("Test error") 42 | end 43 | 44 | it "returns an error response" do 45 | response = TestToolWithTextResponse.call(valid_params) 46 | aggregate_failures do 47 | expect(response.text).to eq("Test error") 48 | expect(response.serialized).to eq( 49 | content: [ 50 | { 51 | type: "text", 52 | text: "Test error" 53 | } 54 | ], 55 | isError: true 56 | ) 57 | end 58 | end 59 | end 60 | end 61 | end 62 | 63 | describe "#initialize" do 64 | it "validates the parameters against the schema" do 65 | expect(JSON::Validator).to receive(:validate!).with( 66 | TestToolWithTextResponse.input_schema, 67 | {"text" => "Hello, world!"} 68 | ) 69 | TestToolWithTextResponse.new({"text" => "Hello, world!"}) 70 | end 71 | 72 | it "stores the parameters" do 73 | tool = TestToolWithTextResponse.new({"number" => "42"}) 74 | expect(tool.params).to eq({"number" => "42"}) 75 | end 76 | end 77 | 78 | describe "responses" do 79 | describe "text response" do 80 | it "formats text response correctly" do 81 | params = {"number" => "21"} 82 | response = TestToolWithTextResponse.call(params) 83 | expect(response.serialized).to eq( 84 | content: [ 85 | { 86 | type: "text", 87 | text: "21 doubled is 42" 88 | } 89 | ], 90 | isError: false 91 | ) 92 | end 93 | end 94 | 95 | describe "image response" do 96 | it "formats image responses correctly" do 97 | params = {"chart_type" => "bar", "format" => "jpg"} 98 | response = TestToolWithImageResponse.call(params) 99 | expect(response.serialized).to eq( 100 | content: [ 101 | { 102 | type: "image", 103 | data: "dGVzdA==", 104 | mimeType: "image/jpeg" 105 | } 106 | ], 107 | isError: false 108 | ) 109 | end 110 | 111 | it "defaults to PNG mime type" do 112 | params = {"chart_type" => "bar"} 113 | response = TestToolWithImageResponseDefaultMimeType.call(params) 114 | expect(response.serialized).to eq( 115 | content: [ 116 | { 117 | type: "image", 118 | data: "dGVzdA==", 119 | mimeType: "image/png" 120 | } 121 | ], 122 | isError: false 123 | ) 124 | end 125 | end 126 | 127 | describe "resource response" do 128 | it "formats resource responses correctly" do 129 | params = {"title" => "Foobar"} 130 | response = TestToolWithResourceResponse.call(params) 131 | expect(response.serialized).to eq( 132 | content: [ 133 | type: "resource", 134 | resource: { 135 | uri: "resource://document/foobar", 136 | mimeType: "application/rtf", 137 | text: "richtextdata" 138 | } 139 | ], 140 | isError: false 141 | ) 142 | end 143 | 144 | it "defaults to text/plain mime type" do 145 | params = {"title" => "foobar", "content" => "baz"} 146 | response = TestToolWithResourceResponseDefaultMimeType.call(params) 147 | expect(response.serialized).to eq( 148 | content: [ 149 | type: "resource", 150 | resource: { 151 | uri: "note://notes/foobar", 152 | mimeType: "text/plain", 153 | text: "baz" 154 | } 155 | ], 156 | isError: false 157 | ) 158 | end 159 | end 160 | 161 | describe "tool error response" do 162 | it "formats error responses correctly" do 163 | params = {"api_endpoint" => "http://example.com", "method" => "GET"} 164 | response = TestToolWithToolErrorResponse.call(params) 165 | expect(response.serialized).to eq( 166 | content: [ 167 | { 168 | type: "text", 169 | text: "Failed to call API at http://example.com: Connection timed out" 170 | } 171 | ], 172 | isError: true 173 | ) 174 | end 175 | end 176 | end 177 | 178 | describe "with_metadata" do 179 | it "sets the class metadata" do 180 | aggregate_failures do 181 | expect(TestToolWithTextResponse.name).to eq("double") 182 | expect(TestToolWithTextResponse.description).to eq("Doubles the provided number") 183 | expect(TestToolWithTextResponse.input_schema).to eq( 184 | type: "object", 185 | properties: { 186 | number: { 187 | type: "string" 188 | } 189 | }, 190 | required: ["number"] 191 | ) 192 | end 193 | end 194 | end 195 | 196 | describe "metadata" do 197 | it "returns class metadata" do 198 | expect(TestToolWithTextResponse.metadata).to eq( 199 | name: "double", 200 | description: "Doubles the provided number", 201 | inputSchema: { 202 | type: "object", 203 | properties: { 204 | number: { 205 | type: "string" 206 | } 207 | }, 208 | required: ["number"] 209 | } 210 | ) 211 | end 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /spec/lib/model_context_protocol/server_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ModelContextProtocol::Server do 4 | describe "router mapping" do 5 | context "completion/complete" do 6 | it "raises an error when an invalid ref/type is provided" do 7 | registry = ModelContextProtocol::Server::Registry.new do 8 | prompts do 9 | register TestPrompt 10 | end 11 | 12 | resource_templates do 13 | register TestResourceTemplate 14 | end 15 | end 16 | 17 | server = described_class.new do |config| 18 | config.name = "Test Server" 19 | config.version = "1.0.0" 20 | config.registry = registry 21 | end 22 | 23 | message = { 24 | "method" => "completion/complete", 25 | "params" => { 26 | "ref" => { 27 | "type" => "ref/invalid_type", 28 | "name" => "foo" 29 | }, 30 | "argument" => { 31 | "name" => "bar", 32 | "value" => "baz" 33 | } 34 | } 35 | } 36 | 37 | expect { 38 | server.router.route(message) 39 | }.to raise_error(ModelContextProtocol::Server::ParameterValidationError, "ref/type invalid") 40 | end 41 | 42 | context "for prompts" do 43 | it "returns a completion for the given prompt" do 44 | registry = ModelContextProtocol::Server::Registry.new do 45 | prompts do 46 | register TestPrompt 47 | end 48 | end 49 | 50 | server = described_class.new do |config| 51 | config.name = "Test Server" 52 | config.version = "1.0.0" 53 | config.registry = registry 54 | end 55 | 56 | message = { 57 | "method" => "completion/complete", 58 | "params" => { 59 | "ref" => { 60 | "type" => "ref/prompt", 61 | "name" => "test_prompt" 62 | }, 63 | "argument" => { 64 | "name" => "message", 65 | "value" => "f" 66 | } 67 | } 68 | } 69 | 70 | response = server.router.route(message) 71 | 72 | expect(response.serialized).to eq( 73 | completion: { 74 | values: ["foo"], 75 | total: 1, 76 | hasMore: false 77 | } 78 | ) 79 | end 80 | 81 | it "returns a null completion when no matching prompt is found" do 82 | registry = ModelContextProtocol::Server::Registry.new do 83 | prompts do 84 | register TestPrompt 85 | end 86 | end 87 | 88 | server = described_class.new do |config| 89 | config.name = "Test Server" 90 | config.version = "1.0.0" 91 | config.registry = registry 92 | end 93 | 94 | message = { 95 | "method" => "completion/complete", 96 | "params" => { 97 | "ref" => { 98 | "type" => "ref/prompt", 99 | "name" => "foo" 100 | }, 101 | "argument" => { 102 | "name" => "bar", 103 | "value" => "baz" 104 | } 105 | } 106 | } 107 | 108 | response = server.router.route(message) 109 | 110 | expect(response.serialized).to eq( 111 | completion: { 112 | values: [], 113 | total: 0, 114 | hasMore: false 115 | } 116 | ) 117 | end 118 | end 119 | 120 | context "for resource templates" do 121 | it "looks up resource templates when direct resource is not found" do 122 | registry = ModelContextProtocol::Server::Registry.new do 123 | resource_templates do 124 | register TestResourceTemplate 125 | end 126 | end 127 | 128 | server = described_class.new do |config| 129 | config.name = "Test Server" 130 | config.version = "1.0.0" 131 | config.registry = registry 132 | end 133 | 134 | message = { 135 | "method" => "completion/complete", 136 | "params" => { 137 | "ref" => { 138 | "type" => "ref/resource", 139 | "uri" => "resource:///{name}" 140 | }, 141 | "argument" => { 142 | "name" => "name", 143 | "value" => "te" 144 | } 145 | } 146 | } 147 | 148 | response = server.router.route(message) 149 | 150 | expect(response.serialized).to eq( 151 | completion: { 152 | values: ["test-resource"], 153 | total: 1, 154 | hasMore: false 155 | } 156 | ) 157 | end 158 | 159 | it "returns a null completion when no matching resource template is found" do 160 | registry = ModelContextProtocol::Server::Registry.new do 161 | resource_templates do 162 | register TestResourceTemplate 163 | end 164 | end 165 | 166 | server = described_class.new do |config| 167 | config.name = "Test Server" 168 | config.version = "1.0.0" 169 | config.registry = registry 170 | end 171 | 172 | message = { 173 | "method" => "completion/complete", 174 | "params" => { 175 | "ref" => { 176 | "type" => "ref/resource", 177 | "uri" => "not-valid" 178 | }, 179 | "argument" => { 180 | "name" => "bar", 181 | "value" => "baz" 182 | } 183 | } 184 | } 185 | 186 | response = server.router.route(message) 187 | 188 | expect(response.serialized).to eq( 189 | completion: { 190 | values: [], 191 | total: 0, 192 | hasMore: false 193 | } 194 | ) 195 | end 196 | end 197 | end 198 | 199 | context "resources/read" do 200 | it "raises an error when resource is not found" do 201 | registry = ModelContextProtocol::Server::Registry.new do 202 | resources do 203 | register TestResource 204 | end 205 | end 206 | 207 | server = described_class.new do |config| 208 | config.name = "Test Server" 209 | config.version = "1.0.0" 210 | config.registry = registry 211 | end 212 | 213 | test_uri = "resource:///invalid" 214 | message = {"method" => "resources/read", "params" => {"uri" => test_uri}} 215 | 216 | expect { 217 | server.router.route(message) 218 | }.to raise_error(ModelContextProtocol::Server::ParameterValidationError, "resource not found for #{test_uri}") 219 | end 220 | 221 | it "returns the serialized resource data when the resource is found" do 222 | registry = ModelContextProtocol::Server::Registry.new do 223 | resources do 224 | register TestResource 225 | end 226 | end 227 | 228 | server = described_class.new do |config| 229 | config.name = "Test Server" 230 | config.version = "1.0.0" 231 | config.registry = registry 232 | end 233 | 234 | test_uri = "resource:///test-resource" 235 | message = {"method" => "resources/read", "params" => {"uri" => test_uri}} 236 | 237 | response = server.router.route(message) 238 | 239 | expect(response.serialized).to eq( 240 | contents: [ 241 | { 242 | mimeType: "text/plain", 243 | text: "Here's the data", 244 | uri: "resource:///test-resource" 245 | } 246 | ] 247 | ) 248 | end 249 | end 250 | 251 | context "resources/templates/list" do 252 | it "returns a list of registered resource templates" do 253 | # Set up a registry with resource templates 254 | registry = ModelContextProtocol::Server::Registry.new do 255 | resource_templates do 256 | register TestResourceTemplate 257 | end 258 | end 259 | 260 | server = described_class.new do |config| 261 | config.name = "Test Server" 262 | config.version = "1.0.0" 263 | config.registry = registry 264 | end 265 | 266 | message = {"method" => "resources/templates/list"} 267 | response = server.router.route(message) 268 | expect(response.serialized).to eq( 269 | resourceTemplates: [ 270 | { 271 | name: "Test Resource Template", 272 | description: "A test resource template", 273 | mimeType: "text/plain", 274 | uriTemplate: "resource:///{name}" 275 | } 276 | ] 277 | ) 278 | end 279 | end 280 | end 281 | 282 | describe ".start" do 283 | it "raises an error for an invalid configuration" do 284 | expect do 285 | server = ModelContextProtocol::Server.new do |config| 286 | config.version = "1.0.0" 287 | config.enable_log = true 288 | config.registry = ModelContextProtocol::Server::Registry.new 289 | end 290 | server.start 291 | end.to raise_error(ModelContextProtocol::Server::Configuration::InvalidServerNameError) 292 | end 293 | 294 | it "begins the StdioTransport" do 295 | transport = instance_double(ModelContextProtocol::Server::StdioTransport) 296 | allow(ModelContextProtocol::Server::StdioTransport).to receive(:new).and_return(transport) 297 | allow(transport).to receive(:begin) 298 | 299 | server = ModelContextProtocol::Server.new do |config| 300 | config.name = "MCP Development Server" 301 | config.version = "1.0.0" 302 | config.enable_log = true 303 | config.registry = ModelContextProtocol::Server::Registry.new 304 | end 305 | server.start 306 | 307 | expect(transport).to have_received(:begin) 308 | end 309 | 310 | context "when logging is not enabled" do 311 | it "initializes the StdioTransport logger with a null logdev" do 312 | transport = instance_double(ModelContextProtocol::Server::StdioTransport) 313 | allow(ModelContextProtocol::Server::StdioTransport).to receive(:new).and_return(transport) 314 | allow(transport).to receive(:begin) 315 | 316 | logger_class = class_double(Logger) 317 | allow(logger_class).to receive(:new) 318 | stub_const("Logger", logger_class) 319 | 320 | server = ModelContextProtocol::Server.new do |config| 321 | config.name = "MCP Development Server" 322 | config.version = "1.0.0" 323 | config.enable_log = false 324 | config.registry = ModelContextProtocol::Server::Registry.new 325 | end 326 | server.start 327 | 328 | expect(logger_class).to have_received(:new).with(File::NULL) 329 | end 330 | end 331 | 332 | context "when logging is enabled" do 333 | it "initializes the StdioTransport logger with a $stderr logdev" do 334 | transport = instance_double(ModelContextProtocol::Server::StdioTransport) 335 | allow(ModelContextProtocol::Server::StdioTransport).to receive(:new).and_return(transport) 336 | allow(transport).to receive(:begin) 337 | 338 | logger_class = class_double(Logger) 339 | allow(logger_class).to receive(:new) 340 | stub_const("Logger", logger_class) 341 | 342 | server = ModelContextProtocol::Server.new do |config| 343 | config.name = "MCP Development Server" 344 | config.version = "1.0.0" 345 | config.enable_log = true 346 | config.registry = ModelContextProtocol::Server::Registry.new 347 | end 348 | server.start 349 | 350 | expect(logger_class).to have_received(:new).with($stderr) 351 | end 352 | end 353 | end 354 | end 355 | -------------------------------------------------------------------------------- /spec/lib/model_context_protocol_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ModelContextProtocol do 4 | it "has a version number" do 5 | expect(ModelContextProtocol::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Dir[File.expand_path("../lib/**/*.rb", __dir__)].sort.each { |f| require f } 4 | Dir[File.expand_path("spec/support/**/*.rb")].sort.each { |file| require file } 5 | 6 | RSpec.configure do |config| 7 | # Enable flags like --only-failures and --next-failure 8 | config.example_status_persistence_file_path = ".rspec_status" 9 | 10 | # Disable RSpec exposing methods globally on `Module` and `main` 11 | config.disable_monkey_patching! 12 | 13 | config.expect_with :rspec do |c| 14 | c.syntax = :expect 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/completions/test_completion.rb: -------------------------------------------------------------------------------- 1 | class TestCompletion < ModelContextProtocol::Server::Completion 2 | def call 3 | hints = { 4 | "message" => ["hello", "world", "foo", "bar"] 5 | } 6 | values = hints[argument_name].grep(/#{argument_value}/) 7 | 8 | respond_with values: 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/completions/test_resource_template_completion.rb: -------------------------------------------------------------------------------- 1 | class TestResourceTemplateCompletion < ModelContextProtocol::Server::Completion 2 | def call 3 | hints = { 4 | "name" => ["test-resource", "project-logo"] 5 | } 6 | values = hints[argument_name].grep(/#{argument_value}/) 7 | 8 | respond_with values: 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/prompts/test_prompt.rb: -------------------------------------------------------------------------------- 1 | class TestPrompt < ModelContextProtocol::Server::Prompt 2 | with_metadata do 3 | name "test_prompt" 4 | description "A test prompt" 5 | end 6 | 7 | with_argument do 8 | name "message" 9 | description "The thing to do" 10 | required true 11 | completion TestCompletion 12 | end 13 | 14 | with_argument do 15 | name "other" 16 | description "Another thing to do" 17 | required false 18 | end 19 | 20 | def call 21 | messages = [ 22 | { 23 | role: "user", 24 | content: { 25 | type: "text", 26 | text: "Do this: #{params["message"]}" 27 | } 28 | } 29 | ] 30 | 31 | respond_with messages: messages 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/support/resource_templates/test_resource_template.rb: -------------------------------------------------------------------------------- 1 | class TestResourceTemplate < ModelContextProtocol::Server::ResourceTemplate 2 | with_metadata do 3 | name "Test Resource Template" 4 | description "A test resource template" 5 | mime_type "text/plain" 6 | uri_template "resource:///{name}" do 7 | completion :name, TestResourceTemplateCompletion 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/resources/test_binary_resource.rb: -------------------------------------------------------------------------------- 1 | class TestBinaryResource < ModelContextProtocol::Server::Resource 2 | with_metadata do 3 | name "Project Logo" 4 | description "The logo for the project" 5 | mime_type "image/jpeg" 6 | uri "resource:///project-logo" 7 | end 8 | 9 | def call 10 | # In a real implementation, we would retrieve the binary resource 11 | # This is a small valid base64 encoded string (represents "test") 12 | data = "dGVzdA==" 13 | respond_with :binary, blob: data 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/resources/test_resource.rb: -------------------------------------------------------------------------------- 1 | class TestResource < ModelContextProtocol::Server::Resource 2 | with_metadata do 3 | name "Test Resource" 4 | description "A test resource" 5 | mime_type "text/plain" 6 | uri "resource:///test-resource" 7 | end 8 | 9 | def call 10 | respond_with :text, text: "Here's the data" 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/support/test_invalid_class.rb: -------------------------------------------------------------------------------- 1 | class TestInvalidClass 2 | def self.metadata 3 | {name: "invalid_class"} 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/tools/test_tool_with_image_response.rb: -------------------------------------------------------------------------------- 1 | class TestToolWithImageResponse < ModelContextProtocol::Server::Tool 2 | with_metadata do 3 | name "custom-chart-generator" 4 | description "Generates a chart in various formats" 5 | input_schema do 6 | { 7 | type: "object", 8 | properties: { 9 | chart_type: { 10 | type: "string", 11 | description: "Type of chart (pie, bar, line)" 12 | }, 13 | format: { 14 | type: "string", 15 | description: "Image format (jpg, svg, etc)" 16 | } 17 | }, 18 | required: ["chart_type", "format"] 19 | } 20 | end 21 | end 22 | 23 | def call 24 | # Map format to mime type 25 | mime_type = case params["format"].downcase 26 | when "svg" 27 | "image/svg+xml" 28 | when "jpg", "jpeg" 29 | "image/jpeg" 30 | else 31 | "image/png" 32 | end 33 | 34 | # In a real implementation, we would generate an actual chart 35 | # This is a small valid base64 encoded string (represents "test") 36 | chart_data = "dGVzdA==" 37 | respond_with :image, data: chart_data, mime_type: 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/support/tools/test_tool_with_image_response_default_mime_type.rb: -------------------------------------------------------------------------------- 1 | class TestToolWithImageResponseDefaultMimeType < ModelContextProtocol::Server::Tool 2 | with_metadata do 3 | name "other-custom-chart-generator" 4 | description "Generates a chart" 5 | input_schema do 6 | { 7 | type: "object", 8 | properties: { 9 | chart_type: { 10 | type: "string", 11 | description: "Type of chart (pie, bar, line)" 12 | } 13 | }, 14 | required: ["chart_type"] 15 | } 16 | end 17 | end 18 | 19 | def call 20 | # In a real implementation, we would generate an actual chart 21 | # This is a small valid base64 encoded string (represents "test") 22 | chart_data = "dGVzdA==" 23 | respond_with :image, data: chart_data 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/tools/test_tool_with_resource_response.rb: -------------------------------------------------------------------------------- 1 | class TestToolWithResourceResponse < ModelContextProtocol::Server::Tool 2 | with_metadata do 3 | name "document-finder" 4 | description "Finds a the document with the given title" 5 | input_schema do 6 | { 7 | type: "object", 8 | properties: { 9 | title: { 10 | type: "string", 11 | description: "The title of the document" 12 | } 13 | }, 14 | required: ["title"] 15 | } 16 | end 17 | end 18 | 19 | def call 20 | title = params["title"].downcase 21 | # In a real implementation, we would do a lookup to get the document data 22 | document = "richtextdata" 23 | respond_with :resource, uri: "resource://document/#{title}", text: document, mime_type: "application/rtf" 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/tools/test_tool_with_resource_response_default_mime_type.rb: -------------------------------------------------------------------------------- 1 | class TestToolWithResourceResponseDefaultMimeType < ModelContextProtocol::Server::Tool 2 | with_metadata do 3 | name "note-creator" 4 | description "Creates a note at the specified location" 5 | input_schema do 6 | { 7 | type: "object", 8 | properties: { 9 | title: { 10 | type: "string", 11 | description: "Title of the note" 12 | }, 13 | content: { 14 | type: "string", 15 | description: "Content of the note" 16 | } 17 | }, 18 | required: ["title", "content"] 19 | } 20 | end 21 | end 22 | 23 | def call 24 | # In a real implementation, we would create an actual file 25 | note_uri = "note://notes/#{params["title"].downcase.gsub(/\s+/, "-")}" 26 | respond_with :resource, uri: note_uri, text: params["content"] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/support/tools/test_tool_with_text_response.rb: -------------------------------------------------------------------------------- 1 | class TestToolWithTextResponse < ModelContextProtocol::Server::Tool 2 | with_metadata do 3 | name "double" 4 | description "Doubles the provided number" 5 | input_schema do 6 | { 7 | type: "object", 8 | properties: { 9 | number: { 10 | type: "string" 11 | } 12 | }, 13 | required: ["number"] 14 | } 15 | end 16 | end 17 | 18 | def call 19 | number = params["number"].to_i 20 | result = number * 2 21 | respond_with :text, text: "#{number} doubled is #{result}" 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/support/tools/test_tool_with_tool_error_response.rb: -------------------------------------------------------------------------------- 1 | class TestToolWithToolErrorResponse < ModelContextProtocol::Server::Tool 2 | with_metadata do 3 | name "api-caller" 4 | description "Makes calls to external APIs" 5 | input_schema do 6 | { 7 | type: "object", 8 | properties: { 9 | api_endpoint: { 10 | type: "string", 11 | description: "API endpoint URL" 12 | }, 13 | method: { 14 | type: "string", 15 | description: "HTTP method (GET, POST, etc)" 16 | } 17 | }, 18 | required: ["api_endpoint", "method"] 19 | } 20 | end 21 | end 22 | 23 | def call 24 | # Simulate an API call failure 25 | respond_with :error, text: "Failed to call API at #{params["api_endpoint"]}: Connection timed out" 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /tasks/mcp.rake: -------------------------------------------------------------------------------- 1 | require "fileutils" 2 | 3 | namespace :mcp do 4 | desc "Generate the development server executable with the correct Ruby path" 5 | task :generate_executable do 6 | destination_path = "bin/dev" 7 | template_path = File.expand_path("templates/dev.erb", __dir__) 8 | 9 | # Create directory if it doesn't exist 10 | FileUtils.mkdir_p(File.dirname(destination_path)) 11 | 12 | # Get the Ruby path 13 | ruby_path = detect_ruby_path 14 | 15 | # Read and process the template 16 | template = File.read(template_path) 17 | content = template.gsub("<%= @ruby_path %>", ruby_path) 18 | 19 | # Write the executable 20 | File.write(destination_path, content) 21 | 22 | # Set permissions 23 | FileUtils.chmod(0o755, destination_path) 24 | 25 | # Show success message 26 | puts "\nCreated executable at: #{File.expand_path(destination_path)}" 27 | puts "Using Ruby path: #{ruby_path}" 28 | end 29 | 30 | def detect_ruby_path 31 | # Get Ruby version from project config 32 | ruby_version = get_project_ruby_version 33 | 34 | if ruby_version && ruby_version.strip != "" 35 | # Find the absolute path to the Ruby executable via ASDF 36 | asdf_ruby_path = `asdf where ruby #{ruby_version}`.strip 37 | 38 | if asdf_ruby_path && !asdf_ruby_path.empty? && File.directory?(asdf_ruby_path) 39 | return File.join(asdf_ruby_path, "bin", "ruby") 40 | end 41 | end 42 | 43 | # Fallback to current Ruby 44 | `which ruby`.strip 45 | end 46 | 47 | def get_project_ruby_version 48 | # Try ASDF first 49 | if File.exist?(".tool-versions") 50 | content = File.read(".tool-versions") 51 | ruby_line = content.lines.find { |line| line.start_with?("ruby ") } 52 | return ruby_line.split[1].strip if ruby_line 53 | end 54 | 55 | # Try .ruby-version file 56 | if File.exist?(".ruby-version") 57 | return File.read(".ruby-version").strip 58 | end 59 | 60 | nil 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /tasks/templates/dev.erb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env <%= @ruby_path %> 2 | 3 | require "bundler/setup" 4 | require_relative "../lib/model_context_protocol" 5 | 6 | Dir[File.join(__dir__, "../spec/support/**/*.rb")].each { |file| require file } 7 | 8 | server = ModelContextProtocol::Server.new do |config| 9 | config.name = "MCP Development Server" 10 | config.version = "1.0.0" 11 | config.enable_log = true 12 | config.registry = ModelContextProtocol::Server::Registry.new do 13 | prompts list_changed: true do 14 | register TestPrompt 15 | end 16 | 17 | resources list_changed: true, subscribe: true do 18 | register TestResource 19 | register TestBinaryResource 20 | end 21 | 22 | resource_templates do 23 | register TestResourceTemplate 24 | end 25 | 26 | tools list_changed: true do 27 | register TestToolWithTextResponse 28 | register TestToolWithImageResponse 29 | register TestToolWithImageResponseDefaultMimeType 30 | register TestToolWithResourceResponse 31 | register TestToolWithResourceResponseDefaultMimeType 32 | register TestToolWithToolErrorResponse 33 | end 34 | end 35 | end 36 | 37 | server.start 38 | --------------------------------------------------------------------------------