Skip to content

Reach a person's own Gmail, Slack or Linear through Composio - #481

Draft
mxmzb wants to merge 93 commits into
mainfrom
composio-transport
Draft

Reach a person's own Gmail, Slack or Linear through Composio#481
mxmzb wants to merge 93 commits into
mainfrom
composio-transport

Conversation

@mxmzb

@mxmzb mxmzb commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

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_servers row marked provenance = "composio" with url composio://<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:

  • useComposioClient has no caller under server/src, so on any real deployment the client is null and every Composio path answers empty or refuses.
  • @composio/core is therefore in devDependencies, not dependencies — only the opt-in live test imports it. Step 3 must move that one line back and re-run bun install the moment a file under server/src imports the SDK. Watch for that import; it is the trigger.
  • No COMPOSIO_API_KEY entry was added to .env.example. An entry there is a promise the running product reads the variable, and nothing does. .env.example is byte-identical to main.
  • When step 3 lands, docker-compose.yml, charts/openbot/templates/_helpers.tpl and docs/configuration.md all need the key forwarding, and @composio/client resolves 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:

  • The id sent to the vendor is the asker's. Send the bot's instead and two tests fail naming both ids.
  • A __version in the model's own arguments cannot stand in for a recorded one, and cannot choose which revision of an action runs.
  • A brokered call is refused when nobody is attributed, and when the asker has not connected the app — including when a colleague has. Drop the actor term from that gate and it becomes "has anybody connected this", handing the broker a stranger's id.
  • A curated catalogue entry cannot be shadowed into a broker by a tampered provenance column, and a brokered row whose id collides with a curated slug is refused rather than dialled at the curated vendor on the wrong credential.
  • A recorded effect may narrow what a bot may do and may never widen it: a reviewed write list outranks a value in a vendor column, and an unrecognised or empty value is a write.

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. readOnlyHint is 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_error and 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 — a composio_connections table and three columns on mcp_tools (effect, destructive, version).

It was 0028 until 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 own 0028 adds — 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_id has 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 the vendorRevoked: false field 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

  • A brokered row's app is resolved from its url, but the vendor's own API has no field for it — the call identifies the app by the action slug alone. Our seam now passes app and action as a named pair so they cannot silently transpose, and states the obligation to refuse a mismatch for whoever writes the real adapter.
  • A run nobody can be identified for records initiator_kind: "person" beside actor: "unattributed". Not a contradiction: one field says what set the run in motion, the other whose account it reached. Recording it as deployment would assert the call went out on the deployment's credential when it never went out at all. Main's DEPLOYMENT_INITIATOR documentation claims that case and nothing sends it there, so the overlap is in the prose; narrowing that sentence is not this branch's call.
  • Delete-and-recreate restores permissions in three other places — MCP servers, skills, and bot-to-bot grants. Real, out of this subject, and written up for follow-up.

Verification

  • bun test server/tests2071 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, lint clean.
  • drizzle-kit check clean; the CI drift probe reports no unwritten migration.
  • The migration applies cleanly on top of main's own 0028 on a scratch database, landing both sets of columns.

🤖 Generated with Claude Code

https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x

mxmzb and others added 25 commits September 10, 2026 18:49
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
mxmzb and others added 29 commits September 10, 2026 18:49
… 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
mxmzb force-pushed the composio-transport branch from 4008ffe to 21218e2 Compare September 10, 2026 16:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant