Repository navigation
Support RubyLLM 2.0 - #159
NielsKSchjoedt wants to merge 1 commit into
Conversation
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.
| # 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. |
There was a problem hiding this comment.
Is it worth major versioning the gem to remove the deprecated method name, since Ruby LLM will make you remove it anyway?
There was a problem hiding this comment.
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:
Tool#executereturns anMCP::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_argumentsfixed the five inbound call sites; this is the sixth, outbound.- The
format_contentnarrowing makes a branch unreachable that was already raisingNoMethodErrorwhenever it fired. Multimodal sampling replies silently become text. - 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
ConfigurationErroris 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 = falseSmaller 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 |
There was a problem hiding this comment.
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
endThe 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) |
There was a problem hiding this comment.
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
endor 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 }) |
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
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"])
endKeeping 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 |
There was a problem hiding this comment.
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/completionsand none hit/v1/responses, which is OpenAI's default in 2.0. The two protocols emit different tool definitions —chat_completions/tools.rb:31-38nests underfunction:,responses/tools.rb:11-19is flat — so MCP tool schemas go untested under the protocol users will actually get. - There's no
match_requests_onanywhere inspec/, 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.
| 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" |
There was a problem hiding this comment.
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.
|
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: 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 |
|
@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:
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. This PR's @crmne — flagging the sampling gap so it reaches you. |
Closes #158.
RubyLLM 2.0.0 shipped on 2026-09-18. The
~> 1.9pin means an application that has upgraded cannot useruby_llm-mcpat 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::Contentis gone. It existed because a message's content could be either aStringor a text-plus-files object; 2.0 settled that by makingMessage#contentaStringand giving files their ownattachments:argument.MCP still returns text and files together in one payload, so
MCP::Contentstays as a value object — it already overrode everything it inherited except attachment processing, andRubyLLM::Attachment.wrapdoes exactly that job now. The pair is split at the boundary by a new#to_message_arguments, which keeps all fiveMessage.newcall sites on one shape rather than spreading the split acrossprompt.rb,resource.rb,sample.rbandsampling_actions.rb.Message.newno longer accepts a content object. 2.0 raisesArgumentErrorunless content is aString, so this would have failed loudly on every prompt, resource and sampling path — not silently.params_schema→parameters_schemaonRubyLLM::Tool.MCP::Tooldefines 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::Attachmentneeded no change —RubyLLM::Attachmentsurvives,Attachment.wrappasses subclasses through untouched, and the inherited#typestill resolves through the overridden#mime_type.On the constraint
I used
>= 2.0, < 3rather 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
param/desc:→parameter/description:.Chat#with_tool(singular) was removed; specs usewith_tools.claude-sonnet-4resolves only tovertexai, which the suite doesn't configure. The provider was already known at each call site, so the newChatBuilderpasses it explicitly.gemini-2.0-flashhas been retired from the registry. Every request here is replayed from a cassette, soChatBuilderskips the lookup — which also stops a future retirement from breaking the suite.Results
1899 examples, 14 failures. RuboCop clean across 240 files. Baseline on
mainwith 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#chatbuilds a chat from a model name chosen by the server, so unlike every other call site there's no provider to pass:claude-sonnet-4ConfigurationError— registry lists it only undervertexaigemini-2.0-flashModelNotFoundError— retired from the registrygpt-4.1Fixing 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 preferpendingwith a reason so CI goes green.Worth a separate conversation:
Sample#chatgives 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, sorecord: :noneand no live HTTP). Worth flagging for anyone else working on this: the default local mode is:new_episodeswithallow_http_connections_when_no_cassette, and because 2.0 hits a new OpenAI endpoint, a plain localrspecappends real 401 responses into 71 committed cassettes. I caught and reverted that; no fixtures are touched in this PR.bun installalso rewritesspec/fixtures/*/bun.lock; those are reverted too.