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
5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
7import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault";7import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault";
8import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
8 9
9// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
10// Single source of truth for the cloud app's plugin list.11// Single source of truth for the cloud app's plugin list.
@@ -40,10 +41,11 @@ interface CloudPluginDeps { · 29 unmodified lines
40 * bypass the real WorkOS API. Production leaves this undefined and41 * bypass the real WorkOS API. Production leaves this undefined and
41 * falls back to the credential-driven default. */42 * falls back to the credential-driven default. */
42 readonly workosVaultClient?: WorkOSVaultClient;43 readonly workosVaultClient?: WorkOSVaultClient;
44 readonly activeToolkitSlug?: string;
43}45}
44 46
45export default defineExecutorConfig({47export default defineExecutorConfig({
46 plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) =>48 plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) =>
47 [49 [
48 openApiHttpPlugin(),50 openApiHttpPlugin(),
49 googleHttpPlugin(),51 googleHttpPlugin(),
@@ -52,6 +54,7 @@ export default defineExecutorConfig({ · 2 unmodified lines
52 dangerouslyAllowStdioMCP: false,54 dangerouslyAllowStdioMCP: false,
53 }),55 }),
54 graphqlHttpPlugin(),56 graphqlHttpPlugin(),
57 toolkitsPlugin({ activeToolkitSlug }),
55 workosVaultPlugin({58 workosVaultPlugin({
56 credentials: workosCredentials ?? { apiKey: "", clientId: "" },59 credentials: workosCredentials ?? { apiKey: "", clientId: "" },
57 ...(workosVaultClient ? { client: workosVaultClient } : {}),60 ...(workosVaultClient ? { client: workosVaultClient } : {}),
apps/cloud/package.json+1−0
@@ -46,6 +46,7 @@ · 45 unmodified lines
46 "@executor-js/plugin-mcp": "workspace:*",46 "@executor-js/plugin-mcp": "workspace:*",
47 "@executor-js/plugin-microsoft": "workspace:*",47 "@executor-js/plugin-microsoft": "workspace:*",
48 "@executor-js/plugin-openapi": "workspace:*",48 "@executor-js/plugin-openapi": "workspace:*",
49 "@executor-js/plugin-toolkits": "workspace:*",
49 "@executor-js/plugin-workos-vault": "workspace:*",50 "@executor-js/plugin-workos-vault": "workspace:*",
50 "@executor-js/react": "workspace:*",51 "@executor-js/react": "workspace:*",
51 "@executor-js/runtime-dynamic-worker": "workspace:*",52 "@executor-js/runtime-dynamic-worker": "workspace:*",
apps/cloud/src/app-paths.test.ts+8−0
@@ -18,14 +18,20 @@ describe("isAppOwnedPath", () => { · 17 unmodified lines
18 "/api/billing/attach",18 "/api/billing/attach",
19 "/api/docs", // Swagger UI19 "/api/docs", // Swagger UI
20 "/mcp",20 "/mcp",
21 "/mcp/toolkits/deploy-kit",
21 "/.well-known/oauth-protected-resource/mcp",22 "/.well-known/oauth-protected-resource/mcp",
23 "/.well-known/oauth-protected-resource/mcp/toolkits/deploy-kit",
22 "/.well-known/oauth-authorization-server",24 "/.well-known/oauth-authorization-server",
23 // Org-pinned MCP: the org's URL slug (what the install card prints) and25 // Org-pinned MCP: the org's URL slug (what the install card prints) and
24 // the legacy WorkOS org-id form both select an org on the MCP plane.26 // the legacy WorkOS org-id form both select an org on the MCP plane.
25 "/acme-corp/mcp",27 "/acme-corp/mcp",
28 "/acme-corp/mcp/toolkits/deploy-kit",
26 "/org_01ABCDEF/mcp",29 "/org_01ABCDEF/mcp",
30 "/org_01ABCDEF/mcp/toolkits/deploy-kit",
27 "/.well-known/oauth-protected-resource/acme-corp/mcp",31 "/.well-known/oauth-protected-resource/acme-corp/mcp",
32 "/.well-known/oauth-protected-resource/acme-corp/mcp/toolkits/deploy-kit",
28 "/.well-known/oauth-protected-resource/org_01ABCDEF/mcp",33 "/.well-known/oauth-protected-resource/org_01ABCDEF/mcp",
34 "/.well-known/oauth-protected-resource/org_01ABCDEF/mcp/toolkits/deploy-kit",
29 ];35 ];
30 for (const pathname of appOwned) {36 for (const pathname of appOwned) {
31 it(`forwards ${pathname} to the app handler`, () => {37 it(`forwards ${pathname} to the app handler`, () => {
@@ -46,7 +52,9 @@ describe("isAppOwnedPath", () => { · 14 unmodified lines
46 "/org",52 "/org",
47 "/assets/app.js",53 "/assets/app.js",
48 "/settings/mcp",54 "/settings/mcp",
55 "/settings/mcp/toolkits/deploy-kit",
49 "/integrations/mcp",56 "/integrations/mcp",
57 "/integrations/mcp/toolkits/deploy-kit",
50 ];58 ];
51 for (const pathname of startOwned) {59 for (const pathname of startOwned) {
52 it(`leaves ${pathname} to the Start router`, () => {60 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
57// Fresh plugin instances per request, carrying the Worker env's WorkOS Vault57// Fresh plugin instances per request, carrying the Worker env's WorkOS Vault
58// credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`.58// credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`.
59export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)({59export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)({
60 plugins: () =>60 plugins: (context) =>
61 executorConfig.plugins({61 executorConfig.plugins({
62 workosCredentials: {62 workosCredentials: {
63 apiKey: env.WORKOS_API_KEY,63 apiKey: env.WORKOS_API_KEY,
64 clientId: env.WORKOS_CLIENT_ID,64 clientId: env.WORKOS_CLIENT_ID,
65 apiUrl: env.WORKOS_API_URL,65 apiUrl: env.WORKOS_API_URL,
66 },66 },
67 activeToolkitSlug:
68 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
67 }),69 }),
68});70});
69 71
apps/cloud/src/mcp/auth-provider.ts+31−3
@@ -43,6 +43,7 @@ import { · 42 unmodified lines
43 mcpOrganizationFromRequest,43 mcpOrganizationFromRequest,
44 protectedResourceMetadataUrlFor,44 protectedResourceMetadataUrlFor,
45 PROTECTED_RESOURCE_METADATA_PATH,45 PROTECTED_RESOURCE_METADATA_PATH,
46 toolkitSlugFromRequest,
46 McpAuth,47 McpAuth,
47 McpAuthLive,48 McpAuthLive,
48 McpOrganizationAuth,49 McpOrganizationAuth,
@@ -57,6 +58,7 @@ import { · 8 unmodified lines
57} from "./oauth-metadata";58} from "./oauth-metadata";
58 59
59const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";60const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
61const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`;
60 62
61const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first";63const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first";
62 64
@@ -94,7 +96,22 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 31 unmodified lines
94 // The bare path is the only one mounted; `prepareMcpOrgScope` rewrites an96 // The bare path is the only one mounted; `prepareMcpOrgScope` rewrites an
95 // org-scoped discovery doc onto it and pins the org in the header we read.97 // org-scoped discovery doc onto it and pins the org in the header we read.
96 handler: (request) =>98 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 ),
98 },115 },
99 {116 {
100 path: AUTHORIZATION_SERVER_METADATA_PATH,117 path: AUTHORIZATION_SERVER_METADATA_PATH,
@@ -103,7 +120,10 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 2 unmodified lines
103 ];120 ];
104 121
105 const resourceMetadataUrl = (request: Request): string =>122 const resourceMetadataUrl = (request: Request): string =>
106 protectedResourceMetadataUrlFor(mcpOrganizationFromRequest(request));123 protectedResourceMetadataUrlFor(
124 mcpOrganizationFromRequest(request),
125 toolkitSlugFromRequest(request),
126 );
107 127
108 /**128 /**
109 * Resolve a verified bearer to a final AuthOutcome by running the live org129 * 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
160 return finishAuthorized(request, result.token);180 return finishAuthorized(request, result.token);
161 }181 }
162 return annotateMcpRequest(request, { token: null, parseBody: false }).pipe(182 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 ),
164 );192 );
165 };193 };
166 194
apps/cloud/src/mcp/auth.ts+31−7
@@ -50,6 +50,7 @@ const MCP_PATH = "/mcp"; · 49 unmodified lines
50export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp";50export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp";
51export const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`;51export const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`;
52export const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`;52export const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`;
53const TOOLKIT_SEGMENT = "/toolkits/";
53 54
54// ---------------------------------------------------------------------------55// ---------------------------------------------------------------------------
55// Org-scoped MCP (the URL pins an org: `/org_xxx/mcp`)56// 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
68export const mcpOrganizationFromRequest = (request: Request): string | null =>69export const mcpOrganizationFromRequest = (request: Request): string | null =>
69 request.headers.get(MCP_ORGANIZATION_HEADER);70 request.headers.get(MCP_ORGANIZATION_HEADER);
70 71
72/** The toolkit slug selected by `/mcp/toolkits/:slug` or its metadata doc. */
73export 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
81const toolkitMcpPath = (toolkitSlug: string | null): string =>
82 toolkitSlug ? `${MCP_PATH}/toolkits/${toolkitSlug}` : MCP_PATH;
83
71/** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy84/** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy
72 * `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client85 * `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client
73 * used), or the bare resource. */86 * used), or the bare resource. */
74export const resourceUrlFor = (organizationSelector: string | null): string =>87export const resourceUrlFor = (
75 organizationSelector ? `${RESOURCE_ORIGIN}/${organizationSelector}${MCP_PATH}` : RESOURCE_URL;88 organizationSelector: string | null,
89 toolkitSlug: string | null = null,
90): string =>
91 organizationSelector
92 ? `${RESOURCE_ORIGIN}/${organizationSelector}${toolkitMcpPath(toolkitSlug)}`
93 : `${RESOURCE_ORIGIN}${toolkitMcpPath(toolkitSlug)}`;
76 94
77/** The protected-resource-metadata URL for an org selector, or the bare one. */95/** The protected-resource-metadata URL for an org selector, or the bare one. */
78export const protectedResourceMetadataUrlFor = (organizationSelector: string | null): string =>96export const protectedResourceMetadataUrlFor = (
79 organizationSelector97 organizationSelector: string | null,
80 ? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationSelector}/mcp`98 toolkitSlug: string | null = null,
81 : PROTECTED_RESOURCE_METADATA_URL;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};
82 105
83type McpUnauthorizedReason = "missing_bearer" | "invalid_token";106type McpUnauthorizedReason = "missing_bearer" | "invalid_token";
84 107
@@ -117,10 +140,11 @@ export const mcpUnauthorized = ( · 32 unmodified lines
117export const bearerChallengeFor = (140export const bearerChallengeFor = (
118 result: McpUnauthorizedResult,141 result: McpUnauthorizedResult,
119 organizationId: string | null = null,142 organizationId: string | null = null,
143 toolkitSlug: string | null = null,
120): string =>144): string =>
121 bearerChallenge(145 bearerChallenge(
122 { reason: result.reason, description: result.description },146 { reason: result.reason, description: result.description },
123 protectedResourceMetadataUrlFor(organizationId),147 protectedResourceMetadataUrlFor(organizationId, toolkitSlug),
124 );148 );
125 149
126// ---------------------------------------------------------------------------150// ---------------------------------------------------------------------------
apps/cloud/src/mcp/mount.test.ts+57−0
@@ -0,0 +1,57 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { protectedResourceMetadataUrlFor, resourceUrlFor, toolkitSlugFromRequest } from "./auth";
4import { classifyMcpPath, prepareMcpOrgScope } from "./mount";
5
6describe("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
50 * id), or `null` for the bare path. Resolved to an org id — and re-checked50 * id), or `null` for the bare path. Resolved to an org id — and re-checked
51 * against live membership — in the auth provider. */51 * against live membership — in the auth provider. */
52 readonly organizationId: string | null;52 readonly organizationId: string | null;
53 readonly toolkitSlug?: string;
53} | null;54} | null;
54 55
55// A path segment counts as an org selector when it's the org's URL slug (the56// 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
60const orgSelectorSegment = (segment: string | undefined): string | null =>61const orgSelectorSegment = (segment: string | undefined): string | null =>
61 segment && (segment.startsWith("org_") || isValidOrgSlug(segment)) ? segment : null;62 segment && (segment.startsWith("org_") || isValidOrgSlug(segment)) ? segment : null;
62 63
63// Matches a trailing MCP endpoint — `mcp` (bare) or `<org>/mcp`. Returns the org64type MatchedMcpSuffix = {
64// selector, `null` for the bare form, or `undefined` when the segments are neither.65 readonly organizationId: string | null;
65const matchMcpSuffix = (segments: readonly string[]): string | null | undefined => {66 readonly toolkitSlug?: string;
66 if (segments.length === 1 && segments[0] === "mcp") return null;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.
71const 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 }
67 if (segments.length === 2 && segments[1] === "mcp") {77 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;
69 }85 }
70 return undefined;86 return undefined;
71};87};
@@ -93,22 +109,24 @@ export const classifyMcpPath = (pathname: string): McpRoute => { · 21 unmodified lines
93 // org sits after the well-known prefix (RFC 9728), not at the path root.109 // org sits after the well-known prefix (RFC 9728), not at the path root.
94 const prmPrefix = "/.well-known/oauth-protected-resource";110 const prmPrefix = "/.well-known/oauth-protected-resource";
95 if (pathname.startsWith(`${prmPrefix}/`)) {111 if (pathname.startsWith(`${prmPrefix}/`)) {
96 const organizationId = matchMcpSuffix(segments.slice(2));112 const matched = matchMcpSuffix(segments.slice(2));
97 return organizationId === undefined113 return matched === undefined ? null : { kind: "oauth-protected-resource", ...matched };
98 ? null
99 : { kind: "oauth-protected-resource", organizationId };
100 }114 }
101 115
102 // MCP transport: `/mcp` or `/<org>/mcp`.116 // MCP transport: `/mcp` or `/<org>/mcp`.
103 const organizationId = matchMcpSuffix(segments);117 const matched = matchMcpSuffix(segments);
104 return organizationId === undefined ? null : { kind: "mcp", organizationId };118 return matched === undefined ? null : { kind: "mcp", ...matched };
105};119};
106 120
107const bareMcpPath = (kind: McpRouteKind): string =>121const bareMcpPath = (route: Exclude<McpRoute, null>): string =>
108 kind === "mcp"122 route.kind === "mcp"
109 ? MCP_PATH123 ? route.toolkitSlug
110 : kind === "oauth-protected-resource"124 ? `${MCP_PATH}/toolkits/${route.toolkitSlug}`
111 ? PROTECTED_RESOURCE_METADATA_PATH125 : MCP_PATH
126 : route.kind === "oauth-protected-resource"
127 ? route.toolkitSlug
128 ? `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/${route.toolkitSlug}`
129 : PROTECTED_RESOURCE_METADATA_PATH
112 : AUTHORIZATION_SERVER_METADATA_PATH;130 : AUTHORIZATION_SERVER_METADATA_PATH;
113 131
114/**132/**
@@ -125,7 +143,7 @@ export const prepareMcpOrgScope = (request: Request): Request => { · 10 unmodified lines
125 const url = new URL(request.url);143 const url = new URL(request.url);
126 const route = classifyMcpPath(url.pathname);144 const route = classifyMcpPath(url.pathname);
127 if (route === null) return request;145 if (route === null) return request;
128 const bare = bareMcpPath(route.kind);146 const bare = bareMcpPath(route);
129 if (url.pathname === bare && !request.headers.has(MCP_ORGANIZATION_HEADER)) return request;147 if (url.pathname === bare && !request.headers.has(MCP_ORGANIZATION_HEADER)) return request;
130 url.pathname = bare;148 url.pathname = bare;
131 const rewritten = new Request(url, request);149 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
17// The `resource` reflects the URL-pinned org (`…/org_xxx/mcp`) when present, so a17// The `resource` reflects the URL-pinned org (`…/org_xxx/mcp`) when present, so a
18// client that discovered metadata via the org-scoped well-known doc gets back the18// client that discovered metadata via the org-scoped well-known doc gets back the
19// matching org-scoped resource id; the bare path yields the bare resource.19// matching org-scoped resource id; the bare path yields the bare resource.
20export const protectedResourceMetadataResponse = (organizationId: string | null = null): Response =>20export const protectedResourceMetadataResponse = (
21 organizationId: string | null = null,
22 toolkitSlug: string | null = null,
23): Response =>
21 jsonWebResponse({24 jsonWebResponse({
22 resource: resourceUrlFor(organizationId),25 resource: resourceUrlFor(organizationId, toolkitSlug),
23 authorization_servers: [AUTHKIT_DOMAIN],26 authorization_servers: [AUTHKIT_DOMAIN],
24 bearer_methods_supported: ["header"],27 bearer_methods_supported: ["header"],
25 // Spec-faithful clients (OpenCode, mcporter) request exactly what is28 // 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
172 organizationName: org.name,172 organizationName: org.name,
173 organizationSlug: org.slug,173 organizationSlug: org.slug,
174 userId: token.userId,174 userId: token.userId,
175 resource: token.resource,
175 elicitationMode: token.elicitationMode,176 elicitationMode: token.elicitationMode,
176 } satisfies SessionMeta;177 } satisfies SessionMeta;
177 }).pipe(178 }).pipe(
@@ -193,6 +194,7 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> { · 15 unmodified lines
193 sessionMeta.userId,194 sessionMeta.userId,
194 sessionMeta.organizationId,195 sessionMeta.organizationId,
195 sessionMeta.organizationName,196 sessionMeta.organizationName,
197 { mcpResource: sessionMeta.resource },
196 ).pipe(198 ).pipe(
197 Effect.provide(CloudExecutionStackLayer),199 Effect.provide(CloudExecutionStackLayer),
198 Effect.withSpan("McpSessionDO.makeExecutionStack"),200 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
14import { Route as CreateOrgRouteImport } from './routes/bare/create-org'14import { Route as CreateOrgRouteImport } from './routes/bare/create-org'
15import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'15import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'
16import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'16import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'
17import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'
17import { Route as SecretsRouteImport } from './routes/app/secrets'18import { Route as SecretsRouteImport } from './routes/app/secrets'
18import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies'19import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies'
19import { Route as OrgRouteImport } from './routes/app/org'20import { Route as OrgRouteImport } from './routes/app/org'
20import { Route as BillingRouteImport } from './routes/app/billing'21import { Route as BillingRouteImport } from './routes/app/billing'
21import { Route as ApiKeysRouteImport } from './routes/app/api-keys'22import { Route as ApiKeysRouteImport } from './routes/app/api-keys'
23import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
22import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId'24import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId'
23import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'25import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
24import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans'26import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans'
@@ -51,6 +53,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = · 26 unmodified lines
51 path: '/{-$orgSlug}/tools',53 path: '/{-$orgSlug}/tools',
52 getParentRoute: () => rootRouteImport,54 getParentRoute: () => rootRouteImport,
53 } as any)55 } as any)
56const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute =
57 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport.update({
58 id: '/{-$orgSlug}/toolkits',
59 path: '/{-$orgSlug}/toolkits',
60 getParentRoute: () => rootRouteImport,
61 } as any)
54const SecretsRoute = SecretsRouteImport.update({62const SecretsRoute = SecretsRouteImport.update({
55 id: '/{-$orgSlug}/secrets',63 id: '/{-$orgSlug}/secrets',
56 path: '/{-$orgSlug}/secrets',64 path: '/{-$orgSlug}/secrets',
@@ -77,6 +85,15 @@ const ApiKeysRoute = ApiKeysRouteImport.update({ · 20 unmodified lines
77 path: '/{-$orgSlug}/api-keys',85 path: '/{-$orgSlug}/api-keys',
78 getParentRoute: () => rootRouteImport,86 getParentRoute: () => rootRouteImport,
79} as any)87} as any)
88const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute =
89 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update(
90 {
91 id: '/$toolkitSlug',
92 path: '/$toolkitSlug',
93 getParentRoute: () =>
94 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute,
95 } as any,
96 )
80const ResumeDotexecutionIdRoute = ResumeDotexecutionIdRouteImport.update({97const ResumeDotexecutionIdRoute = ResumeDotexecutionIdRouteImport.update({
81 id: '/{-$orgSlug}/resume/$executionId',98 id: '/{-$orgSlug}/resume/$executionId',
82 path: '/{-$orgSlug}/resume/$executionId',99 path: '/{-$orgSlug}/resume/$executionId',
@@ -113,11 +130,13 @@ export interface FileRoutesByFullPath { · 30 unmodified lines
113 '/{-$orgSlug}/org': typeof OrgRoute130 '/{-$orgSlug}/org': typeof OrgRoute
114 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute131 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
115 '/{-$orgSlug}/secrets': typeof SecretsRoute132 '/{-$orgSlug}/secrets': typeof SecretsRoute
133 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
116 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute134 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
117 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute135 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
118 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute136 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
119 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute137 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
120 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute138 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
139 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
121 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute140 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
122}141}
123export interface FileRoutesByTo {142export interface FileRoutesByTo {
@@ -129,11 +148,13 @@ export interface FileRoutesByTo { · 5 unmodified lines
129 '/{-$orgSlug}/org': typeof OrgRoute148 '/{-$orgSlug}/org': typeof OrgRoute
130 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute149 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
131 '/{-$orgSlug}/secrets': typeof SecretsRoute150 '/{-$orgSlug}/secrets': typeof SecretsRoute
151 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
132 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute152 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
133 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute153 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
134 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute154 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
135 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute155 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
136 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute156 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
157 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
137 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute158 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
138}159}
139export interface FileRoutesById {160export interface FileRoutesById {
@@ -146,11 +167,13 @@ export interface FileRoutesById { · 6 unmodified lines
146 '/{-$orgSlug}/org': typeof OrgRoute167 '/{-$orgSlug}/org': typeof OrgRoute
147 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute168 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
148 '/{-$orgSlug}/secrets': typeof SecretsRoute169 '/{-$orgSlug}/secrets': typeof SecretsRoute
170 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
149 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute171 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
150 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute172 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
151 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute173 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute
152 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute174 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
153 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute175 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
176 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
154 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute177 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
155}178}
156export interface FileRouteTypes {179export interface FileRouteTypes {
@@ -164,11 +187,13 @@ export interface FileRouteTypes { · 7 unmodified lines
164 | '/{-$orgSlug}/org'187 | '/{-$orgSlug}/org'
165 | '/{-$orgSlug}/policies'188 | '/{-$orgSlug}/policies'
166 | '/{-$orgSlug}/secrets'189 | '/{-$orgSlug}/secrets'
190 | '/{-$orgSlug}/toolkits'
167 | '/{-$orgSlug}/tools'191 | '/{-$orgSlug}/tools'
168 | '/{-$orgSlug}/'192 | '/{-$orgSlug}/'
169 | '/{-$orgSlug}/billing/plans'193 | '/{-$orgSlug}/billing/plans'
170 | '/{-$orgSlug}/integrations/$namespace'194 | '/{-$orgSlug}/integrations/$namespace'
171 | '/{-$orgSlug}/resume/$executionId'195 | '/{-$orgSlug}/resume/$executionId'
196 | '/{-$orgSlug}/toolkits/$toolkitSlug'
172 | '/{-$orgSlug}/integrations/add/$pluginKey'197 | '/{-$orgSlug}/integrations/add/$pluginKey'
173 fileRoutesByTo: FileRoutesByTo198 fileRoutesByTo: FileRoutesByTo
174 to:199 to:
@@ -180,11 +205,13 @@ export interface FileRouteTypes { · 5 unmodified lines
180 | '/{-$orgSlug}/org'205 | '/{-$orgSlug}/org'
181 | '/{-$orgSlug}/policies'206 | '/{-$orgSlug}/policies'
182 | '/{-$orgSlug}/secrets'207 | '/{-$orgSlug}/secrets'
208 | '/{-$orgSlug}/toolkits'
183 | '/{-$orgSlug}/tools'209 | '/{-$orgSlug}/tools'
184 | '/{-$orgSlug}'210 | '/{-$orgSlug}'
185 | '/{-$orgSlug}/billing/plans'211 | '/{-$orgSlug}/billing/plans'
186 | '/{-$orgSlug}/integrations/$namespace'212 | '/{-$orgSlug}/integrations/$namespace'
187 | '/{-$orgSlug}/resume/$executionId'213 | '/{-$orgSlug}/resume/$executionId'
214 | '/{-$orgSlug}/toolkits/$toolkitSlug'
188 | '/{-$orgSlug}/integrations/add/$pluginKey'215 | '/{-$orgSlug}/integrations/add/$pluginKey'
189 id:216 id:
190 | '__root__'217 | '__root__'
@@ -196,11 +223,13 @@ export interface FileRouteTypes { · 5 unmodified lines
196 | '/{-$orgSlug}/org'223 | '/{-$orgSlug}/org'
197 | '/{-$orgSlug}/policies'224 | '/{-$orgSlug}/policies'
198 | '/{-$orgSlug}/secrets'225 | '/{-$orgSlug}/secrets'
226 | '/{-$orgSlug}/toolkits'
199 | '/{-$orgSlug}/tools'227 | '/{-$orgSlug}/tools'
200 | '/{-$orgSlug}/'228 | '/{-$orgSlug}/'
201 | '/{-$orgSlug}/billing_/plans'229 | '/{-$orgSlug}/billing_/plans'
202 | '/{-$orgSlug}/integrations/$namespace'230 | '/{-$orgSlug}/integrations/$namespace'
203 | '/{-$orgSlug}/resume/$executionId'231 | '/{-$orgSlug}/resume/$executionId'
232 | '/{-$orgSlug}/toolkits/$toolkitSlug'
204 | '/{-$orgSlug}/integrations/add/$pluginKey'233 | '/{-$orgSlug}/integrations/add/$pluginKey'
205 fileRoutesById: FileRoutesById234 fileRoutesById: FileRoutesById
206}235}
@@ -213,6 +242,7 @@ export interface RootRouteChildren { · 6 unmodified lines
213 OrgRoute: typeof OrgRoute242 OrgRoute: typeof OrgRoute
214 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute243 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
215 SecretsRoute: typeof SecretsRoute244 SecretsRoute: typeof SecretsRoute
245 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
216 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute246 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
217 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute247 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
218 Billing_DotplansRoute: typeof Billing_DotplansRoute248 Billing_DotplansRoute: typeof Billing_DotplansRoute
@@ -258,6 +288,13 @@ declare module '@tanstack/react-router' { · 39 unmodified lines
258 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport288 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport
259 parentRoute: typeof rootRouteImport289 parentRoute: typeof rootRouteImport
260 }290 }
291 '/{-$orgSlug}/toolkits': {
292 id: '/{-$orgSlug}/toolkits'
293 path: '/{-$orgSlug}/toolkits'
294 fullPath: '/{-$orgSlug}/toolkits'
295 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport
296 parentRoute: typeof rootRouteImport
297 }
261 '/{-$orgSlug}/secrets': {298 '/{-$orgSlug}/secrets': {
262 id: '/{-$orgSlug}/secrets'299 id: '/{-$orgSlug}/secrets'
263 path: '/{-$orgSlug}/secrets'300 path: '/{-$orgSlug}/secrets'
@@ -293,6 +330,13 @@ declare module '@tanstack/react-router' { · 29 unmodified lines
293 preLoaderRoute: typeof ApiKeysRouteImport330 preLoaderRoute: typeof ApiKeysRouteImport
294 parentRoute: typeof rootRouteImport331 parentRoute: typeof rootRouteImport
295 }332 }
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 }
296 '/{-$orgSlug}/resume/$executionId': {340 '/{-$orgSlug}/resume/$executionId': {
297 id: '/{-$orgSlug}/resume/$executionId'341 id: '/{-$orgSlug}/resume/$executionId'
298 path: '/{-$orgSlug}/resume/$executionId'342 path: '/{-$orgSlug}/resume/$executionId'
@@ -324,6 +368,21 @@ declare module '@tanstack/react-router' { · 25 unmodified lines
324 }368 }
325}369}
326 370
371interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren {
372 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
373}
374
375const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren =
376 {
377 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute:
378 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute,
379 }
380
381const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren =
382 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute._addFileChildren(
383 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren,
384 )
385
327const rootRouteChildren: RootRouteChildren = {386const rootRouteChildren: RootRouteChildren = {
328 CreateOrgRoute: CreateOrgRoute,387 CreateOrgRoute: CreateOrgRoute,
329 LoginRoute: LoginRoute,388 LoginRoute: LoginRoute,
@@ -334,6 +393,8 @@ const rootRouteChildren: RootRouteChildren = { · 4 unmodified lines
334 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute:393 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute:
335 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute,394 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute,
336 SecretsRoute: SecretsRoute,395 SecretsRoute: SecretsRoute,
396 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute:
397 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,
337 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:398 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:
338 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,399 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
339 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:400 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
@@ -350,11 +411,15 @@ export const routeTree = rootRouteImport · 10 unmodified lines
350 ._addFileTypes<FileRouteTypes>()411 ._addFileTypes<FileRouteTypes>()
351 412
352import type { getRouter } from './router.tsx'413import type { getRouter } from './router.tsx'
414
353import type { startInstance } from './start.ts'415import type { startInstance } from './start.ts'
416
354declare module '@tanstack/react-start' {417declare module '@tanstack/react-start' {
355 interface Register {418 interface Register {
356 ssr: true419 ssr: true
420
357 router: Awaited<ReturnType<typeof getRouter>>421 router: Awaited<ReturnType<typeof getRouter>>
422
358 config: Awaited<ReturnType<typeof startInstance.getOptions>>423 config: Awaited<ReturnType<typeof startInstance.getOptions>>
359 }424 }
360}425}
apps/host-cloudflare/executor.config.ts+3−1
@@ -5,6 +5,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; · 4 unmodified lines
5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
7import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";7import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
8import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
8 9
9// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
10// Plugin list for the Cloudflare web build. The Vite `executorVitePlugin` reads11// 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
16// ---------------------------------------------------------------------------17// ---------------------------------------------------------------------------
17 18
18export default defineExecutorConfig({19export default defineExecutorConfig({
19 plugins: () =>20 plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) =>
20 [21 [
21 openApiHttpPlugin(),22 openApiHttpPlugin(),
22 googleHttpPlugin(),23 googleHttpPlugin(),
23 microsoftHttpPlugin(),24 microsoftHttpPlugin(),
24 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),25 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
25 graphqlHttpPlugin(),26 graphqlHttpPlugin(),
27 toolkitsPlugin({ activeToolkitSlug }),
26 encryptedSecretsPlugin({ key: process.env.EXECUTOR_SECRET_KEY ?? "build-time-placeholder" }),28 encryptedSecretsPlugin({ key: process.env.EXECUTOR_SECRET_KEY ?? "build-time-placeholder" }),
27 ] as const,29 ] as const,
28});30});
apps/host-cloudflare/package.json+1−0
@@ -29,6 +29,7 @@ · 28 unmodified lines
29 "@executor-js/plugin-mcp": "workspace:*",29 "@executor-js/plugin-mcp": "workspace:*",
30 "@executor-js/plugin-microsoft": "workspace:*",30 "@executor-js/plugin-microsoft": "workspace:*",
31 "@executor-js/plugin-openapi": "workspace:*",31 "@executor-js/plugin-openapi": "workspace:*",
32 "@executor-js/plugin-toolkits": "workspace:*",
32 "@executor-js/react": "workspace:*",33 "@executor-js/react": "workspace:*",
33 "@executor-js/runtime-quickjs": "workspace:*",34 "@executor-js/runtime-quickjs": "workspace:*",
34 "@executor-js/sdk": "workspace:*",35 "@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
32 } as unknown as D1Database;32 } as unknown as D1Database;
33};33};
34 34
35const makeFakeR2 = (): { readonly bucket: R2Bucket; readonly objects: Map<string, string> } => {35const makeFakeR2 = (): {
36 readonly bucket: R2Bucket;
37 readonly objects: Map<string, string>;
38} => {
36 const objects = new Map<string, string>();39 const objects = new Map<string, string>();
37 // oxlint-disable-next-line executor/no-double-cast -- test double: only the R2 methods used by the migration are implemented40 // oxlint-disable-next-line executor/no-double-cast -- test double: only the R2 methods used by the migration are implemented
38 const bucket = {41 const bucket = {
@@ -75,6 +78,23 @@ const insertIntegration = ( · 36 unmodified lines
75 ],78 ],
76 });79 });
77 80
81const 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
78const insertOperationStorage = (98const insertOperationStorage = (
79 client: SqliteDataMigrationClient,99 client: SqliteDataMigrationClient,
80 row: {100 row: {
@@ -103,6 +123,134 @@ const insertOperationStorage = ( · 22 unmodified lines
103 });123 });
104 124
105describe("runCloudflareDataMigrations", () => {125describe("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
106 it.effect("moves Google OpenAPI ownership and copies the R2 spec object", () =>254 it.effect("moves Google OpenAPI ownership and copies the R2 spec object", () =>
107 Effect.gen(function* () {255 Effect.gen(function* () {
108 const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));256 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
20 const sql = typeof stmt === "string" ? stmt : stmt.sql;20 const sql = typeof stmt === "string" ? stmt : stmt.sql;
21 const args = typeof stmt === "string" ? [] : stmt.args;21 const args = typeof stmt === "string" ? [] : stmt.args;
22 const prepared = session.prepare(sql).bind(...args);22 const prepared = session.prepare(sql).bind(...args);
23 if (firstWord(sql) === "SELECT") {23 if (firstWord(sql) === "SELECT" || firstWord(sql) === "PRAGMA") {
24 return prepared.all<T>().then((result) => result.results);24 return prepared.all<T>().then((result) => result.results);
25 }25 }
26 return prepared.run<T>().then(() => []);26 return prepared.run<T>().then(() => []);
@@ -38,6 +38,93 @@ export const d1DataMigrationClient = (db: D1Database): SqliteDataMigrationClient · 11 unmodified lines
38 };38 };
39};39};
40 40
41const 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
53const 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
104export 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
41const r2ObjectName = (tenant: string, pluginId: string, key: string): string =>128const r2ObjectName = (tenant: string, pluginId: string, key: string): string =>
42 `o:${tenant}/${pluginId}/${key}`;129 `o:${tenant}/${pluginId}/${key}`;
43 130
@@ -52,6 +139,7 @@ const copyGoogleOpenApiSpecBlobsToR2 = ( · 8 unmodified lines
52 FROM integration139 FROM integration
53 WHERE plugin_id = 'openapi'140 WHERE plugin_id = 'openapi'
54 AND config IS NOT NULL141 AND config IS NOT NULL
142 AND json_valid(config)
55 AND json_type(config, '$.googleDiscoveryUrls') = 'array'143 AND json_type(config, '$.googleDiscoveryUrls') = 'array'
56 AND json_extract(config, '$.specHash') IS NOT NULL144 AND json_extract(config, '$.specHash') IS NOT NULL
57 AND json_extract(config, '$.specHash') <> ''`,145 AND json_extract(config, '$.specHash') <> ''`,
@@ -67,7 +155,10 @@ const copyGoogleOpenApiSpecBlobsToR2 = ( · 9 unmodified lines
67 }155 }
68 },156 },
69 catch: (cause) =>157 catch: (cause) =>
70 new DataMigrationError({ migration: googleOpenApiOwnershipDataMigration.name, cause }),158 new DataMigrationError({
159 migration: googleOpenApiOwnershipDataMigration.name,
160 cause,
161 }),
71 });162 });
72 163
73const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [164const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [
@@ -86,5 +177,9 @@ export const runCloudflareDataMigrations = ( · 12 unmodified lines
86 bucket: R2Bucket | undefined,177 bucket: R2Bucket | undefined,
87): Promise<readonly string[]> =>178): Promise<readonly string[]> =>
88 Effect.runPromise(179 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 ),
90 );185 );
apps/host-cloudflare/src/execution.ts+5−1
@@ -38,7 +38,11 @@ export const makeCloudflarePluginsProvider = ( · 37 unmodified lines
38 config: CloudflareConfig,38 config: CloudflareConfig,
39): Layer.Layer<PluginsProvider> =>39): Layer.Layer<PluginsProvider> =>
40 Layer.succeed(PluginsProvider)({40 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 }),
42 });46 });
43 47
44export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer<HostConfig> =>48export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer<HostConfig> =>
apps/host-cloudflare/src/mcp/auth.ts+65−3
@@ -1,10 +1,58 @@
1import { Effect, Layer } from "effect";1import { Effect, Layer } from "effect";
2 2
3import { authenticated, McpAuthProvider, unauthorized } from "@executor-js/host-mcp";3import {
4 authenticated,
5 McpAuthProvider,
6 unauthorized,
7 type McpDiscoveryRoute,
8} from "@executor-js/host-mcp";
4 9
5import { makeAccessVerifier } from "../auth/cloudflare-access";10import { makeAccessVerifier } from "../auth/cloudflare-access";
6import type { CloudflareConfig } from "../config";11import type { CloudflareConfig } from "../config";
7 12
13const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
14const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp`;
15const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`;
16
17const 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
31const toolkitPath = (slug: string): string => `/mcp/toolkits/${encodeURIComponent(slug)}`;
32
33const resourcePathForRequest = (request: Request): string => {
34 const slug = toolkitSlugFromPath(new URL(request.url).pathname);
35 return slug ? toolkitPath(slug) : "/mcp";
36};
37
38const 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
45const 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
8// ---------------------------------------------------------------------------56// ---------------------------------------------------------------------------
9// Cloudflare Access McpAuthProvider — the `/mcp` gate, identical identity to the57// Cloudflare Access McpAuthProvider — the `/mcp` gate, identical identity to the
10// API gate. Cloudflare Access sits in front of the Worker and forwards the58// 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
23 71
24export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer<McpAuthProvider> => {72export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer<McpAuthProvider> => {
25 const { verify } = makeAccessVerifier(config);73 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 ];
26 return Layer.succeed(McpAuthProvider)({88 return Layer.succeed(McpAuthProvider)({
27 discoveryRoutes: [],89 discoveryRoutes,
28 resourceMetadataUrl: (request) =>90 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(),
30 authenticate: (request) =>92 authenticate: (request) =>
31 verify(request).pipe(93 verify(request).pipe(
32 Effect.map((principal) => (principal ? authenticated(principal) : unauthorized())),94 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
61 organizationName: this.cfConfig.organizationName,61 organizationName: this.cfConfig.organizationName,
62 organizationSlug: this.cfConfig.organizationSlug,62 organizationSlug: this.cfConfig.organizationSlug,
63 userId: token.userId,63 userId: token.userId,
64 resource: token.resource,
64 elicitationMode: token.elicitationMode,65 elicitationMode: token.elicitationMode,
65 } satisfies SessionMeta);66 } satisfies SessionMeta);
66 }67 }
@@ -79,6 +80,7 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 12 unmodified lines
79 sessionMeta.userId,80 sessionMeta.userId,
80 sessionMeta.organizationId,81 sessionMeta.organizationId,
81 sessionMeta.organizationName,82 sessionMeta.organizationName,
83 { mcpResource: sessionMeta.resource },
82 ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));84 ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));
83 // Browser elicitation mode (the base owns the approval store + the HTTP85 // Browser elicitation mode (the base owns the approval store + the HTTP
84 // approval RPCs): a gated execution pauses and returns an approvalUrl into86 // 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
4import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";4import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
5import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";5import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
6import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";6import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
7import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
7 8
8// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
9// The Cloudflare host's plugin list — the same protocol/provider plugins as10// 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
16// spawn arbitrary stdio MCP processes.17// spawn arbitrary stdio MCP processes.
17// ---------------------------------------------------------------------------18// ---------------------------------------------------------------------------
18 19
19export const makeCloudflarePlugins = (secretKey: string) =>20export const makeCloudflarePlugins = (
21 secretKey: string,
22 options: { readonly activeToolkitSlug?: string } = {},
23) =>
20 [24 [
21 openApiHttpPlugin(),25 openApiHttpPlugin(),
22 googleHttpPlugin(),26 googleHttpPlugin(),
23 microsoftHttpPlugin(),27 microsoftHttpPlugin(),
24 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),28 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
25 graphqlHttpPlugin(),29 graphqlHttpPlugin(),
30 toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }),
26 encryptedSecretsPlugin({ key: secretKey }),31 encryptedSecretsPlugin({ key: secretKey }),
27 ] as const;32 ] as const;
28 33
apps/host-cloudflare/web/routeTree.gen.ts+61−0
@@ -11,8 +11,10 @@ · 10 unmodified lines
11import { Route as rootRouteImport } from './routes/__root'11import { Route as rootRouteImport } from './routes/__root'
12import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'12import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'
13import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'13import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'
14import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'
14import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets'15import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets'
15import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies'16import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies'
17import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
16import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'18import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
17import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'19import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
18import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'20import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'
@@ -30,6 +32,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = · 11 unmodified lines
30 path: '/{-$orgSlug}/tools',32 path: '/{-$orgSlug}/tools',
31 getParentRoute: () => rootRouteImport,33 getParentRoute: () => rootRouteImport,
32 } as any)34 } as any)
35const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute =
36 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport.update({
37 id: '/{-$orgSlug}/toolkits',
38 path: '/{-$orgSlug}/toolkits',
39 getParentRoute: () => rootRouteImport,
40 } as any)
33const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute =41const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute =
34 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport.update({42 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport.update({
35 id: '/{-$orgSlug}/secrets',43 id: '/{-$orgSlug}/secrets',
@@ -42,6 +50,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute = · 6 unmodified lines
42 path: '/{-$orgSlug}/policies',50 path: '/{-$orgSlug}/policies',
43 getParentRoute: () => rootRouteImport,51 getParentRoute: () => rootRouteImport,
44 } as any)52 } as any)
53const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute =
54 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update(
55 {
56 id: '/$toolkitSlug',
57 path: '/$toolkitSlug',
58 getParentRoute: () =>
59 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute,
60 } as any,
61 )
45const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute =62const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute =
46 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update(63 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update(
47 {64 {
@@ -78,20 +95,24 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK · 30 unmodified lines
78export interface FileRoutesByFullPath {95export interface FileRoutesByFullPath {
79 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute96 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
80 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute97 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
98 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
81 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute99 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
82 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute100 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
83 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute101 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
84 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute102 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
103 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
85 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute104 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
86 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute105 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
87}106}
88export interface FileRoutesByTo {107export interface FileRoutesByTo {
89 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute108 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
90 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute109 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
110 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
91 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute111 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
92 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute112 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
93 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute113 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
94 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute114 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
115 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
95 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute116 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
96 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute117 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
97}118}
@@ -99,10 +120,12 @@ export interface FileRoutesById { · 1 unmodified line
99 __root__: typeof rootRouteImport120 __root__: typeof rootRouteImport
100 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute121 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
101 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute122 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
123 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
102 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute124 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
103 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute125 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
104 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute126 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
105 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute127 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
128 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
106 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute129 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
107 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute130 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
108}131}
@@ -111,30 +134,36 @@ export interface FileRouteTypes { · 2 unmodified lines
111 fullPaths:134 fullPaths:
112 | '/{-$orgSlug}/policies'135 | '/{-$orgSlug}/policies'
113 | '/{-$orgSlug}/secrets'136 | '/{-$orgSlug}/secrets'
137 | '/{-$orgSlug}/toolkits'
114 | '/{-$orgSlug}/tools'138 | '/{-$orgSlug}/tools'
115 | '/{-$orgSlug}/'139 | '/{-$orgSlug}/'
116 | '/{-$orgSlug}/integrations/$namespace'140 | '/{-$orgSlug}/integrations/$namespace'
117 | '/{-$orgSlug}/resume/$executionId'141 | '/{-$orgSlug}/resume/$executionId'
142 | '/{-$orgSlug}/toolkits/$toolkitSlug'
118 | '/{-$orgSlug}/integrations/add/$pluginKey'143 | '/{-$orgSlug}/integrations/add/$pluginKey'
119 | '/{-$orgSlug}/plugins/$pluginId/$'144 | '/{-$orgSlug}/plugins/$pluginId/$'
120 fileRoutesByTo: FileRoutesByTo145 fileRoutesByTo: FileRoutesByTo
121 to:146 to:
122 | '/{-$orgSlug}/policies'147 | '/{-$orgSlug}/policies'
123 | '/{-$orgSlug}/secrets'148 | '/{-$orgSlug}/secrets'
149 | '/{-$orgSlug}/toolkits'
124 | '/{-$orgSlug}/tools'150 | '/{-$orgSlug}/tools'
125 | '/{-$orgSlug}'151 | '/{-$orgSlug}'
126 | '/{-$orgSlug}/integrations/$namespace'152 | '/{-$orgSlug}/integrations/$namespace'
127 | '/{-$orgSlug}/resume/$executionId'153 | '/{-$orgSlug}/resume/$executionId'
154 | '/{-$orgSlug}/toolkits/$toolkitSlug'
128 | '/{-$orgSlug}/integrations/add/$pluginKey'155 | '/{-$orgSlug}/integrations/add/$pluginKey'
129 | '/{-$orgSlug}/plugins/$pluginId/$'156 | '/{-$orgSlug}/plugins/$pluginId/$'
130 id:157 id:
131 | '__root__'158 | '__root__'
132 | '/{-$orgSlug}/policies'159 | '/{-$orgSlug}/policies'
133 | '/{-$orgSlug}/secrets'160 | '/{-$orgSlug}/secrets'
161 | '/{-$orgSlug}/toolkits'
134 | '/{-$orgSlug}/tools'162 | '/{-$orgSlug}/tools'
135 | '/{-$orgSlug}/'163 | '/{-$orgSlug}/'
136 | '/{-$orgSlug}/integrations/$namespace'164 | '/{-$orgSlug}/integrations/$namespace'
137 | '/{-$orgSlug}/resume/$executionId'165 | '/{-$orgSlug}/resume/$executionId'
166 | '/{-$orgSlug}/toolkits/$toolkitSlug'
138 | '/{-$orgSlug}/integrations/add/$pluginKey'167 | '/{-$orgSlug}/integrations/add/$pluginKey'
139 | '/{-$orgSlug}/plugins/$pluginId/$'168 | '/{-$orgSlug}/plugins/$pluginId/$'
140 fileRoutesById: FileRoutesById169 fileRoutesById: FileRoutesById
@@ -142,6 +171,7 @@ export interface FileRouteTypes { · 1 unmodified line
142export interface RootRouteChildren {171export interface RootRouteChildren {
143 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute172 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
144 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute173 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
174 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
145 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute175 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
146 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute176 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
147 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute177 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
@@ -166,6 +196,13 @@ declare module '@tanstack/react-router' { · 18 unmodified lines
166 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport196 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport
167 parentRoute: typeof rootRouteImport197 parentRoute: typeof rootRouteImport
168 }198 }
199 '/{-$orgSlug}/toolkits': {
200 id: '/{-$orgSlug}/toolkits'
201 path: '/{-$orgSlug}/toolkits'
202 fullPath: '/{-$orgSlug}/toolkits'
203 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport
204 parentRoute: typeof rootRouteImport
205 }
169 '/{-$orgSlug}/secrets': {206 '/{-$orgSlug}/secrets': {
170 id: '/{-$orgSlug}/secrets'207 id: '/{-$orgSlug}/secrets'
171 path: '/{-$orgSlug}/secrets'208 path: '/{-$orgSlug}/secrets'
@@ -180,6 +217,13 @@ declare module '@tanstack/react-router' { · 8 unmodified lines
180 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport217 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport
181 parentRoute: typeof rootRouteImport218 parentRoute: typeof rootRouteImport
182 }219 }
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 }
183 '/{-$orgSlug}/resume/$executionId': {227 '/{-$orgSlug}/resume/$executionId': {
184 id: '/{-$orgSlug}/resume/$executionId'228 id: '/{-$orgSlug}/resume/$executionId'
185 path: '/{-$orgSlug}/resume/$executionId'229 path: '/{-$orgSlug}/resume/$executionId'
@@ -211,11 +255,28 @@ declare module '@tanstack/react-router' { · 25 unmodified lines
211 }255 }
212}256}
213 257
258interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren {
259 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
260}
261
262const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren =
263 {
264 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute:
265 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute,
266 }
267
268const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren =
269 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute._addFileChildren(
270 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren,
271 )
272
214const rootRouteChildren: RootRouteChildren = {273const rootRouteChildren: RootRouteChildren = {
215 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute:274 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute:
216 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute,275 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute,
217 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute:276 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute:
218 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute,277 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute,
278 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute:
279 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,
219 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:280 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:
220 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,281 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
221 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:282 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
apps/host-cloudflare/wrangler.jsonc+1−1
@@ -12,7 +12,7 @@ · 11 unmodified lines
12 "assets": {12 "assets": {
13 "directory": "./dist",13 "directory": "./dist",
14 "not_found_handling": "single-page-application",14 "not_found_handling": "single-page-application",
15 "run_worker_first": ["/api/*", "/mcp", "/mcp/*"],15 "run_worker_first": ["/api/*", "/mcp", "/mcp/*", "/.well-known/*"],
16 },16 },
17 // D1 is the app's SQLite store (the DbProvider seam). `wrangler deploy`17 // D1 is the app's SQLite store (the DbProvider seam). `wrangler deploy`
18 // auto-provisions it on first deploy; replace database_id after that, or run18 // 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
5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
7import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";7import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
8import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
8 9
9import { resolveSecretKey } from "./src/config";10import { resolveSecretKey } from "./src/config";
10 11
@@ -19,13 +20,14 @@ import { resolveSecretKey } from "./src/config"; · 8 unmodified lines
19// ---------------------------------------------------------------------------20// ---------------------------------------------------------------------------
20 21
21export default defineExecutorConfig({22export default defineExecutorConfig({
22 plugins: () =>23 plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) =>
23 [24 [
24 openApiHttpPlugin(),25 openApiHttpPlugin(),
25 googleHttpPlugin(),26 googleHttpPlugin(),
26 microsoftHttpPlugin(),27 microsoftHttpPlugin(),
27 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),28 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
28 graphqlHttpPlugin(),29 graphqlHttpPlugin(),
30 toolkitsPlugin({ activeToolkitSlug }),
29 // First writable secret provider -> the default for `secrets.set`.31 // First writable secret provider -> the default for `secrets.set`.
30 encryptedSecretsPlugin({ key: resolveSecretKey() }),32 encryptedSecretsPlugin({ key: resolveSecretKey() }),
31 ] as const,33 ] as const,
apps/host-selfhost/package.json+1−0
@@ -32,6 +32,7 @@ · 31 unmodified lines
32 "@executor-js/plugin-mcp": "workspace:*",32 "@executor-js/plugin-mcp": "workspace:*",
33 "@executor-js/plugin-microsoft": "workspace:*",33 "@executor-js/plugin-microsoft": "workspace:*",
34 "@executor-js/plugin-openapi": "workspace:*",34 "@executor-js/plugin-openapi": "workspace:*",
35 "@executor-js/plugin-toolkits": "workspace:*",
35 "@executor-js/react": "workspace:*",36 "@executor-js/react": "workspace:*",
36 "@executor-js/runtime-quickjs": "workspace:*",37 "@executor-js/runtime-quickjs": "workspace:*",
37 "@executor-js/sdk": "workspace:*",38 "@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
106 routes: [106 routes: [
107 // CLI device-login discovery, must precede the /api/auth/* wildcard107 // CLI device-login discovery, must precede the /api/auth/* wildcard
108 // below (Better Auth would otherwise 404 it). The verification page it108 // 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).
110 HttpRouter.add("GET", "/api/auth/cli-login", cliLoginHandler),111 HttpRouter.add("GET", "/api/auth/cli-login", cliLoginHandler),
111 // Better Auth owns the rest of /api/auth/*, the full path reaches it.112 // Better Auth owns the rest of /api/auth/*, the full path reaches it.
112 HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)),113 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
40 40
41export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)(41export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)(
42 {42 {
43 plugins: () => executorConfig.plugins(),43 plugins: (context) =>
44 executorConfig.plugins({
45 activeToolkitSlug:
46 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
47 }),
44 },48 },
45);49);
46 50
apps/host-selfhost/src/mcp/auth.ts+58−2
@@ -46,7 +46,9 @@ import { BetterAuth } from "../auth/better-auth"; · 45 unmodified lines
46// ---------------------------------------------------------------------------46// ---------------------------------------------------------------------------
47 47
48const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";48const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
49const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/:toolkitSlug`;
49const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";50const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
51const TOOLKIT_MCP_SEGMENT = "/mcp/toolkits/";
50 52
51const parseRoles = (role: string | null | undefined): ReadonlyArray<string> =>53const parseRoles = (role: string | null | undefined): ReadonlyArray<string> =>
52 (role ?? "user")54 (role ?? "user")
@@ -66,6 +68,19 @@ const userRole = (user: object): string | null => { · 13 unmodified lines
66const hasBearer = (request: Request): boolean =>68const hasBearer = (request: Request): boolean =>
67 (request.headers.get("authorization") ?? "").startsWith("Bearer ");69 (request.headers.get("authorization") ?? "").startsWith("Bearer ");
68 70
71const 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
79const mcpResourcePathFor = (request: Request): string => {
80 const toolkitSlug = toolkitSlugFromRequest(request);
81 return toolkitSlug ? `/mcp/toolkits/${toolkitSlug}` : "/mcp";
82};
83
69/**84/**
70 * Absolute protected-resource metadata URL for the 401 challenge. Derive the85 * Absolute protected-resource metadata URL for the 401 challenge. Derive the
71 * origin from `baseURL` when set; otherwise from the live request so the URL is86 * 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
73 */88 */
74const resourceMetadataUrlFor = (baseURL: string | undefined, request: Request): string => {89const resourceMetadataUrlFor = (baseURL: string | undefined, request: Request): string => {
75 const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin;90 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
97const 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
102const 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 });
77};119};
78 120
79export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | IdentityProvider> =121export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | IdentityProvider> =
@@ -99,7 +141,21 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I · 19 unmodified lines
99 const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [141 const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [
100 {142 {
101 path: PROTECTED_RESOURCE_METADATA_PATH,143 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 ),
103 },159 },
104 {160 {
105 path: AUTHORIZATION_SERVER_METADATA_PATH,161 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
36 expect(Array.isArray(body.authorization_servers)).toBe(true);36 expect(Array.isArray(body.authorization_servers)).toBe(true);
37});37});
38 38
39test("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
39test("an unauthenticated /mcp request returns 401 with a WWW-Authenticate challenge", async () => {49test("an unauthenticated /mcp request returns 401 with a WWW-Authenticate challenge", async () => {
40 const res = await handler(50 const res = await handler(
41 new Request(`${BASE}/mcp`, {51 new Request(`${BASE}/mcp`, {
@@ -53,6 +63,25 @@ test("an unauthenticated /mcp request returns 401 with a WWW-Authenticate challe · 11 unmodified lines
53 expect(challenge).toContain("resource_metadata=");63 expect(challenge).toContain("resource_metadata=");
54});64});
55 65
66test("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
56// --- End-to-end MCP OAuth: DCR -> authorize -> token -> /mcp with bearer ---85// --- End-to-end MCP OAuth: DCR -> authorize -> token -> /mcp with bearer ---
57const json = async (res: Response) => (await res.json()) as Record<string, unknown>;86const json = async (res: Response) => (await res.json()) as Record<string, unknown>;
58 87
apps/host-selfhost/src/mcp/org-path.test.ts+6−2
@@ -6,17 +6,21 @@ describe("stripMcpOrgSegment", () => { · 5 unmodified lines
6 it("strips a single org segment before /mcp", () => {6 it("strips a single org segment before /mcp", () => {
7 expect(stripMcpOrgSegment("/iI9idP7BZcWpg9wW8cit3xE4r4dFSnHj/mcp")).toBe("/mcp");7 expect(stripMcpOrgSegment("/iI9idP7BZcWpg9wW8cit3xE4r4dFSnHj/mcp")).toBe("/mcp");
8 expect(stripMcpOrgSegment("/org_123/mcp")).toBe("/mcp");8 expect(stripMcpOrgSegment("/org_123/mcp")).toBe("/mcp");
9 expect(stripMcpOrgSegment("/org_123/mcp/toolkits/deploy")).toBe("/mcp/toolkits/deploy");
9 });10 });
10 11
11 it("strips the org segment from the protected-resource discovery path", () => {12 it("strips the org segment from the protected-resource discovery path", () => {
12 expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp")).toBe(13 expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp")).toBe(
13 "/.well-known/oauth-protected-resource/mcp",14 "/.well-known/oauth-protected-resource",
14 );15 );
16 expect(
17 stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp/toolkits/deploy"),
18 ).toBe("/.well-known/oauth-protected-resource/mcp/toolkits/deploy");
15 });19 });
16 20
17 it("leaves the bare paths untouched", () => {21 it("leaves the bare paths untouched", () => {
18 expect(stripMcpOrgSegment("/mcp")).toBeNull();22 expect(stripMcpOrgSegment("/mcp")).toBeNull();
19 expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/mcp")).toBeNull();23 expect(stripMcpOrgSegment("/mcp/toolkits/deploy")).toBeNull();
20 expect(stripMcpOrgSegment("/.well-known/oauth-authorization-server")).toBeNull();24 expect(stripMcpOrgSegment("/.well-known/oauth-authorization-server")).toBeNull();
21 });25 });
22 26
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
20 * applies (already bare, not an MCP path, or an OAuth endpoint like20 * applies (already bare, not an MCP path, or an OAuth endpoint like
21 * `/api/auth/mcp/authorize`).21 * `/api/auth/mcp/authorize`).
22 *22 *
23 * /<seg>/mcp -> /mcp23 * /<seg>/mcp -> /mcp
24 * /.well-known/oauth-protected-resource/<seg>/mcp -> /.well-known/oauth-protected-resource/mcp24 * /<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>
25 */28 */
26export const stripMcpOrgSegment = (pathname: string): string | null => {29export const stripMcpOrgSegment = (pathname: string): string | null => {
27 if (pathname.startsWith(`${PRM_PREFIX}/`)) {30 if (pathname.startsWith(`${PRM_PREFIX}/`)) {
@@ -29,8 +32,16 @@ export const stripMcpOrgSegment = (pathname: string): string | null => { · 1 unmodified line
29 .slice(PRM_PREFIX.length + 1)32 .slice(PRM_PREFIX.length + 1)
30 .split("/")33 .split("/")
31 .filter((segment) => segment.length > 0);34 .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;
33 }40 }
34 const segments = pathname.split("/").filter((segment) => segment.length > 0);41 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;
36};47};
apps/host-selfhost/vite.config.ts+2−2
@@ -66,8 +66,8 @@ function executorApiPlugin(): Plugin { · 65 unmodified lines
66 rawUrl = `${pathname}${original.search}`;66 rawUrl = `${pathname}${original.search}`;
67 }67 }
68 // Match on PATHNAME, not a raw-URL prefix: `/mcp` must NOT swallow the68 // 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`),69 // SPA route `/mcp-consent`, or the dev server misroutes it to the API
70 // or the dev server misroutes them to the API handler and they 404.70 // handler and returns a 404.
71 const path = new URL(rawUrl, devOrigin).pathname;71 const path = new URL(rawUrl, devOrigin).pathname;
72 const handled =72 const handled =
73 path === "/api" ||73 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
11import { Route as rootRouteImport } from './routes/__root'11import { Route as rootRouteImport } from './routes/__root'
12import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'12import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'
13import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'13import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'
14import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'
14import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets'15import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets'
15import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies'16import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies'
16import { Route as ApiKeysRouteImport } from './routes/app/api-keys'17import { Route as ApiKeysRouteImport } from './routes/app/api-keys'
17import { Route as AdminRouteImport } from './routes/app/admin'18import { Route as AdminRouteImport } from './routes/app/admin'
18import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code'19import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code'
20import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
19import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'21import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
20import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'22import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
21import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'23import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'
@@ -33,6 +35,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = · 11 unmodified lines
33 path: '/{-$orgSlug}/tools',35 path: '/{-$orgSlug}/tools',
34 getParentRoute: () => rootRouteImport,36 getParentRoute: () => rootRouteImport,
35 } as any)37 } as any)
38const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute =
39 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport.update({
40 id: '/{-$orgSlug}/toolkits',
41 path: '/{-$orgSlug}/toolkits',
42 getParentRoute: () => rootRouteImport,
43 } as any)
36const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute =44const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute =
37 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport.update({45 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport.update({
38 id: '/{-$orgSlug}/secrets',46 id: '/{-$orgSlug}/secrets',
@@ -60,6 +68,15 @@ const JoinDotcodeRoute = JoinDotcodeRouteImport.update({ · 21 unmodified lines
60 path: '/join/$code',68 path: '/join/$code',
61 getParentRoute: () => rootRouteImport,69 getParentRoute: () => rootRouteImport,
62} as any)70} as any)
71const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute =
72 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update(
73 {
74 id: '/$toolkitSlug',
75 path: '/$toolkitSlug',
76 getParentRoute: () =>
77 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute,
78 } as any,
79 )
63const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute =80const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute =
64 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update(81 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update(
65 {82 {
@@ -99,10 +116,12 @@ export interface FileRoutesByFullPath { · 33 unmodified lines
99 '/{-$orgSlug}/api-keys': typeof ApiKeysRoute116 '/{-$orgSlug}/api-keys': typeof ApiKeysRoute
100 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute117 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
101 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute118 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
119 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
102 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute120 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
103 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute121 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
104 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute122 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
105 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute123 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
124 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
106 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute125 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
107 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute126 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
108}127}
@@ -112,10 +131,12 @@ export interface FileRoutesByTo { · 3 unmodified lines
112 '/{-$orgSlug}/api-keys': typeof ApiKeysRoute131 '/{-$orgSlug}/api-keys': typeof ApiKeysRoute
113 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute132 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
114 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute133 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
134 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
115 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute135 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
116 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute136 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
117 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute137 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
118 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute138 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
139 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
119 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute140 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
120 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute141 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
121}142}
@@ -126,10 +147,12 @@ export interface FileRoutesById { · 4 unmodified lines
126 '/{-$orgSlug}/api-keys': typeof ApiKeysRoute147 '/{-$orgSlug}/api-keys': typeof ApiKeysRoute
127 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute148 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
128 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute149 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
150 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
129 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute151 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
130 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute152 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
131 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute153 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
132 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute154 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
155 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
133 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute156 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
134 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute157 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
135}158}
@@ -141,10 +164,12 @@ export interface FileRouteTypes { · 5 unmodified lines
141 | '/{-$orgSlug}/api-keys'164 | '/{-$orgSlug}/api-keys'
142 | '/{-$orgSlug}/policies'165 | '/{-$orgSlug}/policies'
143 | '/{-$orgSlug}/secrets'166 | '/{-$orgSlug}/secrets'
167 | '/{-$orgSlug}/toolkits'
144 | '/{-$orgSlug}/tools'168 | '/{-$orgSlug}/tools'
145 | '/{-$orgSlug}/'169 | '/{-$orgSlug}/'
146 | '/{-$orgSlug}/integrations/$namespace'170 | '/{-$orgSlug}/integrations/$namespace'
147 | '/{-$orgSlug}/resume/$executionId'171 | '/{-$orgSlug}/resume/$executionId'
172 | '/{-$orgSlug}/toolkits/$toolkitSlug'
148 | '/{-$orgSlug}/integrations/add/$pluginKey'173 | '/{-$orgSlug}/integrations/add/$pluginKey'
149 | '/{-$orgSlug}/plugins/$pluginId/$'174 | '/{-$orgSlug}/plugins/$pluginId/$'
150 fileRoutesByTo: FileRoutesByTo175 fileRoutesByTo: FileRoutesByTo
@@ -154,10 +179,12 @@ export interface FileRouteTypes { · 3 unmodified lines
154 | '/{-$orgSlug}/api-keys'179 | '/{-$orgSlug}/api-keys'
155 | '/{-$orgSlug}/policies'180 | '/{-$orgSlug}/policies'
156 | '/{-$orgSlug}/secrets'181 | '/{-$orgSlug}/secrets'
182 | '/{-$orgSlug}/toolkits'
157 | '/{-$orgSlug}/tools'183 | '/{-$orgSlug}/tools'
158 | '/{-$orgSlug}'184 | '/{-$orgSlug}'
159 | '/{-$orgSlug}/integrations/$namespace'185 | '/{-$orgSlug}/integrations/$namespace'
160 | '/{-$orgSlug}/resume/$executionId'186 | '/{-$orgSlug}/resume/$executionId'
187 | '/{-$orgSlug}/toolkits/$toolkitSlug'
161 | '/{-$orgSlug}/integrations/add/$pluginKey'188 | '/{-$orgSlug}/integrations/add/$pluginKey'
162 | '/{-$orgSlug}/plugins/$pluginId/$'189 | '/{-$orgSlug}/plugins/$pluginId/$'
163 id:190 id:
@@ -167,10 +194,12 @@ export interface FileRouteTypes { · 3 unmodified lines
167 | '/{-$orgSlug}/api-keys'194 | '/{-$orgSlug}/api-keys'
168 | '/{-$orgSlug}/policies'195 | '/{-$orgSlug}/policies'
169 | '/{-$orgSlug}/secrets'196 | '/{-$orgSlug}/secrets'
197 | '/{-$orgSlug}/toolkits'
170 | '/{-$orgSlug}/tools'198 | '/{-$orgSlug}/tools'
171 | '/{-$orgSlug}/'199 | '/{-$orgSlug}/'
172 | '/{-$orgSlug}/integrations/$namespace'200 | '/{-$orgSlug}/integrations/$namespace'
173 | '/{-$orgSlug}/resume/$executionId'201 | '/{-$orgSlug}/resume/$executionId'
202 | '/{-$orgSlug}/toolkits/$toolkitSlug'
174 | '/{-$orgSlug}/integrations/add/$pluginKey'203 | '/{-$orgSlug}/integrations/add/$pluginKey'
175 | '/{-$orgSlug}/plugins/$pluginId/$'204 | '/{-$orgSlug}/plugins/$pluginId/$'
176 fileRoutesById: FileRoutesById205 fileRoutesById: FileRoutesById
@@ -181,6 +210,7 @@ export interface RootRouteChildren { · 4 unmodified lines
181 ApiKeysRoute: typeof ApiKeysRoute210 ApiKeysRoute: typeof ApiKeysRoute
182 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute211 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute
183 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute212 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
213 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
184 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute214 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
185 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute215 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
186 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute216 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
@@ -205,6 +235,13 @@ declare module '@tanstack/react-router' { · 18 unmodified lines
205 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport235 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport
206 parentRoute: typeof rootRouteImport236 parentRoute: typeof rootRouteImport
207 }237 }
238 '/{-$orgSlug}/toolkits': {
239 id: '/{-$orgSlug}/toolkits'
240 path: '/{-$orgSlug}/toolkits'
241 fullPath: '/{-$orgSlug}/toolkits'
242 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport
243 parentRoute: typeof rootRouteImport
244 }
208 '/{-$orgSlug}/secrets': {245 '/{-$orgSlug}/secrets': {
209 id: '/{-$orgSlug}/secrets'246 id: '/{-$orgSlug}/secrets'
210 path: '/{-$orgSlug}/secrets'247 path: '/{-$orgSlug}/secrets'
@@ -240,6 +277,13 @@ declare module '@tanstack/react-router' { · 29 unmodified lines
240 preLoaderRoute: typeof JoinDotcodeRouteImport277 preLoaderRoute: typeof JoinDotcodeRouteImport
241 parentRoute: typeof rootRouteImport278 parentRoute: typeof rootRouteImport
242 }279 }
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 }
243 '/{-$orgSlug}/resume/$executionId': {287 '/{-$orgSlug}/resume/$executionId': {
244 id: '/{-$orgSlug}/resume/$executionId'288 id: '/{-$orgSlug}/resume/$executionId'
245 path: '/{-$orgSlug}/resume/$executionId'289 path: '/{-$orgSlug}/resume/$executionId'
@@ -271,6 +315,21 @@ declare module '@tanstack/react-router' { · 25 unmodified lines
271 }315 }
272}316}
273 317
318interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren {
319 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
320}
321
322const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren =
323 {
324 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute:
325 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute,
326 }
327
328const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren =
329 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute._addFileChildren(
330 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren,
331 )
332
274const rootRouteChildren: RootRouteChildren = {333const rootRouteChildren: RootRouteChildren = {
275 JoinDotcodeRoute: JoinDotcodeRoute,334 JoinDotcodeRoute: JoinDotcodeRoute,
276 AdminRoute: AdminRoute,335 AdminRoute: AdminRoute,
@@ -279,6 +338,8 @@ const rootRouteChildren: RootRouteChildren = { · 2 unmodified lines
279 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute,338 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute,
280 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute:339 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute:
281 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute,340 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute,
341 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute:
342 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,
282 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:343 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:
283 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,344 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
284 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:345 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
11import { plugins as clientPlugins } from "virtual:executor/plugins-client";11import { plugins as clientPlugins } from "virtual:executor/plugins-client";
12 12
13import { authClient } from "../auth-client";13import { authClient } from "../auth-client";
14import { DevicePage } from "../device";14import { DevicePage } from "../chromeless/device-page";
15import { McpConsentPage } from "../mcp-consent";15import { McpConsentPage } from "../chromeless/mcp-consent-page";
16import { LoginPage } from "../login";16import { LoginPage } from "../login";
17import { SetupPage } from "../setup";17import { SetupPage } from "../setup";
18import { fetchNeedsSetup } from "../setup-status";18import { 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
8import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";8import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
9import { onepasswordHttpPlugin } from "@executor-js/plugin-onepassword/api";9import { onepasswordHttpPlugin } from "@executor-js/plugin-onepassword/api";
10import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/server";10import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/server";
11import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
11 12
12// ---------------------------------------------------------------------------13// ---------------------------------------------------------------------------
13// Single source of truth for the local app's plugin list.14// 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
18// First-party and third-party plugins use the same import-and-call flow.19// First-party and third-party plugins use the same import-and-call flow.
19// ---------------------------------------------------------------------------20// ---------------------------------------------------------------------------
20 21
22interface LocalPluginDeps {
23 readonly activeToolkitSlug?: string;
24}
25
21export default defineExecutorConfig({26export default defineExecutorConfig({
22 plugins: () =>27 plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) =>
23 [28 [
24 openApiHttpPlugin(),29 openApiHttpPlugin(),
25 googleHttpPlugin(),30 googleHttpPlugin(),
26 microsoftHttpPlugin(),31 microsoftHttpPlugin(),
27 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),32 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),
28 graphqlHttpPlugin(),33 graphqlHttpPlugin(),
34 toolkitsPlugin({ activeToolkitSlug }),
29 keychainPlugin(),35 keychainPlugin(),
30 fileSecretsPlugin(),36 fileSecretsPlugin(),
31 onepasswordHttpPlugin(),37 onepasswordHttpPlugin(),
apps/local/package.json+1−0
@@ -39,6 +39,7 @@ · 38 unmodified lines
39 "@executor-js/plugin-microsoft": "workspace:*",39 "@executor-js/plugin-microsoft": "workspace:*",
40 "@executor-js/plugin-onepassword": "workspace:*",40 "@executor-js/plugin-onepassword": "workspace:*",
41 "@executor-js/plugin-openapi": "workspace:*",41 "@executor-js/plugin-openapi": "workspace:*",
42 "@executor-js/plugin-toolkits": "workspace:*",
42 "@executor-js/react": "workspace:*",43 "@executor-js/react": "workspace:*",
43 "@executor-js/runtime-quickjs": "workspace:*",44 "@executor-js/runtime-quickjs": "workspace:*",
44 "@executor-js/sdk": "workspace:*",45 "@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
57// Static config wins on conflict, matching the Vite plugin.57// Static config wins on conflict, matching the Vite plugin.
58type LocalPlugins = readonly AnyPlugin[];58type LocalPlugins = readonly AnyPlugin[];
59 59
60const loadLocalPlugins = Effect.gen(function* () {60export interface LocalExecutorOptions {
61 const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd();61 readonly activeToolkitSlug?: string;
62 const staticPlugins = executorConfig.plugins();62}
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 });
81 63
82 return {64const loadLocalPlugins = (options: LocalExecutorOptions = {}) =>
83 cwd,65 Effect.gen(function* () {
84 plugins: [...staticPlugins, ...dedupedDynamic] as LocalPlugins,66 const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd();
85 };67 const staticPlugins = executorConfig.plugins({
86});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 });
87 94
88interface LocalExecutorBundle {95interface LocalExecutorBundle {
89 readonly executor: Executor<LocalPlugins>;96 readonly executor: Executor<LocalPlugins>;
@@ -134,12 +141,12 @@ const handleOrNull = (promise: ReturnType<typeof createExecutorHandle>) => · 44 unmodified lines
134 ),141 ),
135 );142 );
136 143
137const createLocalExecutorLayer = () => {144const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => {
138 const storage = resolveStorage();145 const storage = resolveStorage();
139 146
140 return Layer.effect(LocalExecutorTag)(147 return Layer.effect(LocalExecutorTag)(
141 Effect.gen(function* () {148 Effect.gen(function* () {
142 const { cwd, plugins } = yield* loadLocalPlugins;149 const { cwd, plugins } = yield* loadLocalPlugins(options);
143 const tenantId = makeTenantId(cwd);150 const tenantId = makeTenantId(cwd);
144 const tables = collectTables();151 const tables = collectTables();
145 152
@@ -180,7 +187,11 @@ const createLocalExecutorLayer = () => { · 34 unmodified lines
180 // without touching the data.187 // without touching the data.
181 yield* runSqliteDataMigrations(sqlite.client, localDataMigrations).pipe(188 yield* runSqliteDataMigrations(sqlite.client, localDataMigrations).pipe(
182 Effect.mapError(189 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 }),
184 ),195 ),
185 );196 );
186 197
@@ -223,8 +234,8 @@ const createLocalExecutorLayer = () => { · 36 unmodified lines
223 );234 );
224};235};
225 236
226export const createExecutorHandle = async () => {237export const createExecutorHandle = async (options: LocalExecutorOptions = {}) => {
227 const layer = createLocalExecutorLayer();238 const layer = createLocalExecutorLayer(options);
228 const runtime = ManagedRuntime.make(layer);239 const runtime = ManagedRuntime.make(layer);
229 const bundle = await runtime.runPromise(LocalExecutorTag.asEffect());240 const bundle = await runtime.runPromise(LocalExecutorTag.asEffect());
230 241
apps/local/src/main.ts+18−2
@@ -3,7 +3,7 @@ import { Context, Effect, Layer, ManagedRuntime } from "effect"; · 2 unmodified lines
3import { createExecutionEngine } from "@executor-js/execution";3import { createExecutionEngine } from "@executor-js/execution";
4import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";4import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
5import { makeLocalApiHandler } from "./app";5import { makeLocalApiHandler } from "./app";
6import { getExecutorBundle } from "./executor";6import { createExecutorHandle, getExecutorBundle } from "./executor";
7import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";7import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
8 8
9// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
@@ -67,7 +67,23 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 57 unmodified lines
67 executor,67 executor,
68 codeExecutor: makeQuickJsExecutor(),68 codeExecutor: makeQuickJsExecutor(),
69 });69 });
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 });
71 87
72 return { api: apiHandler, mcp };88 return { api: apiHandler, mcp };
73};89};
apps/local/src/mcp.ts+84−13
@@ -1,9 +1,14 @@
1import { Effect } from "effect";1import { Effect, type Cause } from "effect";
2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";3import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";4import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
5 5
6import { jsonRpcErrorBody } from "@executor-js/host-mcp";6import {
7 defaultMcpResource,
8 jsonRpcErrorBody,
9 mcpResourceKey,
10 type McpResource,
11} from "@executor-js/host-mcp";
7import {12import {
8 createExecutorMcpServer,13 createExecutorMcpServer,
9 type ExecutorMcpServerConfig,14 type ExecutorMcpServerConfig,
@@ -15,10 +20,16 @@ import { · 5 unmodified lines
15 readElicitationMode,20 readElicitationMode,
16} from "@executor-js/host-mcp/browser-approval";21} from "@executor-js/host-mcp/browser-approval";
17import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store";22import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store";
18import { formatPausedExecution, type ResumeResponse } from "@executor-js/execution";23import {
24 formatPausedExecution,
25 type ExecutionEngine,
26 type ResumeResponse,
27} from "@executor-js/execution";
19 28
20import { startIntegrationsRefresh } from "./integrations";29import { startIntegrationsRefresh } from "./integrations";
21 30
31type AnyExecutionEngine = ExecutionEngine<Cause.YieldableError>;
32
22// ---------------------------------------------------------------------------33// ---------------------------------------------------------------------------
23// Streamable HTTP handler34// Streamable HTTP handler
24// ---------------------------------------------------------------------------35// ---------------------------------------------------------------------------
@@ -32,6 +43,18 @@ export type McpRequestHandler = { · 7 unmodified lines
32 readonly close: () => Promise<void>;43 readonly close: () => Promise<void>;
33};44};
34 45
46export interface LocalMcpServerConfig {
47 readonly config: ExecutorMcpServerConfig;
48 readonly close?: () => Promise<void>;
49}
50
51export interface LocalMcpRequestHandlerConfig {
52 readonly defaultConfig: ExecutorMcpServerConfig;
53 readonly createConfigForResource?: (
54 resource: McpResource,
55 ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig;
56}
57
35// Local serves these error bodies in-process; like the self-host store they are58// Local serves these error bodies in-process; like the self-host store they are
36// INNER responses (no CORS) — byte-identical to the prior hand-rolled copy59// INNER responses (no CORS) — byte-identical to the prior hand-rolled copy
37// (`content-type: application/json` only) via the canonical renderer.60// (`content-type: application/json` only) via the canonical renderer.
@@ -79,48 +102,86 @@ const resumeApprovalResult = (executionId: string, response: ResumeResponse) => · 41 unmodified lines
79 isError: false,102 isError: false,
80});103});
81 104
82export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpRequestHandler => {105const toolkitPathPattern = /^\/mcp\/toolkits\/([^/?#]+)\/?$/;
106
107const 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
115const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null =>
116 "engine" in config ? config.engine : null;
117
118const normalizeHandlerConfig = (
119 input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
120): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input });
121
122export const createMcpRequestHandler = (
123 input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
124): McpRequestHandler => {
125 const handlerConfig = normalizeHandlerConfig(input);
83 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();126 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
84 const servers = new Map<string, McpServer>();127 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>>();
85 const approvals = makeInProcessBrowserApprovalStore();131 const approvals = makeInProcessBrowserApprovalStore();
86 // Local runs one shared engine across every MCP session (main.ts builds it and132 const defaultEngine = engineFromConfig(handlerConfig.defaultConfig);
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;
90 133
91 const pausedDetail = (134 const pausedDetail = (
135 sessionId: string,
92 executionId: string,136 executionId: string,
93 ): Promise<ReturnType<typeof formatPausedExecution> | null> =>137 ): Promise<ReturnType<typeof formatPausedExecution> | null> =>
94 engine138 (sessionEngines.get(sessionId) ?? defaultEngine)
95 ? Effect.runPromise(139 ? Effect.runPromise(
96 engine.getPausedExecution(executionId).pipe(140 (sessionEngines.get(sessionId) ?? defaultEngine)!.getPausedExecution(executionId).pipe(
97 Effect.map((paused) => (paused ? formatPausedExecution(paused) : null)),141 Effect.map((paused) => (paused ? formatPausedExecution(paused) : null)),
98 Effect.orElseSucceed(() => null),142 Effect.orElseSucceed(() => null),
99 ),143 ),
100 )144 )
101 : Promise.resolve(null);145 : Promise.resolve(null);
102 146
147 const configForResource = async (resource: McpResource): Promise<LocalMcpServerConfig> => {
148 if (!handlerConfig.createConfigForResource) return { config: handlerConfig.defaultConfig };
149 return handlerConfig.createConfigForResource(resource);
150 };
151
103 const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => {152 const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => {
104 const t = transports.get(id);153 const t = transports.get(id);
105 const s = servers.get(id);154 const s = servers.get(id);
155 const close = sessionClosers.get(id);
106 transports.delete(id);156 transports.delete(id);
107 servers.delete(id);157 servers.delete(id);
158 resources.delete(id);
159 sessionEngines.delete(id);
160 sessionClosers.delete(id);
108 if (opts.transport) await ignoreClose(t ? () => t.close() : undefined);161 if (opts.transport) await ignoreClose(t ? () => t.close() : undefined);
109 if (opts.server) await ignoreClose(s ? () => s.close() : undefined);162 if (opts.server) await ignoreClose(s ? () => s.close() : undefined);
163 await ignoreClose(close);
110 };164 };
111 165
112 return {166 return {
113 handleRequest: async (request) => {167 handleRequest: async (request) => {
168 const resource = resourceFromRequest(request);
169 if (!resource) return jsonError(404, -32001, "MCP resource not found");
114 const sessionId = request.headers.get("mcp-session-id");170 const sessionId = request.headers.get("mcp-session-id");
115 171
116 if (sessionId) {172 if (sessionId) {
117 const transport = transports.get(sessionId);173 const transport = transports.get(sessionId);
118 if (!transport) return jsonError(404, -32001, "Session not found");174 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 }
119 return transport.handleRequest(request);179 return transport.handleRequest(request);
120 }180 }
121 181
122 let created: McpServer | undefined;182 let created: McpServer | undefined;
123 let createdSessionId: string | null = null;183 let createdSessionId: string | null = null;
184 let resourceConfig: LocalMcpServerConfig | null = null;
124 const transport = new WebStandardStreamableHTTPServerTransport({185 const transport = new WebStandardStreamableHTTPServerTransport({
125 sessionIdGenerator: () => crypto.randomUUID(),186 sessionIdGenerator: () => crypto.randomUUID(),
126 enableJsonResponse: true,187 enableJsonResponse: true,
@@ -128,6 +189,10 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 1 unmodified line
128 createdSessionId = sid;189 createdSessionId = sid;
129 transports.set(sid, transport);190 transports.set(sid, transport);
130 if (created) servers.set(sid, created);191 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);
131 },196 },
132 onsessionclosed: (sid) => void dispose(sid, { server: true }),197 onsessionclosed: (sid) => void dispose(sid, { server: true }),
133 });198 });
@@ -140,9 +205,10 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 6 unmodified lines
140 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK handler must return JSON-RPC errors from thrown Promise APIs205 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK handler must return JSON-RPC errors from thrown Promise APIs
141 try {206 try {
142 const elicitationMode = readElicitationMode(request);207 const elicitationMode = readElicitationMode(request);
208 resourceConfig = await configForResource(resource);
143 created = await Effect.runPromise(209 created = await Effect.runPromise(
144 createExecutorMcpServer({210 createExecutorMcpServer({
145 ...config,211 ...resourceConfig.config,
146 browserApprovalStore: approvals.store,212 browserApprovalStore: approvals.store,
147 elicitationMode:213 elicitationMode:
148 elicitationMode === "browser"214 elicitationMode === "browser"
@@ -161,6 +227,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 12 unmodified lines
161 await ignoreClose(() => transport.close());227 await ignoreClose(() => transport.close());
162 const server = created;228 const server = created;
163 await ignoreClose(server ? () => server.close() : undefined);229 await ignoreClose(server ? () => server.close() : undefined);
230 await ignoreClose(resourceConfig?.close);
164 }231 }
165 return response;232 return response;
166 } catch (error) {233 } catch (error) {
@@ -169,6 +236,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 2 unmodified lines
169 await ignoreClose(() => transport.close());236 await ignoreClose(() => transport.close());
170 const server = created;237 const server = created;
171 await ignoreClose(server ? () => server.close() : undefined);238 await ignoreClose(server ? () => server.close() : undefined);
239 await ignoreClose(resourceConfig?.close);
172 }240 }
173 return jsonError(500, -32603, "Internal server error");241 return jsonError(500, -32603, "Internal server error");
174 }242 }
@@ -179,7 +247,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 4 unmodified lines
179 if (!match) return json({ error: "Not found" }, 404);247 if (!match) return json({ error: "Not found" }, 404);
180 if (request.method !== "GET") return json({ error: "Method not allowed" }, 405);248 if (request.method !== "GET") return json({ error: "Method not allowed" }, 405);
181 249
182 const paused = await pausedDetail(decodeURIComponent(match[2]));250 const paused = await pausedDetail(decodeURIComponent(match[1]), decodeURIComponent(match[2]));
183 if (!paused) return json({ error: "Paused execution not found" }, 404);251 if (!paused) return json({ error: "Paused execution not found" }, 404);
184 return json({ text: paused.text, structured: paused.structured });252 return json({ text: paused.text, structured: paused.structured });
185 },253 },
@@ -189,9 +257,12 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 3 unmodified lines
189 if (!match) return json({ error: "Not found" }, 404);257 if (!match) return json({ error: "Not found" }, 404);
190 if (request.method !== "POST") return json({ error: "Method not allowed" }, 405);258 if (request.method !== "POST") return json({ error: "Method not allowed" }, 405);
191 259
260 const sessionId = decodeURIComponent(match[1]);
192 const executionId = decodeURIComponent(match[2]);261 const executionId = decodeURIComponent(match[2]);
193 // The shared engine must still hold the paused execution — guards stale ids.262 // 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 }
195 266
196 const response = await readResumeResponse(request);267 const response = await readResumeResponse(request);
197 if (!response) return json({ error: "Invalid approval response" }, 400);268 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
69 routePath === "/index.html"69 routePath === "/index.html"
70 ? htmlResponse(file)70 ? htmlResponse(file)
71 : new Response(file, {71 : new Response(file, {
72 headers: { "content-type": file.type || "application/octet-stream" },72 headers: {
73 "content-type": file.type || "application/octet-stream",
74 },
73 });75 });
74 }76 }
75 }77 }
@@ -89,7 +91,9 @@ function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string · 13 unmodified lines
89 key === "index.html"91 key === "index.html"
90 ? htmlResponse(file)92 ? htmlResponse(file)
91 : new Response(file, {93 : new Response(file, {
92 headers: { "content-type": file.type || "application/octet-stream" },94 headers: {
95 "content-type": file.type || "application/octet-stream",
96 },
93 });97 });
94 }98 }
95 return routes;99 return routes;
@@ -109,7 +113,11 @@ interface ViteChild { · 13 unmodified lines
109}113}
110 114
111async function allocatePort(): Promise<number> {115async 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 });
113 const port = probe.port ?? 0;121 const port = probe.port ?? 0;
114 probe.stop(true);122 probe.stop(true);
115 return port;123 return port;
@@ -362,7 +370,8 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 246 unmodified lines
362 // provider, which cannot carry our local bearer. Everything else under370 // provider, which cannot carry our local bearer. Everything else under
363 // /api and /mcp requires the bearer.371 // /api and /mcp requires the bearer.
364 const skipAuth = isUnauthenticatedOAuthPath(url.pathname);372 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;
366 375
367 if (isGatedSurface && !skipAuth && !isAuthorized(req)) {376 if (isGatedSurface && !skipAuth && !isAuthorized(req)) {
368 return withCors(377 return withCors(
@@ -377,7 +386,7 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 8 unmodified lines
377 return withCors(oauthClientMetadataResponse(`${url.pathname}${url.search}`, req));386 return withCors(oauthClientMetadataResponse(`${url.pathname}${url.search}`, req));
378 }387 }
379 388
380 if (url.pathname.startsWith("/mcp")) {389 if (isMcpPath) {
381 return withCors(await handlers.mcp.handleRequest(req));390 return withCors(await handlers.mcp.handleRequest(req));
382 }391 }
383 392
apps/local/vite.config.ts+11−5
@@ -16,7 +16,11 @@ import { · 15 unmodified lines
16// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json16// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json
17const rootPackage = JSON.parse(17const rootPackage = JSON.parse(
18 readFileSync(new URL("../../package.json", import.meta.url), "utf8"),18 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};
20 24
21// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json25// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json
22const cliPackage = JSON.parse(26const cliPackage = JSON.parse(
@@ -94,8 +98,9 @@ function executorApiPlugin(): Plugin { · 71 unmodified lines
94 });98 });
95 server.middlewares.use(async (req, res, next) => {99 server.middlewares.use(async (req, res, next) => {
96 const rawUrl = req.url ?? "/";100 const rawUrl = req.url ?? "/";
97 const isApi = rawUrl.startsWith("/api/") || rawUrl === "/api";101 const pathOnly = rawUrl.split("?")[0] ?? "/";
98 const isMcp = rawUrl.startsWith("/mcp");102 const isApi = pathOnly.startsWith("/api/") || pathOnly === "/api";
103 const isMcp = pathOnly === "/mcp" || pathOnly.startsWith("/mcp/");
99 104
100 if (!isApi && !isMcp) return next();105 if (!isApi && !isMcp) return next();
101 106
@@ -106,7 +111,6 @@ function executorApiPlugin(): Plugin { · 4 unmodified lines
106 // callback. The SPA carries the token from its `?_token`/localStorage111 // callback. The SPA carries the token from its `?_token`/localStorage
107 // bootstrap, so the UI is unaffected; external MCP clients use the112 // bootstrap, so the UI is unaffected; external MCP clients use the
108 // daemon port.113 // daemon port.
109 const pathOnly = rawUrl.split("?")[0] ?? "/";
110 const authExempt = pathOnly === "/api/health" || isUnauthenticatedOAuthPath(pathOnly);114 const authExempt = pathOnly === "/api/health" || isUnauthenticatedOAuthPath(pathOnly);
111 if (!authExempt) {115 if (!authExempt) {
112 const presented = req.headers.authorization;116 const presented = req.headers.authorization;
@@ -151,7 +155,9 @@ function executorApiPlugin(): Plugin { · 38 unmodified lines
151 } else if (isMcp) {155 } else if (isMcp) {
152 response = await handlers.mcp.handleRequest(webRequest(rawUrl));156 response = await handlers.mcp.handleRequest(webRequest(rawUrl));
153 } else if (pathOnly === "/api/health" && req.method === "GET") {157 } 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 });
155 } else if (pathOnly.startsWith("/api/mcp-sessions/")) {161 } else if (pathOnly.startsWith("/api/mcp-sessions/")) {
156 const handler =162 const handler =
157 req.method === "GET"163 req.method === "GET"
bun.lock+50−0
@@ -70,6 +70,7 @@ · 69 unmodified lines
70 "@executor-js/plugin-mcp": "workspace:*",70 "@executor-js/plugin-mcp": "workspace:*",
71 "@executor-js/plugin-microsoft": "workspace:*",71 "@executor-js/plugin-microsoft": "workspace:*",
72 "@executor-js/plugin-openapi": "workspace:*",72 "@executor-js/plugin-openapi": "workspace:*",
73 "@executor-js/plugin-toolkits": "workspace:*",
73 "@executor-js/plugin-workos-vault": "workspace:*",74 "@executor-js/plugin-workos-vault": "workspace:*",
74 "@executor-js/react": "workspace:*",75 "@executor-js/react": "workspace:*",
75 "@executor-js/runtime-dynamic-worker": "workspace:*",76 "@executor-js/runtime-dynamic-worker": "workspace:*",
@@ -180,6 +181,7 @@ · 104 unmodified lines
180 "@executor-js/plugin-mcp": "workspace:*",181 "@executor-js/plugin-mcp": "workspace:*",
181 "@executor-js/plugin-microsoft": "workspace:*",182 "@executor-js/plugin-microsoft": "workspace:*",
182 "@executor-js/plugin-openapi": "workspace:*",183 "@executor-js/plugin-openapi": "workspace:*",
184 "@executor-js/plugin-toolkits": "workspace:*",
183 "@executor-js/react": "workspace:*",185 "@executor-js/react": "workspace:*",
184 "@executor-js/runtime-quickjs": "workspace:*",186 "@executor-js/runtime-quickjs": "workspace:*",
185 "@executor-js/sdk": "workspace:*",187 "@executor-js/sdk": "workspace:*",
@@ -228,6 +230,7 @@ · 42 unmodified lines
228 "@executor-js/plugin-mcp": "workspace:*",230 "@executor-js/plugin-mcp": "workspace:*",
229 "@executor-js/plugin-microsoft": "workspace:*",231 "@executor-js/plugin-microsoft": "workspace:*",
230 "@executor-js/plugin-openapi": "workspace:*",232 "@executor-js/plugin-openapi": "workspace:*",
233 "@executor-js/plugin-toolkits": "workspace:*",
231 "@executor-js/react": "workspace:*",234 "@executor-js/react": "workspace:*",
232 "@executor-js/runtime-quickjs": "workspace:*",235 "@executor-js/runtime-quickjs": "workspace:*",
233 "@executor-js/sdk": "workspace:*",236 "@executor-js/sdk": "workspace:*",
@@ -280,6 +283,7 @@ · 46 unmodified lines
280 "@executor-js/plugin-microsoft": "workspace:*",283 "@executor-js/plugin-microsoft": "workspace:*",
281 "@executor-js/plugin-onepassword": "workspace:*",284 "@executor-js/plugin-onepassword": "workspace:*",
282 "@executor-js/plugin-openapi": "workspace:*",285 "@executor-js/plugin-openapi": "workspace:*",
286 "@executor-js/plugin-toolkits": "workspace:*",
283 "@executor-js/react": "workspace:*",287 "@executor-js/react": "workspace:*",
284 "@executor-js/runtime-quickjs": "workspace:*",288 "@executor-js/runtime-quickjs": "workspace:*",
285 "@executor-js/sdk": "workspace:*",289 "@executor-js/sdk": "workspace:*",
@@ -346,6 +350,7 @@ · 60 unmodified lines
346 "@executor-js/plugin-mcp": "workspace:*",350 "@executor-js/plugin-mcp": "workspace:*",
347 "@executor-js/plugin-microsoft": "workspace:*",351 "@executor-js/plugin-microsoft": "workspace:*",
348 "@executor-js/plugin-openapi": "workspace:*",352 "@executor-js/plugin-openapi": "workspace:*",
353 "@executor-js/plugin-toolkits": "workspace:*",
349 "@executor-js/sdk": "workspace:*",354 "@executor-js/sdk": "workspace:*",
350 "@kitlangton/terminal-control": "^0.3.0",355 "@kitlangton/terminal-control": "^0.3.0",
351 "@modelcontextprotocol/sdk": "^1.29.0",356 "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1088,6 +1093,49 @@ · 736 unmodified lines
1088 "react",1093 "react",
1089 ],1094 ],
1090 },1095 },
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 },
1091 "packages/plugins/workos-vault": {1139 "packages/plugins/workos-vault": {
1092 "name": "@executor-js/plugin-workos-vault",1140 "name": "@executor-js/plugin-workos-vault",
1093 "version": "0.0.2",1141 "version": "0.0.2",
@@ -1682,6 +1730,8 @@ · 588 unmodified lines
1682 1730
1683 "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"],1731 "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"],
1684 1732
1733 "@executor-js/plugin-toolkits": ["@executor-js/plugin-toolkits@workspace:packages/plugins/toolkits"],
1734
1685 "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"],1735 "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"],
1686 1736
1687 "@executor-js/react": ["@executor-js/react@workspace:packages/react"],1737 "@executor-js/react": ["@executor-js/react@workspace:packages/react"],
e2e/local/toolkits-mcp.test.ts+320−0
@@ -0,0 +1,320 @@
1import { randomBytes } from "node:crypto";
2import { createServer, type Server } from "node:http";
3import type { AddressInfo } from "node:net";
4
5import { expect } from "@effect/vitest";
6import { Effect } from "effect";
7import { HttpApiClient } from "effect/unstable/httpapi";
8import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
9import { Client } from "@modelcontextprotocol/sdk/client/index.js";
10import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
11import { composePluginApi } from "@executor-js/api/server";
12import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
13import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
14import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
15
16import { scenario } from "../src/scenario";
17import { Cli, RunDir } from "../src/services";
18import { withLocalServer } from "./local-server";
19
20const api = composePluginApi([openApiHttpPlugin(), toolkitsPlugin()] as const);
21
22const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
23
24const 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
69const closeServer = (server: Server): Promise<void> =>
70 new Promise((resolve) => {
71 server.close(() => resolve());
72 });
73
74const 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
102const connectionPattern = (integration: string, name: string): string =>
103 `${integration}.org.${name}.*`;
104
105const pingToolPath = (integration: string, name: string): string =>
106 `${integration}.org.${name}.ping.getPing`;
107
108const callPingCode = (input: {
109 readonly integration: string;
110 readonly connection: string;
111 readonly id: string;
112}) => `
113const listed = await tools.search({ namespace: ${JSON.stringify(input.integration)}, query: "ping", limit: 100 });
114const expected = ${JSON.stringify(`${input.integration}.org.${input.connection}.`)};
115const path = listed.items.map((item) => item.path).find((candidate) => candidate.startsWith(expected));
116if (!path) return { ok: false, reason: "missing", expected, paths: listed.items.map((item) => item.path).sort() };
117let tool = tools;
118for (const segment of path.split(".")) tool = tool?.[segment];
119if (typeof tool !== "function") return { ok: false, reason: "not-callable", path };
120const result = await tool({ id: ${JSON.stringify(input.id)} });
121return JSON.stringify({ ok: result.ok, path, data: result.ok ? result.data : result.error });
122`;
123
124const listVisiblePathsCode = (integration: string) => `
125const listed = await tools.search({ namespace: ${JSON.stringify(integration)}, query: "ping", limit: 100 });
126return JSON.stringify({ paths: listed.items.map((item) => item.path).sort() });
127`;
128
129const 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
138const 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
145const 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
153scenario(
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
27 "@executor-js/plugin-mcp": "workspace:*",27 "@executor-js/plugin-mcp": "workspace:*",
28 "@executor-js/plugin-microsoft": "workspace:*",28 "@executor-js/plugin-microsoft": "workspace:*",
29 "@executor-js/plugin-openapi": "workspace:*",29 "@executor-js/plugin-openapi": "workspace:*",
30 "@executor-js/plugin-toolkits": "workspace:*",
30 "@executor-js/sdk": "workspace:*",31 "@executor-js/sdk": "workspace:*",
31 "@kitlangton/terminal-control": "^0.3.0",32 "@kitlangton/terminal-control": "^0.3.0",
32 "@modelcontextprotocol/sdk": "^1.29.0",33 "@modelcontextprotocol/sdk": "^1.29.0",
e2e/scenarios/toolkits-mcp.test.ts+585−0
@@ -0,0 +1,585 @@
1import { randomBytes } from "node:crypto";
2import { createServer, type Server } from "node:http";
3import type { AddressInfo } from "node:net";
4
5import { expect } from "@effect/vitest";
6import { Effect } from "effect";
7import { composePluginApi } from "@executor-js/api/server";
8import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
9import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
10import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
11
12import { scenario } from "../src/scenario";
13import { Api, Mcp, Target } from "../src/services";
14import type { McpSession } from "../src/surfaces/mcp";
15
16const api = composePluginApi([openApiHttpPlugin(), toolkitsPlugin()] as const);
17
18const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
19
20const 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
58const closeServer = (server: Server): Promise<void> =>
59 new Promise((resolve) => {
60 server.close(() => resolve());
61 });
62
63const 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
91const toolkitUrl = (baseUrl: string, slug: string): string =>
92 new URL(`/mcp/toolkits/${slug}`, baseUrl).toString();
93
94const connectionPattern = (integration: string, owner: "org" | "user", name: string): string =>
95 `${integration}.${owner}.${name}.*`;
96
97const 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
104const callPingCode = (input: {
105 readonly integration: string;
106 readonly owner: "org" | "user";
107 readonly connection: string;
108 readonly id: string;
109}) => `
110const listed = await tools.search({ namespace: ${JSON.stringify(input.integration)}, query: "ping", limit: 100 });
111const expected = ${JSON.stringify(`${input.integration}.${input.owner}.${input.connection}.`)};
112const path = listed.items.map((item) => item.path).find((candidate) => candidate.startsWith(expected));
113if (!path) return { ok: false, reason: "missing", expected, paths: listed.items.map((item) => item.path).sort() };
114let tool = tools;
115for (const segment of path.split(".")) tool = tool?.[segment];
116if (typeof tool !== "function") return { ok: false, reason: "not-callable", path };
117const result = await tool({ id: ${JSON.stringify(input.id)} });
118return { ok: result.ok, path, data: result.ok ? result.data : result.error };
119`;
120
121const visibleConnectionPathsCode = (integration: string) => `
122const listed = await tools.search({ namespace: ${JSON.stringify(integration)}, query: "ping", limit: 100 });
123return { paths: listed.items.map((item) => item.path).sort() };
124`;
125
126const createPolicyCode = (input: {
127 readonly pattern: string;
128 readonly action: "approve" | "require_approval" | "block";
129}) => `
130const created = await tools.executor.coreTools.policies.create({
131 owner: "user",
132 pattern: ${JSON.stringify(input.pattern)},
133 action: ${JSON.stringify(input.action)},
134});
135return JSON.stringify({ ok: created.ok, data: created.ok ? created.data : null, error: created.ok ? null : created.error });
136`;
137
138const assertCallOk = (value: Record<string, unknown>, label: string) => {
139 expect(value.ok, `${label}: ${JSON.stringify(value)}`).toBe(true);
140};
141
142const 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
147scenario(
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
193scenario(
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
386scenario(
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
485scenario(
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 @@
1import { randomBytes } from "node:crypto";
2
3import { expect } from "@effect/vitest";
4import { Effect } from "effect";
5import { composePluginApi } from "@executor-js/api/server";
6import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
7
8import { scenario } from "../src/scenario";
9import { Api, Mcp, Target } from "../src/services";
10import type { Identity } from "../src/target";
11
12const api = composePluginApi([toolkitsPlugin()] as const);
13
14const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
15
16const allowedCode = `
17const result = await tools.executor.coreTools.integrations.list({});
18if (!result.ok) throw new Error(result.error.message);
19return result.data.integrations.map((integration) => integration.slug).includes("executor");
20`;
21
22const blockedCode = `
23const result = await tools.executor.coreTools.policies.list({});
24if (!result.ok) throw new Error(result.error.message);
25return result.data.policies.length;
26`;
27
28const 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
53const 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
70scenario(
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 @@
1import { randomBytes } from "node:crypto";
2
3import { expect } from "@effect/vitest";
4import { Effect } from "effect";
5import { composePluginApi } from "@executor-js/api/server";
6import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
7import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
8import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
9
10import { scenario } from "../src/scenario";
11import { Api, Browser, Target } from "../src/services";
12
13const api = composePluginApi([toolkitsPlugin(), openApiHttpPlugin()] as const);
14
15const 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
30scenario(
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
12import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot";12import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot";
13 13
14export const cloudflareDir = fileURLToPath(new URL("../../apps/host-cloudflare/", import.meta.url));14export const cloudflareDir = fileURLToPath(new URL("../../apps/host-cloudflare/", import.meta.url));
15const wranglerBin = fileURLToPath(
16 new URL("../../apps/host-cloudflare/node_modules/.bin/wrangler", import.meta.url),
17);
15 18
16export interface CloudflareBootOptions {19export interface CloudflareBootOptions {
17 readonly port: number;20 readonly port: number;
@@ -28,12 +31,13 @@ export const bootCloudflare = async (options: CloudflareBootOptions): Promise<Bo · 10 unmodified lines
28 const procs = bootProcesses(31 const procs = bootProcesses(
29 [32 [
30 {33 {
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.
32 // dev-auth + the secret key arrive as `--var` overrides so the worker36 // dev-auth + the secret key arrive as `--var` overrides so the worker
33 // needs no Cloudflare account or real Access app.37 // needs no Cloudflare account or real Access app.
34 cmd: "bunx",38 cmd: process.env.E2E_NODE_BIN ?? "node",
35 args: [39 args: [
36 "wrangler",40 wranglerBin,
37 "dev",41 "dev",
38 "--port",42 "--port",
39 String(options.port),43 String(options.port),
@@ -43,6 +47,8 @@ export const bootCloudflare = async (options: CloudflareBootOptions): Promise<Bo · 3 unmodified lines
43 "ENABLE_DEV_AUTH:true",47 "ENABLE_DEV_AUTH:true",
44 "--var",48 "--var",
45 "EXECUTOR_SECRET_KEY:e2e-secret-key-0123456789abcdef0123456789abcdef",49 "EXECUTOR_SECRET_KEY:e2e-secret-key-0123456789abcdef0123456789abcdef",
50 "--var",
51 "ALLOW_LOCAL_NETWORK:true",
46 ],52 ],
47 cwd: cloudflareDir,53 cwd: cloudflareDir,
48 env: { WRANGLER_SEND_METRICS: "false", CI: "true" },54 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
11import { Effect } from "effect";11import { Effect } from "effect";
12 12
13import { createRuntime, type Runtime } from "@executor-js/mcporter";13import { createRuntime, type Runtime } from "@executor-js/mcporter";
14import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
14 16
15import { appendTraces } from "../trace-harvest";17import { appendTraces } from "../trace-harvest";
16import type { Identity, Target } from "../target";18import type { Identity, Target } from "../target";
@@ -160,7 +162,7 @@ export interface McpSurface { · 143 unmodified lines
160 readonly url: string;162 readonly url: string;
161 readonly session: (163 readonly session: (
162 identity: Identity,164 identity: Identity,
163 options?: { readonly elicitationMode?: McpElicitationMode },165 options?: { readonly elicitationMode?: McpElicitationMode; readonly url?: string },
164 ) => McpSession;166 ) => McpSession;
165 /**167 /**
166 * Mint a real MCP bearer headlessly: protected-resource discovery →168 * Mint a real MCP bearer headlessly: protected-resource discovery →
@@ -188,6 +190,17 @@ interface TokenResponse { · 21 unmodified lines
188 readonly access_token?: string;190 readonly access_token?: string;
189}191}
190 192
193const 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
191const mintBearerFlow = async (target: Target, email: string): Promise<string> => {204const mintBearerFlow = async (target: Target, email: string): Promise<string> => {
192 const consent = target.mcpConsent?.({205 const consent = target.mcpConsent?.({
193 label: email,206 label: email,
@@ -196,21 +209,31 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 2 unmodified lines
196 if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`);209 if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`);
197 210
198 const mcpPath = new URL(target.mcpUrl).pathname;211 const mcpPath = new URL(target.mcpUrl).pathname;
199 const resource = (await (212 let resourceResponse = await fetch(
200 await fetch(new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl))213 new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl),
201 ).json()) as { authorization_servers?: ReadonlyArray<string> };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 );
202 const issuer = resource.authorization_servers?.[0];224 const issuer = resource.authorization_servers?.[0];
203 if (!issuer) throw new Error("mintBearer: no authorization server advertised");225 if (!issuer) throw new Error("mintBearer: no authorization server advertised");
204 const metadata = (await (226 const metadata = await jsonFrom<{
205 await fetch(new URL("/.well-known/oauth-authorization-server", issuer))
206 ).json()) as {
207 readonly authorization_endpoint: string;227 readonly authorization_endpoint: string;
208 readonly token_endpoint: string;228 readonly token_endpoint: string;
209 readonly registration_endpoint: string;229 readonly registration_endpoint: string;
210 };230 }>(
231 await fetch(new URL("/.well-known/oauth-authorization-server", issuer)),
232 "mintBearer: authorization-server metadata",
233 );
211 234
212 const redirectUri = "http://127.0.0.1:9/callback";235 const redirectUri = "http://127.0.0.1:9/callback";
213 const registered = (await (236 const registered = await jsonFrom<{ readonly client_id: string }>(
214 await fetch(metadata.registration_endpoint, {237 await fetch(metadata.registration_endpoint, {
215 method: "POST",238 method: "POST",
216 headers: { "content-type": "application/json" },239 headers: { "content-type": "application/json" },
@@ -221,8 +244,9 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 4 unmodified lines
221 response_types: ["code"],244 response_types: ["code"],
222 token_endpoint_auth_method: "none",245 token_endpoint_auth_method: "none",
223 }),246 }),
224 })247 }),
225 ).json()) as { readonly client_id: string };248 "mintBearer: dynamic client registration",
249 );
226 250
227 const verifier = randomBytes(32).toString("base64url");251 const verifier = randomBytes(32).toString("base64url");
228 const authorizeUrl = new URL(metadata.authorization_endpoint);252 const authorizeUrl = new URL(metadata.authorization_endpoint);
@@ -240,7 +264,7 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 11 unmodified lines
240 redirectUrl: redirectUri,264 redirectUrl: redirectUri,
241 });265 });
242 266
243 const token = (await (267 const token = await jsonFrom<TokenResponse>(
244 await fetch(metadata.token_endpoint, {268 await fetch(metadata.token_endpoint, {
245 method: "POST",269 method: "POST",
246 headers: { "content-type": "application/x-www-form-urlencoded" },270 headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -251,8 +275,9 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 4 unmodified lines
251 client_id: registered.client_id,275 client_id: registered.client_id,
252 code_verifier: verifier,276 code_verifier: verifier,
253 }),277 }),
254 })278 }),
255 ).json()) as TokenResponse;279 "mintBearer: token exchange",
280 );
256 if (!token.access_token) throw new Error("mintBearer: token exchange returned no token");281 if (!token.access_token) throw new Error("mintBearer: token exchange returned no token");
257 return token.access_token;282 return token.access_token;
258};283};
@@ -261,7 +286,8 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 2 unmodified lines
261 url: target.mcpUrl,286 url: target.mcpUrl,
262 mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)),287 mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)),
263 session: (identity, options) => {288 session: (identity, options) => {
264 if (runDir) installTraceparentFetch(target.mcpUrl, runDir);289 const mcpUrl = options?.url ?? target.mcpUrl;
290 if (runDir) installTraceparentFetch(mcpUrl, runDir);
265 // mcporter caches OAuth tokens (and the DCR client) per server NAME, so a291 // mcporter caches OAuth tokens (and the DCR client) per server NAME, so a
266 // constant name would let a later session reuse an earlier identity's token292 // constant name would let a later session reuse an earlier identity's token
267 // — landing in the wrong org. A unique name per session keeps each293 // — 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
272 // `?elicitation_mode=` query on the MCP endpoint — so a paused execution298 // `?elicitation_mode=` query on the MCP endpoint — so a paused execution
273 // yields an approvalUrl instead of letting the model resume inline.299 // yields an approvalUrl instead of letting the model resume inline.
274 const sessionUrl = options?.elicitationMode300 const sessionUrl = options?.elicitationMode
275 ? `${target.mcpUrl}?elicitation_mode=${options.elicitationMode}`301 ? `${mcpUrl}?elicitation_mode=${options.elicitationMode}`
276 : target.mcpUrl;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
277 let runtimePromise: Promise<Runtime> | undefined;362 let runtimePromise: Promise<Runtime> | undefined;
278 let connected = false;363 let connected = false;
279 364
e2e/targets/selfhost.ts+8−2
@@ -83,7 +83,13 @@ const forcedMcpConsent = · 82 unmodified lines
83 if (!decision.ok) {83 if (!decision.ok) {
84 throw new Error(`forcedMcpConsent: consent grant failed (status ${decision.status})`);84 throw new Error(`forcedMcpConsent: consent grant failed (status ${decision.status})`);
85 }85 }
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 };
87 const code = body.redirectURI ? new URL(body.redirectURI).searchParams.get("code") : null;93 const code = body.redirectURI ? new URL(body.redirectURI).searchParams.get("code") : null;
88 if (!code) {94 if (!code) {
89 throw new Error(95 throw new Error(
@@ -118,6 +124,6 @@ export const selfhostTarget = (): Target => ({ · 28 unmodified lines
118 mcpConsent: (identity: Identity) =>124 mcpConsent: (identity: Identity) =>
119 forcedMcpConsent(SELFHOST_BASE_URL, {125 forcedMcpConsent(SELFHOST_BASE_URL, {
120 email: identity.credentials?.email ?? SELFHOST_ADMIN.email,126 email: identity.credentials?.email ?? SELFHOST_ADMIN.email,
121 password: identity.credentials?.password ?? SELFHOST_ADMIN.password,127 password: identity.credentials?.password || SELFHOST_ADMIN.password,
122 }),128 }),
123});129});
e2e/vitest.config.ts+1−0
@@ -49,6 +49,7 @@ export default defineConfig({ · 48 unmodified lines
49 include: [49 include: [
50 "scenarios/browser-approval.test.ts",50 "scenarios/browser-approval.test.ts",
51 "scenarios/microsoft-graph-full.test.ts",51 "scenarios/microsoft-graph-full.test.ts",
52 "scenarios/toolkits-mcp.test.ts",
52 "cloudflare/**/*.test.ts",53 "cloudflare/**/*.test.ts",
53 ],54 ],
54 fileParallelism: false,55 fileParallelism: false,
packages/app/src/routeTree.gen.ts+58−0
@@ -11,8 +11,10 @@ · 10 unmodified lines
11import { Route as rootRouteImport } from './routes/__root'11import { Route as rootRouteImport } from './routes/__root'
12import { Route as DotDotDotDotDotDotReactSrcRoutesIndexRouteImport } from './../../react/src/routes/index'12import { Route as DotDotDotDotDotDotReactSrcRoutesIndexRouteImport } from './../../react/src/routes/index'
13import { Route as DotDotDotDotDotDotReactSrcRoutesToolsRouteImport } from './../../react/src/routes/tools'13import { Route as DotDotDotDotDotDotReactSrcRoutesToolsRouteImport } from './../../react/src/routes/tools'
14import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport } from './../../react/src/routes/toolkits'
14import { Route as SecretsRouteImport } from './routes/app/secrets'15import { Route as SecretsRouteImport } from './routes/app/secrets'
15import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './../../react/src/routes/policies'16import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './../../react/src/routes/policies'
17import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug'
16import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId'18import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId'
17import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace'19import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace'
18import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$'20import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$'
@@ -30,6 +32,12 @@ const DotDotDotDotDotDotReactSrcRoutesToolsRoute = · 11 unmodified lines
30 path: '/{-$orgSlug}/tools',32 path: '/{-$orgSlug}/tools',
31 getParentRoute: () => rootRouteImport,33 getParentRoute: () => rootRouteImport,
32 } as any)34 } as any)
35const DotDotDotDotDotDotReactSrcRoutesToolkitsRoute =
36 DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport.update({
37 id: '/{-$orgSlug}/toolkits',
38 path: '/{-$orgSlug}/toolkits',
39 getParentRoute: () => rootRouteImport,
40 } as any)
33const SecretsRoute = SecretsRouteImport.update({41const SecretsRoute = SecretsRouteImport.update({
34 id: '/{-$orgSlug}/secrets',42 id: '/{-$orgSlug}/secrets',
35 path: '/{-$orgSlug}/secrets',43 path: '/{-$orgSlug}/secrets',
@@ -41,6 +49,12 @@ const DotDotDotDotDotDotReactSrcRoutesPoliciesRoute = · 5 unmodified lines
41 path: '/{-$orgSlug}/policies',49 path: '/{-$orgSlug}/policies',
42 getParentRoute: () => rootRouteImport,50 getParentRoute: () => rootRouteImport,
43 } as any)51 } as any)
52const DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute =
53 DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update({
54 id: '/$toolkitSlug',
55 path: '/$toolkitSlug',
56 getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesToolkitsRoute,
57 } as any)
44const DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute =58const DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute =
45 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport.update({59 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport.update({
46 id: '/{-$orgSlug}/resume/$executionId',60 id: '/{-$orgSlug}/resume/$executionId',
@@ -71,20 +85,24 @@ const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute = · 24 unmodified lines
71export interface FileRoutesByFullPath {85export interface FileRoutesByFullPath {
72 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute86 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute
73 '/{-$orgSlug}/secrets': typeof SecretsRoute87 '/{-$orgSlug}/secrets': typeof SecretsRoute
88 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
74 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute89 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
75 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute90 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
76 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute91 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
77 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute92 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
93 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
78 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute94 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
79 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute95 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute
80}96}
81export interface FileRoutesByTo {97export interface FileRoutesByTo {
82 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute98 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute
83 '/{-$orgSlug}/secrets': typeof SecretsRoute99 '/{-$orgSlug}/secrets': typeof SecretsRoute
100 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
84 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute101 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
85 '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute102 '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
86 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute103 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
87 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute104 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
105 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
88 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute106 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
89 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute107 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute
90}108}
@@ -92,10 +110,12 @@ export interface FileRoutesById { · 1 unmodified line
92 __root__: typeof rootRouteImport110 __root__: typeof rootRouteImport
93 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute111 '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute
94 '/{-$orgSlug}/secrets': typeof SecretsRoute112 '/{-$orgSlug}/secrets': typeof SecretsRoute
113 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
95 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute114 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
96 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute115 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
97 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute116 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
98 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute117 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
118 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
99 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute119 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
100 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute120 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute
101}121}
@@ -104,30 +124,36 @@ export interface FileRouteTypes { · 2 unmodified lines
104 fullPaths:124 fullPaths:
105 | '/{-$orgSlug}/policies'125 | '/{-$orgSlug}/policies'
106 | '/{-$orgSlug}/secrets'126 | '/{-$orgSlug}/secrets'
127 | '/{-$orgSlug}/toolkits'
107 | '/{-$orgSlug}/tools'128 | '/{-$orgSlug}/tools'
108 | '/{-$orgSlug}/'129 | '/{-$orgSlug}/'
109 | '/{-$orgSlug}/integrations/$namespace'130 | '/{-$orgSlug}/integrations/$namespace'
110 | '/{-$orgSlug}/resume/$executionId'131 | '/{-$orgSlug}/resume/$executionId'
132 | '/{-$orgSlug}/toolkits/$toolkitSlug'
111 | '/{-$orgSlug}/integrations/add/$pluginKey'133 | '/{-$orgSlug}/integrations/add/$pluginKey'
112 | '/{-$orgSlug}/plugins/$pluginId/$'134 | '/{-$orgSlug}/plugins/$pluginId/$'
113 fileRoutesByTo: FileRoutesByTo135 fileRoutesByTo: FileRoutesByTo
114 to:136 to:
115 | '/{-$orgSlug}/policies'137 | '/{-$orgSlug}/policies'
116 | '/{-$orgSlug}/secrets'138 | '/{-$orgSlug}/secrets'
139 | '/{-$orgSlug}/toolkits'
117 | '/{-$orgSlug}/tools'140 | '/{-$orgSlug}/tools'
118 | '/{-$orgSlug}'141 | '/{-$orgSlug}'
119 | '/{-$orgSlug}/integrations/$namespace'142 | '/{-$orgSlug}/integrations/$namespace'
120 | '/{-$orgSlug}/resume/$executionId'143 | '/{-$orgSlug}/resume/$executionId'
144 | '/{-$orgSlug}/toolkits/$toolkitSlug'
121 | '/{-$orgSlug}/integrations/add/$pluginKey'145 | '/{-$orgSlug}/integrations/add/$pluginKey'
122 | '/{-$orgSlug}/plugins/$pluginId/$'146 | '/{-$orgSlug}/plugins/$pluginId/$'
123 id:147 id:
124 | '__root__'148 | '__root__'
125 | '/{-$orgSlug}/policies'149 | '/{-$orgSlug}/policies'
126 | '/{-$orgSlug}/secrets'150 | '/{-$orgSlug}/secrets'
151 | '/{-$orgSlug}/toolkits'
127 | '/{-$orgSlug}/tools'152 | '/{-$orgSlug}/tools'
128 | '/{-$orgSlug}/'153 | '/{-$orgSlug}/'
129 | '/{-$orgSlug}/integrations/$namespace'154 | '/{-$orgSlug}/integrations/$namespace'
130 | '/{-$orgSlug}/resume/$executionId'155 | '/{-$orgSlug}/resume/$executionId'
156 | '/{-$orgSlug}/toolkits/$toolkitSlug'
131 | '/{-$orgSlug}/integrations/add/$pluginKey'157 | '/{-$orgSlug}/integrations/add/$pluginKey'
132 | '/{-$orgSlug}/plugins/$pluginId/$'158 | '/{-$orgSlug}/plugins/$pluginId/$'
133 fileRoutesById: FileRoutesById159 fileRoutesById: FileRoutesById
@@ -135,6 +161,7 @@ export interface FileRouteTypes { · 1 unmodified line
135export interface RootRouteChildren {161export interface RootRouteChildren {
136 DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute162 DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute
137 SecretsRoute: typeof SecretsRoute163 SecretsRoute: typeof SecretsRoute
164 DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
138 DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute165 DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
139 DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute166 DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
140 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute167 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
@@ -159,6 +186,13 @@ declare module '@tanstack/react-router' { · 18 unmodified lines
159 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRouteImport186 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRouteImport
160 parentRoute: typeof rootRouteImport187 parentRoute: typeof rootRouteImport
161 }188 }
189 '/{-$orgSlug}/toolkits': {
190 id: '/{-$orgSlug}/toolkits'
191 path: '/{-$orgSlug}/toolkits'
192 fullPath: '/{-$orgSlug}/toolkits'
193 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport
194 parentRoute: typeof rootRouteImport
195 }
162 '/{-$orgSlug}/secrets': {196 '/{-$orgSlug}/secrets': {
163 id: '/{-$orgSlug}/secrets'197 id: '/{-$orgSlug}/secrets'
164 path: '/{-$orgSlug}/secrets'198 path: '/{-$orgSlug}/secrets'
@@ -173,6 +207,13 @@ declare module '@tanstack/react-router' { · 8 unmodified lines
173 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport207 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport
174 parentRoute: typeof rootRouteImport208 parentRoute: typeof rootRouteImport
175 }209 }
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 }
176 '/{-$orgSlug}/resume/$executionId': {217 '/{-$orgSlug}/resume/$executionId': {
177 id: '/{-$orgSlug}/resume/$executionId'218 id: '/{-$orgSlug}/resume/$executionId'
178 path: '/{-$orgSlug}/resume/$executionId'219 path: '/{-$orgSlug}/resume/$executionId'
@@ -204,10 +245,27 @@ declare module '@tanstack/react-router' { · 25 unmodified lines
204 }245 }
205}246}
206 247
248interface DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren {
249 DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
250}
251
252const DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren: DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren =
253 {
254 DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute:
255 DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute,
256 }
257
258const DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren =
259 DotDotDotDotDotDotReactSrcRoutesToolkitsRoute._addFileChildren(
260 DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren,
261 )
262
207const rootRouteChildren: RootRouteChildren = {263const rootRouteChildren: RootRouteChildren = {
208 DotDotDotDotDotDotReactSrcRoutesPoliciesRoute:264 DotDotDotDotDotDotReactSrcRoutesPoliciesRoute:
209 DotDotDotDotDotDotReactSrcRoutesPoliciesRoute,265 DotDotDotDotDotDotReactSrcRoutesPoliciesRoute,
210 SecretsRoute: SecretsRoute,266 SecretsRoute: SecretsRoute,
267 DotDotDotDotDotDotReactSrcRoutesToolkitsRoute:
268 DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren,
211 DotDotDotDotDotDotReactSrcRoutesToolsRoute:269 DotDotDotDotDotDotReactSrcRoutesToolsRoute:
212 DotDotDotDotDotDotReactSrcRoutesToolsRoute,270 DotDotDotDotDotDotReactSrcRoutesToolsRoute,
213 DotDotDotDotDotDotReactSrcRoutesIndexRoute:271 DotDotDotDotDotDotReactSrcRoutesIndexRoute:
packages/app/src/web/shell.tsx+7−0
@@ -342,6 +342,7 @@ function SidebarContent(props: { · 341 unmodified lines
342 const isHome = props.pathname === "/";342 const isHome = props.pathname === "/";
343 const isSecrets = props.pathname === "/secrets";343 const isSecrets = props.pathname === "/secrets";
344 const isPolicies = props.pathname === "/policies";344 const isPolicies = props.pathname === "/policies";
345 const isToolkits = props.pathname === "/toolkits" || props.pathname.startsWith("/toolkits/");
345 346
346 return (347 return (
347 <>348 <>
@@ -381,6 +382,12 @@ function SidebarContent(props: { · 33 unmodified lines
381 active={isPolicies}382 active={isPolicies}
382 onNavigate={props.onNavigate}383 onNavigate={props.onNavigate}
383 />384 />
385 <NavItem
386 to="/{-$orgSlug}/toolkits"
387 label="Toolkits"
388 active={isToolkits}
389 onNavigate={props.onNavigate}
390 />
384 391
385 <PluginNav pathname={props.pathname} onNavigate={props.onNavigate} />392 <PluginNav pathname={props.pathname} onNavigate={props.onNavigate} />
386 393
packages/core/api/src/server/execution-stack.ts+3−0
@@ -26,6 +26,7 @@ · 25 unmodified lines
26import { Context, Effect, Layer } from "effect";26import { Context, Effect, Layer } from "effect";
27import type * as Cause from "effect/Cause";27import type * as Cause from "effect/Cause";
28 28
29import type { McpResource } from "@executor-js/host-mcp";
29import type { AnyPlugin, Executor, StorageFailure } from "@executor-js/sdk";30import type { AnyPlugin, Executor, StorageFailure } from "@executor-js/sdk";
30import {31import {
31 createExecutionEngine,32 createExecutionEngine,
@@ -95,6 +96,7 @@ export const makeExecutionStack = < · 63 unmodified lines
95 accountId: string,96 accountId: string,
96 organizationId: string,97 organizationId: string,
97 organizationName: string,98 organizationName: string,
99 options?: { readonly mcpResource?: McpResource },
98): Effect.Effect<100): Effect.Effect<
99 { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> },101 { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> },
100 StorageFailure,102 StorageFailure,
@@ -105,6 +107,7 @@ export const makeExecutionStack = < · 4 unmodified lines
105 accountId,107 accountId,
106 organizationId,108 organizationId,
107 organizationName,109 organizationName,
110 { plugins: { mcpResource: options?.mcpResource } },
108 );111 );
109 const codeExecutor = yield* CodeExecutorProvider;112 const codeExecutor = yield* CodeExecutorProvider;
110 const { decorate } = yield* EngineDecorator;113 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
39export const makeMcpBuildServer =39export const makeMcpBuildServer =
40 (executionStack: McpExecutionStackLayer): McpBuildServer =>40 (executionStack: McpExecutionStackLayer): McpBuildServer =>
41 (principal: Principal, options?: McpBuildServerOptions) =>41 (principal: Principal, options?: McpBuildServerOptions) =>
42 makeExecutionStack(42 makeExecutionStack(principal.accountId, principal.organizationId, principal.organizationName, {
43 principal.accountId,43 mcpResource: options?.resource,
44 principal.organizationId,44 }).pipe(
45 principal.organizationName,
46 ).pipe(
47 Effect.map(({ engine }) => engine),45 Effect.map(({ engine }) => engine),
48 // Pin browser-handoff URLs to the principal's org slug when present;46 // Pin browser-handoff URLs to the principal's org slug when present;
49 // absent slug leaves the service unprovided and the URL stays bare.47 // 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
33 33
34import { Context, Effect, Option } from "effect";34import { Context, Effect, Option } from "effect";
35 35
36import type { McpResource } from "@executor-js/host-mcp";
36import {37import {
37 createExecutor,38 createExecutor,
38 Subject,39 Subject,
@@ -164,8 +165,12 @@ export const buildOAuthRedirectUri = (input: { · 125 unmodified lines
164// while a host with static plugins (self-host) just returns a constant array.165// while a host with static plugins (self-host) just returns a constant array.
165// ---------------------------------------------------------------------------166// ---------------------------------------------------------------------------
166 167
168export interface PluginsProviderContext {
169 readonly mcpResource?: McpResource;
170}
171
167export interface PluginsProviderShape {172export interface PluginsProviderShape {
168 readonly plugins: () => readonly AnyPlugin[];173 readonly plugins: (context?: PluginsProviderContext) => readonly AnyPlugin[];
169}174}
170 175
171export class PluginsProvider extends Context.Service<PluginsProvider, PluginsProviderShape>()(176export class PluginsProvider extends Context.Service<PluginsProvider, PluginsProviderShape>()(
@@ -202,6 +207,7 @@ export const makeScopedExecutor = < · 30 unmodified lines
202 // `EngineStackIdentity` (the engine decorator still wants it); not part of the207 // `EngineStackIdentity` (the engine decorator still wants it); not part of the
203 // v2 executor binding, which is `{ tenant, subject }` only.208 // v2 executor binding, which is `{ tenant, subject }` only.
204 _organizationName: string,209 _organizationName: string,
210 options?: { readonly plugins?: PluginsProviderContext },
205): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> =>211): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> =>
206 Effect.gen(function* () {212 Effect.gen(function* () {
207 const { db, blobs } = yield* DbProvider;213 const { db, blobs } = yield* DbProvider;
@@ -242,7 +248,7 @@ export const makeScopedExecutor = < · 34 unmodified lines
242 oauthCallbackPath: config.oauthCallbackPath,248 oauthCallbackPath: config.oauthCallbackPath,
243 });249 });
244 250
245 const plugins = pluginsFactory();251 const plugins = pluginsFactory(options?.plugins);
246 const hostedHttpOptions = {252 const hostedHttpOptions = {
247 allowLocalNetwork: config.allowLocalNetwork,253 allowLocalNetwork: config.allowLocalNetwork,
248 };254 };
packages/core/sdk/src/client.ts+10−1
@@ -46,10 +46,19 @@ export { useAtomValue, useAtomSet, useAtomMount, useAtomRefresh } from "@effect/ · 45 unmodified lines
46// alongside the host's own UI.46// alongside the host's own UI.
47// ---------------------------------------------------------------------------47// ---------------------------------------------------------------------------
48 48
49export 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
49export interface PageDecl {58export interface PageDecl {
50 /** Path relative to the plugin's mount point, e.g. `/`, `/edit/$id`. */59 /** Path relative to the plugin's mount point, e.g. `/`, `/edit/$id`. */
51 readonly path: string;60 readonly path: string;
52 readonly component: ComponentType;61 readonly component: ComponentType<PluginPageProps>;
53 /** Optional sidebar nav metadata — the host renders these alongside its62 /** Optional sidebar nav metadata — the host renders these alongside its
54 * own nav links. Omit to register a page without a nav entry. */63 * own nav links. Omit to register a page without a nav entry. */
55 readonly nav?: {64 readonly nav?: {
packages/core/sdk/src/executor.ts+135−19
@@ -90,6 +90,7 @@ import type { OAuthService } from "./oauth-client"; · 89 unmodified lines
90import {90import {
91 comparePolicyRow,91 comparePolicyRow,
92 isValidPattern,92 isValidPattern,
93 matchPattern,
93 resolveEffectivePolicy,94 resolveEffectivePolicy,
94 rowToToolPolicy,95 rowToToolPolicy,
95 type CreateToolPolicyInput,96 type CreateToolPolicyInput,
@@ -112,6 +113,8 @@ import type { · 16 unmodified lines
112 StaticSourceDecl,113 StaticSourceDecl,
113 StaticToolDecl,114 StaticToolDecl,
114 StorageDeps,115 StorageDeps,
116 ToolPolicyProvider,
117 ToolPolicyProviderRule,
115 ToolInvocationCredential,118 ToolInvocationCredential,
116} from "./plugin";119} from "./plugin";
117import {120import {
@@ -1284,6 +1287,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1166 unmodified lines
1284 // Populated once, never mutated after startup.1287 // Populated once, never mutated after startup.
1285 const staticTools = new Map<string, StaticTools>();1288 const staticTools = new Map<string, StaticTools>();
1286 const runtimes = new Map<string, PluginRuntime>();1289 const runtimes = new Map<string, PluginRuntime>();
1290 let activeToolPolicyProvider: ToolPolicyProvider | null = null;
1287 // Credential providers keyed by `provider.key`, in registration order.1291 // Credential providers keyed by `provider.key`, in registration order.
1288 const credentialProviders = new Map<string, CredentialProvider>();1292 const credentialProviders = new Map<string, CredentialProvider>();
1289 const credentialProviderOrder: string[] = [];1293 const credentialProviderOrder: string[] = [];
@@ -2301,8 +2305,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1011 unmodified lines
2301 readonly integration?: IntegrationSlug;2305 readonly integration?: IntegrationSlug;
2302 readonly owner?: Owner;2306 readonly owner?: Owner;
2303 }): Effect.Effect<readonly Connection[], StorageFailure> =>2307 }): Effect.Effect<readonly Connection[], StorageFailure> =>
2304 core2308 Effect.gen(function* () {
2305 .findMany("connection", {2309 const rows = yield* core.findMany("connection", {
2306 where: (b: AnyCb) =>2310 where: (b: AnyCb) =>
2307 b.and(2311 b.and(
2308 filter?.integration === undefined2312 filter?.integration === undefined
@@ -2310,8 +2314,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line
2310 : b("integration", "=", String(filter.integration)),2314 : b("integration", "=", String(filter.integration)),
2311 filter?.owner === undefined ? true : b("owner", "=", filter.owner),2315 filter?.owner === undefined ? true : b("owner", "=", filter.owner),
2312 ),2316 ),
2313 })2317 });
2314 .pipe(Effect.map((rows) => rows.map(rowToConnection)));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 });
2315 2333
2316 const connectionsGet = (ref: ConnectionRef): Effect.Effect<Connection | null, StorageFailure> =>2334 const connectionsGet = (ref: ConnectionRef): Effect.Effect<Connection | null, StorageFailure> =>
2317 findConnectionRow(ref).pipe(Effect.map((row) => (row ? rowToConnection(row) : null)));2335 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
2414 return yield* produceConnectionTools(integrationRow, ref);2432 return yield* produceConnectionTools(integrationRow, ref);
2415 });2433 });
2416 2434
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
2417 // ------------------------------------------------------------------2510 // ------------------------------------------------------------------
2418 // Tools (read surface)2511 // Tools (read surface)
2419 // ------------------------------------------------------------------2512 // ------------------------------------------------------------------
@@ -2492,16 +2585,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 72 unmodified lines
2492 select: TOOL_INVOCATION_COLUMNS,2585 select: TOOL_INVOCATION_COLUMNS,
2493 });2586 });
2494 const includeBlocked = filter?.includeBlocked ?? false;2587 const includeBlocked = filter?.includeBlocked ?? false;
2495 const policyRows = yield* core.findMany("tool_policy", {});2588 const policyRules = yield* listActivePolicyRuleSet();
2496 const tools: Tool[] = [];2589 const tools: Tool[] = [];
2497 for (const row of rows) {2590 for (const row of rows) {
2498 const tool = rowToTool(row);2591 const tool = rowToTool(row);
2499 if (!matchesToolFilter(tool, filter)) continue;2592 if (!matchesToolFilter(tool, filter)) continue;
2500 if (!includeBlocked) {2593 if (!includeBlocked) {
2501 const effective = resolveEffectivePolicy(2594 const effective = yield* resolvePolicyFromRuleSet(
2502 normalizedPolicyId(tool),2595 normalizedPolicyId(tool),
2503 policyRows,2596 policyRules,
2504 ownerRankForRow,
2505 tool.annotations?.requiresApproval,2597 tool.annotations?.requiresApproval,
2506 );2598 );
2507 if (effective.action === "block") continue;2599 if (effective.action === "block") continue;
@@ -2512,10 +2604,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 4 unmodified lines
2512 const tool = staticToolToTool(entry);2604 const tool = staticToolToTool(entry);
2513 if (!matchesToolFilter(tool, filter)) continue;2605 if (!matchesToolFilter(tool, filter)) continue;
2514 if (!includeBlocked) {2606 if (!includeBlocked) {
2515 const effective = resolveEffectivePolicy(2607 const effective = yield* resolvePolicyFromRuleSet(
2516 normalizedPolicyId(tool),2608 normalizedPolicyId(tool),
2517 policyRows,2609 policyRules,
2518 ownerRankForRow,
2519 tool.annotations?.requiresApproval,2610 tool.annotations?.requiresApproval,
2520 );2611 );
2521 if (effective.action === "block") continue;2612 if (effective.action === "block") continue;
@@ -2529,9 +2620,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines
2529 address: ToolAddress,2620 address: ToolAddress,
2530 ): Effect.Effect<ToolSchemaView | null, StorageFailure> =>2621 ): Effect.Effect<ToolSchemaView | null, StorageFailure> =>
2531 Effect.gen(function* () {2622 Effect.gen(function* () {
2623 const policyRules = yield* listActivePolicyRuleSet();
2532 const staticEntry = staticTools.get(String(address));2624 const staticEntry = staticTools.get(String(address));
2533 if (staticEntry) {2625 if (staticEntry) {
2534 const tool = staticToolToTool(staticEntry);2626 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;
2535 const preview = yield* Effect.tryPromise({2633 const preview = yield* Effect.tryPromise({
2536 try: () =>2634 try: () =>
2537 buildToolTypeScriptPreview({2635 buildToolTypeScriptPreview({
@@ -2567,6 +2665,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines
2567 });2665 });
2568 if (!row) return null;2666 if (!row) return null;
2569 const tool = rowToTool(row);2667 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;
2570 2674
2571 const definitionRows = yield* core.findMany("definition", {2675 const definitionRows = yield* core.findMany("definition", {
2572 where: (b: AnyCb) =>2676 where: (b: AnyCb) =>
@@ -2906,11 +3010,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 333 unmodified lines
2906 // not the 5-segment dynamic form.3010 // not the 5-segment dynamic form.
2907 const staticEntry = staticTools.get(String(address));3011 const staticEntry = staticTools.get(String(address));
2908 if (staticEntry) {3012 if (staticEntry) {
2909 const policyRows = yield* core.findMany("tool_policy", {});3013 const policyRules = yield* listActivePolicyRuleSet();
2910 const policy = resolveEffectivePolicy(3014 const policy = yield* resolvePolicyFromRuleSet(
2911 String(address),3015 String(address),
2912 policyRows,3016 policyRules,
2913 ownerRankForRow,
2914 staticEntry.tool.annotations?.requiresApproval,3017 staticEntry.tool.annotations?.requiresApproval,
2915 );3018 );
2916 if (policy.action === "block") {3019 if (policy.action === "block") {
@@ -2959,12 +3062,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 42 unmodified lines
2959 3062
2960 // Resolve policy (owner-ranked).3063 // Resolve policy (owner-ranked).
2961 const toolForPolicy = rowToTool(row);3064 const toolForPolicy = rowToTool(row);
2962 const policyRows = yield* core.findMany("tool_policy", {});3065 const policyRules = yield* listActivePolicyRuleSet();
2963 const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined;3066 const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined;
2964 const policy = resolveEffectivePolicy(3067 const policy = yield* resolvePolicyFromRuleSet(
2965 normalizedPolicyId(toolForPolicy),3068 normalizedPolicyId(toolForPolicy),
2966 policyRows,3069 policyRules,
2967 ownerRankForRow,
2968 annotations?.requiresApproval,3070 annotations?.requiresApproval,
2969 );3071 );
2970 if (policy.action === "block") {3072 if (policy.action === "block") {
@@ -3186,6 +3288,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 215 unmodified lines
3186 transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect),3288 transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect),
3187 };3289 };
3188 3290
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
3189 // Build extension FIRST so it's available as `self` for staticSources.3305 // Build extension FIRST so it's available as `self` for staticSources.
3190 const extension: object = plugin.extension ? plugin.extension(ctx) : {};3306 const extension: object = plugin.extension ? plugin.extension(ctx) : {};
3191 if (plugin.extension) {3307 if (plugin.extension) {
packages/core/sdk/src/index.ts+2−0
@@ -277,6 +277,8 @@ export { · 276 unmodified lines
277 type AnyPlugin,277 type AnyPlugin,
278 type StorageDeps,278 type StorageDeps,
279 type OwnerBinding,279 type OwnerBinding,
280 type ToolPolicyProvider,
281 type ToolPolicyProviderRule,
280 type IntegrationRecord,282 type IntegrationRecord,
281 type StaticSourceDecl,283 type StaticSourceDecl,
282 type StaticToolDecl,284 type StaticToolDecl,
packages/core/sdk/src/plugin.ts+32−0
@@ -48,6 +48,7 @@ import type { CredentialProvider, ProviderEntry } from "./provider"; · 47 unmodified lines
48import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage";48import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage";
49import type {49import type {
50 CreateToolPolicyInput,50 CreateToolPolicyInput,
51 EffectivePolicy,
51 RemoveToolPolicyInput,52 RemoveToolPolicyInput,
52 ToolPolicy,53 ToolPolicy,
53 UpdateToolPolicyInput,54 UpdateToolPolicyInput,
@@ -88,6 +89,30 @@ export type Elicit = ( · 34 unmodified lines
88 request: ElicitationRequest,89 request: ElicitationRequest,
89) => Effect.Effect<ElicitationResponse, ElicitationDeclinedError>;90) => Effect.Effect<ElicitationResponse, ElicitationDeclinedError>;
90 91
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
101export interface ToolPolicyProviderRule {
102 readonly id: string;
103 readonly pattern: string;
104 readonly action: ToolPolicy["action"];
105 readonly position: string;
106}
107
108export 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
91// ---------------------------------------------------------------------------116// ---------------------------------------------------------------------------
92// IntegrationRecord — the catalog row a plugin reads back (its own opaque117// IntegrationRecord — the catalog row a plugin reads back (its own opaque
93// `config` included). Returned by `ctx.core.integrations.get`.118// `config` included). Returned by `ctx.core.integrations.get`.
@@ -458,6 +483,13 @@ export interface PluginSpec< · 364 unmodified lines
458 /** Service tag the plugin's `handlers` layer requires. */483 /** Service tag the plugin's `handlers` layer requires. */
459 readonly extensionService?: TExtensionService;484 readonly extensionService?: TExtensionService;
460 485
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
461 /** Produce a connection's tools (and shared $defs). The v2 successor to493 /** Produce a connection's tools (and shared $defs). The v2 successor to
462 * registering per-source tools — called by the executor at connection494 * registering per-source tools — called by the executor at connection
463 * create / refresh / oauth.complete; the result is stamped with addresses495 * create / refresh / oauth.complete; the result is stamped with addresses
packages/core/sdk/src/policies.test.ts+81−2
@@ -1,5 +1,5 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Predicate, Result } from "effect";2import { Effect, Predicate, Result, Schema } from "effect";
3 3
4import { type ToolPolicyRow } from "./core-schema";4import { type ToolPolicyRow } from "./core-schema";
5import {5import {
@@ -19,7 +19,7 @@ import { · 13 unmodified lines
19 matchPattern,19 matchPattern,
20 resolveToolPolicy,20 resolveToolPolicy,
21} from "./policies";21} from "./policies";
22import { definePlugin } from "./plugin";22import { definePlugin, tool } from "./plugin";
23import type { CredentialProvider } from "./provider";23import type { CredentialProvider } from "./provider";
24import { makeTestExecutor } from "./testing";24import { makeTestExecutor } from "./testing";
25 25
@@ -512,6 +512,85 @@ describe("blocked tools", () => { · 486 unmodified lines
512 );512 );
513});513});
514 514
515describe("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
515describe("approve / require_approval interaction with annotations", () => {594describe("approve / require_approval interaction with annotations", () => {
516 it.effect("approve skips the elicitation prompt even when plugin requires approval", () =>595 it.effect("approve skips the elicitation prompt even when plugin requires approval", () =>
517 Effect.gen(function* () {596 Effect.gen(function* () {
packages/hosts/cloudflare/src/mcp/do-headers.ts+4−0
@@ -13,9 +13,11 @@ · 12 unmodified lines
13// ---------------------------------------------------------------------------13// ---------------------------------------------------------------------------
14 14
15import { Effect } from "effect";15import { Effect } from "effect";
16import { mcpResourceKey, type McpResource } from "@executor-js/host-mcp";
16 17
17export const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id";18export const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id";
18export const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id";19export const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id";
20export const INTERNAL_RESOURCE_KEY_HEADER = "x-executor-mcp-resource-key";
19 21
20/** The verified identity used to stamp the DO's internal owner headers. */22/** The verified identity used to stamp the DO's internal owner headers. */
21export type VerifiedTokenHeaders = {23export type VerifiedTokenHeaders = {
@@ -70,10 +72,12 @@ export const withPropagationHeaders = ( · 48 unmodified lines
70export const withVerifiedIdentityHeaders = (72export const withVerifiedIdentityHeaders = (
71 request: Request,73 request: Request,
72 token: VerifiedTokenHeaders,74 token: VerifiedTokenHeaders,
75 resource: McpResource,
73): Request => {76): Request => {
74 const headers = new Headers(request.headers);77 const headers = new Headers(request.headers);
75 headers.set(INTERNAL_ACCOUNT_ID_HEADER, token.accountId);78 headers.set(INTERNAL_ACCOUNT_ID_HEADER, token.accountId);
76 headers.set(INTERNAL_ORGANIZATION_ID_HEADER, token.organizationId ?? "");79 headers.set(INTERNAL_ORGANIZATION_ID_HEADER, token.organizationId ?? "");
80 headers.set(INTERNAL_RESOURCE_KEY_HEADER, mcpResourceKey(resource));
77 return new Request(request, { headers });81 return new Request(request, { headers });
78};82};
79 83
packages/hosts/cloudflare/src/mcp/seams.ts+3−0
@@ -1,3 +1,5 @@
1import type { McpResource } from "@executor-js/host-mcp";
2
1import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers";3import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers";
2 4
3// ---------------------------------------------------------------------------5// ---------------------------------------------------------------------------
@@ -10,6 +12,7 @@ import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-header · 6 unmodified lines
10export interface McpSessionInit {12export interface McpSessionInit {
11 readonly organizationId: string;13 readonly organizationId: string;
12 readonly userId: string;14 readonly userId: string;
15 readonly resource: McpResource;
13 readonly elicitationMode: McpElicitationMode;16 readonly elicitationMode: McpElicitationMode;
14 /** Public origin of the create request (`https://host`), so the DO derives a17 /** Public origin of the create request (`https://host`), so the DO derives a
15 * web base URL zero-config when the host configures no static one. */18 * 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
29import {29import {
30 INTERNAL_ACCOUNT_ID_HEADER,30 INTERNAL_ACCOUNT_ID_HEADER,
31 INTERNAL_ORGANIZATION_ID_HEADER,31 INTERNAL_ORGANIZATION_ID_HEADER,
32 INTERNAL_RESOURCE_KEY_HEADER,
32 type IncomingPropagationHeaders,33 type IncomingPropagationHeaders,
33} from "./do-headers";34} from "./do-headers";
35import { mcpResourceKey } from "@executor-js/host-mcp";
34import type { McpSessionInit } from "./seams";36import type { McpSessionInit } from "./seams";
37import type { McpResource } from "@executor-js/host-mcp";
35 38
36// ---------------------------------------------------------------------------39// ---------------------------------------------------------------------------
37// Types40// Types
@@ -122,6 +125,7 @@ export interface SessionDbHandle { · 84 unmodified lines
122export interface SessionMeta {125export interface SessionMeta {
123 readonly organizationId: string;126 readonly organizationId: string;
124 readonly organizationName: string;127 readonly organizationName: string;
128 readonly resource: McpResource;
125 /** The org's URL slug, when the host's `resolveSessionMeta` carried one.129 /** The org's URL slug, when the host's `resolveSessionMeta` carried one.
126 * Pins browser-handoff URLs to the right org's console. */130 * Pins browser-handoff URLs to the right org's console. */
127 readonly organizationSlug?: string;131 readonly organizationSlug?: string;
@@ -541,8 +545,11 @@ export abstract class McpSessionDOBase< · 413 unmodified lines
541 545
542 const accountId = request.headers.get(INTERNAL_ACCOUNT_ID_HEADER);546 const accountId = request.headers.get(INTERNAL_ACCOUNT_ID_HEADER);
543 const organizationId = request.headers.get(INTERNAL_ORGANIZATION_ID_HEADER);547 const organizationId = request.headers.get(INTERNAL_ORGANIZATION_ID_HEADER);
548 const resourceKey = request.headers.get(INTERNAL_RESOURCE_KEY_HEADER);
544 const matches =549 const matches =
545 accountId === sessionMeta.userId && organizationId === sessionMeta.organizationId;550 accountId === sessionMeta.userId &&
551 organizationId === sessionMeta.organizationId &&
552 resourceKey === mcpResourceKey(sessionMeta.resource);
546 553
547 yield* Effect.annotateCurrentSpan({554 yield* Effect.annotateCurrentSpan({
548 "mcp.session.owner_match": matches,555 "mcp.session.owner_match": matches,
@@ -559,9 +566,11 @@ export abstract class McpSessionDOBase< · 10 unmodified lines
559 // Carry the create request's origin onto the persisted meta (the host's566 // Carry the create request's origin onto the persisted meta (the host's
560 // resolveSessionMeta is identity-only and doesn't see it), so a cold567 // resolveSessionMeta is identity-only and doesn't see it), so a cold
561 // isolate rebuilds the runtime with the same web base URL.568 // isolate rebuilds the runtime with the same web base URL.
562 const sessionMeta: SessionMeta = token.webOrigin569 const sessionMeta: SessionMeta = {
563 ? { ...resolved, webOrigin: token.webOrigin }570 ...resolved,
564 : resolved;571 resource: token.resource,
572 ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}),
573 };
565 yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe(574 yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe(
566 Effect.withSpan("mcp.session.save_meta"),575 Effect.withSpan("mcp.session.save_meta"),
567 );576 );
packages/hosts/cloudflare/src/mcp/session-store.test.ts+79−1
@@ -13,13 +13,20 @@ · 12 unmodified lines
13import { describe, expect, it } from "@effect/vitest";13import { describe, expect, it } from "@effect/vitest";
14import { Cause, Effect, Exit } from "effect";14import { Cause, Effect, Exit } from "effect";
15 15
16import { McpSessionStore, type McpDispatchResult, type Principal } from "@executor-js/host-mcp";16import {
17 McpSessionStore,
18 defaultMcpResource,
19 type McpDispatchResult,
20 type Principal,
21} from "@executor-js/host-mcp";
17 22
18import {23import {
19 DO_RELOCATION_MAX_RETRIES,24 DO_RELOCATION_MAX_RETRIES,
20 makeDurableObjectMcpSessionStore,25 makeDurableObjectMcpSessionStore,
26 type McpSessionInit,
21 type McpSessionDOStub,27 type McpSessionDOStub,
22} from "./session-store";28} from "./session-store";
29import { INTERNAL_RESOURCE_KEY_HEADER } from "./do-headers";
23 30
24const RELOCATION_ERROR = "cannot access storage because object has moved to a different machine";31const RELOCATION_ERROR = "cannot access storage because object has moved to a different machine";
25 32
@@ -55,6 +62,7 @@ const dispatchCreate = (stub: McpSessionDOStub): Effect.Effect<McpDispatchResult · 29 unmodified lines
55 return yield* store.dispatch({62 return yield* store.dispatch({
56 request: initializeRequest(),63 request: initializeRequest(),
57 principal: TEST_PRINCIPAL,64 principal: TEST_PRINCIPAL,
65 resource: defaultMcpResource,
58 sessionId: null,66 sessionId: null,
59 method: "POST",67 method: "POST",
60 });68 });
@@ -68,6 +76,76 @@ const failureText = (exit: Exit.Exit<McpDispatchResult>): string => · 7 unmodified lines
68 Exit.isFailure(exit) ? Cause.pretty(exit.cause) : "";76 Exit.isFailure(exit) ? Cause.pretty(exit.cause) : "";
69 77
70describe("makeDurableObjectMcpSessionStore — DO-relocation retry", () => {78describe("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
71 it.live("retries mcp.do.init past a relocation, then returns the DO response", () =>149 it.live("retries mcp.do.init past a relocation, then returns the DO response", () =>
72 Effect.gen(function* () {150 Effect.gen(function* () {
73 let initCalls = 0;151 let initCalls = 0;
packages/hosts/cloudflare/src/mcp/session-store.ts+15−4
@@ -141,12 +141,13 @@ const forwardToExistingSession = ( · 140 unmodified lines
141 sessionId: string,141 sessionId: string,
142 peek: boolean,142 peek: boolean,
143 token: VerifiedTokenHeaders,143 token: VerifiedTokenHeaders,
144 resource: McpDispatchInput["resource"],
144): Effect.Effect<Response> =>145): Effect.Effect<Response> =>
145 Effect.gen(function* () {146 Effect.gen(function* () {
146 const stub = config.getStub(sessionId);147 const stub = config.getStub(sessionId);
147 const propagation = yield* currentPropagationHeaders(request);148 const propagation = yield* currentPropagationHeaders(request);
148 const propagated = withPropagationHeaders(149 const propagated = withPropagationHeaders(
149 withVerifiedIdentityHeaders(request, token),150 withVerifiedIdentityHeaders(request, token, resource),
150 propagation,151 propagation,
151 );152 );
152 const raw = yield* Effect.promise(() => stub.handleRequest(propagated)).pipe(153 const raw = yield* Effect.promise(() => stub.handleRequest(propagated)).pipe(
@@ -168,6 +169,7 @@ const createSession = ( · 15 unmodified lines
168 config: DurableObjectStoreConfig,169 config: DurableObjectStoreConfig,
169 request: Request,170 request: Request,
170 token: VerifiedTokenHeaders,171 token: VerifiedTokenHeaders,
172 resource: McpDispatchInput["resource"],
171): Effect.Effect<Response> =>173): Effect.Effect<Response> =>
172 Effect.gen(function* () {174 Effect.gen(function* () {
173 const stub = config.newStub();175 const stub = config.newStub();
@@ -177,6 +179,7 @@ const createSession = ( · 3 unmodified lines
177 {179 {
178 organizationId: token.organizationId,180 organizationId: token.organizationId,
179 userId: token.accountId,181 userId: token.accountId,
182 resource,
180 elicitationMode: readElicitationMode(request),183 elicitationMode: readElicitationMode(request),
181 // The public origin the client reached us at — lets the DO derive a web184 // The public origin the client reached us at — lets the DO derive a web
182 // base URL with no static config (we read the real URL, not a spoofable185 // base URL with no static config (we read the real URL, not a spoofable
@@ -187,7 +190,7 @@ const createSession = ( · 4 unmodified lines
187 ),190 ),
188 );191 );
189 const propagated = withPropagationHeaders(192 const propagated = withPropagationHeaders(
190 withVerifiedIdentityHeaders(request, token),193 withVerifiedIdentityHeaders(request, token, resource),
191 propagation,194 propagation,
192 );195 );
193 const raw = yield* Effect.promise(() => stub.handleRequest(propagated)).pipe(196 const raw = yield* Effect.promise(() => stub.handleRequest(propagated)).pipe(
@@ -236,6 +239,7 @@ export const makeDurableObjectMcpSessionStore = ( · 42 unmodified lines
236 dispatch: ({239 dispatch: ({
237 request,240 request,
238 principal,241 principal,
242 resource,
239 sessionId,243 sessionId,
240 }: McpDispatchInput): Effect.Effect<McpDispatchResult> => {244 }: McpDispatchInput): Effect.Effect<McpDispatchResult> => {
241 const token: VerifiedTokenHeaders = {245 const token: VerifiedTokenHeaders = {
@@ -243,8 +247,15 @@ export const makeDurableObjectMcpSessionStore = ( · 1 unmodified line
243 organizationId: principal.organizationId,247 organizationId: principal.organizationId,
244 };248 };
245 return sessionId249 return sessionId
246 ? forwardToExistingSession(config, request, sessionId, request.method !== "GET", token)250 ? forwardToExistingSession(
247 : createSession(config, request, token);251 config,
252 request,
253 sessionId,
254 request.method !== "GET",
255 token,
256 resource,
257 )
258 : createSession(config, request, token, resource);
248 },259 },
249 dispose: (sessionId, request) => clearExistingSession(config, sessionId, request),260 dispose: (sessionId, request) => clearExistingSession(config, sessionId, request),
250 });261 });
packages/hosts/mcp/src/envelope.test.ts+64−2
@@ -15,11 +15,13 @@ import { HttpRouter, HttpServer } from "effect/unstable/http"; · 14 unmodified lines
15 15
16import {16import {
17 authenticated,17 authenticated,
18 forbidden,
18 McpAuthProvider,19 McpAuthProvider,
19 McpErrorReporter,20 McpErrorReporter,
20 McpErrorReporterNoop,21 McpErrorReporterNoop,
21 McpServingRoutes,22 McpServingRoutes,
22 McpSessionStore,23 McpSessionStore,
24 type McpResource,
23 type McpDispatchResult,25 type McpDispatchResult,
24 type Principal,26 type Principal,
25} from "./index";27} from "./index";
@@ -64,11 +66,12 @@ const OkStoreLive = Layer.succeed(McpSessionStore)({ · 38 unmodified lines
64const buildHandler = (66const buildHandler = (
65 store: Layer.Layer<McpSessionStore>,67 store: Layer.Layer<McpSessionStore>,
66 reporter: Layer.Layer<McpErrorReporter>,68 reporter: Layer.Layer<McpErrorReporter>,
69 authProvider: Layer.Layer<McpAuthProvider> = AuthProviderLive,
67): ((request: Request) => Promise<Response>) => {70): ((request: Request) => Promise<Response>) => {
68 const Seams = Layer.mergeAll(AuthProviderLive, store, reporter);71 const Seams = Layer.mergeAll(authProvider, store, reporter);
69 const RouteLive = McpServingRoutes.pipe(72 const RouteLive = McpServingRoutes.pipe(
70 HttpRouter.provideRequest(Seams),73 HttpRouter.provideRequest(Seams),
71 Layer.provide(AuthProviderLive),74 Layer.provide(authProvider),
72 );75 );
73 return HttpRouter.toWebHandler(RouteLive.pipe(Layer.provideMerge(HttpServer.layerServices)))76 return HttpRouter.toWebHandler(RouteLive.pipe(Layer.provideMerge(HttpServer.layerServices)))
74 .handler;77 .handler;
@@ -132,4 +135,63 @@ describe("McpServingRoutes envelope", () => { · 57 unmodified lines
132 expect(captures).toHaveLength(1);135 expect(captures).toHaveLength(1);
133 expect(captures[0]).toContain("induced defect");136 expect(captures[0]).toContain("induced defect");
134 });137 });
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
173it("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 });
135});197});
packages/hosts/mcp/src/envelope.ts+77−67
@@ -2,11 +2,13 @@ import { Effect, Match, Predicate } from "effect"; · 1 unmodified line
2import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";2import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3 3
4import {4import {
5 defaultMcpResource,
5 McpAuthProvider,6 McpAuthProvider,
6 McpErrorReporter,7 McpErrorReporter,
7 McpSessionStore,8 McpSessionStore,
8 type AuthOutcome,9 type AuthOutcome,
9 type McpDispatchResult,10 type McpDispatchResult,
11 type McpResource,
10} from "./seams";12} from "./seams";
11 13
12// ---------------------------------------------------------------------------14// ---------------------------------------------------------------------------
@@ -14,7 +16,8 @@ import { · 1 unmodified line
14//16//
15// Routes:17// Routes:
16// GET <provider-declared discovery paths> -> McpAuthProvider metadata18// GET <provider-declared discovery paths> -> McpAuthProvider metadata
17// * /mcp -> authenticate -> dispatch19// * /mcp -> authenticate -> dispatch(default)
20// * /mcp/toolkits/:toolkitSlug -> authenticate -> dispatch(toolkit)
18//21//
19// The provider DECLARES the discovery paths it owns (at least the protected-22// The provider DECLARES the discovery paths it owns (at least the protected-
20// resource metadata document) via `McpAuthProvider.discoveryRoutes`; the23// resource metadata document) via `McpAuthProvider.discoveryRoutes`; the
@@ -24,8 +27,8 @@ import { · 3 unmodified lines
24// WorkOS, external). The envelope only needs the provider's discovery routes,27// WorkOS, external). The envelope only needs the provider's discovery routes,
25// resource-metadata URL, and authenticate.28// resource-metadata URL, and authenticate.
26//29//
27// The envelope hard-codes ONLY the `/mcp` path and CORS. Everything else —30// The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else
28// every `/.well-known/*` path, the resource-metadata URL, the authn/authz31// — every `/.well-known/*` path, the resource-metadata URL, the authn/authz
29// semantics, and the entire session lifecycle (create + forward + ownership) —32// semantics, and the entire session lifecycle (create + forward + ownership) —
30// comes from the two seams.33// comes from the two seams.
31//34//
@@ -38,6 +41,7 @@ import { · 6 unmodified lines
38// ---------------------------------------------------------------------------41// ---------------------------------------------------------------------------
39 42
40const MCP_PATH = "/mcp";43const MCP_PATH = "/mcp";
44const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug";
41 45
42/** The methods the streamable-HTTP transport accepts on `/mcp`. */46/** The methods the streamable-HTTP transport accepts on `/mcp`. */
43const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);47const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
@@ -170,69 +174,67 @@ const renderDispatchError = (lookup: "not-found" | "forbidden"): Response => · 126 unmodified lines
170 ? jsonRpcResponse(404, -32001, "Session not found")174 ? jsonRpcResponse(404, -32001, "Session not found")
171 : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");175 : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
172 176
173/** Dispatch a `/mcp` request through authenticate -> store.dispatch -> transport. */177/** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */
174const mcpDispatch = Effect.gen(function* () {178const mcpDispatch = (resource: McpResource) =>
175 const httpRequest = yield* HttpServerRequest.HttpServerRequest;179 Effect.gen(function* () {
176 const auth = yield* McpAuthProvider;180 const httpRequest = yield* HttpServerRequest.HttpServerRequest;
177 const store = yield* McpSessionStore;181 const auth = yield* McpAuthProvider;
178 const request = yield* toWebRequest(httpRequest);182 const store = yield* McpSessionStore;
183 const request = yield* toWebRequest(httpRequest);
179 184
180 // CORS preflight: answer before auth so unauthenticated clients can probe.185 // CORS preflight: answer before auth so unauthenticated clients can probe.
181 if (request.method === "OPTIONS") {186 if (request.method === "OPTIONS") {
182 return HttpServerResponse.raw(corsPreflightResponse());187 return HttpServerResponse.raw(corsPreflightResponse());
183 }188 }
184 189
185 // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other190 // 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 —191 // 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 method192 // otherwise it would fall through and spin up a session engine for a method
188 // the transport can't serve.193 // the transport can't serve.
189 if (!ALLOWED_MCP_METHODS.has(request.method)) {194 if (!ALLOWED_MCP_METHODS.has(request.method)) {
190 return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed"));195 return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed"));
191 }196 }
192 197
193 const sessionId = request.headers.get("mcp-session-id");198 const sessionId = request.headers.get("mcp-session-id");
194 199
195 // Authenticate (and, for session-aware providers, authorize) on EVERY200 // Authenticate (and, for session-aware providers, authorize) on EVERY
196 // request. On a non-Authenticated outcome:201 // request. Non-authenticated outcomes render directly. Session teardown is
197 // - Forbidden -> dispose the live session first (cloud tears down a DO202 // only safe after the store can validate the authenticated principal and MCP
198 // whose org access was revoked), then render the 403. The203 // resource; an auth-level Forbidden may not carry either.
199 // inbound request is forwarded so the store can propagate204 const outcome = yield* auth.authenticate(request);
200 // the request's W3C trace context onto the teardown RPC.205 if (!Predicate.isTagged(outcome, "Authenticated")) {
201 // - other -> render directly.206 return HttpServerResponse.raw(renderAuthError(auth, request, outcome));
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);
206 }207 }
207 return HttpServerResponse.raw(renderAuthError(auth, request, outcome));208 const principal = outcome.principal;
208 }
209 const principal = outcome.principal;
210 209
211 // No session id: per the streamable-HTTP transport contract, only POST opens210 // 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 a211 // 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 up212 // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up
214 // an engine for a bare GET/DELETE.213 // an engine for a bare GET/DELETE.
215 if (!sessionId) {214 if (!sessionId) {
216 if (request.method === "GET") {215 if (request.method === "GET") {
217 return HttpServerResponse.raw(216 return HttpServerResponse.raw(
218 jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"),217 jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"),
219 );218 );
219 }
220 if (request.method === "DELETE") {
221 return HttpServerResponse.raw(
222 new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }),
223 );
224 }
220 }225 }
221 if (request.method === "DELETE") {
222 return HttpServerResponse.raw(
223 new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }),
224 );
225 }
226 }
227 226
228 const result: McpDispatchResult = yield* store.dispatch({227 const result: McpDispatchResult = yield* store.dispatch({
229 request,228 request,
230 principal,229 principal,
231 sessionId,230 resource,
232 method: request.method,231 sessionId,
232 method: request.method,
233 });
234 return HttpServerResponse.raw(
235 result instanceof Response ? result : renderDispatchError(result),
236 );
233 });237 });
234 return HttpServerResponse.raw(result instanceof Response ? result : renderDispatchError(result));
235});
236 238
237/**239/**
238 * The `/mcp` route. Wraps {@link mcpDispatch} in a top-level `catchCause`: a240 * The `/mcp` route. Wraps {@link mcpDispatch} in a top-level `catchCause`: a
@@ -242,15 +244,22 @@ const mcpDispatch = Effect.gen(function* () { · 3 unmodified lines
242 * otherwise, since the envelope returns a `Response`) and rendered as a stable244 * otherwise, since the envelope returns a `Response`) and rendered as a stable
243 * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body.245 * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body.
244 */246 */
245const mcpRoute = mcpDispatch.pipe(247const mcpRoute = (resource: McpResource) =>
246 Effect.catchCause((cause) =>248 mcpDispatch(resource).pipe(
247 Effect.gen(function* () {249 Effect.catchCause((cause) =>
248 const reporter = yield* McpErrorReporter;250 Effect.gen(function* () {
249 yield* reporter.report(cause);251 const reporter = yield* McpErrorReporter;
250 return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error"));252 yield* reporter.report(cause);
251 }),253 return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error"));
252 ),254 }),
253);255 ),
256 );
257
258const 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});
254 263
255/**264/**
256 * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges265 * 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
273 Effect.sync(() => HttpServerResponse.raw(corsPreflightResponse())),282 Effect.sync(() => HttpServerResponse.raw(corsPreflightResponse())),
274 );283 );
275 }284 }
276 yield* router.add("*", MCP_PATH, mcpRoute);285 yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource));
286 yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute);
277 }),287 }),
278);288);
packages/hosts/mcp/src/in-memory-session-store.ts+35−11
@@ -17,10 +17,13 @@ import { · 16 unmodified lines
17import { jsonRpcErrorBody } from "./envelope";17import { jsonRpcErrorBody } from "./envelope";
18import {18import {
19 McpSessionStore,19 McpSessionStore,
20 defaultMcpResource,
21 mcpResourceKey,
20 principalOwns,22 principalOwns,
21 type McpDispatchInput,23 type McpDispatchInput,
22 type McpDispatchResult,24 type McpDispatchResult,
23 type Principal,25 type Principal,
26 type McpResource,
24} from "./seams";27} from "./seams";
25import type { BrowserApprovalStore } from "./tool-server";28import type { BrowserApprovalStore } from "./tool-server";
26 29
@@ -61,6 +64,7 @@ export interface BuiltMcpServer { · 34 unmodified lines
61 64
62/** The browser-mode wiring the store hands a build call when a session opts in. */65/** The browser-mode wiring the store hands a build call when a session opts in. */
63export interface McpBuildServerOptions {66export interface McpBuildServerOptions {
67 readonly resource?: McpResource;
64 readonly elicitationMode?:68 readonly elicitationMode?:
65 | { readonly mode: "browser"; readonly approvalUrl: (executionId: string) => string }69 | { readonly mode: "browser"; readonly approvalUrl: (executionId: string) => string }
66 | { readonly mode: "model" }70 | { readonly mode: "model" }
@@ -120,6 +124,19 @@ const json = (value: unknown, status = 200): Response => · 53 unmodified lines
120const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;124const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;
121const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;125const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;
122 126
127interface SessionOwner {
128 readonly principal: Principal;
129 readonly resource: McpResource;
130}
131
132const sessionOwnerMatches = (
133 owner: SessionOwner,
134 principal: Principal,
135 resource: McpResource,
136): boolean =>
137 principalOwns(owner.principal, principal) &&
138 mcpResourceKey(owner.resource) === mcpResourceKey(resource);
139
123/**140/**
124 * Build the in-process session store plus an explicit `close()` that disposes141 * Build the in-process session store plus an explicit `close()` that disposes
125 * all live sessions. `close()` is not part of the seam — it is the host lifetime142 * 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
137): InMemoryMcpSessionStore => {154): InMemoryMcpSessionStore => {
138 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();155 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
139 const servers = new Map<string, McpServer>();156 const servers = new Map<string, McpServer>();
140 const owners = new Map<string, Principal>();157 const owners = new Map<string, SessionOwner>();
141 const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>();158 const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>();
142 const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore();159 const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore();
143 160
@@ -181,12 +198,13 @@ export const makeInMemoryMcpSessionStore = ( · 37 unmodified lines
181 const forward = (198 const forward = (
182 sessionId: string,199 sessionId: string,
183 principal: Principal,200 principal: Principal,
201 resource: McpResource,
184 request: Request,202 request: Request,
185 ): Effect.Effect<McpDispatchResult> => {203 ): Effect.Effect<McpDispatchResult> => {
186 const transport = transports.get(sessionId);204 const transport = transports.get(sessionId);
187 const owner = owners.get(sessionId);205 const owner = owners.get(sessionId);
188 if (!transport || !owner) return Effect.succeed("not-found");206 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");
190 return runHandleRequest(transport, request);208 return runHandleRequest(transport, request);
191 };209 };
192 210
@@ -218,12 +236,16 @@ export const makeInMemoryMcpSessionStore = ( · 25 unmodified lines
218 };236 };
219 237
220 /** Open a new session: build the server, connect a transport, drive the request. */238 /** 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> => {
222 let createdSessionId: string | null = null;244 let createdSessionId: string | null = null;
223 return buildServer(245 return buildServer(principal, {
224 principal,246 ...buildOptionsFor(request, () => createdSessionId),
225 buildOptionsFor(request, () => createdSessionId),247 resource,
226 ).pipe(248 }).pipe(
227 Effect.flatMap(({ mcpServer, engine }) =>249 Effect.flatMap(({ mcpServer, engine }) =>
228 Effect.gen(function* () {250 Effect.gen(function* () {
229 const transport = new WebStandardStreamableHTTPServerTransport({251 const transport = new WebStandardStreamableHTTPServerTransport({
@@ -233,7 +255,7 @@ export const makeInMemoryMcpSessionStore = ( · 3 unmodified lines
233 createdSessionId = sid;255 createdSessionId = sid;
234 transports.set(sid, transport);256 transports.set(sid, transport);
235 servers.set(sid, mcpServer);257 servers.set(sid, mcpServer);
236 owners.set(sid, principal);258 owners.set(sid, { principal, resource });
237 engines.set(sid, engine);259 engines.set(sid, engine);
238 },260 },
239 onsessionclosed: (sid) => void dispose(sid, { server: true }),261 onsessionclosed: (sid) => void dispose(sid, { server: true }),
@@ -259,8 +281,10 @@ export const makeInMemoryMcpSessionStore = ( · 19 unmodified lines
259 };281 };
260 282
261 const store: McpSessionStore["Service"] = {283 const store: McpSessionStore["Service"] = {
262 dispatch: ({ request, principal, sessionId }: McpDispatchInput) =>284 dispatch: ({ request, principal, resource, sessionId }: McpDispatchInput) =>
263 sessionId ? forward(sessionId, principal, request) : create(principal, request),285 sessionId
286 ? forward(sessionId, principal, resource, request)
287 : create(principal, resource ?? defaultMcpResource, request),
264 dispose: (sessionId) =>288 dispose: (sessionId) =>
265 Effect.promise(() => dispose(sessionId, { transport: true, server: true })),289 Effect.promise(() => dispose(sessionId, { transport: true, server: true })),
266 };290 };
@@ -271,7 +295,7 @@ export const makeInMemoryMcpSessionStore = ( · 4 unmodified lines
271 ): "allowed" | "not-found" | "forbidden" => {295 ): "allowed" | "not-found" | "forbidden" => {
272 const owner = owners.get(sessionId);296 const owner = owners.get(sessionId);
273 if (!owner) return "not-found";297 if (!owner) return "not-found";
274 if (principal && !principalOwns(owner, principal)) return "forbidden";298 if (principal && !principalOwns(owner.principal, principal)) return "forbidden";
275 return "allowed";299 return "allowed";
276 };300 };
277 301
packages/hosts/mcp/src/index.ts+3−0
@@ -18,6 +18,8 @@ export { · 17 unmodified lines
18 McpSessionStore,18 McpSessionStore,
19 McpErrorReporter,19 McpErrorReporter,
20 McpErrorReporterNoop,20 McpErrorReporterNoop,
21 defaultMcpResource,
22 mcpResourceKey,
21 principalOwns,23 principalOwns,
22 authenticated,24 authenticated,
23 unauthorized,25 unauthorized,
@@ -31,6 +33,7 @@ export { · 7 unmodified lines
31 type McpDiscoveryRoute,33 type McpDiscoveryRoute,
32 type McpDispatchInput,34 type McpDispatchInput,
33 type McpDispatchResult,35 type McpDispatchResult,
36 type McpResource,
34} from "./seams";37} from "./seams";
35 38
36export { McpServingRoutes, jsonRpcErrorBody } from "./envelope";39export { 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
56export const principalOwns = (owner: Principal, principal: Principal): boolean =>56export const principalOwns = (owner: Principal, principal: Principal): boolean =>
57 owner.accountId === principal.accountId && owner.organizationId === principal.organizationId;57 owner.accountId === principal.accountId && owner.organizationId === principal.organizationId;
58 58
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
69export type McpResource =
70 | { readonly kind: "default" }
71 | { readonly kind: "toolkit"; readonly slug: string };
72
73export const defaultMcpResource: McpResource = { kind: "default" };
74
75export const mcpResourceKey = (resource: McpResource): string =>
76 resource.kind === "default" ? "default" : `toolkit:${resource.slug}`;
77
59// ---------------------------------------------------------------------------78// ---------------------------------------------------------------------------
60// AuthOutcome — the result of `McpAuthProvider.authenticate`.79// AuthOutcome — the result of `McpAuthProvider.authenticate`.
61//80//
@@ -210,6 +229,7 @@ export class McpAuthProvider extends Context.Service< · 148 unmodified lines
210export interface McpDispatchInput {229export interface McpDispatchInput {
211 readonly request: Request;230 readonly request: Request;
212 readonly principal: Principal;231 readonly principal: Principal;
232 readonly resource: McpResource;
213 readonly sessionId: string | null;233 readonly sessionId: string | null;
214 readonly method: string;234 readonly method: string;
215}235}
packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts+66−5
@@ -35,6 +35,23 @@ const insertIntegration = ( · 34 unmodified lines
35 ],35 ],
36 });36 });
37 37
38const 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
38const insertBlob = (55const insertBlob = (
39 client: SqliteDataMigrationClient,56 client: SqliteDataMigrationClient,
40 row: {57 row: {
@@ -84,7 +101,11 @@ const insertOperationStorage = ( · 43 unmodified lines
84 101
85const insertTool = (102const insertTool = (
86 client: SqliteDataMigrationClient,103 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 },
88) =>109) =>
89 client.execute({110 client.execute({
90 sql: `INSERT INTO tool111 sql: `INSERT INTO tool
@@ -95,7 +116,11 @@ const insertTool = ( · 4 unmodified lines
95 116
96const insertDefinition = (117const insertDefinition = (
97 client: SqliteDataMigrationClient,118 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 },
99) =>124) =>
100 client.execute({125 client.execute({
101 sql: `INSERT INTO definition126 sql: `INSERT INTO definition
@@ -105,6 +130,31 @@ const insertDefinition = ( · 3 unmodified lines
105 });130 });
106 131
107describe("runSqliteGoogleOpenApiOwnershipMigration", () => {132describe("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
108 it.effect("moves OpenAPI-owned Google bundle rows to the Google plugin", () =>158 it.effect("moves OpenAPI-owned Google bundle rows to the Google plugin", () =>
109 Effect.gen(function* () {159 Effect.gen(function* () {
110 const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));160 const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
@@ -128,7 +178,10 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 17 unmodified lines
128 tenant: "org_1",178 tenant: "org_1",
129 slug: "stripe",179 slug: "stripe",
130 pluginId: "openapi",180 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 },
132 }),185 }),
133 );186 );
134 yield* Effect.promise(() =>187 yield* Effect.promise(() =>
@@ -156,10 +209,18 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 21 unmodified lines
156 }),209 }),
157 );210 );
158 yield* Effect.promise(() =>211 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 }),
160 );217 );
161 yield* Effect.promise(() =>218 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 }),
163 );224 );
164 }225 }
165 226
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
4const MIGRATION_NAME = "2026-06-20-google-openapi-ownership";4const MIGRATION_NAME = "2026-06-20-google-openapi-ownership";
5const googleOpenApiCandidate = (alias?: string): string => {5const googleOpenApiCandidate = (alias?: string): string => {
6 const column = (name: string) => (alias ? `${alias}.${name}` : name);6 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'`;
8};8};
9 9
10const execute = (10const 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 @@
1import { defineClientPlugin } from "@executor-js/sdk/client";
2
3import { ToolkitsPage } from "./page";
4
5export 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 @@
1import { useId, useMemo, useState } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";
3import { useAtomSet, useAtomValue } from "@effect/atom-react";
4import * as Atom from "effect/unstable/reactivity/Atom";
5import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
6import { ArrowLeftIcon, BoxIcon, PlugIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react";
7import {
8 createPluginAtomClient,
9 useIntegrationPlugins,
10 type IntegrationPlugin,
11 type PluginPageProps,
12} from "@executor-js/sdk/client";
13import {
14 matchPattern,
15 type EffectivePolicy,
16 type Integration,
17 type Owner,
18 type ToolAddress,
19 type ToolPolicyAction,
20} from "@executor-js/sdk/shared";
21import { integrationsOptimisticAtom, toolsAllAtom } from "@executor-js/react/api/atoms";
22import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
23import { useOrganizationSlug } from "@executor-js/react/api/organization-context";
24import {
25 getExecutorApiBaseUrl,
26 getExecutorOrganizationHeaders,
27 getExecutorServerAuthorizationHeader,
28} from "@executor-js/react/api/server-connection";
29import { ownerLabel, useOwnerDisplay } from "@executor-js/react/api/owner-display";
30import { Badge } from "@executor-js/react/components/badge";
31import { Button } from "@executor-js/react/components/button";
32import { CopyButton } from "@executor-js/react/components/copy-button";
33import {
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";
44import {
45 IntegrationFavicon,
46 integrationPresetIconUrl,
47} from "@executor-js/react/components/integration-favicon";
48import {
49 Dialog,
50 DialogContent,
51 DialogDescription,
52 DialogFooter,
53 DialogHeader,
54 DialogTitle,
55} from "@executor-js/react/components/dialog";
56import { Input } from "@executor-js/react/components/input";
57import { Label } from "@executor-js/react/components/label";
58import { Skeleton } from "@executor-js/react/components/skeleton";
59import { ToolDetail, ToolDetailEmpty } from "@executor-js/react/components/tool-detail";
60import { ToolTree, type ToolSummary } from "@executor-js/react/components/tool-tree";
61import { cn } from "@executor-js/react/lib/utils";
62
63import {
64 ToolkitsApi,
65 type ToolkitConnectionResponse,
66 type ToolkitPolicyResponse,
67 type ToolkitResponse,
68} from "./shared";
69
70const ToolkitsClient = createPluginAtomClient(ToolkitsApi, {
71 baseUrl: getExecutorApiBaseUrl,
72 authorizationHeader: getExecutorServerAuthorizationHeader,
73 headers: getExecutorOrganizationHeaders,
74});
75
76const toolkitWriteKeys = [
77 ReactivityKey.connections,
78 ReactivityKey.policies,
79 ReactivityKey.tools,
80] as const;
81
82const toolkitsAtom = ToolkitsClient.query("toolkits", "list", {
83 timeToLive: "30 seconds",
84 reactivityKeys: [ReactivityKey.policies],
85});
86
87const toolkitPoliciesAtom = Atom.family((toolkitId: string) =>
88 ToolkitsClient.query("toolkits", "listPolicies", {
89 params: { toolkitId },
90 timeToLive: "30 seconds",
91 reactivityKeys: [ReactivityKey.policies],
92 }),
93);
94
95const toolkitConnectionsAtom = Atom.family((toolkitId: string) =>
96 ToolkitsClient.query("toolkits", "listConnections", {
97 params: { toolkitId },
98 timeToLive: "30 seconds",
99 reactivityKeys: [ReactivityKey.connections],
100 }),
101);
102
103const createToolkit = ToolkitsClient.mutation("toolkits", "create");
104const removeToolkit = ToolkitsClient.mutation("toolkits", "remove");
105const createToolkitPolicy = ToolkitsClient.mutation("toolkits", "createPolicy");
106const updateToolkitPolicy = ToolkitsClient.mutation("toolkits", "updatePolicy");
107const removeToolkitPolicy = ToolkitsClient.mutation("toolkits", "removePolicy");
108const createToolkitConnection = ToolkitsClient.mutation("toolkits", "createConnection");
109const removeToolkitConnection = ToolkitsClient.mutation("toolkits", "removeConnection");
110
111type 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
122const 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
128const pluginDefaultPolicy = (requiresApproval: boolean | undefined): EffectivePolicy =>
129 requiresApproval
130 ? { action: "require_approval", source: "plugin-default" }
131 : { action: "approve", source: "plugin-default" };
132
133const 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
139const 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
156const toolMatchId = (tool: ToolRow): string =>
157 tool.static ? String(tool.address) : String(tool.address).replace(/^tools\./, "");
158
159const toolCanAppearInToolkit = (toolkit: ToolkitResponse, tool: ToolRow): boolean =>
160 toolkit.owner === "user" || tool.static === true || tool.owner !== "user";
161
162const 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
168const identityPattern = (displayPattern: string): string => displayPattern;
169
170const 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
175const 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
183const toolkitCardStyle = { minHeight: "9rem" };
184const toolkitShelfStyle = { minHeight: "28.5rem" };
185const toolkitGridContainerStyle = { maxWidth: "80rem" };
186const toolkitToolTreeStyle = { width: "24rem" };
187
188const neutralToolkitIconClass = "border-border/70 bg-muted/30 text-muted-foreground";
189
190type 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
199type IntegrationMeta = {
200 readonly name: string;
201 readonly sourceId: string;
202 readonly icon?: string | null;
203 readonly url?: string;
204};
205
206const toolOwner = (tool: ToolRow): Owner => (tool.static ? "org" : (tool.owner ?? "org"));
207
208const toolConnectionName = (tool: ToolRow): string => {
209 if (tool.connection && tool.connection.length > 0) return tool.connection;
210 return tool.static ? "built-in" : "default";
211};
212
213const 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
237const connectionPatternForTool = (tool: ToolRow): string => `${policyPrefixForTool(tool)}.*`;
238
239const 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
249const 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
256const 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
264const 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
295const compareTools = (a: ToolRow, b: ToolRow): number =>
296 String(a.name).localeCompare(String(b.name)) || toolMatchId(a).localeCompare(toolMatchId(b));
297
298const connectionTitle = (group: ToolkitConnectionGroup): string =>
299 `${ownerLabel(group.owner)} ${group.integration} / ${group.connection}`;
300
301const connectionTitleForHost = (group: ToolkitConnectionGroup, showOwnerLabels: boolean): string =>
302 showOwnerLabels ? connectionTitle(group) : `${group.integration} / ${group.connection}`;
303
304const connectionDisplayTitle = (group: ToolkitConnectionGroup, meta: IntegrationMeta): string =>
305 group.connection === "built-in" || group.connection === "default" ? meta.name : group.connection;
306
307const 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
312const 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
321const 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
344type 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
354const 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
386const 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
405const 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
415function 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
451const connectionCountLabel = (count: number): string =>
452 count === 0 ? "No connections" : `${count} ${count === 1 ? "connection" : "connections"}`;
453
454function 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
512const addToolkitScopeLabel = (owner: Owner, showOwnerLabels: boolean): string =>
513 showOwnerLabels ? (owner === "org" ? "workspace" : "personal") : "";
514const addToolkitTitle = (owner: Owner, showOwnerLabels: boolean): string =>
515 showOwnerLabels
516 ? owner === "org"
517 ? "New workspace toolkit"
518 : "New personal toolkit"
519 : "New toolkit";
520
521function 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
595function 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
619function 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
664function 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
709function 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
728function 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
762function 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
948function 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
1034function 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
1184function 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
1200function 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
1220function 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
1237function 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
1293function 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
1381export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Predicate, Result } from "effect";
3import { makeTestExecutor } from "@executor-js/sdk/testing";
4
5import { toolkitsPlugin } from "./server";
6
7describe("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 @@
1import {
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";
20import { addGroup, capture } from "@executor-js/api";
21import { generateKeyBetween } from "fractional-indexing";
22
23import { ToolkitError, ToolkitsApi } from "./shared";
24
25const ToolkitRecord = Schema.Struct({
26 id: Schema.String,
27 slug: Schema.String,
28 name: Schema.String,
29 createdAt: Schema.Number,
30 updatedAt: Schema.Number,
31});
32type ToolkitRecord = typeof ToolkitRecord.Type;
33
34const 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});
43type ToolkitPolicyRecord = typeof ToolkitPolicyRecord.Type;
44
45const 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});
53type ToolkitConnectionRecord = typeof ToolkitConnectionRecord.Type;
54
55const toolkitsCollection = definePluginStorageCollection("toolkits", ToolkitRecord, {
56 indexes: ["slug", "name", "updatedAt"],
57});
58
59const toolkitPoliciesCollection = definePluginStorageCollection(
60 "toolkitPolicies",
61 ToolkitPolicyRecord,
62 {
63 indexes: ["toolkitId", "pattern", "position", ["toolkitId", "position"]],
64 },
65);
66
67const toolkitConnectionsCollection = definePluginStorageCollection(
68 "toolkitConnections",
69 ToolkitConnectionRecord,
70 {
71 indexes: ["toolkitId", "pattern", "position", ["toolkitId", "position"]],
72 },
73);
74
75type ToolkitStorage = {
76 readonly toolkits: PluginStorageCollectionFacade<typeof toolkitsCollection>;
77 readonly policies: PluginStorageCollectionFacade<typeof toolkitPoliciesCollection>;
78 readonly connections: PluginStorageCollectionFacade<typeof toolkitConnectionsCollection>;
79};
80
81export interface ToolkitsPluginOptions {
82 /** When set, this executor instance enforces only the named toolkit's rules. */
83 readonly activeToolkitSlug?: string;
84}
85
86const newId = (prefix: string): string =>
87 `${prefix}_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
88
89const slugPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
90
91const slugify = (name: string): string =>
92 name
93 .trim()
94 .toLowerCase()
95 .replace(/[^a-z0-9]+/g, "-")
96 .replace(/^-+|-+$/g, "")
97 .slice(0, 63);
98
99const 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
115const fail = (message: string): Effect.Effect<never, ToolkitError> =>
116 Effect.fail(new ToolkitError({ message }));
117
118const 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
127const 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
136const blockedPolicy = (pattern = "*"): EffectivePolicy => ({
137 action: "block",
138 source: "user",
139 pattern,
140});
141
142const pluginDefaultPolicy = (defaultRequiresApproval: boolean | undefined): EffectivePolicy =>
143 defaultRequiresApproval
144 ? { action: "require_approval", source: "plugin-default" }
145 : { action: "approve", source: "plugin-default" };
146
147const 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
153const 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
180const 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
194const isPersonalDynamicToolId = (toolId: string): boolean => toolId.split(".")[1] === "user";
195
196const 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
205const 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
215const 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
224const makeToolkitStorage = (pluginStorage: PluginStorageFacade): ToolkitStorage => ({
225 toolkits: pluginStorage.collection(toolkitsCollection),
226 policies: pluginStorage.collection(toolkitPoliciesCollection),
227 connections: pluginStorage.collection(toolkitConnectionsCollection),
228});
229
230const 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
533export type ToolkitsExtension = ReturnType<typeof makeToolkitsExtension>;
534
535export class ToolkitsExtensionService extends Context.Service<
536 ToolkitsExtensionService,
537 ToolkitsExtension
538>()("ToolkitsExtensionService") {}
539
540const ExecutorApiWithToolkits = addGroup(ToolkitsApi);
541
542const 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
640const 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
649export 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
673export default toolkitsPlugin;
packages/plugins/toolkits/src/shared.ts+166−0
@@ -0,0 +1,166 @@
1import { Schema } from "effect";
2import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
3import { InternalError, Owner, ToolPolicyActionSchema } from "@executor-js/sdk/shared";
4
5export class ToolkitError extends Schema.TaggedErrorClass<ToolkitError>()(
6 "ToolkitError",
7 { message: Schema.String },
8 { httpApiStatus: 400 },
9) {}
10
11const ToolkitParams = {
12 toolkitId: Schema.String,
13};
14
15const ToolkitPolicyParams = {
16 toolkitId: Schema.String,
17 policyId: Schema.String,
18};
19
20const ToolkitConnectionParams = {
21 toolkitId: Schema.String,
22 connectionId: Schema.String,
23};
24
25export 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});
33export type ToolkitResponse = typeof ToolkitResponse.Type;
34
35export 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});
44export type ToolkitPolicyResponse = typeof ToolkitPolicyResponse.Type;
45
46export 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});
54export type ToolkitConnectionResponse = typeof ToolkitConnectionResponse.Type;
55
56const CreateToolkitPayload = Schema.Struct({
57 owner: Owner,
58 name: Schema.String,
59 slug: Schema.optional(Schema.String),
60});
61
62const UpdateToolkitPayload = Schema.Struct({
63 name: Schema.optional(Schema.String),
64 slug: Schema.optional(Schema.String),
65});
66
67const CreateToolkitPolicyPayload = Schema.Struct({
68 pattern: Schema.String,
69 action: ToolPolicyActionSchema,
70 position: Schema.optional(Schema.String),
71});
72
73const UpdateToolkitPolicyPayload = Schema.Struct({
74 pattern: Schema.optional(Schema.String),
75 action: Schema.optional(ToolPolicyActionSchema),
76 position: Schema.optional(Schema.String),
77});
78
79const CreateToolkitConnectionPayload = Schema.Struct({
80 pattern: Schema.String,
81 position: Schema.optional(Schema.String),
82});
83
84const ToolkitErrors = [InternalError, ToolkitError] as const;
85
86export 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 @@
1import { defineConfig } from "tsup";
2
3export 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 @@
1import { defineConfig } from "vitest/config";
2
3export 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
172 * applies a user rule to this tool's exact id. */172 * applies a user rule to this tool's exact id. */
173 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;173 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
174 onClearPolicy?: (pattern: string) => void;174 onClearPolicy?: (pattern: string) => void;
175 patternForDisplay?: (displayPattern: string) => string;
175 /** Run-tab wiring. When `integration` + `runToolName` are provided, a third176 /** Run-tab wiring. When `integration` + `runToolName` are provided, a third
176 * "Run" tab hosts the per-connection tool tester. */177 * "Run" tab hosts the per-connection tool tester. */
177 integration?: IntegrationSlug;178 integration?: IntegrationSlug;
@@ -187,6 +188,7 @@ export function ToolDetail(props: { · 9 unmodified lines
187 const canRun = props.integration != null && props.runToolName != null;188 const canRun = props.integration != null && props.runToolName != null;
188 // Don't strand the user on the Run tab when this ToolDetail has no run wiring.189 // Don't strand the user on the Run tab when this ToolDetail has no run wiring.
189 const activeTab = tab === "run" && !canRun ? "schema" : tab;190 const activeTab = tab === "run" && !canRun ? "schema" : tab;
191 const isBlocked = props.policy?.action === "block";
190 192
191 const data = useMemo(() => {193 const data = useMemo(() => {
192 if (!AsyncResult.isSuccess(toolContract)) return null;194 if (!AsyncResult.isSuccess(toolContract)) return null;
@@ -244,6 +246,7 @@ export function ToolDetail(props: { · 51 unmodified lines
244 policy={props.policy}246 policy={props.policy}
245 onSetPolicy={props.onSetPolicy}247 onSetPolicy={props.onSetPolicy}
246 onClearPolicy={props.onClearPolicy}248 onClearPolicy={props.onClearPolicy}
249 patternForDisplay={props.patternForDisplay}
247 />250 />
248 </div>251 </div>
249 {data?.description && <ToolDescription description={data.description} />}252 {data?.description && <ToolDescription description={data.description} />}
@@ -300,48 +303,59 @@ export function ToolDetail(props: { · 50 unmodified lines
300 303
301 {/* Content */}304 {/* Content */}
302 <div className="flex-1 overflow-y-auto">305 <div className="flex-1 overflow-y-auto">
303 {AsyncResult.match(toolContract, {306 {isBlocked ? (
304 onInitial: () => <div className="p-5 text-sm text-muted-foreground">Loading…</div>,307 <div className="px-5 py-5">
305 onFailure: () => <div className="p-5 text-sm text-destructive">Something went wrong</div>,308 <EmptySection
306 onSuccess: () =>309 title="Blocked"
307 activeTab === "run" && props.integration && props.runToolName ? (310 message="This tool is not available through the current toolkit."
308 <div className="px-5 py-5">311 />
309 <ToolRunPanel312 </div>
310 integration={props.integration}313 ) : (
311 toolName={props.runToolName}314 AsyncResult.match(toolContract, {
312 connections={props.connections ?? []}315 onInitial: () => <div className="p-5 text-sm text-muted-foreground">Loading…</div>,
313 initialConnectionName={props.initialConnectionName}316 onFailure: () => (
314 />317 <div className="p-5 text-sm text-destructive">Something went wrong</div>
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 />
343 ),318 ),
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 )}
345 </div>359 </div>
346 </div>360 </div>
347 );361 );
@@ -400,12 +414,15 @@ function PolicyBadgeMenu(props: { · 52 unmodified lines
400 policy?: EffectivePolicy;414 policy?: EffectivePolicy;
401 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;415 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
402 onClearPolicy?: (pattern: string) => void;416 onClearPolicy?: (pattern: string) => void;
417 patternForDisplay?: (displayPattern: string) => string;
403}) {418}) {
404 const interactive = !!props.onSetPolicy;419 const interactive = !!props.onSetPolicy;
405 // The same pattern bridge the tree rows apply — the pattern WRITTEN and the420 // The same pattern bridge the tree rows apply — the pattern WRITTEN and the
406 // pattern LOOKED UP must be the same string, or the menu authors rules that421 // pattern LOOKED UP must be the same string, or the menu authors rules that
407 // never match and can't see its own rule afterward.422 // 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);
409 // The "Clear" affordance only makes sense when there's a user rule426 // The "Clear" affordance only makes sense when there's a user rule
410 // pinned to this exact tool id — clearing a wildcard rule from a427 // pinned to this exact tool id — clearing a wildcard rule from a
411 // single tool's detail header would silently affect siblings.428 // 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
41 /** Name of the connection (account) that produced this tool. Present only in41 /** Name of the connection (account) that produced this tool. Present only in
42 * the account-grouped view; ignored in the flat tree. */42 * the account-grouped view; ignored in the flat tree. */
43 readonly connection?: string;43 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;
44}47}
45 48
46// ---------------------------------------------------------------------------49// ---------------------------------------------------------------------------
@@ -53,9 +56,10 @@ export interface ToolSummary { · 6 unmodified lines
53// ---------------------------------------------------------------------------56// ---------------------------------------------------------------------------
54 57
55export type AccountGroup = {58export type AccountGroup = {
56 /** Stable key `${owner}:${connection}`. */59 /** Stable key `${owner}:${integration}:${connection}`. */
57 readonly key: string;60 readonly key: string;
58 readonly owner: Owner;61 readonly owner: Owner;
62 readonly integration: string;
59 readonly connection: string;63 readonly connection: string;
60 /** Section header, e.g. "Personal · axiom-mcp". */64 /** Section header, e.g. "Personal · axiom-mcp". */
61 readonly label: string;65 readonly label: string;
@@ -69,14 +73,18 @@ export type AccountGroup = { · 7 unmodified lines
69 * Pure — unit-testable without React.73 * Pure — unit-testable without React.
70 */74 */
71export const buildAccountGroups = (tools: readonly ToolSummary[]): readonly AccountGroup[] => {75export 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 >();
73 for (const tool of tools) {80 for (const tool of tools) {
74 const owner: Owner = tool.owner ?? "org";81 const owner: Owner = tool.owner ?? "org";
82 const integration = tool.integration ?? "";
75 const connection = tool.connection ?? "";83 const connection = tool.connection ?? "";
76 const key = `${owner}:${connection}`;84 const key = `${owner}:${integration}:${connection}`;
77 let group = byKey.get(key);85 let group = byKey.get(key);
78 if (!group) {86 if (!group) {
79 group = { owner, connection, tools: [] };87 group = { owner, integration, connection, tools: [] };
80 byKey.set(key, group);88 byKey.set(key, group);
81 }89 }
82 group.tools.push(tool);90 group.tools.push(tool);
@@ -85,14 +93,20 @@ export const buildAccountGroups = (tools: readonly ToolSummary[]): readonly Acco · 2 unmodified lines
85 const ownerRank = (owner: Owner): number => (owner === "org" ? 0 : 1);93 const ownerRank = (owner: Owner): number => (owner === "org" ? 0 : 1);
86 return [...byKey.values()]94 return [...byKey.values()]
87 .sort(95 .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),
89 )100 )
90 .map((group) => ({101 .map((group) => ({
91 key: `${group.owner}:${group.connection}`,102 key: `${group.owner}:${group.integration}:${group.connection}`,
92 owner: group.owner,103 owner: group.owner,
104 integration: group.integration,
93 connection: group.connection,105 connection: group.connection,
94 label: group.connection106 label: group.connection
95 ? `${ownerLabel(group.owner)} · ${group.connection}`107 ? `${ownerLabel(group.owner)} · ${
108 group.integration ? `${group.integration} / ` : ""
109 }${group.connection}`
96 : ownerLabel(group.owner),110 : ownerLabel(group.owner),
97 tools: group.tools,111 tools: group.tools,
98 }));112 }));
@@ -283,6 +297,8 @@ export function ToolTree(props: { · 184 unmodified lines
283 * emit the tool's full dotted id; group rows emit `prefix.*`. */297 * emit the tool's full dotted id; group rows emit `prefix.*`. */
284 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;298 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
285 onClearPolicy?: (pattern: string) => void;299 onClearPolicy?: (pattern: string) => void;
300 /** Maps the displayed row path into the persisted policy pattern. */
301 patternForDisplay?: (displayPattern: string) => string;
286 /** Sorted user-authored policies (most-precedent first). Used to302 /** Sorted user-authored policies (most-precedent first). Used to
287 * decide whether a node has its own exact-pattern user rule today303 * decide whether a node has its own exact-pattern user rule today
288 * (so the menu can show a "Clear" option). Optional — when absent,304 * (so the menu can show a "Clear" option). Optional — when absent,
@@ -300,6 +316,7 @@ export function ToolTree(props: { · 11 unmodified lines
300 onSelect,316 onSelect,
301 onSetPolicy,317 onSetPolicy,
302 onClearPolicy,318 onClearPolicy,
319 patternForDisplay = toPolicyPattern,
303 policies,320 policies,
304 groupByConnection,321 groupByConnection,
305 } = props;322 } = props;
@@ -358,6 +375,7 @@ export function ToolTree(props: { · 52 unmodified lines
358 onSelect,375 onSelect,
359 onSetPolicy,376 onSetPolicy,
360 onClearPolicy,377 onClearPolicy,
378 patternForDisplay,
361 exactPatterns,379 exactPatterns,
362 search,380 search,
363 terms,381 terms,
@@ -404,7 +422,9 @@ export function ToolTree(props: { · 40 unmodified lines
404 </Badge>422 </Badge>
405 ) : null}423 ) : null}
406 <span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">424 <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)}
408 </span>428 </span>
409 <span className="shrink-0 tabular-nums text-xs text-muted-foreground">429 <span className="shrink-0 tabular-nums text-xs text-muted-foreground">
410 {group.tools.length}430 {group.tools.length}
@@ -433,6 +453,7 @@ function ToolTreeBody(props: { · 22 unmodified lines
433 onSelect: (toolId: string) => void;453 onSelect: (toolId: string) => void;
434 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;454 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
435 onClearPolicy?: (pattern: string) => void;455 onClearPolicy?: (pattern: string) => void;
456 patternForDisplay: (displayPattern: string) => string;
436 exactPatterns: ReadonlyMap<string, ToolPolicyAction>;457 exactPatterns: ReadonlyMap<string, ToolPolicyAction>;
437 search: string;458 search: string;
438 terms: readonly string[];459 terms: readonly string[];
@@ -444,6 +465,7 @@ function ToolTreeBody(props: { · 5 unmodified lines
444 onSelect,465 onSelect,
445 onSetPolicy,466 onSetPolicy,
446 onClearPolicy,467 onClearPolicy,
468 patternForDisplay,
447 exactPatterns,469 exactPatterns,
448 search,470 search,
449 terms,471 terms,
@@ -510,7 +532,8 @@ function ToolTreeBody(props: { · 60 unmodified lines
510 search={search}532 search={search}
511 onSetPolicy={onSetPolicy}533 onSetPolicy={onSetPolicy}
512 onClearPolicy={onClearPolicy}534 onClearPolicy={onClearPolicy}
513 exactRule={exactPatterns.get(toPolicyPattern(row.tool.name))}535 patternForDisplay={patternForDisplay}
536 exactRule={exactPatterns.get(patternForDisplay(row.tool.name))}
514 />537 />
515 ) : (538 ) : (
516 <ToolGroupRow539 <ToolGroupRow
@@ -524,7 +547,8 @@ function ToolTreeBody(props: { · 7 unmodified lines
524 search={search}547 search={search}
525 onSetPolicy={onSetPolicy}548 onSetPolicy={onSetPolicy}
526 onClearPolicy={onClearPolicy}549 onClearPolicy={onClearPolicy}
527 exactRule={exactPatterns.get(toPolicyPattern(`${row.path}.*`))}550 patternForDisplay={patternForDisplay}
551 exactRule={exactPatterns.get(patternForDisplay(`${row.path}.*`))}
528 />552 />
529 ),553 ),
530 )}554 )}
@@ -610,6 +634,7 @@ function ToolGroupRow(props: { · 79 unmodified lines
610 search: string;634 search: string;
611 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;635 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
612 onClearPolicy?: (pattern: string) => void;636 onClearPolicy?: (pattern: string) => void;
637 patternForDisplay: (displayPattern: string) => string;
613 exactRule?: ToolPolicyAction;638 exactRule?: ToolPolicyAction;
614}) {639}) {
615 const showActions = !!props.onSetPolicy;640 const showActions = !!props.onSetPolicy;
@@ -647,7 +672,7 @@ function ToolGroupRow(props: { · 31 unmodified lines
647 )}672 )}
648 >673 >
649 <PolicyActionMenu674 <PolicyActionMenu
650 pattern={toPolicyPattern(`${props.path}.*`)}675 pattern={props.patternForDisplay(`${props.path}.*`)}
651 current={props.exactRule}676 current={props.exactRule}
652 onSet={props.onSetPolicy!}677 onSet={props.onSetPolicy!}
653 onClear={props.onClearPolicy}678 onClear={props.onClearPolicy}
@@ -668,6 +693,7 @@ function ToolLeafRow(props: { · 14 unmodified lines
668 search: string;693 search: string;
669 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;694 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
670 onClearPolicy?: (pattern: string) => void;695 onClearPolicy?: (pattern: string) => void;
696 patternForDisplay: (displayPattern: string) => string;
671 exactRule?: ToolPolicyAction;697 exactRule?: ToolPolicyAction;
672}) {698}) {
673 const label = props.tool.name.split(".").pop() ?? props.tool.name;699 const label = props.tool.name.split(".").pop() ?? props.tool.name;
@@ -719,7 +745,7 @@ function ToolLeafRow(props: { · 45 unmodified lines
719 )}745 )}
720 >746 >
721 <PolicyActionMenu747 <PolicyActionMenu
722 pattern={toPolicyPattern(props.tool.name)}748 pattern={props.patternForDisplay(props.tool.name)}
723 current={props.exactRule}749 current={props.exactRule}
724 onSet={props.onSetPolicy!}750 onSet={props.onSetPolicy!}
725 onClear={props.onClearPolicy}751 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
9// added without a consoleRoutes() entry, or vice versa — apps would silently9// added without a consoleRoutes() entry, or vice versa — apps would silently
10// lack a route that this package's pages link to. Lock them together.10// lack a route that this package's pages link to. Lock them together.
11 11
12const collectPaths = (route: unknown): ReadonlyArray<string> => {12const collectPaths = (route: unknown, parentId = ""): ReadonlyArray<string> => {
13 const node = route as {13 const node = route as {
14 options?: { id?: string };14 options?: { id?: string };
15 children?: ReadonlyArray<unknown>;15 children?: ReadonlyArray<unknown>;
16 };16 };
17 const children = node.children ?? [];17 const children = node.children ?? [];
18 const id = node.options?.id;18 const id = node.options?.id;
19 const own = typeof id === "string" ? [id] : [];19 const resolved =
20 return [...own, ...children.flatMap(collectPaths)];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))];
21};27};
22 28
23// The generated ids are the scope-relative contract paths nested under the29// 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
39 "/policies",39 "/policies",
40 "/secrets",40 "/secrets",
41 "/tools",41 "/tools",
42 "/toolkits",
43 "/toolkits/$toolkitSlug",
42 "/resume/$executionId",44 "/resume/$executionId",
43 "/plugins/$pluginId/$",45 "/plugins/$pluginId/$",
44] as const;46] as const;
@@ -71,6 +73,8 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 26 unmodified lines
71 ["/policies", route("/policies", file("policies.tsx"))],73 ["/policies", route("/policies", file("policies.tsx"))],
72 ["/secrets", route("/secrets", file("secrets.tsx"))],74 ["/secrets", route("/secrets", file("secrets.tsx"))],
73 ["/tools", route("/tools", file("tools.tsx"))],75 ["/tools", route("/tools", file("tools.tsx"))],
76 ["/toolkits", route("/toolkits", file("toolkits.tsx"))],
77 ["/toolkits/$toolkitSlug", route("/toolkits/$toolkitSlug", file("toolkits.$toolkitSlug.tsx"))],
74 ["/resume/$executionId", route("/resume/$executionId", file("resume.$executionId.tsx"))],78 ["/resume/$executionId", route("/resume/$executionId", file("resume.$executionId.tsx"))],
75 ["/plugins/$pluginId/$", route("/plugins/$pluginId/$", file("plugins.$pluginId.$.tsx"))],79 ["/plugins/$pluginId/$", route("/plugins/$pluginId/$", file("plugins.$pluginId.$.tsx"))],
76 ];80 ];
packages/react/src/multiplayer/shell.tsx+26−2
@@ -21,7 +21,7 @@ import { · 20 unmodified lines
21 integrationPresetIconUrl,21 integrationPresetIconUrl,
22} from "../components/integration-favicon";22} from "../components/integration-favicon";
23import { CommandPalette } from "../components/command-palette";23import { CommandPalette } from "../components/command-palette";
24import { useIntegrationPlugins } from "@executor-js/sdk/client";24import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";
25import { useAuth } from "./auth-context";25import { useAuth } from "./auth-context";
26 26
27// ---------------------------------------------------------------------------27// ---------------------------------------------------------------------------
@@ -49,6 +49,7 @@ export const defaultShellNavItems: ReadonlyArray<ShellNavItem> = [ · 21 unmodified lines
49 { to: "/", label: "Integrations" },49 { to: "/", label: "Integrations" },
50 { to: "/secrets", label: "Providers" },50 { to: "/secrets", label: "Providers" },
51 { to: "/policies", label: "Policies" },51 { to: "/policies", label: "Policies" },
52 { to: "/toolkits", label: "Toolkits" },
52];53];
53 54
54/** Canonical public docs (Mintlify). Same-origin on cloud (executor.sh proxies55/** 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
63// the host's own route tree, which this package can't see.64// the host's own route tree, which this package can't see.
64const orgScopedTo = (to: string): string => (to === "/" ? "/{-$orgSlug}" : `/{-$orgSlug}${to}`);65const orgScopedTo = (to: string): string => (to === "/" ? "/{-$orgSlug}" : `/{-$orgSlug}${to}`);
65 66
67const normalizePluginPagePath = (path: string): string => {
68 if (!path || path === "/") return "/";
69 return path.startsWith("/") ? path : `/${path}`;
70};
71
72const pluginPageNavPath = (pluginId: string, path: string): string => {
73 const normalized = normalizePluginPagePath(path);
74 return normalized === "/" ? `/plugins/${pluginId}/` : `/plugins/${pluginId}${normalized}`;
75};
76
66/** The pathname with the active org-slug prefix stripped, for active-state77/** The pathname with the active org-slug prefix stripped, for active-state
67 * comparisons against scope-relative paths. */78 * comparisons against scope-relative paths. */
68function useScopeRelativePathname(): string {79function useScopeRelativePathname(): string {
@@ -311,7 +322,20 @@ function UserFooter(props: Pick<ShellProps, "onSignOut" | "orgMenuSlot">) { · 242 unmodified lines
311function SidebarContent(322function SidebarContent(
312 props: ShellProps & { pathname: string; onNavigate?: () => void; showBrand?: boolean },323 props: ShellProps & { pathname: string; onNavigate?: () => void; showBrand?: boolean },
313) {324) {
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];
315 return (339 return (
316 <>340 <>
317 {props.showBrand !== false && (341 {props.showBrand !== false && (
packages/react/src/routes/plugin-route-match.test.ts+34−0
@@ -0,0 +1,34 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { matchPluginPage, matchPluginPagePath } from "./plugins.$pluginId.$";
4
5describe("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 @@
1import { createFileRoute, notFound } from "@tanstack/react-router";1import { createFileRoute, Navigate, notFound } from "@tanstack/react-router";
2import { useClientPlugins } from "@executor-js/sdk/client";2import { useClientPlugins } from "@executor-js/sdk/client";
3 3
4// ---------------------------------------------------------------------------4// ---------------------------------------------------------------------------
@@ -11,10 +11,10 @@ import { useClientPlugins } from "@executor-js/sdk/client"; · 6 unmodified lines
11// plugin to `executor.config.ts` is sufficient for its pages to mount11// plugin to `executor.config.ts` is sufficient for its pages to mount
12// here, with no per-route imports.12// here, with no per-route imports.
13//13//
14// Match logic is intentionally tiny: exact path equality between the URL14// Plugin pages use the same lightweight route vocabulary as the rest of the
15// remainder and a `PageDecl.path`, with `""` and `/` treated as the15// console route tree: static segments and `$param` segments. The host route
16// same root. Plugins that want parameterized paths can build their own16// owns that matching so plugin detail views can be real URLs instead of
17// in-component routing for now.17// in-component state.
18// ---------------------------------------------------------------------------18// ---------------------------------------------------------------------------
19 19
20export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({20export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({
@@ -23,21 +23,93 @@ export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({ · 2 unmodified lines
23 23
24function normalizePath(input: string): string {24function normalizePath(input: string): string {
25 if (!input || input === "/") return "/";25 if (!input || input === "/") return "/";
26 return input.startsWith("/") ? input : `/${input}`;26 const withLeadingSlash = input.startsWith("/") ? input : `/${input}`;
27 return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/, "") : "/";
27}28}
28 29
30const pathSegments = (input: string): readonly string[] =>
31 normalizePath(input)
32 .split("/")
33 .filter((segment) => segment.length > 0);
34
35export 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
56const matchScore = (pattern: string): number =>
57 pathSegments(pattern).reduce((score, segment) => score + (segment.startsWith("$") ? 1 : 2), 0);
58
59export 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
29function PluginRouteComponent() {86function PluginRouteComponent() {
30 const { pluginId, _splat: rest } = Route.useParams();87 const { orgSlug, pluginId, _splat: rest } = Route.useParams();
31 const plugins = useClientPlugins();88 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
32 const plugin = plugins.find((p) => p.id === pluginId);105 const plugin = plugins.find((p) => p.id === pluginId);
33 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound()106 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound()
34 if (!plugin) throw notFound();107 if (!plugin) throw notFound();
35 108
36 const target = normalizePath(rest ?? "/");109 const match = matchPluginPage(plugin.pages, target);
37 const page = plugin.pages?.find((p) => normalizePath(p.path) === target);
38 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound()110 // 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();
40 112
41 const Component = page.component;113 const Component = match.page.component;
42 return <Component />;114 return <Component params={match.params} path={target} pluginId={pluginId} />;
43}115}
packages/react/src/routes/routeTree.gen.ts+53−0
@@ -11,8 +11,10 @@ · 10 unmodified lines
11import { Route as rootRouteImport } from './__root'11import { Route as rootRouteImport } from './__root'
12import { Route as DotIndexRouteImport } from './index'12import { Route as DotIndexRouteImport } from './index'
13import { Route as DotToolsRouteImport } from './tools'13import { Route as DotToolsRouteImport } from './tools'
14import { Route as DotToolkitsRouteImport } from './toolkits'
14import { Route as DotSecretsRouteImport } from './secrets'15import { Route as DotSecretsRouteImport } from './secrets'
15import { Route as DotPoliciesRouteImport } from './policies'16import { Route as DotPoliciesRouteImport } from './policies'
17import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug'
16import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId'18import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId'
17import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace'19import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace'
18import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$'20import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$'
@@ -28,6 +30,11 @@ const DotToolsRoute = DotToolsRouteImport.update({ · 9 unmodified lines
28 path: '/{-$orgSlug}/tools',30 path: '/{-$orgSlug}/tools',
29 getParentRoute: () => rootRouteImport,31 getParentRoute: () => rootRouteImport,
30} as any)32} as any)
33const DotToolkitsRoute = DotToolkitsRouteImport.update({
34 id: '/{-$orgSlug}/toolkits',
35 path: '/{-$orgSlug}/toolkits',
36 getParentRoute: () => rootRouteImport,
37} as any)
31const DotSecretsRoute = DotSecretsRouteImport.update({38const DotSecretsRoute = DotSecretsRouteImport.update({
32 id: '/{-$orgSlug}/secrets',39 id: '/{-$orgSlug}/secrets',
33 path: '/{-$orgSlug}/secrets',40 path: '/{-$orgSlug}/secrets',
@@ -38,6 +45,12 @@ const DotPoliciesRoute = DotPoliciesRouteImport.update({ · 4 unmodified lines
38 path: '/{-$orgSlug}/policies',45 path: '/{-$orgSlug}/policies',
39 getParentRoute: () => rootRouteImport,46 getParentRoute: () => rootRouteImport,
40} as any)47} as any)
48const DotToolkitsDottoolkitSlugRoute =
49 DotToolkitsDottoolkitSlugRouteImport.update({
50 id: '/$toolkitSlug',
51 path: '/$toolkitSlug',
52 getParentRoute: () => DotToolkitsRoute,
53 } as any)
41const DotResumeDotexecutionIdRoute = DotResumeDotexecutionIdRouteImport.update({54const DotResumeDotexecutionIdRoute = DotResumeDotexecutionIdRouteImport.update({
42 id: '/{-$orgSlug}/resume/$executionId',55 id: '/{-$orgSlug}/resume/$executionId',
43 path: '/{-$orgSlug}/resume/$executionId',56 path: '/{-$orgSlug}/resume/$executionId',
@@ -65,20 +78,24 @@ const DotIntegrationsDotaddDotpluginKeyRoute = · 21 unmodified lines
65export interface FileRoutesByFullPath {78export interface FileRoutesByFullPath {
66 '/{-$orgSlug}/policies': typeof DotPoliciesRoute79 '/{-$orgSlug}/policies': typeof DotPoliciesRoute
67 '/{-$orgSlug}/secrets': typeof DotSecretsRoute80 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
81 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
68 '/{-$orgSlug}/tools': typeof DotToolsRoute82 '/{-$orgSlug}/tools': typeof DotToolsRoute
69 '/{-$orgSlug}/': typeof DotIndexRoute83 '/{-$orgSlug}/': typeof DotIndexRoute
70 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute84 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
71 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute85 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
86 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
72 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute87 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
73 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute88 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute
74}89}
75export interface FileRoutesByTo {90export interface FileRoutesByTo {
76 '/{-$orgSlug}/policies': typeof DotPoliciesRoute91 '/{-$orgSlug}/policies': typeof DotPoliciesRoute
77 '/{-$orgSlug}/secrets': typeof DotSecretsRoute92 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
93 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
78 '/{-$orgSlug}/tools': typeof DotToolsRoute94 '/{-$orgSlug}/tools': typeof DotToolsRoute
79 '/{-$orgSlug}': typeof DotIndexRoute95 '/{-$orgSlug}': typeof DotIndexRoute
80 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute96 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
81 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute97 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
98 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
82 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute99 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
83 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute100 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute
84}101}
@@ -86,10 +103,12 @@ export interface FileRoutesById { · 1 unmodified line
86 __root__: typeof rootRouteImport103 __root__: typeof rootRouteImport
87 '/{-$orgSlug}/policies': typeof DotPoliciesRoute104 '/{-$orgSlug}/policies': typeof DotPoliciesRoute
88 '/{-$orgSlug}/secrets': typeof DotSecretsRoute105 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
106 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
89 '/{-$orgSlug}/tools': typeof DotToolsRoute107 '/{-$orgSlug}/tools': typeof DotToolsRoute
90 '/{-$orgSlug}/': typeof DotIndexRoute108 '/{-$orgSlug}/': typeof DotIndexRoute
91 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute109 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
92 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute110 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
111 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
93 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute112 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
94 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute113 '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute
95}114}
@@ -98,30 +117,36 @@ export interface FileRouteTypes { · 2 unmodified lines
98 fullPaths:117 fullPaths:
99 | '/{-$orgSlug}/policies'118 | '/{-$orgSlug}/policies'
100 | '/{-$orgSlug}/secrets'119 | '/{-$orgSlug}/secrets'
120 | '/{-$orgSlug}/toolkits'
101 | '/{-$orgSlug}/tools'121 | '/{-$orgSlug}/tools'
102 | '/{-$orgSlug}/'122 | '/{-$orgSlug}/'
103 | '/{-$orgSlug}/integrations/$namespace'123 | '/{-$orgSlug}/integrations/$namespace'
104 | '/{-$orgSlug}/resume/$executionId'124 | '/{-$orgSlug}/resume/$executionId'
125 | '/{-$orgSlug}/toolkits/$toolkitSlug'
105 | '/{-$orgSlug}/integrations/add/$pluginKey'126 | '/{-$orgSlug}/integrations/add/$pluginKey'
106 | '/{-$orgSlug}/plugins/$pluginId/$'127 | '/{-$orgSlug}/plugins/$pluginId/$'
107 fileRoutesByTo: FileRoutesByTo128 fileRoutesByTo: FileRoutesByTo
108 to:129 to:
109 | '/{-$orgSlug}/policies'130 | '/{-$orgSlug}/policies'
110 | '/{-$orgSlug}/secrets'131 | '/{-$orgSlug}/secrets'
132 | '/{-$orgSlug}/toolkits'
111 | '/{-$orgSlug}/tools'133 | '/{-$orgSlug}/tools'
112 | '/{-$orgSlug}'134 | '/{-$orgSlug}'
113 | '/{-$orgSlug}/integrations/$namespace'135 | '/{-$orgSlug}/integrations/$namespace'
114 | '/{-$orgSlug}/resume/$executionId'136 | '/{-$orgSlug}/resume/$executionId'
137 | '/{-$orgSlug}/toolkits/$toolkitSlug'
115 | '/{-$orgSlug}/integrations/add/$pluginKey'138 | '/{-$orgSlug}/integrations/add/$pluginKey'
116 | '/{-$orgSlug}/plugins/$pluginId/$'139 | '/{-$orgSlug}/plugins/$pluginId/$'
117 id:140 id:
118 | '__root__'141 | '__root__'
119 | '/{-$orgSlug}/policies'142 | '/{-$orgSlug}/policies'
120 | '/{-$orgSlug}/secrets'143 | '/{-$orgSlug}/secrets'
144 | '/{-$orgSlug}/toolkits'
121 | '/{-$orgSlug}/tools'145 | '/{-$orgSlug}/tools'
122 | '/{-$orgSlug}/'146 | '/{-$orgSlug}/'
123 | '/{-$orgSlug}/integrations/$namespace'147 | '/{-$orgSlug}/integrations/$namespace'
124 | '/{-$orgSlug}/resume/$executionId'148 | '/{-$orgSlug}/resume/$executionId'
149 | '/{-$orgSlug}/toolkits/$toolkitSlug'
125 | '/{-$orgSlug}/integrations/add/$pluginKey'150 | '/{-$orgSlug}/integrations/add/$pluginKey'
126 | '/{-$orgSlug}/plugins/$pluginId/$'151 | '/{-$orgSlug}/plugins/$pluginId/$'
127 fileRoutesById: FileRoutesById152 fileRoutesById: FileRoutesById
@@ -129,6 +154,7 @@ export interface FileRouteTypes { · 1 unmodified line
129export interface RootRouteChildren {154export interface RootRouteChildren {
130 DotPoliciesRoute: typeof DotPoliciesRoute155 DotPoliciesRoute: typeof DotPoliciesRoute
131 DotSecretsRoute: typeof DotSecretsRoute156 DotSecretsRoute: typeof DotSecretsRoute
157 DotToolkitsRoute: typeof DotToolkitsRouteWithChildren
132 DotToolsRoute: typeof DotToolsRoute158 DotToolsRoute: typeof DotToolsRoute
133 DotIndexRoute: typeof DotIndexRoute159 DotIndexRoute: typeof DotIndexRoute
134 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute160 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute
@@ -153,6 +179,13 @@ declare module '@tanstack/react-router' { · 18 unmodified lines
153 preLoaderRoute: typeof DotToolsRouteImport179 preLoaderRoute: typeof DotToolsRouteImport
154 parentRoute: typeof rootRouteImport180 parentRoute: typeof rootRouteImport
155 }181 }
182 '/{-$orgSlug}/toolkits': {
183 id: '/{-$orgSlug}/toolkits'
184 path: '/{-$orgSlug}/toolkits'
185 fullPath: '/{-$orgSlug}/toolkits'
186 preLoaderRoute: typeof DotToolkitsRouteImport
187 parentRoute: typeof rootRouteImport
188 }
156 '/{-$orgSlug}/secrets': {189 '/{-$orgSlug}/secrets': {
157 id: '/{-$orgSlug}/secrets'190 id: '/{-$orgSlug}/secrets'
158 path: '/{-$orgSlug}/secrets'191 path: '/{-$orgSlug}/secrets'
@@ -167,6 +200,13 @@ declare module '@tanstack/react-router' { · 8 unmodified lines
167 preLoaderRoute: typeof DotPoliciesRouteImport200 preLoaderRoute: typeof DotPoliciesRouteImport
168 parentRoute: typeof rootRouteImport201 parentRoute: typeof rootRouteImport
169 }202 }
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 }
170 '/{-$orgSlug}/resume/$executionId': {210 '/{-$orgSlug}/resume/$executionId': {
171 id: '/{-$orgSlug}/resume/$executionId'211 id: '/{-$orgSlug}/resume/$executionId'
172 path: '/{-$orgSlug}/resume/$executionId'212 path: '/{-$orgSlug}/resume/$executionId'
@@ -198,9 +238,22 @@ declare module '@tanstack/react-router' { · 25 unmodified lines
198 }238 }
199}239}
200 240
241interface DotToolkitsRouteChildren {
242 DotToolkitsDottoolkitSlugRoute: typeof DotToolkitsDottoolkitSlugRoute
243}
244
245const DotToolkitsRouteChildren: DotToolkitsRouteChildren = {
246 DotToolkitsDottoolkitSlugRoute: DotToolkitsDottoolkitSlugRoute,
247}
248
249const DotToolkitsRouteWithChildren = DotToolkitsRoute._addFileChildren(
250 DotToolkitsRouteChildren,
251)
252
201const rootRouteChildren: RootRouteChildren = {253const rootRouteChildren: RootRouteChildren = {
202 DotPoliciesRoute: DotPoliciesRoute,254 DotPoliciesRoute: DotPoliciesRoute,
203 DotSecretsRoute: DotSecretsRoute,255 DotSecretsRoute: DotSecretsRoute,
256 DotToolkitsRoute: DotToolkitsRouteWithChildren,
204 DotToolsRoute: DotToolsRoute,257 DotToolsRoute: DotToolsRoute,
205 DotIndexRoute: DotIndexRoute,258 DotIndexRoute: DotIndexRoute,
206 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,259 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,
packages/react/src/routes/toolkits-route.tsx+23−0
@@ -0,0 +1,23 @@
1import { notFound } from "@tanstack/react-router";
2import { useClientPlugins } from "@executor-js/sdk/client";
3
4export 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 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { ToolkitsPluginRoute } from "./toolkits-route";
4
5export const Route = createFileRoute("/{-$orgSlug}/toolkits/$toolkitSlug")({
6 component: ToolkitsRouteComponent,
7});
8
9function ToolkitsRouteComponent() {
10 const { toolkitSlug } = Route.useParams();
11 return <ToolkitsPluginRoute toolkitSlug={toolkitSlug} />;
12}
packages/react/src/routes/toolkits.tsx+12−0
@@ -0,0 +1,12 @@
1import { createFileRoute, useParams } from "@tanstack/react-router";
2
3import { ToolkitsPluginRoute } from "./toolkits-route";
4
5export const Route = createFileRoute("/{-$orgSlug}/toolkits")({
6 component: ToolkitsRouteComponent,
7});
8
9function 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