Skip to content

Commit 79dbd57

Browse files
authored
Narrow a Bot's tools in the query, not after reading every one (#184)
`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 #178 — so its cost is paid first, on exactly the large catalogues that change exists to make work. At the thousand tools #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.
1 parent 0ef5b01 commit 79dbd57

2 files changed

Lines changed: 65 additions & 3 deletions

File tree

server/src/plugins/store.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,12 +1050,34 @@ export function createPluginStore(options: PluginStoreOptions) {
10501050
.filter((row) => row.kind === "skill")
10511051
.map((row) => row.ref);
10521052

1053+
/*
1054+
* Narrowed in the query to the servers this Bot is actually granted something from, the same way
1055+
* `knownToolRefs` does it and for the same reason: a deployment aiming at a thousand tools should
1056+
* not read all of them to offer a handful. This is the run-time path, so it ran on every run of
1057+
* every Bot, selected every row in `mcp_tools`, and then discarded almost all of them here — and
1058+
* it sits underneath tool selection, so its cost is paid before the narrowing that was added to
1059+
* make large catalogues work.
1060+
*
1061+
* The exact ref is still matched below rather than in the query. Narrowing by server is a
1062+
* predicate the composite primary key can use; naming every (server, tool) pair would be exact
1063+
* and is not worth a clause per grant, because a server's own tool list is the bound on what
1064+
* comes back.
1065+
*/
1066+
const grantedServers = [
1067+
...new Set(toolRefs.map((ref) => ref.split("/")[0] ?? "")),
1068+
];
10531069
const toolRows =
1054-
toolRefs.length === 0
1070+
grantedServers.length === 0
10551071
? []
1056-
: await database.select().from(mcpTools).orderBy(asc(mcpTools.name));
1072+
: await database
1073+
.select()
1074+
.from(mcpTools)
1075+
.where(inArray(mcpTools.serverId, grantedServers))
1076+
.orderBy(asc(mcpTools.name));
1077+
// A set, so this is a lookup per row rather than a walk of the grants per row.
1078+
const granted = new Set(toolRefs);
10571079
const grantedTools = toolRows
1058-
.filter((row) => toolRefs.includes(`${row.serverId}/${row.name}`))
1080+
.filter((row) => granted.has(`${row.serverId}/${row.name}`))
10591081
.map((row) => {
10601082
const ref = `${row.serverId}/${row.name}`;
10611083
return {

server/tests/plugin-store.integration.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ const strangerId = `agent_plugin_stranger_${suite}`;
3535
const serverId = "google-drive";
3636
const toolName = "search_files";
3737
const ref = `${serverId}/${toolName}`;
38+
/** A tool on the same server that nobody is granted. Suite-scoped, so it is never a real one. */
39+
const siblingToolName = `not_granted_${suite}`;
3840

3941
let policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] };
4042

@@ -136,6 +138,22 @@ beforeAll(async () => {
136138
.insert(mcpTools)
137139
.values({ serverId, name: toolName, description: "Search files." })
138140
.onConflictDoNothing();
141+
/*
142+
* A second tool on the SAME server, granted to nobody.
143+
*
144+
* `listForAgent` narrows to the servers a Bot holds something from and then matches the exact ref,
145+
* and this is what makes the second half load-bearing: without it, holding one tool from a server
146+
* would offer every tool that server has. Suite-scoped, so it is unambiguously a fixture and
147+
* cannot collide with a name the vendor really advertises.
148+
*/
149+
await database
150+
.insert(mcpTools)
151+
.values({
152+
serverId,
153+
name: siblingToolName,
154+
description: "A tool on the same server that nobody was granted.",
155+
})
156+
.onConflictDoNothing();
139157
});
140158

141159
afterAll(async () => {
@@ -157,6 +175,12 @@ afterAll(async () => {
157175
inArray(pluginGrants.agentId, [holderId, strangerId]),
158176
),
159177
);
178+
// Suite-scoped, so it is this suite's whatever else is true of the server.
179+
await database
180+
.delete(mcpTools)
181+
.where(
182+
and(eq(mcpTools.serverId, serverId), eq(mcpTools.name, siblingToolName)),
183+
);
160184
// A server row is deployment configuration, so it belongs to the deployment rather than here.
161185
// The fixture tool goes whether or not this suite owns the server, but only if it put it there.
162186
if (!toolWasAlreadyAdvertised) {
@@ -219,6 +243,22 @@ describe("a grant is the permission", () => {
219243
expect(nothing.tools).toEqual([]);
220244
expect(nothing.skills).toEqual([]);
221245
});
246+
247+
test("holding one tool from a server does not offer that server's others", async () => {
248+
/*
249+
* The property the exact-ref match protects, now that the query narrows by server rather than
250+
* reading the whole catalogue. Widening this to "every tool on a server you hold anything from"
251+
* would pass every other test in this file: the Bot would still be offered what it holds, and the
252+
* stranger would still be offered nothing.
253+
*/
254+
await store.grant("mcp", ref, holderId, "admin@openbot.local");
255+
const held = await store.listForAgent(holderId);
256+
257+
expect(held.tools.map((tool) => tool.ref)).toEqual([ref]);
258+
expect(held.tools.map((tool) => tool.ref)).not.toContain(
259+
`${serverId}/${siblingToolName}`,
260+
);
261+
});
222262
});
223263

224264
describe("the policy is asked as well as the grant", () => {

0 commit comments

Comments
 (0)