History

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.

Rhys Sullivan committed 4 months ago

Commit e8ed3f01 parent 452ad042 files · +140 −9Browse files

Unified · Split

2 files
  1. e2e/scenarios/toolkits-mcp.test.ts +119 −0
  2. packages/core/sdk/src/executor.ts +21 −9
e2e/scenarios/toolkits-mcp.test.ts+119−0
@@ -583,3 +583,122 @@ scenario( · 582 unmodified lines
583 );583 );
584 }),584 }),
585);585);
586
587scenario(
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+21−9
@@ -1709,16 +1709,28 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1708 unmodified lines
1709 };1709 };
1710 1710
1711 const integrationsList = (): Effect.Effect<readonly Integration[], StorageFailure> =>1711 const integrationsList = (): Effect.Effect<readonly Integration[], StorageFailure> =>
1712 core1712 Effect.gen(function* () {
1713 .findMany("integration", {})1713 const rows = yield* core.findMany("integration", {});
1714 .pipe(1714 const staticIntegrations = staticSources().map(staticSourceToIntegration);
1715 Effect.map((rows) => [1715 const dbIntegrations = rows.map((row) =>
1716 ...staticSources().map(staticSourceToIntegration),1716 rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)),
1717 ...rows.map((row) =>
1718 rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)),
1719 ),
1720 ]),
1721 );1717 );
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 });
1722 1734
1723 const integrationsGet = (1735 const integrationsGet = (
1724 slug: IntegrationSlug,1736 slug: IntegrationSlug,