commit a4df3b86883a6b15de09c27cf7f1bb14f0376438 Author: Topher Bullock Date: Thu Feb 27 10:33:57 2025 -0500 Internal Release v0.1.1 - implement `Prompt` - adjust Tool approach - update readme for tool calling - document usage of `Prompt` diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d9a2449 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6279cda --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + entry: + - { ruby: 3.2, allowed-failure: false } + - { ruby: 3.3, allowed-failure: false } + - { ruby: 3.4, allowed-failure: false } + - { ruby: head, allowed-failure: true } + name: Test Ruby ${{ matrix.entry.ruby }} + steps: + - uses: actions/checkout@v3 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.entry.ruby }} + bundler-cache: true + - run: bundle exec rake test + continue-on-error: ${{ matrix.entry.allowed-failure }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9106b2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..9c5ae4e --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,2 @@ +inherit_gem: + rubocop-shopify: rubocop.yml diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..15a2799 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.3.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..3662e8e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at topher.bullock@shopify.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..893c454 --- /dev/null +++ b/Gemfile @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify runtime dependencies in the gemspec +gemspec + +# Specify development dependencies below +gem "minitest", "~> 5.1", require: false +gem "rake", "~> 13.0" +gem "rubocop-shopify", require: false + +gem "activesupport" +gem "minitest-reporters" +gem "mocha" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..57c3115 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,95 @@ +PATH + remote: . + specs: + model_context_protocol (0.1.1) + activesupport + +GEM + remote: https://rubygems.org/ + specs: + activesupport (8.0.2) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + ansi (1.5.0) + ast (2.4.3) + base64 (0.2.0) + benchmark (0.4.0) + bigdecimal (3.1.9) + builder (3.3.0) + concurrent-ruby (1.3.5) + connection_pool (2.5.3) + drb (2.2.1) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + json (2.11.3) + language_server-protocol (3.17.0.4) + lint_roller (1.1.0) + logger (1.7.0) + minitest (5.25.5) + minitest-reporters (1.7.1) + ansi + builder + minitest (>= 5.0) + ruby-progressbar + mocha (2.7.1) + ruby2_keywords (>= 0.0.5) + parallel (1.27.0) + parser (3.3.8.0) + ast (~> 2.4.1) + racc + prism (1.4.0) + racc (1.8.1) + rainbow (3.1.1) + rake (13.2.1) + regexp_parser (2.10.0) + rubocop (1.75.5) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.44.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.44.1) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-shopify (2.17.0) + rubocop (~> 1.62) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + securerandom (0.4.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + uri (1.0.3) + +PLATFORMS + arm64-darwin-23 + ruby + +DEPENDENCIES + activesupport + minitest (~> 5.1) + minitest-reporters + mocha + model_context_protocol! + rake (~> 13.0) + rubocop-shopify + +BUNDLED WITH + 2.5.9 diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..07cf111 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2025 TODO: Write your name + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c9204c --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Model Context Protocol + +A Ruby gem implementing Model Context Protocol + JSON RPC 2.0 spec + +## Overview + +Model Context Protocol provides a standardized way to interact with language models through a JSON RPC 2.0 interface. + +It handles request/response formatting, error handling, and tool integrations in a type-safe manner using Sorbet. + +See https://spec.modelcontextprotocol.io/specification/ + +This implementation breaks from the transport examples in the spec as it implements a POST-based JSON RPC call rather +than a stateful SSE transport. + +## MCP Server + +The `ModelContextProtocol::Server` class is the core component that handles JSON-RPC requests and responses. +It implements the Model Context Protocol specification, handling model context requests and responses. + +### Key Features + +- Implements JSON-RPC 2.0 message handling +- Supports protocol initialization and capability negotiation +- Manages tool registration and invocation +- Provides type-safe request/response handling using Sorbet + +### Supported Methods + +- `initialize` - Initializes the protocol and returns server capabilities +- `ping` - Simple health check that returns "pong" +- `tools/list` - Lists all registered tools and their schemas +- `tools/call` - Invokes a specific tool with provided arguments +- `prompts/list` - Lists all registered prompts and their schemas +- `prompts/get` - Retrieves a specific prompt by name + +### Usage + +Implement an `ApplicationController` which calls the `Server#handle` method, eg + +```ruby +module ModelContextProtocol + class ApplicationController < ActionController::Base + + sig { void } + def index + server = ModelContextProtocol::Server.new( + name: "my_server", + tools: [someTool, anotherTool], + prompts: [myPrompt] + ) + render(json: server.handle(request.body.read).to_h) + end + end +end +``` + +## Tools + +MCP spec includes [Tools](https://modelcontextprotocol.io/docs/concepts/tools) which provide functionality to LLM apps. + +This gem provides a `ModelContextProtocol::Tool` class that can be instantiated to create tools. + +Tools can be passed into the `ModelContextProtocol::Server` constructor to register them with the server. + +### Example Tool Implementation + +The `Tool` class allows creating tools that can be used within the model context. +To create a tool, instantiate the class with the required parameters and an optional block: + +```ruby +tool = ModelContextProtocol::Tool.new(name: "my_tool", description: "This tool performs specific functionality...") do |args| + # Implement the tool's functionality here + result = process_something(args["parameter_name"]) + ModelContextProtocol::Tool::Response.new([{ type: "text", text: result }], false ) +end +``` + +## Prompts + +MCP spec includes +[Prompts](https://modelcontextprotocol.io/docs/concepts/prompts), `Prompts` enable servers to define reusable prompt +templates and workflows that clients can easily surface to users and LLMs + +The `Prompt` class allows creating prompts that can be used within the model context. + +To create a prompt, instantiate the class with the required parameters and an optional block: + +```ruby +prompt = ModelContextProtocol::Prompt.new(name: "my_prompt", description: "This prompt performs specific functionality...") do |args| + # Implement the prompt's functionality here + result = template_something(args["parameter_name"]) + ModelContextProtocol::Prompt::Response.new([{ type: "text", text: result }], false ) +end +``` + + + + +## Releases + +TODO diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..0d0ad08 --- /dev/null +++ b/Rakefile @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.ruby_opts = ["-W0", "-W:deprecated"] + t.libs << "test" + t.libs << "lib" + t.test_files = FileList["test/**/*_test.rb"] +end + +require "rubocop/rake_task" + +RuboCop::RakeTask.new + +task default: [:test, :rubocop] diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..2439419 --- /dev/null +++ b/bin/console @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "model_context_protocol" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/bin/rake b/bin/rake new file mode 100755 index 0000000..206697c --- /dev/null +++ b/bin/rake @@ -0,0 +1,31 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'rake' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "pathname" +ENV["BUNDLE_GEMFILE"] ||= File.expand_path( + "../../Gemfile", + Pathname.new(__FILE__).realpath, +) + +bundle_binstub = File.expand_path("../bundle", __FILE__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("rake", "rake") diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/dev.yml b/dev.yml new file mode 100644 index 0000000..38c8d16 --- /dev/null +++ b/dev.yml @@ -0,0 +1,31 @@ +name: mcp-ruby + +type: ruby + +up: + - ruby + - bundler + +commands: + console: + desc: Open console with the gem loaded + run: bin/console + build: + desc: Build the gem using rake build + run: bin/rake build + test: + desc: Run tests + syntax: + argument: file + optional: args... + run: | + if [[ $# -eq 0 ]]; then + bin/rake test + else + bin/rake -I test "$@" + fi + style: + desc: Run rubocop + aliases: [rubocop, lint] + run: bin/rubocop + diff --git a/lib/model_context_protocol.rb b/lib/model_context_protocol.rb new file mode 100644 index 0000000..9ddf047 --- /dev/null +++ b/lib/model_context_protocol.rb @@ -0,0 +1,20 @@ +# typed: strict +# frozen_string_literal: true + +require "model_context_protocol/json_rpc" +require "model_context_protocol/server" +require "model_context_protocol/tool" +require "model_context_protocol/content" +require "model_context_protocol/resource" +require "model_context_protocol/prompt" + +module ModelContextProtocol + class Annotations + attr_reader :audience, :priority + + def initialize(audience: nil, priority: nil) + @audience = audience + @priority = priority + end + end +end diff --git a/lib/model_context_protocol/content.rb b/lib/model_context_protocol/content.rb new file mode 100644 index 0000000..e0b94b0 --- /dev/null +++ b/lib/model_context_protocol/content.rb @@ -0,0 +1,33 @@ +# typed: true +# frozen_string_literal: true + +module ModelContextProtocol + module Content + class Text + attr_reader :text, :annotations + + def initialize(text, annotations: nil) + @text = text + @annotations = annotations + end + + def to_h + { text:, annotations: }.compact + end + end + + class Image + attr_reader :data, :mime_type, :annotations + + def initialize(data, mime_type, annotations: nil) + @data = data + @mime_type = mime_type + @annotations = annotations + end + + def to_h + { data:, mime_type:, annotations: }.compact + end + end + end +end diff --git a/lib/model_context_protocol/json_rpc.rb b/lib/model_context_protocol/json_rpc.rb new file mode 100644 index 0000000..a19289d --- /dev/null +++ b/lib/model_context_protocol/json_rpc.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "model_context_protocol/json_rpc/error" +require "model_context_protocol/json_rpc/request" +require "model_context_protocol/json_rpc/response" + +module ModelContextProtocol + module JsonRPC + VERSION = "2.0" + end +end diff --git a/lib/model_context_protocol/json_rpc/error.rb b/lib/model_context_protocol/json_rpc/error.rb new file mode 100644 index 0000000..bb9e125 --- /dev/null +++ b/lib/model_context_protocol/json_rpc/error.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module ModelContextProtocol + module JsonRPC + class Error < StandardError + PARSE_ERROR_CODE = -32700 + INVALID_REQUEST_CODE = -32600 + METHOD_NOT_FOUND_CODE = -32601 + INVALID_PARAMS_CODE = -32602 + INTERNAL_ERROR_CODE = -32603 + + attr_reader :code, :message, :data + + def initialize(code:, message:, data: nil) + super(message) + @code = code + @message = message + @data = data + end + + def to_h + { code:, message:, data: }.compact + end + end + + class InvalidRequestError < Error + def initialize(message:) + super(code: INVALID_REQUEST_CODE, message: message) + end + end + + class ParseError < Error + def initialize(message:) + super(code: PARSE_ERROR_CODE, message: message) + end + end + + class MethodNotFoundError < Error + def initialize(message:) + super(code: METHOD_NOT_FOUND_CODE, message: message) + end + end + + class InvalidParamsError < Error + def initialize(message:) + super(code: INVALID_PARAMS_CODE, message: message) + end + end + + class InternalError < Error + def initialize(message:) + super(code: INTERNAL_ERROR_CODE, message: message) + end + end + + class InvalidResponse < StandardError; end + end +end diff --git a/lib/model_context_protocol/json_rpc/request.rb b/lib/model_context_protocol/json_rpc/request.rb new file mode 100644 index 0000000..dcb6482 --- /dev/null +++ b/lib/model_context_protocol/json_rpc/request.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "json" + +module ModelContextProtocol + module JsonRPC + class Request + class << self + def parse(json) + parsed = JSON.parse(json) + from_h(parsed) + rescue JSON::ParserError => e + raise ParseError.new(message: e.message) + end + + private + + def from_h(hash) + new(version: hash["jsonrpc"], method: hash["method"], params: hash["params"], id: hash["id"]) + end + end + + attr_reader :version, :method, :params, :id + + def initialize(version:, method:, params: nil, id: nil) + @version = version + @method = method + @params = params + @id = id + end + + def notification? + id.nil? + end + + def valid? + @version == JsonRPC::VERSION && !method.start_with?("rpc.") + end + + def method_not_found! + raise MethodNotFoundError.new(message: "Method not found #{method}") + end + + def validate! + raise InvalidRequestError.new(message: "Method cannot start with 'rpc.'") if method.start_with?("rpc.") + raise InvalidRequestError.new(message: "Invalid JSON-RPC version") unless valid? + end + end + end +end diff --git a/lib/model_context_protocol/json_rpc/response.rb b/lib/model_context_protocol/json_rpc/response.rb new file mode 100644 index 0000000..e2a81fa --- /dev/null +++ b/lib/model_context_protocol/json_rpc/response.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ModelContextProtocol + module JsonRPC + class Response + attr_reader :version, :result, :error, :id + + def initialize(result: nil, error: nil, id: nil) + @version = VERSION + @result = result + @error = error + @id = id + + validate! + end + + def to_h + { id:, result:, jsonrpc: @version, error: @error&.to_h }.compact + end + + private + + def validate! + raise InvalidResponse, "Result and error cannot both be present" if @result && @error + raise InvalidResponse, "Either result or error must be present" if @result.nil? && @error.nil? + end + end + end +end diff --git a/lib/model_context_protocol/prompt.rb b/lib/model_context_protocol/prompt.rb new file mode 100644 index 0000000..befff83 --- /dev/null +++ b/lib/model_context_protocol/prompt.rb @@ -0,0 +1,79 @@ +# typed: strict +# frozen_string_literal: true + +module ModelContextProtocol + class Prompt + class Argument + attr_reader :name, :description, :required, :arguments + + def initialize(name:, description: nil, required: false) + @name = name + @description = description + @required = required + @arguments = arguments + end + + def to_h + { name:, description:, required: }.compact + end + end + + class Message + attr_reader :role, :content + + def initialize(role:, content:) + @role = role + @content = content + end + + def to_h + { role:, content: content.to_h }.compact + end + end + + class Result + attr_reader :description, :messages + + def initialize(description: nil, messages: []) + @description = description + @messages = messages + end + + def to_h + { description:, messages: messages.map(&:to_h) }.compact + end + end + + attr_reader :name, :description, :arguments + + def initialize(name:, description: nil, arguments: [], &block) + @name = name + @description = description + @arguments = arguments + @template_block = block + end + + def template(args) + validate_args!(args) + result = @template_block.call(args) + result + end + + def to_h + { name:, description:, arguments: arguments.map(&:to_h) }.compact + end + + private + + def required_args + arguments.filter_map { |arg| arg.name if arg.required } + end + + def validate_args!(args) + missing = required_args - args.keys + return if missing.empty? + + raise ArgumentError, "Missing required arguments: #{missing.join(", ")}" + end + end +end diff --git a/lib/model_context_protocol/resource.rb b/lib/model_context_protocol/resource.rb new file mode 100644 index 0000000..2a33343 --- /dev/null +++ b/lib/model_context_protocol/resource.rb @@ -0,0 +1,58 @@ +# typed: strict +# frozen_string_literal: true + +module ModelContextProtocol + module Resource + class Embedded + attr_reader :resource, :annotations + + def initialize(resource:, annotations: nil) + @resource = resource + @annotations = annotations + end + + def to_h + { resource: resource.to_h, annotations: }.compact + end + end + + class Contents + attr_reader :uri, :mime_type + + def initialize(uri:, mime_type: nil) + @uri = uri + @mime_type = mime_type + end + + def to_h + { uri:, mime_type: }.compact + end + end + + class TextContents < Contents + attr_reader :text + + def initialize(text:, uri:, mime_type:) + super(uri: uri, mime_type: mime_type) + @text = text + end + + def to_h + super.merge(text: text) + end + end + + class BlobContents < Contents + attr_reader :data + + def initialize(data:, uri:, mime_type:) + super(uri: uri, mime_type: mime_type) + @data = data + end + + def to_h + super.merge(data: data) + end + end + end +end diff --git a/lib/model_context_protocol/server.rb b/lib/model_context_protocol/server.rb new file mode 100644 index 0000000..eda3152 --- /dev/null +++ b/lib/model_context_protocol/server.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module ModelContextProtocol + class Server + PROTOCOL_VERSION = "2024-11-05" + + attr_accessor :name, :tools, :prompts + + def initialize(name: "model_context_protocol", tools: [], prompts: []) + @name = name + @tools = tools.to_h { |t| [t.name, t] } + @prompts = prompts.to_h { |p| [p.name, p] } + end + + def handle(request) + response = begin + parsed_request = JsonRPC::Request.parse(request) + parsed_request.validate! + + if parsed_request.notification? + handle_notification(parsed_request) + else + call_method(parsed_request) + end + rescue JsonRPC::Error => e + JsonRPC::Response.new(id: parsed_request&.id, error: e) + end + + response + end + + private + + def handle_notification(request) + nil + end + + def call_method(request) + case request.method + when "initialize" + JsonRPC::Response.new(id: request.id, result: { + protocolVersion: PROTOCOL_VERSION, + capabilities: capabilities, + serverInfo: server_info, + }) + when "ping" + JsonRPC::Response.new(id: request.id, result: "pong") + when "tools/list" + list_tools(request) + when "tools/call" + call_tool(request) + when "prompts/list" + list_prompts(request) + when "prompts/get" + get_prompt(request) + else + raise JsonRPC::MethodNotFoundError.new(message: "Method not found #{request.method}") + end + end + + def capabilities + @capabilities ||= { + experimental: nil, + logging: nil, + prompts: nil, + resources: nil, + tools: {}, + model_config: nil, + }.compact + end + + def server_info + @server_info ||= { + name:, + version: ModelContextProtocol::VERSION, + } + end + + def call_tool(request) + tool_name = request.params&.dig("name") + tool = tools[tool_name] + raise JsonRPC::MethodNotFoundError.new(message: "Tool not found #{tool_name}") unless tool + + tool_args = request.params&.dig("arguments") + + result = begin + tool.call(**tool_args) + rescue => e + raise JsonRPC::InternalError.new(message: e.message) + end + + JsonRPC::Response.new(id: request.id, result: result.to_h) + end + + def list_tools(request) + JsonRPC::Response.new(id: request.id, result: { tools: @tools.map { |_, tool| tool.to_h } }) + end + + def list_prompts(request) + JsonRPC::Response.new(id: request.id, result: { prompts: @prompts.map { |_, prompt| prompt.to_h } }) + end + + def get_prompt(request) + prompt_name = request.params&.dig("name") + prompt = @prompts[prompt_name] + raise JsonRPC::MethodNotFoundError.new(message: "Prompt not found #{prompt_name}") unless prompt + + begin + result = prompt.template(request.params&.dig("arguments") || {}) + rescue ArgumentError => e + raise JsonRPC::InvalidParamsError.new(message: e.message) + end + + JsonRPC::Response.new(id: request.id, result: result.to_h) + end + end +end diff --git a/lib/model_context_protocol/tool.rb b/lib/model_context_protocol/tool.rb new file mode 100644 index 0000000..5ce5da3 --- /dev/null +++ b/lib/model_context_protocol/tool.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module ModelContextProtocol + class Tool + class Response + attr_reader :content, :is_error + + def initialize(content, is_error = false) + @content = content + @is_error = is_error + end + + def to_h + { content:, is_error: }.compact + end + end + + attr_reader :name, :description, :input_schema + + def initialize(name:, description: nil, input_schema: nil, &block) + @name = name + @description = description + @input_schema = input_schema + @tool_block = block_given? ? block : -> { Response.new([{ type: "text", content: "OK" }]) } + end + + def call(*args) + @tool_block.call(*args) + end + + def to_h + { name:, description:, inputSchema: input_schema } + end + end +end diff --git a/lib/model_context_protocol/version.rb b/lib/model_context_protocol/version.rb new file mode 100644 index 0000000..7a7f378 --- /dev/null +++ b/lib/model_context_protocol/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module ModelContextProtocol + VERSION = "0.1.1" +end diff --git a/model_context_protocol.gemspec b/model_context_protocol.gemspec new file mode 100644 index 0000000..2484af9 --- /dev/null +++ b/model_context_protocol.gemspec @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative "lib/model_context_protocol/version" + +Gem::Specification.new do |spec| + spec.name = "model_context_protocol" + spec.version = ModelContextProtocol::VERSION + spec.authors = ["Shopify Engineering"] + spec.email = ["gems@shopify.com"] + + spec.summary = "The official Ruby SDK for Model Context Protocol servers and clients" + spec.description = spec.summary + spec.homepage = "https://github.com/modelcontextprotocol/ruby-sdk" + spec.license = "MIT" + + spec.required_ruby_version = ">= 3.2.0" + + spec.metadata["allowed_push_host"] = "https://rubygems.org" + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage + + spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do + %x(git ls-files -z).split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + end + + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_dependency("activesupport") +end diff --git a/test/model_context_protocol/json_rpc/error_test.rb b/test/model_context_protocol/json_rpc/error_test.rb new file mode 100644 index 0000000..f02091b --- /dev/null +++ b/test/model_context_protocol/json_rpc/error_test.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "test_helper" + +module ModelContextProtocol + module JsonRPC + class ErrorTest < ActiveSupport::TestCase + test "Error#initialize sets attributes" do + code = -32700 + message = "Test error" + data = { foo: "bar" } + error = Error.new(code: code, message: message, data: data) + + assert_equal code, error.code + assert_equal message, error.message + assert_equal data, error.data + end + + test "Error#to_h returns hash representation" do + error = Error.new(code: -32700, message: "Test error", data: { foo: "bar" }) + expected = { + code: -32700, + message: "Test error", + data: { foo: "bar" }, + } + assert_equal expected, error.to_h + end + + test "Error#to_h excludes nil data" do + error = Error.new(code: -32700, message: "Test error", data: nil) + expected = { + code: -32700, + message: "Test error", + } + assert_equal expected, error.to_h + end + + test "InvalidRequestError sets correct code" do + error = InvalidRequestError.new(message: "Invalid request") + assert_equal Error::INVALID_REQUEST_CODE, error.code + assert_equal "Invalid request", error.message + end + + test "ParseError sets correct code" do + error = ParseError.new(message: "Parse error") + assert_equal Error::PARSE_ERROR_CODE, error.code + assert_equal "Parse error", error.message + end + + test "MethodNotFoundError sets correct code" do + error = MethodNotFoundError.new(message: "Method not found") + assert_equal Error::METHOD_NOT_FOUND_CODE, error.code + assert_equal "Method not found", error.message + end + + test "InvalidParamsError sets correct code" do + error = InvalidParamsError.new(message: "Invalid params") + assert_equal Error::INVALID_PARAMS_CODE, error.code + assert_equal "Invalid params", error.message + end + end + end +end diff --git a/test/model_context_protocol/json_rpc/request_test.rb b/test/model_context_protocol/json_rpc/request_test.rb new file mode 100644 index 0000000..4c6fd09 --- /dev/null +++ b/test/model_context_protocol/json_rpc/request_test.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "test_helper" + +module ModelContextProtocol + module JsonRPC + class RequestTest < ActiveSupport::TestCase + test "Request.parse parses valid JSON-RPC request" do + json = { + jsonrpc: "2.0", + method: "test_method", + params: { foo: "bar" }, + id: 1, + }.to_json + + request = Request.parse(json) + assert_equal "2.0", request.version + assert_equal "test_method", request.method + assert_equal({ "foo" => "bar" }, request.params) + assert_equal 1, request.id + end + + test "Request.parse raises ParseError for invalid JSON" do + assert_raises(ParseError) do + Request.parse("invalid json") + end + end + + test "Request#initialize sets attributes" do + request = Request.new( + version: "2.0", + method: "test_method", + params: { "foo" => "bar" }, + id: 1, + ) + + assert_equal "2.0", request.version + assert_equal "test_method", request.method + assert_equal({ "foo" => "bar" }, request.params) + assert_equal 1, request.id + end + + test "Request#notification? returns true when id is nil" do + request = Request.new( + version: "2.0", + method: "test_method", + ) + assert request.notification? + end + + test "Request#notification? returns false when id is present" do + request = Request.new( + version: "2.0", + method: "test_method", + id: 1, + ) + refute request.notification? + end + + test "Request#valid? returns true for valid request" do + request = Request.new( + version: "2.0", + method: "test_method", + ) + assert request.valid? + end + + test "Request#valid? returns false for invalid version" do + request = Request.new( + version: "1.0", + method: "test_method", + ) + refute request.valid? + end + + test "Request#validate! raises InvalidRequestError for invalid version" do + assert_raises(InvalidRequestError) do + Request.new( + version: "1.0", + method: "test_method", + ).validate! + end + end + + test "Request#validate! raises InvalidRequestError for rpc prefixed method" do + assert_raises(InvalidRequestError) do + Request.new( + version: "2.0", + method: "rpc.test", + ).validate! + end + end + + test "Request#method_not_found! raises MethodNotFoundError" do + request = Request.new( + version: "2.0", + method: "test_method", + ) + + assert_raises(MethodNotFoundError) do + request.method_not_found! + end + end + end + end +end diff --git a/test/model_context_protocol/json_rpc/response_test.rb b/test/model_context_protocol/json_rpc/response_test.rb new file mode 100644 index 0000000..21cf6da --- /dev/null +++ b/test/model_context_protocol/json_rpc/response_test.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "test_helper" + +module ModelContextProtocol + module JsonRPC + class ResponseTest < ActiveSupport::TestCase + test "Response#initialize sets attributes with result" do + result = { foo: "bar" } + id = 1 + response = Response.new(result: result, id: id) + + assert_equal "2.0", response.version + assert_equal result, response.result + assert_nil response.error + assert_equal id, response.id + end + + test "Response#initialize sets attributes with error" do + error = Error.new(code: -32700, message: "Test error") + id = 1 + response = Response.new(error: error, id: id) + + assert_equal "2.0", response.version + assert_nil response.result + assert_equal error, response.error + assert_equal id, response.id + end + + test "Response#to_h returns hash representation with result" do + result = { foo: "bar" } + id = 1 + response = Response.new(result: result, id: id) + expected = { + jsonrpc: "2.0", + result: result, + id: id, + } + assert_equal expected, response.to_h + end + + test "Response#to_h returns hash representation with error" do + error = Error.new(code: -32700, message: "Test error") + id = 1 + response = Response.new(error: error, id: id) + expected = { + jsonrpc: "2.0", + error: error.to_h, + id: id, + } + assert_equal expected, response.to_h + end + + test "Response#initialize raises InvalidResponse when both result and error present" do + assert_raises(InvalidResponse) do + Response.new( + result: { foo: "bar" }, + error: Error.new(code: -32700, message: "Test error"), + ) + end + end + + test "Response#initialize raises InvalidResponse when neither result nor error present" do + assert_raises(InvalidResponse) do + Response.new + end + end + end + end +end diff --git a/test/model_context_protocol/prompt_test.rb b/test/model_context_protocol/prompt_test.rb new file mode 100644 index 0000000..716c412 --- /dev/null +++ b/test/model_context_protocol/prompt_test.rb @@ -0,0 +1,51 @@ +# typed: strict +# frozen_string_literal: true + +require "test_helper" + +module ModelContextProtocol + class PromptTest < ActiveSupport::TestCase + test "#template validates arguments" do + prompt = Prompt.new( + name: "test_prompt", + description: "Test prompt", + arguments: [ + Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), + ], + ) + assert_raises(ArgumentError) do + prompt.template({}) + end + end + + test "#template returns a Result with description and messages when arguments are valid" do + prompt = Prompt.new( + name: "test_prompt", + description: "Test prompt", + arguments: [ + Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), + ], + ) do |_| + Prompt::Result.new( + description: "Hello, world!", + messages: [ + Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")), + Prompt::Message.new(role: "assistant", content: Content::Text.new("Hello, friend!")), + ], + ) + end + + expected = { + description: "Hello, world!", + messages: [ + { role: "user", content: { text: "Hello, world!" } }, + { role: "assistant", content: { text: "Hello, friend!" } }, + ], + } + + result = prompt.template({ "test_argument" => "Hello, friend!" }) + + assert_equal expected, result.to_h + end + end +end diff --git a/test/model_context_protocol/server_test.rb b/test/model_context_protocol/server_test.rb new file mode 100644 index 0000000..6d19753 --- /dev/null +++ b/test/model_context_protocol/server_test.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +require "test_helper" + +module ModelContextProtocol + class ServerTest < ActiveSupport::TestCase + setup do + @tool = Tool.new(name: "test_tool", description: "Test tool", input_schema: {}) + + @prompt = Prompt.new( + name: "test_prompt", + description: "Test prompt", + arguments: [ + Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), + ], + ) do |_| + Prompt::Result.new( + description: "Hello, world!", + messages: [ + Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")), + ], + ) + end + + @server_name = "test_server" + @server = Server.new(name: @server_name, tools: [@tool], prompts: [@prompt]) + end + + test "ping request returns pong" do + request = { + jsonrpc: "2.0", + method: "ping", + id: 1, + }.to_json + + response = @server.handle(request) + refute_nil response + + assert_equal "pong", response.result + assert_equal 1, response.id + end + + test "initialize request returns protocol info, server info, and capabilities" do + request = { + jsonrpc: "2.0", + method: "initialize", + id: 1, + }.to_json + + response = @server.handle(request) + refute_nil response + + result = response.result + + assert_equal Server::PROTOCOL_VERSION, result[:protocolVersion] + assert_kind_of Hash, result[:capabilities] + assert_equal @server_name, result[:serverInfo][:name] + assert_equal ModelContextProtocol::VERSION, result[:serverInfo][:version] + end + + test "tools/list returns available tools" do + request = { + jsonrpc: "2.0", + method: "tools/list", + id: 1, + }.to_json + + response = @server.handle(request) + assert_kind_of Array, response.result[:tools] + assert_equal "test_tool", response.result[:tools][0][:name] + assert_equal "Test tool", response.result[:tools][0][:description] + assert_equal({}, response.result[:tools][0][:inputSchema]) + end + + test "returns nil for notification requests" do + request = { + jsonrpc: "2.0", + method: "some_notification", + }.to_json + + assert_nil @server.handle(request) + end + + test "tools/call executes tool and returns result" do + tool_name = "test_tool" + tool_args = { "arg" => "value" } + tool_response = Tool::Response.new([{ "result" => "success" }]) + + @tool.expects(:call).with(**tool_args).returns(tool_response) + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: tool_name, + arguments: tool_args, + }, + id: 1, + }.to_json + + response = @server.handle(request) + assert_equal tool_response.to_h, response.result + end + + test "tools/call returns error if the tool raises an error" do + @tool.expects(:call).raises(StandardError.new("Tool error")) + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "test_tool", + arguments: {}, + }, + id: 1, + }.to_json + + response = @server.handle(request) + + assert_instance_of(JsonRPC::InternalError, response.error) + assert_equal "Tool error", response.error&.message + end + + test "returns error response for invalid requests" do + response = @server.handle("invalid json") + assert_instance_of JsonRPC::Response, response + assert_instance_of(JsonRPC::ParseError, response.error) + end + + test "tools/call returns error for unknown tool" do + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "unknown_tool", + arguments: {}, + }, + id: 1, + }.to_json + + response = @server.handle(request) + assert_instance_of(JsonRPC::Response, response) + assert_instance_of(JsonRPC::MethodNotFoundError, response.error) + end + + test "prompts/list returns list of prompts" do + request = { + jsonrpc: "2.0", + method: "prompts/list", + id: 1, + }.to_json + + response = @server.handle(request) + assert_equal({ prompts: [@prompt.to_h] }, response.result) + end + + test "prompts/get returns templated prompt" do + request = { + jsonrpc: "2.0", + method: "prompts/get", + id: 1, + params: { + name: "test_prompt", + arguments: { "test_argument" => "Hello, friend!" }, + }, + }.to_json + + expected_result = { + description: "Hello, world!", + messages: [ + { role: "user", content: { text: "Hello, world!" } }, + ], + } + + response = @server.handle(request) + + assert_equal(expected_result, response.result) + end + + test "prompts/get returns error if prompt is not found" do + request = { + jsonrpc: "2.0", + method: "prompts/get", + id: 1, + params: { + name: "unknown_prompt", + arguments: {}, + }, + }.to_json + + response = @server.handle(request) + assert_instance_of(JsonRPC::Response, response) + assert_instance_of(JsonRPC::MethodNotFoundError, response.error) + end + + test "prompts/get returns error if prompt arguments are invalid" do + request = { + jsonrpc: "2.0", + method: "prompts/get", + id: 1, + params: { + name: "test_prompt", + arguments: { "unknown_argument" => "Hello, friend!" }, + }, + }.to_json + + response = @server.handle(request) + assert_instance_of(JsonRPC::Response, response) + assert_instance_of(JsonRPC::InvalidParamsError, response.error) + assert_equal "Missing required arguments: test_argument", response.error.message + end + + test "unknown method returns method not found error" do + request = { + jsonrpc: "2.0", + method: "unknown_method", + id: 1, + }.to_json + + response = @server.handle(request) + assert_instance_of(JsonRPC::Response, response) + assert_instance_of(JsonRPC::MethodNotFoundError, response.error) + end + end +end diff --git a/test/model_context_protocol/tool_test.rb b/test/model_context_protocol/tool_test.rb new file mode 100644 index 0000000..e095039 --- /dev/null +++ b/test/model_context_protocol/tool_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "test_helper" + +module ModelContextProtocol + class ToolTest < ActiveSupport::TestCase + test "#to_h returns a hash with name, description, and inputSchema" do + tool = Tool.new(name: "mock_tool", description: "a mock tool for testing") + assert_equal tool.to_h, { name: "mock_tool", description: "a mock tool for testing", inputSchema: nil } + end + + test "#call invokes the tool block and returns the response" do + tool = Tool.new(name: "mock_tool", description: "a mock tool for testing") do |_| + Tool::Response.new([{ type: "text", content: "OK" }]) + end + + response = tool.call + assert_equal response.content, [{ type: "text", content: "OK" }] + assert_equal response.is_error, false + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..b2fd0b1 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +ENV["RAILS_ENV"] ||= "test" + +require "bundler/setup" +require "model_context_protocol" + +require "minitest/autorun" +require "minitest/reporters" +require "minitest/mock" +require "mocha/minitest" + +require "active_support" +require "active_support/test_case" + +Minitest::Reporters.use!(Minitest::Reporters::ProgressReporter.new) + +module ActiveSupport + class TestCase + end +end