Skip to content

Support RubyLLM 2.0 - #159

Closed
NielsKSchjoedt wants to merge 1 commit into
patvice:mainfrom
NielsKSchjoedt:support-ruby-llm-2
Closed

NielsKSchjoedt wants to merge 1 commit into
patvice:mainfrom
NielsKSchjoedt:support-ruby-llm-2

Conversation

@NielsKSchjoedt

Copy link
Copy Markdown

Closes #158.

RubyLLM 2.0.0 shipped on 2026-09-18. The ~> 1.9 pin means an application that has upgraded cannot use ruby_llm-mcp at all — Bundler simply refuses to resolve. This ports the gem.

It isn't a pin bump: 2.0 removes things the gem builds on.

Library changes

RubyLLM::Content is gone. It existed because a message's content could be either a String or a text-plus-files object; 2.0 settled that by making Message#content a String and giving files their own attachments: argument.

MCP still returns text and files together in one payload, so MCP::Content stays as a value object — it already overrode everything it inherited except attachment processing, and RubyLLM::Attachment.wrap does exactly that job now. The pair is split at the boundary by a new #to_message_arguments, which keeps all five Message.new call sites on one shape rather than spreading the split across prompt.rb, resource.rb, sample.rb and sampling_actions.rb.

Message.new no longer accepts a content object. 2.0 raises ArgumentError unless content is a String, so this would have failed loudly on every prompt, resource and sampling path — not silently.

params_schema → parameters_schema on RubyLLM::Tool. MCP::Tool defines the new name and keeps the old one as an alias, since it's part of this gem's own surface (Tool#to_h) and callers may read it directly.

MCP::Attachment needed no change — RubyLLM::Attachment survives, Attachment.wrap passes subclasses through untouched, and the inherited #type still resolves through the overridden #mime_type.

On the constraint

I used >= 2.0, < 3 rather than a range covering both lines. Supporting 1.x too would mean shimming a class 2.0 deleted, which seemed worse than a major version of this gem tracking the major version of RubyLLM. Easy to change if you'd rather.

Spec changes are 2.0 behaviour, not this port

  • Tool DSL: param / desc: → parameter / description:.
  • Chat#with_tool (singular) was removed; specs use with_tools.
  • A bare model id now resolves through the registry, and claude-sonnet-4 resolves only to vertexai, which the suite doesn't configure. The provider was already known at each call site, so the new ChatBuilder passes it explicitly.
  • gemini-2.0-flash has been retired from the registry. Every request here is replayed from a cassette, so ChatBuilder skips the lookup — which also stops a future retirement from breaking the suite.
  • OpenAI defaults to the Responses API in 2.0 while the committed cassettes were recorded against Chat Completions, so the protocol is pinned where a cassette is replayed.

Results

1899 examples, 14 failures. RuboCop clean across 240 files. Baseline on main with ruby_llm 1.16 is 1899/0, so that's the comparison.

The 14 remaining failures need your API keys

They're all in sample_spec.rb, and they are cassette problems rather than code problems. Sample#chat builds a chat from a model name chosen by the server, so unlike every other call site there's no provider to pass:

Model the fixture server asks for What 2.0 does
claude-sonnet-4 ConfigurationError — registry lists it only under vertexai
gemini-2.0-flash ModelNotFoundError — retired from the registry
gpt-4.1 builds fine, then misses the cassette (Responses vs Chat Completions)

Fixing them needs the sampling cassettes re-recorded (VCR_REFRESH=true) against models that exist in the 2.0 registry — which needs real provider credentials I don't have. I'd rather leave them visibly red than mark them pending and have the gap disappear; say the word if you'd prefer pending with a reason so CI goes green.

Worth a separate conversation: Sample#chat gives the host no way to influence how a server-supplied model name becomes a chat (provider, protocol). That's more exposed under 2.0's stricter resolution. I didn't want to design an API for it uninvited, but I'm happy to if you think it's the right direction.

Notes on verification

I ran the suite as CI does (CI=true, so record: :none and no live HTTP). Worth flagging for anyone else working on this: the default local mode is :new_episodes with allow_http_connections_when_no_cassette, and because 2.0 hits a new OpenAI endpoint, a plain local rspec appends real 401 responses into 71 committed cassettes. I caught and reverted that; no fixtures are touched in this PR.

bun install also rewrites spec/fixtures/*/bun.lock; those are reverted too.

RubyLLM 2.0.0 was released on 2026-09-18. The `~> 1.9` pin keeps this gem off
it, so an application that has upgraded cannot use ruby_llm-mcp at all —
Bundler refuses to resolve. Issue patvice#158 asks for this.

Unlike a pin bump, 2.0 removes things this gem builds on.

RubyLLM::Content is gone. It existed because a message's content could be
either a String or a text-plus-files object; 2.0 settled that by making
Message#content a String and giving files their own `attachments:` argument.
MCP still returns text and files together in one payload, so MCP::Content
stays as a value object — it already overrode everything it inherited except
attachment processing, which RubyLLM::Attachment.wrap now does — and the pair
is split at the boundary by #to_message_arguments. That keeps the five
Message.new call sites on one shape instead of spreading the split around.

Message.new no longer accepts a content object at all: 2.0 raises
ArgumentError unless content is a String, so this would have failed loudly on
every prompt, resource and sampling path.

RubyLLM::Tool renamed the schema hook params_schema -> parameters_schema.
MCP::Tool now defines the new name and keeps the old one as an alias, since
it is part of this gem's own surface (see Tool#to_h) and callers may read it.

MCP::Attachment needed no change: RubyLLM::Attachment survives, Attachment.wrap
passes subclasses through untouched, and the inherited #type still resolves
through the overridden #mime_type.

The constraint is `>= 2.0, < 3` rather than a range covering both lines.
Supporting 1.x as well would mean shimming a deleted class, which seemed worse
than a major version of this gem tracking the major version of RubyLLM. Happy
to do it the other way if you'd prefer.

Spec changes are consequences of 2.0 behaviour, not of this port:

- Tool DSL: `param`/`desc:` -> `parameter`/`description:`.
- Chat#with_tool (singular) was removed; specs use with_tools.
- A bare model id now resolves through the registry, and "claude-sonnet-4"
  resolves only to vertexai, which the suite does not configure. The provider
  was already known at each call site, so ChatBuilder passes it explicitly.
- "gemini-2.0-flash" has been retired from the registry. Every request here is
  replayed from a cassette, so ChatBuilder skips the lookup, which also stops a
  future retirement from breaking the suite.
- OpenAI defaults to the Responses API in 2.0 while the committed cassettes were
  recorded against Chat Completions, so the protocol is pinned where a cassette
  is replayed.

1899 examples, 14 failures, rubocop clean. The 14 are all in sample_spec and
all need cassettes re-recorded with real API keys — see the PR description.
Comment thread lib/ruby_llm/mcp/tool.rb
Comment on lines +53 to +55
# RubyLLM 2.0 renamed the Tool schema hook from params_schema to
# parameters_schema. The old name stays as an alias: it is part of this
# gem's own surface (see #to_h) and callers may read it directly.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth major versioning the gem to remove the deprecated method name, since Ruby LLM will make you remove it anyway?

@newstler newstler left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went through this against the ruby_llm 2.0.0 source, running the changed paths rather than reading the diff alone.

The port holds up where it looks. I verified Attachment.wrap is behaviourally equivalent to the old process_attachments special-casing across every input shape — nil, [], an array of MCP::Attachment, a mixed array, a bare attachment, a String path, a Hash, blank entries — including object identity on passthrough, and the blank-dropping predicate is character-for-character the old blank_attachment_entry?. Message.new(content: nil, attachments: [...]) genuinely renders better under 2.0 than it did under 1.x. The five Message.new conversions are consistent, and centralising the split in to_message_arguments is the right shape.

Three things would be nice to resolved before it goes in, left inline:

  1. Tool#execute returns an MCP::Content, and 2.0 reduces that to "". MCP tools returning images or audio now send nothing to the model, on every provider, with no error. to_message_arguments fixed the five inbound call sites; this is the sixth, outbound.
  2. The format_content narrowing makes a branch unreachable that was already raising NoMethodError whenever it fired. Multimodal sampling replies silently become text.
  3. The 14 failures look like the sampling path not being ported rather than cassette drift — five of the seven distinct failures raise before any HTTP, and the same ConfigurationError is reachable by a user in production.

Two more that don't anchor to changed lines:

MCP::Attachment#content returns base64 where 2.0's Attachment#content contract is raw bytes (encoded is the base64 accessor). 2.0 added Protocol#upload_large_attachment?, which auto-uploads attachments above a per-provider threshold via StringIO.new(attachment.content) and sizes them with attachment.content.bytesize (protocols/files.rb:88,115). A large MCP image on Gemini or Anthropic would upload base64 text as though it were the file bytes. The inline path is fine — type => :image and for_llm => data:image/png;base64,... both check out — so this only bites above the threshold. Pre-existing in shape, newly reachable in 2.0; a tracking issue would do.

The cassette footgun you flagged is real, and larger than stated. One spec run without CI=true appended 70 lines to a committed cassette here, including Authorization: Bearer test — filter_sensitive_data interpolates ENV.fetch("OPENAI_API_KEY", nil), so the placeholder isn't redacted when the variable is unset. It also isn't 71 files: all 236 cassettes carry an external provider host. This PR makes it much likelier to fire, since 14 specs now miss their cassettes on the default local path. It's ~3 lines in a file this PR already touches:

record_mode = ENV["VCR_REFRESH"] == "true" ? :all : :none
config.allow_http_connections_when_no_cassette = false

Smaller things. attr_reader :content in content.rb reads an ivar that is never assigned — 1.x's superclass never set it either, so it isn't a regression, but this PR hand-writes the class and that's the moment to drop it. prompt.rb's create_content_for_message has no else, so an unrecognised MCP content type now raises NoMethodError on nil.to_message_arguments instead of producing an empty message; sampling_actions.rb:67 already has the else raise Errors::InvalidFormatError pattern to copy. And spec/fixtures/typescript-mcp has a committed bun.lock that nothing in the spec bootstrap installs — without node_modules, bun resolves a newer SDK that rejects the fixture's malformed_tool and the server dies at boot, taking most of the suite with it.

# a String and giving files their own +attachments+ argument. MCP still
# returns the two together in one payload, so the pairing stays here and is
# split at the boundary — see #to_message_arguments.
class Content

@newstler newstler Sep 22, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tool results carrying an image or audio now reach the model as an empty string.

Dropping < RubyLLM::Content is right for the Message boundary, but it also changes a contract on the way back out. Tool#execute still returns an MCP::Content, and 2.0 routes tool results through RubyLLM::Tool.split_result, which handles Attachment and Array and sends everything else to result.to_s. MCP::Content#to_s is text.to_s, and image/audio content is built with text: nil.

Against ruby_llm 2.0.0:

split_result(image MCP::Content)  => ["", []]
split_result(blob  MCP::Content)  => ["dog.png: a dog", []]
split_result(["txt", attachment]) => ["txt", [#<RubyLLM::MCP::Attachment mime_type: "image/png">]]

Under 1.x, content_like? matched the subclass and the whole object passed through, so the provider serialised the media. Now get_dog_image yields "" with no error on any provider — and the #to_s comment a few lines below still describes this class as the mechanism that makes image tool results work.

tool_spec.rb:87-106 stays green because it asserts on execute's return value, which is still a well-formed MCP::Content. The break is at the RubyLLM boundary, which no spec crosses.

#execute is documented public surface — docs/server/resources.md:465,489,532 tells users to check result.is_a?(RubyLLM::MCP::Content) — so I'd leave it alone and convert at #call, which is what Chat actually invokes:

# content.rb
def to_tool_result
  attachments.any? ? [text.to_s, *attachments] : text.to_s
end

# tool.rb
def call(...)
  result = super
  result.is_a?(MCP::Content) ? result.to_tool_result : result
end

The bare-String return matters: split_array_result JSON-encodes an array containing no Attachment, so ["hello"] would reach the model literally.

If it's useful, a spec driving get_dog_image through a real Chat and asserting the tool message carries an attachment would pin it — the fixture tool already exists in spec/fixtures/typescript-mcp/src/tools/media.ts.


def format_content(content)
if content.is_a?(RubyLLM::Content)
if content.is_a?(RubyLLM::MCP::Content)

@newstler newstler Sep 22, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is now unreachable, and it was raising whenever it did run.

format_content is only ever called as format_content(message.content). In 2.0, Message#normalize_content accepts only String or nil and raises otherwise, so message.content can never hold an MCP::Content — narrowing from RubyLLM::Content makes the branch dead rather than type-correct. Multimodal sampling replies now fall through to else and serialise as { type: "text" }, with text: nil for attachment-only content.

Separately, the branch body can't work as written. content.text.none? calls none? on a String, which isn't Enumerable:

"".none?  => NoMethodError: undefined method 'none?' for an instance of String
nil.none? => NoMethodError: undefined method 'none?' for nil

That's pre-existing, and the rescue StandardError in Sample#execute swallowed it — which is probably why nobody noticed image sampling never worked. This change removes the last chance of it surfacing.

Under 2.0 the media rides on message.attachments, so either read that:

def format_content(message)
  attachment = message.attachments.first
  if message.content.to_s.empty? && attachment
    { type: attachment.type, data: attachment.content, mimeType: attachment.mime_type }
  else
    { type: "text", text: message.content }
  end
end

or drop the branch and note that sampling is text-only under 2.0. Either reads better than a conditional that can't fire, I think.

The only coverage is messages_spec.rb:683, which passes double("Content", text: "Hello, world!"). That matches neither the old guard nor the new one, lands in else, and serialises through Object#to_json — so the schema assertion passes on the double's inspect string. It would stay green with this line changed to anything.

content = create_content_for_message(message["content"])

RubyLLM::Message.new({ role: role, content: content })
RubyLLM::Message.new({ role: role, **content.to_message_arguments })

@newstler newstler Sep 22, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 14 failures look like a code gap rather than a cassette gap, and they should be fixable without credentials.

Running sample_spec.rb with RUBY_LLM_DEBUG=1:

Failure Error HTTP reached?
claude-sonnet-4 x2 ConfigurationError: VertexAI provider is not configured no
gemini-2.0-flash x3 ModelNotFoundError no
gpt-4.1 x2 VCR miss: /v1/responses vs cassette /v1/chat/completions yes

Seven distinct failures across two sampling-capable native transports is 14, which matches your count. Five of the seven raise before a request is made, so no cassette can affect them.

The cause is Sample#chat at line 163: RubyLLM::Chat.new(model: model) — no provider:, no protocol:, no assume_model_exists:. It's the one Chat.new that didn't get the ChatBuilder treatment, and it's library code rather than spec code. preferred_model takes either a bare String or a block receiving the server's model_preferences, so a user on 2.0 who sets "claude-sonnet-4" hits that same ConfigurationError in production, as does a host that returns hints.first.

This looks like the design gap you raised at the end of the description. From out here it reads less like a separate conversation and more like what the red suite is already pointing at. Giving config.sampling a provider (and optionally protocol / assume_model_exists) and threading it through Sample#chat turns the specs green and fixes the runtime path in the same change. For gpt-4.1, pinning protocol: :chat_completions on the sampling chat reuses the existing cassette as-is.

One thought on the pending option you offered: if the above holds, it would file a live gap as a fixture annoyance, which might make it harder to find later.

Comment thread lib/ruby_llm/mcp/tool.rb
# RubyLLM 2.0 renamed the Tool schema hook from params_schema to
# parameters_schema. The old name stays as an alias: it is part of this
# gem's own surface (see #to_h) and callers may read it directly.
def parameters_schema

@newstler newstler Sep 22, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rename looks like the load-bearing part of this hunk, and I don't think anything pins it.

parameters_schema is the right hook — 2.0's providers all call it (protocols/chat_completions/tools.rb:21, anthropic/tools.rb:51, gemini/tools.rb:121, cohere/tools.rb:36, converse/chat.rb:338), and defining it here correctly overrides the base implementation that would otherwise infer a schema from execute(**params).

But grep -rn "parameters_schema" spec/ returns nothing — all ~45 assertions in tool_spec.rb read the alias. Delete def parameters_schema, keep only params_schema, and the suite stays green while the gem stops working with RubyLLM 2.0.

it "exposes the schema under the name RubyLLM 2.0 calls" do
  expect(tool.parameters_schema).to eq(tool_response["inputSchema"])
end

Keeping params_schema as an alias is right, and for a reason beyond compatibility: #to_h emits it as a key, and the OAuth generator view reads it five times (lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb:57,61,66,84,87). The generator template could probably point at the new name though, since it has no compatibility obligation to its own gem.

provider: model_config[:provider],
assume_model_exists: true
}
options[:protocol] = :chat_completions if model_config[:provider] == :openai

@newstler newstler Sep 22, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these pins leave the specs and the library running different configurations.

provider:, assume_model_exists: true and the protocol pin are a reasonable way to get a cassette suite running, and the header comment is honest about the trade. The catch is that sample.rb:164 and sampling_actions.rb:36 build their chats from bare 2.0 defaults — so the path under test and the path that ships disagree, and 5 of the 14 failures are precisely the error assume_model_exists suppresses here.

Two coverage holes worth naming in the description if the pins stay:

  • 82 cassettes hit api.openai.com/v1/chat/completions and none hit /v1/responses, which is OpenAI's default in 2.0. The two protocols emit different tool definitions — chat_completions/tools.rb:31-38 nests under function:, responses/tools.rb:11-19 is flat — so MCP tool schemas go untested under the protocol users will actually get.
  • There's no match_requests_on anywhere in spec/, so VCR matches on method and URI and request bodies are never compared. Combined with the pin, the suite asserts very little about what goes on the wire.

If COMPLEX_FUNCTION_MODELS moved to models that still resolve in the 2.0 registry, assume_model_exists could come back off and that check would be doing work again.

Comment thread ruby_llm-mcp.gemspec
spec.add_dependency "json-schema", ">= 5.0", "< 7"
spec.add_dependency "json_schemer", "~> 2.4"
spec.add_dependency "ruby_llm", "~> 1.9"
spec.add_dependency "ruby_llm", ">= 2.0", "< 3"

@newstler newstler Sep 22, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping 1.x seems right to me; possibly worth pairing with a major bump.

Agreed on >= 2.0, < 3 over a compatibility shim. Supporting both lines would mean branching on defined?(RubyLLM::Content), which makes MCP::Content's ancestry depend on the host app's lockfile, plus two sets of cassettes for a suite where cassettes are already the bottleneck.

version.rb is still 1.0.1 — though release numbering may well be @patvice's call rather than something for this branch. This changes MCP::Content's ancestry, moves the dependency floor, and — once the tool-result path is sorted — changes tool-result behaviour. The repo already has docs/guides/upgrading-0.6-to-0.7.md through -0.8-to-1.0.md, so 2.0.0 plus an upgrading-1.0-to-2.0.md would follow the existing convention. Worth stating the ruby_llm floor in the README too — the gemspec isn't where people look when Bundler refuses to resolve.

chat.with_tool -> with_tools belongs in those notes as well. RubyLLM::Chat#with_tool is gone in 2.0, so it's a downstream break for this gem's users, and right now it's only visible as a spec edit.

One note on the constraint itself: content.rb calls RubyLLM::Attachment.wrap, which upstream marks # :nodoc: (attachment.rb:48). < 3 would accept a 2.x minor moving it out from under you.

@newstler

newstler commented Sep 22, 2026 •

Copy link
Copy Markdown

Rereading my review above — rather than leave it as a pile of suggestions, I've put the concrete parts up as NielsKSchjoedt#1 against this branch, so it should merge into your PR and keep your commits. Five small commits, take or drop whichever.

The one I'd most like you to sanity-check is the tool-result path: Tool#execute returns an MCP::Content, and 2.0's split_result reduces that to to_s, so a tool returning an image sends the model an empty string. The conversion goes in #call rather than #execute so your documented return type stays put.

Left the version bump and the upgrade guide out — that's @patvice's call on release numbering rather than something to slip into your branch. Thanks for doing this port; the Attachment.wrap reasoning in your description held up everywhere I checked it.

@NielsKSchjoedt

Copy link
Copy Markdown
Author

@newstler — thank you. This is one of the most careful reviews I've had on anything, and you're right on the points that matter:

  • Outbound tool results. Tool#execute returning an MCP::Content that 2.0 reduces to "" was a real, silent bug — images and audio would reach the model as nothing. I fixed the five inbound Message.new sites and missed the sixth, outbound one.
  • The 14 failures. I described them as cassette drift. You're right that most fail before any HTTP call: the sampling path wasn't actually ported, and the ConfigurationError is reachable in production. That description was wrong.
  • The VCR footgun. Worse than I said — it records Authorization: Bearer test, because filter_sensitive_data interpolates nil when the variable is unset, and every cassette is exposed, not only the 71 my run happened to touch.

I'm closing this in favour of #161, which covers more ground: RubyLLM 1.9 through 2.x rather than 2.x only (much easier to release), multimodal tool results, and a CI matrix.

One thing #161 doesn't cover yet, flagged here so it isn't lost with this PR: MCP sampling still raises on RubyLLM 2.0. Sample#create_message and SamplingActions pass an MCP::Content to RubyLLM::Message.new, which 2.0 rejects. Against #161's head (a8cc59c) on RubyLLM 2.0.0:

ArgumentError: Message content must be a String, got RubyLLM::MCP::Content.
Pass files via attachments: and structured data as JSON.

This PR's to_message_arguments change covers those Message.new sites, and your NielsKSchjoedt#1 builds the rest of the sampling path on top of it — attachments from Message#attachments, and a provider for server-chosen models. The VCR record-mode change there is worth keeping whichever port lands.

@crmne — flagging the sampling gap so it reaches you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support for ruby_llm 2.0?

3 participants