Reach a person's own Gmail, Slack or Linear through Composio - #481
Draft
mxmzb wants to merge 93 commits into
Draft
Reach a person's own Gmail, Slack or Linear through Composio#481mxmzb wants to merge 93 commits into
mxmzb wants to merge 93 commits into
Conversation
mxmzb
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
September 10, 2026 13:33
mxmzb
marked this pull request as draft
September 10, 2026 13:53
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…sted Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
The plan's code blocks were written before being run through the formatter, and the executor was told three times to copy them verbatim — so it correctly chose verbatim over formatted and reported the deviation rather than silently fixing it. Whitespace only: six wrap sites across the two files, no content change, and the suite is the same 24 passing tests either side of this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
… connections Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…he real thing Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…places Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…and check the new columns round-trip Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…ture ids
The Composio fixtures at the bottom of this file insert at `gmail`, `notion`,
`bot_helper` and `user_asker`, and none of those ids is a choice:
`seedNotionServer` needs `catalogueEntry("notion")` to resolve to the real
catalogue entry, and `gmail` is the toolkit slug that gets sent to the vendor.
`freshDatabase` deleted them unconditionally to make room, which against a
database somebody is using is a lot to take.
`mcp_user_credentials` references `mcp_servers.id`, so removing a real `notion`
row takes every person's per-user credential row with it and leaves their
encrypted vault rows referenced by nothing — unreachable from any screen and
invisible to `retireConnectionsFor`, which exists to stop exactly that state.
Removing a real Bot takes six tables: its channel memberships, its agent
profile, everyone's preferences for it, its routines and all of their run
history, its component exclusions and its plugin grants. The fixtures then
re-insert byte-identical look-alikes, so nothing on screen would say it
happened.
The `*WasAlreadyConfigured` pattern the older suites use cannot help here: they
only read those rows, so skipping a delete is enough for them, while a fixture
that inserts at an id cannot coexist with a real row there at all — skipping the
delete would just turn the disaster into a primary-key conflict, and
capture-and-restore restores after the cascade has already run. So this refuses
to run instead: a file-level guard looks for the rows the suite intends to own
and throws naming what it found and where to point DATABASE_URL. That is what
makes the deletes safe, and the comment now says so.
Two more things while in here. There was no file-level `afterAll`, so the last
test's fixtures were permanent — one run left behind a `notion` server row and a
`notion-fetch` action nobody configured, which makes that database advertise a
connector nobody set up, and on the next run the rotation suites read the leak
as the deployment's own row and correctly declined to clean it. And the
connection delete matched on the toolkit alone, which is every person's Gmail
connection rather than the fixture's; it now names the two people this file
invents.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
`serverWasAlreadyConfigured` and `toolWasAlreadyAdvertised` were captured in a file-level `beforeAll` and read by the file-level `afterAll`, which deleted the `google-drive` server row and its `search_files` tool row when they were `false`. Both were initialised to `false`, so the value that authorised the delete was also the value they held before anything had looked. A flag meaning "delete this" must not default to the value that authorises deletion, because a setup that aborts leaves every flag sitting at its default. The guard added alongside them is what made that path routine rather than exceptional. It is a file-level `beforeAll` ahead of the capturing one, and it throws by design whenever the database already holds the fixture ids this suite inserts at — a documented outcome rather than a crash. bun runs `afterAll` anyway, and the capturing hook never got to run, so the teardown concluded it had created rows it had never looked at and removed them. Verified against a database holding an operator's own `google-drive` connector and its advertised `search_files` tool, plus a `gmail` row to trip the guard: the run refused as designed, `gmail` survived, and the two rows the suite had no business touching were gone. The cascade off `mcp_servers.id` then takes every person's per-user credential row and strands their encrypted vault rows behind a dangling reference, invisible to `retireConnectionsFor` — the exact harm the guard's own comment cites as its reason for existing. So the flags now count creations. `suiteCreatedServerRow` and `suiteCreatedToolRow` are set to `true` only where the capture actually ran and found the row absent, and the teardown deletes on `true`. `false` covers both "the deployment already had it" and "nobody ever looked", which is the right answer for both: neither is this suite's row to remove. An early throw leaves both at their initialisers and nothing is deleted. Gating that `afterAll` on `ownsFixtureIds` would have worked today and answers a different ownership question — which ids the guard cleared, not which rows this run wrote — and would break silently if either hook moved. The two describe-scoped `notionWasAlreadyConfigured` teardowns keep the inverted shape; a file-level `beforeAll` that throws stops describe-scoped hooks from running at all, so that path cannot reach them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…ial behind it `reachedAs` was derived from the collapsed `CredentialSource`, and `CREDENTIAL_BY_AUTH` maps both `none` and `builtin` to `"none"` — so by the time the answer was chosen, the two kinds were indistinguishable. They do not share an answer. A credential-free endpoint is public: it touches nobody's account and answers every person identically, so naming the asker asserts a per-person attribution that does not exist. The builtin one has no credential for the opposite reason — the call runs against this deployment's own tables as the person whose turn it is, so the person is exactly who it reached. Collapsing both to one credential source erased that distinction at precisely the point it mattered, and gave `person` to a public endpoint. `REACHED_AS_BY_AUTH` is keyed on the auth kind instead, which is the field the answer actually depends on, and being a `Record` over that union it makes the compiler ask the question for any auth kind added later. That is what the module already claimed and could not deliver: the existing `Record` only forced a new kind to declare a credential source, and `reachedAs` fell out of that, so the first `none` entry would have got no compile error, no failing test, and a wrong audit row. No catalogue slug uses `none` today, so nothing changes at runtime — Drive and Notion are `user-oauth`, Routines is `builtin`, and all three still resolve to the person. The new test constructs a `none` entry directly so the file's stated exhaustiveness property holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…om an entry `transportFor` took a catalogue entry and read `entry?.transport ?? "mcp"` off it. That was complete while every server either had a frozen entry or was somebody's MCP endpoint. A Composio app is neither — no entry, so the fallback answered MCP and `composio://gmail` would have been dialled as an HTTP server. `accessFor` already resolves the kind once for every row shape. Both call sites in the store now read it off `access` and pass the kind, so this file only does the lookup. The Drive test asserted the same fallback through `transportFor(null)`. It now composes through `accessFor`, which is where the absent-entry decision moved; the property it checks is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Three of the four stated that the catalogue entry decides the protocol, which is precisely the defect this branch removed: a Composio app has no entry, so deriving the transport from one dialled `composio://gmail` as an HTTP MCP server. A reader reconstructing the rule from those comments reconstructs the bug. `accessFor` decides now, once, for every row shape, and the comments say so. The fourth comment was not wrong, only orphaned: it was written about `entry`, and a later commit inserted the `access` block between it and the return, so it had come to read as a preamble to something that is never null. Moved back above the line it describes, unchanged. The listing path now has a test. It asserts that a Composio row reaches the Composio client, and it goes red when `refreshTools` resolves the transport from the entry instead — verified by making that change and watching it fail. The calling path cannot be covered until a version argument is threaded through, so it is left for later. A Drive test's negative assertion became a positive one. Asserting "not the Drive adapter" for an entry-less server would have been satisfied by any wrongly resolved transport, including the very defect under repair; it now asserts the MCP adapter by identity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
The phrase substitution landed without rewrapping the paragraph around it, so a commit whose whole purpose was making four comments readable left one of them the widest line in its file by twenty-nine columns, wrapping mid-link in any ordinary editor. Biome does not reflow prose, so no gate caught it. Whitespace only: the sentence is unchanged and the paragraph now wraps at the same width as the two above it.
…has not connected A brokered app holds one deployment key and relies on Composio to keep people's accounts apart, so the only thing that separates them is the person id the call runs under. Two states therefore have to be refused before anything is spent at the vendor: a run attributed to nobody, and a person who has not connected that app at all. Both are refused in `connectionTokenFor`, beside the `user-oauth` refusals they are modelled on, for the same two reasons — the person gets a sentence naming the step they can take, and no call is spent finding out. The transport refuses again as a last line. `connectionTokenFor` now takes the `ServerAccess` descriptor its callers already hold, and the per-person branch reads `access.credential` rather than the catalogue entry's auth kind, so both branches decide from one source instead of two that can disagree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…e's mailbox The brokered branch of `connectionTokenFor` refuses two things: a run that is not attributed to anybody, and an asker who has not connected the app. Both shipped with nothing in the suite exercising them. A throw planted as the first statement inside the `if (access.credential === "brokered")` branch left the whole suite green at 1883 pass — nothing entered that arm, so either refusal could have been deleted without a single test noticing. Silently deletable is the worst state for a security refusal to be in. Two tests now enter it, and each goes red when its own refusal is deleted. Removing the `if (!actorId)` block makes the unattributed test fail on the wrong refusal reaching it; removing the `if (!connected)` block makes the not- connected test fail because the call resolves and the stub records a slug, which is a call spent at the broker to find out what the row already knew. Both assert that the vendor stub was never reached, so the refusal is proven to happen before a call is spent rather than merely somewhere. The docblock above the function had stopped enumerating the branches it governs while the code appealed to it as authority. It described a two-case function keyed on auth kinds the body no longer reads, said nothing about the brokered arm that carries both refusals, and stated unconditionally that a refresh token is exchanged per call — telling a reader on the brokered path that something is exchanged where no refresh token exists at all. It now names all four kinds, and says which paths have nothing to cache versus the one where not caching is a decision. `row.url` went with it. It was read nowhere in the function, and dropping it fits the parameter back onto one line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Both call sites in the plugin store still called classifyTool with three arguments, so every Composio action was classified by the catalogue's hand-written write list. A Composio app has no such list and no catalogue entry at all, so every action read as a write regardless of the effect the vendor recorded when the action was listed. callTool now selects the recorded effect alongside the input schema and passes it; listServers already selected every column, so its tool map only needed the argument. destructive and version are selected in callTool but not yet read: version is consumed by a later change and destructive by the confirmation card, and selecting them now keeps that a one-line diff rather than a re-shaped query. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…l path The recorded effect was already being selected and passed at both call sites, but nothing asserted the result at either one. The call path's audit row and the admin page's listing both depended on it and neither would have said so if the argument went away. The admin-page site had no coverage at all, and it needed nothing deferred to test it: listServers reads the seeded row directly, so a plain assertion on the returned tool's effect is enough. Each test now fails only when its own call site regresses. Reverting the callTool call to three arguments fails the audit-row test alone; reverting the listServers call fails the listing test alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
The block claimed a server with no catalogue entry behind it was a write throughout, because nothing reviewed said any tool of theirs only reads. A test in this same branch asserts the opposite: composio-classify.test.ts takes a recorded `read` on a null entry and expects a read, as does the store integration test just added for the brokered call path. It also described the advertised-and-absent-from-the-write-list case as the only way to produce a read, and said so as "it is the only one". A second read-producing case has existed unmentioned since the recorded effect was introduced: an advertised action whose recorded effect is exactly `read`, which is a read regardless of the write list and regardless of whether an entry exists. The doc now names both sources, the order they are consulted in, and both ways a read can be earned. The reconciliation helper's justification inherited the same stale premise. Its conclusion is still right — an entry-less server is not reconciled — but the reason is no longer that all of its tools are already writes. It is that a brokered app is classified from the vendor's per-action label rather than from a hand-written list, so there is no under-inclusion here to find. Comments only; no behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
… the vendor's own Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…ts it to a key Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…ust under a property Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
The transport's sentence changed when the remedy became conditional on the vendor publishing a version at all, and this assertion still matched the old unconditional one. The comment above it claimed a one-click fix, which is the thing the new sentence exists to stop promising. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Six of them passed whether the code did what their name said or not. The url-names-no-app test asserted the refusal and nothing else, so with the default stub answering `[]` the guard could be moved to after the dial and every assertion still passed while the transport handed a hostname to Composio as an app slug. The three cap tests allowed anything under 25,000 against a `MAX_RESULT_CHARS` of 20,000, so a cap raised to 24,000 passed, and the one named "capped visibly" never looked for the `[truncated]` marker at all. The empty-answer test read the sentence and neither of the two fields beside it, and the envelope test listed three strings that must not appear, which is only ever as long as the fields the envelope had the day it was written. The bounds are now the constant itself, imported rather than restated, and the envelope test pins the whole string. Four branches nothing reached are reached: an action Composio published no version for, and the empty-thrown-message arm of both fallbacks, plus `vendorSentence`'s trim and its type guard, each of which would otherwise hand a model a blank refusal or `[object Object]`. Every test here was confirmed red against a mutation of the behaviour it names, and no source file changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…the curated vendor Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…now it did Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
The ownership guard asked about `mcp_servers`, `agents` and `composio_connections` and never about `users`, while `freshDatabase` deleted `users.user_leaver` before every test whatever the guard had decided. A real account at that id goes through ten cascades — sign-in accounts, live sessions, roles, channel memberships, per-Bot preferences, written instructions, skills, routines, per-user connector credentials — and the fixture then re-inserts a look-alike, so nothing says it happened. The guard now covers the person too, and the deletes wait on the same positive evidence the teardown does rather than on the assumption that a thrown `beforeAll` stops everything below it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…lient it actually saw Three ways a row outlived the run. The dynamic-registration suite wrapped the vault's `create` and not its `rotate`, so every reconnect and every re-registration after the first minted a `notion` credential nothing recorded — thirteen rows a run, the last of them a live `mcp_user_token` for a person, revoked by nothing and referenced by nothing. The custom-credential suite minted four tokens and deleted three, leaving the upsert's own live in the vault against a server that no longer exists. And both OAuth suites restored `mcp_servers.credential_id` from a variable only their `beforeAll` assigns, while `afterAll` runs whether or not that `beforeAll` finished — so a setup that died early wrote null over a deployment's client and then deleted the row, on the strength of a flag still sitting at its initialiser. The capture now starts at `undefined`, which is nobody having looked, and both the restore and the delete wait on evidence rather than on an absence of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
`audit_events` is append-only and 0012 closed the last way around that, so this file cannot tidy the trail and every row every previous run wrote is still there. The refusals are recorded against `google-drive/search_files` and named by rules about `google-drive`, spellings forced for the same reason the fixtures are, so the queries matched nine hundred rows this run had nothing to do with. The reader test's `limit(1)` was in fact answered by the oldest row in the table — a refusal from yesterday, written by code that is not this branch's — and the dry-run test read `recorded[0]` out of the same pile. Those are tests that cannot fail: delete the line that writes the row and they stay green. Every query now carries the run's start, taken from the database's own clock in the first hook the file registers, and the two that indexed into an unordered list say how many rows they expect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
… here
`composio_connections.user_id` is notNull and notNull does not exclude the empty string, so
`("gmail", "")` is a row a deployment can legally hold — which is exactly what the test that
inserts one is about. The delete that takes it back runs inline, outside `freshDatabase` and
outside every other sweep, at an id nothing had established was this file's.
With it in the list, the guard's claim is now true of every table this file deletes from at an id
it did not invent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
`listTools` mapped a `tools/list` answer into name, description and schema and dropped `annotations` entirely. The MCP specification defines `annotations.destructiveHint` and servers publish it, so `effect` and `destructive` were never set for any MCP-transport listing. That silence was doing more work than it looked. The docblock on `ListedTool` asserted that an MCP server publishes no effect, and the argument that a recorded effect could not disturb any existing curated read rested on it — true of the behaviour, but only because the annotations were being thrown away. The consequence: a tool a vendor declares destructive fell through `classifyTool` to read for any curated entry whose hand-written `writeTools` happened to omit the name. Fail-open, on a permission-adjacent decision. Only `destructiveHint` is believed, and only on an explicit true. It can move an action from read to write and never the other way, so a server that lies with it restricts itself and nothing else. `readOnlyHint` is withheld: for a curated vendor it changes no answer, since an advertised name absent from `writeTools` already classifies as a read, and the one case where it would change an answer is a server an administrator added by URL, where believing it means letting an arbitrary server declare its own tools harmless. The SDK says as much where it declares the hints. The specification's default of destructive-when-not-read-only is likewise not applied, because applying it would reclassify every unannotated action of every MCP vendor as a write. A tool carrying no annotations is unchanged in every respect, so Notion's reviewed write list goes on deciding exactly as it did, and Drive never reaches this code at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…ilter `additionalProperties` is a full subschema in both `ParametersSchema` and `JSONSchemaPropertySchema` (`@composio/core` 0.18.1, `src/types/tool.types.ts:154` and `:111`), which is how a toolkit spells a bag of attachments. `stagesAFile` never descended it, so a `file_uploadable` parameter hidden there escaped the filter and the action was offered to a model under both auto-upload settings — a bucket key nobody here can issue, or a server-side path nobody should promise — and every call against it failed. The comment claiming the walked list was closed stated what BOUNDED it rather than what completed it, which is how the gap survived a reading. `patternProperties`, `not`, the conditional trio, `items` as a tuple and the comparison against `true` rather than truthiness were all walked already and asserted by nothing; each has a case now. Two assertions that could not fail go with it, because both were argued for in an earlier wave and neither held. The cap and page-size tests compared against the very constants under test: `MAX_RESULT_CHARS` 20,000 to 40,000 and `LISTING_LIMIT` 1000 to 20 were both applied to the modules and all 44 tests stayed green. And the "schema unaltered" test compared the answer to the same object reference the stub had handed over, so an in-place `delete` inside `listTools` passed it. The numbers are written out here now and read back from the modules in one test, and the schema is compared to a snapshot taken before the call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…ported Four ways this transport mishandled what Composio said back. THE ANSWER WAS DEREFERENCED OUTSIDE ITS GUARD, at three sites. `actions.length` and `actions.filter` sit after the try that wraps `listActions`, and `reportedFailure(answer, …)` sits after the one that wraps `execute`. A client resolving null therefore propagated `null is not an object (evaluating 'actions.length')` into the row's `lastError` for an administrator to read, and threw a `TypeError` straight out of `callTool` — which this module documents as never throwing and `store.ts` relies on not throwing, because an exception ends a person's turn mid-run with nothing said and nothing audited. `ComposioActions` is our own projection and its adapter is unwritten, so a return type is not a promise about what resolves. Both shapes are settled now while a sentence can still be written, elements included: `[null]` is the same failure one level down. AN `error` BESIDE `successful: true` WAS DROPPED AND AUDITED AS A SUCCESS. `ToolExecuteResponseSchema` spells the two as independent required fields and `transformToolExecuteResponse` copies both off the wire (`@composio/core` 0.18.1, `src/models/Tools.ts:215-222`), so the combination is a shape the vendor's own schema permits. Taking the sentence as a failure is the vendor's arithmetic rather than a house rule: where the SDK derives the flag it writes `successful: !response.error` (`:1247`). A WHITESPACE-ONLY VERSION WAS TRUTHY WHEN RECORDED AND EMPTY WHEN SENT, so it was written to `mcp_tools` as a version this deployment believed it held and was then permanently uncallable, with a refusal naming a refresh that records the same blank again. It is trimmed at both ends now. `vendorSentence` had the same split: it trimmed in its guard and returned the padded string. `listingSentence` DID NOT FILTER THE VENDOR'S PLACEHOLDER the way `callTool` does, so "Error executing the tool X" reached the Plugins page — where the reader had just asked to refresh that very app. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
`toolkitOf`'s answer was validated and then discarded: `execute` received the action slug alone, so the app a brokered call ran against was implied by the slug a LISTING recorded, while the person's connection had been gated on the app the url names NOW. A url edited between a refresh and a call was therefore gated on one app and run against another — somebody's Slack connection satisfying the gate for a Gmail action that still runs in their Gmail. That is the branch's central guarantee, and a check performed and dropped is not one. The app is an argument of the call now, in a named record rather than four positional strings so a transposition cannot pass silently. It is asserted to FOLLOW the url and not merely to be present, which is what a discarded check could never show. THE VENDOR'S WIRE GENUINELY CANNOT CARRY IT, so the obligation is written into the projection where an implementation has to meet it. `ToolExecuteParams` has no toolkit field and the client's method takes the slug alone — `execute(toolSlug, params, options)` over `arguments`, `user_id`, `version` and connection overrides (`@composio/client` 0.1.0-alpha.76, `resources/tools.d.ts:41`, `:480-532`) — and the core SDK sends exactly that (`@composio/core` 0.18.1, `src/models/Tools.ts:1013`). What an implementation can do is refuse a mismatch, and it has what it needs to: `tools.execute` already resolves the tool by slug before running it (`:1163`, resolver at `:693`) and the resolved tool carries the app the vendor will run it against as `Tool.toolkit.slug` (`src/types/tool.types.ts:189`). Disagreement is required to throw, which `callTool` already turns into a refusal with a sentence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
`composio_connections.user_id` is notNull and notNull does not exclude the
empty string, so `(app, "")` is a legal row and two suites write one. Neither
owned it. `plugin-store.integration.test.ts` refused to run on any `""` row
whatever app it named, and deleted every `""` row whatever app it named;
`composio-connections.test.ts` writes one against its own run-suffixed app. A
run of the second killed before its cleanup therefore stranded a row that made
all 81 tests in the first refuse permanently, and the two running together had
the first deleting the second's fixture mid-test.
Both now key on the PAIR. The guard refuses on `("gmail", "")` and nothing
else, the cleanup deletes `("gmail", "")` and nothing else, and the same pair
joins the sweep in `freshDatabase` so a row stranded earlier in a run is gone
before the next test looks. A witness row at a suffixed app, inserted and
removed by the test itself, is what fails if either delete widens again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
…nothing Wave 1 added the guard that refuses to commit an empty listing over actions already held; wave 2 then made the Composio transport throw rather than answer `[]`, which is correct and routed wave 1's three tests around the guard into the vendor `catch` several lines earlier. `if (listed.length === 0)` could be replaced with `if (false)` and all 89 tests stayed green: only the "commit when nothing is held" half was covered, and the data-loss half was not. A second describe drives the guard through a stub that answers `[]` — a vendor's own answer, which no throw can stand in for — and asserts the three things the guard promises: the held action survives with its `version`, the row says the actions were kept and takes no refresh stamp, and no grant is withdrawn or filed as unadvertised. All three redden under `if (false)`. The describe above it stated as its premise that the transport answers `[]` rather than throwing, which stopped being true; it is retitled and rewritten to what it actually covers. Its "not cleared" assertion read `not.toBeNull()` against a `lastError` the refresh had overwritten, so it passed on a different string than the one it was about; it now names the transport's own sentence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
`listTools` now reads `annotations.destructiveHint`, so an MCP listing can fill in `effect` and `destructive` — and the docblock in this file said the opposite: that those three columns are Composio's and every other transport "has to keep coming out of that insert as null and false". Its own test still passed, because its fixtures carry no annotations; a comment nothing can falsify is how this branch has repeatedly ended up with code written to match it. Narrowed to what that test is actually about. The new test states the asymmetry the transport implements. A tool declaring `destructiveHint` records `write` and `true`. Two tools declaring `readOnlyHint` — one on Notion's reviewed write list, one not — record NULL, which is the assertion that matters: both classify correctly whatever the column holds, so only an empty column says the hint was never read. That is what keeps `readOnlyHint` from becoming an opt-out for a server an administrator added by URL, where `classifyTool` answers from the column before it reaches "no reviewed list means everything is a write". The mock hands its fixtures back verbatim, so annotations already travelled at runtime and were only unspellable in `MCPToolDefinition`; the helper's parameter is widened rather than each fixture cast. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
`ServerRowAmbiguousError` refuses a row whose provenance says composio and whose id is a curated slug, and it was caught nowhere. The refresh route rethrew it into the default handler — a 500 with no JSON, which the admin page renders as "That did not work" — while `grantedTools` copied its message into a model's context, so a sentence naming a column of ours and telling the reader to correct it became a Bot's explanation to an end user. The one audience that could act on it saw nothing; the one that could not saw all of it. The distinction the codebase already draws by hand is now asked once, as `isDeploymentFault`: `PluginRefusedError` is the class relayed verbatim because the asker can act on it, and this is its opposite. The refresh route, which is admin-gated, answers 409 with the sentence in full. `grantedTools` answers "That tool could not be called." and nothing more. The tool-call route, which is not admin-gated, answers 500 with a neutral sentence instead of 502 and `failed: true`, which asserted a vendor had been reached. Landed with the second refusal on that shelf rather than after it, because the shelf is what makes either of them safe. A catalogue entry may no longer declare `transport: "composio"`: `CuratedTransportKind` makes it a compile error, and `accessFor` refuses it at resolution for when the type is bypassed. Unrefused it produced a Composio dial with `toolkit: null` and a credential taken from the entry's auth kind, so both gates that keep one person's brokered account out of another's were keyed on a null app and skipped, and the trail named whose account had been reached from a field unrelated to it. No entry declares it today, which is why this is a door being shut. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
A vendor naming one action twice hits `mcp_tools`' `(server_id, name)` primary key, and a U+0000 anywhere in what a vendor wrote is a byte PostgreSQL will not encode. Both aborted the wholesale replace from inside its transaction, which sits outside the vendor `try` above it, so what left `refreshTools` was drizzle's `DrizzleQueryError` — `Failed query:` plus the whole insert, then `params:` and every value bound to it. That is the disclosure shape of a credential leak one layer out, and it went to the logs and to any caller that prints an error while `lastError` kept whatever it held before. Neither reaches a statement now. `storableTools` drops a name the vendor listed twice, keeping the first occurrence because nothing says which of two identical names is real, and strips U+0000 from the name, the description, the version and the schema. What is left is a transaction failing for reasons that are genuinely not the vendor's — which is what the comment on the replace has always claimed — and that is raised as a `PluginInvariantError` carrying the driver's own complaint and none of the query, through `databaseComplaint`. The count returned and the set the stranded-grant audit compares against are both taken from what was stored rather than from what was listed, so a duplicate is one action in both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
`mcp.account_disconnected` is written by the two acts that can end a brokered
connection, and they keyed it differently: offboarding on the connection's
toolkit, removing the app on `mcp_servers.id`. Nothing holds those two strings
equal — the id is a display key and the slug in the url is what the broker is
asked about — so on any renamed row half the trail is filed under a name the
other half never mentions and no single query answers what happened to one
person's brokered access. Both were written by recent waves, and every fixture
in this suite spelled the two the same, which is why it went unseen.
The app is the key, because the app is what was consented to: the gate is
`(toolkit, user_id)`, the delete is by toolkit, and the row outlives the server
row entirely, so the id may not exist by the time anybody asks. Which server
row was removed is still recorded — the `configuration.changed` row written in
the same call names it.
ALSO CARRIES ONE CHANGE THAT BELONGS TO THE COMMIT BEFORE IT, in the same file
and named here rather than left to be found: `storableTools` reads
`.replaceAll` off a description and stringifies a schema, where the mapping it
replaced passed both straight through — so a transport handing back undefined
used to get the `""` and `{}` those columns default to, and would now get a
TypeError thrown from outside the vendor `try`. Both fields are required by
`McpTool` and supplied by every transport here, so this restores the tolerance
the insert already had rather than adding an answer of its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
The refuse-to-run guard, the per-test sweep and the teardown each decided for
themselves what makes a `composio_connections` row this file's, and all three
answered differently. Two asked by PERSON across every app; one test's cleanup
asked by the ANONYMOUS ACTOR across every app; the pair `("gmail", "")` was in
none of them. So the file refused to run over rows it does not create, deleted
rows it does not create, and left behind one that it does.
Both halves of the key, once, as `ownedConnections`: every connection row this
file writes is at `gmail`, for the two people it invents and the anonymous
actor alike. A `("slack", "user_asker")` row is now neither refused over nor
swept — and a `composio_connections` row is the entire gate on a brokered
call, referenced by nothing, so deleting one is not recoverable by any
operation the product has.
One test did genuinely depend on owning `user_asker` at every app: it asserted
a refusal because the row's url dialled `slack` and nobody had connected
`slack`, which a real deployment row would have turned into a completed call
reading as this gate being broken. The app it dials is now suite-scoped, which
is what the property actually needs. The `user_leaver` select beside it is
narrowed to the pair for the same reason.
The witness row at `sweep_witness_${suite}`, which this file added and only its
own `finally` removed, joins the teardown by its exact suffixed name: nothing
else could reach it — the sweep is keyed on `gmail`, and the anonymous actor is
what `retireConnectionsFor` refuses to act on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
The tool-list replace was fixed at its own site; every other query of ours
throws the same `DrizzleQueryError` — `Failed query:` plus the statement, then
`params:` and every bound value — into a `catch` that copies `error.message`
onward. On the call path those values are credential ids, user ids and server
ids, and the message reached three audiences at once: a model's tool result
("That tool could not be called: <our SQL>"), the `failure` field of the
`mcp.call_failed` audit row, and a signed-in browser as a 502 labelled
`failed: true`, which also asserted a vendor had answered. On the refresh path
it went into `lastError`, the column the Plugins page draws as what the vendor
said.
`isDeploymentFault` now answers for a query failure too, recognised by shape:
`query` and `params` as own properties of an `Error`. By shape and not by
class deliberately — drizzle's class is reachable only through a deep import
that is not part of its published surface, and the shape is what makes the
message dangerous. `withoutStatement` is what every site that copies a message
now asks, and `deploymentFaultSentence` is what the admin route shows.
The narrowing in the refresh's vendor `catch` was DEAD: it tested for
`PluginInvariantError`, and its own comment named two throws that cannot
arrive there — `connectionTokenFor` is not called for `composio`, the only
brokered transport, and the `person-oauth` narrowing is unreachable because
`accessFor` answers that credential only for a `user-oauth` entry. It could be
deleted with every test green while the arrival it should have caught went
past it into the column. It asks about the whole shelf now, and the query
failure is the member that actually gets there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Adding a server refreshes it before answering — deliberately, so a bad credential is reported now rather than the first time a Bot uses it — so everything `refreshTools` raises arrives on the add routes too, and the two faults added in this branch are both reachable there: a vendor listing one action twice, and a query of ours failing. Registering an OAuth client resolves the row first, so a row whose columns contradict each other refuses there as well. All three mapped only `CatalogueEntryUnknownError` and `CustomServerRefusedError`, so the same fault was a named sentence on the refresh button and "That did not work" on the add form. Mapping one route and not its siblings is the shape that made this class hard to see the first time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
`removeServer` reads liveness from `credentials` before asking the vault to revoke, and nothing asserted it. The test beside this one inserts a live row and takes the true branch; the one after it has no credential at all and never runs the query. So `isNull(revoked_at)` could be dropped with the whole suite green — while in production `credentials.revoke` throws "not found or already revoked", which propagates before `delete(mcpServers)` and leaves a server row no number of attempts can remove, on a DELETE route with no catch. A previous removal that failed after the revoke, or a key rotated by hand, both produce the row. Asserted as "the vault was not asked" rather than as the absence of a throw: the stub here is deliberately forgiving, so a test waiting for it to complain would pass with the clause gone. What the read decides is whether the call is made at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
The transport's execute took its app, action, person and version positionally and now takes them as one named record, so slug and toolkit cannot silently transpose. These stubs still destructured positionally, which bound the whole record to what they called the slug — so the assertions about which person and which version reached the vendor were comparing against an object. Re-proved by mutation rather than by the suite going green: passing the Bot's id where the asker's belongs still reddens both identity tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
The paragraph said the optional fields on a listing were ones a broker publishes and an MCP server does not, and that mcp.ts read none of them. Both were false. The MCP specification defines a destructive hint, servers publish it, and it was being dropped -- so a tool its own vendor called destructive classified as a read wherever a curated write list had omitted it. Says what is true now, including which hint is deliberately not read and why: a hint may narrow what a Bot may do and may never widen it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
The rebase brought main's initiator_kind alongside our actor field, and a run nobody can be named for now writes person as the initiator and unattributed as the actor. That reads as a contradiction and is not one: one field says what set the run in motion, the other says whose account it reached. Recorded rather than resolved. DEPLOYMENT_INITIATOR's own sentence claims the unidentifiable-caller case and would answer it the other way, but nothing sends it there, so the overlap is in the prose. Narrowing that sentence, or adding a third initiator kind, is not this branch's call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
Three files gained members appended after the type members of an existing import rather than in sorted position. No workflow runs biome's assist domain, so nothing would have caught it, and the pre-push gate found it by measuring this branch against main rather than by trusting that the findings were old. Applied by biome's own safe fix; no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x
mxmzb
force-pushed
the
composio-transport
branch
from
September 10, 2026 16:50
4008ffe to
21218e2
Compare
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 adds
Composio becomes a fourth way a connector is reached, alongside MCP, the Google Drive REST adapter, and Routines.
An app an operator enables is an ordinary
mcp_serversrow markedprovenance = "composio"with urlcomposio://<toolkit>. A new transport dials it, and each call runs in the asking person's own account: the deployment holds one key for the broker, and which person's Gmail or Slack it opens is decided entirely by the user id sent alongside that key — taken from their authenticated session, never from anything a model supplied.Everything downstream is untouched. The permission check, the rule engine and the audit row work exactly as they do for Drive and Notion.
Read this first: steps 1 and 2 only, deliberately
Nothing in the shipped product installs a Composio client, reads
COMPOSIO_API_KEY, enables an app, or creates a connection row. That is intended for this change and it has a visible consequence: the "you have not connected this app" refusal is reachable, while the thing it tells you to go and do does not exist yet.Concretely, so nobody has to discover it:
useComposioClienthas no caller underserver/src, so on any real deployment the client is null and every Composio path answers empty or refuses.@composio/coreis therefore indevDependencies, notdependencies— only the opt-in live test imports it. Step 3 must move that one line back and re-runbun installthe moment a file underserver/srcimports the SDK. Watch for that import; it is the trigger.COMPOSIO_API_KEYentry was added to.env.example. An entry there is a promise the running product reads the variable, and nothing does..env.exampleis byte-identical to main.docker-compose.yml,charts/openbot/templates/_helpers.tplanddocs/configuration.mdall need the key forwarding, and@composio/clientresolves to an alpha (0.1.0-alpha.76, pinned by@composio/core) which will then enter the production image. Exact text for all four is in the review ledger.The guarantee, and how it is held
The branch's central claim is that three previously independent derivations — which protocol dials a row, whose credential it spends, whose name the audit trail records — plus a fourth nobody had noticed (which app at the broker a row is) are now resolved once, in
access.ts, and read as fields everywhere else.Each of these is gated by a test that fails when the guarantee fails, verified by mutating the source and watching the test die rather than by the suite being green:
__versionin the model's own arguments cannot stand in for a recorded one, and cannot choose which revision of an action runs.Behaviour changes outside Composio
Two, both small and both corrections:
An MCP server's declared effect is now believed. The MCP specification defines
annotations.destructiveHint, servers publish it, and the listing code was dropping it — so a tool its own vendor called destructive classified as a read wherever a hand-written write list omitted it. That hint is now read.readOnlyHintis deliberately not read: the SDK's own types warn against making tool decisions from annotations sent by untrusted servers, and honouring it would let anyone who can add a server by URL declare everything read-only and be believed. Drive is unaffected by construction — it never enters that code path. For Notion the only cell that changes is a destructive-declared tool absent from the reviewed list; nothing can move toward read.A failed query no longer carries its statement onward. Several call sites copied a database error's message into
last_errorand into the audit payload, which meant a failed query put its SQL and parameters in both. Deployment faults are now recognised by shape and raised rather than relayed.Migration
0029_composio— acomposio_connectionstable and three columns onmcp_tools(effect,destructive,version).It was
0028until this rebase; main had already taken that number. The snapshot is regenerated rather than renamed, because a rename alone would have left the newest snapshot describing a database missing the two columns main's own0028adds — which would make the next schema generation emit commands to drop them. That was reproduced before being fixed, and the drift probe is clean now.composio_connections.user_idhas no foreign key, by design. The row must outlive the person so offboarding can find it; that is the whole reason the table exists rather than reusing the existing credential join. Offboarding now deletes those rows and audits each one — note it shuts the gate this deployment owns and does not revoke at Composio, which thevendorRevoked: falsefield records honestly.Review
Two full unbiased review rounds, then a targeted fix cycle.
Round one produced thirty mandatory findings, all fixed. Round two — the byte-identical confirmation round — produced about twenty-five, roughly half of them gaps in round one's own fixes, and the correctness half of those is fixed here. Its most consequential finding is worth naming, because it is the kind only a second round catches: the first round fixed a data-loss bug where an empty listing wiped every tool row, and a later fix made the transport throw instead of returning empty. Both were correct. Together they routed the first round's tests around the guard, so it could be deleted outright with the suite still green. It is genuinely gated now.
Two recurring causes account for most of the rest, and both are worth knowing when reading this diff: a comment asserting something the code or the dependency does not do, with code then written to match the comment; and a test that passes regardless of the behaviour it names. One false sentence in an interface's own documentation produced both a real bug and the two test stubs that could not catch it.
The remaining test-quality tail is deliberately deferred and listed in full in the review ledger — assertions comparing a value to itself, loops that pass vacuously on an empty set, and pre-existing debt in the files this branch happens to touch.
Known and deliberately not fixed here
initiator_kind: "person"besideactor: "unattributed". Not a contradiction: one field says what set the run in motion, the other whose account it reached. Recording it asdeploymentwould assert the call went out on the deployment's credential when it never went out at all. Main'sDEPLOYMENT_INITIATORdocumentation claims that case and nothing sends it there, so the overlap is in the prose; narrowing that sentence is not this branch's call.Verification
bun test server/tests— 2071 pass, 4 skip, 0 fail across 136 files. The 4 skips are the live vendor suite, which needs an API key. Test count accounts exactly: 1841 at the merge base, +83 from main, +151 from this branch.typecheck,format:check,lintclean.drizzle-kit checkclean; the CI drift probe reports no unwritten migration.0028on a scratch database, landing both sets of columns.🤖 Generated with Claude Code
https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x