├── .rspec ├── docs ├── .gitignore ├── assets │ └── images │ │ └── favicon │ │ ├── favicon.ico │ │ ├── favicon-96x96.png │ │ └── apple-touch-icon.png ├── Gemfile ├── _sass │ └── custom │ │ └── custom.scss ├── _includes │ └── header.html ├── 404.html ├── guides │ ├── index.md │ ├── upgrading-0.6-to-0.7.md │ └── upgrading-0.8-to-1.0.md ├── _config.yml ├── client │ └── index.md └── server │ └── index.md ├── spec ├── fixtures │ ├── typescript-mcp │ │ ├── index.ts │ │ ├── resources │ │ │ ├── resource_without_metadata.txt │ │ │ ├── second-file.txt │ │ │ ├── dog.png │ │ │ ├── jackhammer.wav │ │ │ ├── colourful-gecko.png │ │ │ ├── background-birds.wav │ │ │ ├── test.txt │ │ │ └── my.md │ │ ├── Dockerfile │ │ ├── src │ │ │ ├── resources │ │ │ │ ├── index.ts │ │ │ │ ├── templates.ts │ │ │ │ └── media.ts │ │ │ ├── prompts │ │ │ │ ├── index.ts │ │ │ │ ├── simple.ts │ │ │ │ └── greetings.ts │ │ │ ├── tools │ │ │ │ ├── index.ts │ │ │ │ ├── weather.ts │ │ │ │ └── media.ts │ │ │ └── utils │ │ │ │ └── file-utils.ts │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── README.md │ ├── pagination-server │ │ ├── index.ts │ │ ├── Dockerfile │ │ ├── package.json │ │ ├── tsconfig.json │ │ ├── test_stdio.sh │ │ └── src │ │ │ └── tools │ │ │ └── index.ts │ ├── fast-mcp-ruby │ │ ├── Gemfile │ │ └── Dockerfile │ └── vcr_cassettes │ │ ├── Cancellation_Integration │ │ ├── with_stdio-native │ │ │ └── End-to-end_cancellation_with_stdio-native │ │ │ │ ├── allows_multiple_cancellations_without_errors.yml │ │ │ │ ├── properly_cleans_up_cancelled_requests.yml │ │ │ │ └── handles_server-initiated_cancellation_of_sampling_requests.yml │ │ └── with_streamable-native │ │ │ └── End-to-end_cancellation_with_streamable-native │ │ │ └── properly_cleans_up_cancelled_requests.yml │ │ ├── with_stdio-native_with_gemini_gemini-2_0-flash_ask_prompt_handles_prompts_when_available.yml │ │ ├── with_streamable-native_with_gemini_gemini-2_0-flash_ask_prompt_handles_prompts_when_available.yml │ │ ├── sample_with_stdio-native_with_gemini_gemini-2_0-flash_provides_information_about_the_sample_with_a_guard.yml │ │ ├── sample_with_stdio-native_client_can_call_a_block_to_determine_the_preferred_model_accessing_the_model_preferences.yml │ │ ├── with_stdio-native_with_gemini_gemini-2_0-flash_with_prompt_adds_prompt_to_the_chat_when_available.yml │ │ ├── sample_with_streamable-native_with_gemini_gemini-2_0-flash_executes_a_chat_message_and_provides_information_to_the_server_without_a_guard.yml │ │ ├── with_streamable-native_with_gemini_gemini-2_0-flash_with_prompt_adds_prompt_to_the_chat_when_available.yml │ │ ├── with_streamable-native_with_gemini_gemini-2_0-flash_with_resource_template_handles_template_arguments_correctly.yml │ │ ├── with_stdio-native_with_gemini_gemini-2_0-flash_with_resource_template_adds_resource_templates_to_the_chat_and_uses_them.yml │ │ ├── with_streamable-native_with_gemini_gemini-2_0-flash_with_resource_template_adds_resource_templates_to_the_chat_and_uses_them.yml │ │ ├── with_stdio-native_with_gemini_gemini-2_0-flash_with_resource_template_handles_template_arguments_correctly.yml │ │ ├── sample_with_streamable-native_client_can_call_a_block_to_determine_the_preferred_model_accessing_the_model_preferences.yml │ │ ├── sample_with_streamable-native_with_gemini_gemini-2_0-flash_provides_information_about_the_sample_with_a_guard.yml │ │ ├── sample_with_stdio-native_with_gemini_gemini-2_0-flash_executes_a_chat_message_and_provides_information_to_the_server_without_a_guard.yml │ │ ├── with_stdio-mcp-sdk_with_gemini_gemini-2_0-flash_with_resource_adds_a_single_text_resource_to_the_chat.yml │ │ ├── with_stdio-native_with_gemini_gemini-2_0-flash_with_resource_adds_a_single_text_resource_to_the_chat.yml │ │ ├── with_streamable-native_with_gemini_gemini-2_0-flash_with_resource_adds_a_single_text_resource_to_the_chat.yml │ │ └── with_streamable-mcp-sdk_with_gemini_gemini-2_0-flash_with_resource_adds_a_single_text_resource_to_the_chat.yml ├── support │ ├── time_support.rb │ ├── simple_multiply_tool.rb │ ├── client_sync_helpers.rb │ ├── client_runner.rb │ └── mcp_test_configuration.rb └── ruby_llm │ └── mcp │ ├── auth │ ├── grant_strategies_spec.rb │ └── memory_storage_spec.rb │ ├── progress_spec.rb │ ├── adapters │ ├── mcp_sdk_sse_spec.rb │ ├── mcp_sdk_stdio_spec.rb │ └── mcp_sdk_streamable_spec.rb │ ├── native │ └── response_handler_spec.rb │ ├── notification_handler_spec.rb │ └── prompt_spec.rb ├── lib ├── ruby_llm │ ├── mcp │ │ ├── version.rb │ │ ├── native.rb │ │ ├── logging.rb │ │ ├── native │ │ │ ├── notification.rb │ │ │ ├── transports │ │ │ │ └── support │ │ │ │ │ ├── http_client.rb │ │ │ │ │ ├── timeout.rb │ │ │ │ │ └── rate_limit.rb │ │ │ ├── protocol.rb │ │ │ ├── messages │ │ │ │ ├── helpers.rb │ │ │ │ └── notifications.rb │ │ │ ├── messages.rb │ │ │ └── cancellable_operation.rb │ │ ├── completion.rb │ │ ├── railtie.rb │ │ ├── attachment.rb │ │ ├── error.rb │ │ ├── content.rb │ │ ├── auth │ │ │ ├── browser │ │ │ │ ├── callback_server.rb │ │ │ │ ├── opener.rb │ │ │ │ └── callback_handler.rb │ │ │ ├── grant_strategies │ │ │ │ ├── authorization_code.rb │ │ │ │ ├── client_credentials.rb │ │ │ │ └── base.rb │ │ │ ├── security.rb │ │ │ ├── session_manager.rb │ │ │ ├── flows │ │ │ │ └── client_credentials_flow.rb │ │ │ ├── memory_storage.rb │ │ │ └── http_response_handler.rb │ │ ├── roots.rb │ │ ├── progress.rb │ │ ├── adapters │ │ │ └── mcp_transports │ │ │ │ ├── coordinator_stub.rb │ │ │ │ ├── sse.rb │ │ │ │ └── stdio.rb │ │ ├── server_capabilities.rb │ │ ├── elicitation.rb │ │ ├── errors.rb │ │ ├── resource_template.rb │ │ └── result.rb │ └── chat.rb ├── tasks │ └── release.rake └── generators │ └── ruby_llm │ └── mcp │ ├── oauth │ └── templates │ │ ├── migrations │ │ ├── create_mcp_oauth_credentials.rb.tt │ │ └── create_mcp_oauth_states.rb.tt │ │ ├── jobs │ │ └── cleanup_expired_oauth_states_job.rb.tt │ │ ├── models │ │ ├── mcp_oauth_state.rb.tt │ │ └── mcp_oauth_credential.rb.tt │ │ └── lib │ │ └── mcp_client.rb.tt │ └── install │ ├── templates │ ├── mcps.yml │ └── initializer.rb │ └── install_generator.rb ├── bin ├── setup └── console ├── .gitignore ├── Rakefile ├── examples ├── tools │ ├── sse_mcp_tool_call.rb │ ├── sse_mcp_with_gpt.rb │ ├── streamable_mcp.rb │ └── local_mcp.rb ├── resources │ └── list_resources.rb ├── utilities │ └── progress.rb └── prompts │ └── streamable_prompt_call.rb ├── Gemfile ├── .github └── workflows │ ├── cicd.yml │ └── docs.yml ├── LICENSE ├── .rubocop.yml └── ruby_llm-mcp.gemspec /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _site 2 | .sass-cache 3 | .jekyll-cache 4 | .jekyll-metadata 5 | vendor 6 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/index.ts: -------------------------------------------------------------------------------- 1 | // Entry point - import and run the server 2 | import "./src/index.ts"; 3 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/resources/resource_without_metadata.txt: -------------------------------------------------------------------------------- 1 | This is a new resource without metadata 2 | -------------------------------------------------------------------------------- /spec/fixtures/pagination-server/index.ts: -------------------------------------------------------------------------------- 1 | // Entry point - import and run the server 2 | import "./src/index.ts"; 3 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/resources/second-file.txt: -------------------------------------------------------------------------------- 1 | This is the second file that I want to be able to use as a resource 2 | -------------------------------------------------------------------------------- /docs/assets/images/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patvice/ruby_llm-mcp/HEAD/docs/assets/images/favicon/favicon.ico -------------------------------------------------------------------------------- /docs/assets/images/favicon/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patvice/ruby_llm-mcp/HEAD/docs/assets/images/favicon/favicon-96x96.png -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | VERSION = "0.8.0" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/resources/dog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patvice/ruby_llm-mcp/HEAD/spec/fixtures/typescript-mcp/resources/dog.png -------------------------------------------------------------------------------- /docs/assets/images/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patvice/ruby_llm-mcp/HEAD/docs/assets/images/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /spec/fixtures/fast-mcp-ruby/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "fast-mcp" 6 | gem "puma" 7 | gem "rack" 8 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/resources/jackhammer.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patvice/ruby_llm-mcp/HEAD/spec/fixtures/typescript-mcp/resources/jackhammer.wav -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/resources/colourful-gecko.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patvice/ruby_llm-mcp/HEAD/spec/fixtures/typescript-mcp/resources/colourful-gecko.png -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/resources/background-birds.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patvice/ruby_llm-mcp/HEAD/spec/fixtures/typescript-mcp/resources/background-birds.wav -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/resources/test.txt: -------------------------------------------------------------------------------- 1 | Hello, this is a test file! 2 | This content will be served by the MCP resource. 3 | You can modify this file and it will be cached for 5 minutes. 4 | -------------------------------------------------------------------------------- /spec/fixtures/pagination-server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:1.2.16 2 | RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* 3 | WORKDIR /app 4 | COPY . . 5 | RUN bun install 6 | EXPOSE 3007 7 | CMD ["bun", "index.ts"] 8 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:1.2.16 2 | RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* 3 | WORKDIR /app 4 | COPY . . 5 | RUN bun install 6 | EXPOSE 3005 7 | CMD ["bun", "index.ts"] 8 | -------------------------------------------------------------------------------- /docs/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "jekyll", "~> 4.4.1" 6 | gem "just-the-docs", "0.10.1" 7 | gem "minima", "~> 2.5" 8 | 9 | group :jekyll_plugins do 10 | gem "jekyll-seo-tag" 11 | gem "jekyll-sitemap" 12 | end 13 | -------------------------------------------------------------------------------- /docs/_sass/custom/custom.scss: -------------------------------------------------------------------------------- 1 | .logo-container { 2 | display: flex; 3 | align-items: center; 4 | flex-wrap: wrap; 5 | gap: 1em; 6 | } 7 | 8 | .badge-container { 9 | display: flex; 10 | align-items: center; 11 | flex-wrap: wrap; 12 | gap: 0.5em; 13 | margin: 1em 0; 14 | } 15 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/resources/my.md: -------------------------------------------------------------------------------- 1 | # My Markdown File 2 | 3 | This is a markdown file that I want to be able to use as a resource 4 | 5 | ## Section 1 6 | 7 | This is a section in my markdown file 8 | 9 | ## Section 2 10 | 11 | This is another section in my markdown file 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /scripts/ 10 | 11 | # rspec failure tracking 12 | .rspec_status 13 | .env 14 | *.gem 15 | 16 | .DS_Store 17 | .ruby-version 18 | Gemfile.lock 19 | 20 | node_modules/ 21 | .vcr_debug.log 22 | -------------------------------------------------------------------------------- /spec/fixtures/fast-mcp-ruby/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.4 2 | RUN apt-get update && \ 3 | apt-get install -y curl && \ 4 | rm -rf /var/lib/apt/lists/* 5 | WORKDIR /app 6 | COPY . . 7 | RUN bundle install 8 | ENV BIND=tcp://0.0.0.0:3006 9 | ENV DOCKER=true 10 | EXPOSE 3006 11 | CMD ["ruby", "lib/app.rb"] 12 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/native.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "native/messages" 4 | 5 | module RubyLLM 6 | module MCP 7 | # Native protocol implementation namespace 8 | # This module contains the native Ruby MCP protocol implementation 9 | module Native 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | Dir.glob("lib/tasks/*.rake").each { |file| load file } 7 | 8 | RSpec::Core::RakeTask.new(:spec) 9 | 10 | require "rubocop/rake_task" 11 | 12 | RuboCop::RakeTask.new 13 | 14 | task default: %i[spec rubocop] 15 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "ruby_llm/mcp" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Logging 6 | DEBUG = "debug" 7 | INFO = "info" 8 | NOTICE = "notice" 9 | WARNING = "warning" 10 | ERROR = "error" 11 | CRITICAL = "critical" 12 | ALERT = "alert" 13 | EMERGENCY = "emergency" 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/native/notification.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Native 6 | class Notification 7 | attr_reader :type, :params 8 | 9 | def initialize(response) 10 | @type = response["method"] 11 | @params = response["params"] 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/completion.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | class Completion 6 | attr_reader :argument, :values, :total, :has_more 7 | 8 | def initialize(argument:, values:, total:, has_more:) 9 | @argument = argument 10 | @values = values 11 | @total = total 12 | @has_more = has_more 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if defined?(Rails::Railtie) 4 | module RubyLLM 5 | module MCP 6 | class Railtie < Rails::Railtie 7 | generators do 8 | require_relative "../../generators/ruby_llm/mcp/install/install_generator" 9 | require_relative "../../generators/ruby_llm/mcp/oauth/install_generator" 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /docs/_includes/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/attachment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | class Attachment < RubyLLM::Attachment 6 | attr_reader :content, :mime_type 7 | 8 | def initialize(content, mime_type) # rubocop:disable Lint/MissingSuper 9 | @content = content 10 | @mime_type = mime_type 11 | end 12 | 13 | def encoded 14 | @content 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/support/time_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TimeSupport 4 | def freeze_time(&block) 5 | time = Time.now 6 | allow(Time).to receive(:now).and_return(time) 7 | block.call 8 | allow(Time).to receive(:now).and_call_original 9 | end 10 | 11 | def travel_to(time) 12 | allow(Time).to receive(:now).and_return(time) 13 | end 14 | end 15 | 16 | RSpec.configure do |config| 17 | config.include TimeSupport 18 | end 19 | -------------------------------------------------------------------------------- /spec/support/simple_multiply_tool.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Simple test tool class using base RubyLLM::Parameter 4 | class SimpleMultiplyTool < RubyLLM::Tool 5 | description "Multiply two numbers together" 6 | 7 | param :x, type: :number, desc: "First number", required: true 8 | param :y, type: :number, desc: "Second number", required: true 9 | 10 | def execute(x:, y:) # rubocop:disable Naming/MethodParameterName 11 | (x * y).to_s 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/src/resources/index.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { setupTextResources } from "./text.js"; 3 | import { setupMediaResources } from "./media.js"; 4 | import { setupTemplateResources } from "./templates.js"; 5 | 6 | export function setupResources(server: McpServer) { 7 | // Setup different categories of resources 8 | setupTextResources(server); 9 | setupMediaResources(server); 10 | setupTemplateResources(server); 11 | } 12 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/src/prompts/index.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { setupSimplePrompts } from "./simple.js"; 3 | import { setupGreetingPrompts } from "./greetings.js"; 4 | import { setupProtocol2025Prompts } from "./protocol-2025-06-18.js"; 5 | 6 | export function setupPrompts(server: McpServer) { 7 | // Setup different categories of prompts 8 | setupSimplePrompts(server); 9 | setupGreetingPrompts(server); 10 | setupProtocol2025Prompts(server); 11 | } 12 | -------------------------------------------------------------------------------- /docs/404.html: -------------------------------------------------------------------------------- 1 | --- 2 | permalink: /404.html 3 | layout: page 4 | --- 5 | 6 | 19 | 20 |
21 |

404

22 | 23 |

Page not found :(

24 |

The requested page could not be found.

25 |
26 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mcp-streamable", 3 | "module": "index.ts", 4 | "type": "module", 5 | "scripts": { 6 | "start:http": "bun src/index.ts", 7 | "start:cli": "bun src/index.ts --stdio", 8 | "dev": "bun --watch src/index.ts" 9 | }, 10 | "devDependencies": { 11 | "@types/bun": "latest", 12 | "@types/express": "^5.0.2" 13 | }, 14 | "peerDependencies": { 15 | "typescript": "^5.0.0" 16 | }, 17 | "dependencies": { 18 | "@modelcontextprotocol/sdk": "^1.13.1", 19 | "agents": "^0.0.94", 20 | "express": "^5.1.0", 21 | "zod": "^3.25.51" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/tools/sse_mcp_tool_call.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "ruby_llm/mcp" 5 | require "debug" 6 | require "dotenv" 7 | 8 | Dotenv.load 9 | 10 | RubyLLM.configure do |config| 11 | config.openai_api_key = ENV.fetch("OPENAI_API_KEY", nil) 12 | end 13 | 14 | client = RubyLLM::MCP.client( 15 | name: "local_mcp", 16 | transport_type: :sse, 17 | config: { 18 | url: "http://localhost:9292/mcp/sse" 19 | } 20 | ) 21 | 22 | tools = client.tools 23 | puts "Tools:\n" 24 | puts tools.map { |tool| " #{tool.name}: #{tool.description}" }.join("\n") 25 | puts "\nTotal tools: #{tools.size}" 26 | -------------------------------------------------------------------------------- /spec/fixtures/pagination-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mcp-streamable", 3 | "module": "index.ts", 4 | "type": "module", 5 | "scripts": { 6 | "start:http": "bun src/index.ts", 7 | "start:cli": "bun src/index.ts --stdio", 8 | "dev": "bun --watch src/index.ts" 9 | }, 10 | "devDependencies": { 11 | "@types/bun": "latest", 12 | "@types/express": "^5.0.2" 13 | }, 14 | "peerDependencies": { 15 | "typescript": "^5.0.0" 16 | }, 17 | "dependencies": { 18 | "@modelcontextprotocol/sdk": "^1.13.1", 19 | "agents": "^0.0.94", 20 | "express": "^5.1.0", 21 | "zod": "^3.25.51" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | group :development do 8 | if RUBY_VERSION >= "3.2.0" 9 | gem "mcp", "~> 0.4" 10 | end 11 | 12 | # Development dependencies 13 | gem "activesupport" 14 | gem "bundler", ">= 2.0" 15 | gem "debug" 16 | gem "dotenv", ">= 3.0" 17 | gem "json_schemer" 18 | gem "rake", ">= 13.0" 19 | gem "rdoc", "~> 6.15" 20 | gem "reline" 21 | gem "rspec", "~> 3.12" 22 | gem "rubocop", ">= 1.76" 23 | gem "rubocop-rake", ">= 0.7" 24 | gem "rubocop-rspec", ">= 3.6" 25 | gem "simplecov" 26 | gem "vcr" 27 | gem "webmock", "~> 3.25" 28 | 29 | # For another MCP server test 30 | gem "fast-mcp" 31 | gem "puma" 32 | gem "rack" 33 | end 34 | -------------------------------------------------------------------------------- /lib/tasks/release.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :release do 4 | desc "Release a new version of the gem" 5 | task :version do 6 | # Load the current version from version.rb 7 | require_relative "../../lib/ruby_llm/mcp/version" 8 | version = RubyLLM::MCP::VERSION 9 | 10 | puts "Releasing version #{version}..." 11 | 12 | # Make sure we are on the main branch 13 | system "git checkout main" 14 | system "git pull origin main" 15 | 16 | # Create a new tag for the version 17 | system "git tag -a v#{version} -m 'Release version #{version}'" 18 | system "git push origin v#{version}" 19 | 20 | system "gem build ruby_llm-mcp.gemspec" 21 | system "gem push ruby_llm-mcp-#{version}.gem" 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /spec/fixtures/pagination-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/README.md: -------------------------------------------------------------------------------- 1 | # MCP Streamable Server Test 2 | 3 | A Model Context Protocol (MCP) server using Streamable HTTP transport to test Ruby MCP, following the patterns from the [official TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk). 4 | 5 | ## Usage 6 | 7 | ### Development 8 | 9 | ```bash 10 | # Install dependencies 11 | bun install 12 | 13 | # Start the server 14 | bun start 15 | 16 | # Start with auto-reload during development 17 | bun run dev 18 | ``` 19 | 20 | The server will start on port 3005 (or the port specified in the `PORT` environment variable). 21 | 22 | ### Endpoints 23 | 24 | - **MCP Protocol**: `http://localhost:3005/mcp` - Main MCP endpoint (supports GET, POST, DELETE) 25 | - **Health Check**: `http://localhost:3005/health` - Server health status 26 | -------------------------------------------------------------------------------- /examples/resources/list_resources.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "ruby_llm/mcp" 5 | require "debug" 6 | require "dotenv" 7 | 8 | Dotenv.load 9 | 10 | RubyLLM.configure do |config| 11 | config.openai_api_key = ENV.fetch("OPENAI_API_KEY", nil) 12 | end 13 | 14 | client = RubyLLM::MCP.client( 15 | name: "streamable_mcp", 16 | transport_type: :streamable, 17 | config: { 18 | url: "http://localhost:3005/mcp" 19 | } 20 | ) 21 | resources = client.resources 22 | 23 | resources.each do |resource| 24 | puts "Resource: #{resource.name}" 25 | puts "Description: #{resource.description}" 26 | puts "MIME Type: #{resource.mime_type}" 27 | puts "Template: #{resource.template?}" 28 | puts "Content: #{resource.content}" 29 | puts "--------------------------------" 30 | end 31 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/native/transports/support/http_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Native 6 | module Transports 7 | module Support 8 | class HTTPClient 9 | CONNECTION_KEY = :ruby_llm_mcp_client_connection 10 | 11 | def self.connection 12 | Thread.current[CONNECTION_KEY] ||= build_connection 13 | end 14 | 15 | def self.build_connection 16 | HTTPX.with( 17 | pool_options: { 18 | max_connections: RubyLLM::MCP.config.max_connections, 19 | pool_timeout: RubyLLM::MCP.config.pool_timeout 20 | } 21 | ) 22 | end 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | class Error 6 | def initialize(error_data) 7 | @code = error_data["code"] 8 | @message = error_data["message"] 9 | @data = error_data["data"] 10 | end 11 | 12 | def type 13 | case @code 14 | when -32_700 15 | :parse_error 16 | when -32_600 17 | :invalid_request 18 | when -32_601 19 | :method_not_found 20 | when -32_602 21 | :invalid_params 22 | when -32_603 23 | :internal_error 24 | else 25 | :custom_error 26 | end 27 | end 28 | 29 | def to_s 30 | "Error: code: #{@code} (#{type}), message: #{@message}, data: #{@data}" 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateMcpOauthCredentials < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] 4 | def change 5 | create_table :<%= credential_table_name %> do |t| 6 | t.references :<%= user_table_name.singularize %>, null: false, foreign_key: true, index: true 7 | t.string :name, null: false 8 | t.string :server_url, null: false 9 | t.text :token_data 10 | t.text :client_info_data 11 | t.datetime :token_expires_at 12 | t.datetime :last_refreshed_at 13 | 14 | t.timestamps 15 | 16 | t.index [:<%= user_table_name.singularize %>_id, :server_url], unique: true, name: "index_mcp_oauth_on_<%= user_table_name.singularize %>_and_server" 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /examples/utilities/progress.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "ruby_llm/mcp" 5 | require "debug" 6 | require "dotenv" 7 | 8 | Dotenv.load 9 | 10 | RubyLLM.configure do |config| 11 | config.openai_api_key = ENV.fetch("OPENAI_API_KEY", nil) 12 | end 13 | 14 | RubyLLM::MCP.configure do |config| 15 | config.log_level = Logger::ERROR 16 | end 17 | 18 | # Test with streamable HTTP transport 19 | client = RubyLLM::MCP.client( 20 | name: "streamable_mcp", 21 | transport_type: :streamable, 22 | config: { 23 | url: "http://localhost:3005/mcp" 24 | } 25 | ) 26 | 27 | puts "Connected to streamable MCP server" 28 | client.on_progress do |progress| 29 | puts "Progress: #{progress.progress}%" 30 | end 31 | 32 | result = client.tool("progress").execute(operation: "processing", steps: 3) 33 | puts "Result: #{result}" 34 | -------------------------------------------------------------------------------- /examples/prompts/streamable_prompt_call.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "ruby_llm/mcp" 5 | require "debug" 6 | require "dotenv" 7 | 8 | Dotenv.load 9 | 10 | RubyLLM.configure do |config| 11 | config.openai_api_key = ENV.fetch("OPENAI_API_KEY", nil) 12 | end 13 | 14 | # Test with streamable HTTP transport 15 | client = RubyLLM::MCP.client( 16 | name: "streamable_mcp", 17 | transport_type: :streamable, 18 | config: { 19 | url: "http://localhost:3005/mcp" 20 | } 21 | ) 22 | 23 | prompt_one = client.prompts["poem_of_the_day"] 24 | prompt_two = client.prompts["greeting"] 25 | 26 | chat = RubyLLM.chat(model: "gpt-4.1") 27 | 28 | puts "Prompt One - Poem of the Day\n" 29 | response = chat.ask_prompt(prompt_one) 30 | puts response.content 31 | 32 | puts "\n\nPrompt Two - Greeting\n" 33 | response = chat.ask_prompt(prompt_two, arguments: { name: "John" }) 34 | puts response.content 35 | -------------------------------------------------------------------------------- /lib/generators/ruby_llm/mcp/install/templates/mcps.yml: -------------------------------------------------------------------------------- 1 | mcp_servers: 2 | filesystem: 3 | # SDK adapter to use (optional, defaults to config.default_adapter) 4 | # Options: ruby_llm, mcp_sdk 5 | # adapter: ruby_llm 6 | 7 | transport_type: stdio 8 | command: npx 9 | args: 10 | - "@modelcontextprotocol/server-filesystem" 11 | - "<%%= Rails.root %>" 12 | env: {} 13 | with_prefix: true 14 | 15 | # Example with MCP SDK (official) 16 | # weather: 17 | # adapter: mcp_sdk 18 | # transport_type: http 19 | # url: "https://api.example.com/mcp" 20 | # headers: 21 | # Authorization: "Bearer <%%= ENV['WEATHER_API_KEY'] %>" 22 | 23 | # Example with SSE (RubyLLM adapter only) 24 | # notifications: 25 | # adapter: ruby_llm 26 | # transport_type: sse 27 | # url: "https://api.example.com/mcp/sse" 28 | # headers: 29 | # Authorization: "Bearer <%%= ENV['API_KEY'] %>" 30 | -------------------------------------------------------------------------------- /lib/ruby_llm/chat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This is an override of the RubyLLM::Chat class to add convenient methods to more 4 | # easily work with the MCP clients. 5 | module RubyLLM 6 | class Chat 7 | def with_resources(*resources, **args) 8 | resources.each do |resource| 9 | resource.include(self, **args) 10 | end 11 | self 12 | end 13 | 14 | def with_resource(resource) 15 | resource.include(self) 16 | self 17 | end 18 | 19 | def with_resource_template(resource_template, arguments: {}) 20 | resource = resource_template.fetch_resource(arguments: arguments) 21 | resource.include(self) 22 | self 23 | end 24 | 25 | def with_prompt(prompt, arguments: {}) 26 | prompt.include(self, arguments: arguments) 27 | self 28 | end 29 | 30 | def ask_prompt(prompt, ...) 31 | prompt.ask(self, ...) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/content.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | class Content < RubyLLM::Content 6 | attr_reader :text, :attachments, :content 7 | 8 | def initialize(text: nil, attachments: nil) # rubocop:disable Lint/MissingSuper 9 | @text = text 10 | @attachments = [] 11 | 12 | # Handle MCP::Attachment objects directly without processing 13 | if attachments.is_a?(Array) && attachments.all? { |a| a.is_a?(MCP::Attachment) } 14 | @attachments = attachments 15 | elsif attachments 16 | # Let parent class process other types of attachments 17 | process_attachments(attachments) 18 | end 19 | end 20 | 21 | # This is a workaround to allow the content object to be passed as the tool call 22 | # to return audio or image attachments. 23 | def to_s 24 | text.to_s 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/auth/browser/callback_server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Auth 6 | module Browser 7 | # Callback server wrapper for clean shutdown 8 | # Manages server lifecycle and thread coordination 9 | class CallbackServer 10 | def initialize(server, thread, stop_proc) 11 | @server = server 12 | @thread = thread 13 | @stop_proc = stop_proc 14 | end 15 | 16 | # Shutdown server and cleanup resources 17 | # @return [nil] always returns nil 18 | def shutdown 19 | @stop_proc.call 20 | @server.close unless @server.closed? 21 | @thread.join(5) # Wait max 5 seconds for thread to finish 22 | rescue StandardError 23 | # Ignore shutdown errors 24 | nil 25 | end 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/native/protocol.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Native 6 | module Protocol 7 | module_function 8 | 9 | LATEST_PROTOCOL_VERSION = "2025-06-18" 10 | DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26" 11 | SUPPORTED_PROTOCOL_VERSIONS = [ 12 | LATEST_PROTOCOL_VERSION, 13 | "2025-03-26", 14 | "2024-11-05", 15 | "2024-10-07" 16 | ].freeze 17 | 18 | def supported_version?(version) 19 | SUPPORTED_PROTOCOL_VERSIONS.include?(version) 20 | end 21 | 22 | def supported_versions 23 | SUPPORTED_PROTOCOL_VERSIONS 24 | end 25 | 26 | def latest_version 27 | LATEST_PROTOCOL_VERSION 28 | end 29 | 30 | def default_negotiated_version 31 | DEFAULT_NEGOTIATED_PROTOCOL_VERSION 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Background job to cleanup expired OAuth states 4 | # Schedule this to run hourly or daily to prevent state table bloat 5 | # 6 | # Example with sidekiq-scheduler: 7 | # cleanup_expired_states: 8 | # cron: '0 * * * *' # Every hour 9 | # class: CleanupExpiredOauthStatesJob 10 | # 11 | # Example with whenever gem: 12 | # every 1.hour do 13 | # runner "CleanupExpiredOauthStatesJob.perform_later" 14 | # end 15 | class CleanupExpiredOauthStatesJob < ApplicationJob 16 | queue_as :default 17 | 18 | # Cleanup expired OAuth flow states 19 | # States are temporary and only needed during the OAuth flow (typically < 10 minutes) 20 | def perform 21 | deleted_count = <%= state_model_name %>.cleanup_expired 22 | 23 | Rails.logger.info "Cleaned up #{deleted_count} expired MCP OAuth states" 24 | 25 | deleted_count 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/roots.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | class Roots 6 | attr_reader :paths 7 | 8 | def initialize(paths: [], adapter: nil) 9 | @paths = paths 10 | @adapter = adapter 11 | end 12 | 13 | def active? 14 | @paths.any? 15 | end 16 | 17 | def add(path) 18 | @paths << path 19 | @adapter.roots_list_change_notification 20 | end 21 | 22 | def remove(path) 23 | @paths.delete(path) 24 | @adapter.roots_list_change_notification 25 | end 26 | 27 | def to_request 28 | @paths.map do |path| 29 | name = File.basename(path, ".*") 30 | 31 | { 32 | uri: "file://#{path}", 33 | name: name 34 | } 35 | end 36 | end 37 | 38 | def to_h 39 | { 40 | paths: to_request 41 | } 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Auth 6 | module GrantStrategies 7 | # Authorization Code grant strategy 8 | # Used for user authorization with PKCE (OAuth 2.1) 9 | class AuthorizationCode < Base 10 | # Public clients don't use client_secret 11 | # @return [String] "none" 12 | def auth_method 13 | "none" 14 | end 15 | 16 | # Authorization code and refresh token grants 17 | # @return [Array] grant types 18 | def grant_types_list 19 | %w[authorization_code refresh_token] 20 | end 21 | 22 | # Only "code" response type for authorization code flow 23 | # @return [Array] response types 24 | def response_types_list 25 | ["code"] 26 | end 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/progress.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | class Progress 6 | attr_reader :progress_token, :progress, :total, :message, :client 7 | 8 | def initialize(coordinator, progress_handler, progress_data) 9 | @coordinator = coordinator 10 | @client = coordinator.client 11 | @progress_handler = progress_handler 12 | 13 | @progress_token = progress_data["progressToken"] 14 | @progress = progress_data["progress"] 15 | @total = progress_data["total"] 16 | @message = progress_data["message"] 17 | end 18 | 19 | def execute_progress_handler 20 | @progress_handler.call(self) 21 | end 22 | 23 | def to_h 24 | { 25 | progress_token: @progress_token, 26 | progress: @progress, 27 | total: @total, 28 | message: @message 29 | } 30 | end 31 | 32 | alias to_json to_h 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/src/tools/index.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { setupMediaTools } from "./media.js"; 3 | import { setupMessagingTools } from "./messaging.js"; 4 | import { setupWeatherTools } from "./weather.js"; 5 | import { setupUtilityTools } from "./utilities.js"; 6 | import { setupNotificationTools } from "./notifications.js"; 7 | import { setupClientInteractionTools } from "./client-interaction.js"; 8 | import { setupProtocol2025Features } from "./protocol-2025-06-18.js"; 9 | import { setupElicitationTools } from "./elicitation.js"; 10 | 11 | export function setupTools(server: McpServer) { 12 | // Setup different categories of tools 13 | setupUtilityTools(server); 14 | setupMediaTools(server); 15 | setupMessagingTools(server); 16 | setupWeatherTools(server); 17 | setupNotificationTools(server); 18 | setupClientInteractionTools(server); 19 | setupProtocol2025Features(server); 20 | setupElicitationTools(server); 21 | } 22 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/src/tools/weather.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { z } from "zod"; 3 | 4 | export function setupWeatherTools(server: McpServer) { 5 | server.tool( 6 | "get_weather_from_locations", 7 | "Get the weather from a list of locations", 8 | { locations: z.array(z.string()) }, 9 | async ({ locations }) => ({ 10 | content: [ 11 | { type: "text", text: `Weather for ${locations.join(", ")} is great!` }, 12 | ], 13 | }) 14 | ); 15 | 16 | server.tool( 17 | "get_weather_from_geocode", 18 | "Get the weather from a list of locations", 19 | { 20 | geocode: z.object({ 21 | latitude: z.number(), 22 | longitude: z.number(), 23 | }), 24 | }, 25 | async ({ geocode }) => ({ 26 | content: [ 27 | { 28 | type: "text", 29 | text: `Weather for ${geocode.latitude}, ${geocode.longitude} is great!`, 30 | }, 31 | ], 32 | }) 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= state_model_name %> < ApplicationRecord 4 | belongs_to :<%= user_table_name.singularize %> 5 | 6 | encrypts :pkce_data 7 | 8 | validates :state_param, presence: true 9 | validates :expires_at, presence: true 10 | 11 | scope :expired, -> { where("expires_at < ?", Time.current) } 12 | scope :for_<%= user_table_name.singularize %>, ->(<%= user_table_name.singularize %>_id) { where(<%= user_table_name.singularize %>_id: <%= user_table_name.singularize %>_id) } 13 | 14 | # Clean up expired OAuth flow states 15 | def self.cleanup_expired 16 | expired.delete_all 17 | end 18 | 19 | # Get PKCE object from stored data 20 | def pkce 21 | return nil unless pkce_data.present? 22 | 23 | RubyLLM::MCP::Auth::PKCE.from_h(JSON.parse(pkce_data, symbolize_names: true)) 24 | end 25 | 26 | # Set PKCE object 27 | def pkce=(pkce_obj) 28 | self.pkce_data = pkce_obj.to_h.to_json 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/native/messages/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | 5 | module RubyLLM 6 | module MCP 7 | module Native 8 | module Messages 9 | # Helper methods for message construction 10 | module Helpers 11 | def generate_id 12 | SecureRandom.uuid 13 | end 14 | 15 | def add_progress_token(params, tracking_progress: false) 16 | return params unless tracking_progress 17 | 18 | params[:_meta] ||= {} 19 | params[:_meta][:progressToken] = generate_id 20 | params 21 | end 22 | 23 | def add_cursor(params, cursor) 24 | return params unless cursor 25 | 26 | params[:cursor] = cursor 27 | params 28 | end 29 | 30 | def format_completion_context(context) 31 | return nil if context.nil? 32 | 33 | { arguments: context } 34 | end 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/src/resources/templates.ts: -------------------------------------------------------------------------------- 1 | import { 2 | McpServer, 3 | ResourceTemplate, 4 | } from "@modelcontextprotocol/sdk/server/mcp.js"; 5 | 6 | export function setupTemplateResources(server: McpServer) { 7 | server.resource( 8 | "greeting", 9 | new ResourceTemplate("greeting://{name}", { 10 | list: undefined, 11 | complete: { 12 | name: async (value) => { 13 | const names = ["Alice", "Bob", "Charlie"]; 14 | 15 | if (!value) { 16 | return names; 17 | } 18 | 19 | return names.filter((name) => 20 | name.toLowerCase().includes(value.toLowerCase()) 21 | ); 22 | }, 23 | }, 24 | }), 25 | { 26 | name: "greeting", 27 | description: "A greeting resource", 28 | mimeType: "text/plain", 29 | }, 30 | async (uri, { name }) => ({ 31 | contents: [ 32 | { 33 | uri: uri.href, 34 | text: `Hello, ${name}!`, 35 | }, 36 | ], 37 | }) 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Auth 6 | module GrantStrategies 7 | # Client Credentials grant strategy 8 | # Used for application authentication without user interaction 9 | class ClientCredentials < Base 10 | # Client credentials require client_secret 11 | # @return [String] "client_secret_post" 12 | def auth_method 13 | "client_secret_post" 14 | end 15 | 16 | # Client credentials and refresh token grants 17 | # @return [Array] grant types 18 | def grant_types_list 19 | %w[client_credentials refresh_token] 20 | end 21 | 22 | # No response types for client credentials flow (no redirect) 23 | # @return [Array] response types (empty) 24 | def response_types_list 25 | [] 26 | end 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateMcpOauthStates < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] 4 | def change 5 | create_table :<%= state_table_name %> do |t| 6 | t.references :<%= user_table_name.singularize %>, null: false, foreign_key: true 7 | t.string :server_url, null: false 8 | t.string :state_param, null: false 9 | t.text :pkce_data, null: false 10 | t.datetime :expires_at, null: false 11 | t.string :name 12 | t.string :scope 13 | 14 | t.timestamps 15 | 16 | t.index [:<%= user_table_name.singularize %>_id, :state_param], unique: true, name: "index_mcp_oauth_states_on_<%= user_table_name.singularize %>_and_state" 17 | t.index [:<%= user_table_name.singularize %>_id, :server_url], unique: true, name: "index_mcp_oauth_states_on_<%= user_table_name.singularize %>_and_server" 18 | t.index :expires_at, name: "index_mcp_oauth_states_on_expires_at" 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /.github/workflows/cicd.yml: -------------------------------------------------------------------------------- 1 | name: CICD 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | name: Ruby Tests 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | ruby: ['3.1', '3.2', '3.3', '3.4'] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: oven-sh/setup-bun@v2 20 | with: 21 | bun-version: 1.2.16 22 | 23 | - name: Set up Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | 29 | - name: Install Ruby dependencies 30 | run: | 31 | bundle install 32 | 33 | - name: Install Bun dependencies for test fixtures 34 | run: | 35 | cd spec/fixtures/typescript-mcp && bun install 36 | cd ../../.. 37 | cd spec/fixtures/pagination-server && bun install 38 | 39 | - name: Run specs 40 | run: bundle exec rake 41 | env: 42 | CI: "true" 43 | GITHUB_ACTIONS: "true" 44 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/native/transports/support/timeout.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Native 6 | module Transports 7 | module Support 8 | module Timeout 9 | def with_timeout(seconds, request_id: nil) 10 | result = nil 11 | exception = nil 12 | 13 | worker = Thread.new do 14 | result = yield 15 | rescue StandardError => e 16 | exception = e 17 | end 18 | 19 | if worker.join(seconds) 20 | raise exception if exception 21 | 22 | result 23 | else 24 | worker.kill # stop the thread (can still have some risk if shared resources) 25 | raise RubyLLM::MCP::Errors::TimeoutError.new( 26 | message: "Request timed out after #{seconds} seconds", 27 | request_id: request_id 28 | ) 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /examples/tools/sse_mcp_with_gpt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "ruby_llm/mcp" 5 | require "debug" 6 | require "dotenv" 7 | 8 | Dotenv.load 9 | 10 | RubyLLM.configure do |config| 11 | config.openai_api_key = ENV.fetch("OPENAI_API_KEY", nil) 12 | end 13 | 14 | client = RubyLLM::MCP.client( 15 | name: "local_mcp", 16 | transport_type: :sse, 17 | config: { 18 | url: "http://localhost:9292/mcp/sse" 19 | } 20 | ) 21 | 22 | tools = client.tools 23 | puts tools.map { |tool| " #{tool.name}: #{tool.description}" }.join("\n") 24 | puts "-" * 50 25 | 26 | chat = RubyLLM.chat(model: "gpt-4.1") 27 | chat.with_tools(*client.tools) 28 | 29 | message = "Can you call a tool from the ones provided and let me know what it does?" 30 | puts "Asking: #{message}" 31 | puts "-" * 50 32 | 33 | chat.ask(message) do |chunk| 34 | if chunk.tool_call? 35 | chunk.tool_calls.each do |key, tool_call| 36 | next if tool_call.name.nil? 37 | 38 | puts "\n🔧 Tool call(#{key}) - name: #{tool_call.name}\n" 39 | end 40 | else 41 | print chunk.content 42 | end 43 | end 44 | puts "\n" 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Patrick Vice 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 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM::MCP::Adapters::MCPTransports 4 | # Minimal coordinator stub for MCP transports 5 | # The native transports expect a coordinator object, but for the MCP SDK adapter 6 | # we don't need to process results (just pass them through) 7 | # as MCP SDK adapter doesn't methods that requires responsing to the MCP server as of yet. 8 | class CoordinatorStub 9 | attr_reader :name, :protocol_version 10 | attr_accessor :transport 11 | 12 | def initialize 13 | @name = "MCP-SDK-Adapter" 14 | @protocol_version = RubyLLM::MCP::Native::Protocol.default_negotiated_version 15 | @transport = nil 16 | end 17 | 18 | def process_result(result) 19 | result 20 | end 21 | 22 | def client_capabilities 23 | {} # MCP SDK doesn't provide client capabilities 24 | end 25 | 26 | def request(body, **options) 27 | # For notifications (cancelled, etc), we need to send them through the transport 28 | return nil unless @transport 29 | 30 | @transport.request(body, **options) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/native/messages/notifications.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Native 6 | module Messages 7 | # Notification message builders 8 | # Notifications do not have IDs and do not expect responses 9 | module Notifications 10 | extend Helpers 11 | 12 | module_function 13 | 14 | def initialized 15 | { 16 | jsonrpc: JSONRPC_VERSION, 17 | method: METHOD_NOTIFICATION_INITIALIZED 18 | } 19 | end 20 | 21 | def cancelled(request_id:, reason:) 22 | { 23 | jsonrpc: JSONRPC_VERSION, 24 | method: METHOD_NOTIFICATION_CANCELLED, 25 | params: { 26 | requestId: request_id, 27 | reason: reason 28 | } 29 | } 30 | end 31 | 32 | def roots_list_changed 33 | { 34 | jsonrpc: JSONRPC_VERSION, 35 | method: METHOD_NOTIFICATION_ROOTS_LIST_CHANGED 36 | } 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/auth/grant_strategies/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Auth 6 | module GrantStrategies 7 | # Base strategy for OAuth grant types 8 | # Defines interface for grant-specific configuration 9 | class Base 10 | # Get token endpoint authentication method 11 | # @return [String] auth method (e.g., "none", "client_secret_post") 12 | def auth_method 13 | raise NotImplementedError, "#{self.class} must implement #auth_method" 14 | end 15 | 16 | # Get list of grant types to request during registration 17 | # @return [Array] grant types 18 | def grant_types_list 19 | raise NotImplementedError, "#{self.class} must implement #grant_types_list" 20 | end 21 | 22 | # Get list of response types to request during registration 23 | # @return [Array] response types 24 | def response_types_list 25 | raise NotImplementedError, "#{self.class} must implement #response_types_list" 26 | end 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/server_capabilities.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | class ServerCapabilities 6 | attr_accessor :capabilities 7 | 8 | def initialize(capabilities = {}) 9 | @capabilities = capabilities 10 | end 11 | 12 | def resources_list? 13 | !@capabilities["resources"].nil? 14 | end 15 | 16 | def resources_list_changes? 17 | @capabilities.dig("resources", "listChanged") || false 18 | end 19 | 20 | def resource_subscribe? 21 | @capabilities.dig("resources", "subscribe") || false 22 | end 23 | 24 | def tools_list? 25 | !@capabilities["tools"].nil? 26 | end 27 | 28 | def tools_list_changes? 29 | @capabilities.dig("tools", "listChanged") || false 30 | end 31 | 32 | def prompt_list? 33 | !@capabilities["prompts"].nil? 34 | end 35 | 36 | def prompt_list_changes? 37 | @capabilities.dig("prompts", "listChanged") || false 38 | end 39 | 40 | def completion? 41 | !@capabilities["completions"].nil? 42 | end 43 | 44 | def logging? 45 | !@capabilities["logging"].nil? 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/auth/browser/opener.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Auth 6 | module Browser 7 | # Browser opening utilities for different operating systems 8 | # Handles cross-platform browser launching 9 | class Opener 10 | attr_reader :logger 11 | 12 | def initialize(logger: nil) 13 | @logger = logger || MCP.logger 14 | end 15 | 16 | # Open browser to URL 17 | # @param url [String] URL to open 18 | # @return [Boolean] true if successful 19 | def open_browser(url) 20 | case RbConfig::CONFIG["host_os"] 21 | when /darwin/ 22 | system("open", url) 23 | when /linux|bsd/ 24 | system("xdg-open", url) 25 | when /mswin|mingw|cygwin/ 26 | system("start", url) 27 | else 28 | @logger.warn("Unknown operating system, cannot open browser automatically") 29 | false 30 | end 31 | rescue StandardError => e 32 | @logger.warn("Failed to open browser: #{e.message}") 33 | false 34 | end 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /examples/tools/streamable_mcp.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "ruby_llm/mcp" 5 | require "debug" 6 | require "dotenv" 7 | 8 | Dotenv.load 9 | 10 | RubyLLM.configure do |config| 11 | config.openai_api_key = ENV.fetch("OPENAI_API_KEY", nil) 12 | end 13 | 14 | # Test with streamable HTTP transport 15 | client = RubyLLM::MCP.client( 16 | name: "streamable_mcp", 17 | transport_type: :streamable, 18 | config: { 19 | url: "http://localhost:3005/mcp" 20 | } 21 | ) 22 | 23 | puts "Connected to streamable MCP server" 24 | puts "Available tools:" 25 | tools = client.tools 26 | puts tools.map { |tool| " - #{tool.name}: #{tool.description}" }.join("\n") 27 | puts "-" * 50 28 | 29 | chat = RubyLLM.chat(model: "gpt-4.1") 30 | chat.with_tools(*client.tools) 31 | 32 | message = "Can you use one of the available tools to help me with a task? can you add 1 and 3 and output the result?" 33 | puts "Asking: #{message}" 34 | puts "-" * 50 35 | 36 | chat.ask(message) do |chunk| 37 | if chunk.tool_call? 38 | chunk.tool_calls.each do |key, tool_call| 39 | next if tool_call.name.nil? 40 | 41 | puts "\n🔧 Tool call(#{key}) - #{tool_call.name}" 42 | end 43 | else 44 | print chunk.content 45 | end 46 | end 47 | puts "\n" 48 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/native/transports/support/rate_limit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Native 6 | module Transports 7 | module Support 8 | class RateLimit 9 | def initialize(limit: 10, interval: 1000) 10 | @limit = limit 11 | @interval = interval 12 | @timestamps = [] 13 | @mutex = Mutex.new 14 | end 15 | 16 | def exceeded? 17 | now = current_time 18 | 19 | @mutex.synchronize do 20 | purge_old(now) 21 | @timestamps.size >= @limit 22 | end 23 | end 24 | 25 | def add 26 | now = current_time 27 | 28 | @mutex.synchronize do 29 | purge_old(now) 30 | @timestamps << now 31 | end 32 | end 33 | 34 | private 35 | 36 | def current_time 37 | Process.clock_gettime(Process::CLOCK_MONOTONIC) 38 | end 39 | 40 | def purge_old(now) 41 | cutoff = now - @interval 42 | @timestamps.reject! { |t| t < cutoff } 43 | end 44 | end 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | plugins: 4 | - rubocop-rake 5 | - rubocop-rspec 6 | 7 | AllCops: 8 | NewCops: enable 9 | TargetRubyVersion: 3.1 10 | Exclude: 11 | - 'vendor/**/*' 12 | - 'bin/**/*' 13 | - 'db/**/*' 14 | - 'tmp/**/*' 15 | 16 | Style/ClassAndModuleChildren: 17 | Enabled: false 18 | 19 | Style/Documentation: 20 | Enabled: false 21 | 22 | Style/FrozenStringLiteralComment: 23 | Enabled: true 24 | 25 | Style/GuardClause: 26 | Enabled: false 27 | 28 | Style/IfUnlessModifier: 29 | Enabled: false 30 | 31 | Style/StringLiterals: 32 | EnforcedStyle: double_quotes 33 | 34 | Naming/AccessorMethodName: 35 | Enabled: false 36 | 37 | Layout/LineLength: 38 | Max: 120 39 | 40 | Metrics/AbcSize: 41 | Enabled: false 42 | 43 | Metrics/BlockLength: 44 | Exclude: 45 | - 'spec/**/*' 46 | 47 | Metrics/ClassLength: 48 | Enabled: false 49 | 50 | Metrics/CyclomaticComplexity: 51 | Enabled: false 52 | 53 | Metrics/MethodLength: 54 | Max: 35 55 | 56 | Metrics/ModuleLength: 57 | Enabled: false 58 | 59 | Metrics/PerceivedComplexity: 60 | Enabled: false 61 | 62 | RSpec/DescribedClass: 63 | Enabled: false 64 | 65 | RSpec/ExampleLength: 66 | Max: 30 67 | 68 | 69 | RSpec/MultipleExpectations: 70 | Max: 10 71 | 72 | RSpec/MultipleMemoizedHelpers: 73 | Max: 15 74 | 75 | RSpec/NestedGroups: 76 | Max: 10 77 | -------------------------------------------------------------------------------- /docs/guides/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Guides 4 | nav_order: 3 5 | description: "Guides for using RubyLLM MCP" 6 | has_children: true 7 | permalink: /guides/ 8 | --- 9 | 10 | # Guides 11 | {: .no_toc } 12 | 13 | This section contains guides for using RubyLLM MCP. 14 | 15 | ## Getting Started 16 | 17 | 1. **[Getting Started]({% link guides/getting-started.md %})** - Get up and running quickly 18 | 2. **[Configuration]({% link configuration.md %})** - Configure clients and transports 19 | 3. **[Adapters & Transports]({% link guides/adapters.md %})** {: .label .label-green } 1.0 - Choose adapters and configure transports 20 | 4. **[Rails Integration]({% link guides/rails-integration.md %})** - Use with Rails applications 21 | 22 | ## Working with OAuth Servers? 23 | 24 | - **[OAuth 2.1 Authentication]({% link guides/oauth.md %})** {: .label .label-green } 1.0 - OAuth 2.1 support with PKCE and browser authentication 25 | - **[Rails OAuth Integration]({% link guides/rails-oauth.md %})** {: .label .label-green } 1.0 - Multi-user OAuth for Rails applications 26 | 27 | ## Upgrading 28 | 29 | - **[Upgrading from 0.8 to 1.0]({% link guides/upgrading-0.8-to-1.0.md %})** - Migration guide for version 1.0 30 | - **[Upgrading from 0.7 to 0.8]({% link guides/upgrading-0.7-to-0.8.md %})** - Migration guide for version 0.8 31 | - **[Upgrading from 0.6 to 0.7]({% link guides/upgrading-0.6-to-0.7.md %})** - Migration guide for version 0.7 32 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/native/messages.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Native 6 | # Centralized message builders for MCP JSON-RPC communication 7 | # All message construction happens here, returning properly formatted bodies 8 | module Messages 9 | JSONRPC_VERSION = JsonRpc::VERSION 10 | 11 | # Request methods 12 | METHOD_INITIALIZE = "initialize" 13 | METHOD_PING = "ping" 14 | METHOD_TOOLS_LIST = "tools/list" 15 | METHOD_TOOLS_CALL = "tools/call" 16 | METHOD_RESOURCES_LIST = "resources/list" 17 | METHOD_RESOURCES_READ = "resources/read" 18 | METHOD_RESOURCES_TEMPLATES_LIST = "resources/templates/list" 19 | METHOD_RESOURCES_SUBSCRIBE = "resources/subscribe" 20 | METHOD_PROMPTS_LIST = "prompts/list" 21 | METHOD_PROMPTS_GET = "prompts/get" 22 | METHOD_COMPLETION_COMPLETE = "completion/complete" 23 | METHOD_LOGGING_SET_LEVEL = "logging/setLevel" 24 | 25 | # Notification methods 26 | METHOD_NOTIFICATION_INITIALIZED = "notifications/initialized" 27 | METHOD_NOTIFICATION_CANCELLED = "notifications/cancelled" 28 | METHOD_NOTIFICATION_ROOTS_LIST_CHANGED = "notifications/roots/list_changed" 29 | 30 | # Reference types 31 | REF_TYPE_PROMPT = "ref/prompt" 32 | REF_TYPE_RESOURCE = "ref/resource" 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/support/client_sync_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Client Synchronization Helpers 4 | # 5 | # This module provides helper methods for waiting on client state changes 6 | # and tool availability in tests. 7 | module ClientSyncHelpers 8 | # Wait for a specific tool to become available on a client 9 | # 10 | # @param client [RubyLLM::MCP::Client] The MCP client to check 11 | # @param tool_name [String] The name of the tool to wait for 12 | # @param max_wait_time [Integer, Float] Maximum time to wait in seconds (default: 5) 13 | # @return [RubyLLM::MCP::Tool] The tool instance when found 14 | # @raise [RuntimeError] If the tool is not found within the timeout period 15 | def wait_for_tool(client, tool_name, max_wait_time: 5) 16 | start_time = Time.now 17 | tool = nil 18 | 19 | loop do 20 | tool = client.tool(tool_name) 21 | break if tool 22 | 23 | elapsed = Time.now - start_time 24 | if elapsed > max_wait_time 25 | available_tools = begin 26 | client.tools.map(&:name).join(", ") 27 | rescue StandardError 28 | "Unable to fetch tools" 29 | end 30 | raise "Timeout waiting for tool '#{tool_name}' after #{elapsed.round(2)}s. \ 31 | Available tools: #{available_tools}. Client alive: #{client.alive?}" 32 | end 33 | 34 | sleep 0.1 35 | end 36 | 37 | tool 38 | end 39 | end 40 | 41 | RSpec.configure do |config| 42 | config.include ClientSyncHelpers 43 | end 44 | -------------------------------------------------------------------------------- /lib/generators/ruby_llm/mcp/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators/base" 4 | 5 | module RubyLLM 6 | module MCP 7 | module Generators 8 | class InstallGenerator < Rails::Generators::Base 9 | source_root File.expand_path("templates", __dir__) 10 | 11 | namespace "ruby_llm:mcp:install" 12 | 13 | desc "Install RubyLLM MCP configuration files" 14 | 15 | def create_initializer 16 | template "initializer.rb", "config/initializers/ruby_llm_mcp.rb" 17 | end 18 | 19 | def create_config_file 20 | template "mcps.yml", "config/mcps.yml" 21 | end 22 | 23 | def display_readme 24 | return unless behavior == :invoke 25 | 26 | say "✅ RubyLLM MCP installed!", :green 27 | say "" 28 | say "Next steps:", :blue 29 | say " 1. Configure config/initializers/ruby_llm_mcp.rb (main settings)" 30 | say " 2. Define servers in config/mcps.yml" 31 | say " 3. Install MCP servers (e.g., npm install @modelcontextprotocol/server-filesystem)" 32 | say " 4. Set environment variables for authentication" 33 | say "" 34 | say "📚 Full docs: https://www.rubyllm-mcp.com/", :cyan 35 | say "" 36 | say "⭐ Help us improve!", :magenta 37 | say " Report issues or show your support with a GitHub star: https://github.com/patvice/ruby_llm-mcp" 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/support/client_runner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ClientRunner 4 | attr_reader :client, :options, :name 5 | 6 | class << self 7 | def mcp_sdk_available? 8 | @mcp_sdk_available ||= begin 9 | require "mcp" 10 | true 11 | rescue LoadError 12 | false 13 | end 14 | end 15 | 16 | def build_client_runners(configs) 17 | @client_runners ||= {} 18 | 19 | configs.each do |config| 20 | # Skip MCP SDK clients if the gem is not installed 21 | if config[:adapter] == :mcp_sdk && !mcp_sdk_available? 22 | puts "Skipping #{config[:name]} - MCP SDK gem not installed" 23 | next 24 | end 25 | 26 | @client_runners[config[:name]] = ClientRunner.new(config[:name], config[:options]) 27 | end 28 | end 29 | 30 | def client_runners 31 | @client_runners ||= {} 32 | end 33 | 34 | def fetch_client(name) 35 | client_runners[name].client 36 | end 37 | 38 | def start_all 39 | @client_runners.each_value(&:start) 40 | end 41 | 42 | def stop_all 43 | @client_runners.each_value(&:stop) 44 | end 45 | end 46 | 47 | def initialize(name, options = {}) 48 | @name = name 49 | @options = options 50 | @client = nil 51 | end 52 | 53 | def start 54 | return @client unless @client.nil? 55 | 56 | @client = RubyLLM::MCP::Client.new(**@options) 57 | @client.start 58 | 59 | @client 60 | end 61 | 62 | def stop 63 | @client.stop 64 | @client = nil 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/elicitation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | class Elicitation 6 | ACCEPT_ACTION = "accept" 7 | CANCEL_ACTION = "cancel" 8 | REJECT_ACTION = "reject" 9 | 10 | attr_writer :structured_response 11 | 12 | def initialize(coordinator, result) 13 | @coordinator = coordinator 14 | @result = result 15 | @id = result.id 16 | 17 | @message = @result.params["message"] 18 | @requested_schema = @result.params["requestedSchema"] 19 | end 20 | 21 | def execute 22 | success = @coordinator.elicitation_callback&.call(self) 23 | 24 | if success 25 | valid = validate_response 26 | if valid 27 | @coordinator.elicitation_response(id: @id, 28 | elicitation: { 29 | action: ACCEPT_ACTION, content: @structured_response 30 | }) 31 | else 32 | @coordinator.elicitation_response(id: @id, elicitation: { action: CANCEL_ACTION, content: nil }) 33 | end 34 | else 35 | @coordinator.elicitation_response(id: @id, elicitation: { action: REJECT_ACTION, content: nil }) 36 | end 37 | end 38 | 39 | def message 40 | @result.params["message"] 41 | end 42 | 43 | def validate_response 44 | JSON::Validator.validate(@requested_schema, @structured_response) 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/auth/security.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Auth 6 | # Security utilities for OAuth implementation 7 | module Security 8 | module_function 9 | 10 | # Constant-time string comparison to prevent timing attacks 11 | # @param a [String] first string 12 | # @param b [String] second string 13 | # @return [Boolean] true if strings are equal 14 | def secure_compare(first, second) 15 | # Handle nil values 16 | return false if first.nil? || second.nil? 17 | 18 | # Use Rails/ActiveSupport's secure_compare if available (more battle-tested) 19 | if defined?(ActiveSupport::SecurityUtils) && ActiveSupport::SecurityUtils.respond_to?(:secure_compare) 20 | return ActiveSupport::SecurityUtils.secure_compare(first, second) 21 | end 22 | 23 | # Fallback to our own implementation 24 | constant_time_compare?(first, second) 25 | end 26 | 27 | # Constant-time comparison implementation 28 | # @param a [String] first string 29 | # @param b [String] second string 30 | # @return [Boolean] true if strings are equal 31 | def constant_time_compare?(first, second) 32 | return false unless first.bytesize == second.bytesize 33 | 34 | l = first.unpack("C*") 35 | r = 0 36 | i = -1 37 | 38 | second.each_byte { |v| r |= v ^ l[i += 1] } 39 | r.zero? 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/support/mcp_test_configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MCPTestConfiguration 4 | module_function 5 | 6 | class NullLogger 7 | def debug(*); end 8 | def info(*); end 9 | def warn(*); end 10 | def error(*); end 11 | def fatal(*); end 12 | def unknown(*); end 13 | end 14 | 15 | def configure_ruby_llm! 16 | RubyLLM.configure do |config| 17 | config.openai_api_key = ENV.fetch("OPENAI_API_KEY", "test") 18 | config.anthropic_api_key = ENV.fetch("ANTHROPIC_API_KEY", "test") 19 | config.gemini_api_key = ENV.fetch("GEMINI_API_KEY", "test") 20 | config.deepseek_api_key = ENV.fetch("DEEPSEEK_API_KEY", "test") 21 | config.openrouter_api_key = ENV.fetch("OPENROUTER_API_KEY", "test") 22 | 23 | config.bedrock_api_key = ENV.fetch("AWS_ACCESS_KEY_ID", "test") 24 | config.bedrock_secret_key = ENV.fetch("AWS_SECRET_ACCESS_KEY", "test") 25 | config.bedrock_region = ENV.fetch("AWS_REGION", "us-west-2") 26 | config.bedrock_session_token = ENV.fetch("AWS_SESSION_TOKEN", nil) 27 | 28 | config.request_timeout = 240 29 | config.max_retries = 10 30 | config.retry_interval = 1 31 | config.retry_backoff_factor = 3 32 | config.retry_interval_randomness = 0.5 33 | end 34 | end 35 | 36 | def configure! 37 | RubyLLM::MCP.configure do |config| 38 | if ENV["RUBY_LLM_DEBUG"] 39 | config.log_level = :debug 40 | else 41 | config.logger = NullLogger.new 42 | end 43 | end 44 | end 45 | 46 | def reset_config! 47 | RubyLLM::MCP.config.reset! 48 | configure! 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /examples/tools/local_mcp.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "ruby_llm/mcp" 5 | require "debug" 6 | require "dotenv" 7 | 8 | Dotenv.load 9 | 10 | RubyLLM.configure do |config| 11 | config.openai_api_key = ENV.fetch("OPENAI_API_KEY", nil) 12 | end 13 | 14 | RubyLLM::MCP.configure do |config| 15 | config.log_level = Logger::ERROR 16 | end 17 | 18 | # Test with filesystem MCP server using stdio transport 19 | client = RubyLLM::MCP.client( 20 | name: "filesystem", 21 | transport_type: :stdio, 22 | config: { 23 | command: "bunx", 24 | args: [ 25 | "@modelcontextprotocol/server-filesystem", 26 | File.expand_path("../../", __dir__) # Allow access to the current directory 27 | ] 28 | } 29 | ) 30 | 31 | puts "Transport type: #{File.expand_path('..', __dir__)}" 32 | 33 | puts "Connected to filesystem MCP server" 34 | puts "Available tools:" 35 | tools = client.tools 36 | puts tools.map(&:display_name).join("\n") 37 | puts "-" * 50 38 | 39 | # message = "Can you list the files in the current directly and give me a summary of the contents of each file?" 40 | # puts "Asking: #{message}" 41 | # puts "-" * 50 42 | 43 | tool = client.tool("read_file") 44 | puts tool.execute(path: "README.md") 45 | 46 | # chat = RubyLLM.chat(model: "gpt-4.1") 47 | # chat.with_tools(*tools) 48 | 49 | # chat.ask(message) do |chunk| 50 | # if chunk.tool_call? 51 | # chunk.tool_calls.each do |key, tool_call| 52 | # next if tool_call.name.nil? 53 | 54 | # puts "\n🔧 Tool call(#{key}) - #{tool_call.name}" 55 | # end 56 | # else 57 | # print chunk.content 58 | # end 59 | # end 60 | # puts "\n" 61 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM::MCP::Adapters::MCPTransports 4 | # Custom SSE transport for MCP SDK adapter 5 | # Wraps the native SSE transport to provide the interface expected by MCP::Client 6 | class SSE 7 | attr_reader :native_transport 8 | 9 | def initialize(url:, headers: {}, version: :http2, request_timeout: 10_000) 10 | # Create a minimal coordinator-like object for the native transport 11 | @coordinator = CoordinatorStub.new 12 | 13 | @native_transport = RubyLLM::MCP::Native::Transports::SSE.new( 14 | url: url, 15 | coordinator: @coordinator, 16 | request_timeout: request_timeout, 17 | options: { 18 | headers: headers, 19 | version: version 20 | } 21 | ) 22 | end 23 | 24 | def start 25 | @native_transport.start 26 | end 27 | 28 | def close 29 | @native_transport.close 30 | end 31 | 32 | # Send a JSON-RPC request and return the response 33 | # This is the interface expected by MCP::Client 34 | # 35 | # @param request [Hash] A JSON-RPC request object 36 | # @return [Hash] A JSON-RPC response object 37 | def send_request(request:) 38 | start unless @native_transport.alive? 39 | 40 | unless request["id"] || request[:id] 41 | request["id"] = SecureRandom.uuid 42 | end 43 | result = @native_transport.request(request, wait_for_response: true) 44 | 45 | if result.is_a?(RubyLLM::MCP::Result) 46 | result.response 47 | else 48 | result 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM::MCP::Adapters::MCPTransports 4 | # Custom Stdio transport for MCP SDK adapter 5 | # Wraps the native Stdio transport to provide the interface expected by MCP::Client 6 | class Stdio 7 | attr_reader :native_transport 8 | 9 | def initialize(command:, args: [], env: {}, request_timeout: 10_000) 10 | # Create a minimal coordinator-like object for the native transport 11 | @coordinator = CoordinatorStub.new 12 | 13 | @native_transport = RubyLLM::MCP::Native::Transports::Stdio.new( 14 | command: command, 15 | args: args, 16 | env: env, 17 | coordinator: @coordinator, 18 | request_timeout: request_timeout 19 | ) 20 | 21 | @coordinator.transport = @native_transport 22 | end 23 | 24 | def start 25 | @native_transport.start 26 | end 27 | 28 | def close 29 | @native_transport.close 30 | end 31 | 32 | # Send a JSON-RPC request and return the response 33 | # This is the interface expected by MCP::Client 34 | # 35 | # @param request [Hash] A JSON-RPC request object 36 | # @return [Hash] A JSON-RPC response object 37 | def send_request(request:) 38 | start unless @native_transport.alive? 39 | 40 | unless request["id"] || request[:id] 41 | request["id"] = SecureRandom.uuid 42 | end 43 | result = @native_transport.request(request, wait_for_response: true) 44 | 45 | if result.is_a?(RubyLLM::MCP::Result) 46 | result.response 47 | else 48 | result 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= credential_model_name %> < ApplicationRecord 4 | belongs_to :<%= user_table_name.singularize %> 5 | 6 | encrypts :token_data 7 | encrypts :client_info_data 8 | 9 | validates :name, presence: true, 10 | uniqueness: { scope: :<%= user_table_name.singularize %>_id } 11 | validates :server_url, presence: true, 12 | uniqueness: { scope: :<%= user_table_name.singularize %>_id }, 13 | format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) } 14 | 15 | # Get token object from stored data 16 | def token 17 | return nil unless token_data.present? 18 | 19 | RubyLLM::MCP::Auth::Token.from_h(JSON.parse(token_data, symbolize_names: true)) 20 | end 21 | 22 | # Set token and update expiration 23 | def token=(token_obj) 24 | self.token_data = token_obj.to_h.to_json 25 | self.token_expires_at = token_obj.expires_at 26 | end 27 | 28 | # Get client info object from stored data 29 | def client_info 30 | return nil unless client_info_data.present? 31 | 32 | RubyLLM::MCP::Auth::ClientInfo.from_h(JSON.parse(client_info_data, symbolize_names: true)) 33 | end 34 | 35 | # Set client info 36 | def client_info=(info_obj) 37 | self.client_info_data = info_obj.to_h.to_json 38 | end 39 | 40 | # Check if token is expired 41 | def expired? 42 | token&.expired? 43 | end 44 | 45 | # Check if token expires soon (within 5 minutes) 46 | def expires_soon? 47 | token&.expires_soon? 48 | end 49 | 50 | # Check if token is valid 51 | def valid_token? 52 | token && !token.expired? 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/generators/ruby_llm/mcp/install/templates/initializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Configure RubyLLM MCP 4 | RubyLLM::MCP.configure do |config| 5 | # Default SDK adapter to use (:ruby_llm or :mcp_sdk) 6 | # - :ruby_llm: Full-featured, supports all MCP features + extensions 7 | # - :mcp_sdk: Official SDK, limited features but maintained by Anthropic 8 | config.default_adapter = :ruby_llm 9 | 10 | # Request timeout in milliseconds 11 | config.request_timeout = 8000 12 | 13 | # Maximum connections in the pool 14 | config.max_connections = Float::INFINITY 15 | 16 | # Pool timeout in seconds 17 | config.pool_timeout = 5 18 | 19 | # Path to MCPs configuration file 20 | config.config_path = Rails.root.join("config", "mcps.yml") 21 | 22 | # Launch MCPs (:automatic, :manual) 23 | config.launch_control = :automatic 24 | 25 | # Configure roots for file system access (RubyLLM adapter only) 26 | # config.roots = [ 27 | # Rails.root.to_s 28 | # ] 29 | 30 | # Configure sampling (RubyLLM adapter only) 31 | config.sampling.enabled = false 32 | 33 | # Set preferred model for sampling 34 | # config.sampling.preferred_model do 35 | # "claude-sonnet-4" 36 | # end 37 | 38 | # Set a guard for sampling 39 | # config.sampling.guard do 40 | # Rails.env.development? 41 | # end 42 | 43 | # Event handlers (RubyLLM adapter only) 44 | # config.on_progress do |progress_token, progress, total| 45 | # # Handle progress updates 46 | # end 47 | 48 | # config.on_human_in_the_loop do |tool_name, arguments| 49 | # # Return true to allow, false to deny 50 | # true 51 | # end 52 | 53 | # config.on_logging do |level, logger_name, data| 54 | # # Handle logging from MCP servers 55 | # end 56 | end 57 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | 2 | 3 | title: RubyLLM::MCP 4 | email: patrickgvice@gmail.com 5 | description: >- 6 | A Ruby client for the Model Context Protocol (MCP) that seamlessly integrates with RubyLLM. 7 | baseurl: "/" 8 | url: "https://www.rubyllm-mcp.com" 9 | 10 | theme: just-the-docs 11 | plugins: 12 | - jekyll-seo-tag 13 | - jekyll-sitemap 14 | 15 | # Just the Docs settings 16 | 17 | logo: "/assets/images/rubyllm-mcp-logo-text.svg" 18 | favicon_ico: "/assets/images/favicon/favicon.ico" 19 | favicon_png: "/assets/images/favicon/favicon-96x96.png" 20 | favicon_svg: "/assets/images/favicon/favicon.svg" 21 | 22 | # Enable search 23 | search_enabled: true 24 | search: 25 | heading_level: 2 26 | previews: 3 27 | preview_words_before: 5 28 | preview_words_after: 10 29 | tokenizer_separator: /[\s/]+/ 30 | rel_url: true 31 | button: false 32 | 33 | back_to_top: true 34 | back_to_top_text: "Back to top" 35 | 36 | # Footer content 37 | footer_content: "Copyright © 2025 Patrick Vice. Distributed under an MIT license." 38 | 39 | # navigation 40 | nav_enabled: true 41 | 42 | nav_external_links: 43 | - title: Github 44 | url: https://github.com/patvice/ruby_llm-mcp 45 | hide_icon: false # set to true to hide the external link icon - defaults to false 46 | opens_in_new_tab: false # set to true to open this link in a new tab - defaults to false 47 | 48 | # Make Anchor links show on hover 49 | heading_anchors: true 50 | 51 | # Color scheme 52 | color_scheme: light 53 | 54 | # Callouts 55 | callouts: 56 | new: 57 | title: New 58 | color: green 59 | warning: 60 | title: Warning 61 | color: yellow 62 | note: 63 | title: Note 64 | color: blue 65 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/native/cancellable_operation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Native 6 | # Wraps server-initiated requests to support cancellation 7 | # Executes the request in a separate thread that can be terminated on cancellation 8 | class CancellableOperation 9 | attr_reader :request_id, :thread 10 | 11 | def initialize(request_id) 12 | @request_id = request_id 13 | @cancelled = false 14 | @mutex = Mutex.new 15 | @thread = nil 16 | @result = nil 17 | @error = nil 18 | end 19 | 20 | def cancelled? 21 | @mutex.synchronize { @cancelled } 22 | end 23 | 24 | def cancel 25 | @mutex.synchronize { @cancelled = true } 26 | if @thread&.alive? 27 | @thread.raise(Errors::RequestCancelled.new( 28 | message: "Request #{@request_id} was cancelled", 29 | request_id: @request_id 30 | )) 31 | end 32 | end 33 | 34 | # Execute a block in a separate thread 35 | # This allows the thread to be terminated if cancellation is requested 36 | # Returns the result of the block or re-raises any error that occurred 37 | def execute(&) 38 | @thread = Thread.new do 39 | Thread.current.abort_on_exception = false 40 | begin 41 | @result = yield 42 | rescue Errors::RequestCancelled, StandardError => e 43 | @error = e 44 | end 45 | end 46 | 47 | @thread.join 48 | raise @error if @error && !@error.is_a?(Errors::RequestCancelled) 49 | 50 | @result 51 | ensure 52 | @thread = nil 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/src/utils/file-utils.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | import { join } from "node:path"; 3 | import { existsSync } from "node:fs"; 4 | 5 | /** 6 | * Determines if we're in the root of ruby_llm-mcp or in the typescript-mcp directory 7 | * and returns the correct path to the resources folder 8 | */ 9 | function getResourcesPath(): string { 10 | const cwd = process.cwd(); 11 | 12 | // Check if we're in the root of ruby_llm-mcp (look for ruby_llm-mcp.gemspec) 13 | if (existsSync(join(cwd, "ruby_llm-mcp.gemspec"))) { 14 | return "./spec/fixtures/typescript-mcp/resources"; 15 | } 16 | 17 | // Check if we're in the typescript-mcp directory (look for tsconfig.json) 18 | if (existsSync(join(cwd, "tsconfig.json"))) { 19 | return "./resources"; 20 | } 21 | 22 | // Fallback to the full path from root 23 | return "./spec/fixtures/typescript-mcp/resources"; 24 | } 25 | 26 | /** 27 | * Reads a file from the resources directory, automatically resolving the correct path 28 | * based on the current working directory 29 | */ 30 | export async function readResourceFile(filename: string): Promise { 31 | const resourcesPath = getResourcesPath(); 32 | const fullPath = join(resourcesPath, filename); 33 | return await readFile(fullPath); 34 | } 35 | 36 | /** 37 | * Reads a text file from the resources directory and returns it as a string 38 | */ 39 | export async function readResourceTextFile( 40 | filename: string, 41 | encoding: BufferEncoding = "utf-8" 42 | ): Promise { 43 | const resourcesPath = getResourcesPath(); 44 | const fullPath = join(resourcesPath, filename); 45 | return await readFile(fullPath, encoding); 46 | } 47 | 48 | /** 49 | * Gets the full path to a resource file 50 | */ 51 | export function getResourcePath(filename: string): string { 52 | const resourcesPath = getResourcesPath(); 53 | return join(resourcesPath, filename); 54 | } 55 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/auth/session_manager.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Auth 6 | # Service for managing OAuth session state (PKCE and CSRF state) 7 | # Handles creation, validation, and cleanup of temporary session data 8 | class SessionManager 9 | attr_reader :storage 10 | 11 | def initialize(storage) 12 | @storage = storage 13 | end 14 | 15 | # Create a new OAuth session with PKCE and CSRF state 16 | # @param server_url [String] MCP server URL 17 | # @return [Hash] session data with :pkce and :state 18 | def create_session(server_url) 19 | pkce = PKCE.new 20 | state = SecureRandom.urlsafe_base64(CSRF_STATE_SIZE) 21 | 22 | storage.set_pkce(server_url, pkce) 23 | storage.set_state(server_url, state) 24 | 25 | { pkce: pkce, state: state } 26 | end 27 | 28 | # Validate state parameter and retrieve session data 29 | # @param server_url [String] MCP server URL 30 | # @param state [String] state parameter from callback 31 | # @return [Hash] session data with :pkce and :client_info 32 | # @raise [ArgumentError] if state is invalid 33 | def validate_and_retrieve_session(server_url, state) 34 | stored_state = storage.get_state(server_url) 35 | unless stored_state && Security.secure_compare(stored_state, state) 36 | raise ArgumentError, "Invalid state parameter" 37 | end 38 | 39 | { 40 | pkce: storage.get_pkce(server_url), 41 | client_info: storage.get_client_info(server_url) 42 | } 43 | end 44 | 45 | # Clean up temporary session data 46 | # @param server_url [String] MCP server URL 47 | def cleanup_session(server_url) 48 | storage.delete_pkce(server_url) 49 | storage.delete_state(server_url) 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/ruby_llm/mcp/auth/grant_strategies_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe RubyLLM::MCP::Auth::GrantStrategies do 6 | describe "Base" do 7 | subject(:strategy) { RubyLLM::MCP::Auth::GrantStrategies::Base.new } 8 | 9 | it "raises NotImplementedError for auth_method" do 10 | expect { strategy.auth_method }.to raise_error(NotImplementedError, /must implement #auth_method/) 11 | end 12 | 13 | it "raises NotImplementedError for grant_types_list" do 14 | expect { strategy.grant_types_list }.to raise_error(NotImplementedError, /must implement #grant_types_list/) 15 | end 16 | 17 | it "raises NotImplementedError for response_types_list" do 18 | expect { strategy.response_types_list }.to raise_error(NotImplementedError, /must implement #response_types_list/) 19 | end 20 | end 21 | 22 | describe "AuthorizationCode" do 23 | subject(:strategy) { RubyLLM::MCP::Auth::GrantStrategies::AuthorizationCode.new } 24 | 25 | it "uses 'none' auth method for public clients" do 26 | expect(strategy.auth_method).to eq("none") 27 | end 28 | 29 | it "requests authorization_code and refresh_token grant types" do 30 | expect(strategy.grant_types_list).to eq(%w[authorization_code refresh_token]) 31 | end 32 | 33 | it "requests 'code' response type" do 34 | expect(strategy.response_types_list).to eq(["code"]) 35 | end 36 | end 37 | 38 | describe "ClientCredentials" do 39 | subject(:strategy) { RubyLLM::MCP::Auth::GrantStrategies::ClientCredentials.new } 40 | 41 | it "uses 'client_secret_post' auth method" do 42 | expect(strategy.auth_method).to eq("client_secret_post") 43 | end 44 | 45 | it "requests client_credentials and refresh_token grant types" do 46 | expect(strategy.grant_types_list).to eq(%w[client_credentials refresh_token]) 47 | end 48 | 49 | it "requests no response types (no redirect flow)" do 50 | expect(strategy.response_types_list).to eq([]) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # Sample workflow for building and deploying a Jekyll site to GitHub Pages 7 | name: Deploy docs to GitHub Pages 8 | 9 | on: 10 | push: 11 | branches: ["main"] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 17 | permissions: 18 | contents: read 19 | pages: write 20 | id-token: write 21 | 22 | # Allow one concurrent deployment 23 | concurrency: 24 | group: "pages" 25 | cancel-in-progress: true 26 | 27 | jobs: 28 | # Build job 29 | build: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | - name: Setup Ruby 35 | uses: ruby/setup-ruby@v1 36 | with: 37 | ruby-version: '3.4' 38 | bundler-cache: true 39 | cache-version: 0 40 | - name: Setup Pages 41 | id: pages 42 | uses: actions/configure-pages@v5 43 | - name: Build with Jekyll 44 | # Outputs to the './_site' directory by default 45 | run: | 46 | cd ./docs 47 | bundle install 48 | bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}" --destination ../_site 49 | env: 50 | JEKYLL_ENV: production 51 | - name: Upload artifact 52 | # Automatically uploads an artifact from the './_site' directory by default 53 | uses: actions/upload-pages-artifact@v3 54 | 55 | # Deployment job 56 | deploy: 57 | environment: 58 | name: github-pages 59 | url: ${{ steps.deployment.outputs.page_url }} 60 | runs-on: ubuntu-latest 61 | needs: build 62 | steps: 63 | - name: Deploy to GitHub Pages 64 | id: deployment 65 | uses: actions/deploy-pages@v4 66 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/src/prompts/simple.ts: -------------------------------------------------------------------------------- 1 | import { 2 | McpServer, 3 | type RegisteredPrompt, 4 | } from "@modelcontextprotocol/sdk/server/mcp.js"; 5 | 6 | let prompt: RegisteredPrompt; 7 | 8 | export const data = { 9 | enable: () => prompt.enable(), 10 | }; 11 | 12 | export function setupSimplePrompts(server: McpServer) { 13 | server.prompt( 14 | "say_hello", 15 | "This is a simple prompt that will say hello", 16 | async () => ({ 17 | messages: [ 18 | { 19 | role: "user", 20 | content: { 21 | type: "text", 22 | text: "Hello, how are you? Can you also say Hello back?", 23 | }, 24 | }, 25 | ], 26 | }) 27 | ); 28 | 29 | server.prompt( 30 | "multiple_messages", 31 | "This is a simple prompt that will say hello with a name", 32 | async () => ({ 33 | messages: [ 34 | { 35 | role: "assistant", 36 | content: { 37 | type: "text", 38 | text: "You are great at saying hello, the best in the world at it.", 39 | }, 40 | }, 41 | { 42 | role: "user", 43 | content: { 44 | type: "text", 45 | text: "Hello, how are you?", 46 | }, 47 | }, 48 | ], 49 | }) 50 | ); 51 | 52 | server.prompt("poem_of_the_day", "Generates a poem of the day", async () => ({ 53 | messages: [ 54 | { 55 | role: "user", 56 | content: { 57 | type: "text", 58 | text: "Can you write me a beautiful poem about the current day? Make sure to include the word 'poem' in your response.", 59 | }, 60 | }, 61 | ], 62 | })); 63 | 64 | prompt = server.prompt( 65 | "disabled_prompt", 66 | "This is a disabled prompt", 67 | async () => ({ 68 | messages: [ 69 | { 70 | role: "user", 71 | content: { type: "text", text: "This is a disabled prompt" }, 72 | }, 73 | ], 74 | }) 75 | ); 76 | 77 | prompt.disable(); 78 | } 79 | -------------------------------------------------------------------------------- /docs/guides/upgrading-0.6-to-0.7.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Upgrading from 0.6 to 0.7 4 | parent: Guides 5 | nav_order: 10 6 | description: "Guide for upgrading from RubyLLM MCP 0.6 to 0.7" 7 | --- 8 | 9 | # Upgrading from 0.6 to 0.7 10 | {: .no_toc } 11 | 12 | This guide covers the changes and migration steps when upgrading from RubyLLM MCP version 0.6.x to 0.7.x. 13 | 14 | ## Table of contents 15 | {: .no_toc .text-delta } 16 | 17 | 1. TOC 18 | {:toc} 19 | 20 | --- 21 | 22 | ## Breaking Changes 23 | 24 | ### RubyLLM 1.9 Requirement 25 | 26 | Version 0.7 requires RubyLLM 1.9 or higher. Make sure to update your `ruby_llm` dependency: 27 | 28 | ```ruby 29 | # Gemfile 30 | gem 'ruby_llm', '~> 1.9' 31 | gem 'ruby_llm-mcp', '~> 0.7' 32 | ``` 33 | 34 | Then run: 35 | 36 | ```bash 37 | bundle update ruby_llm ruby_llm-mcp 38 | ``` 39 | 40 | ## Deprecated Features 41 | 42 | ### Complex Parameters Support (Now Default) 43 | 44 | {: .warning } 45 | The `support_complex_parameters!` method is deprecated and will be removed in version 0.8.0. 46 | 47 | **What Changed:** 48 | 49 | In version 0.6.x and earlier, you had to explicitly enable complex parameter support for MCP tools to handle arrays and nested objects: 50 | 51 | ```ruby 52 | # Version 0.6.x (OLD - deprecated) 53 | RubyLLM::MCP.configure do |config| 54 | config.support_complex_parameters! 55 | end 56 | ``` 57 | 58 | **In version 0.7.x, complex parameters are supported by default.** You no longer need to call this method. 59 | 60 | ## Getting Help 61 | 62 | If you encounter issues during the upgrade: 63 | 64 | 1. Check the [GitHub Issues](https://github.com/patvice/ruby_llm-mcp/issues) for similar problems 65 | 2. Review the [Configuration Guide]({% link configuration.md %}) for updated examples 66 | 3. Open a new issue with details about your setup and the error message 67 | 68 | ## Next Steps 69 | 70 | After upgrading: 71 | 72 | - Review the [Configuration Guide]({% link configuration.md %}) for new features 73 | - Check out [Tools Documentation]({% link server/tools.md %}) for updated examples 74 | - Explore any new features in the [Release Notes](https://github.com/patvice/ruby_llm-mcp/releases) 75 | -------------------------------------------------------------------------------- /docs/guides/upgrading-0.8-to-1.0.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Upgrading from 0.8 to 1.0 4 | parent: Guides 5 | nav_order: 12 6 | description: "Quick guide for upgrading from RubyLLM MCP 0.8 to 1.0" 7 | --- 8 | 9 | # Upgrading from 0.8 to 1.0 10 | {: .no_toc } 11 | 12 | Version 1.0 is a stable release with **no breaking changes**. Upgrade by updating your Gemfile. 13 | 14 | ## Table of contents 15 | {: .no_toc .text-delta } 16 | 17 | 1. TOC 18 | {:toc} 19 | 20 | --- 21 | 22 | ## Breaking Changes 23 | 24 | {: .label .label-green } 25 | ✓ No Breaking Changes 26 | 27 | Version 1.0 maintains full backward compatibility with 0.8.x. All existing code continues to work without modifications. 28 | 29 | ## Upgrade Steps 30 | 31 | ### 1. Update Gemfile 32 | 33 | ```ruby 34 | gem 'ruby_llm-mcp', '~> 1.0' 35 | ``` 36 | 37 | Optional - for MCP SDK adapter (requires Ruby 3.2+): 38 | 39 | ```ruby 40 | gem 'mcp', '~> 0.4' 41 | ``` 42 | 43 | Then run: 44 | 45 | ```bash 46 | bundle update ruby_llm-mcp 47 | ``` 48 | 49 | ### 2. Done! 50 | 51 | Your existing 0.8 code will work without changes. 52 | 53 | ## What's New in 1.0 54 | 55 | - **Stable adapter system** - Production-ready RubyLLM and MCP SDK adapters 56 | - **Enhanced documentation** - Merged and improved guides 57 | - **Custom transport clarity** - Proper namespace documentation for `RubyLLM::MCP::Native::Transport.register_transport` 58 | 59 | ## Optional: Custom Transport Registration 60 | 61 | If you're using custom transports, ensure you use the correct namespace: 62 | 63 | ```ruby 64 | # Correct registration (was unclear in 0.8 docs) 65 | RubyLLM::MCP::Native::Transport.register_transport(:custom, CustomTransport) 66 | ``` 67 | 68 | ## Resources 69 | 70 | - **[Adapters & Transports]({% link guides/adapters.md %})** - Comprehensive guide 71 | - **[OAuth 2.1 Support]({% link guides/oauth.md %})** - Production-ready OAuth 72 | - **[Getting Started]({% link guides/getting-started.md %})** - Quick start guide 73 | 74 | --- 75 | 76 | **Congratulations on upgrading to 1.0!** 🎉 77 | 78 | **Questions?** [Open an issue](https://github.com/patvice/ruby_llm-mcp/issues) or check the [documentation]({% link index.md %}). 79 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/Cancellation_Integration/with_stdio-native/End-to-end_cancellation_with_stdio-native/allows_multiple_cancellations_without_errors.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://api.openai.com/v1/chat/completions 6 | body: 7 | encoding: UTF-8 8 | string: '{"model":"gpt-4o","messages":[{"role":"developer","content":"You are 9 | a helpful assistant."},{"role":"user","content":"Hello, how are you?"}],"stream":false}' 10 | headers: 11 | User-Agent: 12 | - Faraday v2.14.0 13 | Authorization: 14 | - Bearer test 15 | Content-Type: 16 | - application/json 17 | Accept-Encoding: 18 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 19 | Accept: 20 | - "*/*" 21 | response: 22 | status: 23 | code: 401 24 | message: Unauthorized 25 | headers: 26 | Date: 27 | - Mon, 24 Nov 2025 18:09:15 GMT 28 | Content-Type: 29 | - application/json; charset=utf-8 30 | Content-Length: 31 | - '254' 32 | Connection: 33 | - keep-alive 34 | Vary: 35 | - Origin 36 | X-Request-Id: 37 | - "" 38 | X-Envoy-Upstream-Service-Time: 39 | - '0' 40 | X-Openai-Proxy-Wasm: 41 | - v0.1 42 | Cf-Cache-Status: 43 | - DYNAMIC 44 | Set-Cookie: 45 | - "" 46 | - "" 47 | Strict-Transport-Security: 48 | - max-age=31536000; includeSubDomains; preload 49 | X-Content-Type-Options: 50 | - nosniff 51 | Server: 52 | - cloudflare 53 | Cf-Ray: 54 | - "" 55 | Alt-Svc: 56 | - h3=":443"; ma=86400 57 | body: 58 | encoding: UTF-8 59 | string: | 60 | { 61 | "error": { 62 | "message": "Incorrect API key provided: test. You can find your API key at https://platform.openai.com/account/api-keys.", 63 | "type": "invalid_request_error", 64 | "param": null, 65 | "code": "invalid_api_key" 66 | } 67 | } 68 | recorded_at: Mon, 24 Nov 2025 18:09:15 GMT 69 | recorded_with: VCR 6.3.1 70 | -------------------------------------------------------------------------------- /docs/client/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Client Interactions 4 | nav_order: 5 5 | description: "Client-side features and capabilities for MCP integration" 6 | has_children: true 7 | permalink: /client/ 8 | --- 9 | 10 | # Client Interactions 11 | 12 | Client interactions cover the features and capabilities that your MCP client provides to servers and manages locally. These are client-side features that enhance the MCP experience by enabling advanced functionality like sampling, filesystem access, and custom transport implementations. 13 | 14 | ## Overview 15 | 16 | MCP clients offer several key capabilities: 17 | 18 | - **[Sampling]({% link client/sampling.md %})** - Allow servers to use your LLM for their own requests 19 | - **[Roots]({% link client/roots.md %})** - Provide filesystem access to servers within specified directories 20 | 21 | ## Client Capabilities 22 | 23 | ### Sampling 24 | 25 | Enable MCP servers to offload LLM requests to your client rather than making them directly. This allows servers to use your LLM connections and configurations while maintaining their own logic and workflows. 26 | 27 | ### Roots 28 | 29 | Provide controlled filesystem access to MCP servers, allowing them to understand your project structure and access files within specified directories for more powerful and context-aware operations. 30 | 31 | ### Transports 32 | 33 | Handle the communication protocol between your client and MCP servers. Use built-in transports or create custom implementations for specialized communication needs. 34 | 35 | ## Getting Started 36 | 37 | Explore each client interaction type to understand how to configure and use client-side features: 38 | 39 | - **[Sampling]({% link client/sampling.md %})** - Allow servers to use your LLM 40 | - **[Roots]({% link client/roots.md %})** - Provide filesystem access to servers 41 | 42 | ## Next Steps 43 | 44 | Once you understand client interactions, explore: 45 | 46 | - **[Server Interactions]({% link server/index.md %})** - Working with server capabilities 47 | - **[Configuration]({% link configuration.md %})** - Advanced client configuration options 48 | - **[Rails Integration]({% link guides/rails-integration.md %})** - Using MCP with Rails applications 49 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/Cancellation_Integration/with_stdio-native/End-to-end_cancellation_with_stdio-native/properly_cleans_up_cancelled_requests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://api.openai.com/v1/chat/completions 6 | body: 7 | encoding: UTF-8 8 | string: '{"model":"gpt-4o","messages":[{"role":"developer","content":"You are 9 | a helpful assistant."},{"role":"user","content":"This is a long message that 10 | should be cancelled"}],"stream":false}' 11 | headers: 12 | User-Agent: 13 | - Faraday v2.14.0 14 | Authorization: 15 | - Bearer test 16 | Content-Type: 17 | - application/json 18 | Accept-Encoding: 19 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 20 | Accept: 21 | - "*/*" 22 | response: 23 | status: 24 | code: 401 25 | message: Unauthorized 26 | headers: 27 | Date: 28 | - Mon, 24 Nov 2025 18:14:12 GMT 29 | Content-Type: 30 | - application/json; charset=utf-8 31 | Content-Length: 32 | - '254' 33 | Connection: 34 | - keep-alive 35 | Vary: 36 | - Origin 37 | X-Request-Id: 38 | - "" 39 | X-Envoy-Upstream-Service-Time: 40 | - '0' 41 | X-Openai-Proxy-Wasm: 42 | - v0.1 43 | Cf-Cache-Status: 44 | - DYNAMIC 45 | Set-Cookie: 46 | - "" 47 | - "" 48 | Strict-Transport-Security: 49 | - max-age=31536000; includeSubDomains; preload 50 | X-Content-Type-Options: 51 | - nosniff 52 | Server: 53 | - cloudflare 54 | Cf-Ray: 55 | - "" 56 | Alt-Svc: 57 | - h3=":443"; ma=86400 58 | body: 59 | encoding: UTF-8 60 | string: | 61 | { 62 | "error": { 63 | "message": "Incorrect API key provided: test. You can find your API key at https://platform.openai.com/account/api-keys.", 64 | "type": "invalid_request_error", 65 | "param": null, 66 | "code": "invalid_api_key" 67 | } 68 | } 69 | recorded_at: Mon, 24 Nov 2025 18:14:12 GMT 70 | recorded_with: VCR 6.3.1 71 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/Cancellation_Integration/with_streamable-native/End-to-end_cancellation_with_streamable-native/properly_cleans_up_cancelled_requests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://api.openai.com/v1/chat/completions 6 | body: 7 | encoding: UTF-8 8 | string: '{"model":"gpt-4o","messages":[{"role":"developer","content":"You are 9 | a helpful assistant."},{"role":"user","content":"This request should be cancelled 10 | by the client"}],"stream":false}' 11 | headers: 12 | User-Agent: 13 | - Faraday v2.14.0 14 | Authorization: 15 | - Bearer test 16 | Content-Type: 17 | - application/json 18 | Accept-Encoding: 19 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 20 | Accept: 21 | - "*/*" 22 | response: 23 | status: 24 | code: 401 25 | message: Unauthorized 26 | headers: 27 | Date: 28 | - Mon, 24 Nov 2025 18:15:31 GMT 29 | Content-Type: 30 | - application/json; charset=utf-8 31 | Content-Length: 32 | - '254' 33 | Connection: 34 | - keep-alive 35 | Vary: 36 | - Origin 37 | X-Request-Id: 38 | - "" 39 | X-Envoy-Upstream-Service-Time: 40 | - '0' 41 | X-Openai-Proxy-Wasm: 42 | - v0.1 43 | Cf-Cache-Status: 44 | - DYNAMIC 45 | Set-Cookie: 46 | - "" 47 | - "" 48 | Strict-Transport-Security: 49 | - max-age=31536000; includeSubDomains; preload 50 | X-Content-Type-Options: 51 | - nosniff 52 | Server: 53 | - cloudflare 54 | Cf-Ray: 55 | - "" 56 | Alt-Svc: 57 | - h3=":443"; ma=86400 58 | body: 59 | encoding: UTF-8 60 | string: | 61 | { 62 | "error": { 63 | "message": "Incorrect API key provided: test. You can find your API key at https://platform.openai.com/account/api-keys.", 64 | "type": "invalid_request_error", 65 | "param": null, 66 | "code": "invalid_api_key" 67 | } 68 | } 69 | recorded_at: Mon, 24 Nov 2025 18:15:31 GMT 70 | recorded_with: VCR 6.3.1 71 | -------------------------------------------------------------------------------- /spec/ruby_llm/mcp/progress_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe RubyLLM::MCP::Progress do 4 | before(:all) do # rubocop:disable RSpec/BeforeAfterAll 5 | ClientRunner.build_client_runners(CLIENT_OPTIONS) 6 | ClientRunner.start_all 7 | end 8 | 9 | after(:all) do # rubocop:disable RSpec/BeforeAfterAll 10 | ClientRunner.stop_all 11 | end 12 | 13 | # Progress tests - only run on adapters that support progress tracking 14 | each_client_supporting(:progress_tracking) do 15 | describe "basic tool execution" do 16 | it "if progress is given but no handler, no error will be raised" do 17 | expect { client.tool("simple_progress").execute(progress: 75) }.not_to raise_error 18 | end 19 | 20 | it "can get progress from a tool" do 21 | progress = nil 22 | client.on_progress do |progress_update| 23 | progress = progress_update 24 | end 25 | 26 | client.tool("simple_progress").execute(progress: 75) 27 | 28 | expect(progress.progress).to eq(75) 29 | expect(progress.message).to eq("Progress: 75%") 30 | expect(progress.progress_token).to be_a(String) 31 | end 32 | 33 | it "does not process progress if no handler is provided" do 34 | client.on_progress 35 | 36 | allow(RubyLLM::MCP::Progress).to receive(:new).and_return(nil) 37 | client.tool("simple_progress").execute(progress: 75) 38 | 39 | expect(RubyLLM::MCP::Progress).not_to have_received(:new) 40 | end 41 | 42 | it "progress will contain a progress token" do 43 | progress = nil 44 | client.on_progress do |progress_update| 45 | progress = progress_update 46 | end 47 | 48 | client.tool("simple_progress").execute(progress: 75) 49 | expect(progress.to_h[:progress_token]).to be_a(String) 50 | end 51 | 52 | it "can get multiple progress updates from a tool" do 53 | steps = 3 54 | count = 0 55 | client.on_progress do 56 | count += 1 57 | end 58 | client.tool("progress").execute(operation: "test_op", steps: steps) 59 | 60 | expect(count).to eq(steps + 1) 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/Cancellation_Integration/with_stdio-native/End-to-end_cancellation_with_stdio-native/handles_server-initiated_cancellation_of_sampling_requests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://api.openai.com/v1/chat/completions 6 | body: 7 | encoding: UTF-8 8 | string: '{"model":"gpt-4o","messages":[{"role":"developer","content":"You are 9 | a helpful assistant."},{"role":"user","content":"This is a long message that 10 | should be cancelled"}],"stream":false}' 11 | headers: 12 | User-Agent: 13 | - Faraday v2.14.0 14 | Authorization: 15 | - Bearer test 16 | Content-Type: 17 | - application/json 18 | Accept-Encoding: 19 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 20 | Accept: 21 | - "*/*" 22 | response: 23 | status: 24 | code: 401 25 | message: Unauthorized 26 | headers: 27 | Date: 28 | - Mon, 24 Nov 2025 18:14:12 GMT 29 | Content-Type: 30 | - application/json; charset=utf-8 31 | Content-Length: 32 | - '254' 33 | Connection: 34 | - keep-alive 35 | Vary: 36 | - Origin 37 | X-Request-Id: 38 | - "" 39 | X-Envoy-Upstream-Service-Time: 40 | - '1' 41 | X-Openai-Proxy-Wasm: 42 | - v0.1 43 | Cf-Cache-Status: 44 | - DYNAMIC 45 | Set-Cookie: 46 | - "" 47 | - "" 48 | Strict-Transport-Security: 49 | - max-age=31536000; includeSubDomains; preload 50 | X-Content-Type-Options: 51 | - nosniff 52 | Server: 53 | - cloudflare 54 | Cf-Ray: 55 | - "" 56 | Alt-Svc: 57 | - h3=":443"; ma=86400 58 | body: 59 | encoding: UTF-8 60 | string: | 61 | { 62 | "error": { 63 | "message": "Incorrect API key provided: test. You can find your API key at https://platform.openai.com/account/api-keys.", 64 | "type": "invalid_request_error", 65 | "param": null, 66 | "code": "invalid_api_key" 67 | } 68 | } 69 | recorded_at: Mon, 24 Nov 2025 18:14:12 GMT 70 | recorded_with: VCR 6.3.1 71 | -------------------------------------------------------------------------------- /ruby_llm-mcp.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/ruby_llm/mcp/version" 4 | 5 | # rubocop:disable Metrics/BlockLength 6 | Gem::Specification.new do |spec| 7 | spec.name = "ruby_llm-mcp" 8 | spec.version = RubyLLM::MCP::VERSION 9 | spec.authors = ["Patrick Vice"] 10 | spec.email = ["patrickgvice@gmail.com"] 11 | 12 | spec.summary = "A RubyLLM MCP Client" 13 | spec.description = <<~DESC 14 | A Ruby client for the Model Context Protocol (MCP) that seamlessly integrates with RubyLLM. 15 | Supports both native full-featured implementation and the official mcp-sdk gem. 16 | Connect to MCP servers via SSE, stdio, or HTTP transports, automatically convert MCP tools into 17 | RubyLLM-compatible tools, and enable AI models to interact with external data sources and 18 | services. Makes using MCP with RubyLLM as easy as possible. 19 | DESC 20 | 21 | spec.homepage = "https://www.rubyllm-mcp.com" 22 | spec.license = "MIT" 23 | spec.required_ruby_version = Gem::Requirement.new(">= 3.1.3") 24 | 25 | spec.metadata["homepage_uri"] = spec.homepage 26 | spec.metadata["source_code_uri"] = "https://github.com/patvice/ruby_llm-mcp" 27 | spec.metadata["changelog_uri"] = "#{spec.metadata['source_code_uri']}/commits/main" 28 | spec.metadata["documentation_uri"] = "#{spec.homepage}/guides/" 29 | spec.metadata["bug_tracker_uri"] = "#{spec.metadata['source_code_uri']}/issues" 30 | 31 | spec.metadata["rubygems_mfa_required"] = "true" 32 | 33 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 34 | 35 | # Specify which files should be added to the gem when it is released. 36 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 37 | spec.files = Dir.glob("lib/**/*") + ["README.md", "LICENSE"] 38 | spec.require_paths = ["lib"] 39 | 40 | # Runtime dependencies 41 | spec.add_dependency "httpx", "~> 1.4" 42 | spec.add_dependency "json-schema", "~> 5.0" 43 | spec.add_dependency "ruby_llm", "~> 1.9" 44 | spec.add_dependency "zeitwerk", "~> 2" 45 | 46 | # Optional dependency for mcp_sdk adapter 47 | # Users who want to use adapter: :mcp_sdk should add to their Gemfile: 48 | # gem 'mcp', '~> 0.4' 49 | end 50 | # rubocop:enable Metrics/BlockLength 51 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/src/resources/media.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { readResourceFile } from "../utils/file-utils.js"; 3 | 4 | export function setupMediaResources(server: McpServer) { 5 | server.resource( 6 | "dog.png", 7 | "file://dog.png/", 8 | { 9 | name: "dog.png", 10 | description: "A picture of a dog", 11 | mimeType: "image/png", 12 | }, 13 | async (uri) => { 14 | try { 15 | const imageBuffer = await readResourceFile("dog.png"); 16 | const base64Image = imageBuffer.toString("base64"); 17 | 18 | return { 19 | contents: [ 20 | { 21 | uri: uri.href, 22 | blob: base64Image, 23 | mimeType: "image/png", 24 | }, 25 | ], 26 | }; 27 | } catch (error) { 28 | const errorMessage = 29 | error instanceof Error ? error.message : String(error); 30 | return { 31 | contents: [ 32 | { 33 | uri: uri.href, 34 | text: `Error reading dog image: ${errorMessage}`, 35 | }, 36 | ], 37 | }; 38 | } 39 | } 40 | ); 41 | 42 | server.resource( 43 | "jackhammer.wav", 44 | "file://jackhammer.wav/", 45 | { 46 | name: "jackhammer.wav", 47 | description: "A jackhammer audio file", 48 | mimeType: "audio/wav", 49 | }, 50 | async (uri) => { 51 | try { 52 | const audioBuffer = await readResourceFile("jackhammer.wav"); 53 | const base64Audio = audioBuffer.toString("base64"); 54 | 55 | return { 56 | contents: [ 57 | { 58 | uri: uri.href, 59 | blob: base64Audio, 60 | mimeType: "audio/wav", 61 | }, 62 | ], 63 | }; 64 | } catch (error) { 65 | const errorMessage = 66 | error instanceof Error ? error.message : String(error); 67 | return { 68 | contents: [ 69 | { 70 | uri: uri.href, 71 | text: `Error reading jackhammer audio: ${errorMessage}`, 72 | }, 73 | ], 74 | }; 75 | } 76 | } 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /spec/fixtures/pagination-server/test_stdio.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Testing MCP Pagination Server via stdio..." 4 | 5 | # Create a temporary file for MCP commands 6 | cat > mcp_test_commands.json << 'EOF' 7 | {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"tools":{},"resources":{},"prompts":{}},"clientInfo":{"name":"test-client","version":"1.0.0"}}} 8 | {"jsonrpc":"2.0","method":"notifications/initialized"} 9 | {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} 10 | {"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"cursor":"page_2"}} 11 | {"jsonrpc":"2.0","id":4,"method":"resources/list","params":{}} 12 | {"jsonrpc":"2.0","id":5,"method":"resources/list","params":{"cursor":"page_2"}} 13 | {"jsonrpc":"2.0","id":6,"method":"prompts/list","params":{}} 14 | {"jsonrpc":"2.0","id":7,"method":"prompts/list","params":{"cursor":"page_2"}} 15 | {"jsonrpc":"2.0","id":8,"method":"resources/templates/list","params":{}} 16 | {"jsonrpc":"2.0","id":9,"method":"resources/templates/list","params":{"cursor":"page_2"}} 17 | EOF 18 | 19 | echo "Sending MCP commands..." 20 | echo 21 | 22 | # Send commands to the server 23 | bun src/index.ts --stdio < mcp_test_commands.json | while IFS= read -r line; do 24 | if [[ $line == *"\"method\":"* ]]; then 25 | echo ">>> Server response: $line" 26 | elif [[ $line == *"\"id\":1"* ]]; then 27 | echo "✅ Initialization: $line" 28 | elif [[ $line == *"\"id\":2"* ]]; then 29 | echo "🔧 Tools Page 1: $line" 30 | elif [[ $line == *"\"id\":3"* ]]; then 31 | echo "🔧 Tools Page 2: $line" 32 | elif [[ $line == *"\"id\":4"* ]]; then 33 | echo "📁 Resources Page 1: $line" 34 | elif [[ $line == *"\"id\":5"* ]]; then 35 | echo "📁 Resources Page 2: $line" 36 | elif [[ $line == *"\"id\":6"* ]]; then 37 | echo "💬 Prompts Page 1: $line" 38 | elif [[ $line == *"\"id\":7"* ]]; then 39 | echo "💬 Prompts Page 2: $line" 40 | elif [[ $line == *"\"id\":8"* ]]; then 41 | echo "🔗 Resource Templates Page 1: $line" 42 | elif [[ $line == *"\"id\":9"* ]]; then 43 | echo "🔗 Resource Templates Page 2: $line" 44 | elif [[ -n "$line" ]]; then 45 | echo "📄 Response: $line" 46 | fi 47 | done 48 | 49 | # Clean up 50 | rm -f mcp_test_commands.json 51 | 52 | echo 53 | echo "Test completed!" 54 | -------------------------------------------------------------------------------- /spec/ruby_llm/mcp/auth/memory_storage_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe RubyLLM::MCP::Auth::MemoryStorage do 6 | let(:storage) { described_class.new } 7 | let(:server_url) { "https://mcp.example.com/api" } 8 | 9 | describe "token storage" do 10 | let(:token) do 11 | RubyLLM::MCP::Auth::Token.new(access_token: "test_token") 12 | end 13 | 14 | it "stores and retrieves tokens" do 15 | storage.set_token(server_url, token) 16 | 17 | expect(storage.get_token(server_url)).to eq(token) 18 | end 19 | 20 | it "returns nil for non-existent tokens" do 21 | expect(storage.get_token("https://other.example.com")).to be_nil 22 | end 23 | end 24 | 25 | describe "client info storage" do 26 | let(:client_info) do 27 | RubyLLM::MCP::Auth::ClientInfo.new(client_id: "test_id") 28 | end 29 | 30 | it "stores and retrieves client info" do 31 | storage.set_client_info(server_url, client_info) 32 | 33 | expect(storage.get_client_info(server_url)).to eq(client_info) 34 | end 35 | end 36 | 37 | describe "server metadata storage" do 38 | let(:metadata) do 39 | RubyLLM::MCP::Auth::ServerMetadata.new( 40 | issuer: "https://auth.example.com", 41 | authorization_endpoint: "https://auth.example.com/authorize", 42 | token_endpoint: "https://auth.example.com/token", 43 | options: {} 44 | ) 45 | end 46 | 47 | it "stores and retrieves server metadata" do 48 | storage.set_server_metadata(server_url, metadata) 49 | 50 | expect(storage.get_server_metadata(server_url)).to eq(metadata) 51 | end 52 | end 53 | 54 | describe "PKCE storage" do 55 | let(:pkce) { RubyLLM::MCP::Auth::PKCE.new } 56 | 57 | it "stores, retrieves, and deletes PKCE" do 58 | storage.set_pkce(server_url, pkce) 59 | 60 | expect(storage.get_pkce(server_url)).to eq(pkce) 61 | 62 | storage.delete_pkce(server_url) 63 | 64 | expect(storage.get_pkce(server_url)).to be_nil 65 | end 66 | end 67 | 68 | describe "state storage" do 69 | let(:state) { "test_state" } 70 | 71 | it "stores, retrieves, and deletes state" do 72 | storage.set_state(server_url, state) 73 | 74 | expect(storage.get_state(server_url)).to eq(state) 75 | 76 | storage.delete_state(server_url) 77 | 78 | expect(storage.get_state(server_url)).to be_nil 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Errors 6 | class BaseError < StandardError 7 | attr_reader :message 8 | 9 | def initialize(message:) 10 | @message = message 11 | super(message) 12 | end 13 | end 14 | 15 | module Capabilities 16 | class CompletionNotAvailable < BaseError; end 17 | class ResourceSubscribeNotAvailable < BaseError; end 18 | end 19 | 20 | class InvalidFormatError < BaseError; end 21 | 22 | class InvalidProtocolVersionError < BaseError; end 23 | 24 | class InvalidTransportType < BaseError; end 25 | 26 | class ProgressHandlerNotAvailable < BaseError; end 27 | 28 | class PromptArgumentError < BaseError; end 29 | 30 | class ResponseError < BaseError 31 | attr_reader :error 32 | 33 | def initialize(message:, error:) 34 | @error = error 35 | super(message: message) 36 | end 37 | end 38 | 39 | class AuthenticationRequiredError < BaseError 40 | attr_reader :code 41 | 42 | def initialize(message: "Authentication required", code: 401) 43 | @code = code 44 | super(message: message) 45 | end 46 | end 47 | 48 | class ConfigurationError < BaseError; end 49 | 50 | class SessionExpiredError < BaseError; end 51 | 52 | class TimeoutError < BaseError 53 | attr_reader :request_id 54 | 55 | def initialize(message:, request_id: nil) 56 | @request_id = request_id 57 | super(message: message) 58 | end 59 | end 60 | 61 | class TransportError < BaseError 62 | attr_reader :code, :error 63 | 64 | def initialize(message:, code: nil, error: nil) 65 | @code = code 66 | @error = error 67 | super(message: message) 68 | end 69 | end 70 | 71 | class UnknownRequest < BaseError; end 72 | 73 | class UnsupportedProtocolVersion < BaseError; end 74 | 75 | class UnsupportedFeature < BaseError; end 76 | 77 | class UnsupportedTransport < BaseError; end 78 | 79 | class AdapterConfigurationError < BaseError; end 80 | 81 | class RequestCancelled < BaseError 82 | attr_reader :request_id 83 | 84 | def initialize(message:, request_id:) 85 | @request_id = request_id 86 | super(message: message) 87 | end 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/ruby_llm/mcp/adapters/mcp_sdk_sse_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MCPSdkSSERunner 4 | class << self 5 | def instance 6 | @instance ||= MCPSdkSSERunner.new 7 | end 8 | end 9 | 10 | def start 11 | client.start 12 | end 13 | 14 | def stop 15 | client&.stop 16 | end 17 | 18 | def client 19 | @client ||= RubyLLM::MCP::Client.new( 20 | name: "fast-mcp-ruby-sdk", 21 | adapter: :mcp_sdk, 22 | transport_type: :sse, 23 | config: { 24 | url: "http://localhost:#{TestServerManager::PORTS[:sse]}/mcp/sse", 25 | version: :http1 26 | } 27 | ) 28 | end 29 | end 30 | 31 | RSpec.describe RubyLLM::MCP::Adapters::MCPSdkAdapter do # rubocop:disable RSpec/SpecFilePathFormat 32 | let(:client) { MCPSdkSSERunner.instance.client } 33 | 34 | before(:all) do # rubocop:disable RSpec/BeforeAfterAll 35 | if RUBY_VERSION < "3.2.0" 36 | skip "Specs require Ruby 3.2+" 37 | else 38 | MCPSdkSSERunner.instance.start 39 | end 40 | end 41 | 42 | after(:all) do # rubocop:disable RSpec/BeforeAfterAll 43 | if RUBY_VERSION >= "3.2.0" 44 | MCPSdkSSERunner.instance.stop 45 | end 46 | end 47 | 48 | describe "connection" do 49 | it "starts the transport and establishes connection" do 50 | expect(client.alive?).to be(true) 51 | end 52 | end 53 | 54 | describe "tools" do 55 | it "can list tools over SSE" do 56 | tools = client.tools 57 | expect(tools.count).to eq(2) 58 | end 59 | 60 | it "can execute a tool over SSE" do 61 | tool = client.tool("CalculateTool") 62 | result = tool.execute(operation: "add", x: 1.0, y: 2.0) 63 | expect(result.to_s).to eq("3.0") 64 | end 65 | end 66 | 67 | describe "resources" do 68 | it "can list resources over SSE" do 69 | resources = client.resources 70 | expect(resources.count).to eq(1) 71 | end 72 | 73 | it "can read a resource over SSE" do 74 | resource = client.resources.first 75 | content = resource.content 76 | expect(content).not_to be_nil 77 | expect(content).to be_a(String) 78 | end 79 | end 80 | 81 | describe "transport lifecycle" do 82 | it "can restart the connection" do 83 | client.stop 84 | expect(client.alive?).to be(false) 85 | 86 | client.start 87 | expect(client.alive?).to be(true) 88 | 89 | # Verify functionality after restart 90 | tools = client.tools 91 | expect(tools.count).to eq(2) 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/src/prompts/greetings.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; 3 | import { z } from "zod"; 4 | 5 | export function setupGreetingPrompts(server: McpServer) { 6 | server.prompt( 7 | "specific_language_greeting", 8 | "Generates a greeting in a specific language", 9 | { 10 | name: z.string().describe("Name of the person I am to greet"), 11 | language: completable( 12 | z.string().describe("Name the language you are to greet in"), 13 | (name: string) => { 14 | const languages = [ 15 | "English", 16 | "Spanish", 17 | "French", 18 | "German", 19 | "Italian", 20 | "Portuguese", 21 | "Russian", 22 | "Chinese", 23 | "Japanese", 24 | "Korean", 25 | ]; 26 | 27 | return languages.filter((language) => 28 | language.toLowerCase().includes(name.toLowerCase()) 29 | ); 30 | } 31 | ), 32 | }, 33 | async ({ name, language }) => { 34 | const greeting = `Can you create a greeting for ${name} in ${language}?`; 35 | 36 | return { 37 | messages: [ 38 | { 39 | role: "user", 40 | content: { 41 | type: "text", 42 | text: greeting, 43 | }, 44 | }, 45 | ], 46 | }; 47 | } 48 | ); 49 | 50 | // Prompt that takes a name argument 51 | server.prompt( 52 | "greeting", 53 | "Generates a greeting in a random language", 54 | { 55 | name: z.string().describe("Name of the person I am to greet"), 56 | }, 57 | async ({ name }) => { 58 | const languages = [ 59 | "English", 60 | "Spanish", 61 | "French", 62 | "German", 63 | "Italian", 64 | "Portuguese", 65 | "Russian", 66 | "Chinese", 67 | "Japanese", 68 | "Korean", 69 | ]; 70 | const randomLanguage = 71 | languages[Math.floor(Math.random() * languages.length)]; 72 | const greeting = `Can you create a greeting for ${name} in ${randomLanguage}?`; 73 | 74 | return { 75 | messages: [ 76 | { 77 | role: "user", 78 | content: { 79 | type: "text", 80 | text: greeting, 81 | }, 82 | }, 83 | ], 84 | }; 85 | } 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /spec/ruby_llm/mcp/adapters/mcp_sdk_stdio_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MCPSdkStdioRunner 4 | class << self 5 | def instance 6 | @instance ||= MCPSdkStdioRunner.new 7 | end 8 | end 9 | 10 | def start 11 | client.start 12 | end 13 | 14 | def stop 15 | client&.stop 16 | end 17 | 18 | def client 19 | @client ||= RubyLLM::MCP::Client.new( 20 | name: "typescript-mcp-stdio", 21 | adapter: :mcp_sdk, 22 | transport_type: :stdio, 23 | config: { 24 | command: "bun", 25 | args: ["spec/fixtures/typescript-mcp/index.ts", "--", "--silent", "--stdio"] 26 | } 27 | ) 28 | end 29 | end 30 | 31 | RSpec.describe RubyLLM::MCP::Adapters::MCPSdkAdapter do # rubocop:disable RSpec/SpecFilePathFormat 32 | let(:client) { MCPSdkStdioRunner.instance.client } 33 | 34 | before(:all) do # rubocop:disable RSpec/BeforeAfterAll 35 | if RUBY_VERSION < "3.2.0" 36 | skip "Specs require Ruby 3.2+" 37 | else 38 | MCPSdkStdioRunner.instance.start 39 | end 40 | end 41 | 42 | after(:all) do # rubocop:disable RSpec/BeforeAfterAll 43 | if RUBY_VERSION >= "3.2.0" 44 | MCPSdkStdioRunner.instance.stop 45 | end 46 | end 47 | 48 | describe "connection" do 49 | it "starts the transport and establishes connection" do 50 | expect(client.alive?).to be(true) 51 | end 52 | end 53 | 54 | describe "tools" do 55 | it "can list tools over stdio" do 56 | tools = client.tools 57 | expect(tools.count).to be > 0 58 | end 59 | 60 | it "can execute a tool over stdio" do 61 | tool = client.tool("add") 62 | result = tool.execute(a: 5, b: 3) 63 | expect(result).not_to be_nil 64 | expect(result.to_s).to eq("8") 65 | end 66 | end 67 | 68 | describe "resources" do 69 | it "can list resources over stdio" do 70 | resources = client.resources 71 | expect(resources.count).to be > 0 72 | end 73 | 74 | it "can read a resource over stdio" do 75 | resource = client.resources.first 76 | content = resource.content 77 | expect(content).not_to be_nil 78 | expect(content).to be_a(String) 79 | end 80 | end 81 | 82 | describe "transport lifecycle" do 83 | it "can restart the connection" do 84 | client.stop 85 | expect(client.alive?).to be(false) 86 | 87 | client.start 88 | expect(client.alive?).to be(true) 89 | 90 | # Verify functionality after restart 91 | tools = client.tools 92 | expect(tools.count).to be > 0 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Auth 6 | module Flows 7 | # Orchestrates OAuth 2.1 Client Credentials flow 8 | # Used for application authentication without user interaction 9 | class ClientCredentialsFlow 10 | attr_reader :discoverer, :client_registrar, :token_manager, :storage, :logger 11 | 12 | def initialize(discoverer:, client_registrar:, token_manager:, storage:, logger:) 13 | @discoverer = discoverer 14 | @client_registrar = client_registrar 15 | @token_manager = token_manager 16 | @storage = storage 17 | @logger = logger 18 | end 19 | 20 | # Perform client credentials flow 21 | # @param server_url [String] MCP server URL 22 | # @param redirect_uri [String] redirect URI (used for registration only) 23 | # @param scope [String, nil] requested scope 24 | # @return [Token] access token 25 | def execute(server_url, redirect_uri, scope) 26 | logger.debug("Starting OAuth client credentials flow") 27 | 28 | # 1. Discover authorization server 29 | server_metadata = discoverer.discover(server_url) 30 | raise Errors::TransportError.new(message: "OAuth server discovery failed") unless server_metadata 31 | 32 | # 2. Register client (or get cached client) with client credentials grant 33 | client_info = client_registrar.get_or_register( 34 | server_url, 35 | server_metadata, 36 | :client_credentials, 37 | redirect_uri, 38 | scope 39 | ) 40 | 41 | # 3. Validate that we have a client secret 42 | unless client_info.client_secret 43 | raise Errors::TransportError.new( 44 | message: "Client credentials flow requires client_secret" 45 | ) 46 | end 47 | 48 | # 4. Exchange client credentials for token 49 | token = token_manager.exchange_client_credentials( 50 | server_metadata, 51 | client_info, 52 | scope, 53 | server_url 54 | ) 55 | 56 | # 5. Store token 57 | storage.set_token(server_url, token) 58 | 59 | logger.info("Client credentials authentication completed successfully") 60 | token 61 | end 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/ruby_llm/mcp/adapters/mcp_sdk_streamable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MCPSdkStreamableRunner 4 | class << self 5 | def instance 6 | @instance ||= MCPSdkStreamableRunner.new 7 | end 8 | end 9 | 10 | def start 11 | client.start 12 | end 13 | 14 | def stop 15 | client&.stop 16 | end 17 | 18 | def client 19 | @client ||= RubyLLM::MCP::Client.new( 20 | name: "typescript-mcp-streamable", 21 | adapter: :mcp_sdk, 22 | transport_type: :streamable_http, 23 | config: { 24 | url: "http://localhost:#{TestServerManager::PORTS[:http]}/mcp" 25 | } 26 | ) 27 | end 28 | end 29 | 30 | RSpec.describe RubyLLM::MCP::Adapters::MCPSdkAdapter do # rubocop:disable RSpec/SpecFilePathFormat 31 | let(:client) { MCPSdkStreamableRunner.instance.client } 32 | 33 | before(:all) do # rubocop:disable RSpec/BeforeAfterAll 34 | if RUBY_VERSION < "3.2.0" 35 | skip "Specs require Ruby 3.2+" 36 | else 37 | MCPSdkStreamableRunner.instance.start 38 | end 39 | end 40 | 41 | after(:all) do # rubocop:disable RSpec/BeforeAfterAll 42 | if RUBY_VERSION >= "3.2.0" 43 | MCPSdkStreamableRunner.instance.stop 44 | end 45 | end 46 | 47 | describe "connection" do 48 | it "starts the transport and establishes connection" do 49 | expect(client.alive?).to be(true) 50 | end 51 | end 52 | 53 | describe "tools" do 54 | it "can list tools over streamable HTTP" do 55 | tools = client.tools 56 | expect(tools.count).to be > 0 57 | end 58 | 59 | it "can execute a tool over streamable HTTP" do 60 | tool = client.tool("add") 61 | result = tool.execute(a: 5, b: 3) 62 | expect(result).not_to be_nil 63 | expect(result.to_s).to eq("8") 64 | end 65 | end 66 | 67 | describe "resources" do 68 | it "can list resources over streamable HTTP" do 69 | resources = client.resources 70 | expect(resources.count).to be > 0 71 | end 72 | 73 | it "can read a resource over streamable HTTP" do 74 | resource = client.resources.first 75 | content = resource.content 76 | expect(content).not_to be_nil 77 | expect(content).to be_a(String) 78 | end 79 | end 80 | 81 | describe "transport lifecycle" do 82 | it "can restart the connection" do 83 | client.stop 84 | expect(client.alive?).to be(false) 85 | 86 | client.start 87 | expect(client.alive?).to be(true) 88 | 89 | # Verify functionality after restart 90 | tools = client.tools 91 | expect(tools.count).to be > 0 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/auth/memory_storage.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Auth 6 | # In-memory storage for OAuth data 7 | # Stores tokens, client registrations, server metadata, and temporary session data 8 | class MemoryStorage 9 | def initialize 10 | @tokens = {} 11 | @client_infos = {} 12 | @server_metadata = {} 13 | @pkce_data = {} 14 | @state_data = {} 15 | @resource_metadata = {} 16 | end 17 | 18 | # Token storage 19 | def get_token(server_url) 20 | @tokens[server_url] 21 | end 22 | 23 | def set_token(server_url, token) 24 | @tokens[server_url] = token 25 | end 26 | 27 | def delete_token(server_url) 28 | @tokens.delete(server_url) 29 | end 30 | 31 | # Client registration storage 32 | def get_client_info(server_url) 33 | @client_infos[server_url] 34 | end 35 | 36 | def set_client_info(server_url, client_info) 37 | @client_infos[server_url] = client_info 38 | end 39 | 40 | # Server metadata caching 41 | def get_server_metadata(server_url) 42 | @server_metadata[server_url] 43 | end 44 | 45 | def set_server_metadata(server_url, metadata) 46 | @server_metadata[server_url] = metadata 47 | end 48 | 49 | # PKCE state management (temporary) 50 | def get_pkce(server_url) 51 | @pkce_data[server_url] 52 | end 53 | 54 | def set_pkce(server_url, pkce) 55 | @pkce_data[server_url] = pkce 56 | end 57 | 58 | def delete_pkce(server_url) 59 | @pkce_data.delete(server_url) 60 | end 61 | 62 | # State parameter management (temporary) 63 | def get_state(server_url) 64 | @state_data[server_url] 65 | end 66 | 67 | def set_state(server_url, state) 68 | @state_data[server_url] = state 69 | end 70 | 71 | def delete_state(server_url) 72 | @state_data.delete(server_url) 73 | end 74 | 75 | # Resource metadata management 76 | def get_resource_metadata(server_url) 77 | @resource_metadata[server_url] 78 | end 79 | 80 | def set_resource_metadata(server_url, metadata) 81 | @resource_metadata[server_url] = metadata 82 | end 83 | 84 | def delete_resource_metadata(server_url) 85 | @resource_metadata.delete(server_url) 86 | end 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/auth/http_response_handler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Auth 6 | # Utility class for handling HTTP responses in OAuth flows 7 | # Consolidates error handling and response parsing 8 | class HttpResponseHandler 9 | # Handle and parse a successful HTTP response 10 | # @param response [HTTPX::Response, HTTPX::ErrorResponse] HTTP response 11 | # @param context [String] description for error messages (e.g., "Token exchange") 12 | # @param expected_status [Integer, Array] expected status code(s) 13 | # @return [Hash] parsed JSON response 14 | # @raise [Errors::TransportError] if response is an error or unexpected status 15 | def self.handle_response(response, context:, expected_status: 200) 16 | expected_statuses = Array(expected_status) 17 | 18 | # Handle HTTPX ErrorResponse (connection failures, timeouts, etc.) 19 | if response.is_a?(HTTPX::ErrorResponse) 20 | error_message = response.error&.message || "Request failed" 21 | raise Errors::TransportError.new( 22 | message: "#{context} failed: #{error_message}" 23 | ) 24 | end 25 | 26 | unless expected_statuses.include?(response.status) 27 | raise Errors::TransportError.new( 28 | message: "#{context} failed: HTTP #{response.status}", 29 | code: response.status 30 | ) 31 | end 32 | 33 | JSON.parse(response.body.to_s) 34 | end 35 | 36 | # Extract redirect URI mismatch details from error response 37 | # @param body [String] error response body 38 | # @return [Hash, nil] mismatch details or nil 39 | def self.extract_redirect_mismatch(body) 40 | data = JSON.parse(body) 41 | error = data["error"] || data[:error] 42 | return nil unless error == "unauthorized_client" 43 | 44 | description = data["error_description"] || data[:error_description] 45 | return nil unless description.is_a?(String) 46 | 47 | # Parse common OAuth error message format 48 | # Matches: "You sent and we expected " 49 | match = description.match(%r{You sent\s+(https?://[^\s,]+)[\s,]+and we expected\s+(https?://\S+?)\.?\s*$}i) 50 | return nil unless match 51 | 52 | { 53 | sent: match[1], 54 | expected: match[2], 55 | description: description 56 | } 57 | rescue JSON::ParserError 58 | nil 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/ruby_llm/mcp/native/response_handler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe RubyLLM::MCP::Native::ResponseHandler do 4 | let(:client) { instance_double(RubyLLM::MCP::Native::Client) } 5 | let(:request_handler) { RubyLLM::MCP::Native::ResponseHandler.new(client) } 6 | 7 | before do 8 | allow(client).to receive(:error_response).and_return(true) 9 | allow(client).to receive(:register_in_flight_request) 10 | allow(client).to receive(:unregister_in_flight_request) 11 | end 12 | 13 | it "response with an error code if the request is unknown" do 14 | result = RubyLLM::MCP::Result.new( 15 | { "id" => "123", "method" => "unknown/request", "params" => {} } 16 | ) 17 | 18 | request_handler.execute(result) 19 | error_message = "Method not found: #{result.method}" 20 | expect(client).to have_received(:error_response).with( 21 | id: "123", 22 | message: error_message, 23 | code: RubyLLM::MCP::Native::JsonRpc::ErrorCodes::METHOD_NOT_FOUND 24 | ) 25 | end 26 | 27 | describe "cancellation handling" do 28 | it "registers and unregisters in-flight requests" do 29 | allow(client).to receive(:ping_response) 30 | result = RubyLLM::MCP::Result.new( 31 | { "id" => "456", "method" => "ping", "params" => {} } 32 | ) 33 | 34 | request_handler.execute(result) 35 | 36 | expect(client).to have_received(:register_in_flight_request).with("456", instance_of(RubyLLM::MCP::Native::CancellableOperation)) 37 | expect(client).to have_received(:unregister_in_flight_request).with("456") 38 | end 39 | 40 | it "does not send a response when a request is cancelled" do 41 | allow(client).to receive(:roots_paths).and_return(["/path"]) 42 | allow(client).to receive(:roots_list_response) 43 | 44 | result = RubyLLM::MCP::Result.new( 45 | { "id" => "789", "method" => "roots/list", "params" => {} } 46 | ) 47 | 48 | # Simulate cancellation by stubbing the CancellableOperation to raise RequestCancelled 49 | cancelled_operation = instance_double(RubyLLM::MCP::Native::CancellableOperation) 50 | allow(RubyLLM::MCP::Native::CancellableOperation).to receive(:new).with("789").and_return(cancelled_operation) 51 | allow(cancelled_operation).to receive(:execute).and_raise( 52 | RubyLLM::MCP::Errors::RequestCancelled.new(message: "Cancelled", request_id: "789") 53 | ) 54 | 55 | result = request_handler.execute(result) 56 | 57 | expect(result).to be true 58 | expect(client).not_to have_received(:roots_list_response) 59 | expect(client).to have_received(:unregister_in_flight_request).with("789") 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/with_stdio-native_with_gemini_gemini-2_0-flash_ask_prompt_handles_prompts_when_available.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"Hello, how are you? Can 9 | you also say Hello back?"}]}],"generationConfig":{}}' 10 | headers: 11 | User-Agent: 12 | - Faraday v2.14.0 13 | X-Goog-Api-Key: 14 | - "" 15 | Content-Type: 16 | - application/json 17 | Accept-Encoding: 18 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 19 | Accept: 20 | - "*/*" 21 | response: 22 | status: 23 | code: 200 24 | message: OK 25 | headers: 26 | Content-Type: 27 | - application/json; charset=UTF-8 28 | Vary: 29 | - Origin 30 | - Referer 31 | - X-Origin 32 | Date: 33 | - Mon, 10 Nov 2025 23:37:49 GMT 34 | Server: 35 | - scaffolding on HTTPServer2 36 | X-Xss-Protection: 37 | - '0' 38 | X-Frame-Options: 39 | - SAMEORIGIN 40 | X-Content-Type-Options: 41 | - nosniff 42 | Server-Timing: 43 | - gfet4t7; dur=510 44 | Alt-Svc: 45 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 46 | Transfer-Encoding: 47 | - chunked 48 | body: 49 | encoding: ASCII-8BIT 50 | string: | 51 | { 52 | "candidates": [ 53 | { 54 | "content": { 55 | "parts": [ 56 | { 57 | "text": "Hello! I'm doing well, thank you for asking. How are you?\n" 58 | } 59 | ], 60 | "role": "model" 61 | }, 62 | "finishReason": "STOP", 63 | "avgLogprobs": -0.020141805211702984 64 | } 65 | ], 66 | "usageMetadata": { 67 | "promptTokenCount": 13, 68 | "candidatesTokenCount": 18, 69 | "totalTokenCount": 31, 70 | "promptTokensDetails": [ 71 | { 72 | "modality": "TEXT", 73 | "tokenCount": 13 74 | } 75 | ], 76 | "candidatesTokensDetails": [ 77 | { 78 | "modality": "TEXT", 79 | "tokenCount": 18 80 | } 81 | ] 82 | }, 83 | "modelVersion": "gemini-2.0-flash", 84 | "responseId": "THcSaf6MLazJ-8YP6YnQmQc" 85 | } 86 | recorded_at: Mon, 10 Nov 2025 23:37:49 GMT 87 | recorded_with: VCR 6.3.1 88 | -------------------------------------------------------------------------------- /spec/ruby_llm/mcp/notification_handler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../../lib/ruby_llm/mcp/result" 4 | 5 | class FakeLogger 6 | def error(message) 7 | @error_message = message 8 | end 9 | 10 | attr_reader :error_message 11 | end 12 | 13 | RSpec.describe RubyLLM::MCP::NotificationHandler do 14 | let(:client) { instance_double(RubyLLM::MCP::Client) } 15 | let(:notification_handler) { RubyLLM::MCP::NotificationHandler.new(client) } 16 | 17 | before do 18 | # Allow client methods that NotificationHandler might call 19 | allow(client).to receive(:tracking_progress?).and_return(false) 20 | end 21 | 22 | after do 23 | MCPTestConfiguration.reset_config! 24 | end 25 | 26 | describe "notifications/cancelled" do 27 | it "calls cancel_in_flight_request on the client with the request ID" do 28 | allow(client).to receive(:cancel_in_flight_request).and_return(true) 29 | 30 | notification = RubyLLM::MCP::Notification.new( 31 | { "method" => "notifications/cancelled", "params" => { "requestId" => "req-123", "reason" => "Timeout" } } 32 | ) 33 | 34 | notification_handler.execute(notification) 35 | 36 | expect(client).to have_received(:cancel_in_flight_request).with("req-123") 37 | end 38 | 39 | it "handles cancellation when request is not found" do 40 | allow(client).to receive(:cancel_in_flight_request).and_return(false) 41 | 42 | notification = RubyLLM::MCP::Notification.new( 43 | { "method" => "notifications/cancelled", "params" => { "requestId" => "req-456" } } 44 | ) 45 | 46 | expect { notification_handler.execute(notification) }.not_to raise_error 47 | end 48 | 49 | it "handles cancellation without a reason" do 50 | allow(client).to receive(:cancel_in_flight_request).and_return(true) 51 | 52 | notification = RubyLLM::MCP::Notification.new( 53 | { "method" => "notifications/cancelled", "params" => { "requestId" => "req-789" } } 54 | ) 55 | 56 | expect { notification_handler.execute(notification) }.not_to raise_error 57 | expect(client).to have_received(:cancel_in_flight_request).with("req-789") 58 | end 59 | end 60 | 61 | it "calling an unknown notification will log an error and do nothing else" do 62 | logger = FakeLogger.new 63 | RubyLLM::MCP.configure do |config| 64 | config.logger = logger 65 | end 66 | 67 | notification = RubyLLM::MCP::Notification.new( 68 | { "method" => "notifications/unknown", "params" => {} } 69 | ) 70 | 71 | notification_handler.execute(notification) 72 | expect(logger.error_message).to eq("Unknown notification type: notifications/unknown params: {}") 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/with_streamable-native_with_gemini_gemini-2_0-flash_ask_prompt_handles_prompts_when_available.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"Hello, how are you? Can 9 | you also say Hello back?"}]}],"generationConfig":{}}' 10 | headers: 11 | User-Agent: 12 | - Faraday v2.14.0 13 | X-Goog-Api-Key: 14 | - "" 15 | Content-Type: 16 | - application/json 17 | Accept-Encoding: 18 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 19 | Accept: 20 | - "*/*" 21 | response: 22 | status: 23 | code: 200 24 | message: OK 25 | headers: 26 | Content-Type: 27 | - application/json; charset=UTF-8 28 | Vary: 29 | - Origin 30 | - Referer 31 | - X-Origin 32 | Date: 33 | - Mon, 10 Nov 2025 23:38:41 GMT 34 | Server: 35 | - scaffolding on HTTPServer2 36 | X-Xss-Protection: 37 | - '0' 38 | X-Frame-Options: 39 | - SAMEORIGIN 40 | X-Content-Type-Options: 41 | - nosniff 42 | Server-Timing: 43 | - gfet4t7; dur=457 44 | Alt-Svc: 45 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 46 | Transfer-Encoding: 47 | - chunked 48 | body: 49 | encoding: ASCII-8BIT 50 | string: | 51 | { 52 | "candidates": [ 53 | { 54 | "content": { 55 | "parts": [ 56 | { 57 | "text": "Hello! I'm doing well, thank you for asking. How are you?\n" 58 | } 59 | ], 60 | "role": "model" 61 | }, 62 | "finishReason": "STOP", 63 | "avgLogprobs": -0.020141805211702984 64 | } 65 | ], 66 | "usageMetadata": { 67 | "promptTokenCount": 13, 68 | "candidatesTokenCount": 18, 69 | "totalTokenCount": 31, 70 | "promptTokensDetails": [ 71 | { 72 | "modality": "TEXT", 73 | "tokenCount": 13 74 | } 75 | ], 76 | "candidatesTokensDetails": [ 77 | { 78 | "modality": "TEXT", 79 | "tokenCount": 18 80 | } 81 | ] 82 | }, 83 | "modelVersion": "gemini-2.0-flash", 84 | "responseId": "gXcSadHmCtfO_uMPhOu7gQY" 85 | } 86 | recorded_at: Mon, 10 Nov 2025 23:38:41 GMT 87 | recorded_with: VCR 6.3.1 88 | -------------------------------------------------------------------------------- /spec/ruby_llm/mcp/prompt_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe RubyLLM::MCP::Prompt do 4 | before(:all) do # rubocop:disable RSpec/BeforeAfterAll 5 | ClientRunner.build_client_runners(CLIENT_OPTIONS) 6 | ClientRunner.start_all 7 | end 8 | 9 | after(:all) do # rubocop:disable RSpec/BeforeAfterAll 10 | ClientRunner.stop_all 11 | end 12 | 13 | context "with #{PAGINATION_CLIENT_CONFIG[:name]}" do 14 | let(:client) { RubyLLM::MCP::Client.new(**PAGINATION_CLIENT_CONFIG) } 15 | 16 | before do 17 | client.start 18 | end 19 | 20 | after do 21 | client.stop 22 | end 23 | 24 | describe "prompts_list" do 25 | it "paginates prompts list to get all prompts" do 26 | prompts = client.prompts 27 | expect(prompts.count).to eq(2) 28 | end 29 | end 30 | end 31 | 32 | # Prompt tests - only run on adapters that support prompts 33 | each_client_supporting(:prompts) do |_config| 34 | describe "prompts_list" do 35 | it "returns array of prompts" do 36 | prompts = client.prompts 37 | expect(prompts).to be_a(Array) 38 | end 39 | end 40 | 41 | describe "#execute_prompt" do 42 | it "returns prompt messages" do 43 | prompt = client.prompt("say_hello") 44 | messages = prompt.fetch 45 | 46 | expect(messages).to be_a(Array) 47 | expect(messages.first).to be_a(RubyLLM::Message) 48 | expect(messages.first.role).to eq(:user) 49 | expect(messages.first.content).to eq("Hello, how are you? Can you also say Hello back?") 50 | end 51 | 52 | it "returns multiple messages" do 53 | prompt = client.prompt("multiple_messages") 54 | messages = prompt.fetch 55 | 56 | expect(messages).to be_a(Array) 57 | 58 | message = messages.first 59 | expect(message).to be_a(RubyLLM::Message) 60 | expect(message.role).to eq(:assistant) 61 | expect(message.content).to eq("You are great at saying hello, the best in the world at it.") 62 | 63 | message = messages.last 64 | expect(message).to be_a(RubyLLM::Message) 65 | expect(message.role).to eq(:user) 66 | expect(message.content).to eq("Hello, how are you?") 67 | end 68 | end 69 | end 70 | 71 | # Refresh via notifications - only supported by adapters with notification support 72 | each_client_supporting(:notifications) do |_config| 73 | describe "prompts_list" do 74 | it "refreshes prompts when requested" do 75 | tool = client.tool("send_list_changed") 76 | prompt_count = client.prompts.count 77 | tool.execute(type: "prompts") 78 | 79 | expect(client.prompts.count).to eq(prompt_count + 1) 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/sample_with_stdio-native_with_gemini_gemini-2_0-flash_provides_information_about_the_sample_with_a_guard.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"You are a helpful assistant."}]},{"role":"user","parts":[{"text":"Hello, 9 | how are you?"}]}],"generationConfig":{}}' 10 | headers: 11 | User-Agent: 12 | - Faraday v2.14.0 13 | X-Goog-Api-Key: 14 | - "" 15 | Content-Type: 16 | - application/json 17 | Accept-Encoding: 18 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 19 | Accept: 20 | - "*/*" 21 | response: 22 | status: 23 | code: 200 24 | message: OK 25 | headers: 26 | Content-Type: 27 | - application/json; charset=UTF-8 28 | Vary: 29 | - Origin 30 | - Referer 31 | - X-Origin 32 | Date: 33 | - Mon, 10 Nov 2025 23:41:11 GMT 34 | Server: 35 | - scaffolding on HTTPServer2 36 | X-Xss-Protection: 37 | - '0' 38 | X-Frame-Options: 39 | - SAMEORIGIN 40 | X-Content-Type-Options: 41 | - nosniff 42 | Server-Timing: 43 | - gfet4t7; dur=586 44 | Alt-Svc: 45 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 46 | Transfer-Encoding: 47 | - chunked 48 | body: 49 | encoding: ASCII-8BIT 50 | string: | 51 | { 52 | "candidates": [ 53 | { 54 | "content": { 55 | "parts": [ 56 | { 57 | "text": "Hello! I am doing well, thank you for asking. How can I help you today?\n" 58 | } 59 | ], 60 | "role": "model" 61 | }, 62 | "finishReason": "STOP", 63 | "avgLogprobs": -0.048752468824386594 64 | } 65 | ], 66 | "usageMetadata": { 67 | "promptTokenCount": 12, 68 | "candidatesTokenCount": 20, 69 | "totalTokenCount": 32, 70 | "promptTokensDetails": [ 71 | { 72 | "modality": "TEXT", 73 | "tokenCount": 12 74 | } 75 | ], 76 | "candidatesTokensDetails": [ 77 | { 78 | "modality": "TEXT", 79 | "tokenCount": 20 80 | } 81 | ] 82 | }, 83 | "modelVersion": "gemini-2.0-flash", 84 | "responseId": "FngSaaqcIIia_uMPlrqU0AE" 85 | } 86 | recorded_at: Mon, 10 Nov 2025 23:41:11 GMT 87 | recorded_with: VCR 6.3.1 88 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/sample_with_stdio-native_client_can_call_a_block_to_determine_the_preferred_model_accessing_the_model_preferences.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"You are a helpful assistant."}]},{"role":"user","parts":[{"text":"Hello, 9 | how are you?"}]}],"generationConfig":{}}' 10 | headers: 11 | User-Agent: 12 | - Faraday v2.14.0 13 | X-Goog-Api-Key: 14 | - "" 15 | Content-Type: 16 | - application/json 17 | Accept-Encoding: 18 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 19 | Accept: 20 | - "*/*" 21 | response: 22 | status: 23 | code: 200 24 | message: OK 25 | headers: 26 | Content-Type: 27 | - application/json; charset=UTF-8 28 | Vary: 29 | - Origin 30 | - Referer 31 | - X-Origin 32 | Date: 33 | - Mon, 10 Nov 2025 23:41:03 GMT 34 | Server: 35 | - scaffolding on HTTPServer2 36 | X-Xss-Protection: 37 | - '0' 38 | X-Frame-Options: 39 | - SAMEORIGIN 40 | X-Content-Type-Options: 41 | - nosniff 42 | Server-Timing: 43 | - gfet4t7; dur=464 44 | Alt-Svc: 45 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 46 | Transfer-Encoding: 47 | - chunked 48 | body: 49 | encoding: ASCII-8BIT 50 | string: | 51 | { 52 | "candidates": [ 53 | { 54 | "content": { 55 | "parts": [ 56 | { 57 | "text": "Hello! I am doing well, thank you for asking. How can I help you today?\n" 58 | } 59 | ], 60 | "role": "model" 61 | }, 62 | "finishReason": "STOP", 63 | "avgLogprobs": -0.11083180904388427 64 | } 65 | ], 66 | "usageMetadata": { 67 | "promptTokenCount": 12, 68 | "candidatesTokenCount": 20, 69 | "totalTokenCount": 32, 70 | "promptTokensDetails": [ 71 | { 72 | "modality": "TEXT", 73 | "tokenCount": 12 74 | } 75 | ], 76 | "candidatesTokensDetails": [ 77 | { 78 | "modality": "TEXT", 79 | "tokenCount": 20 80 | } 81 | ] 82 | }, 83 | "modelVersion": "gemini-2.0-flash", 84 | "responseId": "DngSab3dKtWZ_uMPvJWH-Ao" 85 | } 86 | recorded_at: Mon, 10 Nov 2025 23:41:03 GMT 87 | recorded_with: VCR 6.3.1 88 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/with_stdio-native_with_gemini_gemini-2_0-flash_with_prompt_adds_prompt_to_the_chat_when_available.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"Hello, how are you? Can 9 | you also say Hello back?"}]},{"role":"user","parts":[{"text":"Please respond 10 | based on the prompt provided."}]}],"generationConfig":{}}' 11 | headers: 12 | User-Agent: 13 | - Faraday v2.14.0 14 | X-Goog-Api-Key: 15 | - "" 16 | Content-Type: 17 | - application/json 18 | Accept-Encoding: 19 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 20 | Accept: 21 | - "*/*" 22 | response: 23 | status: 24 | code: 200 25 | message: OK 26 | headers: 27 | Content-Type: 28 | - application/json; charset=UTF-8 29 | Vary: 30 | - Origin 31 | - Referer 32 | - X-Origin 33 | Date: 34 | - Mon, 10 Nov 2025 23:37:55 GMT 35 | Server: 36 | - scaffolding on HTTPServer2 37 | X-Xss-Protection: 38 | - '0' 39 | X-Frame-Options: 40 | - SAMEORIGIN 41 | X-Content-Type-Options: 42 | - nosniff 43 | Server-Timing: 44 | - gfet4t7; dur=507 45 | Alt-Svc: 46 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 47 | Transfer-Encoding: 48 | - chunked 49 | body: 50 | encoding: ASCII-8BIT 51 | string: | 52 | { 53 | "candidates": [ 54 | { 55 | "content": { 56 | "parts": [ 57 | { 58 | "text": "Hello! I'm doing well, thank you.\n" 59 | } 60 | ], 61 | "role": "model" 62 | }, 63 | "finishReason": "STOP", 64 | "avgLogprobs": -0.22922990719477335 65 | } 66 | ], 67 | "usageMetadata": { 68 | "promptTokenCount": 21, 69 | "candidatesTokenCount": 12, 70 | "totalTokenCount": 33, 71 | "promptTokensDetails": [ 72 | { 73 | "modality": "TEXT", 74 | "tokenCount": 21 75 | } 76 | ], 77 | "candidatesTokensDetails": [ 78 | { 79 | "modality": "TEXT", 80 | "tokenCount": 12 81 | } 82 | ] 83 | }, 84 | "modelVersion": "gemini-2.0-flash", 85 | "responseId": "U3cSaaeKHMmG-8YP5q2v6AQ" 86 | } 87 | recorded_at: Mon, 10 Nov 2025 23:37:55 GMT 88 | recorded_with: VCR 6.3.1 89 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/sample_with_streamable-native_with_gemini_gemini-2_0-flash_executes_a_chat_message_and_provides_information_to_the_server_without_a_guard.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"You are a helpful assistant."}]},{"role":"user","parts":[{"text":"Hello, 9 | how are you?"}]}],"generationConfig":{}}' 10 | headers: 11 | User-Agent: 12 | - Faraday v2.14.0 13 | X-Goog-Api-Key: 14 | - "" 15 | Content-Type: 16 | - application/json 17 | Accept-Encoding: 18 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 19 | Accept: 20 | - "*/*" 21 | response: 22 | status: 23 | code: 200 24 | message: OK 25 | headers: 26 | Content-Type: 27 | - application/json; charset=UTF-8 28 | Vary: 29 | - Origin 30 | - Referer 31 | - X-Origin 32 | Date: 33 | - Mon, 10 Nov 2025 23:41:18 GMT 34 | Server: 35 | - scaffolding on HTTPServer2 36 | X-Xss-Protection: 37 | - '0' 38 | X-Frame-Options: 39 | - SAMEORIGIN 40 | X-Content-Type-Options: 41 | - nosniff 42 | Server-Timing: 43 | - gfet4t7; dur=549 44 | Alt-Svc: 45 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 46 | Transfer-Encoding: 47 | - chunked 48 | body: 49 | encoding: ASCII-8BIT 50 | string: | 51 | { 52 | "candidates": [ 53 | { 54 | "content": { 55 | "parts": [ 56 | { 57 | "text": "I am doing well, thank you for asking! How can I help you today?\n" 58 | } 59 | ], 60 | "role": "model" 61 | }, 62 | "finishReason": "STOP", 63 | "avgLogprobs": -0.051865610811445445 64 | } 65 | ], 66 | "usageMetadata": { 67 | "promptTokenCount": 12, 68 | "candidatesTokenCount": 18, 69 | "totalTokenCount": 30, 70 | "promptTokensDetails": [ 71 | { 72 | "modality": "TEXT", 73 | "tokenCount": 12 74 | } 75 | ], 76 | "candidatesTokensDetails": [ 77 | { 78 | "modality": "TEXT", 79 | "tokenCount": 18 80 | } 81 | ] 82 | }, 83 | "modelVersion": "gemini-2.0-flash", 84 | "responseId": "HngSaZzjDryN_PUPz8iq-QM" 85 | } 86 | recorded_at: Mon, 10 Nov 2025 23:41:18 GMT 87 | recorded_with: VCR 6.3.1 88 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/with_streamable-native_with_gemini_gemini-2_0-flash_with_prompt_adds_prompt_to_the_chat_when_available.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"Hello, how are you? Can 9 | you also say Hello back?"}]},{"role":"user","parts":[{"text":"Please respond 10 | based on the prompt provided."}]}],"generationConfig":{}}' 11 | headers: 12 | User-Agent: 13 | - Faraday v2.14.0 14 | X-Goog-Api-Key: 15 | - "" 16 | Content-Type: 17 | - application/json 18 | Accept-Encoding: 19 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 20 | Accept: 21 | - "*/*" 22 | response: 23 | status: 24 | code: 200 25 | message: OK 26 | headers: 27 | Content-Type: 28 | - application/json; charset=UTF-8 29 | Vary: 30 | - Origin 31 | - Referer 32 | - X-Origin 33 | Date: 34 | - Mon, 10 Nov 2025 23:38:47 GMT 35 | Server: 36 | - scaffolding on HTTPServer2 37 | X-Xss-Protection: 38 | - '0' 39 | X-Frame-Options: 40 | - SAMEORIGIN 41 | X-Content-Type-Options: 42 | - nosniff 43 | Server-Timing: 44 | - gfet4t7; dur=468 45 | Alt-Svc: 46 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 47 | Transfer-Encoding: 48 | - chunked 49 | body: 50 | encoding: ASCII-8BIT 51 | string: | 52 | { 53 | "candidates": [ 54 | { 55 | "content": { 56 | "parts": [ 57 | { 58 | "text": "Hello! I am doing well, thank you for asking.\n" 59 | } 60 | ], 61 | "role": "model" 62 | }, 63 | "finishReason": "STOP", 64 | "avgLogprobs": -0.4526798908527081 65 | } 66 | ], 67 | "usageMetadata": { 68 | "promptTokenCount": 21, 69 | "candidatesTokenCount": 13, 70 | "totalTokenCount": 34, 71 | "promptTokensDetails": [ 72 | { 73 | "modality": "TEXT", 74 | "tokenCount": 21 75 | } 76 | ], 77 | "candidatesTokensDetails": [ 78 | { 79 | "modality": "TEXT", 80 | "tokenCount": 13 81 | } 82 | ] 83 | }, 84 | "modelVersion": "gemini-2.0-flash", 85 | "responseId": "h3cSad3HCcaM_PUPqOXt-QY" 86 | } 87 | recorded_at: Mon, 10 Nov 2025 23:38:47 GMT 88 | recorded_with: VCR 6.3.1 89 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/with_streamable-native_with_gemini_gemini-2_0-flash_with_resource_template_handles_template_arguments_correctly.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"greeting (greeting://Bob): 9 | A greeting resource\n\nHello, Bob!"}]},{"role":"user","parts":[{"text":"Use 10 | the greeting template to say hello to Bob"}]}],"generationConfig":{}}' 11 | headers: 12 | User-Agent: 13 | - Faraday v2.14.0 14 | X-Goog-Api-Key: 15 | - "" 16 | Content-Type: 17 | - application/json 18 | Accept-Encoding: 19 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 20 | Accept: 21 | - "*/*" 22 | response: 23 | status: 24 | code: 200 25 | message: OK 26 | headers: 27 | Content-Type: 28 | - application/json; charset=UTF-8 29 | Vary: 30 | - Origin 31 | - Referer 32 | - X-Origin 33 | Date: 34 | - Mon, 10 Nov 2025 23:37:20 GMT 35 | Server: 36 | - scaffolding on HTTPServer2 37 | X-Xss-Protection: 38 | - '0' 39 | X-Frame-Options: 40 | - SAMEORIGIN 41 | X-Content-Type-Options: 42 | - nosniff 43 | Server-Timing: 44 | - gfet4t7; dur=477 45 | Alt-Svc: 46 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 47 | Transfer-Encoding: 48 | - chunked 49 | body: 50 | encoding: ASCII-8BIT 51 | string: | 52 | { 53 | "candidates": [ 54 | { 55 | "content": { 56 | "parts": [ 57 | { 58 | "text": "```text\nHello, Bob!\n```\n" 59 | } 60 | ], 61 | "role": "model" 62 | }, 63 | "finishReason": "STOP", 64 | "avgLogprobs": -0.19193058013916015 65 | } 66 | ], 67 | "usageMetadata": { 68 | "promptTokenCount": 23, 69 | "candidatesTokenCount": 10, 70 | "totalTokenCount": 33, 71 | "promptTokensDetails": [ 72 | { 73 | "modality": "TEXT", 74 | "tokenCount": 23 75 | } 76 | ], 77 | "candidatesTokensDetails": [ 78 | { 79 | "modality": "TEXT", 80 | "tokenCount": 10 81 | } 82 | ] 83 | }, 84 | "modelVersion": "gemini-2.0-flash", 85 | "responseId": "MHcSacTVIOn3jrEP5Mre-QM" 86 | } 87 | recorded_at: Mon, 10 Nov 2025 23:37:20 GMT 88 | recorded_with: VCR 6.3.1 89 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/with_stdio-native_with_gemini_gemini-2_0-flash_with_resource_template_adds_resource_templates_to_the_chat_and_uses_them.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"greeting (greeting://Alice): 9 | A greeting resource\n\nHello, Alice!"}]},{"role":"user","parts":[{"text":"Can 10 | you greet Alice using the greeting template?"}]}],"generationConfig":{}}' 11 | headers: 12 | User-Agent: 13 | - Faraday v2.14.0 14 | X-Goog-Api-Key: 15 | - "" 16 | Content-Type: 17 | - application/json 18 | Accept-Encoding: 19 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 20 | Accept: 21 | - "*/*" 22 | response: 23 | status: 24 | code: 200 25 | message: OK 26 | headers: 27 | Content-Type: 28 | - application/json; charset=UTF-8 29 | Vary: 30 | - Origin 31 | - Referer 32 | - X-Origin 33 | Date: 34 | - Mon, 10 Nov 2025 23:37:10 GMT 35 | Server: 36 | - scaffolding on HTTPServer2 37 | X-Xss-Protection: 38 | - '0' 39 | X-Frame-Options: 40 | - SAMEORIGIN 41 | X-Content-Type-Options: 42 | - nosniff 43 | Server-Timing: 44 | - gfet4t7; dur=446 45 | Alt-Svc: 46 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 47 | Transfer-Encoding: 48 | - chunked 49 | body: 50 | encoding: ASCII-8BIT 51 | string: | 52 | { 53 | "candidates": [ 54 | { 55 | "content": { 56 | "parts": [ 57 | { 58 | "text": "Hello, Alice!\n" 59 | } 60 | ], 61 | "role": "model" 62 | }, 63 | "finishReason": "STOP", 64 | "avgLogprobs": -0.0077104568481445312 65 | } 66 | ], 67 | "usageMetadata": { 68 | "promptTokenCount": 23, 69 | "candidatesTokenCount": 5, 70 | "totalTokenCount": 28, 71 | "promptTokensDetails": [ 72 | { 73 | "modality": "TEXT", 74 | "tokenCount": 23 75 | } 76 | ], 77 | "candidatesTokensDetails": [ 78 | { 79 | "modality": "TEXT", 80 | "tokenCount": 5 81 | } 82 | ] 83 | }, 84 | "modelVersion": "gemini-2.0-flash", 85 | "responseId": "JncSaabvH9Cb-8YPmvmkmQo" 86 | } 87 | recorded_at: Mon, 10 Nov 2025 23:37:10 GMT 88 | recorded_with: VCR 6.3.1 89 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/with_streamable-native_with_gemini_gemini-2_0-flash_with_resource_template_adds_resource_templates_to_the_chat_and_uses_them.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"greeting (greeting://Alice): 9 | A greeting resource\n\nHello, Alice!"}]},{"role":"user","parts":[{"text":"Can 10 | you greet Alice using the greeting template?"}]}],"generationConfig":{}}' 11 | headers: 12 | User-Agent: 13 | - Faraday v2.14.0 14 | X-Goog-Api-Key: 15 | - "" 16 | Content-Type: 17 | - application/json 18 | Accept-Encoding: 19 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 20 | Accept: 21 | - "*/*" 22 | response: 23 | status: 24 | code: 200 25 | message: OK 26 | headers: 27 | Content-Type: 28 | - application/json; charset=UTF-8 29 | Vary: 30 | - Origin 31 | - Referer 32 | - X-Origin 33 | Date: 34 | - Mon, 10 Nov 2025 23:37:20 GMT 35 | Server: 36 | - scaffolding on HTTPServer2 37 | X-Xss-Protection: 38 | - '0' 39 | X-Frame-Options: 40 | - SAMEORIGIN 41 | X-Content-Type-Options: 42 | - nosniff 43 | Server-Timing: 44 | - gfet4t7; dur=438 45 | Alt-Svc: 46 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 47 | Transfer-Encoding: 48 | - chunked 49 | body: 50 | encoding: ASCII-8BIT 51 | string: | 52 | { 53 | "candidates": [ 54 | { 55 | "content": { 56 | "parts": [ 57 | { 58 | "text": "Hello, Alice!\n" 59 | } 60 | ], 61 | "role": "model" 62 | }, 63 | "finishReason": "STOP", 64 | "avgLogprobs": -0.0077104568481445312 65 | } 66 | ], 67 | "usageMetadata": { 68 | "promptTokenCount": 23, 69 | "candidatesTokenCount": 5, 70 | "totalTokenCount": 28, 71 | "promptTokensDetails": [ 72 | { 73 | "modality": "TEXT", 74 | "tokenCount": 23 75 | } 76 | ], 77 | "candidatesTokensDetails": [ 78 | { 79 | "modality": "TEXT", 80 | "tokenCount": 5 81 | } 82 | ] 83 | }, 84 | "modelVersion": "gemini-2.0-flash", 85 | "responseId": "MHcSaf-RAd_K-8YPxLOo4AY" 86 | } 87 | recorded_at: Mon, 10 Nov 2025 23:37:20 GMT 88 | recorded_with: VCR 6.3.1 89 | -------------------------------------------------------------------------------- /docs/server/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Server Interactions 4 | nav_order: 4 5 | description: "Understanding and working with MCP server capabilities" 6 | has_children: true 7 | permalink: /server/ 8 | --- 9 | 10 | # Server Interactions 11 | {: .no_toc } 12 | 13 | Server interactions encompass all the capabilities that MCP servers provide to enhance your applications. These are the features that servers expose to clients, enabling rich functionality through tools, resources, prompts, and real-time notifications. 14 | 15 | ## Overview 16 | 17 | MCP servers offer four main types of interactions: 18 | 19 | - **[Tools]({% link server/tools.md %})** - Server-side operations that can be executed by LLMs 20 | - **[Resources]({% link server/resources.md %})** - Static and dynamic data that can be included in conversations 21 | - **[Prompts]({% link server/prompts.md %})** - Pre-defined prompts with arguments for consistent interactions 22 | - **[Notifications]({% link server/notifications.md %})** - Real-time updates from servers about ongoing operations 23 | 24 | ## Table of contents 25 | 26 | 1. TOC 27 | {:toc} 28 | 29 | ## Server Capabilities 30 | 31 | ### Tools 32 | 33 | Execute server-side operations like reading files, making API calls, or running calculations. Tools are automatically converted into RubyLLM-compatible tools for seamless LLM integration. 34 | 35 | ### Resources 36 | 37 | Access structured data from files, databases, or dynamic sources. Resources can be static content or parameterized templates that generate content based on arguments. 38 | 39 | ### Prompts 40 | 41 | Use pre-defined prompts with arguments to ensure consistent interactions across your application. Prompts help standardize common queries and maintain formatting consistency. 42 | 43 | ### Notifications 44 | 45 | Handle real-time updates from servers including logging messages, progress tracking, and resource change notifications during long-running operations. 46 | 47 | ## Getting Started 48 | 49 | Explore each server interaction type to understand how to leverage MCP server capabilities: 50 | 51 | - **[Tools]({% link server/tools.md %})** - Execute server-side operations 52 | - **[Resources]({% link server/resources.md %})** - Access and include data in conversations 53 | - **[Prompts]({% link server/prompts.md %})** - Use predefined prompts with arguments 54 | - **[Notifications]({% link server/notifications.md %})** - Handle real-time server updates 55 | 56 | ## Next Steps 57 | 58 | Once you understand server interactions, explore: 59 | 60 | - **[Client Interactions]({% link client/index.md %})** - Client-side features like sampling and roots 61 | - **[Configuration]({% link configuration.md %})** - Advanced client configuration options 62 | - **[Rails Integration]({% link guides/rails-integration.md %})** - Using MCP with Rails applications 63 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/with_stdio-native_with_gemini_gemini-2_0-flash_with_resource_template_handles_template_arguments_correctly.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"greeting (greeting://Bob): 9 | A greeting resource\n\nHello, Bob!"}]},{"role":"user","parts":[{"text":"Use 10 | the greeting template to say hello to Bob"}]}],"generationConfig":{}}' 11 | headers: 12 | User-Agent: 13 | - Faraday v2.14.0 14 | X-Goog-Api-Key: 15 | - "" 16 | Content-Type: 17 | - application/json 18 | Accept-Encoding: 19 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 20 | Accept: 21 | - "*/*" 22 | response: 23 | status: 24 | code: 200 25 | message: OK 26 | headers: 27 | Content-Type: 28 | - application/json; charset=UTF-8 29 | Vary: 30 | - Origin 31 | - Referer 32 | - X-Origin 33 | Date: 34 | - Mon, 10 Nov 2025 23:37:11 GMT 35 | Server: 36 | - scaffolding on HTTPServer2 37 | X-Xss-Protection: 38 | - '0' 39 | X-Frame-Options: 40 | - SAMEORIGIN 41 | X-Content-Type-Options: 42 | - nosniff 43 | Server-Timing: 44 | - gfet4t7; dur=594 45 | Alt-Svc: 46 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 47 | Transfer-Encoding: 48 | - chunked 49 | body: 50 | encoding: ASCII-8BIT 51 | string: | 52 | { 53 | "candidates": [ 54 | { 55 | "content": { 56 | "parts": [ 57 | { 58 | "text": "Okay, using the provided greeting resource:\n\n**Hello, Bob!**\n" 59 | } 60 | ], 61 | "role": "model" 62 | }, 63 | "finishReason": "STOP", 64 | "avgLogprobs": -0.40445190668106079 65 | } 66 | ], 67 | "usageMetadata": { 68 | "promptTokenCount": 23, 69 | "candidatesTokenCount": 16, 70 | "totalTokenCount": 39, 71 | "promptTokensDetails": [ 72 | { 73 | "modality": "TEXT", 74 | "tokenCount": 23 75 | } 76 | ], 77 | "candidatesTokensDetails": [ 78 | { 79 | "modality": "TEXT", 80 | "tokenCount": 16 81 | } 82 | ] 83 | }, 84 | "modelVersion": "gemini-2.0-flash", 85 | "responseId": "J3cSaeuLBKmq-8YPmMS-mAw" 86 | } 87 | recorded_at: Mon, 10 Nov 2025 23:37:11 GMT 88 | recorded_with: VCR 6.3.1 89 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/resource_template.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "httpx" 4 | 5 | module RubyLLM 6 | module MCP 7 | class ResourceTemplate 8 | attr_reader :uri, :name, :description, :mime_type, :adapter, :template 9 | 10 | def initialize(adapter, resource) 11 | @adapter = adapter 12 | @uri = resource["uriTemplate"] 13 | @name = resource["name"] 14 | @description = resource["description"] 15 | @mime_type = resource["mimeType"] 16 | end 17 | 18 | def fetch_resource(arguments: {}) 19 | uri = apply_template(@uri, arguments) 20 | result = read_response(uri) 21 | content_response = result.value.dig("contents", 0) 22 | 23 | Resource.new(adapter, { 24 | "uri" => uri, 25 | "name" => "#{@name} (#{uri})", 26 | "description" => @description, 27 | "mimeType" => @mime_type, 28 | "content_response" => content_response 29 | }) 30 | end 31 | 32 | def to_content(arguments: {}) 33 | fetch_resource(arguments: arguments).to_content 34 | end 35 | 36 | def complete(argument, value, context: nil) 37 | if @adapter.capabilities.completion? 38 | result = @adapter.completion_resource(uri: @uri, argument: argument, value: value, context: context) 39 | result.raise_error! if result.error? 40 | 41 | response = result.value["completion"] 42 | 43 | Completion.new(argument: argument, values: response["values"], total: response["total"], 44 | has_more: response["hasMore"]) 45 | else 46 | message = "Completion is not available for this MCP server" 47 | raise Errors::Capabilities::CompletionNotAvailable.new(message: message) 48 | end 49 | end 50 | 51 | private 52 | 53 | def content_type 54 | if @content.key?("type") 55 | @content["type"] 56 | else 57 | "text" 58 | end 59 | end 60 | 61 | def read_response(uri) 62 | parsed = URI.parse(uri) 63 | case parsed.scheme 64 | when "http", "https" 65 | fetch_uri_content(uri) 66 | else # file:// or git:// 67 | @adapter.resource_read(uri: uri) 68 | end 69 | end 70 | 71 | def fetch_uri_content(uri) 72 | response = HTTPX.get(uri) 73 | { "result" => { "contents" => [{ "text" => response.body }] } } 74 | end 75 | 76 | def apply_template(uri, arguments) 77 | uri.gsub(/\{(\w+)\}/) do 78 | arguments[::Regexp.last_match(1).to_s] || 79 | arguments[::Regexp.last_match(1).to_sym] || 80 | "{#{::Regexp.last_match(1)}}" 81 | end 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/sample_with_streamable-native_client_can_call_a_block_to_determine_the_preferred_model_accessing_the_model_preferences.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"You are a helpful assistant."}]},{"role":"user","parts":[{"text":"Hello, 9 | how are you?"}]}],"generationConfig":{}}' 10 | headers: 11 | User-Agent: 12 | - Faraday v2.14.0 13 | X-Goog-Api-Key: 14 | - "" 15 | Content-Type: 16 | - application/json 17 | Accept-Encoding: 18 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 19 | Accept: 20 | - "*/*" 21 | response: 22 | status: 23 | code: 200 24 | message: OK 25 | headers: 26 | Content-Type: 27 | - application/json; charset=UTF-8 28 | Vary: 29 | - Origin 30 | - Referer 31 | - X-Origin 32 | Date: 33 | - Mon, 10 Nov 2025 23:41:13 GMT 34 | Server: 35 | - scaffolding on HTTPServer2 36 | X-Xss-Protection: 37 | - '0' 38 | X-Frame-Options: 39 | - SAMEORIGIN 40 | X-Content-Type-Options: 41 | - nosniff 42 | Server-Timing: 43 | - gfet4t7; dur=709 44 | Alt-Svc: 45 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 46 | Transfer-Encoding: 47 | - chunked 48 | body: 49 | encoding: ASCII-8BIT 50 | string: | 51 | { 52 | "candidates": [ 53 | { 54 | "content": { 55 | "parts": [ 56 | { 57 | "text": "Hello! As a large language model, I don't experience feelings like humans do, so I don't get \"good\" or \"bad\" days. However, I am functioning properly and ready to assist you. How can I help you today?\n" 58 | } 59 | ], 60 | "role": "model" 61 | }, 62 | "finishReason": "STOP", 63 | "avgLogprobs": -0.22422160742417821 64 | } 65 | ], 66 | "usageMetadata": { 67 | "promptTokenCount": 12, 68 | "candidatesTokenCount": 53, 69 | "totalTokenCount": 65, 70 | "promptTokensDetails": [ 71 | { 72 | "modality": "TEXT", 73 | "tokenCount": 12 74 | } 75 | ], 76 | "candidatesTokensDetails": [ 77 | { 78 | "modality": "TEXT", 79 | "tokenCount": 53 80 | } 81 | ] 82 | }, 83 | "modelVersion": "gemini-2.0-flash", 84 | "responseId": "GHgSacPuMuLh_uMPvbrt4QU" 85 | } 86 | recorded_at: Mon, 10 Nov 2025 23:41:13 GMT 87 | recorded_with: VCR 6.3.1 88 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/sample_with_streamable-native_with_gemini_gemini-2_0-flash_provides_information_about_the_sample_with_a_guard.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"You are a helpful assistant."}]},{"role":"user","parts":[{"text":"Hello, 9 | how are you?"}]}],"generationConfig":{}}' 10 | headers: 11 | User-Agent: 12 | - Faraday v2.14.0 13 | X-Goog-Api-Key: 14 | - "" 15 | Content-Type: 16 | - application/json 17 | Accept-Encoding: 18 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 19 | Accept: 20 | - "*/*" 21 | response: 22 | status: 23 | code: 200 24 | message: OK 25 | headers: 26 | Content-Type: 27 | - application/json; charset=UTF-8 28 | Vary: 29 | - Origin 30 | - Referer 31 | - X-Origin 32 | Date: 33 | - Mon, 10 Nov 2025 23:41:19 GMT 34 | Server: 35 | - scaffolding on HTTPServer2 36 | X-Xss-Protection: 37 | - '0' 38 | X-Frame-Options: 39 | - SAMEORIGIN 40 | X-Content-Type-Options: 41 | - nosniff 42 | Server-Timing: 43 | - gfet4t7; dur=698 44 | Alt-Svc: 45 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 46 | Transfer-Encoding: 47 | - chunked 48 | body: 49 | encoding: ASCII-8BIT 50 | string: | 51 | { 52 | "candidates": [ 53 | { 54 | "content": { 55 | "parts": [ 56 | { 57 | "text": "Hello! As a large language model, I don't experience feelings like humans do, so I can't say I'm \"good\" or \"bad\" in the same way. However, I'm functioning as intended and ready to assist you. How can I help you today?\n" 58 | } 59 | ], 60 | "role": "model" 61 | }, 62 | "finishReason": "STOP", 63 | "avgLogprobs": -0.264712568189277 64 | } 65 | ], 66 | "usageMetadata": { 67 | "promptTokenCount": 12, 68 | "candidatesTokenCount": 61, 69 | "totalTokenCount": 73, 70 | "promptTokensDetails": [ 71 | { 72 | "modality": "TEXT", 73 | "tokenCount": 12 74 | } 75 | ], 76 | "candidatesTokensDetails": [ 77 | { 78 | "modality": "TEXT", 79 | "tokenCount": 61 80 | } 81 | ] 82 | }, 83 | "modelVersion": "gemini-2.0-flash", 84 | "responseId": "HngSaZW8N9HY_uMP7cjM6QY" 85 | } 86 | recorded_at: Mon, 10 Nov 2025 23:41:19 GMT 87 | recorded_with: VCR 6.3.1 88 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/sample_with_stdio-native_with_gemini_gemini-2_0-flash_executes_a_chat_message_and_provides_information_to_the_server_without_a_guard.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"You are a helpful assistant."}]},{"role":"user","parts":[{"text":"Hello, 9 | how are you?"}]}],"generationConfig":{}}' 10 | headers: 11 | User-Agent: 12 | - Faraday v2.14.0 13 | X-Goog-Api-Key: 14 | - "" 15 | Content-Type: 16 | - application/json 17 | Accept-Encoding: 18 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 19 | Accept: 20 | - "*/*" 21 | response: 22 | status: 23 | code: 200 24 | message: OK 25 | headers: 26 | Content-Type: 27 | - application/json; charset=UTF-8 28 | Vary: 29 | - Origin 30 | - Referer 31 | - X-Origin 32 | Date: 33 | - Mon, 10 Nov 2025 23:41:10 GMT 34 | Server: 35 | - scaffolding on HTTPServer2 36 | X-Xss-Protection: 37 | - '0' 38 | X-Frame-Options: 39 | - SAMEORIGIN 40 | X-Content-Type-Options: 41 | - nosniff 42 | Server-Timing: 43 | - gfet4t7; dur=617 44 | Alt-Svc: 45 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 46 | Transfer-Encoding: 47 | - chunked 48 | body: 49 | encoding: ASCII-8BIT 50 | string: | 51 | { 52 | "candidates": [ 53 | { 54 | "content": { 55 | "parts": [ 56 | { 57 | "text": "Hello! As a large language model, I don't experience feelings like humans do. However, I am functioning optimally and ready to assist you with any questions or tasks you may have. How can I help you today?\n" 58 | } 59 | ], 60 | "role": "model" 61 | }, 62 | "finishReason": "STOP", 63 | "avgLogprobs": -0.1599848166756008 64 | } 65 | ], 66 | "usageMetadata": { 67 | "promptTokenCount": 12, 68 | "candidatesTokenCount": 46, 69 | "totalTokenCount": 58, 70 | "promptTokensDetails": [ 71 | { 72 | "modality": "TEXT", 73 | "tokenCount": 12 74 | } 75 | ], 76 | "candidatesTokensDetails": [ 77 | { 78 | "modality": "TEXT", 79 | "tokenCount": 46 80 | } 81 | ] 82 | }, 83 | "modelVersion": "gemini-2.0-flash", 84 | "responseId": "FXgSaayTLcaM_PUPqOXt-QY" 85 | } 86 | recorded_at: Mon, 10 Nov 2025 23:41:10 GMT 87 | recorded_with: VCR 6.3.1 88 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/auth/browser/callback_handler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | module Auth 6 | module Browser 7 | # Handles OAuth callback request processing 8 | # Extracts and validates OAuth parameters from callback requests 9 | class CallbackHandler 10 | attr_reader :callback_path, :logger 11 | 12 | def initialize(callback_path:, logger: nil) 13 | @callback_path = callback_path 14 | @logger = logger || MCP.logger 15 | end 16 | 17 | # Validate that the request path matches the expected callback path 18 | # @param path [String] request path 19 | # @return [Boolean] true if path is valid 20 | def valid_callback_path?(path) 21 | uri_path, = path.split("?", 2) 22 | uri_path == @callback_path 23 | end 24 | 25 | # Parse callback parameters from path 26 | # @param path [String] full request path with query string 27 | # @param http_server [HttpServer] HTTP server instance for parsing 28 | # @return [Hash] parsed parameters 29 | def parse_callback_params(path, http_server) 30 | _, query_string = path.split("?", 2) 31 | params = http_server.parse_query_params(query_string || "") 32 | @logger.debug("Callback params: #{params.keys.join(', ')}") 33 | params 34 | end 35 | 36 | # Extract OAuth parameters from parsed params 37 | # @param params [Hash] parsed query parameters 38 | # @return [Hash] OAuth parameters (code, state, error, error_description) 39 | def extract_oauth_params(params) 40 | { 41 | code: params["code"], 42 | state: params["state"], 43 | error: params["error"], 44 | error_description: params["error_description"] 45 | } 46 | end 47 | 48 | # Update result hash with OAuth parameters (thread-safe) 49 | # @param oauth_params [Hash] OAuth parameters 50 | # @param result [Hash] result container 51 | # @param mutex [Mutex] synchronization mutex 52 | # @param condition [ConditionVariable] wait condition 53 | def update_result_with_oauth_params(oauth_params, result, mutex, condition) 54 | mutex.synchronize do 55 | if oauth_params[:error] 56 | result[:error] = oauth_params[:error_description] || oauth_params[:error] 57 | elsif oauth_params[:code] && oauth_params[:state] 58 | result[:code] = oauth_params[:code] 59 | result[:state] = oauth_params[:state] 60 | else 61 | result[:error] = "Invalid callback: missing code or state parameter" 62 | end 63 | result[:completed] = true 64 | condition.signal 65 | end 66 | end 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/fixtures/typescript-mcp/src/tools/media.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { readResourceFile } from "../utils/file-utils.js"; 3 | import { z } from "zod"; 4 | 5 | export function setupMediaTools(server: McpServer) { 6 | server.tool( 7 | "get_jackhammer_audio", 8 | "Returns the jackhammer audio file as a base64-encoded WAV", 9 | {}, 10 | async () => { 11 | try { 12 | // Read the jackhammer audio file from resources 13 | const audioBuffer = await readResourceFile("jackhammer.wav"); 14 | 15 | // Convert to base64 16 | const base64Audio = audioBuffer.toString("base64"); 17 | 18 | return { 19 | content: [ 20 | { 21 | type: "audio", 22 | data: base64Audio, 23 | mimeType: "audio/wav", 24 | }, 25 | ], 26 | }; 27 | } catch (error) { 28 | const errorMessage = 29 | error instanceof Error ? error.message : String(error); 30 | return { 31 | content: [ 32 | { 33 | type: "text", 34 | text: `Error reading jackhammer audio file: ${errorMessage}`, 35 | }, 36 | ], 37 | }; 38 | } 39 | } 40 | ); 41 | 42 | server.tool( 43 | "get_dog_image", 44 | "Returns the dog image as a base64-encoded PNG", 45 | {}, 46 | async () => { 47 | try { 48 | // Read the dog image file from resources 49 | const imageBuffer = await readResourceFile("dog.png"); 50 | 51 | // Convert to base64 52 | const base64Image = imageBuffer.toString("base64"); 53 | 54 | return { 55 | content: [ 56 | { 57 | type: "image", 58 | data: base64Image, 59 | mimeType: "image/png", 60 | }, 61 | ], 62 | }; 63 | } catch (error) { 64 | const currentDir = process.cwd(); 65 | const errorMessage = 66 | error instanceof Error ? error.message : String(error); 67 | return { 68 | content: [ 69 | { 70 | type: "text", 71 | text: `Error reading dog image file: ${errorMessage} - current dir: ${currentDir}`, 72 | }, 73 | ], 74 | }; 75 | } 76 | } 77 | ); 78 | 79 | server.tool( 80 | "get_file_resource", 81 | "Returns a file resource reference", 82 | { 83 | filename: z.string().optional().default("example.txt"), 84 | }, 85 | async ({ filename }) => { 86 | return { 87 | content: [ 88 | { 89 | type: "resource", 90 | resource: { 91 | uri: `file://${filename}`, 92 | text: `This is the content of ${filename}`, 93 | mimeType: "text/plain", 94 | }, 95 | }, 96 | ], 97 | }; 98 | } 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /lib/ruby_llm/mcp/result.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module MCP 5 | class Notification 6 | attr_reader :type, :params 7 | 8 | def initialize(response) 9 | @type = response["method"] 10 | @params = response["params"] 11 | end 12 | end 13 | 14 | class Result 15 | attr_reader :response, :session_id, :id, :method, :result, :params, :error, :next_cursor 16 | 17 | REQUEST_METHODS = { 18 | ping: "ping", 19 | roots: "roots/list", 20 | sampling: "sampling/createMessage", 21 | elicitation: "elicitation/create" 22 | }.freeze 23 | 24 | def initialize(response, session_id: nil) 25 | @response = response 26 | @session_id = session_id 27 | @id = response["id"] 28 | @method = response["method"] 29 | @result = response["result"] || {} 30 | @params = response["params"] || {} 31 | @error = response["error"] || {} 32 | 33 | @result_is_error = response.dig("result", "isError") || false 34 | @next_cursor = response.dig("result", "nextCursor") 35 | 36 | # Track whether result/error keys exist (for JSON-RPC detection) 37 | @has_result = response.key?("result") 38 | @has_error = response.key?("error") 39 | end 40 | 41 | REQUEST_METHODS.each do |method_name, method_value| 42 | define_method "#{method_name}?" do 43 | @method == method_value 44 | end 45 | end 46 | 47 | alias value result 48 | 49 | def notification 50 | Notification.new(@response) 51 | end 52 | 53 | def to_error 54 | Error.new(@error) 55 | end 56 | 57 | def execution_error? 58 | @result_is_error 59 | end 60 | 61 | def raise_error! 62 | error = to_error 63 | message = "Response error: #{error}" 64 | raise Errors::ResponseError.new(message: message, error: error) 65 | end 66 | 67 | def matching_id?(request_id) 68 | @id&.to_s == request_id.to_s 69 | end 70 | 71 | def notification? 72 | @id.nil? && !@method.nil? 73 | end 74 | 75 | def next_cursor? 76 | !@next_cursor.nil? 77 | end 78 | 79 | def request? 80 | !@id.nil? && !@method.nil? 81 | end 82 | 83 | def response? 84 | !@id.nil? && @method.nil? && (@has_result || @has_error) 85 | end 86 | 87 | def success? 88 | @has_result 89 | end 90 | 91 | def tool_success? 92 | success? && !@result_is_error 93 | end 94 | 95 | def error? 96 | !@error.empty? 97 | end 98 | 99 | def to_s 100 | inspect 101 | end 102 | 103 | def inspect 104 | "#<#{self.class.name}:0x#{object_id.to_s(16)} id: #{@id}, result: #{@result}, error: #{@error}, method: #{@method}, params: #{@params}>" # rubocop:disable Layout/LineLength 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/with_stdio-mcp-sdk_with_gemini_gemini-2_0-flash_with_resource_adds_a_single_text_resource_to_the_chat.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"test.txt: A text file\n\nHello, 9 | this is a test file!\nThis content will be served by the MCP resource.\nYou 10 | can modify this file and it will be cached for 5 minutes.\n"}]},{"role":"user","parts":[{"text":"What 11 | does the test file contain?"}]}],"generationConfig":{}}' 12 | headers: 13 | User-Agent: 14 | - Faraday v2.14.0 15 | X-Goog-Api-Key: 16 | - "" 17 | Content-Type: 18 | - application/json 19 | Accept-Encoding: 20 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 21 | Accept: 22 | - "*/*" 23 | response: 24 | status: 25 | code: 200 26 | message: OK 27 | headers: 28 | Content-Type: 29 | - application/json; charset=UTF-8 30 | Vary: 31 | - Origin 32 | - Referer 33 | - X-Origin 34 | Date: 35 | - Mon, 10 Nov 2025 23:34:49 GMT 36 | Server: 37 | - scaffolding on HTTPServer2 38 | X-Xss-Protection: 39 | - '0' 40 | X-Frame-Options: 41 | - SAMEORIGIN 42 | X-Content-Type-Options: 43 | - nosniff 44 | Server-Timing: 45 | - gfet4t7; dur=663 46 | Alt-Svc: 47 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 48 | Transfer-Encoding: 49 | - chunked 50 | body: 51 | encoding: ASCII-8BIT 52 | string: | 53 | { 54 | "candidates": [ 55 | { 56 | "content": { 57 | "parts": [ 58 | { 59 | "text": "The test file contains the following text:\n\n```\nHello, this is a test file!\nThis content will be served by the MCP resource.\nYou can modify this file and it will be cached for 5 minutes.\n```\n" 60 | } 61 | ], 62 | "role": "model" 63 | }, 64 | "finishReason": "STOP", 65 | "avgLogprobs": -0.041909976881377549 66 | } 67 | ], 68 | "usageMetadata": { 69 | "promptTokenCount": 51, 70 | "candidatesTokenCount": 49, 71 | "totalTokenCount": 100, 72 | "promptTokensDetails": [ 73 | { 74 | "modality": "TEXT", 75 | "tokenCount": 51 76 | } 77 | ], 78 | "candidatesTokensDetails": [ 79 | { 80 | "modality": "TEXT", 81 | "tokenCount": 49 82 | } 83 | ] 84 | }, 85 | "modelVersion": "gemini-2.0-flash", 86 | "responseId": "mXYSadrkE8Xd_uMP_-SO4AU" 87 | } 88 | recorded_at: Mon, 10 Nov 2025 23:34:49 GMT 89 | recorded_with: VCR 6.3.1 90 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/with_stdio-native_with_gemini_gemini-2_0-flash_with_resource_adds_a_single_text_resource_to_the_chat.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"test.txt: A text file\n\nHello, 9 | this is a test file!\nThis content will be served by the MCP resource.\nYou 10 | can modify this file and it will be cached for 5 minutes.\n"}]},{"role":"user","parts":[{"text":"What 11 | does the test file contain?"}]}],"generationConfig":{}}' 12 | headers: 13 | User-Agent: 14 | - Faraday v2.14.0 15 | X-Goog-Api-Key: 16 | - "" 17 | Content-Type: 18 | - application/json 19 | Accept-Encoding: 20 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 21 | Accept: 22 | - "*/*" 23 | response: 24 | status: 25 | code: 200 26 | message: OK 27 | headers: 28 | Content-Type: 29 | - application/json; charset=UTF-8 30 | Vary: 31 | - Origin 32 | - Referer 33 | - X-Origin 34 | Date: 35 | - Mon, 10 Nov 2025 23:31:23 GMT 36 | Server: 37 | - scaffolding on HTTPServer2 38 | X-Xss-Protection: 39 | - '0' 40 | X-Frame-Options: 41 | - SAMEORIGIN 42 | X-Content-Type-Options: 43 | - nosniff 44 | Server-Timing: 45 | - gfet4t7; dur=615 46 | Alt-Svc: 47 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 48 | Transfer-Encoding: 49 | - chunked 50 | body: 51 | encoding: ASCII-8BIT 52 | string: | 53 | { 54 | "candidates": [ 55 | { 56 | "content": { 57 | "parts": [ 58 | { 59 | "text": "The `test.txt` file contains the following text:\n\n```\nHello, this is a test file!\nThis content will be served by the MCP resource.\nYou can modify this file and it will be cached for 5 minutes.\n```\n" 60 | } 61 | ], 62 | "role": "model" 63 | }, 64 | "finishReason": "STOP", 65 | "avgLogprobs": -0.0068269608155736381 66 | } 67 | ], 68 | "usageMetadata": { 69 | "promptTokenCount": 51, 70 | "candidatesTokenCount": 53, 71 | "totalTokenCount": 104, 72 | "promptTokensDetails": [ 73 | { 74 | "modality": "TEXT", 75 | "tokenCount": 51 76 | } 77 | ], 78 | "candidatesTokensDetails": [ 79 | { 80 | "modality": "TEXT", 81 | "tokenCount": 53 82 | } 83 | ] 84 | }, 85 | "modelVersion": "gemini-2.0-flash", 86 | "responseId": "ynUSaYDpJc6RjrEPkbGJiAc" 87 | } 88 | recorded_at: Mon, 10 Nov 2025 23:31:23 GMT 89 | recorded_with: VCR 6.3.1 90 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/with_streamable-native_with_gemini_gemini-2_0-flash_with_resource_adds_a_single_text_resource_to_the_chat.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"test.txt: A text file\n\nHello, 9 | this is a test file!\nThis content will be served by the MCP resource.\nYou 10 | can modify this file and it will be cached for 5 minutes.\n"}]},{"role":"user","parts":[{"text":"What 11 | does the test file contain?"}]}],"generationConfig":{}}' 12 | headers: 13 | User-Agent: 14 | - Faraday v2.14.0 15 | X-Goog-Api-Key: 16 | - "" 17 | Content-Type: 18 | - application/json 19 | Accept-Encoding: 20 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 21 | Accept: 22 | - "*/*" 23 | response: 24 | status: 25 | code: 200 26 | message: OK 27 | headers: 28 | Content-Type: 29 | - application/json; charset=UTF-8 30 | Vary: 31 | - Origin 32 | - Referer 33 | - X-Origin 34 | Date: 35 | - Mon, 10 Nov 2025 23:33:07 GMT 36 | Server: 37 | - scaffolding on HTTPServer2 38 | X-Xss-Protection: 39 | - '0' 40 | X-Frame-Options: 41 | - SAMEORIGIN 42 | X-Content-Type-Options: 43 | - nosniff 44 | Server-Timing: 45 | - gfet4t7; dur=710 46 | Alt-Svc: 47 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 48 | Transfer-Encoding: 49 | - chunked 50 | body: 51 | encoding: ASCII-8BIT 52 | string: | 53 | { 54 | "candidates": [ 55 | { 56 | "content": { 57 | "parts": [ 58 | { 59 | "text": "The `test.txt` file contains the following text:\n\n```\nHello, this is a test file!\nThis content will be served by the MCP resource.\nYou can modify this file and it will be cached for 5 minutes.\n```\n" 60 | } 61 | ], 62 | "role": "model" 63 | }, 64 | "finishReason": "STOP", 65 | "avgLogprobs": -0.042276467917100435 66 | } 67 | ], 68 | "usageMetadata": { 69 | "promptTokenCount": 51, 70 | "candidatesTokenCount": 53, 71 | "totalTokenCount": 104, 72 | "promptTokensDetails": [ 73 | { 74 | "modality": "TEXT", 75 | "tokenCount": 51 76 | } 77 | ], 78 | "candidatesTokensDetails": [ 79 | { 80 | "modality": "TEXT", 81 | "tokenCount": 53 82 | } 83 | ] 84 | }, 85 | "modelVersion": "gemini-2.0-flash", 86 | "responseId": "MnYSabXPOdHY_uMP7cjM6QY" 87 | } 88 | recorded_at: Mon, 10 Nov 2025 23:33:07 GMT 89 | recorded_with: VCR 6.3.1 90 | -------------------------------------------------------------------------------- /lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Creates per-<%= user_variable_name %> MCP clients with OAuth authentication 4 | class McpClient 5 | class NotAuthenticatedError < StandardError; end 6 | 7 | # Create MCP client for a specific <%= user_variable_name %> 8 | # @param <%= user_variable_name %> [<%= user_model_name %>] the <%= user_variable_name %> to create client for 9 | # @param server_url [String] MCP server URL (required) 10 | # @param scope [String] OAuth scopes to request 11 | # @return [RubyLLM::MCP::Client] configured MCP client 12 | # @raise [NotAuthenticatedError] if <%= user_variable_name %> hasn't connected to MCP server 13 | # @raise [ArgumentError] if server_url not provided 14 | def self.for(<%= user_variable_name %>, server_url:, scope: nil) 15 | raise ArgumentError, "server_url is required" unless server_url.present? 16 | scope ||= "mcp:read mcp:write" 17 | 18 | unless <%= user_variable_name %>.mcp_connected?(server_url) 19 | raise NotAuthenticatedError, 20 | "<%= user_model_name %> #{<%= user_variable_name %>.id} has not connected to MCP server: #{server_url}. " \ 21 | "Please complete OAuth flow first." 22 | end 23 | 24 | storage = <%= user_variable_name %>.mcp_token_storage(server_url) 25 | 26 | RubyLLM::MCP.client( 27 | name: "<%= user_variable_name %>-#{<%= user_variable_name %>.id}-#{server_url.hash.abs}", 28 | transport_type: determine_transport_type(server_url), 29 | config: { 30 | url: server_url, 31 | oauth: { 32 | storage: storage, 33 | scope: scope 34 | } 35 | } 36 | ) 37 | end 38 | 39 | # Create MCP client for <%= user_variable_name %>, returning nil if not authenticated 40 | # @param <%= user_variable_name %> [<%= user_model_name %>] the <%= user_variable_name %> 41 | # @param server_url [String] MCP server URL (required) 42 | # @return [RubyLLM::MCP::Client, nil] client or nil 43 | def self.for_with_fallback(<%= user_variable_name %>, server_url:) 44 | self.for(<%= user_variable_name %>, server_url: server_url) 45 | rescue NotAuthenticatedError, ArgumentError 46 | nil 47 | end 48 | 49 | # Check if <%= user_variable_name %> has valid MCP connection 50 | # @param <%= user_variable_name %> [<%= user_model_name %>] the <%= user_variable_name %> 51 | # @param server_url [String] MCP server URL (required) 52 | # @return [Boolean] true if <%= user_variable_name %> has valid token 53 | def self.connected?(<%= user_variable_name %>, server_url:) 54 | return false unless server_url.present? 55 | 56 | credential = <%= user_variable_name %>.mcp_oauth_credentials.find_by(server_url: server_url) 57 | credential&.valid_token? || false 58 | end 59 | 60 | # Determine transport type from URL 61 | # @param url [String] server URL 62 | # @return [Symbol] :sse or :streamable 63 | def self.determine_transport_type(url) 64 | url.include?("/sse") ? :sse : :streamable 65 | end 66 | 67 | private_class_method :determine_transport_type 68 | end 69 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/with_streamable-mcp-sdk_with_gemini_gemini-2_0-flash_with_resource_adds_a_single_text_resource_to_the_chat.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent 6 | body: 7 | encoding: UTF-8 8 | string: '{"contents":[{"role":"user","parts":[{"text":"test.txt: A text file\n\nHello, 9 | this is a test file!\nThis content will be served by the MCP resource.\nYou 10 | can modify this file and it will be cached for 5 minutes.\n"}]},{"role":"user","parts":[{"text":"What 11 | does the test file contain?"}]}],"generationConfig":{}}' 12 | headers: 13 | User-Agent: 14 | - Faraday v2.14.0 15 | X-Goog-Api-Key: 16 | - "" 17 | Content-Type: 18 | - application/json 19 | Accept-Encoding: 20 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 21 | Accept: 22 | - "*/*" 23 | response: 24 | status: 25 | code: 200 26 | message: OK 27 | headers: 28 | Content-Type: 29 | - application/json; charset=UTF-8 30 | Vary: 31 | - Origin 32 | - Referer 33 | - X-Origin 34 | Date: 35 | - Mon, 10 Nov 2025 23:36:33 GMT 36 | Server: 37 | - scaffolding on HTTPServer2 38 | X-Xss-Protection: 39 | - '0' 40 | X-Frame-Options: 41 | - SAMEORIGIN 42 | X-Content-Type-Options: 43 | - nosniff 44 | Server-Timing: 45 | - gfet4t7; dur=728 46 | Alt-Svc: 47 | - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 48 | Transfer-Encoding: 49 | - chunked 50 | body: 51 | encoding: ASCII-8BIT 52 | string: | 53 | { 54 | "candidates": [ 55 | { 56 | "content": { 57 | "parts": [ 58 | { 59 | "text": "The test file, named \"test.txt\", contains the following content:\n\n```\nHello, this is a test file!\nThis content will be served by the MCP resource.\nYou can modify this file and it will be cached for 5 minutes.\n```\n" 60 | } 61 | ], 62 | "role": "model" 63 | }, 64 | "finishReason": "STOP", 65 | "avgLogprobs": -0.049586551530020576 66 | } 67 | ], 68 | "usageMetadata": { 69 | "promptTokenCount": 51, 70 | "candidatesTokenCount": 56, 71 | "totalTokenCount": 107, 72 | "promptTokensDetails": [ 73 | { 74 | "modality": "TEXT", 75 | "tokenCount": 51 76 | } 77 | ], 78 | "candidatesTokensDetails": [ 79 | { 80 | "modality": "TEXT", 81 | "tokenCount": 56 82 | } 83 | ] 84 | }, 85 | "modelVersion": "gemini-2.0-flash", 86 | "responseId": "AHcSadj1F_6C-8YPxuCwmQU" 87 | } 88 | recorded_at: Mon, 10 Nov 2025 23:36:33 GMT 89 | recorded_with: VCR 6.3.1 90 | -------------------------------------------------------------------------------- /spec/fixtures/pagination-server/src/tools/index.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; 3 | import { z } from "zod"; 4 | 5 | export function setupTools(server: McpServer) { 6 | // Tool 1: Add Numbers - will appear on page 1 7 | server.tool( 8 | "add_numbers", 9 | "Add two numbers together", 10 | { 11 | a: z.number().describe("First number"), 12 | b: z.number().describe("Second number"), 13 | }, 14 | async ({ a, b }) => { 15 | const result = a + b; 16 | return { 17 | content: [ 18 | { 19 | type: "text", 20 | text: `The sum of ${a} and ${b} is ${result}`, 21 | }, 22 | ], 23 | }; 24 | } 25 | ); 26 | 27 | // Tool 2: Multiply Numbers - will appear on page 2 28 | server.tool( 29 | "multiply_numbers", 30 | "Multiply two numbers together", 31 | { 32 | a: z.number().describe("First number"), 33 | b: z.number().describe("Second number"), 34 | }, 35 | async ({ a, b }) => { 36 | const result = a * b; 37 | return { 38 | content: [ 39 | { 40 | type: "text", 41 | text: `The product of ${a} and ${b} is ${result}`, 42 | }, 43 | ], 44 | }; 45 | } 46 | ); 47 | 48 | // Override the default tools/list handler to implement pagination 49 | const rawServer = server.server; 50 | rawServer.setRequestHandler(ListToolsRequestSchema, async (request) => { 51 | const cursor = request.params?.cursor; 52 | const tools = [ 53 | { 54 | name: "add_numbers", 55 | description: "Add two numbers together", 56 | inputSchema: { 57 | type: "object", 58 | properties: { 59 | a: { type: "number", description: "First number" }, 60 | b: { type: "number", description: "Second number" }, 61 | }, 62 | required: ["a", "b"], 63 | }, 64 | }, 65 | { 66 | name: "multiply_numbers", 67 | description: "Multiply two numbers together", 68 | inputSchema: { 69 | type: "object", 70 | properties: { 71 | a: { type: "number", description: "First number" }, 72 | b: { type: "number", description: "Second number" }, 73 | }, 74 | required: ["a", "b"], 75 | }, 76 | }, 77 | ]; 78 | 79 | // Pagination logic: 1 tool per page 80 | if (!cursor) { 81 | // Page 1: Return first tool 82 | return { 83 | tools: [tools[0]], 84 | nextCursor: "page_2", 85 | }; 86 | } else if (cursor === "page_2") { 87 | // Page 2: Return second tool 88 | return { 89 | tools: [tools[1]], 90 | // No nextCursor - this is the last page 91 | }; 92 | } else { 93 | // Invalid cursor or beyond available pages 94 | return { 95 | tools: [], 96 | }; 97 | } 98 | }); 99 | } 100 | --------------------------------------------------------------------------------