Learning MCP by fixing it
Nine pull requests to RubyLLM's MCP client, and what each bug taught me about the Model Context Protocol: sessions, stdio processes, OAuth races and outage backoff.
You can learn a protocol by reading its specification. I learned the Model Context Protocol a different way: by finding the places where a real client got it slightly wrong, and fixing them.
The client is the MCP support in RubyLLM, the Ruby library for talking to language models. In early November 2026 I opened nine pull requests against it. The maintainer merged all nine between 3 and 9 November. None of them adds a feature. Each one closes a gap between what MCP promises and what happens when a Rails app runs it in production, with many users, many threads and processes that crash.
Nine small fixes to RubyLLM's MCP client, all of them merged. Each bug sits where shared state meets production: many users, many threads, processes that crash. Below are the diagrams and the lesson from each.
This post walks through them in the order you meet the parts of MCP: what a client does, how it connects, how it authorizes, and what it does when a server is down. For each bug: what MCP expects, what went wrong, the fix, and why it matters. If you only have a minute, this image is the whole post:
MCP in one picture
MCP connects an application that uses a language model to the outside systems that model should be able to use. It has three roles:
- The host is your application. In my case a Rails app: it owns the users, the chats and the decisions about what each user may do.
- The client is the piece inside the host that speaks MCP. Here,
RubyLLM::MCP. - The server offers capabilities: tools the model can call (
tools/list,tools/call), resources it can read by URI, and prompts, which are message templates. A server can be a remote service like Linear or GitHub, or a local program.
Every message is JSON-RPC. It travels over one of two transports: Streamable HTTP for a remote server, or stdio for a local one, where the client starts the server as a child process and writes JSON to its stdin. Remote servers usually need authorization, which in MCP means OAuth with PKCE.
Your app (the host) uses an MCP client to reach servers that offer tools, resources and prompts, over Streamable HTTP or stdio, usually with OAuth. The fixes landed in four parts of the client:
In RubyLLM all of this hides behind a small class. This is the shape of the Linear example in the MCP guide:
class Linear < RubyLLM::MCP
url "https://mcp.linear.app/mcp"
inputs :user
oauth owner: :user
end
linear = Linear.new(user: current_user)
chat = RubyLLM.chat.with_mcp(linear)
chat.ask("Which of my issues are blocked?")
Four lines describe the server. Behind them the client finds the protocol version, opens a connection, signs in as that user, refreshes their token, lists the tools and hands them to the model. Each step is a place where shared state can go wrong. That is where the bugs were.
One client, two generations of the protocol
One detail matters for the first fix. RubyLLM speaks the 2026-07-28 revision of MCP. It starts with a server/discover request; a server on that revision answers with the versions it supports, and there is no session to set up. A server from an older revision (2024-11-05 to 2025-11-25) gets the old handshake instead: initialize, then notifications/initialized.
RubyLLM speaks the 2026-07-28 revision, which has no sessions. Older servers still use the initialize handshake and a session that can end, so the client has to notice and start a new one.
Those older servers keep a session. Over HTTP, the server hands out an Mcp-Session-Id header and answers 404 once that session is gone. Over stdio, the session lives as long as the server process. When a session ends, the client must notice, run the handshake again and retry. Most MCP servers in use today are still on the older revisions, so this path runs constantly.
Sessions: one new session per ended session #1401
Agents call tools in parallel. So picture two requests on the same client, both sent on session S1, both coming back with "this session has ended". Before #1401, each of them ran the handshake. The handshake sat behind a mutex, so they took turns, but nothing told the second thread that the first had already started a new session. The second handshake replaced the first one's session, and that session was never closed. It stayed open on the server.
Two requests found the same session ended, and each started a new one, leaving one open on the server. Now a request starts a new session only if the one that ended is still the current one; otherwise it retries on the new one.
The fix gives SessionExpired the session that ended: the HTTP session ID, or the stdio process. Both transports expose their current session with a session reader. A thread starts a new session only when the one that ended is still the current one; otherwise another thread already did the work, and it retries on that:
rescue SessionExpired => e
reconnect(e.session)
call(method, params, headers:, &)
end
def reconnect(expired)
@connecting.synchronize do
@server = handshake if @server.nil? || @transport.session.equal?(expired)
end
end
The handshake still runs under the same mutex, and nothing locks the request itself, so tool calls still run in parallel. The issue's script ran a legacy server on loopback: on main it opened three sessions and left one open; with the fix it opened two and closed the second.
stdio: a server is a process, and processes restart #1402 · #1403 · #1404
A stdio server is a program the client starts, such as GitHub's MCP server in a Docker container. You pass it secrets through env, and in RubyLLM a value can be a block or a method name, so each user gets their own token:
env GITHUB_PERSONAL_ACCESS_TOKEN: -> { user.github_token }
The client starts that process again after close or after a crash. Before #1402, the blocks ran once, when the MCP object built its transport. Every later process got the values from the first start. If the app rotated the token in between, the new process still got the old one.
A stdio server is a child process, and it restarts. #1402 reads env at each start, so a rotated token reaches the new process. #1403 makes the test fail if app secrets or proxy settings leak into it, and #1404 points the docs at GitHub's own server.
Now the MCP object hands the transport a block, and Stdio#start calls it each time it starts the process:
# lib/ruby_llm/mcp.rb: the transport gets a block, not a Hash
env: -> { settings.env.transform_values { |value| resolve(value) } },
# lib/ruby_llm/mcp/stdio.rb, in Stdio#start
env = ENV.slice(*INHERITED_ENV).merge(environment)
@stdin, @stdout, @process = Open3.popen2(env, *@command, unsetenv_others: true, **options)
The second line shows the other half of the stdio story. Since an earlier upstream change (#1359), a server process inherits only a short allowlist from your app, INHERITED_ENV (things like PATH, HOME and LANG), plus what you pass in env. unsetenv_others: true drops everything else. A local MCP server is somebody else's code, and it shouldn't see your Rails secrets or proxy settings.
#1403 changed no library code. The 2.2 upgrade guide says a stdio server no longer inherits a token, a proxy, NODE_EXTRA_CA_CERTS or BUNDLE_PATH, but the spec that guarded this checked only a fake API key and BUNDLE_GEMFILE. You could add HTTPS_PROXY or RAILS_ENV to the allowlist and every spec still passed. Now the spec plants all of them in the parent, in every upper- and lowercase proxy spelling, and fails if any reach the server. It names the variables that leaked, never their values.
#1404 is a one-line docs fix. The guide's env example used an npm package that npm now marks as deprecated. It now runs GitHub's own server, which also shows the boundary nicely: Docker's -e forwards only the variable that env gave the docker process.
class GitHub < RubyLLM::MCP
command "docker", "run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server"
env GITHUB_PERSONAL_ACCESS_TOKEN: ENV.fetch("GITHUB_TOKEN")
end
OAuth: the lock is about order #1425 · #1432 · #1426 · #1578
Four of the nine fixes are in OAuth. That isn't an accident. A connection to a remote MCP server has state: an authorization in progress, an access token, a refresh token, an expiry. Several MCP objects for the same user can touch that state at once: a web request, a background job, a callback. Every operation reads and writes the same row in the credential store.
Four fixes keep one user's credentials in order. Disconnect and authorize now take the same per-key lock as refresh and re-read the store inside it (#1425, #1432). The lock is freed once no thread needs it (#1426), and an old instance whose credentials were deleted gets a clean 401 instead of a crash (#1578).
RubyLLM already held a per-key lock while it refreshed a token, so two threads wouldn't spend the same rotating refresh token twice. The bugs were all about operations that skipped that lock, or trusted a copy of the state they had read earlier.
Disconnect has to win #1425
A user clicks "disconnect Linear" while a background job is refreshing their token. deauthorize deleted the credentials without taking the lock. The refresh was already waiting on the token endpoint; when the answer came back it wrote the new token into the store. The user had disconnected, and the server stayed connected for every later instance.
A user disconnected while a refresh was waiting on the token endpoint. The delete landed first, then the refresh wrote a new token back. Now deauthorize waits for the refresh, then deletes.
The fix is four lines: deauthorize runs inside the same synchronize as refresh. It waits for a refresh in flight, then deletes what that refresh wrote.
def deauthorize
synchronize do
@credential = nil
store.delete(key)
end
end
The spec holds the refresh at the token endpoint with a Queue, deauthorizes from a second thread, then lets the refresh finish. On main the store ended up holding access-2.
The same race, one step earlier #1432
Once one operation is fixed, look for its siblings. authorize, which finishes the OAuth callback by exchanging the code for tokens, took neither the lock nor a fresh read of the store. That allowed two bad orders. A user disconnects while the callback is mid-exchange: the delete lands first, then the exchange writes tokens back. Or an instance that cached the pending authorization completes a callback after another instance forgot it.
The same race happened one step earlier, during the OAuth callback. Now authorize holds the lock and reads the store again, so a callback after a disconnect raises instead of restoring tokens.
def authorize(params)
synchronize do
@credential = store.read(key)
pending = credential&.fetch('pending', nil) or raise Error, 'No authorization in progress'
check_callback(pending, params)
tokens = token_request('authorization_code', code: value(params, :code),
redirect_uri: pending['redirect_uri'],
code_verifier: pending['verifier'], client: pending)
store_tokens(tokens, client: pending.slice(*CLIENT_FIELDS, 'scope'))
end
end
Two changes: hold the lock, and read the store again inside it. A callback after a disconnect now raises "No authorization in progress". A new authorization started after the disconnect still works, and a spec guards that too.
A lock table needs a lifetime #1426
The per-key lock came from a Hash that made a new Mutex for every key it was asked about, and never let one go:
@refreshes = Hash.new { |refreshes, key| refreshes[key] = Mutex.new }
A key is one owner on one server. In a long-running multi-tenant process that's one mutex per user, forever: about 23 MB after 100,000 keys. You can't just delete the entry when a thread leaves, either. If another thread is waiting on that mutex and a third arrives, the third would get a fresh mutex and run at the same time as the second. So each entry now counts the threads that hold or wait for its lock, and the last one out deletes it:
One mutex per user was kept forever, about 23 MB after 100,000 keys. Now each entry counts the threads that hold or wait for its lock, and the last one out deletes it.
def self.refreshing(key, &)
lock = @refreshes_lock.synchronize do
entry = @refreshes[key] ||= [Mutex.new, 0]
entry[1] += 1
entry.first
end
begin
lock.synchronize(&)
ensure
@refreshes_lock.synchronize do
@refreshes.delete(key) if (@refreshes[key][1] -= 1).zero?
end
end
end
Same-key refreshes still share one mutex, and different keys still refresh in parallel. I chose this over lock striping (a fixed pool of mutexes) because striping makes unrelated users wait on each other's token requests. The overhead is about 0.2 µs per call, which a token request dwarfs.
Treat "gone" as a normal state #1578
The last OAuth fix follows from the first. After #1425, an old instance can't write credentials back once another instance deletes them. But that old instance still holds its cached token, and when the token came due, refresh found no row, returned false, and access_token read credential['access_token'] from nil. A job whose user had disconnected crashed with NoMethodError, an error outside RubyLLM::Error that your app's rescue clauses won't catch.
A job whose user had disconnected crashed with NoMethodError when its cached token came due. Now it gets RubyLLM::UnauthorizedError, like any new instance.
renew if expiring?
credential.to_h['access_token'] # was credential['access_token']
Now the request goes out without a token, and the server's 401 raises RubyLLM::UnauthorizedError, the same as for a new instance. No token request is sent, and the store stays empty.
Two limits apply to all four. The in-process mutex covers threads; across processes, the guarantee comes from the credential store's own synchronize(key), which the Active Record store implements with a row lock. And deauthorize forgets the credentials locally; it doesn't revoke the token at the provider.
Outages: key the backoff on what actually differs #1577
When an MCP server can't be reached, RubyLLM doesn't stop the chat. The turn goes on without that server's tools, and the server sits out for a minute across the whole process, so a dead server doesn't hold up every request. That process-wide memory needs a key. For a server with a static URL, the key was the URL.
But each MCP object connects through its own RubyLLM.context, with its own proxy, TLS options, Faraday adapter and timeout. If tenant A's context goes through a proxy that is down, A's failure marked the URL as down, and tenant B, who reaches the same URL directly, lost its tools too.
One tenant's broken proxy hid a server's tools from every tenant on the same URL. #1577 keys the backoff on the URL plus the context's proxy, TLS options, adapter and timeout.
The key now adds the four settings the HTTP transport builds its connection from:
def key(server)
settings = server.class
endpoint = [settings.url, *settings.command]
return server if settings.transport || !endpoint.compact.all?(String)
config = server.send(:config)
ssl = Transport::Connection.ssl_snapshot(config.ssl) if config.ssl
[*endpoint, config.http_proxy, ssl, config.faraday_adapter, config.request_timeout]
end
Measured against a loopback MCP server, with tenant A's proxy down:
main | With #1577 | |
|---|---|---|
| Tenant A (proxy down), tools offered | [] | [] |
| Tenant B (direct), tools offered | [] | [:echo] |
Tenant B, linear.tools | ["echo"] | ["echo"] |
This isolates connection routes, not users. Two users whose contexts share the same settings still share one backoff, which is what you want: a job that builds its MCP objects anew keeps skipping a route that is really dead.
The fix is the small part
Every one of these fixes is short. Most of each pull request is the test that proves the bug exists and stays fixed:
Most of each pull request is tests: 130 lines of library code against 534 of tests and docs. Each fix started with a reproduction through the public API and a spec that fails on main.
Show the numbers as a table
| PR | Topic | Library | Tests and docs | Status |
|---|---|---|---|---|
| #1401 | Sessions | 43 | 187 | Merged |
| #1402 | stdio env | 38 | 87 | Merged |
| #1403 | Env isolation test | 0 | 9 | Merged |
| #1404 | Docs example | 0 | 2 | Merged |
| #1425 | Deauthorize lock | 6 | 30 | Merged |
| #1426 | Lock lifetime | 16 | 72 | Merged |
| #1432 | Authorize lock | 14 | 62 | Merged |
| #1577 | Outage keys | 11 | 72 | Merged |
| #1578 | Forgotten token | 2 | 13 | Merged |
That ratio is the method, and it's where most of the learning happened. Each pull request followed the same steps:
- Reproduce through the public API. A short script on
mainthat shows the bug the way an app would meet it: three sessions instead of two,token-v1twice,authorized_after_deauthorize: true. - Write specs that fail on
main. For races, the threads are lined up withQueues andThread#stop?, never withsleep, so the bad order happens every run. - Make every part of the fix necessary. Revert one part, and a spec must fail. In #1426, never deleting, deleting while others wait, not counting, and a fresh lock per caller each fail a spec.
- Add guards for what must not change. Different keys still refresh in parallel; a new authorization after a disconnect still works; a single request still recovers its session.
- Show before and after in the description. A small table of what
maindoes and what the branch does, measured, not described.
Writing the failing spec is how you learn a protocol in this kind of depth. To stop a refresh at the token endpoint, you have to know exactly when a client talks to that endpoint. To prove that one session leaks, you have to know what ends a session over HTTP and what ends one over stdio. The specification tells you the rules; a failing test makes you understand the order things happen in.
Six patterns I took away
- Compare identity, not presence. "Is there a session?" was the wrong check; "is it still the one that ended?" was the right one. (#1401)
- Read values when the thing that uses them starts. Not when its wrapper is built. (#1402)
- A boundary needs a test that can fail. Plant the secrets, then prove they don't cross. (#1403)
- One lock per resource, for every write, with a fresh read inside it. Locks are about the order of operations on one user's state. (#1425, #1432)
- Everything you create per key needs a way to go away. Locks, entries, caches. (#1426)
- Key shared state on everything that changes the outcome. And treat "gone" as a normal state, not a crash. (#1577, #1578)
None of these is specific to MCP. They're the same lessons a Rails app teaches you about background jobs, caches and webhooks. MCP is a good place to learn them because a client sits between so many moving parts: your users, your threads, a child process, a remote server and an OAuth provider.
Limits worth knowing
All nine fixes are merged but not yet in a released gem. deauthorize forgets credentials locally and doesn't revoke the token, and cross-process safety depends on the credential store.
- All nine pull requests are merged on
main, but on 9 November 2026 none of them was in a released gem yet: the latest release, 2.1.1, came out on 11 October. The maintainer says #1577 will be in 2.2. - The session fix in #1401 only applies to servers older than the 2026-07-28 revision, which has no protocol-level session.
- #1402 reads
envat each start. It doesn't change the environment of a process that is already running. deauthorizeforgets credentials in your store. It is not a token revocation at the provider.- Cross-process safety for OAuth depends on the credential store. The Active Record store locks the row; a custom shared store has to implement
synchronize(key)itself. - The tool descriptions a server sends are not a permission system. Your app still decides what each user may do, and tool output can carry prompt injection.
A reading path through the source
Read five files in order, each next to its pull requests: mcp.rb, client.rb, http.rb and stdio.rb, oauth.rb, then outages.rb.
If you want to learn MCP the same way, read RubyLLM's MCP code in this order, with each pull request's diff and specs open next to it:
lib/ruby_llm/mcp.rb: the class DSL (url,command,env,inputs,oauth) and how it builds a transport.lib/ruby_llm/mcp/client.rb:discover,handshakeandreconnect. Start with #1401.lib/ruby_llm/mcp/http.rbandstdio.rb: the two transports and their session lifetimes. Then #1402 and #1403.lib/ruby_llm/mcp/oauth.rb:refreshing,authorize,deauthorizeandaccess_token. Then #1425, #1426, #1432 and #1578, in that order.lib/ruby_llm/mcp/outages.rb: which errors count as an outage, and the key. Then #1577.
Then pick one invariant, such as "after a disconnect, no credentials", and try to break it with two threads. If you can, you have your first pull request. All my merged ones are listed on my open-source page, and RubyLLM's MCP guide covers the parts I didn't touch: resources, prompts, input requests, progress and subscriptions.
Read this with an AI
This post is long and structured on purpose, so you can hand it to an AI and work through it together. A plain Markdown copy, without the page around it, lives at /blog/learning-mcp-by-fixing-rubyllm.md. Chat assistants and coding agents such as Claude Code or Codex can read it straight from that address.
A Markdown copy of this post, made for chat assistants and coding agents, is at /blog/learning-mcp-by-fixing-rubyllm.md. The full post has ready-made prompts.
Or start from one of these prompts. The first works in any chat. Run the other two from a coding agent inside your own repository, where it can read your code.
-
Learn MCP from it
Read https://www.wangyaodi.com/blog/learning-mcp-by-fixing-rubyllm.md and teach me the Model Context Protocol through the bugs it describes: one section at a time, in plain words, with one question for me after each section before you move on.
-
Review your code
Read https://www.wangyaodi.com/blog/learning-mcp-by-fixing-rubyllm.md and use its six patterns as a checklist. Review the code in this repository that keeps shared state: sessions, OAuth credentials, caches, backoffs and locks. List each place where state can be written outside its lock, read stale, never cleaned up, or keyed on too little, with the file and line.
-
Write the failing test
Read https://www.wangyaodi.com/blog/learning-mcp-by-fixing-rubyllm.md, then pick the riskiest race you found in this repository. Write a spec that reproduces it every time, lining the threads up with queues instead of sleeps, so it fails before the fix and passes after it. Don't change library code until I've seen the failing spec.
How this post was made
I wrote this post with AI, and every post here says how it was made. The work behind it is mine: nine pull requests, with their code, specs and descriptions public on GitHub. Here is who did what:
- Sources
- My nine pull requests to crmne/ruby_llm (diffs, specs and descriptions) and RubyLLM's MCP guide, listed under References.
- Study notes
- A first study guide to the pull requests, made with ChatGPT.
- Draft
- The text, diagrams, chart and images, drafted with Claude Code.
- Checks
- Every code sample, number and pull request state checked against GitHub on 9 November 2026.
- Review
- Read and approved by me before publishing.
- Updated
- 9 November 2026: #1577 and #1578 were merged after the post went out, so their status, the counts and the summary image now say so.
If you find a mistake, tell me on X and I'll fix it here.
References
All nine pull requests are on GitHub, along with the upstream changes and docs they build on.
My pull requests to crmne/ruby_llm, in the order of this post:
- #1401 Start one new MCP session per ended session (merged 3 Nov 2026)
- #1402 Read stdio MCP env values each time the server starts (merged 3 Nov 2026)
- #1403 Cover proxy and Bundler variables in stdio isolation (merged 3 Nov 2026)
- #1404 Use GitHub's own MCP server in the stdio env example (merged 3 Nov 2026)
- #1425 Hold the refresh lock while forgetting MCP credentials (merged 4 Nov 2026)
- #1426 Drop a refresh lock once no thread needs it (merged 4 Nov 2026)
- #1432 Hold the refresh lock while completing an MCP authorization (merged 5 Nov 2026)
- #1577 Key MCP outages on the context's connection settings (merged 9 Nov 2026)
- #1578 Send no MCP token once forgotten credentials expire (merged 9 Nov 2026)
Upstream changes they build on, by RubyLLM's maintainer:
- #1359 Give stdio MCP servers only a safe environment
- #1493 Go on without an MCP server that can't be reached
Docs:
- Model Context Protocol: the specification
- RubyLLM MCP guide
- GitHub's MCP server, used in the stdio example