From e8ed3f02422495d6452afbd4e3e0026f524dde2f Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:05:10 -0700 Subject: [PATCH] Scope integrations.list to the active toolkit policy A toolkit-scoped MCP session listed the full workspace provider catalog through integrations.list, including providers the toolkit grants no tools from. tools.list and connections.list were already narrowed to the toolkit's granted surface, but integrations.list was not, so a provider with zero granted tools still surfaced in the catalog (via the integrations.list core tool and the console "Tool providers" view). Filter integrationsList by the active tool policy provider, mirroring connectionsList: static system sources stay, and DB-backed providers are kept only when they contribute at least one visible tool under the policy. Behavior is unchanged when no policy provider is active (the workspace console still lists the full catalog). Covered by an e2e scenario that drives the real toolkit MCP endpoint and asserts a provider the toolkit grants no tools is absent from the catalog while still callable providers remain. --- e2e/scenarios/toolkits-mcp.test.ts | 119 +++++++++++++++++++++++++++++ packages/core/sdk/src/executor.ts | 30 +++++--- 2 files changed, 140 insertions(+), 9 deletions(-) diff --git a/e2e/scenarios/toolkits-mcp.test.ts b/e2e/scenarios/toolkits-mcp.test.ts index 7ebc682f9..bd0b91229 100644 --- a/e2e/scenarios/toolkits-mcp.test.ts +++ b/e2e/scenarios/toolkits-mcp.test.ts @@ -583,3 +583,122 @@ scenario( ); }), ); + +scenario( + "Toolkits ยท the provider catalog hides integrations the toolkit grants no tools", + { timeout: 240_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const upstream = yield* servePingApi; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + + // Two providers exist in the workspace; the toolkit will include only one. + const granted = unique("granted_ping"); + const ungranted = unique("ungranted_ping"); + const toolkitName = unique("scoped-kit"); + + const listCatalog = (session: McpSession) => + executeJson( + session, + `const r = await tools.executor.coreTools.integrations.list({}); + if (!r.ok) return { error: r.error }; + return { slugs: r.data.integrations.map((i) => i.slug) };`, + ).pipe( + Effect.map((value) => { + expect(value.error, `integrations.list failed: ${JSON.stringify(value.error)}`).toBe( + undefined, + ); + return value.slugs as string[]; + }), + ); + + yield* Effect.gen(function* () { + for (const slug of [granted, ungranted]) { + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: pingSpec(upstream.url) }, + slug: IntegrationSlug.make(slug), + baseUrl: upstream.url, + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { "x-e2e-token": [{ type: "variable", name: "token" }] }, + }, + ], + }, + }); + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make(slug), + template: AuthTemplateSlug.make("apiKey"), + value: "unused-token", + }, + }); + } + + const toolkit = yield* client.toolkits.create({ + payload: { owner: "org", name: toolkitName }, + }); + // Only the granted provider's connection is part of the toolkit. + yield* client.toolkits.createConnection({ + params: { toolkitId: toolkit.id }, + payload: { pattern: connectionPattern(granted, "org", "main") }, + }); + // Grant the executor core-tools namespace so the agent can introspect + // the catalog (the dev box has introspection, just not the gmail tools). + yield* client.toolkits.createConnection({ + params: { toolkitId: toolkit.id }, + payload: { pattern: "executor.coreTools.*" }, + }); + + // The unscoped workspace endpoint advertises the full catalog: both + // providers are connected at the workspace level. + const workspaceCatalog = yield* listCatalog(mcp.session(identity)); + expect(workspaceCatalog, "workspace catalog lists the granted provider").toContain(granted); + expect(workspaceCatalog, "workspace catalog lists the ungranted provider").toContain( + ungranted, + ); + + // The toolkit-scoped endpoint must advertise only providers it grants + // tools from. The ungranted provider is the "shown with 0 tools" leak. + const session = mcp.session(identity, { + url: toolkitUrl(target.baseUrl, toolkit.slug), + }); + const toolkitCatalog = yield* listCatalog(session); + expect(toolkitCatalog, "toolkit catalog lists the granted provider").toContain(granted); + expect(toolkitCatalog, "toolkit catalog hides a provider it grants no tools").not.toContain( + ungranted, + ); + + // The exclusion is real access control, not a display trick: the + // ungranted provider exposes no callable tools through the toolkit. + const search = yield* executeJson( + session, + `const r = await tools.search({ namespace: ${JSON.stringify(ungranted)}, query: "ping", limit: 100 }); + return { paths: r.items.map((i) => i.path) };`, + ); + expect(search.paths as string[], "ungranted provider exposes no callable tools").toEqual( + [], + ); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const listed = yield* client.toolkits.list(); + yield* Effect.forEach( + listed.toolkits.filter((toolkit) => toolkit.name === toolkitName), + (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }), + { discard: true }, + ); + }).pipe(Effect.ignore), + ), + ); + }), + ), +); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 7f46b2279..5dfd80b19 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1709,16 +1709,28 @@ export const createExecutor = => - core - .findMany("integration", {}) - .pipe( - Effect.map((rows) => [ - ...staticSources().map(staticSourceToIntegration), - ...rows.map((row) => - rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)), - ), - ]), + Effect.gen(function* () { + const rows = yield* core.findMany("integration", {}); + const staticIntegrations = staticSources().map(staticSourceToIntegration); + const dbIntegrations = rows.map((row) => + rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)), ); + // A scoped toolkit must not advertise providers it grants no tools from + // (mirrors `connectionsList`). Static sources are system namespaces, not + // user providers, so they stay; DB-backed integrations are filtered to + // those that contribute at least one visible tool under the active policy. + if (!activeToolPolicyProvider) return [...staticIntegrations, ...dbIntegrations]; + const visibleTools = yield* toolsList({ includeAnnotations: false }); + const visibleIntegrationSlugs = new Set( + visibleTools.filter((tool) => !tool.static).map((tool) => String(tool.integration)), + ); + return [ + ...staticIntegrations, + ...dbIntegrations.filter((integration) => + visibleIntegrationSlugs.has(String(integration.slug)), + ), + ]; + }); const integrationsGet = ( slug: IntegrationSlug,