Skip to content

Commit e8ed3f0

Browse files
committed
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.
1 parent 452ad04 commit e8ed3f0

2 files changed

Lines changed: 140 additions & 9 deletions

File tree

e2e/scenarios/toolkits-mcp.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,3 +583,122 @@ scenario(
583583
);
584584
}),
585585
);
586+
587+
scenario(
588+
"Toolkits · the provider catalog hides integrations the toolkit grants no tools",
589+
{ timeout: 240_000 },
590+
Effect.scoped(
591+
Effect.gen(function* () {
592+
const target = yield* Target;
593+
const mcp = yield* Mcp;
594+
const { client: makeClient } = yield* Api;
595+
const upstream = yield* servePingApi;
596+
const identity = yield* target.newIdentity();
597+
const client = yield* makeClient(api, identity);
598+
599+
// Two providers exist in the workspace; the toolkit will include only one.
600+
const granted = unique("granted_ping");
601+
const ungranted = unique("ungranted_ping");
602+
const toolkitName = unique("scoped-kit");
603+
604+
const listCatalog = (session: McpSession) =>
605+
executeJson(
606+
session,
607+
`const r = await tools.executor.coreTools.integrations.list({});
608+
if (!r.ok) return { error: r.error };
609+
return { slugs: r.data.integrations.map((i) => i.slug) };`,
610+
).pipe(
611+
Effect.map((value) => {
612+
expect(value.error, `integrations.list failed: ${JSON.stringify(value.error)}`).toBe(
613+
undefined,
614+
);
615+
return value.slugs as string[];
616+
}),
617+
);
618+
619+
yield* Effect.gen(function* () {
620+
for (const slug of [granted, ungranted]) {
621+
yield* client.openapi.addSpec({
622+
payload: {
623+
spec: { kind: "blob", value: pingSpec(upstream.url) },
624+
slug: IntegrationSlug.make(slug),
625+
baseUrl: upstream.url,
626+
authenticationTemplate: [
627+
{
628+
slug: "apiKey",
629+
type: "apiKey",
630+
headers: { "x-e2e-token": [{ type: "variable", name: "token" }] },
631+
},
632+
],
633+
},
634+
});
635+
yield* client.connections.create({
636+
payload: {
637+
owner: "org",
638+
name: ConnectionName.make("main"),
639+
integration: IntegrationSlug.make(slug),
640+
template: AuthTemplateSlug.make("apiKey"),
641+
value: "unused-token",
642+
},
643+
});
644+
}
645+
646+
const toolkit = yield* client.toolkits.create({
647+
payload: { owner: "org", name: toolkitName },
648+
});
649+
// Only the granted provider's connection is part of the toolkit.
650+
yield* client.toolkits.createConnection({
651+
params: { toolkitId: toolkit.id },
652+
payload: { pattern: connectionPattern(granted, "org", "main") },
653+
});
654+
// Grant the executor core-tools namespace so the agent can introspect
655+
// the catalog (the dev box has introspection, just not the gmail tools).
656+
yield* client.toolkits.createConnection({
657+
params: { toolkitId: toolkit.id },
658+
payload: { pattern: "executor.coreTools.*" },
659+
});
660+
661+
// The unscoped workspace endpoint advertises the full catalog: both
662+
// providers are connected at the workspace level.
663+
const workspaceCatalog = yield* listCatalog(mcp.session(identity));
664+
expect(workspaceCatalog, "workspace catalog lists the granted provider").toContain(granted);
665+
expect(workspaceCatalog, "workspace catalog lists the ungranted provider").toContain(
666+
ungranted,
667+
);
668+
669+
// The toolkit-scoped endpoint must advertise only providers it grants
670+
// tools from. The ungranted provider is the "shown with 0 tools" leak.
671+
const session = mcp.session(identity, {
672+
url: toolkitUrl(target.baseUrl, toolkit.slug),
673+
});
674+
const toolkitCatalog = yield* listCatalog(session);
675+
expect(toolkitCatalog, "toolkit catalog lists the granted provider").toContain(granted);
676+
expect(toolkitCatalog, "toolkit catalog hides a provider it grants no tools").not.toContain(
677+
ungranted,
678+
);
679+
680+
// The exclusion is real access control, not a display trick: the
681+
// ungranted provider exposes no callable tools through the toolkit.
682+
const search = yield* executeJson(
683+
session,
684+
`const r = await tools.search({ namespace: ${JSON.stringify(ungranted)}, query: "ping", limit: 100 });
685+
return { paths: r.items.map((i) => i.path) };`,
686+
);
687+
expect(search.paths as string[], "ungranted provider exposes no callable tools").toEqual(
688+
[],
689+
);
690+
}).pipe(
691+
Effect.ensuring(
692+
Effect.gen(function* () {
693+
const listed = yield* client.toolkits.list();
694+
yield* Effect.forEach(
695+
listed.toolkits.filter((toolkit) => toolkit.name === toolkitName),
696+
(toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }),
697+
{ discard: true },
698+
);
699+
}).pipe(Effect.ignore),
700+
),
701+
);
702+
}),
703+
),
704+
);

packages/core/sdk/src/executor.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1709,16 +1709,28 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
17091709
};
17101710

17111711
const integrationsList = (): Effect.Effect<readonly Integration[], StorageFailure> =>
1712-
core
1713-
.findMany("integration", {})
1714-
.pipe(
1715-
Effect.map((rows) => [
1716-
...staticSources().map(staticSourceToIntegration),
1717-
...rows.map((row) =>
1718-
rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)),
1719-
),
1720-
]),
1712+
Effect.gen(function* () {
1713+
const rows = yield* core.findMany("integration", {});
1714+
const staticIntegrations = staticSources().map(staticSourceToIntegration);
1715+
const dbIntegrations = rows.map((row) =>
1716+
rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)),
17211717
);
1718+
// A scoped toolkit must not advertise providers it grants no tools from
1719+
// (mirrors `connectionsList`). Static sources are system namespaces, not
1720+
// user providers, so they stay; DB-backed integrations are filtered to
1721+
// those that contribute at least one visible tool under the active policy.
1722+
if (!activeToolPolicyProvider) return [...staticIntegrations, ...dbIntegrations];
1723+
const visibleTools = yield* toolsList({ includeAnnotations: false });
1724+
const visibleIntegrationSlugs = new Set(
1725+
visibleTools.filter((tool) => !tool.static).map((tool) => String(tool.integration)),
1726+
);
1727+
return [
1728+
...staticIntegrations,
1729+
...dbIntegrations.filter((integration) =>
1730+
visibleIntegrationSlugs.has(String(integration.slug)),
1731+
),
1732+
];
1733+
});
17221734

17231735
const integrationsGet = (
17241736
slug: IntegrationSlug,

0 commit comments

Comments
 (0)