History

Merge pull request #1145 from RhysSullivan/codex/toolkits-scoped-mcp

[codex] Add scoped MCP toolkits

Rhys Sullivan committed 4 months ago

Commit ca8a9622 parents 4b361b9 3f68c6a94 files · +6259 −331Browse files

Unified · Split

94 files
  1. apps/cloud/executor.config.ts +4 −1
  2. apps/cloud/package.json +1 −0
  3. apps/cloud/src/app-paths.test.ts +8 −0
  4. apps/cloud/src/engine/execution-stack.ts +3 −1
  5. apps/cloud/src/mcp/auth-provider.ts +31 −3
  6. apps/cloud/src/mcp/auth.ts +31 −7
  7. apps/cloud/src/mcp/mount.test.ts +57 −0
  8. apps/cloud/src/mcp/mount.ts +35 −17
  9. apps/cloud/src/mcp/oauth-metadata.ts +5 −2
  10. apps/cloud/src/mcp/session-durable-object.ts +2 −0
  11. apps/cloud/src/routeTree.gen.ts +65 −0
  12. apps/host-cloudflare/executor.config.ts +3 −1
  13. apps/host-cloudflare/package.json +1 −0
  14. apps/host-cloudflare/src/db/data-migrations.test.ts +149 −1
  15. apps/host-cloudflare/src/db/data-migrations.ts +98 −3
  16. apps/host-cloudflare/src/execution.ts +5 −1
  17. apps/host-cloudflare/src/mcp/auth.ts +65 −3
  18. apps/host-cloudflare/src/mcp/session-durable-object.ts +2 −0
  19. apps/host-cloudflare/src/plugins.ts +6 −1
  20. apps/host-cloudflare/web/routeTree.gen.ts +61 −0
  21. apps/host-cloudflare/wrangler.jsonc +1 −1
  22. apps/host-selfhost/executor.config.ts +3 −1
  23. apps/host-selfhost/package.json +1 −0
  24. apps/host-selfhost/src/app.ts +2 −1
  25. apps/host-selfhost/src/execution.ts +5 −1
  26. apps/host-selfhost/src/mcp/auth.ts +58 −2
  27. apps/host-selfhost/src/mcp/mcp-oauth.test.ts +29 −0
  28. apps/host-selfhost/src/mcp/org-path.test.ts +6 −2
  29. apps/host-selfhost/src/mcp/org-path.ts +15 −4
  30. apps/host-selfhost/vite.config.ts +2 −2
  31. apps/host-selfhost/web/chromeless/device-page.tsx +0 −0
  32. apps/host-selfhost/web/chromeless/mcp-consent-page.tsx +0 −0
  33. apps/host-selfhost/web/routeTree.gen.ts +61 −0
  34. apps/host-selfhost/web/routes/__root.tsx +2 −2
  35. apps/local/executor.config.ts +7 −1
  36. apps/local/package.json +1 −0
  37. apps/local/src/executor.ts +42 −31
  38. apps/local/src/main.ts +18 −2
  39. apps/local/src/mcp.ts +84 −13
  40. apps/local/src/serve.ts +14 −5
  41. apps/local/vite.config.ts +11 −5
  42. bun.lock +50 −0
  43. e2e/local/toolkits-mcp.test.ts +320 −0
  44. e2e/package.json +1 −0
  45. e2e/scenarios/toolkits-mcp.test.ts +585 −0
  46. e2e/selfhost/toolkits-mcp.test.ts +146 −0
  47. e2e/selfhost/toolkits-ui.test.ts +308 −0
  48. e2e/setup/cloudflare.boot.ts +9 −3
  49. e2e/src/surfaces/mcp.ts +102 −17
  50. e2e/targets/selfhost.ts +8 −2
  51. e2e/vitest.config.ts +1 −0
  52. packages/app/src/routeTree.gen.ts +58 −0
  53. packages/app/src/web/shell.tsx +7 −0
  54. packages/core/api/src/server/execution-stack.ts +3 −0
  55. packages/core/api/src/server/mcp-build.ts +3 −5
  56. packages/core/api/src/server/scoped-executor.ts +8 −2
  57. packages/core/sdk/src/client.ts +10 −1
  58. packages/core/sdk/src/executor.ts +135 −19
  59. packages/core/sdk/src/index.ts +2 −0
  60. packages/core/sdk/src/plugin.ts +32 −0
  61. packages/core/sdk/src/policies.test.ts +81 −2
  62. packages/hosts/cloudflare/src/mcp/do-headers.ts +4 −0
  63. packages/hosts/cloudflare/src/mcp/seams.ts +3 −0
  64. packages/hosts/cloudflare/src/mcp/session-durable-object.ts +13 −4
  65. packages/hosts/cloudflare/src/mcp/session-store.test.ts +79 −1
  66. packages/hosts/cloudflare/src/mcp/session-store.ts +15 −4
  67. packages/hosts/mcp/src/envelope.test.ts +64 −2
  68. packages/hosts/mcp/src/envelope.ts +77 −67
  69. packages/hosts/mcp/src/in-memory-session-store.ts +35 −11
  70. packages/hosts/mcp/src/index.ts +3 −0
  71. packages/hosts/mcp/src/seams.ts +20 −0
  72. packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts +66 −5
  73. packages/plugins/google/src/sdk/openapi-ownership-migration.ts +1 −1
  74. packages/plugins/toolkits/CHANGELOG.md +1 −0
  75. packages/plugins/toolkits/package.json +105 −0
  76. packages/plugins/toolkits/src/client.tsx +17 −0
  77. packages/plugins/toolkits/src/page.tsx +1488 −0
  78. packages/plugins/toolkits/src/server.test.ts +168 −0
  79. packages/plugins/toolkits/src/server.ts +673 −0
  80. packages/plugins/toolkits/src/shared.ts +166 −0
  81. packages/plugins/toolkits/tsconfig.json +23 −0
  82. packages/plugins/toolkits/tsup.config.ts +14 −0
  83. packages/plugins/toolkits/vitest.config.ts +7 −0
  84. packages/react/src/components/tool-detail.tsx +59 −42
  85. packages/react/src/components/tool-tree.tsx +38 −12
  86. packages/react/src/console-routes.test.ts +9 −3
  87. packages/react/src/console-routes.ts +4 −0
  88. packages/react/src/multiplayer/shell.tsx +26 −2
  89. packages/react/src/routes/plugin-route-match.test.ts +34 −0
  90. packages/react/src/routes/plugins.$pluginId.$.tsx +84 −12
  91. packages/react/src/routes/routeTree.gen.ts +53 −0
  92. packages/react/src/routes/toolkits-route.tsx +23 −0
  93. packages/react/src/routes/toolkits.$toolkitSlug.tsx +12 −0
  94. packages/react/src/routes/toolkits.tsx +12 −0
apps/cloud/executor.config.ts+4−1
@@ -5,6 +5,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; · 4 unmodified lines
55 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
66 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
77 import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault";
8+import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
89
910 // ---------------------------------------------------------------------------
1011 // Single source of truth for the cloud app's plugin list.
@@ -40,10 +41,11 @@ interface CloudPluginDeps { · 29 unmodified lines
4041 * bypass the real WorkOS API. Production leaves this undefined and
4142 * falls back to the credential-driven default. */
4243 readonly workosVaultClient?: WorkOSVaultClient;
44+ readonly activeToolkitSlug?: string;
4345 }
4446
4547 export default defineExecutorConfig({
46− plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) =>
48+ plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) =>
4749 [
4850 openApiHttpPlugin(),
4951 googleHttpPlugin(),
@@ -52,6 +54,7 @@ export default defineExecutorConfig({ · 2 unmodified lines
5254 dangerouslyAllowStdioMCP: false,
5355 }),
5456 graphqlHttpPlugin(),
57+ toolkitsPlugin({ activeToolkitSlug }),
5558 workosVaultPlugin({
5659 credentials: workosCredentials ?? { apiKey: "", clientId: "" },
5760 ...(workosVaultClient ? { client: workosVaultClient } : {}),
apps/cloud/package.json+1−0
@@ -46,6 +46,7 @@ · 45 unmodified lines
4646 "@executor-js/plugin-mcp": "workspace:*",
4747 "@executor-js/plugin-microsoft": "workspace:*",
4848 "@executor-js/plugin-openapi": "workspace:*",
49+ "@executor-js/plugin-toolkits": "workspace:*",
4950 "@executor-js/plugin-workos-vault": "workspace:*",
5051 "@executor-js/react": "workspace:*",
5152 "@executor-js/runtime-dynamic-worker": "workspace:*",
apps/cloud/src/app-paths.test.ts+8−0
@@ -18,14 +18,20 @@ describe("isAppOwnedPath", () => { · 17 unmodified lines
1818 "/api/billing/attach",
1919 "/api/docs", // Swagger UI
2020 "/mcp",
21+ "/mcp/toolkits/deploy-kit",
2122 "/.well-known/oauth-protected-resource/mcp",
23+ "/.well-known/oauth-protected-resource/mcp/toolkits/deploy-kit",
2224 "/.well-known/oauth-authorization-server",
2325 // Org-pinned MCP: the org's URL slug (what the install card prints) and
2426 // the legacy WorkOS org-id form both select an org on the MCP plane.
2527 "/acme-corp/mcp",
28+ "/acme-corp/mcp/toolkits/deploy-kit",
2629 "/org_01ABCDEF/mcp",
30+ "/org_01ABCDEF/mcp/toolkits/deploy-kit",
2731 "/.well-known/oauth-protected-resource/acme-corp/mcp",
32+ "/.well-known/oauth-protected-resource/acme-corp/mcp/toolkits/deploy-kit",
2833 "/.well-known/oauth-protected-resource/org_01ABCDEF/mcp",
34+ "/.well-known/oauth-protected-resource/org_01ABCDEF/mcp/toolkits/deploy-kit",
2935 ];
3036 for (const pathname of appOwned) {
3137 it(`forwards ${pathname} to the app handler`, () => {
@@ -46,7 +52,9 @@ describe("isAppOwnedPath", () => { · 14 unmodified lines
4652 "/org",
4753 "/assets/app.js",
4854 "/settings/mcp",
55+ "/settings/mcp/toolkits/deploy-kit",
4956 "/integrations/mcp",
57+ "/integrations/mcp/toolkits/deploy-kit",
5058 ];
5159 for (const pathname of startOwned) {
5260 it(`leaves ${pathname} to the Start router`, () => {
apps/cloud/src/engine/execution-stack.ts+3−1
@@ -57,13 +57,15 @@ export const CloudDbProvider = cloudDbProviderLayer(collectTables()); · 56 unmodified lines
5757 // Fresh plugin instances per request, carrying the Worker env's WorkOS Vault
5858 // credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`.
5959 export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)({
60− plugins: () =>
60+ plugins: (context) =>
6161 executorConfig.plugins({
6262 workosCredentials: {
6363 apiKey: env.WORKOS_API_KEY,
6464 clientId: env.WORKOS_CLIENT_ID,
6565 apiUrl: env.WORKOS_API_URL,
6666 },
67+ activeToolkitSlug:
68+ context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
6769 }),
6870 });
6971
apps/cloud/src/mcp/auth-provider.ts+31−3
@@ -43,6 +43,7 @@ import { · 42 unmodified lines
4343 mcpOrganizationFromRequest,
4444 protectedResourceMetadataUrlFor,
4545 PROTECTED_RESOURCE_METADATA_PATH,
46+ toolkitSlugFromRequest,
4647 McpAuth,
4748 McpAuthLive,
4849 McpOrganizationAuth,
@@ -57,6 +58,7 @@ import { · 8 unmodified lines
5758 } from "./oauth-metadata";
5859
5960 const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
61+const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`;
6062
6163 const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first";
6264
@@ -94,7 +96,22 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 31 unmodified lines
9496 // The bare path is the only one mounted; `prepareMcpOrgScope` rewrites an
9597 // org-scoped discovery doc onto it and pins the org in the header we read.
9698 handler: (request) =>
97− Effect.succeed(protectedResourceMetadataResponse(mcpOrganizationFromRequest(request))),
99+ Effect.succeed(
100+ protectedResourceMetadataResponse(
101+ mcpOrganizationFromRequest(request),
102+ toolkitSlugFromRequest(request),
103+ ),
104+ ),
105+ },
106+ {
107+ path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH,
108+ handler: (request) =>
109+ Effect.succeed(
110+ protectedResourceMetadataResponse(
111+ mcpOrganizationFromRequest(request),
112+ toolkitSlugFromRequest(request),
113+ ),
114+ ),
98115 },
99116 {
100117 path: AUTHORIZATION_SERVER_METADATA_PATH,
@@ -103,7 +120,10 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 2 unmodified lines
103120 ];
104121
105122 const resourceMetadataUrl = (request: Request): string =>
106− protectedResourceMetadataUrlFor(mcpOrganizationFromRequest(request));
123+ protectedResourceMetadataUrlFor(
124+ mcpOrganizationFromRequest(request),
125+ toolkitSlugFromRequest(request),
126+ );
107127
108128 /**
109129 * Resolve a verified bearer to a final AuthOutcome by running the live org
@@ -160,7 +180,15 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 50 unmodified lines
160180 return finishAuthorized(request, result.token);
161181 }
162182 return annotateMcpRequest(request, { token: null, parseBody: false }).pipe(
163− Effect.as(unauthorized(bearerChallengeFor(result, mcpOrganizationFromRequest(request)))),
183+ Effect.as(
184+ unauthorized(
185+ bearerChallengeFor(
186+ result,
187+ mcpOrganizationFromRequest(request),
188+ toolkitSlugFromRequest(request),
189+ ),
190+ ),
191+ ),
164192 );
165193 };
166194
apps/cloud/src/mcp/auth.ts+31−7
@@ -50,6 +50,7 @@ const MCP_PATH = "/mcp"; · 49 unmodified lines
5050 export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp";
5151 export const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`;
5252 export const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`;
53+const TOOLKIT_SEGMENT = "/toolkits/";
5354
5455 // ---------------------------------------------------------------------------
5556 // Org-scoped MCP (the URL pins an org: `/org_xxx/mcp`)
@@ -68,17 +69,39 @@ export const MCP_ORGANIZATION_HEADER = "x-executor-mcp-organization"; · 12 unmodified lines
6869 export const mcpOrganizationFromRequest = (request: Request): string | null =>
6970 request.headers.get(MCP_ORGANIZATION_HEADER);
7071
72+/** The toolkit slug selected by `/mcp/toolkits/:slug` or its metadata doc. */
73+export const toolkitSlugFromRequest = (request: Request): string | null => {
74+ const pathname = new URL(request.url).pathname;
75+ const index = pathname.indexOf(TOOLKIT_SEGMENT);
76+ if (index < 0) return null;
77+ const slug = pathname.slice(index + TOOLKIT_SEGMENT.length).split("/", 1)[0];
78+ return slug && slug.length > 0 ? slug : null;
79+};
80+
81+const toolkitMcpPath = (toolkitSlug: string | null): string =>
82+ toolkitSlug ? `${MCP_PATH}/toolkits/${toolkitSlug}` : MCP_PATH;
83+
7184 /** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy
7285 * `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client
7386 * used), or the bare resource. */
74−export const resourceUrlFor = (organizationSelector: string | null): string =>
75− organizationSelector ? `${RESOURCE_ORIGIN}/${organizationSelector}${MCP_PATH}` : RESOURCE_URL;
87+export const resourceUrlFor = (
88+ organizationSelector: string | null,
89+ toolkitSlug: string | null = null,
90+): string =>
91+ organizationSelector
92+ ? `${RESOURCE_ORIGIN}/${organizationSelector}${toolkitMcpPath(toolkitSlug)}`
93+ : `${RESOURCE_ORIGIN}${toolkitMcpPath(toolkitSlug)}`;
7694
7795 /** The protected-resource-metadata URL for an org selector, or the bare one. */
78−export const protectedResourceMetadataUrlFor = (organizationSelector: string | null): string =>
79− organizationSelector
80− ? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationSelector}/mcp`
81− : PROTECTED_RESOURCE_METADATA_URL;
96+export const protectedResourceMetadataUrlFor = (
97+ organizationSelector: string | null,
98+ toolkitSlug: string | null = null,
99+): string => {
100+ const toolkitSuffix = toolkitSlug ? `/toolkits/${toolkitSlug}` : "";
101+ return organizationSelector
102+ ? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationSelector}/mcp${toolkitSuffix}`
103+ : `${PROTECTED_RESOURCE_METADATA_URL}${toolkitSuffix}`;
104+};
82105
83106 type McpUnauthorizedReason = "missing_bearer" | "invalid_token";
84107
@@ -117,10 +140,11 @@ export const mcpUnauthorized = ( · 32 unmodified lines
117140 export const bearerChallengeFor = (
118141 result: McpUnauthorizedResult,
119142 organizationId: string | null = null,
143+ toolkitSlug: string | null = null,
120144 ): string =>
121145 bearerChallenge(
122146 { reason: result.reason, description: result.description },
123− protectedResourceMetadataUrlFor(organizationId),
147+ protectedResourceMetadataUrlFor(organizationId, toolkitSlug),
124148 );
125149
126150 // ---------------------------------------------------------------------------
apps/cloud/src/mcp/mount.test.ts+57−0
@@ -0,0 +1,57 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { protectedResourceMetadataUrlFor, resourceUrlFor, toolkitSlugFromRequest } from "./auth";
4+import { classifyMcpPath, prepareMcpOrgScope } from "./mount";
5+
6+describe("cloud MCP toolkit route normalization", () => {
7+ it("classifies toolkit MCP and protected-resource metadata paths", () => {
8+ expect(classifyMcpPath("/mcp/toolkits/deploy")).toEqual({
9+ kind: "mcp",
10+ organizationId: null,
11+ toolkitSlug: "deploy",
12+ });
13+ expect(classifyMcpPath("/acme/mcp/toolkits/deploy")).toEqual({
14+ kind: "mcp",
15+ organizationId: "acme",
16+ toolkitSlug: "deploy",
17+ });
18+ expect(classifyMcpPath("/.well-known/oauth-protected-resource/mcp/toolkits/deploy")).toEqual({
19+ kind: "oauth-protected-resource",
20+ organizationId: null,
21+ toolkitSlug: "deploy",
22+ });
23+ expect(
24+ classifyMcpPath("/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy"),
25+ ).toEqual({
26+ kind: "oauth-protected-resource",
27+ organizationId: "acme",
28+ toolkitSlug: "deploy",
29+ });
30+ });
31+
32+ it("rewrites org-scoped toolkit metadata to the mounted toolkit metadata route", () => {
33+ const request = new Request(
34+ "https://executor.sh/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy?x=1",
35+ { headers: { "x-executor-mcp-organization": "spoofed" } },
36+ );
37+
38+ const rewritten = prepareMcpOrgScope(request);
39+ const url = new URL(rewritten.url);
40+
41+ expect(url.pathname).toBe("/.well-known/oauth-protected-resource/mcp/toolkits/deploy");
42+ expect(url.search).toBe("?x=1");
43+ expect(rewritten.headers.get("x-executor-mcp-organization")).toBe("acme");
44+ expect(toolkitSlugFromRequest(rewritten)).toBe("deploy");
45+ });
46+
47+ it("builds toolkit-specific resource and metadata URLs", () => {
48+ expect(resourceUrlFor(null, "deploy")).toBe("https://executor.sh/mcp/toolkits/deploy");
49+ expect(resourceUrlFor("acme", "deploy")).toBe("https://executor.sh/acme/mcp/toolkits/deploy");
50+ expect(protectedResourceMetadataUrlFor(null, "deploy")).toBe(
51+ "https://executor.sh/.well-known/oauth-protected-resource/mcp/toolkits/deploy",
52+ );
53+ expect(protectedResourceMetadataUrlFor("acme", "deploy")).toBe(
54+ "https://executor.sh/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy",
55+ );
56+ });
57+});
apps/cloud/src/mcp/mount.ts+35−17
@@ -50,6 +50,7 @@ type McpRoute = { · 49 unmodified lines
5050 * id), or `null` for the bare path. Resolved to an org id — and re-checked
5151 * against live membership — in the auth provider. */
5252 readonly organizationId: string | null;
53+ readonly toolkitSlug?: string;
5354 } | null;
5455
5556 // A path segment counts as an org selector when it's the org's URL slug (the
@@ -60,12 +61,27 @@ type McpRoute = { · 4 unmodified lines
6061 const orgSelectorSegment = (segment: string | undefined): string | null =>
6162 segment && (segment.startsWith("org_") || isValidOrgSlug(segment)) ? segment : null;
6263
63−// Matches a trailing MCP endpoint — `mcp` (bare) or `<org>/mcp`. Returns the org
64−// selector, `null` for the bare form, or `undefined` when the segments are neither.
65−const matchMcpSuffix = (segments: readonly string[]): string | null | undefined => {
66− if (segments.length === 1 && segments[0] === "mcp") return null;
64+type MatchedMcpSuffix = {
65+ readonly organizationId: string | null;
66+ readonly toolkitSlug?: string;
67+};
68+
69+// Matches a trailing MCP endpoint: `mcp`, `mcp/toolkits/<slug>`, or either with
70+// a leading org selector. Returns undefined when the segments are not MCP.
71+const matchMcpSuffix = (segments: readonly string[]): MatchedMcpSuffix | undefined => {
72+ if (segments.length === 1 && segments[0] === "mcp") return { organizationId: null };
73+ if (segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits") {
74+ const toolkitSlug = segments[2];
75+ return toolkitSlug ? { organizationId: null, toolkitSlug } : undefined;
76+ }
6777 if (segments.length === 2 && segments[1] === "mcp") {
68− return orgSelectorSegment(segments[0]) ?? undefined;
78+ const organizationId = orgSelectorSegment(segments[0]);
79+ return organizationId ? { organizationId } : undefined;
80+ }
81+ if (segments.length === 4 && segments[1] === "mcp" && segments[2] === "toolkits") {
82+ const organizationId = orgSelectorSegment(segments[0]);
83+ const toolkitSlug = segments[3];
84+ return organizationId && toolkitSlug ? { organizationId, toolkitSlug } : undefined;
6985 }
7086 return undefined;
7187 };
@@ -93,22 +109,24 @@ export const classifyMcpPath = (pathname: string): McpRoute => { · 21 unmodified lines
93109 // org sits after the well-known prefix (RFC 9728), not at the path root.
94110 const prmPrefix = "/.well-known/oauth-protected-resource";
95111 if (pathname.startsWith(`${prmPrefix}/`)) {
96− const organizationId = matchMcpSuffix(segments.slice(2));
97− return organizationId === undefined
98− ? null
99− : { kind: "oauth-protected-resource", organizationId };
112+ const matched = matchMcpSuffix(segments.slice(2));
113+ return matched === undefined ? null : { kind: "oauth-protected-resource", ...matched };
100114 }
101115
102116 // MCP transport: `/mcp` or `/<org>/mcp`.
103− const organizationId = matchMcpSuffix(segments);
104− return organizationId === undefined ? null : { kind: "mcp", organizationId };
117+ const matched = matchMcpSuffix(segments);
118+ return matched === undefined ? null : { kind: "mcp", ...matched };
105119 };
106120
107−const bareMcpPath = (kind: McpRouteKind): string =>
108− kind === "mcp"
109− ? MCP_PATH
110− : kind === "oauth-protected-resource"
111− ? PROTECTED_RESOURCE_METADATA_PATH
121+const bareMcpPath = (route: Exclude<McpRoute, null>): string =>
122+ route.kind === "mcp"
123+ ? route.toolkitSlug
124+ ? `${MCP_PATH}/toolkits/${route.toolkitSlug}`
125+ : MCP_PATH
126+ : route.kind === "oauth-protected-resource"
127+ ? route.toolkitSlug
128+ ? `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/${route.toolkitSlug}`
129+ : PROTECTED_RESOURCE_METADATA_PATH
112130 : AUTHORIZATION_SERVER_METADATA_PATH;
113131
114132 /**
@@ -125,7 +143,7 @@ export const prepareMcpOrgScope = (request: Request): Request => { · 10 unmodified lines
125143 const url = new URL(request.url);
126144 const route = classifyMcpPath(url.pathname);
127145 if (route === null) return request;
128− const bare = bareMcpPath(route.kind);
146+ const bare = bareMcpPath(route);
129147 if (url.pathname === bare && !request.headers.has(MCP_ORGANIZATION_HEADER)) return request;
130148 url.pathname = bare;
131149 const rewritten = new Request(url, request);
apps/cloud/src/mcp/oauth-metadata.ts+5−2
@@ -17,9 +17,12 @@ const jsonWebResponse = (body: unknown, status = 200): Response => · 16 unmodified lines
1717 // The `resource` reflects the URL-pinned org (`…/org_xxx/mcp`) when present, so a
1818 // client that discovered metadata via the org-scoped well-known doc gets back the
1919 // matching org-scoped resource id; the bare path yields the bare resource.
20−export const protectedResourceMetadataResponse = (organizationId: string | null = null): Response =>
20+export const protectedResourceMetadataResponse = (
21+ organizationId: string | null = null,
22+ toolkitSlug: string | null = null,
23+): Response =>
2124 jsonWebResponse({
22− resource: resourceUrlFor(organizationId),
25+ resource: resourceUrlFor(organizationId, toolkitSlug),
2326 authorization_servers: [AUTHKIT_DOMAIN],
2427 bearer_methods_supported: ["header"],
2528 // Spec-faithful clients (OpenCode, mcporter) request exactly what is
apps/cloud/src/mcp/session-durable-object.ts+2−0
@@ -172,6 +172,7 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> { · 171 unmodified lines
172172 organizationName: org.name,
173173 organizationSlug: org.slug,
174174 userId: token.userId,
175+ resource: token.resource,
175176 elicitationMode: token.elicitationMode,
176177 } satisfies SessionMeta;
177178 }).pipe(
@@ -193,6 +194,7 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> { · 15 unmodified lines
193194 sessionMeta.userId,
194195 sessionMeta.organizationId,
195196 sessionMeta.organizationName,
197+ { mcpResource: sessionMeta.resource },
196198 ).pipe(
197199 Effect.provide(CloudExecutionStackLayer),
198200 Effect.withSpan("McpSessionDO.makeExecutionStack"),
apps/cloud/src/routeTree.gen.ts+65−0
@@ -14,11 +14,13 @@ import { Route as LoginRouteImport } from './routes/bare/login' · 13 unmodified lines
1414 import { Route as CreateOrgRouteImport } from './routes/bare/create-org'
1515 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'
1616 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'
17+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'
1718 import { Route as SecretsRouteImport } from './routes/app/secrets'
1819 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies'
1920 import { Route as OrgRouteImport } from './routes/app/org'
2021 import { Route as BillingRouteImport } from './routes/app/billing'
2122 import { Route as ApiKeysRouteImport } from './routes/app/api-keys'
23+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
2224 import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId'
2325 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
2426 import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans'
@@ -51,6 +53,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = · 26 unmodified lines
5153 path: '/{-$orgSlug}/tools',
5254 getParentRoute: () => rootRouteImport,
5355 } as any)
56+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute =
57+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport.update({
58+ id: '/{-$orgSlug}/toolkits',
59+ path: '/{-$orgSlug}/toolkits',
60+ getParentRoute: () => rootRouteImport,
61+ } as any)
5462 const SecretsRoute = SecretsRouteImport.update({
5563 id: '/{-$orgSlug}/secrets',
5664 path: '/{-$orgSlug}/secrets',
@@ -77,6 +85,15 @@ const ApiKeysRoute = ApiKeysRouteImport.update({ · 20 unmodified lines
7785 path: '/{-$orgSlug}/api-keys',
7886 getParentRoute: () => rootRouteImport,
7987 } as any)
88+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute =
89+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update(
90+ {
91+ id: '/$toolkitSlug',
92+ path: '/$toolkitSlug',
93+ getParentRoute: () =>
94+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute,
95+ } as any,
96+ )
8097 const ResumeDotexecutionIdRoute = ResumeDotexecutionIdRouteImport.update({
8198 id: '/{-$orgSlug}/resume/$executionId',
8299 path: '/{-$orgSlug}/resume/$executionId',
@@ -113,11 +130,13 @@ export interface FileRoutesByFullPath { · 30 unmodified lines
113130 '/{-$orgSlug}/org': typeof OrgRoute
114131 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
115132 '/{-$orgSlug}/secrets': typeof SecretsRoute
133+ '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
116134 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
117135 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
118136 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
119137 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
120138 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
139+ '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
121140 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
122141 }
123142 export interface FileRoutesByTo {
@@ -129,11 +148,13 @@ export interface FileRoutesByTo { · 5 unmodified lines
129148 '/{-$orgSlug}/org': typeof OrgRoute
130149 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
131150 '/{-$orgSlug}/secrets': typeof SecretsRoute
151+ '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
132152 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
133153 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
134154 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
135155 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
136156 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
157+ '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
137158 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
138159 }
139160 export interface FileRoutesById {
@@ -146,11 +167,13 @@ export interface FileRoutesById { · 6 unmodified lines
146167 '/{-$orgSlug}/org': typeof OrgRoute
147168 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
148169 '/{-$orgSlug}/secrets': typeof SecretsRoute
170+ '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
149171 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
150172 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
151173 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute
152174 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
153175 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
176+ '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
154177 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
155178 }
156179 export interface FileRouteTypes {
@@ -164,11 +187,13 @@ export interface FileRouteTypes { · 7 unmodified lines
164187 | '/{-$orgSlug}/org'
165188 | '/{-$orgSlug}/policies'
166189 | '/{-$orgSlug}/secrets'
190+ | '/{-$orgSlug}/toolkits'
167191 | '/{-$orgSlug}/tools'
168192 | '/{-$orgSlug}/'
169193 | '/{-$orgSlug}/billing/plans'
170194 | '/{-$orgSlug}/integrations/$namespace'
171195 | '/{-$orgSlug}/resume/$executionId'
196+ | '/{-$orgSlug}/toolkits/$toolkitSlug'
172197 | '/{-$orgSlug}/integrations/add/$pluginKey'
173198 fileRoutesByTo: FileRoutesByTo
174199 to:
@@ -180,11 +205,13 @@ export interface FileRouteTypes { · 5 unmodified lines
180205 | '/{-$orgSlug}/org'
181206 | '/{-$orgSlug}/policies'
182207 | '/{-$orgSlug}/secrets'
208+ | '/{-$orgSlug}/toolkits'
183209 | '/{-$orgSlug}/tools'
184210 | '/{-$orgSlug}'
185211 | '/{-$orgSlug}/billing/plans'
186212 | '/{-$orgSlug}/integrations/$namespace'
187213 | '/{-$orgSlug}/resume/$executionId'
214+ | '/{-$orgSlug}/toolkits/$toolkitSlug'
188215 | '/{-$orgSlug}/integrations/add/$pluginKey'
189216 id:
190217 | '__root__'
@@ -196,11 +223,13 @@ export interface FileRouteTypes { · 5 unmodified lines
196223 | '/{-$orgSlug}/org'
197224 | '/{-$orgSlug}/policies'
198225 | '/{-$orgSlug}/secrets'
226+ | '/{-$orgSlug}/toolkits'
199227 | '/{-$orgSlug}/tools'
200228 | '/{-$orgSlug}/'
201229 | '/{-$orgSlug}/billing_/plans'
202230 | '/{-$orgSlug}/integrations/$namespace'
203231 | '/{-$orgSlug}/resume/$executionId'
232+ | '/{-$orgSlug}/toolkits/$toolkitSlug'
204233 | '/{-$orgSlug}/integrations/add/$pluginKey'
205234 fileRoutesById: FileRoutesById
206235 }
@@ -213,6 +242,7 @@ export interface RootRouteChildren { · 6 unmodified lines
213242 OrgRoute: typeof OrgRoute
214243 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
215244 SecretsRoute: typeof SecretsRoute
245+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
216246 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
217247 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
218248 Billing_DotplansRoute: typeof Billing_DotplansRoute
@@ -258,6 +288,13 @@ declare module '@tanstack/react-router' { · 39 unmodified lines
258288 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport
259289 parentRoute: typeof rootRouteImport
260290 }
291+ '/{-$orgSlug}/toolkits': {
292+ id: '/{-$orgSlug}/toolkits'
293+ path: '/{-$orgSlug}/toolkits'
294+ fullPath: '/{-$orgSlug}/toolkits'
295+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport
296+ parentRoute: typeof rootRouteImport
297+ }
261298 '/{-$orgSlug}/secrets': {
262299 id: '/{-$orgSlug}/secrets'
263300 path: '/{-$orgSlug}/secrets'
@@ -293,6 +330,13 @@ declare module '@tanstack/react-router' { · 29 unmodified lines
293330 preLoaderRoute: typeof ApiKeysRouteImport
294331 parentRoute: typeof rootRouteImport
295332 }
333+ '/{-$orgSlug}/toolkits/$toolkitSlug': {
334+ id: '/{-$orgSlug}/toolkits/$toolkitSlug'
335+ path: '/$toolkitSlug'
336+ fullPath: '/{-$orgSlug}/toolkits/$toolkitSlug'
337+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport
338+ parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute
339+ }
296340 '/{-$orgSlug}/resume/$executionId': {
297341 id: '/{-$orgSlug}/resume/$executionId'
298342 path: '/{-$orgSlug}/resume/$executionId'
@@ -324,6 +368,21 @@ declare module '@tanstack/react-router' { · 25 unmodified lines
324368 }
325369 }
326370
371+interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren {
372+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
373+}
374+
375+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren =
376+ {
377+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute:
378+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute,
379+ }
380+
381+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren =
382+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute._addFileChildren(
383+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren,
384+ )
385+
327386 const rootRouteChildren: RootRouteChildren = {
328387 CreateOrgRoute: CreateOrgRoute,
329388 LoginRoute: LoginRoute,
@@ -334,6 +393,8 @@ const rootRouteChildren: RootRouteChildren = { · 4 unmodified lines
334393 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute:
335394 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute,
336395 SecretsRoute: SecretsRoute,
396+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute:
397+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,
337398 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:
338399 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
339400 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
@@ -350,11 +411,15 @@ export const routeTree = rootRouteImport · 10 unmodified lines
350411 ._addFileTypes<FileRouteTypes>()
351412
352413 import type { getRouter } from './router.tsx'
414+
353415 import type { startInstance } from './start.ts'
416+
354417 declare module '@tanstack/react-start' {
355418 interface Register {
356419 ssr: true
420+
357421 router: Awaited<ReturnType<typeof getRouter>>
422+
358423 config: Awaited<ReturnType<typeof startInstance.getOptions>>
359424 }
360425 }
apps/host-cloudflare/executor.config.ts+3−1
@@ -5,6 +5,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; · 4 unmodified lines
55 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
66 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
77 import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
8+import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
89
910 // ---------------------------------------------------------------------------
1011 // Plugin list for the Cloudflare web build. The Vite `executorVitePlugin` reads
@@ -16,13 +17,14 @@ import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; · 5 unmodified lines
1617 // ---------------------------------------------------------------------------
1718
1819 export default defineExecutorConfig({
19− plugins: () =>
20+ plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) =>
2021 [
2122 openApiHttpPlugin(),
2223 googleHttpPlugin(),
2324 microsoftHttpPlugin(),
2425 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
2526 graphqlHttpPlugin(),
27+ toolkitsPlugin({ activeToolkitSlug }),
2628 encryptedSecretsPlugin({ key: process.env.EXECUTOR_SECRET_KEY ?? "build-time-placeholder" }),
2729 ] as const,
2830 });
apps/host-cloudflare/package.json+1−0
@@ -29,6 +29,7 @@ · 28 unmodified lines
2929 "@executor-js/plugin-mcp": "workspace:*",
3030 "@executor-js/plugin-microsoft": "workspace:*",
3131 "@executor-js/plugin-openapi": "workspace:*",
32+ "@executor-js/plugin-toolkits": "workspace:*",
3233 "@executor-js/react": "workspace:*",
3334 "@executor-js/runtime-quickjs": "workspace:*",
3435 "@executor-js/sdk": "workspace:*",
apps/host-cloudflare/src/db/data-migrations.test.ts+149−1
@@ -32,7 +32,10 @@ const makeFakeD1 = (client: SqliteDataMigrationClient): D1Database => { · 31 unmodified lines
3232 } as unknown as D1Database;
3333 };
3434
35−const makeFakeR2 = (): { readonly bucket: R2Bucket; readonly objects: Map<string, string> } => {
35+const makeFakeR2 = (): {
36+ readonly bucket: R2Bucket;
37+ readonly objects: Map<string, string>;
38+} => {
3639 const objects = new Map<string, string>();
3740 // oxlint-disable-next-line executor/no-double-cast -- test double: only the R2 methods used by the migration are implemented
3841 const bucket = {
@@ -75,6 +78,23 @@ const insertIntegration = ( · 36 unmodified lines
7578 ],
7679 });
7780
81+const insertIntegrationRawConfig = (
82+ client: SqliteDataMigrationClient,
83+ row: {
84+ readonly rowId: string;
85+ readonly tenant: string;
86+ readonly slug: string;
87+ readonly pluginId: string;
88+ readonly config: string;
89+ },
90+) =>
91+ client.execute({
92+ sql: `INSERT INTO integration
93+ (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at)
94+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`,
95+ args: [row.rowId, row.tenant, row.slug, row.pluginId, row.slug, row.slug, row.config, now, now],
96+ });
97+
7898 const insertOperationStorage = (
7999 client: SqliteDataMigrationClient,
80100 row: {
@@ -103,6 +123,134 @@ const insertOperationStorage = ( · 22 unmodified lines
103123 });
104124
105125 describe("runCloudflareDataMigrations", () => {
126+ it.effect("rebuilds legacy connection tables from item_id to item_ids", () =>
127+ Effect.gen(function* () {
128+ const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
129+ const { bucket } = makeFakeR2();
130+
131+ yield* Effect.promise(() => db.client.execute("DROP TABLE connection"));
132+ yield* Effect.promise(() =>
133+ db.client.execute(`
134+ CREATE TABLE connection (
135+ integration text NOT NULL,
136+ name text NOT NULL,
137+ template text NOT NULL,
138+ provider text NOT NULL,
139+ item_id text NOT NULL,
140+ identity_label text,
141+ description text,
142+ tools_synced_at integer,
143+ oauth_client text,
144+ oauth_client_owner text,
145+ refresh_item_id text,
146+ expires_at integer,
147+ oauth_scope text,
148+ oauth_token_url text,
149+ provider_state text,
150+ created_at integer NOT NULL,
151+ updated_at integer NOT NULL,
152+ row_id text PRIMARY KEY NOT NULL,
153+ tenant text NOT NULL,
154+ owner text NOT NULL,
155+ subject text NOT NULL
156+ )
157+ `),
158+ );
159+ yield* Effect.promise(() =>
160+ db.client.execute({
161+ sql: `INSERT INTO connection
162+ (integration, name, template, provider, item_id, created_at, updated_at, row_id, tenant, owner, subject)
163+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
164+ args: [
165+ "legacy",
166+ "main",
167+ "apiKey",
168+ "encrypted",
169+ "legacy-item",
170+ now,
171+ now,
172+ "legacy-connection-row",
173+ "default",
174+ "org",
175+ "",
176+ ],
177+ }),
178+ );
179+
180+ const d1 = makeFakeD1(db.client);
181+ expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toContain(
182+ "2026-06-20-google-openapi-ownership",
183+ );
184+
185+ const columns = yield* Effect.promise(() =>
186+ db.client.execute("PRAGMA table_info('connection')"),
187+ );
188+ expect(columns.rows.map((row) => row.name)).toContain("item_ids");
189+ expect(columns.rows.map((row) => row.name)).not.toContain("item_id");
190+
191+ yield* Effect.promise(() =>
192+ db.client.execute({
193+ sql: `INSERT INTO connection
194+ (integration, name, template, provider, item_ids, created_at, updated_at, row_id, tenant, owner, subject)
195+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
196+ args: [
197+ "probe",
198+ "selected",
199+ "apiKey",
200+ "encrypted",
201+ JSON.stringify({ token: "item" }),
202+ now,
203+ now,
204+ "connection-row",
205+ "default",
206+ "org",
207+ "",
208+ ],
209+ }),
210+ );
211+
212+ const rows = yield* Effect.promise(() =>
213+ db.client.execute("SELECT name, item_ids FROM connection ORDER BY name"),
214+ );
215+ expect(rows.rows).toEqual([
216+ { name: "main", item_ids: JSON.stringify({ token: "legacy-item" }) },
217+ { name: "selected", item_ids: JSON.stringify({ token: "item" }) },
218+ ]);
219+
220+ yield* Effect.promise(() => db.close());
221+ }),
222+ );
223+
224+ it.effect("clears malformed legacy OpenAPI integration config rows", () =>
225+ Effect.gen(function* () {
226+ const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
227+ const { bucket } = makeFakeR2();
228+
229+ yield* Effect.promise(() =>
230+ insertIntegrationRawConfig(db.client, {
231+ rowId: "malformed-row",
232+ tenant: "org_1",
233+ slug: "broken",
234+ pluginId: "openapi",
235+ config: "",
236+ }),
237+ );
238+
239+ const d1 = makeFakeD1(db.client);
240+ expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([
241+ "2026-06-20-google-openapi-ownership",
242+ ]);
243+ expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);
244+
245+ const integrations = yield* Effect.promise(() =>
246+ db.client.execute("SELECT slug, plugin_id, config FROM integration"),
247+ );
248+ expect(integrations.rows).toEqual([{ slug: "broken", plugin_id: "openapi", config: null }]);
249+
250+ yield* Effect.promise(() => db.close());
251+ }),
252+ );
253+
106254 it.effect("moves Google OpenAPI ownership and copies the R2 spec object", () =>
107255 Effect.gen(function* () {
108256 const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
apps/host-cloudflare/src/db/data-migrations.ts+98−3
@@ -20,7 +20,7 @@ const queryRows = <T extends Record<string, unknown>>( · 19 unmodified lines
2020 const sql = typeof stmt === "string" ? stmt : stmt.sql;
2121 const args = typeof stmt === "string" ? [] : stmt.args;
2222 const prepared = session.prepare(sql).bind(...args);
23− if (firstWord(sql) === "SELECT") {
23+ if (firstWord(sql) === "SELECT" || firstWord(sql) === "PRAGMA") {
2424 return prepared.all<T>().then((result) => result.results);
2525 }
2626 return prepared.run<T>().then(() => []);
@@ -38,6 +38,93 @@ export const d1DataMigrationClient = (db: D1Database): SqliteDataMigrationClient · 11 unmodified lines
3838 };
3939 };
4040
41+const tableColumns = async (db: D1Database, table: string): Promise<ReadonlySet<string>> => {
42+ const session = db.withSession("first-primary");
43+ const result = await session.prepare(`PRAGMA table_info('${table}')`).all<{
44+ readonly name?: unknown;
45+ }>();
46+ return new Set(
47+ result.results
48+ .map((row) => row.name)
49+ .filter((name): name is string => typeof name === "string"),
50+ );
51+};
52+
53+const rebuildLegacyConnectionTable = async (db: D1Database): Promise<void> => {
54+ const statements = [
55+ `DROP TABLE IF EXISTS connection_next`,
56+ `DROP TABLE IF EXISTS connection_legacy_item_id`,
57+ `CREATE TABLE connection_next (
58+ integration text NOT NULL,
59+ name text NOT NULL,
60+ template text NOT NULL,
61+ provider text NOT NULL,
62+ item_ids json NOT NULL,
63+ identity_label text,
64+ description text,
65+ tools_synced_at integer,
66+ oauth_client text,
67+ oauth_client_owner text,
68+ refresh_item_id text,
69+ expires_at integer,
70+ oauth_scope text,
71+ oauth_token_url text,
72+ provider_state text,
73+ created_at integer NOT NULL,
74+ updated_at integer NOT NULL,
75+ row_id text PRIMARY KEY NOT NULL,
76+ tenant text NOT NULL,
77+ owner text NOT NULL,
78+ subject text NOT NULL
79+ )`,
80+ `INSERT INTO connection_next
81+ (integration, name, template, provider, item_ids, identity_label,
82+ description, tools_synced_at, oauth_client, oauth_client_owner,
83+ refresh_item_id, expires_at, oauth_scope, oauth_token_url,
84+ provider_state, created_at, updated_at, row_id, tenant, owner, subject)
85+ SELECT integration, name, template, provider,
86+ CASE
87+ WHEN item_ids IS NOT NULL AND item_ids <> '{}' THEN item_ids
88+ ELSE json_object('token', item_id)
89+ END,
90+ identity_label, description, tools_synced_at, oauth_client,
91+ oauth_client_owner, refresh_item_id, expires_at, oauth_scope,
92+ oauth_token_url, provider_state, created_at, updated_at, row_id,
93+ tenant, owner, subject
94+ FROM connection`,
95+ `ALTER TABLE connection RENAME TO connection_legacy_item_id`,
96+ `ALTER TABLE connection_next RENAME TO connection`,
97+ `DROP TABLE connection_legacy_item_id`,
98+ ];
99+ for (const statement of statements) {
100+ await db.prepare(statement).run();
101+ }
102+};
103+
104+export const ensureCloudflareD1SchemaCompatibility = async (db: D1Database): Promise<void> => {
105+ const integrationColumns = await tableColumns(db, "integration");
106+ if (integrationColumns.has("config")) {
107+ await db
108+ .prepare(
109+ `UPDATE integration
110+ SET config = NULL
111+ WHERE config IS NOT NULL
112+ AND NOT json_valid(config)`,
113+ )
114+ .run();
115+ }
116+
117+ const connectionColumns = await tableColumns(db, "connection");
118+ if (connectionColumns.size === 0) return;
119+ if (!connectionColumns.has("item_ids")) {
120+ await db.prepare(`ALTER TABLE connection ADD COLUMN item_ids json NOT NULL DEFAULT '{}'`).run();
121+ }
122+ const updatedConnectionColumns = await tableColumns(db, "connection");
123+ if (updatedConnectionColumns.has("item_id")) {
124+ await rebuildLegacyConnectionTable(db);
125+ }
126+};
127+
41128 const r2ObjectName = (tenant: string, pluginId: string, key: string): string =>
42129 `o:${tenant}/${pluginId}/${key}`;
43130
@@ -52,6 +139,7 @@ const copyGoogleOpenApiSpecBlobsToR2 = ( · 8 unmodified lines
52139 FROM integration
53140 WHERE plugin_id = 'openapi'
54141 AND config IS NOT NULL
142+ AND json_valid(config)
55143 AND json_type(config, '$.googleDiscoveryUrls') = 'array'
56144 AND json_extract(config, '$.specHash') IS NOT NULL
57145 AND json_extract(config, '$.specHash') <> ''`,
@@ -67,7 +155,10 @@ const copyGoogleOpenApiSpecBlobsToR2 = ( · 9 unmodified lines
67155 }
68156 },
69157 catch: (cause) =>
70− new DataMigrationError({ migration: googleOpenApiOwnershipDataMigration.name, cause }),
158+ new DataMigrationError({
159+ migration: googleOpenApiOwnershipDataMigration.name,
160+ cause,
161+ }),
71162 });
72163
73164 const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [
@@ -86,5 +177,9 @@ export const runCloudflareDataMigrations = ( · 12 unmodified lines
86177 bucket: R2Bucket | undefined,
87178 ): Promise<readonly string[]> =>
88179 Effect.runPromise(
89− runSqliteDataMigrations(d1DataMigrationClient(db), cloudflareDataMigrations(bucket)),
180+ Effect.promise(() => ensureCloudflareD1SchemaCompatibility(db)).pipe(
181+ Effect.flatMap(() =>
182+ runSqliteDataMigrations(d1DataMigrationClient(db), cloudflareDataMigrations(bucket)),
183+ ),
184+ ),
90185 );
apps/host-cloudflare/src/execution.ts+5−1
@@ -38,7 +38,11 @@ export const makeCloudflarePluginsProvider = ( · 37 unmodified lines
3838 config: CloudflareConfig,
3939 ): Layer.Layer<PluginsProvider> =>
4040 Layer.succeed(PluginsProvider)({
41− plugins: () => makeCloudflarePlugins(config.secretKey),
41+ plugins: (context) =>
42+ makeCloudflarePlugins(config.secretKey, {
43+ activeToolkitSlug:
44+ context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
45+ }),
4246 });
4347
4448 export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer<HostConfig> =>
apps/host-cloudflare/src/mcp/auth.ts+65−3
@@ -1,10 +1,58 @@
11 import { Effect, Layer } from "effect";
22
3−import { authenticated, McpAuthProvider, unauthorized } from "@executor-js/host-mcp";
3+import {
4+ authenticated,
5+ McpAuthProvider,
6+ unauthorized,
7+ type McpDiscoveryRoute,
8+} from "@executor-js/host-mcp";
49
510 import { makeAccessVerifier } from "../auth/cloudflare-access";
611 import type { CloudflareConfig } from "../config";
712
13+const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
14+const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp`;
15+const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`;
16+
17+const toolkitSlugFromPath = (pathname: string): string | undefined => {
18+ const mcpPrefix = "/mcp/toolkits/";
19+ if (pathname.startsWith(mcpPrefix)) {
20+ const slug = pathname.slice(mcpPrefix.length).split("/", 1)[0];
21+ return slug ? decodeURIComponent(slug) : undefined;
22+ }
23+ const metadataPrefix = `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/`;
24+ if (pathname.startsWith(metadataPrefix)) {
25+ const slug = pathname.slice(metadataPrefix.length).split("/", 1)[0];
26+ return slug ? decodeURIComponent(slug) : undefined;
27+ }
28+ return undefined;
29+};
30+
31+const toolkitPath = (slug: string): string => `/mcp/toolkits/${encodeURIComponent(slug)}`;
32+
33+const resourcePathForRequest = (request: Request): string => {
34+ const slug = toolkitSlugFromPath(new URL(request.url).pathname);
35+ return slug ? toolkitPath(slug) : "/mcp";
36+};
37+
38+const metadataPathForRequest = (request: Request): string => {
39+ const slug = toolkitSlugFromPath(new URL(request.url).pathname);
40+ return slug
41+ ? `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/${encodeURIComponent(slug)}`
42+ : MCP_PROTECTED_RESOURCE_METADATA_PATH;
43+};
44+
45+const protectedResourceMetadataResponse = (request: Request): Response => {
46+ const url = new URL(request.url);
47+ return new Response(
48+ JSON.stringify({
49+ resource: new URL(resourcePathForRequest(request), url.origin).toString(),
50+ authorization_servers: [],
51+ }),
52+ { headers: { "content-type": "application/json" } },
53+ );
54+};
55+
856 // ---------------------------------------------------------------------------
957 // Cloudflare Access McpAuthProvider — the `/mcp` gate, identical identity to the
1058 // API gate. Cloudflare Access sits in front of the Worker and forwards the
@@ -23,10 +71,24 @@ import type { CloudflareConfig } from "../config"; · 12 unmodified lines
2371
2472 export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer<McpAuthProvider> => {
2573 const { verify } = makeAccessVerifier(config);
74+ const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [
75+ {
76+ path: PROTECTED_RESOURCE_METADATA_PATH,
77+ handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)),
78+ },
79+ {
80+ path: MCP_PROTECTED_RESOURCE_METADATA_PATH,
81+ handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)),
82+ },
83+ {
84+ path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH,
85+ handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)),
86+ },
87+ ];
2688 return Layer.succeed(McpAuthProvider)({
27− discoveryRoutes: [],
89+ discoveryRoutes,
2890 resourceMetadataUrl: (request) =>
29− new URL("/.well-known/oauth-protected-resource", new URL(request.url).origin).toString(),
91+ new URL(metadataPathForRequest(request), new URL(request.url).origin).toString(),
3092 authenticate: (request) =>
3193 verify(request).pipe(
3294 Effect.map((principal) => (principal ? authenticated(principal) : unauthorized())),
apps/host-cloudflare/src/mcp/session-durable-object.ts+2−0
@@ -61,6 +61,7 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 60 unmodified lines
6161 organizationName: this.cfConfig.organizationName,
6262 organizationSlug: this.cfConfig.organizationSlug,
6363 userId: token.userId,
64+ resource: token.resource,
6465 elicitationMode: token.elicitationMode,
6566 } satisfies SessionMeta);
6667 }
@@ -79,6 +80,7 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 12 unmodified lines
7980 sessionMeta.userId,
8081 sessionMeta.organizationId,
8182 sessionMeta.organizationName,
83+ { mcpResource: sessionMeta.resource },
8284 ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));
8385 // Browser elicitation mode (the base owns the approval store + the HTTP
8486 // approval RPCs): a gated execution pauses and returns an approvalUrl into
apps/host-cloudflare/src/plugins.ts+6−1
@@ -4,6 +4,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; · 3 unmodified lines
44 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
55 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
66 import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
7+import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
78
89 // ---------------------------------------------------------------------------
910 // The Cloudflare host's plugin list — the same protocol/provider plugins as
@@ -16,13 +17,17 @@ import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; · 6 unmodified lines
1617 // spawn arbitrary stdio MCP processes.
1718 // ---------------------------------------------------------------------------
1819
19−export const makeCloudflarePlugins = (secretKey: string) =>
20+export const makeCloudflarePlugins = (
21+ secretKey: string,
22+ options: { readonly activeToolkitSlug?: string } = {},
23+) =>
2024 [
2125 openApiHttpPlugin(),
2226 googleHttpPlugin(),
2327 microsoftHttpPlugin(),
2428 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
2529 graphqlHttpPlugin(),
30+ toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }),
2631 encryptedSecretsPlugin({ key: secretKey }),
2732 ] as const;
2833
apps/host-cloudflare/web/routeTree.gen.ts+61−0
@@ -11,8 +11,10 @@ · 10 unmodified lines
1111 import { Route as rootRouteImport } from './routes/__root'
1212 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'
1313 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'
14+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'
1415 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets'
1516 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies'
17+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
1618 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
1719 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
1820 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'
@@ -30,6 +32,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = · 11 unmodified lines
3032 path: '/{-$orgSlug}/tools',
3133 getParentRoute: () => rootRouteImport,
3234 } as any)
35+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute =
36+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport.update({
37+ id: '/{-$orgSlug}/toolkits',
38+ path: '/{-$orgSlug}/toolkits',
39+ getParentRoute: () => rootRouteImport,
40+ } as any)
3341 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute =
3442 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport.update({
3543 id: '/{-$orgSlug}/secrets',
@@ -42,6 +50,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute = · 6 unmodified lines
4250 path: '/{-$orgSlug}/policies',
4351 getParentRoute: () => rootRouteImport,
4452 } as any)
53+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute =
54+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update(
55+ {
56+ id: '/$toolkitSlug',
57+ path: '/$toolkitSlug',
58+ getParentRoute: () =>
59+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute,
60+ } as any,
61+ )
4562 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute =
4663 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update(
4764 {
@@ -78,20 +95,24 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK · 30 unmodified lines
7895 export interface FileRoutesByFullPath {
7996 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
8097 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
98+ '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
8199 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
82100 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
83101 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
84102 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
103+ '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
85104 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
86105 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
87106 }
88107 export interface FileRoutesByTo {
89108 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
90109 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
110+ '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
91111 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
92112 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
93113 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
94114 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
115+ '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
95116 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
96117 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
97118 }
@@ -99,10 +120,12 @@ export interface FileRoutesById { · 1 unmodified line
99120 __root__: typeof rootRouteImport
100121 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
101122 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
123+ '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
102124 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
103125 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
104126 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
105127 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
128+ '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
106129 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
107130 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
108131 }
@@ -111,30 +134,36 @@ export interface FileRouteTypes { · 2 unmodified lines
111134 fullPaths:
112135 | '/{-$orgSlug}/policies'
113136 | '/{-$orgSlug}/secrets'
137+ | '/{-$orgSlug}/toolkits'
114138 | '/{-$orgSlug}/tools'
115139 | '/{-$orgSlug}/'
116140 | '/{-$orgSlug}/integrations/$namespace'
117141 | '/{-$orgSlug}/resume/$executionId'
142+ | '/{-$orgSlug}/toolkits/$toolkitSlug'
118143 | '/{-$orgSlug}/integrations/add/$pluginKey'
119144 | '/{-$orgSlug}/plugins/$pluginId/$'
120145 fileRoutesByTo: FileRoutesByTo
121146 to:
122147 | '/{-$orgSlug}/policies'
123148 | '/{-$orgSlug}/secrets'
149+ | '/{-$orgSlug}/toolkits'
124150 | '/{-$orgSlug}/tools'
125151 | '/{-$orgSlug}'
126152 | '/{-$orgSlug}/integrations/$namespace'
127153 | '/{-$orgSlug}/resume/$executionId'
154+ | '/{-$orgSlug}/toolkits/$toolkitSlug'
128155 | '/{-$orgSlug}/integrations/add/$pluginKey'
129156 | '/{-$orgSlug}/plugins/$pluginId/$'
130157 id:
131158 | '__root__'
132159 | '/{-$orgSlug}/policies'
133160 | '/{-$orgSlug}/secrets'
161+ | '/{-$orgSlug}/toolkits'
134162 | '/{-$orgSlug}/tools'
135163 | '/{-$orgSlug}/'
136164 | '/{-$orgSlug}/integrations/$namespace'
137165 | '/{-$orgSlug}/resume/$executionId'
166+ | '/{-$orgSlug}/toolkits/$toolkitSlug'
138167 | '/{-$orgSlug}/integrations/add/$pluginKey'
139168 | '/{-$orgSlug}/plugins/$pluginId/$'
140169 fileRoutesById: FileRoutesById
@@ -142,6 +171,7 @@ export interface FileRouteTypes { · 1 unmodified line
142171 export interface RootRouteChildren {
143172 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
144173 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
174+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
145175 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
146176 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
147177 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
@@ -166,6 +196,13 @@ declare module '@tanstack/react-router' { · 18 unmodified lines
166196 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport
167197 parentRoute: typeof rootRouteImport
168198 }
199+ '/{-$orgSlug}/toolkits': {
200+ id: '/{-$orgSlug}/toolkits'
201+ path: '/{-$orgSlug}/toolkits'
202+ fullPath: '/{-$orgSlug}/toolkits'
203+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport
204+ parentRoute: typeof rootRouteImport
205+ }
169206 '/{-$orgSlug}/secrets': {
170207 id: '/{-$orgSlug}/secrets'
171208 path: '/{-$orgSlug}/secrets'
@@ -180,6 +217,13 @@ declare module '@tanstack/react-router' { · 8 unmodified lines
180217 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport
181218 parentRoute: typeof rootRouteImport
182219 }
220+ '/{-$orgSlug}/toolkits/$toolkitSlug': {
221+ id: '/{-$orgSlug}/toolkits/$toolkitSlug'
222+ path: '/$toolkitSlug'
223+ fullPath: '/{-$orgSlug}/toolkits/$toolkitSlug'
224+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport
225+ parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute
226+ }
183227 '/{-$orgSlug}/resume/$executionId': {
184228 id: '/{-$orgSlug}/resume/$executionId'
185229 path: '/{-$orgSlug}/resume/$executionId'
@@ -211,11 +255,28 @@ declare module '@tanstack/react-router' { · 25 unmodified lines
211255 }
212256 }
213257
258+interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren {
259+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
260+}
261+
262+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren =
263+ {
264+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute:
265+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute,
266+ }
267+
268+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren =
269+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute._addFileChildren(
270+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren,
271+ )
272+
214273 const rootRouteChildren: RootRouteChildren = {
215274 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute:
216275 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute,
217276 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute:
218277 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute,
278+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute:
279+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,
219280 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:
220281 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
221282 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
apps/host-cloudflare/wrangler.jsonc+1−1
@@ -12,7 +12,7 @@ · 11 unmodified lines
1212 "assets": {
1313 "directory": "./dist",
1414 "not_found_handling": "single-page-application",
15− "run_worker_first": ["/api/*", "/mcp", "/mcp/*"],
15+ "run_worker_first": ["/api/*", "/mcp", "/mcp/*", "/.well-known/*"],
1616 },
1717 // D1 is the app's SQLite store (the DbProvider seam). `wrangler deploy`
1818 // auto-provisions it on first deploy; replace database_id after that, or run
apps/host-selfhost/executor.config.ts+3−1
@@ -5,6 +5,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; · 4 unmodified lines
55 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
66 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
77 import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
8+import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
89
910 import { resolveSecretKey } from "./src/config";
1011
@@ -19,13 +20,14 @@ import { resolveSecretKey } from "./src/config"; · 8 unmodified lines
1920 // ---------------------------------------------------------------------------
2021
2122 export default defineExecutorConfig({
22− plugins: () =>
23+ plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) =>
2324 [
2425 openApiHttpPlugin(),
2526 googleHttpPlugin(),
2627 microsoftHttpPlugin(),
2728 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
2829 graphqlHttpPlugin(),
30+ toolkitsPlugin({ activeToolkitSlug }),
2931 // First writable secret provider -> the default for `secrets.set`.
3032 encryptedSecretsPlugin({ key: resolveSecretKey() }),
3133 ] as const,
apps/host-selfhost/package.json+1−0
@@ -32,6 +32,7 @@ · 31 unmodified lines
3232 "@executor-js/plugin-mcp": "workspace:*",
3333 "@executor-js/plugin-microsoft": "workspace:*",
3434 "@executor-js/plugin-openapi": "workspace:*",
35+ "@executor-js/plugin-toolkits": "workspace:*",
3536 "@executor-js/react": "workspace:*",
3637 "@executor-js/runtime-quickjs": "workspace:*",
3738 "@executor-js/sdk": "workspace:*",
apps/host-selfhost/src/app.ts+2−1
@@ -106,7 +106,8 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 105 unmodified lines
106106 routes: [
107107 // CLI device-login discovery, must precede the /api/auth/* wildcard
108108 // below (Better Auth would otherwise 404 it). The verification page it
109− // points at (/device) is a console SPA route (web/device.tsx).
109+ // points at (/device) is a console SPA route
110+ // (web/chromeless/device-page.tsx).
110111 HttpRouter.add("GET", "/api/auth/cli-login", cliLoginHandler),
111112 // Better Auth owns the rest of /api/auth/*, the full path reaches it.
112113 HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)),
apps/host-selfhost/src/execution.ts+5−1
@@ -40,7 +40,11 @@ export { makeExecutionStack } from "@executor-js/api/server"; · 39 unmodified lines
4040
4141 export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)(
4242 {
43− plugins: () => executorConfig.plugins(),
43+ plugins: (context) =>
44+ executorConfig.plugins({
45+ activeToolkitSlug:
46+ context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
47+ }),
4448 },
4549 );
4650
apps/host-selfhost/src/mcp/auth.ts+58−2
@@ -46,7 +46,9 @@ import { BetterAuth } from "../auth/better-auth"; · 45 unmodified lines
4646 // ---------------------------------------------------------------------------
4747
4848 const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
49+const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/:toolkitSlug`;
4950 const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
51+const TOOLKIT_MCP_SEGMENT = "/mcp/toolkits/";
5052
5153 const parseRoles = (role: string | null | undefined): ReadonlyArray<string> =>
5254 (role ?? "user")
@@ -66,6 +68,19 @@ const userRole = (user: object): string | null => { · 13 unmodified lines
6668 const hasBearer = (request: Request): boolean =>
6769 (request.headers.get("authorization") ?? "").startsWith("Bearer ");
6870
71+const toolkitSlugFromRequest = (request: Request): string | null => {
72+ const pathname = new URL(request.url).pathname;
73+ const index = pathname.indexOf(TOOLKIT_MCP_SEGMENT);
74+ if (index < 0) return null;
75+ const slug = pathname.slice(index + TOOLKIT_MCP_SEGMENT.length).split("/", 1)[0];
76+ return slug && slug.length > 0 ? slug : null;
77+};
78+
79+const mcpResourcePathFor = (request: Request): string => {
80+ const toolkitSlug = toolkitSlugFromRequest(request);
81+ return toolkitSlug ? `/mcp/toolkits/${toolkitSlug}` : "/mcp";
82+};
83+
6984 /**
7085 * Absolute protected-resource metadata URL for the 401 challenge. Derive the
7186 * origin from `baseURL` when set; otherwise from the live request so the URL is
@@ -73,7 +88,34 @@ const hasBearer = (request: Request): boolean => · 1 unmodified line
7388 */
7489 const resourceMetadataUrlFor = (baseURL: string | undefined, request: Request): string => {
7590 const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin;
76− return `${origin}${PROTECTED_RESOURCE_METADATA_PATH}`;
91+ const toolkitSlug = toolkitSlugFromRequest(request);
92+ return toolkitSlug
93+ ? `${origin}${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/${toolkitSlug}`
94+ : `${origin}${PROTECTED_RESOURCE_METADATA_PATH}`;
95+};
96+
97+const resourceUrlFor = (baseURL: string | undefined, request: Request): string => {
98+ const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin;
99+ return `${origin}${mcpResourcePathFor(request)}`;
100+};
101+
102+const toolkitProtectedResourceMetadata = (
103+ request: Request,
104+ response: Response,
105+ baseURL: string | undefined,
106+): Effect.Effect<Response> => {
107+ const toolkitSlug = toolkitSlugFromRequest(request);
108+ if (!toolkitSlug) return Effect.succeed(response);
109+ return Effect.promise(async () => {
110+ const body = (await response.json()) as Record<string, unknown>;
111+ const headers = new Headers(response.headers);
112+ headers.set("content-type", "application/json");
113+ return new Response(JSON.stringify({ ...body, resource: resourceUrlFor(baseURL, request) }), {
114+ status: response.status,
115+ statusText: response.statusText,
116+ headers,
117+ });
118+ });
77119 };
78120
79121 export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | IdentityProvider> =
@@ -99,7 +141,21 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I · 19 unmodified lines
99141 const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [
100142 {
101143 path: PROTECTED_RESOURCE_METADATA_PATH,
102− handler: (request) => Effect.promise(() => prMetadata(request)),
144+ handler: (request) =>
145+ Effect.promise(() => prMetadata(request)).pipe(
146+ Effect.flatMap((response) =>
147+ toolkitProtectedResourceMetadata(request, response, baseURL),
148+ ),
149+ ),
150+ },
151+ {
152+ path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH,
153+ handler: (request) =>
154+ Effect.promise(() => prMetadata(request)).pipe(
155+ Effect.flatMap((response) =>
156+ toolkitProtectedResourceMetadata(request, response, baseURL),
157+ ),
158+ ),
103159 },
104160 {
105161 path: AUTHORIZATION_SERVER_METADATA_PATH,
apps/host-selfhost/src/mcp/mcp-oauth.test.ts+29−0
@@ -36,6 +36,16 @@ test("serves OAuth Protected Resource metadata at the origin root", async () => · 35 unmodified lines
3636 expect(Array.isArray(body.authorization_servers)).toBe(true);
3737 });
3838
39+test("serves OAuth Protected Resource metadata for a toolkit MCP resource", async () => {
40+ const res = await handler(
41+ new Request(`${BASE}/.well-known/oauth-protected-resource/mcp/toolkits/deploy`),
42+ );
43+ expect(res.status).toBe(200);
44+ const body = (await res.json()) as Record<string, unknown>;
45+ expect(body.resource).toBe(`${BASE}/mcp/toolkits/deploy`);
46+ expect(Array.isArray(body.authorization_servers)).toBe(true);
47+});
48+
3949 test("an unauthenticated /mcp request returns 401 with a WWW-Authenticate challenge", async () => {
4050 const res = await handler(
4151 new Request(`${BASE}/mcp`, {
@@ -53,6 +63,25 @@ test("an unauthenticated /mcp request returns 401 with a WWW-Authenticate challe · 11 unmodified lines
5363 expect(challenge).toContain("resource_metadata=");
5464 });
5565
66+test("an unauthenticated toolkit MCP request challenges with toolkit metadata", async () => {
67+ const res = await handler(
68+ new Request(`${BASE}/mcp/toolkits/deploy`, {
69+ method: "POST",
70+ headers: {
71+ "content-type": "application/json",
72+ accept: "application/json, text/event-stream",
73+ },
74+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
75+ }),
76+ );
77+ expect(res.status).toBe(401);
78+ const challenge = res.headers.get("www-authenticate") ?? "";
79+ expect(challenge).toContain("Bearer");
80+ expect(challenge).toContain(
81+ `resource_metadata="${BASE}/.well-known/oauth-protected-resource/mcp/toolkits/deploy"`,
82+ );
83+});
84+
5685 // --- End-to-end MCP OAuth: DCR -> authorize -> token -> /mcp with bearer ---
5786 const json = async (res: Response) => (await res.json()) as Record<string, unknown>;
5887
apps/host-selfhost/src/mcp/org-path.test.ts+6−2
@@ -6,17 +6,21 @@ describe("stripMcpOrgSegment", () => { · 5 unmodified lines
66 it("strips a single org segment before /mcp", () => {
77 expect(stripMcpOrgSegment("/iI9idP7BZcWpg9wW8cit3xE4r4dFSnHj/mcp")).toBe("/mcp");
88 expect(stripMcpOrgSegment("/org_123/mcp")).toBe("/mcp");
9+ expect(stripMcpOrgSegment("/org_123/mcp/toolkits/deploy")).toBe("/mcp/toolkits/deploy");
910 });
1011
1112 it("strips the org segment from the protected-resource discovery path", () => {
1213 expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp")).toBe(
13− "/.well-known/oauth-protected-resource/mcp",
14+ "/.well-known/oauth-protected-resource",
1415 );
16+ expect(
17+ stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp/toolkits/deploy"),
18+ ).toBe("/.well-known/oauth-protected-resource/mcp/toolkits/deploy");
1519 });
1620
1721 it("leaves the bare paths untouched", () => {
1822 expect(stripMcpOrgSegment("/mcp")).toBeNull();
19− expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/mcp")).toBeNull();
23+ expect(stripMcpOrgSegment("/mcp/toolkits/deploy")).toBeNull();
2024 expect(stripMcpOrgSegment("/.well-known/oauth-authorization-server")).toBeNull();
2125 });
2226
apps/host-selfhost/src/mcp/org-path.ts+15−4
@@ -20,8 +20,11 @@ const PRM_PREFIX = "/.well-known/oauth-protected-resource"; · 19 unmodified lines
2020 * applies (already bare, not an MCP path, or an OAuth endpoint like
2121 * `/api/auth/mcp/authorize`).
2222 *
23− * /<seg>/mcp -> /mcp
24− * /.well-known/oauth-protected-resource/<seg>/mcp -> /.well-known/oauth-protected-resource/mcp
23+ * /<seg>/mcp -> /mcp
24+ * /<seg>/mcp/toolkits/<toolkit> -> /mcp/toolkits/<toolkit>
25+ * /.well-known/oauth-protected-resource/<seg>/mcp -> /.well-known/oauth-protected-resource
26+ * /.well-known/oauth-protected-resource/<seg>/mcp/toolkits/<toolkit>
27+ * -> /.well-known/oauth-protected-resource/mcp/toolkits/<toolkit>
2528 */
2629 export const stripMcpOrgSegment = (pathname: string): string | null => {
2730 if (pathname.startsWith(`${PRM_PREFIX}/`)) {
@@ -29,8 +32,16 @@ export const stripMcpOrgSegment = (pathname: string): string | null => { · 1 unmodified line
2932 .slice(PRM_PREFIX.length + 1)
3033 .split("/")
3134 .filter((segment) => segment.length > 0);
32− return rest.length === 2 && rest[1] === "mcp" ? `${PRM_PREFIX}/mcp` : null;
35+ if (rest.length === 2 && rest[1] === "mcp") return PRM_PREFIX;
36+ if (rest.length === 4 && rest[1] === "mcp" && rest[2] === "toolkits") {
37+ return `${PRM_PREFIX}/mcp/toolkits/${rest[3]}`;
38+ }
39+ return null;
3340 }
3441 const segments = pathname.split("/").filter((segment) => segment.length > 0);
35− return segments.length === 2 && segments[1] === "mcp" ? "/mcp" : null;
42+ if (segments.length === 2 && segments[1] === "mcp") return "/mcp";
43+ if (segments.length === 4 && segments[1] === "mcp" && segments[2] === "toolkits") {
44+ return `/mcp/toolkits/${segments[3]}`;
45+ }
46+ return null;
3647 };
apps/host-selfhost/vite.config.ts+2−2
@@ -66,8 +66,8 @@ function executorApiPlugin(): Plugin { · 65 unmodified lines
6666 rawUrl = `${pathname}${original.search}`;
6767 }
6868 // Match on PATHNAME, not a raw-URL prefix: `/mcp` must NOT swallow the
69− // SPA route `/mcp-consent` (nor its source module `/mcp-consent.tsx`),
70− // or the dev server misroutes them to the API handler and they 404.
69+ // SPA route `/mcp-consent`, or the dev server misroutes it to the API
70+ // handler and returns a 404.
7171 const path = new URL(rawUrl, devOrigin).pathname;
7272 const handled =
7373 path === "/api" ||
apps/host-selfhost/web/chromeless/device-page.tsxrenamed from apps/host-selfhost/web/device.tsx+0−0

File renamed without changes.

apps/host-selfhost/web/chromeless/mcp-consent-page.tsxrenamed from apps/host-selfhost/web/mcp-consent.tsx+0−0

File renamed without changes.

apps/host-selfhost/web/routeTree.gen.ts+61−0
@@ -11,11 +11,13 @@ · 10 unmodified lines
1111 import { Route as rootRouteImport } from './routes/__root'
1212 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'
1313 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'
14+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'
1415 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets'
1516 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies'
1617 import { Route as ApiKeysRouteImport } from './routes/app/api-keys'
1718 import { Route as AdminRouteImport } from './routes/app/admin'
1819 import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code'
20+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
1921 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
2022 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
2123 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'
@@ -33,6 +35,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = · 11 unmodified lines
3335 path: '/{-$orgSlug}/tools',
3436 getParentRoute: () => rootRouteImport,
3537 } as any)
38+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute =
39+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport.update({
40+ id: '/{-$orgSlug}/toolkits',
41+ path: '/{-$orgSlug}/toolkits',
42+ getParentRoute: () => rootRouteImport,
43+ } as any)
3644 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute =
3745 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport.update({
3846 id: '/{-$orgSlug}/secrets',
@@ -60,6 +68,15 @@ const JoinDotcodeRoute = JoinDotcodeRouteImport.update({ · 21 unmodified lines
6068 path: '/join/$code',
6169 getParentRoute: () => rootRouteImport,
6270 } as any)
71+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute =
72+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update(
73+ {
74+ id: '/$toolkitSlug',
75+ path: '/$toolkitSlug',
76+ getParentRoute: () =>
77+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute,
78+ } as any,
79+ )
6380 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute =
6481 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update(
6582 {
@@ -99,10 +116,12 @@ export interface FileRoutesByFullPath { · 33 unmodified lines
99116 '/{-$orgSlug}/api-keys': typeof ApiKeysRoute
100117 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
101118 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
119+ '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
102120 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
103121 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
104122 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
105123 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
124+ '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
106125 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
107126 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
108127 }
@@ -112,10 +131,12 @@ export interface FileRoutesByTo { · 3 unmodified lines
112131 '/{-$orgSlug}/api-keys': typeof ApiKeysRoute
113132 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
114133 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
134+ '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
115135 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
116136 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
117137 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
118138 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
139+ '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
119140 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
120141 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
121142 }
@@ -126,10 +147,12 @@ export interface FileRoutesById { · 4 unmodified lines
126147 '/{-$orgSlug}/api-keys': typeof ApiKeysRoute
127148 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
128149 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
150+ '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
129151 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
130152 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
131153 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
132154 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
155+ '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
133156 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
134157 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
135158 }
@@ -141,10 +164,12 @@ export interface FileRouteTypes { · 5 unmodified lines
141164 | '/{-$orgSlug}/api-keys'
142165 | '/{-$orgSlug}/policies'
143166 | '/{-$orgSlug}/secrets'
167+ | '/{-$orgSlug}/toolkits'
144168 | '/{-$orgSlug}/tools'
145169 | '/{-$orgSlug}/'
146170 | '/{-$orgSlug}/integrations/$namespace'
147171 | '/{-$orgSlug}/resume/$executionId'
172+ | '/{-$orgSlug}/toolkits/$toolkitSlug'
148173 | '/{-$orgSlug}/integrations/add/$pluginKey'
149174 | '/{-$orgSlug}/plugins/$pluginId/$'
150175 fileRoutesByTo: FileRoutesByTo
@@ -154,10 +179,12 @@ export interface FileRouteTypes { · 3 unmodified lines
154179 | '/{-$orgSlug}/api-keys'
155180 | '/{-$orgSlug}/policies'
156181 | '/{-$orgSlug}/secrets'
182+ | '/{-$orgSlug}/toolkits'
157183 | '/{-$orgSlug}/tools'
158184 | '/{-$orgSlug}'
159185 | '/{-$orgSlug}/integrations/$namespace'
160186 | '/{-$orgSlug}/resume/$executionId'
187+ | '/{-$orgSlug}/toolkits/$toolkitSlug'
161188 | '/{-$orgSlug}/integrations/add/$pluginKey'
162189 | '/{-$orgSlug}/plugins/$pluginId/$'
163190 id:
@@ -167,10 +194,12 @@ export interface FileRouteTypes { · 3 unmodified lines
167194 | '/{-$orgSlug}/api-keys'
168195 | '/{-$orgSlug}/policies'
169196 | '/{-$orgSlug}/secrets'
197+ | '/{-$orgSlug}/toolkits'
170198 | '/{-$orgSlug}/tools'
171199 | '/{-$orgSlug}/'
172200 | '/{-$orgSlug}/integrations/$namespace'
173201 | '/{-$orgSlug}/resume/$executionId'
202+ | '/{-$orgSlug}/toolkits/$toolkitSlug'
174203 | '/{-$orgSlug}/integrations/add/$pluginKey'
175204 | '/{-$orgSlug}/plugins/$pluginId/$'
176205 fileRoutesById: FileRoutesById
@@ -181,6 +210,7 @@ export interface RootRouteChildren { · 4 unmodified lines
181210 ApiKeysRoute: typeof ApiKeysRoute
182211 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
183212 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
213+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
184214 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
185215 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
186216 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
@@ -205,6 +235,13 @@ declare module '@tanstack/react-router' { · 18 unmodified lines
205235 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport
206236 parentRoute: typeof rootRouteImport
207237 }
238+ '/{-$orgSlug}/toolkits': {
239+ id: '/{-$orgSlug}/toolkits'
240+ path: '/{-$orgSlug}/toolkits'
241+ fullPath: '/{-$orgSlug}/toolkits'
242+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport
243+ parentRoute: typeof rootRouteImport
244+ }
208245 '/{-$orgSlug}/secrets': {
209246 id: '/{-$orgSlug}/secrets'
210247 path: '/{-$orgSlug}/secrets'
@@ -240,6 +277,13 @@ declare module '@tanstack/react-router' { · 29 unmodified lines
240277 preLoaderRoute: typeof JoinDotcodeRouteImport
241278 parentRoute: typeof rootRouteImport
242279 }
280+ '/{-$orgSlug}/toolkits/$toolkitSlug': {
281+ id: '/{-$orgSlug}/toolkits/$toolkitSlug'
282+ path: '/$toolkitSlug'
283+ fullPath: '/{-$orgSlug}/toolkits/$toolkitSlug'
284+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport
285+ parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute
286+ }
243287 '/{-$orgSlug}/resume/$executionId': {
244288 id: '/{-$orgSlug}/resume/$executionId'
245289 path: '/{-$orgSlug}/resume/$executionId'
@@ -271,6 +315,21 @@ declare module '@tanstack/react-router' { · 25 unmodified lines
271315 }
272316 }
273317
318+interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren {
319+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
320+}
321+
322+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren =
323+ {
324+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute:
325+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute,
326+ }
327+
328+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren =
329+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute._addFileChildren(
330+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren,
331+ )
332+
274333 const rootRouteChildren: RootRouteChildren = {
275334 JoinDotcodeRoute: JoinDotcodeRoute,
276335 AdminRoute: AdminRoute,
@@ -279,6 +338,8 @@ const rootRouteChildren: RootRouteChildren = { · 2 unmodified lines
279338 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute,
280339 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute:
281340 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute,
341+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute:
342+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,
282343 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:
283344 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
284345 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
apps/host-selfhost/web/routes/__root.tsx+2−2
@@ -11,8 +11,8 @@ import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shel · 10 unmodified lines
1111 import { plugins as clientPlugins } from "virtual:executor/plugins-client";
1212
1313 import { authClient } from "../auth-client";
14−import { DevicePage } from "../device";
15−import { McpConsentPage } from "../mcp-consent";
14+import { DevicePage } from "../chromeless/device-page";
15+import { McpConsentPage } from "../chromeless/mcp-consent-page";
1616 import { LoginPage } from "../login";
1717 import { SetupPage } from "../setup";
1818 import { fetchNeedsSetup } from "../setup-status";
apps/local/executor.config.ts+7−1
@@ -8,6 +8,7 @@ import { keychainPlugin } from "@executor-js/plugin-keychain"; · 7 unmodified lines
88 import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
99 import { onepasswordHttpPlugin } from "@executor-js/plugin-onepassword/api";
1010 import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/server";
11+import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
1112
1213 // ---------------------------------------------------------------------------
1314 // Single source of truth for the local app's plugin list.
@@ -18,14 +19,19 @@ import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/serv · 4 unmodified lines
1819 // First-party and third-party plugins use the same import-and-call flow.
1920 // ---------------------------------------------------------------------------
2021
22+interface LocalPluginDeps {
23+ readonly activeToolkitSlug?: string;
24+}
25+
2126 export default defineExecutorConfig({
22− plugins: () =>
27+ plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) =>
2328 [
2429 openApiHttpPlugin(),
2530 googleHttpPlugin(),
2631 microsoftHttpPlugin(),
2732 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),
2833 graphqlHttpPlugin(),
34+ toolkitsPlugin({ activeToolkitSlug }),
2935 keychainPlugin(),
3036 fileSecretsPlugin(),
3137 onepasswordHttpPlugin(),
apps/local/package.json+1−0
@@ -39,6 +39,7 @@ · 38 unmodified lines
3939 "@executor-js/plugin-microsoft": "workspace:*",
4040 "@executor-js/plugin-onepassword": "workspace:*",
4141 "@executor-js/plugin-openapi": "workspace:*",
42+ "@executor-js/plugin-toolkits": "workspace:*",
4243 "@executor-js/react": "workspace:*",
4344 "@executor-js/runtime-quickjs": "workspace:*",
4445 "@executor-js/sdk": "workspace:*",
apps/local/src/executor.ts+42−31
@@ -57,33 +57,40 @@ const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "ex · 56 unmodified lines
5757 // Static config wins on conflict, matching the Vite plugin.
5858 type LocalPlugins = readonly AnyPlugin[];
5959
60−const loadLocalPlugins = Effect.gen(function* () {
61− const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd();
62− const staticPlugins = executorConfig.plugins();
63− const dynamicPlugins =
64− (yield* Effect.promise(() => loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }))) ??
65− [];
66−
67− const staticPackageNames = new Set(
68− staticPlugins.map((plugin) => plugin.packageName).filter((name): name is string => !!name),
69− );
70− const dedupedDynamic = dynamicPlugins.filter((plugin) => {
71− if (plugin.packageName && staticPackageNames.has(plugin.packageName)) {
72− console.warn(
73− `[executor] plugin "${plugin.packageName}" appears in both ` +
74− `executor.config.ts and executor.jsonc#plugins. The static ` +
75− `entry wins; the jsonc entry is ignored.`,
76− );
77− return false;
78− }
79− return true;
80− });
60+export interface LocalExecutorOptions {
61+ readonly activeToolkitSlug?: string;
62+}
8163
82− return {
83− cwd,
84− plugins: [...staticPlugins, ...dedupedDynamic] as LocalPlugins,
85− };
86−});
64+const loadLocalPlugins = (options: LocalExecutorOptions = {}) =>
65+ Effect.gen(function* () {
66+ const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd();
67+ const staticPlugins = executorConfig.plugins({
68+ activeToolkitSlug: options.activeToolkitSlug,
69+ });
70+ const dynamicPlugins =
71+ (yield* Effect.promise(() => loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }))) ??
72+ [];
73+
74+ const staticPackageNames = new Set(
75+ staticPlugins.map((plugin) => plugin.packageName).filter((name): name is string => !!name),
76+ );
77+ const dedupedDynamic = dynamicPlugins.filter((plugin) => {
78+ if (plugin.packageName && staticPackageNames.has(plugin.packageName)) {
79+ console.warn(
80+ `[executor] plugin "${plugin.packageName}" appears in both ` +
81+ `executor.config.ts and executor.jsonc#plugins. The static ` +
82+ `entry wins; the jsonc entry is ignored.`,
83+ );
84+ return false;
85+ }
86+ return true;
87+ });
88+
89+ return {
90+ cwd,
91+ plugins: [...staticPlugins, ...dedupedDynamic] as LocalPlugins,
92+ };
93+ });
8794
8895 interface LocalExecutorBundle {
8996 readonly executor: Executor<LocalPlugins>;
@@ -134,12 +141,12 @@ const handleOrNull = (promise: ReturnType<typeof createExecutorHandle>) => · 44 unmodified lines
134141 ),
135142 );
136143
137−const createLocalExecutorLayer = () => {
144+const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => {
138145 const storage = resolveStorage();
139146
140147 return Layer.effect(LocalExecutorTag)(
141148 Effect.gen(function* () {
142− const { cwd, plugins } = yield* loadLocalPlugins;
149+ const { cwd, plugins } = yield* loadLocalPlugins(options);
143150 const tenantId = makeTenantId(cwd);
144151 const tables = collectTables();
145152
@@ -180,7 +187,11 @@ const createLocalExecutorLayer = () => { · 34 unmodified lines
180187 // without touching the data.
181188 yield* runSqliteDataMigrations(sqlite.client, localDataMigrations).pipe(
182189 Effect.mapError(
183− (cause) => new LocalExecutorCreateError({ message: CREATE_SQLITE_ERROR_MESSAGE, cause }),
190+ (cause) =>
191+ new LocalExecutorCreateError({
192+ message: CREATE_SQLITE_ERROR_MESSAGE,
193+ cause,
194+ }),
184195 ),
185196 );
186197
@@ -223,8 +234,8 @@ const createLocalExecutorLayer = () => { · 36 unmodified lines
223234 );
224235 };
225236
226−export const createExecutorHandle = async () => {
227− const layer = createLocalExecutorLayer();
237+export const createExecutorHandle = async (options: LocalExecutorOptions = {}) => {
238+ const layer = createLocalExecutorLayer(options);
228239 const runtime = ManagedRuntime.make(layer);
229240 const bundle = await runtime.runPromise(LocalExecutorTag.asEffect());
230241
apps/local/src/main.ts+18−2
@@ -3,7 +3,7 @@ import { Context, Effect, Layer, ManagedRuntime } from "effect"; · 2 unmodified lines
33 import { createExecutionEngine } from "@executor-js/execution";
44 import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
55 import { makeLocalApiHandler } from "./app";
6−import { getExecutorBundle } from "./executor";
6+import { createExecutorHandle, getExecutorBundle } from "./executor";
77 import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
88
99 // ---------------------------------------------------------------------------
@@ -67,7 +67,23 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 57 unmodified lines
6767 executor,
6868 codeExecutor: makeQuickJsExecutor(),
6969 });
70− const mcp = createMcpRequestHandler({ engine });
70+ const mcp = createMcpRequestHandler({
71+ defaultConfig: { engine },
72+ createConfigForResource: async (resource) => {
73+ if (resource.kind === "default") return { config: { engine } };
74+ const handle = await createExecutorHandle({
75+ activeToolkitSlug: resource.slug,
76+ });
77+ const toolkitEngine = createExecutionEngine({
78+ executor: handle.executor,
79+ codeExecutor: makeQuickJsExecutor(),
80+ });
81+ return {
82+ config: { engine: toolkitEngine },
83+ close: handle.dispose,
84+ };
85+ },
86+ });
7187
7288 return { api: apiHandler, mcp };
7389 };
apps/local/src/mcp.ts+84−13
@@ -1,9 +1,14 @@
1−import { Effect } from "effect";
1+import { Effect, type Cause } from "effect";
22 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
33 import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
44 import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
55
6−import { jsonRpcErrorBody } from "@executor-js/host-mcp";
6+import {
7+ defaultMcpResource,
8+ jsonRpcErrorBody,
9+ mcpResourceKey,
10+ type McpResource,
11+} from "@executor-js/host-mcp";
712 import {
813 createExecutorMcpServer,
914 type ExecutorMcpServerConfig,
@@ -15,10 +20,16 @@ import { · 5 unmodified lines
1520 readElicitationMode,
1621 } from "@executor-js/host-mcp/browser-approval";
1722 import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store";
18−import { formatPausedExecution, type ResumeResponse } from "@executor-js/execution";
23+import {
24+ formatPausedExecution,
25+ type ExecutionEngine,
26+ type ResumeResponse,
27+} from "@executor-js/execution";
1928
2029 import { startIntegrationsRefresh } from "./integrations";
2130
31+type AnyExecutionEngine = ExecutionEngine<Cause.YieldableError>;
32+
2233 // ---------------------------------------------------------------------------
2334 // Streamable HTTP handler
2435 // ---------------------------------------------------------------------------
@@ -32,6 +43,18 @@ export type McpRequestHandler = { · 7 unmodified lines
3243 readonly close: () => Promise<void>;
3344 };
3445
46+export interface LocalMcpServerConfig {
47+ readonly config: ExecutorMcpServerConfig;
48+ readonly close?: () => Promise<void>;
49+}
50+
51+export interface LocalMcpRequestHandlerConfig {
52+ readonly defaultConfig: ExecutorMcpServerConfig;
53+ readonly createConfigForResource?: (
54+ resource: McpResource,
55+ ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig;
56+}
57+
3558 // Local serves these error bodies in-process; like the self-host store they are
3659 // INNER responses (no CORS) — byte-identical to the prior hand-rolled copy
3760 // (`content-type: application/json` only) via the canonical renderer.
@@ -79,48 +102,86 @@ const resumeApprovalResult = (executionId: string, response: ResumeResponse) => · 41 unmodified lines
79102 isError: false,
80103 });
81104
82−export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpRequestHandler => {
105+const toolkitPathPattern = /^\/mcp\/toolkits\/([^/?#]+)\/?$/;
106+
107+const resourceFromRequest = (request: Request): McpResource | null => {
108+ const pathname = new URL(request.url).pathname;
109+ if (pathname === "/mcp" || pathname === "/mcp/") return defaultMcpResource;
110+ const match = toolkitPathPattern.exec(pathname);
111+ if (!match) return null;
112+ return { kind: "toolkit", slug: decodeURIComponent(match[1]) };
113+};
114+
115+const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null =>
116+ "engine" in config ? config.engine : null;
117+
118+const normalizeHandlerConfig = (
119+ input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
120+): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input });
121+
122+export const createMcpRequestHandler = (
123+ input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
124+): McpRequestHandler => {
125+ const handlerConfig = normalizeHandlerConfig(input);
83126 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
84127 const servers = new Map<string, McpServer>();
128+ const resources = new Map<string, McpResource>();
129+ const sessionEngines = new Map<string, AnyExecutionEngine>();
130+ const sessionClosers = new Map<string, () => Promise<void>>();
85131 const approvals = makeInProcessBrowserApprovalStore();
86− // Local runs one shared engine across every MCP session (main.ts builds it and
87− // passes it in), so the paused-execution lookup for browser approval reads it
88− // directly — there is no per-session engine to track.
89− const engine = "engine" in config ? config.engine : null;
132+ const defaultEngine = engineFromConfig(handlerConfig.defaultConfig);
90133
91134 const pausedDetail = (
135+ sessionId: string,
92136 executionId: string,
93137 ): Promise<ReturnType<typeof formatPausedExecution> | null> =>
94− engine
138+ (sessionEngines.get(sessionId) ?? defaultEngine)
95139 ? Effect.runPromise(
96− engine.getPausedExecution(executionId).pipe(
140+ (sessionEngines.get(sessionId) ?? defaultEngine)!.getPausedExecution(executionId).pipe(
97141 Effect.map((paused) => (paused ? formatPausedExecution(paused) : null)),
98142 Effect.orElseSucceed(() => null),
99143 ),
100144 )
101145 : Promise.resolve(null);
102146
147+ const configForResource = async (resource: McpResource): Promise<LocalMcpServerConfig> => {
148+ if (!handlerConfig.createConfigForResource) return { config: handlerConfig.defaultConfig };
149+ return handlerConfig.createConfigForResource(resource);
150+ };
151+
103152 const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => {
104153 const t = transports.get(id);
105154 const s = servers.get(id);
155+ const close = sessionClosers.get(id);
106156 transports.delete(id);
107157 servers.delete(id);
158+ resources.delete(id);
159+ sessionEngines.delete(id);
160+ sessionClosers.delete(id);
108161 if (opts.transport) await ignoreClose(t ? () => t.close() : undefined);
109162 if (opts.server) await ignoreClose(s ? () => s.close() : undefined);
163+ await ignoreClose(close);
110164 };
111165
112166 return {
113167 handleRequest: async (request) => {
168+ const resource = resourceFromRequest(request);
169+ if (!resource) return jsonError(404, -32001, "MCP resource not found");
114170 const sessionId = request.headers.get("mcp-session-id");
115171
116172 if (sessionId) {
117173 const transport = transports.get(sessionId);
118174 if (!transport) return jsonError(404, -32001, "Session not found");
175+ const sessionResource = resources.get(sessionId);
176+ if (!sessionResource || mcpResourceKey(sessionResource) !== mcpResourceKey(resource)) {
177+ return jsonError(403, -32003, "Session belongs to a different MCP resource");
178+ }
119179 return transport.handleRequest(request);
120180 }
121181
122182 let created: McpServer | undefined;
123183 let createdSessionId: string | null = null;
184+ let resourceConfig: LocalMcpServerConfig | null = null;
124185 const transport = new WebStandardStreamableHTTPServerTransport({
125186 sessionIdGenerator: () => crypto.randomUUID(),
126187 enableJsonResponse: true,
@@ -128,6 +189,10 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 1 unmodified line
128189 createdSessionId = sid;
129190 transports.set(sid, transport);
130191 if (created) servers.set(sid, created);
192+ resources.set(sid, resource);
193+ const engine = resourceConfig ? engineFromConfig(resourceConfig.config) : null;
194+ if (engine) sessionEngines.set(sid, engine);
195+ if (resourceConfig?.close) sessionClosers.set(sid, resourceConfig.close);
131196 },
132197 onsessionclosed: (sid) => void dispose(sid, { server: true }),
133198 });
@@ -140,9 +205,10 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 6 unmodified lines
140205 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK handler must return JSON-RPC errors from thrown Promise APIs
141206 try {
142207 const elicitationMode = readElicitationMode(request);
208+ resourceConfig = await configForResource(resource);
143209 created = await Effect.runPromise(
144210 createExecutorMcpServer({
145− ...config,
211+ ...resourceConfig.config,
146212 browserApprovalStore: approvals.store,
147213 elicitationMode:
148214 elicitationMode === "browser"
@@ -161,6 +227,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 12 unmodified lines
161227 await ignoreClose(() => transport.close());
162228 const server = created;
163229 await ignoreClose(server ? () => server.close() : undefined);
230+ await ignoreClose(resourceConfig?.close);
164231 }
165232 return response;
166233 } catch (error) {
@@ -169,6 +236,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 2 unmodified lines
169236 await ignoreClose(() => transport.close());
170237 const server = created;
171238 await ignoreClose(server ? () => server.close() : undefined);
239+ await ignoreClose(resourceConfig?.close);
172240 }
173241 return jsonError(500, -32603, "Internal server error");
174242 }
@@ -179,7 +247,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 4 unmodified lines
179247 if (!match) return json({ error: "Not found" }, 404);
180248 if (request.method !== "GET") return json({ error: "Method not allowed" }, 405);
181249
182− const paused = await pausedDetail(decodeURIComponent(match[2]));
250+ const paused = await pausedDetail(decodeURIComponent(match[1]), decodeURIComponent(match[2]));
183251 if (!paused) return json({ error: "Paused execution not found" }, 404);
184252 return json({ text: paused.text, structured: paused.structured });
185253 },
@@ -189,9 +257,12 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 3 unmodified lines
189257 if (!match) return json({ error: "Not found" }, 404);
190258 if (request.method !== "POST") return json({ error: "Method not allowed" }, 405);
191259
260+ const sessionId = decodeURIComponent(match[1]);
192261 const executionId = decodeURIComponent(match[2]);
193262 // The shared engine must still hold the paused execution — guards stale ids.
194− if (!(await pausedDetail(executionId))) return json({ error: "MCP session not found" }, 404);
263+ if (!(await pausedDetail(sessionId, executionId))) {
264+ return json({ error: "MCP session not found" }, 404);
265+ }
195266
196267 const response = await readResumeResponse(request);
197268 if (!response) return json({ error: "Invalid approval response" }, 400);
apps/local/src/serve.ts+14−5
@@ -69,7 +69,9 @@ function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHan · 68 unmodified lines
6969 routePath === "/index.html"
7070 ? htmlResponse(file)
7171 : new Response(file, {
72− headers: { "content-type": file.type || "application/octet-stream" },
72+ headers: {
73+ "content-type": file.type || "application/octet-stream",
74+ },
7375 });
7476 }
7577 }
@@ -89,7 +91,9 @@ function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string · 13 unmodified lines
8991 key === "index.html"
9092 ? htmlResponse(file)
9193 : new Response(file, {
92− headers: { "content-type": file.type || "application/octet-stream" },
94+ headers: {
95+ "content-type": file.type || "application/octet-stream",
96+ },
9397 });
9498 }
9599 return routes;
@@ -109,7 +113,11 @@ interface ViteChild { · 13 unmodified lines
109113 }
110114
111115 async function allocatePort(): Promise<number> {
112− const probe = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response() });
116+ const probe = Bun.serve({
117+ port: 0,
118+ hostname: "127.0.0.1",
119+ fetch: () => new Response(),
120+ });
113121 const port = probe.port ?? 0;
114122 probe.stop(true);
115123 return port;
@@ -362,7 +370,8 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 246 unmodified lines
362370 // provider, which cannot carry our local bearer. Everything else under
363371 // /api and /mcp requires the bearer.
364372 const skipAuth = isUnauthenticatedOAuthPath(url.pathname);
365− const isGatedSurface = url.pathname.startsWith("/api") || url.pathname.startsWith("/mcp");
373+ const isMcpPath = url.pathname === "/mcp" || url.pathname.startsWith("/mcp/");
374+ const isGatedSurface = url.pathname.startsWith("/api") || isMcpPath;
366375
367376 if (isGatedSurface && !skipAuth && !isAuthorized(req)) {
368377 return withCors(
@@ -377,7 +386,7 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 8 unmodified lines
377386 return withCors(oauthClientMetadataResponse(`${url.pathname}${url.search}`, req));
378387 }
379388
380− if (url.pathname.startsWith("/mcp")) {
389+ if (isMcpPath) {
381390 return withCors(await handlers.mcp.handleRequest(req));
382391 }
383392
apps/local/vite.config.ts+11−5
@@ -16,7 +16,11 @@ import { · 15 unmodified lines
1616 // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json
1717 const rootPackage = JSON.parse(
1818 readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
19−) as { version: string; homepage?: string; repository?: string | { url?: string } };
19+) as {
20+ version: string;
21+ homepage?: string;
22+ repository?: string | { url?: string };
23+};
2024
2125 // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json
2226 const cliPackage = JSON.parse(
@@ -94,8 +98,9 @@ function executorApiPlugin(): Plugin { · 71 unmodified lines
9498 });
9599 server.middlewares.use(async (req, res, next) => {
96100 const rawUrl = req.url ?? "/";
97− const isApi = rawUrl.startsWith("/api/") || rawUrl === "/api";
98− const isMcp = rawUrl.startsWith("/mcp");
101+ const pathOnly = rawUrl.split("?")[0] ?? "/";
102+ const isApi = pathOnly.startsWith("/api/") || pathOnly === "/api";
103+ const isMcp = pathOnly === "/mcp" || pathOnly.startsWith("/mcp/");
99104
100105 if (!isApi && !isMcp) return next();
101106
@@ -106,7 +111,6 @@ function executorApiPlugin(): Plugin { · 4 unmodified lines
106111 // callback. The SPA carries the token from its `?_token`/localStorage
107112 // bootstrap, so the UI is unaffected; external MCP clients use the
108113 // daemon port.
109− const pathOnly = rawUrl.split("?")[0] ?? "/";
110114 const authExempt = pathOnly === "/api/health" || isUnauthenticatedOAuthPath(pathOnly);
111115 if (!authExempt) {
112116 const presented = req.headers.authorization;
@@ -151,7 +155,9 @@ function executorApiPlugin(): Plugin { · 38 unmodified lines
151155 } else if (isMcp) {
152156 response = await handlers.mcp.handleRequest(webRequest(rawUrl));
153157 } else if (pathOnly === "/api/health" && req.method === "GET") {
154− response = new Response("ok", { headers: { "content-type": "text/plain" } });
158+ response = new Response("ok", {
159+ headers: { "content-type": "text/plain" },
160+ });
155161 } else if (pathOnly.startsWith("/api/mcp-sessions/")) {
156162 const handler =
157163 req.method === "GET"
bun.lock+50−0
@@ -70,6 +70,7 @@ · 69 unmodified lines
7070 "@executor-js/plugin-mcp": "workspace:*",
7171 "@executor-js/plugin-microsoft": "workspace:*",
7272 "@executor-js/plugin-openapi": "workspace:*",
73+ "@executor-js/plugin-toolkits": "workspace:*",
7374 "@executor-js/plugin-workos-vault": "workspace:*",
7475 "@executor-js/react": "workspace:*",
7576 "@executor-js/runtime-dynamic-worker": "workspace:*",
@@ -180,6 +181,7 @@ · 104 unmodified lines
180181 "@executor-js/plugin-mcp": "workspace:*",
181182 "@executor-js/plugin-microsoft": "workspace:*",
182183 "@executor-js/plugin-openapi": "workspace:*",
184+ "@executor-js/plugin-toolkits": "workspace:*",
183185 "@executor-js/react": "workspace:*",
184186 "@executor-js/runtime-quickjs": "workspace:*",
185187 "@executor-js/sdk": "workspace:*",
@@ -228,6 +230,7 @@ · 42 unmodified lines
228230 "@executor-js/plugin-mcp": "workspace:*",
229231 "@executor-js/plugin-microsoft": "workspace:*",
230232 "@executor-js/plugin-openapi": "workspace:*",
233+ "@executor-js/plugin-toolkits": "workspace:*",
231234 "@executor-js/react": "workspace:*",
232235 "@executor-js/runtime-quickjs": "workspace:*",
233236 "@executor-js/sdk": "workspace:*",
@@ -280,6 +283,7 @@ · 46 unmodified lines
280283 "@executor-js/plugin-microsoft": "workspace:*",
281284 "@executor-js/plugin-onepassword": "workspace:*",
282285 "@executor-js/plugin-openapi": "workspace:*",
286+ "@executor-js/plugin-toolkits": "workspace:*",
283287 "@executor-js/react": "workspace:*",
284288 "@executor-js/runtime-quickjs": "workspace:*",
285289 "@executor-js/sdk": "workspace:*",
@@ -346,6 +350,7 @@ · 60 unmodified lines
346350 "@executor-js/plugin-mcp": "workspace:*",
347351 "@executor-js/plugin-microsoft": "workspace:*",
348352 "@executor-js/plugin-openapi": "workspace:*",
353+ "@executor-js/plugin-toolkits": "workspace:*",
349354 "@executor-js/sdk": "workspace:*",
350355 "@kitlangton/terminal-control": "^0.3.0",
351356 "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1088,6 +1093,49 @@ · 736 unmodified lines
10881093 "react",
10891094 ],
10901095 },
1096+ "packages/plugins/toolkits": {
1097+ "name": "@executor-js/plugin-toolkits",
1098+ "version": "1.5.12",
1099+ "dependencies": {
1100+ "@executor-js/sdk": "workspace:*",
1101+ },
1102+ "devDependencies": {
1103+ "@effect/atom-react": "catalog:",
1104+ "@effect/vitest": "catalog:",
1105+ "@executor-js/api": "workspace:*",
1106+ "@executor-js/react": "workspace:*",
1107+ "@tanstack/react-router": "catalog:",
1108+ "@types/node": "catalog:",
1109+ "@types/react": "catalog:",
1110+ "bun-types": "catalog:",
1111+ "effect": "catalog:",
1112+ "fractional-indexing": "^3.2.0",
1113+ "lucide-react": "^1.7.0",
1114+ "react": "catalog:",
1115+ "tsup": "catalog:",
1116+ "typescript": "catalog:",
1117+ "vitest": "catalog:",
1118+ },
1119+ "peerDependencies": {
1120+ "@effect/atom-react": "catalog:",
1121+ "@executor-js/api": "workspace:*",
1122+ "@executor-js/react": "workspace:*",
1123+ "@tanstack/react-router": "catalog:",
1124+ "effect": "catalog:",
1125+ "fractional-indexing": "^3.2.0",
1126+ "lucide-react": "^1.7.0",
1127+ "react": "catalog:",
1128+ },
1129+ "optionalPeers": [
1130+ "@effect/atom-react",
1131+ "@executor-js/api",
1132+ "@executor-js/react",
1133+ "@tanstack/react-router",
1134+ "fractional-indexing",
1135+ "lucide-react",
1136+ "react",
1137+ ],
1138+ },
10911139 "packages/plugins/workos-vault": {
10921140 "name": "@executor-js/plugin-workos-vault",
10931141 "version": "0.0.2",
@@ -1682,6 +1730,8 @@ · 588 unmodified lines
16821730
16831731 "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"],
16841732
1733+ "@executor-js/plugin-toolkits": ["@executor-js/plugin-toolkits@workspace:packages/plugins/toolkits"],
1734+
16851735 "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"],
16861736
16871737 "@executor-js/react": ["@executor-js/react@workspace:packages/react"],
e2e/local/toolkits-mcp.test.ts+320−0
@@ -0,0 +1,320 @@
1+import { randomBytes } from "node:crypto";
2+import { createServer, type Server } from "node:http";
3+import type { AddressInfo } from "node:net";
4+
5+import { expect } from "@effect/vitest";
6+import { Effect } from "effect";
7+import { HttpApiClient } from "effect/unstable/httpapi";
8+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
9+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
10+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
11+import { composePluginApi } from "@executor-js/api/server";
12+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
13+import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
14+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
15+
16+import { scenario } from "../src/scenario";
17+import { Cli, RunDir } from "../src/services";
18+import { withLocalServer } from "./local-server";
19+
20+const api = composePluginApi([openApiHttpPlugin(), toolkitsPlugin()] as const);
21+
22+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
23+
24+const pingSpec = (baseUrl: string): string =>
25+ JSON.stringify({
26+ openapi: "3.0.3",
27+ info: { title: "Local Toolkit Ping API", version: "1.0.0" },
28+ servers: [{ url: baseUrl }],
29+ paths: {
30+ "/ping/{id}": {
31+ get: {
32+ operationId: "getPing",
33+ summary: "Return a ping payload",
34+ security: [{ apiKey: [] }],
35+ parameters: [
36+ {
37+ name: "id",
38+ in: "path",
39+ required: true,
40+ schema: { type: "string" },
41+ },
42+ ],
43+ responses: {
44+ "200": {
45+ description: "A ping payload",
46+ content: {
47+ "application/json": {
48+ schema: {
49+ type: "object",
50+ properties: {
51+ id: { type: "string" },
52+ path: { type: "string" },
53+ },
54+ },
55+ },
56+ },
57+ },
58+ },
59+ },
60+ },
61+ },
62+ components: {
63+ securitySchemes: {
64+ apiKey: { type: "apiKey", in: "header", name: "x-e2e-token" },
65+ },
66+ },
67+ });
68+
69+const closeServer = (server: Server): Promise<void> =>
70+ new Promise((resolve) => {
71+ server.close(() => resolve());
72+ });
73+
74+const servePingApi = Effect.acquireRelease(
75+ Effect.promise(
76+ () =>
77+ new Promise<{ readonly url: string; readonly server: Server }>((resolve) => {
78+ const server = createServer((request, response) => {
79+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
80+ if (request.method === "GET" && url.pathname.startsWith("/ping/")) {
81+ response.writeHead(200, { "content-type": "application/json" });
82+ response.end(
83+ JSON.stringify({
84+ id: decodeURIComponent(url.pathname.slice("/ping/".length)),
85+ path: url.pathname,
86+ }),
87+ );
88+ return;
89+ }
90+ response.writeHead(404, { "content-type": "application/json" });
91+ response.end(JSON.stringify({ error: "not_found" }));
92+ });
93+ server.listen(0, "127.0.0.1", () => {
94+ const address = server.address() as AddressInfo;
95+ resolve({ url: `http://127.0.0.1:${address.port}`, server });
96+ });
97+ }),
98+ ),
99+ ({ server }) => Effect.promise(() => closeServer(server)).pipe(Effect.ignore),
100+);
101+
102+const connectionPattern = (integration: string, name: string): string =>
103+ `${integration}.org.${name}.*`;
104+
105+const pingToolPath = (integration: string, name: string): string =>
106+ `${integration}.org.${name}.ping.getPing`;
107+
108+const callPingCode = (input: {
109+ readonly integration: string;
110+ readonly connection: string;
111+ readonly id: string;
112+}) => `
113+const listed = await tools.search({ namespace: ${JSON.stringify(input.integration)}, query: "ping", limit: 100 });
114+const expected = ${JSON.stringify(`${input.integration}.org.${input.connection}.`)};
115+const path = listed.items.map((item) => item.path).find((candidate) => candidate.startsWith(expected));
116+if (!path) return { ok: false, reason: "missing", expected, paths: listed.items.map((item) => item.path).sort() };
117+let tool = tools;
118+for (const segment of path.split(".")) tool = tool?.[segment];
119+if (typeof tool !== "function") return { ok: false, reason: "not-callable", path };
120+const result = await tool({ id: ${JSON.stringify(input.id)} });
121+return JSON.stringify({ ok: result.ok, path, data: result.ok ? result.data : result.error });
122+`;
123+
124+const listVisiblePathsCode = (integration: string) => `
125+const listed = await tools.search({ namespace: ${JSON.stringify(integration)}, query: "ping", limit: 100 });
126+return JSON.stringify({ paths: listed.items.map((item) => item.path).sort() });
127+`;
128+
129+const makeMcp = async (url: string, token: string, name: string) => {
130+ const client = new Client({ name, version: "1.0.0" }, { capabilities: {} });
131+ const transport = new StreamableHTTPClientTransport(new URL(url), {
132+ requestInit: { headers: { authorization: `Bearer ${token}` } },
133+ });
134+ await client.connect(transport);
135+ return { client, transport };
136+};
137+
138+const textFromCall = (result: Awaited<ReturnType<Client["callTool"]>>): string => {
139+ const blocks = result.content ?? [];
140+ const text = blocks.find((block) => block.type === "text")?.text;
141+ if (typeof text !== "string") throw new Error(`MCP call returned no text block`);
142+ return text;
143+};
144+
145+const executeJson = async (client: Client, code: string): Promise<Record<string, unknown>> => {
146+ const result = await client.callTool({
147+ name: "execute",
148+ arguments: { code },
149+ });
150+ return JSON.parse(textFromCall(result)) as Record<string, unknown>;
151+};
152+
153+scenario(
154+ "Local toolkits · scoped MCP hides blocked and unselected connections",
155+ { timeout: 240_000 },
156+ Effect.scoped(
157+ Effect.gen(function* () {
158+ const cli = yield* Cli;
159+ const runDir = yield* RunDir;
160+ const upstream = yield* servePingApi;
161+
162+ yield* withLocalServer(cli, runDir, (server) =>
163+ Effect.gen(function* () {
164+ const client = yield* HttpApiClient.make(api, {
165+ baseUrl: new URL("/api", server.origin).toString(),
166+ transformClient: HttpClient.mapRequest((request) =>
167+ HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
168+ ),
169+ }).pipe(Effect.provide(FetchHttpClient.layer));
170+
171+ const integration = unique("local_toolkit_ping");
172+ const selected = "selected";
173+ const blocked = "blocked";
174+ const unselected = "unselected";
175+
176+ yield* client.openapi.addSpec({
177+ payload: {
178+ spec: { kind: "blob", value: pingSpec(upstream.url) },
179+ slug: IntegrationSlug.make(integration),
180+ baseUrl: upstream.url,
181+ authenticationTemplate: [
182+ {
183+ slug: "apiKey",
184+ type: "apiKey",
185+ headers: {
186+ "x-e2e-token": [{ type: "variable", name: "token" }],
187+ },
188+ },
189+ ],
190+ },
191+ });
192+
193+ for (const name of [selected, blocked, unselected]) {
194+ yield* client.connections.create({
195+ payload: {
196+ owner: "org",
197+ name: ConnectionName.make(name),
198+ integration: IntegrationSlug.make(integration),
199+ template: AuthTemplateSlug.make("apiKey"),
200+ value: "unused-token",
201+ },
202+ });
203+ }
204+
205+ const toolkit = yield* client.toolkits.create({
206+ payload: { owner: "org", name: unique("local-kit") },
207+ });
208+ yield* client.toolkits.createConnection({
209+ params: { toolkitId: toolkit.id },
210+ payload: { pattern: connectionPattern(integration, selected) },
211+ });
212+ yield* client.toolkits.createConnection({
213+ params: { toolkitId: toolkit.id },
214+ payload: { pattern: connectionPattern(integration, blocked) },
215+ });
216+ yield* client.toolkits.createPolicy({
217+ params: { toolkitId: toolkit.id },
218+ payload: {
219+ pattern: connectionPattern(integration, selected),
220+ action: "approve",
221+ },
222+ });
223+ yield* client.toolkits.createPolicy({
224+ params: { toolkitId: toolkit.id },
225+ payload: {
226+ pattern: connectionPattern(integration, blocked),
227+ action: "block",
228+ },
229+ });
230+
231+ const toolkitMcp = yield* Effect.promise(() =>
232+ makeMcp(
233+ new URL(`/mcp/toolkits/${toolkit.slug}`, server.origin).toString(),
234+ server.token,
235+ "local-toolkit-e2e",
236+ ),
237+ );
238+ yield* Effect.addFinalizer(() =>
239+ Effect.promise(() => toolkitMcp.client.close()).pipe(Effect.ignore),
240+ );
241+
242+ const selectedCall = yield* Effect.promise(() =>
243+ executeJson(
244+ toolkitMcp.client,
245+ callPingCode({
246+ integration,
247+ connection: selected,
248+ id: "from-toolkit",
249+ }),
250+ ),
251+ );
252+ expect(selectedCall.ok, `selected call: ${JSON.stringify(selectedCall)}`).toBe(true);
253+ expect((selectedCall.data as { id?: unknown }).id).toBe("from-toolkit");
254+
255+ const visible = yield* Effect.promise(() =>
256+ executeJson(toolkitMcp.client, listVisiblePathsCode(integration)),
257+ );
258+ expect(visible.paths).toContain(pingToolPath(integration, selected));
259+ expect(visible.paths).not.toContain(pingToolPath(integration, blocked));
260+ expect(visible.paths).not.toContain(pingToolPath(integration, unselected));
261+
262+ const blockedCall = yield* Effect.promise(() =>
263+ executeJson(
264+ toolkitMcp.client,
265+ callPingCode({
266+ integration,
267+ connection: blocked,
268+ id: "blocked-should-not-run",
269+ }),
270+ ),
271+ );
272+ expect(blockedCall.reason, `blocked call: ${JSON.stringify(blockedCall)}`).toBe(
273+ "missing",
274+ );
275+
276+ const unselectedCall = yield* Effect.promise(() =>
277+ executeJson(
278+ toolkitMcp.client,
279+ callPingCode({
280+ integration,
281+ connection: unselected,
282+ id: "should-not-run",
283+ }),
284+ ),
285+ );
286+ expect(unselectedCall.reason, `unselected call: ${JSON.stringify(unselectedCall)}`).toBe(
287+ "missing",
288+ );
289+
290+ const defaultMcp = yield* Effect.promise(() =>
291+ makeMcp(new URL("/mcp", server.origin).toString(), server.token, "local-default-e2e"),
292+ );
293+ yield* Effect.addFinalizer(() =>
294+ Effect.promise(() => defaultMcp.client.close()).pipe(Effect.ignore),
295+ );
296+
297+ const leakedSession = defaultMcp.transport.sessionId;
298+ expect(typeof leakedSession, "default MCP session has an id").toBe("string");
299+ const crossResource = yield* Effect.promise(() =>
300+ fetch(new URL(`/mcp/toolkits/${toolkit.slug}`, server.origin), {
301+ method: "POST",
302+ headers: {
303+ authorization: `Bearer ${server.token}`,
304+ "mcp-session-id": leakedSession ?? "",
305+ "content-type": "application/json",
306+ accept: "application/json, text/event-stream",
307+ },
308+ body: JSON.stringify({
309+ jsonrpc: "2.0",
310+ id: 1,
311+ method: "tools/list",
312+ }),
313+ }),
314+ );
315+ expect(crossResource.status, "default session id cannot cross into toolkit").toBe(403);
316+ }),
317+ );
318+ }),
319+ ),
320+);
e2e/package.json+1−0
@@ -27,6 +27,7 @@ · 26 unmodified lines
2727 "@executor-js/plugin-mcp": "workspace:*",
2828 "@executor-js/plugin-microsoft": "workspace:*",
2929 "@executor-js/plugin-openapi": "workspace:*",
30+ "@executor-js/plugin-toolkits": "workspace:*",
3031 "@executor-js/sdk": "workspace:*",
3132 "@kitlangton/terminal-control": "^0.3.0",
3233 "@modelcontextprotocol/sdk": "^1.29.0",
e2e/scenarios/toolkits-mcp.test.ts+585−0
@@ -0,0 +1,585 @@
1+import { randomBytes } from "node:crypto";
2+import { createServer, type Server } from "node:http";
3+import type { AddressInfo } from "node:net";
4+
5+import { expect } from "@effect/vitest";
6+import { Effect } from "effect";
7+import { composePluginApi } from "@executor-js/api/server";
8+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
9+import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
10+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
11+
12+import { scenario } from "../src/scenario";
13+import { Api, Mcp, Target } from "../src/services";
14+import type { McpSession } from "../src/surfaces/mcp";
15+
16+const api = composePluginApi([openApiHttpPlugin(), toolkitsPlugin()] as const);
17+
18+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
19+
20+const pingSpec = (baseUrl: string): string =>
21+ JSON.stringify({
22+ openapi: "3.0.3",
23+ info: { title: "Toolkit Ping API", version: "1.0.0" },
24+ servers: [{ url: baseUrl }],
25+ paths: {
26+ "/ping/{id}": {
27+ get: {
28+ operationId: "getPing",
29+ summary: "Return a ping payload",
30+ security: [{ apiKey: [] }],
31+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
32+ responses: {
33+ "200": {
34+ description: "A ping payload",
35+ content: {
36+ "application/json": {
37+ schema: {
38+ type: "object",
39+ properties: {
40+ id: { type: "string" },
41+ path: { type: "string" },
42+ },
43+ },
44+ },
45+ },
46+ },
47+ },
48+ },
49+ },
50+ },
51+ components: {
52+ securitySchemes: {
53+ apiKey: { type: "apiKey", in: "header", name: "x-e2e-token" },
54+ },
55+ },
56+ });
57+
58+const closeServer = (server: Server): Promise<void> =>
59+ new Promise((resolve) => {
60+ server.close(() => resolve());
61+ });
62+
63+const servePingApi = Effect.acquireRelease(
64+ Effect.promise(
65+ () =>
66+ new Promise<{ readonly url: string; readonly server: Server }>((resolve) => {
67+ const server = createServer((request, response) => {
68+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
69+ if (request.method === "GET" && url.pathname.startsWith("/ping/")) {
70+ response.writeHead(200, { "content-type": "application/json" });
71+ response.end(
72+ JSON.stringify({
73+ id: decodeURIComponent(url.pathname.slice("/ping/".length)),
74+ path: url.pathname,
75+ }),
76+ );
77+ return;
78+ }
79+ response.writeHead(404, { "content-type": "application/json" });
80+ response.end(JSON.stringify({ error: "not_found" }));
81+ });
82+ server.listen(0, "127.0.0.1", () => {
83+ const address = server.address() as AddressInfo;
84+ resolve({ url: `http://127.0.0.1:${address.port}`, server });
85+ });
86+ }),
87+ ),
88+ ({ server }) => Effect.promise(() => closeServer(server)).pipe(Effect.ignore),
89+);
90+
91+const toolkitUrl = (baseUrl: string, slug: string): string =>
92+ new URL(`/mcp/toolkits/${slug}`, baseUrl).toString();
93+
94+const connectionPattern = (integration: string, owner: "org" | "user", name: string): string =>
95+ `${integration}.${owner}.${name}.*`;
96+
97+const executeJson = (session: McpSession, code: string) =>
98+ Effect.gen(function* () {
99+ const result = yield* session.call("execute", { code });
100+ expect(result.ok, `execute completed (got: ${result.text.slice(0, 500)})`).toBe(true);
101+ return JSON.parse(result.text) as Record<string, unknown>;
102+ });
103+
104+const callPingCode = (input: {
105+ readonly integration: string;
106+ readonly owner: "org" | "user";
107+ readonly connection: string;
108+ readonly id: string;
109+}) => `
110+const listed = await tools.search({ namespace: ${JSON.stringify(input.integration)}, query: "ping", limit: 100 });
111+const expected = ${JSON.stringify(`${input.integration}.${input.owner}.${input.connection}.`)};
112+const path = listed.items.map((item) => item.path).find((candidate) => candidate.startsWith(expected));
113+if (!path) return { ok: false, reason: "missing", expected, paths: listed.items.map((item) => item.path).sort() };
114+let tool = tools;
115+for (const segment of path.split(".")) tool = tool?.[segment];
116+if (typeof tool !== "function") return { ok: false, reason: "not-callable", path };
117+const result = await tool({ id: ${JSON.stringify(input.id)} });
118+return { ok: result.ok, path, data: result.ok ? result.data : result.error };
119+`;
120+
121+const visibleConnectionPathsCode = (integration: string) => `
122+const listed = await tools.search({ namespace: ${JSON.stringify(integration)}, query: "ping", limit: 100 });
123+return { paths: listed.items.map((item) => item.path).sort() };
124+`;
125+
126+const createPolicyCode = (input: {
127+ readonly pattern: string;
128+ readonly action: "approve" | "require_approval" | "block";
129+}) => `
130+const created = await tools.executor.coreTools.policies.create({
131+ owner: "user",
132+ pattern: ${JSON.stringify(input.pattern)},
133+ action: ${JSON.stringify(input.action)},
134+});
135+return JSON.stringify({ ok: created.ok, data: created.ok ? created.data : null, error: created.ok ? null : created.error });
136+`;
137+
138+const assertCallOk = (value: Record<string, unknown>, label: string) => {
139+ expect(value.ok, `${label}: ${JSON.stringify(value)}`).toBe(true);
140+};
141+
142+const assertCallMissing = (value: Record<string, unknown>, label: string) => {
143+ expect(value.ok, `${label}: ${JSON.stringify(value)}`).toBe(false);
144+ expect(value.reason, `${label} is missing from the toolkit catalog`).toBe("missing");
145+};
146+
147+scenario(
148+ "Toolkits · OAuth metadata and challenges stay scoped to the toolkit endpoint",
149+ { timeout: 60_000 },
150+ Effect.gen(function* () {
151+ const target = yield* Target;
152+ const slug = unique("metadata-kit");
153+ const mcpUrl = new URL(`/mcp/toolkits/${slug}`, target.baseUrl);
154+ const metadataUrl = new URL(
155+ `/.well-known/oauth-protected-resource/mcp/toolkits/${slug}`,
156+ target.baseUrl,
157+ );
158+
159+ const metadataResponse = yield* Effect.promise(() => fetch(metadataUrl));
160+ expect(metadataResponse.status, "toolkit protected-resource metadata is served").toBe(200);
161+ const metadata = (yield* Effect.promise(() => metadataResponse.json())) as Record<
162+ string,
163+ unknown
164+ >;
165+ expect(metadata.resource, "metadata advertises the toolkit MCP resource").toBe(
166+ mcpUrl.toString(),
167+ );
168+ expect(
169+ Array.isArray(metadata.authorization_servers),
170+ "metadata still advertises authorization servers",
171+ ).toBe(true);
172+
173+ if (target.name === "cloudflare") return;
174+
175+ const challenged = yield* Effect.promise(() =>
176+ fetch(mcpUrl, {
177+ method: "POST",
178+ headers: {
179+ "content-type": "application/json",
180+ accept: "application/json, text/event-stream",
181+ },
182+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
183+ }),
184+ );
185+ expect(challenged.status, "unauthenticated toolkit MCP requests are challenged").toBe(401);
186+ expect(
187+ challenged.headers.get("www-authenticate") ?? "",
188+ "challenge points clients at toolkit metadata",
189+ ).toContain(metadataUrl.toString());
190+ }),
191+);
192+
193+scenario(
194+ "Toolkits · workspace and personal MCP endpoints expose the right connection sets",
195+ { timeout: 240_000 },
196+ Effect.scoped(
197+ Effect.gen(function* () {
198+ const target = yield* Target;
199+ const mcp = yield* Mcp;
200+ const { client: makeClient } = yield* Api;
201+ const upstream = yield* servePingApi;
202+ const identity = yield* target.newIdentity();
203+ const client = yield* makeClient(api, identity);
204+
205+ const integration = unique("toolkit_ping");
206+ const workspaceToolkitName = unique("workspace-kit");
207+ const personalToolkitName = unique("personal-kit");
208+ const workspaceConnections = Array.from({ length: 30 }, (_, index) => `shared${index}`);
209+ const personalConnection = "mine";
210+
211+ yield* Effect.gen(function* () {
212+ yield* client.openapi.addSpec({
213+ payload: {
214+ spec: { kind: "blob", value: pingSpec(upstream.url) },
215+ slug: IntegrationSlug.make(integration),
216+ baseUrl: upstream.url,
217+ authenticationTemplate: [
218+ {
219+ slug: "apiKey",
220+ type: "apiKey",
221+ headers: { "x-e2e-token": [{ type: "variable", name: "token" }] },
222+ },
223+ ],
224+ },
225+ });
226+
227+ for (const name of workspaceConnections) {
228+ yield* client.connections.create({
229+ payload: {
230+ owner: "org",
231+ name: ConnectionName.make(name),
232+ integration: IntegrationSlug.make(integration),
233+ template: AuthTemplateSlug.make("apiKey"),
234+ value: "unused-token",
235+ },
236+ });
237+ }
238+ yield* client.connections.create({
239+ payload: {
240+ owner: "user",
241+ name: ConnectionName.make(personalConnection),
242+ integration: IntegrationSlug.make(integration),
243+ template: AuthTemplateSlug.make("apiKey"),
244+ value: "unused-token",
245+ },
246+ });
247+
248+ const workspaceToolkit = yield* client.toolkits.create({
249+ payload: { owner: "org", name: workspaceToolkitName },
250+ });
251+ for (const name of workspaceConnections) {
252+ yield* client.toolkits.createConnection({
253+ params: { toolkitId: workspaceToolkit.id },
254+ payload: { pattern: connectionPattern(integration, "org", name) },
255+ });
256+ }
257+ yield* client.toolkits.createConnection({
258+ params: { toolkitId: workspaceToolkit.id },
259+ payload: { pattern: connectionPattern(integration, "user", personalConnection) },
260+ });
261+
262+ const personalToolkit = yield* client.toolkits.create({
263+ payload: { owner: "user", name: personalToolkitName },
264+ });
265+ yield* client.toolkits.createConnection({
266+ params: { toolkitId: personalToolkit.id },
267+ payload: { pattern: connectionPattern(integration, "org", workspaceConnections[0]!) },
268+ });
269+ yield* client.toolkits.createConnection({
270+ params: { toolkitId: personalToolkit.id },
271+ payload: { pattern: connectionPattern(integration, "user", personalConnection) },
272+ });
273+
274+ const workspaceSession = mcp.session(identity, {
275+ url: toolkitUrl(target.baseUrl, workspaceToolkit.slug),
276+ });
277+ const personalSession = mcp.session(identity, {
278+ url: toolkitUrl(target.baseUrl, personalToolkit.slug),
279+ });
280+
281+ const workspacePaths = yield* executeJson(
282+ workspaceSession,
283+ visibleConnectionPathsCode(integration),
284+ );
285+ const paths = workspacePaths.paths as string[];
286+ expect(paths.length, "workspace toolkit exposes every workspace connection").toBe(
287+ workspaceConnections.length,
288+ );
289+ for (const name of workspaceConnections) {
290+ expect(paths, `workspace toolkit includes ${name}`).toContain(
291+ `${integration}.org.${name}.ping.getPing`,
292+ );
293+ }
294+ expect(
295+ paths,
296+ "workspace toolkit does not expose a personal connection even when its pattern was added",
297+ ).not.toContain(`${integration}.user.${personalConnection}.ping.getPing`);
298+
299+ const workspaceCall = yield* executeJson(
300+ workspaceSession,
301+ callPingCode({
302+ integration,
303+ owner: "org",
304+ connection: workspaceConnections[3]!,
305+ id: "workspace-call",
306+ }),
307+ );
308+ assertCallOk(workspaceCall, "workspace connection is callable");
309+
310+ const lateWorkspaceCall = yield* executeJson(
311+ workspaceSession,
312+ callPingCode({
313+ integration,
314+ owner: "org",
315+ connection: workspaceConnections.at(-1)!,
316+ id: "workspace-late-call",
317+ }),
318+ );
319+ assertCallOk(lateWorkspaceCall, "late workspace connection is callable");
320+
321+ const personalBlockedFromWorkspace = yield* executeJson(
322+ workspaceSession,
323+ callPingCode({
324+ integration,
325+ owner: "user",
326+ connection: personalConnection,
327+ id: "workspace-personal-blocked",
328+ }),
329+ );
330+ assertCallMissing(personalBlockedFromWorkspace, "workspace toolkit blocks personal tools");
331+
332+ const personalWorkspaceCall = yield* executeJson(
333+ personalSession,
334+ callPingCode({
335+ integration,
336+ owner: "org",
337+ connection: workspaceConnections[0]!,
338+ id: "personal-workspace-call",
339+ }),
340+ );
341+ assertCallOk(personalWorkspaceCall, "personal toolkit can call a workspace connection");
342+
343+ const personalPaths = yield* executeJson(
344+ personalSession,
345+ visibleConnectionPathsCode(integration),
346+ );
347+ const personalVisiblePaths = personalPaths.paths as string[];
348+ expect(
349+ personalVisiblePaths,
350+ "personal toolkit includes its selected workspace tool",
351+ ).toContain(`${integration}.org.${workspaceConnections[0]}.ping.getPing`);
352+ expect(
353+ personalVisiblePaths,
354+ "personal toolkit excludes unselected workspace tools from the same integration",
355+ ).not.toContain(`${integration}.org.${workspaceConnections[1]}.ping.getPing`);
356+
357+ const personalOwnCall = yield* executeJson(
358+ personalSession,
359+ callPingCode({
360+ integration,
361+ owner: "user",
362+ connection: personalConnection,
363+ id: "personal-own-call",
364+ }),
365+ );
366+ assertCallOk(personalOwnCall, "personal toolkit can call a personal connection");
367+ }).pipe(
368+ Effect.ensuring(
369+ Effect.gen(function* () {
370+ const listed = yield* client.toolkits.list();
371+ yield* Effect.forEach(
372+ listed.toolkits.filter((toolkit) =>
373+ [workspaceToolkitName, personalToolkitName].includes(toolkit.name),
374+ ),
375+ (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }),
376+ { discard: true },
377+ );
378+ yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore);
379+ }).pipe(Effect.ignore),
380+ ),
381+ );
382+ }),
383+ ),
384+);
385+
386+scenario(
387+ "Toolkits · an open MCP session follows toolkit connection add and remove changes",
388+ { timeout: 240_000 },
389+ Effect.scoped(
390+ Effect.gen(function* () {
391+ const target = yield* Target;
392+ const mcp = yield* Mcp;
393+ const { client: makeClient } = yield* Api;
394+ const upstream = yield* servePingApi;
395+ const identity = yield* target.newIdentity();
396+ const client = yield* makeClient(api, identity);
397+
398+ const integration = unique("toolkit_live");
399+ const toolkitName = unique("live-session-kit");
400+ const connection = "main";
401+
402+ yield* Effect.gen(function* () {
403+ yield* client.openapi.addSpec({
404+ payload: {
405+ spec: { kind: "blob", value: pingSpec(upstream.url) },
406+ slug: IntegrationSlug.make(integration),
407+ baseUrl: upstream.url,
408+ authenticationTemplate: [
409+ {
410+ slug: "apiKey",
411+ type: "apiKey",
412+ headers: { "x-e2e-token": [{ type: "variable", name: "token" }] },
413+ },
414+ ],
415+ },
416+ });
417+ yield* client.connections.create({
418+ payload: {
419+ owner: "org",
420+ name: ConnectionName.make(connection),
421+ integration: IntegrationSlug.make(integration),
422+ template: AuthTemplateSlug.make("apiKey"),
423+ value: "unused-token",
424+ },
425+ });
426+
427+ const toolkit = yield* client.toolkits.create({
428+ payload: { owner: "org", name: toolkitName },
429+ });
430+ const pattern = connectionPattern(integration, "org", connection);
431+
432+ const session = mcp.session(identity, { url: toolkitUrl(target.baseUrl, toolkit.slug) });
433+
434+ const initiallyMissing = yield* executeJson(
435+ session,
436+ callPingCode({ integration, owner: "org", connection, id: "before-add" }),
437+ );
438+ assertCallMissing(initiallyMissing, "empty toolkit does not expose the connection");
439+
440+ const toolkitConnection = yield* client.toolkits.createConnection({
441+ params: { toolkitId: toolkit.id },
442+ payload: { pattern },
443+ });
444+ const afterFirstAdd = yield* executeJson(
445+ session,
446+ callPingCode({ integration, owner: "org", connection, id: "after-first-add" }),
447+ );
448+ assertCallOk(afterFirstAdd, "same MCP session sees a newly added connection");
449+
450+ yield* client.toolkits.removeConnection({
451+ params: { toolkitId: toolkit.id, connectionId: toolkitConnection.id },
452+ });
453+ const removed = yield* executeJson(
454+ session,
455+ callPingCode({ integration, owner: "org", connection, id: "after-remove" }),
456+ );
457+ assertCallMissing(removed, "same MCP session loses the removed connection");
458+
459+ yield* client.toolkits.createConnection({
460+ params: { toolkitId: toolkit.id },
461+ payload: { pattern },
462+ });
463+ const afterAdd = yield* executeJson(
464+ session,
465+ callPingCode({ integration, owner: "org", connection, id: "after-add" }),
466+ );
467+ assertCallOk(afterAdd, "same MCP session sees the re-added connection");
468+ }).pipe(
469+ Effect.ensuring(
470+ Effect.gen(function* () {
471+ const listed = yield* client.toolkits.list();
472+ yield* Effect.forEach(
473+ listed.toolkits.filter((toolkit) => toolkit.name === toolkitName),
474+ (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }),
475+ { discard: true },
476+ );
477+ yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore);
478+ }).pipe(Effect.ignore),
479+ ),
480+ );
481+ }),
482+ ),
483+);
484+
485+scenario(
486+ "Toolkits · approve and block policies change destructive core-tool side effects",
487+ { timeout: 240_000 },
488+ Effect.gen(function* () {
489+ const target = yield* Target;
490+ const mcp = yield* Mcp;
491+ const { client: makeClient } = yield* Api;
492+ const identity = yield* target.newIdentity();
493+ const client = yield* makeClient(api, identity);
494+
495+ const approveToolkitName = unique("approve-core-tools-kit");
496+ const blockToolkitName = unique("block-core-tools-kit");
497+ const approvedPattern = `${unique("toolkit-approved-policy")}.*`;
498+ const blockedPattern = `${unique("toolkit-blocked-policy")}.*`;
499+
500+ yield* Effect.gen(function* () {
501+ const approveToolkit = yield* client.toolkits.create({
502+ payload: { owner: "org", name: approveToolkitName },
503+ });
504+ yield* client.toolkits.createConnection({
505+ params: { toolkitId: approveToolkit.id },
506+ payload: { pattern: "executor.coreTools.*" },
507+ });
508+ yield* client.toolkits.createPolicy({
509+ params: { toolkitId: approveToolkit.id },
510+ payload: { pattern: "executor.coreTools.policies.create", action: "approve" },
511+ });
512+
513+ const approveSession = mcp.session(identity, {
514+ url: toolkitUrl(target.baseUrl, approveToolkit.slug),
515+ });
516+ const approved = yield* approveSession.call("execute", {
517+ code: createPolicyCode({ pattern: approvedPattern, action: "block" }),
518+ });
519+ expect(approved.text, "approved policy create does not pause for approval").not.toContain(
520+ "Execution paused",
521+ );
522+ expect(approved.text, "approved policy create does not return an execution id").not.toContain(
523+ "executionId:",
524+ );
525+ expect(approved.ok, `approved policy create succeeded: ${approved.text}`).toBe(true);
526+ const approvedPayload = JSON.parse(approved.text) as Record<string, unknown>;
527+ expect(approvedPayload.ok, `approved policy create result: ${approved.text}`).toBe(true);
528+ const afterApproved = yield* client.policies.list();
529+ expect(
530+ afterApproved.map((policy) => `${policy.owner} ${policy.pattern} ${policy.action}`),
531+ "approved toolkit policy created the user policy",
532+ ).toContain(`user ${approvedPattern} block`);
533+
534+ const blockToolkit = yield* client.toolkits.create({
535+ payload: { owner: "org", name: blockToolkitName },
536+ });
537+ yield* client.toolkits.createConnection({
538+ params: { toolkitId: blockToolkit.id },
539+ payload: { pattern: "executor.coreTools.*" },
540+ });
541+ yield* client.toolkits.createPolicy({
542+ params: { toolkitId: blockToolkit.id },
543+ payload: { pattern: "executor.coreTools.policies.create", action: "block" },
544+ });
545+
546+ const blockSession = mcp.session(identity, {
547+ url: toolkitUrl(target.baseUrl, blockToolkit.slug),
548+ });
549+ const blocked = yield* blockSession.call("execute", {
550+ code: createPolicyCode({ pattern: blockedPattern, action: "block" }),
551+ });
552+ expect(blocked.text, "blocked policy create does not pause for approval").not.toContain(
553+ "Execution paused",
554+ );
555+ const afterBlocked = yield* client.policies.list();
556+ expect(
557+ afterBlocked.map((policy) => `${policy.owner} ${policy.pattern} ${policy.action}`),
558+ "blocked toolkit policy prevents the user policy side effect",
559+ ).not.toContain(`user ${blockedPattern} block`);
560+ }).pipe(
561+ Effect.ensuring(
562+ Effect.gen(function* () {
563+ const listed = yield* client.toolkits.list();
564+ yield* Effect.forEach(
565+ listed.toolkits.filter((toolkit) =>
566+ [approveToolkitName, blockToolkitName].includes(toolkit.name),
567+ ),
568+ (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }),
569+ { discard: true },
570+ );
571+ const policies = yield* client.policies.list();
572+ yield* Effect.forEach(
573+ policies.filter((policy) => [approvedPattern, blockedPattern].includes(policy.pattern)),
574+ (policy) =>
575+ client.policies.remove({
576+ params: { policyId: policy.id },
577+ payload: { owner: policy.owner },
578+ }),
579+ { discard: true },
580+ );
581+ }).pipe(Effect.ignore),
582+ ),
583+ );
584+ }),
585+);
e2e/selfhost/toolkits-mcp.test.ts+146−0
@@ -0,0 +1,146 @@
1+import { randomBytes } from "node:crypto";
2+
3+import { expect } from "@effect/vitest";
4+import { Effect } from "effect";
5+import { composePluginApi } from "@executor-js/api/server";
6+import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
7+
8+import { scenario } from "../src/scenario";
9+import { Api, Mcp, Target } from "../src/services";
10+import type { Identity } from "../src/target";
11+
12+const api = composePluginApi([toolkitsPlugin()] as const);
13+
14+const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
15+
16+const allowedCode = `
17+const result = await tools.executor.coreTools.integrations.list({});
18+if (!result.ok) throw new Error(result.error.message);
19+return result.data.integrations.map((integration) => integration.slug).includes("executor");
20+`;
21+
22+const blockedCode = `
23+const result = await tools.executor.coreTools.policies.list({});
24+if (!result.ok) throw new Error(result.error.message);
25+return result.data.policies.length;
26+`;
27+
28+const initializeSession = async (url: string, bearer: string): Promise<string> => {
29+ const response = await fetch(url, {
30+ method: "POST",
31+ headers: {
32+ authorization: `Bearer ${bearer}`,
33+ "content-type": "application/json",
34+ accept: "application/json, text/event-stream",
35+ },
36+ body: JSON.stringify({
37+ jsonrpc: "2.0",
38+ id: 1,
39+ method: "initialize",
40+ params: {
41+ protocolVersion: "2025-03-26",
42+ capabilities: {},
43+ clientInfo: { name: "toolkit-e2e", version: "1" },
44+ },
45+ }),
46+ });
47+ expect(response.status, "toolkit initialize succeeds").toBe(200);
48+ const sessionId = response.headers.get("mcp-session-id");
49+ expect(sessionId, "initialize returns a session id").toEqual(expect.any(String));
50+ return sessionId!;
51+};
52+
53+const callToolsListWithSession = async (
54+ url: string,
55+ bearer: string,
56+ sessionId: string,
57+): Promise<Response> =>
58+ fetch(url, {
59+ method: "POST",
60+ headers: {
61+ authorization: `Bearer ${bearer}`,
62+ "mcp-session-id": sessionId,
63+ "mcp-protocol-version": "2025-03-26",
64+ "content-type": "application/json",
65+ accept: "application/json, text/event-stream",
66+ },
67+ body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }),
68+ });
69+
70+scenario(
71+ "Toolkits · self-host MCP exposes only the toolkit's allowed tools",
72+ { timeout: 180_000 },
73+ Effect.gen(function* () {
74+ const target = yield* Target;
75+ const { client: makeApiClient } = yield* Api;
76+ const mcp = yield* Mcp;
77+ const identity = yield* target.newIdentity();
78+ const client = yield* makeApiClient(api, identity);
79+ const suffix = randomBytes(4).toString("hex");
80+
81+ const toolkit = yield* client.toolkits.create({
82+ payload: {
83+ owner: "org",
84+ name: `toolkits-e2e-${suffix}`,
85+ },
86+ });
87+
88+ yield* Effect.gen(function* () {
89+ yield* client.toolkits.createConnection({
90+ params: { toolkitId: toolkit.id },
91+ payload: {
92+ pattern: "executor.*",
93+ },
94+ });
95+ yield* client.toolkits.createPolicy({
96+ params: { toolkitId: toolkit.id },
97+ payload: {
98+ pattern: "executor.coreTools.integrations.list",
99+ action: "approve",
100+ },
101+ });
102+ yield* client.toolkits.createPolicy({
103+ params: { toolkitId: toolkit.id },
104+ payload: {
105+ pattern: "executor.coreTools.policies.list",
106+ action: "block",
107+ },
108+ });
109+
110+ const toolkitUrl = new URL(
111+ `/e2e-org/mcp/toolkits/${toolkit.slug}`,
112+ target.baseUrl,
113+ ).toString();
114+ const toolkitSession = mcp.session(identity, { url: toolkitUrl });
115+ const toolkitTools = yield* toolkitSession.listTools();
116+ expect(toolkitTools, "the toolkit endpoint still advertises execute").toContain("execute");
117+
118+ const allowed = yield* toolkitSession.call("execute", { code: allowedCode });
119+ expect(allowed.ok, `allowed toolkit tool call succeeds; response:\n${allowed.text}`).toBe(
120+ true,
121+ );
122+ expect(allowed.text, "allowed call returns the integration result").toContain("true");
123+
124+ const blocked = yield* toolkitSession.call("execute", { code: blockedCode });
125+ expect(blocked.ok, `blocked toolkit tool call fails; response:\n${blocked.text}`).toBe(false);
126+ expect(blocked.text, "blocked call explains that the tool is unavailable").toMatch(
127+ /blocked|not found|not available/i,
128+ );
129+
130+ const normalSession = mcp.session(identity);
131+ const normal = yield* normalSession.call("execute", { code: blockedCode });
132+ expect(normal.ok, "normal MCP is not scoped by the toolkit rules").toBe(true);
133+
134+ const bearer = yield* mcp.mintBearer(emailOf(identity));
135+ const sessionId = yield* Effect.promise(() => initializeSession(toolkitUrl, bearer));
136+ const reusedOnDefault = yield* Effect.promise(() =>
137+ callToolsListWithSession(target.mcpUrl, bearer, sessionId),
138+ );
139+ expect(reusedOnDefault.status, "toolkit session id cannot be reused on /mcp").toBe(403);
140+ }).pipe(
141+ Effect.ensuring(
142+ client.toolkits.remove({ params: { toolkitId: toolkit.id } }).pipe(Effect.ignore),
143+ ),
144+ );
145+ }),
146+);
e2e/selfhost/toolkits-ui.test.ts+308−0
@@ -0,0 +1,308 @@
1+import { randomBytes } from "node:crypto";
2+
3+import { expect } from "@effect/vitest";
4+import { Effect } from "effect";
5+import { composePluginApi } from "@executor-js/api/server";
6+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
7+import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
8+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
9+
10+import { scenario } from "../src/scenario";
11+import { Api, Browser, Target } from "../src/services";
12+
13+const api = composePluginApi([toolkitsPlugin(), openApiHttpPlugin()] as const);
14+
15+const hiddenPersonalSpec = (baseUrl: string): string =>
16+ JSON.stringify({
17+ openapi: "3.0.3",
18+ info: { title: "Personal Hidden API", version: "1.0.0" },
19+ servers: [{ url: baseUrl }],
20+ paths: {
21+ "/personal-only": {
22+ get: {
23+ operationId: "personalOnly",
24+ responses: { "200": { description: "Personal-only response" } },
25+ },
26+ },
27+ },
28+ });
29+
30+scenario(
31+ "Toolkits · self-host UI creates a toolkit and configures tools",
32+ { timeout: 180_000 },
33+ Effect.gen(function* () {
34+ const target = yield* Target;
35+ const browser = yield* Browser;
36+ const { client: makeApiClient } = yield* Api;
37+ const identity = yield* target.newIdentity();
38+ const client = yield* makeApiClient(api, identity);
39+
40+ const suffix = randomBytes(4).toString("hex");
41+ const prefix = `toolkits-ui-${suffix}`;
42+ const name = `${prefix}-created`;
43+ const slug = name;
44+ const hiddenPersonalIntegration = `${prefix}-personal-api`;
45+ const hiddenPersonalConnection = "mine";
46+ const seededToolkits = [
47+ { owner: "org" as const, name: `${prefix}-workspace-a` },
48+ { owner: "org" as const, name: `${prefix}-workspace-b` },
49+ { owner: "user" as const, name: `${prefix}-personal-a` },
50+ { owner: "user" as const, name: `${prefix}-personal-b` },
51+ { owner: "user" as const, name: `${prefix}-personal-c` },
52+ ];
53+ let addedConnectionPattern = "";
54+ const blockPattern = "executor.coreTools.policies.list";
55+
56+ const cleanup = Effect.gen(function* () {
57+ yield* client.connections
58+ .remove({
59+ params: {
60+ owner: "user",
61+ integration: IntegrationSlug.make(hiddenPersonalIntegration),
62+ name: ConnectionName.make(hiddenPersonalConnection),
63+ },
64+ })
65+ .pipe(Effect.ignore);
66+ yield* client.openapi
67+ .removeSpec({ params: { slug: hiddenPersonalIntegration } })
68+ .pipe(Effect.ignore);
69+ const listed = yield* client.toolkits.list();
70+ yield* Effect.forEach(
71+ listed.toolkits.filter((row) => row.slug.startsWith(prefix)),
72+ (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }),
73+ { discard: true },
74+ );
75+ }).pipe(Effect.ignore);
76+
77+ yield* Effect.gen(function* () {
78+ yield* Effect.forEach(
79+ seededToolkits,
80+ (toolkit) => client.toolkits.create({ payload: toolkit }),
81+ { discard: true },
82+ );
83+ yield* client.openapi.addSpec({
84+ payload: {
85+ spec: { kind: "blob", value: hiddenPersonalSpec("http://127.0.0.1:59999") },
86+ slug: IntegrationSlug.make(hiddenPersonalIntegration),
87+ baseUrl: "http://127.0.0.1:59999",
88+ authenticationTemplate: [
89+ {
90+ slug: "apiKey",
91+ type: "apiKey",
92+ headers: { "x-api-key": [{ type: "variable", name: "token" }] },
93+ },
94+ ],
95+ },
96+ });
97+ yield* client.connections.create({
98+ payload: {
99+ owner: "user",
100+ name: ConnectionName.make(hiddenPersonalConnection),
101+ integration: IntegrationSlug.make(hiddenPersonalIntegration),
102+ template: AuthTemplateSlug.make("apiKey"),
103+ value: "unused-token",
104+ },
105+ });
106+
107+ yield* browser.session(identity, async ({ page, step }) => {
108+ await step("Open the Toolkits plugin page", async () => {
109+ await page.goto("/default/toolkits/", { waitUntil: "domcontentloaded" });
110+ await page.getByRole("heading", { name: "Toolkits" }).waitFor();
111+ await page.getByRole("heading", { name: "Workspace" }).waitFor();
112+ await page.getByRole("heading", { name: "Personal" }).waitFor();
113+ await page.getByRole("button", { name: "Add workspace toolkit" }).waitFor();
114+ await page.getByRole("button", { name: "Add personal toolkit" }).waitFor();
115+ await page.locator('main [data-slot="skeleton"]').first().waitFor({ state: "detached" });
116+ expect(await page.locator('main [data-slot="skeleton"]').count()).toBe(0);
117+ const seededCard = page.getByRole("link", {
118+ name: `Open toolkit ${prefix}-workspace-a`,
119+ });
120+ await seededCard.waitFor();
121+ expect(await seededCard.getByText("/mcp/toolkits").count()).toBe(0);
122+ expect(await seededCard.getByText("Workspace tools").count()).toBe(0);
123+ expect(await page.getByLabel("New toolkit").count()).toBe(0);
124+ });
125+
126+ await step("Create a workspace toolkit from the add card", async () => {
127+ const workspaceSection = page.locator("section").filter({
128+ has: page.getByRole("heading", { name: "Workspace" }),
129+ });
130+ await workspaceSection.getByRole("button", { name: "Add workspace toolkit" }).click();
131+ await page.getByRole("dialog", { name: "New workspace toolkit" }).waitFor();
132+ await page.getByLabel("Toolkit name").fill(name);
133+ await page.getByRole("button", { name: "Create toolkit" }).click();
134+ await page.getByRole("link", { name: `Open toolkit ${name}` }).waitFor();
135+ });
136+
137+ await step("Validate owner sections render as three-column grids", async () => {
138+ const workspaceSection = page.locator("section").filter({
139+ has: page.getByRole("heading", { name: "Workspace" }),
140+ });
141+ const personalSection = page.locator("section").filter({
142+ has: page.getByRole("heading", { name: "Personal" }),
143+ });
144+
145+ const workspaceColumns = await workspaceSection
146+ .getByRole("link", { name: /^Open toolkit/ })
147+ .evaluateAll((nodes) =>
148+ nodes.slice(0, 3).map((node) => Math.round(node.getBoundingClientRect().left)),
149+ );
150+ const personalColumns = await personalSection
151+ .getByRole("link", { name: /^Open toolkit/ })
152+ .evaluateAll((nodes) =>
153+ nodes.slice(0, 3).map((node) => Math.round(node.getBoundingClientRect().left)),
154+ );
155+
156+ expect(new Set(workspaceColumns).size).toBe(3);
157+ expect(new Set(personalColumns).size).toBe(3);
158+ });
159+
160+ await step("Open the created toolkit from the grid", async () => {
161+ await page.getByRole("link", { name: `Open toolkit ${name}` }).click();
162+ await page.waitForURL(new RegExp(`/toolkits/${slug}$`));
163+ expect(page.url()).toMatch(new RegExp(`/toolkits/${slug}$`));
164+ await page
165+ .locator("code")
166+ .filter({ hasText: `/mcp/toolkits/${slug}` })
167+ .waitFor();
168+ await page.getByText("No connections added").waitFor();
169+ expect(await page.getByLabel("New toolkit").count()).toBe(0);
170+ });
171+
172+ await step("Return to the toolkit grid with browser-visible routing", async () => {
173+ await page.getByRole("button", { name: "Toolkits" }).click();
174+ await page.waitForURL(/\/toolkits\/?$/);
175+ expect(page.url()).toMatch(/\/toolkits\/?$/);
176+ await page.getByRole("heading", { name: "Workspace" }).waitFor();
177+ await page.getByRole("link", { name: `Open toolkit ${name}` }).waitFor();
178+ });
179+
180+ await step("Open the created toolkit from a direct URL", async () => {
181+ await page.goto(`/default/toolkits/${slug}`, { waitUntil: "domcontentloaded" });
182+ expect(page.url()).toMatch(new RegExp(`/toolkits/${slug}$`));
183+ await page
184+ .locator("code")
185+ .filter({ hasText: `/mcp/toolkits/${slug}` })
186+ .waitFor();
187+ await page.getByText("No connections added").waitFor();
188+ expect(await page.getByLabel("New toolkit").count()).toBe(0);
189+ });
190+
191+ await step("Cancel toolkit deletion from the confirmation modal", async () => {
192+ await page.getByRole("button", { name: "Delete toolkit" }).click();
193+ const dialog = page.getByRole("alertdialog", { name: `Delete ${name}?` });
194+ await dialog.waitFor();
195+ await dialog.getByRole("button", { name: "Cancel" }).click();
196+ await dialog.waitFor({ state: "detached" });
197+ await page.getByRole("heading", { name }).waitFor();
198+ });
199+
200+ await step("The connection picker explains hidden personal connections", async () => {
201+ await page.getByRole("button", { name: "Manage toolkit connections" }).click();
202+ const dialog = page.getByRole("dialog", { name: "Manage connections" });
203+ await dialog.waitFor();
204+ await dialog
205+ .getByText(
206+ "You have 1 personal connection that is not shown because this is a shared toolkit.",
207+ )
208+ .waitFor();
209+ });
210+
211+ await step("Add a connection to the toolkit", async () => {
212+ const dialog = page.getByRole("dialog", { name: "Manage connections" });
213+ await dialog.waitFor();
214+ await dialog.getByLabel("Search connections and tools").fill("policies.list");
215+ expect(await dialog.getByRole("button", { name: /^Add tool/ }).count()).toBe(0);
216+ addedConnectionPattern = "executor.*";
217+ expect(await dialog.getByText(addedConnectionPattern, { exact: true }).count()).toBe(0);
218+ await dialog
219+ .getByRole("button", { name: /^Add connection / })
220+ .first()
221+ .click();
222+ await dialog.getByRole("button", { name: /^Remove connection / }).waitFor();
223+ await page.keyboard.press("Escape");
224+ await dialog.waitFor({ state: "hidden" });
225+ const toolkitTools = page.getByRole("region", { name: "Toolkit tools" });
226+ await toolkitTools.waitFor();
227+ await toolkitTools.getByLabel("Filter tools").fill("policies.list");
228+ await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().waitFor();
229+ await toolkitTools.getByLabel("Filter tools").clear();
230+ });
231+
232+ await step("The add connection list reflects the saved toolkit connection", async () => {
233+ await page.getByRole("button", { name: "Manage toolkit connections" }).click();
234+ const dialog = page.getByRole("dialog", { name: "Manage connections" });
235+ await dialog.waitFor();
236+ await dialog.getByLabel("Search connections and tools").fill("policies.list");
237+ await dialog.getByRole("button", { name: /^Remove connection / }).waitFor();
238+ expect(await dialog.getByRole("button", { name: /^Add connection / }).count()).toBe(0);
239+ await page.keyboard.press("Escape");
240+ await dialog.waitFor({ state: "hidden" });
241+ });
242+
243+ await step("Remove the connection from the manage modal", async () => {
244+ await page.getByRole("button", { name: "Manage toolkit connections" }).click();
245+ const removeDialog = page.getByRole("dialog", { name: "Manage connections" });
246+ await removeDialog.waitFor();
247+ await removeDialog.getByLabel("Search connections and tools").fill("policies.list");
248+ await removeDialog
249+ .getByRole("button", { name: /^Remove connection / })
250+ .first()
251+ .click();
252+ await removeDialog.getByRole("button", { name: /^Add connection / }).waitFor();
253+ await page.keyboard.press("Escape");
254+ await removeDialog.waitFor({ state: "hidden" });
255+ await page.getByText("No connections added").waitFor();
256+ await page.getByRole("button", { name: "Manage toolkit connections" }).click();
257+ const dialog = page.getByRole("dialog", { name: "Manage connections" });
258+ await dialog.waitFor();
259+ await dialog.getByLabel("Search connections and tools").fill("policies.list");
260+ await dialog
261+ .getByRole("button", { name: /^Add connection / })
262+ .first()
263+ .click();
264+ await dialog.getByRole("button", { name: /^Remove connection / }).waitFor();
265+ await page.keyboard.press("Escape");
266+ await dialog.waitFor({ state: "hidden" });
267+ const toolkitTools = page.getByRole("region", { name: "Toolkit tools" });
268+ await toolkitTools.getByLabel("Filter tools").fill("policies.list");
269+ await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().waitFor();
270+ await toolkitTools.getByLabel("Filter tools").clear();
271+ });
272+
273+ await step("Block one tool from the toolkit tools list", async () => {
274+ const toolkitTools = page.getByRole("region", { name: "Toolkit tools" });
275+ await toolkitTools.getByLabel("Filter tools").fill("policies.list");
276+ await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().click();
277+ await page.getByRole("button", { name: "Set policy", exact: true }).click();
278+ await page.getByText(blockPattern, { exact: true }).waitFor();
279+ await page.getByRole("menuitem", { name: "Block" }).click();
280+ await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().waitFor();
281+ await page.getByText("This tool is not available through the current toolkit.").waitFor();
282+ });
283+ });
284+
285+ const listed = yield* client.toolkits.list();
286+ const toolkit = listed.toolkits.find((row) => row.slug === slug);
287+ expect(toolkit, "the UI-created toolkit persisted").toBeDefined();
288+ if (!toolkit) return;
289+ expect(toolkit.owner).toBe("org");
290+
291+ const { policies } = yield* client.toolkits.listPolicies({
292+ params: { toolkitId: toolkit.id },
293+ });
294+ const { connections } = yield* client.toolkits.listConnections({
295+ params: { toolkitId: toolkit.id },
296+ });
297+ expect(addedConnectionPattern.length, "the UI selected a connection").toBeGreaterThan(0);
298+ expect(
299+ connections.map((connection) => connection.pattern),
300+ "the UI-authored toolkit connection persisted",
301+ ).toContain(addedConnectionPattern);
302+ expect(
303+ policies.map((policy) => `${policy.pattern} ${policy.action}`).sort(),
304+ "the UI-authored toolkit access persisted with its action",
305+ ).toEqual([`${blockPattern} block`]);
306+ }).pipe(Effect.ensuring(cleanup));
307+ }),
308+);
e2e/setup/cloudflare.boot.ts+9−3
@@ -12,6 +12,9 @@ import { promisify } from "node:util"; · 11 unmodified lines
1212 import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot";
1313
1414 export const cloudflareDir = fileURLToPath(new URL("../../apps/host-cloudflare/", import.meta.url));
15+const wranglerBin = fileURLToPath(
16+ new URL("../../apps/host-cloudflare/node_modules/.bin/wrangler", import.meta.url),
17+);
1518
1619 export interface CloudflareBootOptions {
1720 readonly port: number;
@@ -28,12 +31,13 @@ export const bootCloudflare = async (options: CloudflareBootOptions): Promise<Bo · 10 unmodified lines
2831 const procs = bootProcesses(
2932 [
3033 {
31− // bunx resolves host-cloudflare's own wrangler. `--local` is the default;
34+ // Run wrangler under Node, not Bun. Wrangler rejects the Bun runtime for
35+ // workerd dev server websockets.
3236 // dev-auth + the secret key arrive as `--var` overrides so the worker
3337 // needs no Cloudflare account or real Access app.
34− cmd: "bunx",
38+ cmd: process.env.E2E_NODE_BIN ?? "node",
3539 args: [
36− "wrangler",
40+ wranglerBin,
3741 "dev",
3842 "--port",
3943 String(options.port),
@@ -43,6 +47,8 @@ export const bootCloudflare = async (options: CloudflareBootOptions): Promise<Bo · 3 unmodified lines
4347 "ENABLE_DEV_AUTH:true",
4448 "--var",
4549 "EXECUTOR_SECRET_KEY:e2e-secret-key-0123456789abcdef0123456789abcdef",
50+ "--var",
51+ "ALLOW_LOCAL_NETWORK:true",
4652 ],
4753 cwd: cloudflareDir,
4854 env: { WRANGLER_SEND_METRICS: "false", CI: "true" },
e2e/src/surfaces/mcp.ts+102−17
@@ -11,6 +11,8 @@ import { join } from "node:path"; · 10 unmodified lines
1111 import { Effect } from "effect";
1212
1313 import { createRuntime, type Runtime } from "@executor-js/mcporter";
14+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
1416
1517 import { appendTraces } from "../trace-harvest";
1618 import type { Identity, Target } from "../target";
@@ -160,7 +162,7 @@ export interface McpSurface { · 143 unmodified lines
160162 readonly url: string;
161163 readonly session: (
162164 identity: Identity,
163− options?: { readonly elicitationMode?: McpElicitationMode },
165+ options?: { readonly elicitationMode?: McpElicitationMode; readonly url?: string },
164166 ) => McpSession;
165167 /**
166168 * Mint a real MCP bearer headlessly: protected-resource discovery →
@@ -188,6 +190,17 @@ interface TokenResponse { · 21 unmodified lines
188190 readonly access_token?: string;
189191 }
190192
193+const jsonFrom = async <T>(response: Response, label: string): Promise<T> => {
194+ const text = await response.text();
195+ if (!text) {
196+ throw new Error(`${label}: empty response body (status ${response.status})`);
197+ }
198+ if (!response.ok) {
199+ throw new Error(`${label}: request failed (status ${response.status}): ${text}`);
200+ }
201+ return JSON.parse(text) as T;
202+};
203+
191204 const mintBearerFlow = async (target: Target, email: string): Promise<string> => {
192205 const consent = target.mcpConsent?.({
193206 label: email,
@@ -196,21 +209,31 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 2 unmodified lines
196209 if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`);
197210
198211 const mcpPath = new URL(target.mcpUrl).pathname;
199− const resource = (await (
200− await fetch(new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl))
201− ).json()) as { authorization_servers?: ReadonlyArray<string> };
212+ let resourceResponse = await fetch(
213+ new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl),
214+ );
215+ if (resourceResponse.status === 404) {
216+ resourceResponse = await fetch(
217+ new URL("/.well-known/oauth-protected-resource", target.baseUrl),
218+ );
219+ }
220+ const resource = await jsonFrom<{ authorization_servers?: ReadonlyArray<string> }>(
221+ resourceResponse,
222+ "mintBearer: protected-resource metadata",
223+ );
202224 const issuer = resource.authorization_servers?.[0];
203225 if (!issuer) throw new Error("mintBearer: no authorization server advertised");
204− const metadata = (await (
205− await fetch(new URL("/.well-known/oauth-authorization-server", issuer))
206− ).json()) as {
226+ const metadata = await jsonFrom<{
207227 readonly authorization_endpoint: string;
208228 readonly token_endpoint: string;
209229 readonly registration_endpoint: string;
210− };
230+ }>(
231+ await fetch(new URL("/.well-known/oauth-authorization-server", issuer)),
232+ "mintBearer: authorization-server metadata",
233+ );
211234
212235 const redirectUri = "http://127.0.0.1:9/callback";
213− const registered = (await (
236+ const registered = await jsonFrom<{ readonly client_id: string }>(
214237 await fetch(metadata.registration_endpoint, {
215238 method: "POST",
216239 headers: { "content-type": "application/json" },
@@ -221,8 +244,9 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 4 unmodified lines
221244 response_types: ["code"],
222245 token_endpoint_auth_method: "none",
223246 }),
224− })
225− ).json()) as { readonly client_id: string };
247+ }),
248+ "mintBearer: dynamic client registration",
249+ );
226250
227251 const verifier = randomBytes(32).toString("base64url");
228252 const authorizeUrl = new URL(metadata.authorization_endpoint);
@@ -240,7 +264,7 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 11 unmodified lines
240264 redirectUrl: redirectUri,
241265 });
242266
243− const token = (await (
267+ const token = await jsonFrom<TokenResponse>(
244268 await fetch(metadata.token_endpoint, {
245269 method: "POST",
246270 headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -251,8 +275,9 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 4 unmodified lines
251275 client_id: registered.client_id,
252276 code_verifier: verifier,
253277 }),
254− })
255− ).json()) as TokenResponse;
278+ }),
279+ "mintBearer: token exchange",
280+ );
256281 if (!token.access_token) throw new Error("mintBearer: token exchange returned no token");
257282 return token.access_token;
258283 };
@@ -261,7 +286,8 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 2 unmodified lines
261286 url: target.mcpUrl,
262287 mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)),
263288 session: (identity, options) => {
264− if (runDir) installTraceparentFetch(target.mcpUrl, runDir);
289+ const mcpUrl = options?.url ?? target.mcpUrl;
290+ if (runDir) installTraceparentFetch(mcpUrl, runDir);
265291 // mcporter caches OAuth tokens (and the DCR client) per server NAME, so a
266292 // constant name would let a later session reuse an earlier identity's token
267293 // — landing in the wrong org. A unique name per session keeps each
@@ -272,8 +298,67 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 4 unmodified lines
272298 // `?elicitation_mode=` query on the MCP endpoint — so a paused execution
273299 // yields an approvalUrl instead of letting the model resume inline.
274300 const sessionUrl = options?.elicitationMode
275− ? `${target.mcpUrl}?elicitation_mode=${options.elicitationMode}`
276− : target.mcpUrl;
301+ ? `${mcpUrl}?elicitation_mode=${options.elicitationMode}`
302+ : mcpUrl;
303+
304+ if (target.name === "cloudflare") {
305+ let clientPromise: Promise<Client> | undefined;
306+ const client = () => {
307+ if (!clientPromise) {
308+ clientPromise = (async () => {
309+ const directClient = new Client(
310+ { name: serverName, version: "1.0.0" },
311+ { capabilities: {} },
312+ );
313+ const transport = new StreamableHTTPClientTransport(new URL(sessionUrl), {
314+ requestInit: { headers: identity.headers ?? {} },
315+ });
316+ await directClient.connect(transport);
317+ return directClient;
318+ })();
319+ }
320+ return clientPromise;
321+ };
322+
323+ const listTools = () =>
324+ Effect.promise(async () => {
325+ const listed = await (await client()).listTools();
326+ return listed.tools.map((tool) => tool.name);
327+ });
328+
329+ const call = (name: string, args: Record<string, unknown> = {}) =>
330+ Effect.promise(async (): Promise<McpCallResult> => {
331+ const raw = await (await client()).callTool({ name, arguments: args });
332+ const isError = Boolean((raw as { isError?: boolean })?.isError);
333+ return { raw, text: textOf(raw), ok: !isError };
334+ });
335+
336+ return {
337+ listTools,
338+ describeTools: () =>
339+ Effect.promise(async (): Promise<ReadonlyArray<McpToolDef>> => {
340+ const listed = await (await client()).listTools();
341+ return listed.tools.map((tool) => ({
342+ name: tool.name,
343+ description: tool.description ?? "",
344+ }));
345+ }),
346+ call,
347+ approvePaused: (text, content = {}) =>
348+ Effect.suspend(() => {
349+ const match = /\bexecutionId:\s*(\S+)/.exec(text);
350+ if (!match)
351+ return Effect.die(new Error("approvePaused: executionId not found in text"));
352+ return call("resume", {
353+ executionId: match[1],
354+ action: "accept",
355+ content: JSON.stringify(content),
356+ });
357+ }),
358+ awaitResume: (executionId) => call("resume", { executionId }),
359+ };
360+ }
361+
277362 let runtimePromise: Promise<Runtime> | undefined;
278363 let connected = false;
279364
e2e/targets/selfhost.ts+8−2
@@ -83,7 +83,13 @@ const forcedMcpConsent = · 82 unmodified lines
8383 if (!decision.ok) {
8484 throw new Error(`forcedMcpConsent: consent grant failed (status ${decision.status})`);
8585 }
86− const body = (await decision.json()) as { redirectURI?: string };
86+ const decisionText = await decision.text();
87+ if (!decisionText) {
88+ throw new Error(
89+ `forcedMcpConsent: consent grant returned an empty body (status ${decision.status})`,
90+ );
91+ }
92+ const body = JSON.parse(decisionText) as { redirectURI?: string };
8793 const code = body.redirectURI ? new URL(body.redirectURI).searchParams.get("code") : null;
8894 if (!code) {
8995 throw new Error(
@@ -118,6 +124,6 @@ export const selfhostTarget = (): Target => ({ · 28 unmodified lines
118124 mcpConsent: (identity: Identity) =>
119125 forcedMcpConsent(SELFHOST_BASE_URL, {
120126 email: identity.credentials?.email ?? SELFHOST_ADMIN.email,
121− password: identity.credentials?.password ?? SELFHOST_ADMIN.password,
127+ password: identity.credentials?.password || SELFHOST_ADMIN.password,
122128 }),
123129 });
e2e/vitest.config.ts+1−0
@@ -49,6 +49,7 @@ export default defineConfig({ · 48 unmodified lines
4949 include: [
5050 "scenarios/browser-approval.test.ts",
5151 "scenarios/microsoft-graph-full.test.ts",
52+ "scenarios/toolkits-mcp.test.ts",
5253 "cloudflare/**/*.test.ts",
5354 ],
5455 fileParallelism: false,
packages/app/src/routeTree.gen.ts+58−0
@@ -11,8 +11,10 @@ · 10 unmodified lines
1111 import { Route as rootRouteImport } from './routes/__root'
1212 import { Route as DotDotDotDotDotDotReactSrcRoutesIndexRouteImport } from './../../react/src/routes/index'
1313 import { Route as DotDotDotDotDotDotReactSrcRoutesToolsRouteImport } from './../../react/src/routes/tools'
14+import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport } from './../../react/src/routes/toolkits'
1415 import { Route as SecretsRouteImport } from './routes/app/secrets'
1516 import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './../../react/src/routes/policies'
17+import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug'
1618 import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId'
1719 import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace'
1820 import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$'
@@ -30,6 +32,12 @@ const DotDotDotDotDotDotReactSrcRoutesToolsRoute = · 11 unmodified lines
3032 path: '/{-$orgSlug}/tools',
3133 getParentRoute: () => rootRouteImport,
3234 } as any)
35+const DotDotDotDotDotDotReactSrcRoutesToolkitsRoute =
36+ DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport.update({
37+ id: '/{-$orgSlug}/toolkits',
38+ path: '/{-$orgSlug}/toolkits',
39+ getParentRoute: () => rootRouteImport,
40+ } as any)
3341 const SecretsRoute = SecretsRouteImport.update({
3442 id: '/{-$orgSlug}/secrets',
3543 path: '/{-$orgSlug}/secrets',
@@ -41,6 +49,12 @@ const DotDotDotDotDotDotReactSrcRoutesPoliciesRoute = · 5 unmodified lines
4149 path: '/{-$orgSlug}/policies',
4250 getParentRoute: () => rootRouteImport,
4351 } as any)
52+const DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute =
53+ DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update({
54+ id: '/$toolkitSlug',
55+ path: '/$toolkitSlug',
56+ getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesToolkitsRoute,
57+ } as any)
4458 const DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute =
4559 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport.update({
4660 id: '/{-$orgSlug}/resume/$executionId',
@@ -71,20 +85,24 @@ const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute = · 24 unmodified lines
7185 export interface FileRoutesByFullPath {
7286 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute
7387 '/{-$orgSlug}/secrets': typeof SecretsRoute
88+ '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
7489 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
7590 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
7691 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
7792 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
93+ '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
7894 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
7995 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute
8096 }
8197 export interface FileRoutesByTo {
8298 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute
8399 '/{-$orgSlug}/secrets': typeof SecretsRoute
100+ '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
84101 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
85102 '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
86103 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
87104 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
105+ '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
88106 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
89107 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute
90108 }
@@ -92,10 +110,12 @@ export interface FileRoutesById { · 1 unmodified line
92110 __root__: typeof rootRouteImport
93111 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute
94112 '/{-$orgSlug}/secrets': typeof SecretsRoute
113+ '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
95114 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
96115 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
97116 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
98117 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
118+ '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
99119 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
100120 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute
101121 }
@@ -104,30 +124,36 @@ export interface FileRouteTypes { · 2 unmodified lines
104124 fullPaths:
105125 | '/{-$orgSlug}/policies'
106126 | '/{-$orgSlug}/secrets'
127+ | '/{-$orgSlug}/toolkits'
107128 | '/{-$orgSlug}/tools'
108129 | '/{-$orgSlug}/'
109130 | '/{-$orgSlug}/integrations/$namespace'
110131 | '/{-$orgSlug}/resume/$executionId'
132+ | '/{-$orgSlug}/toolkits/$toolkitSlug'
111133 | '/{-$orgSlug}/integrations/add/$pluginKey'
112134 | '/{-$orgSlug}/plugins/$pluginId/$'
113135 fileRoutesByTo: FileRoutesByTo
114136 to:
115137 | '/{-$orgSlug}/policies'
116138 | '/{-$orgSlug}/secrets'
139+ | '/{-$orgSlug}/toolkits'
117140 | '/{-$orgSlug}/tools'
118141 | '/{-$orgSlug}'
119142 | '/{-$orgSlug}/integrations/$namespace'
120143 | '/{-$orgSlug}/resume/$executionId'
144+ | '/{-$orgSlug}/toolkits/$toolkitSlug'
121145 | '/{-$orgSlug}/integrations/add/$pluginKey'
122146 | '/{-$orgSlug}/plugins/$pluginId/$'
123147 id:
124148 | '__root__'
125149 | '/{-$orgSlug}/policies'
126150 | '/{-$orgSlug}/secrets'
151+ | '/{-$orgSlug}/toolkits'
127152 | '/{-$orgSlug}/tools'
128153 | '/{-$orgSlug}/'
129154 | '/{-$orgSlug}/integrations/$namespace'
130155 | '/{-$orgSlug}/resume/$executionId'
156+ | '/{-$orgSlug}/toolkits/$toolkitSlug'
131157 | '/{-$orgSlug}/integrations/add/$pluginKey'
132158 | '/{-$orgSlug}/plugins/$pluginId/$'
133159 fileRoutesById: FileRoutesById
@@ -135,6 +161,7 @@ export interface FileRouteTypes { · 1 unmodified line
135161 export interface RootRouteChildren {
136162 DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute
137163 SecretsRoute: typeof SecretsRoute
164+ DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
138165 DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
139166 DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
140167 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
@@ -159,6 +186,13 @@ declare module '@tanstack/react-router' { · 18 unmodified lines
159186 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRouteImport
160187 parentRoute: typeof rootRouteImport
161188 }
189+ '/{-$orgSlug}/toolkits': {
190+ id: '/{-$orgSlug}/toolkits'
191+ path: '/{-$orgSlug}/toolkits'
192+ fullPath: '/{-$orgSlug}/toolkits'
193+ preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport
194+ parentRoute: typeof rootRouteImport
195+ }
162196 '/{-$orgSlug}/secrets': {
163197 id: '/{-$orgSlug}/secrets'
164198 path: '/{-$orgSlug}/secrets'
@@ -173,6 +207,13 @@ declare module '@tanstack/react-router' { · 8 unmodified lines
173207 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport
174208 parentRoute: typeof rootRouteImport
175209 }
210+ '/{-$orgSlug}/toolkits/$toolkitSlug': {
211+ id: '/{-$orgSlug}/toolkits/$toolkitSlug'
212+ path: '/$toolkitSlug'
213+ fullPath: '/{-$orgSlug}/toolkits/$toolkitSlug'
214+ preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport
215+ parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRoute
216+ }
176217 '/{-$orgSlug}/resume/$executionId': {
177218 id: '/{-$orgSlug}/resume/$executionId'
178219 path: '/{-$orgSlug}/resume/$executionId'
@@ -204,10 +245,27 @@ declare module '@tanstack/react-router' { · 25 unmodified lines
204245 }
205246 }
206247
248+interface DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren {
249+ DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
250+}
251+
252+const DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren: DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren =
253+ {
254+ DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute:
255+ DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute,
256+ }
257+
258+const DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren =
259+ DotDotDotDotDotDotReactSrcRoutesToolkitsRoute._addFileChildren(
260+ DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren,
261+ )
262+
207263 const rootRouteChildren: RootRouteChildren = {
208264 DotDotDotDotDotDotReactSrcRoutesPoliciesRoute:
209265 DotDotDotDotDotDotReactSrcRoutesPoliciesRoute,
210266 SecretsRoute: SecretsRoute,
267+ DotDotDotDotDotDotReactSrcRoutesToolkitsRoute:
268+ DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren,
211269 DotDotDotDotDotDotReactSrcRoutesToolsRoute:
212270 DotDotDotDotDotDotReactSrcRoutesToolsRoute,
213271 DotDotDotDotDotDotReactSrcRoutesIndexRoute:
packages/app/src/web/shell.tsx+7−0
@@ -342,6 +342,7 @@ function SidebarContent(props: { · 341 unmodified lines
342342 const isHome = props.pathname === "/";
343343 const isSecrets = props.pathname === "/secrets";
344344 const isPolicies = props.pathname === "/policies";
345+ const isToolkits = props.pathname === "/toolkits" || props.pathname.startsWith("/toolkits/");
345346
346347 return (
347348 <>
@@ -381,6 +382,12 @@ function SidebarContent(props: { · 33 unmodified lines
381382 active={isPolicies}
382383 onNavigate={props.onNavigate}
383384 />
385+ <NavItem
386+ to="/{-$orgSlug}/toolkits"
387+ label="Toolkits"
388+ active={isToolkits}
389+ onNavigate={props.onNavigate}
390+ />
384391
385392 <PluginNav pathname={props.pathname} onNavigate={props.onNavigate} />
386393
packages/core/api/src/server/execution-stack.ts+3−0
@@ -26,6 +26,7 @@ · 25 unmodified lines
2626 import { Context, Effect, Layer } from "effect";
2727 import type * as Cause from "effect/Cause";
2828
29+import type { McpResource } from "@executor-js/host-mcp";
2930 import type { AnyPlugin, Executor, StorageFailure } from "@executor-js/sdk";
3031 import {
3132 createExecutionEngine,
@@ -95,6 +96,7 @@ export const makeExecutionStack = < · 63 unmodified lines
9596 accountId: string,
9697 organizationId: string,
9798 organizationName: string,
99+ options?: { readonly mcpResource?: McpResource },
98100 ): Effect.Effect<
99101 { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> },
100102 StorageFailure,
@@ -105,6 +107,7 @@ export const makeExecutionStack = < · 4 unmodified lines
105107 accountId,
106108 organizationId,
107109 organizationName,
110+ { plugins: { mcpResource: options?.mcpResource } },
108111 );
109112 const codeExecutor = yield* CodeExecutorProvider;
110113 const { decorate } = yield* EngineDecorator;
packages/core/api/src/server/mcp-build.ts+3−5
@@ -39,11 +39,9 @@ export type McpExecutionStackLayer = Layer.Layer< · 38 unmodified lines
3939 export const makeMcpBuildServer =
4040 (executionStack: McpExecutionStackLayer): McpBuildServer =>
4141 (principal: Principal, options?: McpBuildServerOptions) =>
42− makeExecutionStack(
43− principal.accountId,
44− principal.organizationId,
45− principal.organizationName,
46− ).pipe(
42+ makeExecutionStack(principal.accountId, principal.organizationId, principal.organizationName, {
43+ mcpResource: options?.resource,
44+ }).pipe(
4745 Effect.map(({ engine }) => engine),
4846 // Pin browser-handoff URLs to the principal's org slug when present;
4947 // absent slug leaves the service unprovided and the URL stays bare.
packages/core/api/src/server/scoped-executor.ts+8−2
@@ -33,6 +33,7 @@ · 32 unmodified lines
3333
3434 import { Context, Effect, Option } from "effect";
3535
36+import type { McpResource } from "@executor-js/host-mcp";
3637 import {
3738 createExecutor,
3839 Subject,
@@ -164,8 +165,12 @@ export const buildOAuthRedirectUri = (input: { · 125 unmodified lines
164165 // while a host with static plugins (self-host) just returns a constant array.
165166 // ---------------------------------------------------------------------------
166167
168+export interface PluginsProviderContext {
169+ readonly mcpResource?: McpResource;
170+}
171+
167172 export interface PluginsProviderShape {
168− readonly plugins: () => readonly AnyPlugin[];
173+ readonly plugins: (context?: PluginsProviderContext) => readonly AnyPlugin[];
169174 }
170175
171176 export class PluginsProvider extends Context.Service<PluginsProvider, PluginsProviderShape>()(
@@ -202,6 +207,7 @@ export const makeScopedExecutor = < · 30 unmodified lines
202207 // `EngineStackIdentity` (the engine decorator still wants it); not part of the
203208 // v2 executor binding, which is `{ tenant, subject }` only.
204209 _organizationName: string,
210+ options?: { readonly plugins?: PluginsProviderContext },
205211 ): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> =>
206212 Effect.gen(function* () {
207213 const { db, blobs } = yield* DbProvider;
@@ -242,7 +248,7 @@ export const makeScopedExecutor = < · 34 unmodified lines
242248 oauthCallbackPath: config.oauthCallbackPath,
243249 });
244250
245− const plugins = pluginsFactory();
251+ const plugins = pluginsFactory(options?.plugins);
246252 const hostedHttpOptions = {
247253 allowLocalNetwork: config.allowLocalNetwork,
248254 };
packages/core/sdk/src/client.ts+10−1
@@ -46,10 +46,19 @@ export { useAtomValue, useAtomSet, useAtomMount, useAtomRefresh } from "@effect/ · 45 unmodified lines
4646 // alongside the host's own UI.
4747 // ---------------------------------------------------------------------------
4848
49+export interface PluginPageProps {
50+ /** Plugin-relative route params captured from `PageDecl.path` segments. */
51+ readonly params: Readonly<Record<string, string>>;
52+ /** The normalized plugin-relative URL path that matched this page. */
53+ readonly path: string;
54+ /** The plugin id from `/plugins/$pluginId/...`. */
55+ readonly pluginId: string;
56+}
57+
4958 export interface PageDecl {
5059 /** Path relative to the plugin's mount point, e.g. `/`, `/edit/$id`. */
5160 readonly path: string;
52− readonly component: ComponentType;
61+ readonly component: ComponentType<PluginPageProps>;
5362 /** Optional sidebar nav metadata — the host renders these alongside its
5463 * own nav links. Omit to register a page without a nav entry. */
5564 readonly nav?: {
packages/core/sdk/src/executor.ts+135−19
@@ -90,6 +90,7 @@ import type { OAuthService } from "./oauth-client"; · 89 unmodified lines
9090 import {
9191 comparePolicyRow,
9292 isValidPattern,
93+ matchPattern,
9394 resolveEffectivePolicy,
9495 rowToToolPolicy,
9596 type CreateToolPolicyInput,
@@ -112,6 +113,8 @@ import type { · 16 unmodified lines
112113 StaticSourceDecl,
113114 StaticToolDecl,
114115 StorageDeps,
116+ ToolPolicyProvider,
117+ ToolPolicyProviderRule,
115118 ToolInvocationCredential,
116119 } from "./plugin";
117120 import {
@@ -1284,6 +1287,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1166 unmodified lines
12841287 // Populated once, never mutated after startup.
12851288 const staticTools = new Map<string, StaticTools>();
12861289 const runtimes = new Map<string, PluginRuntime>();
1290+ let activeToolPolicyProvider: ToolPolicyProvider | null = null;
12871291 // Credential providers keyed by `provider.key`, in registration order.
12881292 const credentialProviders = new Map<string, CredentialProvider>();
12891293 const credentialProviderOrder: string[] = [];
@@ -2301,8 +2305,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1011 unmodified lines
23012305 readonly integration?: IntegrationSlug;
23022306 readonly owner?: Owner;
23032307 }): Effect.Effect<readonly Connection[], StorageFailure> =>
2304− core
2305− .findMany("connection", {
2308+ Effect.gen(function* () {
2309+ const rows = yield* core.findMany("connection", {
23062310 where: (b: AnyCb) =>
23072311 b.and(
23082312 filter?.integration === undefined
@@ -2310,8 +2314,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line
23102314 : b("integration", "=", String(filter.integration)),
23112315 filter?.owner === undefined ? true : b("owner", "=", filter.owner),
23122316 ),
2313− })
2314− .pipe(Effect.map((rows) => rows.map(rowToConnection)));
2317+ });
2318+ const connections = rows.map(rowToConnection);
2319+ if (!activeToolPolicyProvider) return connections;
2320+
2321+ const visibleTools = yield* toolsList({ includeAnnotations: false });
2322+ const visibleConnectionKeys = new Set(
2323+ visibleTools
2324+ .filter((tool) => !tool.static)
2325+ .map((tool) => `${tool.owner}:${tool.integration}:${tool.connection}`),
2326+ );
2327+ return connections.filter((connection) =>
2328+ visibleConnectionKeys.has(
2329+ `${connection.owner}:${connection.integration}:${connection.name}`,
2330+ ),
2331+ );
2332+ });
23152333
23162334 const connectionsGet = (ref: ConnectionRef): Effect.Effect<Connection | null, StorageFailure> =>
23172335 findConnectionRow(ref).pipe(Effect.map((row) => (row ? rowToConnection(row) : null)));
@@ -2414,6 +2432,81 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 96 unmodified lines
24142432 return yield* produceConnectionTools(integrationRow, ref);
24152433 });
24162434
2435+ // ------------------------------------------------------------------
2436+ // Active policy source.
2437+ // ------------------------------------------------------------------
2438+
2439+ type ActivePolicyRuleSet =
2440+ | { readonly kind: "global"; readonly rows: readonly ToolPolicyRow[] }
2441+ | {
2442+ readonly kind: "provider";
2443+ readonly provider: ToolPolicyProvider;
2444+ readonly rules: readonly ToolPolicyProviderRule[] | null;
2445+ };
2446+
2447+ const compareProviderPolicyRule = (
2448+ a: ToolPolicyProviderRule,
2449+ b: ToolPolicyProviderRule,
2450+ ): number => {
2451+ if (a.position < b.position) return -1;
2452+ if (a.position > b.position) return 1;
2453+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
2454+ };
2455+
2456+ const resolveProviderPolicyFromRules = (
2457+ toolId: string,
2458+ rules: readonly ToolPolicyProviderRule[],
2459+ ): EffectivePolicy => {
2460+ for (const rule of [...rules].sort(compareProviderPolicyRule)) {
2461+ if (!matchPattern(rule.pattern, toolId)) continue;
2462+ return {
2463+ action: rule.action,
2464+ source: "user",
2465+ pattern: rule.pattern,
2466+ policyId: rule.id,
2467+ };
2468+ }
2469+ // Toolkit-style providers are capability allowlists. No matching rule
2470+ // means the tool is outside the capability boundary.
2471+ return {
2472+ action: "block",
2473+ source: "user",
2474+ pattern: "*",
2475+ };
2476+ };
2477+
2478+ const listActivePolicyRuleSet = (): Effect.Effect<ActivePolicyRuleSet, StorageFailure> =>
2479+ activeToolPolicyProvider
2480+ ? activeToolPolicyProvider.resolve
2481+ ? Effect.succeed({
2482+ kind: "provider" as const,
2483+ provider: activeToolPolicyProvider,
2484+ rules: null,
2485+ })
2486+ : activeToolPolicyProvider.list().pipe(
2487+ Effect.map((rules) => ({
2488+ kind: "provider" as const,
2489+ provider: activeToolPolicyProvider!,
2490+ rules,
2491+ })),
2492+ )
2493+ : core
2494+ .findMany("tool_policy", {})
2495+ .pipe(Effect.map((rows) => ({ kind: "global" as const, rows })));
2496+
2497+ const resolvePolicyFromRuleSet = (
2498+ toolId: string,
2499+ ruleSet: ActivePolicyRuleSet,
2500+ defaultRequiresApproval?: boolean,
2501+ ): Effect.Effect<EffectivePolicy, StorageFailure> =>
2502+ ruleSet.kind === "provider"
2503+ ? ruleSet.provider.resolve
2504+ ? ruleSet.provider.resolve({ toolId, defaultRequiresApproval })
2505+ : Effect.succeed(resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? []))
2506+ : Effect.succeed(
2507+ resolveEffectivePolicy(toolId, ruleSet.rows, ownerRankForRow, defaultRequiresApproval),
2508+ );
2509+
24172510 // ------------------------------------------------------------------
24182511 // Tools (read surface)
24192512 // ------------------------------------------------------------------
@@ -2492,16 +2585,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 72 unmodified lines
24922585 select: TOOL_INVOCATION_COLUMNS,
24932586 });
24942587 const includeBlocked = filter?.includeBlocked ?? false;
2495− const policyRows = yield* core.findMany("tool_policy", {});
2588+ const policyRules = yield* listActivePolicyRuleSet();
24962589 const tools: Tool[] = [];
24972590 for (const row of rows) {
24982591 const tool = rowToTool(row);
24992592 if (!matchesToolFilter(tool, filter)) continue;
25002593 if (!includeBlocked) {
2501− const effective = resolveEffectivePolicy(
2594+ const effective = yield* resolvePolicyFromRuleSet(
25022595 normalizedPolicyId(tool),
2503− policyRows,
2504− ownerRankForRow,
2596+ policyRules,
25052597 tool.annotations?.requiresApproval,
25062598 );
25072599 if (effective.action === "block") continue;
@@ -2512,10 +2604,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 4 unmodified lines
25122604 const tool = staticToolToTool(entry);
25132605 if (!matchesToolFilter(tool, filter)) continue;
25142606 if (!includeBlocked) {
2515− const effective = resolveEffectivePolicy(
2607+ const effective = yield* resolvePolicyFromRuleSet(
25162608 normalizedPolicyId(tool),
2517− policyRows,
2518− ownerRankForRow,
2609+ policyRules,
25192610 tool.annotations?.requiresApproval,
25202611 );
25212612 if (effective.action === "block") continue;
@@ -2529,9 +2620,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines
25292620 address: ToolAddress,
25302621 ): Effect.Effect<ToolSchemaView | null, StorageFailure> =>
25312622 Effect.gen(function* () {
2623+ const policyRules = yield* listActivePolicyRuleSet();
25322624 const staticEntry = staticTools.get(String(address));
25332625 if (staticEntry) {
25342626 const tool = staticToolToTool(staticEntry);
2627+ const effective = yield* resolvePolicyFromRuleSet(
2628+ normalizedPolicyId(tool),
2629+ policyRules,
2630+ tool.annotations?.requiresApproval,
2631+ );
2632+ if (effective.action === "block") return null;
25352633 const preview = yield* Effect.tryPromise({
25362634 try: () =>
25372635 buildToolTypeScriptPreview({
@@ -2567,6 +2665,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines
25672665 });
25682666 if (!row) return null;
25692667 const tool = rowToTool(row);
2668+ const effective = yield* resolvePolicyFromRuleSet(
2669+ normalizedPolicyId(tool),
2670+ policyRules,
2671+ tool.annotations?.requiresApproval,
2672+ );
2673+ if (effective.action === "block") return null;
25702674
25712675 const definitionRows = yield* core.findMany("definition", {
25722676 where: (b: AnyCb) =>
@@ -2906,11 +3010,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 333 unmodified lines
29063010 // not the 5-segment dynamic form.
29073011 const staticEntry = staticTools.get(String(address));
29083012 if (staticEntry) {
2909− const policyRows = yield* core.findMany("tool_policy", {});
2910− const policy = resolveEffectivePolicy(
3013+ const policyRules = yield* listActivePolicyRuleSet();
3014+ const policy = yield* resolvePolicyFromRuleSet(
29113015 String(address),
2912− policyRows,
2913− ownerRankForRow,
3016+ policyRules,
29143017 staticEntry.tool.annotations?.requiresApproval,
29153018 );
29163019 if (policy.action === "block") {
@@ -2959,12 +3062,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 42 unmodified lines
29593062
29603063 // Resolve policy (owner-ranked).
29613064 const toolForPolicy = rowToTool(row);
2962− const policyRows = yield* core.findMany("tool_policy", {});
3065+ const policyRules = yield* listActivePolicyRuleSet();
29633066 const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined;
2964− const policy = resolveEffectivePolicy(
3067+ const policy = yield* resolvePolicyFromRuleSet(
29653068 normalizedPolicyId(toolForPolicy),
2966− policyRows,
2967− ownerRankForRow,
3069+ policyRules,
29683070 annotations?.requiresApproval,
29693071 );
29703072 if (policy.action === "block") {
@@ -3186,6 +3288,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 215 unmodified lines
31863288 transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect),
31873289 };
31883290
3291+ if (plugin.toolPolicyProvider) {
3292+ const rawProvider = plugin.toolPolicyProvider(ctx);
3293+ const provider = Effect.isEffect(rawProvider) ? yield* rawProvider : rawProvider;
3294+ if (provider) {
3295+ if (activeToolPolicyProvider) {
3296+ return yield* new StorageError({
3297+ message: "Only one plugin can provide the active tool policy source.",
3298+ cause: undefined,
3299+ });
3300+ }
3301+ activeToolPolicyProvider = provider;
3302+ }
3303+ }
3304+
31893305 // Build extension FIRST so it's available as `self` for staticSources.
31903306 const extension: object = plugin.extension ? plugin.extension(ctx) : {};
31913307 if (plugin.extension) {
packages/core/sdk/src/index.ts+2−0
@@ -277,6 +277,8 @@ export { · 276 unmodified lines
277277 type AnyPlugin,
278278 type StorageDeps,
279279 type OwnerBinding,
280+ type ToolPolicyProvider,
281+ type ToolPolicyProviderRule,
280282 type IntegrationRecord,
281283 type StaticSourceDecl,
282284 type StaticToolDecl,
packages/core/sdk/src/plugin.ts+32−0
@@ -48,6 +48,7 @@ import type { CredentialProvider, ProviderEntry } from "./provider"; · 47 unmodified lines
4848 import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage";
4949 import type {
5050 CreateToolPolicyInput,
51+ EffectivePolicy,
5152 RemoveToolPolicyInput,
5253 ToolPolicy,
5354 UpdateToolPolicyInput,
@@ -88,6 +89,30 @@ export type Elicit = ( · 34 unmodified lines
8889 request: ElicitationRequest,
8990 ) => Effect.Effect<ElicitationResponse, ElicitationDeclinedError>;
9091
92+// ---------------------------------------------------------------------------
93+// Active tool-policy provider.
94+//
95+// Normal executors resolve policies from core's owner-scoped `tool_policy`
96+// table. A plugin may opt one executor instance into a different rule source
97+// (for example, a toolkit-specific policy set). Core still owns enforcement;
98+// the plugin owns where those policy-shaped rows are stored.
99+// ---------------------------------------------------------------------------
100+
101+export interface ToolPolicyProviderRule {
102+ readonly id: string;
103+ readonly pattern: string;
104+ readonly action: ToolPolicy["action"];
105+ readonly position: string;
106+}
107+
108+export interface ToolPolicyProvider {
109+ readonly list: () => Effect.Effect<readonly ToolPolicyProviderRule[], StorageFailure>;
110+ readonly resolve?: (input: {
111+ readonly toolId: string;
112+ readonly defaultRequiresApproval?: boolean;
113+ }) => Effect.Effect<EffectivePolicy, StorageFailure>;
114+}
115+
91116 // ---------------------------------------------------------------------------
92117 // IntegrationRecord — the catalog row a plugin reads back (its own opaque
93118 // `config` included). Returned by `ctx.core.integrations.get`.
@@ -458,6 +483,13 @@ export interface PluginSpec< · 364 unmodified lines
458483 /** Service tag the plugin's `handlers` layer requires. */
459484 readonly extensionService?: TExtensionService;
460485
486+ /** Optional active policy source for this executor instance. At most one
487+ * loaded plugin may return a provider. When absent, core uses the normal
488+ * owner-scoped tool policies. */
489+ readonly toolPolicyProvider?: (
490+ ctx: PluginCtx<TStore>,
491+ ) => ToolPolicyProvider | null | Effect.Effect<ToolPolicyProvider | null, StorageFailure>;
492+
461493 /** Produce a connection's tools (and shared $defs). The v2 successor to
462494 * registering per-source tools — called by the executor at connection
463495 * create / refresh / oauth.complete; the result is stamped with addresses
packages/core/sdk/src/policies.test.ts+81−2
@@ -1,5 +1,5 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { Effect, Predicate, Result } from "effect";
2+import { Effect, Predicate, Result, Schema } from "effect";
33
44 import { type ToolPolicyRow } from "./core-schema";
55 import {
@@ -19,7 +19,7 @@ import { · 13 unmodified lines
1919 matchPattern,
2020 resolveToolPolicy,
2121 } from "./policies";
22−import { definePlugin } from "./plugin";
22+import { definePlugin, tool } from "./plugin";
2323 import type { CredentialProvider } from "./provider";
2424 import { makeTestExecutor } from "./testing";
2525
@@ -512,6 +512,85 @@ describe("blocked tools", () => { · 486 unmodified lines
512512 );
513513 });
514514
515+describe("active tool-policy provider", () => {
516+ const staticPlugin = definePlugin(() => ({
517+ id: "toolkit-fixture" as const,
518+ storage: () => ({}),
519+ staticSources: () => [
520+ {
521+ id: "toolkit-fixture.ctl",
522+ kind: "control" as const,
523+ name: "Toolkit Fixture",
524+ tools: [
525+ tool({
526+ name: "allowed",
527+ description: "allowed",
528+ inputSchema: Schema.toStandardSchemaV1(
529+ Schema.toStandardJSONSchemaV1(Schema.Struct({})),
530+ ),
531+ execute: () => Effect.succeed("allowed"),
532+ }),
533+ tool({
534+ name: "hidden",
535+ description: "hidden",
536+ inputSchema: Schema.toStandardSchemaV1(
537+ Schema.toStandardJSONSchemaV1(Schema.Struct({})),
538+ ),
539+ execute: () => Effect.succeed("hidden"),
540+ }),
541+ ],
542+ },
543+ ],
544+ }))();
545+
546+ const policyProviderPlugin = definePlugin(() => ({
547+ id: "toolkit-policy-provider" as const,
548+ storage: () => ({}),
549+ toolPolicyProvider: () => ({
550+ list: () =>
551+ Effect.succeed([
552+ {
553+ id: "allow-static",
554+ pattern: "toolkit-fixture.ctl.allowed",
555+ action: "approve" as const,
556+ position: "a0",
557+ },
558+ ]),
559+ }),
560+ }))();
561+
562+ it.effect("uses provider rules as an allowlist for list, schema, and execute", () =>
563+ Effect.gen(function* () {
564+ const executor = yield* makeTestExecutor({
565+ plugins: [staticPlugin, policyProviderPlugin] as const,
566+ });
567+
568+ const tools = yield* executor.tools.list();
569+ expect(tools.map((t) => String(t.address)).sort()).toEqual(["toolkit-fixture.ctl.allowed"]);
570+
571+ const allowedSchema = yield* executor.tools.schema(
572+ ToolAddress.make("toolkit-fixture.ctl.allowed"),
573+ );
574+ expect(allowedSchema?.name).toBe("allowed");
575+
576+ const hiddenSchema = yield* executor.tools.schema(
577+ ToolAddress.make("toolkit-fixture.ctl.hidden"),
578+ );
579+ expect(hiddenSchema).toBeNull();
580+
581+ const allowed = yield* executor.execute(ToolAddress.make("toolkit-fixture.ctl.allowed"), {});
582+ expect(allowed).toBe("allowed");
583+
584+ const blocked = yield* Effect.result(
585+ executor.execute(ToolAddress.make("toolkit-fixture.ctl.hidden"), {}),
586+ );
587+ expect(Result.isFailure(blocked)).toBe(true);
588+ if (!Result.isFailure(blocked)) return;
589+ expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true);
590+ }),
591+ );
592+});
593+
515594 describe("approve / require_approval interaction with annotations", () => {
516595 it.effect("approve skips the elicitation prompt even when plugin requires approval", () =>
517596 Effect.gen(function* () {
packages/hosts/cloudflare/src/mcp/do-headers.ts+4−0
@@ -13,9 +13,11 @@ · 12 unmodified lines
1313 // ---------------------------------------------------------------------------
1414
1515 import { Effect } from "effect";
16+import { mcpResourceKey, type McpResource } from "@executor-js/host-mcp";
1617
1718 export const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id";
1819 export const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id";
20+export const INTERNAL_RESOURCE_KEY_HEADER = "x-executor-mcp-resource-key";
1921
2022 /** The verified identity used to stamp the DO's internal owner headers. */
2123 export type VerifiedTokenHeaders = {
@@ -70,10 +72,12 @@ export const withPropagationHeaders = ( · 48 unmodified lines
7072 export const withVerifiedIdentityHeaders = (
7173 request: Request,
7274 token: VerifiedTokenHeaders,
75+ resource: McpResource,
7376 ): Request => {
7477 const headers = new Headers(request.headers);
7578 headers.set(INTERNAL_ACCOUNT_ID_HEADER, token.accountId);
7679 headers.set(INTERNAL_ORGANIZATION_ID_HEADER, token.organizationId ?? "");
80+ headers.set(INTERNAL_RESOURCE_KEY_HEADER, mcpResourceKey(resource));
7781 return new Request(request, { headers });
7882 };
7983
packages/hosts/cloudflare/src/mcp/seams.ts+3−0
@@ -1,3 +1,5 @@
1+import type { McpResource } from "@executor-js/host-mcp";
2+
13 import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers";
24
35 // ---------------------------------------------------------------------------
@@ -10,6 +12,7 @@ import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-header · 6 unmodified lines
1012 export interface McpSessionInit {
1113 readonly organizationId: string;
1214 readonly userId: string;
15+ readonly resource: McpResource;
1316 readonly elicitationMode: McpElicitationMode;
1417 /** Public origin of the create request (`https://host`), so the DO derives a
1518 * web base URL zero-config when the host configures no static one. */
packages/hosts/cloudflare/src/mcp/session-durable-object.ts+13−4
@@ -29,9 +29,12 @@ import { makeMcpWorkerTransport, type McpWorkerTransport } from "./worker-transp · 28 unmodified lines
2929 import {
3030 INTERNAL_ACCOUNT_ID_HEADER,
3131 INTERNAL_ORGANIZATION_ID_HEADER,
32+ INTERNAL_RESOURCE_KEY_HEADER,
3233 type IncomingPropagationHeaders,
3334 } from "./do-headers";
35+import { mcpResourceKey } from "@executor-js/host-mcp";
3436 import type { McpSessionInit } from "./seams";
37+import type { McpResource } from "@executor-js/host-mcp";
3538
3639 // ---------------------------------------------------------------------------
3740 // Types
@@ -122,6 +125,7 @@ export interface SessionDbHandle { · 84 unmodified lines
122125 export interface SessionMeta {
123126 readonly organizationId: string;
124127 readonly organizationName: string;
128+ readonly resource: McpResource;
125129 /** The org's URL slug, when the host's `resolveSessionMeta` carried one.
126130 * Pins browser-handoff URLs to the right org's console. */
127131 readonly organizationSlug?: string;
@@ -541,8 +545,11 @@ export abstract class McpSessionDOBase< · 413 unmodified lines
541545
542546 const accountId = request.headers.get(INTERNAL_ACCOUNT_ID_HEADER);
543547 const organizationId = request.headers.get(INTERNAL_ORGANIZATION_ID_HEADER);
548+ const resourceKey = request.headers.get(INTERNAL_RESOURCE_KEY_HEADER);
544549 const matches =
545− accountId === sessionMeta.userId && organizationId === sessionMeta.organizationId;
550+ accountId === sessionMeta.userId &&
551+ organizationId === sessionMeta.organizationId &&
552+ resourceKey === mcpResourceKey(sessionMeta.resource);
546553
547554 yield* Effect.annotateCurrentSpan({
548555 "mcp.session.owner_match": matches,
@@ -559,9 +566,11 @@ export abstract class McpSessionDOBase< · 10 unmodified lines
559566 // Carry the create request's origin onto the persisted meta (the host's
560567 // resolveSessionMeta is identity-only and doesn't see it), so a cold
561568 // isolate rebuilds the runtime with the same web base URL.
562− const sessionMeta: SessionMeta = token.webOrigin
563− ? { ...resolved, webOrigin: token.webOrigin }
564− : resolved;
569+ const sessionMeta: SessionMeta = {
570+ ...resolved,
571+ resource: token.resource,
572+ ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}),
573+ };
565574 yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe(
566575 Effect.withSpan("mcp.session.save_meta"),
567576 );
packages/hosts/cloudflare/src/mcp/session-store.test.ts+79−1
@@ -13,13 +13,20 @@ · 12 unmodified lines
1313 import { describe, expect, it } from "@effect/vitest";
1414 import { Cause, Effect, Exit } from "effect";
1515
16−import { McpSessionStore, type McpDispatchResult, type Principal } from "@executor-js/host-mcp";
16+import {
17+ McpSessionStore,
18+ defaultMcpResource,
19+ type McpDispatchResult,
20+ type Principal,
21+} from "@executor-js/host-mcp";
1722
1823 import {
1924 DO_RELOCATION_MAX_RETRIES,
2025 makeDurableObjectMcpSessionStore,
26+ type McpSessionInit,
2127 type McpSessionDOStub,
2228 } from "./session-store";
29+import { INTERNAL_RESOURCE_KEY_HEADER } from "./do-headers";
2330
2431 const RELOCATION_ERROR = "cannot access storage because object has moved to a different machine";
2532
@@ -55,6 +62,7 @@ const dispatchCreate = (stub: McpSessionDOStub): Effect.Effect<McpDispatchResult · 29 unmodified lines
5562 return yield* store.dispatch({
5663 request: initializeRequest(),
5764 principal: TEST_PRINCIPAL,
65+ resource: defaultMcpResource,
5866 sessionId: null,
5967 method: "POST",
6068 });
@@ -68,6 +76,76 @@ const failureText = (exit: Exit.Exit<McpDispatchResult>): string => · 7 unmodified lines
6876 Exit.isFailure(exit) ? Cause.pretty(exit.cause) : "";
6977
7078 describe("makeDurableObjectMcpSessionStore — DO-relocation retry", () => {
79+ it.live("passes the requested MCP resource into session init", () =>
80+ Effect.gen(function* () {
81+ let initMeta: McpSessionInit | undefined;
82+ const stub: McpSessionDOStub = {
83+ init: (meta) => {
84+ initMeta = meta;
85+ return Promise.resolve();
86+ },
87+ handleRequest: () => Promise.resolve(okResponse()),
88+ clearSession: () => Promise.resolve(),
89+ };
90+
91+ const result = yield* Effect.gen(function* () {
92+ const store = yield* McpSessionStore;
93+ return yield* store.dispatch({
94+ request: initializeRequest(),
95+ principal: TEST_PRINCIPAL,
96+ resource: { kind: "toolkit", slug: "deploy" },
97+ sessionId: null,
98+ method: "POST",
99+ });
100+ }).pipe(
101+ Effect.provide(
102+ makeDurableObjectMcpSessionStore({ newStub: () => stub, getStub: () => stub }),
103+ ),
104+ );
105+
106+ expect(result).toBeInstanceOf(Response);
107+ expect(initMeta?.resource, "the DO session is keyed to the requested resource").toEqual({
108+ kind: "toolkit",
109+ slug: "deploy",
110+ });
111+ }),
112+ );
113+
114+ it.live("stamps the requested MCP resource on forwarded session requests", () =>
115+ Effect.gen(function* () {
116+ let forwardedResourceKey: string | null = null;
117+ const stub: McpSessionDOStub = {
118+ init: () => Promise.resolve(),
119+ handleRequest: (request) => {
120+ forwardedResourceKey = request.headers.get(INTERNAL_RESOURCE_KEY_HEADER);
121+ return Promise.resolve(okResponse());
122+ },
123+ clearSession: () => Promise.resolve(),
124+ };
125+
126+ const result = yield* Effect.gen(function* () {
127+ const store = yield* McpSessionStore;
128+ return yield* store.dispatch({
129+ request: initializeRequest(),
130+ principal: TEST_PRINCIPAL,
131+ resource: { kind: "toolkit", slug: "deploy" },
132+ sessionId: "existing-session",
133+ method: "POST",
134+ });
135+ }).pipe(
136+ Effect.provide(
137+ makeDurableObjectMcpSessionStore({ newStub: () => stub, getStub: () => stub }),
138+ ),
139+ );
140+
141+ expect(result).toBeInstanceOf(Response);
142+ expect(
143+ forwardedResourceKey,
144+ "the DO can reject a session id reused on another resource",
145+ ).toBe("toolkit:deploy");
146+ }),
147+ );
148+
71149 it.live("retries mcp.do.init past a relocation, then returns the DO response", () =>
72150 Effect.gen(function* () {
73151 let initCalls = 0;
packages/hosts/cloudflare/src/mcp/session-store.ts+15−4
@@ -141,12 +141,13 @@ const forwardToExistingSession = ( · 140 unmodified lines
141141 sessionId: string,
142142 peek: boolean,
143143 token: VerifiedTokenHeaders,
144+ resource: McpDispatchInput["resource"],
144145 ): Effect.Effect<Response> =>
145146 Effect.gen(function* () {
146147 const stub = config.getStub(sessionId);
147148 const propagation = yield* currentPropagationHeaders(request);
148149 const propagated = withPropagationHeaders(
149− withVerifiedIdentityHeaders(request, token),
150+ withVerifiedIdentityHeaders(request, token, resource),
150151 propagation,
151152 );
152153 const raw = yield* Effect.promise(() => stub.handleRequest(propagated)).pipe(
@@ -168,6 +169,7 @@ const createSession = ( · 15 unmodified lines
168169 config: DurableObjectStoreConfig,
169170 request: Request,
170171 token: VerifiedTokenHeaders,
172+ resource: McpDispatchInput["resource"],
171173 ): Effect.Effect<Response> =>
172174 Effect.gen(function* () {
173175 const stub = config.newStub();
@@ -177,6 +179,7 @@ const createSession = ( · 3 unmodified lines
177179 {
178180 organizationId: token.organizationId,
179181 userId: token.accountId,
182+ resource,
180183 elicitationMode: readElicitationMode(request),
181184 // The public origin the client reached us at — lets the DO derive a web
182185 // base URL with no static config (we read the real URL, not a spoofable
@@ -187,7 +190,7 @@ const createSession = ( · 4 unmodified lines
187190 ),
188191 );
189192 const propagated = withPropagationHeaders(
190− withVerifiedIdentityHeaders(request, token),
193+ withVerifiedIdentityHeaders(request, token, resource),
191194 propagation,
192195 );
193196 const raw = yield* Effect.promise(() => stub.handleRequest(propagated)).pipe(
@@ -236,6 +239,7 @@ export const makeDurableObjectMcpSessionStore = ( · 42 unmodified lines
236239 dispatch: ({
237240 request,
238241 principal,
242+ resource,
239243 sessionId,
240244 }: McpDispatchInput): Effect.Effect<McpDispatchResult> => {
241245 const token: VerifiedTokenHeaders = {
@@ -243,8 +247,15 @@ export const makeDurableObjectMcpSessionStore = ( · 1 unmodified line
243247 organizationId: principal.organizationId,
244248 };
245249 return sessionId
246− ? forwardToExistingSession(config, request, sessionId, request.method !== "GET", token)
247− : createSession(config, request, token);
250+ ? forwardToExistingSession(
251+ config,
252+ request,
253+ sessionId,
254+ request.method !== "GET",
255+ token,
256+ resource,
257+ )
258+ : createSession(config, request, token, resource);
248259 },
249260 dispose: (sessionId, request) => clearExistingSession(config, sessionId, request),
250261 });
packages/hosts/mcp/src/envelope.test.ts+64−2
@@ -15,11 +15,13 @@ import { HttpRouter, HttpServer } from "effect/unstable/http"; · 14 unmodified lines
1515
1616 import {
1717 authenticated,
18+ forbidden,
1819 McpAuthProvider,
1920 McpErrorReporter,
2021 McpErrorReporterNoop,
2122 McpServingRoutes,
2223 McpSessionStore,
24+ type McpResource,
2325 type McpDispatchResult,
2426 type Principal,
2527 } from "./index";
@@ -64,11 +66,12 @@ const OkStoreLive = Layer.succeed(McpSessionStore)({ · 38 unmodified lines
6466 const buildHandler = (
6567 store: Layer.Layer<McpSessionStore>,
6668 reporter: Layer.Layer<McpErrorReporter>,
69+ authProvider: Layer.Layer<McpAuthProvider> = AuthProviderLive,
6770 ): ((request: Request) => Promise<Response>) => {
68− const Seams = Layer.mergeAll(AuthProviderLive, store, reporter);
71+ const Seams = Layer.mergeAll(authProvider, store, reporter);
6972 const RouteLive = McpServingRoutes.pipe(
7073 HttpRouter.provideRequest(Seams),
71− Layer.provide(AuthProviderLive),
74+ Layer.provide(authProvider),
7275 );
7376 return HttpRouter.toWebHandler(RouteLive.pipe(Layer.provideMerge(HttpServer.layerServices)))
7477 .handler;
@@ -132,4 +135,63 @@ describe("McpServingRoutes envelope", () => { · 57 unmodified lines
132135 expect(captures).toHaveLength(1);
133136 expect(captures[0]).toContain("induced defect");
134137 });
138+
139+ it("does not dispose a session id on an auth-level Forbidden outcome", async () => {
140+ const disposed = await Effect.runPromise(Ref.make<ReadonlyArray<string>>([]));
141+ const ForbiddenAuthProviderLive = Layer.succeed(McpAuthProvider)({
142+ discoveryRoutes: [],
143+ resourceMetadataUrl: (request) => `${new URL(request.url).origin}${DISCOVERY_PATH}`,
144+ authenticate: () => Effect.succeed(forbidden("No organization in session", -32001)),
145+ });
146+ const RecordingStoreLive = Layer.succeed(McpSessionStore)({
147+ dispatch: (): Effect.Effect<McpDispatchResult> => Effect.die("dispatch should not run"),
148+ dispose: (sessionId) => Ref.update(disposed, (ids) => [...ids, sessionId]),
149+ });
150+
151+ const handler = buildHandler(
152+ RecordingStoreLive,
153+ McpErrorReporterNoop,
154+ ForbiddenAuthProviderLive,
155+ );
156+ const response = await handler(
157+ new Request("https://host.test/mcp/toolkits/deploy", {
158+ method: "POST",
159+ headers: {
160+ authorization: "Bearer x",
161+ "mcp-session-id": "leaked-session",
162+ "content-type": "application/json",
163+ },
164+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
165+ }),
166+ );
167+
168+ expect(response.status).toBe(403);
169+ expect(await Effect.runPromise(Ref.get(disposed))).toEqual([]);
170+ });
171+});
172+
173+it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => {
174+ const seen = await Effect.runPromise(Ref.make<McpResource | null>(null));
175+ const RecordingStoreLive = Layer.succeed(McpSessionStore)({
176+ dispatch: ({ resource }): Effect.Effect<McpDispatchResult> =>
177+ Ref.set(seen, resource).pipe(
178+ Effect.as(new Response(JSON.stringify({ jsonrpc: "2.0", id: 1 }), { status: 200 })),
179+ ),
180+ dispose: () => Effect.void,
181+ });
182+
183+ const handler = buildHandler(RecordingStoreLive, McpErrorReporterNoop);
184+ const response = await handler(
185+ new Request("https://host.test/mcp/toolkits/deploy", {
186+ method: "POST",
187+ headers: { authorization: "Bearer x", "content-type": "application/json" },
188+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
189+ }),
190+ );
191+
192+ expect(response.status).toBe(200);
193+ expect(await Effect.runPromise(Ref.get(seen))).toEqual({
194+ kind: "toolkit",
195+ slug: "deploy",
196+ });
135197 });
packages/hosts/mcp/src/envelope.ts+77−67
@@ -2,11 +2,13 @@ import { Effect, Match, Predicate } from "effect"; · 1 unmodified line
22 import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
33
44 import {
5+ defaultMcpResource,
56 McpAuthProvider,
67 McpErrorReporter,
78 McpSessionStore,
89 type AuthOutcome,
910 type McpDispatchResult,
11+ type McpResource,
1012 } from "./seams";
1113
1214 // ---------------------------------------------------------------------------
@@ -14,7 +16,8 @@ import { · 1 unmodified line
1416 //
1517 // Routes:
1618 // GET <provider-declared discovery paths> -> McpAuthProvider metadata
17−// * /mcp -> authenticate -> dispatch
19+// * /mcp -> authenticate -> dispatch(default)
20+// * /mcp/toolkits/:toolkitSlug -> authenticate -> dispatch(toolkit)
1821 //
1922 // The provider DECLARES the discovery paths it owns (at least the protected-
2023 // resource metadata document) via `McpAuthProvider.discoveryRoutes`; the
@@ -24,8 +27,8 @@ import { · 3 unmodified lines
2427 // WorkOS, external). The envelope only needs the provider's discovery routes,
2528 // resource-metadata URL, and authenticate.
2629 //
27−// The envelope hard-codes ONLY the `/mcp` path and CORS. Everything else —
28−// every `/.well-known/*` path, the resource-metadata URL, the authn/authz
30+// The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else
31+// — every `/.well-known/*` path, the resource-metadata URL, the authn/authz
2932 // semantics, and the entire session lifecycle (create + forward + ownership) —
3033 // comes from the two seams.
3134 //
@@ -38,6 +41,7 @@ import { · 6 unmodified lines
3841 // ---------------------------------------------------------------------------
3942
4043 const MCP_PATH = "/mcp";
44+const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug";
4145
4246 /** The methods the streamable-HTTP transport accepts on `/mcp`. */
4347 const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
@@ -170,69 +174,67 @@ const renderDispatchError = (lookup: "not-found" | "forbidden"): Response => · 126 unmodified lines
170174 ? jsonRpcResponse(404, -32001, "Session not found")
171175 : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
172176
173−/** Dispatch a `/mcp` request through authenticate -> store.dispatch -> transport. */
174−const mcpDispatch = Effect.gen(function* () {
175− const httpRequest = yield* HttpServerRequest.HttpServerRequest;
176− const auth = yield* McpAuthProvider;
177− const store = yield* McpSessionStore;
178− const request = yield* toWebRequest(httpRequest);
177+/** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */
178+const mcpDispatch = (resource: McpResource) =>
179+ Effect.gen(function* () {
180+ const httpRequest = yield* HttpServerRequest.HttpServerRequest;
181+ const auth = yield* McpAuthProvider;
182+ const store = yield* McpSessionStore;
183+ const request = yield* toWebRequest(httpRequest);
179184
180− // CORS preflight: answer before auth so unauthenticated clients can probe.
181− if (request.method === "OPTIONS") {
182− return HttpServerResponse.raw(corsPreflightResponse());
183− }
185+ // CORS preflight: answer before auth so unauthenticated clients can probe.
186+ if (request.method === "OPTIONS") {
187+ return HttpServerResponse.raw(corsPreflightResponse());
188+ }
184189
185− // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other
186− // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch —
187− // otherwise it would fall through and spin up a session engine for a method
188− // the transport can't serve.
189− if (!ALLOWED_MCP_METHODS.has(request.method)) {
190− return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed"));
191− }
190+ // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other
191+ // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch —
192+ // otherwise it would fall through and spin up a session engine for a method
193+ // the transport can't serve.
194+ if (!ALLOWED_MCP_METHODS.has(request.method)) {
195+ return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed"));
196+ }
192197
193− const sessionId = request.headers.get("mcp-session-id");
198+ const sessionId = request.headers.get("mcp-session-id");
194199
195− // Authenticate (and, for session-aware providers, authorize) on EVERY
196− // request. On a non-Authenticated outcome:
197− // - Forbidden -> dispose the live session first (cloud tears down a DO
198− // whose org access was revoked), then render the 403. The
199− // inbound request is forwarded so the store can propagate
200− // the request's W3C trace context onto the teardown RPC.
201− // - other -> render directly.
202− const outcome = yield* auth.authenticate(request);
203− if (!Predicate.isTagged(outcome, "Authenticated")) {
204− if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
205− yield* store.dispose(sessionId, request);
200+ // Authenticate (and, for session-aware providers, authorize) on EVERY
201+ // request. Non-authenticated outcomes render directly. Session teardown is
202+ // only safe after the store can validate the authenticated principal and MCP
203+ // resource; an auth-level Forbidden may not carry either.
204+ const outcome = yield* auth.authenticate(request);
205+ if (!Predicate.isTagged(outcome, "Authenticated")) {
206+ return HttpServerResponse.raw(renderAuthError(auth, request, outcome));
206207 }
207− return HttpServerResponse.raw(renderAuthError(auth, request, outcome));
208− }
209− const principal = outcome.principal;
208+ const principal = outcome.principal;
210209
211− // No session id: per the streamable-HTTP transport contract, only POST opens
212− // a session. A GET needs an existing id (400); a DELETE on nothing is a
213− // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up
214− // an engine for a bare GET/DELETE.
215− if (!sessionId) {
216− if (request.method === "GET") {
217− return HttpServerResponse.raw(
218− jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"),
219− );
210+ // No session id: per the streamable-HTTP transport contract, only POST opens
211+ // a session. A GET needs an existing id (400); a DELETE on nothing is a
212+ // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up
213+ // an engine for a bare GET/DELETE.
214+ if (!sessionId) {
215+ if (request.method === "GET") {
216+ return HttpServerResponse.raw(
217+ jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"),
218+ );
219+ }
220+ if (request.method === "DELETE") {
221+ return HttpServerResponse.raw(
222+ new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }),
223+ );
224+ }
220225 }
221− if (request.method === "DELETE") {
222− return HttpServerResponse.raw(
223− new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }),
224− );
225− }
226− }
227226
228− const result: McpDispatchResult = yield* store.dispatch({
229− request,
230− principal,
231− sessionId,
232− method: request.method,
227+ const result: McpDispatchResult = yield* store.dispatch({
228+ request,
229+ principal,
230+ resource,
231+ sessionId,
232+ method: request.method,
233+ });
234+ return HttpServerResponse.raw(
235+ result instanceof Response ? result : renderDispatchError(result),
236+ );
233237 });
234− return HttpServerResponse.raw(result instanceof Response ? result : renderDispatchError(result));
235−});
236238
237239 /**
238240 * The `/mcp` route. Wraps {@link mcpDispatch} in a top-level `catchCause`: a
@@ -242,15 +244,22 @@ const mcpDispatch = Effect.gen(function* () { · 3 unmodified lines
242244 * otherwise, since the envelope returns a `Response`) and rendered as a stable
243245 * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body.
244246 */
245−const mcpRoute = mcpDispatch.pipe(
246− Effect.catchCause((cause) =>
247− Effect.gen(function* () {
248− const reporter = yield* McpErrorReporter;
249− yield* reporter.report(cause);
250− return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error"));
251− }),
252− ),
253−);
247+const mcpRoute = (resource: McpResource) =>
248+ mcpDispatch(resource).pipe(
249+ Effect.catchCause((cause) =>
250+ Effect.gen(function* () {
251+ const reporter = yield* McpErrorReporter;
252+ yield* reporter.report(cause);
253+ return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error"));
254+ }),
255+ ),
256+ );
257+
258+const toolkitMcpRoute = Effect.gen(function* () {
259+ const params = yield* HttpRouter.params;
260+ const slug = params.toolkitSlug;
261+ return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource);
262+});
254263
255264 /**
256265 * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges
@@ -273,6 +282,7 @@ export const McpServingRoutes = HttpRouter.use((router) => · 16 unmodified lines
273282 Effect.sync(() => HttpServerResponse.raw(corsPreflightResponse())),
274283 );
275284 }
276− yield* router.add("*", MCP_PATH, mcpRoute);
285+ yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource));
286+ yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute);
277287 }),
278288 );
packages/hosts/mcp/src/in-memory-session-store.ts+35−11
@@ -17,10 +17,13 @@ import { · 16 unmodified lines
1717 import { jsonRpcErrorBody } from "./envelope";
1818 import {
1919 McpSessionStore,
20+ defaultMcpResource,
21+ mcpResourceKey,
2022 principalOwns,
2123 type McpDispatchInput,
2224 type McpDispatchResult,
2325 type Principal,
26+ type McpResource,
2427 } from "./seams";
2528 import type { BrowserApprovalStore } from "./tool-server";
2629
@@ -61,6 +64,7 @@ export interface BuiltMcpServer { · 34 unmodified lines
6164
6265 /** The browser-mode wiring the store hands a build call when a session opts in. */
6366 export interface McpBuildServerOptions {
67+ readonly resource?: McpResource;
6468 readonly elicitationMode?:
6569 | { readonly mode: "browser"; readonly approvalUrl: (executionId: string) => string }
6670 | { readonly mode: "model" }
@@ -120,6 +124,19 @@ const json = (value: unknown, status = 200): Response => · 53 unmodified lines
120124 const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;
121125 const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;
122126
127+interface SessionOwner {
128+ readonly principal: Principal;
129+ readonly resource: McpResource;
130+}
131+
132+const sessionOwnerMatches = (
133+ owner: SessionOwner,
134+ principal: Principal,
135+ resource: McpResource,
136+): boolean =>
137+ principalOwns(owner.principal, principal) &&
138+ mcpResourceKey(owner.resource) === mcpResourceKey(resource);
139+
123140 /**
124141 * Build the in-process session store plus an explicit `close()` that disposes
125142 * all live sessions. `close()` is not part of the seam — it is the host lifetime
@@ -137,7 +154,7 @@ export const makeInMemoryMcpSessionStore = ( · 11 unmodified lines
137154 ): InMemoryMcpSessionStore => {
138155 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
139156 const servers = new Map<string, McpServer>();
140− const owners = new Map<string, Principal>();
157+ const owners = new Map<string, SessionOwner>();
141158 const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>();
142159 const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore();
143160
@@ -181,12 +198,13 @@ export const makeInMemoryMcpSessionStore = ( · 37 unmodified lines
181198 const forward = (
182199 sessionId: string,
183200 principal: Principal,
201+ resource: McpResource,
184202 request: Request,
185203 ): Effect.Effect<McpDispatchResult> => {
186204 const transport = transports.get(sessionId);
187205 const owner = owners.get(sessionId);
188206 if (!transport || !owner) return Effect.succeed("not-found");
189− if (!principalOwns(owner, principal)) return Effect.succeed("forbidden");
207+ if (!sessionOwnerMatches(owner, principal, resource)) return Effect.succeed("forbidden");
190208 return runHandleRequest(transport, request);
191209 };
192210
@@ -218,12 +236,16 @@ export const makeInMemoryMcpSessionStore = ( · 25 unmodified lines
218236 };
219237
220238 /** Open a new session: build the server, connect a transport, drive the request. */
221− const create = (principal: Principal, request: Request): Effect.Effect<McpDispatchResult> => {
239+ const create = (
240+ principal: Principal,
241+ resource: McpResource,
242+ request: Request,
243+ ): Effect.Effect<McpDispatchResult> => {
222244 let createdSessionId: string | null = null;
223− return buildServer(
224− principal,
225− buildOptionsFor(request, () => createdSessionId),
226− ).pipe(
245+ return buildServer(principal, {
246+ ...buildOptionsFor(request, () => createdSessionId),
247+ resource,
248+ }).pipe(
227249 Effect.flatMap(({ mcpServer, engine }) =>
228250 Effect.gen(function* () {
229251 const transport = new WebStandardStreamableHTTPServerTransport({
@@ -233,7 +255,7 @@ export const makeInMemoryMcpSessionStore = ( · 3 unmodified lines
233255 createdSessionId = sid;
234256 transports.set(sid, transport);
235257 servers.set(sid, mcpServer);
236− owners.set(sid, principal);
258+ owners.set(sid, { principal, resource });
237259 engines.set(sid, engine);
238260 },
239261 onsessionclosed: (sid) => void dispose(sid, { server: true }),
@@ -259,8 +281,10 @@ export const makeInMemoryMcpSessionStore = ( · 19 unmodified lines
259281 };
260282
261283 const store: McpSessionStore["Service"] = {
262− dispatch: ({ request, principal, sessionId }: McpDispatchInput) =>
263− sessionId ? forward(sessionId, principal, request) : create(principal, request),
284+ dispatch: ({ request, principal, resource, sessionId }: McpDispatchInput) =>
285+ sessionId
286+ ? forward(sessionId, principal, resource, request)
287+ : create(principal, resource ?? defaultMcpResource, request),
264288 dispose: (sessionId) =>
265289 Effect.promise(() => dispose(sessionId, { transport: true, server: true })),
266290 };
@@ -271,7 +295,7 @@ export const makeInMemoryMcpSessionStore = ( · 4 unmodified lines
271295 ): "allowed" | "not-found" | "forbidden" => {
272296 const owner = owners.get(sessionId);
273297 if (!owner) return "not-found";
274− if (principal && !principalOwns(owner, principal)) return "forbidden";
298+ if (principal && !principalOwns(owner.principal, principal)) return "forbidden";
275299 return "allowed";
276300 };
277301
packages/hosts/mcp/src/index.ts+3−0
@@ -18,6 +18,8 @@ export { · 17 unmodified lines
1818 McpSessionStore,
1919 McpErrorReporter,
2020 McpErrorReporterNoop,
21+ defaultMcpResource,
22+ mcpResourceKey,
2123 principalOwns,
2224 authenticated,
2325 unauthorized,
@@ -31,6 +33,7 @@ export { · 7 unmodified lines
3133 type McpDiscoveryRoute,
3234 type McpDispatchInput,
3335 type McpDispatchResult,
36+ type McpResource,
3437 } from "./seams";
3538
3639 export { McpServingRoutes, jsonRpcErrorBody } from "./envelope";
packages/hosts/mcp/src/seams.ts+20−0
@@ -56,6 +56,25 @@ export type Principal = Schema.Schema.Type<typeof Principal>; · 55 unmodified lines
5656 export const principalOwns = (owner: Principal, principal: Principal): boolean =>
5757 owner.accountId === principal.accountId && owner.organizationId === principal.organizationId;
5858
59+// ---------------------------------------------------------------------------
60+// Shared MCP resource identity.
61+//
62+// The default `/mcp` endpoint and named sub-resources such as
63+// `/mcp/toolkits/<slug>` share auth and transport machinery, but a serving
64+// session belongs to the exact resource path that minted it. This keeps a
65+// leaked/reused `mcp-session-id` from crossing from one exposed capability set
66+// into another.
67+// ---------------------------------------------------------------------------
68+
69+export type McpResource =
70+ | { readonly kind: "default" }
71+ | { readonly kind: "toolkit"; readonly slug: string };
72+
73+export const defaultMcpResource: McpResource = { kind: "default" };
74+
75+export const mcpResourceKey = (resource: McpResource): string =>
76+ resource.kind === "default" ? "default" : `toolkit:${resource.slug}`;
77+
5978 // ---------------------------------------------------------------------------
6079 // AuthOutcome — the result of `McpAuthProvider.authenticate`.
6180 //
@@ -210,6 +229,7 @@ export class McpAuthProvider extends Context.Service< · 148 unmodified lines
210229 export interface McpDispatchInput {
211230 readonly request: Request;
212231 readonly principal: Principal;
232+ readonly resource: McpResource;
213233 readonly sessionId: string | null;
214234 readonly method: string;
215235 }
packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts+66−5
@@ -35,6 +35,23 @@ const insertIntegration = ( · 34 unmodified lines
3535 ],
3636 });
3737
38+const insertIntegrationRawConfig = (
39+ client: SqliteDataMigrationClient,
40+ row: {
41+ readonly rowId: string;
42+ readonly tenant: string;
43+ readonly slug: string;
44+ readonly pluginId: string;
45+ readonly config: string;
46+ },
47+) =>
48+ client.execute({
49+ sql: `INSERT INTO integration
50+ (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at)
51+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`,
52+ args: [row.rowId, row.tenant, row.slug, row.pluginId, row.slug, row.slug, row.config, now, now],
53+ });
54+
3855 const insertBlob = (
3956 client: SqliteDataMigrationClient,
4057 row: {
@@ -84,7 +101,11 @@ const insertOperationStorage = ( · 43 unmodified lines
84101
85102 const insertTool = (
86103 client: SqliteDataMigrationClient,
87− row: { readonly tenant: string; readonly pluginId: string; readonly integration: string },
104+ row: {
105+ readonly tenant: string;
106+ readonly pluginId: string;
107+ readonly integration: string;
108+ },
88109 ) =>
89110 client.execute({
90111 sql: `INSERT INTO tool
@@ -95,7 +116,11 @@ const insertTool = ( · 4 unmodified lines
95116
96117 const insertDefinition = (
97118 client: SqliteDataMigrationClient,
98− row: { readonly tenant: string; readonly pluginId: string; readonly integration: string },
119+ row: {
120+ readonly tenant: string;
121+ readonly pluginId: string;
122+ readonly integration: string;
123+ },
99124 ) =>
100125 client.execute({
101126 sql: `INSERT INTO definition
@@ -105,6 +130,31 @@ const insertDefinition = ( · 3 unmodified lines
105130 });
106131
107132 describe("runSqliteGoogleOpenApiOwnershipMigration", () => {
133+ it.effect("skips malformed legacy integration config rows", () =>
134+ Effect.gen(function* () {
135+ const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
136+
137+ yield* Effect.promise(() =>
138+ insertIntegrationRawConfig(db.client, {
139+ rowId: "malformed-row",
140+ tenant: "org_1",
141+ slug: "broken",
142+ pluginId: "openapi",
143+ config: "",
144+ }),
145+ );
146+
147+ expect(yield* runSqliteGoogleOpenApiOwnershipMigration(db.client)).toBe(0);
148+
149+ const integrations = yield* Effect.promise(() =>
150+ db.client.execute("SELECT slug, plugin_id, config FROM integration"),
151+ );
152+ expect(integrations.rows).toEqual([{ slug: "broken", plugin_id: "openapi", config: "" }]);
153+
154+ yield* Effect.promise(() => db.close());
155+ }),
156+ );
157+
108158 it.effect("moves OpenAPI-owned Google bundle rows to the Google plugin", () =>
109159 Effect.gen(function* () {
110160 const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
@@ -128,7 +178,10 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 17 unmodified lines
128178 tenant: "org_1",
129179 slug: "stripe",
130180 pluginId: "openapi",
131− config: { specHash: "stripehash", sourceUrl: "https://stripe.example/openapi.json" },
181+ config: {
182+ specHash: "stripehash",
183+ sourceUrl: "https://stripe.example/openapi.json",
184+ },
132185 }),
133186 );
134187 yield* Effect.promise(() =>
@@ -156,10 +209,18 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 21 unmodified lines
156209 }),
157210 );
158211 yield* Effect.promise(() =>
159− insertTool(client, { tenant: "org_1", pluginId: "openapi", integration }),
212+ insertTool(client, {
213+ tenant: "org_1",
214+ pluginId: "openapi",
215+ integration,
216+ }),
160217 );
161218 yield* Effect.promise(() =>
162− insertDefinition(client, { tenant: "org_1", pluginId: "openapi", integration }),
219+ insertDefinition(client, {
220+ tenant: "org_1",
221+ pluginId: "openapi",
222+ integration,
223+ }),
163224 );
164225 }
165226
packages/plugins/google/src/sdk/openapi-ownership-migration.ts+1−1
@@ -4,7 +4,7 @@ import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js · 3 unmodified lines
44 const MIGRATION_NAME = "2026-06-20-google-openapi-ownership";
55 const googleOpenApiCandidate = (alias?: string): string => {
66 const column = (name: string) => (alias ? `${alias}.${name}` : name);
7− return `${column("plugin_id")} = 'openapi' AND ${column("config")} IS NOT NULL AND json_type(${column("config")}, '$.googleDiscoveryUrls') = 'array'`;
7+ return `${column("plugin_id")} = 'openapi' AND ${column("config")} IS NOT NULL AND json_valid(${column("config")}) AND json_type(${column("config")}, '$.googleDiscoveryUrls') = 'array'`;
88 };
99
1010 const execute = (
packages/plugins/toolkits/CHANGELOG.md+1−0
@@ -0,0 +1 @@
1+# @executor-js/plugin-toolkits
packages/plugins/toolkits/package.json+105−0
@@ -0,0 +1,105 @@
1+{
2+ "name": "@executor-js/plugin-toolkits",
3+ "version": "1.5.12",
4+ "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/toolkits",
5+ "bugs": {
6+ "url": "https://github.com/RhysSullivan/executor/issues"
7+ },
8+ "license": "MIT",
9+ "repository": {
10+ "type": "git",
11+ "url": "git+https://github.com/RhysSullivan/executor.git",
12+ "directory": "packages/plugins/toolkits"
13+ },
14+ "files": [
15+ "dist"
16+ ],
17+ "type": "module",
18+ "exports": {
19+ "./server": "./src/server.ts",
20+ "./client": "./src/client.tsx",
21+ "./shared": "./src/shared.ts"
22+ },
23+ "publishConfig": {
24+ "access": "public",
25+ "exports": {
26+ "./server": {
27+ "import": {
28+ "types": "./dist/server.d.ts",
29+ "default": "./dist/server.js"
30+ }
31+ },
32+ "./client": {
33+ "import": {
34+ "types": "./dist/client.d.ts",
35+ "default": "./dist/client.js"
36+ }
37+ },
38+ "./shared": {
39+ "import": {
40+ "types": "./dist/shared.d.ts",
41+ "default": "./dist/shared.js"
42+ }
43+ }
44+ }
45+ },
46+ "scripts": {
47+ "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)",
48+ "typecheck": "tsgo --noEmit",
49+ "test": "vitest run",
50+ "typecheck:slow": "tsc --noEmit"
51+ },
52+ "dependencies": {
53+ "@executor-js/sdk": "workspace:*"
54+ },
55+ "devDependencies": {
56+ "@effect/atom-react": "catalog:",
57+ "@effect/vitest": "catalog:",
58+ "@executor-js/api": "workspace:*",
59+ "@executor-js/react": "workspace:*",
60+ "@tanstack/react-router": "catalog:",
61+ "@types/node": "catalog:",
62+ "@types/react": "catalog:",
63+ "bun-types": "catalog:",
64+ "effect": "catalog:",
65+ "fractional-indexing": "^3.2.0",
66+ "lucide-react": "^1.7.0",
67+ "react": "catalog:",
68+ "tsup": "catalog:",
69+ "typescript": "catalog:",
70+ "vitest": "catalog:"
71+ },
72+ "peerDependencies": {
73+ "@effect/atom-react": "catalog:",
74+ "@executor-js/api": "workspace:*",
75+ "@executor-js/react": "workspace:*",
76+ "@tanstack/react-router": "catalog:",
77+ "effect": "catalog:",
78+ "fractional-indexing": "^3.2.0",
79+ "lucide-react": "^1.7.0",
80+ "react": "catalog:"
81+ },
82+ "peerDependenciesMeta": {
83+ "@effect/atom-react": {
84+ "optional": true
85+ },
86+ "@executor-js/api": {
87+ "optional": true
88+ },
89+ "@executor-js/react": {
90+ "optional": true
91+ },
92+ "@tanstack/react-router": {
93+ "optional": true
94+ },
95+ "fractional-indexing": {
96+ "optional": true
97+ },
98+ "lucide-react": {
99+ "optional": true
100+ },
101+ "react": {
102+ "optional": true
103+ }
104+ }
105+}
packages/plugins/toolkits/src/client.tsx+17−0
@@ -0,0 +1,17 @@
1+import { defineClientPlugin } from "@executor-js/sdk/client";
2+
3+import { ToolkitsPage } from "./page";
4+
5+export default defineClientPlugin({
6+ id: "toolkits" as const,
7+ pages: [
8+ {
9+ path: "/",
10+ component: ToolkitsPage,
11+ },
12+ {
13+ path: "/$toolkitSlug",
14+ component: ToolkitsPage,
15+ },
16+ ],
17+});
packages/plugins/toolkits/src/page.tsx+1488−0
@@ -0,0 +1,1488 @@
1+import { useId, useMemo, useState } from "react";
2+import { Link, useNavigate } from "@tanstack/react-router";
3+import { useAtomSet, useAtomValue } from "@effect/atom-react";
4+import * as Atom from "effect/unstable/reactivity/Atom";
5+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
6+import { ArrowLeftIcon, BoxIcon, PlugIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react";
7+import {
8+ createPluginAtomClient,
9+ useIntegrationPlugins,
10+ type IntegrationPlugin,
11+ type PluginPageProps,
12+} from "@executor-js/sdk/client";
13+import {
14+ matchPattern,
15+ type EffectivePolicy,
16+ type Integration,
17+ type Owner,
18+ type ToolAddress,
19+ type ToolPolicyAction,
20+} from "@executor-js/sdk/shared";
21+import { integrationsOptimisticAtom, toolsAllAtom } from "@executor-js/react/api/atoms";
22+import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
23+import { useOrganizationSlug } from "@executor-js/react/api/organization-context";
24+import {
25+ getExecutorApiBaseUrl,
26+ getExecutorOrganizationHeaders,
27+ getExecutorServerAuthorizationHeader,
28+} from "@executor-js/react/api/server-connection";
29+import { ownerLabel, useOwnerDisplay } from "@executor-js/react/api/owner-display";
30+import { Badge } from "@executor-js/react/components/badge";
31+import { Button } from "@executor-js/react/components/button";
32+import { CopyButton } from "@executor-js/react/components/copy-button";
33+import {
34+ AlertDialog,
35+ AlertDialogAction,
36+ AlertDialogCancel,
37+ AlertDialogContent,
38+ AlertDialogDescription,
39+ AlertDialogFooter,
40+ AlertDialogHeader,
41+ AlertDialogTitle,
42+ AlertDialogTrigger,
43+} from "@executor-js/react/components/alert-dialog";
44+import {
45+ IntegrationFavicon,
46+ integrationPresetIconUrl,
47+} from "@executor-js/react/components/integration-favicon";
48+import {
49+ Dialog,
50+ DialogContent,
51+ DialogDescription,
52+ DialogFooter,
53+ DialogHeader,
54+ DialogTitle,
55+} from "@executor-js/react/components/dialog";
56+import { Input } from "@executor-js/react/components/input";
57+import { Label } from "@executor-js/react/components/label";
58+import { Skeleton } from "@executor-js/react/components/skeleton";
59+import { ToolDetail, ToolDetailEmpty } from "@executor-js/react/components/tool-detail";
60+import { ToolTree, type ToolSummary } from "@executor-js/react/components/tool-tree";
61+import { cn } from "@executor-js/react/lib/utils";
62+
63+import {
64+ ToolkitsApi,
65+ type ToolkitConnectionResponse,
66+ type ToolkitPolicyResponse,
67+ type ToolkitResponse,
68+} from "./shared";
69+
70+const ToolkitsClient = createPluginAtomClient(ToolkitsApi, {
71+ baseUrl: getExecutorApiBaseUrl,
72+ authorizationHeader: getExecutorServerAuthorizationHeader,
73+ headers: getExecutorOrganizationHeaders,
74+});
75+
76+const toolkitWriteKeys = [
77+ ReactivityKey.connections,
78+ ReactivityKey.policies,
79+ ReactivityKey.tools,
80+] as const;
81+
82+const toolkitsAtom = ToolkitsClient.query("toolkits", "list", {
83+ timeToLive: "30 seconds",
84+ reactivityKeys: [ReactivityKey.policies],
85+});
86+
87+const toolkitPoliciesAtom = Atom.family((toolkitId: string) =>
88+ ToolkitsClient.query("toolkits", "listPolicies", {
89+ params: { toolkitId },
90+ timeToLive: "30 seconds",
91+ reactivityKeys: [ReactivityKey.policies],
92+ }),
93+);
94+
95+const toolkitConnectionsAtom = Atom.family((toolkitId: string) =>
96+ ToolkitsClient.query("toolkits", "listConnections", {
97+ params: { toolkitId },
98+ timeToLive: "30 seconds",
99+ reactivityKeys: [ReactivityKey.connections],
100+ }),
101+);
102+
103+const createToolkit = ToolkitsClient.mutation("toolkits", "create");
104+const removeToolkit = ToolkitsClient.mutation("toolkits", "remove");
105+const createToolkitPolicy = ToolkitsClient.mutation("toolkits", "createPolicy");
106+const updateToolkitPolicy = ToolkitsClient.mutation("toolkits", "updatePolicy");
107+const removeToolkitPolicy = ToolkitsClient.mutation("toolkits", "removePolicy");
108+const createToolkitConnection = ToolkitsClient.mutation("toolkits", "createConnection");
109+const removeToolkitConnection = ToolkitsClient.mutation("toolkits", "removeConnection");
110+
111+type ToolRow = {
112+ readonly address: ToolAddress;
113+ readonly integration: string;
114+ readonly owner?: Owner;
115+ readonly connection?: string;
116+ readonly name: string;
117+ readonly description?: string;
118+ readonly requiresApproval?: boolean;
119+ readonly static?: boolean;
120+};
121+
122+const comparePolicy = (a: ToolkitPolicyResponse, b: ToolkitPolicyResponse): number => {
123+ if (a.position < b.position) return -1;
124+ if (a.position > b.position) return 1;
125+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
126+};
127+
128+const pluginDefaultPolicy = (requiresApproval: boolean | undefined): EffectivePolicy =>
129+ requiresApproval
130+ ? { action: "require_approval", source: "plugin-default" }
131+ : { action: "approve", source: "plugin-default" };
132+
133+const isLegacyConnectionPolicy = (policy: ToolkitPolicyResponse): boolean => {
134+ if (policy.action !== "approve") return false;
135+ const parts = policy.pattern.split(".");
136+ return parts.at(-1) === "*" && (parts.length === 3 || parts.length === 4);
137+};
138+
139+const resolveToolkitPolicy = (
140+ matchId: string,
141+ policies: readonly ToolkitPolicyResponse[],
142+ requiresApproval?: boolean,
143+): EffectivePolicy => {
144+ for (const policy of [...policies].sort(comparePolicy)) {
145+ if (!matchPattern(policy.pattern, matchId)) continue;
146+ return {
147+ action: policy.action,
148+ source: "user",
149+ pattern: policy.pattern,
150+ policyId: policy.id,
151+ };
152+ }
153+ return pluginDefaultPolicy(requiresApproval);
154+};
155+
156+const toolMatchId = (tool: ToolRow): string =>
157+ tool.static ? String(tool.address) : String(tool.address).replace(/^tools\./, "");
158+
159+const toolCanAppearInToolkit = (toolkit: ToolkitResponse, tool: ToolRow): boolean =>
160+ toolkit.owner === "user" || tool.static === true || tool.owner !== "user";
161+
162+const toolkitUrlFor = (orgSlug: string | undefined, slug: string): string => {
163+ const path = orgSlug ? `/${orgSlug}/mcp/toolkits/${slug}` : `/mcp/toolkits/${slug}`;
164+ if (typeof window === "undefined") return path;
165+ return `${window.location.origin}${path}`;
166+};
167+
168+const identityPattern = (displayPattern: string): string => displayPattern;
169+
170+const compareToolkitRows = (a: ToolkitResponse, b: ToolkitResponse): number => {
171+ if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt;
172+ return a.name.localeCompare(b.name);
173+};
174+
175+const toolkitByRouteSlug = (
176+ toolkits: readonly ToolkitResponse[],
177+ slug: string,
178+): ToolkitResponse | null => {
179+ const matches = toolkits.filter((toolkit) => toolkit.slug === slug);
180+ return matches.find((toolkit) => toolkit.owner === "org") ?? matches[0] ?? null;
181+};
182+
183+const toolkitCardStyle = { minHeight: "9rem" };
184+const toolkitShelfStyle = { minHeight: "28.5rem" };
185+const toolkitGridContainerStyle = { maxWidth: "80rem" };
186+const toolkitToolTreeStyle = { width: "24rem" };
187+
188+const neutralToolkitIconClass = "border-border/70 bg-muted/30 text-muted-foreground";
189+
190+type ToolkitConnectionGroup = {
191+ readonly id: string;
192+ readonly owner: Owner;
193+ readonly integration: string;
194+ readonly connection: string;
195+ readonly patterns: readonly string[];
196+ readonly tools: readonly ToolRow[];
197+};
198+
199+type IntegrationMeta = {
200+ readonly name: string;
201+ readonly sourceId: string;
202+ readonly icon?: string | null;
203+ readonly url?: string;
204+};
205+
206+const toolOwner = (tool: ToolRow): Owner => (tool.static ? "org" : (tool.owner ?? "org"));
207+
208+const toolConnectionName = (tool: ToolRow): string => {
209+ if (tool.connection && tool.connection.length > 0) return tool.connection;
210+ return tool.static ? "built-in" : "default";
211+};
212+
213+const policyPrefixForTool = (tool: ToolRow): string => {
214+ const matchId = toolMatchId(tool);
215+ const integration = String(tool.integration);
216+ if (tool.connection && tool.connection.length > 0) {
217+ const connection = String(tool.connection);
218+ if (tool.owner) {
219+ const ownerConnectionPrefix = `${integration}.${tool.owner}.${connection}`;
220+ if (matchId === ownerConnectionPrefix || matchId.startsWith(`${ownerConnectionPrefix}.`)) {
221+ return ownerConnectionPrefix;
222+ }
223+ }
224+ const connectionPrefix = `${integration}.${connection}`;
225+ if (matchId === connectionPrefix || matchId.startsWith(`${connectionPrefix}.`)) {
226+ return connectionPrefix;
227+ }
228+ }
229+ const name = String(tool.name);
230+ if (name.length > 0 && matchId.endsWith(`.${name}`)) {
231+ return matchId.slice(0, -name.length - 1);
232+ }
233+ const segments = matchId.split(".");
234+ return segments.length > 1 ? segments.slice(0, -1).join(".") : matchId;
235+};
236+
237+const connectionPatternForTool = (tool: ToolRow): string => `${policyPrefixForTool(tool)}.*`;
238+
239+const compareConnectionGroups = (a: ToolkitConnectionGroup, b: ToolkitConnectionGroup): number => {
240+ const ownerRank = (owner: Owner) => (owner === "org" ? 0 : 1);
241+ return (
242+ ownerRank(a.owner) - ownerRank(b.owner) ||
243+ a.integration.localeCompare(b.integration) ||
244+ a.connection.localeCompare(b.connection) ||
245+ a.id.localeCompare(b.id)
246+ );
247+};
248+
249+const patternSubsumes = (candidate: string, covered: string): boolean => {
250+ if (candidate === covered) return true;
251+ if (!candidate.endsWith(".*")) return false;
252+ const prefix = candidate.slice(0, -1);
253+ return covered.startsWith(prefix);
254+};
255+
256+const reducePolicyPatterns = (patterns: readonly string[]): readonly string[] => {
257+ const unique = [...new Set(patterns)].sort((a, b) => a.localeCompare(b));
258+ return unique.filter(
259+ (pattern) =>
260+ !unique.some((candidate) => candidate !== pattern && patternSubsumes(candidate, pattern)),
261+ );
262+};
263+
264+const buildConnectionGroups = (tools: readonly ToolRow[]): readonly ToolkitConnectionGroup[] => {
265+ const groups = new Map<string, ToolkitConnectionGroup & { tools: ToolRow[] }>();
266+ for (const tool of tools) {
267+ const owner = toolOwner(tool);
268+ const key = `${owner}:${tool.integration}:${toolConnectionName(tool)}`;
269+ const existing = groups.get(key);
270+ if (existing) {
271+ existing.tools.push(tool);
272+ continue;
273+ }
274+ groups.set(key, {
275+ id: key,
276+ owner,
277+ integration: String(tool.integration),
278+ connection: toolConnectionName(tool),
279+ patterns: [],
280+ tools: [tool],
281+ });
282+ }
283+ return [...groups.values()]
284+ .map((group) => {
285+ const sortedTools = [...group.tools].sort(compareTools);
286+ return {
287+ ...group,
288+ patterns: reducePolicyPatterns(sortedTools.map(connectionPatternForTool)),
289+ tools: sortedTools,
290+ };
291+ })
292+ .sort(compareConnectionGroups);
293+};
294+
295+const compareTools = (a: ToolRow, b: ToolRow): number =>
296+ String(a.name).localeCompare(String(b.name)) || toolMatchId(a).localeCompare(toolMatchId(b));
297+
298+const connectionTitle = (group: ToolkitConnectionGroup): string =>
299+ `${ownerLabel(group.owner)} ${group.integration} / ${group.connection}`;
300+
301+const connectionTitleForHost = (group: ToolkitConnectionGroup, showOwnerLabels: boolean): string =>
302+ showOwnerLabels ? connectionTitle(group) : `${group.integration} / ${group.connection}`;
303+
304+const connectionDisplayTitle = (group: ToolkitConnectionGroup, meta: IntegrationMeta): string =>
305+ group.connection === "built-in" || group.connection === "default" ? meta.name : group.connection;
306+
307+const connectionDisplaySubtitle = (group: ToolkitConnectionGroup, meta: IntegrationMeta): string =>
308+ group.connection === "built-in" || group.connection === "default"
309+ ? `${group.tools.length} ${group.tools.length === 1 ? "tool" : "tools"}`
310+ : `${meta.name} · ${group.tools.length} ${group.tools.length === 1 ? "tool" : "tools"}`;
311+
312+const connectionDisplaySubtitleForHost = (
313+ group: ToolkitConnectionGroup,
314+ meta: IntegrationMeta,
315+ showOwnerLabels: boolean,
316+): string => {
317+ const subtitle = connectionDisplaySubtitle(group, meta);
318+ return showOwnerLabels ? `${ownerLabel(group.owner)} · ${subtitle}` : subtitle;
319+};
320+
321+const integrationMetaFor = (
322+ group: ToolkitConnectionGroup,
323+ integrations: readonly Integration[],
324+ integrationPlugins: readonly IntegrationPlugin[],
325+): IntegrationMeta => {
326+ const integration = integrations.find((row) => String(row.slug) === group.integration);
327+ const fallbackName = group.integration
328+ .replace(/[_-]/g, " ")
329+ .replace(/\b\w/g, (character) => character.toUpperCase());
330+ const source = {
331+ id: group.integration,
332+ kind: integration?.kind ?? group.integration,
333+ name: integration?.name ?? fallbackName,
334+ url: integration?.displayUrl,
335+ };
336+ return {
337+ name: source.name,
338+ sourceId: group.integration,
339+ icon: integrationPresetIconUrl(source, integrationPlugins),
340+ ...(source.url ? { url: source.url } : {}),
341+ };
342+};
343+
344+type ConfiguredConnectionView = {
345+ readonly id: string;
346+ readonly title: string;
347+ readonly subtitle: string;
348+ readonly pattern: string;
349+ readonly sourceId: string;
350+ readonly icon?: string | null;
351+ readonly url?: string;
352+};
353+
354+const configuredConnectionViews = (
355+ connections: readonly ToolkitConnectionResponse[],
356+ connectionGroups: readonly ToolkitConnectionGroup[],
357+ integrations: readonly Integration[],
358+ integrationPlugins: readonly IntegrationPlugin[],
359+ showOwnerLabels: boolean,
360+): readonly ConfiguredConnectionView[] =>
361+ connections.map((connection) => {
362+ const group = connectionGroups.find((candidate) =>
363+ candidate.patterns.includes(connection.pattern),
364+ );
365+ if (!group) {
366+ return {
367+ id: connection.id,
368+ title: connection.pattern,
369+ subtitle: "Configured pattern",
370+ pattern: connection.pattern,
371+ sourceId: connection.pattern.split(".")[0] ?? "toolkit",
372+ };
373+ }
374+ const meta = integrationMetaFor(group, integrations, integrationPlugins);
375+ return {
376+ id: connection.id,
377+ title: connectionDisplayTitle(group, meta),
378+ subtitle: connectionDisplaySubtitleForHost(group, meta, showOwnerLabels),
379+ pattern: connection.pattern,
380+ sourceId: meta.sourceId,
381+ icon: meta.icon,
382+ ...(meta.url ? { url: meta.url } : {}),
383+ };
384+ });
385+
386+const legacyConnectionPolicyIds = (
387+ policies: readonly ToolkitPolicyResponse[],
388+ connectionGroups: readonly ToolkitConnectionGroup[],
389+ connections: readonly ToolkitConnectionResponse[],
390+): ReadonlySet<string> => {
391+ const persistedPatterns = new Set(connections.map((connection) => connection.pattern));
392+ const connectionPatterns = new Set(connectionGroups.flatMap((group) => group.patterns));
393+ return new Set(
394+ policies
395+ .filter(
396+ (policy) =>
397+ isLegacyConnectionPolicy(policy) &&
398+ connectionPatterns.has(policy.pattern) &&
399+ !persistedPatterns.has(policy.pattern),
400+ )
401+ .map((policy) => policy.id),
402+ );
403+};
404+
405+const configuredConnectionPatterns = (
406+ connections: readonly ToolkitConnectionResponse[],
407+ policies: readonly ToolkitPolicyResponse[],
408+ legacyPolicyIds: ReadonlySet<string>,
409+): ReadonlySet<string> =>
410+ new Set([
411+ ...connections.map((connection) => connection.pattern),
412+ ...policies.filter((policy) => legacyPolicyIds.has(policy.id)).map((policy) => policy.pattern),
413+ ]);
414+
415+function ToolkitConnectionIconStack(props: { connections: readonly ConfiguredConnectionView[] }) {
416+ const visibleConnections = props.connections.slice(0, 3);
417+ if (visibleConnections.length === 0) {
418+ return (
419+ <div
420+ className={cn(
421+ "flex size-9 shrink-0 items-center justify-center rounded-md border",
422+ neutralToolkitIconClass,
423+ )}
424+ >
425+ <BoxIcon className="size-4" />
426+ </div>
427+ );
428+ }
429+
430+ return (
431+ <div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border/70 bg-muted/20">
432+ <div className="flex -space-x-2">
433+ {visibleConnections.map((connection) => (
434+ <span
435+ key={connection.id}
436+ className="flex size-5 items-center justify-center rounded-sm border border-background bg-card shadow-xs"
437+ >
438+ <IntegrationFavicon
439+ icon={connection.icon}
440+ sourceId={connection.sourceId}
441+ url={connection.url}
442+ size={14}
443+ />
444+ </span>
445+ ))}
446+ </div>
447+ </div>
448+ );
449+}
450+
451+const connectionCountLabel = (count: number): string =>
452+ count === 0 ? "No connections" : `${count} ${count === 1 ? "connection" : "connections"}`;
453+
454+function ToolkitTile(props: { showOwnerLabels: boolean; toolkit: ToolkitResponse }) {
455+ const toolkit = props.toolkit;
456+ const tools = useAtomValue(toolsAllAtom);
457+ const integrations = useAtomValue(integrationsOptimisticAtom);
458+ const connections = useAtomValue(toolkitConnectionsAtom(toolkit.id));
459+ const integrationPlugins = useIntegrationPlugins();
460+ const visibleTools = useMemo(
461+ () =>
462+ AsyncResult.isSuccess(tools)
463+ ? (tools.value as readonly ToolRow[]).filter((tool) =>
464+ toolCanAppearInToolkit(toolkit, tool),
465+ )
466+ : [],
467+ [toolkit, tools],
468+ );
469+ const connectionGroups = useMemo(() => buildConnectionGroups(visibleTools), [visibleTools]);
470+ const connectionRows = AsyncResult.isSuccess(connections) ? connections.value.connections : [];
471+ const integrationRows = AsyncResult.isSuccess(integrations)
472+ ? (integrations.value as readonly Integration[])
473+ : [];
474+ const tileDataReady =
475+ AsyncResult.isSuccess(connections) &&
476+ AsyncResult.isSuccess(tools) &&
477+ AsyncResult.isSuccess(integrations);
478+ const configuredConnections = tileDataReady
479+ ? configuredConnectionViews(
480+ connectionRows,
481+ connectionGroups,
482+ integrationRows,
483+ integrationPlugins,
484+ props.showOwnerLabels,
485+ )
486+ : [];
487+ return (
488+ <Link
489+ to="/{-$orgSlug}/toolkits/$toolkitSlug"
490+ params={{ toolkitSlug: toolkit.slug }}
491+ aria-label={`Open toolkit ${toolkit.name}`}
492+ className="group flex min-h-36 min-w-0 self-start flex-col justify-between rounded-md border border-border/70 bg-card p-3.5 text-left text-card-foreground shadow-xs transition-[border-color,background-color,box-shadow] hover:border-foreground/25 hover:bg-muted/20 hover:shadow-sm focus-visible:ring-[3px] focus-visible:ring-ring/30 focus-visible:outline-none"
493+ style={toolkitCardStyle}
494+ >
495+ <div className="flex min-w-0 items-start gap-3">
496+ {tileDataReady ? (
497+ <ToolkitConnectionIconStack connections={configuredConnections} />
498+ ) : (
499+ <Skeleton className="size-9 shrink-0 rounded-md" />
500+ )}
501+ <div className="min-w-0 flex-1">
502+ <div className="truncate text-sm font-semibold text-foreground">{toolkit.name}</div>
503+ <div className="mt-1 truncate text-xs text-muted-foreground">
504+ {tileDataReady ? connectionCountLabel(configuredConnections.length) : "Loading"}
505+ </div>
506+ </div>
507+ </div>
508+ </Link>
509+ );
510+}
511+
512+const addToolkitScopeLabel = (owner: Owner, showOwnerLabels: boolean): string =>
513+ showOwnerLabels ? (owner === "org" ? "workspace" : "personal") : "";
514+const addToolkitTitle = (owner: Owner, showOwnerLabels: boolean): string =>
515+ showOwnerLabels
516+ ? owner === "org"
517+ ? "New workspace toolkit"
518+ : "New personal toolkit"
519+ : "New toolkit";
520+
521+function CreateToolkitDialog(props: {
522+ owner: Owner;
523+ showOwnerLabels: boolean;
524+ open: boolean;
525+ onOpenChange: (open: boolean) => void;
526+ onCreate: (input: { owner: Owner; name: string }) => Promise<void>;
527+}) {
528+ const inputId = useId();
529+ const [name, setName] = useState("");
530+ const trimmed = name.trim();
531+ const title = addToolkitTitle(props.owner, props.showOwnerLabels);
532+
533+ const submit = async () => {
534+ if (!trimmed) return;
535+ await props.onCreate({ owner: props.owner, name: trimmed });
536+ setName("");
537+ props.onOpenChange(false);
538+ };
539+
540+ return (
541+ <Dialog
542+ open={props.open}
543+ onOpenChange={(nextOpen) => {
544+ props.onOpenChange(nextOpen);
545+ if (!nextOpen) setName("");
546+ }}
547+ >
548+ <DialogContent
549+ className="flex flex-col gap-0 overflow-hidden rounded-md border-border/80 bg-card p-0 shadow-2xl"
550+ style={{ width: "min(24rem, calc(100vw - 2rem))" }}
551+ >
552+ <form
553+ className="grid"
554+ onSubmit={(event) => {
555+ event.preventDefault();
556+ void submit();
557+ }}
558+ >
559+ <DialogHeader className="px-4 pb-1 pt-4">
560+ <DialogTitle className="text-sm">{title}</DialogTitle>
561+ <DialogDescription className="text-xs">
562+ Group tools and expose them at a dedicated MCP URL.
563+ </DialogDescription>
564+ </DialogHeader>
565+ <div className="grid gap-1.5 px-4 py-3">
566+ <Label htmlFor={inputId} className="text-xs text-muted-foreground">
567+ Toolkit name
568+ </Label>
569+ <Input
570+ id={inputId}
571+ value={name}
572+ onChange={(event) => setName(event.target.value)}
573+ placeholder="e.g. Support tools"
574+ autoFocus
575+ className="h-9"
576+ />
577+ </div>
578+ <DialogFooter className="px-4 pb-4 pt-1">
579+ <Button
580+ type="submit"
581+ size="sm"
582+ disabled={!trimmed}
583+ aria-label="Create toolkit"
584+ className="h-8"
585+ >
586+ Create toolkit
587+ </Button>
588+ </DialogFooter>
589+ </form>
590+ </DialogContent>
591+ </Dialog>
592+ );
593+}
594+
595+function AddToolkitCard(props: { owner: Owner; showOwnerLabels: boolean; onClick: () => void }) {
596+ const scopeLabel = addToolkitScopeLabel(props.owner, props.showOwnerLabels);
597+ return (
598+ <Button
599+ type="button"
600+ variant="ghost"
601+ onClick={props.onClick}
602+ aria-label={scopeLabel ? `Add ${scopeLabel} toolkit` : "Add toolkit"}
603+ className="group flex h-auto min-h-36 min-w-0 self-start items-center justify-center rounded-md border border-dashed border-border/75 bg-card/40 p-0 text-muted-foreground transition-[border-color,background-color,box-shadow,color] hover:border-foreground/25 hover:bg-muted/20 hover:text-foreground hover:shadow-sm focus-visible:ring-[3px] focus-visible:ring-ring/30 focus-visible:outline-none"
604+ style={toolkitCardStyle}
605+ >
606+ <span
607+ className={cn(
608+ "flex size-12 items-center justify-center rounded-md border transition-[border-color,background-color,color,transform]",
609+ neutralToolkitIconClass,
610+ "group-hover:scale-105",
611+ )}
612+ >
613+ <PlusIcon className="size-6" />
614+ </span>
615+ </Button>
616+ );
617+}
618+
619+function ToolkitSection(props: {
620+ owner: Owner;
621+ title?: string;
622+ showOwnerLabels: boolean;
623+ toolkits: readonly ToolkitResponse[];
624+ onCreate: (input: { owner: Owner; name: string }) => Promise<void>;
625+}) {
626+ const rows = [...props.toolkits].sort(compareToolkitRows);
627+ const [createOpen, setCreateOpen] = useState(false);
628+ const openCreate = () => setCreateOpen(true);
629+ return (
630+ <section className="space-y-3">
631+ {props.title ? (
632+ <div className="border-b border-border/60 pb-2">
633+ <h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
634+ {props.title}
635+ </h2>
636+ </div>
637+ ) : null}
638+
639+ <div
640+ className="grid content-start grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3"
641+ style={toolkitShelfStyle}
642+ >
643+ {rows.map((toolkit) => (
644+ <ToolkitTile key={toolkit.id} showOwnerLabels={props.showOwnerLabels} toolkit={toolkit} />
645+ ))}
646+ <AddToolkitCard
647+ owner={props.owner}
648+ showOwnerLabels={props.showOwnerLabels}
649+ onClick={openCreate}
650+ />
651+ </div>
652+
653+ <CreateToolkitDialog
654+ owner={props.owner}
655+ showOwnerLabels={props.showOwnerLabels}
656+ open={createOpen}
657+ onOpenChange={setCreateOpen}
658+ onCreate={props.onCreate}
659+ />
660+ </section>
661+ );
662+}
663+
664+function ToolkitGrid(props: {
665+ toolkits: readonly ToolkitResponse[];
666+ onCreate: (input: { owner: Owner; name: string }) => Promise<void>;
667+}) {
668+ const ownerDisplay = useOwnerDisplay();
669+ // Toolkit shelves are grouped by owner; the selected toolkit owns the page.
670+ const workspaceToolkits = props.toolkits.filter((toolkit) => toolkit.owner === "org");
671+ const personalToolkits = props.toolkits.filter((toolkit) => toolkit.owner === "user");
672+ if (!ownerDisplay.showOwnerLabels) {
673+ return (
674+ <div className="min-h-0 flex-1 overflow-y-auto">
675+ <div className="w-full space-y-7 px-4 py-4" style={toolkitGridContainerStyle}>
676+ <ToolkitSection
677+ owner="org"
678+ showOwnerLabels={false}
679+ toolkits={props.toolkits}
680+ onCreate={props.onCreate}
681+ />
682+ </div>
683+ </div>
684+ );
685+ }
686+
687+ return (
688+ <div className="min-h-0 flex-1 overflow-y-auto">
689+ <div className="w-full space-y-7 px-4 py-4" style={toolkitGridContainerStyle}>
690+ <ToolkitSection
691+ owner="org"
692+ showOwnerLabels
693+ title="Workspace"
694+ toolkits={workspaceToolkits}
695+ onCreate={props.onCreate}
696+ />
697+ <ToolkitSection
698+ owner="user"
699+ showOwnerLabels
700+ title="Personal"
701+ toolkits={personalToolkits}
702+ onCreate={props.onCreate}
703+ />
704+ </div>
705+ </div>
706+ );
707+}
708+
709+function ToolkitContentsEmpty(props: { onManageConnections: () => void }) {
710+ return (
711+ <div className="flex h-full items-center justify-center px-6 py-10">
712+ <div className="max-w-sm text-center">
713+ <div className="mx-auto flex size-11 items-center justify-center rounded-md border border-border/70 bg-muted/30 text-muted-foreground">
714+ <PlugIcon className="size-5" />
715+ </div>
716+ <h3 className="mt-4 text-sm font-semibold text-foreground">No connections added</h3>
717+ <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
718+ Add a connection to decide what this toolkit exposes.
719+ </p>
720+ <Button type="button" size="sm" onClick={props.onManageConnections} className="mt-4">
721+ Manage connections
722+ </Button>
723+ </div>
724+ </div>
725+ );
726+}
727+
728+function ToolkitToolsPanel(props: {
729+ tools: readonly ToolSummary[];
730+ selectedToolId: string | null;
731+ policies: readonly ToolkitPolicyResponse[];
732+ onManageConnections: () => void;
733+ onSelectTool: (toolId: string) => void;
734+ onSetPolicy: (pattern: string, action: ToolPolicyAction) => void;
735+ onClearPolicy: (pattern: string) => void;
736+}) {
737+ return (
738+ <div
739+ role="region"
740+ aria-label="Toolkit tools"
741+ className="flex min-h-0 shrink-0 flex-col border-r border-border/60"
742+ style={toolkitToolTreeStyle}
743+ >
744+ {props.tools.length === 0 ? (
745+ <ToolkitContentsEmpty onManageConnections={props.onManageConnections} />
746+ ) : (
747+ <ToolTree
748+ tools={props.tools}
749+ selectedToolId={props.selectedToolId}
750+ onSelect={props.onSelectTool}
751+ onSetPolicy={props.onSetPolicy}
752+ onClearPolicy={props.onClearPolicy}
753+ patternForDisplay={identityPattern}
754+ policies={props.policies}
755+ groupByConnection
756+ />
757+ )}
758+ </div>
759+ );
760+}
761+
762+function AddConnectionDialog(props: {
763+ open: boolean;
764+ groups: readonly ToolkitConnectionGroup[];
765+ configuredConnections: readonly ConfiguredConnectionView[];
766+ hiddenPersonalConnectionCount: number;
767+ integrations: readonly Integration[];
768+ integrationPlugins: readonly IntegrationPlugin[];
769+ showOwnerLabels: boolean;
770+ onOpenChange: (open: boolean) => void;
771+ onAddPatterns: (patterns: readonly string[]) => Promise<void> | void;
772+ onRemoveConnection: (connectionId: string) => Promise<void> | void;
773+}) {
774+ const searchId = useId();
775+ const [query, setQuery] = useState("");
776+ const trimmedQuery = query.trim().toLowerCase();
777+ const filteredGroups = useMemo(() => {
778+ if (!trimmedQuery) return props.groups;
779+ return props.groups.filter((group) => {
780+ const corpus = [
781+ connectionTitle(group),
782+ ...group.patterns,
783+ ownerLabel(group.owner),
784+ ...group.tools.flatMap((tool) => [tool.name, tool.description ?? ""]),
785+ ]
786+ .join(" ")
787+ .toLowerCase();
788+ return corpus.includes(trimmedQuery);
789+ });
790+ }, [props.groups, trimmedQuery]);
791+
792+ const addConnection = async (patterns: readonly string[]) => {
793+ await props.onAddPatterns(patterns);
794+ };
795+
796+ const removeConnection = async (connectionId: string) => {
797+ await props.onRemoveConnection(connectionId);
798+ };
799+
800+ return (
801+ <Dialog
802+ open={props.open}
803+ onOpenChange={(nextOpen) => {
804+ props.onOpenChange(nextOpen);
805+ if (!nextOpen) {
806+ setQuery("");
807+ }
808+ }}
809+ >
810+ <DialogContent
811+ className="gap-0 overflow-hidden rounded-md border-border/80 bg-card p-0 shadow-2xl"
812+ style={{
813+ width: "min(36rem, calc(100vw - 2rem))",
814+ maxWidth: "min(36rem, calc(100vw - 2rem))",
815+ maxHeight: "min(42rem, calc(100vh - 4rem))",
816+ }}
817+ >
818+ <DialogHeader className="border-b border-border/60 px-5 py-4">
819+ <div className="flex items-start gap-3">
820+ <span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border/70 bg-muted/30 text-muted-foreground">
821+ <PlugIcon className="size-4" />
822+ </span>
823+ <div className="min-w-0">
824+ <DialogTitle className="text-sm">Manage connections</DialogTitle>
825+ <DialogDescription className="mt-1 text-xs">
826+ Choose which connected accounts this toolkit can use.
827+ </DialogDescription>
828+ </div>
829+ </div>
830+ </DialogHeader>
831+
832+ <div className="flex min-h-0 flex-col overflow-hidden">
833+ <div className="border-b border-border/50 p-3">
834+ <Label htmlFor={searchId} className="sr-only">
835+ Search connections and tools
836+ </Label>
837+ <div className="flex items-center gap-2 rounded-md border border-border/70 bg-muted/20 px-3 transition-colors focus-within:border-primary/35 focus-within:bg-muted/25 focus-within:ring-1 focus-within:ring-primary/15">
838+ <SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
839+ <Input
840+ id={searchId}
841+ value={query}
842+ onChange={(event) => setQuery(event.target.value)}
843+ placeholder="Search connections"
844+ className="h-8 min-w-0 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0"
845+ />
846+ </div>
847+ {props.hiddenPersonalConnectionCount > 0 ? (
848+ <p className="mt-2 px-1 text-xs leading-5 text-muted-foreground">
849+ You have {props.hiddenPersonalConnectionCount} personal{" "}
850+ {props.hiddenPersonalConnectionCount === 1 ? "connection" : "connections"} that{" "}
851+ {props.hiddenPersonalConnectionCount === 1 ? "is" : "are"} not shown because this is
852+ a shared toolkit.
853+ </p>
854+ ) : null}
855+ </div>
856+ <div className="min-h-0 overflow-y-auto p-3" style={{ maxHeight: "32rem" }}>
857+ <div className="mb-2 flex items-center justify-between px-1">
858+ <span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
859+ Connections
860+ </span>
861+ <span className="text-xs tabular-nums text-muted-foreground">
862+ {filteredGroups.length}
863+ </span>
864+ </div>
865+ {filteredGroups.length === 0 ? (
866+ <div className="rounded-md border border-dashed border-border/70 px-4 py-8 text-center text-xs text-muted-foreground">
867+ No connections match.
868+ </div>
869+ ) : (
870+ <div className="overflow-hidden rounded-md border border-border/70 bg-background/30">
871+ {filteredGroups.map((group) => {
872+ const title = connectionTitleForHost(group, props.showOwnerLabels);
873+ const configuredConnection = props.configuredConnections.find((connection) =>
874+ group.patterns.includes(connection.pattern),
875+ );
876+ const added = configuredConnection !== undefined;
877+ const meta = integrationMetaFor(
878+ group,
879+ props.integrations,
880+ props.integrationPlugins,
881+ );
882+ return (
883+ <div
884+ key={group.id}
885+ className="flex min-w-0 items-center gap-3 border-b border-border/50 px-3 py-2.5 last:border-b-0 hover:bg-muted/20"
886+ >
887+ <span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border/70 bg-muted/30">
888+ <IntegrationFavicon
889+ icon={meta.icon}
890+ sourceId={meta.sourceId}
891+ url={meta.url}
892+ size={18}
893+ />
894+ </span>
895+ <div className="min-w-0 flex-1 space-y-1">
896+ <div className="flex min-w-0 items-center gap-2">
897+ <span className="truncate text-xs font-medium text-foreground">
898+ {connectionDisplayTitle(group, meta)}
899+ </span>
900+ {props.showOwnerLabels ? (
901+ <Badge variant="outline" className="shrink-0 text-[10px]">
902+ {ownerLabel(group.owner)}
903+ </Badge>
904+ ) : null}
905+ </div>
906+ <div className="flex min-w-0 items-center gap-2">
907+ <span className="truncate text-[11px] text-muted-foreground">
908+ {connectionDisplaySubtitle(group, meta)}
909+ </span>
910+ </div>
911+ </div>
912+ <Button
913+ type="button"
914+ variant={added ? "outline" : "default"}
915+ size="sm"
916+ aria-label={`${added ? "Remove connection" : "Add connection"} ${title}`}
917+ onClick={() =>
918+ void (added && configuredConnection
919+ ? removeConnection(configuredConnection.id)
920+ : addConnection(group.patterns))
921+ }
922+ className={cn(
923+ "h-7 shrink-0 px-2.5 text-xs",
924+ added
925+ ? "border-border/70 bg-transparent text-muted-foreground hover:text-destructive"
926+ : "bg-primary/10 text-primary hover:bg-primary/15",
927+ )}
928+ >
929+ {added ? (
930+ <Trash2Icon className="size-3.5" />
931+ ) : (
932+ <PlusIcon className="size-3.5" />
933+ )}
934+ {added ? "Remove" : "Add"}
935+ </Button>
936+ </div>
937+ );
938+ })}
939+ </div>
940+ )}
941+ </div>
942+ </div>
943+ </DialogContent>
944+ </Dialog>
945+ );
946+}
947+
948+function ToolkitHeader(props: {
949+ toolkit: ToolkitResponse;
950+ showOwnerLabels: boolean;
951+ mcpUrl: string;
952+ onBack: () => void;
953+ onManageConnections: () => void;
954+ onRemove: () => void;
955+}) {
956+ return (
957+ <div className="shrink-0 border-b border-border/60 bg-background/95 px-5 py-3">
958+ <div className="flex min-w-0 flex-wrap items-start justify-between gap-x-6 gap-y-3">
959+ <div className="min-w-0 flex-1">
960+ <Button
961+ type="button"
962+ variant="ghost"
963+ size="xs"
964+ onClick={props.onBack}
965+ className="-ml-1 h-6 px-1 text-xs text-muted-foreground hover:text-foreground"
966+ >
967+ <ArrowLeftIcon className="size-3.5" />
968+ Toolkits
969+ </Button>
970+ <div className="mt-1 flex min-w-0 items-center gap-2">
971+ <h2 className="truncate text-sm font-semibold text-foreground">{props.toolkit.name}</h2>
972+ {props.showOwnerLabels ? (
973+ <Badge variant="outline" className="text-[10px]">
974+ {ownerLabel(props.toolkit.owner)}
975+ </Badge>
976+ ) : null}
977+ </div>
978+ <div className="mt-2 flex max-w-3xl min-w-0 items-center gap-2 rounded-md bg-muted/20 px-2 py-1 text-xs text-muted-foreground ring-1 ring-border/40">
979+ <span className="shrink-0 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/70">
980+ MCP
981+ </span>
982+ <code className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">
983+ {props.mcpUrl}
984+ </code>
985+ <CopyButton value={props.mcpUrl} className="-mr-1" />
986+ </div>
987+ </div>
988+ <div className="flex shrink-0 items-center gap-1 pt-0.5">
989+ <Button
990+ type="button"
991+ variant="ghost"
992+ size="sm"
993+ aria-label="Manage toolkit connections"
994+ onClick={props.onManageConnections}
995+ className="h-8 text-muted-foreground hover:text-foreground"
996+ >
997+ <PlugIcon className="size-3.5" />
998+ Connections
999+ </Button>
1000+ <AlertDialog>
1001+ <AlertDialogTrigger asChild>
1002+ <Button
1003+ type="button"
1004+ variant="ghost"
1005+ size="icon-sm"
1006+ aria-label="Delete toolkit"
1007+ className="text-muted-foreground hover:text-destructive"
1008+ >
1009+ <Trash2Icon className="size-3.5" />
1010+ </Button>
1011+ </AlertDialogTrigger>
1012+ <AlertDialogContent size="sm">
1013+ <AlertDialogHeader>
1014+ <AlertDialogTitle>Delete {props.toolkit.name}?</AlertDialogTitle>
1015+ <AlertDialogDescription>
1016+ This removes the toolkit and its dedicated MCP endpoint. Connections are not
1017+ deleted.
1018+ </AlertDialogDescription>
1019+ </AlertDialogHeader>
1020+ <AlertDialogFooter>
1021+ <AlertDialogCancel>Cancel</AlertDialogCancel>
1022+ <AlertDialogAction variant="destructive" onClick={props.onRemove}>
1023+ Delete
1024+ </AlertDialogAction>
1025+ </AlertDialogFooter>
1026+ </AlertDialogContent>
1027+ </AlertDialog>
1028+ </div>
1029+ </div>
1030+ </div>
1031+ );
1032+}
1033+
1034+function ToolkitWorkspace(props: {
1035+ toolkit: ToolkitResponse;
1036+ showOwnerLabels: boolean;
1037+ policies: readonly ToolkitPolicyResponse[];
1038+ connections: readonly ToolkitConnectionResponse[];
1039+ tools: readonly ToolRow[];
1040+ integrations: readonly Integration[];
1041+ integrationPlugins: readonly IntegrationPlugin[];
1042+ mcpUrl: string;
1043+ onBack: () => void;
1044+ onRemoveToolkit: () => void;
1045+ onAddConnection: (pattern: string) => Promise<void> | void;
1046+ onRemoveConnection: (connectionId: string) => Promise<void> | void;
1047+ onSetPolicy: (pattern: string, action: ToolPolicyAction) => Promise<void> | void;
1048+ onClearPolicy: (pattern: string) => Promise<void> | void;
1049+}) {
1050+ const [addOpen, setAddOpen] = useState(false);
1051+ const [selectedToolId, setSelectedToolId] = useState<string | null>(null);
1052+ const visibleTools = useMemo(
1053+ () => props.tools.filter((tool) => toolCanAppearInToolkit(props.toolkit, tool)),
1054+ [props.toolkit, props.tools],
1055+ );
1056+ const connectionGroups = useMemo(() => buildConnectionGroups(visibleTools), [visibleTools]);
1057+ const hiddenPersonalConnectionCount = useMemo(() => {
1058+ if (props.toolkit.owner !== "org") return 0;
1059+ return buildConnectionGroups(props.tools.filter((tool) => toolOwner(tool) === "user")).length;
1060+ }, [props.toolkit.owner, props.tools]);
1061+ const configuredConnections = useMemo(
1062+ () =>
1063+ configuredConnectionViews(
1064+ props.connections,
1065+ connectionGroups,
1066+ props.integrations,
1067+ props.integrationPlugins,
1068+ props.showOwnerLabels,
1069+ ),
1070+ [
1071+ connectionGroups,
1072+ props.connections,
1073+ props.integrationPlugins,
1074+ props.integrations,
1075+ props.showOwnerLabels,
1076+ ],
1077+ );
1078+ const legacyPolicyIds = useMemo(
1079+ () => legacyConnectionPolicyIds(props.policies, connectionGroups, props.connections),
1080+ [connectionGroups, props.connections, props.policies],
1081+ );
1082+ const accessPolicies = useMemo(
1083+ () => props.policies.filter((policy) => !legacyPolicyIds.has(policy.id)),
1084+ [legacyPolicyIds, props.policies],
1085+ );
1086+ const connectionPatterns = useMemo(
1087+ () => configuredConnectionPatterns(props.connections, props.policies, legacyPolicyIds),
1088+ [legacyPolicyIds, props.connections, props.policies],
1089+ );
1090+ const configuredToolIds = useMemo(() => {
1091+ const ids = new Set<string>();
1092+ for (const tool of visibleTools) {
1093+ const id = toolMatchId(tool);
1094+ if ([...connectionPatterns].some((pattern) => matchPattern(pattern, id))) ids.add(id);
1095+ }
1096+ return ids;
1097+ }, [connectionPatterns, visibleTools]);
1098+ const configuredTools = useMemo(
1099+ () =>
1100+ visibleTools.filter((tool) => configuredToolIds.has(toolMatchId(tool))).sort(compareTools),
1101+ [visibleTools, configuredToolIds],
1102+ );
1103+ const toolkitTools: ToolSummary[] = useMemo(
1104+ () =>
1105+ configuredTools.map((tool) => {
1106+ const id = toolMatchId(tool);
1107+ return {
1108+ id,
1109+ name: id,
1110+ description: tool.description,
1111+ policy: resolveToolkitPolicy(id, accessPolicies, tool.requiresApproval),
1112+ owner: toolOwner(tool),
1113+ connection: toolConnectionName(tool),
1114+ integration: String(tool.integration),
1115+ };
1116+ }),
1117+ [accessPolicies, configuredTools],
1118+ );
1119+ const selectedTool = selectedToolId
1120+ ? (configuredTools.find((tool) => toolMatchId(tool) === selectedToolId) ?? null)
1121+ : null;
1122+ const selectedToolPolicy = selectedTool
1123+ ? (toolkitTools.find((tool) => tool.id === selectedToolId)?.policy ?? null)
1124+ : null;
1125+
1126+ return (
1127+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
1128+ <ToolkitHeader
1129+ toolkit={props.toolkit}
1130+ showOwnerLabels={props.showOwnerLabels}
1131+ mcpUrl={props.mcpUrl}
1132+ onBack={props.onBack}
1133+ onManageConnections={() => setAddOpen(true)}
1134+ onRemove={props.onRemoveToolkit}
1135+ />
1136+
1137+ <div className="flex min-h-0 flex-1 overflow-hidden">
1138+ <ToolkitToolsPanel
1139+ tools={toolkitTools}
1140+ selectedToolId={selectedToolId}
1141+ policies={accessPolicies}
1142+ onManageConnections={() => setAddOpen(true)}
1143+ onSelectTool={setSelectedToolId}
1144+ onSetPolicy={(pattern, action) => void props.onSetPolicy(pattern, action)}
1145+ onClearPolicy={(pattern) => void props.onClearPolicy(pattern)}
1146+ />
1147+ <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
1148+ {selectedTool && selectedToolPolicy ? (
1149+ <ToolDetail
1150+ address={selectedTool.address}
1151+ toolName={toolMatchId(selectedTool)}
1152+ staticTool={selectedTool.static === true}
1153+ policy={selectedToolPolicy}
1154+ onSetPolicy={props.onSetPolicy}
1155+ onClearPolicy={props.onClearPolicy}
1156+ patternForDisplay={identityPattern}
1157+ />
1158+ ) : (
1159+ <ToolDetailEmpty hasTools={toolkitTools.length > 0} />
1160+ )}
1161+ </div>
1162+ </div>
1163+
1164+ <AddConnectionDialog
1165+ open={addOpen}
1166+ onOpenChange={setAddOpen}
1167+ groups={connectionGroups}
1168+ configuredConnections={configuredConnections}
1169+ hiddenPersonalConnectionCount={hiddenPersonalConnectionCount}
1170+ integrations={props.integrations}
1171+ integrationPlugins={props.integrationPlugins}
1172+ showOwnerLabels={props.showOwnerLabels}
1173+ onAddPatterns={async (patterns) => {
1174+ for (const pattern of patterns) {
1175+ await props.onAddConnection(pattern);
1176+ }
1177+ }}
1178+ onRemoveConnection={props.onRemoveConnection}
1179+ />
1180+ </div>
1181+ );
1182+}
1183+
1184+function ToolkitTileSkeleton(props: { index: number }) {
1185+ return (
1186+ <div
1187+ className="flex min-h-36 min-w-0 items-start gap-3 rounded-md border border-border/60 bg-card/35 p-3.5"
1188+ style={toolkitCardStyle}
1189+ aria-hidden="true"
1190+ >
1191+ <Skeleton className="size-9 shrink-0 rounded-md" />
1192+ <div className="min-w-0 flex-1 space-y-2 pt-0.5">
1193+ <Skeleton className="h-4" style={{ width: `${52 + ((props.index * 17) % 28)}%` }} />
1194+ <Skeleton className="h-3" style={{ width: `${34 + ((props.index * 11) % 24)}%` }} />
1195+ </div>
1196+ </div>
1197+ );
1198+}
1199+
1200+function ToolkitSectionSkeleton(props: { title?: string; offset: number }) {
1201+ return (
1202+ <section className="space-y-3" aria-label="Loading toolkits">
1203+ {props.title ? (
1204+ <div className="border-b border-border/60 pb-2">
1205+ <h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
1206+ {props.title}
1207+ </h2>
1208+ </div>
1209+ ) : null}
1210+
1211+ <div className="grid content-start grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
1212+ {Array.from({ length: 3 }).map((_, index) => (
1213+ <ToolkitTileSkeleton key={index} index={props.offset + index} />
1214+ ))}
1215+ </div>
1216+ </section>
1217+ );
1218+}
1219+
1220+function ToolkitGridSkeleton(props: { showOwnerLabels: boolean }) {
1221+ return (
1222+ <div className="min-h-0 flex-1 overflow-y-auto">
1223+ <div className="w-full space-y-7 px-4 py-4" style={toolkitGridContainerStyle}>
1224+ {props.showOwnerLabels ? (
1225+ <>
1226+ <ToolkitSectionSkeleton title="Workspace" offset={0} />
1227+ <ToolkitSectionSkeleton title="Personal" offset={3} />
1228+ </>
1229+ ) : (
1230+ <ToolkitSectionSkeleton offset={0} />
1231+ )}
1232+ </div>
1233+ </div>
1234+ );
1235+}
1236+
1237+function ToolkitDetailSkeleton() {
1238+ return (
1239+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
1240+ <div className="shrink-0 border-b border-border/60 bg-background/95 px-4 py-3">
1241+ <Skeleton className="mb-3 h-7 w-20 rounded-md" />
1242+ <div className="space-y-2">
1243+ <div className="flex min-w-0 items-center gap-2">
1244+ <Skeleton className="h-4 w-40" />
1245+ <Skeleton className="h-5 w-16 rounded-full" />
1246+ <Skeleton className="h-3 w-12" />
1247+ </div>
1248+ <Skeleton className="h-6 w-full max-w-md" />
1249+ </div>
1250+ </div>
1251+
1252+ <div className="flex min-h-0 flex-1 overflow-hidden">
1253+ <div
1254+ className="flex min-h-0 shrink-0 flex-col border-r border-border/60"
1255+ style={toolkitToolTreeStyle}
1256+ >
1257+ <div className="border-b border-border/60 px-3 py-3">
1258+ <div className="flex items-center justify-between gap-3">
1259+ <div className="space-y-2">
1260+ <Skeleton className="h-3 w-28" />
1261+ <Skeleton className="h-3 w-14" />
1262+ </div>
1263+ <Skeleton className="h-8 w-16 rounded-md" />
1264+ </div>
1265+ </div>
1266+ <div className="space-y-1 p-3">
1267+ {Array.from({ length: 9 }).map((_, index) => (
1268+ <div key={index} className="flex items-center gap-2 rounded-md px-2 py-1.5">
1269+ <Skeleton className="size-4 shrink-0 rounded" />
1270+ <Skeleton className="h-3.5" style={{ width: `${48 + ((index * 13) % 38)}%` }} />
1271+ </div>
1272+ ))}
1273+ </div>
1274+ </div>
1275+ <div className="flex min-w-0 flex-1 flex-col gap-5 overflow-hidden p-6">
1276+ <div className="space-y-2">
1277+ <Skeleton className="h-5 w-52" />
1278+ <Skeleton className="h-4 w-full max-w-lg" />
1279+ <Skeleton className="h-4 w-full max-w-sm" />
1280+ </div>
1281+ <div className="space-y-3">
1282+ <Skeleton className="h-4 w-24" />
1283+ <Skeleton className="h-24 w-full rounded-md" />
1284+ <Skeleton className="h-4 w-28" />
1285+ <Skeleton className="h-24 w-full rounded-md" />
1286+ </div>
1287+ </div>
1288+ </div>
1289+ </div>
1290+ );
1291+}
1292+
1293+function ToolkitDetailView(props: {
1294+ toolkit: ToolkitResponse;
1295+ showOwnerLabels: boolean;
1296+ tools: readonly ToolRow[];
1297+ integrations: readonly Integration[];
1298+ integrationPlugins: readonly IntegrationPlugin[];
1299+ orgSlug?: string;
1300+ onBack: () => void;
1301+ onRemoveToolkit: (toolkit: ToolkitResponse) => void;
1302+}) {
1303+ const policies = useAtomValue(toolkitPoliciesAtom(props.toolkit.id));
1304+ const connections = useAtomValue(toolkitConnectionsAtom(props.toolkit.id));
1305+ const doCreatePolicy = useAtomSet(createToolkitPolicy, { mode: "promiseExit" });
1306+ const doUpdatePolicy = useAtomSet(updateToolkitPolicy, { mode: "promiseExit" });
1307+ const doRemovePolicy = useAtomSet(removeToolkitPolicy, { mode: "promiseExit" });
1308+ const doCreateConnection = useAtomSet(createToolkitConnection, { mode: "promiseExit" });
1309+ const doRemoveConnection = useAtomSet(removeToolkitConnection, { mode: "promiseExit" });
1310+ const policyRows = AsyncResult.isSuccess(policies) ? policies.value.policies : [];
1311+ const connectionRows = AsyncResult.isSuccess(connections) ? connections.value.connections : [];
1312+
1313+ const setPolicyHandler = async (pattern: string, action: ToolPolicyAction) => {
1314+ const existing = policyRows.find((policy) => policy.pattern === pattern);
1315+ if (existing) {
1316+ await doUpdatePolicy({
1317+ params: { toolkitId: props.toolkit.id, policyId: existing.id },
1318+ payload: { action },
1319+ reactivityKeys: toolkitWriteKeys,
1320+ });
1321+ return;
1322+ }
1323+ await doCreatePolicy({
1324+ params: { toolkitId: props.toolkit.id },
1325+ payload: { pattern, action },
1326+ reactivityKeys: toolkitWriteKeys,
1327+ });
1328+ };
1329+
1330+ const addConnectionHandler = async (pattern: string) => {
1331+ await doCreateConnection({
1332+ params: { toolkitId: props.toolkit.id },
1333+ payload: { pattern },
1334+ reactivityKeys: toolkitWriteKeys,
1335+ });
1336+ };
1337+
1338+ const removeConnectionHandler = async (connectionId: string) => {
1339+ await doRemoveConnection({
1340+ params: { toolkitId: props.toolkit.id, connectionId },
1341+ reactivityKeys: toolkitWriteKeys,
1342+ });
1343+ };
1344+
1345+ const clearPolicyHandler = async (pattern: string) => {
1346+ const existing = policyRows.find((policy) => policy.pattern === pattern);
1347+ if (!existing) return;
1348+ await doRemovePolicy({
1349+ params: { toolkitId: props.toolkit.id, policyId: existing.id },
1350+ reactivityKeys: toolkitWriteKeys,
1351+ });
1352+ };
1353+
1354+ if (AsyncResult.isFailure(policies) || AsyncResult.isFailure(connections)) {
1355+ return <div className="p-6 text-sm text-destructive">Failed to load toolkit</div>;
1356+ }
1357+ if (!AsyncResult.isSuccess(policies) || !AsyncResult.isSuccess(connections)) {
1358+ return <ToolkitDetailSkeleton />;
1359+ }
1360+
1361+ return (
1362+ <ToolkitWorkspace
1363+ toolkit={props.toolkit}
1364+ showOwnerLabels={props.showOwnerLabels}
1365+ policies={policyRows}
1366+ connections={connectionRows}
1367+ tools={props.tools}
1368+ integrations={props.integrations}
1369+ integrationPlugins={props.integrationPlugins}
1370+ mcpUrl={toolkitUrlFor(props.orgSlug, props.toolkit.slug)}
1371+ onBack={props.onBack}
1372+ onRemoveToolkit={() => props.onRemoveToolkit(props.toolkit)}
1373+ onAddConnection={addConnectionHandler}
1374+ onRemoveConnection={removeConnectionHandler}
1375+ onSetPolicy={setPolicyHandler}
1376+ onClearPolicy={clearPolicyHandler}
1377+ />
1378+ );
1379+}
1380+
1381+export function ToolkitsPage(props: PluginPageProps) {
1382+ const navigate = useNavigate();
1383+ const organizationSlug = useOrganizationSlug();
1384+ const ownerDisplay = useOwnerDisplay();
1385+ const integrationPlugins = useIntegrationPlugins();
1386+ const toolkits = useAtomValue(toolkitsAtom);
1387+ const tools = useAtomValue(toolsAllAtom);
1388+ const integrations = useAtomValue(integrationsOptimisticAtom);
1389+ const doCreateToolkit = useAtomSet(createToolkit, { mode: "promiseExit" });
1390+ const doRemoveToolkit = useAtomSet(removeToolkit, { mode: "promiseExit" });
1391+ const selectedToolkitSlug = props.params.toolkitSlug ?? null;
1392+
1393+ const toolkitRows = AsyncResult.isSuccess(toolkits) ? toolkits.value.toolkits : [];
1394+ const selectedToolkit =
1395+ selectedToolkitSlug === null ? null : toolkitByRouteSlug(toolkitRows, selectedToolkitSlug);
1396+
1397+ const toolRows = AsyncResult.isSuccess(tools) ? (tools.value as readonly ToolRow[]) : [];
1398+ const integrationRows = AsyncResult.isSuccess(integrations)
1399+ ? (integrations.value as readonly Integration[])
1400+ : [];
1401+ const toolkitsReady = AsyncResult.isSuccess(toolkits);
1402+ const toolkitsFailed = AsyncResult.isFailure(toolkits);
1403+ const toolsReady = AsyncResult.isSuccess(tools);
1404+ const toolsFailed = AsyncResult.isFailure(tools);
1405+ const navigateToIndex = () =>
1406+ navigate({
1407+ to: "/{-$orgSlug}/toolkits",
1408+ });
1409+
1410+ const createToolkitHandler = async (input: { owner: Owner; name: string }) => {
1411+ await doCreateToolkit({
1412+ payload: input,
1413+ reactivityKeys: toolkitWriteKeys,
1414+ });
1415+ };
1416+
1417+ const removeToolkitHandler = async (toolkit: ToolkitResponse) => {
1418+ await doRemoveToolkit({
1419+ params: { toolkitId: toolkit.id },
1420+ reactivityKeys: toolkitWriteKeys,
1421+ });
1422+ await navigateToIndex();
1423+ };
1424+
1425+ return (
1426+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
1427+ {selectedToolkitSlug === null ? (
1428+ <div className="flex min-h-12 shrink-0 items-center gap-3 border-b border-border bg-background/95 px-4 py-2 backdrop-blur-sm">
1429+ <div className="flex min-w-0 items-center gap-3">
1430+ <h1 className="truncate text-sm font-semibold text-foreground">Toolkits</h1>
1431+ {AsyncResult.isSuccess(toolkits) && (
1432+ <span className="hidden text-xs tabular-nums text-muted-foreground sm:block">
1433+ {toolkitRows.length} {toolkitRows.length === 1 ? "toolkit" : "toolkits"}
1434+ </span>
1435+ )}
1436+ </div>
1437+ </div>
1438+ ) : null}
1439+
1440+ {selectedToolkitSlug !== null && !toolkitsReady ? (
1441+ toolkitsFailed ? (
1442+ <div className="p-6 text-sm text-destructive">Failed to load toolkit</div>
1443+ ) : (
1444+ <ToolkitDetailSkeleton />
1445+ )
1446+ ) : !toolkitsReady ? (
1447+ toolkitsFailed ? (
1448+ <div className="p-6 text-sm text-destructive">Failed to load toolkits</div>
1449+ ) : (
1450+ <ToolkitGridSkeleton showOwnerLabels={ownerDisplay.showOwnerLabels} />
1451+ )
1452+ ) : selectedToolkit ? (
1453+ toolsFailed ? (
1454+ <div className="p-6 text-sm text-destructive">Failed to load toolkit tools</div>
1455+ ) : !toolsReady ? (
1456+ <ToolkitDetailSkeleton />
1457+ ) : (
1458+ <ToolkitDetailView
1459+ toolkit={selectedToolkit}
1460+ showOwnerLabels={ownerDisplay.showOwnerLabels}
1461+ tools={toolRows}
1462+ integrations={integrationRows}
1463+ integrationPlugins={integrationPlugins}
1464+ orgSlug={organizationSlug ?? undefined}
1465+ onBack={() => void navigateToIndex()}
1466+ onRemoveToolkit={removeToolkitHandler}
1467+ />
1468+ )
1469+ ) : selectedToolkitSlug !== null ? (
1470+ <div className="flex min-h-0 flex-1 flex-col p-6">
1471+ <Button
1472+ type="button"
1473+ variant="ghost"
1474+ size="xs"
1475+ onClick={() => void navigateToIndex()}
1476+ className="mb-4 w-fit -ml-1 text-muted-foreground"
1477+ >
1478+ <ArrowLeftIcon className="size-3.5" />
1479+ Toolkits
1480+ </Button>
1481+ <div className="text-sm text-muted-foreground">Toolkit not found</div>
1482+ </div>
1483+ ) : (
1484+ <ToolkitGrid toolkits={toolkitRows} onCreate={createToolkitHandler} />
1485+ )}
1486+ </div>
1487+ );
1488+}
packages/plugins/toolkits/src/server.test.ts+168−0
@@ -0,0 +1,168 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Predicate, Result } from "effect";
3+import { makeTestExecutor } from "@executor-js/sdk/testing";
4+
5+import { toolkitsPlugin } from "./server";
6+
7+describe("toolkitsPlugin", () => {
8+ it.effect("creates toolkits and manages ordered policy rules", () =>
9+ Effect.gen(function* () {
10+ const executor = yield* makeTestExecutor({
11+ plugins: [toolkitsPlugin()] as const,
12+ });
13+
14+ const toolkit = yield* executor.toolkits.create({
15+ owner: "org",
16+ name: "Deploy Kit",
17+ });
18+ expect(toolkit.slug).toBe("deploy-kit");
19+
20+ const connection = yield* executor.toolkits.createConnection(toolkit.id, {
21+ pattern: "github.org.main.*",
22+ });
23+ const duplicateConnection = yield* executor.toolkits.createConnection(toolkit.id, {
24+ pattern: "github.org.main.*",
25+ });
26+ expect(duplicateConnection.id).toBe(connection.id);
27+
28+ const first = yield* executor.toolkits.createPolicy(toolkit.id, {
29+ pattern: "github.org.main.repos.*",
30+ action: "approve",
31+ });
32+ const second = yield* executor.toolkits.createPolicy(toolkit.id, {
33+ pattern: "github.*",
34+ action: "block",
35+ });
36+
37+ const policies = yield* executor.toolkits.listPolicies(toolkit.id);
38+ expect(policies.map((policy) => policy.id)).toEqual([second.id, first.id]);
39+
40+ yield* executor.toolkits.updatePolicy(toolkit.id, first.id, {
41+ action: "require_approval",
42+ });
43+ const rules = yield* executor.toolkits.policyRulesForSlug("deploy-kit");
44+ expect(rules.find((rule) => rule.id === first.id)?.action).toBe("require_approval");
45+
46+ const connections = yield* executor.toolkits.listConnections(toolkit.id);
47+ expect(connections.map((row) => row.pattern)).toEqual(["github.org.main.*"]);
48+ }),
49+ );
50+
51+ it.effect("rejects duplicate visible slugs", () =>
52+ Effect.gen(function* () {
53+ const executor = yield* makeTestExecutor({
54+ plugins: [toolkitsPlugin()] as const,
55+ });
56+ yield* executor.toolkits.create({ owner: "org", name: "Deploy Kit" });
57+
58+ const duplicate = yield* Effect.result(
59+ executor.toolkits.create({ owner: "user", name: "Deploy Kit" }),
60+ );
61+ expect(Result.isFailure(duplicate)).toBe(true);
62+ if (!Result.isFailure(duplicate)) return;
63+ expect(Predicate.isTagged("ToolkitError")(duplicate.failure)).toBe(true);
64+ }),
65+ );
66+
67+ it.effect("resolves toolkit policies with implicit deny and workspace owner limits", () =>
68+ Effect.gen(function* () {
69+ const executor = yield* makeTestExecutor({
70+ plugins: [toolkitsPlugin()] as const,
71+ });
72+
73+ const workspace = yield* executor.toolkits.create({
74+ owner: "org",
75+ name: "Workspace Kit",
76+ });
77+ yield* executor.toolkits.createConnection(workspace.id, {
78+ pattern: "github.org.main.*",
79+ });
80+
81+ const workspaceTool = yield* executor.toolkits.resolvePolicyForSlug(
82+ workspace.slug,
83+ "github.org.main.repos.list",
84+ );
85+ expect(workspaceTool.action).toBe("approve");
86+ expect(workspaceTool.source).toBe("plugin-default");
87+
88+ const defaultApprovalTool = yield* executor.toolkits.resolvePolicyForSlug(
89+ workspace.slug,
90+ "github.org.main.repos.delete",
91+ true,
92+ );
93+ expect(defaultApprovalTool.action).toBe("require_approval");
94+ expect(defaultApprovalTool.source).toBe("plugin-default");
95+
96+ yield* executor.toolkits.createPolicy(workspace.id, {
97+ pattern: "github.org.main.repos.delete",
98+ action: "approve",
99+ });
100+ const explicitTool = yield* executor.toolkits.resolvePolicyForSlug(
101+ workspace.slug,
102+ "github.org.main.repos.delete",
103+ true,
104+ );
105+ expect(explicitTool.action).toBe("approve");
106+ expect(explicitTool.source).toBe("user");
107+
108+ const personalTool = yield* executor.toolkits.resolvePolicyForSlug(
109+ workspace.slug,
110+ "github.user.main.repos.list",
111+ );
112+ expect(personalTool.action).toBe("block");
113+
114+ const missingTool = yield* executor.toolkits.resolvePolicyForSlug(
115+ workspace.slug,
116+ "slack.org.main.chat.post",
117+ );
118+ expect(missingTool.action).toBe("block");
119+
120+ const personal = yield* executor.toolkits.create({
121+ owner: "user",
122+ name: "Personal Kit",
123+ });
124+ yield* executor.toolkits.createConnection(personal.id, {
125+ pattern: "github.user.main.*",
126+ });
127+ const personalToolkitTool = yield* executor.toolkits.resolvePolicyForSlug(
128+ personal.slug,
129+ "github.user.main.repos.list",
130+ );
131+ expect(personalToolkitTool.action).toBe("approve");
132+ }),
133+ );
134+
135+ it.effect("treats a persisted connection-root approve as an access policy", () =>
136+ Effect.gen(function* () {
137+ const executor = yield* makeTestExecutor({
138+ plugins: [toolkitsPlugin()] as const,
139+ });
140+
141+ const toolkit = yield* executor.toolkits.create({
142+ owner: "org",
143+ name: "Core Tools Kit",
144+ });
145+ yield* executor.toolkits.createConnection(toolkit.id, {
146+ pattern: "executor.coreTools.*",
147+ });
148+ yield* executor.toolkits.createPolicy(toolkit.id, {
149+ pattern: "executor.coreTools.*",
150+ action: "approve",
151+ });
152+
153+ const result = yield* executor.toolkits.resolvePolicyForSlug(
154+ toolkit.slug,
155+ "executor.coreTools.connections.remove",
156+ true,
157+ );
158+ expect(result.action).toBe("approve");
159+ expect(result.source).toBe("user");
160+
161+ const rules = yield* executor.toolkits.policyRulesForSlug(toolkit.slug);
162+ expect(
163+ rules.map((rule) => `${rule.pattern} ${rule.action}`),
164+ "policy listing agrees with toolkit enforcement",
165+ ).toContain("executor.coreTools.* approve");
166+ }),
167+ );
168+});
packages/plugins/toolkits/src/server.ts+673−0
@@ -0,0 +1,673 @@
1+import {
2+ Context,
3+ definePlugin,
4+ definePluginStorageCollection,
5+ Effect,
6+ HttpApiBuilder,
7+ isValidPattern,
8+ matchPattern,
9+ Schema,
10+ type EffectivePolicy,
11+ type Owner,
12+ type PluginCtx,
13+ type PluginStorageFacade,
14+ type PluginStorageCollectionFacade,
15+ type StorageFailure,
16+ type ToolPolicyAction,
17+ type ToolPolicyProvider,
18+ type ToolPolicyProviderRule,
19+} from "@executor-js/sdk/core";
20+import { addGroup, capture } from "@executor-js/api";
21+import { generateKeyBetween } from "fractional-indexing";
22+
23+import { ToolkitError, ToolkitsApi } from "./shared";
24+
25+const ToolkitRecord = Schema.Struct({
26+ id: Schema.String,
27+ slug: Schema.String,
28+ name: Schema.String,
29+ createdAt: Schema.Number,
30+ updatedAt: Schema.Number,
31+});
32+type ToolkitRecord = typeof ToolkitRecord.Type;
33+
34+const ToolkitPolicyRecord = Schema.Struct({
35+ id: Schema.String,
36+ toolkitId: Schema.String,
37+ pattern: Schema.String,
38+ action: Schema.Literals(["approve", "require_approval", "block"]),
39+ position: Schema.String,
40+ createdAt: Schema.Number,
41+ updatedAt: Schema.Number,
42+});
43+type ToolkitPolicyRecord = typeof ToolkitPolicyRecord.Type;
44+
45+const ToolkitConnectionRecord = Schema.Struct({
46+ id: Schema.String,
47+ toolkitId: Schema.String,
48+ pattern: Schema.String,
49+ position: Schema.String,
50+ createdAt: Schema.Number,
51+ updatedAt: Schema.Number,
52+});
53+type ToolkitConnectionRecord = typeof ToolkitConnectionRecord.Type;
54+
55+const toolkitsCollection = definePluginStorageCollection("toolkits", ToolkitRecord, {
56+ indexes: ["slug", "name", "updatedAt"],
57+});
58+
59+const toolkitPoliciesCollection = definePluginStorageCollection(
60+ "toolkitPolicies",
61+ ToolkitPolicyRecord,
62+ {
63+ indexes: ["toolkitId", "pattern", "position", ["toolkitId", "position"]],
64+ },
65+);
66+
67+const toolkitConnectionsCollection = definePluginStorageCollection(
68+ "toolkitConnections",
69+ ToolkitConnectionRecord,
70+ {
71+ indexes: ["toolkitId", "pattern", "position", ["toolkitId", "position"]],
72+ },
73+);
74+
75+type ToolkitStorage = {
76+ readonly toolkits: PluginStorageCollectionFacade<typeof toolkitsCollection>;
77+ readonly policies: PluginStorageCollectionFacade<typeof toolkitPoliciesCollection>;
78+ readonly connections: PluginStorageCollectionFacade<typeof toolkitConnectionsCollection>;
79+};
80+
81+export interface ToolkitsPluginOptions {
82+ /** When set, this executor instance enforces only the named toolkit's rules. */
83+ readonly activeToolkitSlug?: string;
84+}
85+
86+const newId = (prefix: string): string =>
87+ `${prefix}_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
88+
89+const slugPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
90+
91+const slugify = (name: string): string =>
92+ name
93+ .trim()
94+ .toLowerCase()
95+ .replace(/[^a-z0-9]+/g, "-")
96+ .replace(/^-+|-+$/g, "")
97+ .slice(0, 63);
98+
99+const normalizeSlugEffect = (input: {
100+ readonly name: string;
101+ readonly slug?: string;
102+}): Effect.Effect<string, ToolkitError> => {
103+ const slug = (input.slug ?? slugify(input.name)).trim().toLowerCase();
104+ if (!slugPattern.test(slug)) {
105+ return Effect.fail(
106+ new ToolkitError({
107+ message:
108+ "Toolkit slug must be 1-63 lowercase letters, numbers, or hyphens, and cannot start or end with a hyphen.",
109+ }),
110+ );
111+ }
112+ return Effect.succeed(slug);
113+};
114+
115+const fail = (message: string): Effect.Effect<never, ToolkitError> =>
116+ Effect.fail(new ToolkitError({ message }));
117+
118+const validatePolicyPattern = (pattern: string): Effect.Effect<void, ToolkitError> =>
119+ isValidPattern(pattern)
120+ ? Effect.void
121+ : Effect.fail(
122+ new ToolkitError({
123+ message: `Invalid toolkit policy pattern: ${pattern}`,
124+ }),
125+ );
126+
127+const comparePositioned = (
128+ a: Pick<ToolkitPolicyRecord, "id" | "position">,
129+ b: Pick<ToolkitPolicyRecord, "id" | "position">,
130+): number => {
131+ if (a.position < b.position) return -1;
132+ if (a.position > b.position) return 1;
133+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
134+};
135+
136+const blockedPolicy = (pattern = "*"): EffectivePolicy => ({
137+ action: "block",
138+ source: "user",
139+ pattern,
140+});
141+
142+const pluginDefaultPolicy = (defaultRequiresApproval: boolean | undefined): EffectivePolicy =>
143+ defaultRequiresApproval
144+ ? { action: "require_approval", source: "plugin-default" }
145+ : { action: "approve", source: "plugin-default" };
146+
147+const isLegacyConnectionPolicy = (policy: ToolkitPolicyRecord): boolean => {
148+ if (policy.action !== "approve") return false;
149+ const parts = policy.pattern.split(".");
150+ return parts.at(-1) === "*" && (parts.length === 3 || parts.length === 4);
151+};
152+
153+const resolveToolkitPolicy = (
154+ toolId: string,
155+ connections: readonly ToolkitConnectionRecord[],
156+ policies: readonly ToolkitPolicyRecord[],
157+ defaultRequiresApproval?: boolean,
158+): EffectivePolicy => {
159+ const legacyPolicyIds = legacyConnectionPolicyIds(policies, connections);
160+ const connected =
161+ connections.some((connection) => matchPattern(connection.pattern, toolId)) ||
162+ policies.some(
163+ (policy) => legacyPolicyIds.has(policy.id) && matchPattern(policy.pattern, toolId),
164+ );
165+ if (!connected) return blockedPolicy();
166+
167+ for (const policy of [...policies].sort(comparePositioned)) {
168+ if (legacyPolicyIds.has(policy.id)) continue;
169+ if (!matchPattern(policy.pattern, toolId)) continue;
170+ return {
171+ action: policy.action,
172+ source: "user",
173+ pattern: policy.pattern,
174+ policyId: policy.id,
175+ };
176+ }
177+ return pluginDefaultPolicy(defaultRequiresApproval);
178+};
179+
180+const legacyConnectionPolicyIds = (
181+ policies: readonly ToolkitPolicyRecord[],
182+ connections: readonly ToolkitConnectionRecord[],
183+): ReadonlySet<string> => {
184+ const connectionPatterns = new Set(connections.map((connection) => connection.pattern));
185+ return new Set(
186+ policies
187+ .filter(
188+ (policy) => isLegacyConnectionPolicy(policy) && !connectionPatterns.has(policy.pattern),
189+ )
190+ .map((policy) => policy.id),
191+ );
192+};
193+
194+const isPersonalDynamicToolId = (toolId: string): boolean => toolId.split(".")[1] === "user";
195+
196+const toolkitToResponse = (entry: { readonly owner: Owner; readonly data: ToolkitRecord }) => ({
197+ id: entry.data.id,
198+ owner: entry.owner,
199+ slug: entry.data.slug,
200+ name: entry.data.name,
201+ createdAt: entry.data.createdAt,
202+ updatedAt: entry.data.updatedAt,
203+});
204+
205+const policyToResponse = (policy: ToolkitPolicyRecord) => ({
206+ id: policy.id,
207+ toolkitId: policy.toolkitId,
208+ pattern: policy.pattern,
209+ action: policy.action,
210+ position: policy.position,
211+ createdAt: policy.createdAt,
212+ updatedAt: policy.updatedAt,
213+});
214+
215+const connectionToResponse = (connection: ToolkitConnectionRecord) => ({
216+ id: connection.id,
217+ toolkitId: connection.toolkitId,
218+ pattern: connection.pattern,
219+ position: connection.position,
220+ createdAt: connection.createdAt,
221+ updatedAt: connection.updatedAt,
222+});
223+
224+const makeToolkitStorage = (pluginStorage: PluginStorageFacade): ToolkitStorage => ({
225+ toolkits: pluginStorage.collection(toolkitsCollection),
226+ policies: pluginStorage.collection(toolkitPoliciesCollection),
227+ connections: pluginStorage.collection(toolkitConnectionsCollection),
228+});
229+
230+const makeToolkitsExtension = (ctx: PluginCtx<ToolkitStorage>) => {
231+ const storage = ctx.storage;
232+
233+ const list = () =>
234+ storage.toolkits
235+ .query({ orderBy: [{ field: "name" }] })
236+ .pipe(
237+ Effect.map((entries) =>
238+ entries
239+ .map(toolkitToResponse)
240+ .sort(
241+ (a, b) =>
242+ (a.owner === b.owner ? 0 : a.owner === "org" ? -1 : 1) ||
243+ a.name.localeCompare(b.name) ||
244+ a.slug.localeCompare(b.slug),
245+ ),
246+ ),
247+ );
248+
249+ const getEntry = (toolkitId: string) => storage.toolkits.get({ key: toolkitId });
250+
251+ const getBySlugEntry = (slug: string) =>
252+ storage.toolkits
253+ .query({ where: { slug } })
254+ .pipe(
255+ Effect.map(
256+ (entries) => entries.find((entry) => entry.owner === "org") ?? entries[0] ?? null,
257+ ),
258+ );
259+
260+ const requireToolkit = (toolkitId: string) =>
261+ getEntry(toolkitId).pipe(
262+ Effect.flatMap((entry) => (entry ? Effect.succeed(entry) : fail("Toolkit not found."))),
263+ );
264+
265+ const assertSlugAvailable = (slug: string, ignoreToolkitId?: string) =>
266+ storage.toolkits.query({ where: { slug } }).pipe(
267+ Effect.flatMap((entries) => {
268+ const collision = entries.find((entry) => entry.data.id !== ignoreToolkitId);
269+ return collision ? fail(`Toolkit slug "${slug}" is already in use.`) : Effect.void;
270+ }),
271+ );
272+
273+ const listPoliciesForRecord = (toolkitId: string) =>
274+ storage.policies
275+ .query({ where: { toolkitId } })
276+ .pipe(Effect.map((entries) => entries.map((entry) => entry.data).sort(comparePositioned)));
277+
278+ const listConnectionsForRecord = (toolkitId: string) =>
279+ storage.connections
280+ .query({ where: { toolkitId } })
281+ .pipe(Effect.map((entries) => entries.map((entry) => entry.data).sort(comparePositioned)));
282+
283+ const requirePolicy = (toolkitId: string, policyId: string, owner: Owner) =>
284+ storage.policies
285+ .getForOwner({ owner, key: policyId })
286+ .pipe(
287+ Effect.flatMap((entry) =>
288+ entry && entry.data.toolkitId === toolkitId
289+ ? Effect.succeed(entry)
290+ : fail("Toolkit policy not found."),
291+ ),
292+ );
293+
294+ const requireConnection = (toolkitId: string, connectionId: string, owner: Owner) =>
295+ storage.connections
296+ .getForOwner({ owner, key: connectionId })
297+ .pipe(
298+ Effect.flatMap((entry) =>
299+ entry && entry.data.toolkitId === toolkitId
300+ ? Effect.succeed(entry)
301+ : fail("Toolkit connection not found."),
302+ ),
303+ );
304+
305+ const create = (input: {
306+ readonly owner: Owner;
307+ readonly name: string;
308+ readonly slug?: string;
309+ }) =>
310+ Effect.gen(function* () {
311+ const name = input.name.trim();
312+ if (!name) return yield* fail("Toolkit name is required.");
313+ const slug = yield* normalizeSlugEffect({ name, slug: input.slug });
314+ yield* assertSlugAvailable(slug);
315+ const now = Date.now();
316+ const id = newId("tk");
317+ const entry = yield* storage.toolkits.put({
318+ owner: input.owner,
319+ key: id,
320+ data: { id, slug, name, createdAt: now, updatedAt: now },
321+ });
322+ return toolkitToResponse(entry);
323+ });
324+
325+ const update = (toolkitId: string, input: { readonly name?: string; readonly slug?: string }) =>
326+ Effect.gen(function* () {
327+ const existing = yield* requireToolkit(toolkitId);
328+ const name = input.name === undefined ? existing.data.name : input.name.trim();
329+ if (!name) return yield* fail("Toolkit name is required.");
330+ const slug =
331+ input.slug === undefined
332+ ? existing.data.slug
333+ : yield* normalizeSlugEffect({ name, slug: input.slug });
334+ yield* assertSlugAvailable(slug, toolkitId);
335+ const entry = yield* storage.toolkits.put({
336+ owner: existing.owner,
337+ key: toolkitId,
338+ data: { ...existing.data, name, slug, updatedAt: Date.now() },
339+ });
340+ return toolkitToResponse(entry);
341+ });
342+
343+ const remove = (toolkitId: string) =>
344+ Effect.gen(function* () {
345+ const toolkit = yield* requireToolkit(toolkitId);
346+ const policies = yield* listPoliciesForRecord(toolkitId);
347+ const connections = yield* listConnectionsForRecord(toolkitId);
348+ yield* ctx.pluginStorage.removeMany({
349+ owner: toolkit.owner,
350+ entries: [
351+ { collection: toolkitsCollection.name, key: toolkitId },
352+ ...policies.map((policy) => ({
353+ collection: toolkitPoliciesCollection.name,
354+ key: policy.id,
355+ })),
356+ ...connections.map((connection) => ({
357+ collection: toolkitConnectionsCollection.name,
358+ key: connection.id,
359+ })),
360+ ],
361+ });
362+ });
363+
364+ const listPolicies = (toolkitId: string) =>
365+ requireToolkit(toolkitId).pipe(Effect.flatMap(() => listPoliciesForRecord(toolkitId)));
366+
367+ const createPolicy = (
368+ toolkitId: string,
369+ input: {
370+ readonly pattern: string;
371+ readonly action: ToolPolicyAction;
372+ readonly position?: string;
373+ },
374+ ) =>
375+ Effect.gen(function* () {
376+ yield* validatePolicyPattern(input.pattern);
377+ const toolkit = yield* requireToolkit(toolkitId);
378+ const existing = yield* listPoliciesForRecord(toolkitId);
379+ const minPosition = existing
380+ .map((row) => row.position)
381+ .sort()
382+ .at(0);
383+ const now = Date.now();
384+ const id = newId("tkpol");
385+ const entry = yield* storage.policies.put({
386+ owner: toolkit.owner,
387+ key: id,
388+ data: {
389+ id,
390+ toolkitId,
391+ pattern: input.pattern,
392+ action: input.action,
393+ position: input.position ?? generateKeyBetween(null, minPosition ?? null),
394+ createdAt: now,
395+ updatedAt: now,
396+ },
397+ });
398+ return policyToResponse(entry.data);
399+ });
400+
401+ const updatePolicy = (
402+ toolkitId: string,
403+ policyId: string,
404+ input: {
405+ readonly pattern?: string;
406+ readonly action?: ToolPolicyAction;
407+ readonly position?: string;
408+ },
409+ ) =>
410+ Effect.gen(function* () {
411+ if (input.pattern !== undefined) yield* validatePolicyPattern(input.pattern);
412+ const toolkit = yield* requireToolkit(toolkitId);
413+ const existing = yield* requirePolicy(toolkitId, policyId, toolkit.owner);
414+ const entry = yield* storage.policies.put({
415+ owner: toolkit.owner,
416+ key: policyId,
417+ data: {
418+ ...existing.data,
419+ ...(input.pattern !== undefined ? { pattern: input.pattern } : {}),
420+ ...(input.action !== undefined ? { action: input.action } : {}),
421+ ...(input.position !== undefined ? { position: input.position } : {}),
422+ updatedAt: Date.now(),
423+ },
424+ });
425+ return policyToResponse(entry.data);
426+ });
427+
428+ const removePolicy = (toolkitId: string, policyId: string) =>
429+ Effect.gen(function* () {
430+ const toolkit = yield* requireToolkit(toolkitId);
431+ yield* requirePolicy(toolkitId, policyId, toolkit.owner);
432+ yield* storage.policies.remove({ owner: toolkit.owner, key: policyId });
433+ });
434+
435+ const listConnections = (toolkitId: string) =>
436+ requireToolkit(toolkitId).pipe(Effect.flatMap(() => listConnectionsForRecord(toolkitId)));
437+
438+ const createConnection = (
439+ toolkitId: string,
440+ input: {
441+ readonly pattern: string;
442+ readonly position?: string;
443+ },
444+ ) =>
445+ Effect.gen(function* () {
446+ yield* validatePolicyPattern(input.pattern);
447+ const toolkit = yield* requireToolkit(toolkitId);
448+ const existing = yield* listConnectionsForRecord(toolkitId);
449+ const duplicate = existing.find((connection) => connection.pattern === input.pattern);
450+ if (duplicate) return connectionToResponse(duplicate);
451+ const minPosition = existing
452+ .map((row) => row.position)
453+ .sort()
454+ .at(0);
455+ const now = Date.now();
456+ const id = newId("tkconn");
457+ const entry = yield* storage.connections.put({
458+ owner: toolkit.owner,
459+ key: id,
460+ data: {
461+ id,
462+ toolkitId,
463+ pattern: input.pattern,
464+ position: input.position ?? generateKeyBetween(null, minPosition ?? null),
465+ createdAt: now,
466+ updatedAt: now,
467+ },
468+ });
469+ return connectionToResponse(entry.data);
470+ });
471+
472+ const removeConnection = (toolkitId: string, connectionId: string) =>
473+ Effect.gen(function* () {
474+ const toolkit = yield* requireToolkit(toolkitId);
475+ yield* requireConnection(toolkitId, connectionId, toolkit.owner);
476+ yield* storage.connections.remove({ owner: toolkit.owner, key: connectionId });
477+ });
478+
479+ const policyRulesForSlug = (
480+ slug: string,
481+ ): Effect.Effect<readonly ToolPolicyProviderRule[], StorageFailure> =>
482+ Effect.gen(function* () {
483+ const toolkit = yield* getBySlugEntry(slug);
484+ if (!toolkit) return [];
485+ const policies = yield* listPoliciesForRecord(toolkit.data.id);
486+ const connections = yield* listConnectionsForRecord(toolkit.data.id);
487+ const legacyPolicyIds = legacyConnectionPolicyIds(policies, connections);
488+ return policies
489+ .filter((policy) => !legacyPolicyIds.has(policy.id))
490+ .map((policy) => ({
491+ id: policy.id,
492+ pattern: policy.pattern,
493+ action: policy.action,
494+ position: policy.position,
495+ }));
496+ });
497+
498+ const resolvePolicyForSlug = (
499+ slug: string,
500+ toolId: string,
501+ defaultRequiresApproval?: boolean,
502+ ): Effect.Effect<EffectivePolicy, StorageFailure> =>
503+ Effect.gen(function* () {
504+ const toolkit = yield* getBySlugEntry(slug);
505+ if (!toolkit) return blockedPolicy();
506+ if (toolkit.owner === "org" && isPersonalDynamicToolId(toolId)) return blockedPolicy();
507+ const policies = yield* listPoliciesForRecord(toolkit.data.id);
508+ const connections = yield* listConnectionsForRecord(toolkit.data.id);
509+ return resolveToolkitPolicy(toolId, connections, policies, defaultRequiresApproval);
510+ });
511+
512+ return {
513+ list,
514+ create,
515+ update,
516+ remove,
517+ listPolicies: (toolkitId: string) =>
518+ listPolicies(toolkitId).pipe(Effect.map((policies) => policies.map(policyToResponse))),
519+ createPolicy,
520+ updatePolicy,
521+ removePolicy,
522+ listConnections: (toolkitId: string) =>
523+ listConnections(toolkitId).pipe(
524+ Effect.map((connections) => connections.map(connectionToResponse)),
525+ ),
526+ createConnection,
527+ removeConnection,
528+ policyRulesForSlug,
529+ resolvePolicyForSlug,
530+ };
531+};
532+
533+export type ToolkitsExtension = ReturnType<typeof makeToolkitsExtension>;
534+
535+export class ToolkitsExtensionService extends Context.Service<
536+ ToolkitsExtensionService,
537+ ToolkitsExtension
538+>()("ToolkitsExtensionService") {}
539+
540+const ExecutorApiWithToolkits = addGroup(ToolkitsApi);
541+
542+const ToolkitsHandlers = HttpApiBuilder.group(ExecutorApiWithToolkits, "toolkits", (handlers) =>
543+ handlers
544+ .handle("list", () =>
545+ capture(
546+ Effect.gen(function* () {
547+ const ext = yield* ToolkitsExtensionService;
548+ const toolkits = yield* ext.list();
549+ return { toolkits };
550+ }),
551+ ),
552+ )
553+ .handle("create", ({ payload }) =>
554+ capture(
555+ Effect.gen(function* () {
556+ const ext = yield* ToolkitsExtensionService;
557+ return yield* ext.create(payload);
558+ }),
559+ ),
560+ )
561+ .handle("update", ({ params, payload }) =>
562+ capture(
563+ Effect.gen(function* () {
564+ const ext = yield* ToolkitsExtensionService;
565+ return yield* ext.update(params.toolkitId, payload);
566+ }),
567+ ),
568+ )
569+ .handle("remove", ({ params }) =>
570+ capture(
571+ Effect.gen(function* () {
572+ const ext = yield* ToolkitsExtensionService;
573+ yield* ext.remove(params.toolkitId);
574+ return { removed: true };
575+ }),
576+ ),
577+ )
578+ .handle("listPolicies", ({ params }) =>
579+ capture(
580+ Effect.gen(function* () {
581+ const ext = yield* ToolkitsExtensionService;
582+ const policies = yield* ext.listPolicies(params.toolkitId);
583+ return { policies };
584+ }),
585+ ),
586+ )
587+ .handle("createPolicy", ({ params, payload }) =>
588+ capture(
589+ Effect.gen(function* () {
590+ const ext = yield* ToolkitsExtensionService;
591+ return yield* ext.createPolicy(params.toolkitId, payload);
592+ }),
593+ ),
594+ )
595+ .handle("updatePolicy", ({ params, payload }) =>
596+ capture(
597+ Effect.gen(function* () {
598+ const ext = yield* ToolkitsExtensionService;
599+ return yield* ext.updatePolicy(params.toolkitId, params.policyId, payload);
600+ }),
601+ ),
602+ )
603+ .handle("removePolicy", ({ params }) =>
604+ capture(
605+ Effect.gen(function* () {
606+ const ext = yield* ToolkitsExtensionService;
607+ yield* ext.removePolicy(params.toolkitId, params.policyId);
608+ return { removed: true };
609+ }),
610+ ),
611+ )
612+ .handle("listConnections", ({ params }) =>
613+ capture(
614+ Effect.gen(function* () {
615+ const ext = yield* ToolkitsExtensionService;
616+ const connections = yield* ext.listConnections(params.toolkitId);
617+ return { connections };
618+ }),
619+ ),
620+ )
621+ .handle("createConnection", ({ params, payload }) =>
622+ capture(
623+ Effect.gen(function* () {
624+ const ext = yield* ToolkitsExtensionService;
625+ return yield* ext.createConnection(params.toolkitId, payload);
626+ }),
627+ ),
628+ )
629+ .handle("removeConnection", ({ params }) =>
630+ capture(
631+ Effect.gen(function* () {
632+ const ext = yield* ToolkitsExtensionService;
633+ yield* ext.removeConnection(params.toolkitId, params.connectionId);
634+ return { removed: true };
635+ }),
636+ ),
637+ ),
638+);
639+
640+const makePolicyProvider = (
641+ extension: Pick<ToolkitsExtension, "policyRulesForSlug" | "resolvePolicyForSlug">,
642+ slug: string,
643+): ToolPolicyProvider => ({
644+ list: () => extension.policyRulesForSlug(slug),
645+ resolve: ({ toolId, defaultRequiresApproval }) =>
646+ extension.resolvePolicyForSlug(slug, toolId, defaultRequiresApproval),
647+});
648+
649+export const toolkitsPlugin = definePlugin((options: ToolkitsPluginOptions = {}) => {
650+ const activeToolkitSlug = options.activeToolkitSlug;
651+ return {
652+ id: "toolkits" as const,
653+ packageName: "@executor-js/plugin-toolkits",
654+ pluginStorage: {
655+ toolkits: toolkitsCollection,
656+ toolkitPolicies: toolkitPoliciesCollection,
657+ toolkitConnections: toolkitConnectionsCollection,
658+ },
659+ storage: ({ pluginStorage }) => makeToolkitStorage(pluginStorage),
660+ extension: makeToolkitsExtension,
661+ routes: () => ToolkitsApi,
662+ handlers: () => ToolkitsHandlers,
663+ extensionService: ToolkitsExtensionService,
664+ ...(activeToolkitSlug
665+ ? {
666+ toolPolicyProvider: (ctx: PluginCtx<ToolkitStorage>) =>
667+ makePolicyProvider(makeToolkitsExtension(ctx), activeToolkitSlug),
668+ }
669+ : {}),
670+ };
671+});
672+
673+export default toolkitsPlugin;
packages/plugins/toolkits/src/shared.ts+166−0
@@ -0,0 +1,166 @@
1+import { Schema } from "effect";
2+import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
3+import { InternalError, Owner, ToolPolicyActionSchema } from "@executor-js/sdk/shared";
4+
5+export class ToolkitError extends Schema.TaggedErrorClass<ToolkitError>()(
6+ "ToolkitError",
7+ { message: Schema.String },
8+ { httpApiStatus: 400 },
9+) {}
10+
11+const ToolkitParams = {
12+ toolkitId: Schema.String,
13+};
14+
15+const ToolkitPolicyParams = {
16+ toolkitId: Schema.String,
17+ policyId: Schema.String,
18+};
19+
20+const ToolkitConnectionParams = {
21+ toolkitId: Schema.String,
22+ connectionId: Schema.String,
23+};
24+
25+export const ToolkitResponse = Schema.Struct({
26+ id: Schema.String,
27+ owner: Owner,
28+ slug: Schema.String,
29+ name: Schema.String,
30+ createdAt: Schema.Number,
31+ updatedAt: Schema.Number,
32+});
33+export type ToolkitResponse = typeof ToolkitResponse.Type;
34+
35+export const ToolkitPolicyResponse = Schema.Struct({
36+ id: Schema.String,
37+ toolkitId: Schema.String,
38+ pattern: Schema.String,
39+ action: ToolPolicyActionSchema,
40+ position: Schema.String,
41+ createdAt: Schema.Number,
42+ updatedAt: Schema.Number,
43+});
44+export type ToolkitPolicyResponse = typeof ToolkitPolicyResponse.Type;
45+
46+export const ToolkitConnectionResponse = Schema.Struct({
47+ id: Schema.String,
48+ toolkitId: Schema.String,
49+ pattern: Schema.String,
50+ position: Schema.String,
51+ createdAt: Schema.Number,
52+ updatedAt: Schema.Number,
53+});
54+export type ToolkitConnectionResponse = typeof ToolkitConnectionResponse.Type;
55+
56+const CreateToolkitPayload = Schema.Struct({
57+ owner: Owner,
58+ name: Schema.String,
59+ slug: Schema.optional(Schema.String),
60+});
61+
62+const UpdateToolkitPayload = Schema.Struct({
63+ name: Schema.optional(Schema.String),
64+ slug: Schema.optional(Schema.String),
65+});
66+
67+const CreateToolkitPolicyPayload = Schema.Struct({
68+ pattern: Schema.String,
69+ action: ToolPolicyActionSchema,
70+ position: Schema.optional(Schema.String),
71+});
72+
73+const UpdateToolkitPolicyPayload = Schema.Struct({
74+ pattern: Schema.optional(Schema.String),
75+ action: Schema.optional(ToolPolicyActionSchema),
76+ position: Schema.optional(Schema.String),
77+});
78+
79+const CreateToolkitConnectionPayload = Schema.Struct({
80+ pattern: Schema.String,
81+ position: Schema.optional(Schema.String),
82+});
83+
84+const ToolkitErrors = [InternalError, ToolkitError] as const;
85+
86+export const ToolkitsApi = HttpApiGroup.make("toolkits")
87+ .add(
88+ HttpApiEndpoint.get("list", "/toolkits", {
89+ success: Schema.Struct({ toolkits: Schema.Array(ToolkitResponse) }),
90+ error: ToolkitErrors,
91+ }),
92+ )
93+ .add(
94+ HttpApiEndpoint.post("create", "/toolkits", {
95+ payload: CreateToolkitPayload,
96+ success: ToolkitResponse,
97+ error: ToolkitErrors,
98+ }),
99+ )
100+ .add(
101+ HttpApiEndpoint.patch("update", "/toolkits/:toolkitId", {
102+ params: ToolkitParams,
103+ payload: UpdateToolkitPayload,
104+ success: ToolkitResponse,
105+ error: ToolkitErrors,
106+ }),
107+ )
108+ .add(
109+ HttpApiEndpoint.delete("remove", "/toolkits/:toolkitId", {
110+ params: ToolkitParams,
111+ success: Schema.Struct({ removed: Schema.Boolean }),
112+ error: ToolkitErrors,
113+ }),
114+ )
115+ .add(
116+ HttpApiEndpoint.get("listPolicies", "/toolkits/:toolkitId/policies", {
117+ params: ToolkitParams,
118+ success: Schema.Struct({ policies: Schema.Array(ToolkitPolicyResponse) }),
119+ error: ToolkitErrors,
120+ }),
121+ )
122+ .add(
123+ HttpApiEndpoint.post("createPolicy", "/toolkits/:toolkitId/policies", {
124+ params: ToolkitParams,
125+ payload: CreateToolkitPolicyPayload,
126+ success: ToolkitPolicyResponse,
127+ error: ToolkitErrors,
128+ }),
129+ )
130+ .add(
131+ HttpApiEndpoint.patch("updatePolicy", "/toolkits/:toolkitId/policies/:policyId", {
132+ params: ToolkitPolicyParams,
133+ payload: UpdateToolkitPolicyPayload,
134+ success: ToolkitPolicyResponse,
135+ error: ToolkitErrors,
136+ }),
137+ )
138+ .add(
139+ HttpApiEndpoint.delete("removePolicy", "/toolkits/:toolkitId/policies/:policyId", {
140+ params: ToolkitPolicyParams,
141+ success: Schema.Struct({ removed: Schema.Boolean }),
142+ error: ToolkitErrors,
143+ }),
144+ )
145+ .add(
146+ HttpApiEndpoint.get("listConnections", "/toolkits/:toolkitId/connections", {
147+ params: ToolkitParams,
148+ success: Schema.Struct({ connections: Schema.Array(ToolkitConnectionResponse) }),
149+ error: ToolkitErrors,
150+ }),
151+ )
152+ .add(
153+ HttpApiEndpoint.post("createConnection", "/toolkits/:toolkitId/connections", {
154+ params: ToolkitParams,
155+ payload: CreateToolkitConnectionPayload,
156+ success: ToolkitConnectionResponse,
157+ error: ToolkitErrors,
158+ }),
159+ )
160+ .add(
161+ HttpApiEndpoint.delete("removeConnection", "/toolkits/:toolkitId/connections/:connectionId", {
162+ params: ToolkitConnectionParams,
163+ success: Schema.Struct({ removed: Schema.Boolean }),
164+ error: ToolkitErrors,
165+ }),
166+ );
packages/plugins/toolkits/tsconfig.json+23−0
@@ -0,0 +1,23 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "lib": ["ES2022", "DOM"],
9+ "types": ["bun-types", "node"],
10+ "noUnusedLocals": true,
11+ "noImplicitOverride": true,
12+ "jsx": "react-jsx",
13+ "plugins": [
14+ {
15+ "name": "@effect/language-service",
16+ "ignoreEffectSuggestionsInTscExitCode": true,
17+ "ignoreEffectWarningsInTscExitCode": true,
18+ "diagnosticSeverity": {}
19+ }
20+ ]
21+ },
22+ "include": ["src/**/*.ts", "src/**/*.tsx"]
23+}
packages/plugins/toolkits/tsup.config.ts+14−0
@@ -0,0 +1,14 @@
1+import { defineConfig } from "tsup";
2+
3+export default defineConfig({
4+ entry: {
5+ server: "src/server.ts",
6+ client: "src/client.tsx",
7+ shared: "src/shared.ts",
8+ },
9+ format: ["esm"],
10+ dts: true,
11+ clean: true,
12+ sourcemap: true,
13+ external: ["react"],
14+});
packages/plugins/toolkits/vitest.config.ts+7−0
@@ -0,0 +1,7 @@
1+import { defineConfig } from "vitest/config";
2+
3+export default defineConfig({
4+ test: {
5+ globals: false,
6+ },
7+});
packages/react/src/components/tool-detail.tsx+59−42
@@ -172,6 +172,7 @@ export function ToolDetail(props: { · 171 unmodified lines
172172 * applies a user rule to this tool's exact id. */
173173 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
174174 onClearPolicy?: (pattern: string) => void;
175+ patternForDisplay?: (displayPattern: string) => string;
175176 /** Run-tab wiring. When `integration` + `runToolName` are provided, a third
176177 * "Run" tab hosts the per-connection tool tester. */
177178 integration?: IntegrationSlug;
@@ -187,6 +188,7 @@ export function ToolDetail(props: { · 9 unmodified lines
187188 const canRun = props.integration != null && props.runToolName != null;
188189 // Don't strand the user on the Run tab when this ToolDetail has no run wiring.
189190 const activeTab = tab === "run" && !canRun ? "schema" : tab;
191+ const isBlocked = props.policy?.action === "block";
190192
191193 const data = useMemo(() => {
192194 if (!AsyncResult.isSuccess(toolContract)) return null;
@@ -244,6 +246,7 @@ export function ToolDetail(props: { · 51 unmodified lines
244246 policy={props.policy}
245247 onSetPolicy={props.onSetPolicy}
246248 onClearPolicy={props.onClearPolicy}
249+ patternForDisplay={props.patternForDisplay}
247250 />
248251 </div>
249252 {data?.description && <ToolDescription description={data.description} />}
@@ -300,48 +303,59 @@ export function ToolDetail(props: { · 50 unmodified lines
300303
301304 {/* Content */}
302305 <div className="flex-1 overflow-y-auto">
303− {AsyncResult.match(toolContract, {
304− onInitial: () => <div className="p-5 text-sm text-muted-foreground">Loading…</div>,
305− onFailure: () => <div className="p-5 text-sm text-destructive">Something went wrong</div>,
306− onSuccess: () =>
307− activeTab === "run" && props.integration && props.runToolName ? (
308− <div className="px-5 py-5">
309− <ToolRunPanel
310− integration={props.integration}
311− toolName={props.runToolName}
312− connections={props.connections ?? []}
313− initialConnectionName={props.initialConnectionName}
314− />
315− </div>
316− ) : activeTab === "schema" ? (
317− <div className="px-5 py-5 space-y-5">
318− {data?.inputSchema ? (
319− <SchemaExplorer
320− schema={data.inputSchema}
321− schemaDefinitions={data.schemaDefinitions}
322− title="Parameters"
323− />
324− ) : (
325− <EmptySection title="Parameters" message="None" />
326− )}
327− {data?.outputSchema ? (
328− <SchemaExplorer
329− schema={data.outputSchema}
330− schemaDefinitions={data.schemaDefinitions}
331− title="Response"
332− />
333− ) : (
334− <EmptySection title="Response" message="None" />
335− )}
336− </div>
337− ) : (
338− <ToolTypeScriptPanel
339− inputTypeScript={data?.inputTypeScript ?? null}
340− outputTypeScript={data?.outputTypeScript ?? null}
341− definitions={data?.definitions ?? []}
342− />
306+ {isBlocked ? (
307+ <div className="px-5 py-5">
308+ <EmptySection
309+ title="Blocked"
310+ message="This tool is not available through the current toolkit."
311+ />
312+ </div>
313+ ) : (
314+ AsyncResult.match(toolContract, {
315+ onInitial: () => <div className="p-5 text-sm text-muted-foreground">Loading…</div>,
316+ onFailure: () => (
317+ <div className="p-5 text-sm text-destructive">Something went wrong</div>
343318 ),
344− })}
319+ onSuccess: () =>
320+ activeTab === "run" && props.integration && props.runToolName ? (
321+ <div className="px-5 py-5">
322+ <ToolRunPanel
323+ integration={props.integration}
324+ toolName={props.runToolName}
325+ connections={props.connections ?? []}
326+ initialConnectionName={props.initialConnectionName}
327+ />
328+ </div>
329+ ) : activeTab === "schema" ? (
330+ <div className="px-5 py-5 space-y-5">
331+ {data?.inputSchema ? (
332+ <SchemaExplorer
333+ schema={data.inputSchema}
334+ schemaDefinitions={data.schemaDefinitions}
335+ title="Parameters"
336+ />
337+ ) : (
338+ <EmptySection title="Parameters" message="None" />
339+ )}
340+ {data?.outputSchema ? (
341+ <SchemaExplorer
342+ schema={data.outputSchema}
343+ schemaDefinitions={data.schemaDefinitions}
344+ title="Response"
345+ />
346+ ) : (
347+ <EmptySection title="Response" message="None" />
348+ )}
349+ </div>
350+ ) : (
351+ <ToolTypeScriptPanel
352+ inputTypeScript={data?.inputTypeScript ?? null}
353+ outputTypeScript={data?.outputTypeScript ?? null}
354+ definitions={data?.definitions ?? []}
355+ />
356+ ),
357+ })
358+ )}
345359 </div>
346360 </div>
347361 );
@@ -400,12 +414,15 @@ function PolicyBadgeMenu(props: { · 52 unmodified lines
400414 policy?: EffectivePolicy;
401415 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
402416 onClearPolicy?: (pattern: string) => void;
417+ patternForDisplay?: (displayPattern: string) => string;
403418 }) {
404419 const interactive = !!props.onSetPolicy;
405420 // The same pattern bridge the tree rows apply — the pattern WRITTEN and the
406421 // pattern LOOKED UP must be the same string, or the menu authors rules that
407422 // never match and can't see its own rule afterward.
408− const pattern = props.staticTool ? props.toolName : toPolicyPattern(props.toolName);
423+ const pattern = props.staticTool
424+ ? props.toolName
425+ : (props.patternForDisplay ?? toPolicyPattern)(props.toolName);
409426 // The "Clear" affordance only makes sense when there's a user rule
410427 // pinned to this exact tool id — clearing a wildcard rule from a
411428 // single tool's detail header would silently affect siblings.
packages/react/src/components/tool-tree.tsx+38−12
@@ -41,6 +41,9 @@ export interface ToolSummary { · 40 unmodified lines
4141 /** Name of the connection (account) that produced this tool. Present only in
4242 * the account-grouped view; ignored in the flat tree. */
4343 readonly connection?: string;
44+ /** Integration that produced this tool. Used to disambiguate same-name
45+ * connections from different integrations in grouped views. */
46+ readonly integration?: string;
4447 }
4548
4649 // ---------------------------------------------------------------------------
@@ -53,9 +56,10 @@ export interface ToolSummary { · 6 unmodified lines
5356 // ---------------------------------------------------------------------------
5457
5558 export type AccountGroup = {
56− /** Stable key `${owner}:${connection}`. */
59+ /** Stable key `${owner}:${integration}:${connection}`. */
5760 readonly key: string;
5861 readonly owner: Owner;
62+ readonly integration: string;
5963 readonly connection: string;
6064 /** Section header, e.g. "Personal · axiom-mcp". */
6165 readonly label: string;
@@ -69,14 +73,18 @@ export type AccountGroup = { · 7 unmodified lines
6973 * Pure — unit-testable without React.
7074 */
7175 export const buildAccountGroups = (tools: readonly ToolSummary[]): readonly AccountGroup[] => {
72− const byKey = new Map<string, { owner: Owner; connection: string; tools: ToolSummary[] }>();
76+ const byKey = new Map<
77+ string,
78+ { owner: Owner; integration: string; connection: string; tools: ToolSummary[] }
79+ >();
7380 for (const tool of tools) {
7481 const owner: Owner = tool.owner ?? "org";
82+ const integration = tool.integration ?? "";
7583 const connection = tool.connection ?? "";
76− const key = `${owner}:${connection}`;
84+ const key = `${owner}:${integration}:${connection}`;
7785 let group = byKey.get(key);
7886 if (!group) {
79− group = { owner, connection, tools: [] };
87+ group = { owner, integration, connection, tools: [] };
8088 byKey.set(key, group);
8189 }
8290 group.tools.push(tool);
@@ -85,14 +93,20 @@ export const buildAccountGroups = (tools: readonly ToolSummary[]): readonly Acco · 2 unmodified lines
8593 const ownerRank = (owner: Owner): number => (owner === "org" ? 0 : 1);
8694 return [...byKey.values()]
8795 .sort(
88− (a, b) => ownerRank(a.owner) - ownerRank(b.owner) || a.connection.localeCompare(b.connection),
96+ (a, b) =>
97+ ownerRank(a.owner) - ownerRank(b.owner) ||
98+ a.integration.localeCompare(b.integration) ||
99+ a.connection.localeCompare(b.connection),
89100 )
90101 .map((group) => ({
91− key: `${group.owner}:${group.connection}`,
102+ key: `${group.owner}:${group.integration}:${group.connection}`,
92103 owner: group.owner,
104+ integration: group.integration,
93105 connection: group.connection,
94106 label: group.connection
95− ? `${ownerLabel(group.owner)} · ${group.connection}`
107+ ? `${ownerLabel(group.owner)} · ${
108+ group.integration ? `${group.integration} / ` : ""
109+ }${group.connection}`
96110 : ownerLabel(group.owner),
97111 tools: group.tools,
98112 }));
@@ -283,6 +297,8 @@ export function ToolTree(props: { · 184 unmodified lines
283297 * emit the tool's full dotted id; group rows emit `prefix.*`. */
284298 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
285299 onClearPolicy?: (pattern: string) => void;
300+ /** Maps the displayed row path into the persisted policy pattern. */
301+ patternForDisplay?: (displayPattern: string) => string;
286302 /** Sorted user-authored policies (most-precedent first). Used to
287303 * decide whether a node has its own exact-pattern user rule today
288304 * (so the menu can show a "Clear" option). Optional — when absent,
@@ -300,6 +316,7 @@ export function ToolTree(props: { · 11 unmodified lines
300316 onSelect,
301317 onSetPolicy,
302318 onClearPolicy,
319+ patternForDisplay = toPolicyPattern,
303320 policies,
304321 groupByConnection,
305322 } = props;
@@ -358,6 +375,7 @@ export function ToolTree(props: { · 52 unmodified lines
358375 onSelect,
359376 onSetPolicy,
360377 onClearPolicy,
378+ patternForDisplay,
361379 exactPatterns,
362380 search,
363381 terms,
@@ -404,7 +422,9 @@ export function ToolTree(props: { · 40 unmodified lines
404422 </Badge>
405423 ) : null}
406424 <span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">
407− {group.connection || ownerDisplay.label(group.owner)}
425+ {group.integration && group.connection
426+ ? `${group.integration} / ${group.connection}`
427+ : group.connection || ownerDisplay.label(group.owner)}
408428 </span>
409429 <span className="shrink-0 tabular-nums text-xs text-muted-foreground">
410430 {group.tools.length}
@@ -433,6 +453,7 @@ function ToolTreeBody(props: { · 22 unmodified lines
433453 onSelect: (toolId: string) => void;
434454 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
435455 onClearPolicy?: (pattern: string) => void;
456+ patternForDisplay: (displayPattern: string) => string;
436457 exactPatterns: ReadonlyMap<string, ToolPolicyAction>;
437458 search: string;
438459 terms: readonly string[];
@@ -444,6 +465,7 @@ function ToolTreeBody(props: { · 5 unmodified lines
444465 onSelect,
445466 onSetPolicy,
446467 onClearPolicy,
468+ patternForDisplay,
447469 exactPatterns,
448470 search,
449471 terms,
@@ -510,7 +532,8 @@ function ToolTreeBody(props: { · 60 unmodified lines
510532 search={search}
511533 onSetPolicy={onSetPolicy}
512534 onClearPolicy={onClearPolicy}
513− exactRule={exactPatterns.get(toPolicyPattern(row.tool.name))}
535+ patternForDisplay={patternForDisplay}
536+ exactRule={exactPatterns.get(patternForDisplay(row.tool.name))}
514537 />
515538 ) : (
516539 <ToolGroupRow
@@ -524,7 +547,8 @@ function ToolTreeBody(props: { · 7 unmodified lines
524547 search={search}
525548 onSetPolicy={onSetPolicy}
526549 onClearPolicy={onClearPolicy}
527− exactRule={exactPatterns.get(toPolicyPattern(`${row.path}.*`))}
550+ patternForDisplay={patternForDisplay}
551+ exactRule={exactPatterns.get(patternForDisplay(`${row.path}.*`))}
528552 />
529553 ),
530554 )}
@@ -610,6 +634,7 @@ function ToolGroupRow(props: { · 79 unmodified lines
610634 search: string;
611635 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
612636 onClearPolicy?: (pattern: string) => void;
637+ patternForDisplay: (displayPattern: string) => string;
613638 exactRule?: ToolPolicyAction;
614639 }) {
615640 const showActions = !!props.onSetPolicy;
@@ -647,7 +672,7 @@ function ToolGroupRow(props: { · 31 unmodified lines
647672 )}
648673 >
649674 <PolicyActionMenu
650− pattern={toPolicyPattern(`${props.path}.*`)}
675+ pattern={props.patternForDisplay(`${props.path}.*`)}
651676 current={props.exactRule}
652677 onSet={props.onSetPolicy!}
653678 onClear={props.onClearPolicy}
@@ -668,6 +693,7 @@ function ToolLeafRow(props: { · 14 unmodified lines
668693 search: string;
669694 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
670695 onClearPolicy?: (pattern: string) => void;
696+ patternForDisplay: (displayPattern: string) => string;
671697 exactRule?: ToolPolicyAction;
672698 }) {
673699 const label = props.tool.name.split(".").pop() ?? props.tool.name;
@@ -719,7 +745,7 @@ function ToolLeafRow(props: { · 45 unmodified lines
719745 )}
720746 >
721747 <PolicyActionMenu
722− pattern={toPolicyPattern(props.tool.name)}
748+ pattern={props.patternForDisplay(props.tool.name)}
723749 current={props.exactRule}
724750 onSet={props.onSetPolicy!}
725751 onClear={props.onClearPolicy}
packages/react/src/console-routes.test.ts+9−3
@@ -9,15 +9,21 @@ import { routeTree } from "./routes/routeTree.gen"; · 8 unmodified lines
99 // added without a consoleRoutes() entry, or vice versa — apps would silently
1010 // lack a route that this package's pages link to. Lock them together.
1111
12−const collectPaths = (route: unknown): ReadonlyArray<string> => {
12+const collectPaths = (route: unknown, parentId = ""): ReadonlyArray<string> => {
1313 const node = route as {
1414 options?: { id?: string };
1515 children?: ReadonlyArray<unknown>;
1616 };
1717 const children = node.children ?? [];
1818 const id = node.options?.id;
19− const own = typeof id === "string" ? [id] : [];
20− return [...own, ...children.flatMap(collectPaths)];
19+ const resolved =
20+ typeof id === "string"
21+ ? parentId && !id.startsWith(`/${ORG_SLUG_SEGMENT}`)
22+ ? `${parentId.replace(/\/$/, "")}${id}`
23+ : id
24+ : parentId;
25+ const own = typeof id === "string" ? [resolved] : [];
26+ return [...own, ...children.flatMap((child) => collectPaths(child, resolved))];
2127 };
2228
2329 // The generated ids are the scope-relative contract paths nested under the
packages/react/src/console-routes.ts+4−0
@@ -39,6 +39,8 @@ export const CONSOLE_ROUTE_PATHS = [ · 38 unmodified lines
3939 "/policies",
4040 "/secrets",
4141 "/tools",
42+ "/toolkits",
43+ "/toolkits/$toolkitSlug",
4244 "/resume/$executionId",
4345 "/plugins/$pluginId/$",
4446 ] as const;
@@ -71,6 +73,8 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 26 unmodified lines
7173 ["/policies", route("/policies", file("policies.tsx"))],
7274 ["/secrets", route("/secrets", file("secrets.tsx"))],
7375 ["/tools", route("/tools", file("tools.tsx"))],
76+ ["/toolkits", route("/toolkits", file("toolkits.tsx"))],
77+ ["/toolkits/$toolkitSlug", route("/toolkits/$toolkitSlug", file("toolkits.$toolkitSlug.tsx"))],
7478 ["/resume/$executionId", route("/resume/$executionId", file("resume.$executionId.tsx"))],
7579 ["/plugins/$pluginId/$", route("/plugins/$pluginId/$", file("plugins.$pluginId.$.tsx"))],
7680 ];
packages/react/src/multiplayer/shell.tsx+26−2
@@ -21,7 +21,7 @@ import { · 20 unmodified lines
2121 integrationPresetIconUrl,
2222 } from "../components/integration-favicon";
2323 import { CommandPalette } from "../components/command-palette";
24−import { useIntegrationPlugins } from "@executor-js/sdk/client";
24+import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";
2525 import { useAuth } from "./auth-context";
2626
2727 // ---------------------------------------------------------------------------
@@ -49,6 +49,7 @@ export const defaultShellNavItems: ReadonlyArray<ShellNavItem> = [ · 21 unmodified lines
4949 { to: "/", label: "Integrations" },
5050 { to: "/secrets", label: "Providers" },
5151 { to: "/policies", label: "Policies" },
52+ { to: "/toolkits", label: "Toolkits" },
5253 ];
5354
5455 /** Canonical public docs (Mintlify). Same-origin on cloud (executor.sh proxies
@@ -63,6 +64,16 @@ export const DEFAULT_DOCS_URL = "https://executor.sh/docs"; · 8 unmodified lines
6364 // the host's own route tree, which this package can't see.
6465 const orgScopedTo = (to: string): string => (to === "/" ? "/{-$orgSlug}" : `/{-$orgSlug}${to}`);
6566
67+const normalizePluginPagePath = (path: string): string => {
68+ if (!path || path === "/") return "/";
69+ return path.startsWith("/") ? path : `/${path}`;
70+};
71+
72+const pluginPageNavPath = (pluginId: string, path: string): string => {
73+ const normalized = normalizePluginPagePath(path);
74+ return normalized === "/" ? `/plugins/${pluginId}/` : `/plugins/${pluginId}${normalized}`;
75+};
76+
6677 /** The pathname with the active org-slug prefix stripped, for active-state
6778 * comparisons against scope-relative paths. */
6879 function useScopeRelativePathname(): string {
@@ -311,7 +322,20 @@ function UserFooter(props: Pick<ShellProps, "onSignOut" | "orgMenuSlot">) { · 242 unmodified lines
311322 function SidebarContent(
312323 props: ShellProps & { pathname: string; onNavigate?: () => void; showBrand?: boolean },
313324 ) {
314− const navItems = props.navItems ?? defaultShellNavItems;
325+ const plugins = useClientPlugins();
326+ const pluginNavItems = plugins.flatMap((plugin) =>
327+ (plugin.pages ?? []).flatMap((page) =>
328+ page.nav
329+ ? [
330+ {
331+ to: pluginPageNavPath(plugin.id, page.path),
332+ label: page.nav.label,
333+ },
334+ ]
335+ : [],
336+ ),
337+ );
338+ const navItems = [...(props.navItems ?? defaultShellNavItems), ...pluginNavItems];
315339 return (
316340 <>
317341 {props.showBrand !== false && (
packages/react/src/routes/plugin-route-match.test.ts+34−0
@@ -0,0 +1,34 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { matchPluginPage, matchPluginPagePath } from "./plugins.$pluginId.$";
4+
5+describe("plugin page route matching", () => {
6+ it("matches plugin roots", () => {
7+ expect(matchPluginPagePath("/", "/")).toEqual({});
8+ expect(matchPluginPagePath("/", "")).toEqual({});
9+ });
10+
11+ it("matches static plugin subpages", () => {
12+ expect(matchPluginPagePath("/settings", "/settings")).toEqual({});
13+ expect(matchPluginPagePath("/settings", "/other")).toBeNull();
14+ });
15+
16+ it("captures $params from plugin subpages", () => {
17+ expect(matchPluginPagePath("/$toolkitSlug", "/deploy-tools")).toEqual({
18+ toolkitSlug: "deploy-tools",
19+ });
20+ });
21+
22+ it("does not let parameter pages swallow deeper paths", () => {
23+ expect(matchPluginPagePath("/$toolkitSlug", "/deploy-tools/rules")).toBeNull();
24+ });
25+
26+ it("prefers static plugin pages over parameter pages", () => {
27+ const match = matchPluginPage([{ path: "/$id" }, { path: "/settings" }], "/settings");
28+
29+ expect(match).toEqual({
30+ page: { path: "/settings" },
31+ params: {},
32+ });
33+ });
34+});
packages/react/src/routes/plugins.$pluginId.$.tsx+84−12
@@ -1,4 +1,4 @@
1−import { createFileRoute, notFound } from "@tanstack/react-router";
1+import { createFileRoute, Navigate, notFound } from "@tanstack/react-router";
22 import { useClientPlugins } from "@executor-js/sdk/client";
33
44 // ---------------------------------------------------------------------------
@@ -11,10 +11,10 @@ import { useClientPlugins } from "@executor-js/sdk/client"; · 6 unmodified lines
1111 // plugin to `executor.config.ts` is sufficient for its pages to mount
1212 // here, with no per-route imports.
1313 //
14−// Match logic is intentionally tiny: exact path equality between the URL
15−// remainder and a `PageDecl.path`, with `""` and `/` treated as the
16−// same root. Plugins that want parameterized paths can build their own
17−// in-component routing for now.
14+// Plugin pages use the same lightweight route vocabulary as the rest of the
15+// console route tree: static segments and `$param` segments. The host route
16+// owns that matching so plugin detail views can be real URLs instead of
17+// in-component state.
1818 // ---------------------------------------------------------------------------
1919
2020 export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({
@@ -23,21 +23,93 @@ export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({ · 2 unmodified lines
2323
2424 function normalizePath(input: string): string {
2525 if (!input || input === "/") return "/";
26− return input.startsWith("/") ? input : `/${input}`;
26+ const withLeadingSlash = input.startsWith("/") ? input : `/${input}`;
27+ return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/, "") : "/";
2728 }
2829
30+const pathSegments = (input: string): readonly string[] =>
31+ normalizePath(input)
32+ .split("/")
33+ .filter((segment) => segment.length > 0);
34+
35+export const matchPluginPagePath = (
36+ pattern: string,
37+ target: string,
38+): Readonly<Record<string, string>> | null => {
39+ const patternSegments = pathSegments(pattern);
40+ const targetSegments = pathSegments(target);
41+ if (patternSegments.length !== targetSegments.length) return null;
42+
43+ const params: Record<string, string> = {};
44+ for (let index = 0; index < patternSegments.length; index += 1) {
45+ const patternSegment = patternSegments[index]!;
46+ const targetSegment = targetSegments[index]!;
47+ if (patternSegment.startsWith("$") && patternSegment.length > 1) {
48+ params[patternSegment.slice(1)] = decodeURIComponent(targetSegment);
49+ continue;
50+ }
51+ if (patternSegment !== targetSegment) return null;
52+ }
53+ return params;
54+};
55+
56+const matchScore = (pattern: string): number =>
57+ pathSegments(pattern).reduce((score, segment) => score + (segment.startsWith("$") ? 1 : 2), 0);
58+
59+export const matchPluginPage = <TPage extends { readonly path: string }>(
60+ pages: readonly TPage[] | undefined,
61+ target: string,
62+): { readonly page: TPage; readonly params: Readonly<Record<string, string>> } | null => {
63+ const matches =
64+ pages
65+ ?.map((page, index) => ({
66+ page,
67+ index,
68+ params: matchPluginPagePath(page.path, target),
69+ score: matchScore(page.path),
70+ }))
71+ .filter(
72+ (
73+ candidate,
74+ ): candidate is {
75+ readonly page: TPage;
76+ readonly index: number;
77+ readonly params: Readonly<Record<string, string>>;
78+ readonly score: number;
79+ } => candidate.params !== null,
80+ )
81+ .sort((a, b) => b.score - a.score || a.index - b.index) ?? [];
82+ const first = matches[0];
83+ return first ? { page: first.page, params: first.params } : null;
84+};
85+
2986 function PluginRouteComponent() {
30− const { pluginId, _splat: rest } = Route.useParams();
87+ const { orgSlug, pluginId, _splat: rest } = Route.useParams();
3188 const plugins = useClientPlugins();
89+ const target = normalizePath(rest ?? "/");
90+ if (pluginId === "toolkits") {
91+ const segments = pathSegments(target);
92+ const toolkitSlug = segments[0];
93+ if (toolkitSlug) {
94+ return (
95+ <Navigate
96+ to="/{-$orgSlug}/toolkits/$toolkitSlug"
97+ params={{ orgSlug, toolkitSlug }}
98+ replace
99+ />
100+ );
101+ }
102+ return <Navigate to="/{-$orgSlug}/toolkits" params={{ orgSlug }} replace />;
103+ }
104+
32105 const plugin = plugins.find((p) => p.id === pluginId);
33106 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound()
34107 if (!plugin) throw notFound();
35108
36− const target = normalizePath(rest ?? "/");
37− const page = plugin.pages?.find((p) => normalizePath(p.path) === target);
109+ const match = matchPluginPage(plugin.pages, target);
38110 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound()
39− if (!page) throw notFound();
111+ if (!match) throw notFound();
40112
41− const Component = page.component;
42− return <Component />;
113+ const Component = match.page.component;
114+ return <Component params={match.params} path={target} pluginId={pluginId} />;
43115 }
packages/react/src/routes/routeTree.gen.ts+53−0
@@ -11,8 +11,10 @@ · 10 unmodified lines
1111 import { Route as rootRouteImport } from './__root'
1212 import { Route as DotIndexRouteImport } from './index'
1313 import { Route as DotToolsRouteImport } from './tools'
14+import { Route as DotToolkitsRouteImport } from './toolkits'
1415 import { Route as DotSecretsRouteImport } from './secrets'
1516 import { Route as DotPoliciesRouteImport } from './policies'
17+import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug'
1618 import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId'
1719 import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace'
1820 import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$'
@@ -28,6 +30,11 @@ const DotToolsRoute = DotToolsRouteImport.update({ · 9 unmodified lines
2830 path: '/{-$orgSlug}/tools',
2931 getParentRoute: () => rootRouteImport,
3032 } as any)
33+const DotToolkitsRoute = DotToolkitsRouteImport.update({
34+ id: '/{-$orgSlug}/toolkits',
35+ path: '/{-$orgSlug}/toolkits',
36+ getParentRoute: () => rootRouteImport,
37+} as any)
3138 const DotSecretsRoute = DotSecretsRouteImport.update({
3239 id: '/{-$orgSlug}/secrets',
3340 path: '/{-$orgSlug}/secrets',
@@ -38,6 +45,12 @@ const DotPoliciesRoute = DotPoliciesRouteImport.update({ · 4 unmodified lines
3845 path: '/{-$orgSlug}/policies',
3946 getParentRoute: () => rootRouteImport,
4047 } as any)
48+const DotToolkitsDottoolkitSlugRoute =
49+ DotToolkitsDottoolkitSlugRouteImport.update({
50+ id: '/$toolkitSlug',
51+ path: '/$toolkitSlug',
52+ getParentRoute: () => DotToolkitsRoute,
53+ } as any)
4154 const DotResumeDotexecutionIdRoute = DotResumeDotexecutionIdRouteImport.update({
4255 id: '/{-$orgSlug}/resume/$executionId',
4356 path: '/{-$orgSlug}/resume/$executionId',
@@ -65,20 +78,24 @@ const DotIntegrationsDotaddDotpluginKeyRoute = · 21 unmodified lines
6578 export interface FileRoutesByFullPath {
6679 '/{-$orgSlug}/policies': typeof DotPoliciesRoute
6780 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
81+ '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
6882 '/{-$orgSlug}/tools': typeof DotToolsRoute
6983 '/{-$orgSlug}/': typeof DotIndexRoute
7084 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
7185 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
86+ '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
7287 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
7388 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute
7489 }
7590 export interface FileRoutesByTo {
7691 '/{-$orgSlug}/policies': typeof DotPoliciesRoute
7792 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
93+ '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
7894 '/{-$orgSlug}/tools': typeof DotToolsRoute
7995 '/{-$orgSlug}': typeof DotIndexRoute
8096 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
8197 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
98+ '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
8299 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
83100 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute
84101 }
@@ -86,10 +103,12 @@ export interface FileRoutesById { · 1 unmodified line
86103 __root__: typeof rootRouteImport
87104 '/{-$orgSlug}/policies': typeof DotPoliciesRoute
88105 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
106+ '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
89107 '/{-$orgSlug}/tools': typeof DotToolsRoute
90108 '/{-$orgSlug}/': typeof DotIndexRoute
91109 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
92110 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
111+ '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
93112 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
94113 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute
95114 }
@@ -98,30 +117,36 @@ export interface FileRouteTypes { · 2 unmodified lines
98117 fullPaths:
99118 | '/{-$orgSlug}/policies'
100119 | '/{-$orgSlug}/secrets'
120+ | '/{-$orgSlug}/toolkits'
101121 | '/{-$orgSlug}/tools'
102122 | '/{-$orgSlug}/'
103123 | '/{-$orgSlug}/integrations/$namespace'
104124 | '/{-$orgSlug}/resume/$executionId'
125+ | '/{-$orgSlug}/toolkits/$toolkitSlug'
105126 | '/{-$orgSlug}/integrations/add/$pluginKey'
106127 | '/{-$orgSlug}/plugins/$pluginId/$'
107128 fileRoutesByTo: FileRoutesByTo
108129 to:
109130 | '/{-$orgSlug}/policies'
110131 | '/{-$orgSlug}/secrets'
132+ | '/{-$orgSlug}/toolkits'
111133 | '/{-$orgSlug}/tools'
112134 | '/{-$orgSlug}'
113135 | '/{-$orgSlug}/integrations/$namespace'
114136 | '/{-$orgSlug}/resume/$executionId'
137+ | '/{-$orgSlug}/toolkits/$toolkitSlug'
115138 | '/{-$orgSlug}/integrations/add/$pluginKey'
116139 | '/{-$orgSlug}/plugins/$pluginId/$'
117140 id:
118141 | '__root__'
119142 | '/{-$orgSlug}/policies'
120143 | '/{-$orgSlug}/secrets'
144+ | '/{-$orgSlug}/toolkits'
121145 | '/{-$orgSlug}/tools'
122146 | '/{-$orgSlug}/'
123147 | '/{-$orgSlug}/integrations/$namespace'
124148 | '/{-$orgSlug}/resume/$executionId'
149+ | '/{-$orgSlug}/toolkits/$toolkitSlug'
125150 | '/{-$orgSlug}/integrations/add/$pluginKey'
126151 | '/{-$orgSlug}/plugins/$pluginId/$'
127152 fileRoutesById: FileRoutesById
@@ -129,6 +154,7 @@ export interface FileRouteTypes { · 1 unmodified line
129154 export interface RootRouteChildren {
130155 DotPoliciesRoute: typeof DotPoliciesRoute
131156 DotSecretsRoute: typeof DotSecretsRoute
157+ DotToolkitsRoute: typeof DotToolkitsRouteWithChildren
132158 DotToolsRoute: typeof DotToolsRoute
133159 DotIndexRoute: typeof DotIndexRoute
134160 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute
@@ -153,6 +179,13 @@ declare module '@tanstack/react-router' { · 18 unmodified lines
153179 preLoaderRoute: typeof DotToolsRouteImport
154180 parentRoute: typeof rootRouteImport
155181 }
182+ '/{-$orgSlug}/toolkits': {
183+ id: '/{-$orgSlug}/toolkits'
184+ path: '/{-$orgSlug}/toolkits'
185+ fullPath: '/{-$orgSlug}/toolkits'
186+ preLoaderRoute: typeof DotToolkitsRouteImport
187+ parentRoute: typeof rootRouteImport
188+ }
156189 '/{-$orgSlug}/secrets': {
157190 id: '/{-$orgSlug}/secrets'
158191 path: '/{-$orgSlug}/secrets'
@@ -167,6 +200,13 @@ declare module '@tanstack/react-router' { · 8 unmodified lines
167200 preLoaderRoute: typeof DotPoliciesRouteImport
168201 parentRoute: typeof rootRouteImport
169202 }
203+ '/{-$orgSlug}/toolkits/$toolkitSlug': {
204+ id: '/{-$orgSlug}/toolkits/$toolkitSlug'
205+ path: '/$toolkitSlug'
206+ fullPath: '/{-$orgSlug}/toolkits/$toolkitSlug'
207+ preLoaderRoute: typeof DotToolkitsDottoolkitSlugRouteImport
208+ parentRoute: typeof DotToolkitsRoute
209+ }
170210 '/{-$orgSlug}/resume/$executionId': {
171211 id: '/{-$orgSlug}/resume/$executionId'
172212 path: '/{-$orgSlug}/resume/$executionId'
@@ -198,9 +238,22 @@ declare module '@tanstack/react-router' { · 25 unmodified lines
198238 }
199239 }
200240
241+interface DotToolkitsRouteChildren {
242+ DotToolkitsDottoolkitSlugRoute: typeof DotToolkitsDottoolkitSlugRoute
243+}
244+
245+const DotToolkitsRouteChildren: DotToolkitsRouteChildren = {
246+ DotToolkitsDottoolkitSlugRoute: DotToolkitsDottoolkitSlugRoute,
247+}
248+
249+const DotToolkitsRouteWithChildren = DotToolkitsRoute._addFileChildren(
250+ DotToolkitsRouteChildren,
251+)
252+
201253 const rootRouteChildren: RootRouteChildren = {
202254 DotPoliciesRoute: DotPoliciesRoute,
203255 DotSecretsRoute: DotSecretsRoute,
256+ DotToolkitsRoute: DotToolkitsRouteWithChildren,
204257 DotToolsRoute: DotToolsRoute,
205258 DotIndexRoute: DotIndexRoute,
206259 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,
packages/react/src/routes/toolkits-route.tsx+23−0
@@ -0,0 +1,23 @@
1+import { notFound } from "@tanstack/react-router";
2+import { useClientPlugins } from "@executor-js/sdk/client";
3+
4+export function ToolkitsPluginRoute(props: { toolkitSlug?: string }) {
5+ const plugins = useClientPlugins();
6+ const plugin = plugins.find((candidate) => candidate.id === "toolkits");
7+ const path = props.toolkitSlug ? `/${props.toolkitSlug}` : "/";
8+ const page = plugin?.pages?.find((candidate) =>
9+ props.toolkitSlug ? candidate.path === "/$toolkitSlug" : candidate.path === "/",
10+ );
11+
12+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound()
13+ if (!plugin || !page) throw notFound();
14+
15+ const Component = page.component;
16+ return (
17+ <Component
18+ params={props.toolkitSlug ? { toolkitSlug: props.toolkitSlug } : {}}
19+ path={path}
20+ pluginId={plugin.id}
21+ />
22+ );
23+}
packages/react/src/routes/toolkits.$toolkitSlug.tsx+12−0
@@ -0,0 +1,12 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+
3+import { ToolkitsPluginRoute } from "./toolkits-route";
4+
5+export const Route = createFileRoute("/{-$orgSlug}/toolkits/$toolkitSlug")({
6+ component: ToolkitsRouteComponent,
7+});
8+
9+function ToolkitsRouteComponent() {
10+ const { toolkitSlug } = Route.useParams();
11+ return <ToolkitsPluginRoute toolkitSlug={toolkitSlug} />;
12+}
packages/react/src/routes/toolkits.tsx+12−0
@@ -0,0 +1,12 @@
1+import { createFileRoute, useParams } from "@tanstack/react-router";
2+
3+import { ToolkitsPluginRoute } from "./toolkits-route";
4+
5+export const Route = createFileRoute("/{-$orgSlug}/toolkits")({
6+ component: ToolkitsRouteComponent,
7+});
8+
9+function ToolkitsRouteComponent() {
10+ const { toolkitSlug } = useParams({ strict: false }) as { toolkitSlug?: string };
11+ return <ToolkitsPluginRoute toolkitSlug={toolkitSlug} />;
12+}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t