Replace a connector's tool list in one step, so a failed refresh leaves it alone - #304
Merged
Merged
Conversation
…es it alone
`refreshTools` replaced the list with a `delete` and then an `insert`, auto-committed separately. The
catch below them says what is supposed to happen when a refresh goes wrong:
The tools already held are left alone: a vendor being briefly unreachable is not a reason to
revoke what Bots are using.
That is only true of a failure BEFORE the delete, which is the one the suite covers. Once the delete
has committed, anything that stops the insert leaves the connector with no tools at all.
It does not take a crash. `mcp_tools` is keyed on `(server_id, name)`, and the list is passed through
to a single multi-row insert with no de-duplication, so a server that answers `tools/list` with the
same name twice makes that insert fail on a duplicate key — a custom server an administrator added by
URL, or a curated vendor having a bad day. A pod killed mid-refresh, a dropped connection or a
statement timeout do the same thing.
`mcp_tools` is shared, so this is not one replica: every one of them loses that connector at the same
moment. `listForAgent` reads grants against the tool list, so every grant an administrator made stops
being offered without being revoked, and `grantedToolGuidance` then tells the Bot outright that it
holds none of that vendor's tools and to say so. Nothing repairs it: `refreshTools` is only ever
reached from `addServer`, `addCustomServer` and an administrator pressing Refresh, so the connector
stays empty until somebody notices `lastError` on the Plugins page.
Rolled back together, the vendor's bad answer lands in `lastError` and the Bots go on using what they
were granted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kevin9327
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
August 31, 2026 11:24
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
# Conflicts: # CHANGELOG.md
davidmckayv
previously approved these changes
Aug 31, 2026
davidmckayv
left a comment
Contributor
There was a problem hiding this comment.
Verified + CI verify green; template-appropriate correctness fix.
# Conflicts: # CHANGELOG.md
davidmckayv
approved these changes
Aug 31, 2026
davidmckayv
left a comment
Contributor
There was a problem hiding this comment.
CI verify green pre-rebase; CHANGELOG-only rebase; template-appropriate.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
server/src/plugins/store.ts:1927-1937replaced a connector's tool list with two auto-committedstatements:
Eighty lines below, the catch states the invariant this is supposed to hold
(
store.ts:2016-2019):That holds only for a failure before the delete — a refused token exchange, an unreachable host —
which is the case
plugin-store.integration.test.ts:2194covers. Once the delete has committed,anything that stops the insert leaves the connector with no tools at all.
It does not take a crash.
mcp_toolsis keyed on(server_id, name)(
server/src/db/schema/plugins.ts:110) andtools.map(...)is not de-duplicated, so a server thatanswers
tools/listwith the samenametwice makes the single multi-row insert fail on a duplicatekey. A custom server an administrator added by URL, or a curated vendor having a bad day, is enough.
A pod killed mid-refresh, a dropped connection or a statement timeout do the same thing.
And it is not one replica.
mcp_toolsis shared state, so every replica loses that connector atthe same moment:
listForAgent(store.ts:2444-2455) reads grants against the tool list, so every grant anadministrator made stops being offered without ever being revoked.
grantedToolGuidance(plugins/tools.ts:79-147) then tells the Bot outright that it holds none ofthat vendor's tools and to say so — so the deployment does not fail, it confidently answers wrong.
refreshToolshas exactly three call sites —addServer(store.ts:1591),addCustomServer(store.ts:1748) andPOST /api/plugins/servers/:id/refresh(
plugins/routes.ts:320). There is no timer and no queued job, so the connector stays empty untilsomebody reads
lastErroron the Plugins page and presses Refresh by hand.Wrapping the pair in the transaction the store already uses elsewhere (
store.ts:914,:1117,:1219) makes the replacement one step: the vendor's bad answer lands inlastErrorand the Bots goon using what they were granted, which is what the comment always said.
I deliberately did not add a de-duplication pass. It would only mask the one trigger that needs
no infrastructure fault, and it would quietly accept a malformed tool list rather than recording it —
lastErrornaming the duplicate is more useful to an operator than a list silently repaired.Where it runs
Postgres and shared; the change is that a half-applied replacement can no longer be observed by
any of them, because it can no longer commit.
transaction. Two concurrent refreshes of the same server still serialise the way they did — the
later one's delete waits on the earlier one's row locks and then replaces the list wholesale,
which is the same outcome as before and the same outcome as either one alone.
Boundary and audit
lastErrorand theconfiguration.changed/ withdrawn-grant rows below are untouched — they now run only when thereplacement actually landed, which is the point.
Changelog
CHANGELOG.md, underUnreleased.Proof
The two existing
refreshToolscases inplugin-store.integration.test.ts(:2149,:2209) coverthe successful replacement and the pre-delete failure; CI runs both against a real PostgreSQL.
No new test, and I want to be straight about why rather than quietly ship without one. The suite
reaches this code through the vendor, and
MCPMockkeys its tools by name(
mcp-mock.js:20-23,this.tools.set(def.name, …)), so it cannot be made to advertise a duplicate.The other triggers are a killed pod and a dropped connection. What would pin it is a fault-injected
databasewhoseinsertonmcpToolsthrows inside the real transaction — and I have no PostgreSQLon this machine, so I could not run that wrapper before sending it, and I would rather not put an
unverified fixture in front of you. If you would like it, say so and I will add it; I would also be
glad to be told a seam I have missed.