[codex] Add scoped MCP toolkits
Unified · Split
| @@ -5,6 +5,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; · 4 unmodified lines | |||
| 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; | 7 | import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; |
| 8 | import { 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 and | 41 | * 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 | ||
| 45 | export default defineExecutorConfig({ | 47 | export 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 } : {}), |
| @@ -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:*", |
| @@ -18,14 +18,20 @@ describe("isAppOwnedPath", () => { · 17 unmodified lines | |||
| 18 | "/api/billing/attach", | 18 | "/api/billing/attach", |
| 19 | "/api/docs", // Swagger UI | 19 | "/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) and | 25 | // 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`, () => { |
| @@ -57,13 +57,15 @@ export const CloudDbProvider = cloudDbProviderLayer(collectTables()); · 56 unmodified lines | |||
| 57 | // Fresh plugin instances per request, carrying the Worker env's WorkOS Vault | 57 | // 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()`. |
| 59 | export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)({ | 59 | export 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 | ||
| @@ -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 | ||
| 59 | const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; | 60 | const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; |
| 61 | const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`; | ||
| 60 | 62 | ||
| 61 | const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first"; | 63 | const 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 an | 96 | // 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 org | 129 | * 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 | ||
| @@ -50,6 +50,7 @@ const MCP_PATH = "/mcp"; · 49 unmodified lines | |||
| 50 | export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp"; | 50 | export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp"; |
| 51 | export const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`; | 51 | export const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`; |
| 52 | export const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`; | 52 | export const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`; |
| 53 | const 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 | |||
| 68 | export const mcpOrganizationFromRequest = (request: Request): string | null => | 69 | export 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. */ | ||
| 73 | export const toolkitSlugFromRequest = (request: Request): string | null => { | ||
| 74 | const pathname = new URL(request.url).pathname; | ||
| 75 | const index = pathname.indexOf(TOOLKIT_SEGMENT); | ||
| 76 | if (index < 0) return null; | ||
| 77 | const slug = pathname.slice(index + TOOLKIT_SEGMENT.length).split("/", 1)[0]; | ||
| 78 | return slug && slug.length > 0 ? slug : null; | ||
| 79 | }; | ||
| 80 | |||
| 81 | const toolkitMcpPath = (toolkitSlug: string | null): string => | ||
| 82 | toolkitSlug ? `${MCP_PATH}/toolkits/${toolkitSlug}` : MCP_PATH; | ||
| 83 | |||
| 71 | /** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy | 84 | /** 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 client | 85 | * `…/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. */ |
| 74 | export const resourceUrlFor = (organizationSelector: string | null): string => | 87 | export 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. */ |
| 78 | export const protectedResourceMetadataUrlFor = (organizationSelector: string | null): string => | 96 | export const protectedResourceMetadataUrlFor = ( |
| 79 | organizationSelector | 97 | 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 | ||
| 83 | type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; | 106 | type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; |
| 84 | 107 | ||
| @@ -117,10 +140,11 @@ export const mcpUnauthorized = ( · 32 unmodified lines | |||
| 117 | export const bearerChallengeFor = ( | 140 | export 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 | // --------------------------------------------------------------------------- |
| @@ -0,0 +1,57 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { protectedResourceMetadataUrlFor, resourceUrlFor, toolkitSlugFromRequest } from "./auth"; | ||
| 4 | import { classifyMcpPath, prepareMcpOrgScope } from "./mount"; | ||
| 5 | |||
| 6 | describe("cloud MCP toolkit route normalization", () => { | ||
| 7 | it("classifies toolkit MCP and protected-resource metadata paths", () => { | ||
| 8 | expect(classifyMcpPath("/mcp/toolkits/deploy")).toEqual({ | ||
| 9 | kind: "mcp", | ||
| 10 | organizationId: null, | ||
| 11 | toolkitSlug: "deploy", | ||
| 12 | }); | ||
| 13 | expect(classifyMcpPath("/acme/mcp/toolkits/deploy")).toEqual({ | ||
| 14 | kind: "mcp", | ||
| 15 | organizationId: "acme", | ||
| 16 | toolkitSlug: "deploy", | ||
| 17 | }); | ||
| 18 | expect(classifyMcpPath("/.well-known/oauth-protected-resource/mcp/toolkits/deploy")).toEqual({ | ||
| 19 | kind: "oauth-protected-resource", | ||
| 20 | organizationId: null, | ||
| 21 | toolkitSlug: "deploy", | ||
| 22 | }); | ||
| 23 | expect( | ||
| 24 | classifyMcpPath("/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy"), | ||
| 25 | ).toEqual({ | ||
| 26 | kind: "oauth-protected-resource", | ||
| 27 | organizationId: "acme", | ||
| 28 | toolkitSlug: "deploy", | ||
| 29 | }); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("rewrites org-scoped toolkit metadata to the mounted toolkit metadata route", () => { | ||
| 33 | const request = new Request( | ||
| 34 | "https://executor.sh/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy?x=1", | ||
| 35 | { headers: { "x-executor-mcp-organization": "spoofed" } }, | ||
| 36 | ); | ||
| 37 | |||
| 38 | const rewritten = prepareMcpOrgScope(request); | ||
| 39 | const url = new URL(rewritten.url); | ||
| 40 | |||
| 41 | expect(url.pathname).toBe("/.well-known/oauth-protected-resource/mcp/toolkits/deploy"); | ||
| 42 | expect(url.search).toBe("?x=1"); | ||
| 43 | expect(rewritten.headers.get("x-executor-mcp-organization")).toBe("acme"); | ||
| 44 | expect(toolkitSlugFromRequest(rewritten)).toBe("deploy"); | ||
| 45 | }); | ||
| 46 | |||
| 47 | it("builds toolkit-specific resource and metadata URLs", () => { | ||
| 48 | expect(resourceUrlFor(null, "deploy")).toBe("https://executor.sh/mcp/toolkits/deploy"); | ||
| 49 | expect(resourceUrlFor("acme", "deploy")).toBe("https://executor.sh/acme/mcp/toolkits/deploy"); | ||
| 50 | expect(protectedResourceMetadataUrlFor(null, "deploy")).toBe( | ||
| 51 | "https://executor.sh/.well-known/oauth-protected-resource/mcp/toolkits/deploy", | ||
| 52 | ); | ||
| 53 | expect(protectedResourceMetadataUrlFor("acme", "deploy")).toBe( | ||
| 54 | "https://executor.sh/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy", | ||
| 55 | ); | ||
| 56 | }); | ||
| 57 | }); | ||
| @@ -50,6 +50,7 @@ type McpRoute = { · 49 unmodified lines | |||
| 50 | * id), or `null` for the bare path. Resolved to an org id — and re-checked | 50 | * 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 (the | 56 | // 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 | |||
| 60 | const orgSelectorSegment = (segment: string | undefined): string | null => | 61 | const 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 org | 64 | type MatchedMcpSuffix = { |
| 64 | // selector, `null` for the bare form, or `undefined` when the segments are neither. | 65 | readonly organizationId: string | null; |
| 65 | const 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. | ||
| 71 | const matchMcpSuffix = (segments: readonly string[]): MatchedMcpSuffix | undefined => { | ||
| 72 | if (segments.length === 1 && segments[0] === "mcp") return { organizationId: null }; | ||
| 73 | if (segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits") { | ||
| 74 | const toolkitSlug = segments[2]; | ||
| 75 | return toolkitSlug ? { organizationId: null, toolkitSlug } : undefined; | ||
| 76 | } | ||
| 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 === undefined | 113 | 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 | ||
| 107 | const bareMcpPath = (kind: McpRouteKind): string => | 121 | const bareMcpPath = (route: Exclude<McpRoute, null>): string => |
| 108 | kind === "mcp" | 122 | route.kind === "mcp" |
| 109 | ? MCP_PATH | 123 | ? route.toolkitSlug |
| 110 | : kind === "oauth-protected-resource" | 124 | ? `${MCP_PATH}/toolkits/${route.toolkitSlug}` |
| 111 | ? PROTECTED_RESOURCE_METADATA_PATH | 125 | : MCP_PATH |
| 126 | : route.kind === "oauth-protected-resource" | ||
| 127 | ? route.toolkitSlug | ||
| 128 | ? `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/${route.toolkitSlug}` | ||
| 129 | : PROTECTED_RESOURCE_METADATA_PATH | ||
| 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); |
| @@ -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 a | 17 | // 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 the | 18 | // 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. |
| 20 | export const protectedResourceMetadataResponse = (organizationId: string | null = null): Response => | 20 | export const protectedResourceMetadataResponse = ( |
| 21 | organizationId: string | null = null, | ||
| 22 | toolkitSlug: string | null = null, | ||
| 23 | ): Response => | ||
| 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 is | 28 | // Spec-faithful clients (OpenCode, mcporter) request exactly what is |
| @@ -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"), |
| @@ -14,11 +14,13 @@ import { Route as LoginRouteImport } from './routes/bare/login' · 13 unmodified lines | |||
| 14 | import { Route as CreateOrgRouteImport } from './routes/bare/create-org' | 14 | import { Route as CreateOrgRouteImport } from './routes/bare/create-org' |
| 15 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' | 15 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' |
| 16 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' | 16 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' |
| 17 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' | ||
| 17 | import { Route as SecretsRouteImport } from './routes/app/secrets' | 18 | import { Route as SecretsRouteImport } from './routes/app/secrets' |
| 18 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' | 19 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' |
| 19 | import { Route as OrgRouteImport } from './routes/app/org' | 20 | import { Route as OrgRouteImport } from './routes/app/org' |
| 20 | import { Route as BillingRouteImport } from './routes/app/billing' | 21 | import { Route as BillingRouteImport } from './routes/app/billing' |
| 21 | import { Route as ApiKeysRouteImport } from './routes/app/api-keys' | 22 | import { Route as ApiKeysRouteImport } from './routes/app/api-keys' |
| 23 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' | ||
| 22 | import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' | 24 | import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' |
| 23 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' | 25 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 24 | import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans' | 26 | import { 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) |
| 56 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute = | ||
| 57 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport.update({ | ||
| 58 | id: '/{-$orgSlug}/toolkits', | ||
| 59 | path: '/{-$orgSlug}/toolkits', | ||
| 60 | getParentRoute: () => rootRouteImport, | ||
| 61 | } as any) | ||
| 54 | const SecretsRoute = SecretsRouteImport.update({ | 62 | const 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) |
| 88 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute = | ||
| 89 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update( | ||
| 90 | { | ||
| 91 | id: '/$toolkitSlug', | ||
| 92 | path: '/$toolkitSlug', | ||
| 93 | getParentRoute: () => | ||
| 94 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute, | ||
| 95 | } as any, | ||
| 96 | ) | ||
| 80 | const ResumeDotexecutionIdRoute = ResumeDotexecutionIdRouteImport.update({ | 97 | const 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 OrgRoute | 130 | '/{-$orgSlug}/org': typeof OrgRoute |
| 114 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute | 131 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 115 | '/{-$orgSlug}/secrets': typeof SecretsRoute | 132 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 133 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | ||
| 116 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 134 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 117 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 135 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 118 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute | 136 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute |
| 119 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 137 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 120 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute | 138 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 139 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | ||
| 121 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 140 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 122 | } | 141 | } |
| 123 | export interface FileRoutesByTo { | 142 | export interface FileRoutesByTo { |
| @@ -129,11 +148,13 @@ export interface FileRoutesByTo { · 5 unmodified lines | |||
| 129 | '/{-$orgSlug}/org': typeof OrgRoute | 148 | '/{-$orgSlug}/org': typeof OrgRoute |
| 130 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute | 149 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 131 | '/{-$orgSlug}/secrets': typeof SecretsRoute | 150 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 151 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | ||
| 132 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 152 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 133 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 153 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 134 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute | 154 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute |
| 135 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 155 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 136 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute | 156 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 157 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | ||
| 137 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 158 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 138 | } | 159 | } |
| 139 | export interface FileRoutesById { | 160 | export interface FileRoutesById { |
| @@ -146,11 +167,13 @@ export interface FileRoutesById { · 6 unmodified lines | |||
| 146 | '/{-$orgSlug}/org': typeof OrgRoute | 167 | '/{-$orgSlug}/org': typeof OrgRoute |
| 147 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute | 168 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 148 | '/{-$orgSlug}/secrets': typeof SecretsRoute | 169 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 170 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | ||
| 149 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 171 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 150 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 172 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 151 | '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute | 173 | '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute |
| 152 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 174 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 153 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute | 175 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 176 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | ||
| 154 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 177 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 155 | } | 178 | } |
| 156 | export interface FileRouteTypes { | 179 | export 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: FileRoutesByTo | 198 | 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: FileRoutesById | 234 | fileRoutesById: FileRoutesById |
| 206 | } | 235 | } |
| @@ -213,6 +242,7 @@ export interface RootRouteChildren { · 6 unmodified lines | |||
| 213 | OrgRoute: typeof OrgRoute | 242 | OrgRoute: typeof OrgRoute |
| 214 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute | 243 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 215 | SecretsRoute: typeof SecretsRoute | 244 | SecretsRoute: typeof SecretsRoute |
| 245 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | ||
| 216 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 246 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 217 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 247 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 218 | Billing_DotplansRoute: typeof Billing_DotplansRoute | 248 | Billing_DotplansRoute: typeof Billing_DotplansRoute |
| @@ -258,6 +288,13 @@ declare module '@tanstack/react-router' { · 39 unmodified lines | |||
| 258 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport | 288 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport |
| 259 | parentRoute: typeof rootRouteImport | 289 | 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 ApiKeysRouteImport | 330 | preLoaderRoute: typeof ApiKeysRouteImport |
| 294 | parentRoute: typeof rootRouteImport | 331 | 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 | ||
| 371 | interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { | ||
| 372 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | ||
| 373 | } | ||
| 374 | |||
| 375 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren = | ||
| 376 | { | ||
| 377 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: | ||
| 378 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute, | ||
| 379 | } | ||
| 380 | |||
| 381 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = | ||
| 382 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute._addFileChildren( | ||
| 383 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren, | ||
| 384 | ) | ||
| 385 | |||
| 327 | const rootRouteChildren: RootRouteChildren = { | 386 | const 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 | ||
| 352 | import type { getRouter } from './router.tsx' | 413 | import type { getRouter } from './router.tsx' |
| 414 | |||
| 353 | import type { startInstance } from './start.ts' | 415 | import type { startInstance } from './start.ts' |
| 416 | |||
| 354 | declare module '@tanstack/react-start' { | 417 | declare module '@tanstack/react-start' { |
| 355 | interface Register { | 418 | interface Register { |
| 356 | ssr: true | 419 | 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 | } |
| @@ -5,6 +5,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; · 4 unmodified lines | |||
| 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; | 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| 8 | import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | ||
| 8 | 9 | ||
| 9 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 10 | // Plugin list for the Cloudflare web build. The Vite `executorVitePlugin` reads | 11 | // 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 | ||
| 18 | export default defineExecutorConfig({ | 19 | export 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 | }); |
| @@ -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:*", |
| @@ -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 | ||
| 35 | const makeFakeR2 = (): { readonly bucket: R2Bucket; readonly objects: Map<string, string> } => { | 35 | const makeFakeR2 = (): { |
| 36 | readonly bucket: R2Bucket; | ||
| 37 | readonly objects: Map<string, string>; | ||
| 38 | } => { | ||
| 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 implemented | 40 | // 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 | ||
| 81 | const insertIntegrationRawConfig = ( | ||
| 82 | client: SqliteDataMigrationClient, | ||
| 83 | row: { | ||
| 84 | readonly rowId: string; | ||
| 85 | readonly tenant: string; | ||
| 86 | readonly slug: string; | ||
| 87 | readonly pluginId: string; | ||
| 88 | readonly config: string; | ||
| 89 | }, | ||
| 90 | ) => | ||
| 91 | client.execute({ | ||
| 92 | sql: `INSERT INTO integration | ||
| 93 | (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at) | ||
| 94 | VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`, | ||
| 95 | args: [row.rowId, row.tenant, row.slug, row.pluginId, row.slug, row.slug, row.config, now, now], | ||
| 96 | }); | ||
| 97 | |||
| 78 | const insertOperationStorage = ( | 98 | const insertOperationStorage = ( |
| 79 | client: SqliteDataMigrationClient, | 99 | client: SqliteDataMigrationClient, |
| 80 | row: { | 100 | row: { |
| @@ -103,6 +123,134 @@ const insertOperationStorage = ( · 22 unmodified lines | |||
| 103 | }); | 123 | }); |
| 104 | 124 | ||
| 105 | describe("runCloudflareDataMigrations", () => { | 125 | describe("runCloudflareDataMigrations", () => { |
| 126 | it.effect("rebuilds legacy connection tables from item_id to item_ids", () => | ||
| 127 | Effect.gen(function* () { | ||
| 128 | const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); | ||
| 129 | const { bucket } = makeFakeR2(); | ||
| 130 | |||
| 131 | yield* Effect.promise(() => db.client.execute("DROP TABLE connection")); | ||
| 132 | yield* Effect.promise(() => | ||
| 133 | db.client.execute(` | ||
| 134 | CREATE TABLE connection ( | ||
| 135 | integration text NOT NULL, | ||
| 136 | name text NOT NULL, | ||
| 137 | template text NOT NULL, | ||
| 138 | provider text NOT NULL, | ||
| 139 | item_id text NOT NULL, | ||
| 140 | identity_label text, | ||
| 141 | description text, | ||
| 142 | tools_synced_at integer, | ||
| 143 | oauth_client text, | ||
| 144 | oauth_client_owner text, | ||
| 145 | refresh_item_id text, | ||
| 146 | expires_at integer, | ||
| 147 | oauth_scope text, | ||
| 148 | oauth_token_url text, | ||
| 149 | provider_state text, | ||
| 150 | created_at integer NOT NULL, | ||
| 151 | updated_at integer NOT NULL, | ||
| 152 | row_id text PRIMARY KEY NOT NULL, | ||
| 153 | tenant text NOT NULL, | ||
| 154 | owner text NOT NULL, | ||
| 155 | subject text NOT NULL | ||
| 156 | ) | ||
| 157 | `), | ||
| 158 | ); | ||
| 159 | yield* Effect.promise(() => | ||
| 160 | db.client.execute({ | ||
| 161 | sql: `INSERT INTO connection | ||
| 162 | (integration, name, template, provider, item_id, created_at, updated_at, row_id, tenant, owner, subject) | ||
| 163 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | ||
| 164 | args: [ | ||
| 165 | "legacy", | ||
| 166 | "main", | ||
| 167 | "apiKey", | ||
| 168 | "encrypted", | ||
| 169 | "legacy-item", | ||
| 170 | now, | ||
| 171 | now, | ||
| 172 | "legacy-connection-row", | ||
| 173 | "default", | ||
| 174 | "org", | ||
| 175 | "", | ||
| 176 | ], | ||
| 177 | }), | ||
| 178 | ); | ||
| 179 | |||
| 180 | const d1 = makeFakeD1(db.client); | ||
| 181 | expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toContain( | ||
| 182 | "2026-06-20-google-openapi-ownership", | ||
| 183 | ); | ||
| 184 | |||
| 185 | const columns = yield* Effect.promise(() => | ||
| 186 | db.client.execute("PRAGMA table_info('connection')"), | ||
| 187 | ); | ||
| 188 | expect(columns.rows.map((row) => row.name)).toContain("item_ids"); | ||
| 189 | expect(columns.rows.map((row) => row.name)).not.toContain("item_id"); | ||
| 190 | |||
| 191 | yield* Effect.promise(() => | ||
| 192 | db.client.execute({ | ||
| 193 | sql: `INSERT INTO connection | ||
| 194 | (integration, name, template, provider, item_ids, created_at, updated_at, row_id, tenant, owner, subject) | ||
| 195 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | ||
| 196 | args: [ | ||
| 197 | "probe", | ||
| 198 | "selected", | ||
| 199 | "apiKey", | ||
| 200 | "encrypted", | ||
| 201 | JSON.stringify({ token: "item" }), | ||
| 202 | now, | ||
| 203 | now, | ||
| 204 | "connection-row", | ||
| 205 | "default", | ||
| 206 | "org", | ||
| 207 | "", | ||
| 208 | ], | ||
| 209 | }), | ||
| 210 | ); | ||
| 211 | |||
| 212 | const rows = yield* Effect.promise(() => | ||
| 213 | db.client.execute("SELECT name, item_ids FROM connection ORDER BY name"), | ||
| 214 | ); | ||
| 215 | expect(rows.rows).toEqual([ | ||
| 216 | { name: "main", item_ids: JSON.stringify({ token: "legacy-item" }) }, | ||
| 217 | { name: "selected", item_ids: JSON.stringify({ token: "item" }) }, | ||
| 218 | ]); | ||
| 219 | |||
| 220 | yield* Effect.promise(() => db.close()); | ||
| 221 | }), | ||
| 222 | ); | ||
| 223 | |||
| 224 | it.effect("clears malformed legacy OpenAPI integration config rows", () => | ||
| 225 | Effect.gen(function* () { | ||
| 226 | const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); | ||
| 227 | const { bucket } = makeFakeR2(); | ||
| 228 | |||
| 229 | yield* Effect.promise(() => | ||
| 230 | insertIntegrationRawConfig(db.client, { | ||
| 231 | rowId: "malformed-row", | ||
| 232 | tenant: "org_1", | ||
| 233 | slug: "broken", | ||
| 234 | pluginId: "openapi", | ||
| 235 | config: "", | ||
| 236 | }), | ||
| 237 | ); | ||
| 238 | |||
| 239 | const d1 = makeFakeD1(db.client); | ||
| 240 | expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([ | ||
| 241 | "2026-06-20-google-openapi-ownership", | ||
| 242 | ]); | ||
| 243 | expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]); | ||
| 244 | |||
| 245 | const integrations = yield* Effect.promise(() => | ||
| 246 | db.client.execute("SELECT slug, plugin_id, config FROM integration"), | ||
| 247 | ); | ||
| 248 | expect(integrations.rows).toEqual([{ slug: "broken", plugin_id: "openapi", config: null }]); | ||
| 249 | |||
| 250 | yield* Effect.promise(() => db.close()); | ||
| 251 | }), | ||
| 252 | ); | ||
| 253 | |||
| 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() })); |
| @@ -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 | ||
| 41 | const tableColumns = async (db: D1Database, table: string): Promise<ReadonlySet<string>> => { | ||
| 42 | const session = db.withSession("first-primary"); | ||
| 43 | const result = await session.prepare(`PRAGMA table_info('${table}')`).all<{ | ||
| 44 | readonly name?: unknown; | ||
| 45 | }>(); | ||
| 46 | return new Set( | ||
| 47 | result.results | ||
| 48 | .map((row) => row.name) | ||
| 49 | .filter((name): name is string => typeof name === "string"), | ||
| 50 | ); | ||
| 51 | }; | ||
| 52 | |||
| 53 | const rebuildLegacyConnectionTable = async (db: D1Database): Promise<void> => { | ||
| 54 | const statements = [ | ||
| 55 | `DROP TABLE IF EXISTS connection_next`, | ||
| 56 | `DROP TABLE IF EXISTS connection_legacy_item_id`, | ||
| 57 | `CREATE TABLE connection_next ( | ||
| 58 | integration text NOT NULL, | ||
| 59 | name text NOT NULL, | ||
| 60 | template text NOT NULL, | ||
| 61 | provider text NOT NULL, | ||
| 62 | item_ids json NOT NULL, | ||
| 63 | identity_label text, | ||
| 64 | description text, | ||
| 65 | tools_synced_at integer, | ||
| 66 | oauth_client text, | ||
| 67 | oauth_client_owner text, | ||
| 68 | refresh_item_id text, | ||
| 69 | expires_at integer, | ||
| 70 | oauth_scope text, | ||
| 71 | oauth_token_url text, | ||
| 72 | provider_state text, | ||
| 73 | created_at integer NOT NULL, | ||
| 74 | updated_at integer NOT NULL, | ||
| 75 | row_id text PRIMARY KEY NOT NULL, | ||
| 76 | tenant text NOT NULL, | ||
| 77 | owner text NOT NULL, | ||
| 78 | subject text NOT NULL | ||
| 79 | )`, | ||
| 80 | `INSERT INTO connection_next | ||
| 81 | (integration, name, template, provider, item_ids, identity_label, | ||
| 82 | description, tools_synced_at, oauth_client, oauth_client_owner, | ||
| 83 | refresh_item_id, expires_at, oauth_scope, oauth_token_url, | ||
| 84 | provider_state, created_at, updated_at, row_id, tenant, owner, subject) | ||
| 85 | SELECT integration, name, template, provider, | ||
| 86 | CASE | ||
| 87 | WHEN item_ids IS NOT NULL AND item_ids <> '{}' THEN item_ids | ||
| 88 | ELSE json_object('token', item_id) | ||
| 89 | END, | ||
| 90 | identity_label, description, tools_synced_at, oauth_client, | ||
| 91 | oauth_client_owner, refresh_item_id, expires_at, oauth_scope, | ||
| 92 | oauth_token_url, provider_state, created_at, updated_at, row_id, | ||
| 93 | tenant, owner, subject | ||
| 94 | FROM connection`, | ||
| 95 | `ALTER TABLE connection RENAME TO connection_legacy_item_id`, | ||
| 96 | `ALTER TABLE connection_next RENAME TO connection`, | ||
| 97 | `DROP TABLE connection_legacy_item_id`, | ||
| 98 | ]; | ||
| 99 | for (const statement of statements) { | ||
| 100 | await db.prepare(statement).run(); | ||
| 101 | } | ||
| 102 | }; | ||
| 103 | |||
| 104 | export const ensureCloudflareD1SchemaCompatibility = async (db: D1Database): Promise<void> => { | ||
| 105 | const integrationColumns = await tableColumns(db, "integration"); | ||
| 106 | if (integrationColumns.has("config")) { | ||
| 107 | await db | ||
| 108 | .prepare( | ||
| 109 | `UPDATE integration | ||
| 110 | SET config = NULL | ||
| 111 | WHERE config IS NOT NULL | ||
| 112 | AND NOT json_valid(config)`, | ||
| 113 | ) | ||
| 114 | .run(); | ||
| 115 | } | ||
| 116 | |||
| 117 | const connectionColumns = await tableColumns(db, "connection"); | ||
| 118 | if (connectionColumns.size === 0) return; | ||
| 119 | if (!connectionColumns.has("item_ids")) { | ||
| 120 | await db.prepare(`ALTER TABLE connection ADD COLUMN item_ids json NOT NULL DEFAULT '{}'`).run(); | ||
| 121 | } | ||
| 122 | const updatedConnectionColumns = await tableColumns(db, "connection"); | ||
| 123 | if (updatedConnectionColumns.has("item_id")) { | ||
| 124 | await rebuildLegacyConnectionTable(db); | ||
| 125 | } | ||
| 126 | }; | ||
| 127 | |||
| 41 | const r2ObjectName = (tenant: string, pluginId: string, key: string): string => | 128 | const 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 integration | 139 | FROM integration |
| 53 | WHERE plugin_id = 'openapi' | 140 | WHERE plugin_id = 'openapi' |
| 54 | AND config IS NOT NULL | 141 | 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 NULL | 144 | 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 | ||
| 73 | const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [ | 164 | const 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 | ); |
| @@ -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 | ||
| 44 | export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer<HostConfig> => | 48 | export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer<HostConfig> => |
| @@ -1,10 +1,58 @@ | |||
| 1 | import { Effect, Layer } from "effect"; | 1 | import { Effect, Layer } from "effect"; |
| 2 | 2 | ||
| 3 | import { authenticated, McpAuthProvider, unauthorized } from "@executor-js/host-mcp"; | 3 | import { |
| 4 | authenticated, | ||
| 5 | McpAuthProvider, | ||
| 6 | unauthorized, | ||
| 7 | type McpDiscoveryRoute, | ||
| 8 | } from "@executor-js/host-mcp"; | ||
| 4 | 9 | ||
| 5 | import { makeAccessVerifier } from "../auth/cloudflare-access"; | 10 | import { makeAccessVerifier } from "../auth/cloudflare-access"; |
| 6 | import type { CloudflareConfig } from "../config"; | 11 | import type { CloudflareConfig } from "../config"; |
| 7 | 12 | ||
| 13 | const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; | ||
| 14 | const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp`; | ||
| 15 | const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`; | ||
| 16 | |||
| 17 | const toolkitSlugFromPath = (pathname: string): string | undefined => { | ||
| 18 | const mcpPrefix = "/mcp/toolkits/"; | ||
| 19 | if (pathname.startsWith(mcpPrefix)) { | ||
| 20 | const slug = pathname.slice(mcpPrefix.length).split("/", 1)[0]; | ||
| 21 | return slug ? decodeURIComponent(slug) : undefined; | ||
| 22 | } | ||
| 23 | const metadataPrefix = `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/`; | ||
| 24 | if (pathname.startsWith(metadataPrefix)) { | ||
| 25 | const slug = pathname.slice(metadataPrefix.length).split("/", 1)[0]; | ||
| 26 | return slug ? decodeURIComponent(slug) : undefined; | ||
| 27 | } | ||
| 28 | return undefined; | ||
| 29 | }; | ||
| 30 | |||
| 31 | const toolkitPath = (slug: string): string => `/mcp/toolkits/${encodeURIComponent(slug)}`; | ||
| 32 | |||
| 33 | const resourcePathForRequest = (request: Request): string => { | ||
| 34 | const slug = toolkitSlugFromPath(new URL(request.url).pathname); | ||
| 35 | return slug ? toolkitPath(slug) : "/mcp"; | ||
| 36 | }; | ||
| 37 | |||
| 38 | const metadataPathForRequest = (request: Request): string => { | ||
| 39 | const slug = toolkitSlugFromPath(new URL(request.url).pathname); | ||
| 40 | return slug | ||
| 41 | ? `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/${encodeURIComponent(slug)}` | ||
| 42 | : MCP_PROTECTED_RESOURCE_METADATA_PATH; | ||
| 43 | }; | ||
| 44 | |||
| 45 | const protectedResourceMetadataResponse = (request: Request): Response => { | ||
| 46 | const url = new URL(request.url); | ||
| 47 | return new Response( | ||
| 48 | JSON.stringify({ | ||
| 49 | resource: new URL(resourcePathForRequest(request), url.origin).toString(), | ||
| 50 | authorization_servers: [], | ||
| 51 | }), | ||
| 52 | { headers: { "content-type": "application/json" } }, | ||
| 53 | ); | ||
| 54 | }; | ||
| 55 | |||
| 8 | // --------------------------------------------------------------------------- | 56 | // --------------------------------------------------------------------------- |
| 9 | // Cloudflare Access McpAuthProvider — the `/mcp` gate, identical identity to the | 57 | // Cloudflare Access McpAuthProvider — the `/mcp` gate, identical identity to the |
| 10 | // API gate. Cloudflare Access sits in front of the Worker and forwards the | 58 | // 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 | ||
| 24 | export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer<McpAuthProvider> => { | 72 | export 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())), |
| @@ -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 HTTP | 85 | // Browser elicitation mode (the base owns the approval store + the HTTP |
| 84 | // approval RPCs): a gated execution pauses and returns an approvalUrl into | 86 | // approval RPCs): a gated execution pauses and returns an approvalUrl into |
| @@ -4,6 +4,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; · 3 unmodified lines | |||
| 4 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 4 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 5 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 5 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 6 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; | 6 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| 7 | import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | ||
| 7 | 8 | ||
| 8 | // --------------------------------------------------------------------------- | 9 | // --------------------------------------------------------------------------- |
| 9 | // The Cloudflare host's plugin list — the same protocol/provider plugins as | 10 | // 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 | ||
| 19 | export const makeCloudflarePlugins = (secretKey: string) => | 20 | export 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 | ||
| @@ -11,8 +11,10 @@ · 10 unmodified lines | |||
| 11 | import { Route as rootRouteImport } from './routes/__root' | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' | 12 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' |
| 13 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' | 13 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' |
| 14 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' | ||
| 14 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' | 15 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' |
| 15 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' | 16 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' |
| 17 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' | ||
| 16 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' | 18 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' |
| 17 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' | 19 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 18 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' | 20 | import { 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) |
| 35 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute = | ||
| 36 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport.update({ | ||
| 37 | id: '/{-$orgSlug}/toolkits', | ||
| 38 | path: '/{-$orgSlug}/toolkits', | ||
| 39 | getParentRoute: () => rootRouteImport, | ||
| 40 | } as any) | ||
| 33 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute = | 41 | const 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) |
| 53 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute = | ||
| 54 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update( | ||
| 55 | { | ||
| 56 | id: '/$toolkitSlug', | ||
| 57 | path: '/$toolkitSlug', | ||
| 58 | getParentRoute: () => | ||
| 59 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute, | ||
| 60 | } as any, | ||
| 61 | ) | ||
| 45 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = | 62 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = |
| 46 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update( | 63 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update( |
| 47 | { | 64 | { |
| @@ -78,20 +95,24 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK · 30 unmodified lines | |||
| 78 | export interface FileRoutesByFullPath { | 95 | export interface FileRoutesByFullPath { |
| 79 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute | 96 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 80 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute | 97 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 98 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | ||
| 81 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 99 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 82 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 100 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 83 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 101 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 84 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute | 102 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 103 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | ||
| 85 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 104 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 86 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute | 105 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| 87 | } | 106 | } |
| 88 | export interface FileRoutesByTo { | 107 | export interface FileRoutesByTo { |
| 89 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute | 108 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 90 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute | 109 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 110 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | ||
| 91 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 111 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 92 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 112 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 93 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 113 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 94 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute | 114 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 115 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | ||
| 95 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 116 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 96 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute | 117 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| 97 | } | 118 | } |
| @@ -99,10 +120,12 @@ export interface FileRoutesById { · 1 unmodified line | |||
| 99 | __root__: typeof rootRouteImport | 120 | __root__: typeof rootRouteImport |
| 100 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute | 121 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 101 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute | 122 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 123 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | ||
| 102 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 124 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 103 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 125 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 104 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 126 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 105 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute | 127 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 128 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | ||
| 106 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 129 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 107 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute | 130 | '/{-$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: FileRoutesByTo | 145 | 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: FileRoutesById | 169 | fileRoutesById: FileRoutesById |
| @@ -142,6 +171,7 @@ export interface FileRouteTypes { · 1 unmodified line | |||
| 142 | export interface RootRouteChildren { | 171 | export interface RootRouteChildren { |
| 143 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute | 172 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 144 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute | 173 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 174 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | ||
| 145 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 175 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 146 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 176 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 147 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 177 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| @@ -166,6 +196,13 @@ declare module '@tanstack/react-router' { · 18 unmodified lines | |||
| 166 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport | 196 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport |
| 167 | parentRoute: typeof rootRouteImport | 197 | 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 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport | 217 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport |
| 181 | parentRoute: typeof rootRouteImport | 218 | 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 | ||
| 258 | interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { | ||
| 259 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | ||
| 260 | } | ||
| 261 | |||
| 262 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren = | ||
| 263 | { | ||
| 264 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: | ||
| 265 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute, | ||
| 266 | } | ||
| 267 | |||
| 268 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = | ||
| 269 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute._addFileChildren( | ||
| 270 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren, | ||
| 271 | ) | ||
| 272 | |||
| 214 | const rootRouteChildren: RootRouteChildren = { | 273 | const 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: |
| @@ -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 run | 18 | // auto-provisions it on first deploy; replace database_id after that, or run |
| @@ -5,6 +5,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; · 4 unmodified lines | |||
| 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; | 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| 8 | import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | ||
| 8 | 9 | ||
| 9 | import { resolveSecretKey } from "./src/config"; | 10 | import { resolveSecretKey } from "./src/config"; |
| 10 | 11 | ||
| @@ -19,13 +20,14 @@ import { resolveSecretKey } from "./src/config"; · 8 unmodified lines | |||
| 19 | // --------------------------------------------------------------------------- | 20 | // --------------------------------------------------------------------------- |
| 20 | 21 | ||
| 21 | export default defineExecutorConfig({ | 22 | export 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, |
| @@ -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:*", |
| @@ -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/* wildcard | 107 | // CLI device-login discovery, must precede the /api/auth/* wildcard |
| 108 | // below (Better Auth would otherwise 404 it). The verification page it | 108 | // 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)), |
| @@ -40,7 +40,11 @@ export { makeExecutionStack } from "@executor-js/api/server"; · 39 unmodified lines | |||
| 40 | 40 | ||
| 41 | export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)( | 41 | export 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 | ||
| @@ -46,7 +46,9 @@ import { BetterAuth } from "../auth/better-auth"; · 45 unmodified lines | |||
| 46 | // --------------------------------------------------------------------------- | 46 | // --------------------------------------------------------------------------- |
| 47 | 47 | ||
| 48 | const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; | 48 | const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; |
| 49 | const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/:toolkitSlug`; | ||
| 49 | const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; | 50 | const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; |
| 51 | const TOOLKIT_MCP_SEGMENT = "/mcp/toolkits/"; | ||
| 50 | 52 | ||
| 51 | const parseRoles = (role: string | null | undefined): ReadonlyArray<string> => | 53 | const 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 | |||
| 66 | const hasBearer = (request: Request): boolean => | 68 | const hasBearer = (request: Request): boolean => |
| 67 | (request.headers.get("authorization") ?? "").startsWith("Bearer "); | 69 | (request.headers.get("authorization") ?? "").startsWith("Bearer "); |
| 68 | 70 | ||
| 71 | const toolkitSlugFromRequest = (request: Request): string | null => { | ||
| 72 | const pathname = new URL(request.url).pathname; | ||
| 73 | const index = pathname.indexOf(TOOLKIT_MCP_SEGMENT); | ||
| 74 | if (index < 0) return null; | ||
| 75 | const slug = pathname.slice(index + TOOLKIT_MCP_SEGMENT.length).split("/", 1)[0]; | ||
| 76 | return slug && slug.length > 0 ? slug : null; | ||
| 77 | }; | ||
| 78 | |||
| 79 | const mcpResourcePathFor = (request: Request): string => { | ||
| 80 | const toolkitSlug = toolkitSlugFromRequest(request); | ||
| 81 | return toolkitSlug ? `/mcp/toolkits/${toolkitSlug}` : "/mcp"; | ||
| 82 | }; | ||
| 83 | |||
| 69 | /** | 84 | /** |
| 70 | * Absolute protected-resource metadata URL for the 401 challenge. Derive the | 85 | * 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 is | 86 | * 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 | */ |
| 74 | const resourceMetadataUrlFor = (baseURL: string | undefined, request: Request): string => { | 89 | const 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 | |||
| 97 | const resourceUrlFor = (baseURL: string | undefined, request: Request): string => { | ||
| 98 | const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin; | ||
| 99 | return `${origin}${mcpResourcePathFor(request)}`; | ||
| 100 | }; | ||
| 101 | |||
| 102 | const toolkitProtectedResourceMetadata = ( | ||
| 103 | request: Request, | ||
| 104 | response: Response, | ||
| 105 | baseURL: string | undefined, | ||
| 106 | ): Effect.Effect<Response> => { | ||
| 107 | const toolkitSlug = toolkitSlugFromRequest(request); | ||
| 108 | if (!toolkitSlug) return Effect.succeed(response); | ||
| 109 | return Effect.promise(async () => { | ||
| 110 | const body = (await response.json()) as Record<string, unknown>; | ||
| 111 | const headers = new Headers(response.headers); | ||
| 112 | headers.set("content-type", "application/json"); | ||
| 113 | return new Response(JSON.stringify({ ...body, resource: resourceUrlFor(baseURL, request) }), { | ||
| 114 | status: response.status, | ||
| 115 | statusText: response.statusText, | ||
| 116 | headers, | ||
| 117 | }); | ||
| 118 | }); | ||
| 77 | }; | 119 | }; |
| 78 | 120 | ||
| 79 | export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | IdentityProvider> = | 121 | export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | IdentityProvider> = |
| @@ -99,7 +141,21 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I · 19 unmodified lines | |||
| 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, |
| @@ -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 | ||
| 39 | test("serves OAuth Protected Resource metadata for a toolkit MCP resource", async () => { | ||
| 40 | const res = await handler( | ||
| 41 | new Request(`${BASE}/.well-known/oauth-protected-resource/mcp/toolkits/deploy`), | ||
| 42 | ); | ||
| 43 | expect(res.status).toBe(200); | ||
| 44 | const body = (await res.json()) as Record<string, unknown>; | ||
| 45 | expect(body.resource).toBe(`${BASE}/mcp/toolkits/deploy`); | ||
| 46 | expect(Array.isArray(body.authorization_servers)).toBe(true); | ||
| 47 | }); | ||
| 48 | |||
| 39 | test("an unauthenticated /mcp request returns 401 with a WWW-Authenticate challenge", async () => { | 49 | test("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 | ||
| 66 | test("an unauthenticated toolkit MCP request challenges with toolkit metadata", async () => { | ||
| 67 | const res = await handler( | ||
| 68 | new Request(`${BASE}/mcp/toolkits/deploy`, { | ||
| 69 | method: "POST", | ||
| 70 | headers: { | ||
| 71 | "content-type": "application/json", | ||
| 72 | accept: "application/json, text/event-stream", | ||
| 73 | }, | ||
| 74 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), | ||
| 75 | }), | ||
| 76 | ); | ||
| 77 | expect(res.status).toBe(401); | ||
| 78 | const challenge = res.headers.get("www-authenticate") ?? ""; | ||
| 79 | expect(challenge).toContain("Bearer"); | ||
| 80 | expect(challenge).toContain( | ||
| 81 | `resource_metadata="${BASE}/.well-known/oauth-protected-resource/mcp/toolkits/deploy"`, | ||
| 82 | ); | ||
| 83 | }); | ||
| 84 | |||
| 56 | // --- End-to-end MCP OAuth: DCR -> authorize -> token -> /mcp with bearer --- | 85 | // --- End-to-end MCP OAuth: DCR -> authorize -> token -> /mcp with bearer --- |
| 57 | const json = async (res: Response) => (await res.json()) as Record<string, unknown>; | 86 | const json = async (res: Response) => (await res.json()) as Record<string, unknown>; |
| 58 | 87 | ||
| @@ -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 | ||
| @@ -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 like | 20 | * 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 -> /mcp | 23 | * /<seg>/mcp -> /mcp |
| 24 | * /.well-known/oauth-protected-resource/<seg>/mcp -> /.well-known/oauth-protected-resource/mcp | 24 | * /<seg>/mcp/toolkits/<toolkit> -> /mcp/toolkits/<toolkit> |
| 25 | * /.well-known/oauth-protected-resource/<seg>/mcp -> /.well-known/oauth-protected-resource | ||
| 26 | * /.well-known/oauth-protected-resource/<seg>/mcp/toolkits/<toolkit> | ||
| 27 | * -> /.well-known/oauth-protected-resource/mcp/toolkits/<toolkit> | ||
| 25 | */ | 28 | */ |
| 26 | export const stripMcpOrgSegment = (pathname: string): string | null => { | 29 | export 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 | }; |
| @@ -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 the | 68 | // 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" || |
File renamed without changes.
File renamed without changes.
| @@ -11,11 +11,13 @@ · 10 unmodified lines | |||
| 11 | import { Route as rootRouteImport } from './routes/__root' | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' | 12 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' |
| 13 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' | 13 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' |
| 14 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' | ||
| 14 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' | 15 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' |
| 15 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' | 16 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' |
| 16 | import { Route as ApiKeysRouteImport } from './routes/app/api-keys' | 17 | import { Route as ApiKeysRouteImport } from './routes/app/api-keys' |
| 17 | import { Route as AdminRouteImport } from './routes/app/admin' | 18 | import { Route as AdminRouteImport } from './routes/app/admin' |
| 18 | import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' | 19 | import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' |
| 20 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' | ||
| 19 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' | 21 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' |
| 20 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' | 22 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 21 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' | 23 | import { 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) |
| 38 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute = | ||
| 39 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport.update({ | ||
| 40 | id: '/{-$orgSlug}/toolkits', | ||
| 41 | path: '/{-$orgSlug}/toolkits', | ||
| 42 | getParentRoute: () => rootRouteImport, | ||
| 43 | } as any) | ||
| 36 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute = | 44 | const 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) |
| 71 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute = | ||
| 72 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update( | ||
| 73 | { | ||
| 74 | id: '/$toolkitSlug', | ||
| 75 | path: '/$toolkitSlug', | ||
| 76 | getParentRoute: () => | ||
| 77 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute, | ||
| 78 | } as any, | ||
| 79 | ) | ||
| 63 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = | 80 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = |
| 64 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update( | 81 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update( |
| 65 | { | 82 | { |
| @@ -99,10 +116,12 @@ export interface FileRoutesByFullPath { · 33 unmodified lines | |||
| 99 | '/{-$orgSlug}/api-keys': typeof ApiKeysRoute | 116 | '/{-$orgSlug}/api-keys': typeof ApiKeysRoute |
| 100 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute | 117 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 101 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute | 118 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 119 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | ||
| 102 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 120 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 103 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 121 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 104 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 122 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 105 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute | 123 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 124 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | ||
| 106 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 125 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 107 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute | 126 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| 108 | } | 127 | } |
| @@ -112,10 +131,12 @@ export interface FileRoutesByTo { · 3 unmodified lines | |||
| 112 | '/{-$orgSlug}/api-keys': typeof ApiKeysRoute | 131 | '/{-$orgSlug}/api-keys': typeof ApiKeysRoute |
| 113 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute | 132 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 114 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute | 133 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 134 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | ||
| 115 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 135 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 116 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 136 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 117 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 137 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 118 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute | 138 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 139 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | ||
| 119 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 140 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 120 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute | 141 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| 121 | } | 142 | } |
| @@ -126,10 +147,12 @@ export interface FileRoutesById { · 4 unmodified lines | |||
| 126 | '/{-$orgSlug}/api-keys': typeof ApiKeysRoute | 147 | '/{-$orgSlug}/api-keys': typeof ApiKeysRoute |
| 127 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute | 148 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 128 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute | 149 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 150 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | ||
| 129 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 151 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 130 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 152 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 131 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 153 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 132 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute | 154 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 155 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | ||
| 133 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 156 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 134 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute | 157 | '/{-$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: FileRoutesByTo | 175 | 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: FileRoutesById | 205 | fileRoutesById: FileRoutesById |
| @@ -181,6 +210,7 @@ export interface RootRouteChildren { · 4 unmodified lines | |||
| 181 | ApiKeysRoute: typeof ApiKeysRoute | 210 | ApiKeysRoute: typeof ApiKeysRoute |
| 182 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute | 211 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 183 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute | 212 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 213 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | ||
| 184 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 214 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 185 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 215 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 186 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 216 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| @@ -205,6 +235,13 @@ declare module '@tanstack/react-router' { · 18 unmodified lines | |||
| 205 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport | 235 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport |
| 206 | parentRoute: typeof rootRouteImport | 236 | 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 JoinDotcodeRouteImport | 277 | preLoaderRoute: typeof JoinDotcodeRouteImport |
| 241 | parentRoute: typeof rootRouteImport | 278 | 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 | ||
| 318 | interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { | ||
| 319 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | ||
| 320 | } | ||
| 321 | |||
| 322 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren = | ||
| 323 | { | ||
| 324 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: | ||
| 325 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute, | ||
| 326 | } | ||
| 327 | |||
| 328 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = | ||
| 329 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute._addFileChildren( | ||
| 330 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren, | ||
| 331 | ) | ||
| 332 | |||
| 274 | const rootRouteChildren: RootRouteChildren = { | 333 | const 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: |
| @@ -11,8 +11,8 @@ import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shel · 10 unmodified lines | |||
| 11 | import { plugins as clientPlugins } from "virtual:executor/plugins-client"; | 11 | import { plugins as clientPlugins } from "virtual:executor/plugins-client"; |
| 12 | 12 | ||
| 13 | import { authClient } from "../auth-client"; | 13 | import { authClient } from "../auth-client"; |
| 14 | import { DevicePage } from "../device"; | 14 | import { DevicePage } from "../chromeless/device-page"; |
| 15 | import { McpConsentPage } from "../mcp-consent"; | 15 | import { McpConsentPage } from "../chromeless/mcp-consent-page"; |
| 16 | import { LoginPage } from "../login"; | 16 | import { LoginPage } from "../login"; |
| 17 | import { SetupPage } from "../setup"; | 17 | import { SetupPage } from "../setup"; |
| 18 | import { fetchNeedsSetup } from "../setup-status"; | 18 | import { fetchNeedsSetup } from "../setup-status"; |
| @@ -8,6 +8,7 @@ import { keychainPlugin } from "@executor-js/plugin-keychain"; · 7 unmodified lines | |||
| 8 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; | 8 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; |
| 9 | import { onepasswordHttpPlugin } from "@executor-js/plugin-onepassword/api"; | 9 | import { onepasswordHttpPlugin } from "@executor-js/plugin-onepassword/api"; |
| 10 | import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/server"; | 10 | import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/server"; |
| 11 | import { 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 | ||
| 22 | interface LocalPluginDeps { | ||
| 23 | readonly activeToolkitSlug?: string; | ||
| 24 | } | ||
| 25 | |||
| 21 | export default defineExecutorConfig({ | 26 | export 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(), |
| @@ -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:*", |
| @@ -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. |
| 58 | type LocalPlugins = readonly AnyPlugin[]; | 58 | type LocalPlugins = readonly AnyPlugin[]; |
| 59 | 59 | ||
| 60 | const loadLocalPlugins = Effect.gen(function* () { | 60 | export 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 { | 64 | const 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 | ||
| 88 | interface LocalExecutorBundle { | 95 | interface 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 | ||
| 137 | const createLocalExecutorLayer = () => { | 144 | const 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 | ||
| 226 | export const createExecutorHandle = async () => { | 237 | export 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 | ||
| @@ -3,7 +3,7 @@ import { Context, Effect, Layer, ManagedRuntime } from "effect"; · 2 unmodified lines | |||
| 3 | import { createExecutionEngine } from "@executor-js/execution"; | 3 | import { createExecutionEngine } from "@executor-js/execution"; |
| 4 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; | 4 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 5 | import { makeLocalApiHandler } from "./app"; | 5 | import { makeLocalApiHandler } from "./app"; |
| 6 | import { getExecutorBundle } from "./executor"; | 6 | import { createExecutorHandle, getExecutorBundle } from "./executor"; |
| 7 | import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; | 7 | import { 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 | }; |
| @@ -1,9 +1,14 @@ | |||
| 1 | import { Effect } from "effect"; | 1 | import { Effect, type Cause } from "effect"; |
| 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; |
| 4 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | 4 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; |
| 5 | 5 | ||
| 6 | import { jsonRpcErrorBody } from "@executor-js/host-mcp"; | 6 | import { |
| 7 | defaultMcpResource, | ||
| 8 | jsonRpcErrorBody, | ||
| 9 | mcpResourceKey, | ||
| 10 | type McpResource, | ||
| 11 | } from "@executor-js/host-mcp"; | ||
| 7 | import { | 12 | import { |
| 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"; |
| 17 | import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store"; | 22 | import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store"; |
| 18 | import { formatPausedExecution, type ResumeResponse } from "@executor-js/execution"; | 23 | import { |
| 24 | formatPausedExecution, | ||
| 25 | type ExecutionEngine, | ||
| 26 | type ResumeResponse, | ||
| 27 | } from "@executor-js/execution"; | ||
| 19 | 28 | ||
| 20 | import { startIntegrationsRefresh } from "./integrations"; | 29 | import { startIntegrationsRefresh } from "./integrations"; |
| 21 | 30 | ||
| 31 | type AnyExecutionEngine = ExecutionEngine<Cause.YieldableError>; | ||
| 32 | |||
| 22 | // --------------------------------------------------------------------------- | 33 | // --------------------------------------------------------------------------- |
| 23 | // Streamable HTTP handler | 34 | // 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 | ||
| 46 | export interface LocalMcpServerConfig { | ||
| 47 | readonly config: ExecutorMcpServerConfig; | ||
| 48 | readonly close?: () => Promise<void>; | ||
| 49 | } | ||
| 50 | |||
| 51 | export interface LocalMcpRequestHandlerConfig { | ||
| 52 | readonly defaultConfig: ExecutorMcpServerConfig; | ||
| 53 | readonly createConfigForResource?: ( | ||
| 54 | resource: McpResource, | ||
| 55 | ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig; | ||
| 56 | } | ||
| 57 | |||
| 35 | // Local serves these error bodies in-process; like the self-host store they are | 58 | // 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 copy | 59 | // 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 | ||
| 82 | export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpRequestHandler => { | 105 | const toolkitPathPattern = /^\/mcp\/toolkits\/([^/?#]+)\/?$/; |
| 106 | |||
| 107 | const resourceFromRequest = (request: Request): McpResource | null => { | ||
| 108 | const pathname = new URL(request.url).pathname; | ||
| 109 | if (pathname === "/mcp" || pathname === "/mcp/") return defaultMcpResource; | ||
| 110 | const match = toolkitPathPattern.exec(pathname); | ||
| 111 | if (!match) return null; | ||
| 112 | return { kind: "toolkit", slug: decodeURIComponent(match[1]) }; | ||
| 113 | }; | ||
| 114 | |||
| 115 | const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null => | ||
| 116 | "engine" in config ? config.engine : null; | ||
| 117 | |||
| 118 | const normalizeHandlerConfig = ( | ||
| 119 | input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig, | ||
| 120 | ): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input }); | ||
| 121 | |||
| 122 | export const createMcpRequestHandler = ( | ||
| 123 | input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig, | ||
| 124 | ): McpRequestHandler => { | ||
| 125 | const handlerConfig = normalizeHandlerConfig(input); | ||
| 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 and | 132 | 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 | engine | 138 | (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 APIs | 205 | // 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); |
| @@ -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 | ||
| 111 | async function allocatePort(): Promise<number> { | 115 | async function allocatePort(): Promise<number> { |
| 112 | const probe = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response() }); | 116 | const probe = Bun.serve({ |
| 117 | port: 0, | ||
| 118 | hostname: "127.0.0.1", | ||
| 119 | fetch: () => new Response(), | ||
| 120 | }); | ||
| 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 under | 370 | // 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 | ||
| @@ -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.json | 16 | // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json |
| 17 | const rootPackage = JSON.parse( | 17 | const 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.json | 25 | // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json |
| 22 | const cliPackage = JSON.parse( | 26 | const 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`/localStorage | 111 | // callback. The SPA carries the token from its `?_token`/localStorage |
| 107 | // bootstrap, so the UI is unaffected; external MCP clients use the | 112 | // 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" |
| @@ -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"], |
| @@ -0,0 +1,320 @@ | |||
| 1 | import { randomBytes } from "node:crypto"; | ||
| 2 | import { createServer, type Server } from "node:http"; | ||
| 3 | import type { AddressInfo } from "node:net"; | ||
| 4 | |||
| 5 | import { expect } from "@effect/vitest"; | ||
| 6 | import { Effect } from "effect"; | ||
| 7 | import { HttpApiClient } from "effect/unstable/httpapi"; | ||
| 8 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | ||
| 9 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | ||
| 10 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | ||
| 11 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 12 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| 13 | import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | ||
| 14 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 15 | |||
| 16 | import { scenario } from "../src/scenario"; | ||
| 17 | import { Cli, RunDir } from "../src/services"; | ||
| 18 | import { withLocalServer } from "./local-server"; | ||
| 19 | |||
| 20 | const api = composePluginApi([openApiHttpPlugin(), toolkitsPlugin()] as const); | ||
| 21 | |||
| 22 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; | ||
| 23 | |||
| 24 | const pingSpec = (baseUrl: string): string => | ||
| 25 | JSON.stringify({ | ||
| 26 | openapi: "3.0.3", | ||
| 27 | info: { title: "Local Toolkit Ping API", version: "1.0.0" }, | ||
| 28 | servers: [{ url: baseUrl }], | ||
| 29 | paths: { | ||
| 30 | "/ping/{id}": { | ||
| 31 | get: { | ||
| 32 | operationId: "getPing", | ||
| 33 | summary: "Return a ping payload", | ||
| 34 | security: [{ apiKey: [] }], | ||
| 35 | parameters: [ | ||
| 36 | { | ||
| 37 | name: "id", | ||
| 38 | in: "path", | ||
| 39 | required: true, | ||
| 40 | schema: { type: "string" }, | ||
| 41 | }, | ||
| 42 | ], | ||
| 43 | responses: { | ||
| 44 | "200": { | ||
| 45 | description: "A ping payload", | ||
| 46 | content: { | ||
| 47 | "application/json": { | ||
| 48 | schema: { | ||
| 49 | type: "object", | ||
| 50 | properties: { | ||
| 51 | id: { type: "string" }, | ||
| 52 | path: { type: "string" }, | ||
| 53 | }, | ||
| 54 | }, | ||
| 55 | }, | ||
| 56 | }, | ||
| 57 | }, | ||
| 58 | }, | ||
| 59 | }, | ||
| 60 | }, | ||
| 61 | }, | ||
| 62 | components: { | ||
| 63 | securitySchemes: { | ||
| 64 | apiKey: { type: "apiKey", in: "header", name: "x-e2e-token" }, | ||
| 65 | }, | ||
| 66 | }, | ||
| 67 | }); | ||
| 68 | |||
| 69 | const closeServer = (server: Server): Promise<void> => | ||
| 70 | new Promise((resolve) => { | ||
| 71 | server.close(() => resolve()); | ||
| 72 | }); | ||
| 73 | |||
| 74 | const servePingApi = Effect.acquireRelease( | ||
| 75 | Effect.promise( | ||
| 76 | () => | ||
| 77 | new Promise<{ readonly url: string; readonly server: Server }>((resolve) => { | ||
| 78 | const server = createServer((request, response) => { | ||
| 79 | const url = new URL(request.url ?? "/", "http://127.0.0.1"); | ||
| 80 | if (request.method === "GET" && url.pathname.startsWith("/ping/")) { | ||
| 81 | response.writeHead(200, { "content-type": "application/json" }); | ||
| 82 | response.end( | ||
| 83 | JSON.stringify({ | ||
| 84 | id: decodeURIComponent(url.pathname.slice("/ping/".length)), | ||
| 85 | path: url.pathname, | ||
| 86 | }), | ||
| 87 | ); | ||
| 88 | return; | ||
| 89 | } | ||
| 90 | response.writeHead(404, { "content-type": "application/json" }); | ||
| 91 | response.end(JSON.stringify({ error: "not_found" })); | ||
| 92 | }); | ||
| 93 | server.listen(0, "127.0.0.1", () => { | ||
| 94 | const address = server.address() as AddressInfo; | ||
| 95 | resolve({ url: `http://127.0.0.1:${address.port}`, server }); | ||
| 96 | }); | ||
| 97 | }), | ||
| 98 | ), | ||
| 99 | ({ server }) => Effect.promise(() => closeServer(server)).pipe(Effect.ignore), | ||
| 100 | ); | ||
| 101 | |||
| 102 | const connectionPattern = (integration: string, name: string): string => | ||
| 103 | `${integration}.org.${name}.*`; | ||
| 104 | |||
| 105 | const pingToolPath = (integration: string, name: string): string => | ||
| 106 | `${integration}.org.${name}.ping.getPing`; | ||
| 107 | |||
| 108 | const callPingCode = (input: { | ||
| 109 | readonly integration: string; | ||
| 110 | readonly connection: string; | ||
| 111 | readonly id: string; | ||
| 112 | }) => ` | ||
| 113 | const listed = await tools.search({ namespace: ${JSON.stringify(input.integration)}, query: "ping", limit: 100 }); | ||
| 114 | const expected = ${JSON.stringify(`${input.integration}.org.${input.connection}.`)}; | ||
| 115 | const path = listed.items.map((item) => item.path).find((candidate) => candidate.startsWith(expected)); | ||
| 116 | if (!path) return { ok: false, reason: "missing", expected, paths: listed.items.map((item) => item.path).sort() }; | ||
| 117 | let tool = tools; | ||
| 118 | for (const segment of path.split(".")) tool = tool?.[segment]; | ||
| 119 | if (typeof tool !== "function") return { ok: false, reason: "not-callable", path }; | ||
| 120 | const result = await tool({ id: ${JSON.stringify(input.id)} }); | ||
| 121 | return JSON.stringify({ ok: result.ok, path, data: result.ok ? result.data : result.error }); | ||
| 122 | `; | ||
| 123 | |||
| 124 | const listVisiblePathsCode = (integration: string) => ` | ||
| 125 | const listed = await tools.search({ namespace: ${JSON.stringify(integration)}, query: "ping", limit: 100 }); | ||
| 126 | return JSON.stringify({ paths: listed.items.map((item) => item.path).sort() }); | ||
| 127 | `; | ||
| 128 | |||
| 129 | const makeMcp = async (url: string, token: string, name: string) => { | ||
| 130 | const client = new Client({ name, version: "1.0.0" }, { capabilities: {} }); | ||
| 131 | const transport = new StreamableHTTPClientTransport(new URL(url), { | ||
| 132 | requestInit: { headers: { authorization: `Bearer ${token}` } }, | ||
| 133 | }); | ||
| 134 | await client.connect(transport); | ||
| 135 | return { client, transport }; | ||
| 136 | }; | ||
| 137 | |||
| 138 | const textFromCall = (result: Awaited<ReturnType<Client["callTool"]>>): string => { | ||
| 139 | const blocks = result.content ?? []; | ||
| 140 | const text = blocks.find((block) => block.type === "text")?.text; | ||
| 141 | if (typeof text !== "string") throw new Error(`MCP call returned no text block`); | ||
| 142 | return text; | ||
| 143 | }; | ||
| 144 | |||
| 145 | const executeJson = async (client: Client, code: string): Promise<Record<string, unknown>> => { | ||
| 146 | const result = await client.callTool({ | ||
| 147 | name: "execute", | ||
| 148 | arguments: { code }, | ||
| 149 | }); | ||
| 150 | return JSON.parse(textFromCall(result)) as Record<string, unknown>; | ||
| 151 | }; | ||
| 152 | |||
| 153 | scenario( | ||
| 154 | "Local toolkits · scoped MCP hides blocked and unselected connections", | ||
| 155 | { timeout: 240_000 }, | ||
| 156 | Effect.scoped( | ||
| 157 | Effect.gen(function* () { | ||
| 158 | const cli = yield* Cli; | ||
| 159 | const runDir = yield* RunDir; | ||
| 160 | const upstream = yield* servePingApi; | ||
| 161 | |||
| 162 | yield* withLocalServer(cli, runDir, (server) => | ||
| 163 | Effect.gen(function* () { | ||
| 164 | const client = yield* HttpApiClient.make(api, { | ||
| 165 | baseUrl: new URL("/api", server.origin).toString(), | ||
| 166 | transformClient: HttpClient.mapRequest((request) => | ||
| 167 | HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`), | ||
| 168 | ), | ||
| 169 | }).pipe(Effect.provide(FetchHttpClient.layer)); | ||
| 170 | |||
| 171 | const integration = unique("local_toolkit_ping"); | ||
| 172 | const selected = "selected"; | ||
| 173 | const blocked = "blocked"; | ||
| 174 | const unselected = "unselected"; | ||
| 175 | |||
| 176 | yield* client.openapi.addSpec({ | ||
| 177 | payload: { | ||
| 178 | spec: { kind: "blob", value: pingSpec(upstream.url) }, | ||
| 179 | slug: IntegrationSlug.make(integration), | ||
| 180 | baseUrl: upstream.url, | ||
| 181 | authenticationTemplate: [ | ||
| 182 | { | ||
| 183 | slug: "apiKey", | ||
| 184 | type: "apiKey", | ||
| 185 | headers: { | ||
| 186 | "x-e2e-token": [{ type: "variable", name: "token" }], | ||
| 187 | }, | ||
| 188 | }, | ||
| 189 | ], | ||
| 190 | }, | ||
| 191 | }); | ||
| 192 | |||
| 193 | for (const name of [selected, blocked, unselected]) { | ||
| 194 | yield* client.connections.create({ | ||
| 195 | payload: { | ||
| 196 | owner: "org", | ||
| 197 | name: ConnectionName.make(name), | ||
| 198 | integration: IntegrationSlug.make(integration), | ||
| 199 | template: AuthTemplateSlug.make("apiKey"), | ||
| 200 | value: "unused-token", | ||
| 201 | }, | ||
| 202 | }); | ||
| 203 | } | ||
| 204 | |||
| 205 | const toolkit = yield* client.toolkits.create({ | ||
| 206 | payload: { owner: "org", name: unique("local-kit") }, | ||
| 207 | }); | ||
| 208 | yield* client.toolkits.createConnection({ | ||
| 209 | params: { toolkitId: toolkit.id }, | ||
| 210 | payload: { pattern: connectionPattern(integration, selected) }, | ||
| 211 | }); | ||
| 212 | yield* client.toolkits.createConnection({ | ||
| 213 | params: { toolkitId: toolkit.id }, | ||
| 214 | payload: { pattern: connectionPattern(integration, blocked) }, | ||
| 215 | }); | ||
| 216 | yield* client.toolkits.createPolicy({ | ||
| 217 | params: { toolkitId: toolkit.id }, | ||
| 218 | payload: { | ||
| 219 | pattern: connectionPattern(integration, selected), | ||
| 220 | action: "approve", | ||
| 221 | }, | ||
| 222 | }); | ||
| 223 | yield* client.toolkits.createPolicy({ | ||
| 224 | params: { toolkitId: toolkit.id }, | ||
| 225 | payload: { | ||
| 226 | pattern: connectionPattern(integration, blocked), | ||
| 227 | action: "block", | ||
| 228 | }, | ||
| 229 | }); | ||
| 230 | |||
| 231 | const toolkitMcp = yield* Effect.promise(() => | ||
| 232 | makeMcp( | ||
| 233 | new URL(`/mcp/toolkits/${toolkit.slug}`, server.origin).toString(), | ||
| 234 | server.token, | ||
| 235 | "local-toolkit-e2e", | ||
| 236 | ), | ||
| 237 | ); | ||
| 238 | yield* Effect.addFinalizer(() => | ||
| 239 | Effect.promise(() => toolkitMcp.client.close()).pipe(Effect.ignore), | ||
| 240 | ); | ||
| 241 | |||
| 242 | const selectedCall = yield* Effect.promise(() => | ||
| 243 | executeJson( | ||
| 244 | toolkitMcp.client, | ||
| 245 | callPingCode({ | ||
| 246 | integration, | ||
| 247 | connection: selected, | ||
| 248 | id: "from-toolkit", | ||
| 249 | }), | ||
| 250 | ), | ||
| 251 | ); | ||
| 252 | expect(selectedCall.ok, `selected call: ${JSON.stringify(selectedCall)}`).toBe(true); | ||
| 253 | expect((selectedCall.data as { id?: unknown }).id).toBe("from-toolkit"); | ||
| 254 | |||
| 255 | const visible = yield* Effect.promise(() => | ||
| 256 | executeJson(toolkitMcp.client, listVisiblePathsCode(integration)), | ||
| 257 | ); | ||
| 258 | expect(visible.paths).toContain(pingToolPath(integration, selected)); | ||
| 259 | expect(visible.paths).not.toContain(pingToolPath(integration, blocked)); | ||
| 260 | expect(visible.paths).not.toContain(pingToolPath(integration, unselected)); | ||
| 261 | |||
| 262 | const blockedCall = yield* Effect.promise(() => | ||
| 263 | executeJson( | ||
| 264 | toolkitMcp.client, | ||
| 265 | callPingCode({ | ||
| 266 | integration, | ||
| 267 | connection: blocked, | ||
| 268 | id: "blocked-should-not-run", | ||
| 269 | }), | ||
| 270 | ), | ||
| 271 | ); | ||
| 272 | expect(blockedCall.reason, `blocked call: ${JSON.stringify(blockedCall)}`).toBe( | ||
| 273 | "missing", | ||
| 274 | ); | ||
| 275 | |||
| 276 | const unselectedCall = yield* Effect.promise(() => | ||
| 277 | executeJson( | ||
| 278 | toolkitMcp.client, | ||
| 279 | callPingCode({ | ||
| 280 | integration, | ||
| 281 | connection: unselected, | ||
| 282 | id: "should-not-run", | ||
| 283 | }), | ||
| 284 | ), | ||
| 285 | ); | ||
| 286 | expect(unselectedCall.reason, `unselected call: ${JSON.stringify(unselectedCall)}`).toBe( | ||
| 287 | "missing", | ||
| 288 | ); | ||
| 289 | |||
| 290 | const defaultMcp = yield* Effect.promise(() => | ||
| 291 | makeMcp(new URL("/mcp", server.origin).toString(), server.token, "local-default-e2e"), | ||
| 292 | ); | ||
| 293 | yield* Effect.addFinalizer(() => | ||
| 294 | Effect.promise(() => defaultMcp.client.close()).pipe(Effect.ignore), | ||
| 295 | ); | ||
| 296 | |||
| 297 | const leakedSession = defaultMcp.transport.sessionId; | ||
| 298 | expect(typeof leakedSession, "default MCP session has an id").toBe("string"); | ||
| 299 | const crossResource = yield* Effect.promise(() => | ||
| 300 | fetch(new URL(`/mcp/toolkits/${toolkit.slug}`, server.origin), { | ||
| 301 | method: "POST", | ||
| 302 | headers: { | ||
| 303 | authorization: `Bearer ${server.token}`, | ||
| 304 | "mcp-session-id": leakedSession ?? "", | ||
| 305 | "content-type": "application/json", | ||
| 306 | accept: "application/json, text/event-stream", | ||
| 307 | }, | ||
| 308 | body: JSON.stringify({ | ||
| 309 | jsonrpc: "2.0", | ||
| 310 | id: 1, | ||
| 311 | method: "tools/list", | ||
| 312 | }), | ||
| 313 | }), | ||
| 314 | ); | ||
| 315 | expect(crossResource.status, "default session id cannot cross into toolkit").toBe(403); | ||
| 316 | }), | ||
| 317 | ); | ||
| 318 | }), | ||
| 319 | ), | ||
| 320 | ); | ||
| @@ -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", |
| @@ -0,0 +1,585 @@ | |||
| 1 | import { randomBytes } from "node:crypto"; | ||
| 2 | import { createServer, type Server } from "node:http"; | ||
| 3 | import type { AddressInfo } from "node:net"; | ||
| 4 | |||
| 5 | import { expect } from "@effect/vitest"; | ||
| 6 | import { Effect } from "effect"; | ||
| 7 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 8 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| 9 | import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | ||
| 10 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 11 | |||
| 12 | import { scenario } from "../src/scenario"; | ||
| 13 | import { Api, Mcp, Target } from "../src/services"; | ||
| 14 | import type { McpSession } from "../src/surfaces/mcp"; | ||
| 15 | |||
| 16 | const api = composePluginApi([openApiHttpPlugin(), toolkitsPlugin()] as const); | ||
| 17 | |||
| 18 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; | ||
| 19 | |||
| 20 | const pingSpec = (baseUrl: string): string => | ||
| 21 | JSON.stringify({ | ||
| 22 | openapi: "3.0.3", | ||
| 23 | info: { title: "Toolkit Ping API", version: "1.0.0" }, | ||
| 24 | servers: [{ url: baseUrl }], | ||
| 25 | paths: { | ||
| 26 | "/ping/{id}": { | ||
| 27 | get: { | ||
| 28 | operationId: "getPing", | ||
| 29 | summary: "Return a ping payload", | ||
| 30 | security: [{ apiKey: [] }], | ||
| 31 | parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], | ||
| 32 | responses: { | ||
| 33 | "200": { | ||
| 34 | description: "A ping payload", | ||
| 35 | content: { | ||
| 36 | "application/json": { | ||
| 37 | schema: { | ||
| 38 | type: "object", | ||
| 39 | properties: { | ||
| 40 | id: { type: "string" }, | ||
| 41 | path: { type: "string" }, | ||
| 42 | }, | ||
| 43 | }, | ||
| 44 | }, | ||
| 45 | }, | ||
| 46 | }, | ||
| 47 | }, | ||
| 48 | }, | ||
| 49 | }, | ||
| 50 | }, | ||
| 51 | components: { | ||
| 52 | securitySchemes: { | ||
| 53 | apiKey: { type: "apiKey", in: "header", name: "x-e2e-token" }, | ||
| 54 | }, | ||
| 55 | }, | ||
| 56 | }); | ||
| 57 | |||
| 58 | const closeServer = (server: Server): Promise<void> => | ||
| 59 | new Promise((resolve) => { | ||
| 60 | server.close(() => resolve()); | ||
| 61 | }); | ||
| 62 | |||
| 63 | const servePingApi = Effect.acquireRelease( | ||
| 64 | Effect.promise( | ||
| 65 | () => | ||
| 66 | new Promise<{ readonly url: string; readonly server: Server }>((resolve) => { | ||
| 67 | const server = createServer((request, response) => { | ||
| 68 | const url = new URL(request.url ?? "/", "http://127.0.0.1"); | ||
| 69 | if (request.method === "GET" && url.pathname.startsWith("/ping/")) { | ||
| 70 | response.writeHead(200, { "content-type": "application/json" }); | ||
| 71 | response.end( | ||
| 72 | JSON.stringify({ | ||
| 73 | id: decodeURIComponent(url.pathname.slice("/ping/".length)), | ||
| 74 | path: url.pathname, | ||
| 75 | }), | ||
| 76 | ); | ||
| 77 | return; | ||
| 78 | } | ||
| 79 | response.writeHead(404, { "content-type": "application/json" }); | ||
| 80 | response.end(JSON.stringify({ error: "not_found" })); | ||
| 81 | }); | ||
| 82 | server.listen(0, "127.0.0.1", () => { | ||
| 83 | const address = server.address() as AddressInfo; | ||
| 84 | resolve({ url: `http://127.0.0.1:${address.port}`, server }); | ||
| 85 | }); | ||
| 86 | }), | ||
| 87 | ), | ||
| 88 | ({ server }) => Effect.promise(() => closeServer(server)).pipe(Effect.ignore), | ||
| 89 | ); | ||
| 90 | |||
| 91 | const toolkitUrl = (baseUrl: string, slug: string): string => | ||
| 92 | new URL(`/mcp/toolkits/${slug}`, baseUrl).toString(); | ||
| 93 | |||
| 94 | const connectionPattern = (integration: string, owner: "org" | "user", name: string): string => | ||
| 95 | `${integration}.${owner}.${name}.*`; | ||
| 96 | |||
| 97 | const executeJson = (session: McpSession, code: string) => | ||
| 98 | Effect.gen(function* () { | ||
| 99 | const result = yield* session.call("execute", { code }); | ||
| 100 | expect(result.ok, `execute completed (got: ${result.text.slice(0, 500)})`).toBe(true); | ||
| 101 | return JSON.parse(result.text) as Record<string, unknown>; | ||
| 102 | }); | ||
| 103 | |||
| 104 | const callPingCode = (input: { | ||
| 105 | readonly integration: string; | ||
| 106 | readonly owner: "org" | "user"; | ||
| 107 | readonly connection: string; | ||
| 108 | readonly id: string; | ||
| 109 | }) => ` | ||
| 110 | const listed = await tools.search({ namespace: ${JSON.stringify(input.integration)}, query: "ping", limit: 100 }); | ||
| 111 | const expected = ${JSON.stringify(`${input.integration}.${input.owner}.${input.connection}.`)}; | ||
| 112 | const path = listed.items.map((item) => item.path).find((candidate) => candidate.startsWith(expected)); | ||
| 113 | if (!path) return { ok: false, reason: "missing", expected, paths: listed.items.map((item) => item.path).sort() }; | ||
| 114 | let tool = tools; | ||
| 115 | for (const segment of path.split(".")) tool = tool?.[segment]; | ||
| 116 | if (typeof tool !== "function") return { ok: false, reason: "not-callable", path }; | ||
| 117 | const result = await tool({ id: ${JSON.stringify(input.id)} }); | ||
| 118 | return { ok: result.ok, path, data: result.ok ? result.data : result.error }; | ||
| 119 | `; | ||
| 120 | |||
| 121 | const visibleConnectionPathsCode = (integration: string) => ` | ||
| 122 | const listed = await tools.search({ namespace: ${JSON.stringify(integration)}, query: "ping", limit: 100 }); | ||
| 123 | return { paths: listed.items.map((item) => item.path).sort() }; | ||
| 124 | `; | ||
| 125 | |||
| 126 | const createPolicyCode = (input: { | ||
| 127 | readonly pattern: string; | ||
| 128 | readonly action: "approve" | "require_approval" | "block"; | ||
| 129 | }) => ` | ||
| 130 | const created = await tools.executor.coreTools.policies.create({ | ||
| 131 | owner: "user", | ||
| 132 | pattern: ${JSON.stringify(input.pattern)}, | ||
| 133 | action: ${JSON.stringify(input.action)}, | ||
| 134 | }); | ||
| 135 | return JSON.stringify({ ok: created.ok, data: created.ok ? created.data : null, error: created.ok ? null : created.error }); | ||
| 136 | `; | ||
| 137 | |||
| 138 | const assertCallOk = (value: Record<string, unknown>, label: string) => { | ||
| 139 | expect(value.ok, `${label}: ${JSON.stringify(value)}`).toBe(true); | ||
| 140 | }; | ||
| 141 | |||
| 142 | const assertCallMissing = (value: Record<string, unknown>, label: string) => { | ||
| 143 | expect(value.ok, `${label}: ${JSON.stringify(value)}`).toBe(false); | ||
| 144 | expect(value.reason, `${label} is missing from the toolkit catalog`).toBe("missing"); | ||
| 145 | }; | ||
| 146 | |||
| 147 | scenario( | ||
| 148 | "Toolkits · OAuth metadata and challenges stay scoped to the toolkit endpoint", | ||
| 149 | { timeout: 60_000 }, | ||
| 150 | Effect.gen(function* () { | ||
| 151 | const target = yield* Target; | ||
| 152 | const slug = unique("metadata-kit"); | ||
| 153 | const mcpUrl = new URL(`/mcp/toolkits/${slug}`, target.baseUrl); | ||
| 154 | const metadataUrl = new URL( | ||
| 155 | `/.well-known/oauth-protected-resource/mcp/toolkits/${slug}`, | ||
| 156 | target.baseUrl, | ||
| 157 | ); | ||
| 158 | |||
| 159 | const metadataResponse = yield* Effect.promise(() => fetch(metadataUrl)); | ||
| 160 | expect(metadataResponse.status, "toolkit protected-resource metadata is served").toBe(200); | ||
| 161 | const metadata = (yield* Effect.promise(() => metadataResponse.json())) as Record< | ||
| 162 | string, | ||
| 163 | unknown | ||
| 164 | >; | ||
| 165 | expect(metadata.resource, "metadata advertises the toolkit MCP resource").toBe( | ||
| 166 | mcpUrl.toString(), | ||
| 167 | ); | ||
| 168 | expect( | ||
| 169 | Array.isArray(metadata.authorization_servers), | ||
| 170 | "metadata still advertises authorization servers", | ||
| 171 | ).toBe(true); | ||
| 172 | |||
| 173 | if (target.name === "cloudflare") return; | ||
| 174 | |||
| 175 | const challenged = yield* Effect.promise(() => | ||
| 176 | fetch(mcpUrl, { | ||
| 177 | method: "POST", | ||
| 178 | headers: { | ||
| 179 | "content-type": "application/json", | ||
| 180 | accept: "application/json, text/event-stream", | ||
| 181 | }, | ||
| 182 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), | ||
| 183 | }), | ||
| 184 | ); | ||
| 185 | expect(challenged.status, "unauthenticated toolkit MCP requests are challenged").toBe(401); | ||
| 186 | expect( | ||
| 187 | challenged.headers.get("www-authenticate") ?? "", | ||
| 188 | "challenge points clients at toolkit metadata", | ||
| 189 | ).toContain(metadataUrl.toString()); | ||
| 190 | }), | ||
| 191 | ); | ||
| 192 | |||
| 193 | scenario( | ||
| 194 | "Toolkits · workspace and personal MCP endpoints expose the right connection sets", | ||
| 195 | { timeout: 240_000 }, | ||
| 196 | Effect.scoped( | ||
| 197 | Effect.gen(function* () { | ||
| 198 | const target = yield* Target; | ||
| 199 | const mcp = yield* Mcp; | ||
| 200 | const { client: makeClient } = yield* Api; | ||
| 201 | const upstream = yield* servePingApi; | ||
| 202 | const identity = yield* target.newIdentity(); | ||
| 203 | const client = yield* makeClient(api, identity); | ||
| 204 | |||
| 205 | const integration = unique("toolkit_ping"); | ||
| 206 | const workspaceToolkitName = unique("workspace-kit"); | ||
| 207 | const personalToolkitName = unique("personal-kit"); | ||
| 208 | const workspaceConnections = Array.from({ length: 30 }, (_, index) => `shared${index}`); | ||
| 209 | const personalConnection = "mine"; | ||
| 210 | |||
| 211 | yield* Effect.gen(function* () { | ||
| 212 | yield* client.openapi.addSpec({ | ||
| 213 | payload: { | ||
| 214 | spec: { kind: "blob", value: pingSpec(upstream.url) }, | ||
| 215 | slug: IntegrationSlug.make(integration), | ||
| 216 | baseUrl: upstream.url, | ||
| 217 | authenticationTemplate: [ | ||
| 218 | { | ||
| 219 | slug: "apiKey", | ||
| 220 | type: "apiKey", | ||
| 221 | headers: { "x-e2e-token": [{ type: "variable", name: "token" }] }, | ||
| 222 | }, | ||
| 223 | ], | ||
| 224 | }, | ||
| 225 | }); | ||
| 226 | |||
| 227 | for (const name of workspaceConnections) { | ||
| 228 | yield* client.connections.create({ | ||
| 229 | payload: { | ||
| 230 | owner: "org", | ||
| 231 | name: ConnectionName.make(name), | ||
| 232 | integration: IntegrationSlug.make(integration), | ||
| 233 | template: AuthTemplateSlug.make("apiKey"), | ||
| 234 | value: "unused-token", | ||
| 235 | }, | ||
| 236 | }); | ||
| 237 | } | ||
| 238 | yield* client.connections.create({ | ||
| 239 | payload: { | ||
| 240 | owner: "user", | ||
| 241 | name: ConnectionName.make(personalConnection), | ||
| 242 | integration: IntegrationSlug.make(integration), | ||
| 243 | template: AuthTemplateSlug.make("apiKey"), | ||
| 244 | value: "unused-token", | ||
| 245 | }, | ||
| 246 | }); | ||
| 247 | |||
| 248 | const workspaceToolkit = yield* client.toolkits.create({ | ||
| 249 | payload: { owner: "org", name: workspaceToolkitName }, | ||
| 250 | }); | ||
| 251 | for (const name of workspaceConnections) { | ||
| 252 | yield* client.toolkits.createConnection({ | ||
| 253 | params: { toolkitId: workspaceToolkit.id }, | ||
| 254 | payload: { pattern: connectionPattern(integration, "org", name) }, | ||
| 255 | }); | ||
| 256 | } | ||
| 257 | yield* client.toolkits.createConnection({ | ||
| 258 | params: { toolkitId: workspaceToolkit.id }, | ||
| 259 | payload: { pattern: connectionPattern(integration, "user", personalConnection) }, | ||
| 260 | }); | ||
| 261 | |||
| 262 | const personalToolkit = yield* client.toolkits.create({ | ||
| 263 | payload: { owner: "user", name: personalToolkitName }, | ||
| 264 | }); | ||
| 265 | yield* client.toolkits.createConnection({ | ||
| 266 | params: { toolkitId: personalToolkit.id }, | ||
| 267 | payload: { pattern: connectionPattern(integration, "org", workspaceConnections[0]!) }, | ||
| 268 | }); | ||
| 269 | yield* client.toolkits.createConnection({ | ||
| 270 | params: { toolkitId: personalToolkit.id }, | ||
| 271 | payload: { pattern: connectionPattern(integration, "user", personalConnection) }, | ||
| 272 | }); | ||
| 273 | |||
| 274 | const workspaceSession = mcp.session(identity, { | ||
| 275 | url: toolkitUrl(target.baseUrl, workspaceToolkit.slug), | ||
| 276 | }); | ||
| 277 | const personalSession = mcp.session(identity, { | ||
| 278 | url: toolkitUrl(target.baseUrl, personalToolkit.slug), | ||
| 279 | }); | ||
| 280 | |||
| 281 | const workspacePaths = yield* executeJson( | ||
| 282 | workspaceSession, | ||
| 283 | visibleConnectionPathsCode(integration), | ||
| 284 | ); | ||
| 285 | const paths = workspacePaths.paths as string[]; | ||
| 286 | expect(paths.length, "workspace toolkit exposes every workspace connection").toBe( | ||
| 287 | workspaceConnections.length, | ||
| 288 | ); | ||
| 289 | for (const name of workspaceConnections) { | ||
| 290 | expect(paths, `workspace toolkit includes ${name}`).toContain( | ||
| 291 | `${integration}.org.${name}.ping.getPing`, | ||
| 292 | ); | ||
| 293 | } | ||
| 294 | expect( | ||
| 295 | paths, | ||
| 296 | "workspace toolkit does not expose a personal connection even when its pattern was added", | ||
| 297 | ).not.toContain(`${integration}.user.${personalConnection}.ping.getPing`); | ||
| 298 | |||
| 299 | const workspaceCall = yield* executeJson( | ||
| 300 | workspaceSession, | ||
| 301 | callPingCode({ | ||
| 302 | integration, | ||
| 303 | owner: "org", | ||
| 304 | connection: workspaceConnections[3]!, | ||
| 305 | id: "workspace-call", | ||
| 306 | }), | ||
| 307 | ); | ||
| 308 | assertCallOk(workspaceCall, "workspace connection is callable"); | ||
| 309 | |||
| 310 | const lateWorkspaceCall = yield* executeJson( | ||
| 311 | workspaceSession, | ||
| 312 | callPingCode({ | ||
| 313 | integration, | ||
| 314 | owner: "org", | ||
| 315 | connection: workspaceConnections.at(-1)!, | ||
| 316 | id: "workspace-late-call", | ||
| 317 | }), | ||
| 318 | ); | ||
| 319 | assertCallOk(lateWorkspaceCall, "late workspace connection is callable"); | ||
| 320 | |||
| 321 | const personalBlockedFromWorkspace = yield* executeJson( | ||
| 322 | workspaceSession, | ||
| 323 | callPingCode({ | ||
| 324 | integration, | ||
| 325 | owner: "user", | ||
| 326 | connection: personalConnection, | ||
| 327 | id: "workspace-personal-blocked", | ||
| 328 | }), | ||
| 329 | ); | ||
| 330 | assertCallMissing(personalBlockedFromWorkspace, "workspace toolkit blocks personal tools"); | ||
| 331 | |||
| 332 | const personalWorkspaceCall = yield* executeJson( | ||
| 333 | personalSession, | ||
| 334 | callPingCode({ | ||
| 335 | integration, | ||
| 336 | owner: "org", | ||
| 337 | connection: workspaceConnections[0]!, | ||
| 338 | id: "personal-workspace-call", | ||
| 339 | }), | ||
| 340 | ); | ||
| 341 | assertCallOk(personalWorkspaceCall, "personal toolkit can call a workspace connection"); | ||
| 342 | |||
| 343 | const personalPaths = yield* executeJson( | ||
| 344 | personalSession, | ||
| 345 | visibleConnectionPathsCode(integration), | ||
| 346 | ); | ||
| 347 | const personalVisiblePaths = personalPaths.paths as string[]; | ||
| 348 | expect( | ||
| 349 | personalVisiblePaths, | ||
| 350 | "personal toolkit includes its selected workspace tool", | ||
| 351 | ).toContain(`${integration}.org.${workspaceConnections[0]}.ping.getPing`); | ||
| 352 | expect( | ||
| 353 | personalVisiblePaths, | ||
| 354 | "personal toolkit excludes unselected workspace tools from the same integration", | ||
| 355 | ).not.toContain(`${integration}.org.${workspaceConnections[1]}.ping.getPing`); | ||
| 356 | |||
| 357 | const personalOwnCall = yield* executeJson( | ||
| 358 | personalSession, | ||
| 359 | callPingCode({ | ||
| 360 | integration, | ||
| 361 | owner: "user", | ||
| 362 | connection: personalConnection, | ||
| 363 | id: "personal-own-call", | ||
| 364 | }), | ||
| 365 | ); | ||
| 366 | assertCallOk(personalOwnCall, "personal toolkit can call a personal connection"); | ||
| 367 | }).pipe( | ||
| 368 | Effect.ensuring( | ||
| 369 | Effect.gen(function* () { | ||
| 370 | const listed = yield* client.toolkits.list(); | ||
| 371 | yield* Effect.forEach( | ||
| 372 | listed.toolkits.filter((toolkit) => | ||
| 373 | [workspaceToolkitName, personalToolkitName].includes(toolkit.name), | ||
| 374 | ), | ||
| 375 | (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }), | ||
| 376 | { discard: true }, | ||
| 377 | ); | ||
| 378 | yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore); | ||
| 379 | }).pipe(Effect.ignore), | ||
| 380 | ), | ||
| 381 | ); | ||
| 382 | }), | ||
| 383 | ), | ||
| 384 | ); | ||
| 385 | |||
| 386 | scenario( | ||
| 387 | "Toolkits · an open MCP session follows toolkit connection add and remove changes", | ||
| 388 | { timeout: 240_000 }, | ||
| 389 | Effect.scoped( | ||
| 390 | Effect.gen(function* () { | ||
| 391 | const target = yield* Target; | ||
| 392 | const mcp = yield* Mcp; | ||
| 393 | const { client: makeClient } = yield* Api; | ||
| 394 | const upstream = yield* servePingApi; | ||
| 395 | const identity = yield* target.newIdentity(); | ||
| 396 | const client = yield* makeClient(api, identity); | ||
| 397 | |||
| 398 | const integration = unique("toolkit_live"); | ||
| 399 | const toolkitName = unique("live-session-kit"); | ||
| 400 | const connection = "main"; | ||
| 401 | |||
| 402 | yield* Effect.gen(function* () { | ||
| 403 | yield* client.openapi.addSpec({ | ||
| 404 | payload: { | ||
| 405 | spec: { kind: "blob", value: pingSpec(upstream.url) }, | ||
| 406 | slug: IntegrationSlug.make(integration), | ||
| 407 | baseUrl: upstream.url, | ||
| 408 | authenticationTemplate: [ | ||
| 409 | { | ||
| 410 | slug: "apiKey", | ||
| 411 | type: "apiKey", | ||
| 412 | headers: { "x-e2e-token": [{ type: "variable", name: "token" }] }, | ||
| 413 | }, | ||
| 414 | ], | ||
| 415 | }, | ||
| 416 | }); | ||
| 417 | yield* client.connections.create({ | ||
| 418 | payload: { | ||
| 419 | owner: "org", | ||
| 420 | name: ConnectionName.make(connection), | ||
| 421 | integration: IntegrationSlug.make(integration), | ||
| 422 | template: AuthTemplateSlug.make("apiKey"), | ||
| 423 | value: "unused-token", | ||
| 424 | }, | ||
| 425 | }); | ||
| 426 | |||
| 427 | const toolkit = yield* client.toolkits.create({ | ||
| 428 | payload: { owner: "org", name: toolkitName }, | ||
| 429 | }); | ||
| 430 | const pattern = connectionPattern(integration, "org", connection); | ||
| 431 | |||
| 432 | const session = mcp.session(identity, { url: toolkitUrl(target.baseUrl, toolkit.slug) }); | ||
| 433 | |||
| 434 | const initiallyMissing = yield* executeJson( | ||
| 435 | session, | ||
| 436 | callPingCode({ integration, owner: "org", connection, id: "before-add" }), | ||
| 437 | ); | ||
| 438 | assertCallMissing(initiallyMissing, "empty toolkit does not expose the connection"); | ||
| 439 | |||
| 440 | const toolkitConnection = yield* client.toolkits.createConnection({ | ||
| 441 | params: { toolkitId: toolkit.id }, | ||
| 442 | payload: { pattern }, | ||
| 443 | }); | ||
| 444 | const afterFirstAdd = yield* executeJson( | ||
| 445 | session, | ||
| 446 | callPingCode({ integration, owner: "org", connection, id: "after-first-add" }), | ||
| 447 | ); | ||
| 448 | assertCallOk(afterFirstAdd, "same MCP session sees a newly added connection"); | ||
| 449 | |||
| 450 | yield* client.toolkits.removeConnection({ | ||
| 451 | params: { toolkitId: toolkit.id, connectionId: toolkitConnection.id }, | ||
| 452 | }); | ||
| 453 | const removed = yield* executeJson( | ||
| 454 | session, | ||
| 455 | callPingCode({ integration, owner: "org", connection, id: "after-remove" }), | ||
| 456 | ); | ||
| 457 | assertCallMissing(removed, "same MCP session loses the removed connection"); | ||
| 458 | |||
| 459 | yield* client.toolkits.createConnection({ | ||
| 460 | params: { toolkitId: toolkit.id }, | ||
| 461 | payload: { pattern }, | ||
| 462 | }); | ||
| 463 | const afterAdd = yield* executeJson( | ||
| 464 | session, | ||
| 465 | callPingCode({ integration, owner: "org", connection, id: "after-add" }), | ||
| 466 | ); | ||
| 467 | assertCallOk(afterAdd, "same MCP session sees the re-added connection"); | ||
| 468 | }).pipe( | ||
| 469 | Effect.ensuring( | ||
| 470 | Effect.gen(function* () { | ||
| 471 | const listed = yield* client.toolkits.list(); | ||
| 472 | yield* Effect.forEach( | ||
| 473 | listed.toolkits.filter((toolkit) => toolkit.name === toolkitName), | ||
| 474 | (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }), | ||
| 475 | { discard: true }, | ||
| 476 | ); | ||
| 477 | yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore); | ||
| 478 | }).pipe(Effect.ignore), | ||
| 479 | ), | ||
| 480 | ); | ||
| 481 | }), | ||
| 482 | ), | ||
| 483 | ); | ||
| 484 | |||
| 485 | scenario( | ||
| 486 | "Toolkits · approve and block policies change destructive core-tool side effects", | ||
| 487 | { timeout: 240_000 }, | ||
| 488 | Effect.gen(function* () { | ||
| 489 | const target = yield* Target; | ||
| 490 | const mcp = yield* Mcp; | ||
| 491 | const { client: makeClient } = yield* Api; | ||
| 492 | const identity = yield* target.newIdentity(); | ||
| 493 | const client = yield* makeClient(api, identity); | ||
| 494 | |||
| 495 | const approveToolkitName = unique("approve-core-tools-kit"); | ||
| 496 | const blockToolkitName = unique("block-core-tools-kit"); | ||
| 497 | const approvedPattern = `${unique("toolkit-approved-policy")}.*`; | ||
| 498 | const blockedPattern = `${unique("toolkit-blocked-policy")}.*`; | ||
| 499 | |||
| 500 | yield* Effect.gen(function* () { | ||
| 501 | const approveToolkit = yield* client.toolkits.create({ | ||
| 502 | payload: { owner: "org", name: approveToolkitName }, | ||
| 503 | }); | ||
| 504 | yield* client.toolkits.createConnection({ | ||
| 505 | params: { toolkitId: approveToolkit.id }, | ||
| 506 | payload: { pattern: "executor.coreTools.*" }, | ||
| 507 | }); | ||
| 508 | yield* client.toolkits.createPolicy({ | ||
| 509 | params: { toolkitId: approveToolkit.id }, | ||
| 510 | payload: { pattern: "executor.coreTools.policies.create", action: "approve" }, | ||
| 511 | }); | ||
| 512 | |||
| 513 | const approveSession = mcp.session(identity, { | ||
| 514 | url: toolkitUrl(target.baseUrl, approveToolkit.slug), | ||
| 515 | }); | ||
| 516 | const approved = yield* approveSession.call("execute", { | ||
| 517 | code: createPolicyCode({ pattern: approvedPattern, action: "block" }), | ||
| 518 | }); | ||
| 519 | expect(approved.text, "approved policy create does not pause for approval").not.toContain( | ||
| 520 | "Execution paused", | ||
| 521 | ); | ||
| 522 | expect(approved.text, "approved policy create does not return an execution id").not.toContain( | ||
| 523 | "executionId:", | ||
| 524 | ); | ||
| 525 | expect(approved.ok, `approved policy create succeeded: ${approved.text}`).toBe(true); | ||
| 526 | const approvedPayload = JSON.parse(approved.text) as Record<string, unknown>; | ||
| 527 | expect(approvedPayload.ok, `approved policy create result: ${approved.text}`).toBe(true); | ||
| 528 | const afterApproved = yield* client.policies.list(); | ||
| 529 | expect( | ||
| 530 | afterApproved.map((policy) => `${policy.owner} ${policy.pattern} ${policy.action}`), | ||
| 531 | "approved toolkit policy created the user policy", | ||
| 532 | ).toContain(`user ${approvedPattern} block`); | ||
| 533 | |||
| 534 | const blockToolkit = yield* client.toolkits.create({ | ||
| 535 | payload: { owner: "org", name: blockToolkitName }, | ||
| 536 | }); | ||
| 537 | yield* client.toolkits.createConnection({ | ||
| 538 | params: { toolkitId: blockToolkit.id }, | ||
| 539 | payload: { pattern: "executor.coreTools.*" }, | ||
| 540 | }); | ||
| 541 | yield* client.toolkits.createPolicy({ | ||
| 542 | params: { toolkitId: blockToolkit.id }, | ||
| 543 | payload: { pattern: "executor.coreTools.policies.create", action: "block" }, | ||
| 544 | }); | ||
| 545 | |||
| 546 | const blockSession = mcp.session(identity, { | ||
| 547 | url: toolkitUrl(target.baseUrl, blockToolkit.slug), | ||
| 548 | }); | ||
| 549 | const blocked = yield* blockSession.call("execute", { | ||
| 550 | code: createPolicyCode({ pattern: blockedPattern, action: "block" }), | ||
| 551 | }); | ||
| 552 | expect(blocked.text, "blocked policy create does not pause for approval").not.toContain( | ||
| 553 | "Execution paused", | ||
| 554 | ); | ||
| 555 | const afterBlocked = yield* client.policies.list(); | ||
| 556 | expect( | ||
| 557 | afterBlocked.map((policy) => `${policy.owner} ${policy.pattern} ${policy.action}`), | ||
| 558 | "blocked toolkit policy prevents the user policy side effect", | ||
| 559 | ).not.toContain(`user ${blockedPattern} block`); | ||
| 560 | }).pipe( | ||
| 561 | Effect.ensuring( | ||
| 562 | Effect.gen(function* () { | ||
| 563 | const listed = yield* client.toolkits.list(); | ||
| 564 | yield* Effect.forEach( | ||
| 565 | listed.toolkits.filter((toolkit) => | ||
| 566 | [approveToolkitName, blockToolkitName].includes(toolkit.name), | ||
| 567 | ), | ||
| 568 | (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }), | ||
| 569 | { discard: true }, | ||
| 570 | ); | ||
| 571 | const policies = yield* client.policies.list(); | ||
| 572 | yield* Effect.forEach( | ||
| 573 | policies.filter((policy) => [approvedPattern, blockedPattern].includes(policy.pattern)), | ||
| 574 | (policy) => | ||
| 575 | client.policies.remove({ | ||
| 576 | params: { policyId: policy.id }, | ||
| 577 | payload: { owner: policy.owner }, | ||
| 578 | }), | ||
| 579 | { discard: true }, | ||
| 580 | ); | ||
| 581 | }).pipe(Effect.ignore), | ||
| 582 | ), | ||
| 583 | ); | ||
| 584 | }), | ||
| 585 | ); | ||
| @@ -0,0 +1,146 @@ | |||
| 1 | import { randomBytes } from "node:crypto"; | ||
| 2 | |||
| 3 | import { expect } from "@effect/vitest"; | ||
| 4 | import { Effect } from "effect"; | ||
| 5 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 6 | import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | ||
| 7 | |||
| 8 | import { scenario } from "../src/scenario"; | ||
| 9 | import { Api, Mcp, Target } from "../src/services"; | ||
| 10 | import type { Identity } from "../src/target"; | ||
| 11 | |||
| 12 | const api = composePluginApi([toolkitsPlugin()] as const); | ||
| 13 | |||
| 14 | const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; | ||
| 15 | |||
| 16 | const allowedCode = ` | ||
| 17 | const result = await tools.executor.coreTools.integrations.list({}); | ||
| 18 | if (!result.ok) throw new Error(result.error.message); | ||
| 19 | return result.data.integrations.map((integration) => integration.slug).includes("executor"); | ||
| 20 | `; | ||
| 21 | |||
| 22 | const blockedCode = ` | ||
| 23 | const result = await tools.executor.coreTools.policies.list({}); | ||
| 24 | if (!result.ok) throw new Error(result.error.message); | ||
| 25 | return result.data.policies.length; | ||
| 26 | `; | ||
| 27 | |||
| 28 | const initializeSession = async (url: string, bearer: string): Promise<string> => { | ||
| 29 | const response = await fetch(url, { | ||
| 30 | method: "POST", | ||
| 31 | headers: { | ||
| 32 | authorization: `Bearer ${bearer}`, | ||
| 33 | "content-type": "application/json", | ||
| 34 | accept: "application/json, text/event-stream", | ||
| 35 | }, | ||
| 36 | body: JSON.stringify({ | ||
| 37 | jsonrpc: "2.0", | ||
| 38 | id: 1, | ||
| 39 | method: "initialize", | ||
| 40 | params: { | ||
| 41 | protocolVersion: "2025-03-26", | ||
| 42 | capabilities: {}, | ||
| 43 | clientInfo: { name: "toolkit-e2e", version: "1" }, | ||
| 44 | }, | ||
| 45 | }), | ||
| 46 | }); | ||
| 47 | expect(response.status, "toolkit initialize succeeds").toBe(200); | ||
| 48 | const sessionId = response.headers.get("mcp-session-id"); | ||
| 49 | expect(sessionId, "initialize returns a session id").toEqual(expect.any(String)); | ||
| 50 | return sessionId!; | ||
| 51 | }; | ||
| 52 | |||
| 53 | const callToolsListWithSession = async ( | ||
| 54 | url: string, | ||
| 55 | bearer: string, | ||
| 56 | sessionId: string, | ||
| 57 | ): Promise<Response> => | ||
| 58 | fetch(url, { | ||
| 59 | method: "POST", | ||
| 60 | headers: { | ||
| 61 | authorization: `Bearer ${bearer}`, | ||
| 62 | "mcp-session-id": sessionId, | ||
| 63 | "mcp-protocol-version": "2025-03-26", | ||
| 64 | "content-type": "application/json", | ||
| 65 | accept: "application/json, text/event-stream", | ||
| 66 | }, | ||
| 67 | body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }), | ||
| 68 | }); | ||
| 69 | |||
| 70 | scenario( | ||
| 71 | "Toolkits · self-host MCP exposes only the toolkit's allowed tools", | ||
| 72 | { timeout: 180_000 }, | ||
| 73 | Effect.gen(function* () { | ||
| 74 | const target = yield* Target; | ||
| 75 | const { client: makeApiClient } = yield* Api; | ||
| 76 | const mcp = yield* Mcp; | ||
| 77 | const identity = yield* target.newIdentity(); | ||
| 78 | const client = yield* makeApiClient(api, identity); | ||
| 79 | const suffix = randomBytes(4).toString("hex"); | ||
| 80 | |||
| 81 | const toolkit = yield* client.toolkits.create({ | ||
| 82 | payload: { | ||
| 83 | owner: "org", | ||
| 84 | name: `toolkits-e2e-${suffix}`, | ||
| 85 | }, | ||
| 86 | }); | ||
| 87 | |||
| 88 | yield* Effect.gen(function* () { | ||
| 89 | yield* client.toolkits.createConnection({ | ||
| 90 | params: { toolkitId: toolkit.id }, | ||
| 91 | payload: { | ||
| 92 | pattern: "executor.*", | ||
| 93 | }, | ||
| 94 | }); | ||
| 95 | yield* client.toolkits.createPolicy({ | ||
| 96 | params: { toolkitId: toolkit.id }, | ||
| 97 | payload: { | ||
| 98 | pattern: "executor.coreTools.integrations.list", | ||
| 99 | action: "approve", | ||
| 100 | }, | ||
| 101 | }); | ||
| 102 | yield* client.toolkits.createPolicy({ | ||
| 103 | params: { toolkitId: toolkit.id }, | ||
| 104 | payload: { | ||
| 105 | pattern: "executor.coreTools.policies.list", | ||
| 106 | action: "block", | ||
| 107 | }, | ||
| 108 | }); | ||
| 109 | |||
| 110 | const toolkitUrl = new URL( | ||
| 111 | `/e2e-org/mcp/toolkits/${toolkit.slug}`, | ||
| 112 | target.baseUrl, | ||
| 113 | ).toString(); | ||
| 114 | const toolkitSession = mcp.session(identity, { url: toolkitUrl }); | ||
| 115 | const toolkitTools = yield* toolkitSession.listTools(); | ||
| 116 | expect(toolkitTools, "the toolkit endpoint still advertises execute").toContain("execute"); | ||
| 117 | |||
| 118 | const allowed = yield* toolkitSession.call("execute", { code: allowedCode }); | ||
| 119 | expect(allowed.ok, `allowed toolkit tool call succeeds; response:\n${allowed.text}`).toBe( | ||
| 120 | true, | ||
| 121 | ); | ||
| 122 | expect(allowed.text, "allowed call returns the integration result").toContain("true"); | ||
| 123 | |||
| 124 | const blocked = yield* toolkitSession.call("execute", { code: blockedCode }); | ||
| 125 | expect(blocked.ok, `blocked toolkit tool call fails; response:\n${blocked.text}`).toBe(false); | ||
| 126 | expect(blocked.text, "blocked call explains that the tool is unavailable").toMatch( | ||
| 127 | /blocked|not found|not available/i, | ||
| 128 | ); | ||
| 129 | |||
| 130 | const normalSession = mcp.session(identity); | ||
| 131 | const normal = yield* normalSession.call("execute", { code: blockedCode }); | ||
| 132 | expect(normal.ok, "normal MCP is not scoped by the toolkit rules").toBe(true); | ||
| 133 | |||
| 134 | const bearer = yield* mcp.mintBearer(emailOf(identity)); | ||
| 135 | const sessionId = yield* Effect.promise(() => initializeSession(toolkitUrl, bearer)); | ||
| 136 | const reusedOnDefault = yield* Effect.promise(() => | ||
| 137 | callToolsListWithSession(target.mcpUrl, bearer, sessionId), | ||
| 138 | ); | ||
| 139 | expect(reusedOnDefault.status, "toolkit session id cannot be reused on /mcp").toBe(403); | ||
| 140 | }).pipe( | ||
| 141 | Effect.ensuring( | ||
| 142 | client.toolkits.remove({ params: { toolkitId: toolkit.id } }).pipe(Effect.ignore), | ||
| 143 | ), | ||
| 144 | ); | ||
| 145 | }), | ||
| 146 | ); | ||
| @@ -0,0 +1,308 @@ | |||
| 1 | import { randomBytes } from "node:crypto"; | ||
| 2 | |||
| 3 | import { expect } from "@effect/vitest"; | ||
| 4 | import { Effect } from "effect"; | ||
| 5 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 6 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| 7 | import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | ||
| 8 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 9 | |||
| 10 | import { scenario } from "../src/scenario"; | ||
| 11 | import { Api, Browser, Target } from "../src/services"; | ||
| 12 | |||
| 13 | const api = composePluginApi([toolkitsPlugin(), openApiHttpPlugin()] as const); | ||
| 14 | |||
| 15 | const hiddenPersonalSpec = (baseUrl: string): string => | ||
| 16 | JSON.stringify({ | ||
| 17 | openapi: "3.0.3", | ||
| 18 | info: { title: "Personal Hidden API", version: "1.0.0" }, | ||
| 19 | servers: [{ url: baseUrl }], | ||
| 20 | paths: { | ||
| 21 | "/personal-only": { | ||
| 22 | get: { | ||
| 23 | operationId: "personalOnly", | ||
| 24 | responses: { "200": { description: "Personal-only response" } }, | ||
| 25 | }, | ||
| 26 | }, | ||
| 27 | }, | ||
| 28 | }); | ||
| 29 | |||
| 30 | scenario( | ||
| 31 | "Toolkits · self-host UI creates a toolkit and configures tools", | ||
| 32 | { timeout: 180_000 }, | ||
| 33 | Effect.gen(function* () { | ||
| 34 | const target = yield* Target; | ||
| 35 | const browser = yield* Browser; | ||
| 36 | const { client: makeApiClient } = yield* Api; | ||
| 37 | const identity = yield* target.newIdentity(); | ||
| 38 | const client = yield* makeApiClient(api, identity); | ||
| 39 | |||
| 40 | const suffix = randomBytes(4).toString("hex"); | ||
| 41 | const prefix = `toolkits-ui-${suffix}`; | ||
| 42 | const name = `${prefix}-created`; | ||
| 43 | const slug = name; | ||
| 44 | const hiddenPersonalIntegration = `${prefix}-personal-api`; | ||
| 45 | const hiddenPersonalConnection = "mine"; | ||
| 46 | const seededToolkits = [ | ||
| 47 | { owner: "org" as const, name: `${prefix}-workspace-a` }, | ||
| 48 | { owner: "org" as const, name: `${prefix}-workspace-b` }, | ||
| 49 | { owner: "user" as const, name: `${prefix}-personal-a` }, | ||
| 50 | { owner: "user" as const, name: `${prefix}-personal-b` }, | ||
| 51 | { owner: "user" as const, name: `${prefix}-personal-c` }, | ||
| 52 | ]; | ||
| 53 | let addedConnectionPattern = ""; | ||
| 54 | const blockPattern = "executor.coreTools.policies.list"; | ||
| 55 | |||
| 56 | const cleanup = Effect.gen(function* () { | ||
| 57 | yield* client.connections | ||
| 58 | .remove({ | ||
| 59 | params: { | ||
| 60 | owner: "user", | ||
| 61 | integration: IntegrationSlug.make(hiddenPersonalIntegration), | ||
| 62 | name: ConnectionName.make(hiddenPersonalConnection), | ||
| 63 | }, | ||
| 64 | }) | ||
| 65 | .pipe(Effect.ignore); | ||
| 66 | yield* client.openapi | ||
| 67 | .removeSpec({ params: { slug: hiddenPersonalIntegration } }) | ||
| 68 | .pipe(Effect.ignore); | ||
| 69 | const listed = yield* client.toolkits.list(); | ||
| 70 | yield* Effect.forEach( | ||
| 71 | listed.toolkits.filter((row) => row.slug.startsWith(prefix)), | ||
| 72 | (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }), | ||
| 73 | { discard: true }, | ||
| 74 | ); | ||
| 75 | }).pipe(Effect.ignore); | ||
| 76 | |||
| 77 | yield* Effect.gen(function* () { | ||
| 78 | yield* Effect.forEach( | ||
| 79 | seededToolkits, | ||
| 80 | (toolkit) => client.toolkits.create({ payload: toolkit }), | ||
| 81 | { discard: true }, | ||
| 82 | ); | ||
| 83 | yield* client.openapi.addSpec({ | ||
| 84 | payload: { | ||
| 85 | spec: { kind: "blob", value: hiddenPersonalSpec("http://127.0.0.1:59999") }, | ||
| 86 | slug: IntegrationSlug.make(hiddenPersonalIntegration), | ||
| 87 | baseUrl: "http://127.0.0.1:59999", | ||
| 88 | authenticationTemplate: [ | ||
| 89 | { | ||
| 90 | slug: "apiKey", | ||
| 91 | type: "apiKey", | ||
| 92 | headers: { "x-api-key": [{ type: "variable", name: "token" }] }, | ||
| 93 | }, | ||
| 94 | ], | ||
| 95 | }, | ||
| 96 | }); | ||
| 97 | yield* client.connections.create({ | ||
| 98 | payload: { | ||
| 99 | owner: "user", | ||
| 100 | name: ConnectionName.make(hiddenPersonalConnection), | ||
| 101 | integration: IntegrationSlug.make(hiddenPersonalIntegration), | ||
| 102 | template: AuthTemplateSlug.make("apiKey"), | ||
| 103 | value: "unused-token", | ||
| 104 | }, | ||
| 105 | }); | ||
| 106 | |||
| 107 | yield* browser.session(identity, async ({ page, step }) => { | ||
| 108 | await step("Open the Toolkits plugin page", async () => { | ||
| 109 | await page.goto("/default/toolkits/", { waitUntil: "domcontentloaded" }); | ||
| 110 | await page.getByRole("heading", { name: "Toolkits" }).waitFor(); | ||
| 111 | await page.getByRole("heading", { name: "Workspace" }).waitFor(); | ||
| 112 | await page.getByRole("heading", { name: "Personal" }).waitFor(); | ||
| 113 | await page.getByRole("button", { name: "Add workspace toolkit" }).waitFor(); | ||
| 114 | await page.getByRole("button", { name: "Add personal toolkit" }).waitFor(); | ||
| 115 | await page.locator('main [data-slot="skeleton"]').first().waitFor({ state: "detached" }); | ||
| 116 | expect(await page.locator('main [data-slot="skeleton"]').count()).toBe(0); | ||
| 117 | const seededCard = page.getByRole("link", { | ||
| 118 | name: `Open toolkit ${prefix}-workspace-a`, | ||
| 119 | }); | ||
| 120 | await seededCard.waitFor(); | ||
| 121 | expect(await seededCard.getByText("/mcp/toolkits").count()).toBe(0); | ||
| 122 | expect(await seededCard.getByText("Workspace tools").count()).toBe(0); | ||
| 123 | expect(await page.getByLabel("New toolkit").count()).toBe(0); | ||
| 124 | }); | ||
| 125 | |||
| 126 | await step("Create a workspace toolkit from the add card", async () => { | ||
| 127 | const workspaceSection = page.locator("section").filter({ | ||
| 128 | has: page.getByRole("heading", { name: "Workspace" }), | ||
| 129 | }); | ||
| 130 | await workspaceSection.getByRole("button", { name: "Add workspace toolkit" }).click(); | ||
| 131 | await page.getByRole("dialog", { name: "New workspace toolkit" }).waitFor(); | ||
| 132 | await page.getByLabel("Toolkit name").fill(name); | ||
| 133 | await page.getByRole("button", { name: "Create toolkit" }).click(); | ||
| 134 | await page.getByRole("link", { name: `Open toolkit ${name}` }).waitFor(); | ||
| 135 | }); | ||
| 136 | |||
| 137 | await step("Validate owner sections render as three-column grids", async () => { | ||
| 138 | const workspaceSection = page.locator("section").filter({ | ||
| 139 | has: page.getByRole("heading", { name: "Workspace" }), | ||
| 140 | }); | ||
| 141 | const personalSection = page.locator("section").filter({ | ||
| 142 | has: page.getByRole("heading", { name: "Personal" }), | ||
| 143 | }); | ||
| 144 | |||
| 145 | const workspaceColumns = await workspaceSection | ||
| 146 | .getByRole("link", { name: /^Open toolkit/ }) | ||
| 147 | .evaluateAll((nodes) => | ||
| 148 | nodes.slice(0, 3).map((node) => Math.round(node.getBoundingClientRect().left)), | ||
| 149 | ); | ||
| 150 | const personalColumns = await personalSection | ||
| 151 | .getByRole("link", { name: /^Open toolkit/ }) | ||
| 152 | .evaluateAll((nodes) => | ||
| 153 | nodes.slice(0, 3).map((node) => Math.round(node.getBoundingClientRect().left)), | ||
| 154 | ); | ||
| 155 | |||
| 156 | expect(new Set(workspaceColumns).size).toBe(3); | ||
| 157 | expect(new Set(personalColumns).size).toBe(3); | ||
| 158 | }); | ||
| 159 | |||
| 160 | await step("Open the created toolkit from the grid", async () => { | ||
| 161 | await page.getByRole("link", { name: `Open toolkit ${name}` }).click(); | ||
| 162 | await page.waitForURL(new RegExp(`/toolkits/${slug}$`)); | ||
| 163 | expect(page.url()).toMatch(new RegExp(`/toolkits/${slug}$`)); | ||
| 164 | await page | ||
| 165 | .locator("code") | ||
| 166 | .filter({ hasText: `/mcp/toolkits/${slug}` }) | ||
| 167 | .waitFor(); | ||
| 168 | await page.getByText("No connections added").waitFor(); | ||
| 169 | expect(await page.getByLabel("New toolkit").count()).toBe(0); | ||
| 170 | }); | ||
| 171 | |||
| 172 | await step("Return to the toolkit grid with browser-visible routing", async () => { | ||
| 173 | await page.getByRole("button", { name: "Toolkits" }).click(); | ||
| 174 | await page.waitForURL(/\/toolkits\/?$/); | ||
| 175 | expect(page.url()).toMatch(/\/toolkits\/?$/); | ||
| 176 | await page.getByRole("heading", { name: "Workspace" }).waitFor(); | ||
| 177 | await page.getByRole("link", { name: `Open toolkit ${name}` }).waitFor(); | ||
| 178 | }); | ||
| 179 | |||
| 180 | await step("Open the created toolkit from a direct URL", async () => { | ||
| 181 | await page.goto(`/default/toolkits/${slug}`, { waitUntil: "domcontentloaded" }); | ||
| 182 | expect(page.url()).toMatch(new RegExp(`/toolkits/${slug}$`)); | ||
| 183 | await page | ||
| 184 | .locator("code") | ||
| 185 | .filter({ hasText: `/mcp/toolkits/${slug}` }) | ||
| 186 | .waitFor(); | ||
| 187 | await page.getByText("No connections added").waitFor(); | ||
| 188 | expect(await page.getByLabel("New toolkit").count()).toBe(0); | ||
| 189 | }); | ||
| 190 | |||
| 191 | await step("Cancel toolkit deletion from the confirmation modal", async () => { | ||
| 192 | await page.getByRole("button", { name: "Delete toolkit" }).click(); | ||
| 193 | const dialog = page.getByRole("alertdialog", { name: `Delete ${name}?` }); | ||
| 194 | await dialog.waitFor(); | ||
| 195 | await dialog.getByRole("button", { name: "Cancel" }).click(); | ||
| 196 | await dialog.waitFor({ state: "detached" }); | ||
| 197 | await page.getByRole("heading", { name }).waitFor(); | ||
| 198 | }); | ||
| 199 | |||
| 200 | await step("The connection picker explains hidden personal connections", async () => { | ||
| 201 | await page.getByRole("button", { name: "Manage toolkit connections" }).click(); | ||
| 202 | const dialog = page.getByRole("dialog", { name: "Manage connections" }); | ||
| 203 | await dialog.waitFor(); | ||
| 204 | await dialog | ||
| 205 | .getByText( | ||
| 206 | "You have 1 personal connection that is not shown because this is a shared toolkit.", | ||
| 207 | ) | ||
| 208 | .waitFor(); | ||
| 209 | }); | ||
| 210 | |||
| 211 | await step("Add a connection to the toolkit", async () => { | ||
| 212 | const dialog = page.getByRole("dialog", { name: "Manage connections" }); | ||
| 213 | await dialog.waitFor(); | ||
| 214 | await dialog.getByLabel("Search connections and tools").fill("policies.list"); | ||
| 215 | expect(await dialog.getByRole("button", { name: /^Add tool/ }).count()).toBe(0); | ||
| 216 | addedConnectionPattern = "executor.*"; | ||
| 217 | expect(await dialog.getByText(addedConnectionPattern, { exact: true }).count()).toBe(0); | ||
| 218 | await dialog | ||
| 219 | .getByRole("button", { name: /^Add connection / }) | ||
| 220 | .first() | ||
| 221 | .click(); | ||
| 222 | await dialog.getByRole("button", { name: /^Remove connection / }).waitFor(); | ||
| 223 | await page.keyboard.press("Escape"); | ||
| 224 | await dialog.waitFor({ state: "hidden" }); | ||
| 225 | const toolkitTools = page.getByRole("region", { name: "Toolkit tools" }); | ||
| 226 | await toolkitTools.waitFor(); | ||
| 227 | await toolkitTools.getByLabel("Filter tools").fill("policies.list"); | ||
| 228 | await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().waitFor(); | ||
| 229 | await toolkitTools.getByLabel("Filter tools").clear(); | ||
| 230 | }); | ||
| 231 | |||
| 232 | await step("The add connection list reflects the saved toolkit connection", async () => { | ||
| 233 | await page.getByRole("button", { name: "Manage toolkit connections" }).click(); | ||
| 234 | const dialog = page.getByRole("dialog", { name: "Manage connections" }); | ||
| 235 | await dialog.waitFor(); | ||
| 236 | await dialog.getByLabel("Search connections and tools").fill("policies.list"); | ||
| 237 | await dialog.getByRole("button", { name: /^Remove connection / }).waitFor(); | ||
| 238 | expect(await dialog.getByRole("button", { name: /^Add connection / }).count()).toBe(0); | ||
| 239 | await page.keyboard.press("Escape"); | ||
| 240 | await dialog.waitFor({ state: "hidden" }); | ||
| 241 | }); | ||
| 242 | |||
| 243 | await step("Remove the connection from the manage modal", async () => { | ||
| 244 | await page.getByRole("button", { name: "Manage toolkit connections" }).click(); | ||
| 245 | const removeDialog = page.getByRole("dialog", { name: "Manage connections" }); | ||
| 246 | await removeDialog.waitFor(); | ||
| 247 | await removeDialog.getByLabel("Search connections and tools").fill("policies.list"); | ||
| 248 | await removeDialog | ||
| 249 | .getByRole("button", { name: /^Remove connection / }) | ||
| 250 | .first() | ||
| 251 | .click(); | ||
| 252 | await removeDialog.getByRole("button", { name: /^Add connection / }).waitFor(); | ||
| 253 | await page.keyboard.press("Escape"); | ||
| 254 | await removeDialog.waitFor({ state: "hidden" }); | ||
| 255 | await page.getByText("No connections added").waitFor(); | ||
| 256 | await page.getByRole("button", { name: "Manage toolkit connections" }).click(); | ||
| 257 | const dialog = page.getByRole("dialog", { name: "Manage connections" }); | ||
| 258 | await dialog.waitFor(); | ||
| 259 | await dialog.getByLabel("Search connections and tools").fill("policies.list"); | ||
| 260 | await dialog | ||
| 261 | .getByRole("button", { name: /^Add connection / }) | ||
| 262 | .first() | ||
| 263 | .click(); | ||
| 264 | await dialog.getByRole("button", { name: /^Remove connection / }).waitFor(); | ||
| 265 | await page.keyboard.press("Escape"); | ||
| 266 | await dialog.waitFor({ state: "hidden" }); | ||
| 267 | const toolkitTools = page.getByRole("region", { name: "Toolkit tools" }); | ||
| 268 | await toolkitTools.getByLabel("Filter tools").fill("policies.list"); | ||
| 269 | await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().waitFor(); | ||
| 270 | await toolkitTools.getByLabel("Filter tools").clear(); | ||
| 271 | }); | ||
| 272 | |||
| 273 | await step("Block one tool from the toolkit tools list", async () => { | ||
| 274 | const toolkitTools = page.getByRole("region", { name: "Toolkit tools" }); | ||
| 275 | await toolkitTools.getByLabel("Filter tools").fill("policies.list"); | ||
| 276 | await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().click(); | ||
| 277 | await page.getByRole("button", { name: "Set policy", exact: true }).click(); | ||
| 278 | await page.getByText(blockPattern, { exact: true }).waitFor(); | ||
| 279 | await page.getByRole("menuitem", { name: "Block" }).click(); | ||
| 280 | await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().waitFor(); | ||
| 281 | await page.getByText("This tool is not available through the current toolkit.").waitFor(); | ||
| 282 | }); | ||
| 283 | }); | ||
| 284 | |||
| 285 | const listed = yield* client.toolkits.list(); | ||
| 286 | const toolkit = listed.toolkits.find((row) => row.slug === slug); | ||
| 287 | expect(toolkit, "the UI-created toolkit persisted").toBeDefined(); | ||
| 288 | if (!toolkit) return; | ||
| 289 | expect(toolkit.owner).toBe("org"); | ||
| 290 | |||
| 291 | const { policies } = yield* client.toolkits.listPolicies({ | ||
| 292 | params: { toolkitId: toolkit.id }, | ||
| 293 | }); | ||
| 294 | const { connections } = yield* client.toolkits.listConnections({ | ||
| 295 | params: { toolkitId: toolkit.id }, | ||
| 296 | }); | ||
| 297 | expect(addedConnectionPattern.length, "the UI selected a connection").toBeGreaterThan(0); | ||
| 298 | expect( | ||
| 299 | connections.map((connection) => connection.pattern), | ||
| 300 | "the UI-authored toolkit connection persisted", | ||
| 301 | ).toContain(addedConnectionPattern); | ||
| 302 | expect( | ||
| 303 | policies.map((policy) => `${policy.pattern} ${policy.action}`).sort(), | ||
| 304 | "the UI-authored toolkit access persisted with its action", | ||
| 305 | ).toEqual([`${blockPattern} block`]); | ||
| 306 | }).pipe(Effect.ensuring(cleanup)); | ||
| 307 | }), | ||
| 308 | ); | ||
| @@ -12,6 +12,9 @@ import { promisify } from "node:util"; · 11 unmodified lines | |||
| 12 | import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot"; | 12 | import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot"; |
| 13 | 13 | ||
| 14 | export const cloudflareDir = fileURLToPath(new URL("../../apps/host-cloudflare/", import.meta.url)); | 14 | export const cloudflareDir = fileURLToPath(new URL("../../apps/host-cloudflare/", import.meta.url)); |
| 15 | const wranglerBin = fileURLToPath( | ||
| 16 | new URL("../../apps/host-cloudflare/node_modules/.bin/wrangler", import.meta.url), | ||
| 17 | ); | ||
| 15 | 18 | ||
| 16 | export interface CloudflareBootOptions { | 19 | export 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 worker | 36 | // 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" }, |
| @@ -11,6 +11,8 @@ import { join } from "node:path"; · 10 unmodified lines | |||
| 11 | import { Effect } from "effect"; | 11 | import { Effect } from "effect"; |
| 12 | 12 | ||
| 13 | import { createRuntime, type Runtime } from "@executor-js/mcporter"; | 13 | import { createRuntime, type Runtime } from "@executor-js/mcporter"; |
| 14 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | ||
| 15 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | ||
| 14 | 16 | ||
| 15 | import { appendTraces } from "../trace-harvest"; | 17 | import { appendTraces } from "../trace-harvest"; |
| 16 | import type { Identity, Target } from "../target"; | 18 | import 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 | ||
| 193 | const jsonFrom = async <T>(response: Response, label: string): Promise<T> => { | ||
| 194 | const text = await response.text(); | ||
| 195 | if (!text) { | ||
| 196 | throw new Error(`${label}: empty response body (status ${response.status})`); | ||
| 197 | } | ||
| 198 | if (!response.ok) { | ||
| 199 | throw new Error(`${label}: request failed (status ${response.status}): ${text}`); | ||
| 200 | } | ||
| 201 | return JSON.parse(text) as T; | ||
| 202 | }; | ||
| 203 | |||
| 191 | const mintBearerFlow = async (target: Target, email: string): Promise<string> => { | 204 | const 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 a | 291 | // 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 token | 292 | // 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 each | 293 | // — 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 execution | 298 | // `?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?.elicitationMode | 300 | 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 | ||
| @@ -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 | }); |
| @@ -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, |
| @@ -11,8 +11,10 @@ · 10 unmodified lines | |||
| 11 | import { Route as rootRouteImport } from './routes/__root' | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | import { Route as DotDotDotDotDotDotReactSrcRoutesIndexRouteImport } from './../../react/src/routes/index' | 12 | import { Route as DotDotDotDotDotDotReactSrcRoutesIndexRouteImport } from './../../react/src/routes/index' |
| 13 | import { Route as DotDotDotDotDotDotReactSrcRoutesToolsRouteImport } from './../../react/src/routes/tools' | 13 | import { Route as DotDotDotDotDotDotReactSrcRoutesToolsRouteImport } from './../../react/src/routes/tools' |
| 14 | import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport } from './../../react/src/routes/toolkits' | ||
| 14 | import { Route as SecretsRouteImport } from './routes/app/secrets' | 15 | import { Route as SecretsRouteImport } from './routes/app/secrets' |
| 15 | import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './../../react/src/routes/policies' | 16 | import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './../../react/src/routes/policies' |
| 17 | import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug' | ||
| 16 | import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId' | 18 | import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId' |
| 17 | import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace' | 19 | import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace' |
| 18 | import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$' | 20 | import { 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) |
| 35 | const DotDotDotDotDotDotReactSrcRoutesToolkitsRoute = | ||
| 36 | DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport.update({ | ||
| 37 | id: '/{-$orgSlug}/toolkits', | ||
| 38 | path: '/{-$orgSlug}/toolkits', | ||
| 39 | getParentRoute: () => rootRouteImport, | ||
| 40 | } as any) | ||
| 33 | const SecretsRoute = SecretsRouteImport.update({ | 41 | const 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) |
| 52 | const DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute = | ||
| 53 | DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update({ | ||
| 54 | id: '/$toolkitSlug', | ||
| 55 | path: '/$toolkitSlug', | ||
| 56 | getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesToolkitsRoute, | ||
| 57 | } as any) | ||
| 44 | const DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute = | 58 | const 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 | |||
| 71 | export interface FileRoutesByFullPath { | 85 | export interface FileRoutesByFullPath { |
| 72 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute | 86 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute |
| 73 | '/{-$orgSlug}/secrets': typeof SecretsRoute | 87 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 88 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren | ||
| 74 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute | 89 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute |
| 75 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute | 90 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 76 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute | 91 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 77 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute | 92 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 93 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute | ||
| 78 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 94 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 79 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute | 95 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| 80 | } | 96 | } |
| 81 | export interface FileRoutesByTo { | 97 | export interface FileRoutesByTo { |
| 82 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute | 98 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute |
| 83 | '/{-$orgSlug}/secrets': typeof SecretsRoute | 99 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 100 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren | ||
| 84 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute | 101 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute |
| 85 | '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute | 102 | '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 86 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute | 103 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 87 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute | 104 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 105 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute | ||
| 88 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 106 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 89 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute | 107 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| 90 | } | 108 | } |
| @@ -92,10 +110,12 @@ export interface FileRoutesById { · 1 unmodified line | |||
| 92 | __root__: typeof rootRouteImport | 110 | __root__: typeof rootRouteImport |
| 93 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute | 111 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute |
| 94 | '/{-$orgSlug}/secrets': typeof SecretsRoute | 112 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 113 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren | ||
| 95 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute | 114 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute |
| 96 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute | 115 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 97 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute | 116 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 98 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute | 117 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 118 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute | ||
| 99 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 119 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 100 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute | 120 | '/{-$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: FileRoutesByTo | 135 | 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: FileRoutesById | 159 | fileRoutesById: FileRoutesById |
| @@ -135,6 +161,7 @@ export interface FileRouteTypes { · 1 unmodified line | |||
| 135 | export interface RootRouteChildren { | 161 | export interface RootRouteChildren { |
| 136 | DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute | 162 | DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute |
| 137 | SecretsRoute: typeof SecretsRoute | 163 | SecretsRoute: typeof SecretsRoute |
| 164 | DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren | ||
| 138 | DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute | 165 | DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute |
| 139 | DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute | 166 | DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 140 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute | 167 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| @@ -159,6 +186,13 @@ declare module '@tanstack/react-router' { · 18 unmodified lines | |||
| 159 | preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRouteImport | 186 | preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRouteImport |
| 160 | parentRoute: typeof rootRouteImport | 187 | 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 DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport | 207 | preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport |
| 174 | parentRoute: typeof rootRouteImport | 208 | 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 | ||
| 248 | interface DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren { | ||
| 249 | DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute | ||
| 250 | } | ||
| 251 | |||
| 252 | const DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren: DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren = | ||
| 253 | { | ||
| 254 | DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute: | ||
| 255 | DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute, | ||
| 256 | } | ||
| 257 | |||
| 258 | const DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren = | ||
| 259 | DotDotDotDotDotDotReactSrcRoutesToolkitsRoute._addFileChildren( | ||
| 260 | DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren, | ||
| 261 | ) | ||
| 262 | |||
| 207 | const rootRouteChildren: RootRouteChildren = { | 263 | const 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: |
| @@ -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 | ||
| @@ -26,6 +26,7 @@ · 25 unmodified lines | |||
| 26 | import { Context, Effect, Layer } from "effect"; | 26 | import { Context, Effect, Layer } from "effect"; |
| 27 | import type * as Cause from "effect/Cause"; | 27 | import type * as Cause from "effect/Cause"; |
| 28 | 28 | ||
| 29 | import type { McpResource } from "@executor-js/host-mcp"; | ||
| 29 | import type { AnyPlugin, Executor, StorageFailure } from "@executor-js/sdk"; | 30 | import type { AnyPlugin, Executor, StorageFailure } from "@executor-js/sdk"; |
| 30 | import { | 31 | import { |
| 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; |
| @@ -39,11 +39,9 @@ export type McpExecutionStackLayer = Layer.Layer< · 38 unmodified lines | |||
| 39 | export const makeMcpBuildServer = | 39 | export 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. |
| @@ -33,6 +33,7 @@ · 32 unmodified lines | |||
| 33 | 33 | ||
| 34 | import { Context, Effect, Option } from "effect"; | 34 | import { Context, Effect, Option } from "effect"; |
| 35 | 35 | ||
| 36 | import type { McpResource } from "@executor-js/host-mcp"; | ||
| 36 | import { | 37 | import { |
| 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 | ||
| 168 | export interface PluginsProviderContext { | ||
| 169 | readonly mcpResource?: McpResource; | ||
| 170 | } | ||
| 171 | |||
| 167 | export interface PluginsProviderShape { | 172 | export interface PluginsProviderShape { |
| 168 | readonly plugins: () => readonly AnyPlugin[]; | 173 | readonly plugins: (context?: PluginsProviderContext) => readonly AnyPlugin[]; |
| 169 | } | 174 | } |
| 170 | 175 | ||
| 171 | export class PluginsProvider extends Context.Service<PluginsProvider, PluginsProviderShape>()( | 176 | export 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 the | 207 | // `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 | }; |
| @@ -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 | ||
| 49 | export interface PluginPageProps { | ||
| 50 | /** Plugin-relative route params captured from `PageDecl.path` segments. */ | ||
| 51 | readonly params: Readonly<Record<string, string>>; | ||
| 52 | /** The normalized plugin-relative URL path that matched this page. */ | ||
| 53 | readonly path: string; | ||
| 54 | /** The plugin id from `/plugins/$pluginId/...`. */ | ||
| 55 | readonly pluginId: string; | ||
| 56 | } | ||
| 57 | |||
| 49 | export interface PageDecl { | 58 | export 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 its | 62 | /** 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?: { |
| @@ -90,6 +90,7 @@ import type { OAuthService } from "./oauth-client"; · 89 unmodified lines | |||
| 90 | import { | 90 | import { |
| 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"; |
| 117 | import { | 120 | import { |
| @@ -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 | core | 2308 | 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 === undefined | 2312 | 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) { |
| @@ -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, |
| @@ -48,6 +48,7 @@ import type { CredentialProvider, ProviderEntry } from "./provider"; · 47 unmodified lines | |||
| 48 | import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage"; | 48 | import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage"; |
| 49 | import type { | 49 | import 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 | |||
| 101 | export interface ToolPolicyProviderRule { | ||
| 102 | readonly id: string; | ||
| 103 | readonly pattern: string; | ||
| 104 | readonly action: ToolPolicy["action"]; | ||
| 105 | readonly position: string; | ||
| 106 | } | ||
| 107 | |||
| 108 | export interface ToolPolicyProvider { | ||
| 109 | readonly list: () => Effect.Effect<readonly ToolPolicyProviderRule[], StorageFailure>; | ||
| 110 | readonly resolve?: (input: { | ||
| 111 | readonly toolId: string; | ||
| 112 | readonly defaultRequiresApproval?: boolean; | ||
| 113 | }) => Effect.Effect<EffectivePolicy, StorageFailure>; | ||
| 114 | } | ||
| 115 | |||
| 91 | // --------------------------------------------------------------------------- | 116 | // --------------------------------------------------------------------------- |
| 92 | // IntegrationRecord — the catalog row a plugin reads back (its own opaque | 117 | // 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 to | 493 | /** Produce a connection's tools (and shared $defs). The v2 successor to |
| 462 | * registering per-source tools — called by the executor at connection | 494 | * registering per-source tools — called by the executor at connection |
| 463 | * create / refresh / oauth.complete; the result is stamped with addresses | 495 | * create / refresh / oauth.complete; the result is stamped with addresses |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect, Predicate, Result } from "effect"; | 2 | import { Effect, Predicate, Result, Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { type ToolPolicyRow } from "./core-schema"; | 4 | import { type ToolPolicyRow } from "./core-schema"; |
| 5 | import { | 5 | import { |
| @@ -19,7 +19,7 @@ import { · 13 unmodified lines | |||
| 19 | matchPattern, | 19 | matchPattern, |
| 20 | resolveToolPolicy, | 20 | resolveToolPolicy, |
| 21 | } from "./policies"; | 21 | } from "./policies"; |
| 22 | import { definePlugin } from "./plugin"; | 22 | import { definePlugin, tool } from "./plugin"; |
| 23 | import type { CredentialProvider } from "./provider"; | 23 | import type { CredentialProvider } from "./provider"; |
| 24 | import { makeTestExecutor } from "./testing"; | 24 | import { makeTestExecutor } from "./testing"; |
| 25 | 25 | ||
| @@ -512,6 +512,85 @@ describe("blocked tools", () => { · 486 unmodified lines | |||
| 512 | ); | 512 | ); |
| 513 | }); | 513 | }); |
| 514 | 514 | ||
| 515 | describe("active tool-policy provider", () => { | ||
| 516 | const staticPlugin = definePlugin(() => ({ | ||
| 517 | id: "toolkit-fixture" as const, | ||
| 518 | storage: () => ({}), | ||
| 519 | staticSources: () => [ | ||
| 520 | { | ||
| 521 | id: "toolkit-fixture.ctl", | ||
| 522 | kind: "control" as const, | ||
| 523 | name: "Toolkit Fixture", | ||
| 524 | tools: [ | ||
| 525 | tool({ | ||
| 526 | name: "allowed", | ||
| 527 | description: "allowed", | ||
| 528 | inputSchema: Schema.toStandardSchemaV1( | ||
| 529 | Schema.toStandardJSONSchemaV1(Schema.Struct({})), | ||
| 530 | ), | ||
| 531 | execute: () => Effect.succeed("allowed"), | ||
| 532 | }), | ||
| 533 | tool({ | ||
| 534 | name: "hidden", | ||
| 535 | description: "hidden", | ||
| 536 | inputSchema: Schema.toStandardSchemaV1( | ||
| 537 | Schema.toStandardJSONSchemaV1(Schema.Struct({})), | ||
| 538 | ), | ||
| 539 | execute: () => Effect.succeed("hidden"), | ||
| 540 | }), | ||
| 541 | ], | ||
| 542 | }, | ||
| 543 | ], | ||
| 544 | }))(); | ||
| 545 | |||
| 546 | const policyProviderPlugin = definePlugin(() => ({ | ||
| 547 | id: "toolkit-policy-provider" as const, | ||
| 548 | storage: () => ({}), | ||
| 549 | toolPolicyProvider: () => ({ | ||
| 550 | list: () => | ||
| 551 | Effect.succeed([ | ||
| 552 | { | ||
| 553 | id: "allow-static", | ||
| 554 | pattern: "toolkit-fixture.ctl.allowed", | ||
| 555 | action: "approve" as const, | ||
| 556 | position: "a0", | ||
| 557 | }, | ||
| 558 | ]), | ||
| 559 | }), | ||
| 560 | }))(); | ||
| 561 | |||
| 562 | it.effect("uses provider rules as an allowlist for list, schema, and execute", () => | ||
| 563 | Effect.gen(function* () { | ||
| 564 | const executor = yield* makeTestExecutor({ | ||
| 565 | plugins: [staticPlugin, policyProviderPlugin] as const, | ||
| 566 | }); | ||
| 567 | |||
| 568 | const tools = yield* executor.tools.list(); | ||
| 569 | expect(tools.map((t) => String(t.address)).sort()).toEqual(["toolkit-fixture.ctl.allowed"]); | ||
| 570 | |||
| 571 | const allowedSchema = yield* executor.tools.schema( | ||
| 572 | ToolAddress.make("toolkit-fixture.ctl.allowed"), | ||
| 573 | ); | ||
| 574 | expect(allowedSchema?.name).toBe("allowed"); | ||
| 575 | |||
| 576 | const hiddenSchema = yield* executor.tools.schema( | ||
| 577 | ToolAddress.make("toolkit-fixture.ctl.hidden"), | ||
| 578 | ); | ||
| 579 | expect(hiddenSchema).toBeNull(); | ||
| 580 | |||
| 581 | const allowed = yield* executor.execute(ToolAddress.make("toolkit-fixture.ctl.allowed"), {}); | ||
| 582 | expect(allowed).toBe("allowed"); | ||
| 583 | |||
| 584 | const blocked = yield* Effect.result( | ||
| 585 | executor.execute(ToolAddress.make("toolkit-fixture.ctl.hidden"), {}), | ||
| 586 | ); | ||
| 587 | expect(Result.isFailure(blocked)).toBe(true); | ||
| 588 | if (!Result.isFailure(blocked)) return; | ||
| 589 | expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true); | ||
| 590 | }), | ||
| 591 | ); | ||
| 592 | }); | ||
| 593 | |||
| 515 | describe("approve / require_approval interaction with annotations", () => { | 594 | describe("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* () { |
| @@ -13,9 +13,11 @@ · 12 unmodified lines | |||
| 13 | // --------------------------------------------------------------------------- | 13 | // --------------------------------------------------------------------------- |
| 14 | 14 | ||
| 15 | import { Effect } from "effect"; | 15 | import { Effect } from "effect"; |
| 16 | import { mcpResourceKey, type McpResource } from "@executor-js/host-mcp"; | ||
| 16 | 17 | ||
| 17 | export const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id"; | 18 | export const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id"; |
| 18 | export const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id"; | 19 | export const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id"; |
| 20 | export 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. */ |
| 21 | export type VerifiedTokenHeaders = { | 23 | export type VerifiedTokenHeaders = { |
| @@ -70,10 +72,12 @@ export const withPropagationHeaders = ( · 48 unmodified lines | |||
| 70 | export const withVerifiedIdentityHeaders = ( | 72 | export 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 | ||
| @@ -1,3 +1,5 @@ | |||
| 1 | import type { McpResource } from "@executor-js/host-mcp"; | ||
| 2 | |||
| 1 | import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; | 3 | import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; |
| 2 | 4 | ||
| 3 | // --------------------------------------------------------------------------- | 5 | // --------------------------------------------------------------------------- |
| @@ -10,6 +12,7 @@ import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-header · 6 unmodified lines | |||
| 10 | export interface McpSessionInit { | 12 | export 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 a | 17 | /** 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. */ |
| @@ -29,9 +29,12 @@ import { makeMcpWorkerTransport, type McpWorkerTransport } from "./worker-transp · 28 unmodified lines | |||
| 29 | import { | 29 | import { |
| 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"; |
| 35 | import { mcpResourceKey } from "@executor-js/host-mcp"; | ||
| 34 | import type { McpSessionInit } from "./seams"; | 36 | import type { McpSessionInit } from "./seams"; |
| 37 | import type { McpResource } from "@executor-js/host-mcp"; | ||
| 35 | 38 | ||
| 36 | // --------------------------------------------------------------------------- | 39 | // --------------------------------------------------------------------------- |
| 37 | // Types | 40 | // Types |
| @@ -122,6 +125,7 @@ export interface SessionDbHandle { · 84 unmodified lines | |||
| 122 | export interface SessionMeta { | 125 | export 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's | 566 | // 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 cold | 567 | // 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.webOrigin | 569 | 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 | ); |
| @@ -13,13 +13,20 @@ · 12 unmodified lines | |||
| 13 | import { describe, expect, it } from "@effect/vitest"; | 13 | import { describe, expect, it } from "@effect/vitest"; |
| 14 | import { Cause, Effect, Exit } from "effect"; | 14 | import { Cause, Effect, Exit } from "effect"; |
| 15 | 15 | ||
| 16 | import { McpSessionStore, type McpDispatchResult, type Principal } from "@executor-js/host-mcp"; | 16 | import { |
| 17 | McpSessionStore, | ||
| 18 | defaultMcpResource, | ||
| 19 | type McpDispatchResult, | ||
| 20 | type Principal, | ||
| 21 | } from "@executor-js/host-mcp"; | ||
| 17 | 22 | ||
| 18 | import { | 23 | import { |
| 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"; |
| 29 | import { INTERNAL_RESOURCE_KEY_HEADER } from "./do-headers"; | ||
| 23 | 30 | ||
| 24 | const RELOCATION_ERROR = "cannot access storage because object has moved to a different machine"; | 31 | const 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 | ||
| 70 | describe("makeDurableObjectMcpSessionStore — DO-relocation retry", () => { | 78 | describe("makeDurableObjectMcpSessionStore — DO-relocation retry", () => { |
| 79 | it.live("passes the requested MCP resource into session init", () => | ||
| 80 | Effect.gen(function* () { | ||
| 81 | let initMeta: McpSessionInit | undefined; | ||
| 82 | const stub: McpSessionDOStub = { | ||
| 83 | init: (meta) => { | ||
| 84 | initMeta = meta; | ||
| 85 | return Promise.resolve(); | ||
| 86 | }, | ||
| 87 | handleRequest: () => Promise.resolve(okResponse()), | ||
| 88 | clearSession: () => Promise.resolve(), | ||
| 89 | }; | ||
| 90 | |||
| 91 | const result = yield* Effect.gen(function* () { | ||
| 92 | const store = yield* McpSessionStore; | ||
| 93 | return yield* store.dispatch({ | ||
| 94 | request: initializeRequest(), | ||
| 95 | principal: TEST_PRINCIPAL, | ||
| 96 | resource: { kind: "toolkit", slug: "deploy" }, | ||
| 97 | sessionId: null, | ||
| 98 | method: "POST", | ||
| 99 | }); | ||
| 100 | }).pipe( | ||
| 101 | Effect.provide( | ||
| 102 | makeDurableObjectMcpSessionStore({ newStub: () => stub, getStub: () => stub }), | ||
| 103 | ), | ||
| 104 | ); | ||
| 105 | |||
| 106 | expect(result).toBeInstanceOf(Response); | ||
| 107 | expect(initMeta?.resource, "the DO session is keyed to the requested resource").toEqual({ | ||
| 108 | kind: "toolkit", | ||
| 109 | slug: "deploy", | ||
| 110 | }); | ||
| 111 | }), | ||
| 112 | ); | ||
| 113 | |||
| 114 | it.live("stamps the requested MCP resource on forwarded session requests", () => | ||
| 115 | Effect.gen(function* () { | ||
| 116 | let forwardedResourceKey: string | null = null; | ||
| 117 | const stub: McpSessionDOStub = { | ||
| 118 | init: () => Promise.resolve(), | ||
| 119 | handleRequest: (request) => { | ||
| 120 | forwardedResourceKey = request.headers.get(INTERNAL_RESOURCE_KEY_HEADER); | ||
| 121 | return Promise.resolve(okResponse()); | ||
| 122 | }, | ||
| 123 | clearSession: () => Promise.resolve(), | ||
| 124 | }; | ||
| 125 | |||
| 126 | const result = yield* Effect.gen(function* () { | ||
| 127 | const store = yield* McpSessionStore; | ||
| 128 | return yield* store.dispatch({ | ||
| 129 | request: initializeRequest(), | ||
| 130 | principal: TEST_PRINCIPAL, | ||
| 131 | resource: { kind: "toolkit", slug: "deploy" }, | ||
| 132 | sessionId: "existing-session", | ||
| 133 | method: "POST", | ||
| 134 | }); | ||
| 135 | }).pipe( | ||
| 136 | Effect.provide( | ||
| 137 | makeDurableObjectMcpSessionStore({ newStub: () => stub, getStub: () => stub }), | ||
| 138 | ), | ||
| 139 | ); | ||
| 140 | |||
| 141 | expect(result).toBeInstanceOf(Response); | ||
| 142 | expect( | ||
| 143 | forwardedResourceKey, | ||
| 144 | "the DO can reject a session id reused on another resource", | ||
| 145 | ).toBe("toolkit:deploy"); | ||
| 146 | }), | ||
| 147 | ); | ||
| 148 | |||
| 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; |
| @@ -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 web | 184 | // 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 spoofable | 185 | // 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 sessionId | 249 | 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 | }); |
| @@ -15,11 +15,13 @@ import { HttpRouter, HttpServer } from "effect/unstable/http"; · 14 unmodified lines | |||
| 15 | 15 | ||
| 16 | import { | 16 | import { |
| 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 | |||
| 64 | const buildHandler = ( | 66 | const 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 | |||
| 173 | it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => { | ||
| 174 | const seen = await Effect.runPromise(Ref.make<McpResource | null>(null)); | ||
| 175 | const RecordingStoreLive = Layer.succeed(McpSessionStore)({ | ||
| 176 | dispatch: ({ resource }): Effect.Effect<McpDispatchResult> => | ||
| 177 | Ref.set(seen, resource).pipe( | ||
| 178 | Effect.as(new Response(JSON.stringify({ jsonrpc: "2.0", id: 1 }), { status: 200 })), | ||
| 179 | ), | ||
| 180 | dispose: () => Effect.void, | ||
| 181 | }); | ||
| 182 | |||
| 183 | const handler = buildHandler(RecordingStoreLive, McpErrorReporterNoop); | ||
| 184 | const response = await handler( | ||
| 185 | new Request("https://host.test/mcp/toolkits/deploy", { | ||
| 186 | method: "POST", | ||
| 187 | headers: { authorization: "Bearer x", "content-type": "application/json" }, | ||
| 188 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), | ||
| 189 | }), | ||
| 190 | ); | ||
| 191 | |||
| 192 | expect(response.status).toBe(200); | ||
| 193 | expect(await Effect.runPromise(Ref.get(seen))).toEqual({ | ||
| 194 | kind: "toolkit", | ||
| 195 | slug: "deploy", | ||
| 196 | }); | ||
| 135 | }); | 197 | }); |
| @@ -2,11 +2,13 @@ import { Effect, Match, Predicate } from "effect"; · 1 unmodified line | |||
| 2 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | 2 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 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 metadata | 18 | // GET <provider-declared discovery paths> -> McpAuthProvider metadata |
| 17 | // * /mcp -> authenticate -> dispatch | 19 | // * /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`; the | 23 | // 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/authz | 31 | // — 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 | ||
| 40 | const MCP_PATH = "/mcp"; | 43 | const MCP_PATH = "/mcp"; |
| 44 | const 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`. */ |
| 43 | const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); | 47 | const 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. */ |
| 174 | const mcpDispatch = Effect.gen(function* () { | 178 | const 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 other | 190 | // 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 method | 192 | // 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 EVERY | 200 | // 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 DO | 202 | // only safe after the store can validate the authenticated principal and MCP |
| 198 | // whose org access was revoked), then render the 403. The | 203 | // resource; an auth-level Forbidden may not carry either. |
| 199 | // inbound request is forwarded so the store can propagate | 204 | 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 opens | 210 | // No session id: per the streamable-HTTP transport contract, only POST opens |
| 212 | // a session. A GET needs an existing id (400); a DELETE on nothing is a | 211 | // a session. A GET needs an existing id (400); a DELETE on nothing is a |
| 213 | // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up | 212 | // 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`: a | 240 | * 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 stable | 244 | * 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 | */ |
| 245 | const mcpRoute = mcpDispatch.pipe( | 247 | const 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 | |||
| 258 | const toolkitMcpRoute = Effect.gen(function* () { | ||
| 259 | const params = yield* HttpRouter.params; | ||
| 260 | const slug = params.toolkitSlug; | ||
| 261 | return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource); | ||
| 262 | }); | ||
| 254 | 263 | ||
| 255 | /** | 264 | /** |
| 256 | * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges | 265 | * 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 | ); |
| @@ -17,10 +17,13 @@ import { · 16 unmodified lines | |||
| 17 | import { jsonRpcErrorBody } from "./envelope"; | 17 | import { jsonRpcErrorBody } from "./envelope"; |
| 18 | import { | 18 | import { |
| 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"; |
| 25 | import type { BrowserApprovalStore } from "./tool-server"; | 28 | import 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. */ |
| 63 | export interface McpBuildServerOptions { | 66 | export 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 | |||
| 120 | const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; | 124 | const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; |
| 121 | const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; | 125 | const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; |
| 122 | 126 | ||
| 127 | interface SessionOwner { | ||
| 128 | readonly principal: Principal; | ||
| 129 | readonly resource: McpResource; | ||
| 130 | } | ||
| 131 | |||
| 132 | const sessionOwnerMatches = ( | ||
| 133 | owner: SessionOwner, | ||
| 134 | principal: Principal, | ||
| 135 | resource: McpResource, | ||
| 136 | ): boolean => | ||
| 137 | principalOwns(owner.principal, principal) && | ||
| 138 | mcpResourceKey(owner.resource) === mcpResourceKey(resource); | ||
| 139 | |||
| 123 | /** | 140 | /** |
| 124 | * Build the in-process session store plus an explicit `close()` that disposes | 141 | * 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 lifetime | 142 | * 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 | ||
| @@ -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 | ||
| 36 | export { McpServingRoutes, jsonRpcErrorBody } from "./envelope"; | 39 | export { McpServingRoutes, jsonRpcErrorBody } from "./envelope"; |
| @@ -56,6 +56,25 @@ export type Principal = Schema.Schema.Type<typeof Principal>; · 55 unmodified lines | |||
| 56 | export const principalOwns = (owner: Principal, principal: Principal): boolean => | 56 | export 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 | |||
| 69 | export type McpResource = | ||
| 70 | | { readonly kind: "default" } | ||
| 71 | | { readonly kind: "toolkit"; readonly slug: string }; | ||
| 72 | |||
| 73 | export const defaultMcpResource: McpResource = { kind: "default" }; | ||
| 74 | |||
| 75 | export const mcpResourceKey = (resource: McpResource): string => | ||
| 76 | resource.kind === "default" ? "default" : `toolkit:${resource.slug}`; | ||
| 77 | |||
| 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 | |||
| 210 | export interface McpDispatchInput { | 229 | export 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 | } |
| @@ -35,6 +35,23 @@ const insertIntegration = ( · 34 unmodified lines | |||
| 35 | ], | 35 | ], |
| 36 | }); | 36 | }); |
| 37 | 37 | ||
| 38 | const insertIntegrationRawConfig = ( | ||
| 39 | client: SqliteDataMigrationClient, | ||
| 40 | row: { | ||
| 41 | readonly rowId: string; | ||
| 42 | readonly tenant: string; | ||
| 43 | readonly slug: string; | ||
| 44 | readonly pluginId: string; | ||
| 45 | readonly config: string; | ||
| 46 | }, | ||
| 47 | ) => | ||
| 48 | client.execute({ | ||
| 49 | sql: `INSERT INTO integration | ||
| 50 | (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at) | ||
| 51 | VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`, | ||
| 52 | args: [row.rowId, row.tenant, row.slug, row.pluginId, row.slug, row.slug, row.config, now, now], | ||
| 53 | }); | ||
| 54 | |||
| 38 | const insertBlob = ( | 55 | const insertBlob = ( |
| 39 | client: SqliteDataMigrationClient, | 56 | client: SqliteDataMigrationClient, |
| 40 | row: { | 57 | row: { |
| @@ -84,7 +101,11 @@ const insertOperationStorage = ( · 43 unmodified lines | |||
| 84 | 101 | ||
| 85 | const insertTool = ( | 102 | const 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 tool | 111 | sql: `INSERT INTO tool |
| @@ -95,7 +116,11 @@ const insertTool = ( · 4 unmodified lines | |||
| 95 | 116 | ||
| 96 | const insertDefinition = ( | 117 | const 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 definition | 126 | sql: `INSERT INTO definition |
| @@ -105,6 +130,31 @@ const insertDefinition = ( · 3 unmodified lines | |||
| 105 | }); | 130 | }); |
| 106 | 131 | ||
| 107 | describe("runSqliteGoogleOpenApiOwnershipMigration", () => { | 132 | describe("runSqliteGoogleOpenApiOwnershipMigration", () => { |
| 133 | it.effect("skips malformed legacy integration config rows", () => | ||
| 134 | Effect.gen(function* () { | ||
| 135 | const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); | ||
| 136 | |||
| 137 | yield* Effect.promise(() => | ||
| 138 | insertIntegrationRawConfig(db.client, { | ||
| 139 | rowId: "malformed-row", | ||
| 140 | tenant: "org_1", | ||
| 141 | slug: "broken", | ||
| 142 | pluginId: "openapi", | ||
| 143 | config: "", | ||
| 144 | }), | ||
| 145 | ); | ||
| 146 | |||
| 147 | expect(yield* runSqliteGoogleOpenApiOwnershipMigration(db.client)).toBe(0); | ||
| 148 | |||
| 149 | const integrations = yield* Effect.promise(() => | ||
| 150 | db.client.execute("SELECT slug, plugin_id, config FROM integration"), | ||
| 151 | ); | ||
| 152 | expect(integrations.rows).toEqual([{ slug: "broken", plugin_id: "openapi", config: "" }]); | ||
| 153 | |||
| 154 | yield* Effect.promise(() => db.close()); | ||
| 155 | }), | ||
| 156 | ); | ||
| 157 | |||
| 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 | ||
| @@ -4,7 +4,7 @@ import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js · 3 unmodified lines | |||
| 4 | const MIGRATION_NAME = "2026-06-20-google-openapi-ownership"; | 4 | const MIGRATION_NAME = "2026-06-20-google-openapi-ownership"; |
| 5 | const googleOpenApiCandidate = (alias?: string): string => { | 5 | const 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 | ||
| 10 | const execute = ( | 10 | const execute = ( |
| @@ -0,0 +1 @@ | |||
| 1 | # @executor-js/plugin-toolkits | ||
| @@ -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 | } | ||
| @@ -0,0 +1,17 @@ | |||
| 1 | import { defineClientPlugin } from "@executor-js/sdk/client"; | ||
| 2 | |||
| 3 | import { ToolkitsPage } from "./page"; | ||
| 4 | |||
| 5 | export default defineClientPlugin({ | ||
| 6 | id: "toolkits" as const, | ||
| 7 | pages: [ | ||
| 8 | { | ||
| 9 | path: "/", | ||
| 10 | component: ToolkitsPage, | ||
| 11 | }, | ||
| 12 | { | ||
| 13 | path: "/$toolkitSlug", | ||
| 14 | component: ToolkitsPage, | ||
| 15 | }, | ||
| 16 | ], | ||
| 17 | }); | ||
| @@ -0,0 +1,1488 @@ | |||
| 1 | import { useId, useMemo, useState } from "react"; | ||
| 2 | import { Link, useNavigate } from "@tanstack/react-router"; | ||
| 3 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | ||
| 4 | import * as Atom from "effect/unstable/reactivity/Atom"; | ||
| 5 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 6 | import { ArrowLeftIcon, BoxIcon, PlugIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react"; | ||
| 7 | import { | ||
| 8 | createPluginAtomClient, | ||
| 9 | useIntegrationPlugins, | ||
| 10 | type IntegrationPlugin, | ||
| 11 | type PluginPageProps, | ||
| 12 | } from "@executor-js/sdk/client"; | ||
| 13 | import { | ||
| 14 | matchPattern, | ||
| 15 | type EffectivePolicy, | ||
| 16 | type Integration, | ||
| 17 | type Owner, | ||
| 18 | type ToolAddress, | ||
| 19 | type ToolPolicyAction, | ||
| 20 | } from "@executor-js/sdk/shared"; | ||
| 21 | import { integrationsOptimisticAtom, toolsAllAtom } from "@executor-js/react/api/atoms"; | ||
| 22 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; | ||
| 23 | import { useOrganizationSlug } from "@executor-js/react/api/organization-context"; | ||
| 24 | import { | ||
| 25 | getExecutorApiBaseUrl, | ||
| 26 | getExecutorOrganizationHeaders, | ||
| 27 | getExecutorServerAuthorizationHeader, | ||
| 28 | } from "@executor-js/react/api/server-connection"; | ||
| 29 | import { ownerLabel, useOwnerDisplay } from "@executor-js/react/api/owner-display"; | ||
| 30 | import { Badge } from "@executor-js/react/components/badge"; | ||
| 31 | import { Button } from "@executor-js/react/components/button"; | ||
| 32 | import { CopyButton } from "@executor-js/react/components/copy-button"; | ||
| 33 | import { | ||
| 34 | AlertDialog, | ||
| 35 | AlertDialogAction, | ||
| 36 | AlertDialogCancel, | ||
| 37 | AlertDialogContent, | ||
| 38 | AlertDialogDescription, | ||
| 39 | AlertDialogFooter, | ||
| 40 | AlertDialogHeader, | ||
| 41 | AlertDialogTitle, | ||
| 42 | AlertDialogTrigger, | ||
| 43 | } from "@executor-js/react/components/alert-dialog"; | ||
| 44 | import { | ||
| 45 | IntegrationFavicon, | ||
| 46 | integrationPresetIconUrl, | ||
| 47 | } from "@executor-js/react/components/integration-favicon"; | ||
| 48 | import { | ||
| 49 | Dialog, | ||
| 50 | DialogContent, | ||
| 51 | DialogDescription, | ||
| 52 | DialogFooter, | ||
| 53 | DialogHeader, | ||
| 54 | DialogTitle, | ||
| 55 | } from "@executor-js/react/components/dialog"; | ||
| 56 | import { Input } from "@executor-js/react/components/input"; | ||
| 57 | import { Label } from "@executor-js/react/components/label"; | ||
| 58 | import { Skeleton } from "@executor-js/react/components/skeleton"; | ||
| 59 | import { ToolDetail, ToolDetailEmpty } from "@executor-js/react/components/tool-detail"; | ||
| 60 | import { ToolTree, type ToolSummary } from "@executor-js/react/components/tool-tree"; | ||
| 61 | import { cn } from "@executor-js/react/lib/utils"; | ||
| 62 | |||
| 63 | import { | ||
| 64 | ToolkitsApi, | ||
| 65 | type ToolkitConnectionResponse, | ||
| 66 | type ToolkitPolicyResponse, | ||
| 67 | type ToolkitResponse, | ||
| 68 | } from "./shared"; | ||
| 69 | |||
| 70 | const ToolkitsClient = createPluginAtomClient(ToolkitsApi, { | ||
| 71 | baseUrl: getExecutorApiBaseUrl, | ||
| 72 | authorizationHeader: getExecutorServerAuthorizationHeader, | ||
| 73 | headers: getExecutorOrganizationHeaders, | ||
| 74 | }); | ||
| 75 | |||
| 76 | const toolkitWriteKeys = [ | ||
| 77 | ReactivityKey.connections, | ||
| 78 | ReactivityKey.policies, | ||
| 79 | ReactivityKey.tools, | ||
| 80 | ] as const; | ||
| 81 | |||
| 82 | const toolkitsAtom = ToolkitsClient.query("toolkits", "list", { | ||
| 83 | timeToLive: "30 seconds", | ||
| 84 | reactivityKeys: [ReactivityKey.policies], | ||
| 85 | }); | ||
| 86 | |||
| 87 | const toolkitPoliciesAtom = Atom.family((toolkitId: string) => | ||
| 88 | ToolkitsClient.query("toolkits", "listPolicies", { | ||
| 89 | params: { toolkitId }, | ||
| 90 | timeToLive: "30 seconds", | ||
| 91 | reactivityKeys: [ReactivityKey.policies], | ||
| 92 | }), | ||
| 93 | ); | ||
| 94 | |||
| 95 | const toolkitConnectionsAtom = Atom.family((toolkitId: string) => | ||
| 96 | ToolkitsClient.query("toolkits", "listConnections", { | ||
| 97 | params: { toolkitId }, | ||
| 98 | timeToLive: "30 seconds", | ||
| 99 | reactivityKeys: [ReactivityKey.connections], | ||
| 100 | }), | ||
| 101 | ); | ||
| 102 | |||
| 103 | const createToolkit = ToolkitsClient.mutation("toolkits", "create"); | ||
| 104 | const removeToolkit = ToolkitsClient.mutation("toolkits", "remove"); | ||
| 105 | const createToolkitPolicy = ToolkitsClient.mutation("toolkits", "createPolicy"); | ||
| 106 | const updateToolkitPolicy = ToolkitsClient.mutation("toolkits", "updatePolicy"); | ||
| 107 | const removeToolkitPolicy = ToolkitsClient.mutation("toolkits", "removePolicy"); | ||
| 108 | const createToolkitConnection = ToolkitsClient.mutation("toolkits", "createConnection"); | ||
| 109 | const removeToolkitConnection = ToolkitsClient.mutation("toolkits", "removeConnection"); | ||
| 110 | |||
| 111 | type ToolRow = { | ||
| 112 | readonly address: ToolAddress; | ||
| 113 | readonly integration: string; | ||
| 114 | readonly owner?: Owner; | ||
| 115 | readonly connection?: string; | ||
| 116 | readonly name: string; | ||
| 117 | readonly description?: string; | ||
| 118 | readonly requiresApproval?: boolean; | ||
| 119 | readonly static?: boolean; | ||
| 120 | }; | ||
| 121 | |||
| 122 | const comparePolicy = (a: ToolkitPolicyResponse, b: ToolkitPolicyResponse): number => { | ||
| 123 | if (a.position < b.position) return -1; | ||
| 124 | if (a.position > b.position) return 1; | ||
| 125 | return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; | ||
| 126 | }; | ||
| 127 | |||
| 128 | const pluginDefaultPolicy = (requiresApproval: boolean | undefined): EffectivePolicy => | ||
| 129 | requiresApproval | ||
| 130 | ? { action: "require_approval", source: "plugin-default" } | ||
| 131 | : { action: "approve", source: "plugin-default" }; | ||
| 132 | |||
| 133 | const isLegacyConnectionPolicy = (policy: ToolkitPolicyResponse): boolean => { | ||
| 134 | if (policy.action !== "approve") return false; | ||
| 135 | const parts = policy.pattern.split("."); | ||
| 136 | return parts.at(-1) === "*" && (parts.length === 3 || parts.length === 4); | ||
| 137 | }; | ||
| 138 | |||
| 139 | const resolveToolkitPolicy = ( | ||
| 140 | matchId: string, | ||
| 141 | policies: readonly ToolkitPolicyResponse[], | ||
| 142 | requiresApproval?: boolean, | ||
| 143 | ): EffectivePolicy => { | ||
| 144 | for (const policy of [...policies].sort(comparePolicy)) { | ||
| 145 | if (!matchPattern(policy.pattern, matchId)) continue; | ||
| 146 | return { | ||
| 147 | action: policy.action, | ||
| 148 | source: "user", | ||
| 149 | pattern: policy.pattern, | ||
| 150 | policyId: policy.id, | ||
| 151 | }; | ||
| 152 | } | ||
| 153 | return pluginDefaultPolicy(requiresApproval); | ||
| 154 | }; | ||
| 155 | |||
| 156 | const toolMatchId = (tool: ToolRow): string => | ||
| 157 | tool.static ? String(tool.address) : String(tool.address).replace(/^tools\./, ""); | ||
| 158 | |||
| 159 | const toolCanAppearInToolkit = (toolkit: ToolkitResponse, tool: ToolRow): boolean => | ||
| 160 | toolkit.owner === "user" || tool.static === true || tool.owner !== "user"; | ||
| 161 | |||
| 162 | const toolkitUrlFor = (orgSlug: string | undefined, slug: string): string => { | ||
| 163 | const path = orgSlug ? `/${orgSlug}/mcp/toolkits/${slug}` : `/mcp/toolkits/${slug}`; | ||
| 164 | if (typeof window === "undefined") return path; | ||
| 165 | return `${window.location.origin}${path}`; | ||
| 166 | }; | ||
| 167 | |||
| 168 | const identityPattern = (displayPattern: string): string => displayPattern; | ||
| 169 | |||
| 170 | const compareToolkitRows = (a: ToolkitResponse, b: ToolkitResponse): number => { | ||
| 171 | if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt; | ||
| 172 | return a.name.localeCompare(b.name); | ||
| 173 | }; | ||
| 174 | |||
| 175 | const toolkitByRouteSlug = ( | ||
| 176 | toolkits: readonly ToolkitResponse[], | ||
| 177 | slug: string, | ||
| 178 | ): ToolkitResponse | null => { | ||
| 179 | const matches = toolkits.filter((toolkit) => toolkit.slug === slug); | ||
| 180 | return matches.find((toolkit) => toolkit.owner === "org") ?? matches[0] ?? null; | ||
| 181 | }; | ||
| 182 | |||
| 183 | const toolkitCardStyle = { minHeight: "9rem" }; | ||
| 184 | const toolkitShelfStyle = { minHeight: "28.5rem" }; | ||
| 185 | const toolkitGridContainerStyle = { maxWidth: "80rem" }; | ||
| 186 | const toolkitToolTreeStyle = { width: "24rem" }; | ||
| 187 | |||
| 188 | const neutralToolkitIconClass = "border-border/70 bg-muted/30 text-muted-foreground"; | ||
| 189 | |||
| 190 | type ToolkitConnectionGroup = { | ||
| 191 | readonly id: string; | ||
| 192 | readonly owner: Owner; | ||
| 193 | readonly integration: string; | ||
| 194 | readonly connection: string; | ||
| 195 | readonly patterns: readonly string[]; | ||
| 196 | readonly tools: readonly ToolRow[]; | ||
| 197 | }; | ||
| 198 | |||
| 199 | type IntegrationMeta = { | ||
| 200 | readonly name: string; | ||
| 201 | readonly sourceId: string; | ||
| 202 | readonly icon?: string | null; | ||
| 203 | readonly url?: string; | ||
| 204 | }; | ||
| 205 | |||
| 206 | const toolOwner = (tool: ToolRow): Owner => (tool.static ? "org" : (tool.owner ?? "org")); | ||
| 207 | |||
| 208 | const toolConnectionName = (tool: ToolRow): string => { | ||
| 209 | if (tool.connection && tool.connection.length > 0) return tool.connection; | ||
| 210 | return tool.static ? "built-in" : "default"; | ||
| 211 | }; | ||
| 212 | |||
| 213 | const policyPrefixForTool = (tool: ToolRow): string => { | ||
| 214 | const matchId = toolMatchId(tool); | ||
| 215 | const integration = String(tool.integration); | ||
| 216 | if (tool.connection && tool.connection.length > 0) { | ||
| 217 | const connection = String(tool.connection); | ||
| 218 | if (tool.owner) { | ||
| 219 | const ownerConnectionPrefix = `${integration}.${tool.owner}.${connection}`; | ||
| 220 | if (matchId === ownerConnectionPrefix || matchId.startsWith(`${ownerConnectionPrefix}.`)) { | ||
| 221 | return ownerConnectionPrefix; | ||
| 222 | } | ||
| 223 | } | ||
| 224 | const connectionPrefix = `${integration}.${connection}`; | ||
| 225 | if (matchId === connectionPrefix || matchId.startsWith(`${connectionPrefix}.`)) { | ||
| 226 | return connectionPrefix; | ||
| 227 | } | ||
| 228 | } | ||
| 229 | const name = String(tool.name); | ||
| 230 | if (name.length > 0 && matchId.endsWith(`.${name}`)) { | ||
| 231 | return matchId.slice(0, -name.length - 1); | ||
| 232 | } | ||
| 233 | const segments = matchId.split("."); | ||
| 234 | return segments.length > 1 ? segments.slice(0, -1).join(".") : matchId; | ||
| 235 | }; | ||
| 236 | |||
| 237 | const connectionPatternForTool = (tool: ToolRow): string => `${policyPrefixForTool(tool)}.*`; | ||
| 238 | |||
| 239 | const compareConnectionGroups = (a: ToolkitConnectionGroup, b: ToolkitConnectionGroup): number => { | ||
| 240 | const ownerRank = (owner: Owner) => (owner === "org" ? 0 : 1); | ||
| 241 | return ( | ||
| 242 | ownerRank(a.owner) - ownerRank(b.owner) || | ||
| 243 | a.integration.localeCompare(b.integration) || | ||
| 244 | a.connection.localeCompare(b.connection) || | ||
| 245 | a.id.localeCompare(b.id) | ||
| 246 | ); | ||
| 247 | }; | ||
| 248 | |||
| 249 | const patternSubsumes = (candidate: string, covered: string): boolean => { | ||
| 250 | if (candidate === covered) return true; | ||
| 251 | if (!candidate.endsWith(".*")) return false; | ||
| 252 | const prefix = candidate.slice(0, -1); | ||
| 253 | return covered.startsWith(prefix); | ||
| 254 | }; | ||
| 255 | |||
| 256 | const reducePolicyPatterns = (patterns: readonly string[]): readonly string[] => { | ||
| 257 | const unique = [...new Set(patterns)].sort((a, b) => a.localeCompare(b)); | ||
| 258 | return unique.filter( | ||
| 259 | (pattern) => | ||
| 260 | !unique.some((candidate) => candidate !== pattern && patternSubsumes(candidate, pattern)), | ||
| 261 | ); | ||
| 262 | }; | ||
| 263 | |||
| 264 | const buildConnectionGroups = (tools: readonly ToolRow[]): readonly ToolkitConnectionGroup[] => { | ||
| 265 | const groups = new Map<string, ToolkitConnectionGroup & { tools: ToolRow[] }>(); | ||
| 266 | for (const tool of tools) { | ||
| 267 | const owner = toolOwner(tool); | ||
| 268 | const key = `${owner}:${tool.integration}:${toolConnectionName(tool)}`; | ||
| 269 | const existing = groups.get(key); | ||
| 270 | if (existing) { | ||
| 271 | existing.tools.push(tool); | ||
| 272 | continue; | ||
| 273 | } | ||
| 274 | groups.set(key, { | ||
| 275 | id: key, | ||
| 276 | owner, | ||
| 277 | integration: String(tool.integration), | ||
| 278 | connection: toolConnectionName(tool), | ||
| 279 | patterns: [], | ||
| 280 | tools: [tool], | ||
| 281 | }); | ||
| 282 | } | ||
| 283 | return [...groups.values()] | ||
| 284 | .map((group) => { | ||
| 285 | const sortedTools = [...group.tools].sort(compareTools); | ||
| 286 | return { | ||
| 287 | ...group, | ||
| 288 | patterns: reducePolicyPatterns(sortedTools.map(connectionPatternForTool)), | ||
| 289 | tools: sortedTools, | ||
| 290 | }; | ||
| 291 | }) | ||
| 292 | .sort(compareConnectionGroups); | ||
| 293 | }; | ||
| 294 | |||
| 295 | const compareTools = (a: ToolRow, b: ToolRow): number => | ||
| 296 | String(a.name).localeCompare(String(b.name)) || toolMatchId(a).localeCompare(toolMatchId(b)); | ||
| 297 | |||
| 298 | const connectionTitle = (group: ToolkitConnectionGroup): string => | ||
| 299 | `${ownerLabel(group.owner)} ${group.integration} / ${group.connection}`; | ||
| 300 | |||
| 301 | const connectionTitleForHost = (group: ToolkitConnectionGroup, showOwnerLabels: boolean): string => | ||
| 302 | showOwnerLabels ? connectionTitle(group) : `${group.integration} / ${group.connection}`; | ||
| 303 | |||
| 304 | const connectionDisplayTitle = (group: ToolkitConnectionGroup, meta: IntegrationMeta): string => | ||
| 305 | group.connection === "built-in" || group.connection === "default" ? meta.name : group.connection; | ||
| 306 | |||
| 307 | const connectionDisplaySubtitle = (group: ToolkitConnectionGroup, meta: IntegrationMeta): string => | ||
| 308 | group.connection === "built-in" || group.connection === "default" | ||
| 309 | ? `${group.tools.length} ${group.tools.length === 1 ? "tool" : "tools"}` | ||
| 310 | : `${meta.name} · ${group.tools.length} ${group.tools.length === 1 ? "tool" : "tools"}`; | ||
| 311 | |||
| 312 | const connectionDisplaySubtitleForHost = ( | ||
| 313 | group: ToolkitConnectionGroup, | ||
| 314 | meta: IntegrationMeta, | ||
| 315 | showOwnerLabels: boolean, | ||
| 316 | ): string => { | ||
| 317 | const subtitle = connectionDisplaySubtitle(group, meta); | ||
| 318 | return showOwnerLabels ? `${ownerLabel(group.owner)} · ${subtitle}` : subtitle; | ||
| 319 | }; | ||
| 320 | |||
| 321 | const integrationMetaFor = ( | ||
| 322 | group: ToolkitConnectionGroup, | ||
| 323 | integrations: readonly Integration[], | ||
| 324 | integrationPlugins: readonly IntegrationPlugin[], | ||
| 325 | ): IntegrationMeta => { | ||
| 326 | const integration = integrations.find((row) => String(row.slug) === group.integration); | ||
| 327 | const fallbackName = group.integration | ||
| 328 | .replace(/[_-]/g, " ") | ||
| 329 | .replace(/\b\w/g, (character) => character.toUpperCase()); | ||
| 330 | const source = { | ||
| 331 | id: group.integration, | ||
| 332 | kind: integration?.kind ?? group.integration, | ||
| 333 | name: integration?.name ?? fallbackName, | ||
| 334 | url: integration?.displayUrl, | ||
| 335 | }; | ||
| 336 | return { | ||
| 337 | name: source.name, | ||
| 338 | sourceId: group.integration, | ||
| 339 | icon: integrationPresetIconUrl(source, integrationPlugins), | ||
| 340 | ...(source.url ? { url: source.url } : {}), | ||
| 341 | }; | ||
| 342 | }; | ||
| 343 | |||
| 344 | type ConfiguredConnectionView = { | ||
| 345 | readonly id: string; | ||
| 346 | readonly title: string; | ||
| 347 | readonly subtitle: string; | ||
| 348 | readonly pattern: string; | ||
| 349 | readonly sourceId: string; | ||
| 350 | readonly icon?: string | null; | ||
| 351 | readonly url?: string; | ||
| 352 | }; | ||
| 353 | |||
| 354 | const configuredConnectionViews = ( | ||
| 355 | connections: readonly ToolkitConnectionResponse[], | ||
| 356 | connectionGroups: readonly ToolkitConnectionGroup[], | ||
| 357 | integrations: readonly Integration[], | ||
| 358 | integrationPlugins: readonly IntegrationPlugin[], | ||
| 359 | showOwnerLabels: boolean, | ||
| 360 | ): readonly ConfiguredConnectionView[] => | ||
| 361 | connections.map((connection) => { | ||
| 362 | const group = connectionGroups.find((candidate) => | ||
| 363 | candidate.patterns.includes(connection.pattern), | ||
| 364 | ); | ||
| 365 | if (!group) { | ||
| 366 | return { | ||
| 367 | id: connection.id, | ||
| 368 | title: connection.pattern, | ||
| 369 | subtitle: "Configured pattern", | ||
| 370 | pattern: connection.pattern, | ||
| 371 | sourceId: connection.pattern.split(".")[0] ?? "toolkit", | ||
| 372 | }; | ||
| 373 | } | ||
| 374 | const meta = integrationMetaFor(group, integrations, integrationPlugins); | ||
| 375 | return { | ||
| 376 | id: connection.id, | ||
| 377 | title: connectionDisplayTitle(group, meta), | ||
| 378 | subtitle: connectionDisplaySubtitleForHost(group, meta, showOwnerLabels), | ||
| 379 | pattern: connection.pattern, | ||
| 380 | sourceId: meta.sourceId, | ||
| 381 | icon: meta.icon, | ||
| 382 | ...(meta.url ? { url: meta.url } : {}), | ||
| 383 | }; | ||
| 384 | }); | ||
| 385 | |||
| 386 | const legacyConnectionPolicyIds = ( | ||
| 387 | policies: readonly ToolkitPolicyResponse[], | ||
| 388 | connectionGroups: readonly ToolkitConnectionGroup[], | ||
| 389 | connections: readonly ToolkitConnectionResponse[], | ||
| 390 | ): ReadonlySet<string> => { | ||
| 391 | const persistedPatterns = new Set(connections.map((connection) => connection.pattern)); | ||
| 392 | const connectionPatterns = new Set(connectionGroups.flatMap((group) => group.patterns)); | ||
| 393 | return new Set( | ||
| 394 | policies | ||
| 395 | .filter( | ||
| 396 | (policy) => | ||
| 397 | isLegacyConnectionPolicy(policy) && | ||
| 398 | connectionPatterns.has(policy.pattern) && | ||
| 399 | !persistedPatterns.has(policy.pattern), | ||
| 400 | ) | ||
| 401 | .map((policy) => policy.id), | ||
| 402 | ); | ||
| 403 | }; | ||
| 404 | |||
| 405 | const configuredConnectionPatterns = ( | ||
| 406 | connections: readonly ToolkitConnectionResponse[], | ||
| 407 | policies: readonly ToolkitPolicyResponse[], | ||
| 408 | legacyPolicyIds: ReadonlySet<string>, | ||
| 409 | ): ReadonlySet<string> => | ||
| 410 | new Set([ | ||
| 411 | ...connections.map((connection) => connection.pattern), | ||
| 412 | ...policies.filter((policy) => legacyPolicyIds.has(policy.id)).map((policy) => policy.pattern), | ||
| 413 | ]); | ||
| 414 | |||
| 415 | function ToolkitConnectionIconStack(props: { connections: readonly ConfiguredConnectionView[] }) { | ||
| 416 | const visibleConnections = props.connections.slice(0, 3); | ||
| 417 | if (visibleConnections.length === 0) { | ||
| 418 | return ( | ||
| 419 | <div | ||
| 420 | className={cn( | ||
| 421 | "flex size-9 shrink-0 items-center justify-center rounded-md border", | ||
| 422 | neutralToolkitIconClass, | ||
| 423 | )} | ||
| 424 | > | ||
| 425 | <BoxIcon className="size-4" /> | ||
| 426 | </div> | ||
| 427 | ); | ||
| 428 | } | ||
| 429 | |||
| 430 | return ( | ||
| 431 | <div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border/70 bg-muted/20"> | ||
| 432 | <div className="flex -space-x-2"> | ||
| 433 | {visibleConnections.map((connection) => ( | ||
| 434 | <span | ||
| 435 | key={connection.id} | ||
| 436 | className="flex size-5 items-center justify-center rounded-sm border border-background bg-card shadow-xs" | ||
| 437 | > | ||
| 438 | <IntegrationFavicon | ||
| 439 | icon={connection.icon} | ||
| 440 | sourceId={connection.sourceId} | ||
| 441 | url={connection.url} | ||
| 442 | size={14} | ||
| 443 | /> | ||
| 444 | </span> | ||
| 445 | ))} | ||
| 446 | </div> | ||
| 447 | </div> | ||
| 448 | ); | ||
| 449 | } | ||
| 450 | |||
| 451 | const connectionCountLabel = (count: number): string => | ||
| 452 | count === 0 ? "No connections" : `${count} ${count === 1 ? "connection" : "connections"}`; | ||
| 453 | |||
| 454 | function ToolkitTile(props: { showOwnerLabels: boolean; toolkit: ToolkitResponse }) { | ||
| 455 | const toolkit = props.toolkit; | ||
| 456 | const tools = useAtomValue(toolsAllAtom); | ||
| 457 | const integrations = useAtomValue(integrationsOptimisticAtom); | ||
| 458 | const connections = useAtomValue(toolkitConnectionsAtom(toolkit.id)); | ||
| 459 | const integrationPlugins = useIntegrationPlugins(); | ||
| 460 | const visibleTools = useMemo( | ||
| 461 | () => | ||
| 462 | AsyncResult.isSuccess(tools) | ||
| 463 | ? (tools.value as readonly ToolRow[]).filter((tool) => | ||
| 464 | toolCanAppearInToolkit(toolkit, tool), | ||
| 465 | ) | ||
| 466 | : [], | ||
| 467 | [toolkit, tools], | ||
| 468 | ); | ||
| 469 | const connectionGroups = useMemo(() => buildConnectionGroups(visibleTools), [visibleTools]); | ||
| 470 | const connectionRows = AsyncResult.isSuccess(connections) ? connections.value.connections : []; | ||
| 471 | const integrationRows = AsyncResult.isSuccess(integrations) | ||
| 472 | ? (integrations.value as readonly Integration[]) | ||
| 473 | : []; | ||
| 474 | const tileDataReady = | ||
| 475 | AsyncResult.isSuccess(connections) && | ||
| 476 | AsyncResult.isSuccess(tools) && | ||
| 477 | AsyncResult.isSuccess(integrations); | ||
| 478 | const configuredConnections = tileDataReady | ||
| 479 | ? configuredConnectionViews( | ||
| 480 | connectionRows, | ||
| 481 | connectionGroups, | ||
| 482 | integrationRows, | ||
| 483 | integrationPlugins, | ||
| 484 | props.showOwnerLabels, | ||
| 485 | ) | ||
| 486 | : []; | ||
| 487 | return ( | ||
| 488 | <Link | ||
| 489 | to="/{-$orgSlug}/toolkits/$toolkitSlug" | ||
| 490 | params={{ toolkitSlug: toolkit.slug }} | ||
| 491 | aria-label={`Open toolkit ${toolkit.name}`} | ||
| 492 | className="group flex min-h-36 min-w-0 self-start flex-col justify-between rounded-md border border-border/70 bg-card p-3.5 text-left text-card-foreground shadow-xs transition-[border-color,background-color,box-shadow] hover:border-foreground/25 hover:bg-muted/20 hover:shadow-sm focus-visible:ring-[3px] focus-visible:ring-ring/30 focus-visible:outline-none" | ||
| 493 | style={toolkitCardStyle} | ||
| 494 | > | ||
| 495 | <div className="flex min-w-0 items-start gap-3"> | ||
| 496 | {tileDataReady ? ( | ||
| 497 | <ToolkitConnectionIconStack connections={configuredConnections} /> | ||
| 498 | ) : ( | ||
| 499 | <Skeleton className="size-9 shrink-0 rounded-md" /> | ||
| 500 | )} | ||
| 501 | <div className="min-w-0 flex-1"> | ||
| 502 | <div className="truncate text-sm font-semibold text-foreground">{toolkit.name}</div> | ||
| 503 | <div className="mt-1 truncate text-xs text-muted-foreground"> | ||
| 504 | {tileDataReady ? connectionCountLabel(configuredConnections.length) : "Loading"} | ||
| 505 | </div> | ||
| 506 | </div> | ||
| 507 | </div> | ||
| 508 | </Link> | ||
| 509 | ); | ||
| 510 | } | ||
| 511 | |||
| 512 | const addToolkitScopeLabel = (owner: Owner, showOwnerLabels: boolean): string => | ||
| 513 | showOwnerLabels ? (owner === "org" ? "workspace" : "personal") : ""; | ||
| 514 | const addToolkitTitle = (owner: Owner, showOwnerLabels: boolean): string => | ||
| 515 | showOwnerLabels | ||
| 516 | ? owner === "org" | ||
| 517 | ? "New workspace toolkit" | ||
| 518 | : "New personal toolkit" | ||
| 519 | : "New toolkit"; | ||
| 520 | |||
| 521 | function CreateToolkitDialog(props: { | ||
| 522 | owner: Owner; | ||
| 523 | showOwnerLabels: boolean; | ||
| 524 | open: boolean; | ||
| 525 | onOpenChange: (open: boolean) => void; | ||
| 526 | onCreate: (input: { owner: Owner; name: string }) => Promise<void>; | ||
| 527 | }) { | ||
| 528 | const inputId = useId(); | ||
| 529 | const [name, setName] = useState(""); | ||
| 530 | const trimmed = name.trim(); | ||
| 531 | const title = addToolkitTitle(props.owner, props.showOwnerLabels); | ||
| 532 | |||
| 533 | const submit = async () => { | ||
| 534 | if (!trimmed) return; | ||
| 535 | await props.onCreate({ owner: props.owner, name: trimmed }); | ||
| 536 | setName(""); | ||
| 537 | props.onOpenChange(false); | ||
| 538 | }; | ||
| 539 | |||
| 540 | return ( | ||
| 541 | <Dialog | ||
| 542 | open={props.open} | ||
| 543 | onOpenChange={(nextOpen) => { | ||
| 544 | props.onOpenChange(nextOpen); | ||
| 545 | if (!nextOpen) setName(""); | ||
| 546 | }} | ||
| 547 | > | ||
| 548 | <DialogContent | ||
| 549 | className="flex flex-col gap-0 overflow-hidden rounded-md border-border/80 bg-card p-0 shadow-2xl" | ||
| 550 | style={{ width: "min(24rem, calc(100vw - 2rem))" }} | ||
| 551 | > | ||
| 552 | <form | ||
| 553 | className="grid" | ||
| 554 | onSubmit={(event) => { | ||
| 555 | event.preventDefault(); | ||
| 556 | void submit(); | ||
| 557 | }} | ||
| 558 | > | ||
| 559 | <DialogHeader className="px-4 pb-1 pt-4"> | ||
| 560 | <DialogTitle className="text-sm">{title}</DialogTitle> | ||
| 561 | <DialogDescription className="text-xs"> | ||
| 562 | Group tools and expose them at a dedicated MCP URL. | ||
| 563 | </DialogDescription> | ||
| 564 | </DialogHeader> | ||
| 565 | <div className="grid gap-1.5 px-4 py-3"> | ||
| 566 | <Label htmlFor={inputId} className="text-xs text-muted-foreground"> | ||
| 567 | Toolkit name | ||
| 568 | </Label> | ||
| 569 | <Input | ||
| 570 | id={inputId} | ||
| 571 | value={name} | ||
| 572 | onChange={(event) => setName(event.target.value)} | ||
| 573 | placeholder="e.g. Support tools" | ||
| 574 | autoFocus | ||
| 575 | className="h-9" | ||
| 576 | /> | ||
| 577 | </div> | ||
| 578 | <DialogFooter className="px-4 pb-4 pt-1"> | ||
| 579 | <Button | ||
| 580 | type="submit" | ||
| 581 | size="sm" | ||
| 582 | disabled={!trimmed} | ||
| 583 | aria-label="Create toolkit" | ||
| 584 | className="h-8" | ||
| 585 | > | ||
| 586 | Create toolkit | ||
| 587 | </Button> | ||
| 588 | </DialogFooter> | ||
| 589 | </form> | ||
| 590 | </DialogContent> | ||
| 591 | </Dialog> | ||
| 592 | ); | ||
| 593 | } | ||
| 594 | |||
| 595 | function AddToolkitCard(props: { owner: Owner; showOwnerLabels: boolean; onClick: () => void }) { | ||
| 596 | const scopeLabel = addToolkitScopeLabel(props.owner, props.showOwnerLabels); | ||
| 597 | return ( | ||
| 598 | <Button | ||
| 599 | type="button" | ||
| 600 | variant="ghost" | ||
| 601 | onClick={props.onClick} | ||
| 602 | aria-label={scopeLabel ? `Add ${scopeLabel} toolkit` : "Add toolkit"} | ||
| 603 | className="group flex h-auto min-h-36 min-w-0 self-start items-center justify-center rounded-md border border-dashed border-border/75 bg-card/40 p-0 text-muted-foreground transition-[border-color,background-color,box-shadow,color] hover:border-foreground/25 hover:bg-muted/20 hover:text-foreground hover:shadow-sm focus-visible:ring-[3px] focus-visible:ring-ring/30 focus-visible:outline-none" | ||
| 604 | style={toolkitCardStyle} | ||
| 605 | > | ||
| 606 | <span | ||
| 607 | className={cn( | ||
| 608 | "flex size-12 items-center justify-center rounded-md border transition-[border-color,background-color,color,transform]", | ||
| 609 | neutralToolkitIconClass, | ||
| 610 | "group-hover:scale-105", | ||
| 611 | )} | ||
| 612 | > | ||
| 613 | <PlusIcon className="size-6" /> | ||
| 614 | </span> | ||
| 615 | </Button> | ||
| 616 | ); | ||
| 617 | } | ||
| 618 | |||
| 619 | function ToolkitSection(props: { | ||
| 620 | owner: Owner; | ||
| 621 | title?: string; | ||
| 622 | showOwnerLabels: boolean; | ||
| 623 | toolkits: readonly ToolkitResponse[]; | ||
| 624 | onCreate: (input: { owner: Owner; name: string }) => Promise<void>; | ||
| 625 | }) { | ||
| 626 | const rows = [...props.toolkits].sort(compareToolkitRows); | ||
| 627 | const [createOpen, setCreateOpen] = useState(false); | ||
| 628 | const openCreate = () => setCreateOpen(true); | ||
| 629 | return ( | ||
| 630 | <section className="space-y-3"> | ||
| 631 | {props.title ? ( | ||
| 632 | <div className="border-b border-border/60 pb-2"> | ||
| 633 | <h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground"> | ||
| 634 | {props.title} | ||
| 635 | </h2> | ||
| 636 | </div> | ||
| 637 | ) : null} | ||
| 638 | |||
| 639 | <div | ||
| 640 | className="grid content-start grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3" | ||
| 641 | style={toolkitShelfStyle} | ||
| 642 | > | ||
| 643 | {rows.map((toolkit) => ( | ||
| 644 | <ToolkitTile key={toolkit.id} showOwnerLabels={props.showOwnerLabels} toolkit={toolkit} /> | ||
| 645 | ))} | ||
| 646 | <AddToolkitCard | ||
| 647 | owner={props.owner} | ||
| 648 | showOwnerLabels={props.showOwnerLabels} | ||
| 649 | onClick={openCreate} | ||
| 650 | /> | ||
| 651 | </div> | ||
| 652 | |||
| 653 | <CreateToolkitDialog | ||
| 654 | owner={props.owner} | ||
| 655 | showOwnerLabels={props.showOwnerLabels} | ||
| 656 | open={createOpen} | ||
| 657 | onOpenChange={setCreateOpen} | ||
| 658 | onCreate={props.onCreate} | ||
| 659 | /> | ||
| 660 | </section> | ||
| 661 | ); | ||
| 662 | } | ||
| 663 | |||
| 664 | function ToolkitGrid(props: { | ||
| 665 | toolkits: readonly ToolkitResponse[]; | ||
| 666 | onCreate: (input: { owner: Owner; name: string }) => Promise<void>; | ||
| 667 | }) { | ||
| 668 | const ownerDisplay = useOwnerDisplay(); | ||
| 669 | // Toolkit shelves are grouped by owner; the selected toolkit owns the page. | ||
| 670 | const workspaceToolkits = props.toolkits.filter((toolkit) => toolkit.owner === "org"); | ||
| 671 | const personalToolkits = props.toolkits.filter((toolkit) => toolkit.owner === "user"); | ||
| 672 | if (!ownerDisplay.showOwnerLabels) { | ||
| 673 | return ( | ||
| 674 | <div className="min-h-0 flex-1 overflow-y-auto"> | ||
| 675 | <div className="w-full space-y-7 px-4 py-4" style={toolkitGridContainerStyle}> | ||
| 676 | <ToolkitSection | ||
| 677 | owner="org" | ||
| 678 | showOwnerLabels={false} | ||
| 679 | toolkits={props.toolkits} | ||
| 680 | onCreate={props.onCreate} | ||
| 681 | /> | ||
| 682 | </div> | ||
| 683 | </div> | ||
| 684 | ); | ||
| 685 | } | ||
| 686 | |||
| 687 | return ( | ||
| 688 | <div className="min-h-0 flex-1 overflow-y-auto"> | ||
| 689 | <div className="w-full space-y-7 px-4 py-4" style={toolkitGridContainerStyle}> | ||
| 690 | <ToolkitSection | ||
| 691 | owner="org" | ||
| 692 | showOwnerLabels | ||
| 693 | title="Workspace" | ||
| 694 | toolkits={workspaceToolkits} | ||
| 695 | onCreate={props.onCreate} | ||
| 696 | /> | ||
| 697 | <ToolkitSection | ||
| 698 | owner="user" | ||
| 699 | showOwnerLabels | ||
| 700 | title="Personal" | ||
| 701 | toolkits={personalToolkits} | ||
| 702 | onCreate={props.onCreate} | ||
| 703 | /> | ||
| 704 | </div> | ||
| 705 | </div> | ||
| 706 | ); | ||
| 707 | } | ||
| 708 | |||
| 709 | function ToolkitContentsEmpty(props: { onManageConnections: () => void }) { | ||
| 710 | return ( | ||
| 711 | <div className="flex h-full items-center justify-center px-6 py-10"> | ||
| 712 | <div className="max-w-sm text-center"> | ||
| 713 | <div className="mx-auto flex size-11 items-center justify-center rounded-md border border-border/70 bg-muted/30 text-muted-foreground"> | ||
| 714 | <PlugIcon className="size-5" /> | ||
| 715 | </div> | ||
| 716 | <h3 className="mt-4 text-sm font-semibold text-foreground">No connections added</h3> | ||
| 717 | <p className="mt-1.5 text-sm leading-6 text-muted-foreground"> | ||
| 718 | Add a connection to decide what this toolkit exposes. | ||
| 719 | </p> | ||
| 720 | <Button type="button" size="sm" onClick={props.onManageConnections} className="mt-4"> | ||
| 721 | Manage connections | ||
| 722 | </Button> | ||
| 723 | </div> | ||
| 724 | </div> | ||
| 725 | ); | ||
| 726 | } | ||
| 727 | |||
| 728 | function ToolkitToolsPanel(props: { | ||
| 729 | tools: readonly ToolSummary[]; | ||
| 730 | selectedToolId: string | null; | ||
| 731 | policies: readonly ToolkitPolicyResponse[]; | ||
| 732 | onManageConnections: () => void; | ||
| 733 | onSelectTool: (toolId: string) => void; | ||
| 734 | onSetPolicy: (pattern: string, action: ToolPolicyAction) => void; | ||
| 735 | onClearPolicy: (pattern: string) => void; | ||
| 736 | }) { | ||
| 737 | return ( | ||
| 738 | <div | ||
| 739 | role="region" | ||
| 740 | aria-label="Toolkit tools" | ||
| 741 | className="flex min-h-0 shrink-0 flex-col border-r border-border/60" | ||
| 742 | style={toolkitToolTreeStyle} | ||
| 743 | > | ||
| 744 | {props.tools.length === 0 ? ( | ||
| 745 | <ToolkitContentsEmpty onManageConnections={props.onManageConnections} /> | ||
| 746 | ) : ( | ||
| 747 | <ToolTree | ||
| 748 | tools={props.tools} | ||
| 749 | selectedToolId={props.selectedToolId} | ||
| 750 | onSelect={props.onSelectTool} | ||
| 751 | onSetPolicy={props.onSetPolicy} | ||
| 752 | onClearPolicy={props.onClearPolicy} | ||
| 753 | patternForDisplay={identityPattern} | ||
| 754 | policies={props.policies} | ||
| 755 | groupByConnection | ||
| 756 | /> | ||
| 757 | )} | ||
| 758 | </div> | ||
| 759 | ); | ||
| 760 | } | ||
| 761 | |||
| 762 | function AddConnectionDialog(props: { | ||
| 763 | open: boolean; | ||
| 764 | groups: readonly ToolkitConnectionGroup[]; | ||
| 765 | configuredConnections: readonly ConfiguredConnectionView[]; | ||
| 766 | hiddenPersonalConnectionCount: number; | ||
| 767 | integrations: readonly Integration[]; | ||
| 768 | integrationPlugins: readonly IntegrationPlugin[]; | ||
| 769 | showOwnerLabels: boolean; | ||
| 770 | onOpenChange: (open: boolean) => void; | ||
| 771 | onAddPatterns: (patterns: readonly string[]) => Promise<void> | void; | ||
| 772 | onRemoveConnection: (connectionId: string) => Promise<void> | void; | ||
| 773 | }) { | ||
| 774 | const searchId = useId(); | ||
| 775 | const [query, setQuery] = useState(""); | ||
| 776 | const trimmedQuery = query.trim().toLowerCase(); | ||
| 777 | const filteredGroups = useMemo(() => { | ||
| 778 | if (!trimmedQuery) return props.groups; | ||
| 779 | return props.groups.filter((group) => { | ||
| 780 | const corpus = [ | ||
| 781 | connectionTitle(group), | ||
| 782 | ...group.patterns, | ||
| 783 | ownerLabel(group.owner), | ||
| 784 | ...group.tools.flatMap((tool) => [tool.name, tool.description ?? ""]), | ||
| 785 | ] | ||
| 786 | .join(" ") | ||
| 787 | .toLowerCase(); | ||
| 788 | return corpus.includes(trimmedQuery); | ||
| 789 | }); | ||
| 790 | }, [props.groups, trimmedQuery]); | ||
| 791 | |||
| 792 | const addConnection = async (patterns: readonly string[]) => { | ||
| 793 | await props.onAddPatterns(patterns); | ||
| 794 | }; | ||
| 795 | |||
| 796 | const removeConnection = async (connectionId: string) => { | ||
| 797 | await props.onRemoveConnection(connectionId); | ||
| 798 | }; | ||
| 799 | |||
| 800 | return ( | ||
| 801 | <Dialog | ||
| 802 | open={props.open} | ||
| 803 | onOpenChange={(nextOpen) => { | ||
| 804 | props.onOpenChange(nextOpen); | ||
| 805 | if (!nextOpen) { | ||
| 806 | setQuery(""); | ||
| 807 | } | ||
| 808 | }} | ||
| 809 | > | ||
| 810 | <DialogContent | ||
| 811 | className="gap-0 overflow-hidden rounded-md border-border/80 bg-card p-0 shadow-2xl" | ||
| 812 | style={{ | ||
| 813 | width: "min(36rem, calc(100vw - 2rem))", | ||
| 814 | maxWidth: "min(36rem, calc(100vw - 2rem))", | ||
| 815 | maxHeight: "min(42rem, calc(100vh - 4rem))", | ||
| 816 | }} | ||
| 817 | > | ||
| 818 | <DialogHeader className="border-b border-border/60 px-5 py-4"> | ||
| 819 | <div className="flex items-start gap-3"> | ||
| 820 | <span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border/70 bg-muted/30 text-muted-foreground"> | ||
| 821 | <PlugIcon className="size-4" /> | ||
| 822 | </span> | ||
| 823 | <div className="min-w-0"> | ||
| 824 | <DialogTitle className="text-sm">Manage connections</DialogTitle> | ||
| 825 | <DialogDescription className="mt-1 text-xs"> | ||
| 826 | Choose which connected accounts this toolkit can use. | ||
| 827 | </DialogDescription> | ||
| 828 | </div> | ||
| 829 | </div> | ||
| 830 | </DialogHeader> | ||
| 831 | |||
| 832 | <div className="flex min-h-0 flex-col overflow-hidden"> | ||
| 833 | <div className="border-b border-border/50 p-3"> | ||
| 834 | <Label htmlFor={searchId} className="sr-only"> | ||
| 835 | Search connections and tools | ||
| 836 | </Label> | ||
| 837 | <div className="flex items-center gap-2 rounded-md border border-border/70 bg-muted/20 px-3 transition-colors focus-within:border-primary/35 focus-within:bg-muted/25 focus-within:ring-1 focus-within:ring-primary/15"> | ||
| 838 | <SearchIcon className="size-3.5 shrink-0 text-muted-foreground" /> | ||
| 839 | <Input | ||
| 840 | id={searchId} | ||
| 841 | value={query} | ||
| 842 | onChange={(event) => setQuery(event.target.value)} | ||
| 843 | placeholder="Search connections" | ||
| 844 | className="h-8 min-w-0 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0" | ||
| 845 | /> | ||
| 846 | </div> | ||
| 847 | {props.hiddenPersonalConnectionCount > 0 ? ( | ||
| 848 | <p className="mt-2 px-1 text-xs leading-5 text-muted-foreground"> | ||
| 849 | You have {props.hiddenPersonalConnectionCount} personal{" "} | ||
| 850 | {props.hiddenPersonalConnectionCount === 1 ? "connection" : "connections"} that{" "} | ||
| 851 | {props.hiddenPersonalConnectionCount === 1 ? "is" : "are"} not shown because this is | ||
| 852 | a shared toolkit. | ||
| 853 | </p> | ||
| 854 | ) : null} | ||
| 855 | </div> | ||
| 856 | <div className="min-h-0 overflow-y-auto p-3" style={{ maxHeight: "32rem" }}> | ||
| 857 | <div className="mb-2 flex items-center justify-between px-1"> | ||
| 858 | <span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"> | ||
| 859 | Connections | ||
| 860 | </span> | ||
| 861 | <span className="text-xs tabular-nums text-muted-foreground"> | ||
| 862 | {filteredGroups.length} | ||
| 863 | </span> | ||
| 864 | </div> | ||
| 865 | {filteredGroups.length === 0 ? ( | ||
| 866 | <div className="rounded-md border border-dashed border-border/70 px-4 py-8 text-center text-xs text-muted-foreground"> | ||
| 867 | No connections match. | ||
| 868 | </div> | ||
| 869 | ) : ( | ||
| 870 | <div className="overflow-hidden rounded-md border border-border/70 bg-background/30"> | ||
| 871 | {filteredGroups.map((group) => { | ||
| 872 | const title = connectionTitleForHost(group, props.showOwnerLabels); | ||
| 873 | const configuredConnection = props.configuredConnections.find((connection) => | ||
| 874 | group.patterns.includes(connection.pattern), | ||
| 875 | ); | ||
| 876 | const added = configuredConnection !== undefined; | ||
| 877 | const meta = integrationMetaFor( | ||
| 878 | group, | ||
| 879 | props.integrations, | ||
| 880 | props.integrationPlugins, | ||
| 881 | ); | ||
| 882 | return ( | ||
| 883 | <div | ||
| 884 | key={group.id} | ||
| 885 | className="flex min-w-0 items-center gap-3 border-b border-border/50 px-3 py-2.5 last:border-b-0 hover:bg-muted/20" | ||
| 886 | > | ||
| 887 | <span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border/70 bg-muted/30"> | ||
| 888 | <IntegrationFavicon | ||
| 889 | icon={meta.icon} | ||
| 890 | sourceId={meta.sourceId} | ||
| 891 | url={meta.url} | ||
| 892 | size={18} | ||
| 893 | /> | ||
| 894 | </span> | ||
| 895 | <div className="min-w-0 flex-1 space-y-1"> | ||
| 896 | <div className="flex min-w-0 items-center gap-2"> | ||
| 897 | <span className="truncate text-xs font-medium text-foreground"> | ||
| 898 | {connectionDisplayTitle(group, meta)} | ||
| 899 | </span> | ||
| 900 | {props.showOwnerLabels ? ( | ||
| 901 | <Badge variant="outline" className="shrink-0 text-[10px]"> | ||
| 902 | {ownerLabel(group.owner)} | ||
| 903 | </Badge> | ||
| 904 | ) : null} | ||
| 905 | </div> | ||
| 906 | <div className="flex min-w-0 items-center gap-2"> | ||
| 907 | <span className="truncate text-[11px] text-muted-foreground"> | ||
| 908 | {connectionDisplaySubtitle(group, meta)} | ||
| 909 | </span> | ||
| 910 | </div> | ||
| 911 | </div> | ||
| 912 | <Button | ||
| 913 | type="button" | ||
| 914 | variant={added ? "outline" : "default"} | ||
| 915 | size="sm" | ||
| 916 | aria-label={`${added ? "Remove connection" : "Add connection"} ${title}`} | ||
| 917 | onClick={() => | ||
| 918 | void (added && configuredConnection | ||
| 919 | ? removeConnection(configuredConnection.id) | ||
| 920 | : addConnection(group.patterns)) | ||
| 921 | } | ||
| 922 | className={cn( | ||
| 923 | "h-7 shrink-0 px-2.5 text-xs", | ||
| 924 | added | ||
| 925 | ? "border-border/70 bg-transparent text-muted-foreground hover:text-destructive" | ||
| 926 | : "bg-primary/10 text-primary hover:bg-primary/15", | ||
| 927 | )} | ||
| 928 | > | ||
| 929 | {added ? ( | ||
| 930 | <Trash2Icon className="size-3.5" /> | ||
| 931 | ) : ( | ||
| 932 | <PlusIcon className="size-3.5" /> | ||
| 933 | )} | ||
| 934 | {added ? "Remove" : "Add"} | ||
| 935 | </Button> | ||
| 936 | </div> | ||
| 937 | ); | ||
| 938 | })} | ||
| 939 | </div> | ||
| 940 | )} | ||
| 941 | </div> | ||
| 942 | </div> | ||
| 943 | </DialogContent> | ||
| 944 | </Dialog> | ||
| 945 | ); | ||
| 946 | } | ||
| 947 | |||
| 948 | function ToolkitHeader(props: { | ||
| 949 | toolkit: ToolkitResponse; | ||
| 950 | showOwnerLabels: boolean; | ||
| 951 | mcpUrl: string; | ||
| 952 | onBack: () => void; | ||
| 953 | onManageConnections: () => void; | ||
| 954 | onRemove: () => void; | ||
| 955 | }) { | ||
| 956 | return ( | ||
| 957 | <div className="shrink-0 border-b border-border/60 bg-background/95 px-5 py-3"> | ||
| 958 | <div className="flex min-w-0 flex-wrap items-start justify-between gap-x-6 gap-y-3"> | ||
| 959 | <div className="min-w-0 flex-1"> | ||
| 960 | <Button | ||
| 961 | type="button" | ||
| 962 | variant="ghost" | ||
| 963 | size="xs" | ||
| 964 | onClick={props.onBack} | ||
| 965 | className="-ml-1 h-6 px-1 text-xs text-muted-foreground hover:text-foreground" | ||
| 966 | > | ||
| 967 | <ArrowLeftIcon className="size-3.5" /> | ||
| 968 | Toolkits | ||
| 969 | </Button> | ||
| 970 | <div className="mt-1 flex min-w-0 items-center gap-2"> | ||
| 971 | <h2 className="truncate text-sm font-semibold text-foreground">{props.toolkit.name}</h2> | ||
| 972 | {props.showOwnerLabels ? ( | ||
| 973 | <Badge variant="outline" className="text-[10px]"> | ||
| 974 | {ownerLabel(props.toolkit.owner)} | ||
| 975 | </Badge> | ||
| 976 | ) : null} | ||
| 977 | </div> | ||
| 978 | <div className="mt-2 flex max-w-3xl min-w-0 items-center gap-2 rounded-md bg-muted/20 px-2 py-1 text-xs text-muted-foreground ring-1 ring-border/40"> | ||
| 979 | <span className="shrink-0 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/70"> | ||
| 980 | MCP | ||
| 981 | </span> | ||
| 982 | <code className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground"> | ||
| 983 | {props.mcpUrl} | ||
| 984 | </code> | ||
| 985 | <CopyButton value={props.mcpUrl} className="-mr-1" /> | ||
| 986 | </div> | ||
| 987 | </div> | ||
| 988 | <div className="flex shrink-0 items-center gap-1 pt-0.5"> | ||
| 989 | <Button | ||
| 990 | type="button" | ||
| 991 | variant="ghost" | ||
| 992 | size="sm" | ||
| 993 | aria-label="Manage toolkit connections" | ||
| 994 | onClick={props.onManageConnections} | ||
| 995 | className="h-8 text-muted-foreground hover:text-foreground" | ||
| 996 | > | ||
| 997 | <PlugIcon className="size-3.5" /> | ||
| 998 | Connections | ||
| 999 | </Button> | ||
| 1000 | <AlertDialog> | ||
| 1001 | <AlertDialogTrigger asChild> | ||
| 1002 | <Button | ||
| 1003 | type="button" | ||
| 1004 | variant="ghost" | ||
| 1005 | size="icon-sm" | ||
| 1006 | aria-label="Delete toolkit" | ||
| 1007 | className="text-muted-foreground hover:text-destructive" | ||
| 1008 | > | ||
| 1009 | <Trash2Icon className="size-3.5" /> | ||
| 1010 | </Button> | ||
| 1011 | </AlertDialogTrigger> | ||
| 1012 | <AlertDialogContent size="sm"> | ||
| 1013 | <AlertDialogHeader> | ||
| 1014 | <AlertDialogTitle>Delete {props.toolkit.name}?</AlertDialogTitle> | ||
| 1015 | <AlertDialogDescription> | ||
| 1016 | This removes the toolkit and its dedicated MCP endpoint. Connections are not | ||
| 1017 | deleted. | ||
| 1018 | </AlertDialogDescription> | ||
| 1019 | </AlertDialogHeader> | ||
| 1020 | <AlertDialogFooter> | ||
| 1021 | <AlertDialogCancel>Cancel</AlertDialogCancel> | ||
| 1022 | <AlertDialogAction variant="destructive" onClick={props.onRemove}> | ||
| 1023 | Delete | ||
| 1024 | </AlertDialogAction> | ||
| 1025 | </AlertDialogFooter> | ||
| 1026 | </AlertDialogContent> | ||
| 1027 | </AlertDialog> | ||
| 1028 | </div> | ||
| 1029 | </div> | ||
| 1030 | </div> | ||
| 1031 | ); | ||
| 1032 | } | ||
| 1033 | |||
| 1034 | function ToolkitWorkspace(props: { | ||
| 1035 | toolkit: ToolkitResponse; | ||
| 1036 | showOwnerLabels: boolean; | ||
| 1037 | policies: readonly ToolkitPolicyResponse[]; | ||
| 1038 | connections: readonly ToolkitConnectionResponse[]; | ||
| 1039 | tools: readonly ToolRow[]; | ||
| 1040 | integrations: readonly Integration[]; | ||
| 1041 | integrationPlugins: readonly IntegrationPlugin[]; | ||
| 1042 | mcpUrl: string; | ||
| 1043 | onBack: () => void; | ||
| 1044 | onRemoveToolkit: () => void; | ||
| 1045 | onAddConnection: (pattern: string) => Promise<void> | void; | ||
| 1046 | onRemoveConnection: (connectionId: string) => Promise<void> | void; | ||
| 1047 | onSetPolicy: (pattern: string, action: ToolPolicyAction) => Promise<void> | void; | ||
| 1048 | onClearPolicy: (pattern: string) => Promise<void> | void; | ||
| 1049 | }) { | ||
| 1050 | const [addOpen, setAddOpen] = useState(false); | ||
| 1051 | const [selectedToolId, setSelectedToolId] = useState<string | null>(null); | ||
| 1052 | const visibleTools = useMemo( | ||
| 1053 | () => props.tools.filter((tool) => toolCanAppearInToolkit(props.toolkit, tool)), | ||
| 1054 | [props.toolkit, props.tools], | ||
| 1055 | ); | ||
| 1056 | const connectionGroups = useMemo(() => buildConnectionGroups(visibleTools), [visibleTools]); | ||
| 1057 | const hiddenPersonalConnectionCount = useMemo(() => { | ||
| 1058 | if (props.toolkit.owner !== "org") return 0; | ||
| 1059 | return buildConnectionGroups(props.tools.filter((tool) => toolOwner(tool) === "user")).length; | ||
| 1060 | }, [props.toolkit.owner, props.tools]); | ||
| 1061 | const configuredConnections = useMemo( | ||
| 1062 | () => | ||
| 1063 | configuredConnectionViews( | ||
| 1064 | props.connections, | ||
| 1065 | connectionGroups, | ||
| 1066 | props.integrations, | ||
| 1067 | props.integrationPlugins, | ||
| 1068 | props.showOwnerLabels, | ||
| 1069 | ), | ||
| 1070 | [ | ||
| 1071 | connectionGroups, | ||
| 1072 | props.connections, | ||
| 1073 | props.integrationPlugins, | ||
| 1074 | props.integrations, | ||
| 1075 | props.showOwnerLabels, | ||
| 1076 | ], | ||
| 1077 | ); | ||
| 1078 | const legacyPolicyIds = useMemo( | ||
| 1079 | () => legacyConnectionPolicyIds(props.policies, connectionGroups, props.connections), | ||
| 1080 | [connectionGroups, props.connections, props.policies], | ||
| 1081 | ); | ||
| 1082 | const accessPolicies = useMemo( | ||
| 1083 | () => props.policies.filter((policy) => !legacyPolicyIds.has(policy.id)), | ||
| 1084 | [legacyPolicyIds, props.policies], | ||
| 1085 | ); | ||
| 1086 | const connectionPatterns = useMemo( | ||
| 1087 | () => configuredConnectionPatterns(props.connections, props.policies, legacyPolicyIds), | ||
| 1088 | [legacyPolicyIds, props.connections, props.policies], | ||
| 1089 | ); | ||
| 1090 | const configuredToolIds = useMemo(() => { | ||
| 1091 | const ids = new Set<string>(); | ||
| 1092 | for (const tool of visibleTools) { | ||
| 1093 | const id = toolMatchId(tool); | ||
| 1094 | if ([...connectionPatterns].some((pattern) => matchPattern(pattern, id))) ids.add(id); | ||
| 1095 | } | ||
| 1096 | return ids; | ||
| 1097 | }, [connectionPatterns, visibleTools]); | ||
| 1098 | const configuredTools = useMemo( | ||
| 1099 | () => | ||
| 1100 | visibleTools.filter((tool) => configuredToolIds.has(toolMatchId(tool))).sort(compareTools), | ||
| 1101 | [visibleTools, configuredToolIds], | ||
| 1102 | ); | ||
| 1103 | const toolkitTools: ToolSummary[] = useMemo( | ||
| 1104 | () => | ||
| 1105 | configuredTools.map((tool) => { | ||
| 1106 | const id = toolMatchId(tool); | ||
| 1107 | return { | ||
| 1108 | id, | ||
| 1109 | name: id, | ||
| 1110 | description: tool.description, | ||
| 1111 | policy: resolveToolkitPolicy(id, accessPolicies, tool.requiresApproval), | ||
| 1112 | owner: toolOwner(tool), | ||
| 1113 | connection: toolConnectionName(tool), | ||
| 1114 | integration: String(tool.integration), | ||
| 1115 | }; | ||
| 1116 | }), | ||
| 1117 | [accessPolicies, configuredTools], | ||
| 1118 | ); | ||
| 1119 | const selectedTool = selectedToolId | ||
| 1120 | ? (configuredTools.find((tool) => toolMatchId(tool) === selectedToolId) ?? null) | ||
| 1121 | : null; | ||
| 1122 | const selectedToolPolicy = selectedTool | ||
| 1123 | ? (toolkitTools.find((tool) => tool.id === selectedToolId)?.policy ?? null) | ||
| 1124 | : null; | ||
| 1125 | |||
| 1126 | return ( | ||
| 1127 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> | ||
| 1128 | <ToolkitHeader | ||
| 1129 | toolkit={props.toolkit} | ||
| 1130 | showOwnerLabels={props.showOwnerLabels} | ||
| 1131 | mcpUrl={props.mcpUrl} | ||
| 1132 | onBack={props.onBack} | ||
| 1133 | onManageConnections={() => setAddOpen(true)} | ||
| 1134 | onRemove={props.onRemoveToolkit} | ||
| 1135 | /> | ||
| 1136 | |||
| 1137 | <div className="flex min-h-0 flex-1 overflow-hidden"> | ||
| 1138 | <ToolkitToolsPanel | ||
| 1139 | tools={toolkitTools} | ||
| 1140 | selectedToolId={selectedToolId} | ||
| 1141 | policies={accessPolicies} | ||
| 1142 | onManageConnections={() => setAddOpen(true)} | ||
| 1143 | onSelectTool={setSelectedToolId} | ||
| 1144 | onSetPolicy={(pattern, action) => void props.onSetPolicy(pattern, action)} | ||
| 1145 | onClearPolicy={(pattern) => void props.onClearPolicy(pattern)} | ||
| 1146 | /> | ||
| 1147 | <div className="flex min-w-0 flex-1 flex-col overflow-hidden"> | ||
| 1148 | {selectedTool && selectedToolPolicy ? ( | ||
| 1149 | <ToolDetail | ||
| 1150 | address={selectedTool.address} | ||
| 1151 | toolName={toolMatchId(selectedTool)} | ||
| 1152 | staticTool={selectedTool.static === true} | ||
| 1153 | policy={selectedToolPolicy} | ||
| 1154 | onSetPolicy={props.onSetPolicy} | ||
| 1155 | onClearPolicy={props.onClearPolicy} | ||
| 1156 | patternForDisplay={identityPattern} | ||
| 1157 | /> | ||
| 1158 | ) : ( | ||
| 1159 | <ToolDetailEmpty hasTools={toolkitTools.length > 0} /> | ||
| 1160 | )} | ||
| 1161 | </div> | ||
| 1162 | </div> | ||
| 1163 | |||
| 1164 | <AddConnectionDialog | ||
| 1165 | open={addOpen} | ||
| 1166 | onOpenChange={setAddOpen} | ||
| 1167 | groups={connectionGroups} | ||
| 1168 | configuredConnections={configuredConnections} | ||
| 1169 | hiddenPersonalConnectionCount={hiddenPersonalConnectionCount} | ||
| 1170 | integrations={props.integrations} | ||
| 1171 | integrationPlugins={props.integrationPlugins} | ||
| 1172 | showOwnerLabels={props.showOwnerLabels} | ||
| 1173 | onAddPatterns={async (patterns) => { | ||
| 1174 | for (const pattern of patterns) { | ||
| 1175 | await props.onAddConnection(pattern); | ||
| 1176 | } | ||
| 1177 | }} | ||
| 1178 | onRemoveConnection={props.onRemoveConnection} | ||
| 1179 | /> | ||
| 1180 | </div> | ||
| 1181 | ); | ||
| 1182 | } | ||
| 1183 | |||
| 1184 | function ToolkitTileSkeleton(props: { index: number }) { | ||
| 1185 | return ( | ||
| 1186 | <div | ||
| 1187 | className="flex min-h-36 min-w-0 items-start gap-3 rounded-md border border-border/60 bg-card/35 p-3.5" | ||
| 1188 | style={toolkitCardStyle} | ||
| 1189 | aria-hidden="true" | ||
| 1190 | > | ||
| 1191 | <Skeleton className="size-9 shrink-0 rounded-md" /> | ||
| 1192 | <div className="min-w-0 flex-1 space-y-2 pt-0.5"> | ||
| 1193 | <Skeleton className="h-4" style={{ width: `${52 + ((props.index * 17) % 28)}%` }} /> | ||
| 1194 | <Skeleton className="h-3" style={{ width: `${34 + ((props.index * 11) % 24)}%` }} /> | ||
| 1195 | </div> | ||
| 1196 | </div> | ||
| 1197 | ); | ||
| 1198 | } | ||
| 1199 | |||
| 1200 | function ToolkitSectionSkeleton(props: { title?: string; offset: number }) { | ||
| 1201 | return ( | ||
| 1202 | <section className="space-y-3" aria-label="Loading toolkits"> | ||
| 1203 | {props.title ? ( | ||
| 1204 | <div className="border-b border-border/60 pb-2"> | ||
| 1205 | <h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground"> | ||
| 1206 | {props.title} | ||
| 1207 | </h2> | ||
| 1208 | </div> | ||
| 1209 | ) : null} | ||
| 1210 | |||
| 1211 | <div className="grid content-start grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3"> | ||
| 1212 | {Array.from({ length: 3 }).map((_, index) => ( | ||
| 1213 | <ToolkitTileSkeleton key={index} index={props.offset + index} /> | ||
| 1214 | ))} | ||
| 1215 | </div> | ||
| 1216 | </section> | ||
| 1217 | ); | ||
| 1218 | } | ||
| 1219 | |||
| 1220 | function ToolkitGridSkeleton(props: { showOwnerLabels: boolean }) { | ||
| 1221 | return ( | ||
| 1222 | <div className="min-h-0 flex-1 overflow-y-auto"> | ||
| 1223 | <div className="w-full space-y-7 px-4 py-4" style={toolkitGridContainerStyle}> | ||
| 1224 | {props.showOwnerLabels ? ( | ||
| 1225 | <> | ||
| 1226 | <ToolkitSectionSkeleton title="Workspace" offset={0} /> | ||
| 1227 | <ToolkitSectionSkeleton title="Personal" offset={3} /> | ||
| 1228 | </> | ||
| 1229 | ) : ( | ||
| 1230 | <ToolkitSectionSkeleton offset={0} /> | ||
| 1231 | )} | ||
| 1232 | </div> | ||
| 1233 | </div> | ||
| 1234 | ); | ||
| 1235 | } | ||
| 1236 | |||
| 1237 | function ToolkitDetailSkeleton() { | ||
| 1238 | return ( | ||
| 1239 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> | ||
| 1240 | <div className="shrink-0 border-b border-border/60 bg-background/95 px-4 py-3"> | ||
| 1241 | <Skeleton className="mb-3 h-7 w-20 rounded-md" /> | ||
| 1242 | <div className="space-y-2"> | ||
| 1243 | <div className="flex min-w-0 items-center gap-2"> | ||
| 1244 | <Skeleton className="h-4 w-40" /> | ||
| 1245 | <Skeleton className="h-5 w-16 rounded-full" /> | ||
| 1246 | <Skeleton className="h-3 w-12" /> | ||
| 1247 | </div> | ||
| 1248 | <Skeleton className="h-6 w-full max-w-md" /> | ||
| 1249 | </div> | ||
| 1250 | </div> | ||
| 1251 | |||
| 1252 | <div className="flex min-h-0 flex-1 overflow-hidden"> | ||
| 1253 | <div | ||
| 1254 | className="flex min-h-0 shrink-0 flex-col border-r border-border/60" | ||
| 1255 | style={toolkitToolTreeStyle} | ||
| 1256 | > | ||
| 1257 | <div className="border-b border-border/60 px-3 py-3"> | ||
| 1258 | <div className="flex items-center justify-between gap-3"> | ||
| 1259 | <div className="space-y-2"> | ||
| 1260 | <Skeleton className="h-3 w-28" /> | ||
| 1261 | <Skeleton className="h-3 w-14" /> | ||
| 1262 | </div> | ||
| 1263 | <Skeleton className="h-8 w-16 rounded-md" /> | ||
| 1264 | </div> | ||
| 1265 | </div> | ||
| 1266 | <div className="space-y-1 p-3"> | ||
| 1267 | {Array.from({ length: 9 }).map((_, index) => ( | ||
| 1268 | <div key={index} className="flex items-center gap-2 rounded-md px-2 py-1.5"> | ||
| 1269 | <Skeleton className="size-4 shrink-0 rounded" /> | ||
| 1270 | <Skeleton className="h-3.5" style={{ width: `${48 + ((index * 13) % 38)}%` }} /> | ||
| 1271 | </div> | ||
| 1272 | ))} | ||
| 1273 | </div> | ||
| 1274 | </div> | ||
| 1275 | <div className="flex min-w-0 flex-1 flex-col gap-5 overflow-hidden p-6"> | ||
| 1276 | <div className="space-y-2"> | ||
| 1277 | <Skeleton className="h-5 w-52" /> | ||
| 1278 | <Skeleton className="h-4 w-full max-w-lg" /> | ||
| 1279 | <Skeleton className="h-4 w-full max-w-sm" /> | ||
| 1280 | </div> | ||
| 1281 | <div className="space-y-3"> | ||
| 1282 | <Skeleton className="h-4 w-24" /> | ||
| 1283 | <Skeleton className="h-24 w-full rounded-md" /> | ||
| 1284 | <Skeleton className="h-4 w-28" /> | ||
| 1285 | <Skeleton className="h-24 w-full rounded-md" /> | ||
| 1286 | </div> | ||
| 1287 | </div> | ||
| 1288 | </div> | ||
| 1289 | </div> | ||
| 1290 | ); | ||
| 1291 | } | ||
| 1292 | |||
| 1293 | function ToolkitDetailView(props: { | ||
| 1294 | toolkit: ToolkitResponse; | ||
| 1295 | showOwnerLabels: boolean; | ||
| 1296 | tools: readonly ToolRow[]; | ||
| 1297 | integrations: readonly Integration[]; | ||
| 1298 | integrationPlugins: readonly IntegrationPlugin[]; | ||
| 1299 | orgSlug?: string; | ||
| 1300 | onBack: () => void; | ||
| 1301 | onRemoveToolkit: (toolkit: ToolkitResponse) => void; | ||
| 1302 | }) { | ||
| 1303 | const policies = useAtomValue(toolkitPoliciesAtom(props.toolkit.id)); | ||
| 1304 | const connections = useAtomValue(toolkitConnectionsAtom(props.toolkit.id)); | ||
| 1305 | const doCreatePolicy = useAtomSet(createToolkitPolicy, { mode: "promiseExit" }); | ||
| 1306 | const doUpdatePolicy = useAtomSet(updateToolkitPolicy, { mode: "promiseExit" }); | ||
| 1307 | const doRemovePolicy = useAtomSet(removeToolkitPolicy, { mode: "promiseExit" }); | ||
| 1308 | const doCreateConnection = useAtomSet(createToolkitConnection, { mode: "promiseExit" }); | ||
| 1309 | const doRemoveConnection = useAtomSet(removeToolkitConnection, { mode: "promiseExit" }); | ||
| 1310 | const policyRows = AsyncResult.isSuccess(policies) ? policies.value.policies : []; | ||
| 1311 | const connectionRows = AsyncResult.isSuccess(connections) ? connections.value.connections : []; | ||
| 1312 | |||
| 1313 | const setPolicyHandler = async (pattern: string, action: ToolPolicyAction) => { | ||
| 1314 | const existing = policyRows.find((policy) => policy.pattern === pattern); | ||
| 1315 | if (existing) { | ||
| 1316 | await doUpdatePolicy({ | ||
| 1317 | params: { toolkitId: props.toolkit.id, policyId: existing.id }, | ||
| 1318 | payload: { action }, | ||
| 1319 | reactivityKeys: toolkitWriteKeys, | ||
| 1320 | }); | ||
| 1321 | return; | ||
| 1322 | } | ||
| 1323 | await doCreatePolicy({ | ||
| 1324 | params: { toolkitId: props.toolkit.id }, | ||
| 1325 | payload: { pattern, action }, | ||
| 1326 | reactivityKeys: toolkitWriteKeys, | ||
| 1327 | }); | ||
| 1328 | }; | ||
| 1329 | |||
| 1330 | const addConnectionHandler = async (pattern: string) => { | ||
| 1331 | await doCreateConnection({ | ||
| 1332 | params: { toolkitId: props.toolkit.id }, | ||
| 1333 | payload: { pattern }, | ||
| 1334 | reactivityKeys: toolkitWriteKeys, | ||
| 1335 | }); | ||
| 1336 | }; | ||
| 1337 | |||
| 1338 | const removeConnectionHandler = async (connectionId: string) => { | ||
| 1339 | await doRemoveConnection({ | ||
| 1340 | params: { toolkitId: props.toolkit.id, connectionId }, | ||
| 1341 | reactivityKeys: toolkitWriteKeys, | ||
| 1342 | }); | ||
| 1343 | }; | ||
| 1344 | |||
| 1345 | const clearPolicyHandler = async (pattern: string) => { | ||
| 1346 | const existing = policyRows.find((policy) => policy.pattern === pattern); | ||
| 1347 | if (!existing) return; | ||
| 1348 | await doRemovePolicy({ | ||
| 1349 | params: { toolkitId: props.toolkit.id, policyId: existing.id }, | ||
| 1350 | reactivityKeys: toolkitWriteKeys, | ||
| 1351 | }); | ||
| 1352 | }; | ||
| 1353 | |||
| 1354 | if (AsyncResult.isFailure(policies) || AsyncResult.isFailure(connections)) { | ||
| 1355 | return <div className="p-6 text-sm text-destructive">Failed to load toolkit</div>; | ||
| 1356 | } | ||
| 1357 | if (!AsyncResult.isSuccess(policies) || !AsyncResult.isSuccess(connections)) { | ||
| 1358 | return <ToolkitDetailSkeleton />; | ||
| 1359 | } | ||
| 1360 | |||
| 1361 | return ( | ||
| 1362 | <ToolkitWorkspace | ||
| 1363 | toolkit={props.toolkit} | ||
| 1364 | showOwnerLabels={props.showOwnerLabels} | ||
| 1365 | policies={policyRows} | ||
| 1366 | connections={connectionRows} | ||
| 1367 | tools={props.tools} | ||
| 1368 | integrations={props.integrations} | ||
| 1369 | integrationPlugins={props.integrationPlugins} | ||
| 1370 | mcpUrl={toolkitUrlFor(props.orgSlug, props.toolkit.slug)} | ||
| 1371 | onBack={props.onBack} | ||
| 1372 | onRemoveToolkit={() => props.onRemoveToolkit(props.toolkit)} | ||
| 1373 | onAddConnection={addConnectionHandler} | ||
| 1374 | onRemoveConnection={removeConnectionHandler} | ||
| 1375 | onSetPolicy={setPolicyHandler} | ||
| 1376 | onClearPolicy={clearPolicyHandler} | ||
| 1377 | /> | ||
| 1378 | ); | ||
| 1379 | } | ||
| 1380 | |||
| 1381 | export function ToolkitsPage(props: PluginPageProps) { | ||
| 1382 | const navigate = useNavigate(); | ||
| 1383 | const organizationSlug = useOrganizationSlug(); | ||
| 1384 | const ownerDisplay = useOwnerDisplay(); | ||
| 1385 | const integrationPlugins = useIntegrationPlugins(); | ||
| 1386 | const toolkits = useAtomValue(toolkitsAtom); | ||
| 1387 | const tools = useAtomValue(toolsAllAtom); | ||
| 1388 | const integrations = useAtomValue(integrationsOptimisticAtom); | ||
| 1389 | const doCreateToolkit = useAtomSet(createToolkit, { mode: "promiseExit" }); | ||
| 1390 | const doRemoveToolkit = useAtomSet(removeToolkit, { mode: "promiseExit" }); | ||
| 1391 | const selectedToolkitSlug = props.params.toolkitSlug ?? null; | ||
| 1392 | |||
| 1393 | const toolkitRows = AsyncResult.isSuccess(toolkits) ? toolkits.value.toolkits : []; | ||
| 1394 | const selectedToolkit = | ||
| 1395 | selectedToolkitSlug === null ? null : toolkitByRouteSlug(toolkitRows, selectedToolkitSlug); | ||
| 1396 | |||
| 1397 | const toolRows = AsyncResult.isSuccess(tools) ? (tools.value as readonly ToolRow[]) : []; | ||
| 1398 | const integrationRows = AsyncResult.isSuccess(integrations) | ||
| 1399 | ? (integrations.value as readonly Integration[]) | ||
| 1400 | : []; | ||
| 1401 | const toolkitsReady = AsyncResult.isSuccess(toolkits); | ||
| 1402 | const toolkitsFailed = AsyncResult.isFailure(toolkits); | ||
| 1403 | const toolsReady = AsyncResult.isSuccess(tools); | ||
| 1404 | const toolsFailed = AsyncResult.isFailure(tools); | ||
| 1405 | const navigateToIndex = () => | ||
| 1406 | navigate({ | ||
| 1407 | to: "/{-$orgSlug}/toolkits", | ||
| 1408 | }); | ||
| 1409 | |||
| 1410 | const createToolkitHandler = async (input: { owner: Owner; name: string }) => { | ||
| 1411 | await doCreateToolkit({ | ||
| 1412 | payload: input, | ||
| 1413 | reactivityKeys: toolkitWriteKeys, | ||
| 1414 | }); | ||
| 1415 | }; | ||
| 1416 | |||
| 1417 | const removeToolkitHandler = async (toolkit: ToolkitResponse) => { | ||
| 1418 | await doRemoveToolkit({ | ||
| 1419 | params: { toolkitId: toolkit.id }, | ||
| 1420 | reactivityKeys: toolkitWriteKeys, | ||
| 1421 | }); | ||
| 1422 | await navigateToIndex(); | ||
| 1423 | }; | ||
| 1424 | |||
| 1425 | return ( | ||
| 1426 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> | ||
| 1427 | {selectedToolkitSlug === null ? ( | ||
| 1428 | <div className="flex min-h-12 shrink-0 items-center gap-3 border-b border-border bg-background/95 px-4 py-2 backdrop-blur-sm"> | ||
| 1429 | <div className="flex min-w-0 items-center gap-3"> | ||
| 1430 | <h1 className="truncate text-sm font-semibold text-foreground">Toolkits</h1> | ||
| 1431 | {AsyncResult.isSuccess(toolkits) && ( | ||
| 1432 | <span className="hidden text-xs tabular-nums text-muted-foreground sm:block"> | ||
| 1433 | {toolkitRows.length} {toolkitRows.length === 1 ? "toolkit" : "toolkits"} | ||
| 1434 | </span> | ||
| 1435 | )} | ||
| 1436 | </div> | ||
| 1437 | </div> | ||
| 1438 | ) : null} | ||
| 1439 | |||
| 1440 | {selectedToolkitSlug !== null && !toolkitsReady ? ( | ||
| 1441 | toolkitsFailed ? ( | ||
| 1442 | <div className="p-6 text-sm text-destructive">Failed to load toolkit</div> | ||
| 1443 | ) : ( | ||
| 1444 | <ToolkitDetailSkeleton /> | ||
| 1445 | ) | ||
| 1446 | ) : !toolkitsReady ? ( | ||
| 1447 | toolkitsFailed ? ( | ||
| 1448 | <div className="p-6 text-sm text-destructive">Failed to load toolkits</div> | ||
| 1449 | ) : ( | ||
| 1450 | <ToolkitGridSkeleton showOwnerLabels={ownerDisplay.showOwnerLabels} /> | ||
| 1451 | ) | ||
| 1452 | ) : selectedToolkit ? ( | ||
| 1453 | toolsFailed ? ( | ||
| 1454 | <div className="p-6 text-sm text-destructive">Failed to load toolkit tools</div> | ||
| 1455 | ) : !toolsReady ? ( | ||
| 1456 | <ToolkitDetailSkeleton /> | ||
| 1457 | ) : ( | ||
| 1458 | <ToolkitDetailView | ||
| 1459 | toolkit={selectedToolkit} | ||
| 1460 | showOwnerLabels={ownerDisplay.showOwnerLabels} | ||
| 1461 | tools={toolRows} | ||
| 1462 | integrations={integrationRows} | ||
| 1463 | integrationPlugins={integrationPlugins} | ||
| 1464 | orgSlug={organizationSlug ?? undefined} | ||
| 1465 | onBack={() => void navigateToIndex()} | ||
| 1466 | onRemoveToolkit={removeToolkitHandler} | ||
| 1467 | /> | ||
| 1468 | ) | ||
| 1469 | ) : selectedToolkitSlug !== null ? ( | ||
| 1470 | <div className="flex min-h-0 flex-1 flex-col p-6"> | ||
| 1471 | <Button | ||
| 1472 | type="button" | ||
| 1473 | variant="ghost" | ||
| 1474 | size="xs" | ||
| 1475 | onClick={() => void navigateToIndex()} | ||
| 1476 | className="mb-4 w-fit -ml-1 text-muted-foreground" | ||
| 1477 | > | ||
| 1478 | <ArrowLeftIcon className="size-3.5" /> | ||
| 1479 | Toolkits | ||
| 1480 | </Button> | ||
| 1481 | <div className="text-sm text-muted-foreground">Toolkit not found</div> | ||
| 1482 | </div> | ||
| 1483 | ) : ( | ||
| 1484 | <ToolkitGrid toolkits={toolkitRows} onCreate={createToolkitHandler} /> | ||
| 1485 | )} | ||
| 1486 | </div> | ||
| 1487 | ); | ||
| 1488 | } | ||
| @@ -0,0 +1,168 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Predicate, Result } from "effect"; | ||
| 3 | import { makeTestExecutor } from "@executor-js/sdk/testing"; | ||
| 4 | |||
| 5 | import { toolkitsPlugin } from "./server"; | ||
| 6 | |||
| 7 | describe("toolkitsPlugin", () => { | ||
| 8 | it.effect("creates toolkits and manages ordered policy rules", () => | ||
| 9 | Effect.gen(function* () { | ||
| 10 | const executor = yield* makeTestExecutor({ | ||
| 11 | plugins: [toolkitsPlugin()] as const, | ||
| 12 | }); | ||
| 13 | |||
| 14 | const toolkit = yield* executor.toolkits.create({ | ||
| 15 | owner: "org", | ||
| 16 | name: "Deploy Kit", | ||
| 17 | }); | ||
| 18 | expect(toolkit.slug).toBe("deploy-kit"); | ||
| 19 | |||
| 20 | const connection = yield* executor.toolkits.createConnection(toolkit.id, { | ||
| 21 | pattern: "github.org.main.*", | ||
| 22 | }); | ||
| 23 | const duplicateConnection = yield* executor.toolkits.createConnection(toolkit.id, { | ||
| 24 | pattern: "github.org.main.*", | ||
| 25 | }); | ||
| 26 | expect(duplicateConnection.id).toBe(connection.id); | ||
| 27 | |||
| 28 | const first = yield* executor.toolkits.createPolicy(toolkit.id, { | ||
| 29 | pattern: "github.org.main.repos.*", | ||
| 30 | action: "approve", | ||
| 31 | }); | ||
| 32 | const second = yield* executor.toolkits.createPolicy(toolkit.id, { | ||
| 33 | pattern: "github.*", | ||
| 34 | action: "block", | ||
| 35 | }); | ||
| 36 | |||
| 37 | const policies = yield* executor.toolkits.listPolicies(toolkit.id); | ||
| 38 | expect(policies.map((policy) => policy.id)).toEqual([second.id, first.id]); | ||
| 39 | |||
| 40 | yield* executor.toolkits.updatePolicy(toolkit.id, first.id, { | ||
| 41 | action: "require_approval", | ||
| 42 | }); | ||
| 43 | const rules = yield* executor.toolkits.policyRulesForSlug("deploy-kit"); | ||
| 44 | expect(rules.find((rule) => rule.id === first.id)?.action).toBe("require_approval"); | ||
| 45 | |||
| 46 | const connections = yield* executor.toolkits.listConnections(toolkit.id); | ||
| 47 | expect(connections.map((row) => row.pattern)).toEqual(["github.org.main.*"]); | ||
| 48 | }), | ||
| 49 | ); | ||
| 50 | |||
| 51 | it.effect("rejects duplicate visible slugs", () => | ||
| 52 | Effect.gen(function* () { | ||
| 53 | const executor = yield* makeTestExecutor({ | ||
| 54 | plugins: [toolkitsPlugin()] as const, | ||
| 55 | }); | ||
| 56 | yield* executor.toolkits.create({ owner: "org", name: "Deploy Kit" }); | ||
| 57 | |||
| 58 | const duplicate = yield* Effect.result( | ||
| 59 | executor.toolkits.create({ owner: "user", name: "Deploy Kit" }), | ||
| 60 | ); | ||
| 61 | expect(Result.isFailure(duplicate)).toBe(true); | ||
| 62 | if (!Result.isFailure(duplicate)) return; | ||
| 63 | expect(Predicate.isTagged("ToolkitError")(duplicate.failure)).toBe(true); | ||
| 64 | }), | ||
| 65 | ); | ||
| 66 | |||
| 67 | it.effect("resolves toolkit policies with implicit deny and workspace owner limits", () => | ||
| 68 | Effect.gen(function* () { | ||
| 69 | const executor = yield* makeTestExecutor({ | ||
| 70 | plugins: [toolkitsPlugin()] as const, | ||
| 71 | }); | ||
| 72 | |||
| 73 | const workspace = yield* executor.toolkits.create({ | ||
| 74 | owner: "org", | ||
| 75 | name: "Workspace Kit", | ||
| 76 | }); | ||
| 77 | yield* executor.toolkits.createConnection(workspace.id, { | ||
| 78 | pattern: "github.org.main.*", | ||
| 79 | }); | ||
| 80 | |||
| 81 | const workspaceTool = yield* executor.toolkits.resolvePolicyForSlug( | ||
| 82 | workspace.slug, | ||
| 83 | "github.org.main.repos.list", | ||
| 84 | ); | ||
| 85 | expect(workspaceTool.action).toBe("approve"); | ||
| 86 | expect(workspaceTool.source).toBe("plugin-default"); | ||
| 87 | |||
| 88 | const defaultApprovalTool = yield* executor.toolkits.resolvePolicyForSlug( | ||
| 89 | workspace.slug, | ||
| 90 | "github.org.main.repos.delete", | ||
| 91 | true, | ||
| 92 | ); | ||
| 93 | expect(defaultApprovalTool.action).toBe("require_approval"); | ||
| 94 | expect(defaultApprovalTool.source).toBe("plugin-default"); | ||
| 95 | |||
| 96 | yield* executor.toolkits.createPolicy(workspace.id, { | ||
| 97 | pattern: "github.org.main.repos.delete", | ||
| 98 | action: "approve", | ||
| 99 | }); | ||
| 100 | const explicitTool = yield* executor.toolkits.resolvePolicyForSlug( | ||
| 101 | workspace.slug, | ||
| 102 | "github.org.main.repos.delete", | ||
| 103 | true, | ||
| 104 | ); | ||
| 105 | expect(explicitTool.action).toBe("approve"); | ||
| 106 | expect(explicitTool.source).toBe("user"); | ||
| 107 | |||
| 108 | const personalTool = yield* executor.toolkits.resolvePolicyForSlug( | ||
| 109 | workspace.slug, | ||
| 110 | "github.user.main.repos.list", | ||
| 111 | ); | ||
| 112 | expect(personalTool.action).toBe("block"); | ||
| 113 | |||
| 114 | const missingTool = yield* executor.toolkits.resolvePolicyForSlug( | ||
| 115 | workspace.slug, | ||
| 116 | "slack.org.main.chat.post", | ||
| 117 | ); | ||
| 118 | expect(missingTool.action).toBe("block"); | ||
| 119 | |||
| 120 | const personal = yield* executor.toolkits.create({ | ||
| 121 | owner: "user", | ||
| 122 | name: "Personal Kit", | ||
| 123 | }); | ||
| 124 | yield* executor.toolkits.createConnection(personal.id, { | ||
| 125 | pattern: "github.user.main.*", | ||
| 126 | }); | ||
| 127 | const personalToolkitTool = yield* executor.toolkits.resolvePolicyForSlug( | ||
| 128 | personal.slug, | ||
| 129 | "github.user.main.repos.list", | ||
| 130 | ); | ||
| 131 | expect(personalToolkitTool.action).toBe("approve"); | ||
| 132 | }), | ||
| 133 | ); | ||
| 134 | |||
| 135 | it.effect("treats a persisted connection-root approve as an access policy", () => | ||
| 136 | Effect.gen(function* () { | ||
| 137 | const executor = yield* makeTestExecutor({ | ||
| 138 | plugins: [toolkitsPlugin()] as const, | ||
| 139 | }); | ||
| 140 | |||
| 141 | const toolkit = yield* executor.toolkits.create({ | ||
| 142 | owner: "org", | ||
| 143 | name: "Core Tools Kit", | ||
| 144 | }); | ||
| 145 | yield* executor.toolkits.createConnection(toolkit.id, { | ||
| 146 | pattern: "executor.coreTools.*", | ||
| 147 | }); | ||
| 148 | yield* executor.toolkits.createPolicy(toolkit.id, { | ||
| 149 | pattern: "executor.coreTools.*", | ||
| 150 | action: "approve", | ||
| 151 | }); | ||
| 152 | |||
| 153 | const result = yield* executor.toolkits.resolvePolicyForSlug( | ||
| 154 | toolkit.slug, | ||
| 155 | "executor.coreTools.connections.remove", | ||
| 156 | true, | ||
| 157 | ); | ||
| 158 | expect(result.action).toBe("approve"); | ||
| 159 | expect(result.source).toBe("user"); | ||
| 160 | |||
| 161 | const rules = yield* executor.toolkits.policyRulesForSlug(toolkit.slug); | ||
| 162 | expect( | ||
| 163 | rules.map((rule) => `${rule.pattern} ${rule.action}`), | ||
| 164 | "policy listing agrees with toolkit enforcement", | ||
| 165 | ).toContain("executor.coreTools.* approve"); | ||
| 166 | }), | ||
| 167 | ); | ||
| 168 | }); | ||
| @@ -0,0 +1,673 @@ | |||
| 1 | import { | ||
| 2 | Context, | ||
| 3 | definePlugin, | ||
| 4 | definePluginStorageCollection, | ||
| 5 | Effect, | ||
| 6 | HttpApiBuilder, | ||
| 7 | isValidPattern, | ||
| 8 | matchPattern, | ||
| 9 | Schema, | ||
| 10 | type EffectivePolicy, | ||
| 11 | type Owner, | ||
| 12 | type PluginCtx, | ||
| 13 | type PluginStorageFacade, | ||
| 14 | type PluginStorageCollectionFacade, | ||
| 15 | type StorageFailure, | ||
| 16 | type ToolPolicyAction, | ||
| 17 | type ToolPolicyProvider, | ||
| 18 | type ToolPolicyProviderRule, | ||
| 19 | } from "@executor-js/sdk/core"; | ||
| 20 | import { addGroup, capture } from "@executor-js/api"; | ||
| 21 | import { generateKeyBetween } from "fractional-indexing"; | ||
| 22 | |||
| 23 | import { ToolkitError, ToolkitsApi } from "./shared"; | ||
| 24 | |||
| 25 | const ToolkitRecord = Schema.Struct({ | ||
| 26 | id: Schema.String, | ||
| 27 | slug: Schema.String, | ||
| 28 | name: Schema.String, | ||
| 29 | createdAt: Schema.Number, | ||
| 30 | updatedAt: Schema.Number, | ||
| 31 | }); | ||
| 32 | type ToolkitRecord = typeof ToolkitRecord.Type; | ||
| 33 | |||
| 34 | const ToolkitPolicyRecord = Schema.Struct({ | ||
| 35 | id: Schema.String, | ||
| 36 | toolkitId: Schema.String, | ||
| 37 | pattern: Schema.String, | ||
| 38 | action: Schema.Literals(["approve", "require_approval", "block"]), | ||
| 39 | position: Schema.String, | ||
| 40 | createdAt: Schema.Number, | ||
| 41 | updatedAt: Schema.Number, | ||
| 42 | }); | ||
| 43 | type ToolkitPolicyRecord = typeof ToolkitPolicyRecord.Type; | ||
| 44 | |||
| 45 | const ToolkitConnectionRecord = Schema.Struct({ | ||
| 46 | id: Schema.String, | ||
| 47 | toolkitId: Schema.String, | ||
| 48 | pattern: Schema.String, | ||
| 49 | position: Schema.String, | ||
| 50 | createdAt: Schema.Number, | ||
| 51 | updatedAt: Schema.Number, | ||
| 52 | }); | ||
| 53 | type ToolkitConnectionRecord = typeof ToolkitConnectionRecord.Type; | ||
| 54 | |||
| 55 | const toolkitsCollection = definePluginStorageCollection("toolkits", ToolkitRecord, { | ||
| 56 | indexes: ["slug", "name", "updatedAt"], | ||
| 57 | }); | ||
| 58 | |||
| 59 | const toolkitPoliciesCollection = definePluginStorageCollection( | ||
| 60 | "toolkitPolicies", | ||
| 61 | ToolkitPolicyRecord, | ||
| 62 | { | ||
| 63 | indexes: ["toolkitId", "pattern", "position", ["toolkitId", "position"]], | ||
| 64 | }, | ||
| 65 | ); | ||
| 66 | |||
| 67 | const toolkitConnectionsCollection = definePluginStorageCollection( | ||
| 68 | "toolkitConnections", | ||
| 69 | ToolkitConnectionRecord, | ||
| 70 | { | ||
| 71 | indexes: ["toolkitId", "pattern", "position", ["toolkitId", "position"]], | ||
| 72 | }, | ||
| 73 | ); | ||
| 74 | |||
| 75 | type ToolkitStorage = { | ||
| 76 | readonly toolkits: PluginStorageCollectionFacade<typeof toolkitsCollection>; | ||
| 77 | readonly policies: PluginStorageCollectionFacade<typeof toolkitPoliciesCollection>; | ||
| 78 | readonly connections: PluginStorageCollectionFacade<typeof toolkitConnectionsCollection>; | ||
| 79 | }; | ||
| 80 | |||
| 81 | export interface ToolkitsPluginOptions { | ||
| 82 | /** When set, this executor instance enforces only the named toolkit's rules. */ | ||
| 83 | readonly activeToolkitSlug?: string; | ||
| 84 | } | ||
| 85 | |||
| 86 | const newId = (prefix: string): string => | ||
| 87 | `${prefix}_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; | ||
| 88 | |||
| 89 | const slugPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; | ||
| 90 | |||
| 91 | const slugify = (name: string): string => | ||
| 92 | name | ||
| 93 | .trim() | ||
| 94 | .toLowerCase() | ||
| 95 | .replace(/[^a-z0-9]+/g, "-") | ||
| 96 | .replace(/^-+|-+$/g, "") | ||
| 97 | .slice(0, 63); | ||
| 98 | |||
| 99 | const normalizeSlugEffect = (input: { | ||
| 100 | readonly name: string; | ||
| 101 | readonly slug?: string; | ||
| 102 | }): Effect.Effect<string, ToolkitError> => { | ||
| 103 | const slug = (input.slug ?? slugify(input.name)).trim().toLowerCase(); | ||
| 104 | if (!slugPattern.test(slug)) { | ||
| 105 | return Effect.fail( | ||
| 106 | new ToolkitError({ | ||
| 107 | message: | ||
| 108 | "Toolkit slug must be 1-63 lowercase letters, numbers, or hyphens, and cannot start or end with a hyphen.", | ||
| 109 | }), | ||
| 110 | ); | ||
| 111 | } | ||
| 112 | return Effect.succeed(slug); | ||
| 113 | }; | ||
| 114 | |||
| 115 | const fail = (message: string): Effect.Effect<never, ToolkitError> => | ||
| 116 | Effect.fail(new ToolkitError({ message })); | ||
| 117 | |||
| 118 | const validatePolicyPattern = (pattern: string): Effect.Effect<void, ToolkitError> => | ||
| 119 | isValidPattern(pattern) | ||
| 120 | ? Effect.void | ||
| 121 | : Effect.fail( | ||
| 122 | new ToolkitError({ | ||
| 123 | message: `Invalid toolkit policy pattern: ${pattern}`, | ||
| 124 | }), | ||
| 125 | ); | ||
| 126 | |||
| 127 | const comparePositioned = ( | ||
| 128 | a: Pick<ToolkitPolicyRecord, "id" | "position">, | ||
| 129 | b: Pick<ToolkitPolicyRecord, "id" | "position">, | ||
| 130 | ): number => { | ||
| 131 | if (a.position < b.position) return -1; | ||
| 132 | if (a.position > b.position) return 1; | ||
| 133 | return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; | ||
| 134 | }; | ||
| 135 | |||
| 136 | const blockedPolicy = (pattern = "*"): EffectivePolicy => ({ | ||
| 137 | action: "block", | ||
| 138 | source: "user", | ||
| 139 | pattern, | ||
| 140 | }); | ||
| 141 | |||
| 142 | const pluginDefaultPolicy = (defaultRequiresApproval: boolean | undefined): EffectivePolicy => | ||
| 143 | defaultRequiresApproval | ||
| 144 | ? { action: "require_approval", source: "plugin-default" } | ||
| 145 | : { action: "approve", source: "plugin-default" }; | ||
| 146 | |||
| 147 | const isLegacyConnectionPolicy = (policy: ToolkitPolicyRecord): boolean => { | ||
| 148 | if (policy.action !== "approve") return false; | ||
| 149 | const parts = policy.pattern.split("."); | ||
| 150 | return parts.at(-1) === "*" && (parts.length === 3 || parts.length === 4); | ||
| 151 | }; | ||
| 152 | |||
| 153 | const resolveToolkitPolicy = ( | ||
| 154 | toolId: string, | ||
| 155 | connections: readonly ToolkitConnectionRecord[], | ||
| 156 | policies: readonly ToolkitPolicyRecord[], | ||
| 157 | defaultRequiresApproval?: boolean, | ||
| 158 | ): EffectivePolicy => { | ||
| 159 | const legacyPolicyIds = legacyConnectionPolicyIds(policies, connections); | ||
| 160 | const connected = | ||
| 161 | connections.some((connection) => matchPattern(connection.pattern, toolId)) || | ||
| 162 | policies.some( | ||
| 163 | (policy) => legacyPolicyIds.has(policy.id) && matchPattern(policy.pattern, toolId), | ||
| 164 | ); | ||
| 165 | if (!connected) return blockedPolicy(); | ||
| 166 | |||
| 167 | for (const policy of [...policies].sort(comparePositioned)) { | ||
| 168 | if (legacyPolicyIds.has(policy.id)) continue; | ||
| 169 | if (!matchPattern(policy.pattern, toolId)) continue; | ||
| 170 | return { | ||
| 171 | action: policy.action, | ||
| 172 | source: "user", | ||
| 173 | pattern: policy.pattern, | ||
| 174 | policyId: policy.id, | ||
| 175 | }; | ||
| 176 | } | ||
| 177 | return pluginDefaultPolicy(defaultRequiresApproval); | ||
| 178 | }; | ||
| 179 | |||
| 180 | const legacyConnectionPolicyIds = ( | ||
| 181 | policies: readonly ToolkitPolicyRecord[], | ||
| 182 | connections: readonly ToolkitConnectionRecord[], | ||
| 183 | ): ReadonlySet<string> => { | ||
| 184 | const connectionPatterns = new Set(connections.map((connection) => connection.pattern)); | ||
| 185 | return new Set( | ||
| 186 | policies | ||
| 187 | .filter( | ||
| 188 | (policy) => isLegacyConnectionPolicy(policy) && !connectionPatterns.has(policy.pattern), | ||
| 189 | ) | ||
| 190 | .map((policy) => policy.id), | ||
| 191 | ); | ||
| 192 | }; | ||
| 193 | |||
| 194 | const isPersonalDynamicToolId = (toolId: string): boolean => toolId.split(".")[1] === "user"; | ||
| 195 | |||
| 196 | const toolkitToResponse = (entry: { readonly owner: Owner; readonly data: ToolkitRecord }) => ({ | ||
| 197 | id: entry.data.id, | ||
| 198 | owner: entry.owner, | ||
| 199 | slug: entry.data.slug, | ||
| 200 | name: entry.data.name, | ||
| 201 | createdAt: entry.data.createdAt, | ||
| 202 | updatedAt: entry.data.updatedAt, | ||
| 203 | }); | ||
| 204 | |||
| 205 | const policyToResponse = (policy: ToolkitPolicyRecord) => ({ | ||
| 206 | id: policy.id, | ||
| 207 | toolkitId: policy.toolkitId, | ||
| 208 | pattern: policy.pattern, | ||
| 209 | action: policy.action, | ||
| 210 | position: policy.position, | ||
| 211 | createdAt: policy.createdAt, | ||
| 212 | updatedAt: policy.updatedAt, | ||
| 213 | }); | ||
| 214 | |||
| 215 | const connectionToResponse = (connection: ToolkitConnectionRecord) => ({ | ||
| 216 | id: connection.id, | ||
| 217 | toolkitId: connection.toolkitId, | ||
| 218 | pattern: connection.pattern, | ||
| 219 | position: connection.position, | ||
| 220 | createdAt: connection.createdAt, | ||
| 221 | updatedAt: connection.updatedAt, | ||
| 222 | }); | ||
| 223 | |||
| 224 | const makeToolkitStorage = (pluginStorage: PluginStorageFacade): ToolkitStorage => ({ | ||
| 225 | toolkits: pluginStorage.collection(toolkitsCollection), | ||
| 226 | policies: pluginStorage.collection(toolkitPoliciesCollection), | ||
| 227 | connections: pluginStorage.collection(toolkitConnectionsCollection), | ||
| 228 | }); | ||
| 229 | |||
| 230 | const makeToolkitsExtension = (ctx: PluginCtx<ToolkitStorage>) => { | ||
| 231 | const storage = ctx.storage; | ||
| 232 | |||
| 233 | const list = () => | ||
| 234 | storage.toolkits | ||
| 235 | .query({ orderBy: [{ field: "name" }] }) | ||
| 236 | .pipe( | ||
| 237 | Effect.map((entries) => | ||
| 238 | entries | ||
| 239 | .map(toolkitToResponse) | ||
| 240 | .sort( | ||
| 241 | (a, b) => | ||
| 242 | (a.owner === b.owner ? 0 : a.owner === "org" ? -1 : 1) || | ||
| 243 | a.name.localeCompare(b.name) || | ||
| 244 | a.slug.localeCompare(b.slug), | ||
| 245 | ), | ||
| 246 | ), | ||
| 247 | ); | ||
| 248 | |||
| 249 | const getEntry = (toolkitId: string) => storage.toolkits.get({ key: toolkitId }); | ||
| 250 | |||
| 251 | const getBySlugEntry = (slug: string) => | ||
| 252 | storage.toolkits | ||
| 253 | .query({ where: { slug } }) | ||
| 254 | .pipe( | ||
| 255 | Effect.map( | ||
| 256 | (entries) => entries.find((entry) => entry.owner === "org") ?? entries[0] ?? null, | ||
| 257 | ), | ||
| 258 | ); | ||
| 259 | |||
| 260 | const requireToolkit = (toolkitId: string) => | ||
| 261 | getEntry(toolkitId).pipe( | ||
| 262 | Effect.flatMap((entry) => (entry ? Effect.succeed(entry) : fail("Toolkit not found."))), | ||
| 263 | ); | ||
| 264 | |||
| 265 | const assertSlugAvailable = (slug: string, ignoreToolkitId?: string) => | ||
| 266 | storage.toolkits.query({ where: { slug } }).pipe( | ||
| 267 | Effect.flatMap((entries) => { | ||
| 268 | const collision = entries.find((entry) => entry.data.id !== ignoreToolkitId); | ||
| 269 | return collision ? fail(`Toolkit slug "${slug}" is already in use.`) : Effect.void; | ||
| 270 | }), | ||
| 271 | ); | ||
| 272 | |||
| 273 | const listPoliciesForRecord = (toolkitId: string) => | ||
| 274 | storage.policies | ||
| 275 | .query({ where: { toolkitId } }) | ||
| 276 | .pipe(Effect.map((entries) => entries.map((entry) => entry.data).sort(comparePositioned))); | ||
| 277 | |||
| 278 | const listConnectionsForRecord = (toolkitId: string) => | ||
| 279 | storage.connections | ||
| 280 | .query({ where: { toolkitId } }) | ||
| 281 | .pipe(Effect.map((entries) => entries.map((entry) => entry.data).sort(comparePositioned))); | ||
| 282 | |||
| 283 | const requirePolicy = (toolkitId: string, policyId: string, owner: Owner) => | ||
| 284 | storage.policies | ||
| 285 | .getForOwner({ owner, key: policyId }) | ||
| 286 | .pipe( | ||
| 287 | Effect.flatMap((entry) => | ||
| 288 | entry && entry.data.toolkitId === toolkitId | ||
| 289 | ? Effect.succeed(entry) | ||
| 290 | : fail("Toolkit policy not found."), | ||
| 291 | ), | ||
| 292 | ); | ||
| 293 | |||
| 294 | const requireConnection = (toolkitId: string, connectionId: string, owner: Owner) => | ||
| 295 | storage.connections | ||
| 296 | .getForOwner({ owner, key: connectionId }) | ||
| 297 | .pipe( | ||
| 298 | Effect.flatMap((entry) => | ||
| 299 | entry && entry.data.toolkitId === toolkitId | ||
| 300 | ? Effect.succeed(entry) | ||
| 301 | : fail("Toolkit connection not found."), | ||
| 302 | ), | ||
| 303 | ); | ||
| 304 | |||
| 305 | const create = (input: { | ||
| 306 | readonly owner: Owner; | ||
| 307 | readonly name: string; | ||
| 308 | readonly slug?: string; | ||
| 309 | }) => | ||
| 310 | Effect.gen(function* () { | ||
| 311 | const name = input.name.trim(); | ||
| 312 | if (!name) return yield* fail("Toolkit name is required."); | ||
| 313 | const slug = yield* normalizeSlugEffect({ name, slug: input.slug }); | ||
| 314 | yield* assertSlugAvailable(slug); | ||
| 315 | const now = Date.now(); | ||
| 316 | const id = newId("tk"); | ||
| 317 | const entry = yield* storage.toolkits.put({ | ||
| 318 | owner: input.owner, | ||
| 319 | key: id, | ||
| 320 | data: { id, slug, name, createdAt: now, updatedAt: now }, | ||
| 321 | }); | ||
| 322 | return toolkitToResponse(entry); | ||
| 323 | }); | ||
| 324 | |||
| 325 | const update = (toolkitId: string, input: { readonly name?: string; readonly slug?: string }) => | ||
| 326 | Effect.gen(function* () { | ||
| 327 | const existing = yield* requireToolkit(toolkitId); | ||
| 328 | const name = input.name === undefined ? existing.data.name : input.name.trim(); | ||
| 329 | if (!name) return yield* fail("Toolkit name is required."); | ||
| 330 | const slug = | ||
| 331 | input.slug === undefined | ||
| 332 | ? existing.data.slug | ||
| 333 | : yield* normalizeSlugEffect({ name, slug: input.slug }); | ||
| 334 | yield* assertSlugAvailable(slug, toolkitId); | ||
| 335 | const entry = yield* storage.toolkits.put({ | ||
| 336 | owner: existing.owner, | ||
| 337 | key: toolkitId, | ||
| 338 | data: { ...existing.data, name, slug, updatedAt: Date.now() }, | ||
| 339 | }); | ||
| 340 | return toolkitToResponse(entry); | ||
| 341 | }); | ||
| 342 | |||
| 343 | const remove = (toolkitId: string) => | ||
| 344 | Effect.gen(function* () { | ||
| 345 | const toolkit = yield* requireToolkit(toolkitId); | ||
| 346 | const policies = yield* listPoliciesForRecord(toolkitId); | ||
| 347 | const connections = yield* listConnectionsForRecord(toolkitId); | ||
| 348 | yield* ctx.pluginStorage.removeMany({ | ||
| 349 | owner: toolkit.owner, | ||
| 350 | entries: [ | ||
| 351 | { collection: toolkitsCollection.name, key: toolkitId }, | ||
| 352 | ...policies.map((policy) => ({ | ||
| 353 | collection: toolkitPoliciesCollection.name, | ||
| 354 | key: policy.id, | ||
| 355 | })), | ||
| 356 | ...connections.map((connection) => ({ | ||
| 357 | collection: toolkitConnectionsCollection.name, | ||
| 358 | key: connection.id, | ||
| 359 | })), | ||
| 360 | ], | ||
| 361 | }); | ||
| 362 | }); | ||
| 363 | |||
| 364 | const listPolicies = (toolkitId: string) => | ||
| 365 | requireToolkit(toolkitId).pipe(Effect.flatMap(() => listPoliciesForRecord(toolkitId))); | ||
| 366 | |||
| 367 | const createPolicy = ( | ||
| 368 | toolkitId: string, | ||
| 369 | input: { | ||
| 370 | readonly pattern: string; | ||
| 371 | readonly action: ToolPolicyAction; | ||
| 372 | readonly position?: string; | ||
| 373 | }, | ||
| 374 | ) => | ||
| 375 | Effect.gen(function* () { | ||
| 376 | yield* validatePolicyPattern(input.pattern); | ||
| 377 | const toolkit = yield* requireToolkit(toolkitId); | ||
| 378 | const existing = yield* listPoliciesForRecord(toolkitId); | ||
| 379 | const minPosition = existing | ||
| 380 | .map((row) => row.position) | ||
| 381 | .sort() | ||
| 382 | .at(0); | ||
| 383 | const now = Date.now(); | ||
| 384 | const id = newId("tkpol"); | ||
| 385 | const entry = yield* storage.policies.put({ | ||
| 386 | owner: toolkit.owner, | ||
| 387 | key: id, | ||
| 388 | data: { | ||
| 389 | id, | ||
| 390 | toolkitId, | ||
| 391 | pattern: input.pattern, | ||
| 392 | action: input.action, | ||
| 393 | position: input.position ?? generateKeyBetween(null, minPosition ?? null), | ||
| 394 | createdAt: now, | ||
| 395 | updatedAt: now, | ||
| 396 | }, | ||
| 397 | }); | ||
| 398 | return policyToResponse(entry.data); | ||
| 399 | }); | ||
| 400 | |||
| 401 | const updatePolicy = ( | ||
| 402 | toolkitId: string, | ||
| 403 | policyId: string, | ||
| 404 | input: { | ||
| 405 | readonly pattern?: string; | ||
| 406 | readonly action?: ToolPolicyAction; | ||
| 407 | readonly position?: string; | ||
| 408 | }, | ||
| 409 | ) => | ||
| 410 | Effect.gen(function* () { | ||
| 411 | if (input.pattern !== undefined) yield* validatePolicyPattern(input.pattern); | ||
| 412 | const toolkit = yield* requireToolkit(toolkitId); | ||
| 413 | const existing = yield* requirePolicy(toolkitId, policyId, toolkit.owner); | ||
| 414 | const entry = yield* storage.policies.put({ | ||
| 415 | owner: toolkit.owner, | ||
| 416 | key: policyId, | ||
| 417 | data: { | ||
| 418 | ...existing.data, | ||
| 419 | ...(input.pattern !== undefined ? { pattern: input.pattern } : {}), | ||
| 420 | ...(input.action !== undefined ? { action: input.action } : {}), | ||
| 421 | ...(input.position !== undefined ? { position: input.position } : {}), | ||
| 422 | updatedAt: Date.now(), | ||
| 423 | }, | ||
| 424 | }); | ||
| 425 | return policyToResponse(entry.data); | ||
| 426 | }); | ||
| 427 | |||
| 428 | const removePolicy = (toolkitId: string, policyId: string) => | ||
| 429 | Effect.gen(function* () { | ||
| 430 | const toolkit = yield* requireToolkit(toolkitId); | ||
| 431 | yield* requirePolicy(toolkitId, policyId, toolkit.owner); | ||
| 432 | yield* storage.policies.remove({ owner: toolkit.owner, key: policyId }); | ||
| 433 | }); | ||
| 434 | |||
| 435 | const listConnections = (toolkitId: string) => | ||
| 436 | requireToolkit(toolkitId).pipe(Effect.flatMap(() => listConnectionsForRecord(toolkitId))); | ||
| 437 | |||
| 438 | const createConnection = ( | ||
| 439 | toolkitId: string, | ||
| 440 | input: { | ||
| 441 | readonly pattern: string; | ||
| 442 | readonly position?: string; | ||
| 443 | }, | ||
| 444 | ) => | ||
| 445 | Effect.gen(function* () { | ||
| 446 | yield* validatePolicyPattern(input.pattern); | ||
| 447 | const toolkit = yield* requireToolkit(toolkitId); | ||
| 448 | const existing = yield* listConnectionsForRecord(toolkitId); | ||
| 449 | const duplicate = existing.find((connection) => connection.pattern === input.pattern); | ||
| 450 | if (duplicate) return connectionToResponse(duplicate); | ||
| 451 | const minPosition = existing | ||
| 452 | .map((row) => row.position) | ||
| 453 | .sort() | ||
| 454 | .at(0); | ||
| 455 | const now = Date.now(); | ||
| 456 | const id = newId("tkconn"); | ||
| 457 | const entry = yield* storage.connections.put({ | ||
| 458 | owner: toolkit.owner, | ||
| 459 | key: id, | ||
| 460 | data: { | ||
| 461 | id, | ||
| 462 | toolkitId, | ||
| 463 | pattern: input.pattern, | ||
| 464 | position: input.position ?? generateKeyBetween(null, minPosition ?? null), | ||
| 465 | createdAt: now, | ||
| 466 | updatedAt: now, | ||
| 467 | }, | ||
| 468 | }); | ||
| 469 | return connectionToResponse(entry.data); | ||
| 470 | }); | ||
| 471 | |||
| 472 | const removeConnection = (toolkitId: string, connectionId: string) => | ||
| 473 | Effect.gen(function* () { | ||
| 474 | const toolkit = yield* requireToolkit(toolkitId); | ||
| 475 | yield* requireConnection(toolkitId, connectionId, toolkit.owner); | ||
| 476 | yield* storage.connections.remove({ owner: toolkit.owner, key: connectionId }); | ||
| 477 | }); | ||
| 478 | |||
| 479 | const policyRulesForSlug = ( | ||
| 480 | slug: string, | ||
| 481 | ): Effect.Effect<readonly ToolPolicyProviderRule[], StorageFailure> => | ||
| 482 | Effect.gen(function* () { | ||
| 483 | const toolkit = yield* getBySlugEntry(slug); | ||
| 484 | if (!toolkit) return []; | ||
| 485 | const policies = yield* listPoliciesForRecord(toolkit.data.id); | ||
| 486 | const connections = yield* listConnectionsForRecord(toolkit.data.id); | ||
| 487 | const legacyPolicyIds = legacyConnectionPolicyIds(policies, connections); | ||
| 488 | return policies | ||
| 489 | .filter((policy) => !legacyPolicyIds.has(policy.id)) | ||
| 490 | .map((policy) => ({ | ||
| 491 | id: policy.id, | ||
| 492 | pattern: policy.pattern, | ||
| 493 | action: policy.action, | ||
| 494 | position: policy.position, | ||
| 495 | })); | ||
| 496 | }); | ||
| 497 | |||
| 498 | const resolvePolicyForSlug = ( | ||
| 499 | slug: string, | ||
| 500 | toolId: string, | ||
| 501 | defaultRequiresApproval?: boolean, | ||
| 502 | ): Effect.Effect<EffectivePolicy, StorageFailure> => | ||
| 503 | Effect.gen(function* () { | ||
| 504 | const toolkit = yield* getBySlugEntry(slug); | ||
| 505 | if (!toolkit) return blockedPolicy(); | ||
| 506 | if (toolkit.owner === "org" && isPersonalDynamicToolId(toolId)) return blockedPolicy(); | ||
| 507 | const policies = yield* listPoliciesForRecord(toolkit.data.id); | ||
| 508 | const connections = yield* listConnectionsForRecord(toolkit.data.id); | ||
| 509 | return resolveToolkitPolicy(toolId, connections, policies, defaultRequiresApproval); | ||
| 510 | }); | ||
| 511 | |||
| 512 | return { | ||
| 513 | list, | ||
| 514 | create, | ||
| 515 | update, | ||
| 516 | remove, | ||
| 517 | listPolicies: (toolkitId: string) => | ||
| 518 | listPolicies(toolkitId).pipe(Effect.map((policies) => policies.map(policyToResponse))), | ||
| 519 | createPolicy, | ||
| 520 | updatePolicy, | ||
| 521 | removePolicy, | ||
| 522 | listConnections: (toolkitId: string) => | ||
| 523 | listConnections(toolkitId).pipe( | ||
| 524 | Effect.map((connections) => connections.map(connectionToResponse)), | ||
| 525 | ), | ||
| 526 | createConnection, | ||
| 527 | removeConnection, | ||
| 528 | policyRulesForSlug, | ||
| 529 | resolvePolicyForSlug, | ||
| 530 | }; | ||
| 531 | }; | ||
| 532 | |||
| 533 | export type ToolkitsExtension = ReturnType<typeof makeToolkitsExtension>; | ||
| 534 | |||
| 535 | export class ToolkitsExtensionService extends Context.Service< | ||
| 536 | ToolkitsExtensionService, | ||
| 537 | ToolkitsExtension | ||
| 538 | >()("ToolkitsExtensionService") {} | ||
| 539 | |||
| 540 | const ExecutorApiWithToolkits = addGroup(ToolkitsApi); | ||
| 541 | |||
| 542 | const ToolkitsHandlers = HttpApiBuilder.group(ExecutorApiWithToolkits, "toolkits", (handlers) => | ||
| 543 | handlers | ||
| 544 | .handle("list", () => | ||
| 545 | capture( | ||
| 546 | Effect.gen(function* () { | ||
| 547 | const ext = yield* ToolkitsExtensionService; | ||
| 548 | const toolkits = yield* ext.list(); | ||
| 549 | return { toolkits }; | ||
| 550 | }), | ||
| 551 | ), | ||
| 552 | ) | ||
| 553 | .handle("create", ({ payload }) => | ||
| 554 | capture( | ||
| 555 | Effect.gen(function* () { | ||
| 556 | const ext = yield* ToolkitsExtensionService; | ||
| 557 | return yield* ext.create(payload); | ||
| 558 | }), | ||
| 559 | ), | ||
| 560 | ) | ||
| 561 | .handle("update", ({ params, payload }) => | ||
| 562 | capture( | ||
| 563 | Effect.gen(function* () { | ||
| 564 | const ext = yield* ToolkitsExtensionService; | ||
| 565 | return yield* ext.update(params.toolkitId, payload); | ||
| 566 | }), | ||
| 567 | ), | ||
| 568 | ) | ||
| 569 | .handle("remove", ({ params }) => | ||
| 570 | capture( | ||
| 571 | Effect.gen(function* () { | ||
| 572 | const ext = yield* ToolkitsExtensionService; | ||
| 573 | yield* ext.remove(params.toolkitId); | ||
| 574 | return { removed: true }; | ||
| 575 | }), | ||
| 576 | ), | ||
| 577 | ) | ||
| 578 | .handle("listPolicies", ({ params }) => | ||
| 579 | capture( | ||
| 580 | Effect.gen(function* () { | ||
| 581 | const ext = yield* ToolkitsExtensionService; | ||
| 582 | const policies = yield* ext.listPolicies(params.toolkitId); | ||
| 583 | return { policies }; | ||
| 584 | }), | ||
| 585 | ), | ||
| 586 | ) | ||
| 587 | .handle("createPolicy", ({ params, payload }) => | ||
| 588 | capture( | ||
| 589 | Effect.gen(function* () { | ||
| 590 | const ext = yield* ToolkitsExtensionService; | ||
| 591 | return yield* ext.createPolicy(params.toolkitId, payload); | ||
| 592 | }), | ||
| 593 | ), | ||
| 594 | ) | ||
| 595 | .handle("updatePolicy", ({ params, payload }) => | ||
| 596 | capture( | ||
| 597 | Effect.gen(function* () { | ||
| 598 | const ext = yield* ToolkitsExtensionService; | ||
| 599 | return yield* ext.updatePolicy(params.toolkitId, params.policyId, payload); | ||
| 600 | }), | ||
| 601 | ), | ||
| 602 | ) | ||
| 603 | .handle("removePolicy", ({ params }) => | ||
| 604 | capture( | ||
| 605 | Effect.gen(function* () { | ||
| 606 | const ext = yield* ToolkitsExtensionService; | ||
| 607 | yield* ext.removePolicy(params.toolkitId, params.policyId); | ||
| 608 | return { removed: true }; | ||
| 609 | }), | ||
| 610 | ), | ||
| 611 | ) | ||
| 612 | .handle("listConnections", ({ params }) => | ||
| 613 | capture( | ||
| 614 | Effect.gen(function* () { | ||
| 615 | const ext = yield* ToolkitsExtensionService; | ||
| 616 | const connections = yield* ext.listConnections(params.toolkitId); | ||
| 617 | return { connections }; | ||
| 618 | }), | ||
| 619 | ), | ||
| 620 | ) | ||
| 621 | .handle("createConnection", ({ params, payload }) => | ||
| 622 | capture( | ||
| 623 | Effect.gen(function* () { | ||
| 624 | const ext = yield* ToolkitsExtensionService; | ||
| 625 | return yield* ext.createConnection(params.toolkitId, payload); | ||
| 626 | }), | ||
| 627 | ), | ||
| 628 | ) | ||
| 629 | .handle("removeConnection", ({ params }) => | ||
| 630 | capture( | ||
| 631 | Effect.gen(function* () { | ||
| 632 | const ext = yield* ToolkitsExtensionService; | ||
| 633 | yield* ext.removeConnection(params.toolkitId, params.connectionId); | ||
| 634 | return { removed: true }; | ||
| 635 | }), | ||
| 636 | ), | ||
| 637 | ), | ||
| 638 | ); | ||
| 639 | |||
| 640 | const makePolicyProvider = ( | ||
| 641 | extension: Pick<ToolkitsExtension, "policyRulesForSlug" | "resolvePolicyForSlug">, | ||
| 642 | slug: string, | ||
| 643 | ): ToolPolicyProvider => ({ | ||
| 644 | list: () => extension.policyRulesForSlug(slug), | ||
| 645 | resolve: ({ toolId, defaultRequiresApproval }) => | ||
| 646 | extension.resolvePolicyForSlug(slug, toolId, defaultRequiresApproval), | ||
| 647 | }); | ||
| 648 | |||
| 649 | export const toolkitsPlugin = definePlugin((options: ToolkitsPluginOptions = {}) => { | ||
| 650 | const activeToolkitSlug = options.activeToolkitSlug; | ||
| 651 | return { | ||
| 652 | id: "toolkits" as const, | ||
| 653 | packageName: "@executor-js/plugin-toolkits", | ||
| 654 | pluginStorage: { | ||
| 655 | toolkits: toolkitsCollection, | ||
| 656 | toolkitPolicies: toolkitPoliciesCollection, | ||
| 657 | toolkitConnections: toolkitConnectionsCollection, | ||
| 658 | }, | ||
| 659 | storage: ({ pluginStorage }) => makeToolkitStorage(pluginStorage), | ||
| 660 | extension: makeToolkitsExtension, | ||
| 661 | routes: () => ToolkitsApi, | ||
| 662 | handlers: () => ToolkitsHandlers, | ||
| 663 | extensionService: ToolkitsExtensionService, | ||
| 664 | ...(activeToolkitSlug | ||
| 665 | ? { | ||
| 666 | toolPolicyProvider: (ctx: PluginCtx<ToolkitStorage>) => | ||
| 667 | makePolicyProvider(makeToolkitsExtension(ctx), activeToolkitSlug), | ||
| 668 | } | ||
| 669 | : {}), | ||
| 670 | }; | ||
| 671 | }); | ||
| 672 | |||
| 673 | export default toolkitsPlugin; | ||
| @@ -0,0 +1,166 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | ||
| 3 | import { InternalError, Owner, ToolPolicyActionSchema } from "@executor-js/sdk/shared"; | ||
| 4 | |||
| 5 | export class ToolkitError extends Schema.TaggedErrorClass<ToolkitError>()( | ||
| 6 | "ToolkitError", | ||
| 7 | { message: Schema.String }, | ||
| 8 | { httpApiStatus: 400 }, | ||
| 9 | ) {} | ||
| 10 | |||
| 11 | const ToolkitParams = { | ||
| 12 | toolkitId: Schema.String, | ||
| 13 | }; | ||
| 14 | |||
| 15 | const ToolkitPolicyParams = { | ||
| 16 | toolkitId: Schema.String, | ||
| 17 | policyId: Schema.String, | ||
| 18 | }; | ||
| 19 | |||
| 20 | const ToolkitConnectionParams = { | ||
| 21 | toolkitId: Schema.String, | ||
| 22 | connectionId: Schema.String, | ||
| 23 | }; | ||
| 24 | |||
| 25 | export const ToolkitResponse = Schema.Struct({ | ||
| 26 | id: Schema.String, | ||
| 27 | owner: Owner, | ||
| 28 | slug: Schema.String, | ||
| 29 | name: Schema.String, | ||
| 30 | createdAt: Schema.Number, | ||
| 31 | updatedAt: Schema.Number, | ||
| 32 | }); | ||
| 33 | export type ToolkitResponse = typeof ToolkitResponse.Type; | ||
| 34 | |||
| 35 | export const ToolkitPolicyResponse = Schema.Struct({ | ||
| 36 | id: Schema.String, | ||
| 37 | toolkitId: Schema.String, | ||
| 38 | pattern: Schema.String, | ||
| 39 | action: ToolPolicyActionSchema, | ||
| 40 | position: Schema.String, | ||
| 41 | createdAt: Schema.Number, | ||
| 42 | updatedAt: Schema.Number, | ||
| 43 | }); | ||
| 44 | export type ToolkitPolicyResponse = typeof ToolkitPolicyResponse.Type; | ||
| 45 | |||
| 46 | export const ToolkitConnectionResponse = Schema.Struct({ | ||
| 47 | id: Schema.String, | ||
| 48 | toolkitId: Schema.String, | ||
| 49 | pattern: Schema.String, | ||
| 50 | position: Schema.String, | ||
| 51 | createdAt: Schema.Number, | ||
| 52 | updatedAt: Schema.Number, | ||
| 53 | }); | ||
| 54 | export type ToolkitConnectionResponse = typeof ToolkitConnectionResponse.Type; | ||
| 55 | |||
| 56 | const CreateToolkitPayload = Schema.Struct({ | ||
| 57 | owner: Owner, | ||
| 58 | name: Schema.String, | ||
| 59 | slug: Schema.optional(Schema.String), | ||
| 60 | }); | ||
| 61 | |||
| 62 | const UpdateToolkitPayload = Schema.Struct({ | ||
| 63 | name: Schema.optional(Schema.String), | ||
| 64 | slug: Schema.optional(Schema.String), | ||
| 65 | }); | ||
| 66 | |||
| 67 | const CreateToolkitPolicyPayload = Schema.Struct({ | ||
| 68 | pattern: Schema.String, | ||
| 69 | action: ToolPolicyActionSchema, | ||
| 70 | position: Schema.optional(Schema.String), | ||
| 71 | }); | ||
| 72 | |||
| 73 | const UpdateToolkitPolicyPayload = Schema.Struct({ | ||
| 74 | pattern: Schema.optional(Schema.String), | ||
| 75 | action: Schema.optional(ToolPolicyActionSchema), | ||
| 76 | position: Schema.optional(Schema.String), | ||
| 77 | }); | ||
| 78 | |||
| 79 | const CreateToolkitConnectionPayload = Schema.Struct({ | ||
| 80 | pattern: Schema.String, | ||
| 81 | position: Schema.optional(Schema.String), | ||
| 82 | }); | ||
| 83 | |||
| 84 | const ToolkitErrors = [InternalError, ToolkitError] as const; | ||
| 85 | |||
| 86 | export const ToolkitsApi = HttpApiGroup.make("toolkits") | ||
| 87 | .add( | ||
| 88 | HttpApiEndpoint.get("list", "/toolkits", { | ||
| 89 | success: Schema.Struct({ toolkits: Schema.Array(ToolkitResponse) }), | ||
| 90 | error: ToolkitErrors, | ||
| 91 | }), | ||
| 92 | ) | ||
| 93 | .add( | ||
| 94 | HttpApiEndpoint.post("create", "/toolkits", { | ||
| 95 | payload: CreateToolkitPayload, | ||
| 96 | success: ToolkitResponse, | ||
| 97 | error: ToolkitErrors, | ||
| 98 | }), | ||
| 99 | ) | ||
| 100 | .add( | ||
| 101 | HttpApiEndpoint.patch("update", "/toolkits/:toolkitId", { | ||
| 102 | params: ToolkitParams, | ||
| 103 | payload: UpdateToolkitPayload, | ||
| 104 | success: ToolkitResponse, | ||
| 105 | error: ToolkitErrors, | ||
| 106 | }), | ||
| 107 | ) | ||
| 108 | .add( | ||
| 109 | HttpApiEndpoint.delete("remove", "/toolkits/:toolkitId", { | ||
| 110 | params: ToolkitParams, | ||
| 111 | success: Schema.Struct({ removed: Schema.Boolean }), | ||
| 112 | error: ToolkitErrors, | ||
| 113 | }), | ||
| 114 | ) | ||
| 115 | .add( | ||
| 116 | HttpApiEndpoint.get("listPolicies", "/toolkits/:toolkitId/policies", { | ||
| 117 | params: ToolkitParams, | ||
| 118 | success: Schema.Struct({ policies: Schema.Array(ToolkitPolicyResponse) }), | ||
| 119 | error: ToolkitErrors, | ||
| 120 | }), | ||
| 121 | ) | ||
| 122 | .add( | ||
| 123 | HttpApiEndpoint.post("createPolicy", "/toolkits/:toolkitId/policies", { | ||
| 124 | params: ToolkitParams, | ||
| 125 | payload: CreateToolkitPolicyPayload, | ||
| 126 | success: ToolkitPolicyResponse, | ||
| 127 | error: ToolkitErrors, | ||
| 128 | }), | ||
| 129 | ) | ||
| 130 | .add( | ||
| 131 | HttpApiEndpoint.patch("updatePolicy", "/toolkits/:toolkitId/policies/:policyId", { | ||
| 132 | params: ToolkitPolicyParams, | ||
| 133 | payload: UpdateToolkitPolicyPayload, | ||
| 134 | success: ToolkitPolicyResponse, | ||
| 135 | error: ToolkitErrors, | ||
| 136 | }), | ||
| 137 | ) | ||
| 138 | .add( | ||
| 139 | HttpApiEndpoint.delete("removePolicy", "/toolkits/:toolkitId/policies/:policyId", { | ||
| 140 | params: ToolkitPolicyParams, | ||
| 141 | success: Schema.Struct({ removed: Schema.Boolean }), | ||
| 142 | error: ToolkitErrors, | ||
| 143 | }), | ||
| 144 | ) | ||
| 145 | .add( | ||
| 146 | HttpApiEndpoint.get("listConnections", "/toolkits/:toolkitId/connections", { | ||
| 147 | params: ToolkitParams, | ||
| 148 | success: Schema.Struct({ connections: Schema.Array(ToolkitConnectionResponse) }), | ||
| 149 | error: ToolkitErrors, | ||
| 150 | }), | ||
| 151 | ) | ||
| 152 | .add( | ||
| 153 | HttpApiEndpoint.post("createConnection", "/toolkits/:toolkitId/connections", { | ||
| 154 | params: ToolkitParams, | ||
| 155 | payload: CreateToolkitConnectionPayload, | ||
| 156 | success: ToolkitConnectionResponse, | ||
| 157 | error: ToolkitErrors, | ||
| 158 | }), | ||
| 159 | ) | ||
| 160 | .add( | ||
| 161 | HttpApiEndpoint.delete("removeConnection", "/toolkits/:toolkitId/connections/:connectionId", { | ||
| 162 | params: ToolkitConnectionParams, | ||
| 163 | success: Schema.Struct({ removed: Schema.Boolean }), | ||
| 164 | error: ToolkitErrors, | ||
| 165 | }), | ||
| 166 | ); | ||
| @@ -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 | } | ||
| @@ -0,0 +1,14 @@ | |||
| 1 | import { defineConfig } from "tsup"; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | entry: { | ||
| 5 | server: "src/server.ts", | ||
| 6 | client: "src/client.tsx", | ||
| 7 | shared: "src/shared.ts", | ||
| 8 | }, | ||
| 9 | format: ["esm"], | ||
| 10 | dts: true, | ||
| 11 | clean: true, | ||
| 12 | sourcemap: true, | ||
| 13 | external: ["react"], | ||
| 14 | }); | ||
| @@ -0,0 +1,7 @@ | |||
| 1 | import { defineConfig } from "vitest/config"; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | test: { | ||
| 5 | globals: false, | ||
| 6 | }, | ||
| 7 | }); | ||
| @@ -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 third | 176 | /** 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 | <ToolRunPanel | 312 | </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 the | 420 | // 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 that | 421 | // 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 rule | 426 | // The "Clear" affordance only makes sense when there's a user rule |
| 410 | // pinned to this exact tool id — clearing a wildcard rule from a | 427 | // 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. |
| @@ -41,6 +41,9 @@ export interface ToolSummary { · 40 unmodified lines | |||
| 41 | /** Name of the connection (account) that produced this tool. Present only in | 41 | /** 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 | ||
| 55 | export type AccountGroup = { | 58 | export 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 | */ |
| 71 | export const buildAccountGroups = (tools: readonly ToolSummary[]): readonly AccountGroup[] => { | 75 | export const buildAccountGroups = (tools: readonly ToolSummary[]): readonly AccountGroup[] => { |
| 72 | const byKey = new Map<string, { owner: Owner; connection: string; tools: ToolSummary[] }>(); | 76 | const byKey = new Map< |
| 77 | string, | ||
| 78 | { owner: Owner; integration: string; connection: string; tools: ToolSummary[] } | ||
| 79 | >(); | ||
| 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.connection | 106 | 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 to | 302 | /** Sorted user-authored policies (most-precedent first). Used to |
| 287 | * decide whether a node has its own exact-pattern user rule today | 303 | * 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 | <ToolGroupRow | 539 | <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 | <PolicyActionMenu | 674 | <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 | <PolicyActionMenu | 747 | <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} |
| @@ -9,15 +9,21 @@ import { routeTree } from "./routes/routeTree.gen"; · 8 unmodified lines | |||
| 9 | // added without a consoleRoutes() entry, or vice versa — apps would silently | 9 | // 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 | ||
| 12 | const collectPaths = (route: unknown): ReadonlyArray<string> => { | 12 | const 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 the | 29 | // The generated ids are the scope-relative contract paths nested under the |
| @@ -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 | ]; |
| @@ -21,7 +21,7 @@ import { · 20 unmodified lines | |||
| 21 | integrationPresetIconUrl, | 21 | integrationPresetIconUrl, |
| 22 | } from "../components/integration-favicon"; | 22 | } from "../components/integration-favicon"; |
| 23 | import { CommandPalette } from "../components/command-palette"; | 23 | import { CommandPalette } from "../components/command-palette"; |
| 24 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; | 24 | import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 25 | import { useAuth } from "./auth-context"; | 25 | import { 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 proxies | 55 | /** 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. |
| 64 | const orgScopedTo = (to: string): string => (to === "/" ? "/{-$orgSlug}" : `/{-$orgSlug}${to}`); | 65 | const orgScopedTo = (to: string): string => (to === "/" ? "/{-$orgSlug}" : `/{-$orgSlug}${to}`); |
| 65 | 66 | ||
| 67 | const normalizePluginPagePath = (path: string): string => { | ||
| 68 | if (!path || path === "/") return "/"; | ||
| 69 | return path.startsWith("/") ? path : `/${path}`; | ||
| 70 | }; | ||
| 71 | |||
| 72 | const pluginPageNavPath = (pluginId: string, path: string): string => { | ||
| 73 | const normalized = normalizePluginPagePath(path); | ||
| 74 | return normalized === "/" ? `/plugins/${pluginId}/` : `/plugins/${pluginId}${normalized}`; | ||
| 75 | }; | ||
| 76 | |||
| 66 | /** The pathname with the active org-slug prefix stripped, for active-state | 77 | /** The pathname with the active org-slug prefix stripped, for active-state |
| 67 | * comparisons against scope-relative paths. */ | 78 | * comparisons against scope-relative paths. */ |
| 68 | function useScopeRelativePathname(): string { | 79 | function useScopeRelativePathname(): string { |
| @@ -311,7 +322,20 @@ function UserFooter(props: Pick<ShellProps, "onSignOut" | "orgMenuSlot">) { · 242 unmodified lines | |||
| 311 | function SidebarContent( | 322 | function 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 && ( |
| @@ -0,0 +1,34 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { matchPluginPage, matchPluginPagePath } from "./plugins.$pluginId.$"; | ||
| 4 | |||
| 5 | describe("plugin page route matching", () => { | ||
| 6 | it("matches plugin roots", () => { | ||
| 7 | expect(matchPluginPagePath("/", "/")).toEqual({}); | ||
| 8 | expect(matchPluginPagePath("/", "")).toEqual({}); | ||
| 9 | }); | ||
| 10 | |||
| 11 | it("matches static plugin subpages", () => { | ||
| 12 | expect(matchPluginPagePath("/settings", "/settings")).toEqual({}); | ||
| 13 | expect(matchPluginPagePath("/settings", "/other")).toBeNull(); | ||
| 14 | }); | ||
| 15 | |||
| 16 | it("captures $params from plugin subpages", () => { | ||
| 17 | expect(matchPluginPagePath("/$toolkitSlug", "/deploy-tools")).toEqual({ | ||
| 18 | toolkitSlug: "deploy-tools", | ||
| 19 | }); | ||
| 20 | }); | ||
| 21 | |||
| 22 | it("does not let parameter pages swallow deeper paths", () => { | ||
| 23 | expect(matchPluginPagePath("/$toolkitSlug", "/deploy-tools/rules")).toBeNull(); | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("prefers static plugin pages over parameter pages", () => { | ||
| 27 | const match = matchPluginPage([{ path: "/$id" }, { path: "/settings" }], "/settings"); | ||
| 28 | |||
| 29 | expect(match).toEqual({ | ||
| 30 | page: { path: "/settings" }, | ||
| 31 | params: {}, | ||
| 32 | }); | ||
| 33 | }); | ||
| 34 | }); | ||
| @@ -1,4 +1,4 @@ | |||
| 1 | import { createFileRoute, notFound } from "@tanstack/react-router"; | 1 | import { createFileRoute, Navigate, notFound } from "@tanstack/react-router"; |
| 2 | import { useClientPlugins } from "@executor-js/sdk/client"; | 2 | import { 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 mount | 11 | // 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 URL | 14 | // Plugin pages use the same lightweight route vocabulary as the rest of the |
| 15 | // remainder and a `PageDecl.path`, with `""` and `/` treated as the | 15 | // console route tree: static segments and `$param` segments. The host route |
| 16 | // same root. Plugins that want parameterized paths can build their own | 16 | // 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 | ||
| 20 | export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({ | 20 | export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({ |
| @@ -23,21 +23,93 @@ export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({ · 2 unmodified lines | |||
| 23 | 23 | ||
| 24 | function normalizePath(input: string): string { | 24 | function 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 | ||
| 30 | const pathSegments = (input: string): readonly string[] => | ||
| 31 | normalizePath(input) | ||
| 32 | .split("/") | ||
| 33 | .filter((segment) => segment.length > 0); | ||
| 34 | |||
| 35 | export const matchPluginPagePath = ( | ||
| 36 | pattern: string, | ||
| 37 | target: string, | ||
| 38 | ): Readonly<Record<string, string>> | null => { | ||
| 39 | const patternSegments = pathSegments(pattern); | ||
| 40 | const targetSegments = pathSegments(target); | ||
| 41 | if (patternSegments.length !== targetSegments.length) return null; | ||
| 42 | |||
| 43 | const params: Record<string, string> = {}; | ||
| 44 | for (let index = 0; index < patternSegments.length; index += 1) { | ||
| 45 | const patternSegment = patternSegments[index]!; | ||
| 46 | const targetSegment = targetSegments[index]!; | ||
| 47 | if (patternSegment.startsWith("$") && patternSegment.length > 1) { | ||
| 48 | params[patternSegment.slice(1)] = decodeURIComponent(targetSegment); | ||
| 49 | continue; | ||
| 50 | } | ||
| 51 | if (patternSegment !== targetSegment) return null; | ||
| 52 | } | ||
| 53 | return params; | ||
| 54 | }; | ||
| 55 | |||
| 56 | const matchScore = (pattern: string): number => | ||
| 57 | pathSegments(pattern).reduce((score, segment) => score + (segment.startsWith("$") ? 1 : 2), 0); | ||
| 58 | |||
| 59 | export const matchPluginPage = <TPage extends { readonly path: string }>( | ||
| 60 | pages: readonly TPage[] | undefined, | ||
| 61 | target: string, | ||
| 62 | ): { readonly page: TPage; readonly params: Readonly<Record<string, string>> } | null => { | ||
| 63 | const matches = | ||
| 64 | pages | ||
| 65 | ?.map((page, index) => ({ | ||
| 66 | page, | ||
| 67 | index, | ||
| 68 | params: matchPluginPagePath(page.path, target), | ||
| 69 | score: matchScore(page.path), | ||
| 70 | })) | ||
| 71 | .filter( | ||
| 72 | ( | ||
| 73 | candidate, | ||
| 74 | ): candidate is { | ||
| 75 | readonly page: TPage; | ||
| 76 | readonly index: number; | ||
| 77 | readonly params: Readonly<Record<string, string>>; | ||
| 78 | readonly score: number; | ||
| 79 | } => candidate.params !== null, | ||
| 80 | ) | ||
| 81 | .sort((a, b) => b.score - a.score || a.index - b.index) ?? []; | ||
| 82 | const first = matches[0]; | ||
| 83 | return first ? { page: first.page, params: first.params } : null; | ||
| 84 | }; | ||
| 85 | |||
| 29 | function PluginRouteComponent() { | 86 | function 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 | } |
| @@ -11,8 +11,10 @@ · 10 unmodified lines | |||
| 11 | import { Route as rootRouteImport } from './__root' | 11 | import { Route as rootRouteImport } from './__root' |
| 12 | import { Route as DotIndexRouteImport } from './index' | 12 | import { Route as DotIndexRouteImport } from './index' |
| 13 | import { Route as DotToolsRouteImport } from './tools' | 13 | import { Route as DotToolsRouteImport } from './tools' |
| 14 | import { Route as DotToolkitsRouteImport } from './toolkits' | ||
| 14 | import { Route as DotSecretsRouteImport } from './secrets' | 15 | import { Route as DotSecretsRouteImport } from './secrets' |
| 15 | import { Route as DotPoliciesRouteImport } from './policies' | 16 | import { Route as DotPoliciesRouteImport } from './policies' |
| 17 | import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug' | ||
| 16 | import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId' | 18 | import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId' |
| 17 | import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace' | 19 | import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace' |
| 18 | import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$' | 20 | import { 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) |
| 33 | const DotToolkitsRoute = DotToolkitsRouteImport.update({ | ||
| 34 | id: '/{-$orgSlug}/toolkits', | ||
| 35 | path: '/{-$orgSlug}/toolkits', | ||
| 36 | getParentRoute: () => rootRouteImport, | ||
| 37 | } as any) | ||
| 31 | const DotSecretsRoute = DotSecretsRouteImport.update({ | 38 | const 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) |
| 48 | const DotToolkitsDottoolkitSlugRoute = | ||
| 49 | DotToolkitsDottoolkitSlugRouteImport.update({ | ||
| 50 | id: '/$toolkitSlug', | ||
| 51 | path: '/$toolkitSlug', | ||
| 52 | getParentRoute: () => DotToolkitsRoute, | ||
| 53 | } as any) | ||
| 41 | const DotResumeDotexecutionIdRoute = DotResumeDotexecutionIdRouteImport.update({ | 54 | const 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 | |||
| 65 | export interface FileRoutesByFullPath { | 78 | export interface FileRoutesByFullPath { |
| 66 | '/{-$orgSlug}/policies': typeof DotPoliciesRoute | 79 | '/{-$orgSlug}/policies': typeof DotPoliciesRoute |
| 67 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute | 80 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute |
| 81 | '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren | ||
| 68 | '/{-$orgSlug}/tools': typeof DotToolsRoute | 82 | '/{-$orgSlug}/tools': typeof DotToolsRoute |
| 69 | '/{-$orgSlug}/': typeof DotIndexRoute | 83 | '/{-$orgSlug}/': typeof DotIndexRoute |
| 70 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute | 84 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 71 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute | 85 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 86 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute | ||
| 72 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute | 87 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute |
| 73 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute | 88 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute |
| 74 | } | 89 | } |
| 75 | export interface FileRoutesByTo { | 90 | export interface FileRoutesByTo { |
| 76 | '/{-$orgSlug}/policies': typeof DotPoliciesRoute | 91 | '/{-$orgSlug}/policies': typeof DotPoliciesRoute |
| 77 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute | 92 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute |
| 93 | '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren | ||
| 78 | '/{-$orgSlug}/tools': typeof DotToolsRoute | 94 | '/{-$orgSlug}/tools': typeof DotToolsRoute |
| 79 | '/{-$orgSlug}': typeof DotIndexRoute | 95 | '/{-$orgSlug}': typeof DotIndexRoute |
| 80 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute | 96 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 81 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute | 97 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 98 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute | ||
| 82 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute | 99 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute |
| 83 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute | 100 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute |
| 84 | } | 101 | } |
| @@ -86,10 +103,12 @@ export interface FileRoutesById { · 1 unmodified line | |||
| 86 | __root__: typeof rootRouteImport | 103 | __root__: typeof rootRouteImport |
| 87 | '/{-$orgSlug}/policies': typeof DotPoliciesRoute | 104 | '/{-$orgSlug}/policies': typeof DotPoliciesRoute |
| 88 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute | 105 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute |
| 106 | '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren | ||
| 89 | '/{-$orgSlug}/tools': typeof DotToolsRoute | 107 | '/{-$orgSlug}/tools': typeof DotToolsRoute |
| 90 | '/{-$orgSlug}/': typeof DotIndexRoute | 108 | '/{-$orgSlug}/': typeof DotIndexRoute |
| 91 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute | 109 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 92 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute | 110 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 111 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute | ||
| 93 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute | 112 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute |
| 94 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute | 113 | '/{-$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: FileRoutesByTo | 128 | 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: FileRoutesById | 152 | fileRoutesById: FileRoutesById |
| @@ -129,6 +154,7 @@ export interface FileRouteTypes { · 1 unmodified line | |||
| 129 | export interface RootRouteChildren { | 154 | export interface RootRouteChildren { |
| 130 | DotPoliciesRoute: typeof DotPoliciesRoute | 155 | DotPoliciesRoute: typeof DotPoliciesRoute |
| 131 | DotSecretsRoute: typeof DotSecretsRoute | 156 | DotSecretsRoute: typeof DotSecretsRoute |
| 157 | DotToolkitsRoute: typeof DotToolkitsRouteWithChildren | ||
| 132 | DotToolsRoute: typeof DotToolsRoute | 158 | DotToolsRoute: typeof DotToolsRoute |
| 133 | DotIndexRoute: typeof DotIndexRoute | 159 | DotIndexRoute: typeof DotIndexRoute |
| 134 | DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute | 160 | DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute |
| @@ -153,6 +179,13 @@ declare module '@tanstack/react-router' { · 18 unmodified lines | |||
| 153 | preLoaderRoute: typeof DotToolsRouteImport | 179 | preLoaderRoute: typeof DotToolsRouteImport |
| 154 | parentRoute: typeof rootRouteImport | 180 | 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 DotPoliciesRouteImport | 200 | preLoaderRoute: typeof DotPoliciesRouteImport |
| 168 | parentRoute: typeof rootRouteImport | 201 | 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 | ||
| 241 | interface DotToolkitsRouteChildren { | ||
| 242 | DotToolkitsDottoolkitSlugRoute: typeof DotToolkitsDottoolkitSlugRoute | ||
| 243 | } | ||
| 244 | |||
| 245 | const DotToolkitsRouteChildren: DotToolkitsRouteChildren = { | ||
| 246 | DotToolkitsDottoolkitSlugRoute: DotToolkitsDottoolkitSlugRoute, | ||
| 247 | } | ||
| 248 | |||
| 249 | const DotToolkitsRouteWithChildren = DotToolkitsRoute._addFileChildren( | ||
| 250 | DotToolkitsRouteChildren, | ||
| 251 | ) | ||
| 252 | |||
| 201 | const rootRouteChildren: RootRouteChildren = { | 253 | const 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, |
| @@ -0,0 +1,23 @@ | |||
| 1 | import { notFound } from "@tanstack/react-router"; | ||
| 2 | import { useClientPlugins } from "@executor-js/sdk/client"; | ||
| 3 | |||
| 4 | export function ToolkitsPluginRoute(props: { toolkitSlug?: string }) { | ||
| 5 | const plugins = useClientPlugins(); | ||
| 6 | const plugin = plugins.find((candidate) => candidate.id === "toolkits"); | ||
| 7 | const path = props.toolkitSlug ? `/${props.toolkitSlug}` : "/"; | ||
| 8 | const page = plugin?.pages?.find((candidate) => | ||
| 9 | props.toolkitSlug ? candidate.path === "/$toolkitSlug" : candidate.path === "/", | ||
| 10 | ); | ||
| 11 | |||
| 12 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound() | ||
| 13 | if (!plugin || !page) throw notFound(); | ||
| 14 | |||
| 15 | const Component = page.component; | ||
| 16 | return ( | ||
| 17 | <Component | ||
| 18 | params={props.toolkitSlug ? { toolkitSlug: props.toolkitSlug } : {}} | ||
| 19 | path={path} | ||
| 20 | pluginId={plugin.id} | ||
| 21 | /> | ||
| 22 | ); | ||
| 23 | } | ||
| @@ -0,0 +1,12 @@ | |||
| 1 | import { createFileRoute } from "@tanstack/react-router"; | ||
| 2 | |||
| 3 | import { ToolkitsPluginRoute } from "./toolkits-route"; | ||
| 4 | |||
| 5 | export const Route = createFileRoute("/{-$orgSlug}/toolkits/$toolkitSlug")({ | ||
| 6 | component: ToolkitsRouteComponent, | ||
| 7 | }); | ||
| 8 | |||
| 9 | function ToolkitsRouteComponent() { | ||
| 10 | const { toolkitSlug } = Route.useParams(); | ||
| 11 | return <ToolkitsPluginRoute toolkitSlug={toolkitSlug} />; | ||
| 12 | } | ||
| @@ -0,0 +1,12 @@ | |||
| 1 | import { createFileRoute, useParams } from "@tanstack/react-router"; | ||
| 2 | |||
| 3 | import { ToolkitsPluginRoute } from "./toolkits-route"; | ||
| 4 | |||
| 5 | export const Route = createFileRoute("/{-$orgSlug}/toolkits")({ | ||
| 6 | component: ToolkitsRouteComponent, | ||
| 7 | }); | ||
| 8 | |||
| 9 | function ToolkitsRouteComponent() { | ||
| 10 | const { toolkitSlug } = useParams({ strict: false }) as { toolkitSlug?: string }; | ||
| 11 | return <ToolkitsPluginRoute toolkitSlug={toolkitSlug} />; | ||
| 12 | } | ||