Narrow a Bot's tools in the query, not after reading every one - #184
Conversation
`listForAgent` selected every row in `mcp_tools` and then discarded the ones the Bot was not granted, with a linear `includes` over the grants per row. It is the run-time path: it runs on every run of every Bot, before anything else, and it sits underneath the tool selection added in CopilotKit#178 — so its cost is paid first, on exactly the large catalogues that change exists to make work. At the thousand tools CopilotKit#119 names as the target, that is a thousand rows across the wire and a thousand walks of the grant list to offer a handful. The query now names the servers the Bot holds something from, which is a predicate the `(server_id, name)` primary key can use, and the grant list is a Set rather than an array. `knownToolRefs` in this same file already did it this way, with the same reasoning written above it; this is the run-time half catching up with the save-time half. Narrowing by server rather than by exact pair on purpose. A clause per grant would be exact, and a server's own tool list is already the bound on what comes back, so it buys little for a where clause that grows with the grants. The exact ref is still matched after the read, and that match is now load-bearing in a way it was not before: narrowing by server alone would offer every tool of any server the Bot holds anything from. There is a test for that, because every other test in the file passes without it. Behaviour is unchanged. Same tools, same order, same refusals.
davidmckayv
left a comment
There was a problem hiding this comment.
Validated on a running deployment, not just read.
Broke the exact-ref match to check the new test earns its place — narrowing by server while dropping the per-ref filter, which is the mistake it exists to catch. Two go red, including yours. The property is held by a test rather than by the reader noticing.
Then drove it in Chrome. Two MCP servers, sixteen tools granted to one Bot, and a ninth tool on acme-docs — a server the Bot IS granted from — that nobody was granted. If narrowing by server had widened the offer, that is the tool that would appear.
Asked the Bot to enumerate what it can call:
mcp__acme-docs__document_history mcp__acme-docs__document_metadata
mcp__acme-docs__export_document mcp__acme-docs__find_document
mcp__acme-docs__list_folders mcp__acme-docs__list_recent_documents
mcp__acme-docs__read_document mcp__acme-docs__search_by_owner
Eight. No delete_everything. The audit agrees — selected 8/16, sixteen granted rather than seventeen — and a real find_document call went through after it, so the run-time path still executes as well as filtering.
Merged onto current main, which has moved since your CI ran: 1303 passing, typecheck and lint clean.
The part I would not have thought to write is the note that the exact match became load-bearing in a way it was not before. It was belt-and-braces when the query read everything; it is the boundary now. Saying so where the code changed is what stops somebody removing it later as redundant.
Note: this had never run CI — fork PRs sit at action_required until a maintainer releases the workflow. I approved the run.
What this changes
The one I flagged on #119 and said I would send separately.
listForAgentselected every row inmcp_toolsand then dropped the ones the Bot was not granted:It is the run-time path. It runs on every run of every Bot, and it sits underneath the selection added
in #178 — so its cost is paid before the narrowing that exists to make large catalogues work, on
exactly the deployments that have one. At the thousand tools #119 names as the target that is a
thousand rows across the wire, plus a linear
includesover the grant list per row, to offer a handful.The query now names the servers the Bot holds something from, which is a predicate the
(server_id, name)primary key can use, and the grant list is aSetrather than an array.knownToolRefs, forty lines up in the same file, already did exactly this, with the reasoning alreadywritten above it:
So this is the run-time half catching up with the save-time half, in that half's own idiom rather than
a new one.
Narrowing by server rather than by exact pair, on purpose. A clause per grant would be exact, but a
server's own tool list is already the bound on what comes back, so it buys little for a
wherethatgrows with the number of grants. The exact ref is still matched after the read — and that match is now
load-bearing in a way it was not before: narrowing by server alone would offer every tool of any server
the Bot holds anything from, which is a grant it never had.
Behaviour is unchanged. Same tools, same order, same refusals.
Where it runs
Boundary and audit
rows of the catalogue are fetched to describe them.
grants ∩ catalogue. Whatchanged is that the intersection's second half now also decides what is read.
deleted and not yet rewritten a server's tools — still yields nothing for that ref, which is the
behaviour
knownToolRefsdocuments as deliberate.Changelog
No line. A deployment behaves no differently afterwards: the same Bots are offered the same tools in
the same order. This is the same query answered without reading rows it then threw away.
Proof
One test added to
server/tests/plugin-store.integration.test.ts: a second tool on the same server,granted to nobody, and holding one tool from that server must not offer it.
That test is the point of the PR rather than decoration. Widening the narrowing to "every tool on a
server you hold anything from" passes every other test in the file — the Bot is still offered what it
holds, and the stranger is still offered nothing — so nothing there would have caught it.
What I have not run. This is the integration suite and I have no local PostgreSQL — Docker Desktop
is on this machine but its engine will not come up, so I could not stand one up this time either. CI
is where these first execute. The rest of that file's assertions are untouched, and the one I added
uses the fixtures already there plus one row, cleaned up suite-scoped the way that file is careful
about —
google-drive/search_filesis a real ref, so the new fixture is namednot_granted_<suite>and deleted by exact name rather than by server.
Say the word if you would rather see it green on a live database before it reaches you and I will get
one up rather than have you find out from CI.
What is not covered
tools to time, and I would rather say that than quote a microbenchmark of my own fixtures.
knownToolRefsis left alone. It is the save-time path, called once when a skill is written, and itslooser narrowing is fine there for the reason its own comment gives.