Skip to content

Say which of a connector's granted tools it no longer offers - #187

Merged
davidmckayv merged 1 commit into
CopilotKit:mainfrom
Hotragn:say-which-grants-a-connector-no-longer-offers
Aug 23, 2026
Merged

Say which of a connector's granted tools it no longer offers#187
davidmckayv merged 1 commit into
CopilotKit:mainfrom
Hotragn:say-which-grants-a-connector-no-longer-offers

Conversation

@Hotragn

@Hotragn Hotragn commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

What this changes

Closes #106.

A grant names serverId/toolName, and listForAgent reads it against the tool list, so a grant on a
tool the vendor has stopped advertising reaches no model. As #106 says, that is a property of the
transport rather than of the grant — and google-drive is one line from proving it. The entry's own
comment:

This host has been generally available since 2015. The MCP entry is one line away: set transport
back to mcp and restore the host and path above. Tool names match Google's MCP server exactly, so
grants survive the swap in either direction.

Nothing said it was happening. listServers asked grantsFor about the refs of the tools it had just
listed, which can only ever return a subset of those — so a grant on a withdrawn tool appeared
nowhere at all, on the one screen an administrator reads to answer what a Bot may do.

Reported, not pruned

You asked for this to be decided deliberately. Pruning is the wrong answer, and the reason is stronger
than "don't delete somebody's decision": refreshTools is the only place the prune could go, and it is
not a safe place to decide from.

The tool list is replaced by a bare delete and then an insert, so a failure between them already
leaves a server with no tools. Worse, the bad case is not a failure at all — a vendor answering
successfully with an empty list deletes every tool row, sets lastError to null, and stamps
toolsRefreshedAt. So with pruning, one empty answer from Google silently revokes every Drive grant in
the deployment and the trail records a healthy refresh. That converts a visible, inert discrepancy into
an invisible, destructive one.

So, three parts:

  1. listServers reports them. A new mcpGrantsForServers asks for every MCP grant belonging to
    these servers, matched on the server half in the query (split_part, not a LIKE prefix — a custom
    server's id is text a person chooses, and a % in one would widen the match). Advertised refs stay
    grantedTo on the tool; the rest become withdrawn.
  2. The connector's page draws them, under "Held but not offered", with how many Bots hold each. Its
    own section rather than rows in Tools, because these are not tools the vendor listed and folding
    them in would make the count above wrong. Not drawn at all when empty, which is the normal case.
  3. A refresh that leaves any behind writes a row. This is the part that answers the transport-swap
    worry rather than only displaying it: the discrepancy enters the trail when it arises, so flipping
    transport back to mcp is preceded by a record that those grants were sitting there, instead of
    the only record of the gap being its absence. configuration.changed against the server, naming the
    refs and the Bots — nothing was denied and the refresh succeeded, so it did not seem worth minting an
    event type. Say the word if you would rather it were its own so it is filterable.

An explicit "drop these" action for an administrator is now cheap, and deliberately left undone:
revoking should stay something somebody decided.

Where it runs

  • New state that outlives a request? None. No schema change, no migration.
  • What happens on the second replica? Identical. Two reads and, on a refresh that finds strays, one
    audit insert. Nothing held.
  • Anything serialised? No. The audit row is a plain insert and duplicates are harmless — two
    administrators refreshing at once leave two rows saying the same true thing, which is what the trail
    is for.
  • Anything fanned out to a browser? No. The page reads it on load like the rest of the payload.
  • New listener, port, or schedule? No.

Boundary and audit

  • No boundary moves, and no grant is honoured that was not before. The offered set is still
    grants ∩ advertised tools; this reports the difference rather than changing it. There is a test
    asserting exactly that, because "reporting a grant" turning into "honouring one" is the way this
    change could go wrong.
  • New row, new type of fact: configuration.changed with change: "grants_not_advertised". It is
    not a refusal — nothing was denied — which is why it is not one.
  • Nothing new is trusted from the client. This is all server-derived; the page renders what it is told.

Changelog

Added under Unreleased: a connector says which of its granted tools it no longer offers, the grants
are untouched, and nothing changes for a connector whose grants all match its tool list.

Proof

Two tests in server/tests/plugin-store.integration.test.ts. A tool is advertised, granted, then
withdrawn the way a refresh withdraws one, and then:

  • it is absent from tools and present in withdrawn, with the holding Bot named;
  • listForAgent still does not offer it — the assertion that fails if this had quietly become a
    grant;
  • a connector whose grants all match reports withdrawn empty, because a field only ever exercised
    non-empty is one whose empty shape nobody has checked, and a screen keys off it being empty.
server typecheck    exit 0
app typecheck       exit 0
biome format + lint clean, checked against the committed tree

What I have not run. These are integration tests and I still have no local PostgreSQL — Docker
Desktop is installed on this machine but its engine will not start and WSL2 integration is off, so CI
is where they first execute. I also have not driven the new section in a browser; it is an Item list
in the same shape as the Tools section directly above it, but I have not seen it draw. Say so if you
want either before it lands and I will get a stack up rather than have you find out from the screen.

What is not covered

  • The count in Grants survive a tool being withdrawn, so a transport swap could re-enable writes #106 is yours, not mine. I can confirm the mechanism end to end — the REST adapter
    advertises exactly four names, create_file and copy_file are in writeTools, the swap is one
    field — but I have no deployment holding those four grant rows, so I have not seen them.
  • refreshTools is still not one transaction. I found that while arguing against pruning and have
    left it alone: it is a real gap, it is not this issue, and folding it in would bury the decision you
    asked for under a rewrite of that function. Worth its own issue if you want one.
  • Nothing is reported for a server that was removed entirely. Removing a connector already deletes
    its grants (the switch on this page says so), so there is no stray to report.
  • The section is per-connector. A deployment-wide "everything held but not offered" view would be a
    different screen, and I did not invent one.

@Hotragn
Hotragn force-pushed the say-which-grants-a-connector-no-longer-offers branch from 9e590b4 to 0866a4e Compare August 22, 2026 21:56
@Hotragn

Hotragn commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Merge note. This and #189 both add a section directly under ## Unreleased, so whichever of the
two lands first will leave the other conflicting on CHANGELOG.md and nothing else — I checked with
git merge-tree rather than assuming. Both sections are keepers; resolving is taking both, in either
order.

No other file is shared: this one is server/src/plugins/store.ts and the plugins page, #189 is
server/src/copilot.ts. I will rebase whichever is second as soon as the first merges, so no need to
sequence them on my account.

davidmckayv
davidmckayv previously approved these changes Aug 23, 2026

@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.

Drove it rather than read it.

Granted google-drive/delete_file to a Bot — a tool the connector does not advertise — and loaded the connector page. The new section appears:

Held but not offered
This vendor no longer lists these, so no Bot is told about them and no model can call one. The grant is still recorded, and the tool would be offered again if the vendor started listing it. Revoke from the Bot's own page if that is not what you want.
delete_file — Not listed by Google Drive as of the last refresh. 1 of 5 Bots

Before this, that grant was invisible on the one screen an administrator reads to answer what a Bot may do. A page that shows part of the answer as though it were all of it is the failure worth fixing here.

Refusing to prune is the right call and worth the comment it got: refreshTools cannot tell "the vendor withdrew a tool" from "the vendor returned an empty list", so pruning there would let one bad response revoke every grant and report the refresh healthy.

One thing to tidy, not blocking: the grant lookup and audit write sit inside refreshTools' try, so a failed audit write marks a successful refresh as failed and zeroes the tool count. Elsewhere the repo catches audit-write failures rather than letting them fail the operation. Worth a follow-up.

Checks: stacked with #189, #191 and #114 on current main; typecheck, lint and format clean, 1372 tests pass, 0 fail.

Closes CopilotKit#106.

A grant names `serverId/toolName`, and `listForAgent` reads it against the tool
list, so a grant on a tool the vendor has stopped advertising reaches no model.
That is a property of what the vendor advertises today rather than of the grant.
`google-drive` is one line from proving it: the entry's own comment says the REST
transport can be swapped back to MCP and that "tool names match Google's MCP
server exactly, so grants survive the swap in either direction", so a name that
resolves to nothing today resolves again when it does.

Nothing said it was happening. `listServers` asked for grants on the refs of the
tools it had just listed, which can only ever return a subset of those — so a
grant on a withdrawn tool appeared nowhere, on the one screen an administrator
reads to answer what a Bot may do.

Reported, not pruned, which is the decision the issue asked to be made
deliberately. `refreshTools` is the only place a prune could go and it is the
wrong place: the tool list is replaced by a `delete` and then an `insert`, so a
failure between them already empties a server, and a vendor answering with an
empty list is not a failure at all — every tool row goes, `lastError` is set to
null and the refresh is stamped as healthy. Pruning there means one bad answer
from a vendor silently revokes every grant on that connector and the trail says
the refresh went fine. That turns a visible, inert discrepancy into an invisible,
destructive one.

So: `listServers` reports them, the connector's page draws them under "Held but
not offered", and a refresh that leaves any behind writes a `configuration.changed`
row naming the refs and the Bots. The row is the part that answers the transport
swap rather than only displaying it — the discrepancy enters the trail when it
arises, instead of the only record of the gap being its absence.

An explicit "drop these" action for an administrator is now cheap and is left
undone, because revoking should stay something somebody decided.

Grants are untouched, so the run-time behaviour is unchanged. There is a test for
that specifically: reporting a grant must not become honouring one.
@davidmckayv
davidmckayv force-pushed the say-which-grants-a-connector-no-longer-offers branch from 0866a4e to 9125618 Compare August 23, 2026 22:39
@davidmckayv
davidmckayv merged commit 2c9d5e8 into CopilotKit:main Aug 23, 2026
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.

Grants survive a tool being withdrawn, so a transport swap could re-enable writes

2 participants