diff --git a/.rubocop.yml b/.rubocop.yml index 71716a9..38e6340 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -4,3 +4,7 @@ inherit_gem: plugins: - rubocop-minitest - rubocop-rake + +Security/Eval: + Exclude: + - test/fixtures/files/code_snippet_wrappers/**/*.rb # We must often resort to eval to access local variable diff --git a/README.md b/README.md index c88dfd7..ad18bbd 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ gem 'mcp' And then execute: -```bash +```console $ bundle install ``` Or install it yourself as: -```bash +```console $ gem install mcp ``` @@ -98,6 +98,7 @@ requests. You can use the `Server#handle_json` method to handle requests. + ```ruby class ApplicationController < ActionController::Base @@ -118,6 +119,7 @@ end If you want to build a local command-line application, you can use the stdio transport: + ```ruby #!/usr/bin/env ruby require "mcp" @@ -156,7 +158,8 @@ transport.open You can run this script and then type in requests to the server at the command line. -```bash + +```console $ ./examples/stdio_server.rb {"jsonrpc":"2.0","id":"1","method":"ping"} {"jsonrpc":"2.0","id":"2","method":"tools/list"} @@ -166,6 +169,7 @@ $ ./examples/stdio_server.rb The gem can be configured using the `MCP.configure` block: + ```ruby MCP.configure do |config| config.exception_reporter = ->(exception, server_context) { @@ -186,6 +190,7 @@ or by creating an explicit configuration and passing it into the server. This is useful for systems where an application hosts more than one MCP server but they might require different instrumentation callbacks. + ```ruby configuration = MCP::Configuration.new configuration.exception_reporter = ->(exception, server_context) { @@ -218,6 +223,8 @@ server_context: { [String, Symbol] => Any } ``` **Example:** + + ```ruby server = MCP::Server.new( name: "my_server", @@ -259,6 +266,7 @@ instrumentation_callback = ->(data) { ... } ``` **Example:** + ```ruby config.instrumentation_callback = ->(data) { puts "Instrumentation: #{data.inspect}" @@ -267,16 +275,22 @@ config.instrumentation_callback = ->(data) { ### Server Protocol Version -The server's protocol version can be overridden using the `protocol_version` class method: +The server's protocol version can be overridden via the `Configuration#protocol_version` method: + ```ruby -MCP::Server.protocol_version = "2024-11-05" +MCP.configure do |config| + config.protocol_version = "2024-11-05" +end ``` This will make all new server instances use the specified protocol version instead of the default version. The protocol version can be reset to the default by setting it to `nil`: + ```ruby -MCP::Server.protocol_version = nil +MCP.configure do |config| + config.protocol_version = nil +end ``` Be sure to check the [MCP spec](https://modelcontextprotocol.io/specification/2025-03-26) for the protocol version to understand the supported features for the version being set. @@ -309,6 +323,7 @@ This gem provides a `MCP::Tool` class that can be used to create tools in two wa 1. As a class definition: + ```ruby class MyTool < MCP::Tool description "This tool performs specific functionality..." @@ -336,6 +351,7 @@ tool = MyTool 2. By using the `MCP::Tool.define` method with a block: + ```ruby tool = MCP::Tool.define( name: "my_tool", @@ -372,12 +388,13 @@ The `MCP::Prompt` class provides two ways to create prompts: 1. As a class definition with metadata: + ```ruby class MyPrompt < MCP::Prompt prompt_name "my_prompt" # Optional - defaults to underscored class name description "This prompt performs specific functionality..." arguments [ - Prompt::Argument.new( + MCP::Prompt::Argument.new( name: "message", description: "Input message", required: true @@ -386,16 +403,16 @@ class MyPrompt < MCP::Prompt class << self def template(args, server_context:) - Prompt::Result.new( + MCP::Prompt::Result.new( description: "Response description", messages: [ - Prompt::Message.new( + MCP::Prompt::Message.new( role: "user", - content: Content::Text.new("User message") + content: MCP::Content::Text.new("User message") ), - Prompt::Message.new( + MCP::Prompt::Message.new( role: "assistant", - content: Content::Text.new(args["message"]) + content: MCP::Content::Text.new(args[:message]) ) ] ) @@ -408,28 +425,29 @@ prompt = MyPrompt 2. Using the `MCP::Prompt.define` method: + ```ruby prompt = MCP::Prompt.define( name: "my_prompt", description: "This prompt performs specific functionality...", arguments: [ - Prompt::Argument.new( + MCP::Prompt::Argument.new( name: "message", description: "Input message", required: true ) ] ) do |args, server_context:| - Prompt::Result.new( + MCP::Prompt::Result.new( description: "Response description", messages: [ - Prompt::Message.new( + MCP::Prompt::Message.new( role: "user", - content: Content::Text.new("User message") + content: MCP::Content::Text.new("User message") ), - Prompt::Message.new( + MCP::Prompt::Message.new( role: "assistant", - content: Content::Text.new(args["message"]) + content: MCP::Content::Text.new(args[:message]) ) ] ) @@ -450,6 +468,7 @@ e.g. around authentication state or user preferences. Register prompts with the MCP server: + ```ruby server = MCP::Server.new( name: "my_server", @@ -468,6 +487,7 @@ The server will handle prompt listing and execution through the MCP protocol met The server allows registering a callback to receive information about instrumentation. To register a handler pass a proc/lambda to as `instrumentation_callback` into the server constructor. + ```ruby MCP.configure do |config| config.instrumentation_callback = ->(data) { @@ -493,6 +513,7 @@ MCP spec includes [Resources](https://modelcontextprotocol.io/docs/concepts/reso The `MCP::Resource` class provides a way to register resources with the server. + ```ruby resource = MCP::Resource.new( uri: "https://example.com/my_resource", @@ -509,6 +530,7 @@ server = MCP::Server.new( The server must register a handler for the `resources/read` method to retrieve a resource dynamically. + ```ruby server.resources_read_handler do |params| [{ diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index d246121..675b61b 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -89,11 +89,13 @@ def handle_json(request) end def define_tool(name: nil, description: nil, input_schema: nil, annotations: nil, &block) + @capabilities.support_tools tool = Tool.define(name:, description:, input_schema:, annotations:, &block) @tools[tool.name_value] = tool end def define_prompt(name: nil, description: nil, arguments: [], &block) + @capabilities.support_prompts prompt = Prompt.define(name:, description:, arguments:, &block) @prompts[prompt.name_value] = prompt end @@ -128,6 +130,7 @@ def resources_list_handler(&block) end def resources_read_handler(&block) + @capabilities.support_resources @handlers[Methods::RESOURCES_READ] = block end @@ -142,6 +145,7 @@ def tools_list_handler(&block) end def tools_call_handler(&block) + @capabilities.support_tools @handlers[Methods::TOOLS_CALL] = block end @@ -151,6 +155,7 @@ def prompts_list_handler(&block) end def prompts_get_handler(&block) + @capabilities.support_prompts @handlers[Methods::PROMPTS_GET] = block end diff --git a/test/fixtures/files/code_snippet_wrappers/readme/configuration.rb b/test/fixtures/files/code_snippet_wrappers/readme/configuration.rb new file mode 100644 index 0000000..6b31a2e --- /dev/null +++ b/test/fixtures/files/code_snippet_wrappers/readme/configuration.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "mcp" + +# Stub Bugsnag +class Bugsnag + class Report + attr_reader :metadata + + def initialize + @metadata = {} + end + + def add_metadata(key, value) + @metadata[key] = value + end + end + + class << self + def notify(exception) + report = Report.new + yield report + puts "Bugsnag notified of #{exception.inspect} with metadata #{report.metadata.inspect}" + end + end +end + +require_relative "code_snippet" + +puts MCP::Server.new( + tools: [ + MCP::Tool.define(name: "error_tool") { raise "boom" }, + ], +).handle_json( + { + jsonrpc: "2.0", + id: "1", + method: "tools/call", + params: { name: "error_tool", arguments: {} }, + }.to_json, +) diff --git a/test/fixtures/files/code_snippet_wrappers/readme/instrumentation_callback.rb b/test/fixtures/files/code_snippet_wrappers/readme/instrumentation_callback.rb new file mode 100644 index 0000000..d70fac4 --- /dev/null +++ b/test/fixtures/files/code_snippet_wrappers/readme/instrumentation_callback.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "mcp" + +MCP.configure do |config| + eval(File.read("code_snippet.rb"), binding) + + config.instrumentation_callback.call({ example: "data" }) +end diff --git a/test/fixtures/files/code_snippet_wrappers/readme/per_server_configuration.rb b/test/fixtures/files/code_snippet_wrappers/readme/per_server_configuration.rb new file mode 100644 index 0000000..6cf8e4c --- /dev/null +++ b/test/fixtures/files/code_snippet_wrappers/readme/per_server_configuration.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "mcp" + +# Minimally mock Bugsnag for the test +module Bugsnag + class Report + attr_reader :metadata + + def initialize + @metadata = {} + end + + def add_metadata(key, value) + @metadata[key] = value + end + end + + class << self + def notify(exception) + report = Report.new + yield report + puts "Bugsnag notified of #{exception.inspect} with metadata #{report.metadata.inspect}" + end + end +end + +b = binding +eval(File.read("code_snippet.rb"), b) +server = b.local_variable_get(:server) + +server.define_tool(name: "error_tool") { raise "boom" } + +puts server.handle_json({ + jsonrpc: "2.0", + id: "1", + method: "tools/call", + params: { name: "error_tool", arguments: {} }, +}.to_json) diff --git a/test/fixtures/files/code_snippet_wrappers/readme/prompt_class_definition.rb b/test/fixtures/files/code_snippet_wrappers/readme/prompt_class_definition.rb new file mode 100644 index 0000000..adb58d5 --- /dev/null +++ b/test/fixtures/files/code_snippet_wrappers/readme/prompt_class_definition.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "mcp" + +require_relative "code_snippet" + +b = binding +eval(File.read("code_snippet.rb"), b) +prompt = b.local_variable_get(:prompt) + +server = MCP::Server.new(prompts: [prompt]) + +[ + { jsonrpc: "2.0", id: "1", method: "prompts/list" }, + { jsonrpc: "2.0", id: "2", method: "prompts/get", params: { name: "my_prompt", arguments: { message: "Test message" } } }, +].each { |request| puts server.handle_json(request.to_json) } diff --git a/test/fixtures/files/code_snippet_wrappers/readme/prompt_definition_with_block.rb b/test/fixtures/files/code_snippet_wrappers/readme/prompt_definition_with_block.rb new file mode 120000 index 0000000..f8e6139 --- /dev/null +++ b/test/fixtures/files/code_snippet_wrappers/readme/prompt_definition_with_block.rb @@ -0,0 +1 @@ +prompt_class_definition.rb \ No newline at end of file diff --git a/test/fixtures/files/code_snippet_wrappers/readme/prompts_instrumentation_callback.rb b/test/fixtures/files/code_snippet_wrappers/readme/prompts_instrumentation_callback.rb new file mode 100644 index 0000000..ae59367 --- /dev/null +++ b/test/fixtures/files/code_snippet_wrappers/readme/prompts_instrumentation_callback.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require "mcp" +require_relative "code_snippet" + +puts MCP::Server.new.handle_json({ jsonrpc: "2.0", id: "1", method: "ping" }.to_json) diff --git a/test/fixtures/files/code_snippet_wrappers/readme/prompts_usage.rb b/test/fixtures/files/code_snippet_wrappers/readme/prompts_usage.rb new file mode 100644 index 0000000..878fd8e --- /dev/null +++ b/test/fixtures/files/code_snippet_wrappers/readme/prompts_usage.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "mcp" + +MyPrompt = MCP::Prompt.define( + name: "my_prompt", + description: "Test prompt", + arguments: [ + MCP::Prompt::Argument.new( + name: "message", + description: "Input message", + required: true, + ), + ], +) do |_args, server_context:| + MCP::Prompt::Result.new( + description: "Response with user context", + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new("User ID: #{server_context[:user_id]}"), + ), + ], + ) +end + +current_user = Object.new +def current_user.id = 123 + +b = binding +eval(File.read("code_snippet.rb"), b) +server = b.local_variable_get(:server) + +puts server.handle_json({ + jsonrpc: "2.0", + id: "1", + method: "prompts/get", + params: { name: "my_prompt", arguments: { message: "Test message" } }, +}.to_json) diff --git a/test/fixtures/files/code_snippet_wrappers/readme/rails_controller.rb b/test/fixtures/files/code_snippet_wrappers/readme/rails_controller.rb new file mode 100644 index 0000000..1333e99 --- /dev/null +++ b/test/fixtures/files/code_snippet_wrappers/readme/rails_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "json" +require "stringio" + +# For simplicity, we'll stub out the relevant parts of the Rails API. +module ActionController + class Base + class Request + attr_reader :body + + def initialize(body) + @body = StringIO.new(body) + end + end + + class User + attr_reader :id + + def initialize(id:) + @id = id + end + end + + attr_reader :request + + def initialize(request_body) + @request = Request.new(request_body) + end + + def render(json:) = json + + private + + def current_user = User.new(id: 1) + end +end + +# We need to create the minimal surrounding resources to run the code snippet +require "mcp" + +SomeTool = MCP::Tool.define(name: "some_tool") { MCP::Tool::Response.new(content: "some_tool response") } +AnotherTool = MCP::Tool.define(name: "another_tool") { MCP::Tool::Response.new(content: "another_tool response") } +MyPrompt = MCP::Prompt.define(name: "my_prompt") { MCP::Prompt::Result.new } + +require_relative "code_snippet" + +puts ApplicationController.new( + { jsonrpc: "2.0", id: "1", method: "ping" }.to_json, +).index diff --git a/test/fixtures/files/code_snippet_wrappers/readme/resources.rb b/test/fixtures/files/code_snippet_wrappers/readme/resources.rb new file mode 100644 index 0000000..7c110a3 --- /dev/null +++ b/test/fixtures/files/code_snippet_wrappers/readme/resources.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "mcp" + +b = binding +eval(File.read("code_snippet.rb"), b) +server = b.local_variable_get(:server) + +puts server.handle_json({ jsonrpc: "2.0", id: "1", method: "resources/list" }.to_json) diff --git a/test/fixtures/files/code_snippet_wrappers/readme/resources_read_handler.rb b/test/fixtures/files/code_snippet_wrappers/readme/resources_read_handler.rb new file mode 100644 index 0000000..210aac4 --- /dev/null +++ b/test/fixtures/files/code_snippet_wrappers/readme/resources_read_handler.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "mcp" + +server = MCP::Server.new + +b = binding +eval(File.read("code_snippet.rb"), b) + +puts server.handle_json({ + jsonrpc: "2.0", + id: "1", + method: "resources/read", + params: { uri: "https://example.com/test_resource" }, +}.to_json) diff --git a/test/fixtures/files/code_snippet_wrappers/readme/server_context.rb b/test/fixtures/files/code_snippet_wrappers/readme/server_context.rb new file mode 100644 index 0000000..89aff9a --- /dev/null +++ b/test/fixtures/files/code_snippet_wrappers/readme/server_context.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "mcp" + +current_user = Object.new +def current_user.id = 123 + +request = Object.new +def request.uuid = "...uuid..." + +b = binding +eval(File.read("code_snippet.rb"), b) +server = b.local_variable_get(:server) + +puts server.server_context.to_json diff --git a/test/fixtures/files/code_snippet_wrappers/readme/set_server_protocol_version.rb b/test/fixtures/files/code_snippet_wrappers/readme/set_server_protocol_version.rb new file mode 100644 index 0000000..836b051 --- /dev/null +++ b/test/fixtures/files/code_snippet_wrappers/readme/set_server_protocol_version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "mcp" + +require_relative "code_snippet" + +puts MCP.configuration.protocol_version diff --git a/test/fixtures/files/code_snippet_wrappers/readme/tool_class_definition.rb b/test/fixtures/files/code_snippet_wrappers/readme/tool_class_definition.rb new file mode 100644 index 0000000..0d65654 --- /dev/null +++ b/test/fixtures/files/code_snippet_wrappers/readme/tool_class_definition.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "mcp" + +require_relative "code_snippet" + +b = binding +eval(File.read("code_snippet.rb"), b) +tool = b.local_variable_get(:tool) + +puts MCP::Server.new(tools: [tool]).handle_json( + { + jsonrpc: "2.0", + id: "1", + method: "tools/call", + params: { name: "my_tool", arguments: { message: "Hello, world!" } }, + }.to_json, +) diff --git a/test/fixtures/files/code_snippet_wrappers/readme/tool_definition_with_block.rb b/test/fixtures/files/code_snippet_wrappers/readme/tool_definition_with_block.rb new file mode 120000 index 0000000..9193fc3 --- /dev/null +++ b/test/fixtures/files/code_snippet_wrappers/readme/tool_definition_with_block.rb @@ -0,0 +1 @@ +tool_class_definition.rb \ No newline at end of file diff --git a/test/fixtures/files/code_snippet_wrappers/readme/unset_server_protocol_version.rb b/test/fixtures/files/code_snippet_wrappers/readme/unset_server_protocol_version.rb new file mode 120000 index 0000000..6e5c42a --- /dev/null +++ b/test/fixtures/files/code_snippet_wrappers/readme/unset_server_protocol_version.rb @@ -0,0 +1 @@ +set_server_protocol_version.rb \ No newline at end of file diff --git a/test/integration/examples_test.rb b/test/integration/examples_test.rb new file mode 100644 index 0000000..aa1e51a --- /dev/null +++ b/test/integration/examples_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "test_helper" + +require "open3" +require "json" +require "timeout" +require "tempfile" +require "fileutils" + +require "readme_test_helper" + +# Run tests on the files in the `examples` directory to ensure they work as expected. +# These are not intended to be comprehensive; they are just sanity checks. +class ExamplesTest < ActiveSupport::TestCase + include ReadmeTestHelper + + make_my_diffs_pretty! + + test "examples/stdio_server.rb example works exactly as documented in README" do + command_line, *input_lines = extract_readme_code_snippet("running_stdio_server", language: "console").lines(chomp: true) + + command = command_line.delete_prefix("$ ") + stdin_data = input_lines.join("\n") + + stdout, stderr, status = Open3.capture3(command, chdir: project_root, stdin_data:) + assert_predicate(status, :success?, "Expected #{command} to exit with success, but got exit status #{status.exitstatus}\n\nSTDOUT: #{stdout}\n\nSTDERR: #{stderr}") + assert_empty(stderr, "Expected no stderr in: #{stderr}") + refute_empty(stdout, "Expected stdout not to be empty") + + assert_equal( + [ + { jsonrpc: "2.0", id: "1", result: {} }, + { + jsonrpc: "2.0", + id: "2", + result: { + tools: [ + { + name: "example_tool", + description: "A simple example tool that adds two numbers", + inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] }, + }, + { + name: "echo", + description: "A simple example tool that echoes back its arguments", + inputSchema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, + }, + ], + }, + }, + ], + stdout.lines.map { |line| JSON.parse(line, symbolize_names: true) }, + ) + end +end diff --git a/test/integration/readme_code_snippets_test.rb b/test/integration/readme_code_snippets_test.rb new file mode 100644 index 0000000..4e90578 --- /dev/null +++ b/test/integration/readme_code_snippets_test.rb @@ -0,0 +1,330 @@ +# frozen_string_literal: true + +require "test_helper" + +require "fileutils" +require "open3" +require "tempfile" +require "timeout" + +require "readme_test_helper" + +# Run tests on the code snippets in the README.md file to ensure they work as expected. +# These are not intended to be comprehensive; they are just sanity checks. +class ReadmeCodeSnippetsTest < ActiveSupport::TestCase + include ReadmeTestHelper + + make_my_diffs_pretty! + + test "Rails Controller example handles requests" do + assert_json_lines( + [ + { jsonrpc: "2.0", id: "1", result: {} }, + ], + run_code_snippet("rails_controller"), + ) + end + + test "Stdio Transport example works exactly as documented in README" do + # This snippet is a standalone example server/transport, so we run it directly and send it requests + stdout = Tempfile.create(["stdio_transport", ".rb"]) do |file| + file.write(extract_readme_code_snippet("stdio_transport")) + file.close + FileUtils.chmod("+x", file.path) # Make executable + + # Reuse example input from README, but drop command to start the server (`$ ...` line) + stdin_data = extract_readme_code_snippet("running_stdio_server", language: "console").lines(chomp: true).grep_v(/^\$\s/).join("\n") + + stdout, stderr, status = Open3.capture3(file.path, stdin_data:) + assert_predicate(status, :success?, "Expected #{file.path} to exit with success, but got exit status #{status.exitstatus}\n\nSTDOUT: #{stdout}\n\nSTDERR: #{stderr}") + assert_empty(stderr, "Expected no stderr in: #{stderr}") + refute_empty(stdout, "Expected stdout not to be empty") + + stdout + end + + assert_json_lines( + [ + { jsonrpc: "2.0", id: "1", result: {} }, + { + jsonrpc: "2.0", + id: "2", + result: { + tools: [ + { + name: "example_tool", + description: "A simple example tool that echoes back its arguments", + inputSchema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, + }, + ], + }, + }, + ], + stdout, + ) + end + + test "Configuration example works exactly as documented in README" do + stdout = run_code_snippet("configuration") + + request = { + jsonrpc: "2.0", + id: "1", + method: "tools/call", + params: { name: "error_tool", arguments: {} }, + }.to_json + + error = MCP::Server::RequestHandlerError.new("Internal error calling tool error_tool", request) + metadata = { model_context_protocol: { request: } } + instrumentation_data = { method: "tools/call", tool_name: "error_tool", error: :internal_error, duration: 1.23 } + + response = { + jsonrpc: "2.0", + id: "1", + error: { code: -32603, message: "Internal error", data: "Internal error calling tool error_tool" }, + }.to_json + + assert_equal(<<~STDOUT, stdout) + Bugsnag notified of #{error.inspect} with metadata #{metadata.inspect} + Got instrumentation data #{instrumentation_data} + #{response} + STDOUT + end + + test "Per-Server Configuration example works exactly as documented in README" do + stdout = run_code_snippet("per_server_configuration") + + request = { + jsonrpc: "2.0", + id: "1", + method: "tools/call", + params: { name: "error_tool", arguments: {} }, + }.to_json + + error = MCP::Server::RequestHandlerError.new("Internal error calling tool error_tool", request) + metadata = { model_context_protocol: { request: } } + instrumentation_data = { method: "tools/call", tool_name: "error_tool", error: :internal_error, duration: 1.23 } + + response = { + jsonrpc: "2.0", + id: "1", + error: { code: -32603, message: "Internal error", data: "Internal error calling tool error_tool" }, + }.to_json + + expected_output = <<~OUTPUT + Bugsnag notified of #{error.inspect} with metadata #{metadata.inspect} + Got instrumentation data #{instrumentation_data} + #{response} + OUTPUT + + assert_equal(expected_output, stdout) + end + + test "Server Context example works exactly as documented in README" do + assert_json_lines( + [ + { user_id: 123, request_id: "...uuid..." }, + ], + run_code_snippet("server_context"), + ) + end + + test "Instrumentation Callback example works exactly as documented in README" do + instrumentation_data = { example: "data" } + + assert_equal(<<~STDOUT, run_code_snippet("instrumentation_callback")) + Instrumentation: #{instrumentation_data} + STDOUT + end + + test "Protocol Version example works exactly as documented in README" do + assert_equal("2024-11-05", run_code_snippet("set_server_protocol_version").chomp) + assert_equal(MCP::Configuration::DEFAULT_PROTOCOL_VERSION, run_code_snippet("unset_server_protocol_version").chomp) + end + + test "Tools examples work exactly as documented in README" do + assert_json_lines( + [ + { jsonrpc: "2.0", id: "1", result: { content: [{ type: "text", text: "OK" }], isError: false } }, + ], + run_code_snippet("tool_class_definition"), + ) + + skip "FIXME: this next code snippet is invalid and there doesn't seem to be a way to make both pass..." + + assert_json_lines( + [ + { jsonrpc: "2.0", id: "1", result: { content: [{ type: "text", text: "OK" }], isError: false } }, + ], + run_code_snippet("tool_definition_with_block"), + ) + end + + test "Prompts examples work exactly as documented in README" do + assert_json_lines( + [ + { + jsonrpc: "2.0", + id: "1", + result: { + prompts: [ + { + name: "my_prompt", + description: "This prompt performs specific functionality...", + arguments: [ + { name: "message", description: "Input message", required: true }, + ], + }, + ], + }, + }, + { + jsonrpc: "2.0", + id: "2", + result: { + description: "Response description", + messages: [ + { role: "user", content: { type: "text", text: "User message" } }, + { role: "assistant", content: { type: "text", text: "Test message" } }, + ], + }, + }, + ], + run_code_snippet("prompt_class_definition"), + ) + + assert_json_lines( + [ + { + jsonrpc: "2.0", + id: "1", + result: { + prompts: [ + { + name: "my_prompt", + description: "This prompt performs specific functionality...", + arguments: [ + { name: "message", description: "Input message", required: true }, + ], + }, + ], + }, + }, + { + jsonrpc: "2.0", + id: "2", + result: { + description: "Response description", + messages: [ + { role: "user", content: { type: "text", text: "User message" } }, + { role: "assistant", content: { type: "text", text: "Test message" } }, + ], + }, + }, + ], + run_code_snippet("prompt_definition_with_block"), + ) + end + + test "Prompt usage example works exactly as documented in README" do + assert_json_lines( + [ + { + jsonrpc: "2.0", + id: "1", + result: { + description: "Response with user context", + messages: [ + { role: "user", content: { type: "text", text: "User ID: 123" } }, + ], + }, + }, + ], + run_code_snippet("prompts_usage"), + ) + end + + test "Prompts Instrumentation Callback example works exactly as documented in README" do + stdout = run_code_snippet("prompts_instrumentation_callback") + instrumentation_data = { method: "ping", duration: 1.23 } + + assert_equal(<<~STDOUT, stdout) + Got instrumentation data #{instrumentation_data} + #{{ jsonrpc: "2.0", id: "1", result: {} }.to_json} + STDOUT + end + + test "Resources examples work exactly as documented in README" do + assert_json_lines( + [ + { + jsonrpc: "2.0", + id: "1", + result: { + resources: [ + { + uri: "https://example.com/my_resource", + name: "My Resource", + description: "Lorem ipsum dolor sit amet", + mimeType: "text/html", + }, + ], + }, + }, + ], + run_code_snippet("resources"), + ) + end + + test "Resources Read Handler example works exactly as documented in README" do + assert_json_lines( + [ + { + jsonrpc: "2.0", + id: "1", + result: { + contents: [ + { + uri: "https://example.com/test_resource", + mimeType: "text/plain", + text: "https://example.com/test_resource", + }, + ], + }, + }, + ], + run_code_snippet("resources_read_handler"), + ) + end + + private + + def assert_json_lines(expected, actual, message = "Expected the given JSON lines") + assert_equal( + expected, + actual.lines(chomp: true).map { JSON.parse(_1, symbolize_names: true) }, + message, + ) + end + + def run_code_snippet(code_snippet_name) + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + File.write("code_snippet.rb", extract_readme_code_snippet(code_snippet_name)) + File.write("test.rb", file_fixture("code_snippet_wrappers/readme/#{code_snippet_name}.rb").read) + + stdout, stderr, status = Timeout.timeout(5) { Open3.capture3("ruby", "test.rb") } + assert_predicate(status, :success?, "Expected test.rb to exit with success, but got exit status #{status.exitstatus}\n\nSTDOUT: #{stdout}\n\nSTDERR: #{stderr}") + assert_empty(stderr, "Expected no stderr in: #{stderr}") + refute_empty(stdout, "Expected stdout not to be empty") + + normalize_stdout(stdout) + end + end + end + + def normalize_stdout(stdout) + stdout + .gsub(/\d+\.\d{10,}(?:e-\d+)?/, "1.23") # Normalize long floats e.g. 'duration: 1.23456789012345678-05' => 'duration: 1.23' + end +end diff --git a/test/readme_test_helper.rb b/test/readme_test_helper.rb new file mode 100644 index 0000000..9ccf84f --- /dev/null +++ b/test/readme_test_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ReadmeTestHelper + private + + # Extracts a code snippet from the README.md file by its "SNIPPET ID" comment and language + def extract_readme_code_snippet(id, language: "ruby") + snippet = readme_content[/\n```#{language}\n(.*?)\n```/m, 1] + assert_not_nil(snippet, "Could not find code snippet with ID #{id}") + snippet + end + + def readme_content = File.read(File.join(project_root, "README.md")) + def project_root = File.expand_path("..", __dir__) +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 99f6bba..436adc1 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -21,5 +21,6 @@ module ActiveSupport class TestCase + self.file_fixture_path = File.join(__dir__, "fixtures/files") end end