├── .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 |
--------------------------------------------------------------------------------