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.

9pull requests to the MCP client
9/9merged by the maintainer, 3–9 Nov 2026
4parts of the client: sessions, stdio, OAuth, outages
130lines of library code changed, against 534 of tests and docs

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:

Summary of the post. Sessions, #1401: two requests saw the same session end and both opened a new one; the fix opens a new session only if the ended one is still current. Lesson: compare identity, not presence. stdio, #1402 to #1404: a restarted process kept the first token; the fix reads env each time the process starts. Lesson: read values when the thing that uses them starts. OAuth, #1425, #1426, #1432 and #1578: a refresh could write credentials back after a disconnect; every write now takes the same per-key lock and re-reads inside it. Lesson: a lock is about order. Outages, #1577: one tenant's broken proxy hid tools from every tenant; the backoff is now keyed on the URL plus connection settings. Lesson: key on everything that changes the outcome. 9 pull requests, all merged, 130 lines of library code, 534 lines of tests and docs.
The post in one image. Open it full size to save or share it.

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:

Where the nine pull requests sit in an MCP client A Rails host calls RubyLLM::MCP. Inside the client are three parts: the client core that discovers the protocol version and sends JSON-RPC (pull request 1401), OAuth with its credential store and per-key locks (1425, 1426, 1432, 1578), and outage backoff (1577). The client reaches servers over Streamable HTTP or stdio (1402, 1403, 1404). Servers offer tools, resources and prompts. Host Your Rails app RubyLLM.chat .with_mcp(linear) owns users, chats and permissions Client · RubyLLM::MCP Client discover or initialize, then JSON-RPC requests #1401 OAuth authorize · refresh · deauthorize credential store, one lock per key #1425 #1426 #1432 #1578 Outages skips a server that is down, then tries again after a backoff #1577 Transport → server Streamable HTTP POST and GET to one URL → a remote MCP server stdio a child process the client starts, JSON-RPC over stdin and stdout #1402 #1403 #1404 The server offers tools · resources · prompts listed and called over JSON-RPC
Figure 1. The parts of an MCP client, and where each pull request landed.

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.

Two requests find the same session ended, before and after pull request 1401 Before: requests A and B are both sent on session S1, both get a 404, and each runs a handshake, creating S2 and then S3; S2 is never closed. After: the error carries the session that ended. Request A sees S1 is still current and starts S2. Request B sees the current session is already S2 and retries on it. Before #1401 Request A Request B sent on S1 sent on S1 404 · S1 ended 404 · S1 ended handshake → S2 waits, then handshake → S3 S2 · never closed S3 replaces S2 before anyone closes it. With #1401 · the error carries the session that ended Request A Request B sent on S1 sent on S1 404 · S1 ended 404 · S1 ended S1 is current → handshake → S2 current is S2 → retry on S2 One new session. Both calls succeed, and nothing is left open.
Figure 2. The race in #1401. The fix compares sessions; it doesn't add a lock.

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.

What it taught meA mutex makes threads take turns. It doesn't tell the second thread that the first one already fixed the problem. For that, compare which thing failed with what is current now, by identity.

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.

When env values are read, before and after pull request 1402 A timeline: the MCP object is built, process 1 starts, the token is rotated, the process exits, process 2 starts. Before: env is read once when the object is built, so both processes get token-v1. After: the transport keeps a block and calls it at each start, so process 2 gets token-v2. MCP built process 1 starts token rotated process exits process 2 starts Before env → token-v1 gets token-v1 still token-v1 #1402 keeps a block block → token-v1 block → token-v2
Figure 3. The connector lives longer than the process. Read values at the moment the process starts.

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.

What a stdio server inherits from the application's environment The parent process has PATH, HOME, LANG, HTTPS_PROXY, BUNDLE_PATH, RAILS_ENV, SECRET_KEY_BASE and NODE_EXTRA_CA_CERTS. Open3.popen2 with unsetenv_others passes only the allowlist plus the values from env. The server process gets PATH, HOME, LANG and GITHUB_PERSONAL_ACCESS_TOKEN. Pull request 1403 tests that the blocked variables never reach it. Your app's ENV PATH HOME LANG HTTPS_PROXY× BUNDLE_PATH× RAILS_ENV× SECRET_KEY_BASE× NODE_EXTRA_CA_CERTS× Open3.popen2 unsetenv_others: true INHERITED_ENV allowlist + your env, read at each start Server process ENV PATH HOME LANG GITHUB_PERSONAL_ACCESS_TOKEN only what env passes #1403 tests the × rows
Figure 4. The stdio allowlist is a security boundary. #1403 made its test able to fail.

#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
What it taught meAn object's lifetime and a process's lifetime are different things. Read a value at the moment the thing that uses it starts. And a security boundary is only as real as the test that fails when someone widens it.

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).

The OAuth states of an MCP connection, and the fixes that guard each transition Four states: no credentials, pending, authorized and refreshing. authorization_url moves from no credentials to pending; authorize with the callback moves to authorized (guarded by 1432); a token coming due moves to refreshing and back. deauthorize from any state returns to no credentials (guarded by 1425 during a refresh, and by 1578 for an instance whose token comes due afterwards). 1426 frees the per-key lock that all of these hold. No credentials Pending Authorized Refreshing authorization_url authorize(params) token due renewed #1432 deauthorize: forget the stored credentials #1578 #1425 #1426 every arrow that writes credentials runs inside one lock per key, freed when no thread needs it
Figure 5. OAuth states for one user and one server. Badges mark the transition each fix protects.

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.

A refresh and a deauthorize on the same credentials, before and after pull request 1425 Before: thread 1 reads the refresh token and waits on the token endpoint; thread 2 deletes the credentials meanwhile; thread 1 then writes access-2, so the store ends up with a token again. After: thread 2 waits for the lock thread 1 holds, and deletes after thread 1 writes, so the store ends up empty. Before #1425 · deauthorize skips the lock Thread 1 · refresh Thread 2 · deauthorize read refresh token endpoint… write access-2 delete Store after access-2 connected again With #1425 · deauthorize takes the same lock thread 1 holds the lock for this key Thread 1 · refresh Thread 2 · deauthorize read refresh token endpoint… write access-2 waits for the lock delete Store after empty stays disconnected
Figure 6. The race in #1425. Same work, different order: whoever deletes last decides the outcome.

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.

A per-key lock that counts its users Step 1: thread 1 takes the lock, the entry is key to lock and count 1. Step 2: thread 2 waits, count 2. Step 3: thread 1 leaves, count 1, the entry stays because thread 2 still needs the same lock. Step 4: thread 2 leaves, count 0, the entry is deleted. Step 1 Thread 1 takes it k → [lock, 1] entry created Step 2 Thread 2 waits k → [lock, 2] waiters count too Step 3 Thread 1 leaves k → [lock, 1] same lock for thread 2 Step 4 Thread 2 leaves count 0 entry deleted
Figure 7. #1426. The entry lives exactly as long as some thread needs the lock.
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.

What it taught me"Thread-safe" was the wrong question. The right one is about order: for one user's credentials, every operation that writes them goes through the same lock and reads the current state inside it. Then think about what's left behind: the lock entries, and the instances still holding an old copy.

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.

Outage keys for two tenants on the same MCP server URL, before and after pull request 1577 Before: tenants A and B share one outage key, the URL. A fails through a broken proxy, so both get no tools. After: the key adds the context's proxy, TLS, adapter and timeout. A's key is marked down, B's key is not, and B's tools are listed. Before · key = url Tenant Aproxy that is down Tenant Bdirect connection [url] A fails, so the key is down tools: [] tools: [] B loses them too With #1577 · key = url + the context's connection settings Tenant Aproxy that is down Tenant Bdirect connection [url, proxy, ssl, adapter, timeout] this route is down [url, nil, ssl, adapter, timeout] a different key: still up tools: [] tools: [:echo]
Figure 8. #1577. One tenant's broken proxy should only sideline its own route.

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:

mainWith #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.

What it taught meA cache or a backoff is only correct if its key holds everything that changes the outcome. "Same URL" looked like "same server", but the connection to it was different.

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.

Lines changed per pull request, library code against tests and docs Lines added plus removed. 1401: 43 library, 187 tests. 1402: 38 library, 87 tests and docs. 1403: 9 tests. 1404: 2 docs. 1425: 6 library, 30 tests. 1426: 16 library, 72 tests. 1432: 14 library, 62 tests. 1577: 11 library, 72 tests and docs. 1578: 2 library, 13 tests. In total, 130 lines of library code and 534 of tests and docs. #1401 sessions: 43 library, 187 tests #1401sessions 43 + 187 #1402 stdio env: 38 library, 87 tests and docs #1402stdio env 38 + 87 #1403 env isolation: 9 tests #1403env isolation 0 + 9 #1404 docs example: 2 docs #1404docs example 0 + 2 #1425 deauthorize lock: 6 library, 30 tests #1425deauthorize 6 + 30 #1426 lock lifetime: 16 library, 72 tests #1426lock lifetime 16 + 72 #1432 authorize lock: 14 library, 62 tests #1432authorize 14 + 62 #1577 outage keys: 11 library, 72 tests and docs #1577outage keys 11 + 72 #1578 forgotten token: 2 library, 13 tests #1578forgotten token 2 + 13 Total: 130 lines of library code, 534 of tests and docs
Show the numbers as a table
PRTopicLibraryTests and docsStatus
#1401Sessions43187Merged
#1402stdio env3887Merged
#1403Env isolation test09Merged
#1404Docs example02Merged
#1425Deauthorize lock630Merged
#1426Lock lifetime1672Merged
#1432Authorize lock1462Merged
#1577Outage keys1172Merged
#1578Forgotten token213Merged
Figure 9. Lines added plus removed in each pull request, from GitHub. About four of every five are tests.

That ratio is the method, and it's where most of the learning happened. Each pull request followed the same steps:

  1. Reproduce through the public API. A short script on main that shows the bug the way an app would meet it: three sessions instead of two, token-v1 twice, authorized_after_deauthorize: true.
  2. Write specs that fail on main. For races, the threads are lined up with Queues and Thread#stop?, never with sleep, so the bad order happens every run.
  3. 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.
  4. 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.
  5. Show before and after in the description. A small table of what main does 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

  1. Compare identity, not presence. "Is there a session?" was the wrong check; "is it still the one that ended?" was the right one. (#1401)
  2. Read values when the thing that uses them starts. Not when its wrapper is built. (#1402)
  3. A boundary needs a test that can fail. Plant the secrets, then prove they don't cross. (#1403)
  4. 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)
  5. Everything you create per key needs a way to go away. Locks, entries, caches. (#1426)
  6. 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 env at each start. It doesn't change the environment of a process that is already running.
  • deauthorize forgets 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:

  1. lib/ruby_llm/mcp.rb: the class DSL (url, command, env, inputs, oauth) and how it builds a transport.
  2. lib/ruby_llm/mcp/client.rb: discover, handshake and reconnect. Start with #1401.
  3. lib/ruby_llm/mcp/http.rb and stdio.rb: the two transports and their session lifetimes. Then #1402 and #1403.
  4. lib/ruby_llm/mcp/oauth.rb: refreshing, authorize, deauthorize and access_token. Then #1425, #1426, #1432 and #1578, in that order.
  5. 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.

  1. 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.

  2. 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.

  3. 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:

  1. #1401 Start one new MCP session per ended session (merged 3 Nov 2026)
  2. #1402 Read stdio MCP env values each time the server starts (merged 3 Nov 2026)
  3. #1403 Cover proxy and Bundler variables in stdio isolation (merged 3 Nov 2026)
  4. #1404 Use GitHub's own MCP server in the stdio env example (merged 3 Nov 2026)
  5. #1425 Hold the refresh lock while forgetting MCP credentials (merged 4 Nov 2026)
  6. #1426 Drop a refresh lock once no thread needs it (merged 4 Nov 2026)
  7. #1432 Hold the refresh lock while completing an MCP authorization (merged 5 Nov 2026)
  8. #1577 Key MCP outages on the context's connection settings (merged 9 Nov 2026)
  9. #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: