Skip to content

Narrow a Bot's tools in the query, not after reading every one - #184

Merged
davidmckayv merged 1 commit into
CopilotKit:mainfrom
Hotragn:narrow-the-catalogue-in-the-query
Aug 22, 2026
Merged

Narrow a Bot's tools in the query, not after reading every one#184
davidmckayv merged 1 commit into
CopilotKit:mainfrom
Hotragn:narrow-the-catalogue-in-the-query

Conversation

@Hotragn

@Hotragn Hotragn commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

What this changes

The one I flagged on #119 and said I would send separately.

listForAgent selected every row in mcp_tools and then dropped the ones the Bot was not granted:

const toolRows =
  toolRefs.length === 0
    ? []
    : await database.select().from(mcpTools).orderBy(asc(mcpTools.name));
const grantedTools = toolRows
  .filter((row) => toolRefs.includes(`${row.serverId}/${row.name}`))

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 includes over 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 a Set rather than an array.

knownToolRefs, forty lines up in the same file, already did exactly this, with the reasoning already
written above it:

Narrowed in the query to the servers actually named, rather than reading the whole catalogue and
filtering here. A deployment aiming at a thousand tools should not scan all of them to check three.

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 where that
grows 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

  • New state that outlives a request? None. One query became a narrower query.
  • What happens on the second replica? Identical, and cheaper on each. Nothing is cached or held.
  • Anything serialised? No writes.
  • Anything fanned out to a browser? No.
  • New listener, port, or schedule? No.

Boundary and audit

  • No boundary moves. The grant rows are still what decides, read exactly as before; this changes which
    rows of the catalogue are fetched to describe them.
  • Strictly harder to widen a Bot than it was. The offered set is still grants ∩ catalogue. What
    changed is that the intersection's second half now also decides what is read.
  • No new refusal, no new audit event. A ref whose tool row is absent — the window where a refresh has
    deleted and not yet rewritten a server's tools — still yields nothing for that ref, which is the
    behaviour knownToolRefs documents 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.

server typecheck   exit 0
biome format + lint   clean on both files, checked against the committed tree

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_files is a real ref, so the new fixture is named not_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

  • No measurement. I am claiming a smaller query, not a number: I have no deployment with a thousand
    tools to time, and I would rather say that than quote a microbenchmark of my own fixtures.
  • knownToolRefs is left alone. It is the save-time path, called once when a skill is written, and its
    looser narrowing is fine there for the reason its own comment gives.

`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 davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@davidmckayv
davidmckayv merged commit 79dbd57 into CopilotKit:main Aug 22, 2026
8 checks passed
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.

2 participants