[codex] Add scoped MCP toolkits
Unified · Split
| @@ -5,6 +5,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; · 4 unmodified lines | ||
| 5 | 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | 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 | 11 | // Single source of truth for the cloud app's plugin list. |
| @@ -40,10 +41,11 @@ interface CloudPluginDeps { · 29 unmodified lines | ||
| 40 | 41 | * bypass the real WorkOS API. Production leaves this undefined and |
| 41 | 42 | * falls back to the credential-driven default. */ |
| 42 | 43 | readonly workosVaultClient?: WorkOSVaultClient; |
| 44 | + readonly activeToolkitSlug?: string; | |
| 43 | 45 | } |
| 44 | 46 | |
| 45 | 47 | export default defineExecutorConfig({ |
| 46 | − plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) => | |
| 48 | + plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) => | |
| 47 | 49 | [ |
| 48 | 50 | openApiHttpPlugin(), |
| 49 | 51 | googleHttpPlugin(), |
| @@ -52,6 +54,7 @@ export default defineExecutorConfig({ · 2 unmodified lines | ||
| 52 | 54 | dangerouslyAllowStdioMCP: false, |
| 53 | 55 | }), |
| 54 | 56 | graphqlHttpPlugin(), |
| 57 | + toolkitsPlugin({ activeToolkitSlug }), | |
| 55 | 58 | workosVaultPlugin({ |
| 56 | 59 | credentials: workosCredentials ?? { apiKey: "", clientId: "" }, |
| 57 | 60 | ...(workosVaultClient ? { client: workosVaultClient } : {}), |
| @@ -46,6 +46,7 @@ · 45 unmodified lines | ||
| 46 | 46 | "@executor-js/plugin-mcp": "workspace:*", |
| 47 | 47 | "@executor-js/plugin-microsoft": "workspace:*", |
| 48 | 48 | "@executor-js/plugin-openapi": "workspace:*", |
| 49 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 49 | 50 | "@executor-js/plugin-workos-vault": "workspace:*", |
| 50 | 51 | "@executor-js/react": "workspace:*", |
| 51 | 52 | "@executor-js/runtime-dynamic-worker": "workspace:*", |
| @@ -18,14 +18,20 @@ describe("isAppOwnedPath", () => { · 17 unmodified lines | ||
| 18 | 18 | "/api/billing/attach", |
| 19 | 19 | "/api/docs", // Swagger UI |
| 20 | 20 | "/mcp", |
| 21 | + "/mcp/toolkits/deploy-kit", | |
| 21 | 22 | "/.well-known/oauth-protected-resource/mcp", |
| 23 | + "/.well-known/oauth-protected-resource/mcp/toolkits/deploy-kit", | |
| 22 | 24 | "/.well-known/oauth-authorization-server", |
| 23 | 25 | // Org-pinned MCP: the org's URL slug (what the install card prints) and |
| 24 | 26 | // the legacy WorkOS org-id form both select an org on the MCP plane. |
| 25 | 27 | "/acme-corp/mcp", |
| 28 | + "/acme-corp/mcp/toolkits/deploy-kit", | |
| 26 | 29 | "/org_01ABCDEF/mcp", |
| 30 | + "/org_01ABCDEF/mcp/toolkits/deploy-kit", | |
| 27 | 31 | "/.well-known/oauth-protected-resource/acme-corp/mcp", |
| 32 | + "/.well-known/oauth-protected-resource/acme-corp/mcp/toolkits/deploy-kit", | |
| 28 | 33 | "/.well-known/oauth-protected-resource/org_01ABCDEF/mcp", |
| 34 | + "/.well-known/oauth-protected-resource/org_01ABCDEF/mcp/toolkits/deploy-kit", | |
| 29 | 35 | ]; |
| 30 | 36 | for (const pathname of appOwned) { |
| 31 | 37 | it(`forwards ${pathname} to the app handler`, () => { |
| @@ -46,7 +52,9 @@ describe("isAppOwnedPath", () => { · 14 unmodified lines | ||
| 46 | 52 | "/org", |
| 47 | 53 | "/assets/app.js", |
| 48 | 54 | "/settings/mcp", |
| 55 | + "/settings/mcp/toolkits/deploy-kit", | |
| 49 | 56 | "/integrations/mcp", |
| 57 | + "/integrations/mcp/toolkits/deploy-kit", | |
| 50 | 58 | ]; |
| 51 | 59 | for (const pathname of startOwned) { |
| 52 | 60 | it(`leaves ${pathname} to the Start router`, () => { |
| @@ -57,13 +57,15 @@ export const CloudDbProvider = cloudDbProviderLayer(collectTables()); · 56 unmodified lines | ||
| 57 | 57 | // Fresh plugin instances per request, carrying the Worker env's WorkOS Vault |
| 58 | 58 | // credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`. |
| 59 | 59 | export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)({ |
| 60 | − plugins: () => | |
| 60 | + plugins: (context) => | |
| 61 | 61 | executorConfig.plugins({ |
| 62 | 62 | workosCredentials: { |
| 63 | 63 | apiKey: env.WORKOS_API_KEY, |
| 64 | 64 | clientId: env.WORKOS_CLIENT_ID, |
| 65 | 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 | 43 | mcpOrganizationFromRequest, |
| 44 | 44 | protectedResourceMetadataUrlFor, |
| 45 | 45 | PROTECTED_RESOURCE_METADATA_PATH, |
| 46 | + toolkitSlugFromRequest, | |
| 46 | 47 | McpAuth, |
| 47 | 48 | McpAuthLive, |
| 48 | 49 | McpOrganizationAuth, |
| @@ -57,6 +58,7 @@ import { · 8 unmodified lines | ||
| 57 | 58 | } from "./oauth-metadata"; |
| 58 | 59 | |
| 59 | 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 | 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 | 96 | // The bare path is the only one mounted; `prepareMcpOrgScope` rewrites an |
| 95 | 97 | // org-scoped discovery doc onto it and pins the org in the header we read. |
| 96 | 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 | 117 | path: AUTHORIZATION_SERVER_METADATA_PATH, |
| @@ -103,7 +120,10 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 2 unmodified lines | ||
| 103 | 120 | ]; |
| 104 | 121 | |
| 105 | 122 | const resourceMetadataUrl = (request: Request): string => |
| 106 | − protectedResourceMetadataUrlFor(mcpOrganizationFromRequest(request)); | |
| 123 | + protectedResourceMetadataUrlFor( | |
| 124 | + mcpOrganizationFromRequest(request), | |
| 125 | + toolkitSlugFromRequest(request), | |
| 126 | + ); | |
| 107 | 127 | |
| 108 | 128 | /** |
| 109 | 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 | 180 | return finishAuthorized(request, result.token); |
| 161 | 181 | } |
| 162 | 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 | 50 | export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp"; |
| 51 | 51 | export const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`; |
| 52 | 52 | export const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`; |
| 53 | +const TOOLKIT_SEGMENT = "/toolkits/"; | |
| 53 | 54 | |
| 54 | 55 | // --------------------------------------------------------------------------- |
| 55 | 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 | 69 | export const mcpOrganizationFromRequest = (request: Request): string | null => |
| 69 | 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 | 84 | /** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy |
| 72 | 85 | * `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client |
| 73 | 86 | * used), or the bare resource. */ |
| 74 | −export const resourceUrlFor = (organizationSelector: string | null): string => | |
| 75 | − organizationSelector ? `${RESOURCE_ORIGIN}/${organizationSelector}${MCP_PATH}` : RESOURCE_URL; | |
| 87 | +export const resourceUrlFor = ( | |
| 88 | + organizationSelector: string | null, | |
| 89 | + toolkitSlug: string | null = null, | |
| 90 | +): string => | |
| 91 | + organizationSelector | |
| 92 | + ? `${RESOURCE_ORIGIN}/${organizationSelector}${toolkitMcpPath(toolkitSlug)}` | |
| 93 | + : `${RESOURCE_ORIGIN}${toolkitMcpPath(toolkitSlug)}`; | |
| 76 | 94 | |
| 77 | 95 | /** The protected-resource-metadata URL for an org selector, or the bare one. */ |
| 78 | −export const protectedResourceMetadataUrlFor = (organizationSelector: string | null): string => | |
| 79 | − organizationSelector | |
| 80 | − ? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationSelector}/mcp` | |
| 81 | − : PROTECTED_RESOURCE_METADATA_URL; | |
| 96 | +export const protectedResourceMetadataUrlFor = ( | |
| 97 | + organizationSelector: string | null, | |
| 98 | + toolkitSlug: string | null = null, | |
| 99 | +): string => { | |
| 100 | + const toolkitSuffix = toolkitSlug ? `/toolkits/${toolkitSlug}` : ""; | |
| 101 | + return organizationSelector | |
| 102 | + ? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationSelector}/mcp${toolkitSuffix}` | |
| 103 | + : `${PROTECTED_RESOURCE_METADATA_URL}${toolkitSuffix}`; | |
| 104 | +}; | |
| 82 | 105 | |
| 83 | 106 | type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; |
| 84 | 107 | |
| @@ -117,10 +140,11 @@ export const mcpUnauthorized = ( · 32 unmodified lines | ||
| 117 | 140 | export const bearerChallengeFor = ( |
| 118 | 141 | result: McpUnauthorizedResult, |
| 119 | 142 | organizationId: string | null = null, |
| 143 | + toolkitSlug: string | null = null, | |
| 120 | 144 | ): string => |
| 121 | 145 | bearerChallenge( |
| 122 | 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 | 50 | * id), or `null` for the bare path. Resolved to an org id — and re-checked |
| 51 | 51 | * against live membership — in the auth provider. */ |
| 52 | 52 | readonly organizationId: string | null; |
| 53 | + readonly toolkitSlug?: string; | |
| 53 | 54 | } | null; |
| 54 | 55 | |
| 55 | 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 | 61 | const orgSelectorSegment = (segment: string | undefined): string | null => |
| 61 | 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 | −// selector, `null` for the bare form, or `undefined` when the segments are neither. | |
| 65 | −const matchMcpSuffix = (segments: readonly string[]): string | null | undefined => { | |
| 66 | − if (segments.length === 1 && segments[0] === "mcp") return null; | |
| 64 | +type MatchedMcpSuffix = { | |
| 65 | + readonly organizationId: string | null; | |
| 66 | + readonly toolkitSlug?: string; | |
| 67 | +}; | |
| 68 | + | |
| 69 | +// Matches a trailing MCP endpoint: `mcp`, `mcp/toolkits/<slug>`, or either with | |
| 70 | +// a leading org selector. Returns undefined when the segments are not MCP. | |
| 71 | +const matchMcpSuffix = (segments: readonly string[]): MatchedMcpSuffix | undefined => { | |
| 72 | + if (segments.length === 1 && segments[0] === "mcp") return { organizationId: null }; | |
| 73 | + if (segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits") { | |
| 74 | + const toolkitSlug = segments[2]; | |
| 75 | + return toolkitSlug ? { organizationId: null, toolkitSlug } : undefined; | |
| 76 | + } | |
| 67 | 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 | 86 | return undefined; |
| 71 | 87 | }; |
| @@ -93,22 +109,24 @@ export const classifyMcpPath = (pathname: string): McpRoute => { · 21 unmodified lines | ||
| 93 | 109 | // org sits after the well-known prefix (RFC 9728), not at the path root. |
| 94 | 110 | const prmPrefix = "/.well-known/oauth-protected-resource"; |
| 95 | 111 | if (pathname.startsWith(`${prmPrefix}/`)) { |
| 96 | − const organizationId = matchMcpSuffix(segments.slice(2)); | |
| 97 | − return organizationId === undefined | |
| 98 | − ? null | |
| 99 | − : { kind: "oauth-protected-resource", organizationId }; | |
| 112 | + const matched = matchMcpSuffix(segments.slice(2)); | |
| 113 | + return matched === undefined ? null : { kind: "oauth-protected-resource", ...matched }; | |
| 100 | 114 | } |
| 101 | 115 | |
| 102 | 116 | // MCP transport: `/mcp` or `/<org>/mcp`. |
| 103 | − const organizationId = matchMcpSuffix(segments); | |
| 104 | − return organizationId === undefined ? null : { kind: "mcp", organizationId }; | |
| 117 | + const matched = matchMcpSuffix(segments); | |
| 118 | + return matched === undefined ? null : { kind: "mcp", ...matched }; | |
| 105 | 119 | }; |
| 106 | 120 | |
| 107 | −const bareMcpPath = (kind: McpRouteKind): string => | |
| 108 | − kind === "mcp" | |
| 109 | − ? MCP_PATH | |
| 110 | − : kind === "oauth-protected-resource" | |
| 111 | − ? PROTECTED_RESOURCE_METADATA_PATH | |
| 121 | +const bareMcpPath = (route: Exclude<McpRoute, null>): string => | |
| 122 | + route.kind === "mcp" | |
| 123 | + ? route.toolkitSlug | |
| 124 | + ? `${MCP_PATH}/toolkits/${route.toolkitSlug}` | |
| 125 | + : MCP_PATH | |
| 126 | + : route.kind === "oauth-protected-resource" | |
| 127 | + ? route.toolkitSlug | |
| 128 | + ? `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/${route.toolkitSlug}` | |
| 129 | + : PROTECTED_RESOURCE_METADATA_PATH | |
| 112 | 130 | : AUTHORIZATION_SERVER_METADATA_PATH; |
| 113 | 131 | |
| 114 | 132 | /** |
| @@ -125,7 +143,7 @@ export const prepareMcpOrgScope = (request: Request): Request => { · 10 unmodified lines | ||
| 125 | 143 | const url = new URL(request.url); |
| 126 | 144 | const route = classifyMcpPath(url.pathname); |
| 127 | 145 | if (route === null) return request; |
| 128 | − const bare = bareMcpPath(route.kind); | |
| 146 | + const bare = bareMcpPath(route); | |
| 129 | 147 | if (url.pathname === bare && !request.headers.has(MCP_ORGANIZATION_HEADER)) return request; |
| 130 | 148 | url.pathname = bare; |
| 131 | 149 | const rewritten = new Request(url, request); |
| @@ -17,9 +17,12 @@ const jsonWebResponse = (body: unknown, status = 200): Response => · 16 unmodified lines | ||
| 17 | 17 | // The `resource` reflects the URL-pinned org (`…/org_xxx/mcp`) when present, so a |
| 18 | 18 | // client that discovered metadata via the org-scoped well-known doc gets back the |
| 19 | 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 | 24 | jsonWebResponse({ |
| 22 | − resource: resourceUrlFor(organizationId), | |
| 25 | + resource: resourceUrlFor(organizationId, toolkitSlug), | |
| 23 | 26 | authorization_servers: [AUTHKIT_DOMAIN], |
| 24 | 27 | bearer_methods_supported: ["header"], |
| 25 | 28 | // Spec-faithful clients (OpenCode, mcporter) request exactly what is |
| @@ -172,6 +172,7 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> { · 171 unmodified lines | ||
| 172 | 172 | organizationName: org.name, |
| 173 | 173 | organizationSlug: org.slug, |
| 174 | 174 | userId: token.userId, |
| 175 | + resource: token.resource, | |
| 175 | 176 | elicitationMode: token.elicitationMode, |
| 176 | 177 | } satisfies SessionMeta; |
| 177 | 178 | }).pipe( |
| @@ -193,6 +194,7 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> { · 15 unmodified lines | ||
| 193 | 194 | sessionMeta.userId, |
| 194 | 195 | sessionMeta.organizationId, |
| 195 | 196 | sessionMeta.organizationName, |
| 197 | + { mcpResource: sessionMeta.resource }, | |
| 196 | 198 | ).pipe( |
| 197 | 199 | Effect.provide(CloudExecutionStackLayer), |
| 198 | 200 | Effect.withSpan("McpSessionDO.makeExecutionStack"), |
| @@ -14,11 +14,13 @@ import { Route as LoginRouteImport } from './routes/bare/login' · 13 unmodified lines | ||
| 14 | 14 | import { Route as CreateOrgRouteImport } from './routes/bare/create-org' |
| 15 | 15 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' |
| 16 | 16 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' |
| 17 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' | |
| 17 | 18 | import { Route as SecretsRouteImport } from './routes/app/secrets' |
| 18 | 19 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' |
| 19 | 20 | import { Route as OrgRouteImport } from './routes/app/org' |
| 20 | 21 | import { Route as BillingRouteImport } from './routes/app/billing' |
| 21 | 22 | import { Route as ApiKeysRouteImport } from './routes/app/api-keys' |
| 23 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' | |
| 22 | 24 | import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' |
| 23 | 25 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 24 | 26 | import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans' |
| @@ -51,6 +53,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = · 26 unmodified lines | ||
| 51 | 53 | path: '/{-$orgSlug}/tools', |
| 52 | 54 | getParentRoute: () => rootRouteImport, |
| 53 | 55 | } as any) |
| 56 | +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute = | |
| 57 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport.update({ | |
| 58 | + id: '/{-$orgSlug}/toolkits', | |
| 59 | + path: '/{-$orgSlug}/toolkits', | |
| 60 | + getParentRoute: () => rootRouteImport, | |
| 61 | + } as any) | |
| 54 | 62 | const SecretsRoute = SecretsRouteImport.update({ |
| 55 | 63 | id: '/{-$orgSlug}/secrets', |
| 56 | 64 | path: '/{-$orgSlug}/secrets', |
| @@ -77,6 +85,15 @@ const ApiKeysRoute = ApiKeysRouteImport.update({ · 20 unmodified lines | ||
| 77 | 85 | path: '/{-$orgSlug}/api-keys', |
| 78 | 86 | getParentRoute: () => rootRouteImport, |
| 79 | 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 | 97 | const ResumeDotexecutionIdRoute = ResumeDotexecutionIdRouteImport.update({ |
| 81 | 98 | id: '/{-$orgSlug}/resume/$executionId', |
| 82 | 99 | path: '/{-$orgSlug}/resume/$executionId', |
| @@ -113,11 +130,13 @@ export interface FileRoutesByFullPath { · 30 unmodified lines | ||
| 113 | 130 | '/{-$orgSlug}/org': typeof OrgRoute |
| 114 | 131 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 115 | 132 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 133 | + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | |
| 116 | 134 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 117 | 135 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 118 | 136 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute |
| 119 | 137 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 120 | 138 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 139 | + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | |
| 121 | 140 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 122 | 141 | } |
| 123 | 142 | export interface FileRoutesByTo { |
| @@ -129,11 +148,13 @@ export interface FileRoutesByTo { · 5 unmodified lines | ||
| 129 | 148 | '/{-$orgSlug}/org': typeof OrgRoute |
| 130 | 149 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 131 | 150 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 151 | + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | |
| 132 | 152 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 133 | 153 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 134 | 154 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute |
| 135 | 155 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 136 | 156 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 157 | + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | |
| 137 | 158 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 138 | 159 | } |
| 139 | 160 | export interface FileRoutesById { |
| @@ -146,11 +167,13 @@ export interface FileRoutesById { · 6 unmodified lines | ||
| 146 | 167 | '/{-$orgSlug}/org': typeof OrgRoute |
| 147 | 168 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 148 | 169 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 170 | + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | |
| 149 | 171 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 150 | 172 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 151 | 173 | '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute |
| 152 | 174 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 153 | 175 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 176 | + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | |
| 154 | 177 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 155 | 178 | } |
| 156 | 179 | export interface FileRouteTypes { |
| @@ -164,11 +187,13 @@ export interface FileRouteTypes { · 7 unmodified lines | ||
| 164 | 187 | | '/{-$orgSlug}/org' |
| 165 | 188 | | '/{-$orgSlug}/policies' |
| 166 | 189 | | '/{-$orgSlug}/secrets' |
| 190 | + | '/{-$orgSlug}/toolkits' | |
| 167 | 191 | | '/{-$orgSlug}/tools' |
| 168 | 192 | | '/{-$orgSlug}/' |
| 169 | 193 | | '/{-$orgSlug}/billing/plans' |
| 170 | 194 | | '/{-$orgSlug}/integrations/$namespace' |
| 171 | 195 | | '/{-$orgSlug}/resume/$executionId' |
| 196 | + | '/{-$orgSlug}/toolkits/$toolkitSlug' | |
| 172 | 197 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| 173 | 198 | fileRoutesByTo: FileRoutesByTo |
| 174 | 199 | to: |
| @@ -180,11 +205,13 @@ export interface FileRouteTypes { · 5 unmodified lines | ||
| 180 | 205 | | '/{-$orgSlug}/org' |
| 181 | 206 | | '/{-$orgSlug}/policies' |
| 182 | 207 | | '/{-$orgSlug}/secrets' |
| 208 | + | '/{-$orgSlug}/toolkits' | |
| 183 | 209 | | '/{-$orgSlug}/tools' |
| 184 | 210 | | '/{-$orgSlug}' |
| 185 | 211 | | '/{-$orgSlug}/billing/plans' |
| 186 | 212 | | '/{-$orgSlug}/integrations/$namespace' |
| 187 | 213 | | '/{-$orgSlug}/resume/$executionId' |
| 214 | + | '/{-$orgSlug}/toolkits/$toolkitSlug' | |
| 188 | 215 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| 189 | 216 | id: |
| 190 | 217 | | '__root__' |
| @@ -196,11 +223,13 @@ export interface FileRouteTypes { · 5 unmodified lines | ||
| 196 | 223 | | '/{-$orgSlug}/org' |
| 197 | 224 | | '/{-$orgSlug}/policies' |
| 198 | 225 | | '/{-$orgSlug}/secrets' |
| 226 | + | '/{-$orgSlug}/toolkits' | |
| 199 | 227 | | '/{-$orgSlug}/tools' |
| 200 | 228 | | '/{-$orgSlug}/' |
| 201 | 229 | | '/{-$orgSlug}/billing_/plans' |
| 202 | 230 | | '/{-$orgSlug}/integrations/$namespace' |
| 203 | 231 | | '/{-$orgSlug}/resume/$executionId' |
| 232 | + | '/{-$orgSlug}/toolkits/$toolkitSlug' | |
| 204 | 233 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| 205 | 234 | fileRoutesById: FileRoutesById |
| 206 | 235 | } |
| @@ -213,6 +242,7 @@ export interface RootRouteChildren { · 6 unmodified lines | ||
| 213 | 242 | OrgRoute: typeof OrgRoute |
| 214 | 243 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 215 | 244 | SecretsRoute: typeof SecretsRoute |
| 245 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | |
| 216 | 246 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 217 | 247 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 218 | 248 | Billing_DotplansRoute: typeof Billing_DotplansRoute |
| @@ -258,6 +288,13 @@ declare module '@tanstack/react-router' { · 39 unmodified lines | ||
| 258 | 288 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport |
| 259 | 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 | 298 | '/{-$orgSlug}/secrets': { |
| 262 | 299 | id: '/{-$orgSlug}/secrets' |
| 263 | 300 | path: '/{-$orgSlug}/secrets' |
| @@ -293,6 +330,13 @@ declare module '@tanstack/react-router' { · 29 unmodified lines | ||
| 293 | 330 | preLoaderRoute: typeof ApiKeysRouteImport |
| 294 | 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 | 340 | '/{-$orgSlug}/resume/$executionId': { |
| 297 | 341 | id: '/{-$orgSlug}/resume/$executionId' |
| 298 | 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 | 386 | const rootRouteChildren: RootRouteChildren = { |
| 328 | 387 | CreateOrgRoute: CreateOrgRoute, |
| 329 | 388 | LoginRoute: LoginRoute, |
| @@ -334,6 +393,8 @@ const rootRouteChildren: RootRouteChildren = { · 4 unmodified lines | ||
| 334 | 393 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: |
| 335 | 394 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, |
| 336 | 395 | SecretsRoute: SecretsRoute, |
| 396 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: | |
| 397 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, | |
| 337 | 398 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: |
| 338 | 399 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, |
| 339 | 400 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: |
| @@ -350,11 +411,15 @@ export const routeTree = rootRouteImport · 10 unmodified lines | ||
| 350 | 411 | ._addFileTypes<FileRouteTypes>() |
| 351 | 412 | |
| 352 | 413 | import type { getRouter } from './router.tsx' |
| 414 | + | |
| 353 | 415 | import type { startInstance } from './start.ts' |
| 416 | + | |
| 354 | 417 | declare module '@tanstack/react-start' { |
| 355 | 418 | interface Register { |
| 356 | 419 | ssr: true |
| 420 | + | |
| 357 | 421 | router: Awaited<ReturnType<typeof getRouter>> |
| 422 | + | |
| 358 | 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 | 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| 8 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 8 | 9 | |
| 9 | 10 | // --------------------------------------------------------------------------- |
| 10 | 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 | 19 | export default defineExecutorConfig({ |
| 19 | − plugins: () => | |
| 20 | + plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) => | |
| 20 | 21 | [ |
| 21 | 22 | openApiHttpPlugin(), |
| 22 | 23 | googleHttpPlugin(), |
| 23 | 24 | microsoftHttpPlugin(), |
| 24 | 25 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 25 | 26 | graphqlHttpPlugin(), |
| 27 | + toolkitsPlugin({ activeToolkitSlug }), | |
| 26 | 28 | encryptedSecretsPlugin({ key: process.env.EXECUTOR_SECRET_KEY ?? "build-time-placeholder" }), |
| 27 | 29 | ] as const, |
| 28 | 30 | }); |
| @@ -29,6 +29,7 @@ · 28 unmodified lines | ||
| 29 | 29 | "@executor-js/plugin-mcp": "workspace:*", |
| 30 | 30 | "@executor-js/plugin-microsoft": "workspace:*", |
| 31 | 31 | "@executor-js/plugin-openapi": "workspace:*", |
| 32 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 32 | 33 | "@executor-js/react": "workspace:*", |
| 33 | 34 | "@executor-js/runtime-quickjs": "workspace:*", |
| 34 | 35 | "@executor-js/sdk": "workspace:*", |
| @@ -32,7 +32,10 @@ const makeFakeD1 = (client: SqliteDataMigrationClient): D1Database => { · 31 unmodified lines | ||
| 32 | 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 | 39 | const objects = new Map<string, string>(); |
| 37 | 40 | // oxlint-disable-next-line executor/no-double-cast -- test double: only the R2 methods used by the migration are implemented |
| 38 | 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 | 98 | const insertOperationStorage = ( |
| 79 | 99 | client: SqliteDataMigrationClient, |
| 80 | 100 | row: { |
| @@ -103,6 +123,134 @@ const insertOperationStorage = ( · 22 unmodified lines | ||
| 103 | 123 | }); |
| 104 | 124 | |
| 105 | 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 | 254 | it.effect("moves Google OpenAPI ownership and copies the R2 spec object", () => |
| 107 | 255 | Effect.gen(function* () { |
| 108 | 256 | const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); |
| @@ -20,7 +20,7 @@ const queryRows = <T extends Record<string, unknown>>( · 19 unmodified lines | ||
| 20 | 20 | const sql = typeof stmt === "string" ? stmt : stmt.sql; |
| 21 | 21 | const args = typeof stmt === "string" ? [] : stmt.args; |
| 22 | 22 | const prepared = session.prepare(sql).bind(...args); |
| 23 | − if (firstWord(sql) === "SELECT") { | |
| 23 | + if (firstWord(sql) === "SELECT" || firstWord(sql) === "PRAGMA") { | |
| 24 | 24 | return prepared.all<T>().then((result) => result.results); |
| 25 | 25 | } |
| 26 | 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 | 128 | const r2ObjectName = (tenant: string, pluginId: string, key: string): string => |
| 42 | 129 | `o:${tenant}/${pluginId}/${key}`; |
| 43 | 130 | |
| @@ -52,6 +139,7 @@ const copyGoogleOpenApiSpecBlobsToR2 = ( · 8 unmodified lines | ||
| 52 | 139 | FROM integration |
| 53 | 140 | WHERE plugin_id = 'openapi' |
| 54 | 141 | AND config IS NOT NULL |
| 142 | + AND json_valid(config) | |
| 55 | 143 | AND json_type(config, '$.googleDiscoveryUrls') = 'array' |
| 56 | 144 | AND json_extract(config, '$.specHash') IS NOT NULL |
| 57 | 145 | AND json_extract(config, '$.specHash') <> ''`, |
| @@ -67,7 +155,10 @@ const copyGoogleOpenApiSpecBlobsToR2 = ( · 9 unmodified lines | ||
| 67 | 155 | } |
| 68 | 156 | }, |
| 69 | 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 | 164 | const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [ |
| @@ -86,5 +177,9 @@ export const runCloudflareDataMigrations = ( · 12 unmodified lines | ||
| 86 | 177 | bucket: R2Bucket | undefined, |
| 87 | 178 | ): Promise<readonly string[]> => |
| 88 | 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 | 38 | config: CloudflareConfig, |
| 39 | 39 | ): Layer.Layer<PluginsProvider> => |
| 40 | 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 | 48 | export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer<HostConfig> => |
| @@ -1,10 +1,58 @@ | ||
| 1 | 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 | 10 | import { makeAccessVerifier } from "../auth/cloudflare-access"; |
| 6 | 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 | 57 | // Cloudflare Access McpAuthProvider — the `/mcp` gate, identical identity to the |
| 10 | 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 | 72 | export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer<McpAuthProvider> => { |
| 25 | 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 | 88 | return Layer.succeed(McpAuthProvider)({ |
| 27 | − discoveryRoutes: [], | |
| 89 | + discoveryRoutes, | |
| 28 | 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 | 92 | authenticate: (request) => |
| 31 | 93 | verify(request).pipe( |
| 32 | 94 | Effect.map((principal) => (principal ? authenticated(principal) : unauthorized())), |
| @@ -61,6 +61,7 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 60 unmodified lines | ||
| 61 | 61 | organizationName: this.cfConfig.organizationName, |
| 62 | 62 | organizationSlug: this.cfConfig.organizationSlug, |
| 63 | 63 | userId: token.userId, |
| 64 | + resource: token.resource, | |
| 64 | 65 | elicitationMode: token.elicitationMode, |
| 65 | 66 | } satisfies SessionMeta); |
| 66 | 67 | } |
| @@ -79,6 +80,7 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 12 unmodified lines | ||
| 79 | 80 | sessionMeta.userId, |
| 80 | 81 | sessionMeta.organizationId, |
| 81 | 82 | sessionMeta.organizationName, |
| 83 | + { mcpResource: sessionMeta.resource }, | |
| 82 | 84 | ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); |
| 83 | 85 | // Browser elicitation mode (the base owns the approval store + the HTTP |
| 84 | 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 | 4 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 5 | 5 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 6 | 6 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| 7 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 7 | 8 | |
| 8 | 9 | // --------------------------------------------------------------------------- |
| 9 | 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 | 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 | 25 | openApiHttpPlugin(), |
| 22 | 26 | googleHttpPlugin(), |
| 23 | 27 | microsoftHttpPlugin(), |
| 24 | 28 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 25 | 29 | graphqlHttpPlugin(), |
| 30 | + toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }), | |
| 26 | 31 | encryptedSecretsPlugin({ key: secretKey }), |
| 27 | 32 | ] as const; |
| 28 | 33 | |
| @@ -11,8 +11,10 @@ · 10 unmodified lines | ||
| 11 | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | 12 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' |
| 13 | 13 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' |
| 14 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' | |
| 14 | 15 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' |
| 15 | 16 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' |
| 17 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' | |
| 16 | 18 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' |
| 17 | 19 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 18 | 20 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' |
| @@ -30,6 +32,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = · 11 unmodified lines | ||
| 30 | 32 | path: '/{-$orgSlug}/tools', |
| 31 | 33 | getParentRoute: () => rootRouteImport, |
| 32 | 34 | } as any) |
| 35 | +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute = | |
| 36 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport.update({ | |
| 37 | + id: '/{-$orgSlug}/toolkits', | |
| 38 | + path: '/{-$orgSlug}/toolkits', | |
| 39 | + getParentRoute: () => rootRouteImport, | |
| 40 | + } as any) | |
| 33 | 41 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute = |
| 34 | 42 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport.update({ |
| 35 | 43 | id: '/{-$orgSlug}/secrets', |
| @@ -42,6 +50,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute = · 6 unmodified lines | ||
| 42 | 50 | path: '/{-$orgSlug}/policies', |
| 43 | 51 | getParentRoute: () => rootRouteImport, |
| 44 | 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 | 62 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = |
| 46 | 63 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update( |
| 47 | 64 | { |
| @@ -78,20 +95,24 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK · 30 unmodified lines | ||
| 78 | 95 | export interface FileRoutesByFullPath { |
| 79 | 96 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 80 | 97 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 98 | + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | |
| 81 | 99 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 82 | 100 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 83 | 101 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 84 | 102 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 103 | + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | |
| 85 | 104 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 86 | 105 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| 87 | 106 | } |
| 88 | 107 | export interface FileRoutesByTo { |
| 89 | 108 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 90 | 109 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 110 | + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | |
| 91 | 111 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 92 | 112 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 93 | 113 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 94 | 114 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 115 | + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | |
| 95 | 116 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 96 | 117 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| 97 | 118 | } |
| @@ -99,10 +120,12 @@ export interface FileRoutesById { · 1 unmodified line | ||
| 99 | 120 | __root__: typeof rootRouteImport |
| 100 | 121 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 101 | 122 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 123 | + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | |
| 102 | 124 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 103 | 125 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 104 | 126 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 105 | 127 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 128 | + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | |
| 106 | 129 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 107 | 130 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| 108 | 131 | } |
| @@ -111,30 +134,36 @@ export interface FileRouteTypes { · 2 unmodified lines | ||
| 111 | 134 | fullPaths: |
| 112 | 135 | | '/{-$orgSlug}/policies' |
| 113 | 136 | | '/{-$orgSlug}/secrets' |
| 137 | + | '/{-$orgSlug}/toolkits' | |
| 114 | 138 | | '/{-$orgSlug}/tools' |
| 115 | 139 | | '/{-$orgSlug}/' |
| 116 | 140 | | '/{-$orgSlug}/integrations/$namespace' |
| 117 | 141 | | '/{-$orgSlug}/resume/$executionId' |
| 142 | + | '/{-$orgSlug}/toolkits/$toolkitSlug' | |
| 118 | 143 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| 119 | 144 | | '/{-$orgSlug}/plugins/$pluginId/$' |
| 120 | 145 | fileRoutesByTo: FileRoutesByTo |
| 121 | 146 | to: |
| 122 | 147 | | '/{-$orgSlug}/policies' |
| 123 | 148 | | '/{-$orgSlug}/secrets' |
| 149 | + | '/{-$orgSlug}/toolkits' | |
| 124 | 150 | | '/{-$orgSlug}/tools' |
| 125 | 151 | | '/{-$orgSlug}' |
| 126 | 152 | | '/{-$orgSlug}/integrations/$namespace' |
| 127 | 153 | | '/{-$orgSlug}/resume/$executionId' |
| 154 | + | '/{-$orgSlug}/toolkits/$toolkitSlug' | |
| 128 | 155 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| 129 | 156 | | '/{-$orgSlug}/plugins/$pluginId/$' |
| 130 | 157 | id: |
| 131 | 158 | | '__root__' |
| 132 | 159 | | '/{-$orgSlug}/policies' |
| 133 | 160 | | '/{-$orgSlug}/secrets' |
| 161 | + | '/{-$orgSlug}/toolkits' | |
| 134 | 162 | | '/{-$orgSlug}/tools' |
| 135 | 163 | | '/{-$orgSlug}/' |
| 136 | 164 | | '/{-$orgSlug}/integrations/$namespace' |
| 137 | 165 | | '/{-$orgSlug}/resume/$executionId' |
| 166 | + | '/{-$orgSlug}/toolkits/$toolkitSlug' | |
| 138 | 167 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| 139 | 168 | | '/{-$orgSlug}/plugins/$pluginId/$' |
| 140 | 169 | fileRoutesById: FileRoutesById |
| @@ -142,6 +171,7 @@ export interface FileRouteTypes { · 1 unmodified line | ||
| 142 | 171 | export interface RootRouteChildren { |
| 143 | 172 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 144 | 173 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 174 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | |
| 145 | 175 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 146 | 176 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 147 | 177 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| @@ -166,6 +196,13 @@ declare module '@tanstack/react-router' { · 18 unmodified lines | ||
| 166 | 196 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport |
| 167 | 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 | 206 | '/{-$orgSlug}/secrets': { |
| 170 | 207 | id: '/{-$orgSlug}/secrets' |
| 171 | 208 | path: '/{-$orgSlug}/secrets' |
| @@ -180,6 +217,13 @@ declare module '@tanstack/react-router' { · 8 unmodified lines | ||
| 180 | 217 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport |
| 181 | 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 | 227 | '/{-$orgSlug}/resume/$executionId': { |
| 184 | 228 | id: '/{-$orgSlug}/resume/$executionId' |
| 185 | 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 | 273 | const rootRouteChildren: RootRouteChildren = { |
| 215 | 274 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: |
| 216 | 275 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, |
| 217 | 276 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: |
| 218 | 277 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute, |
| 278 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: | |
| 279 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, | |
| 219 | 280 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: |
| 220 | 281 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, |
| 221 | 282 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: |
| @@ -12,7 +12,7 @@ · 11 unmodified lines | ||
| 12 | 12 | "assets": { |
| 13 | 13 | "directory": "./dist", |
| 14 | 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 | 17 | // D1 is the app's SQLite store (the DbProvider seam). `wrangler deploy` |
| 18 | 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 | 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| 8 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 8 | 9 | |
| 9 | 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 | 22 | export default defineExecutorConfig({ |
| 22 | − plugins: () => | |
| 23 | + plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) => | |
| 23 | 24 | [ |
| 24 | 25 | openApiHttpPlugin(), |
| 25 | 26 | googleHttpPlugin(), |
| 26 | 27 | microsoftHttpPlugin(), |
| 27 | 28 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 28 | 29 | graphqlHttpPlugin(), |
| 30 | + toolkitsPlugin({ activeToolkitSlug }), | |
| 29 | 31 | // First writable secret provider -> the default for `secrets.set`. |
| 30 | 32 | encryptedSecretsPlugin({ key: resolveSecretKey() }), |
| 31 | 33 | ] as const, |
| @@ -32,6 +32,7 @@ · 31 unmodified lines | ||
| 32 | 32 | "@executor-js/plugin-mcp": "workspace:*", |
| 33 | 33 | "@executor-js/plugin-microsoft": "workspace:*", |
| 34 | 34 | "@executor-js/plugin-openapi": "workspace:*", |
| 35 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 35 | 36 | "@executor-js/react": "workspace:*", |
| 36 | 37 | "@executor-js/runtime-quickjs": "workspace:*", |
| 37 | 38 | "@executor-js/sdk": "workspace:*", |
| @@ -106,7 +106,8 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 105 unmodified lines | ||
| 106 | 106 | routes: [ |
| 107 | 107 | // CLI device-login discovery, must precede the /api/auth/* wildcard |
| 108 | 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 | 111 | HttpRouter.add("GET", "/api/auth/cli-login", cliLoginHandler), |
| 111 | 112 | // Better Auth owns the rest of /api/auth/*, the full path reaches it. |
| 112 | 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 | 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 | 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 | 50 | const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; |
| 51 | +const TOOLKIT_MCP_SEGMENT = "/mcp/toolkits/"; | |
| 50 | 52 | |
| 51 | 53 | const parseRoles = (role: string | null | undefined): ReadonlyArray<string> => |
| 52 | 54 | (role ?? "user") |
| @@ -66,6 +68,19 @@ const userRole = (user: object): string | null => { · 13 unmodified lines | ||
| 66 | 68 | const hasBearer = (request: Request): boolean => |
| 67 | 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 | 85 | * Absolute protected-resource metadata URL for the 401 challenge. Derive the |
| 71 | 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 | 89 | const resourceMetadataUrlFor = (baseURL: string | undefined, request: Request): string => { |
| 75 | 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 | 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 | 141 | const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [ |
| 100 | 142 | { |
| 101 | 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 | 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 | 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 | 49 | test("an unauthenticated /mcp request returns 401 with a WWW-Authenticate challenge", async () => { |
| 40 | 50 | const res = await handler( |
| 41 | 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 | 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 | 85 | // --- End-to-end MCP OAuth: DCR -> authorize -> token -> /mcp with bearer --- |
| 57 | 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 | 6 | it("strips a single org segment before /mcp", () => { |
| 7 | 7 | expect(stripMcpOrgSegment("/iI9idP7BZcWpg9wW8cit3xE4r4dFSnHj/mcp")).toBe("/mcp"); |
| 8 | 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 | 12 | it("strips the org segment from the protected-resource discovery path", () => { |
| 12 | 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 | 21 | it("leaves the bare paths untouched", () => { |
| 18 | 22 | expect(stripMcpOrgSegment("/mcp")).toBeNull(); |
| 19 | − expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/mcp")).toBeNull(); | |
| 23 | + expect(stripMcpOrgSegment("/mcp/toolkits/deploy")).toBeNull(); | |
| 20 | 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 | 20 | * applies (already bare, not an MCP path, or an OAuth endpoint like |
| 21 | 21 | * `/api/auth/mcp/authorize`). |
| 22 | 22 | * |
| 23 | − * /<seg>/mcp -> /mcp | |
| 24 | − * /.well-known/oauth-protected-resource/<seg>/mcp -> /.well-known/oauth-protected-resource/mcp | |
| 23 | + * /<seg>/mcp -> /mcp | |
| 24 | + * /<seg>/mcp/toolkits/<toolkit> -> /mcp/toolkits/<toolkit> | |
| 25 | + * /.well-known/oauth-protected-resource/<seg>/mcp -> /.well-known/oauth-protected-resource | |
| 26 | + * /.well-known/oauth-protected-resource/<seg>/mcp/toolkits/<toolkit> | |
| 27 | + * -> /.well-known/oauth-protected-resource/mcp/toolkits/<toolkit> | |
| 25 | 28 | */ |
| 26 | 29 | export const stripMcpOrgSegment = (pathname: string): string | null => { |
| 27 | 30 | if (pathname.startsWith(`${PRM_PREFIX}/`)) { |
| @@ -29,8 +32,16 @@ export const stripMcpOrgSegment = (pathname: string): string | null => { · 1 unmodified line | ||
| 29 | 32 | .slice(PRM_PREFIX.length + 1) |
| 30 | 33 | .split("/") |
| 31 | 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 | 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 | 66 | rawUrl = `${pathname}${original.search}`; |
| 67 | 67 | } |
| 68 | 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`), | |
| 70 | − // or the dev server misroutes them to the API handler and they 404. | |
| 69 | + // SPA route `/mcp-consent`, or the dev server misroutes it to the API | |
| 70 | + // handler and returns a 404. | |
| 71 | 71 | const path = new URL(rawUrl, devOrigin).pathname; |
| 72 | 72 | const handled = |
| 73 | 73 | path === "/api" || |
File renamed without changes.
File renamed without changes.
| @@ -11,11 +11,13 @@ · 10 unmodified lines | ||
| 11 | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | 12 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' |
| 13 | 13 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' |
| 14 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' | |
| 14 | 15 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' |
| 15 | 16 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' |
| 16 | 17 | import { Route as ApiKeysRouteImport } from './routes/app/api-keys' |
| 17 | 18 | import { Route as AdminRouteImport } from './routes/app/admin' |
| 18 | 19 | import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' |
| 20 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' | |
| 19 | 21 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' |
| 20 | 22 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 21 | 23 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' |
| @@ -33,6 +35,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = · 11 unmodified lines | ||
| 33 | 35 | path: '/{-$orgSlug}/tools', |
| 34 | 36 | getParentRoute: () => rootRouteImport, |
| 35 | 37 | } as any) |
| 38 | +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute = | |
| 39 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport.update({ | |
| 40 | + id: '/{-$orgSlug}/toolkits', | |
| 41 | + path: '/{-$orgSlug}/toolkits', | |
| 42 | + getParentRoute: () => rootRouteImport, | |
| 43 | + } as any) | |
| 36 | 44 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute = |
| 37 | 45 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport.update({ |
| 38 | 46 | id: '/{-$orgSlug}/secrets', |
| @@ -60,6 +68,15 @@ const JoinDotcodeRoute = JoinDotcodeRouteImport.update({ · 21 unmodified lines | ||
| 60 | 68 | path: '/join/$code', |
| 61 | 69 | getParentRoute: () => rootRouteImport, |
| 62 | 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 | 80 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = |
| 64 | 81 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update( |
| 65 | 82 | { |
| @@ -99,10 +116,12 @@ export interface FileRoutesByFullPath { · 33 unmodified lines | ||
| 99 | 116 | '/{-$orgSlug}/api-keys': typeof ApiKeysRoute |
| 100 | 117 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 101 | 118 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 119 | + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | |
| 102 | 120 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 103 | 121 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 104 | 122 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 105 | 123 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 124 | + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | |
| 106 | 125 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 107 | 126 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| 108 | 127 | } |
| @@ -112,10 +131,12 @@ export interface FileRoutesByTo { · 3 unmodified lines | ||
| 112 | 131 | '/{-$orgSlug}/api-keys': typeof ApiKeysRoute |
| 113 | 132 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 114 | 133 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 134 | + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | |
| 115 | 135 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 116 | 136 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 117 | 137 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 118 | 138 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 139 | + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | |
| 119 | 140 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 120 | 141 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| 121 | 142 | } |
| @@ -126,10 +147,12 @@ export interface FileRoutesById { · 4 unmodified lines | ||
| 126 | 147 | '/{-$orgSlug}/api-keys': typeof ApiKeysRoute |
| 127 | 148 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 128 | 149 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 150 | + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | |
| 129 | 151 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 130 | 152 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 131 | 153 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 132 | 154 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 155 | + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | |
| 133 | 156 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 134 | 157 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| 135 | 158 | } |
| @@ -141,10 +164,12 @@ export interface FileRouteTypes { · 5 unmodified lines | ||
| 141 | 164 | | '/{-$orgSlug}/api-keys' |
| 142 | 165 | | '/{-$orgSlug}/policies' |
| 143 | 166 | | '/{-$orgSlug}/secrets' |
| 167 | + | '/{-$orgSlug}/toolkits' | |
| 144 | 168 | | '/{-$orgSlug}/tools' |
| 145 | 169 | | '/{-$orgSlug}/' |
| 146 | 170 | | '/{-$orgSlug}/integrations/$namespace' |
| 147 | 171 | | '/{-$orgSlug}/resume/$executionId' |
| 172 | + | '/{-$orgSlug}/toolkits/$toolkitSlug' | |
| 148 | 173 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| 149 | 174 | | '/{-$orgSlug}/plugins/$pluginId/$' |
| 150 | 175 | fileRoutesByTo: FileRoutesByTo |
| @@ -154,10 +179,12 @@ export interface FileRouteTypes { · 3 unmodified lines | ||
| 154 | 179 | | '/{-$orgSlug}/api-keys' |
| 155 | 180 | | '/{-$orgSlug}/policies' |
| 156 | 181 | | '/{-$orgSlug}/secrets' |
| 182 | + | '/{-$orgSlug}/toolkits' | |
| 157 | 183 | | '/{-$orgSlug}/tools' |
| 158 | 184 | | '/{-$orgSlug}' |
| 159 | 185 | | '/{-$orgSlug}/integrations/$namespace' |
| 160 | 186 | | '/{-$orgSlug}/resume/$executionId' |
| 187 | + | '/{-$orgSlug}/toolkits/$toolkitSlug' | |
| 161 | 188 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| 162 | 189 | | '/{-$orgSlug}/plugins/$pluginId/$' |
| 163 | 190 | id: |
| @@ -167,10 +194,12 @@ export interface FileRouteTypes { · 3 unmodified lines | ||
| 167 | 194 | | '/{-$orgSlug}/api-keys' |
| 168 | 195 | | '/{-$orgSlug}/policies' |
| 169 | 196 | | '/{-$orgSlug}/secrets' |
| 197 | + | '/{-$orgSlug}/toolkits' | |
| 170 | 198 | | '/{-$orgSlug}/tools' |
| 171 | 199 | | '/{-$orgSlug}/' |
| 172 | 200 | | '/{-$orgSlug}/integrations/$namespace' |
| 173 | 201 | | '/{-$orgSlug}/resume/$executionId' |
| 202 | + | '/{-$orgSlug}/toolkits/$toolkitSlug' | |
| 174 | 203 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| 175 | 204 | | '/{-$orgSlug}/plugins/$pluginId/$' |
| 176 | 205 | fileRoutesById: FileRoutesById |
| @@ -181,6 +210,7 @@ export interface RootRouteChildren { · 4 unmodified lines | ||
| 181 | 210 | ApiKeysRoute: typeof ApiKeysRoute |
| 182 | 211 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute |
| 183 | 212 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 213 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | |
| 184 | 214 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 185 | 215 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 186 | 216 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| @@ -205,6 +235,13 @@ declare module '@tanstack/react-router' { · 18 unmodified lines | ||
| 205 | 235 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport |
| 206 | 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 | 245 | '/{-$orgSlug}/secrets': { |
| 209 | 246 | id: '/{-$orgSlug}/secrets' |
| 210 | 247 | path: '/{-$orgSlug}/secrets' |
| @@ -240,6 +277,13 @@ declare module '@tanstack/react-router' { · 29 unmodified lines | ||
| 240 | 277 | preLoaderRoute: typeof JoinDotcodeRouteImport |
| 241 | 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 | 287 | '/{-$orgSlug}/resume/$executionId': { |
| 244 | 288 | id: '/{-$orgSlug}/resume/$executionId' |
| 245 | 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 | 333 | const rootRouteChildren: RootRouteChildren = { |
| 275 | 334 | JoinDotcodeRoute: JoinDotcodeRoute, |
| 276 | 335 | AdminRoute: AdminRoute, |
| @@ -279,6 +338,8 @@ const rootRouteChildren: RootRouteChildren = { · 2 unmodified lines | ||
| 279 | 338 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, |
| 280 | 339 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: |
| 281 | 340 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute, |
| 341 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: | |
| 342 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, | |
| 282 | 343 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: |
| 283 | 344 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, |
| 284 | 345 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: |
| @@ -11,8 +11,8 @@ import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shel · 10 unmodified lines | ||
| 11 | 11 | import { plugins as clientPlugins } from "virtual:executor/plugins-client"; |
| 12 | 12 | |
| 13 | 13 | import { authClient } from "../auth-client"; |
| 14 | −import { DevicePage } from "../device"; | |
| 15 | −import { McpConsentPage } from "../mcp-consent"; | |
| 14 | +import { DevicePage } from "../chromeless/device-page"; | |
| 15 | +import { McpConsentPage } from "../chromeless/mcp-consent-page"; | |
| 16 | 16 | import { LoginPage } from "../login"; |
| 17 | 17 | import { SetupPage } from "../setup"; |
| 18 | 18 | import { fetchNeedsSetup } from "../setup-status"; |
| @@ -8,6 +8,7 @@ import { keychainPlugin } from "@executor-js/plugin-keychain"; · 7 unmodified lines | ||
| 8 | 8 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; |
| 9 | 9 | import { onepasswordHttpPlugin } from "@executor-js/plugin-onepassword/api"; |
| 10 | 10 | import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/server"; |
| 11 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 11 | 12 | |
| 12 | 13 | // --------------------------------------------------------------------------- |
| 13 | 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 | 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 | 26 | export default defineExecutorConfig({ |
| 22 | − plugins: () => | |
| 27 | + plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) => | |
| 23 | 28 | [ |
| 24 | 29 | openApiHttpPlugin(), |
| 25 | 30 | googleHttpPlugin(), |
| 26 | 31 | microsoftHttpPlugin(), |
| 27 | 32 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), |
| 28 | 33 | graphqlHttpPlugin(), |
| 34 | + toolkitsPlugin({ activeToolkitSlug }), | |
| 29 | 35 | keychainPlugin(), |
| 30 | 36 | fileSecretsPlugin(), |
| 31 | 37 | onepasswordHttpPlugin(), |
| @@ -39,6 +39,7 @@ · 38 unmodified lines | ||
| 39 | 39 | "@executor-js/plugin-microsoft": "workspace:*", |
| 40 | 40 | "@executor-js/plugin-onepassword": "workspace:*", |
| 41 | 41 | "@executor-js/plugin-openapi": "workspace:*", |
| 42 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 42 | 43 | "@executor-js/react": "workspace:*", |
| 43 | 44 | "@executor-js/runtime-quickjs": "workspace:*", |
| 44 | 45 | "@executor-js/sdk": "workspace:*", |
| @@ -57,33 +57,40 @@ const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "ex · 56 unmodified lines | ||
| 57 | 57 | // Static config wins on conflict, matching the Vite plugin. |
| 58 | 58 | type LocalPlugins = readonly AnyPlugin[]; |
| 59 | 59 | |
| 60 | −const loadLocalPlugins = Effect.gen(function* () { | |
| 61 | − const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd(); | |
| 62 | − const staticPlugins = executorConfig.plugins(); | |
| 63 | − const dynamicPlugins = | |
| 64 | − (yield* Effect.promise(() => loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }))) ?? | |
| 65 | − []; | |
| 66 | − | |
| 67 | − const staticPackageNames = new Set( | |
| 68 | − staticPlugins.map((plugin) => plugin.packageName).filter((name): name is string => !!name), | |
| 69 | − ); | |
| 70 | − const dedupedDynamic = dynamicPlugins.filter((plugin) => { | |
| 71 | − if (plugin.packageName && staticPackageNames.has(plugin.packageName)) { | |
| 72 | − console.warn( | |
| 73 | − `[executor] plugin "${plugin.packageName}" appears in both ` + | |
| 74 | − `executor.config.ts and executor.jsonc#plugins. The static ` + | |
| 75 | − `entry wins; the jsonc entry is ignored.`, | |
| 76 | − ); | |
| 77 | − return false; | |
| 78 | − } | |
| 79 | − return true; | |
| 80 | − }); | |
| 60 | +export interface LocalExecutorOptions { | |
| 61 | + readonly activeToolkitSlug?: string; | |
| 62 | +} | |
| 81 | 63 | |
| 82 | − return { | |
| 83 | − cwd, | |
| 84 | − plugins: [...staticPlugins, ...dedupedDynamic] as LocalPlugins, | |
| 85 | − }; | |
| 86 | −}); | |
| 64 | +const loadLocalPlugins = (options: LocalExecutorOptions = {}) => | |
| 65 | + Effect.gen(function* () { | |
| 66 | + const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd(); | |
| 67 | + const staticPlugins = executorConfig.plugins({ | |
| 68 | + activeToolkitSlug: options.activeToolkitSlug, | |
| 69 | + }); | |
| 70 | + const dynamicPlugins = | |
| 71 | + (yield* Effect.promise(() => loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }))) ?? | |
| 72 | + []; | |
| 73 | + | |
| 74 | + const staticPackageNames = new Set( | |
| 75 | + staticPlugins.map((plugin) => plugin.packageName).filter((name): name is string => !!name), | |
| 76 | + ); | |
| 77 | + const dedupedDynamic = dynamicPlugins.filter((plugin) => { | |
| 78 | + if (plugin.packageName && staticPackageNames.has(plugin.packageName)) { | |
| 79 | + console.warn( | |
| 80 | + `[executor] plugin "${plugin.packageName}" appears in both ` + | |
| 81 | + `executor.config.ts and executor.jsonc#plugins. The static ` + | |
| 82 | + `entry wins; the jsonc entry is ignored.`, | |
| 83 | + ); | |
| 84 | + return false; | |
| 85 | + } | |
| 86 | + return true; | |
| 87 | + }); | |
| 88 | + | |
| 89 | + return { | |
| 90 | + cwd, | |
| 91 | + plugins: [...staticPlugins, ...dedupedDynamic] as LocalPlugins, | |
| 92 | + }; | |
| 93 | + }); | |
| 87 | 94 | |
| 88 | 95 | interface LocalExecutorBundle { |
| 89 | 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 | 145 | const storage = resolveStorage(); |
| 139 | 146 | |
| 140 | 147 | return Layer.effect(LocalExecutorTag)( |
| 141 | 148 | Effect.gen(function* () { |
| 142 | − const { cwd, plugins } = yield* loadLocalPlugins; | |
| 149 | + const { cwd, plugins } = yield* loadLocalPlugins(options); | |
| 143 | 150 | const tenantId = makeTenantId(cwd); |
| 144 | 151 | const tables = collectTables(); |
| 145 | 152 | |
| @@ -180,7 +187,11 @@ const createLocalExecutorLayer = () => { · 34 unmodified lines | ||
| 180 | 187 | // without touching the data. |
| 181 | 188 | yield* runSqliteDataMigrations(sqlite.client, localDataMigrations).pipe( |
| 182 | 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 () => { | |
| 227 | − const layer = createLocalExecutorLayer(); | |
| 237 | +export const createExecutorHandle = async (options: LocalExecutorOptions = {}) => { | |
| 238 | + const layer = createLocalExecutorLayer(options); | |
| 228 | 239 | const runtime = ManagedRuntime.make(layer); |
| 229 | 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 | 3 | import { createExecutionEngine } from "@executor-js/execution"; |
| 4 | 4 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 5 | 5 | import { makeLocalApiHandler } from "./app"; |
| 6 | −import { getExecutorBundle } from "./executor"; | |
| 6 | +import { createExecutorHandle, getExecutorBundle } from "./executor"; | |
| 7 | 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 | 67 | executor, |
| 68 | 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 | 88 | return { api: apiHandler, mcp }; |
| 73 | 89 | }; |
| @@ -1,9 +1,14 @@ | ||
| 1 | −import { Effect } from "effect"; | |
| 1 | +import { Effect, type Cause } from "effect"; | |
| 2 | 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 3 | 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; |
| 4 | 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 | 12 | import { |
| 8 | 13 | createExecutorMcpServer, |
| 9 | 14 | type ExecutorMcpServerConfig, |
| @@ -15,10 +20,16 @@ import { · 5 unmodified lines | ||
| 15 | 20 | readElicitationMode, |
| 16 | 21 | } from "@executor-js/host-mcp/browser-approval"; |
| 17 | 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 | 29 | import { startIntegrationsRefresh } from "./integrations"; |
| 21 | 30 | |
| 31 | +type AnyExecutionEngine = ExecutionEngine<Cause.YieldableError>; | |
| 32 | + | |
| 22 | 33 | // --------------------------------------------------------------------------- |
| 23 | 34 | // Streamable HTTP handler |
| 24 | 35 | // --------------------------------------------------------------------------- |
| @@ -32,6 +43,18 @@ export type McpRequestHandler = { · 7 unmodified lines | ||
| 32 | 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 | 58 | // Local serves these error bodies in-process; like the self-host store they are |
| 36 | 59 | // INNER responses (no CORS) — byte-identical to the prior hand-rolled copy |
| 37 | 60 | // (`content-type: application/json` only) via the canonical renderer. |
| @@ -79,48 +102,86 @@ const resumeApprovalResult = (executionId: string, response: ResumeResponse) => · 41 unmodified lines | ||
| 79 | 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 | 126 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); |
| 84 | 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 | 131 | const approvals = makeInProcessBrowserApprovalStore(); |
| 86 | − // Local runs one shared engine across every MCP session (main.ts builds it and | |
| 87 | − // passes it in), so the paused-execution lookup for browser approval reads it | |
| 88 | − // directly — there is no per-session engine to track. | |
| 89 | − const engine = "engine" in config ? config.engine : null; | |
| 132 | + const defaultEngine = engineFromConfig(handlerConfig.defaultConfig); | |
| 90 | 133 | |
| 91 | 134 | const pausedDetail = ( |
| 135 | + sessionId: string, | |
| 92 | 136 | executionId: string, |
| 93 | 137 | ): Promise<ReturnType<typeof formatPausedExecution> | null> => |
| 94 | − engine | |
| 138 | + (sessionEngines.get(sessionId) ?? defaultEngine) | |
| 95 | 139 | ? Effect.runPromise( |
| 96 | − engine.getPausedExecution(executionId).pipe( | |
| 140 | + (sessionEngines.get(sessionId) ?? defaultEngine)!.getPausedExecution(executionId).pipe( | |
| 97 | 141 | Effect.map((paused) => (paused ? formatPausedExecution(paused) : null)), |
| 98 | 142 | Effect.orElseSucceed(() => null), |
| 99 | 143 | ), |
| 100 | 144 | ) |
| 101 | 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 | 152 | const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => { |
| 104 | 153 | const t = transports.get(id); |
| 105 | 154 | const s = servers.get(id); |
| 155 | + const close = sessionClosers.get(id); | |
| 106 | 156 | transports.delete(id); |
| 107 | 157 | servers.delete(id); |
| 158 | + resources.delete(id); | |
| 159 | + sessionEngines.delete(id); | |
| 160 | + sessionClosers.delete(id); | |
| 108 | 161 | if (opts.transport) await ignoreClose(t ? () => t.close() : undefined); |
| 109 | 162 | if (opts.server) await ignoreClose(s ? () => s.close() : undefined); |
| 163 | + await ignoreClose(close); | |
| 110 | 164 | }; |
| 111 | 165 | |
| 112 | 166 | return { |
| 113 | 167 | handleRequest: async (request) => { |
| 168 | + const resource = resourceFromRequest(request); | |
| 169 | + if (!resource) return jsonError(404, -32001, "MCP resource not found"); | |
| 114 | 170 | const sessionId = request.headers.get("mcp-session-id"); |
| 115 | 171 | |
| 116 | 172 | if (sessionId) { |
| 117 | 173 | const transport = transports.get(sessionId); |
| 118 | 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 | 179 | return transport.handleRequest(request); |
| 120 | 180 | } |
| 121 | 181 | |
| 122 | 182 | let created: McpServer | undefined; |
| 123 | 183 | let createdSessionId: string | null = null; |
| 184 | + let resourceConfig: LocalMcpServerConfig | null = null; | |
| 124 | 185 | const transport = new WebStandardStreamableHTTPServerTransport({ |
| 125 | 186 | sessionIdGenerator: () => crypto.randomUUID(), |
| 126 | 187 | enableJsonResponse: true, |
| @@ -128,6 +189,10 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 1 unmodified line | ||
| 128 | 189 | createdSessionId = sid; |
| 129 | 190 | transports.set(sid, transport); |
| 130 | 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 | 197 | onsessionclosed: (sid) => void dispose(sid, { server: true }), |
| 133 | 198 | }); |
| @@ -140,9 +205,10 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 6 unmodified lines | ||
| 140 | 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 | 206 | try { |
| 142 | 207 | const elicitationMode = readElicitationMode(request); |
| 208 | + resourceConfig = await configForResource(resource); | |
| 143 | 209 | created = await Effect.runPromise( |
| 144 | 210 | createExecutorMcpServer({ |
| 145 | − ...config, | |
| 211 | + ...resourceConfig.config, | |
| 146 | 212 | browserApprovalStore: approvals.store, |
| 147 | 213 | elicitationMode: |
| 148 | 214 | elicitationMode === "browser" |
| @@ -161,6 +227,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 12 unmodified lines | ||
| 161 | 227 | await ignoreClose(() => transport.close()); |
| 162 | 228 | const server = created; |
| 163 | 229 | await ignoreClose(server ? () => server.close() : undefined); |
| 230 | + await ignoreClose(resourceConfig?.close); | |
| 164 | 231 | } |
| 165 | 232 | return response; |
| 166 | 233 | } catch (error) { |
| @@ -169,6 +236,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 2 unmodified lines | ||
| 169 | 236 | await ignoreClose(() => transport.close()); |
| 170 | 237 | const server = created; |
| 171 | 238 | await ignoreClose(server ? () => server.close() : undefined); |
| 239 | + await ignoreClose(resourceConfig?.close); | |
| 172 | 240 | } |
| 173 | 241 | return jsonError(500, -32603, "Internal server error"); |
| 174 | 242 | } |
| @@ -179,7 +247,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 4 unmodified lines | ||
| 179 | 247 | if (!match) return json({ error: "Not found" }, 404); |
| 180 | 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 | 251 | if (!paused) return json({ error: "Paused execution not found" }, 404); |
| 184 | 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 | 257 | if (!match) return json({ error: "Not found" }, 404); |
| 190 | 258 | if (request.method !== "POST") return json({ error: "Method not allowed" }, 405); |
| 191 | 259 | |
| 260 | + const sessionId = decodeURIComponent(match[1]); | |
| 192 | 261 | const executionId = decodeURIComponent(match[2]); |
| 193 | 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 | 267 | const response = await readResumeResponse(request); |
| 197 | 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 | 69 | routePath === "/index.html" |
| 70 | 70 | ? htmlResponse(file) |
| 71 | 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 | 91 | key === "index.html" |
| 90 | 92 | ? htmlResponse(file) |
| 91 | 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 | 99 | return routes; |
| @@ -109,7 +113,11 @@ interface ViteChild { · 13 unmodified lines | ||
| 109 | 113 | } |
| 110 | 114 | |
| 111 | 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 | 121 | const port = probe.port ?? 0; |
| 114 | 122 | probe.stop(true); |
| 115 | 123 | return port; |
| @@ -362,7 +370,8 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 246 unmodified lines | ||
| 362 | 370 | // provider, which cannot carry our local bearer. Everything else under |
| 363 | 371 | // /api and /mcp requires the bearer. |
| 364 | 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 | 376 | if (isGatedSurface && !skipAuth && !isAuthorized(req)) { |
| 368 | 377 | return withCors( |
| @@ -377,7 +386,7 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 8 unmodified lines | ||
| 377 | 386 | return withCors(oauthClientMetadataResponse(`${url.pathname}${url.search}`, req)); |
| 378 | 387 | } |
| 379 | 388 | |
| 380 | − if (url.pathname.startsWith("/mcp")) { | |
| 389 | + if (isMcpPath) { | |
| 381 | 390 | return withCors(await handlers.mcp.handleRequest(req)); |
| 382 | 391 | } |
| 383 | 392 | |
| @@ -16,7 +16,11 @@ import { · 15 unmodified lines | ||
| 16 | 16 | // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json |
| 17 | 17 | const rootPackage = JSON.parse( |
| 18 | 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 | 25 | // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json |
| 22 | 26 | const cliPackage = JSON.parse( |
| @@ -94,8 +98,9 @@ function executorApiPlugin(): Plugin { · 71 unmodified lines | ||
| 94 | 98 | }); |
| 95 | 99 | server.middlewares.use(async (req, res, next) => { |
| 96 | 100 | const rawUrl = req.url ?? "/"; |
| 97 | − const isApi = rawUrl.startsWith("/api/") || rawUrl === "/api"; | |
| 98 | − const isMcp = rawUrl.startsWith("/mcp"); | |
| 101 | + const pathOnly = rawUrl.split("?")[0] ?? "/"; | |
| 102 | + const isApi = pathOnly.startsWith("/api/") || pathOnly === "/api"; | |
| 103 | + const isMcp = pathOnly === "/mcp" || pathOnly.startsWith("/mcp/"); | |
| 99 | 104 | |
| 100 | 105 | if (!isApi && !isMcp) return next(); |
| 101 | 106 | |
| @@ -106,7 +111,6 @@ function executorApiPlugin(): Plugin { · 4 unmodified lines | ||
| 106 | 111 | // callback. The SPA carries the token from its `?_token`/localStorage |
| 107 | 112 | // bootstrap, so the UI is unaffected; external MCP clients use the |
| 108 | 113 | // daemon port. |
| 109 | − const pathOnly = rawUrl.split("?")[0] ?? "/"; | |
| 110 | 114 | const authExempt = pathOnly === "/api/health" || isUnauthenticatedOAuthPath(pathOnly); |
| 111 | 115 | if (!authExempt) { |
| 112 | 116 | const presented = req.headers.authorization; |
| @@ -151,7 +155,9 @@ function executorApiPlugin(): Plugin { · 38 unmodified lines | ||
| 151 | 155 | } else if (isMcp) { |
| 152 | 156 | response = await handlers.mcp.handleRequest(webRequest(rawUrl)); |
| 153 | 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 | 161 | } else if (pathOnly.startsWith("/api/mcp-sessions/")) { |
| 156 | 162 | const handler = |
| 157 | 163 | req.method === "GET" |
| @@ -70,6 +70,7 @@ · 69 unmodified lines | ||
| 70 | 70 | "@executor-js/plugin-mcp": "workspace:*", |
| 71 | 71 | "@executor-js/plugin-microsoft": "workspace:*", |
| 72 | 72 | "@executor-js/plugin-openapi": "workspace:*", |
| 73 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 73 | 74 | "@executor-js/plugin-workos-vault": "workspace:*", |
| 74 | 75 | "@executor-js/react": "workspace:*", |
| 75 | 76 | "@executor-js/runtime-dynamic-worker": "workspace:*", |
| @@ -180,6 +181,7 @@ · 104 unmodified lines | ||
| 180 | 181 | "@executor-js/plugin-mcp": "workspace:*", |
| 181 | 182 | "@executor-js/plugin-microsoft": "workspace:*", |
| 182 | 183 | "@executor-js/plugin-openapi": "workspace:*", |
| 184 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 183 | 185 | "@executor-js/react": "workspace:*", |
| 184 | 186 | "@executor-js/runtime-quickjs": "workspace:*", |
| 185 | 187 | "@executor-js/sdk": "workspace:*", |
| @@ -228,6 +230,7 @@ · 42 unmodified lines | ||
| 228 | 230 | "@executor-js/plugin-mcp": "workspace:*", |
| 229 | 231 | "@executor-js/plugin-microsoft": "workspace:*", |
| 230 | 232 | "@executor-js/plugin-openapi": "workspace:*", |
| 233 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 231 | 234 | "@executor-js/react": "workspace:*", |
| 232 | 235 | "@executor-js/runtime-quickjs": "workspace:*", |
| 233 | 236 | "@executor-js/sdk": "workspace:*", |
| @@ -280,6 +283,7 @@ · 46 unmodified lines | ||
| 280 | 283 | "@executor-js/plugin-microsoft": "workspace:*", |
| 281 | 284 | "@executor-js/plugin-onepassword": "workspace:*", |
| 282 | 285 | "@executor-js/plugin-openapi": "workspace:*", |
| 286 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 283 | 287 | "@executor-js/react": "workspace:*", |
| 284 | 288 | "@executor-js/runtime-quickjs": "workspace:*", |
| 285 | 289 | "@executor-js/sdk": "workspace:*", |
| @@ -346,6 +350,7 @@ · 60 unmodified lines | ||
| 346 | 350 | "@executor-js/plugin-mcp": "workspace:*", |
| 347 | 351 | "@executor-js/plugin-microsoft": "workspace:*", |
| 348 | 352 | "@executor-js/plugin-openapi": "workspace:*", |
| 353 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 349 | 354 | "@executor-js/sdk": "workspace:*", |
| 350 | 355 | "@kitlangton/terminal-control": "^0.3.0", |
| 351 | 356 | "@modelcontextprotocol/sdk": "^1.29.0", |
| @@ -1088,6 +1093,49 @@ · 736 unmodified lines | ||
| 1088 | 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 | 1139 | "packages/plugins/workos-vault": { |
| 1092 | 1140 | "name": "@executor-js/plugin-workos-vault", |
| 1093 | 1141 | "version": "0.0.2", |
| @@ -1682,6 +1730,8 @@ · 588 unmodified lines | ||
| 1682 | 1730 | |
| 1683 | 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 | 1735 | "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"], |
| 1686 | 1736 | |
| 1687 | 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 | 27 | "@executor-js/plugin-mcp": "workspace:*", |
| 28 | 28 | "@executor-js/plugin-microsoft": "workspace:*", |
| 29 | 29 | "@executor-js/plugin-openapi": "workspace:*", |
| 30 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 30 | 31 | "@executor-js/sdk": "workspace:*", |
| 31 | 32 | "@kitlangton/terminal-control": "^0.3.0", |
| 32 | 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 | 12 | import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot"; |
| 13 | 13 | |
| 14 | 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 | 19 | export interface CloudflareBootOptions { |
| 17 | 20 | readonly port: number; |
| @@ -28,12 +31,13 @@ export const bootCloudflare = async (options: CloudflareBootOptions): Promise<Bo · 10 unmodified lines | ||
| 28 | 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 | 36 | // dev-auth + the secret key arrive as `--var` overrides so the worker |
| 33 | 37 | // needs no Cloudflare account or real Access app. |
| 34 | − cmd: "bunx", | |
| 38 | + cmd: process.env.E2E_NODE_BIN ?? "node", | |
| 35 | 39 | args: [ |
| 36 | − "wrangler", | |
| 40 | + wranglerBin, | |
| 37 | 41 | "dev", |
| 38 | 42 | "--port", |
| 39 | 43 | String(options.port), |
| @@ -43,6 +47,8 @@ export const bootCloudflare = async (options: CloudflareBootOptions): Promise<Bo · 3 unmodified lines | ||
| 43 | 47 | "ENABLE_DEV_AUTH:true", |
| 44 | 48 | "--var", |
| 45 | 49 | "EXECUTOR_SECRET_KEY:e2e-secret-key-0123456789abcdef0123456789abcdef", |
| 50 | + "--var", | |
| 51 | + "ALLOW_LOCAL_NETWORK:true", | |
| 46 | 52 | ], |
| 47 | 53 | cwd: cloudflareDir, |
| 48 | 54 | env: { WRANGLER_SEND_METRICS: "false", CI: "true" }, |
| @@ -11,6 +11,8 @@ import { join } from "node:path"; · 10 unmodified lines | ||
| 11 | 11 | import { Effect } from "effect"; |
| 12 | 12 | |
| 13 | 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 | 17 | import { appendTraces } from "../trace-harvest"; |
| 16 | 18 | import type { Identity, Target } from "../target"; |
| @@ -160,7 +162,7 @@ export interface McpSurface { · 143 unmodified lines | ||
| 160 | 162 | readonly url: string; |
| 161 | 163 | readonly session: ( |
| 162 | 164 | identity: Identity, |
| 163 | − options?: { readonly elicitationMode?: McpElicitationMode }, | |
| 165 | + options?: { readonly elicitationMode?: McpElicitationMode; readonly url?: string }, | |
| 164 | 166 | ) => McpSession; |
| 165 | 167 | /** |
| 166 | 168 | * Mint a real MCP bearer headlessly: protected-resource discovery → |
| @@ -188,6 +190,17 @@ interface TokenResponse { · 21 unmodified lines | ||
| 188 | 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 | 204 | const mintBearerFlow = async (target: Target, email: string): Promise<string> => { |
| 192 | 205 | const consent = target.mcpConsent?.({ |
| 193 | 206 | label: email, |
| @@ -196,21 +209,31 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 2 unmodified lines | ||
| 196 | 209 | if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`); |
| 197 | 210 | |
| 198 | 211 | const mcpPath = new URL(target.mcpUrl).pathname; |
| 199 | − const resource = (await ( | |
| 200 | − await fetch(new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl)) | |
| 201 | − ).json()) as { authorization_servers?: ReadonlyArray<string> }; | |
| 212 | + let resourceResponse = await fetch( | |
| 213 | + new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl), | |
| 214 | + ); | |
| 215 | + if (resourceResponse.status === 404) { | |
| 216 | + resourceResponse = await fetch( | |
| 217 | + new URL("/.well-known/oauth-protected-resource", target.baseUrl), | |
| 218 | + ); | |
| 219 | + } | |
| 220 | + const resource = await jsonFrom<{ authorization_servers?: ReadonlyArray<string> }>( | |
| 221 | + resourceResponse, | |
| 222 | + "mintBearer: protected-resource metadata", | |
| 223 | + ); | |
| 202 | 224 | const issuer = resource.authorization_servers?.[0]; |
| 203 | 225 | if (!issuer) throw new Error("mintBearer: no authorization server advertised"); |
| 204 | − const metadata = (await ( | |
| 205 | − await fetch(new URL("/.well-known/oauth-authorization-server", issuer)) | |
| 206 | − ).json()) as { | |
| 226 | + const metadata = await jsonFrom<{ | |
| 207 | 227 | readonly authorization_endpoint: string; |
| 208 | 228 | readonly token_endpoint: string; |
| 209 | 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 | 235 | const redirectUri = "http://127.0.0.1:9/callback"; |
| 213 | − const registered = (await ( | |
| 236 | + const registered = await jsonFrom<{ readonly client_id: string }>( | |
| 214 | 237 | await fetch(metadata.registration_endpoint, { |
| 215 | 238 | method: "POST", |
| 216 | 239 | headers: { "content-type": "application/json" }, |
| @@ -221,8 +244,9 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 4 unmodified lines | ||
| 221 | 244 | response_types: ["code"], |
| 222 | 245 | token_endpoint_auth_method: "none", |
| 223 | 246 | }), |
| 224 | − }) | |
| 225 | − ).json()) as { readonly client_id: string }; | |
| 247 | + }), | |
| 248 | + "mintBearer: dynamic client registration", | |
| 249 | + ); | |
| 226 | 250 | |
| 227 | 251 | const verifier = randomBytes(32).toString("base64url"); |
| 228 | 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 | 264 | redirectUrl: redirectUri, |
| 241 | 265 | }); |
| 242 | 266 | |
| 243 | − const token = (await ( | |
| 267 | + const token = await jsonFrom<TokenResponse>( | |
| 244 | 268 | await fetch(metadata.token_endpoint, { |
| 245 | 269 | method: "POST", |
| 246 | 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 | 275 | client_id: registered.client_id, |
| 252 | 276 | code_verifier: verifier, |
| 253 | 277 | }), |
| 254 | − }) | |
| 255 | − ).json()) as TokenResponse; | |
| 278 | + }), | |
| 279 | + "mintBearer: token exchange", | |
| 280 | + ); | |
| 256 | 281 | if (!token.access_token) throw new Error("mintBearer: token exchange returned no token"); |
| 257 | 282 | return token.access_token; |
| 258 | 283 | }; |
| @@ -261,7 +286,8 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 2 unmodified lines | ||
| 261 | 286 | url: target.mcpUrl, |
| 262 | 287 | mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)), |
| 263 | 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 | 291 | // mcporter caches OAuth tokens (and the DCR client) per server NAME, so a |
| 266 | 292 | // constant name would let a later session reuse an earlier identity's token |
| 267 | 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 | 298 | // `?elicitation_mode=` query on the MCP endpoint — so a paused execution |
| 273 | 299 | // yields an approvalUrl instead of letting the model resume inline. |
| 274 | 300 | const sessionUrl = options?.elicitationMode |
| 275 | − ? `${target.mcpUrl}?elicitation_mode=${options.elicitationMode}` | |
| 276 | − : target.mcpUrl; | |
| 301 | + ? `${mcpUrl}?elicitation_mode=${options.elicitationMode}` | |
| 302 | + : mcpUrl; | |
| 303 | + | |
| 304 | + if (target.name === "cloudflare") { | |
| 305 | + let clientPromise: Promise<Client> | undefined; | |
| 306 | + const client = () => { | |
| 307 | + if (!clientPromise) { | |
| 308 | + clientPromise = (async () => { | |
| 309 | + const directClient = new Client( | |
| 310 | + { name: serverName, version: "1.0.0" }, | |
| 311 | + { capabilities: {} }, | |
| 312 | + ); | |
| 313 | + const transport = new StreamableHTTPClientTransport(new URL(sessionUrl), { | |
| 314 | + requestInit: { headers: identity.headers ?? {} }, | |
| 315 | + }); | |
| 316 | + await directClient.connect(transport); | |
| 317 | + return directClient; | |
| 318 | + })(); | |
| 319 | + } | |
| 320 | + return clientPromise; | |
| 321 | + }; | |
| 322 | + | |
| 323 | + const listTools = () => | |
| 324 | + Effect.promise(async () => { | |
| 325 | + const listed = await (await client()).listTools(); | |
| 326 | + return listed.tools.map((tool) => tool.name); | |
| 327 | + }); | |
| 328 | + | |
| 329 | + const call = (name: string, args: Record<string, unknown> = {}) => | |
| 330 | + Effect.promise(async (): Promise<McpCallResult> => { | |
| 331 | + const raw = await (await client()).callTool({ name, arguments: args }); | |
| 332 | + const isError = Boolean((raw as { isError?: boolean })?.isError); | |
| 333 | + return { raw, text: textOf(raw), ok: !isError }; | |
| 334 | + }); | |
| 335 | + | |
| 336 | + return { | |
| 337 | + listTools, | |
| 338 | + describeTools: () => | |
| 339 | + Effect.promise(async (): Promise<ReadonlyArray<McpToolDef>> => { | |
| 340 | + const listed = await (await client()).listTools(); | |
| 341 | + return listed.tools.map((tool) => ({ | |
| 342 | + name: tool.name, | |
| 343 | + description: tool.description ?? "", | |
| 344 | + })); | |
| 345 | + }), | |
| 346 | + call, | |
| 347 | + approvePaused: (text, content = {}) => | |
| 348 | + Effect.suspend(() => { | |
| 349 | + const match = /\bexecutionId:\s*(\S+)/.exec(text); | |
| 350 | + if (!match) | |
| 351 | + return Effect.die(new Error("approvePaused: executionId not found in text")); | |
| 352 | + return call("resume", { | |
| 353 | + executionId: match[1], | |
| 354 | + action: "accept", | |
| 355 | + content: JSON.stringify(content), | |
| 356 | + }); | |
| 357 | + }), | |
| 358 | + awaitResume: (executionId) => call("resume", { executionId }), | |
| 359 | + }; | |
| 360 | + } | |
| 361 | + | |
| 277 | 362 | let runtimePromise: Promise<Runtime> | undefined; |
| 278 | 363 | let connected = false; |
| 279 | 364 | |
| @@ -83,7 +83,13 @@ const forcedMcpConsent = · 82 unmodified lines | ||
| 83 | 83 | if (!decision.ok) { |
| 84 | 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 | 93 | const code = body.redirectURI ? new URL(body.redirectURI).searchParams.get("code") : null; |
| 88 | 94 | if (!code) { |
| 89 | 95 | throw new Error( |
| @@ -118,6 +124,6 @@ export const selfhostTarget = (): Target => ({ · 28 unmodified lines | ||
| 118 | 124 | mcpConsent: (identity: Identity) => |
| 119 | 125 | forcedMcpConsent(SELFHOST_BASE_URL, { |
| 120 | 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 | 49 | include: [ |
| 50 | 50 | "scenarios/browser-approval.test.ts", |
| 51 | 51 | "scenarios/microsoft-graph-full.test.ts", |
| 52 | + "scenarios/toolkits-mcp.test.ts", | |
| 52 | 53 | "cloudflare/**/*.test.ts", |
| 53 | 54 | ], |
| 54 | 55 | fileParallelism: false, |
| @@ -11,8 +11,10 @@ · 10 unmodified lines | ||
| 11 | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | 12 | import { Route as DotDotDotDotDotDotReactSrcRoutesIndexRouteImport } from './../../react/src/routes/index' |
| 13 | 13 | import { Route as DotDotDotDotDotDotReactSrcRoutesToolsRouteImport } from './../../react/src/routes/tools' |
| 14 | +import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport } from './../../react/src/routes/toolkits' | |
| 14 | 15 | import { Route as SecretsRouteImport } from './routes/app/secrets' |
| 15 | 16 | import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './../../react/src/routes/policies' |
| 17 | +import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug' | |
| 16 | 18 | import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId' |
| 17 | 19 | import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace' |
| 18 | 20 | import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$' |
| @@ -30,6 +32,12 @@ const DotDotDotDotDotDotReactSrcRoutesToolsRoute = · 11 unmodified lines | ||
| 30 | 32 | path: '/{-$orgSlug}/tools', |
| 31 | 33 | getParentRoute: () => rootRouteImport, |
| 32 | 34 | } as any) |
| 35 | +const DotDotDotDotDotDotReactSrcRoutesToolkitsRoute = | |
| 36 | + DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport.update({ | |
| 37 | + id: '/{-$orgSlug}/toolkits', | |
| 38 | + path: '/{-$orgSlug}/toolkits', | |
| 39 | + getParentRoute: () => rootRouteImport, | |
| 40 | + } as any) | |
| 33 | 41 | const SecretsRoute = SecretsRouteImport.update({ |
| 34 | 42 | id: '/{-$orgSlug}/secrets', |
| 35 | 43 | path: '/{-$orgSlug}/secrets', |
| @@ -41,6 +49,12 @@ const DotDotDotDotDotDotReactSrcRoutesPoliciesRoute = · 5 unmodified lines | ||
| 41 | 49 | path: '/{-$orgSlug}/policies', |
| 42 | 50 | getParentRoute: () => rootRouteImport, |
| 43 | 51 | } as any) |
| 52 | +const DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute = | |
| 53 | + DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update({ | |
| 54 | + id: '/$toolkitSlug', | |
| 55 | + path: '/$toolkitSlug', | |
| 56 | + getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesToolkitsRoute, | |
| 57 | + } as any) | |
| 44 | 58 | const DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute = |
| 45 | 59 | DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport.update({ |
| 46 | 60 | id: '/{-$orgSlug}/resume/$executionId', |
| @@ -71,20 +85,24 @@ const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute = · 24 unmodified lines | ||
| 71 | 85 | export interface FileRoutesByFullPath { |
| 72 | 86 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute |
| 73 | 87 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 88 | + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren | |
| 74 | 89 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute |
| 75 | 90 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 76 | 91 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 77 | 92 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 93 | + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute | |
| 78 | 94 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 79 | 95 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| 80 | 96 | } |
| 81 | 97 | export interface FileRoutesByTo { |
| 82 | 98 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute |
| 83 | 99 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 100 | + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren | |
| 84 | 101 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute |
| 85 | 102 | '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 86 | 103 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 87 | 104 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 105 | + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute | |
| 88 | 106 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 89 | 107 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| 90 | 108 | } |
| @@ -92,10 +110,12 @@ export interface FileRoutesById { · 1 unmodified line | ||
| 92 | 110 | __root__: typeof rootRouteImport |
| 93 | 111 | '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute |
| 94 | 112 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 113 | + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren | |
| 95 | 114 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute |
| 96 | 115 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 97 | 116 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 98 | 117 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 118 | + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute | |
| 99 | 119 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 100 | 120 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| 101 | 121 | } |
| @@ -104,30 +124,36 @@ export interface FileRouteTypes { · 2 unmodified lines | ||
| 104 | 124 | fullPaths: |
| 105 | 125 | | '/{-$orgSlug}/policies' |
| 106 | 126 | | '/{-$orgSlug}/secrets' |
| 127 | + | '/{-$orgSlug}/toolkits' | |
| 107 | 128 | | '/{-$orgSlug}/tools' |
| 108 | 129 | | '/{-$orgSlug}/' |
| 109 | 130 | | '/{-$orgSlug}/integrations/$namespace' |
| 110 | 131 | | '/{-$orgSlug}/resume/$executionId' |
| 132 | + | '/{-$orgSlug}/toolkits/$toolkitSlug' | |
| 111 | 133 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| 112 | 134 | | '/{-$orgSlug}/plugins/$pluginId/$' |
| 113 | 135 | fileRoutesByTo: FileRoutesByTo |
| 114 | 136 | to: |
| 115 | 137 | | '/{-$orgSlug}/policies' |
| 116 | 138 | | '/{-$orgSlug}/secrets' |
| 139 | + | '/{-$orgSlug}/toolkits' | |
| 117 | 140 | | '/{-$orgSlug}/tools' |
| 118 | 141 | | '/{-$orgSlug}' |
| 119 | 142 | | '/{-$orgSlug}/integrations/$namespace' |
| 120 | 143 | | '/{-$orgSlug}/resume/$executionId' |
| 144 | + | '/{-$orgSlug}/toolkits/$toolkitSlug' | |
| 121 | 145 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| 122 | 146 | | '/{-$orgSlug}/plugins/$pluginId/$' |
| 123 | 147 | id: |
| 124 | 148 | | '__root__' |
| 125 | 149 | | '/{-$orgSlug}/policies' |
| 126 | 150 | | '/{-$orgSlug}/secrets' |
| 151 | + | '/{-$orgSlug}/toolkits' | |
| 127 | 152 | | '/{-$orgSlug}/tools' |
| 128 | 153 | | '/{-$orgSlug}/' |
| 129 | 154 | | '/{-$orgSlug}/integrations/$namespace' |
| 130 | 155 | | '/{-$orgSlug}/resume/$executionId' |
| 156 | + | '/{-$orgSlug}/toolkits/$toolkitSlug' | |
| 131 | 157 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| 132 | 158 | | '/{-$orgSlug}/plugins/$pluginId/$' |
| 133 | 159 | fileRoutesById: FileRoutesById |
| @@ -135,6 +161,7 @@ export interface FileRouteTypes { · 1 unmodified line | ||
| 135 | 161 | export interface RootRouteChildren { |
| 136 | 162 | DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute |
| 137 | 163 | SecretsRoute: typeof SecretsRoute |
| 164 | + DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren | |
| 138 | 165 | DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute |
| 139 | 166 | DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 140 | 167 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| @@ -159,6 +186,13 @@ declare module '@tanstack/react-router' { · 18 unmodified lines | ||
| 159 | 186 | preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRouteImport |
| 160 | 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 | 196 | '/{-$orgSlug}/secrets': { |
| 163 | 197 | id: '/{-$orgSlug}/secrets' |
| 164 | 198 | path: '/{-$orgSlug}/secrets' |
| @@ -173,6 +207,13 @@ declare module '@tanstack/react-router' { · 8 unmodified lines | ||
| 173 | 207 | preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport |
| 174 | 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 | 217 | '/{-$orgSlug}/resume/$executionId': { |
| 177 | 218 | id: '/{-$orgSlug}/resume/$executionId' |
| 178 | 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 | 263 | const rootRouteChildren: RootRouteChildren = { |
| 208 | 264 | DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: |
| 209 | 265 | DotDotDotDotDotDotReactSrcRoutesPoliciesRoute, |
| 210 | 266 | SecretsRoute: SecretsRoute, |
| 267 | + DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: | |
| 268 | + DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren, | |
| 211 | 269 | DotDotDotDotDotDotReactSrcRoutesToolsRoute: |
| 212 | 270 | DotDotDotDotDotDotReactSrcRoutesToolsRoute, |
| 213 | 271 | DotDotDotDotDotDotReactSrcRoutesIndexRoute: |
| @@ -342,6 +342,7 @@ function SidebarContent(props: { · 341 unmodified lines | ||
| 342 | 342 | const isHome = props.pathname === "/"; |
| 343 | 343 | const isSecrets = props.pathname === "/secrets"; |
| 344 | 344 | const isPolicies = props.pathname === "/policies"; |
| 345 | + const isToolkits = props.pathname === "/toolkits" || props.pathname.startsWith("/toolkits/"); | |
| 345 | 346 | |
| 346 | 347 | return ( |
| 347 | 348 | <> |
| @@ -381,6 +382,12 @@ function SidebarContent(props: { · 33 unmodified lines | ||
| 381 | 382 | active={isPolicies} |
| 382 | 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 | 392 | <PluginNav pathname={props.pathname} onNavigate={props.onNavigate} /> |
| 386 | 393 | |
| @@ -26,6 +26,7 @@ · 25 unmodified lines | ||
| 26 | 26 | import { Context, Effect, Layer } from "effect"; |
| 27 | 27 | import type * as Cause from "effect/Cause"; |
| 28 | 28 | |
| 29 | +import type { McpResource } from "@executor-js/host-mcp"; | |
| 29 | 30 | import type { AnyPlugin, Executor, StorageFailure } from "@executor-js/sdk"; |
| 30 | 31 | import { |
| 31 | 32 | createExecutionEngine, |
| @@ -95,6 +96,7 @@ export const makeExecutionStack = < · 63 unmodified lines | ||
| 95 | 96 | accountId: string, |
| 96 | 97 | organizationId: string, |
| 97 | 98 | organizationName: string, |
| 99 | + options?: { readonly mcpResource?: McpResource }, | |
| 98 | 100 | ): Effect.Effect< |
| 99 | 101 | { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> }, |
| 100 | 102 | StorageFailure, |
| @@ -105,6 +107,7 @@ export const makeExecutionStack = < · 4 unmodified lines | ||
| 105 | 107 | accountId, |
| 106 | 108 | organizationId, |
| 107 | 109 | organizationName, |
| 110 | + { plugins: { mcpResource: options?.mcpResource } }, | |
| 108 | 111 | ); |
| 109 | 112 | const codeExecutor = yield* CodeExecutorProvider; |
| 110 | 113 | const { decorate } = yield* EngineDecorator; |
| @@ -39,11 +39,9 @@ export type McpExecutionStackLayer = Layer.Layer< · 38 unmodified lines | ||
| 39 | 39 | export const makeMcpBuildServer = |
| 40 | 40 | (executionStack: McpExecutionStackLayer): McpBuildServer => |
| 41 | 41 | (principal: Principal, options?: McpBuildServerOptions) => |
| 42 | − makeExecutionStack( | |
| 43 | − principal.accountId, | |
| 44 | − principal.organizationId, | |
| 45 | − principal.organizationName, | |
| 46 | − ).pipe( | |
| 42 | + makeExecutionStack(principal.accountId, principal.organizationId, principal.organizationName, { | |
| 43 | + mcpResource: options?.resource, | |
| 44 | + }).pipe( | |
| 47 | 45 | Effect.map(({ engine }) => engine), |
| 48 | 46 | // Pin browser-handoff URLs to the principal's org slug when present; |
| 49 | 47 | // absent slug leaves the service unprovided and the URL stays bare. |
| @@ -33,6 +33,7 @@ · 32 unmodified lines | ||
| 33 | 33 | |
| 34 | 34 | import { Context, Effect, Option } from "effect"; |
| 35 | 35 | |
| 36 | +import type { McpResource } from "@executor-js/host-mcp"; | |
| 36 | 37 | import { |
| 37 | 38 | createExecutor, |
| 38 | 39 | Subject, |
| @@ -164,8 +165,12 @@ export const buildOAuthRedirectUri = (input: { · 125 unmodified lines | ||
| 164 | 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 | 172 | export interface PluginsProviderShape { |
| 168 | − readonly plugins: () => readonly AnyPlugin[]; | |
| 173 | + readonly plugins: (context?: PluginsProviderContext) => readonly AnyPlugin[]; | |
| 169 | 174 | } |
| 170 | 175 | |
| 171 | 176 | export class PluginsProvider extends Context.Service<PluginsProvider, PluginsProviderShape>()( |
| @@ -202,6 +207,7 @@ export const makeScopedExecutor = < · 30 unmodified lines | ||
| 202 | 207 | // `EngineStackIdentity` (the engine decorator still wants it); not part of the |
| 203 | 208 | // v2 executor binding, which is `{ tenant, subject }` only. |
| 204 | 209 | _organizationName: string, |
| 210 | + options?: { readonly plugins?: PluginsProviderContext }, | |
| 205 | 211 | ): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> => |
| 206 | 212 | Effect.gen(function* () { |
| 207 | 213 | const { db, blobs } = yield* DbProvider; |
| @@ -242,7 +248,7 @@ export const makeScopedExecutor = < · 34 unmodified lines | ||
| 242 | 248 | oauthCallbackPath: config.oauthCallbackPath, |
| 243 | 249 | }); |
| 244 | 250 | |
| 245 | − const plugins = pluginsFactory(); | |
| 251 | + const plugins = pluginsFactory(options?.plugins); | |
| 246 | 252 | const hostedHttpOptions = { |
| 247 | 253 | allowLocalNetwork: config.allowLocalNetwork, |
| 248 | 254 | }; |
| @@ -46,10 +46,19 @@ export { useAtomValue, useAtomSet, useAtomMount, useAtomRefresh } from "@effect/ · 45 unmodified lines | ||
| 46 | 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 | 58 | export interface PageDecl { |
| 50 | 59 | /** Path relative to the plugin's mount point, e.g. `/`, `/edit/$id`. */ |
| 51 | 60 | readonly path: string; |
| 52 | − readonly component: ComponentType; | |
| 61 | + readonly component: ComponentType<PluginPageProps>; | |
| 53 | 62 | /** Optional sidebar nav metadata — the host renders these alongside its |
| 54 | 63 | * own nav links. Omit to register a page without a nav entry. */ |
| 55 | 64 | readonly nav?: { |
| @@ -90,6 +90,7 @@ import type { OAuthService } from "./oauth-client"; · 89 unmodified lines | ||
| 90 | 90 | import { |
| 91 | 91 | comparePolicyRow, |
| 92 | 92 | isValidPattern, |
| 93 | + matchPattern, | |
| 93 | 94 | resolveEffectivePolicy, |
| 94 | 95 | rowToToolPolicy, |
| 95 | 96 | type CreateToolPolicyInput, |
| @@ -112,6 +113,8 @@ import type { · 16 unmodified lines | ||
| 112 | 113 | StaticSourceDecl, |
| 113 | 114 | StaticToolDecl, |
| 114 | 115 | StorageDeps, |
| 116 | + ToolPolicyProvider, | |
| 117 | + ToolPolicyProviderRule, | |
| 115 | 118 | ToolInvocationCredential, |
| 116 | 119 | } from "./plugin"; |
| 117 | 120 | import { |
| @@ -1284,6 +1287,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1166 unmodified lines | ||
| 1284 | 1287 | // Populated once, never mutated after startup. |
| 1285 | 1288 | const staticTools = new Map<string, StaticTools>(); |
| 1286 | 1289 | const runtimes = new Map<string, PluginRuntime>(); |
| 1290 | + let activeToolPolicyProvider: ToolPolicyProvider | null = null; | |
| 1287 | 1291 | // Credential providers keyed by `provider.key`, in registration order. |
| 1288 | 1292 | const credentialProviders = new Map<string, CredentialProvider>(); |
| 1289 | 1293 | const credentialProviderOrder: string[] = []; |
| @@ -2301,8 +2305,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1011 unmodified lines | ||
| 2301 | 2305 | readonly integration?: IntegrationSlug; |
| 2302 | 2306 | readonly owner?: Owner; |
| 2303 | 2307 | }): Effect.Effect<readonly Connection[], StorageFailure> => |
| 2304 | − core | |
| 2305 | − .findMany("connection", { | |
| 2308 | + Effect.gen(function* () { | |
| 2309 | + const rows = yield* core.findMany("connection", { | |
| 2306 | 2310 | where: (b: AnyCb) => |
| 2307 | 2311 | b.and( |
| 2308 | 2312 | filter?.integration === undefined |
| @@ -2310,8 +2314,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line | ||
| 2310 | 2314 | : b("integration", "=", String(filter.integration)), |
| 2311 | 2315 | filter?.owner === undefined ? true : b("owner", "=", filter.owner), |
| 2312 | 2316 | ), |
| 2313 | − }) | |
| 2314 | − .pipe(Effect.map((rows) => rows.map(rowToConnection))); | |
| 2317 | + }); | |
| 2318 | + const connections = rows.map(rowToConnection); | |
| 2319 | + if (!activeToolPolicyProvider) return connections; | |
| 2320 | + | |
| 2321 | + const visibleTools = yield* toolsList({ includeAnnotations: false }); | |
| 2322 | + const visibleConnectionKeys = new Set( | |
| 2323 | + visibleTools | |
| 2324 | + .filter((tool) => !tool.static) | |
| 2325 | + .map((tool) => `${tool.owner}:${tool.integration}:${tool.connection}`), | |
| 2326 | + ); | |
| 2327 | + return connections.filter((connection) => | |
| 2328 | + visibleConnectionKeys.has( | |
| 2329 | + `${connection.owner}:${connection.integration}:${connection.name}`, | |
| 2330 | + ), | |
| 2331 | + ); | |
| 2332 | + }); | |
| 2315 | 2333 | |
| 2316 | 2334 | const connectionsGet = (ref: ConnectionRef): Effect.Effect<Connection | null, StorageFailure> => |
| 2317 | 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 | 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 | 2511 | // Tools (read surface) |
| 2419 | 2512 | // ------------------------------------------------------------------ |
| @@ -2492,16 +2585,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 72 unmodified lines | ||
| 2492 | 2585 | select: TOOL_INVOCATION_COLUMNS, |
| 2493 | 2586 | }); |
| 2494 | 2587 | const includeBlocked = filter?.includeBlocked ?? false; |
| 2495 | − const policyRows = yield* core.findMany("tool_policy", {}); | |
| 2588 | + const policyRules = yield* listActivePolicyRuleSet(); | |
| 2496 | 2589 | const tools: Tool[] = []; |
| 2497 | 2590 | for (const row of rows) { |
| 2498 | 2591 | const tool = rowToTool(row); |
| 2499 | 2592 | if (!matchesToolFilter(tool, filter)) continue; |
| 2500 | 2593 | if (!includeBlocked) { |
| 2501 | − const effective = resolveEffectivePolicy( | |
| 2594 | + const effective = yield* resolvePolicyFromRuleSet( | |
| 2502 | 2595 | normalizedPolicyId(tool), |
| 2503 | − policyRows, | |
| 2504 | − ownerRankForRow, | |
| 2596 | + policyRules, | |
| 2505 | 2597 | tool.annotations?.requiresApproval, |
| 2506 | 2598 | ); |
| 2507 | 2599 | if (effective.action === "block") continue; |
| @@ -2512,10 +2604,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 4 unmodified lines | ||
| 2512 | 2604 | const tool = staticToolToTool(entry); |
| 2513 | 2605 | if (!matchesToolFilter(tool, filter)) continue; |
| 2514 | 2606 | if (!includeBlocked) { |
| 2515 | − const effective = resolveEffectivePolicy( | |
| 2607 | + const effective = yield* resolvePolicyFromRuleSet( | |
| 2516 | 2608 | normalizedPolicyId(tool), |
| 2517 | − policyRows, | |
| 2518 | − ownerRankForRow, | |
| 2609 | + policyRules, | |
| 2519 | 2610 | tool.annotations?.requiresApproval, |
| 2520 | 2611 | ); |
| 2521 | 2612 | if (effective.action === "block") continue; |
| @@ -2529,9 +2620,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines | ||
| 2529 | 2620 | address: ToolAddress, |
| 2530 | 2621 | ): Effect.Effect<ToolSchemaView | null, StorageFailure> => |
| 2531 | 2622 | Effect.gen(function* () { |
| 2623 | + const policyRules = yield* listActivePolicyRuleSet(); | |
| 2532 | 2624 | const staticEntry = staticTools.get(String(address)); |
| 2533 | 2625 | if (staticEntry) { |
| 2534 | 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 | 2633 | const preview = yield* Effect.tryPromise({ |
| 2536 | 2634 | try: () => |
| 2537 | 2635 | buildToolTypeScriptPreview({ |
| @@ -2567,6 +2665,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines | ||
| 2567 | 2665 | }); |
| 2568 | 2666 | if (!row) return null; |
| 2569 | 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 | 2675 | const definitionRows = yield* core.findMany("definition", { |
| 2572 | 2676 | where: (b: AnyCb) => |
| @@ -2906,11 +3010,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 333 unmodified lines | ||
| 2906 | 3010 | // not the 5-segment dynamic form. |
| 2907 | 3011 | const staticEntry = staticTools.get(String(address)); |
| 2908 | 3012 | if (staticEntry) { |
| 2909 | − const policyRows = yield* core.findMany("tool_policy", {}); | |
| 2910 | − const policy = resolveEffectivePolicy( | |
| 3013 | + const policyRules = yield* listActivePolicyRuleSet(); | |
| 3014 | + const policy = yield* resolvePolicyFromRuleSet( | |
| 2911 | 3015 | String(address), |
| 2912 | − policyRows, | |
| 2913 | − ownerRankForRow, | |
| 3016 | + policyRules, | |
| 2914 | 3017 | staticEntry.tool.annotations?.requiresApproval, |
| 2915 | 3018 | ); |
| 2916 | 3019 | if (policy.action === "block") { |
| @@ -2959,12 +3062,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 42 unmodified lines | ||
| 2959 | 3062 | |
| 2960 | 3063 | // Resolve policy (owner-ranked). |
| 2961 | 3064 | const toolForPolicy = rowToTool(row); |
| 2962 | − const policyRows = yield* core.findMany("tool_policy", {}); | |
| 3065 | + const policyRules = yield* listActivePolicyRuleSet(); | |
| 2963 | 3066 | const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined; |
| 2964 | − const policy = resolveEffectivePolicy( | |
| 3067 | + const policy = yield* resolvePolicyFromRuleSet( | |
| 2965 | 3068 | normalizedPolicyId(toolForPolicy), |
| 2966 | − policyRows, | |
| 2967 | − ownerRankForRow, | |
| 3069 | + policyRules, | |
| 2968 | 3070 | annotations?.requiresApproval, |
| 2969 | 3071 | ); |
| 2970 | 3072 | if (policy.action === "block") { |
| @@ -3186,6 +3288,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 215 unmodified lines | ||
| 3186 | 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 | 3305 | // Build extension FIRST so it's available as `self` for staticSources. |
| 3190 | 3306 | const extension: object = plugin.extension ? plugin.extension(ctx) : {}; |
| 3191 | 3307 | if (plugin.extension) { |
| @@ -277,6 +277,8 @@ export { · 276 unmodified lines | ||
| 277 | 277 | type AnyPlugin, |
| 278 | 278 | type StorageDeps, |
| 279 | 279 | type OwnerBinding, |
| 280 | + type ToolPolicyProvider, | |
| 281 | + type ToolPolicyProviderRule, | |
| 280 | 282 | type IntegrationRecord, |
| 281 | 283 | type StaticSourceDecl, |
| 282 | 284 | type StaticToolDecl, |
| @@ -48,6 +48,7 @@ import type { CredentialProvider, ProviderEntry } from "./provider"; · 47 unmodified lines | ||
| 48 | 48 | import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage"; |
| 49 | 49 | import type { |
| 50 | 50 | CreateToolPolicyInput, |
| 51 | + EffectivePolicy, | |
| 51 | 52 | RemoveToolPolicyInput, |
| 52 | 53 | ToolPolicy, |
| 53 | 54 | UpdateToolPolicyInput, |
| @@ -88,6 +89,30 @@ export type Elicit = ( · 34 unmodified lines | ||
| 88 | 89 | request: ElicitationRequest, |
| 89 | 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 | 117 | // IntegrationRecord — the catalog row a plugin reads back (its own opaque |
| 93 | 118 | // `config` included). Returned by `ctx.core.integrations.get`. |
| @@ -458,6 +483,13 @@ export interface PluginSpec< · 364 unmodified lines | ||
| 458 | 483 | /** Service tag the plugin's `handlers` layer requires. */ |
| 459 | 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 | 493 | /** Produce a connection's tools (and shared $defs). The v2 successor to |
| 462 | 494 | * registering per-source tools — called by the executor at connection |
| 463 | 495 | * create / refresh / oauth.complete; the result is stamped with addresses |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 4 | import { type ToolPolicyRow } from "./core-schema"; |
| 5 | 5 | import { |
| @@ -19,7 +19,7 @@ import { · 13 unmodified lines | ||
| 19 | 19 | matchPattern, |
| 20 | 20 | resolveToolPolicy, |
| 21 | 21 | } from "./policies"; |
| 22 | −import { definePlugin } from "./plugin"; | |
| 22 | +import { definePlugin, tool } from "./plugin"; | |
| 23 | 23 | import type { CredentialProvider } from "./provider"; |
| 24 | 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 | 594 | describe("approve / require_approval interaction with annotations", () => { |
| 516 | 595 | it.effect("approve skips the elicitation prompt even when plugin requires approval", () => |
| 517 | 596 | Effect.gen(function* () { |
| @@ -13,9 +13,11 @@ · 12 unmodified lines | ||
| 13 | 13 | // --------------------------------------------------------------------------- |
| 14 | 14 | |
| 15 | 15 | import { Effect } from "effect"; |
| 16 | +import { mcpResourceKey, type McpResource } from "@executor-js/host-mcp"; | |
| 16 | 17 | |
| 17 | 18 | export const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id"; |
| 18 | 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 | 22 | /** The verified identity used to stamp the DO's internal owner headers. */ |
| 21 | 23 | export type VerifiedTokenHeaders = { |
| @@ -70,10 +72,12 @@ export const withPropagationHeaders = ( · 48 unmodified lines | ||
| 70 | 72 | export const withVerifiedIdentityHeaders = ( |
| 71 | 73 | request: Request, |
| 72 | 74 | token: VerifiedTokenHeaders, |
| 75 | + resource: McpResource, | |
| 73 | 76 | ): Request => { |
| 74 | 77 | const headers = new Headers(request.headers); |
| 75 | 78 | headers.set(INTERNAL_ACCOUNT_ID_HEADER, token.accountId); |
| 76 | 79 | headers.set(INTERNAL_ORGANIZATION_ID_HEADER, token.organizationId ?? ""); |
| 80 | + headers.set(INTERNAL_RESOURCE_KEY_HEADER, mcpResourceKey(resource)); | |
| 77 | 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 | 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 | 12 | export interface McpSessionInit { |
| 11 | 13 | readonly organizationId: string; |
| 12 | 14 | readonly userId: string; |
| 15 | + readonly resource: McpResource; | |
| 13 | 16 | readonly elicitationMode: McpElicitationMode; |
| 14 | 17 | /** Public origin of the create request (`https://host`), so the DO derives a |
| 15 | 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 | 29 | import { |
| 30 | 30 | INTERNAL_ACCOUNT_ID_HEADER, |
| 31 | 31 | INTERNAL_ORGANIZATION_ID_HEADER, |
| 32 | + INTERNAL_RESOURCE_KEY_HEADER, | |
| 32 | 33 | type IncomingPropagationHeaders, |
| 33 | 34 | } from "./do-headers"; |
| 35 | +import { mcpResourceKey } from "@executor-js/host-mcp"; | |
| 34 | 36 | import type { McpSessionInit } from "./seams"; |
| 37 | +import type { McpResource } from "@executor-js/host-mcp"; | |
| 35 | 38 | |
| 36 | 39 | // --------------------------------------------------------------------------- |
| 37 | 40 | // Types |
| @@ -122,6 +125,7 @@ export interface SessionDbHandle { · 84 unmodified lines | ||
| 122 | 125 | export interface SessionMeta { |
| 123 | 126 | readonly organizationId: string; |
| 124 | 127 | readonly organizationName: string; |
| 128 | + readonly resource: McpResource; | |
| 125 | 129 | /** The org's URL slug, when the host's `resolveSessionMeta` carried one. |
| 126 | 130 | * Pins browser-handoff URLs to the right org's console. */ |
| 127 | 131 | readonly organizationSlug?: string; |
| @@ -541,8 +545,11 @@ export abstract class McpSessionDOBase< · 413 unmodified lines | ||
| 541 | 545 | |
| 542 | 546 | const accountId = request.headers.get(INTERNAL_ACCOUNT_ID_HEADER); |
| 543 | 547 | const organizationId = request.headers.get(INTERNAL_ORGANIZATION_ID_HEADER); |
| 548 | + const resourceKey = request.headers.get(INTERNAL_RESOURCE_KEY_HEADER); | |
| 544 | 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 | 554 | yield* Effect.annotateCurrentSpan({ |
| 548 | 555 | "mcp.session.owner_match": matches, |
| @@ -559,9 +566,11 @@ export abstract class McpSessionDOBase< · 10 unmodified lines | ||
| 559 | 566 | // Carry the create request's origin onto the persisted meta (the host's |
| 560 | 567 | // resolveSessionMeta is identity-only and doesn't see it), so a cold |
| 561 | 568 | // isolate rebuilds the runtime with the same web base URL. |
| 562 | − const sessionMeta: SessionMeta = token.webOrigin | |
| 563 | − ? { ...resolved, webOrigin: token.webOrigin } | |
| 564 | − : resolved; | |
| 569 | + const sessionMeta: SessionMeta = { | |
| 570 | + ...resolved, | |
| 571 | + resource: token.resource, | |
| 572 | + ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}), | |
| 573 | + }; | |
| 565 | 574 | yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe( |
| 566 | 575 | Effect.withSpan("mcp.session.save_meta"), |
| 567 | 576 | ); |
| @@ -13,13 +13,20 @@ · 12 unmodified lines | ||
| 13 | 13 | import { describe, expect, it } from "@effect/vitest"; |
| 14 | 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 | 23 | import { |
| 19 | 24 | DO_RELOCATION_MAX_RETRIES, |
| 20 | 25 | makeDurableObjectMcpSessionStore, |
| 26 | + type McpSessionInit, | |
| 21 | 27 | type McpSessionDOStub, |
| 22 | 28 | } from "./session-store"; |
| 29 | +import { INTERNAL_RESOURCE_KEY_HEADER } from "./do-headers"; | |
| 23 | 30 | |
| 24 | 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 | 62 | return yield* store.dispatch({ |
| 56 | 63 | request: initializeRequest(), |
| 57 | 64 | principal: TEST_PRINCIPAL, |
| 65 | + resource: defaultMcpResource, | |
| 58 | 66 | sessionId: null, |
| 59 | 67 | method: "POST", |
| 60 | 68 | }); |
| @@ -68,6 +76,76 @@ const failureText = (exit: Exit.Exit<McpDispatchResult>): string => · 7 unmodified lines | ||
| 68 | 76 | Exit.isFailure(exit) ? Cause.pretty(exit.cause) : ""; |
| 69 | 77 | |
| 70 | 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 | 149 | it.live("retries mcp.do.init past a relocation, then returns the DO response", () => |
| 72 | 150 | Effect.gen(function* () { |
| 73 | 151 | let initCalls = 0; |
| @@ -141,12 +141,13 @@ const forwardToExistingSession = ( · 140 unmodified lines | ||
| 141 | 141 | sessionId: string, |
| 142 | 142 | peek: boolean, |
| 143 | 143 | token: VerifiedTokenHeaders, |
| 144 | + resource: McpDispatchInput["resource"], | |
| 144 | 145 | ): Effect.Effect<Response> => |
| 145 | 146 | Effect.gen(function* () { |
| 146 | 147 | const stub = config.getStub(sessionId); |
| 147 | 148 | const propagation = yield* currentPropagationHeaders(request); |
| 148 | 149 | const propagated = withPropagationHeaders( |
| 149 | − withVerifiedIdentityHeaders(request, token), | |
| 150 | + withVerifiedIdentityHeaders(request, token, resource), | |
| 150 | 151 | propagation, |
| 151 | 152 | ); |
| 152 | 153 | const raw = yield* Effect.promise(() => stub.handleRequest(propagated)).pipe( |
| @@ -168,6 +169,7 @@ const createSession = ( · 15 unmodified lines | ||
| 168 | 169 | config: DurableObjectStoreConfig, |
| 169 | 170 | request: Request, |
| 170 | 171 | token: VerifiedTokenHeaders, |
| 172 | + resource: McpDispatchInput["resource"], | |
| 171 | 173 | ): Effect.Effect<Response> => |
| 172 | 174 | Effect.gen(function* () { |
| 173 | 175 | const stub = config.newStub(); |
| @@ -177,6 +179,7 @@ const createSession = ( · 3 unmodified lines | ||
| 177 | 179 | { |
| 178 | 180 | organizationId: token.organizationId, |
| 179 | 181 | userId: token.accountId, |
| 182 | + resource, | |
| 180 | 183 | elicitationMode: readElicitationMode(request), |
| 181 | 184 | // The public origin the client reached us at — lets the DO derive a web |
| 182 | 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 | 192 | const propagated = withPropagationHeaders( |
| 190 | − withVerifiedIdentityHeaders(request, token), | |
| 193 | + withVerifiedIdentityHeaders(request, token, resource), | |
| 191 | 194 | propagation, |
| 192 | 195 | ); |
| 193 | 196 | const raw = yield* Effect.promise(() => stub.handleRequest(propagated)).pipe( |
| @@ -236,6 +239,7 @@ export const makeDurableObjectMcpSessionStore = ( · 42 unmodified lines | ||
| 236 | 239 | dispatch: ({ |
| 237 | 240 | request, |
| 238 | 241 | principal, |
| 242 | + resource, | |
| 239 | 243 | sessionId, |
| 240 | 244 | }: McpDispatchInput): Effect.Effect<McpDispatchResult> => { |
| 241 | 245 | const token: VerifiedTokenHeaders = { |
| @@ -243,8 +247,15 @@ export const makeDurableObjectMcpSessionStore = ( · 1 unmodified line | ||
| 243 | 247 | organizationId: principal.organizationId, |
| 244 | 248 | }; |
| 245 | 249 | return sessionId |
| 246 | − ? forwardToExistingSession(config, request, sessionId, request.method !== "GET", token) | |
| 247 | − : createSession(config, request, token); | |
| 250 | + ? forwardToExistingSession( | |
| 251 | + config, | |
| 252 | + request, | |
| 253 | + sessionId, | |
| 254 | + request.method !== "GET", | |
| 255 | + token, | |
| 256 | + resource, | |
| 257 | + ) | |
| 258 | + : createSession(config, request, token, resource); | |
| 248 | 259 | }, |
| 249 | 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 | 16 | import { |
| 17 | 17 | authenticated, |
| 18 | + forbidden, | |
| 18 | 19 | McpAuthProvider, |
| 19 | 20 | McpErrorReporter, |
| 20 | 21 | McpErrorReporterNoop, |
| 21 | 22 | McpServingRoutes, |
| 22 | 23 | McpSessionStore, |
| 24 | + type McpResource, | |
| 23 | 25 | type McpDispatchResult, |
| 24 | 26 | type Principal, |
| 25 | 27 | } from "./index"; |
| @@ -64,11 +66,12 @@ const OkStoreLive = Layer.succeed(McpSessionStore)({ · 38 unmodified lines | ||
| 64 | 66 | const buildHandler = ( |
| 65 | 67 | store: Layer.Layer<McpSessionStore>, |
| 66 | 68 | reporter: Layer.Layer<McpErrorReporter>, |
| 69 | + authProvider: Layer.Layer<McpAuthProvider> = AuthProviderLive, | |
| 67 | 70 | ): ((request: Request) => Promise<Response>) => { |
| 68 | − const Seams = Layer.mergeAll(AuthProviderLive, store, reporter); | |
| 71 | + const Seams = Layer.mergeAll(authProvider, store, reporter); | |
| 69 | 72 | const RouteLive = McpServingRoutes.pipe( |
| 70 | 73 | HttpRouter.provideRequest(Seams), |
| 71 | − Layer.provide(AuthProviderLive), | |
| 74 | + Layer.provide(authProvider), | |
| 72 | 75 | ); |
| 73 | 76 | return HttpRouter.toWebHandler(RouteLive.pipe(Layer.provideMerge(HttpServer.layerServices))) |
| 74 | 77 | .handler; |
| @@ -132,4 +135,63 @@ describe("McpServingRoutes envelope", () => { · 57 unmodified lines | ||
| 132 | 135 | expect(captures).toHaveLength(1); |
| 133 | 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 | 2 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | + defaultMcpResource, | |
| 5 | 6 | McpAuthProvider, |
| 6 | 7 | McpErrorReporter, |
| 7 | 8 | McpSessionStore, |
| 8 | 9 | type AuthOutcome, |
| 9 | 10 | type McpDispatchResult, |
| 11 | + type McpResource, | |
| 10 | 12 | } from "./seams"; |
| 11 | 13 | |
| 12 | 14 | // --------------------------------------------------------------------------- |
| @@ -14,7 +16,8 @@ import { · 1 unmodified line | ||
| 14 | 16 | // |
| 15 | 17 | // Routes: |
| 16 | 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 | 22 | // The provider DECLARES the discovery paths it owns (at least the protected- |
| 20 | 23 | // resource metadata document) via `McpAuthProvider.discoveryRoutes`; the |
| @@ -24,8 +27,8 @@ import { · 3 unmodified lines | ||
| 24 | 27 | // WorkOS, external). The envelope only needs the provider's discovery routes, |
| 25 | 28 | // resource-metadata URL, and authenticate. |
| 26 | 29 | // |
| 27 | −// The envelope hard-codes ONLY the `/mcp` path and CORS. Everything else — | |
| 28 | −// every `/.well-known/*` path, the resource-metadata URL, the authn/authz | |
| 30 | +// The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else | |
| 31 | +// — every `/.well-known/*` path, the resource-metadata URL, the authn/authz | |
| 29 | 32 | // semantics, and the entire session lifecycle (create + forward + ownership) — |
| 30 | 33 | // comes from the two seams. |
| 31 | 34 | // |
| @@ -38,6 +41,7 @@ import { · 6 unmodified lines | ||
| 38 | 41 | // --------------------------------------------------------------------------- |
| 39 | 42 | |
| 40 | 43 | const MCP_PATH = "/mcp"; |
| 44 | +const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug"; | |
| 41 | 45 | |
| 42 | 46 | /** The methods the streamable-HTTP transport accepts on `/mcp`. */ |
| 43 | 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 | 174 | ? jsonRpcResponse(404, -32001, "Session not found") |
| 171 | 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. */ | |
| 174 | −const mcpDispatch = Effect.gen(function* () { | |
| 175 | − const httpRequest = yield* HttpServerRequest.HttpServerRequest; | |
| 176 | − const auth = yield* McpAuthProvider; | |
| 177 | − const store = yield* McpSessionStore; | |
| 178 | − const request = yield* toWebRequest(httpRequest); | |
| 177 | +/** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */ | |
| 178 | +const mcpDispatch = (resource: McpResource) => | |
| 179 | + Effect.gen(function* () { | |
| 180 | + const httpRequest = yield* HttpServerRequest.HttpServerRequest; | |
| 181 | + const auth = yield* McpAuthProvider; | |
| 182 | + const store = yield* McpSessionStore; | |
| 183 | + const request = yield* toWebRequest(httpRequest); | |
| 179 | 184 | |
| 180 | − // CORS preflight: answer before auth so unauthenticated clients can probe. | |
| 181 | − if (request.method === "OPTIONS") { | |
| 182 | − return HttpServerResponse.raw(corsPreflightResponse()); | |
| 183 | − } | |
| 185 | + // CORS preflight: answer before auth so unauthenticated clients can probe. | |
| 186 | + if (request.method === "OPTIONS") { | |
| 187 | + return HttpServerResponse.raw(corsPreflightResponse()); | |
| 188 | + } | |
| 184 | 189 | |
| 185 | − // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other | |
| 186 | − // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch — | |
| 187 | − // otherwise it would fall through and spin up a session engine for a method | |
| 188 | − // the transport can't serve. | |
| 189 | − if (!ALLOWED_MCP_METHODS.has(request.method)) { | |
| 190 | − return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed")); | |
| 191 | − } | |
| 190 | + // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other | |
| 191 | + // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch — | |
| 192 | + // otherwise it would fall through and spin up a session engine for a method | |
| 193 | + // the transport can't serve. | |
| 194 | + if (!ALLOWED_MCP_METHODS.has(request.method)) { | |
| 195 | + return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed")); | |
| 196 | + } | |
| 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 | |
| 196 | − // request. On a non-Authenticated outcome: | |
| 197 | − // - Forbidden -> dispose the live session first (cloud tears down a DO | |
| 198 | − // whose org access was revoked), then render the 403. The | |
| 199 | − // inbound request is forwarded so the store can propagate | |
| 200 | − // the request's W3C trace context onto the teardown RPC. | |
| 201 | − // - other -> render directly. | |
| 202 | − const outcome = yield* auth.authenticate(request); | |
| 203 | − if (!Predicate.isTagged(outcome, "Authenticated")) { | |
| 204 | − if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { | |
| 205 | − yield* store.dispose(sessionId, request); | |
| 200 | + // Authenticate (and, for session-aware providers, authorize) on EVERY | |
| 201 | + // request. Non-authenticated outcomes render directly. Session teardown is | |
| 202 | + // only safe after the store can validate the authenticated principal and MCP | |
| 203 | + // resource; an auth-level Forbidden may not carry either. | |
| 204 | + const outcome = yield* auth.authenticate(request); | |
| 205 | + if (!Predicate.isTagged(outcome, "Authenticated")) { | |
| 206 | + return HttpServerResponse.raw(renderAuthError(auth, request, outcome)); | |
| 206 | 207 | } |
| 207 | − return HttpServerResponse.raw(renderAuthError(auth, request, outcome)); | |
| 208 | − } | |
| 209 | − const principal = outcome.principal; | |
| 208 | + const principal = outcome.principal; | |
| 210 | 209 | |
| 211 | − // No session id: per the streamable-HTTP transport contract, only POST opens | |
| 212 | − // a session. A GET needs an existing id (400); a DELETE on nothing is a | |
| 213 | − // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up | |
| 214 | − // an engine for a bare GET/DELETE. | |
| 215 | − if (!sessionId) { | |
| 216 | − if (request.method === "GET") { | |
| 217 | − return HttpServerResponse.raw( | |
| 218 | − jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"), | |
| 219 | − ); | |
| 210 | + // No session id: per the streamable-HTTP transport contract, only POST opens | |
| 211 | + // a session. A GET needs an existing id (400); a DELETE on nothing is a | |
| 212 | + // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up | |
| 213 | + // an engine for a bare GET/DELETE. | |
| 214 | + if (!sessionId) { | |
| 215 | + if (request.method === "GET") { | |
| 216 | + return HttpServerResponse.raw( | |
| 217 | + jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"), | |
| 218 | + ); | |
| 219 | + } | |
| 220 | + if (request.method === "DELETE") { | |
| 221 | + return HttpServerResponse.raw( | |
| 222 | + new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }), | |
| 223 | + ); | |
| 224 | + } | |
| 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({ | |
| 229 | − request, | |
| 230 | − principal, | |
| 231 | − sessionId, | |
| 232 | − method: request.method, | |
| 227 | + const result: McpDispatchResult = yield* store.dispatch({ | |
| 228 | + request, | |
| 229 | + principal, | |
| 230 | + resource, | |
| 231 | + sessionId, | |
| 232 | + method: request.method, | |
| 233 | + }); | |
| 234 | + return HttpServerResponse.raw( | |
| 235 | + result instanceof Response ? result : renderDispatchError(result), | |
| 236 | + ); | |
| 233 | 237 | }); |
| 234 | − return HttpServerResponse.raw(result instanceof Response ? result : renderDispatchError(result)); | |
| 235 | −}); | |
| 236 | 238 | |
| 237 | 239 | /** |
| 238 | 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 | 244 | * otherwise, since the envelope returns a `Response`) and rendered as a stable |
| 243 | 245 | * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body. |
| 244 | 246 | */ |
| 245 | −const mcpRoute = mcpDispatch.pipe( | |
| 246 | − Effect.catchCause((cause) => | |
| 247 | − Effect.gen(function* () { | |
| 248 | − const reporter = yield* McpErrorReporter; | |
| 249 | − yield* reporter.report(cause); | |
| 250 | − return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error")); | |
| 251 | − }), | |
| 252 | − ), | |
| 253 | −); | |
| 247 | +const mcpRoute = (resource: McpResource) => | |
| 248 | + mcpDispatch(resource).pipe( | |
| 249 | + Effect.catchCause((cause) => | |
| 250 | + Effect.gen(function* () { | |
| 251 | + const reporter = yield* McpErrorReporter; | |
| 252 | + yield* reporter.report(cause); | |
| 253 | + return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error")); | |
| 254 | + }), | |
| 255 | + ), | |
| 256 | + ); | |
| 257 | + | |
| 258 | +const toolkitMcpRoute = Effect.gen(function* () { | |
| 259 | + const params = yield* HttpRouter.params; | |
| 260 | + const slug = params.toolkitSlug; | |
| 261 | + return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource); | |
| 262 | +}); | |
| 254 | 263 | |
| 255 | 264 | /** |
| 256 | 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 | 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 | 17 | import { jsonRpcErrorBody } from "./envelope"; |
| 18 | 18 | import { |
| 19 | 19 | McpSessionStore, |
| 20 | + defaultMcpResource, | |
| 21 | + mcpResourceKey, | |
| 20 | 22 | principalOwns, |
| 21 | 23 | type McpDispatchInput, |
| 22 | 24 | type McpDispatchResult, |
| 23 | 25 | type Principal, |
| 26 | + type McpResource, | |
| 24 | 27 | } from "./seams"; |
| 25 | 28 | import type { BrowserApprovalStore } from "./tool-server"; |
| 26 | 29 | |
| @@ -61,6 +64,7 @@ export interface BuiltMcpServer { · 34 unmodified lines | ||
| 61 | 64 | |
| 62 | 65 | /** The browser-mode wiring the store hands a build call when a session opts in. */ |
| 63 | 66 | export interface McpBuildServerOptions { |
| 67 | + readonly resource?: McpResource; | |
| 64 | 68 | readonly elicitationMode?: |
| 65 | 69 | | { readonly mode: "browser"; readonly approvalUrl: (executionId: string) => string } |
| 66 | 70 | | { readonly mode: "model" } |
| @@ -120,6 +124,19 @@ const json = (value: unknown, status = 200): Response => · 53 unmodified lines | ||
| 120 | 124 | const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; |
| 121 | 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 | 141 | * Build the in-process session store plus an explicit `close()` that disposes |
| 125 | 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 | 154 | ): InMemoryMcpSessionStore => { |
| 138 | 155 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); |
| 139 | 156 | const servers = new Map<string, McpServer>(); |
| 140 | − const owners = new Map<string, Principal>(); | |
| 157 | + const owners = new Map<string, SessionOwner>(); | |
| 141 | 158 | const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>(); |
| 142 | 159 | const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore(); |
| 143 | 160 | |
| @@ -181,12 +198,13 @@ export const makeInMemoryMcpSessionStore = ( · 37 unmodified lines | ||
| 181 | 198 | const forward = ( |
| 182 | 199 | sessionId: string, |
| 183 | 200 | principal: Principal, |
| 201 | + resource: McpResource, | |
| 184 | 202 | request: Request, |
| 185 | 203 | ): Effect.Effect<McpDispatchResult> => { |
| 186 | 204 | const transport = transports.get(sessionId); |
| 187 | 205 | const owner = owners.get(sessionId); |
| 188 | 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 | 208 | return runHandleRequest(transport, request); |
| 191 | 209 | }; |
| 192 | 210 | |
| @@ -218,12 +236,16 @@ export const makeInMemoryMcpSessionStore = ( · 25 unmodified lines | ||
| 218 | 236 | }; |
| 219 | 237 | |
| 220 | 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 | 244 | let createdSessionId: string | null = null; |
| 223 | − return buildServer( | |
| 224 | − principal, | |
| 225 | − buildOptionsFor(request, () => createdSessionId), | |
| 226 | − ).pipe( | |
| 245 | + return buildServer(principal, { | |
| 246 | + ...buildOptionsFor(request, () => createdSessionId), | |
| 247 | + resource, | |
| 248 | + }).pipe( | |
| 227 | 249 | Effect.flatMap(({ mcpServer, engine }) => |
| 228 | 250 | Effect.gen(function* () { |
| 229 | 251 | const transport = new WebStandardStreamableHTTPServerTransport({ |
| @@ -233,7 +255,7 @@ export const makeInMemoryMcpSessionStore = ( · 3 unmodified lines | ||
| 233 | 255 | createdSessionId = sid; |
| 234 | 256 | transports.set(sid, transport); |
| 235 | 257 | servers.set(sid, mcpServer); |
| 236 | − owners.set(sid, principal); | |
| 258 | + owners.set(sid, { principal, resource }); | |
| 237 | 259 | engines.set(sid, engine); |
| 238 | 260 | }, |
| 239 | 261 | onsessionclosed: (sid) => void dispose(sid, { server: true }), |
| @@ -259,8 +281,10 @@ export const makeInMemoryMcpSessionStore = ( · 19 unmodified lines | ||
| 259 | 281 | }; |
| 260 | 282 | |
| 261 | 283 | const store: McpSessionStore["Service"] = { |
| 262 | − dispatch: ({ request, principal, sessionId }: McpDispatchInput) => | |
| 263 | − sessionId ? forward(sessionId, principal, request) : create(principal, request), | |
| 284 | + dispatch: ({ request, principal, resource, sessionId }: McpDispatchInput) => | |
| 285 | + sessionId | |
| 286 | + ? forward(sessionId, principal, resource, request) | |
| 287 | + : create(principal, resource ?? defaultMcpResource, request), | |
| 264 | 288 | dispose: (sessionId) => |
| 265 | 289 | Effect.promise(() => dispose(sessionId, { transport: true, server: true })), |
| 266 | 290 | }; |
| @@ -271,7 +295,7 @@ export const makeInMemoryMcpSessionStore = ( · 4 unmodified lines | ||
| 271 | 295 | ): "allowed" | "not-found" | "forbidden" => { |
| 272 | 296 | const owner = owners.get(sessionId); |
| 273 | 297 | if (!owner) return "not-found"; |
| 274 | − if (principal && !principalOwns(owner, principal)) return "forbidden"; | |
| 298 | + if (principal && !principalOwns(owner.principal, principal)) return "forbidden"; | |
| 275 | 299 | return "allowed"; |
| 276 | 300 | }; |
| 277 | 301 | |
| @@ -18,6 +18,8 @@ export { · 17 unmodified lines | ||
| 18 | 18 | McpSessionStore, |
| 19 | 19 | McpErrorReporter, |
| 20 | 20 | McpErrorReporterNoop, |
| 21 | + defaultMcpResource, | |
| 22 | + mcpResourceKey, | |
| 21 | 23 | principalOwns, |
| 22 | 24 | authenticated, |
| 23 | 25 | unauthorized, |
| @@ -31,6 +33,7 @@ export { · 7 unmodified lines | ||
| 31 | 33 | type McpDiscoveryRoute, |
| 32 | 34 | type McpDispatchInput, |
| 33 | 35 | type McpDispatchResult, |
| 36 | + type McpResource, | |
| 34 | 37 | } from "./seams"; |
| 35 | 38 | |
| 36 | 39 | export { McpServingRoutes, jsonRpcErrorBody } from "./envelope"; |
| @@ -56,6 +56,25 @@ export type Principal = Schema.Schema.Type<typeof Principal>; · 55 unmodified lines | ||
| 56 | 56 | export const principalOwns = (owner: Principal, principal: Principal): boolean => |
| 57 | 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 | 79 | // AuthOutcome — the result of `McpAuthProvider.authenticate`. |
| 61 | 80 | // |
| @@ -210,6 +229,7 @@ export class McpAuthProvider extends Context.Service< · 148 unmodified lines | ||
| 210 | 229 | export interface McpDispatchInput { |
| 211 | 230 | readonly request: Request; |
| 212 | 231 | readonly principal: Principal; |
| 232 | + readonly resource: McpResource; | |
| 213 | 233 | readonly sessionId: string | null; |
| 214 | 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 | 55 | const insertBlob = ( |
| 39 | 56 | client: SqliteDataMigrationClient, |
| 40 | 57 | row: { |
| @@ -84,7 +101,11 @@ const insertOperationStorage = ( · 43 unmodified lines | ||
| 84 | 101 | |
| 85 | 102 | const insertTool = ( |
| 86 | 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 | 110 | client.execute({ |
| 90 | 111 | sql: `INSERT INTO tool |
| @@ -95,7 +116,11 @@ const insertTool = ( · 4 unmodified lines | ||
| 95 | 116 | |
| 96 | 117 | const insertDefinition = ( |
| 97 | 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 | 125 | client.execute({ |
| 101 | 126 | sql: `INSERT INTO definition |
| @@ -105,6 +130,31 @@ const insertDefinition = ( · 3 unmodified lines | ||
| 105 | 130 | }); |
| 106 | 131 | |
| 107 | 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 | 158 | it.effect("moves OpenAPI-owned Google bundle rows to the Google plugin", () => |
| 109 | 159 | Effect.gen(function* () { |
| 110 | 160 | const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); |
| @@ -128,7 +178,10 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 17 unmodified lines | ||
| 128 | 178 | tenant: "org_1", |
| 129 | 179 | slug: "stripe", |
| 130 | 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 | 187 | yield* Effect.promise(() => |
| @@ -156,10 +209,18 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 21 unmodified lines | ||
| 156 | 209 | }), |
| 157 | 210 | ); |
| 158 | 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 | 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 | 4 | const MIGRATION_NAME = "2026-06-20-google-openapi-ownership"; |
| 5 | 5 | const googleOpenApiCandidate = (alias?: string): string => { |
| 6 | 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 | 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 | 172 | * applies a user rule to this tool's exact id. */ |
| 173 | 173 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 174 | 174 | onClearPolicy?: (pattern: string) => void; |
| 175 | + patternForDisplay?: (displayPattern: string) => string; | |
| 175 | 176 | /** Run-tab wiring. When `integration` + `runToolName` are provided, a third |
| 176 | 177 | * "Run" tab hosts the per-connection tool tester. */ |
| 177 | 178 | integration?: IntegrationSlug; |
| @@ -187,6 +188,7 @@ export function ToolDetail(props: { · 9 unmodified lines | ||
| 187 | 188 | const canRun = props.integration != null && props.runToolName != null; |
| 188 | 189 | // Don't strand the user on the Run tab when this ToolDetail has no run wiring. |
| 189 | 190 | const activeTab = tab === "run" && !canRun ? "schema" : tab; |
| 191 | + const isBlocked = props.policy?.action === "block"; | |
| 190 | 192 | |
| 191 | 193 | const data = useMemo(() => { |
| 192 | 194 | if (!AsyncResult.isSuccess(toolContract)) return null; |
| @@ -244,6 +246,7 @@ export function ToolDetail(props: { · 51 unmodified lines | ||
| 244 | 246 | policy={props.policy} |
| 245 | 247 | onSetPolicy={props.onSetPolicy} |
| 246 | 248 | onClearPolicy={props.onClearPolicy} |
| 249 | + patternForDisplay={props.patternForDisplay} | |
| 247 | 250 | /> |
| 248 | 251 | </div> |
| 249 | 252 | {data?.description && <ToolDescription description={data.description} />} |
| @@ -300,48 +303,59 @@ export function ToolDetail(props: { · 50 unmodified lines | ||
| 300 | 303 | |
| 301 | 304 | {/* Content */} |
| 302 | 305 | <div className="flex-1 overflow-y-auto"> |
| 303 | − {AsyncResult.match(toolContract, { | |
| 304 | − onInitial: () => <div className="p-5 text-sm text-muted-foreground">Loading…</div>, | |
| 305 | − onFailure: () => <div className="p-5 text-sm text-destructive">Something went wrong</div>, | |
| 306 | − onSuccess: () => | |
| 307 | − activeTab === "run" && props.integration && props.runToolName ? ( | |
| 308 | − <div className="px-5 py-5"> | |
| 309 | − <ToolRunPanel | |
| 310 | − integration={props.integration} | |
| 311 | − toolName={props.runToolName} | |
| 312 | − connections={props.connections ?? []} | |
| 313 | − initialConnectionName={props.initialConnectionName} | |
| 314 | − /> | |
| 315 | − </div> | |
| 316 | − ) : activeTab === "schema" ? ( | |
| 317 | − <div className="px-5 py-5 space-y-5"> | |
| 318 | − {data?.inputSchema ? ( | |
| 319 | − <SchemaExplorer | |
| 320 | − schema={data.inputSchema} | |
| 321 | − schemaDefinitions={data.schemaDefinitions} | |
| 322 | − title="Parameters" | |
| 323 | − /> | |
| 324 | − ) : ( | |
| 325 | − <EmptySection title="Parameters" message="None" /> | |
| 326 | − )} | |
| 327 | − {data?.outputSchema ? ( | |
| 328 | − <SchemaExplorer | |
| 329 | − schema={data.outputSchema} | |
| 330 | − schemaDefinitions={data.schemaDefinitions} | |
| 331 | − title="Response" | |
| 332 | − /> | |
| 333 | − ) : ( | |
| 334 | − <EmptySection title="Response" message="None" /> | |
| 335 | − )} | |
| 336 | − </div> | |
| 337 | − ) : ( | |
| 338 | − <ToolTypeScriptPanel | |
| 339 | − inputTypeScript={data?.inputTypeScript ?? null} | |
| 340 | − outputTypeScript={data?.outputTypeScript ?? null} | |
| 341 | − definitions={data?.definitions ?? []} | |
| 342 | − /> | |
| 306 | + {isBlocked ? ( | |
| 307 | + <div className="px-5 py-5"> | |
| 308 | + <EmptySection | |
| 309 | + title="Blocked" | |
| 310 | + message="This tool is not available through the current toolkit." | |
| 311 | + /> | |
| 312 | + </div> | |
| 313 | + ) : ( | |
| 314 | + AsyncResult.match(toolContract, { | |
| 315 | + onInitial: () => <div className="p-5 text-sm text-muted-foreground">Loading…</div>, | |
| 316 | + onFailure: () => ( | |
| 317 | + <div className="p-5 text-sm text-destructive">Something went wrong</div> | |
| 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 | 359 | </div> |
| 346 | 360 | </div> |
| 347 | 361 | ); |
| @@ -400,12 +414,15 @@ function PolicyBadgeMenu(props: { · 52 unmodified lines | ||
| 400 | 414 | policy?: EffectivePolicy; |
| 401 | 415 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 402 | 416 | onClearPolicy?: (pattern: string) => void; |
| 417 | + patternForDisplay?: (displayPattern: string) => string; | |
| 403 | 418 | }) { |
| 404 | 419 | const interactive = !!props.onSetPolicy; |
| 405 | 420 | // The same pattern bridge the tree rows apply — the pattern WRITTEN and the |
| 406 | 421 | // pattern LOOKED UP must be the same string, or the menu authors rules that |
| 407 | 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 | 426 | // The "Clear" affordance only makes sense when there's a user rule |
| 410 | 427 | // pinned to this exact tool id — clearing a wildcard rule from a |
| 411 | 428 | // single tool's detail header would silently affect siblings. |
| @@ -41,6 +41,9 @@ export interface ToolSummary { · 40 unmodified lines | ||
| 41 | 41 | /** Name of the connection (account) that produced this tool. Present only in |
| 42 | 42 | * the account-grouped view; ignored in the flat tree. */ |
| 43 | 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 | 58 | export type AccountGroup = { |
| 56 | − /** Stable key `${owner}:${connection}`. */ | |
| 59 | + /** Stable key `${owner}:${integration}:${connection}`. */ | |
| 57 | 60 | readonly key: string; |
| 58 | 61 | readonly owner: Owner; |
| 62 | + readonly integration: string; | |
| 59 | 63 | readonly connection: string; |
| 60 | 64 | /** Section header, e.g. "Personal · axiom-mcp". */ |
| 61 | 65 | readonly label: string; |
| @@ -69,14 +73,18 @@ export type AccountGroup = { · 7 unmodified lines | ||
| 69 | 73 | * Pure — unit-testable without React. |
| 70 | 74 | */ |
| 71 | 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 | 80 | for (const tool of tools) { |
| 74 | 81 | const owner: Owner = tool.owner ?? "org"; |
| 82 | + const integration = tool.integration ?? ""; | |
| 75 | 83 | const connection = tool.connection ?? ""; |
| 76 | − const key = `${owner}:${connection}`; | |
| 84 | + const key = `${owner}:${integration}:${connection}`; | |
| 77 | 85 | let group = byKey.get(key); |
| 78 | 86 | if (!group) { |
| 79 | − group = { owner, connection, tools: [] }; | |
| 87 | + group = { owner, integration, connection, tools: [] }; | |
| 80 | 88 | byKey.set(key, group); |
| 81 | 89 | } |
| 82 | 90 | group.tools.push(tool); |
| @@ -85,14 +93,20 @@ export const buildAccountGroups = (tools: readonly ToolSummary[]): readonly Acco · 2 unmodified lines | ||
| 85 | 93 | const ownerRank = (owner: Owner): number => (owner === "org" ? 0 : 1); |
| 86 | 94 | return [...byKey.values()] |
| 87 | 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 | 101 | .map((group) => ({ |
| 91 | − key: `${group.owner}:${group.connection}`, | |
| 102 | + key: `${group.owner}:${group.integration}:${group.connection}`, | |
| 92 | 103 | owner: group.owner, |
| 104 | + integration: group.integration, | |
| 93 | 105 | connection: group.connection, |
| 94 | 106 | label: group.connection |
| 95 | − ? `${ownerLabel(group.owner)} · ${group.connection}` | |
| 107 | + ? `${ownerLabel(group.owner)} · ${ | |
| 108 | + group.integration ? `${group.integration} / ` : "" | |
| 109 | + }${group.connection}` | |
| 96 | 110 | : ownerLabel(group.owner), |
| 97 | 111 | tools: group.tools, |
| 98 | 112 | })); |
| @@ -283,6 +297,8 @@ export function ToolTree(props: { · 184 unmodified lines | ||
| 283 | 297 | * emit the tool's full dotted id; group rows emit `prefix.*`. */ |
| 284 | 298 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 285 | 299 | onClearPolicy?: (pattern: string) => void; |
| 300 | + /** Maps the displayed row path into the persisted policy pattern. */ | |
| 301 | + patternForDisplay?: (displayPattern: string) => string; | |
| 286 | 302 | /** Sorted user-authored policies (most-precedent first). Used to |
| 287 | 303 | * decide whether a node has its own exact-pattern user rule today |
| 288 | 304 | * (so the menu can show a "Clear" option). Optional — when absent, |
| @@ -300,6 +316,7 @@ export function ToolTree(props: { · 11 unmodified lines | ||
| 300 | 316 | onSelect, |
| 301 | 317 | onSetPolicy, |
| 302 | 318 | onClearPolicy, |
| 319 | + patternForDisplay = toPolicyPattern, | |
| 303 | 320 | policies, |
| 304 | 321 | groupByConnection, |
| 305 | 322 | } = props; |
| @@ -358,6 +375,7 @@ export function ToolTree(props: { · 52 unmodified lines | ||
| 358 | 375 | onSelect, |
| 359 | 376 | onSetPolicy, |
| 360 | 377 | onClearPolicy, |
| 378 | + patternForDisplay, | |
| 361 | 379 | exactPatterns, |
| 362 | 380 | search, |
| 363 | 381 | terms, |
| @@ -404,7 +422,9 @@ export function ToolTree(props: { · 40 unmodified lines | ||
| 404 | 422 | </Badge> |
| 405 | 423 | ) : null} |
| 406 | 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 | 428 | </span> |
| 409 | 429 | <span className="shrink-0 tabular-nums text-xs text-muted-foreground"> |
| 410 | 430 | {group.tools.length} |
| @@ -433,6 +453,7 @@ function ToolTreeBody(props: { · 22 unmodified lines | ||
| 433 | 453 | onSelect: (toolId: string) => void; |
| 434 | 454 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 435 | 455 | onClearPolicy?: (pattern: string) => void; |
| 456 | + patternForDisplay: (displayPattern: string) => string; | |
| 436 | 457 | exactPatterns: ReadonlyMap<string, ToolPolicyAction>; |
| 437 | 458 | search: string; |
| 438 | 459 | terms: readonly string[]; |
| @@ -444,6 +465,7 @@ function ToolTreeBody(props: { · 5 unmodified lines | ||
| 444 | 465 | onSelect, |
| 445 | 466 | onSetPolicy, |
| 446 | 467 | onClearPolicy, |
| 468 | + patternForDisplay, | |
| 447 | 469 | exactPatterns, |
| 448 | 470 | search, |
| 449 | 471 | terms, |
| @@ -510,7 +532,8 @@ function ToolTreeBody(props: { · 60 unmodified lines | ||
| 510 | 532 | search={search} |
| 511 | 533 | onSetPolicy={onSetPolicy} |
| 512 | 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 | 539 | <ToolGroupRow |
| @@ -524,7 +547,8 @@ function ToolTreeBody(props: { · 7 unmodified lines | ||
| 524 | 547 | search={search} |
| 525 | 548 | onSetPolicy={onSetPolicy} |
| 526 | 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 | 634 | search: string; |
| 611 | 635 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 612 | 636 | onClearPolicy?: (pattern: string) => void; |
| 637 | + patternForDisplay: (displayPattern: string) => string; | |
| 613 | 638 | exactRule?: ToolPolicyAction; |
| 614 | 639 | }) { |
| 615 | 640 | const showActions = !!props.onSetPolicy; |
| @@ -647,7 +672,7 @@ function ToolGroupRow(props: { · 31 unmodified lines | ||
| 647 | 672 | )} |
| 648 | 673 | > |
| 649 | 674 | <PolicyActionMenu |
| 650 | − pattern={toPolicyPattern(`${props.path}.*`)} | |
| 675 | + pattern={props.patternForDisplay(`${props.path}.*`)} | |
| 651 | 676 | current={props.exactRule} |
| 652 | 677 | onSet={props.onSetPolicy!} |
| 653 | 678 | onClear={props.onClearPolicy} |
| @@ -668,6 +693,7 @@ function ToolLeafRow(props: { · 14 unmodified lines | ||
| 668 | 693 | search: string; |
| 669 | 694 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 670 | 695 | onClearPolicy?: (pattern: string) => void; |
| 696 | + patternForDisplay: (displayPattern: string) => string; | |
| 671 | 697 | exactRule?: ToolPolicyAction; |
| 672 | 698 | }) { |
| 673 | 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 | 747 | <PolicyActionMenu |
| 722 | − pattern={toPolicyPattern(props.tool.name)} | |
| 748 | + pattern={props.patternForDisplay(props.tool.name)} | |
| 723 | 749 | current={props.exactRule} |
| 724 | 750 | onSet={props.onSetPolicy!} |
| 725 | 751 | onClear={props.onClearPolicy} |
| @@ -9,15 +9,21 @@ import { routeTree } from "./routes/routeTree.gen"; · 8 unmodified lines | ||
| 9 | 9 | // added without a consoleRoutes() entry, or vice versa — apps would silently |
| 10 | 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 | 13 | const node = route as { |
| 14 | 14 | options?: { id?: string }; |
| 15 | 15 | children?: ReadonlyArray<unknown>; |
| 16 | 16 | }; |
| 17 | 17 | const children = node.children ?? []; |
| 18 | 18 | const id = node.options?.id; |
| 19 | − const own = typeof id === "string" ? [id] : []; | |
| 20 | − return [...own, ...children.flatMap(collectPaths)]; | |
| 19 | + const resolved = | |
| 20 | + typeof id === "string" | |
| 21 | + ? parentId && !id.startsWith(`/${ORG_SLUG_SEGMENT}`) | |
| 22 | + ? `${parentId.replace(/\/$/, "")}${id}` | |
| 23 | + : id | |
| 24 | + : parentId; | |
| 25 | + const own = typeof id === "string" ? [resolved] : []; | |
| 26 | + return [...own, ...children.flatMap((child) => collectPaths(child, resolved))]; | |
| 21 | 27 | }; |
| 22 | 28 | |
| 23 | 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 | 39 | "/policies", |
| 40 | 40 | "/secrets", |
| 41 | 41 | "/tools", |
| 42 | + "/toolkits", | |
| 43 | + "/toolkits/$toolkitSlug", | |
| 42 | 44 | "/resume/$executionId", |
| 43 | 45 | "/plugins/$pluginId/$", |
| 44 | 46 | ] as const; |
| @@ -71,6 +73,8 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 26 unmodified lines | ||
| 71 | 73 | ["/policies", route("/policies", file("policies.tsx"))], |
| 72 | 74 | ["/secrets", route("/secrets", file("secrets.tsx"))], |
| 73 | 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 | 78 | ["/resume/$executionId", route("/resume/$executionId", file("resume.$executionId.tsx"))], |
| 75 | 79 | ["/plugins/$pluginId/$", route("/plugins/$pluginId/$", file("plugins.$pluginId.$.tsx"))], |
| 76 | 80 | ]; |
| @@ -21,7 +21,7 @@ import { · 20 unmodified lines | ||
| 21 | 21 | integrationPresetIconUrl, |
| 22 | 22 | } from "../components/integration-favicon"; |
| 23 | 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 | 25 | import { useAuth } from "./auth-context"; |
| 26 | 26 | |
| 27 | 27 | // --------------------------------------------------------------------------- |
| @@ -49,6 +49,7 @@ export const defaultShellNavItems: ReadonlyArray<ShellNavItem> = [ · 21 unmodified lines | ||
| 49 | 49 | { to: "/", label: "Integrations" }, |
| 50 | 50 | { to: "/secrets", label: "Providers" }, |
| 51 | 51 | { to: "/policies", label: "Policies" }, |
| 52 | + { to: "/toolkits", label: "Toolkits" }, | |
| 52 | 53 | ]; |
| 53 | 54 | |
| 54 | 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 | 64 | // the host's own route tree, which this package can't see. |
| 64 | 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 | 77 | /** The pathname with the active org-slug prefix stripped, for active-state |
| 67 | 78 | * comparisons against scope-relative paths. */ |
| 68 | 79 | function useScopeRelativePathname(): string { |
| @@ -311,7 +322,20 @@ function UserFooter(props: Pick<ShellProps, "onSignOut" | "orgMenuSlot">) { · 242 unmodified lines | ||
| 311 | 322 | function SidebarContent( |
| 312 | 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 | 339 | return ( |
| 316 | 340 | <> |
| 317 | 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 | 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 | 11 | // plugin to `executor.config.ts` is sufficient for its pages to mount |
| 12 | 12 | // here, with no per-route imports. |
| 13 | 13 | // |
| 14 | −// Match logic is intentionally tiny: exact path equality between the URL | |
| 15 | −// remainder and a `PageDecl.path`, with `""` and `/` treated as the | |
| 16 | −// same root. Plugins that want parameterized paths can build their own | |
| 17 | −// in-component routing for now. | |
| 14 | +// Plugin pages use the same lightweight route vocabulary as the rest of the | |
| 15 | +// console route tree: static segments and `$param` segments. The host route | |
| 16 | +// owns that matching so plugin detail views can be real URLs instead of | |
| 17 | +// in-component state. | |
| 18 | 18 | // --------------------------------------------------------------------------- |
| 19 | 19 | |
| 20 | 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 | 24 | function normalizePath(input: string): string { |
| 25 | 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 | 86 | function PluginRouteComponent() { |
| 30 | − const { pluginId, _splat: rest } = Route.useParams(); | |
| 87 | + const { orgSlug, pluginId, _splat: rest } = Route.useParams(); | |
| 31 | 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 | 105 | const plugin = plugins.find((p) => p.id === pluginId); |
| 33 | 106 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound() |
| 34 | 107 | if (!plugin) throw notFound(); |
| 35 | 108 | |
| 36 | − const target = normalizePath(rest ?? "/"); | |
| 37 | − const page = plugin.pages?.find((p) => normalizePath(p.path) === target); | |
| 109 | + const match = matchPluginPage(plugin.pages, target); | |
| 38 | 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; | |
| 42 | − return <Component />; | |
| 113 | + const Component = match.page.component; | |
| 114 | + return <Component params={match.params} path={target} pluginId={pluginId} />; | |
| 43 | 115 | } |
| @@ -11,8 +11,10 @@ · 10 unmodified lines | ||
| 11 | 11 | import { Route as rootRouteImport } from './__root' |
| 12 | 12 | import { Route as DotIndexRouteImport } from './index' |
| 13 | 13 | import { Route as DotToolsRouteImport } from './tools' |
| 14 | +import { Route as DotToolkitsRouteImport } from './toolkits' | |
| 14 | 15 | import { Route as DotSecretsRouteImport } from './secrets' |
| 15 | 16 | import { Route as DotPoliciesRouteImport } from './policies' |
| 17 | +import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug' | |
| 16 | 18 | import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId' |
| 17 | 19 | import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace' |
| 18 | 20 | import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$' |
| @@ -28,6 +30,11 @@ const DotToolsRoute = DotToolsRouteImport.update({ · 9 unmodified lines | ||
| 28 | 30 | path: '/{-$orgSlug}/tools', |
| 29 | 31 | getParentRoute: () => rootRouteImport, |
| 30 | 32 | } as any) |
| 33 | +const DotToolkitsRoute = DotToolkitsRouteImport.update({ | |
| 34 | + id: '/{-$orgSlug}/toolkits', | |
| 35 | + path: '/{-$orgSlug}/toolkits', | |
| 36 | + getParentRoute: () => rootRouteImport, | |
| 37 | +} as any) | |
| 31 | 38 | const DotSecretsRoute = DotSecretsRouteImport.update({ |
| 32 | 39 | id: '/{-$orgSlug}/secrets', |
| 33 | 40 | path: '/{-$orgSlug}/secrets', |
| @@ -38,6 +45,12 @@ const DotPoliciesRoute = DotPoliciesRouteImport.update({ · 4 unmodified lines | ||
| 38 | 45 | path: '/{-$orgSlug}/policies', |
| 39 | 46 | getParentRoute: () => rootRouteImport, |
| 40 | 47 | } as any) |
| 48 | +const DotToolkitsDottoolkitSlugRoute = | |
| 49 | + DotToolkitsDottoolkitSlugRouteImport.update({ | |
| 50 | + id: '/$toolkitSlug', | |
| 51 | + path: '/$toolkitSlug', | |
| 52 | + getParentRoute: () => DotToolkitsRoute, | |
| 53 | + } as any) | |
| 41 | 54 | const DotResumeDotexecutionIdRoute = DotResumeDotexecutionIdRouteImport.update({ |
| 42 | 55 | id: '/{-$orgSlug}/resume/$executionId', |
| 43 | 56 | path: '/{-$orgSlug}/resume/$executionId', |
| @@ -65,20 +78,24 @@ const DotIntegrationsDotaddDotpluginKeyRoute = · 21 unmodified lines | ||
| 65 | 78 | export interface FileRoutesByFullPath { |
| 66 | 79 | '/{-$orgSlug}/policies': typeof DotPoliciesRoute |
| 67 | 80 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute |
| 81 | + '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren | |
| 68 | 82 | '/{-$orgSlug}/tools': typeof DotToolsRoute |
| 69 | 83 | '/{-$orgSlug}/': typeof DotIndexRoute |
| 70 | 84 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 71 | 85 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 86 | + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute | |
| 72 | 87 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute |
| 73 | 88 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute |
| 74 | 89 | } |
| 75 | 90 | export interface FileRoutesByTo { |
| 76 | 91 | '/{-$orgSlug}/policies': typeof DotPoliciesRoute |
| 77 | 92 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute |
| 93 | + '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren | |
| 78 | 94 | '/{-$orgSlug}/tools': typeof DotToolsRoute |
| 79 | 95 | '/{-$orgSlug}': typeof DotIndexRoute |
| 80 | 96 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 81 | 97 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 98 | + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute | |
| 82 | 99 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute |
| 83 | 100 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute |
| 84 | 101 | } |
| @@ -86,10 +103,12 @@ export interface FileRoutesById { · 1 unmodified line | ||
| 86 | 103 | __root__: typeof rootRouteImport |
| 87 | 104 | '/{-$orgSlug}/policies': typeof DotPoliciesRoute |
| 88 | 105 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute |
| 106 | + '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren | |
| 89 | 107 | '/{-$orgSlug}/tools': typeof DotToolsRoute |
| 90 | 108 | '/{-$orgSlug}/': typeof DotIndexRoute |
| 91 | 109 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 92 | 110 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 111 | + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute | |
| 93 | 112 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute |
| 94 | 113 | '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute |
| 95 | 114 | } |
| @@ -98,30 +117,36 @@ export interface FileRouteTypes { · 2 unmodified lines | ||
| 98 | 117 | fullPaths: |
| 99 | 118 | | '/{-$orgSlug}/policies' |
| 100 | 119 | | '/{-$orgSlug}/secrets' |
| 120 | + | '/{-$orgSlug}/toolkits' | |
| 101 | 121 | | '/{-$orgSlug}/tools' |
| 102 | 122 | | '/{-$orgSlug}/' |
| 103 | 123 | | '/{-$orgSlug}/integrations/$namespace' |
| 104 | 124 | | '/{-$orgSlug}/resume/$executionId' |
| 125 | + | '/{-$orgSlug}/toolkits/$toolkitSlug' | |
| 105 | 126 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| 106 | 127 | | '/{-$orgSlug}/plugins/$pluginId/$' |
| 107 | 128 | fileRoutesByTo: FileRoutesByTo |
| 108 | 129 | to: |
| 109 | 130 | | '/{-$orgSlug}/policies' |
| 110 | 131 | | '/{-$orgSlug}/secrets' |
| 132 | + | '/{-$orgSlug}/toolkits' | |
| 111 | 133 | | '/{-$orgSlug}/tools' |
| 112 | 134 | | '/{-$orgSlug}' |
| 113 | 135 | | '/{-$orgSlug}/integrations/$namespace' |
| 114 | 136 | | '/{-$orgSlug}/resume/$executionId' |
| 137 | + | '/{-$orgSlug}/toolkits/$toolkitSlug' | |
| 115 | 138 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| 116 | 139 | | '/{-$orgSlug}/plugins/$pluginId/$' |
| 117 | 140 | id: |
| 118 | 141 | | '__root__' |
| 119 | 142 | | '/{-$orgSlug}/policies' |
| 120 | 143 | | '/{-$orgSlug}/secrets' |
| 144 | + | '/{-$orgSlug}/toolkits' | |
| 121 | 145 | | '/{-$orgSlug}/tools' |
| 122 | 146 | | '/{-$orgSlug}/' |
| 123 | 147 | | '/{-$orgSlug}/integrations/$namespace' |
| 124 | 148 | | '/{-$orgSlug}/resume/$executionId' |
| 149 | + | '/{-$orgSlug}/toolkits/$toolkitSlug' | |
| 125 | 150 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| 126 | 151 | | '/{-$orgSlug}/plugins/$pluginId/$' |
| 127 | 152 | fileRoutesById: FileRoutesById |
| @@ -129,6 +154,7 @@ export interface FileRouteTypes { · 1 unmodified line | ||
| 129 | 154 | export interface RootRouteChildren { |
| 130 | 155 | DotPoliciesRoute: typeof DotPoliciesRoute |
| 131 | 156 | DotSecretsRoute: typeof DotSecretsRoute |
| 157 | + DotToolkitsRoute: typeof DotToolkitsRouteWithChildren | |
| 132 | 158 | DotToolsRoute: typeof DotToolsRoute |
| 133 | 159 | DotIndexRoute: typeof DotIndexRoute |
| 134 | 160 | DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute |
| @@ -153,6 +179,13 @@ declare module '@tanstack/react-router' { · 18 unmodified lines | ||
| 153 | 179 | preLoaderRoute: typeof DotToolsRouteImport |
| 154 | 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 | 189 | '/{-$orgSlug}/secrets': { |
| 157 | 190 | id: '/{-$orgSlug}/secrets' |
| 158 | 191 | path: '/{-$orgSlug}/secrets' |
| @@ -167,6 +200,13 @@ declare module '@tanstack/react-router' { · 8 unmodified lines | ||
| 167 | 200 | preLoaderRoute: typeof DotPoliciesRouteImport |
| 168 | 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 | 210 | '/{-$orgSlug}/resume/$executionId': { |
| 171 | 211 | id: '/{-$orgSlug}/resume/$executionId' |
| 172 | 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 | 253 | const rootRouteChildren: RootRouteChildren = { |
| 202 | 254 | DotPoliciesRoute: DotPoliciesRoute, |
| 203 | 255 | DotSecretsRoute: DotSecretsRoute, |
| 256 | + DotToolkitsRoute: DotToolkitsRouteWithChildren, | |
| 204 | 257 | DotToolsRoute: DotToolsRoute, |
| 205 | 258 | DotIndexRoute: DotIndexRoute, |
| 206 | 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 | +} | |