Unified · Split
| @@ -5,6 +5,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; · 4 unmodified lines | |||
| 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; | 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| 8 | import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | ||
| 8 | 9 | ||
| 9 | import { resolveSecretKey } from "./src/config"; | 10 | import { resolveSecretKey } from "./src/config"; |
| 10 | 11 | ||
| @@ -19,13 +20,14 @@ import { resolveSecretKey } from "./src/config"; · 8 unmodified lines | |||
| 19 | // --------------------------------------------------------------------------- | 20 | // --------------------------------------------------------------------------- |
| 20 | 21 | ||
| 21 | export default defineExecutorConfig({ | 22 | export default defineExecutorConfig({ |
| 22 | plugins: () => | 23 | plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) => |
| 23 | [ | 24 | [ |
| 24 | openApiHttpPlugin(), | 25 | openApiHttpPlugin(), |
| 25 | googleHttpPlugin(), | 26 | googleHttpPlugin(), |
| 26 | microsoftHttpPlugin(), | 27 | microsoftHttpPlugin(), |
| 27 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), | 28 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 28 | graphqlHttpPlugin(), | 29 | graphqlHttpPlugin(), |
| 30 | toolkitsPlugin({ activeToolkitSlug }), | ||
| 29 | // First writable secret provider -> the default for `secrets.set`. | 31 | // First writable secret provider -> the default for `secrets.set`. |
| 30 | encryptedSecretsPlugin({ key: resolveSecretKey() }), | 32 | encryptedSecretsPlugin({ key: resolveSecretKey() }), |
| 31 | ] as const, | 33 | ] as const, |
| @@ -32,6 +32,7 @@ · 31 unmodified lines | |||
| 32 | "@executor-js/plugin-mcp": "workspace:*", | 32 | "@executor-js/plugin-mcp": "workspace:*", |
| 33 | "@executor-js/plugin-microsoft": "workspace:*", | 33 | "@executor-js/plugin-microsoft": "workspace:*", |
| 34 | "@executor-js/plugin-openapi": "workspace:*", | 34 | "@executor-js/plugin-openapi": "workspace:*", |
| 35 | "@executor-js/plugin-toolkits": "workspace:*", | ||
| 35 | "@executor-js/react": "workspace:*", | 36 | "@executor-js/react": "workspace:*", |
| 36 | "@executor-js/runtime-quickjs": "workspace:*", | 37 | "@executor-js/runtime-quickjs": "workspace:*", |
| 37 | "@executor-js/sdk": "workspace:*", | 38 | "@executor-js/sdk": "workspace:*", |
| @@ -106,7 +106,8 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 105 unmodified lines | |||
| 106 | routes: [ | 106 | routes: [ |
| 107 | // CLI device-login discovery, must precede the /api/auth/* wildcard | 107 | // CLI device-login discovery, must precede the /api/auth/* wildcard |
| 108 | // below (Better Auth would otherwise 404 it). The verification page it | 108 | // below (Better Auth would otherwise 404 it). The verification page it |
| 109 | // points at (/device) is a console SPA route (web/device.tsx). | 109 | // points at (/device) is a console SPA route |
| 110 | // (web/chromeless/device-page.tsx). | ||
| 110 | HttpRouter.add("GET", "/api/auth/cli-login", cliLoginHandler), | 111 | HttpRouter.add("GET", "/api/auth/cli-login", cliLoginHandler), |
| 111 | // Better Auth owns the rest of /api/auth/*, the full path reaches it. | 112 | // Better Auth owns the rest of /api/auth/*, the full path reaches it. |
| 112 | HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)), | 113 | HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)), |
| @@ -40,7 +40,11 @@ export { makeExecutionStack } from "@executor-js/api/server"; · 39 unmodified lines | |||
| 40 | 40 | ||
| 41 | export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)( | 41 | export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)( |
| 42 | { | 42 | { |
| 43 | plugins: () => executorConfig.plugins(), | 43 | plugins: (context) => |
| 44 | executorConfig.plugins({ | ||
| 45 | activeToolkitSlug: | ||
| 46 | context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, | ||
| 47 | }), | ||
| 44 | }, | 48 | }, |
| 45 | ); | 49 | ); |
| 46 | 50 | ||
| @@ -6,17 +6,21 @@ describe("stripMcpOrgSegment", () => { · 5 unmodified lines | |||
| 6 | it("strips a single org segment before /mcp", () => { | 6 | it("strips a single org segment before /mcp", () => { |
| 7 | expect(stripMcpOrgSegment("/iI9idP7BZcWpg9wW8cit3xE4r4dFSnHj/mcp")).toBe("/mcp"); | 7 | expect(stripMcpOrgSegment("/iI9idP7BZcWpg9wW8cit3xE4r4dFSnHj/mcp")).toBe("/mcp"); |
| 8 | expect(stripMcpOrgSegment("/org_123/mcp")).toBe("/mcp"); | 8 | expect(stripMcpOrgSegment("/org_123/mcp")).toBe("/mcp"); |
| 9 | expect(stripMcpOrgSegment("/org_123/mcp/toolkits/deploy")).toBe("/mcp/toolkits/deploy"); | ||
| 9 | }); | 10 | }); |
| 10 | 11 | ||
| 11 | it("strips the org segment from the protected-resource discovery path", () => { | 12 | it("strips the org segment from the protected-resource discovery path", () => { |
| 12 | expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp")).toBe( | 13 | expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp")).toBe( |
| 13 | "/.well-known/oauth-protected-resource/mcp", | 14 | "/.well-known/oauth-protected-resource", |
| 14 | ); | 15 | ); |
| 16 | expect( | ||
| 17 | stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp/toolkits/deploy"), | ||
| 18 | ).toBe("/.well-known/oauth-protected-resource"); | ||
| 15 | }); | 19 | }); |
| 16 | 20 | ||
| 17 | it("leaves the bare paths untouched", () => { | 21 | it("leaves the bare paths untouched", () => { |
| 18 | expect(stripMcpOrgSegment("/mcp")).toBeNull(); | 22 | expect(stripMcpOrgSegment("/mcp")).toBeNull(); |
| 19 | expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/mcp")).toBeNull(); | 23 | expect(stripMcpOrgSegment("/mcp/toolkits/deploy")).toBeNull(); |
| 20 | expect(stripMcpOrgSegment("/.well-known/oauth-authorization-server")).toBeNull(); | 24 | expect(stripMcpOrgSegment("/.well-known/oauth-authorization-server")).toBeNull(); |
| 21 | }); | 25 | }); |
| 22 | 26 | ||
| @@ -20,8 +20,9 @@ const PRM_PREFIX = "/.well-known/oauth-protected-resource"; · 19 unmodified lines | |||
| 20 | * applies (already bare, not an MCP path, or an OAuth endpoint like | 20 | * applies (already bare, not an MCP path, or an OAuth endpoint like |
| 21 | * `/api/auth/mcp/authorize`). | 21 | * `/api/auth/mcp/authorize`). |
| 22 | * | 22 | * |
| 23 | * /<seg>/mcp -> /mcp | 23 | * /<seg>/mcp -> /mcp |
| 24 | * /.well-known/oauth-protected-resource/<seg>/mcp -> /.well-known/oauth-protected-resource/mcp | 24 | * /<seg>/mcp/toolkits/<toolkit> -> /mcp/toolkits/<toolkit> |
| 25 | * /.well-known/oauth-protected-resource/<seg>/mcp[...] -> /.well-known/oauth-protected-resource | ||
| 25 | */ | 26 | */ |
| 26 | export const stripMcpOrgSegment = (pathname: string): string | null => { | 27 | export const stripMcpOrgSegment = (pathname: string): string | null => { |
| 27 | if (pathname.startsWith(`${PRM_PREFIX}/`)) { | 28 | if (pathname.startsWith(`${PRM_PREFIX}/`)) { |
| @@ -29,8 +30,14 @@ export const stripMcpOrgSegment = (pathname: string): string | null => { · 1 unmodified line | |||
| 29 | .slice(PRM_PREFIX.length + 1) | 30 | .slice(PRM_PREFIX.length + 1) |
| 30 | .split("/") | 31 | .split("/") |
| 31 | .filter((segment) => segment.length > 0); | 32 | .filter((segment) => segment.length > 0); |
| 32 | return rest.length === 2 && rest[1] === "mcp" ? `${PRM_PREFIX}/mcp` : null; | 33 | if (rest.length === 2 && rest[1] === "mcp") return PRM_PREFIX; |
| 34 | if (rest.length === 4 && rest[1] === "mcp" && rest[2] === "toolkits") return PRM_PREFIX; | ||
| 35 | return null; | ||
| 33 | } | 36 | } |
| 34 | const segments = pathname.split("/").filter((segment) => segment.length > 0); | 37 | const segments = pathname.split("/").filter((segment) => segment.length > 0); |
| 35 | return segments.length === 2 && segments[1] === "mcp" ? "/mcp" : null; | 38 | if (segments.length === 2 && segments[1] === "mcp") return "/mcp"; |
| 39 | if (segments.length === 4 && segments[1] === "mcp" && segments[2] === "toolkits") { | ||
| 40 | return `/mcp/toolkits/${segments[3]}`; | ||
| 41 | } | ||
| 42 | return null; | ||
| 36 | }; | 43 | }; |
| @@ -66,8 +66,8 @@ function executorApiPlugin(): Plugin { · 65 unmodified lines | |||
| 66 | rawUrl = `${pathname}${original.search}`; | 66 | rawUrl = `${pathname}${original.search}`; |
| 67 | } | 67 | } |
| 68 | // Match on PATHNAME, not a raw-URL prefix: `/mcp` must NOT swallow the | 68 | // Match on PATHNAME, not a raw-URL prefix: `/mcp` must NOT swallow the |
| 69 | // SPA route `/mcp-consent` (nor its source module `/mcp-consent.tsx`), | 69 | // SPA route `/mcp-consent`, or the dev server misroutes it to the API |
| 70 | // or the dev server misroutes them to the API handler and they 404. | 70 | // handler and returns a 404. |
| 71 | const path = new URL(rawUrl, devOrigin).pathname; | 71 | const path = new URL(rawUrl, devOrigin).pathname; |
| 72 | const handled = | 72 | const handled = |
| 73 | path === "/api" || | 73 | path === "/api" || |
File renamed without changes.
File renamed without changes.
| @@ -11,8 +11,8 @@ import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shel · 10 unmodified lines | |||
| 11 | import { plugins as clientPlugins } from "virtual:executor/plugins-client"; | 11 | import { plugins as clientPlugins } from "virtual:executor/plugins-client"; |
| 12 | 12 | ||
| 13 | import { authClient } from "../auth-client"; | 13 | import { authClient } from "../auth-client"; |
| 14 | import { DevicePage } from "../device"; | 14 | import { DevicePage } from "../chromeless/device-page"; |
| 15 | import { McpConsentPage } from "../mcp-consent"; | 15 | import { McpConsentPage } from "../chromeless/mcp-consent-page"; |
| 16 | import { LoginPage } from "../login"; | 16 | import { LoginPage } from "../login"; |
| 17 | import { SetupPage } from "../setup"; | 17 | import { SetupPage } from "../setup"; |
| 18 | import { fetchNeedsSetup } from "../setup-status"; | 18 | import { fetchNeedsSetup } from "../setup-status"; |
| @@ -228,6 +228,7 @@ · 227 unmodified lines | |||
| 228 | "@executor-js/plugin-mcp": "workspace:*", | 228 | "@executor-js/plugin-mcp": "workspace:*", |
| 229 | "@executor-js/plugin-microsoft": "workspace:*", | 229 | "@executor-js/plugin-microsoft": "workspace:*", |
| 230 | "@executor-js/plugin-openapi": "workspace:*", | 230 | "@executor-js/plugin-openapi": "workspace:*", |
| 231 | "@executor-js/plugin-toolkits": "workspace:*", | ||
| 231 | "@executor-js/react": "workspace:*", | 232 | "@executor-js/react": "workspace:*", |
| 232 | "@executor-js/runtime-quickjs": "workspace:*", | 233 | "@executor-js/runtime-quickjs": "workspace:*", |
| 233 | "@executor-js/sdk": "workspace:*", | 234 | "@executor-js/sdk": "workspace:*", |
| @@ -346,6 +347,7 @@ · 112 unmodified lines | |||
| 346 | "@executor-js/plugin-mcp": "workspace:*", | 347 | "@executor-js/plugin-mcp": "workspace:*", |
| 347 | "@executor-js/plugin-microsoft": "workspace:*", | 348 | "@executor-js/plugin-microsoft": "workspace:*", |
| 348 | "@executor-js/plugin-openapi": "workspace:*", | 349 | "@executor-js/plugin-openapi": "workspace:*", |
| 350 | "@executor-js/plugin-toolkits": "workspace:*", | ||
| 349 | "@executor-js/sdk": "workspace:*", | 351 | "@executor-js/sdk": "workspace:*", |
| 350 | "@kitlangton/terminal-control": "^0.3.0", | 352 | "@kitlangton/terminal-control": "^0.3.0", |
| 351 | "@modelcontextprotocol/sdk": "^1.29.0", | 353 | "@modelcontextprotocol/sdk": "^1.29.0", |
| @@ -1088,6 +1090,49 @@ · 736 unmodified lines | |||
| 1088 | "react", | 1090 | "react", |
| 1089 | ], | 1091 | ], |
| 1090 | }, | 1092 | }, |
| 1093 | "packages/plugins/toolkits": { | ||
| 1094 | "name": "@executor-js/plugin-toolkits", | ||
| 1095 | "version": "1.5.12", | ||
| 1096 | "dependencies": { | ||
| 1097 | "@executor-js/sdk": "workspace:*", | ||
| 1098 | }, | ||
| 1099 | "devDependencies": { | ||
| 1100 | "@effect/atom-react": "catalog:", | ||
| 1101 | "@effect/vitest": "catalog:", | ||
| 1102 | "@executor-js/api": "workspace:*", | ||
| 1103 | "@executor-js/react": "workspace:*", | ||
| 1104 | "@tanstack/react-router": "catalog:", | ||
| 1105 | "@types/node": "catalog:", | ||
| 1106 | "@types/react": "catalog:", | ||
| 1107 | "bun-types": "catalog:", | ||
| 1108 | "effect": "catalog:", | ||
| 1109 | "fractional-indexing": "^3.2.0", | ||
| 1110 | "lucide-react": "^1.7.0", | ||
| 1111 | "react": "catalog:", | ||
| 1112 | "tsup": "catalog:", | ||
| 1113 | "typescript": "catalog:", | ||
| 1114 | "vitest": "catalog:", | ||
| 1115 | }, | ||
| 1116 | "peerDependencies": { | ||
| 1117 | "@effect/atom-react": "catalog:", | ||
| 1118 | "@executor-js/api": "workspace:*", | ||
| 1119 | "@executor-js/react": "workspace:*", | ||
| 1120 | "@tanstack/react-router": "catalog:", | ||
| 1121 | "effect": "catalog:", | ||
| 1122 | "fractional-indexing": "^3.2.0", | ||
| 1123 | "lucide-react": "^1.7.0", | ||
| 1124 | "react": "catalog:", | ||
| 1125 | }, | ||
| 1126 | "optionalPeers": [ | ||
| 1127 | "@effect/atom-react", | ||
| 1128 | "@executor-js/api", | ||
| 1129 | "@executor-js/react", | ||
| 1130 | "@tanstack/react-router", | ||
| 1131 | "fractional-indexing", | ||
| 1132 | "lucide-react", | ||
| 1133 | "react", | ||
| 1134 | ], | ||
| 1135 | }, | ||
| 1091 | "packages/plugins/workos-vault": { | 1136 | "packages/plugins/workos-vault": { |
| 1092 | "name": "@executor-js/plugin-workos-vault", | 1137 | "name": "@executor-js/plugin-workos-vault", |
| 1093 | "version": "0.0.2", | 1138 | "version": "0.0.2", |
| @@ -1682,6 +1727,8 @@ · 588 unmodified lines | |||
| 1682 | 1727 | ||
| 1683 | "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"], | 1728 | "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"], |
| 1684 | 1729 | ||
| 1730 | "@executor-js/plugin-toolkits": ["@executor-js/plugin-toolkits@workspace:packages/plugins/toolkits"], | ||
| 1731 | |||
| 1685 | "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"], | 1732 | "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"], |
| 1686 | 1733 | ||
| 1687 | "@executor-js/react": ["@executor-js/react@workspace:packages/react"], | 1734 | "@executor-js/react": ["@executor-js/react@workspace:packages/react"], |
| @@ -27,6 +27,7 @@ · 26 unmodified lines | |||
| 27 | "@executor-js/plugin-mcp": "workspace:*", | 27 | "@executor-js/plugin-mcp": "workspace:*", |
| 28 | "@executor-js/plugin-microsoft": "workspace:*", | 28 | "@executor-js/plugin-microsoft": "workspace:*", |
| 29 | "@executor-js/plugin-openapi": "workspace:*", | 29 | "@executor-js/plugin-openapi": "workspace:*", |
| 30 | "@executor-js/plugin-toolkits": "workspace:*", | ||
| 30 | "@executor-js/sdk": "workspace:*", | 31 | "@executor-js/sdk": "workspace:*", |
| 31 | "@kitlangton/terminal-control": "^0.3.0", | 32 | "@kitlangton/terminal-control": "^0.3.0", |
| 32 | "@modelcontextprotocol/sdk": "^1.29.0", | 33 | "@modelcontextprotocol/sdk": "^1.29.0", |
| @@ -0,0 +1,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,193 @@ | |||
| 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, Browser, Target } from "../src/services"; | ||
| 10 | |||
| 11 | const api = composePluginApi([toolkitsPlugin()] as const); | ||
| 12 | |||
| 13 | scenario( | ||
| 14 | "Toolkits · self-host UI creates a toolkit and configures tools", | ||
| 15 | { timeout: 180_000 }, | ||
| 16 | Effect.gen(function* () { | ||
| 17 | const target = yield* Target; | ||
| 18 | const browser = yield* Browser; | ||
| 19 | const { client: makeApiClient } = yield* Api; | ||
| 20 | const identity = yield* target.newIdentity(); | ||
| 21 | const client = yield* makeApiClient(api, identity); | ||
| 22 | |||
| 23 | const suffix = randomBytes(4).toString("hex"); | ||
| 24 | const prefix = `toolkits-ui-${suffix}`; | ||
| 25 | const name = `${prefix}-created`; | ||
| 26 | const slug = name; | ||
| 27 | const seededToolkits = [ | ||
| 28 | { owner: "org" as const, name: `${prefix}-workspace-a` }, | ||
| 29 | { owner: "org" as const, name: `${prefix}-workspace-b` }, | ||
| 30 | { owner: "user" as const, name: `${prefix}-personal-a` }, | ||
| 31 | { owner: "user" as const, name: `${prefix}-personal-b` }, | ||
| 32 | { owner: "user" as const, name: `${prefix}-personal-c` }, | ||
| 33 | ]; | ||
| 34 | let addedConnectionPattern = ""; | ||
| 35 | const blockPattern = "executor.coreTools.policies.list"; | ||
| 36 | |||
| 37 | const cleanup = Effect.gen(function* () { | ||
| 38 | const listed = yield* client.toolkits.list(); | ||
| 39 | yield* Effect.forEach( | ||
| 40 | listed.toolkits.filter((row) => row.slug.startsWith(prefix)), | ||
| 41 | (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }), | ||
| 42 | { discard: true }, | ||
| 43 | ); | ||
| 44 | }).pipe(Effect.ignore); | ||
| 45 | |||
| 46 | yield* Effect.gen(function* () { | ||
| 47 | yield* Effect.forEach( | ||
| 48 | seededToolkits, | ||
| 49 | (toolkit) => client.toolkits.create({ payload: toolkit }), | ||
| 50 | { discard: true }, | ||
| 51 | ); | ||
| 52 | |||
| 53 | yield* browser.session(identity, async ({ page, step }) => { | ||
| 54 | await step("Open the Toolkits plugin page", async () => { | ||
| 55 | await page.goto("/plugins/toolkits/", { waitUntil: "networkidle" }); | ||
| 56 | await page.getByRole("heading", { name: "Toolkits" }).waitFor(); | ||
| 57 | await page.getByRole("heading", { name: "Workspace" }).waitFor(); | ||
| 58 | await page.getByRole("heading", { name: "Personal" }).waitFor(); | ||
| 59 | expect(await page.getByLabel("New toolkit").count()).toBe(0); | ||
| 60 | }); | ||
| 61 | |||
| 62 | await step("Create a workspace toolkit from the add card", async () => { | ||
| 63 | const workspaceSection = page.locator("section").filter({ | ||
| 64 | has: page.getByRole("heading", { name: "Workspace" }), | ||
| 65 | }); | ||
| 66 | await workspaceSection.getByRole("button", { name: "Add workspace toolkit" }).click(); | ||
| 67 | await page.getByRole("dialog", { name: "New workspace toolkit" }).waitFor(); | ||
| 68 | await page.getByLabel("Toolkit name").fill(name); | ||
| 69 | await page.getByRole("button", { name: "Create toolkit" }).click(); | ||
| 70 | await page.getByRole("link", { name: `Open toolkit ${name}` }).waitFor(); | ||
| 71 | }); | ||
| 72 | |||
| 73 | await step("Validate owner sections render as three-column grids", async () => { | ||
| 74 | const workspaceSection = page.locator("section").filter({ | ||
| 75 | has: page.getByRole("heading", { name: "Workspace" }), | ||
| 76 | }); | ||
| 77 | const personalSection = page.locator("section").filter({ | ||
| 78 | has: page.getByRole("heading", { name: "Personal" }), | ||
| 79 | }); | ||
| 80 | |||
| 81 | const workspaceColumns = await workspaceSection | ||
| 82 | .getByRole("link", { name: /^Open toolkit/ }) | ||
| 83 | .evaluateAll((nodes) => | ||
| 84 | nodes.slice(0, 3).map((node) => Math.round(node.getBoundingClientRect().left)), | ||
| 85 | ); | ||
| 86 | const personalColumns = await personalSection | ||
| 87 | .getByRole("link", { name: /^Open toolkit/ }) | ||
| 88 | .evaluateAll((nodes) => | ||
| 89 | nodes.slice(0, 3).map((node) => Math.round(node.getBoundingClientRect().left)), | ||
| 90 | ); | ||
| 91 | |||
| 92 | expect(new Set(workspaceColumns).size).toBe(3); | ||
| 93 | expect(new Set(personalColumns).size).toBe(3); | ||
| 94 | }); | ||
| 95 | |||
| 96 | await step("Open the created toolkit from the grid", async () => { | ||
| 97 | await page.getByRole("link", { name: `Open toolkit ${name}` }).click(); | ||
| 98 | await page.waitForURL(new RegExp(`/plugins/toolkits/${slug}$`)); | ||
| 99 | expect(page.url()).toMatch(new RegExp(`/plugins/toolkits/${slug}$`)); | ||
| 100 | await page | ||
| 101 | .locator("code") | ||
| 102 | .filter({ hasText: `/mcp/toolkits/${slug}` }) | ||
| 103 | .waitFor(); | ||
| 104 | await page.getByText("No connections added").waitFor(); | ||
| 105 | expect(await page.getByLabel("New toolkit").count()).toBe(0); | ||
| 106 | }); | ||
| 107 | |||
| 108 | await step("Return to the toolkit grid with browser-visible routing", async () => { | ||
| 109 | await page.getByRole("button", { name: "Toolkits" }).click(); | ||
| 110 | await page.waitForURL(/\/plugins\/toolkits\/?$/); | ||
| 111 | expect(page.url()).toMatch(/\/plugins\/toolkits\/?$/); | ||
| 112 | await page.getByRole("heading", { name: "Workspace" }).waitFor(); | ||
| 113 | await page.getByRole("link", { name: `Open toolkit ${name}` }).waitFor(); | ||
| 114 | }); | ||
| 115 | |||
| 116 | await step("Open the created toolkit from a direct URL", async () => { | ||
| 117 | await page.goto(`/plugins/toolkits/${slug}`, { waitUntil: "networkidle" }); | ||
| 118 | expect(page.url()).toMatch(new RegExp(`/plugins/toolkits/${slug}$`)); | ||
| 119 | await page | ||
| 120 | .locator("code") | ||
| 121 | .filter({ hasText: `/mcp/toolkits/${slug}` }) | ||
| 122 | .waitFor(); | ||
| 123 | await page.getByText("No connections added").waitFor(); | ||
| 124 | expect(await page.getByLabel("New toolkit").count()).toBe(0); | ||
| 125 | }); | ||
| 126 | |||
| 127 | await step("Add a connection to the toolkit", async () => { | ||
| 128 | await page.getByRole("button", { name: "Add connection to toolkit" }).click(); | ||
| 129 | const dialog = page.getByRole("dialog", { name: "Add connection" }); | ||
| 130 | await dialog.waitFor(); | ||
| 131 | await dialog.getByLabel("Search connections and tools").fill("policies.list"); | ||
| 132 | expect(await dialog.getByRole("button", { name: /^Add tool/ }).count()).toBe(0); | ||
| 133 | addedConnectionPattern = "executor.*"; | ||
| 134 | expect(await dialog.getByText(addedConnectionPattern, { exact: true }).count()).toBe(0); | ||
| 135 | await dialog | ||
| 136 | .getByRole("button", { name: /^Add connection / }) | ||
| 137 | .first() | ||
| 138 | .click(); | ||
| 139 | await dialog.waitFor({ state: "hidden" }); | ||
| 140 | const toolkitTools = page.getByRole("region", { name: "Toolkit tools" }); | ||
| 141 | await toolkitTools.waitFor(); | ||
| 142 | await toolkitTools.getByLabel("Filter tools").fill("policies.list"); | ||
| 143 | await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().waitFor(); | ||
| 144 | await toolkitTools.getByLabel("Filter tools").clear(); | ||
| 145 | }); | ||
| 146 | |||
| 147 | await step("The add connection list reflects the saved toolkit connection", async () => { | ||
| 148 | await page.getByRole("button", { name: "Add connection to toolkit" }).click(); | ||
| 149 | const dialog = page.getByRole("dialog", { name: "Add connection" }); | ||
| 150 | await dialog.waitFor(); | ||
| 151 | await dialog.getByLabel("Search connections and tools").fill("policies.list"); | ||
| 152 | await dialog.getByRole("button", { name: /^Connection added / }).waitFor(); | ||
| 153 | expect(await dialog.getByRole("button", { name: /^Add connection / }).count()).toBe(0); | ||
| 154 | await page.keyboard.press("Escape"); | ||
| 155 | await dialog.waitFor({ state: "hidden" }); | ||
| 156 | }); | ||
| 157 | |||
| 158 | await step("Block one tool from the toolkit tools list", async () => { | ||
| 159 | const toolkitTools = page.getByRole("region", { name: "Toolkit tools" }); | ||
| 160 | await toolkitTools.getByLabel("Filter tools").fill("policies.list"); | ||
| 161 | await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().click(); | ||
| 162 | await page.getByRole("button", { name: "Set policy", exact: true }).click(); | ||
| 163 | await page.getByText(blockPattern, { exact: true }).waitFor(); | ||
| 164 | await page.getByRole("menuitem", { name: "Block" }).click(); | ||
| 165 | await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().waitFor(); | ||
| 166 | await page.getByText("This tool is not available through the current toolkit.").waitFor(); | ||
| 167 | }); | ||
| 168 | }); | ||
| 169 | |||
| 170 | const listed = yield* client.toolkits.list(); | ||
| 171 | const toolkit = listed.toolkits.find((row) => row.slug === slug); | ||
| 172 | expect(toolkit, "the UI-created toolkit persisted").toBeDefined(); | ||
| 173 | if (!toolkit) return; | ||
| 174 | expect(toolkit.owner).toBe("org"); | ||
| 175 | |||
| 176 | const { policies } = yield* client.toolkits.listPolicies({ | ||
| 177 | params: { toolkitId: toolkit.id }, | ||
| 178 | }); | ||
| 179 | const { connections } = yield* client.toolkits.listConnections({ | ||
| 180 | params: { toolkitId: toolkit.id }, | ||
| 181 | }); | ||
| 182 | expect(addedConnectionPattern.length, "the UI selected a connection").toBeGreaterThan(0); | ||
| 183 | expect( | ||
| 184 | connections.map((connection) => connection.pattern), | ||
| 185 | "the UI-authored toolkit connection persisted", | ||
| 186 | ).toContain(addedConnectionPattern); | ||
| 187 | expect( | ||
| 188 | policies.map((policy) => `${policy.pattern} ${policy.action}`).sort(), | ||
| 189 | "the UI-authored toolkit access persisted with its action", | ||
| 190 | ).toEqual([`${blockPattern} block`]); | ||
| 191 | }).pipe(Effect.ensuring(cleanup)); | ||
| 192 | }), | ||
| 193 | ); | ||
| @@ -160,7 +160,7 @@ export interface McpSurface { · 159 unmodified lines | |||
| 160 | readonly url: string; | 160 | readonly url: string; |
| 161 | readonly session: ( | 161 | readonly session: ( |
| 162 | identity: Identity, | 162 | identity: Identity, |
| 163 | options?: { readonly elicitationMode?: McpElicitationMode }, | 163 | options?: { readonly elicitationMode?: McpElicitationMode; readonly url?: string }, |
| 164 | ) => McpSession; | 164 | ) => McpSession; |
| 165 | /** | 165 | /** |
| 166 | * Mint a real MCP bearer headlessly: protected-resource discovery → | 166 | * Mint a real MCP bearer headlessly: protected-resource discovery → |
| @@ -188,6 +188,17 @@ interface TokenResponse { · 21 unmodified lines | |||
| 188 | readonly access_token?: string; | 188 | readonly access_token?: string; |
| 189 | } | 189 | } |
| 190 | 190 | ||
| 191 | const jsonFrom = async <T>(response: Response, label: string): Promise<T> => { | ||
| 192 | const text = await response.text(); | ||
| 193 | if (!text) { | ||
| 194 | throw new Error(`${label}: empty response body (status ${response.status})`); | ||
| 195 | } | ||
| 196 | if (!response.ok) { | ||
| 197 | throw new Error(`${label}: request failed (status ${response.status}): ${text}`); | ||
| 198 | } | ||
| 199 | return JSON.parse(text) as T; | ||
| 200 | }; | ||
| 201 | |||
| 191 | const mintBearerFlow = async (target: Target, email: string): Promise<string> => { | 202 | const mintBearerFlow = async (target: Target, email: string): Promise<string> => { |
| 192 | const consent = target.mcpConsent?.({ | 203 | const consent = target.mcpConsent?.({ |
| 193 | label: email, | 204 | label: email, |
| @@ -196,21 +207,31 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 2 unmodified lines | |||
| 196 | if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`); | 207 | if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`); |
| 197 | 208 | ||
| 198 | const mcpPath = new URL(target.mcpUrl).pathname; | 209 | const mcpPath = new URL(target.mcpUrl).pathname; |
| 199 | const resource = (await ( | 210 | let resourceResponse = await fetch( |
| 200 | await fetch(new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl)) | 211 | new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl), |
| 201 | ).json()) as { authorization_servers?: ReadonlyArray<string> }; | 212 | ); |
| 213 | if (resourceResponse.status === 404) { | ||
| 214 | resourceResponse = await fetch( | ||
| 215 | new URL("/.well-known/oauth-protected-resource", target.baseUrl), | ||
| 216 | ); | ||
| 217 | } | ||
| 218 | const resource = await jsonFrom<{ authorization_servers?: ReadonlyArray<string> }>( | ||
| 219 | resourceResponse, | ||
| 220 | "mintBearer: protected-resource metadata", | ||
| 221 | ); | ||
| 202 | const issuer = resource.authorization_servers?.[0]; | 222 | const issuer = resource.authorization_servers?.[0]; |
| 203 | if (!issuer) throw new Error("mintBearer: no authorization server advertised"); | 223 | if (!issuer) throw new Error("mintBearer: no authorization server advertised"); |
| 204 | const metadata = (await ( | 224 | const metadata = await jsonFrom<{ |
| 205 | await fetch(new URL("/.well-known/oauth-authorization-server", issuer)) | ||
| 206 | ).json()) as { | ||
| 207 | readonly authorization_endpoint: string; | 225 | readonly authorization_endpoint: string; |
| 208 | readonly token_endpoint: string; | 226 | readonly token_endpoint: string; |
| 209 | readonly registration_endpoint: string; | 227 | readonly registration_endpoint: string; |
| 210 | }; | 228 | }>( |
| 229 | await fetch(new URL("/.well-known/oauth-authorization-server", issuer)), | ||
| 230 | "mintBearer: authorization-server metadata", | ||
| 231 | ); | ||
| 211 | 232 | ||
| 212 | const redirectUri = "http://127.0.0.1:9/callback"; | 233 | const redirectUri = "http://127.0.0.1:9/callback"; |
| 213 | const registered = (await ( | 234 | const registered = await jsonFrom<{ readonly client_id: string }>( |
| 214 | await fetch(metadata.registration_endpoint, { | 235 | await fetch(metadata.registration_endpoint, { |
| 215 | method: "POST", | 236 | method: "POST", |
| 216 | headers: { "content-type": "application/json" }, | 237 | headers: { "content-type": "application/json" }, |
| @@ -221,8 +242,9 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 4 unmodified lines | |||
| 221 | response_types: ["code"], | 242 | response_types: ["code"], |
| 222 | token_endpoint_auth_method: "none", | 243 | token_endpoint_auth_method: "none", |
| 223 | }), | 244 | }), |
| 224 | }) | 245 | }), |
| 225 | ).json()) as { readonly client_id: string }; | 246 | "mintBearer: dynamic client registration", |
| 247 | ); | ||
| 226 | 248 | ||
| 227 | const verifier = randomBytes(32).toString("base64url"); | 249 | const verifier = randomBytes(32).toString("base64url"); |
| 228 | const authorizeUrl = new URL(metadata.authorization_endpoint); | 250 | const authorizeUrl = new URL(metadata.authorization_endpoint); |
| @@ -240,7 +262,7 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 11 unmodified lines | |||
| 240 | redirectUrl: redirectUri, | 262 | redirectUrl: redirectUri, |
| 241 | }); | 263 | }); |
| 242 | 264 | ||
| 243 | const token = (await ( | 265 | const token = await jsonFrom<TokenResponse>( |
| 244 | await fetch(metadata.token_endpoint, { | 266 | await fetch(metadata.token_endpoint, { |
| 245 | method: "POST", | 267 | method: "POST", |
| 246 | headers: { "content-type": "application/x-www-form-urlencoded" }, | 268 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| @@ -251,8 +273,9 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 4 unmodified lines | |||
| 251 | client_id: registered.client_id, | 273 | client_id: registered.client_id, |
| 252 | code_verifier: verifier, | 274 | code_verifier: verifier, |
| 253 | }), | 275 | }), |
| 254 | }) | 276 | }), |
| 255 | ).json()) as TokenResponse; | 277 | "mintBearer: token exchange", |
| 278 | ); | ||
| 256 | if (!token.access_token) throw new Error("mintBearer: token exchange returned no token"); | 279 | if (!token.access_token) throw new Error("mintBearer: token exchange returned no token"); |
| 257 | return token.access_token; | 280 | return token.access_token; |
| 258 | }; | 281 | }; |
| @@ -261,7 +284,8 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 2 unmodified lines | |||
| 261 | url: target.mcpUrl, | 284 | url: target.mcpUrl, |
| 262 | mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)), | 285 | mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)), |
| 263 | session: (identity, options) => { | 286 | session: (identity, options) => { |
| 264 | if (runDir) installTraceparentFetch(target.mcpUrl, runDir); | 287 | const mcpUrl = options?.url ?? target.mcpUrl; |
| 288 | if (runDir) installTraceparentFetch(mcpUrl, runDir); | ||
| 265 | // mcporter caches OAuth tokens (and the DCR client) per server NAME, so a | 289 | // mcporter caches OAuth tokens (and the DCR client) per server NAME, so a |
| 266 | // constant name would let a later session reuse an earlier identity's token | 290 | // constant name would let a later session reuse an earlier identity's token |
| 267 | // — landing in the wrong org. A unique name per session keeps each | 291 | // — landing in the wrong org. A unique name per session keeps each |
| @@ -272,8 +296,8 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 4 unmodified lines | |||
| 272 | // `?elicitation_mode=` query on the MCP endpoint — so a paused execution | 296 | // `?elicitation_mode=` query on the MCP endpoint — so a paused execution |
| 273 | // yields an approvalUrl instead of letting the model resume inline. | 297 | // yields an approvalUrl instead of letting the model resume inline. |
| 274 | const sessionUrl = options?.elicitationMode | 298 | const sessionUrl = options?.elicitationMode |
| 275 | ? `${target.mcpUrl}?elicitation_mode=${options.elicitationMode}` | 299 | ? `${mcpUrl}?elicitation_mode=${options.elicitationMode}` |
| 276 | : target.mcpUrl; | 300 | : mcpUrl; |
| 277 | let runtimePromise: Promise<Runtime> | undefined; | 301 | let runtimePromise: Promise<Runtime> | undefined; |
| 278 | let connected = false; | 302 | let connected = false; |
| 279 | 303 | ||
| @@ -83,7 +83,13 @@ const forcedMcpConsent = · 82 unmodified lines | |||
| 83 | if (!decision.ok) { | 83 | if (!decision.ok) { |
| 84 | throw new Error(`forcedMcpConsent: consent grant failed (status ${decision.status})`); | 84 | throw new Error(`forcedMcpConsent: consent grant failed (status ${decision.status})`); |
| 85 | } | 85 | } |
| 86 | const body = (await decision.json()) as { redirectURI?: string }; | 86 | const decisionText = await decision.text(); |
| 87 | if (!decisionText) { | ||
| 88 | throw new Error( | ||
| 89 | `forcedMcpConsent: consent grant returned an empty body (status ${decision.status})`, | ||
| 90 | ); | ||
| 91 | } | ||
| 92 | const body = JSON.parse(decisionText) as { redirectURI?: string }; | ||
| 87 | const code = body.redirectURI ? new URL(body.redirectURI).searchParams.get("code") : null; | 93 | const code = body.redirectURI ? new URL(body.redirectURI).searchParams.get("code") : null; |
| 88 | if (!code) { | 94 | if (!code) { |
| 89 | throw new Error( | 95 | throw new Error( |
| @@ -118,6 +124,6 @@ export const selfhostTarget = (): Target => ({ · 28 unmodified lines | |||
| 118 | mcpConsent: (identity: Identity) => | 124 | mcpConsent: (identity: Identity) => |
| 119 | forcedMcpConsent(SELFHOST_BASE_URL, { | 125 | forcedMcpConsent(SELFHOST_BASE_URL, { |
| 120 | email: identity.credentials?.email ?? SELFHOST_ADMIN.email, | 126 | email: identity.credentials?.email ?? SELFHOST_ADMIN.email, |
| 121 | password: identity.credentials?.password ?? SELFHOST_ADMIN.password, | 127 | password: identity.credentials?.password || SELFHOST_ADMIN.password, |
| 122 | }), | 128 | }), |
| 123 | }); | 129 | }); |
| @@ -26,6 +26,7 @@ · 25 unmodified lines | |||
| 26 | import { Context, Effect, Layer } from "effect"; | 26 | import { Context, Effect, Layer } from "effect"; |
| 27 | import type * as Cause from "effect/Cause"; | 27 | import type * as Cause from "effect/Cause"; |
| 28 | 28 | ||
| 29 | import type { McpResource } from "@executor-js/host-mcp"; | ||
| 29 | import type { AnyPlugin, Executor, StorageFailure } from "@executor-js/sdk"; | 30 | import type { AnyPlugin, Executor, StorageFailure } from "@executor-js/sdk"; |
| 30 | import { | 31 | import { |
| 31 | createExecutionEngine, | 32 | createExecutionEngine, |
| @@ -95,6 +96,7 @@ export const makeExecutionStack = < · 63 unmodified lines | |||
| 95 | accountId: string, | 96 | accountId: string, |
| 96 | organizationId: string, | 97 | organizationId: string, |
| 97 | organizationName: string, | 98 | organizationName: string, |
| 99 | options?: { readonly mcpResource?: McpResource }, | ||
| 98 | ): Effect.Effect< | 100 | ): Effect.Effect< |
| 99 | { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> }, | 101 | { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> }, |
| 100 | StorageFailure, | 102 | StorageFailure, |
| @@ -105,6 +107,7 @@ export const makeExecutionStack = < · 4 unmodified lines | |||
| 105 | accountId, | 107 | accountId, |
| 106 | organizationId, | 108 | organizationId, |
| 107 | organizationName, | 109 | organizationName, |
| 110 | { plugins: { mcpResource: options?.mcpResource } }, | ||
| 108 | ); | 111 | ); |
| 109 | const codeExecutor = yield* CodeExecutorProvider; | 112 | const codeExecutor = yield* CodeExecutorProvider; |
| 110 | const { decorate } = yield* EngineDecorator; | 113 | const { decorate } = yield* EngineDecorator; |
| @@ -39,11 +39,9 @@ export type McpExecutionStackLayer = Layer.Layer< · 38 unmodified lines | |||
| 39 | export const makeMcpBuildServer = | 39 | export const makeMcpBuildServer = |
| 40 | (executionStack: McpExecutionStackLayer): McpBuildServer => | 40 | (executionStack: McpExecutionStackLayer): McpBuildServer => |
| 41 | (principal: Principal, options?: McpBuildServerOptions) => | 41 | (principal: Principal, options?: McpBuildServerOptions) => |
| 42 | makeExecutionStack( | 42 | makeExecutionStack(principal.accountId, principal.organizationId, principal.organizationName, { |
| 43 | principal.accountId, | 43 | mcpResource: options?.resource, |
| 44 | principal.organizationId, | 44 | }).pipe( |
| 45 | principal.organizationName, | ||
| 46 | ).pipe( | ||
| 47 | Effect.map(({ engine }) => engine), | 45 | Effect.map(({ engine }) => engine), |
| 48 | // Pin browser-handoff URLs to the principal's org slug when present; | 46 | // Pin browser-handoff URLs to the principal's org slug when present; |
| 49 | // absent slug leaves the service unprovided and the URL stays bare. | 47 | // absent slug leaves the service unprovided and the URL stays bare. |
| @@ -33,6 +33,7 @@ · 32 unmodified lines | |||
| 33 | 33 | ||
| 34 | import { Context, Effect, Option } from "effect"; | 34 | import { Context, Effect, Option } from "effect"; |
| 35 | 35 | ||
| 36 | import type { McpResource } from "@executor-js/host-mcp"; | ||
| 36 | import { | 37 | import { |
| 37 | createExecutor, | 38 | createExecutor, |
| 38 | OAUTH_CALLBACK_ORG_QUERY_PARAM, | 39 | OAUTH_CALLBACK_ORG_QUERY_PARAM, |
| @@ -169,8 +170,12 @@ export const buildOAuthRedirectUri = (input: { · 130 unmodified lines | |||
| 169 | // while a host with static plugins (self-host) just returns a constant array. | 170 | // while a host with static plugins (self-host) just returns a constant array. |
| 170 | // --------------------------------------------------------------------------- | 171 | // --------------------------------------------------------------------------- |
| 171 | 172 | ||
| 173 | export interface PluginsProviderContext { | ||
| 174 | readonly mcpResource?: McpResource; | ||
| 175 | } | ||
| 176 | |||
| 172 | export interface PluginsProviderShape { | 177 | export interface PluginsProviderShape { |
| 173 | readonly plugins: () => readonly AnyPlugin[]; | 178 | readonly plugins: (context?: PluginsProviderContext) => readonly AnyPlugin[]; |
| 174 | } | 179 | } |
| 175 | 180 | ||
| 176 | export class PluginsProvider extends Context.Service<PluginsProvider, PluginsProviderShape>()( | 181 | export class PluginsProvider extends Context.Service<PluginsProvider, PluginsProviderShape>()( |
| @@ -207,6 +212,7 @@ export const makeScopedExecutor = < · 30 unmodified lines | |||
| 207 | // `EngineStackIdentity` (the engine decorator still wants it); not part of the | 212 | // `EngineStackIdentity` (the engine decorator still wants it); not part of the |
| 208 | // v2 executor binding, which is `{ tenant, subject }` only. | 213 | // v2 executor binding, which is `{ tenant, subject }` only. |
| 209 | _organizationName: string, | 214 | _organizationName: string, |
| 215 | options?: { readonly plugins?: PluginsProviderContext }, | ||
| 210 | ): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> => | 216 | ): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> => |
| 211 | Effect.gen(function* () { | 217 | Effect.gen(function* () { |
| 212 | const { db, blobs } = yield* DbProvider; | 218 | const { db, blobs } = yield* DbProvider; |
| @@ -248,7 +254,7 @@ export const makeScopedExecutor = < · 35 unmodified lines | |||
| 248 | orgSlug, | 254 | orgSlug, |
| 249 | }); | 255 | }); |
| 250 | 256 | ||
| 251 | const plugins = pluginsFactory(); | 257 | const plugins = pluginsFactory(options?.plugins); |
| 252 | const hostedHttpOptions = { | 258 | const hostedHttpOptions = { |
| 253 | allowLocalNetwork: config.allowLocalNetwork, | 259 | allowLocalNetwork: config.allowLocalNetwork, |
| 254 | }; | 260 | }; |
| @@ -46,10 +46,19 @@ export { useAtomValue, useAtomSet, useAtomMount, useAtomRefresh } from "@effect/ · 45 unmodified lines | |||
| 46 | // alongside the host's own UI. | 46 | // alongside the host's own UI. |
| 47 | // --------------------------------------------------------------------------- | 47 | // --------------------------------------------------------------------------- |
| 48 | 48 | ||
| 49 | export interface PluginPageProps { | ||
| 50 | /** Plugin-relative route params captured from `PageDecl.path` segments. */ | ||
| 51 | readonly params: Readonly<Record<string, string>>; | ||
| 52 | /** The normalized plugin-relative URL path that matched this page. */ | ||
| 53 | readonly path: string; | ||
| 54 | /** The plugin id from `/plugins/$pluginId/...`. */ | ||
| 55 | readonly pluginId: string; | ||
| 56 | } | ||
| 57 | |||
| 49 | export interface PageDecl { | 58 | export interface PageDecl { |
| 50 | /** Path relative to the plugin's mount point, e.g. `/`, `/edit/$id`. */ | 59 | /** Path relative to the plugin's mount point, e.g. `/`, `/edit/$id`. */ |
| 51 | readonly path: string; | 60 | readonly path: string; |
| 52 | readonly component: ComponentType; | 61 | readonly component: ComponentType<PluginPageProps>; |
| 53 | /** Optional sidebar nav metadata — the host renders these alongside its | 62 | /** Optional sidebar nav metadata — the host renders these alongside its |
| 54 | * own nav links. Omit to register a page without a nav entry. */ | 63 | * own nav links. Omit to register a page without a nav entry. */ |
| 55 | readonly nav?: { | 64 | readonly nav?: { |
| @@ -90,6 +90,7 @@ import type { OAuthService } from "./oauth-client"; · 89 unmodified lines | |||
| 90 | import { | 90 | import { |
| 91 | comparePolicyRow, | 91 | comparePolicyRow, |
| 92 | isValidPattern, | 92 | isValidPattern, |
| 93 | matchPattern, | ||
| 93 | resolveEffectivePolicy, | 94 | resolveEffectivePolicy, |
| 94 | rowToToolPolicy, | 95 | rowToToolPolicy, |
| 95 | type CreateToolPolicyInput, | 96 | type CreateToolPolicyInput, |
| @@ -112,6 +113,8 @@ import type { · 16 unmodified lines | |||
| 112 | StaticSourceDecl, | 113 | StaticSourceDecl, |
| 113 | StaticToolDecl, | 114 | StaticToolDecl, |
| 114 | StorageDeps, | 115 | StorageDeps, |
| 116 | ToolPolicyProvider, | ||
| 117 | ToolPolicyProviderRule, | ||
| 115 | ToolInvocationCredential, | 118 | ToolInvocationCredential, |
| 116 | } from "./plugin"; | 119 | } from "./plugin"; |
| 117 | import { | 120 | import { |
| @@ -1282,6 +1285,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1164 unmodified lines | |||
| 1282 | // Populated once, never mutated after startup. | 1285 | // Populated once, never mutated after startup. |
| 1283 | const staticTools = new Map<string, StaticTools>(); | 1286 | const staticTools = new Map<string, StaticTools>(); |
| 1284 | const runtimes = new Map<string, PluginRuntime>(); | 1287 | const runtimes = new Map<string, PluginRuntime>(); |
| 1288 | let activeToolPolicyProvider: ToolPolicyProvider | null = null; | ||
| 1285 | // Credential providers keyed by `provider.key`, in registration order. | 1289 | // Credential providers keyed by `provider.key`, in registration order. |
| 1286 | const credentialProviders = new Map<string, CredentialProvider>(); | 1290 | const credentialProviders = new Map<string, CredentialProvider>(); |
| 1287 | const credentialProviderOrder: string[] = []; | 1291 | const credentialProviderOrder: string[] = []; |
| @@ -2299,8 +2303,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1011 unmodified lines | |||
| 2299 | readonly integration?: IntegrationSlug; | 2303 | readonly integration?: IntegrationSlug; |
| 2300 | readonly owner?: Owner; | 2304 | readonly owner?: Owner; |
| 2301 | }): Effect.Effect<readonly Connection[], StorageFailure> => | 2305 | }): Effect.Effect<readonly Connection[], StorageFailure> => |
| 2302 | core | 2306 | Effect.gen(function* () { |
| 2303 | .findMany("connection", { | 2307 | const rows = yield* core.findMany("connection", { |
| 2304 | where: (b: AnyCb) => | 2308 | where: (b: AnyCb) => |
| 2305 | b.and( | 2309 | b.and( |
| 2306 | filter?.integration === undefined | 2310 | filter?.integration === undefined |
| @@ -2308,8 +2312,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line | |||
| 2308 | : b("integration", "=", String(filter.integration)), | 2312 | : b("integration", "=", String(filter.integration)), |
| 2309 | filter?.owner === undefined ? true : b("owner", "=", filter.owner), | 2313 | filter?.owner === undefined ? true : b("owner", "=", filter.owner), |
| 2310 | ), | 2314 | ), |
| 2311 | }) | 2315 | }); |
| 2312 | .pipe(Effect.map((rows) => rows.map(rowToConnection))); | 2316 | const connections = rows.map(rowToConnection); |
| 2317 | if (!activeToolPolicyProvider) return connections; | ||
| 2318 | |||
| 2319 | const visibleTools = yield* toolsList({ includeAnnotations: false }); | ||
| 2320 | const visibleConnectionKeys = new Set( | ||
| 2321 | visibleTools | ||
| 2322 | .filter((tool) => !tool.static) | ||
| 2323 | .map((tool) => `${tool.owner}:${tool.integration}:${tool.connection}`), | ||
| 2324 | ); | ||
| 2325 | return connections.filter((connection) => | ||
| 2326 | visibleConnectionKeys.has( | ||
| 2327 | `${connection.owner}:${connection.integration}:${connection.name}`, | ||
| 2328 | ), | ||
| 2329 | ); | ||
| 2330 | }); | ||
| 2313 | 2331 | ||
| 2314 | const connectionsGet = (ref: ConnectionRef): Effect.Effect<Connection | null, StorageFailure> => | 2332 | const connectionsGet = (ref: ConnectionRef): Effect.Effect<Connection | null, StorageFailure> => |
| 2315 | findConnectionRow(ref).pipe(Effect.map((row) => (row ? rowToConnection(row) : null))); | 2333 | findConnectionRow(ref).pipe(Effect.map((row) => (row ? rowToConnection(row) : null))); |
| @@ -2412,6 +2430,81 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 96 unmodified lines | |||
| 2412 | return yield* produceConnectionTools(integrationRow, ref); | 2430 | return yield* produceConnectionTools(integrationRow, ref); |
| 2413 | }); | 2431 | }); |
| 2414 | 2432 | ||
| 2433 | // ------------------------------------------------------------------ | ||
| 2434 | // Active policy source. | ||
| 2435 | // ------------------------------------------------------------------ | ||
| 2436 | |||
| 2437 | type ActivePolicyRuleSet = | ||
| 2438 | | { readonly kind: "global"; readonly rows: readonly ToolPolicyRow[] } | ||
| 2439 | | { | ||
| 2440 | readonly kind: "provider"; | ||
| 2441 | readonly provider: ToolPolicyProvider; | ||
| 2442 | readonly rules: readonly ToolPolicyProviderRule[] | null; | ||
| 2443 | }; | ||
| 2444 | |||
| 2445 | const compareProviderPolicyRule = ( | ||
| 2446 | a: ToolPolicyProviderRule, | ||
| 2447 | b: ToolPolicyProviderRule, | ||
| 2448 | ): number => { | ||
| 2449 | if (a.position < b.position) return -1; | ||
| 2450 | if (a.position > b.position) return 1; | ||
| 2451 | return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; | ||
| 2452 | }; | ||
| 2453 | |||
| 2454 | const resolveProviderPolicyFromRules = ( | ||
| 2455 | toolId: string, | ||
| 2456 | rules: readonly ToolPolicyProviderRule[], | ||
| 2457 | ): EffectivePolicy => { | ||
| 2458 | for (const rule of [...rules].sort(compareProviderPolicyRule)) { | ||
| 2459 | if (!matchPattern(rule.pattern, toolId)) continue; | ||
| 2460 | return { | ||
| 2461 | action: rule.action, | ||
| 2462 | source: "user", | ||
| 2463 | pattern: rule.pattern, | ||
| 2464 | policyId: rule.id, | ||
| 2465 | }; | ||
| 2466 | } | ||
| 2467 | // Toolkit-style providers are capability allowlists. No matching rule | ||
| 2468 | // means the tool is outside the capability boundary. | ||
| 2469 | return { | ||
| 2470 | action: "block", | ||
| 2471 | source: "user", | ||
| 2472 | pattern: "*", | ||
| 2473 | }; | ||
| 2474 | }; | ||
| 2475 | |||
| 2476 | const listActivePolicyRuleSet = (): Effect.Effect<ActivePolicyRuleSet, StorageFailure> => | ||
| 2477 | activeToolPolicyProvider | ||
| 2478 | ? activeToolPolicyProvider.resolve | ||
| 2479 | ? Effect.succeed({ | ||
| 2480 | kind: "provider" as const, | ||
| 2481 | provider: activeToolPolicyProvider, | ||
| 2482 | rules: null, | ||
| 2483 | }) | ||
| 2484 | : activeToolPolicyProvider.list().pipe( | ||
| 2485 | Effect.map((rules) => ({ | ||
| 2486 | kind: "provider" as const, | ||
| 2487 | provider: activeToolPolicyProvider!, | ||
| 2488 | rules, | ||
| 2489 | })), | ||
| 2490 | ) | ||
| 2491 | : core | ||
| 2492 | .findMany("tool_policy", {}) | ||
| 2493 | .pipe(Effect.map((rows) => ({ kind: "global" as const, rows }))); | ||
| 2494 | |||
| 2495 | const resolvePolicyFromRuleSet = ( | ||
| 2496 | toolId: string, | ||
| 2497 | ruleSet: ActivePolicyRuleSet, | ||
| 2498 | defaultRequiresApproval?: boolean, | ||
| 2499 | ): Effect.Effect<EffectivePolicy, StorageFailure> => | ||
| 2500 | ruleSet.kind === "provider" | ||
| 2501 | ? ruleSet.provider.resolve | ||
| 2502 | ? ruleSet.provider.resolve({ toolId, defaultRequiresApproval }) | ||
| 2503 | : Effect.succeed(resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? [])) | ||
| 2504 | : Effect.succeed( | ||
| 2505 | resolveEffectivePolicy(toolId, ruleSet.rows, ownerRankForRow, defaultRequiresApproval), | ||
| 2506 | ); | ||
| 2507 | |||
| 2415 | // ------------------------------------------------------------------ | 2508 | // ------------------------------------------------------------------ |
| 2416 | // Tools (read surface) | 2509 | // Tools (read surface) |
| 2417 | // ------------------------------------------------------------------ | 2510 | // ------------------------------------------------------------------ |
| @@ -2490,16 +2583,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 72 unmodified lines | |||
| 2490 | select: TOOL_INVOCATION_COLUMNS, | 2583 | select: TOOL_INVOCATION_COLUMNS, |
| 2491 | }); | 2584 | }); |
| 2492 | const includeBlocked = filter?.includeBlocked ?? false; | 2585 | const includeBlocked = filter?.includeBlocked ?? false; |
| 2493 | const policyRows = yield* core.findMany("tool_policy", {}); | 2586 | const policyRules = yield* listActivePolicyRuleSet(); |
| 2494 | const tools: Tool[] = []; | 2587 | const tools: Tool[] = []; |
| 2495 | for (const row of rows) { | 2588 | for (const row of rows) { |
| 2496 | const tool = rowToTool(row); | 2589 | const tool = rowToTool(row); |
| 2497 | if (!matchesToolFilter(tool, filter)) continue; | 2590 | if (!matchesToolFilter(tool, filter)) continue; |
| 2498 | if (!includeBlocked) { | 2591 | if (!includeBlocked) { |
| 2499 | const effective = resolveEffectivePolicy( | 2592 | const effective = yield* resolvePolicyFromRuleSet( |
| 2500 | normalizedPolicyId(tool), | 2593 | normalizedPolicyId(tool), |
| 2501 | policyRows, | 2594 | policyRules, |
| 2502 | ownerRankForRow, | ||
| 2503 | tool.annotations?.requiresApproval, | 2595 | tool.annotations?.requiresApproval, |
| 2504 | ); | 2596 | ); |
| 2505 | if (effective.action === "block") continue; | 2597 | if (effective.action === "block") continue; |
| @@ -2510,10 +2602,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 4 unmodified lines | |||
| 2510 | const tool = staticToolToTool(entry); | 2602 | const tool = staticToolToTool(entry); |
| 2511 | if (!matchesToolFilter(tool, filter)) continue; | 2603 | if (!matchesToolFilter(tool, filter)) continue; |
| 2512 | if (!includeBlocked) { | 2604 | if (!includeBlocked) { |
| 2513 | const effective = resolveEffectivePolicy( | 2605 | const effective = yield* resolvePolicyFromRuleSet( |
| 2514 | normalizedPolicyId(tool), | 2606 | normalizedPolicyId(tool), |
| 2515 | policyRows, | 2607 | policyRules, |
| 2516 | ownerRankForRow, | ||
| 2517 | tool.annotations?.requiresApproval, | 2608 | tool.annotations?.requiresApproval, |
| 2518 | ); | 2609 | ); |
| 2519 | if (effective.action === "block") continue; | 2610 | if (effective.action === "block") continue; |
| @@ -2527,9 +2618,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines | |||
| 2527 | address: ToolAddress, | 2618 | address: ToolAddress, |
| 2528 | ): Effect.Effect<ToolSchemaView | null, StorageFailure> => | 2619 | ): Effect.Effect<ToolSchemaView | null, StorageFailure> => |
| 2529 | Effect.gen(function* () { | 2620 | Effect.gen(function* () { |
| 2621 | const policyRules = yield* listActivePolicyRuleSet(); | ||
| 2530 | const staticEntry = staticTools.get(String(address)); | 2622 | const staticEntry = staticTools.get(String(address)); |
| 2531 | if (staticEntry) { | 2623 | if (staticEntry) { |
| 2532 | const tool = staticToolToTool(staticEntry); | 2624 | const tool = staticToolToTool(staticEntry); |
| 2625 | const effective = yield* resolvePolicyFromRuleSet( | ||
| 2626 | normalizedPolicyId(tool), | ||
| 2627 | policyRules, | ||
| 2628 | tool.annotations?.requiresApproval, | ||
| 2629 | ); | ||
| 2630 | if (effective.action === "block") return null; | ||
| 2533 | const preview = yield* Effect.tryPromise({ | 2631 | const preview = yield* Effect.tryPromise({ |
| 2534 | try: () => | 2632 | try: () => |
| 2535 | buildToolTypeScriptPreview({ | 2633 | buildToolTypeScriptPreview({ |
| @@ -2565,6 +2663,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines | |||
| 2565 | }); | 2663 | }); |
| 2566 | if (!row) return null; | 2664 | if (!row) return null; |
| 2567 | const tool = rowToTool(row); | 2665 | const tool = rowToTool(row); |
| 2666 | const effective = yield* resolvePolicyFromRuleSet( | ||
| 2667 | normalizedPolicyId(tool), | ||
| 2668 | policyRules, | ||
| 2669 | tool.annotations?.requiresApproval, | ||
| 2670 | ); | ||
| 2671 | if (effective.action === "block") return null; | ||
| 2568 | 2672 | ||
| 2569 | const definitionRows = yield* core.findMany("definition", { | 2673 | const definitionRows = yield* core.findMany("definition", { |
| 2570 | where: (b: AnyCb) => | 2674 | where: (b: AnyCb) => |
| @@ -2904,11 +3008,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 333 unmodified lines | |||
| 2904 | // not the 5-segment dynamic form. | 3008 | // not the 5-segment dynamic form. |
| 2905 | const staticEntry = staticTools.get(String(address)); | 3009 | const staticEntry = staticTools.get(String(address)); |
| 2906 | if (staticEntry) { | 3010 | if (staticEntry) { |
| 2907 | const policyRows = yield* core.findMany("tool_policy", {}); | 3011 | const policyRules = yield* listActivePolicyRuleSet(); |
| 2908 | const policy = resolveEffectivePolicy( | 3012 | const policy = yield* resolvePolicyFromRuleSet( |
| 2909 | String(address), | 3013 | String(address), |
| 2910 | policyRows, | 3014 | policyRules, |
| 2911 | ownerRankForRow, | ||
| 2912 | staticEntry.tool.annotations?.requiresApproval, | 3015 | staticEntry.tool.annotations?.requiresApproval, |
| 2913 | ); | 3016 | ); |
| 2914 | if (policy.action === "block") { | 3017 | if (policy.action === "block") { |
| @@ -2957,12 +3060,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 42 unmodified lines | |||
| 2957 | 3060 | ||
| 2958 | // Resolve policy (owner-ranked). | 3061 | // Resolve policy (owner-ranked). |
| 2959 | const toolForPolicy = rowToTool(row); | 3062 | const toolForPolicy = rowToTool(row); |
| 2960 | const policyRows = yield* core.findMany("tool_policy", {}); | 3063 | const policyRules = yield* listActivePolicyRuleSet(); |
| 2961 | const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined; | 3064 | const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined; |
| 2962 | const policy = resolveEffectivePolicy( | 3065 | const policy = yield* resolvePolicyFromRuleSet( |
| 2963 | normalizedPolicyId(toolForPolicy), | 3066 | normalizedPolicyId(toolForPolicy), |
| 2964 | policyRows, | 3067 | policyRules, |
| 2965 | ownerRankForRow, | ||
| 2966 | annotations?.requiresApproval, | 3068 | annotations?.requiresApproval, |
| 2967 | ); | 3069 | ); |
| 2968 | if (policy.action === "block") { | 3070 | if (policy.action === "block") { |
| @@ -3183,6 +3285,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 214 unmodified lines | |||
| 3183 | transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect), | 3285 | transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect), |
| 3184 | }; | 3286 | }; |
| 3185 | 3287 | ||
| 3288 | if (plugin.toolPolicyProvider) { | ||
| 3289 | const rawProvider = plugin.toolPolicyProvider(ctx); | ||
| 3290 | const provider = Effect.isEffect(rawProvider) ? yield* rawProvider : rawProvider; | ||
| 3291 | if (provider) { | ||
| 3292 | if (activeToolPolicyProvider) { | ||
| 3293 | return yield* new StorageError({ | ||
| 3294 | message: "Only one plugin can provide the active tool policy source.", | ||
| 3295 | cause: undefined, | ||
| 3296 | }); | ||
| 3297 | } | ||
| 3298 | activeToolPolicyProvider = provider; | ||
| 3299 | } | ||
| 3300 | } | ||
| 3301 | |||
| 3186 | // Build extension FIRST so it's available as `self` for staticSources. | 3302 | // Build extension FIRST so it's available as `self` for staticSources. |
| 3187 | const extension: object = plugin.extension ? plugin.extension(ctx) : {}; | 3303 | const extension: object = plugin.extension ? plugin.extension(ctx) : {}; |
| 3188 | if (plugin.extension) { | 3304 | if (plugin.extension) { |
| @@ -275,6 +275,8 @@ export { · 274 unmodified lines | |||
| 275 | type AnyPlugin, | 275 | type AnyPlugin, |
| 276 | type StorageDeps, | 276 | type StorageDeps, |
| 277 | type OwnerBinding, | 277 | type OwnerBinding, |
| 278 | type ToolPolicyProvider, | ||
| 279 | type ToolPolicyProviderRule, | ||
| 278 | type IntegrationRecord, | 280 | type IntegrationRecord, |
| 279 | type StaticSourceDecl, | 281 | type StaticSourceDecl, |
| 280 | type StaticToolDecl, | 282 | type StaticToolDecl, |
| @@ -48,6 +48,7 @@ import type { CredentialProvider, ProviderEntry } from "./provider"; · 47 unmodified lines | |||
| 48 | import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage"; | 48 | import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage"; |
| 49 | import type { | 49 | import type { |
| 50 | CreateToolPolicyInput, | 50 | CreateToolPolicyInput, |
| 51 | EffectivePolicy, | ||
| 51 | RemoveToolPolicyInput, | 52 | RemoveToolPolicyInput, |
| 52 | ToolPolicy, | 53 | ToolPolicy, |
| 53 | UpdateToolPolicyInput, | 54 | UpdateToolPolicyInput, |
| @@ -88,6 +89,30 @@ export type Elicit = ( · 34 unmodified lines | |||
| 88 | request: ElicitationRequest, | 89 | request: ElicitationRequest, |
| 89 | ) => Effect.Effect<ElicitationResponse, ElicitationDeclinedError>; | 90 | ) => Effect.Effect<ElicitationResponse, ElicitationDeclinedError>; |
| 90 | 91 | ||
| 92 | // --------------------------------------------------------------------------- | ||
| 93 | // Active tool-policy provider. | ||
| 94 | // | ||
| 95 | // Normal executors resolve policies from core's owner-scoped `tool_policy` | ||
| 96 | // table. A plugin may opt one executor instance into a different rule source | ||
| 97 | // (for example, a toolkit-specific policy set). Core still owns enforcement; | ||
| 98 | // the plugin owns where those policy-shaped rows are stored. | ||
| 99 | // --------------------------------------------------------------------------- | ||
| 100 | |||
| 101 | export interface ToolPolicyProviderRule { | ||
| 102 | readonly id: string; | ||
| 103 | readonly pattern: string; | ||
| 104 | readonly action: ToolPolicy["action"]; | ||
| 105 | readonly position: string; | ||
| 106 | } | ||
| 107 | |||
| 108 | export interface ToolPolicyProvider { | ||
| 109 | readonly list: () => Effect.Effect<readonly ToolPolicyProviderRule[], StorageFailure>; | ||
| 110 | readonly resolve?: (input: { | ||
| 111 | readonly toolId: string; | ||
| 112 | readonly defaultRequiresApproval?: boolean; | ||
| 113 | }) => Effect.Effect<EffectivePolicy, StorageFailure>; | ||
| 114 | } | ||
| 115 | |||
| 91 | // --------------------------------------------------------------------------- | 116 | // --------------------------------------------------------------------------- |
| 92 | // IntegrationRecord — the catalog row a plugin reads back (its own opaque | 117 | // IntegrationRecord — the catalog row a plugin reads back (its own opaque |
| 93 | // `config` included). Returned by `ctx.core.integrations.get`. | 118 | // `config` included). Returned by `ctx.core.integrations.get`. |
| @@ -458,6 +483,13 @@ export interface PluginSpec< · 364 unmodified lines | |||
| 458 | /** Service tag the plugin's `handlers` layer requires. */ | 483 | /** Service tag the plugin's `handlers` layer requires. */ |
| 459 | readonly extensionService?: TExtensionService; | 484 | readonly extensionService?: TExtensionService; |
| 460 | 485 | ||
| 486 | /** Optional active policy source for this executor instance. At most one | ||
| 487 | * loaded plugin may return a provider. When absent, core uses the normal | ||
| 488 | * owner-scoped tool policies. */ | ||
| 489 | readonly toolPolicyProvider?: ( | ||
| 490 | ctx: PluginCtx<TStore>, | ||
| 491 | ) => ToolPolicyProvider | null | Effect.Effect<ToolPolicyProvider | null, StorageFailure>; | ||
| 492 | |||
| 461 | /** Produce a connection's tools (and shared $defs). The v2 successor to | 493 | /** Produce a connection's tools (and shared $defs). The v2 successor to |
| 462 | * registering per-source tools — called by the executor at connection | 494 | * registering per-source tools — called by the executor at connection |
| 463 | * create / refresh / oauth.complete; the result is stamped with addresses | 495 | * create / refresh / oauth.complete; the result is stamped with addresses |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect, Predicate, Result } from "effect"; | 2 | import { Effect, Predicate, Result, Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { type ToolPolicyRow } from "./core-schema"; | 4 | import { type ToolPolicyRow } from "./core-schema"; |
| 5 | import { | 5 | import { |
| @@ -19,7 +19,7 @@ import { · 13 unmodified lines | |||
| 19 | matchPattern, | 19 | matchPattern, |
| 20 | resolveToolPolicy, | 20 | resolveToolPolicy, |
| 21 | } from "./policies"; | 21 | } from "./policies"; |
| 22 | import { definePlugin } from "./plugin"; | 22 | import { definePlugin, tool } from "./plugin"; |
| 23 | import type { CredentialProvider } from "./provider"; | 23 | import type { CredentialProvider } from "./provider"; |
| 24 | import { makeTestExecutor } from "./testing"; | 24 | import { makeTestExecutor } from "./testing"; |
| 25 | 25 | ||
| @@ -512,6 +512,85 @@ describe("blocked tools", () => { · 486 unmodified lines | |||
| 512 | ); | 512 | ); |
| 513 | }); | 513 | }); |
| 514 | 514 | ||
| 515 | describe("active tool-policy provider", () => { | ||
| 516 | const staticPlugin = definePlugin(() => ({ | ||
| 517 | id: "toolkit-fixture" as const, | ||
| 518 | storage: () => ({}), | ||
| 519 | staticSources: () => [ | ||
| 520 | { | ||
| 521 | id: "toolkit-fixture.ctl", | ||
| 522 | kind: "control" as const, | ||
| 523 | name: "Toolkit Fixture", | ||
| 524 | tools: [ | ||
| 525 | tool({ | ||
| 526 | name: "allowed", | ||
| 527 | description: "allowed", | ||
| 528 | inputSchema: Schema.toStandardSchemaV1( | ||
| 529 | Schema.toStandardJSONSchemaV1(Schema.Struct({})), | ||
| 530 | ), | ||
| 531 | execute: () => Effect.succeed("allowed"), | ||
| 532 | }), | ||
| 533 | tool({ | ||
| 534 | name: "hidden", | ||
| 535 | description: "hidden", | ||
| 536 | inputSchema: Schema.toStandardSchemaV1( | ||
| 537 | Schema.toStandardJSONSchemaV1(Schema.Struct({})), | ||
| 538 | ), | ||
| 539 | execute: () => Effect.succeed("hidden"), | ||
| 540 | }), | ||
| 541 | ], | ||
| 542 | }, | ||
| 543 | ], | ||
| 544 | }))(); | ||
| 545 | |||
| 546 | const policyProviderPlugin = definePlugin(() => ({ | ||
| 547 | id: "toolkit-policy-provider" as const, | ||
| 548 | storage: () => ({}), | ||
| 549 | toolPolicyProvider: () => ({ | ||
| 550 | list: () => | ||
| 551 | Effect.succeed([ | ||
| 552 | { | ||
| 553 | id: "allow-static", | ||
| 554 | pattern: "toolkit-fixture.ctl.allowed", | ||
| 555 | action: "approve" as const, | ||
| 556 | position: "a0", | ||
| 557 | }, | ||
| 558 | ]), | ||
| 559 | }), | ||
| 560 | }))(); | ||
| 561 | |||
| 562 | it.effect("uses provider rules as an allowlist for list, schema, and execute", () => | ||
| 563 | Effect.gen(function* () { | ||
| 564 | const executor = yield* makeTestExecutor({ | ||
| 565 | plugins: [staticPlugin, policyProviderPlugin] as const, | ||
| 566 | }); | ||
| 567 | |||
| 568 | const tools = yield* executor.tools.list(); | ||
| 569 | expect(tools.map((t) => String(t.address)).sort()).toEqual(["toolkit-fixture.ctl.allowed"]); | ||
| 570 | |||
| 571 | const allowedSchema = yield* executor.tools.schema( | ||
| 572 | ToolAddress.make("toolkit-fixture.ctl.allowed"), | ||
| 573 | ); | ||
| 574 | expect(allowedSchema?.name).toBe("allowed"); | ||
| 575 | |||
| 576 | const hiddenSchema = yield* executor.tools.schema( | ||
| 577 | ToolAddress.make("toolkit-fixture.ctl.hidden"), | ||
| 578 | ); | ||
| 579 | expect(hiddenSchema).toBeNull(); | ||
| 580 | |||
| 581 | const allowed = yield* executor.execute(ToolAddress.make("toolkit-fixture.ctl.allowed"), {}); | ||
| 582 | expect(allowed).toBe("allowed"); | ||
| 583 | |||
| 584 | const blocked = yield* Effect.result( | ||
| 585 | executor.execute(ToolAddress.make("toolkit-fixture.ctl.hidden"), {}), | ||
| 586 | ); | ||
| 587 | expect(Result.isFailure(blocked)).toBe(true); | ||
| 588 | if (!Result.isFailure(blocked)) return; | ||
| 589 | expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true); | ||
| 590 | }), | ||
| 591 | ); | ||
| 592 | }); | ||
| 593 | |||
| 515 | describe("approve / require_approval interaction with annotations", () => { | 594 | describe("approve / require_approval interaction with annotations", () => { |
| 516 | it.effect("approve skips the elicitation prompt even when plugin requires approval", () => | 595 | it.effect("approve skips the elicitation prompt even when plugin requires approval", () => |
| 517 | Effect.gen(function* () { | 596 | Effect.gen(function* () { |
| @@ -20,6 +20,7 @@ import { · 19 unmodified lines | |||
| 20 | McpErrorReporterNoop, | 20 | McpErrorReporterNoop, |
| 21 | McpServingRoutes, | 21 | McpServingRoutes, |
| 22 | McpSessionStore, | 22 | McpSessionStore, |
| 23 | type McpResource, | ||
| 23 | type McpDispatchResult, | 24 | type McpDispatchResult, |
| 24 | type Principal, | 25 | type Principal, |
| 25 | } from "./index"; | 26 | } from "./index"; |
| @@ -133,3 +134,29 @@ describe("McpServingRoutes envelope", () => { · 107 unmodified lines | |||
| 133 | expect(captures[0]).toContain("induced defect"); | 134 | expect(captures[0]).toContain("induced defect"); |
| 134 | }); | 135 | }); |
| 135 | }); | 136 | }); |
| 137 | |||
| 138 | it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => { | ||
| 139 | const seen = await Effect.runPromise(Ref.make<McpResource | null>(null)); | ||
| 140 | const RecordingStoreLive = Layer.succeed(McpSessionStore)({ | ||
| 141 | dispatch: ({ resource }): Effect.Effect<McpDispatchResult> => | ||
| 142 | Ref.set(seen, resource).pipe( | ||
| 143 | Effect.as(new Response(JSON.stringify({ jsonrpc: "2.0", id: 1 }), { status: 200 })), | ||
| 144 | ), | ||
| 145 | dispose: () => Effect.void, | ||
| 146 | }); | ||
| 147 | |||
| 148 | const handler = buildHandler(RecordingStoreLive, McpErrorReporterNoop); | ||
| 149 | const response = await handler( | ||
| 150 | new Request("https://host.test/mcp/toolkits/deploy", { | ||
| 151 | method: "POST", | ||
| 152 | headers: { authorization: "Bearer x", "content-type": "application/json" }, | ||
| 153 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), | ||
| 154 | }), | ||
| 155 | ); | ||
| 156 | |||
| 157 | expect(response.status).toBe(200); | ||
| 158 | expect(await Effect.runPromise(Ref.get(seen))).toEqual({ | ||
| 159 | kind: "toolkit", | ||
| 160 | slug: "deploy", | ||
| 161 | }); | ||
| 162 | }); | ||
| @@ -2,11 +2,13 @@ import { Effect, Match, Predicate } from "effect"; · 1 unmodified line | |||
| 2 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | 2 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | defaultMcpResource, | ||
| 5 | McpAuthProvider, | 6 | McpAuthProvider, |
| 6 | McpErrorReporter, | 7 | McpErrorReporter, |
| 7 | McpSessionStore, | 8 | McpSessionStore, |
| 8 | type AuthOutcome, | 9 | type AuthOutcome, |
| 9 | type McpDispatchResult, | 10 | type McpDispatchResult, |
| 11 | type McpResource, | ||
| 10 | } from "./seams"; | 12 | } from "./seams"; |
| 11 | 13 | ||
| 12 | // --------------------------------------------------------------------------- | 14 | // --------------------------------------------------------------------------- |
| @@ -14,7 +16,8 @@ import { · 1 unmodified line | |||
| 14 | // | 16 | // |
| 15 | // Routes: | 17 | // Routes: |
| 16 | // GET <provider-declared discovery paths> -> McpAuthProvider metadata | 18 | // GET <provider-declared discovery paths> -> McpAuthProvider metadata |
| 17 | // * /mcp -> authenticate -> dispatch | 19 | // * /mcp -> authenticate -> dispatch(default) |
| 20 | // * /mcp/toolkits/:toolkitSlug -> authenticate -> dispatch(toolkit) | ||
| 18 | // | 21 | // |
| 19 | // The provider DECLARES the discovery paths it owns (at least the protected- | 22 | // The provider DECLARES the discovery paths it owns (at least the protected- |
| 20 | // resource metadata document) via `McpAuthProvider.discoveryRoutes`; the | 23 | // resource metadata document) via `McpAuthProvider.discoveryRoutes`; the |
| @@ -24,8 +27,8 @@ import { · 3 unmodified lines | |||
| 24 | // WorkOS, external). The envelope only needs the provider's discovery routes, | 27 | // WorkOS, external). The envelope only needs the provider's discovery routes, |
| 25 | // resource-metadata URL, and authenticate. | 28 | // resource-metadata URL, and authenticate. |
| 26 | // | 29 | // |
| 27 | // The envelope hard-codes ONLY the `/mcp` path and CORS. Everything else — | 30 | // The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else |
| 28 | // every `/.well-known/*` path, the resource-metadata URL, the authn/authz | 31 | // — every `/.well-known/*` path, the resource-metadata URL, the authn/authz |
| 29 | // semantics, and the entire session lifecycle (create + forward + ownership) — | 32 | // semantics, and the entire session lifecycle (create + forward + ownership) — |
| 30 | // comes from the two seams. | 33 | // comes from the two seams. |
| 31 | // | 34 | // |
| @@ -38,6 +41,7 @@ import { · 6 unmodified lines | |||
| 38 | // --------------------------------------------------------------------------- | 41 | // --------------------------------------------------------------------------- |
| 39 | 42 | ||
| 40 | const MCP_PATH = "/mcp"; | 43 | const MCP_PATH = "/mcp"; |
| 44 | const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug"; | ||
| 41 | 45 | ||
| 42 | /** The methods the streamable-HTTP transport accepts on `/mcp`. */ | 46 | /** The methods the streamable-HTTP transport accepts on `/mcp`. */ |
| 43 | const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); | 47 | const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); |
| @@ -170,69 +174,73 @@ const renderDispatchError = (lookup: "not-found" | "forbidden"): Response => · 126 unmodified lines | |||
| 170 | ? jsonRpcResponse(404, -32001, "Session not found") | 174 | ? jsonRpcResponse(404, -32001, "Session not found") |
| 171 | : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); | 175 | : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); |
| 172 | 176 | ||
| 173 | /** Dispatch a `/mcp` request through authenticate -> store.dispatch -> transport. */ | 177 | /** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */ |
| 174 | const mcpDispatch = Effect.gen(function* () { | 178 | const mcpDispatch = (resource: McpResource) => |
| 175 | const httpRequest = yield* HttpServerRequest.HttpServerRequest; | 179 | Effect.gen(function* () { |
| 176 | const auth = yield* McpAuthProvider; | 180 | const httpRequest = yield* HttpServerRequest.HttpServerRequest; |
| 177 | const store = yield* McpSessionStore; | 181 | const auth = yield* McpAuthProvider; |
| 178 | const request = yield* toWebRequest(httpRequest); | 182 | const store = yield* McpSessionStore; |
| 183 | const request = yield* toWebRequest(httpRequest); | ||
| 179 | 184 | ||
| 180 | // CORS preflight: answer before auth so unauthenticated clients can probe. | 185 | // CORS preflight: answer before auth so unauthenticated clients can probe. |
| 181 | if (request.method === "OPTIONS") { | 186 | if (request.method === "OPTIONS") { |
| 182 | return HttpServerResponse.raw(corsPreflightResponse()); | 187 | return HttpServerResponse.raw(corsPreflightResponse()); |
| 183 | } | 188 | } |
| 184 | 189 | ||
| 185 | // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other | 190 | // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other |
| 186 | // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch — | 191 | // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch — |
| 187 | // otherwise it would fall through and spin up a session engine for a method | 192 | // otherwise it would fall through and spin up a session engine for a method |
| 188 | // the transport can't serve. | 193 | // the transport can't serve. |
| 189 | if (!ALLOWED_MCP_METHODS.has(request.method)) { | 194 | if (!ALLOWED_MCP_METHODS.has(request.method)) { |
| 190 | return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed")); | 195 | return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed")); |
| 191 | } | 196 | } |
| 192 | 197 | ||
| 193 | const sessionId = request.headers.get("mcp-session-id"); | 198 | const sessionId = request.headers.get("mcp-session-id"); |
| 194 | 199 | ||
| 195 | // Authenticate (and, for session-aware providers, authorize) on EVERY | 200 | // Authenticate (and, for session-aware providers, authorize) on EVERY |
| 196 | // request. On a non-Authenticated outcome: | 201 | // request. On a non-Authenticated outcome: |
| 197 | // - Forbidden -> dispose the live session first (cloud tears down a DO | 202 | // - Forbidden -> dispose the live session first (cloud tears down a DO |
| 198 | // whose org access was revoked), then render the 403. The | 203 | // whose org access was revoked), then render the 403. The |
| 199 | // inbound request is forwarded so the store can propagate | 204 | // inbound request is forwarded so the store can propagate |
| 200 | // the request's W3C trace context onto the teardown RPC. | 205 | // the request's W3C trace context onto the teardown RPC. |
| 201 | // - other -> render directly. | 206 | // - other -> render directly. |
| 202 | const outcome = yield* auth.authenticate(request); | 207 | const outcome = yield* auth.authenticate(request); |
| 203 | if (!Predicate.isTagged(outcome, "Authenticated")) { | 208 | if (!Predicate.isTagged(outcome, "Authenticated")) { |
| 204 | if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { | 209 | if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { |
| 205 | yield* store.dispose(sessionId, request); | 210 | yield* store.dispose(sessionId, request); |
| 211 | } | ||
| 212 | return HttpServerResponse.raw(renderAuthError(auth, request, outcome)); | ||
| 206 | } | 213 | } |
| 207 | return HttpServerResponse.raw(renderAuthError(auth, request, outcome)); | 214 | const principal = outcome.principal; |
| 208 | } | ||
| 209 | const principal = outcome.principal; | ||
| 210 | 215 | ||
| 211 | // No session id: per the streamable-HTTP transport contract, only POST opens | 216 | // 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 | 217 | // 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 | 218 | // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up |
| 214 | // an engine for a bare GET/DELETE. | 219 | // an engine for a bare GET/DELETE. |
| 215 | if (!sessionId) { | 220 | if (!sessionId) { |
| 216 | if (request.method === "GET") { | 221 | if (request.method === "GET") { |
| 217 | return HttpServerResponse.raw( | 222 | return HttpServerResponse.raw( |
| 218 | jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"), | 223 | jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"), |
| 219 | ); | 224 | ); |
| 225 | } | ||
| 226 | if (request.method === "DELETE") { | ||
| 227 | return HttpServerResponse.raw( | ||
| 228 | new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }), | ||
| 229 | ); | ||
| 230 | } | ||
| 220 | } | 231 | } |
| 221 | if (request.method === "DELETE") { | ||
| 222 | return HttpServerResponse.raw( | ||
| 223 | new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }), | ||
| 224 | ); | ||
| 225 | } | ||
| 226 | } | ||
| 227 | 232 | ||
| 228 | const result: McpDispatchResult = yield* store.dispatch({ | 233 | const result: McpDispatchResult = yield* store.dispatch({ |
| 229 | request, | 234 | request, |
| 230 | principal, | 235 | principal, |
| 231 | sessionId, | 236 | resource, |
| 232 | method: request.method, | 237 | sessionId, |
| 238 | method: request.method, | ||
| 239 | }); | ||
| 240 | return HttpServerResponse.raw( | ||
| 241 | result instanceof Response ? result : renderDispatchError(result), | ||
| 242 | ); | ||
| 233 | }); | 243 | }); |
| 234 | return HttpServerResponse.raw(result instanceof Response ? result : renderDispatchError(result)); | ||
| 235 | }); | ||
| 236 | 244 | ||
| 237 | /** | 245 | /** |
| 238 | * The `/mcp` route. Wraps {@link mcpDispatch} in a top-level `catchCause`: a | 246 | * The `/mcp` route. Wraps {@link mcpDispatch} in a top-level `catchCause`: a |
| @@ -242,15 +250,22 @@ const mcpDispatch = Effect.gen(function* () { · 3 unmodified lines | |||
| 242 | * otherwise, since the envelope returns a `Response`) and rendered as a stable | 250 | * otherwise, since the envelope returns a `Response`) and rendered as a stable |
| 243 | * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body. | 251 | * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body. |
| 244 | */ | 252 | */ |
| 245 | const mcpRoute = mcpDispatch.pipe( | 253 | const mcpRoute = (resource: McpResource) => |
| 246 | Effect.catchCause((cause) => | 254 | mcpDispatch(resource).pipe( |
| 247 | Effect.gen(function* () { | 255 | Effect.catchCause((cause) => |
| 248 | const reporter = yield* McpErrorReporter; | 256 | Effect.gen(function* () { |
| 249 | yield* reporter.report(cause); | 257 | const reporter = yield* McpErrorReporter; |
| 250 | return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error")); | 258 | yield* reporter.report(cause); |
| 251 | }), | 259 | return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error")); |
| 252 | ), | 260 | }), |
| 253 | ); | 261 | ), |
| 262 | ); | ||
| 263 | |||
| 264 | const toolkitMcpRoute = Effect.gen(function* () { | ||
| 265 | const params = yield* HttpRouter.params; | ||
| 266 | const slug = params.toolkitSlug; | ||
| 267 | return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource); | ||
| 268 | }); | ||
| 254 | 269 | ||
| 255 | /** | 270 | /** |
| 256 | * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges | 271 | * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges |
| @@ -273,6 +288,7 @@ export const McpServingRoutes = HttpRouter.use((router) => · 16 unmodified lines | |||
| 273 | Effect.sync(() => HttpServerResponse.raw(corsPreflightResponse())), | 288 | Effect.sync(() => HttpServerResponse.raw(corsPreflightResponse())), |
| 274 | ); | 289 | ); |
| 275 | } | 290 | } |
| 276 | yield* router.add("*", MCP_PATH, mcpRoute); | 291 | yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource)); |
| 292 | yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute); | ||
| 277 | }), | 293 | }), |
| 278 | ); | 294 | ); |
| @@ -17,10 +17,13 @@ import { · 16 unmodified lines | |||
| 17 | import { jsonRpcErrorBody } from "./envelope"; | 17 | import { jsonRpcErrorBody } from "./envelope"; |
| 18 | import { | 18 | import { |
| 19 | McpSessionStore, | 19 | McpSessionStore, |
| 20 | defaultMcpResource, | ||
| 21 | mcpResourceKey, | ||
| 20 | principalOwns, | 22 | principalOwns, |
| 21 | type McpDispatchInput, | 23 | type McpDispatchInput, |
| 22 | type McpDispatchResult, | 24 | type McpDispatchResult, |
| 23 | type Principal, | 25 | type Principal, |
| 26 | type McpResource, | ||
| 24 | } from "./seams"; | 27 | } from "./seams"; |
| 25 | import type { BrowserApprovalStore } from "./tool-server"; | 28 | import type { BrowserApprovalStore } from "./tool-server"; |
| 26 | 29 | ||
| @@ -61,6 +64,7 @@ export interface BuiltMcpServer { · 34 unmodified lines | |||
| 61 | 64 | ||
| 62 | /** The browser-mode wiring the store hands a build call when a session opts in. */ | 65 | /** The browser-mode wiring the store hands a build call when a session opts in. */ |
| 63 | export interface McpBuildServerOptions { | 66 | export interface McpBuildServerOptions { |
| 67 | readonly resource?: McpResource; | ||
| 64 | readonly elicitationMode?: | 68 | readonly elicitationMode?: |
| 65 | | { readonly mode: "browser"; readonly approvalUrl: (executionId: string) => string } | 69 | | { readonly mode: "browser"; readonly approvalUrl: (executionId: string) => string } |
| 66 | | { readonly mode: "model" } | 70 | | { readonly mode: "model" } |
| @@ -120,6 +124,19 @@ const json = (value: unknown, status = 200): Response => · 53 unmodified lines | |||
| 120 | const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; | 124 | const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; |
| 121 | const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; | 125 | const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; |
| 122 | 126 | ||
| 127 | interface SessionOwner { | ||
| 128 | readonly principal: Principal; | ||
| 129 | readonly resource: McpResource; | ||
| 130 | } | ||
| 131 | |||
| 132 | const sessionOwnerMatches = ( | ||
| 133 | owner: SessionOwner, | ||
| 134 | principal: Principal, | ||
| 135 | resource: McpResource, | ||
| 136 | ): boolean => | ||
| 137 | principalOwns(owner.principal, principal) && | ||
| 138 | mcpResourceKey(owner.resource) === mcpResourceKey(resource); | ||
| 139 | |||
| 123 | /** | 140 | /** |
| 124 | * Build the in-process session store plus an explicit `close()` that disposes | 141 | * Build the in-process session store plus an explicit `close()` that disposes |
| 125 | * all live sessions. `close()` is not part of the seam — it is the host lifetime | 142 | * all live sessions. `close()` is not part of the seam — it is the host lifetime |
| @@ -137,7 +154,7 @@ export const makeInMemoryMcpSessionStore = ( · 11 unmodified lines | |||
| 137 | ): InMemoryMcpSessionStore => { | 154 | ): InMemoryMcpSessionStore => { |
| 138 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); | 155 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); |
| 139 | const servers = new Map<string, McpServer>(); | 156 | const servers = new Map<string, McpServer>(); |
| 140 | const owners = new Map<string, Principal>(); | 157 | const owners = new Map<string, SessionOwner>(); |
| 141 | const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>(); | 158 | const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>(); |
| 142 | const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore(); | 159 | const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore(); |
| 143 | 160 | ||
| @@ -181,12 +198,13 @@ export const makeInMemoryMcpSessionStore = ( · 37 unmodified lines | |||
| 181 | const forward = ( | 198 | const forward = ( |
| 182 | sessionId: string, | 199 | sessionId: string, |
| 183 | principal: Principal, | 200 | principal: Principal, |
| 201 | resource: McpResource, | ||
| 184 | request: Request, | 202 | request: Request, |
| 185 | ): Effect.Effect<McpDispatchResult> => { | 203 | ): Effect.Effect<McpDispatchResult> => { |
| 186 | const transport = transports.get(sessionId); | 204 | const transport = transports.get(sessionId); |
| 187 | const owner = owners.get(sessionId); | 205 | const owner = owners.get(sessionId); |
| 188 | if (!transport || !owner) return Effect.succeed("not-found"); | 206 | if (!transport || !owner) return Effect.succeed("not-found"); |
| 189 | if (!principalOwns(owner, principal)) return Effect.succeed("forbidden"); | 207 | if (!sessionOwnerMatches(owner, principal, resource)) return Effect.succeed("forbidden"); |
| 190 | return runHandleRequest(transport, request); | 208 | return runHandleRequest(transport, request); |
| 191 | }; | 209 | }; |
| 192 | 210 | ||
| @@ -218,12 +236,16 @@ export const makeInMemoryMcpSessionStore = ( · 25 unmodified lines | |||
| 218 | }; | 236 | }; |
| 219 | 237 | ||
| 220 | /** Open a new session: build the server, connect a transport, drive the request. */ | 238 | /** Open a new session: build the server, connect a transport, drive the request. */ |
| 221 | const create = (principal: Principal, request: Request): Effect.Effect<McpDispatchResult> => { | 239 | const create = ( |
| 240 | principal: Principal, | ||
| 241 | resource: McpResource, | ||
| 242 | request: Request, | ||
| 243 | ): Effect.Effect<McpDispatchResult> => { | ||
| 222 | let createdSessionId: string | null = null; | 244 | let createdSessionId: string | null = null; |
| 223 | return buildServer( | 245 | return buildServer(principal, { |
| 224 | principal, | 246 | ...buildOptionsFor(request, () => createdSessionId), |
| 225 | buildOptionsFor(request, () => createdSessionId), | 247 | resource, |
| 226 | ).pipe( | 248 | }).pipe( |
| 227 | Effect.flatMap(({ mcpServer, engine }) => | 249 | Effect.flatMap(({ mcpServer, engine }) => |
| 228 | Effect.gen(function* () { | 250 | Effect.gen(function* () { |
| 229 | const transport = new WebStandardStreamableHTTPServerTransport({ | 251 | const transport = new WebStandardStreamableHTTPServerTransport({ |
| @@ -233,7 +255,7 @@ export const makeInMemoryMcpSessionStore = ( · 3 unmodified lines | |||
| 233 | createdSessionId = sid; | 255 | createdSessionId = sid; |
| 234 | transports.set(sid, transport); | 256 | transports.set(sid, transport); |
| 235 | servers.set(sid, mcpServer); | 257 | servers.set(sid, mcpServer); |
| 236 | owners.set(sid, principal); | 258 | owners.set(sid, { principal, resource }); |
| 237 | engines.set(sid, engine); | 259 | engines.set(sid, engine); |
| 238 | }, | 260 | }, |
| 239 | onsessionclosed: (sid) => void dispose(sid, { server: true }), | 261 | onsessionclosed: (sid) => void dispose(sid, { server: true }), |
| @@ -259,8 +281,10 @@ export const makeInMemoryMcpSessionStore = ( · 19 unmodified lines | |||
| 259 | }; | 281 | }; |
| 260 | 282 | ||
| 261 | const store: McpSessionStore["Service"] = { | 283 | const store: McpSessionStore["Service"] = { |
| 262 | dispatch: ({ request, principal, sessionId }: McpDispatchInput) => | 284 | dispatch: ({ request, principal, resource, sessionId }: McpDispatchInput) => |
| 263 | sessionId ? forward(sessionId, principal, request) : create(principal, request), | 285 | sessionId |
| 286 | ? forward(sessionId, principal, resource, request) | ||
| 287 | : create(principal, resource ?? defaultMcpResource, request), | ||
| 264 | dispose: (sessionId) => | 288 | dispose: (sessionId) => |
| 265 | Effect.promise(() => dispose(sessionId, { transport: true, server: true })), | 289 | Effect.promise(() => dispose(sessionId, { transport: true, server: true })), |
| 266 | }; | 290 | }; |
| @@ -271,7 +295,7 @@ export const makeInMemoryMcpSessionStore = ( · 4 unmodified lines | |||
| 271 | ): "allowed" | "not-found" | "forbidden" => { | 295 | ): "allowed" | "not-found" | "forbidden" => { |
| 272 | const owner = owners.get(sessionId); | 296 | const owner = owners.get(sessionId); |
| 273 | if (!owner) return "not-found"; | 297 | if (!owner) return "not-found"; |
| 274 | if (principal && !principalOwns(owner, principal)) return "forbidden"; | 298 | if (principal && !principalOwns(owner.principal, principal)) return "forbidden"; |
| 275 | return "allowed"; | 299 | return "allowed"; |
| 276 | }; | 300 | }; |
| 277 | 301 | ||
| @@ -18,6 +18,8 @@ export { · 17 unmodified lines | |||
| 18 | McpSessionStore, | 18 | McpSessionStore, |
| 19 | McpErrorReporter, | 19 | McpErrorReporter, |
| 20 | McpErrorReporterNoop, | 20 | McpErrorReporterNoop, |
| 21 | defaultMcpResource, | ||
| 22 | mcpResourceKey, | ||
| 21 | principalOwns, | 23 | principalOwns, |
| 22 | authenticated, | 24 | authenticated, |
| 23 | unauthorized, | 25 | unauthorized, |
| @@ -31,6 +33,7 @@ export { · 7 unmodified lines | |||
| 31 | type McpDiscoveryRoute, | 33 | type McpDiscoveryRoute, |
| 32 | type McpDispatchInput, | 34 | type McpDispatchInput, |
| 33 | type McpDispatchResult, | 35 | type McpDispatchResult, |
| 36 | type McpResource, | ||
| 34 | } from "./seams"; | 37 | } from "./seams"; |
| 35 | 38 | ||
| 36 | export { McpServingRoutes, jsonRpcErrorBody } from "./envelope"; | 39 | export { McpServingRoutes, jsonRpcErrorBody } from "./envelope"; |
| @@ -56,6 +56,25 @@ export type Principal = Schema.Schema.Type<typeof Principal>; · 55 unmodified lines | |||
| 56 | export const principalOwns = (owner: Principal, principal: Principal): boolean => | 56 | export const principalOwns = (owner: Principal, principal: Principal): boolean => |
| 57 | owner.accountId === principal.accountId && owner.organizationId === principal.organizationId; | 57 | owner.accountId === principal.accountId && owner.organizationId === principal.organizationId; |
| 58 | 58 | ||
| 59 | // --------------------------------------------------------------------------- | ||
| 60 | // Shared MCP resource identity. | ||
| 61 | // | ||
| 62 | // The default `/mcp` endpoint and named sub-resources such as | ||
| 63 | // `/mcp/toolkits/<slug>` share auth and transport machinery, but a serving | ||
| 64 | // session belongs to the exact resource path that minted it. This keeps a | ||
| 65 | // leaked/reused `mcp-session-id` from crossing from one exposed capability set | ||
| 66 | // into another. | ||
| 67 | // --------------------------------------------------------------------------- | ||
| 68 | |||
| 69 | export type McpResource = | ||
| 70 | | { readonly kind: "default" } | ||
| 71 | | { readonly kind: "toolkit"; readonly slug: string }; | ||
| 72 | |||
| 73 | export const defaultMcpResource: McpResource = { kind: "default" }; | ||
| 74 | |||
| 75 | export const mcpResourceKey = (resource: McpResource): string => | ||
| 76 | resource.kind === "default" ? "default" : `toolkit:${resource.slug}`; | ||
| 77 | |||
| 59 | // --------------------------------------------------------------------------- | 78 | // --------------------------------------------------------------------------- |
| 60 | // AuthOutcome — the result of `McpAuthProvider.authenticate`. | 79 | // AuthOutcome — the result of `McpAuthProvider.authenticate`. |
| 61 | // | 80 | // |
| @@ -210,6 +229,7 @@ export class McpAuthProvider extends Context.Service< · 148 unmodified lines | |||
| 210 | export interface McpDispatchInput { | 229 | export interface McpDispatchInput { |
| 211 | readonly request: Request; | 230 | readonly request: Request; |
| 212 | readonly principal: Principal; | 231 | readonly principal: Principal; |
| 232 | readonly resource: McpResource; | ||
| 213 | readonly sessionId: string | null; | 233 | readonly sessionId: string | null; |
| 214 | readonly method: string; | 234 | readonly method: string; |
| 215 | } | 235 | } |
| @@ -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,18 @@ | |||
| 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 | nav: { label: "Toolkits" }, | ||
| 12 | }, | ||
| 13 | { | ||
| 14 | path: "/$toolkitSlug", | ||
| 15 | component: ToolkitsPage, | ||
| 16 | }, | ||
| 17 | ], | ||
| 18 | }); | ||
| @@ -0,0 +1,1189 @@ | |||
| 1 | import { useId, useMemo, useState } from "react"; | ||
| 2 | import { Link, useNavigate, useParams } 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 { | ||
| 7 | ArrowLeftIcon, | ||
| 8 | BoxIcon, | ||
| 9 | CheckIcon, | ||
| 10 | PlugIcon, | ||
| 11 | PlusIcon, | ||
| 12 | SearchIcon, | ||
| 13 | Trash2Icon, | ||
| 14 | } from "lucide-react"; | ||
| 15 | import { | ||
| 16 | createPluginAtomClient, | ||
| 17 | useIntegrationPlugins, | ||
| 18 | type IntegrationPlugin, | ||
| 19 | type PluginPageProps, | ||
| 20 | } from "@executor-js/sdk/client"; | ||
| 21 | import { | ||
| 22 | matchPattern, | ||
| 23 | type EffectivePolicy, | ||
| 24 | type Integration, | ||
| 25 | type Owner, | ||
| 26 | type ToolAddress, | ||
| 27 | type ToolPolicyAction, | ||
| 28 | } from "@executor-js/sdk/shared"; | ||
| 29 | import { integrationsOptimisticAtom, toolsAllAtom } from "@executor-js/react/api/atoms"; | ||
| 30 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; | ||
| 31 | import { | ||
| 32 | getExecutorApiBaseUrl, | ||
| 33 | getExecutorOrganizationHeaders, | ||
| 34 | getExecutorServerAuthorizationHeader, | ||
| 35 | } from "@executor-js/react/api/server-connection"; | ||
| 36 | import { ownerLabel } from "@executor-js/react/api/owner-display"; | ||
| 37 | import { Badge } from "@executor-js/react/components/badge"; | ||
| 38 | import { Button } from "@executor-js/react/components/button"; | ||
| 39 | import { CopyButton } from "@executor-js/react/components/copy-button"; | ||
| 40 | import { | ||
| 41 | IntegrationFavicon, | ||
| 42 | integrationPresetIconUrl, | ||
| 43 | } from "@executor-js/react/components/integration-favicon"; | ||
| 44 | import { | ||
| 45 | Dialog, | ||
| 46 | DialogContent, | ||
| 47 | DialogDescription, | ||
| 48 | DialogFooter, | ||
| 49 | DialogHeader, | ||
| 50 | DialogTitle, | ||
| 51 | } from "@executor-js/react/components/dialog"; | ||
| 52 | import { Input } from "@executor-js/react/components/input"; | ||
| 53 | import { Label } from "@executor-js/react/components/label"; | ||
| 54 | import { Skeleton } from "@executor-js/react/components/skeleton"; | ||
| 55 | import { ToolDetail, ToolDetailEmpty } from "@executor-js/react/components/tool-detail"; | ||
| 56 | import { ToolTree, type ToolSummary } from "@executor-js/react/components/tool-tree"; | ||
| 57 | import { cn } from "@executor-js/react/lib/utils"; | ||
| 58 | |||
| 59 | import { | ||
| 60 | ToolkitsApi, | ||
| 61 | type ToolkitConnectionResponse, | ||
| 62 | type ToolkitPolicyResponse, | ||
| 63 | type ToolkitResponse, | ||
| 64 | } from "./shared"; | ||
| 65 | |||
| 66 | const ToolkitsClient = createPluginAtomClient(ToolkitsApi, { | ||
| 67 | baseUrl: getExecutorApiBaseUrl, | ||
| 68 | authorizationHeader: getExecutorServerAuthorizationHeader, | ||
| 69 | headers: getExecutorOrganizationHeaders, | ||
| 70 | }); | ||
| 71 | |||
| 72 | const toolkitWriteKeys = [ | ||
| 73 | ReactivityKey.connections, | ||
| 74 | ReactivityKey.policies, | ||
| 75 | ReactivityKey.tools, | ||
| 76 | ] as const; | ||
| 77 | |||
| 78 | const toolkitsAtom = ToolkitsClient.query("toolkits", "list", { | ||
| 79 | timeToLive: "30 seconds", | ||
| 80 | reactivityKeys: [ReactivityKey.policies], | ||
| 81 | }); | ||
| 82 | |||
| 83 | const toolkitPoliciesAtom = Atom.family((toolkitId: string) => | ||
| 84 | ToolkitsClient.query("toolkits", "listPolicies", { | ||
| 85 | params: { toolkitId }, | ||
| 86 | timeToLive: "30 seconds", | ||
| 87 | reactivityKeys: [ReactivityKey.policies], | ||
| 88 | }), | ||
| 89 | ); | ||
| 90 | |||
| 91 | const toolkitConnectionsAtom = Atom.family((toolkitId: string) => | ||
| 92 | ToolkitsClient.query("toolkits", "listConnections", { | ||
| 93 | params: { toolkitId }, | ||
| 94 | timeToLive: "30 seconds", | ||
| 95 | reactivityKeys: [ReactivityKey.connections], | ||
| 96 | }), | ||
| 97 | ); | ||
| 98 | |||
| 99 | const createToolkit = ToolkitsClient.mutation("toolkits", "create"); | ||
| 100 | const removeToolkit = ToolkitsClient.mutation("toolkits", "remove"); | ||
| 101 | const createToolkitPolicy = ToolkitsClient.mutation("toolkits", "createPolicy"); | ||
| 102 | const updateToolkitPolicy = ToolkitsClient.mutation("toolkits", "updatePolicy"); | ||
| 103 | const removeToolkitPolicy = ToolkitsClient.mutation("toolkits", "removePolicy"); | ||
| 104 | const createToolkitConnection = ToolkitsClient.mutation("toolkits", "createConnection"); | ||
| 105 | |||
| 106 | type ToolRow = { | ||
| 107 | readonly address: ToolAddress; | ||
| 108 | readonly integration: string; | ||
| 109 | readonly owner?: Owner; | ||
| 110 | readonly connection?: string; | ||
| 111 | readonly name: string; | ||
| 112 | readonly description?: string; | ||
| 113 | readonly requiresApproval?: boolean; | ||
| 114 | readonly static?: boolean; | ||
| 115 | }; | ||
| 116 | |||
| 117 | const comparePolicy = (a: ToolkitPolicyResponse, b: ToolkitPolicyResponse): number => { | ||
| 118 | if (a.position < b.position) return -1; | ||
| 119 | if (a.position > b.position) return 1; | ||
| 120 | return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; | ||
| 121 | }; | ||
| 122 | |||
| 123 | const pluginDefaultPolicy = (requiresApproval: boolean | undefined): EffectivePolicy => | ||
| 124 | requiresApproval | ||
| 125 | ? { action: "require_approval", source: "plugin-default" } | ||
| 126 | : { action: "approve", source: "plugin-default" }; | ||
| 127 | |||
| 128 | const isLegacyConnectionPolicy = (policy: ToolkitPolicyResponse): boolean => { | ||
| 129 | if (policy.action !== "approve") return false; | ||
| 130 | const parts = policy.pattern.split("."); | ||
| 131 | return parts.at(-1) === "*" && (parts.length === 3 || parts.length === 4); | ||
| 132 | }; | ||
| 133 | |||
| 134 | const resolveToolkitPolicy = ( | ||
| 135 | matchId: string, | ||
| 136 | policies: readonly ToolkitPolicyResponse[], | ||
| 137 | requiresApproval?: boolean, | ||
| 138 | ): EffectivePolicy => { | ||
| 139 | for (const policy of [...policies].sort(comparePolicy)) { | ||
| 140 | if (!matchPattern(policy.pattern, matchId)) continue; | ||
| 141 | return { | ||
| 142 | action: policy.action, | ||
| 143 | source: "user", | ||
| 144 | pattern: policy.pattern, | ||
| 145 | policyId: policy.id, | ||
| 146 | }; | ||
| 147 | } | ||
| 148 | return pluginDefaultPolicy(requiresApproval); | ||
| 149 | }; | ||
| 150 | |||
| 151 | const toolMatchId = (tool: ToolRow): string => | ||
| 152 | tool.static ? String(tool.address) : String(tool.address).replace(/^tools\./, ""); | ||
| 153 | |||
| 154 | const toolCanAppearInToolkit = (toolkit: ToolkitResponse, tool: ToolRow): boolean => | ||
| 155 | toolkit.owner === "user" || tool.static === true || tool.owner !== "user"; | ||
| 156 | |||
| 157 | const toolkitUrlFor = (orgSlug: string | undefined, slug: string): string => { | ||
| 158 | const path = orgSlug ? `/${orgSlug}/mcp/toolkits/${slug}` : `/mcp/toolkits/${slug}`; | ||
| 159 | if (typeof window === "undefined") return path; | ||
| 160 | return `${window.location.origin}${path}`; | ||
| 161 | }; | ||
| 162 | |||
| 163 | const identityPattern = (displayPattern: string): string => displayPattern; | ||
| 164 | |||
| 165 | const formatUpdatedAt = (value: number): string => | ||
| 166 | new Intl.DateTimeFormat(undefined, { | ||
| 167 | month: "short", | ||
| 168 | day: "numeric", | ||
| 169 | hour: "numeric", | ||
| 170 | minute: "2-digit", | ||
| 171 | }).format(new Date(value)); | ||
| 172 | |||
| 173 | const toolkitScopeLabel = (toolkit: ToolkitResponse): string => | ||
| 174 | toolkit.owner === "org" ? "Workspace tools" : "Workspace + personal tools"; | ||
| 175 | |||
| 176 | const compareToolkitRows = (a: ToolkitResponse, b: ToolkitResponse): number => { | ||
| 177 | if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt; | ||
| 178 | return a.name.localeCompare(b.name); | ||
| 179 | }; | ||
| 180 | |||
| 181 | const toolkitCardStyle = { minHeight: "9rem" }; | ||
| 182 | const toolkitShelfStyle = { minHeight: "28.5rem" }; | ||
| 183 | const toolkitGridContainerStyle = { maxWidth: "80rem" }; | ||
| 184 | const toolkitToolTreeStyle = { width: "24rem" }; | ||
| 185 | |||
| 186 | const ownerAccentClass = (owner: Owner): string => | ||
| 187 | owner === "org" | ||
| 188 | ? "border-sky-500/25 bg-sky-500/10 text-sky-700 dark:text-sky-300" | ||
| 189 | : "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; | ||
| 190 | |||
| 191 | type ToolkitConnectionGroup = { | ||
| 192 | readonly id: string; | ||
| 193 | readonly owner: Owner; | ||
| 194 | readonly integration: string; | ||
| 195 | readonly connection: string; | ||
| 196 | readonly patterns: readonly string[]; | ||
| 197 | readonly tools: readonly ToolRow[]; | ||
| 198 | }; | ||
| 199 | |||
| 200 | type IntegrationMeta = { | ||
| 201 | readonly name: string; | ||
| 202 | readonly sourceId: string; | ||
| 203 | readonly icon?: string | null; | ||
| 204 | readonly url?: string; | ||
| 205 | }; | ||
| 206 | |||
| 207 | const toolOwner = (tool: ToolRow): Owner => (tool.static ? "org" : (tool.owner ?? "org")); | ||
| 208 | |||
| 209 | const toolConnectionName = (tool: ToolRow): string => { | ||
| 210 | if (tool.connection && tool.connection.length > 0) return tool.connection; | ||
| 211 | return tool.static ? "built-in" : "default"; | ||
| 212 | }; | ||
| 213 | |||
| 214 | const policyPrefixForTool = (tool: ToolRow): string => { | ||
| 215 | const matchId = toolMatchId(tool); | ||
| 216 | const integration = String(tool.integration); | ||
| 217 | if (tool.connection && tool.connection.length > 0) { | ||
| 218 | const connection = String(tool.connection); | ||
| 219 | if (tool.owner) { | ||
| 220 | const ownerConnectionPrefix = `${integration}.${tool.owner}.${connection}`; | ||
| 221 | if (matchId === ownerConnectionPrefix || matchId.startsWith(`${ownerConnectionPrefix}.`)) { | ||
| 222 | return ownerConnectionPrefix; | ||
| 223 | } | ||
| 224 | } | ||
| 225 | const connectionPrefix = `${integration}.${connection}`; | ||
| 226 | if (matchId === connectionPrefix || matchId.startsWith(`${connectionPrefix}.`)) { | ||
| 227 | return connectionPrefix; | ||
| 228 | } | ||
| 229 | } | ||
| 230 | const name = String(tool.name); | ||
| 231 | if (name.length > 0 && matchId.endsWith(`.${name}`)) { | ||
| 232 | return matchId.slice(0, -name.length - 1); | ||
| 233 | } | ||
| 234 | const segments = matchId.split("."); | ||
| 235 | return segments.length > 1 ? segments.slice(0, -1).join(".") : matchId; | ||
| 236 | }; | ||
| 237 | |||
| 238 | const connectionPatternForTool = (tool: ToolRow): string => `${policyPrefixForTool(tool)}.*`; | ||
| 239 | |||
| 240 | const compareConnectionGroups = (a: ToolkitConnectionGroup, b: ToolkitConnectionGroup): number => { | ||
| 241 | const ownerRank = (owner: Owner) => (owner === "org" ? 0 : 1); | ||
| 242 | return ( | ||
| 243 | ownerRank(a.owner) - ownerRank(b.owner) || | ||
| 244 | a.integration.localeCompare(b.integration) || | ||
| 245 | a.connection.localeCompare(b.connection) || | ||
| 246 | a.id.localeCompare(b.id) | ||
| 247 | ); | ||
| 248 | }; | ||
| 249 | |||
| 250 | const patternSubsumes = (candidate: string, covered: string): boolean => { | ||
| 251 | if (candidate === covered) return true; | ||
| 252 | if (!candidate.endsWith(".*")) return false; | ||
| 253 | const prefix = candidate.slice(0, -1); | ||
| 254 | return covered.startsWith(prefix); | ||
| 255 | }; | ||
| 256 | |||
| 257 | const reducePolicyPatterns = (patterns: readonly string[]): readonly string[] => { | ||
| 258 | const unique = [...new Set(patterns)].sort((a, b) => a.localeCompare(b)); | ||
| 259 | return unique.filter( | ||
| 260 | (pattern) => | ||
| 261 | !unique.some((candidate) => candidate !== pattern && patternSubsumes(candidate, pattern)), | ||
| 262 | ); | ||
| 263 | }; | ||
| 264 | |||
| 265 | const buildConnectionGroups = (tools: readonly ToolRow[]): readonly ToolkitConnectionGroup[] => { | ||
| 266 | const groups = new Map<string, ToolkitConnectionGroup & { tools: ToolRow[] }>(); | ||
| 267 | for (const tool of tools) { | ||
| 268 | const owner = toolOwner(tool); | ||
| 269 | const key = `${owner}:${tool.integration}:${toolConnectionName(tool)}`; | ||
| 270 | const existing = groups.get(key); | ||
| 271 | if (existing) { | ||
| 272 | existing.tools.push(tool); | ||
| 273 | continue; | ||
| 274 | } | ||
| 275 | groups.set(key, { | ||
| 276 | id: key, | ||
| 277 | owner, | ||
| 278 | integration: String(tool.integration), | ||
| 279 | connection: toolConnectionName(tool), | ||
| 280 | patterns: [], | ||
| 281 | tools: [tool], | ||
| 282 | }); | ||
| 283 | } | ||
| 284 | return [...groups.values()] | ||
| 285 | .map((group) => { | ||
| 286 | const sortedTools = [...group.tools].sort(compareTools); | ||
| 287 | return { | ||
| 288 | ...group, | ||
| 289 | patterns: reducePolicyPatterns(sortedTools.map(connectionPatternForTool)), | ||
| 290 | tools: sortedTools, | ||
| 291 | }; | ||
| 292 | }) | ||
| 293 | .sort(compareConnectionGroups); | ||
| 294 | }; | ||
| 295 | |||
| 296 | const compareTools = (a: ToolRow, b: ToolRow): number => | ||
| 297 | String(a.name).localeCompare(String(b.name)) || toolMatchId(a).localeCompare(toolMatchId(b)); | ||
| 298 | |||
| 299 | const connectionTitle = (group: ToolkitConnectionGroup): string => | ||
| 300 | `${group.integration} / ${group.connection}`; | ||
| 301 | |||
| 302 | const connectionDisplayTitle = (group: ToolkitConnectionGroup, meta: IntegrationMeta): string => | ||
| 303 | group.connection === "built-in" || group.connection === "default" ? meta.name : group.connection; | ||
| 304 | |||
| 305 | const connectionDisplaySubtitle = (group: ToolkitConnectionGroup, meta: IntegrationMeta): string => | ||
| 306 | group.connection === "built-in" || group.connection === "default" | ||
| 307 | ? `${group.tools.length} ${group.tools.length === 1 ? "tool" : "tools"}` | ||
| 308 | : `${meta.name} · ${group.tools.length} ${group.tools.length === 1 ? "tool" : "tools"}`; | ||
| 309 | |||
| 310 | const integrationMetaFor = ( | ||
| 311 | group: ToolkitConnectionGroup, | ||
| 312 | integrations: readonly Integration[], | ||
| 313 | integrationPlugins: readonly IntegrationPlugin[], | ||
| 314 | ): IntegrationMeta => { | ||
| 315 | const integration = integrations.find((row) => String(row.slug) === group.integration); | ||
| 316 | const fallbackName = group.integration | ||
| 317 | .replace(/[_-]/g, " ") | ||
| 318 | .replace(/\b\w/g, (character) => character.toUpperCase()); | ||
| 319 | const source = { | ||
| 320 | id: group.integration, | ||
| 321 | kind: integration?.kind ?? group.integration, | ||
| 322 | name: integration?.name ?? fallbackName, | ||
| 323 | url: integration?.displayUrl, | ||
| 324 | }; | ||
| 325 | return { | ||
| 326 | name: source.name, | ||
| 327 | sourceId: group.integration, | ||
| 328 | icon: integrationPresetIconUrl(source, integrationPlugins), | ||
| 329 | ...(source.url ? { url: source.url } : {}), | ||
| 330 | }; | ||
| 331 | }; | ||
| 332 | |||
| 333 | const legacyConnectionPolicyIds = ( | ||
| 334 | policies: readonly ToolkitPolicyResponse[], | ||
| 335 | connectionGroups: readonly ToolkitConnectionGroup[], | ||
| 336 | connections: readonly ToolkitConnectionResponse[], | ||
| 337 | ): ReadonlySet<string> => { | ||
| 338 | const persistedPatterns = new Set(connections.map((connection) => connection.pattern)); | ||
| 339 | const connectionPatterns = new Set(connectionGroups.flatMap((group) => group.patterns)); | ||
| 340 | return new Set( | ||
| 341 | policies | ||
| 342 | .filter( | ||
| 343 | (policy) => | ||
| 344 | isLegacyConnectionPolicy(policy) && | ||
| 345 | connectionPatterns.has(policy.pattern) && | ||
| 346 | !persistedPatterns.has(policy.pattern), | ||
| 347 | ) | ||
| 348 | .map((policy) => policy.id), | ||
| 349 | ); | ||
| 350 | }; | ||
| 351 | |||
| 352 | const configuredConnectionPatterns = ( | ||
| 353 | connections: readonly ToolkitConnectionResponse[], | ||
| 354 | policies: readonly ToolkitPolicyResponse[], | ||
| 355 | legacyPolicyIds: ReadonlySet<string>, | ||
| 356 | ): ReadonlySet<string> => | ||
| 357 | new Set([ | ||
| 358 | ...connections.map((connection) => connection.pattern), | ||
| 359 | ...policies.filter((policy) => legacyPolicyIds.has(policy.id)).map((policy) => policy.pattern), | ||
| 360 | ]); | ||
| 361 | |||
| 362 | function ToolkitTile(props: { pluginId: string; toolkit: ToolkitResponse }) { | ||
| 363 | const toolkit = props.toolkit; | ||
| 364 | const scope = toolkitScopeLabel(toolkit); | ||
| 365 | return ( | ||
| 366 | <Link | ||
| 367 | to="/{-$orgSlug}/plugins/$pluginId/$" | ||
| 368 | params={{ pluginId: props.pluginId, _splat: toolkit.slug }} | ||
| 369 | aria-label={`Open toolkit ${toolkit.name}`} | ||
| 370 | 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" | ||
| 371 | style={toolkitCardStyle} | ||
| 372 | > | ||
| 373 | <div className="flex min-w-0 items-start gap-3"> | ||
| 374 | <div | ||
| 375 | className={cn( | ||
| 376 | "flex size-9 shrink-0 items-center justify-center rounded-md border", | ||
| 377 | ownerAccentClass(toolkit.owner), | ||
| 378 | )} | ||
| 379 | > | ||
| 380 | <BoxIcon className="size-4" /> | ||
| 381 | </div> | ||
| 382 | <div className="min-w-0 flex-1"> | ||
| 383 | <div className="truncate text-sm font-semibold text-foreground">{toolkit.name}</div> | ||
| 384 | <div className="mt-1 truncate font-mono text-[11px] text-muted-foreground"> | ||
| 385 | {toolkit.slug} | ||
| 386 | </div> | ||
| 387 | </div> | ||
| 388 | </div> | ||
| 389 | |||
| 390 | <div className="mt-5 min-w-0 space-y-3"> | ||
| 391 | <code className="block truncate rounded-md border border-border/60 bg-muted/30 px-2 py-1.5 font-mono text-[11px] text-muted-foreground transition-colors group-hover:border-border"> | ||
| 392 | /mcp/toolkits/{toolkit.slug} | ||
| 393 | </code> | ||
| 394 | <div className="flex min-w-0 items-center justify-between gap-3 text-xs text-muted-foreground"> | ||
| 395 | <span className="truncate">{scope}</span> | ||
| 396 | <span className="shrink-0 tabular-nums">{formatUpdatedAt(toolkit.updatedAt)}</span> | ||
| 397 | </div> | ||
| 398 | </div> | ||
| 399 | </Link> | ||
| 400 | ); | ||
| 401 | } | ||
| 402 | |||
| 403 | const addToolkitScopeLabel = (owner: Owner): string => (owner === "org" ? "workspace" : "personal"); | ||
| 404 | const addToolkitTitle = (owner: Owner): string => | ||
| 405 | owner === "org" ? "New workspace toolkit" : "New personal toolkit"; | ||
| 406 | |||
| 407 | function CreateToolkitDialog(props: { | ||
| 408 | owner: Owner; | ||
| 409 | open: boolean; | ||
| 410 | onOpenChange: (open: boolean) => void; | ||
| 411 | onCreate: (input: { owner: Owner; name: string }) => Promise<void>; | ||
| 412 | }) { | ||
| 413 | const inputId = useId(); | ||
| 414 | const [name, setName] = useState(""); | ||
| 415 | const trimmed = name.trim(); | ||
| 416 | const title = addToolkitTitle(props.owner); | ||
| 417 | |||
| 418 | const submit = async () => { | ||
| 419 | if (!trimmed) return; | ||
| 420 | await props.onCreate({ owner: props.owner, name: trimmed }); | ||
| 421 | setName(""); | ||
| 422 | props.onOpenChange(false); | ||
| 423 | }; | ||
| 424 | |||
| 425 | return ( | ||
| 426 | <Dialog | ||
| 427 | open={props.open} | ||
| 428 | onOpenChange={(nextOpen) => { | ||
| 429 | props.onOpenChange(nextOpen); | ||
| 430 | if (!nextOpen) setName(""); | ||
| 431 | }} | ||
| 432 | > | ||
| 433 | <DialogContent | ||
| 434 | className="flex flex-col gap-0 overflow-hidden rounded-md border-border/80 bg-card p-0 shadow-2xl" | ||
| 435 | style={{ width: "min(24rem, calc(100vw - 2rem))" }} | ||
| 436 | > | ||
| 437 | <form | ||
| 438 | className="grid" | ||
| 439 | onSubmit={(event) => { | ||
| 440 | event.preventDefault(); | ||
| 441 | void submit(); | ||
| 442 | }} | ||
| 443 | > | ||
| 444 | <DialogHeader className="px-4 pb-1 pt-4"> | ||
| 445 | <DialogTitle className="text-sm">{title}</DialogTitle> | ||
| 446 | <DialogDescription className="text-xs"> | ||
| 447 | Group tools and expose them at a dedicated MCP URL. | ||
| 448 | </DialogDescription> | ||
| 449 | </DialogHeader> | ||
| 450 | <div className="grid gap-1.5 px-4 py-3"> | ||
| 451 | <Label htmlFor={inputId} className="text-xs text-muted-foreground"> | ||
| 452 | Toolkit name | ||
| 453 | </Label> | ||
| 454 | <Input | ||
| 455 | id={inputId} | ||
| 456 | value={name} | ||
| 457 | onChange={(event) => setName(event.target.value)} | ||
| 458 | placeholder="e.g. Support tools" | ||
| 459 | autoFocus | ||
| 460 | className="h-9" | ||
| 461 | /> | ||
| 462 | </div> | ||
| 463 | <DialogFooter className="px-4 pb-4 pt-1"> | ||
| 464 | <Button | ||
| 465 | type="submit" | ||
| 466 | size="sm" | ||
| 467 | disabled={!trimmed} | ||
| 468 | aria-label="Create toolkit" | ||
| 469 | className="h-8" | ||
| 470 | > | ||
| 471 | Create toolkit | ||
| 472 | </Button> | ||
| 473 | </DialogFooter> | ||
| 474 | </form> | ||
| 475 | </DialogContent> | ||
| 476 | </Dialog> | ||
| 477 | ); | ||
| 478 | } | ||
| 479 | |||
| 480 | function AddToolkitCard(props: { owner: Owner; onClick: () => void }) { | ||
| 481 | const scopeLabel = addToolkitScopeLabel(props.owner); | ||
| 482 | return ( | ||
| 483 | <button | ||
| 484 | type="button" | ||
| 485 | onClick={props.onClick} | ||
| 486 | aria-label={`Add ${scopeLabel} toolkit`} | ||
| 487 | className="group flex min-h-36 min-w-0 self-start items-center justify-center rounded-md border border-dashed border-border/75 bg-card/40 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" | ||
| 488 | style={toolkitCardStyle} | ||
| 489 | > | ||
| 490 | <span | ||
| 491 | className={cn( | ||
| 492 | "flex size-12 items-center justify-center rounded-md border transition-[border-color,background-color,color,transform]", | ||
| 493 | ownerAccentClass(props.owner), | ||
| 494 | "group-hover:scale-105", | ||
| 495 | )} | ||
| 496 | > | ||
| 497 | <PlusIcon className="size-6" /> | ||
| 498 | </span> | ||
| 499 | </button> | ||
| 500 | ); | ||
| 501 | } | ||
| 502 | |||
| 503 | function ToolkitSection(props: { | ||
| 504 | pluginId: string; | ||
| 505 | owner: Owner; | ||
| 506 | title: string; | ||
| 507 | toolkits: readonly ToolkitResponse[]; | ||
| 508 | onCreate: (input: { owner: Owner; name: string }) => Promise<void>; | ||
| 509 | }) { | ||
| 510 | const rows = [...props.toolkits].sort(compareToolkitRows); | ||
| 511 | const [createOpen, setCreateOpen] = useState(false); | ||
| 512 | const openCreate = () => setCreateOpen(true); | ||
| 513 | return ( | ||
| 514 | <section className="space-y-3"> | ||
| 515 | <div className="border-b border-border/60 pb-2"> | ||
| 516 | <h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground"> | ||
| 517 | {props.title} | ||
| 518 | </h2> | ||
| 519 | </div> | ||
| 520 | |||
| 521 | <div | ||
| 522 | className="grid content-start grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3" | ||
| 523 | style={toolkitShelfStyle} | ||
| 524 | > | ||
| 525 | {rows.map((toolkit) => ( | ||
| 526 | <ToolkitTile key={toolkit.id} pluginId={props.pluginId} toolkit={toolkit} /> | ||
| 527 | ))} | ||
| 528 | <AddToolkitCard owner={props.owner} onClick={openCreate} /> | ||
| 529 | </div> | ||
| 530 | |||
| 531 | <CreateToolkitDialog | ||
| 532 | owner={props.owner} | ||
| 533 | open={createOpen} | ||
| 534 | onOpenChange={setCreateOpen} | ||
| 535 | onCreate={props.onCreate} | ||
| 536 | /> | ||
| 537 | </section> | ||
| 538 | ); | ||
| 539 | } | ||
| 540 | |||
| 541 | function ToolkitGrid(props: { | ||
| 542 | pluginId: string; | ||
| 543 | toolkits: readonly ToolkitResponse[]; | ||
| 544 | onCreate: (input: { owner: Owner; name: string }) => Promise<void>; | ||
| 545 | }) { | ||
| 546 | // Toolkit shelves are grouped by owner; the selected toolkit owns the page. | ||
| 547 | const workspaceToolkits = props.toolkits.filter((toolkit) => toolkit.owner === "org"); | ||
| 548 | const personalToolkits = props.toolkits.filter((toolkit) => toolkit.owner === "user"); | ||
| 549 | |||
| 550 | return ( | ||
| 551 | <div className="min-h-0 flex-1 overflow-y-auto"> | ||
| 552 | <div className="w-full space-y-7 px-4 py-4" style={toolkitGridContainerStyle}> | ||
| 553 | <ToolkitSection | ||
| 554 | pluginId={props.pluginId} | ||
| 555 | owner="org" | ||
| 556 | title="Workspace" | ||
| 557 | toolkits={workspaceToolkits} | ||
| 558 | onCreate={props.onCreate} | ||
| 559 | /> | ||
| 560 | <ToolkitSection | ||
| 561 | pluginId={props.pluginId} | ||
| 562 | owner="user" | ||
| 563 | title="Personal" | ||
| 564 | toolkits={personalToolkits} | ||
| 565 | onCreate={props.onCreate} | ||
| 566 | /> | ||
| 567 | </div> | ||
| 568 | </div> | ||
| 569 | ); | ||
| 570 | } | ||
| 571 | |||
| 572 | function ToolkitContentsEmpty(props: { onAddConnection: () => void }) { | ||
| 573 | return ( | ||
| 574 | <div className="flex h-full items-center justify-center px-6 py-10"> | ||
| 575 | <div className="max-w-sm text-center"> | ||
| 576 | <div className="mx-auto flex size-11 items-center justify-center rounded-md border border-border/70 bg-muted/30 text-muted-foreground"> | ||
| 577 | <PlugIcon className="size-5" /> | ||
| 578 | </div> | ||
| 579 | <h3 className="mt-4 text-sm font-semibold text-foreground">No connections added</h3> | ||
| 580 | <p className="mt-1.5 text-sm leading-6 text-muted-foreground"> | ||
| 581 | Add a connection to decide what this toolkit exposes. | ||
| 582 | </p> | ||
| 583 | <Button type="button" size="sm" onClick={props.onAddConnection} className="mt-4"> | ||
| 584 | <PlusIcon className="size-3.5" /> | ||
| 585 | Add connection | ||
| 586 | </Button> | ||
| 587 | </div> | ||
| 588 | </div> | ||
| 589 | ); | ||
| 590 | } | ||
| 591 | |||
| 592 | function ToolkitToolsPanel(props: { | ||
| 593 | tools: readonly ToolSummary[]; | ||
| 594 | selectedToolId: string | null; | ||
| 595 | policies: readonly ToolkitPolicyResponse[]; | ||
| 596 | onAddConnection: () => void; | ||
| 597 | onSelectTool: (toolId: string) => void; | ||
| 598 | onSetPolicy: (pattern: string, action: ToolPolicyAction) => void; | ||
| 599 | onClearPolicy: (pattern: string) => void; | ||
| 600 | }) { | ||
| 601 | return ( | ||
| 602 | <div | ||
| 603 | role="region" | ||
| 604 | aria-label="Toolkit tools" | ||
| 605 | className="flex min-h-0 shrink-0 flex-col border-r border-border/60" | ||
| 606 | style={toolkitToolTreeStyle} | ||
| 607 | > | ||
| 608 | <div className="border-b border-border/60 px-3 py-3"> | ||
| 609 | <div className="flex items-center justify-between gap-3"> | ||
| 610 | <div className="min-w-0"> | ||
| 611 | <h3 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground"> | ||
| 612 | Toolkit tools | ||
| 613 | </h3> | ||
| 614 | <p className="mt-1 text-xs text-muted-foreground"> | ||
| 615 | {props.tools.length} {props.tools.length === 1 ? "tool" : "tools"} | ||
| 616 | </p> | ||
| 617 | </div> | ||
| 618 | <Button | ||
| 619 | type="button" | ||
| 620 | size="sm" | ||
| 621 | aria-label="Add connection to toolkit" | ||
| 622 | onClick={props.onAddConnection} | ||
| 623 | className="h-8" | ||
| 624 | > | ||
| 625 | <PlusIcon className="size-3.5" /> | ||
| 626 | Add | ||
| 627 | </Button> | ||
| 628 | </div> | ||
| 629 | </div> | ||
| 630 | {props.tools.length === 0 ? ( | ||
| 631 | <ToolkitContentsEmpty onAddConnection={props.onAddConnection} /> | ||
| 632 | ) : ( | ||
| 633 | <ToolTree | ||
| 634 | tools={props.tools} | ||
| 635 | selectedToolId={props.selectedToolId} | ||
| 636 | onSelect={props.onSelectTool} | ||
| 637 | onSetPolicy={props.onSetPolicy} | ||
| 638 | onClearPolicy={props.onClearPolicy} | ||
| 639 | patternForDisplay={identityPattern} | ||
| 640 | policies={props.policies} | ||
| 641 | groupByConnection | ||
| 642 | /> | ||
| 643 | )} | ||
| 644 | </div> | ||
| 645 | ); | ||
| 646 | } | ||
| 647 | |||
| 648 | function AddConnectionDialog(props: { | ||
| 649 | open: boolean; | ||
| 650 | groups: readonly ToolkitConnectionGroup[]; | ||
| 651 | configuredToolIds: ReadonlySet<string>; | ||
| 652 | integrations: readonly Integration[]; | ||
| 653 | integrationPlugins: readonly IntegrationPlugin[]; | ||
| 654 | onOpenChange: (open: boolean) => void; | ||
| 655 | onAddPatterns: (patterns: readonly string[]) => Promise<void> | void; | ||
| 656 | }) { | ||
| 657 | const searchId = useId(); | ||
| 658 | const [query, setQuery] = useState(""); | ||
| 659 | const trimmedQuery = query.trim().toLowerCase(); | ||
| 660 | const filteredGroups = useMemo(() => { | ||
| 661 | if (!trimmedQuery) return props.groups; | ||
| 662 | return props.groups.filter((group) => { | ||
| 663 | const corpus = [ | ||
| 664 | connectionTitle(group), | ||
| 665 | ...group.patterns, | ||
| 666 | ownerLabel(group.owner), | ||
| 667 | ...group.tools.flatMap((tool) => [tool.name, tool.description ?? ""]), | ||
| 668 | ] | ||
| 669 | .join(" ") | ||
| 670 | .toLowerCase(); | ||
| 671 | return corpus.includes(trimmedQuery); | ||
| 672 | }); | ||
| 673 | }, [props.groups, trimmedQuery]); | ||
| 674 | |||
| 675 | const addAndClose = async (patterns: readonly string[]) => { | ||
| 676 | await props.onAddPatterns(patterns); | ||
| 677 | props.onOpenChange(false); | ||
| 678 | }; | ||
| 679 | |||
| 680 | return ( | ||
| 681 | <Dialog | ||
| 682 | open={props.open} | ||
| 683 | onOpenChange={(nextOpen) => { | ||
| 684 | props.onOpenChange(nextOpen); | ||
| 685 | if (!nextOpen) { | ||
| 686 | setQuery(""); | ||
| 687 | } | ||
| 688 | }} | ||
| 689 | > | ||
| 690 | <DialogContent | ||
| 691 | className="gap-0 overflow-hidden rounded-md border-border/80 bg-card p-0 shadow-2xl" | ||
| 692 | style={{ | ||
| 693 | width: "min(36rem, calc(100vw - 2rem))", | ||
| 694 | maxWidth: "min(36rem, calc(100vw - 2rem))", | ||
| 695 | maxHeight: "min(42rem, calc(100vh - 4rem))", | ||
| 696 | }} | ||
| 697 | > | ||
| 698 | <DialogHeader className="border-b border-border/60 px-5 py-4"> | ||
| 699 | <div className="flex items-start gap-3"> | ||
| 700 | <span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border/70 bg-muted/30 text-muted-foreground"> | ||
| 701 | <PlugIcon className="size-4" /> | ||
| 702 | </span> | ||
| 703 | <div className="min-w-0"> | ||
| 704 | <DialogTitle className="text-sm">Add connection</DialogTitle> | ||
| 705 | <DialogDescription className="mt-1 text-xs"> | ||
| 706 | Choose which connected account this toolkit can use. | ||
| 707 | </DialogDescription> | ||
| 708 | </div> | ||
| 709 | </div> | ||
| 710 | </DialogHeader> | ||
| 711 | |||
| 712 | <div className="flex min-h-0 flex-col overflow-hidden"> | ||
| 713 | <div className="border-b border-border/50 p-3"> | ||
| 714 | <Label htmlFor={searchId} className="sr-only"> | ||
| 715 | Search connections and tools | ||
| 716 | </Label> | ||
| 717 | <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"> | ||
| 718 | <SearchIcon className="size-3.5 shrink-0 text-muted-foreground" /> | ||
| 719 | <Input | ||
| 720 | id={searchId} | ||
| 721 | value={query} | ||
| 722 | onChange={(event) => setQuery(event.target.value)} | ||
| 723 | placeholder="Search connections" | ||
| 724 | className="h-8 min-w-0 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0" | ||
| 725 | /> | ||
| 726 | </div> | ||
| 727 | </div> | ||
| 728 | <div className="min-h-0 overflow-y-auto p-3" style={{ maxHeight: "32rem" }}> | ||
| 729 | <div className="mb-2 flex items-center justify-between px-1"> | ||
| 730 | <span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"> | ||
| 731 | Connections | ||
| 732 | </span> | ||
| 733 | <span className="text-xs tabular-nums text-muted-foreground"> | ||
| 734 | {filteredGroups.length} | ||
| 735 | </span> | ||
| 736 | </div> | ||
| 737 | {filteredGroups.length === 0 ? ( | ||
| 738 | <div className="rounded-md border border-dashed border-border/70 px-4 py-8 text-center text-xs text-muted-foreground"> | ||
| 739 | No connections match. | ||
| 740 | </div> | ||
| 741 | ) : ( | ||
| 742 | <div className="overflow-hidden rounded-md border border-border/70 bg-background/30"> | ||
| 743 | {filteredGroups.map((group) => { | ||
| 744 | const title = connectionTitle(group); | ||
| 745 | const added = group.tools.every((tool) => | ||
| 746 | props.configuredToolIds.has(toolMatchId(tool)), | ||
| 747 | ); | ||
| 748 | const meta = integrationMetaFor( | ||
| 749 | group, | ||
| 750 | props.integrations, | ||
| 751 | props.integrationPlugins, | ||
| 752 | ); | ||
| 753 | return ( | ||
| 754 | <div | ||
| 755 | key={group.id} | ||
| 756 | 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" | ||
| 757 | > | ||
| 758 | <span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border/70 bg-muted/30"> | ||
| 759 | <IntegrationFavicon | ||
| 760 | icon={meta.icon} | ||
| 761 | sourceId={meta.sourceId} | ||
| 762 | url={meta.url} | ||
| 763 | size={18} | ||
| 764 | /> | ||
| 765 | </span> | ||
| 766 | <div className="min-w-0 flex-1 space-y-1"> | ||
| 767 | <div className="flex min-w-0 items-center gap-2"> | ||
| 768 | <span className="truncate text-xs font-medium text-foreground"> | ||
| 769 | {connectionDisplayTitle(group, meta)} | ||
| 770 | </span> | ||
| 771 | <Badge variant="outline" className="shrink-0 text-[10px]"> | ||
| 772 | {ownerLabel(group.owner)} | ||
| 773 | </Badge> | ||
| 774 | </div> | ||
| 775 | <div className="flex min-w-0 items-center gap-2"> | ||
| 776 | <span className="truncate text-[11px] text-muted-foreground"> | ||
| 777 | {connectionDisplaySubtitle(group, meta)} | ||
| 778 | </span> | ||
| 779 | </div> | ||
| 780 | </div> | ||
| 781 | <Button | ||
| 782 | type="button" | ||
| 783 | variant={added ? "outline" : "default"} | ||
| 784 | size="sm" | ||
| 785 | disabled={added} | ||
| 786 | aria-label={`${added ? "Connection added" : "Add connection"} ${title}`} | ||
| 787 | onClick={() => void addAndClose(group.patterns)} | ||
| 788 | className={cn( | ||
| 789 | "h-7 shrink-0 px-2.5 text-xs", | ||
| 790 | added | ||
| 791 | ? "border-border/70 bg-transparent text-muted-foreground" | ||
| 792 | : "bg-primary/10 text-primary hover:bg-primary/15", | ||
| 793 | )} | ||
| 794 | > | ||
| 795 | {added ? ( | ||
| 796 | <CheckIcon className="size-3.5" /> | ||
| 797 | ) : ( | ||
| 798 | <PlusIcon className="size-3.5" /> | ||
| 799 | )} | ||
| 800 | {added ? "Added" : "Add"} | ||
| 801 | </Button> | ||
| 802 | </div> | ||
| 803 | ); | ||
| 804 | })} | ||
| 805 | </div> | ||
| 806 | )} | ||
| 807 | </div> | ||
| 808 | </div> | ||
| 809 | </DialogContent> | ||
| 810 | </Dialog> | ||
| 811 | ); | ||
| 812 | } | ||
| 813 | |||
| 814 | function ToolkitHeader(props: { | ||
| 815 | toolkit: ToolkitResponse; | ||
| 816 | toolCount: number; | ||
| 817 | mcpUrl: string; | ||
| 818 | onBack: () => void; | ||
| 819 | onRemove: () => void; | ||
| 820 | }) { | ||
| 821 | return ( | ||
| 822 | <div className="shrink-0 border-b border-border/60 bg-background/95 px-4 py-3"> | ||
| 823 | <Button | ||
| 824 | type="button" | ||
| 825 | variant="ghost" | ||
| 826 | size="xs" | ||
| 827 | onClick={props.onBack} | ||
| 828 | className="mb-2 -ml-1 text-muted-foreground" | ||
| 829 | > | ||
| 830 | <ArrowLeftIcon className="size-3.5" /> | ||
| 831 | Toolkits | ||
| 832 | </Button> | ||
| 833 | <div className="flex min-w-0 items-start justify-between gap-3"> | ||
| 834 | <div className="min-w-0"> | ||
| 835 | <div className="flex min-w-0 items-center gap-2"> | ||
| 836 | <h2 className="truncate text-sm font-semibold text-foreground">{props.toolkit.name}</h2> | ||
| 837 | <Badge variant="outline" className="text-[10px]"> | ||
| 838 | {ownerLabel(props.toolkit.owner)} | ||
| 839 | </Badge> | ||
| 840 | <span className="text-xs tabular-nums text-muted-foreground"> | ||
| 841 | {props.toolCount} {props.toolCount === 1 ? "tool" : "tools"} | ||
| 842 | </span> | ||
| 843 | </div> | ||
| 844 | <div className="mt-1 flex min-w-0 items-center gap-1.5"> | ||
| 845 | <code className="min-w-0 truncate rounded border border-border bg-muted/40 px-1.5 py-0.5 text-[11px] text-muted-foreground"> | ||
| 846 | {props.mcpUrl} | ||
| 847 | </code> | ||
| 848 | <CopyButton value={props.mcpUrl} label="Copy toolkit MCP URL" /> | ||
| 849 | </div> | ||
| 850 | </div> | ||
| 851 | <Button | ||
| 852 | type="button" | ||
| 853 | variant="ghost" | ||
| 854 | size="icon-sm" | ||
| 855 | aria-label="Delete toolkit" | ||
| 856 | onClick={props.onRemove} | ||
| 857 | className="text-muted-foreground hover:text-destructive" | ||
| 858 | > | ||
| 859 | <Trash2Icon className="size-3.5" /> | ||
| 860 | </Button> | ||
| 861 | </div> | ||
| 862 | </div> | ||
| 863 | ); | ||
| 864 | } | ||
| 865 | |||
| 866 | function ToolkitWorkspace(props: { | ||
| 867 | toolkit: ToolkitResponse; | ||
| 868 | policies: readonly ToolkitPolicyResponse[]; | ||
| 869 | connections: readonly ToolkitConnectionResponse[]; | ||
| 870 | tools: readonly ToolRow[]; | ||
| 871 | integrations: readonly Integration[]; | ||
| 872 | integrationPlugins: readonly IntegrationPlugin[]; | ||
| 873 | mcpUrl: string; | ||
| 874 | onBack: () => void; | ||
| 875 | onRemoveToolkit: () => void; | ||
| 876 | onAddConnection: (pattern: string) => Promise<void> | void; | ||
| 877 | onSetPolicy: (pattern: string, action: ToolPolicyAction) => Promise<void> | void; | ||
| 878 | onClearPolicy: (pattern: string) => Promise<void> | void; | ||
| 879 | }) { | ||
| 880 | const [addOpen, setAddOpen] = useState(false); | ||
| 881 | const [selectedToolId, setSelectedToolId] = useState<string | null>(null); | ||
| 882 | const visibleTools = useMemo( | ||
| 883 | () => props.tools.filter((tool) => toolCanAppearInToolkit(props.toolkit, tool)), | ||
| 884 | [props.toolkit, props.tools], | ||
| 885 | ); | ||
| 886 | const connectionGroups = useMemo(() => buildConnectionGroups(visibleTools), [visibleTools]); | ||
| 887 | const legacyPolicyIds = useMemo( | ||
| 888 | () => legacyConnectionPolicyIds(props.policies, connectionGroups, props.connections), | ||
| 889 | [connectionGroups, props.connections, props.policies], | ||
| 890 | ); | ||
| 891 | const accessPolicies = useMemo( | ||
| 892 | () => props.policies.filter((policy) => !legacyPolicyIds.has(policy.id)), | ||
| 893 | [legacyPolicyIds, props.policies], | ||
| 894 | ); | ||
| 895 | const connectionPatterns = useMemo( | ||
| 896 | () => configuredConnectionPatterns(props.connections, props.policies, legacyPolicyIds), | ||
| 897 | [legacyPolicyIds, props.connections, props.policies], | ||
| 898 | ); | ||
| 899 | const configuredToolIds = useMemo(() => { | ||
| 900 | const ids = new Set<string>(); | ||
| 901 | for (const tool of visibleTools) { | ||
| 902 | const id = toolMatchId(tool); | ||
| 903 | if ([...connectionPatterns].some((pattern) => matchPattern(pattern, id))) ids.add(id); | ||
| 904 | } | ||
| 905 | return ids; | ||
| 906 | }, [connectionPatterns, visibleTools]); | ||
| 907 | const configuredTools = useMemo( | ||
| 908 | () => | ||
| 909 | visibleTools.filter((tool) => configuredToolIds.has(toolMatchId(tool))).sort(compareTools), | ||
| 910 | [visibleTools, configuredToolIds], | ||
| 911 | ); | ||
| 912 | const toolkitTools: ToolSummary[] = useMemo( | ||
| 913 | () => | ||
| 914 | configuredTools.map((tool) => { | ||
| 915 | const id = toolMatchId(tool); | ||
| 916 | return { | ||
| 917 | id, | ||
| 918 | name: id, | ||
| 919 | description: tool.description, | ||
| 920 | policy: resolveToolkitPolicy(id, accessPolicies, tool.requiresApproval), | ||
| 921 | owner: toolOwner(tool), | ||
| 922 | connection: toolConnectionName(tool), | ||
| 923 | }; | ||
| 924 | }), | ||
| 925 | [accessPolicies, configuredTools], | ||
| 926 | ); | ||
| 927 | const exposedToolIds = useMemo( | ||
| 928 | () => | ||
| 929 | new Set( | ||
| 930 | toolkitTools | ||
| 931 | .filter((tool) => tool.policy.action !== "block") | ||
| 932 | .map((tool) => tool.id), | ||
| 933 | ), | ||
| 934 | [toolkitTools], | ||
| 935 | ); | ||
| 936 | const selectedTool = selectedToolId | ||
| 937 | ? (configuredTools.find((tool) => toolMatchId(tool) === selectedToolId) ?? null) | ||
| 938 | : null; | ||
| 939 | const selectedToolPolicy = selectedTool | ||
| 940 | ? (toolkitTools.find((tool) => tool.id === selectedToolId)?.policy ?? null) | ||
| 941 | : null; | ||
| 942 | |||
| 943 | return ( | ||
| 944 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> | ||
| 945 | <ToolkitHeader | ||
| 946 | toolkit={props.toolkit} | ||
| 947 | toolCount={exposedToolIds.size} | ||
| 948 | mcpUrl={props.mcpUrl} | ||
| 949 | onBack={props.onBack} | ||
| 950 | onRemove={props.onRemoveToolkit} | ||
| 951 | /> | ||
| 952 | |||
| 953 | <div className="flex min-h-0 flex-1 overflow-hidden"> | ||
| 954 | <ToolkitToolsPanel | ||
| 955 | tools={toolkitTools} | ||
| 956 | selectedToolId={selectedToolId} | ||
| 957 | policies={accessPolicies} | ||
| 958 | onAddConnection={() => setAddOpen(true)} | ||
| 959 | onSelectTool={setSelectedToolId} | ||
| 960 | onSetPolicy={(pattern, action) => void props.onSetPolicy(pattern, action)} | ||
| 961 | onClearPolicy={(pattern) => void props.onClearPolicy(pattern)} | ||
| 962 | /> | ||
| 963 | <div className="flex min-w-0 flex-1 flex-col overflow-hidden"> | ||
| 964 | {selectedTool && selectedToolPolicy ? ( | ||
| 965 | <ToolDetail | ||
| 966 | address={selectedTool.address} | ||
| 967 | toolName={toolMatchId(selectedTool)} | ||
| 968 | staticTool={selectedTool.static === true} | ||
| 969 | policy={selectedToolPolicy} | ||
| 970 | onSetPolicy={props.onSetPolicy} | ||
| 971 | onClearPolicy={props.onClearPolicy} | ||
| 972 | patternForDisplay={identityPattern} | ||
| 973 | /> | ||
| 974 | ) : ( | ||
| 975 | <ToolDetailEmpty hasTools={toolkitTools.length > 0} /> | ||
| 976 | )} | ||
| 977 | </div> | ||
| 978 | </div> | ||
| 979 | |||
| 980 | <AddConnectionDialog | ||
| 981 | open={addOpen} | ||
| 982 | onOpenChange={setAddOpen} | ||
| 983 | groups={connectionGroups} | ||
| 984 | configuredToolIds={configuredToolIds} | ||
| 985 | integrations={props.integrations} | ||
| 986 | integrationPlugins={props.integrationPlugins} | ||
| 987 | onAddPatterns={async (patterns) => { | ||
| 988 | for (const pattern of patterns) { | ||
| 989 | await props.onAddConnection(pattern); | ||
| 990 | } | ||
| 991 | }} | ||
| 992 | /> | ||
| 993 | </div> | ||
| 994 | ); | ||
| 995 | } | ||
| 996 | |||
| 997 | function ToolkitsPageSkeleton() { | ||
| 998 | return ( | ||
| 999 | <div className="flex min-h-0 flex-1 overflow-hidden"> | ||
| 1000 | <div className="flex-1 p-4"> | ||
| 1001 | <Skeleton className="h-8 w-full" /> | ||
| 1002 | <div className="grid grid-cols-1 gap-3 pt-4 sm:grid-cols-2 md:grid-cols-3"> | ||
| 1003 | {Array.from({ length: 6 }).map((_, index) => ( | ||
| 1004 | <Skeleton key={index} className="h-36 w-full rounded-md" /> | ||
| 1005 | ))} | ||
| 1006 | </div> | ||
| 1007 | </div> | ||
| 1008 | </div> | ||
| 1009 | ); | ||
| 1010 | } | ||
| 1011 | |||
| 1012 | function ToolkitDetailView(props: { | ||
| 1013 | toolkit: ToolkitResponse; | ||
| 1014 | tools: readonly ToolRow[]; | ||
| 1015 | integrations: readonly Integration[]; | ||
| 1016 | integrationPlugins: readonly IntegrationPlugin[]; | ||
| 1017 | orgSlug?: string; | ||
| 1018 | onBack: () => void; | ||
| 1019 | onRemoveToolkit: (toolkit: ToolkitResponse) => void; | ||
| 1020 | }) { | ||
| 1021 | const policies = useAtomValue(toolkitPoliciesAtom(props.toolkit.id)); | ||
| 1022 | const connections = useAtomValue(toolkitConnectionsAtom(props.toolkit.id)); | ||
| 1023 | const doCreatePolicy = useAtomSet(createToolkitPolicy, { mode: "promiseExit" }); | ||
| 1024 | const doUpdatePolicy = useAtomSet(updateToolkitPolicy, { mode: "promiseExit" }); | ||
| 1025 | const doRemovePolicy = useAtomSet(removeToolkitPolicy, { mode: "promiseExit" }); | ||
| 1026 | const doCreateConnection = useAtomSet(createToolkitConnection, { mode: "promiseExit" }); | ||
| 1027 | const policyRows = AsyncResult.isSuccess(policies) ? policies.value.policies : []; | ||
| 1028 | const connectionRows = AsyncResult.isSuccess(connections) ? connections.value.connections : []; | ||
| 1029 | |||
| 1030 | const setPolicyHandler = async (pattern: string, action: ToolPolicyAction) => { | ||
| 1031 | const existing = policyRows.find((policy) => policy.pattern === pattern); | ||
| 1032 | if (existing) { | ||
| 1033 | await doUpdatePolicy({ | ||
| 1034 | params: { toolkitId: props.toolkit.id, policyId: existing.id }, | ||
| 1035 | payload: { action }, | ||
| 1036 | reactivityKeys: toolkitWriteKeys, | ||
| 1037 | }); | ||
| 1038 | return; | ||
| 1039 | } | ||
| 1040 | await doCreatePolicy({ | ||
| 1041 | params: { toolkitId: props.toolkit.id }, | ||
| 1042 | payload: { pattern, action }, | ||
| 1043 | reactivityKeys: toolkitWriteKeys, | ||
| 1044 | }); | ||
| 1045 | }; | ||
| 1046 | |||
| 1047 | const addConnectionHandler = async (pattern: string) => { | ||
| 1048 | await doCreateConnection({ | ||
| 1049 | params: { toolkitId: props.toolkit.id }, | ||
| 1050 | payload: { pattern }, | ||
| 1051 | reactivityKeys: toolkitWriteKeys, | ||
| 1052 | }); | ||
| 1053 | }; | ||
| 1054 | |||
| 1055 | const clearPolicyHandler = async (pattern: string) => { | ||
| 1056 | const existing = policyRows.find((policy) => policy.pattern === pattern); | ||
| 1057 | if (!existing) return; | ||
| 1058 | await doRemovePolicy({ | ||
| 1059 | params: { toolkitId: props.toolkit.id, policyId: existing.id }, | ||
| 1060 | reactivityKeys: toolkitWriteKeys, | ||
| 1061 | }); | ||
| 1062 | }; | ||
| 1063 | |||
| 1064 | if (AsyncResult.isFailure(policies) || AsyncResult.isFailure(connections)) { | ||
| 1065 | return <div className="p-6 text-sm text-destructive">Failed to load toolkit</div>; | ||
| 1066 | } | ||
| 1067 | if (!AsyncResult.isSuccess(policies) || !AsyncResult.isSuccess(connections)) { | ||
| 1068 | return <ToolkitsPageSkeleton />; | ||
| 1069 | } | ||
| 1070 | |||
| 1071 | return ( | ||
| 1072 | <ToolkitWorkspace | ||
| 1073 | toolkit={props.toolkit} | ||
| 1074 | policies={policyRows} | ||
| 1075 | connections={connectionRows} | ||
| 1076 | tools={props.tools} | ||
| 1077 | integrations={props.integrations} | ||
| 1078 | integrationPlugins={props.integrationPlugins} | ||
| 1079 | mcpUrl={toolkitUrlFor(props.orgSlug, props.toolkit.slug)} | ||
| 1080 | onBack={props.onBack} | ||
| 1081 | onRemoveToolkit={() => props.onRemoveToolkit(props.toolkit)} | ||
| 1082 | onAddConnection={addConnectionHandler} | ||
| 1083 | onSetPolicy={setPolicyHandler} | ||
| 1084 | onClearPolicy={clearPolicyHandler} | ||
| 1085 | /> | ||
| 1086 | ); | ||
| 1087 | } | ||
| 1088 | |||
| 1089 | export function ToolkitsPage(props: PluginPageProps) { | ||
| 1090 | const params = useParams({ strict: false }) as { orgSlug?: string }; | ||
| 1091 | const navigate = useNavigate(); | ||
| 1092 | const integrationPlugins = useIntegrationPlugins(); | ||
| 1093 | const toolkits = useAtomValue(toolkitsAtom); | ||
| 1094 | const tools = useAtomValue(toolsAllAtom); | ||
| 1095 | const integrations = useAtomValue(integrationsOptimisticAtom); | ||
| 1096 | const doCreateToolkit = useAtomSet(createToolkit, { mode: "promiseExit" }); | ||
| 1097 | const doRemoveToolkit = useAtomSet(removeToolkit, { mode: "promiseExit" }); | ||
| 1098 | const selectedToolkitSlug = props.params.toolkitSlug ?? null; | ||
| 1099 | |||
| 1100 | const toolkitRows = AsyncResult.isSuccess(toolkits) ? toolkits.value.toolkits : []; | ||
| 1101 | const selectedToolkit = | ||
| 1102 | selectedToolkitSlug === null | ||
| 1103 | ? null | ||
| 1104 | : (toolkitRows.find((toolkit) => toolkit.slug === selectedToolkitSlug) ?? null); | ||
| 1105 | |||
| 1106 | const toolRows = AsyncResult.isSuccess(tools) ? (tools.value as readonly ToolRow[]) : []; | ||
| 1107 | const integrationRows = AsyncResult.isSuccess(integrations) | ||
| 1108 | ? (integrations.value as readonly Integration[]) | ||
| 1109 | : []; | ||
| 1110 | const navigateToIndex = () => | ||
| 1111 | navigate({ | ||
| 1112 | to: "/{-$orgSlug}/plugins/$pluginId/$", | ||
| 1113 | params: { pluginId: props.pluginId, _splat: "" }, | ||
| 1114 | }); | ||
| 1115 | |||
| 1116 | const createToolkitHandler = async (input: { owner: Owner; name: string }) => { | ||
| 1117 | await doCreateToolkit({ | ||
| 1118 | payload: input, | ||
| 1119 | reactivityKeys: toolkitWriteKeys, | ||
| 1120 | }); | ||
| 1121 | }; | ||
| 1122 | |||
| 1123 | const removeToolkitHandler = async (toolkit: ToolkitResponse) => { | ||
| 1124 | await doRemoveToolkit({ | ||
| 1125 | params: { toolkitId: toolkit.id }, | ||
| 1126 | reactivityKeys: toolkitWriteKeys, | ||
| 1127 | }); | ||
| 1128 | await navigateToIndex(); | ||
| 1129 | }; | ||
| 1130 | |||
| 1131 | return ( | ||
| 1132 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> | ||
| 1133 | {selectedToolkitSlug === null ? ( | ||
| 1134 | <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"> | ||
| 1135 | <div className="flex min-w-0 items-center gap-3"> | ||
| 1136 | <h1 className="truncate text-sm font-semibold text-foreground">Toolkits</h1> | ||
| 1137 | {AsyncResult.isSuccess(toolkits) && ( | ||
| 1138 | <span className="hidden text-xs tabular-nums text-muted-foreground sm:block"> | ||
| 1139 | {toolkitRows.length} {toolkitRows.length === 1 ? "toolkit" : "toolkits"} | ||
| 1140 | </span> | ||
| 1141 | )} | ||
| 1142 | </div> | ||
| 1143 | </div> | ||
| 1144 | ) : null} | ||
| 1145 | |||
| 1146 | {!AsyncResult.isSuccess(toolkits) || !AsyncResult.isSuccess(tools) ? ( | ||
| 1147 | AsyncResult.isFailure(toolkits) || AsyncResult.isFailure(tools) ? ( | ||
| 1148 | <div className="p-6 text-sm text-destructive">Failed to load toolkits</div> | ||
| 1149 | ) : ( | ||
| 1150 | <ToolkitsPageSkeleton /> | ||
| 1151 | ) | ||
| 1152 | ) : ( | ||
| 1153 | <> | ||
| 1154 | {selectedToolkit ? ( | ||
| 1155 | <ToolkitDetailView | ||
| 1156 | toolkit={selectedToolkit} | ||
| 1157 | tools={toolRows} | ||
| 1158 | integrations={integrationRows} | ||
| 1159 | integrationPlugins={integrationPlugins} | ||
| 1160 | orgSlug={params.orgSlug} | ||
| 1161 | onBack={() => void navigateToIndex()} | ||
| 1162 | onRemoveToolkit={removeToolkitHandler} | ||
| 1163 | /> | ||
| 1164 | ) : selectedToolkitSlug !== null ? ( | ||
| 1165 | <div className="flex min-h-0 flex-1 flex-col p-6"> | ||
| 1166 | <Button | ||
| 1167 | type="button" | ||
| 1168 | variant="ghost" | ||
| 1169 | size="xs" | ||
| 1170 | onClick={() => void navigateToIndex()} | ||
| 1171 | className="mb-4 w-fit -ml-1 text-muted-foreground" | ||
| 1172 | > | ||
| 1173 | <ArrowLeftIcon className="size-3.5" /> | ||
| 1174 | Toolkits | ||
| 1175 | </Button> | ||
| 1176 | <div className="text-sm text-muted-foreground">Toolkit not found</div> | ||
| 1177 | </div> | ||
| 1178 | ) : ( | ||
| 1179 | <ToolkitGrid | ||
| 1180 | pluginId={props.pluginId} | ||
| 1181 | toolkits={toolkitRows} | ||
| 1182 | onCreate={createToolkitHandler} | ||
| 1183 | /> | ||
| 1184 | )} | ||
| 1185 | </> | ||
| 1186 | )} | ||
| 1187 | </div> | ||
| 1188 | ); | ||
| 1189 | } | ||
| @@ -0,0 +1,134 @@ | |||
| 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 | }); | ||
| @@ -0,0 +1,653 @@ | |||
| 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 legacyConnectionPolicyIds = new Set( | ||
| 160 | policies.filter(isLegacyConnectionPolicy).map((policy) => policy.id), | ||
| 161 | ); | ||
| 162 | const connected = | ||
| 163 | connections.some((connection) => matchPattern(connection.pattern, toolId)) || | ||
| 164 | policies.some( | ||
| 165 | (policy) => legacyConnectionPolicyIds.has(policy.id) && matchPattern(policy.pattern, toolId), | ||
| 166 | ); | ||
| 167 | if (!connected) return blockedPolicy(); | ||
| 168 | |||
| 169 | for (const policy of [...policies].sort(comparePositioned)) { | ||
| 170 | if (legacyConnectionPolicyIds.has(policy.id)) continue; | ||
| 171 | if (!matchPattern(policy.pattern, toolId)) continue; | ||
| 172 | return { | ||
| 173 | action: policy.action, | ||
| 174 | source: "user", | ||
| 175 | pattern: policy.pattern, | ||
| 176 | policyId: policy.id, | ||
| 177 | }; | ||
| 178 | } | ||
| 179 | return pluginDefaultPolicy(defaultRequiresApproval); | ||
| 180 | }; | ||
| 181 | |||
| 182 | const isPersonalDynamicToolId = (toolId: string): boolean => toolId.split(".")[1] === "user"; | ||
| 183 | |||
| 184 | const toolkitToResponse = (entry: { readonly owner: Owner; readonly data: ToolkitRecord }) => ({ | ||
| 185 | id: entry.data.id, | ||
| 186 | owner: entry.owner, | ||
| 187 | slug: entry.data.slug, | ||
| 188 | name: entry.data.name, | ||
| 189 | createdAt: entry.data.createdAt, | ||
| 190 | updatedAt: entry.data.updatedAt, | ||
| 191 | }); | ||
| 192 | |||
| 193 | const policyToResponse = (policy: ToolkitPolicyRecord) => ({ | ||
| 194 | id: policy.id, | ||
| 195 | toolkitId: policy.toolkitId, | ||
| 196 | pattern: policy.pattern, | ||
| 197 | action: policy.action, | ||
| 198 | position: policy.position, | ||
| 199 | createdAt: policy.createdAt, | ||
| 200 | updatedAt: policy.updatedAt, | ||
| 201 | }); | ||
| 202 | |||
| 203 | const connectionToResponse = (connection: ToolkitConnectionRecord) => ({ | ||
| 204 | id: connection.id, | ||
| 205 | toolkitId: connection.toolkitId, | ||
| 206 | pattern: connection.pattern, | ||
| 207 | position: connection.position, | ||
| 208 | createdAt: connection.createdAt, | ||
| 209 | updatedAt: connection.updatedAt, | ||
| 210 | }); | ||
| 211 | |||
| 212 | const makeToolkitStorage = (pluginStorage: PluginStorageFacade): ToolkitStorage => ({ | ||
| 213 | toolkits: pluginStorage.collection(toolkitsCollection), | ||
| 214 | policies: pluginStorage.collection(toolkitPoliciesCollection), | ||
| 215 | connections: pluginStorage.collection(toolkitConnectionsCollection), | ||
| 216 | }); | ||
| 217 | |||
| 218 | const makeToolkitsExtension = (ctx: PluginCtx<ToolkitStorage>) => { | ||
| 219 | const storage = ctx.storage; | ||
| 220 | |||
| 221 | const list = () => | ||
| 222 | storage.toolkits | ||
| 223 | .query({ orderBy: [{ field: "name" }] }) | ||
| 224 | .pipe( | ||
| 225 | Effect.map((entries) => | ||
| 226 | entries | ||
| 227 | .map(toolkitToResponse) | ||
| 228 | .sort( | ||
| 229 | (a, b) => | ||
| 230 | (a.owner === b.owner ? 0 : a.owner === "org" ? -1 : 1) || | ||
| 231 | a.name.localeCompare(b.name) || | ||
| 232 | a.slug.localeCompare(b.slug), | ||
| 233 | ), | ||
| 234 | ), | ||
| 235 | ); | ||
| 236 | |||
| 237 | const getEntry = (toolkitId: string) => storage.toolkits.get({ key: toolkitId }); | ||
| 238 | |||
| 239 | const getBySlugEntry = (slug: string) => | ||
| 240 | storage.toolkits.query({ where: { slug } }).pipe(Effect.map((entries) => entries[0] ?? null)); | ||
| 241 | |||
| 242 | const requireToolkit = (toolkitId: string) => | ||
| 243 | getEntry(toolkitId).pipe( | ||
| 244 | Effect.flatMap((entry) => (entry ? Effect.succeed(entry) : fail("Toolkit not found."))), | ||
| 245 | ); | ||
| 246 | |||
| 247 | const assertSlugAvailable = (slug: string, ignoreToolkitId?: string) => | ||
| 248 | storage.toolkits.query({ where: { slug } }).pipe( | ||
| 249 | Effect.flatMap((entries) => { | ||
| 250 | const collision = entries.find((entry) => entry.data.id !== ignoreToolkitId); | ||
| 251 | return collision ? fail(`Toolkit slug "${slug}" is already in use.`) : Effect.void; | ||
| 252 | }), | ||
| 253 | ); | ||
| 254 | |||
| 255 | const listPoliciesForRecord = (toolkitId: string) => | ||
| 256 | storage.policies | ||
| 257 | .query({ where: { toolkitId } }) | ||
| 258 | .pipe(Effect.map((entries) => entries.map((entry) => entry.data).sort(comparePositioned))); | ||
| 259 | |||
| 260 | const listConnectionsForRecord = (toolkitId: string) => | ||
| 261 | storage.connections | ||
| 262 | .query({ where: { toolkitId } }) | ||
| 263 | .pipe(Effect.map((entries) => entries.map((entry) => entry.data).sort(comparePositioned))); | ||
| 264 | |||
| 265 | const requirePolicy = (toolkitId: string, policyId: string, owner: Owner) => | ||
| 266 | storage.policies | ||
| 267 | .getForOwner({ owner, key: policyId }) | ||
| 268 | .pipe( | ||
| 269 | Effect.flatMap((entry) => | ||
| 270 | entry && entry.data.toolkitId === toolkitId | ||
| 271 | ? Effect.succeed(entry) | ||
| 272 | : fail("Toolkit policy not found."), | ||
| 273 | ), | ||
| 274 | ); | ||
| 275 | |||
| 276 | const requireConnection = (toolkitId: string, connectionId: string, owner: Owner) => | ||
| 277 | storage.connections | ||
| 278 | .getForOwner({ owner, key: connectionId }) | ||
| 279 | .pipe( | ||
| 280 | Effect.flatMap((entry) => | ||
| 281 | entry && entry.data.toolkitId === toolkitId | ||
| 282 | ? Effect.succeed(entry) | ||
| 283 | : fail("Toolkit connection not found."), | ||
| 284 | ), | ||
| 285 | ); | ||
| 286 | |||
| 287 | const create = (input: { | ||
| 288 | readonly owner: Owner; | ||
| 289 | readonly name: string; | ||
| 290 | readonly slug?: string; | ||
| 291 | }) => | ||
| 292 | Effect.gen(function* () { | ||
| 293 | const name = input.name.trim(); | ||
| 294 | if (!name) return yield* fail("Toolkit name is required."); | ||
| 295 | const slug = yield* normalizeSlugEffect({ name, slug: input.slug }); | ||
| 296 | yield* assertSlugAvailable(slug); | ||
| 297 | const now = Date.now(); | ||
| 298 | const id = newId("tk"); | ||
| 299 | const entry = yield* storage.toolkits.put({ | ||
| 300 | owner: input.owner, | ||
| 301 | key: id, | ||
| 302 | data: { id, slug, name, createdAt: now, updatedAt: now }, | ||
| 303 | }); | ||
| 304 | return toolkitToResponse(entry); | ||
| 305 | }); | ||
| 306 | |||
| 307 | const update = (toolkitId: string, input: { readonly name?: string; readonly slug?: string }) => | ||
| 308 | Effect.gen(function* () { | ||
| 309 | const existing = yield* requireToolkit(toolkitId); | ||
| 310 | const name = input.name === undefined ? existing.data.name : input.name.trim(); | ||
| 311 | if (!name) return yield* fail("Toolkit name is required."); | ||
| 312 | const slug = | ||
| 313 | input.slug === undefined | ||
| 314 | ? existing.data.slug | ||
| 315 | : yield* normalizeSlugEffect({ name, slug: input.slug }); | ||
| 316 | yield* assertSlugAvailable(slug, toolkitId); | ||
| 317 | const entry = yield* storage.toolkits.put({ | ||
| 318 | owner: existing.owner, | ||
| 319 | key: toolkitId, | ||
| 320 | data: { ...existing.data, name, slug, updatedAt: Date.now() }, | ||
| 321 | }); | ||
| 322 | return toolkitToResponse(entry); | ||
| 323 | }); | ||
| 324 | |||
| 325 | const remove = (toolkitId: string) => | ||
| 326 | Effect.gen(function* () { | ||
| 327 | const toolkit = yield* requireToolkit(toolkitId); | ||
| 328 | const policies = yield* listPoliciesForRecord(toolkitId); | ||
| 329 | const connections = yield* listConnectionsForRecord(toolkitId); | ||
| 330 | yield* ctx.pluginStorage.removeMany({ | ||
| 331 | owner: toolkit.owner, | ||
| 332 | entries: [ | ||
| 333 | { collection: toolkitsCollection.name, key: toolkitId }, | ||
| 334 | ...policies.map((policy) => ({ | ||
| 335 | collection: toolkitPoliciesCollection.name, | ||
| 336 | key: policy.id, | ||
| 337 | })), | ||
| 338 | ...connections.map((connection) => ({ | ||
| 339 | collection: toolkitConnectionsCollection.name, | ||
| 340 | key: connection.id, | ||
| 341 | })), | ||
| 342 | ], | ||
| 343 | }); | ||
| 344 | }); | ||
| 345 | |||
| 346 | const listPolicies = (toolkitId: string) => | ||
| 347 | requireToolkit(toolkitId).pipe(Effect.flatMap(() => listPoliciesForRecord(toolkitId))); | ||
| 348 | |||
| 349 | const createPolicy = ( | ||
| 350 | toolkitId: string, | ||
| 351 | input: { | ||
| 352 | readonly pattern: string; | ||
| 353 | readonly action: ToolPolicyAction; | ||
| 354 | readonly position?: string; | ||
| 355 | }, | ||
| 356 | ) => | ||
| 357 | Effect.gen(function* () { | ||
| 358 | yield* validatePolicyPattern(input.pattern); | ||
| 359 | const toolkit = yield* requireToolkit(toolkitId); | ||
| 360 | const existing = yield* listPoliciesForRecord(toolkitId); | ||
| 361 | const minPosition = existing | ||
| 362 | .map((row) => row.position) | ||
| 363 | .sort() | ||
| 364 | .at(0); | ||
| 365 | const now = Date.now(); | ||
| 366 | const id = newId("tkpol"); | ||
| 367 | const entry = yield* storage.policies.put({ | ||
| 368 | owner: toolkit.owner, | ||
| 369 | key: id, | ||
| 370 | data: { | ||
| 371 | id, | ||
| 372 | toolkitId, | ||
| 373 | pattern: input.pattern, | ||
| 374 | action: input.action, | ||
| 375 | position: input.position ?? generateKeyBetween(null, minPosition ?? null), | ||
| 376 | createdAt: now, | ||
| 377 | updatedAt: now, | ||
| 378 | }, | ||
| 379 | }); | ||
| 380 | return policyToResponse(entry.data); | ||
| 381 | }); | ||
| 382 | |||
| 383 | const updatePolicy = ( | ||
| 384 | toolkitId: string, | ||
| 385 | policyId: string, | ||
| 386 | input: { | ||
| 387 | readonly pattern?: string; | ||
| 388 | readonly action?: ToolPolicyAction; | ||
| 389 | readonly position?: string; | ||
| 390 | }, | ||
| 391 | ) => | ||
| 392 | Effect.gen(function* () { | ||
| 393 | if (input.pattern !== undefined) yield* validatePolicyPattern(input.pattern); | ||
| 394 | const toolkit = yield* requireToolkit(toolkitId); | ||
| 395 | const existing = yield* requirePolicy(toolkitId, policyId, toolkit.owner); | ||
| 396 | const entry = yield* storage.policies.put({ | ||
| 397 | owner: toolkit.owner, | ||
| 398 | key: policyId, | ||
| 399 | data: { | ||
| 400 | ...existing.data, | ||
| 401 | ...(input.pattern !== undefined ? { pattern: input.pattern } : {}), | ||
| 402 | ...(input.action !== undefined ? { action: input.action } : {}), | ||
| 403 | ...(input.position !== undefined ? { position: input.position } : {}), | ||
| 404 | updatedAt: Date.now(), | ||
| 405 | }, | ||
| 406 | }); | ||
| 407 | return policyToResponse(entry.data); | ||
| 408 | }); | ||
| 409 | |||
| 410 | const removePolicy = (toolkitId: string, policyId: string) => | ||
| 411 | Effect.gen(function* () { | ||
| 412 | const toolkit = yield* requireToolkit(toolkitId); | ||
| 413 | yield* requirePolicy(toolkitId, policyId, toolkit.owner); | ||
| 414 | yield* storage.policies.remove({ owner: toolkit.owner, key: policyId }); | ||
| 415 | }); | ||
| 416 | |||
| 417 | const listConnections = (toolkitId: string) => | ||
| 418 | requireToolkit(toolkitId).pipe(Effect.flatMap(() => listConnectionsForRecord(toolkitId))); | ||
| 419 | |||
| 420 | const createConnection = ( | ||
| 421 | toolkitId: string, | ||
| 422 | input: { | ||
| 423 | readonly pattern: string; | ||
| 424 | readonly position?: string; | ||
| 425 | }, | ||
| 426 | ) => | ||
| 427 | Effect.gen(function* () { | ||
| 428 | yield* validatePolicyPattern(input.pattern); | ||
| 429 | const toolkit = yield* requireToolkit(toolkitId); | ||
| 430 | const existing = yield* listConnectionsForRecord(toolkitId); | ||
| 431 | const duplicate = existing.find((connection) => connection.pattern === input.pattern); | ||
| 432 | if (duplicate) return connectionToResponse(duplicate); | ||
| 433 | const minPosition = existing | ||
| 434 | .map((row) => row.position) | ||
| 435 | .sort() | ||
| 436 | .at(0); | ||
| 437 | const now = Date.now(); | ||
| 438 | const id = newId("tkconn"); | ||
| 439 | const entry = yield* storage.connections.put({ | ||
| 440 | owner: toolkit.owner, | ||
| 441 | key: id, | ||
| 442 | data: { | ||
| 443 | id, | ||
| 444 | toolkitId, | ||
| 445 | pattern: input.pattern, | ||
| 446 | position: input.position ?? generateKeyBetween(null, minPosition ?? null), | ||
| 447 | createdAt: now, | ||
| 448 | updatedAt: now, | ||
| 449 | }, | ||
| 450 | }); | ||
| 451 | return connectionToResponse(entry.data); | ||
| 452 | }); | ||
| 453 | |||
| 454 | const removeConnection = (toolkitId: string, connectionId: string) => | ||
| 455 | Effect.gen(function* () { | ||
| 456 | const toolkit = yield* requireToolkit(toolkitId); | ||
| 457 | yield* requireConnection(toolkitId, connectionId, toolkit.owner); | ||
| 458 | yield* storage.connections.remove({ owner: toolkit.owner, key: connectionId }); | ||
| 459 | }); | ||
| 460 | |||
| 461 | const policyRulesForSlug = ( | ||
| 462 | slug: string, | ||
| 463 | ): Effect.Effect<readonly ToolPolicyProviderRule[], StorageFailure> => | ||
| 464 | Effect.gen(function* () { | ||
| 465 | const toolkit = yield* getBySlugEntry(slug); | ||
| 466 | if (!toolkit) return []; | ||
| 467 | const policies = yield* listPoliciesForRecord(toolkit.data.id); | ||
| 468 | return policies | ||
| 469 | .filter((policy) => !isLegacyConnectionPolicy(policy)) | ||
| 470 | .map((policy) => ({ | ||
| 471 | id: policy.id, | ||
| 472 | pattern: policy.pattern, | ||
| 473 | action: policy.action, | ||
| 474 | position: policy.position, | ||
| 475 | })); | ||
| 476 | }); | ||
| 477 | |||
| 478 | const resolvePolicyForSlug = ( | ||
| 479 | slug: string, | ||
| 480 | toolId: string, | ||
| 481 | defaultRequiresApproval?: boolean, | ||
| 482 | ): Effect.Effect<EffectivePolicy, StorageFailure> => | ||
| 483 | Effect.gen(function* () { | ||
| 484 | const toolkit = yield* getBySlugEntry(slug); | ||
| 485 | if (!toolkit) return blockedPolicy(); | ||
| 486 | if (toolkit.owner === "org" && isPersonalDynamicToolId(toolId)) return blockedPolicy(); | ||
| 487 | const policies = yield* listPoliciesForRecord(toolkit.data.id); | ||
| 488 | const connections = yield* listConnectionsForRecord(toolkit.data.id); | ||
| 489 | return resolveToolkitPolicy(toolId, connections, policies, defaultRequiresApproval); | ||
| 490 | }); | ||
| 491 | |||
| 492 | return { | ||
| 493 | list, | ||
| 494 | create, | ||
| 495 | update, | ||
| 496 | remove, | ||
| 497 | listPolicies: (toolkitId: string) => | ||
| 498 | listPolicies(toolkitId).pipe(Effect.map((policies) => policies.map(policyToResponse))), | ||
| 499 | createPolicy, | ||
| 500 | updatePolicy, | ||
| 501 | removePolicy, | ||
| 502 | listConnections: (toolkitId: string) => | ||
| 503 | listConnections(toolkitId).pipe( | ||
| 504 | Effect.map((connections) => connections.map(connectionToResponse)), | ||
| 505 | ), | ||
| 506 | createConnection, | ||
| 507 | removeConnection, | ||
| 508 | policyRulesForSlug, | ||
| 509 | resolvePolicyForSlug, | ||
| 510 | }; | ||
| 511 | }; | ||
| 512 | |||
| 513 | export type ToolkitsExtension = ReturnType<typeof makeToolkitsExtension>; | ||
| 514 | |||
| 515 | export class ToolkitsExtensionService extends Context.Service< | ||
| 516 | ToolkitsExtensionService, | ||
| 517 | ToolkitsExtension | ||
| 518 | >()("ToolkitsExtensionService") {} | ||
| 519 | |||
| 520 | const ExecutorApiWithToolkits = addGroup(ToolkitsApi); | ||
| 521 | |||
| 522 | const ToolkitsHandlers = HttpApiBuilder.group(ExecutorApiWithToolkits, "toolkits", (handlers) => | ||
| 523 | handlers | ||
| 524 | .handle("list", () => | ||
| 525 | capture( | ||
| 526 | Effect.gen(function* () { | ||
| 527 | const ext = yield* ToolkitsExtensionService; | ||
| 528 | const toolkits = yield* ext.list(); | ||
| 529 | return { toolkits }; | ||
| 530 | }), | ||
| 531 | ), | ||
| 532 | ) | ||
| 533 | .handle("create", ({ payload }) => | ||
| 534 | capture( | ||
| 535 | Effect.gen(function* () { | ||
| 536 | const ext = yield* ToolkitsExtensionService; | ||
| 537 | return yield* ext.create(payload); | ||
| 538 | }), | ||
| 539 | ), | ||
| 540 | ) | ||
| 541 | .handle("update", ({ params, payload }) => | ||
| 542 | capture( | ||
| 543 | Effect.gen(function* () { | ||
| 544 | const ext = yield* ToolkitsExtensionService; | ||
| 545 | return yield* ext.update(params.toolkitId, payload); | ||
| 546 | }), | ||
| 547 | ), | ||
| 548 | ) | ||
| 549 | .handle("remove", ({ params }) => | ||
| 550 | capture( | ||
| 551 | Effect.gen(function* () { | ||
| 552 | const ext = yield* ToolkitsExtensionService; | ||
| 553 | yield* ext.remove(params.toolkitId); | ||
| 554 | return { removed: true }; | ||
| 555 | }), | ||
| 556 | ), | ||
| 557 | ) | ||
| 558 | .handle("listPolicies", ({ params }) => | ||
| 559 | capture( | ||
| 560 | Effect.gen(function* () { | ||
| 561 | const ext = yield* ToolkitsExtensionService; | ||
| 562 | const policies = yield* ext.listPolicies(params.toolkitId); | ||
| 563 | return { policies }; | ||
| 564 | }), | ||
| 565 | ), | ||
| 566 | ) | ||
| 567 | .handle("createPolicy", ({ params, payload }) => | ||
| 568 | capture( | ||
| 569 | Effect.gen(function* () { | ||
| 570 | const ext = yield* ToolkitsExtensionService; | ||
| 571 | return yield* ext.createPolicy(params.toolkitId, payload); | ||
| 572 | }), | ||
| 573 | ), | ||
| 574 | ) | ||
| 575 | .handle("updatePolicy", ({ params, payload }) => | ||
| 576 | capture( | ||
| 577 | Effect.gen(function* () { | ||
| 578 | const ext = yield* ToolkitsExtensionService; | ||
| 579 | return yield* ext.updatePolicy(params.toolkitId, params.policyId, payload); | ||
| 580 | }), | ||
| 581 | ), | ||
| 582 | ) | ||
| 583 | .handle("removePolicy", ({ params }) => | ||
| 584 | capture( | ||
| 585 | Effect.gen(function* () { | ||
| 586 | const ext = yield* ToolkitsExtensionService; | ||
| 587 | yield* ext.removePolicy(params.toolkitId, params.policyId); | ||
| 588 | return { removed: true }; | ||
| 589 | }), | ||
| 590 | ), | ||
| 591 | ) | ||
| 592 | .handle("listConnections", ({ params }) => | ||
| 593 | capture( | ||
| 594 | Effect.gen(function* () { | ||
| 595 | const ext = yield* ToolkitsExtensionService; | ||
| 596 | const connections = yield* ext.listConnections(params.toolkitId); | ||
| 597 | return { connections }; | ||
| 598 | }), | ||
| 599 | ), | ||
| 600 | ) | ||
| 601 | .handle("createConnection", ({ params, payload }) => | ||
| 602 | capture( | ||
| 603 | Effect.gen(function* () { | ||
| 604 | const ext = yield* ToolkitsExtensionService; | ||
| 605 | return yield* ext.createConnection(params.toolkitId, payload); | ||
| 606 | }), | ||
| 607 | ), | ||
| 608 | ) | ||
| 609 | .handle("removeConnection", ({ params }) => | ||
| 610 | capture( | ||
| 611 | Effect.gen(function* () { | ||
| 612 | const ext = yield* ToolkitsExtensionService; | ||
| 613 | yield* ext.removeConnection(params.toolkitId, params.connectionId); | ||
| 614 | return { removed: true }; | ||
| 615 | }), | ||
| 616 | ), | ||
| 617 | ), | ||
| 618 | ); | ||
| 619 | |||
| 620 | const makePolicyProvider = ( | ||
| 621 | extension: Pick<ToolkitsExtension, "policyRulesForSlug" | "resolvePolicyForSlug">, | ||
| 622 | slug: string, | ||
| 623 | ): ToolPolicyProvider => ({ | ||
| 624 | list: () => extension.policyRulesForSlug(slug), | ||
| 625 | resolve: ({ toolId, defaultRequiresApproval }) => | ||
| 626 | extension.resolvePolicyForSlug(slug, toolId, defaultRequiresApproval), | ||
| 627 | }); | ||
| 628 | |||
| 629 | export const toolkitsPlugin = definePlugin((options: ToolkitsPluginOptions = {}) => { | ||
| 630 | const activeToolkitSlug = options.activeToolkitSlug; | ||
| 631 | return { | ||
| 632 | id: "toolkits" as const, | ||
| 633 | packageName: "@executor-js/plugin-toolkits", | ||
| 634 | pluginStorage: { | ||
| 635 | toolkits: toolkitsCollection, | ||
| 636 | toolkitPolicies: toolkitPoliciesCollection, | ||
| 637 | toolkitConnections: toolkitConnectionsCollection, | ||
| 638 | }, | ||
| 639 | storage: ({ pluginStorage }) => makeToolkitStorage(pluginStorage), | ||
| 640 | extension: makeToolkitsExtension, | ||
| 641 | routes: () => ToolkitsApi, | ||
| 642 | handlers: () => ToolkitsHandlers, | ||
| 643 | extensionService: ToolkitsExtensionService, | ||
| 644 | ...(activeToolkitSlug | ||
| 645 | ? { | ||
| 646 | toolPolicyProvider: (ctx: PluginCtx<ToolkitStorage>) => | ||
| 647 | makePolicyProvider(makeToolkitsExtension(ctx), activeToolkitSlug), | ||
| 648 | } | ||
| 649 | : {}), | ||
| 650 | }; | ||
| 651 | }); | ||
| 652 | |||
| 653 | export default toolkitsPlugin; | ||
| @@ -0,0 +1,166 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | ||
| 3 | import { InternalError, Owner, ToolPolicyActionSchema } from "@executor-js/sdk/shared"; | ||
| 4 | |||
| 5 | export class ToolkitError extends Schema.TaggedErrorClass<ToolkitError>()( | ||
| 6 | "ToolkitError", | ||
| 7 | { message: Schema.String }, | ||
| 8 | { httpApiStatus: 400 }, | ||
| 9 | ) {} | ||
| 10 | |||
| 11 | const ToolkitParams = { | ||
| 12 | toolkitId: Schema.String, | ||
| 13 | }; | ||
| 14 | |||
| 15 | const ToolkitPolicyParams = { | ||
| 16 | toolkitId: Schema.String, | ||
| 17 | policyId: Schema.String, | ||
| 18 | }; | ||
| 19 | |||
| 20 | const ToolkitConnectionParams = { | ||
| 21 | toolkitId: Schema.String, | ||
| 22 | connectionId: Schema.String, | ||
| 23 | }; | ||
| 24 | |||
| 25 | export const ToolkitResponse = Schema.Struct({ | ||
| 26 | id: Schema.String, | ||
| 27 | owner: Owner, | ||
| 28 | slug: Schema.String, | ||
| 29 | name: Schema.String, | ||
| 30 | createdAt: Schema.Number, | ||
| 31 | updatedAt: Schema.Number, | ||
| 32 | }); | ||
| 33 | export type ToolkitResponse = typeof ToolkitResponse.Type; | ||
| 34 | |||
| 35 | export const ToolkitPolicyResponse = Schema.Struct({ | ||
| 36 | id: Schema.String, | ||
| 37 | toolkitId: Schema.String, | ||
| 38 | pattern: Schema.String, | ||
| 39 | action: ToolPolicyActionSchema, | ||
| 40 | position: Schema.String, | ||
| 41 | createdAt: Schema.Number, | ||
| 42 | updatedAt: Schema.Number, | ||
| 43 | }); | ||
| 44 | export type ToolkitPolicyResponse = typeof ToolkitPolicyResponse.Type; | ||
| 45 | |||
| 46 | export const ToolkitConnectionResponse = Schema.Struct({ | ||
| 47 | id: Schema.String, | ||
| 48 | toolkitId: Schema.String, | ||
| 49 | pattern: Schema.String, | ||
| 50 | position: Schema.String, | ||
| 51 | createdAt: Schema.Number, | ||
| 52 | updatedAt: Schema.Number, | ||
| 53 | }); | ||
| 54 | export type ToolkitConnectionResponse = typeof ToolkitConnectionResponse.Type; | ||
| 55 | |||
| 56 | const CreateToolkitPayload = Schema.Struct({ | ||
| 57 | owner: Owner, | ||
| 58 | name: Schema.String, | ||
| 59 | slug: Schema.optional(Schema.String), | ||
| 60 | }); | ||
| 61 | |||
| 62 | const UpdateToolkitPayload = Schema.Struct({ | ||
| 63 | name: Schema.optional(Schema.String), | ||
| 64 | slug: Schema.optional(Schema.String), | ||
| 65 | }); | ||
| 66 | |||
| 67 | const CreateToolkitPolicyPayload = Schema.Struct({ | ||
| 68 | pattern: Schema.String, | ||
| 69 | action: ToolPolicyActionSchema, | ||
| 70 | position: Schema.optional(Schema.String), | ||
| 71 | }); | ||
| 72 | |||
| 73 | const UpdateToolkitPolicyPayload = Schema.Struct({ | ||
| 74 | pattern: Schema.optional(Schema.String), | ||
| 75 | action: Schema.optional(ToolPolicyActionSchema), | ||
| 76 | position: Schema.optional(Schema.String), | ||
| 77 | }); | ||
| 78 | |||
| 79 | const CreateToolkitConnectionPayload = Schema.Struct({ | ||
| 80 | pattern: Schema.String, | ||
| 81 | position: Schema.optional(Schema.String), | ||
| 82 | }); | ||
| 83 | |||
| 84 | const ToolkitErrors = [InternalError, ToolkitError] as const; | ||
| 85 | |||
| 86 | export const ToolkitsApi = HttpApiGroup.make("toolkits") | ||
| 87 | .add( | ||
| 88 | HttpApiEndpoint.get("list", "/toolkits", { | ||
| 89 | success: Schema.Struct({ toolkits: Schema.Array(ToolkitResponse) }), | ||
| 90 | error: ToolkitErrors, | ||
| 91 | }), | ||
| 92 | ) | ||
| 93 | .add( | ||
| 94 | HttpApiEndpoint.post("create", "/toolkits", { | ||
| 95 | payload: CreateToolkitPayload, | ||
| 96 | success: ToolkitResponse, | ||
| 97 | error: ToolkitErrors, | ||
| 98 | }), | ||
| 99 | ) | ||
| 100 | .add( | ||
| 101 | HttpApiEndpoint.patch("update", "/toolkits/:toolkitId", { | ||
| 102 | params: ToolkitParams, | ||
| 103 | payload: UpdateToolkitPayload, | ||
| 104 | success: ToolkitResponse, | ||
| 105 | error: ToolkitErrors, | ||
| 106 | }), | ||
| 107 | ) | ||
| 108 | .add( | ||
| 109 | HttpApiEndpoint.delete("remove", "/toolkits/:toolkitId", { | ||
| 110 | params: ToolkitParams, | ||
| 111 | success: Schema.Struct({ removed: Schema.Boolean }), | ||
| 112 | error: ToolkitErrors, | ||
| 113 | }), | ||
| 114 | ) | ||
| 115 | .add( | ||
| 116 | HttpApiEndpoint.get("listPolicies", "/toolkits/:toolkitId/policies", { | ||
| 117 | params: ToolkitParams, | ||
| 118 | success: Schema.Struct({ policies: Schema.Array(ToolkitPolicyResponse) }), | ||
| 119 | error: ToolkitErrors, | ||
| 120 | }), | ||
| 121 | ) | ||
| 122 | .add( | ||
| 123 | HttpApiEndpoint.post("createPolicy", "/toolkits/:toolkitId/policies", { | ||
| 124 | params: ToolkitParams, | ||
| 125 | payload: CreateToolkitPolicyPayload, | ||
| 126 | success: ToolkitPolicyResponse, | ||
| 127 | error: ToolkitErrors, | ||
| 128 | }), | ||
| 129 | ) | ||
| 130 | .add( | ||
| 131 | HttpApiEndpoint.patch("updatePolicy", "/toolkits/:toolkitId/policies/:policyId", { | ||
| 132 | params: ToolkitPolicyParams, | ||
| 133 | payload: UpdateToolkitPolicyPayload, | ||
| 134 | success: ToolkitPolicyResponse, | ||
| 135 | error: ToolkitErrors, | ||
| 136 | }), | ||
| 137 | ) | ||
| 138 | .add( | ||
| 139 | HttpApiEndpoint.delete("removePolicy", "/toolkits/:toolkitId/policies/:policyId", { | ||
| 140 | params: ToolkitPolicyParams, | ||
| 141 | success: Schema.Struct({ removed: Schema.Boolean }), | ||
| 142 | error: ToolkitErrors, | ||
| 143 | }), | ||
| 144 | ) | ||
| 145 | .add( | ||
| 146 | HttpApiEndpoint.get("listConnections", "/toolkits/:toolkitId/connections", { | ||
| 147 | params: ToolkitParams, | ||
| 148 | success: Schema.Struct({ connections: Schema.Array(ToolkitConnectionResponse) }), | ||
| 149 | error: ToolkitErrors, | ||
| 150 | }), | ||
| 151 | ) | ||
| 152 | .add( | ||
| 153 | HttpApiEndpoint.post("createConnection", "/toolkits/:toolkitId/connections", { | ||
| 154 | params: ToolkitParams, | ||
| 155 | payload: CreateToolkitConnectionPayload, | ||
| 156 | success: ToolkitConnectionResponse, | ||
| 157 | error: ToolkitErrors, | ||
| 158 | }), | ||
| 159 | ) | ||
| 160 | .add( | ||
| 161 | HttpApiEndpoint.delete("removeConnection", "/toolkits/:toolkitId/connections/:connectionId", { | ||
| 162 | params: ToolkitConnectionParams, | ||
| 163 | success: Schema.Struct({ removed: Schema.Boolean }), | ||
| 164 | error: ToolkitErrors, | ||
| 165 | }), | ||
| 166 | ); | ||
| @@ -0,0 +1,23 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ES2022", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "Bundler", | ||
| 6 | "strict": true, | ||
| 7 | "skipLibCheck": true, | ||
| 8 | "lib": ["ES2022", "DOM"], | ||
| 9 | "types": ["bun-types", "node"], | ||
| 10 | "noUnusedLocals": true, | ||
| 11 | "noImplicitOverride": true, | ||
| 12 | "jsx": "react-jsx", | ||
| 13 | "plugins": [ | ||
| 14 | { | ||
| 15 | "name": "@effect/language-service", | ||
| 16 | "ignoreEffectSuggestionsInTscExitCode": true, | ||
| 17 | "ignoreEffectWarningsInTscExitCode": true, | ||
| 18 | "diagnosticSeverity": {} | ||
| 19 | } | ||
| 20 | ] | ||
| 21 | }, | ||
| 22 | "include": ["src/**/*.ts", "src/**/*.tsx"] | ||
| 23 | } | ||
| @@ -0,0 +1,14 @@ | |||
| 1 | import { defineConfig } from "tsup"; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | entry: { | ||
| 5 | server: "src/server.ts", | ||
| 6 | client: "src/client.tsx", | ||
| 7 | shared: "src/shared.ts", | ||
| 8 | }, | ||
| 9 | format: ["esm"], | ||
| 10 | dts: true, | ||
| 11 | clean: true, | ||
| 12 | sourcemap: true, | ||
| 13 | external: ["react"], | ||
| 14 | }); | ||
| @@ -0,0 +1,7 @@ | |||
| 1 | import { defineConfig } from "vitest/config"; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | test: { | ||
| 5 | globals: false, | ||
| 6 | }, | ||
| 7 | }); | ||
| @@ -172,6 +172,7 @@ export function ToolDetail(props: { · 171 unmodified lines | |||
| 172 | * applies a user rule to this tool's exact id. */ | 172 | * applies a user rule to this tool's exact id. */ |
| 173 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; | 173 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 174 | onClearPolicy?: (pattern: string) => void; | 174 | onClearPolicy?: (pattern: string) => void; |
| 175 | patternForDisplay?: (displayPattern: string) => string; | ||
| 175 | /** Run-tab wiring. When `integration` + `runToolName` are provided, a third | 176 | /** Run-tab wiring. When `integration` + `runToolName` are provided, a third |
| 176 | * "Run" tab hosts the per-connection tool tester. */ | 177 | * "Run" tab hosts the per-connection tool tester. */ |
| 177 | integration?: IntegrationSlug; | 178 | integration?: IntegrationSlug; |
| @@ -187,6 +188,7 @@ export function ToolDetail(props: { · 9 unmodified lines | |||
| 187 | const canRun = props.integration != null && props.runToolName != null; | 188 | const canRun = props.integration != null && props.runToolName != null; |
| 188 | // Don't strand the user on the Run tab when this ToolDetail has no run wiring. | 189 | // Don't strand the user on the Run tab when this ToolDetail has no run wiring. |
| 189 | const activeTab = tab === "run" && !canRun ? "schema" : tab; | 190 | const activeTab = tab === "run" && !canRun ? "schema" : tab; |
| 191 | const isBlocked = props.policy?.action === "block"; | ||
| 190 | 192 | ||
| 191 | const data = useMemo(() => { | 193 | const data = useMemo(() => { |
| 192 | if (!AsyncResult.isSuccess(toolContract)) return null; | 194 | if (!AsyncResult.isSuccess(toolContract)) return null; |
| @@ -244,6 +246,7 @@ export function ToolDetail(props: { · 51 unmodified lines | |||
| 244 | policy={props.policy} | 246 | policy={props.policy} |
| 245 | onSetPolicy={props.onSetPolicy} | 247 | onSetPolicy={props.onSetPolicy} |
| 246 | onClearPolicy={props.onClearPolicy} | 248 | onClearPolicy={props.onClearPolicy} |
| 249 | patternForDisplay={props.patternForDisplay} | ||
| 247 | /> | 250 | /> |
| 248 | </div> | 251 | </div> |
| 249 | {data?.description && <ToolDescription description={data.description} />} | 252 | {data?.description && <ToolDescription description={data.description} />} |
| @@ -300,48 +303,59 @@ export function ToolDetail(props: { · 50 unmodified lines | |||
| 300 | 303 | ||
| 301 | {/* Content */} | 304 | {/* Content */} |
| 302 | <div className="flex-1 overflow-y-auto"> | 305 | <div className="flex-1 overflow-y-auto"> |
| 303 | {AsyncResult.match(toolContract, { | 306 | {isBlocked ? ( |
| 304 | onInitial: () => <div className="p-5 text-sm text-muted-foreground">Loading…</div>, | 307 | <div className="px-5 py-5"> |
| 305 | onFailure: () => <div className="p-5 text-sm text-destructive">Something went wrong</div>, | 308 | <EmptySection |
| 306 | onSuccess: () => | 309 | title="Blocked" |
| 307 | activeTab === "run" && props.integration && props.runToolName ? ( | 310 | message="This tool is not available through the current toolkit." |
| 308 | <div className="px-5 py-5"> | 311 | /> |
| 309 | <ToolRunPanel | 312 | </div> |
| 310 | integration={props.integration} | 313 | ) : ( |
| 311 | toolName={props.runToolName} | 314 | AsyncResult.match(toolContract, { |
| 312 | connections={props.connections ?? []} | 315 | onInitial: () => <div className="p-5 text-sm text-muted-foreground">Loading…</div>, |
| 313 | initialConnectionName={props.initialConnectionName} | 316 | onFailure: () => ( |
| 314 | /> | 317 | <div className="p-5 text-sm text-destructive">Something went wrong</div> |
| 315 | </div> | ||
| 316 | ) : activeTab === "schema" ? ( | ||
| 317 | <div className="px-5 py-5 space-y-5"> | ||
| 318 | {data?.inputSchema ? ( | ||
| 319 | <SchemaExplorer | ||
| 320 | schema={data.inputSchema} | ||
| 321 | schemaDefinitions={data.schemaDefinitions} | ||
| 322 | title="Parameters" | ||
| 323 | /> | ||
| 324 | ) : ( | ||
| 325 | <EmptySection title="Parameters" message="None" /> | ||
| 326 | )} | ||
| 327 | {data?.outputSchema ? ( | ||
| 328 | <SchemaExplorer | ||
| 329 | schema={data.outputSchema} | ||
| 330 | schemaDefinitions={data.schemaDefinitions} | ||
| 331 | title="Response" | ||
| 332 | /> | ||
| 333 | ) : ( | ||
| 334 | <EmptySection title="Response" message="None" /> | ||
| 335 | )} | ||
| 336 | </div> | ||
| 337 | ) : ( | ||
| 338 | <ToolTypeScriptPanel | ||
| 339 | inputTypeScript={data?.inputTypeScript ?? null} | ||
| 340 | outputTypeScript={data?.outputTypeScript ?? null} | ||
| 341 | definitions={data?.definitions ?? []} | ||
| 342 | /> | ||
| 343 | ), | 318 | ), |
| 344 | })} | 319 | onSuccess: () => |
| 320 | activeTab === "run" && props.integration && props.runToolName ? ( | ||
| 321 | <div className="px-5 py-5"> | ||
| 322 | <ToolRunPanel | ||
| 323 | integration={props.integration} | ||
| 324 | toolName={props.runToolName} | ||
| 325 | connections={props.connections ?? []} | ||
| 326 | initialConnectionName={props.initialConnectionName} | ||
| 327 | /> | ||
| 328 | </div> | ||
| 329 | ) : activeTab === "schema" ? ( | ||
| 330 | <div className="px-5 py-5 space-y-5"> | ||
| 331 | {data?.inputSchema ? ( | ||
| 332 | <SchemaExplorer | ||
| 333 | schema={data.inputSchema} | ||
| 334 | schemaDefinitions={data.schemaDefinitions} | ||
| 335 | title="Parameters" | ||
| 336 | /> | ||
| 337 | ) : ( | ||
| 338 | <EmptySection title="Parameters" message="None" /> | ||
| 339 | )} | ||
| 340 | {data?.outputSchema ? ( | ||
| 341 | <SchemaExplorer | ||
| 342 | schema={data.outputSchema} | ||
| 343 | schemaDefinitions={data.schemaDefinitions} | ||
| 344 | title="Response" | ||
| 345 | /> | ||
| 346 | ) : ( | ||
| 347 | <EmptySection title="Response" message="None" /> | ||
| 348 | )} | ||
| 349 | </div> | ||
| 350 | ) : ( | ||
| 351 | <ToolTypeScriptPanel | ||
| 352 | inputTypeScript={data?.inputTypeScript ?? null} | ||
| 353 | outputTypeScript={data?.outputTypeScript ?? null} | ||
| 354 | definitions={data?.definitions ?? []} | ||
| 355 | /> | ||
| 356 | ), | ||
| 357 | }) | ||
| 358 | )} | ||
| 345 | </div> | 359 | </div> |
| 346 | </div> | 360 | </div> |
| 347 | ); | 361 | ); |
| @@ -400,12 +414,15 @@ function PolicyBadgeMenu(props: { · 52 unmodified lines | |||
| 400 | policy?: EffectivePolicy; | 414 | policy?: EffectivePolicy; |
| 401 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; | 415 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 402 | onClearPolicy?: (pattern: string) => void; | 416 | onClearPolicy?: (pattern: string) => void; |
| 417 | patternForDisplay?: (displayPattern: string) => string; | ||
| 403 | }) { | 418 | }) { |
| 404 | const interactive = !!props.onSetPolicy; | 419 | const interactive = !!props.onSetPolicy; |
| 405 | // The same pattern bridge the tree rows apply — the pattern WRITTEN and the | 420 | // The same pattern bridge the tree rows apply — the pattern WRITTEN and the |
| 406 | // pattern LOOKED UP must be the same string, or the menu authors rules that | 421 | // pattern LOOKED UP must be the same string, or the menu authors rules that |
| 407 | // never match and can't see its own rule afterward. | 422 | // never match and can't see its own rule afterward. |
| 408 | const pattern = props.staticTool ? props.toolName : toPolicyPattern(props.toolName); | 423 | const pattern = props.staticTool |
| 424 | ? props.toolName | ||
| 425 | : (props.patternForDisplay ?? toPolicyPattern)(props.toolName); | ||
| 409 | // The "Clear" affordance only makes sense when there's a user rule | 426 | // The "Clear" affordance only makes sense when there's a user rule |
| 410 | // pinned to this exact tool id — clearing a wildcard rule from a | 427 | // pinned to this exact tool id — clearing a wildcard rule from a |
| 411 | // single tool's detail header would silently affect siblings. | 428 | // single tool's detail header would silently affect siblings. |
| @@ -283,6 +283,8 @@ export function ToolTree(props: { · 282 unmodified lines | |||
| 283 | * emit the tool's full dotted id; group rows emit `prefix.*`. */ | 283 | * emit the tool's full dotted id; group rows emit `prefix.*`. */ |
| 284 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; | 284 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 285 | onClearPolicy?: (pattern: string) => void; | 285 | onClearPolicy?: (pattern: string) => void; |
| 286 | /** Maps the displayed row path into the persisted policy pattern. */ | ||
| 287 | patternForDisplay?: (displayPattern: string) => string; | ||
| 286 | /** Sorted user-authored policies (most-precedent first). Used to | 288 | /** Sorted user-authored policies (most-precedent first). Used to |
| 287 | * decide whether a node has its own exact-pattern user rule today | 289 | * decide whether a node has its own exact-pattern user rule today |
| 288 | * (so the menu can show a "Clear" option). Optional — when absent, | 290 | * (so the menu can show a "Clear" option). Optional — when absent, |
| @@ -300,6 +302,7 @@ export function ToolTree(props: { · 11 unmodified lines | |||
| 300 | onSelect, | 302 | onSelect, |
| 301 | onSetPolicy, | 303 | onSetPolicy, |
| 302 | onClearPolicy, | 304 | onClearPolicy, |
| 305 | patternForDisplay = toPolicyPattern, | ||
| 303 | policies, | 306 | policies, |
| 304 | groupByConnection, | 307 | groupByConnection, |
| 305 | } = props; | 308 | } = props; |
| @@ -358,6 +361,7 @@ export function ToolTree(props: { · 52 unmodified lines | |||
| 358 | onSelect, | 361 | onSelect, |
| 359 | onSetPolicy, | 362 | onSetPolicy, |
| 360 | onClearPolicy, | 363 | onClearPolicy, |
| 364 | patternForDisplay, | ||
| 361 | exactPatterns, | 365 | exactPatterns, |
| 362 | search, | 366 | search, |
| 363 | terms, | 367 | terms, |
| @@ -433,6 +437,7 @@ function ToolTreeBody(props: { · 69 unmodified lines | |||
| 433 | onSelect: (toolId: string) => void; | 437 | onSelect: (toolId: string) => void; |
| 434 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; | 438 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 435 | onClearPolicy?: (pattern: string) => void; | 439 | onClearPolicy?: (pattern: string) => void; |
| 440 | patternForDisplay: (displayPattern: string) => string; | ||
| 436 | exactPatterns: ReadonlyMap<string, ToolPolicyAction>; | 441 | exactPatterns: ReadonlyMap<string, ToolPolicyAction>; |
| 437 | search: string; | 442 | search: string; |
| 438 | terms: readonly string[]; | 443 | terms: readonly string[]; |
| @@ -444,6 +449,7 @@ function ToolTreeBody(props: { · 5 unmodified lines | |||
| 444 | onSelect, | 449 | onSelect, |
| 445 | onSetPolicy, | 450 | onSetPolicy, |
| 446 | onClearPolicy, | 451 | onClearPolicy, |
| 452 | patternForDisplay, | ||
| 447 | exactPatterns, | 453 | exactPatterns, |
| 448 | search, | 454 | search, |
| 449 | terms, | 455 | terms, |
| @@ -510,7 +516,8 @@ function ToolTreeBody(props: { · 60 unmodified lines | |||
| 510 | search={search} | 516 | search={search} |
| 511 | onSetPolicy={onSetPolicy} | 517 | onSetPolicy={onSetPolicy} |
| 512 | onClearPolicy={onClearPolicy} | 518 | onClearPolicy={onClearPolicy} |
| 513 | exactRule={exactPatterns.get(toPolicyPattern(row.tool.name))} | 519 | patternForDisplay={patternForDisplay} |
| 520 | exactRule={exactPatterns.get(patternForDisplay(row.tool.name))} | ||
| 514 | /> | 521 | /> |
| 515 | ) : ( | 522 | ) : ( |
| 516 | <ToolGroupRow | 523 | <ToolGroupRow |
| @@ -524,7 +531,8 @@ function ToolTreeBody(props: { · 7 unmodified lines | |||
| 524 | search={search} | 531 | search={search} |
| 525 | onSetPolicy={onSetPolicy} | 532 | onSetPolicy={onSetPolicy} |
| 526 | onClearPolicy={onClearPolicy} | 533 | onClearPolicy={onClearPolicy} |
| 527 | exactRule={exactPatterns.get(toPolicyPattern(`${row.path}.*`))} | 534 | patternForDisplay={patternForDisplay} |
| 535 | exactRule={exactPatterns.get(patternForDisplay(`${row.path}.*`))} | ||
| 528 | /> | 536 | /> |
| 529 | ), | 537 | ), |
| 530 | )} | 538 | )} |
| @@ -610,6 +618,7 @@ function ToolGroupRow(props: { · 79 unmodified lines | |||
| 610 | search: string; | 618 | search: string; |
| 611 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; | 619 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 612 | onClearPolicy?: (pattern: string) => void; | 620 | onClearPolicy?: (pattern: string) => void; |
| 621 | patternForDisplay: (displayPattern: string) => string; | ||
| 613 | exactRule?: ToolPolicyAction; | 622 | exactRule?: ToolPolicyAction; |
| 614 | }) { | 623 | }) { |
| 615 | const showActions = !!props.onSetPolicy; | 624 | const showActions = !!props.onSetPolicy; |
| @@ -647,7 +656,7 @@ function ToolGroupRow(props: { · 31 unmodified lines | |||
| 647 | )} | 656 | )} |
| 648 | > | 657 | > |
| 649 | <PolicyActionMenu | 658 | <PolicyActionMenu |
| 650 | pattern={toPolicyPattern(`${props.path}.*`)} | 659 | pattern={props.patternForDisplay(`${props.path}.*`)} |
| 651 | current={props.exactRule} | 660 | current={props.exactRule} |
| 652 | onSet={props.onSetPolicy!} | 661 | onSet={props.onSetPolicy!} |
| 653 | onClear={props.onClearPolicy} | 662 | onClear={props.onClearPolicy} |
| @@ -668,6 +677,7 @@ function ToolLeafRow(props: { · 14 unmodified lines | |||
| 668 | search: string; | 677 | search: string; |
| 669 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; | 678 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 670 | onClearPolicy?: (pattern: string) => void; | 679 | onClearPolicy?: (pattern: string) => void; |
| 680 | patternForDisplay: (displayPattern: string) => string; | ||
| 671 | exactRule?: ToolPolicyAction; | 681 | exactRule?: ToolPolicyAction; |
| 672 | }) { | 682 | }) { |
| 673 | const label = props.tool.name.split(".").pop() ?? props.tool.name; | 683 | const label = props.tool.name.split(".").pop() ?? props.tool.name; |
| @@ -719,7 +729,7 @@ function ToolLeafRow(props: { · 45 unmodified lines | |||
| 719 | )} | 729 | )} |
| 720 | > | 730 | > |
| 721 | <PolicyActionMenu | 731 | <PolicyActionMenu |
| 722 | pattern={toPolicyPattern(props.tool.name)} | 732 | pattern={props.patternForDisplay(props.tool.name)} |
| 723 | current={props.exactRule} | 733 | current={props.exactRule} |
| 724 | onSet={props.onSetPolicy!} | 734 | onSet={props.onSetPolicy!} |
| 725 | onClear={props.onClearPolicy} | 735 | onClear={props.onClearPolicy} |
| @@ -21,7 +21,7 @@ import { · 20 unmodified lines | |||
| 21 | integrationPresetIconUrl, | 21 | integrationPresetIconUrl, |
| 22 | } from "../components/integration-favicon"; | 22 | } from "../components/integration-favicon"; |
| 23 | import { CommandPalette } from "../components/command-palette"; | 23 | import { CommandPalette } from "../components/command-palette"; |
| 24 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; | 24 | import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 25 | import { useAuth } from "./auth-context"; | 25 | import { useAuth } from "./auth-context"; |
| 26 | 26 | ||
| 27 | // --------------------------------------------------------------------------- | 27 | // --------------------------------------------------------------------------- |
| @@ -63,6 +63,16 @@ export const DEFAULT_DOCS_URL = "https://executor.sh/docs"; · 35 unmodified lines | |||
| 63 | // the host's own route tree, which this package can't see. | 63 | // the host's own route tree, which this package can't see. |
| 64 | const orgScopedTo = (to: string): string => (to === "/" ? "/{-$orgSlug}" : `/{-$orgSlug}${to}`); | 64 | const orgScopedTo = (to: string): string => (to === "/" ? "/{-$orgSlug}" : `/{-$orgSlug}${to}`); |
| 65 | 65 | ||
| 66 | const normalizePluginPagePath = (path: string): string => { | ||
| 67 | if (!path || path === "/") return "/"; | ||
| 68 | return path.startsWith("/") ? path : `/${path}`; | ||
| 69 | }; | ||
| 70 | |||
| 71 | const pluginPageNavPath = (pluginId: string, path: string): string => { | ||
| 72 | const normalized = normalizePluginPagePath(path); | ||
| 73 | return normalized === "/" ? `/plugins/${pluginId}/` : `/plugins/${pluginId}${normalized}`; | ||
| 74 | }; | ||
| 75 | |||
| 66 | /** The pathname with the active org-slug prefix stripped, for active-state | 76 | /** The pathname with the active org-slug prefix stripped, for active-state |
| 67 | * comparisons against scope-relative paths. */ | 77 | * comparisons against scope-relative paths. */ |
| 68 | function useScopeRelativePathname(): string { | 78 | function useScopeRelativePathname(): string { |
| @@ -310,7 +320,20 @@ function UserFooter(props: Pick<ShellProps, "onSignOut" | "orgMenuSlot">) { · 241 unmodified lines | |||
| 310 | function SidebarContent( | 320 | function SidebarContent( |
| 311 | props: ShellProps & { pathname: string; onNavigate?: () => void; showBrand?: boolean }, | 321 | props: ShellProps & { pathname: string; onNavigate?: () => void; showBrand?: boolean }, |
| 312 | ) { | 322 | ) { |
| 313 | const navItems = props.navItems ?? defaultShellNavItems; | 323 | const plugins = useClientPlugins(); |
| 324 | const pluginNavItems = plugins.flatMap((plugin) => | ||
| 325 | (plugin.pages ?? []).flatMap((page) => | ||
| 326 | page.nav | ||
| 327 | ? [ | ||
| 328 | { | ||
| 329 | to: pluginPageNavPath(plugin.id, page.path), | ||
| 330 | label: page.nav.label, | ||
| 331 | }, | ||
| 332 | ] | ||
| 333 | : [], | ||
| 334 | ), | ||
| 335 | ); | ||
| 336 | const navItems = [...(props.navItems ?? defaultShellNavItems), ...pluginNavItems]; | ||
| 314 | return ( | 337 | return ( |
| 315 | <> | 338 | <> |
| 316 | {props.showBrand !== false && ( | 339 | {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 | }); | ||
| @@ -11,10 +11,10 @@ import { useClientPlugins } from "@executor-js/sdk/client"; · 10 unmodified lines | |||
| 11 | // plugin to `executor.config.ts` is sufficient for its pages to mount | 11 | // plugin to `executor.config.ts` is sufficient for its pages to mount |
| 12 | // here, with no per-route imports. | 12 | // here, with no per-route imports. |
| 13 | // | 13 | // |
| 14 | // Match logic is intentionally tiny: exact path equality between the URL | 14 | // Plugin pages use the same lightweight route vocabulary as the rest of the |
| 15 | // remainder and a `PageDecl.path`, with `""` and `/` treated as the | 15 | // console route tree: static segments and `$param` segments. The host route |
| 16 | // same root. Plugins that want parameterized paths can build their own | 16 | // owns that matching so plugin detail views can be real URLs instead of |
| 17 | // in-component routing for now. | 17 | // in-component state. |
| 18 | // --------------------------------------------------------------------------- | 18 | // --------------------------------------------------------------------------- |
| 19 | 19 | ||
| 20 | export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({ | 20 | export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({ |
| @@ -23,9 +23,66 @@ export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({ · 2 unmodified lines | |||
| 23 | 23 | ||
| 24 | function normalizePath(input: string): string { | 24 | function normalizePath(input: string): string { |
| 25 | if (!input || input === "/") return "/"; | 25 | if (!input || input === "/") return "/"; |
| 26 | return input.startsWith("/") ? input : `/${input}`; | 26 | const withLeadingSlash = input.startsWith("/") ? input : `/${input}`; |
| 27 | return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/, "") : "/"; | ||
| 27 | } | 28 | } |
| 28 | 29 | ||
| 30 | const pathSegments = (input: string): readonly string[] => | ||
| 31 | normalizePath(input) | ||
| 32 | .split("/") | ||
| 33 | .filter((segment) => segment.length > 0); | ||
| 34 | |||
| 35 | export const matchPluginPagePath = ( | ||
| 36 | pattern: string, | ||
| 37 | target: string, | ||
| 38 | ): Readonly<Record<string, string>> | null => { | ||
| 39 | const patternSegments = pathSegments(pattern); | ||
| 40 | const targetSegments = pathSegments(target); | ||
| 41 | if (patternSegments.length !== targetSegments.length) return null; | ||
| 42 | |||
| 43 | const params: Record<string, string> = {}; | ||
| 44 | for (let index = 0; index < patternSegments.length; index += 1) { | ||
| 45 | const patternSegment = patternSegments[index]!; | ||
| 46 | const targetSegment = targetSegments[index]!; | ||
| 47 | if (patternSegment.startsWith("$") && patternSegment.length > 1) { | ||
| 48 | params[patternSegment.slice(1)] = decodeURIComponent(targetSegment); | ||
| 49 | continue; | ||
| 50 | } | ||
| 51 | if (patternSegment !== targetSegment) return null; | ||
| 52 | } | ||
| 53 | return params; | ||
| 54 | }; | ||
| 55 | |||
| 56 | const matchScore = (pattern: string): number => | ||
| 57 | pathSegments(pattern).reduce((score, segment) => score + (segment.startsWith("$") ? 1 : 2), 0); | ||
| 58 | |||
| 59 | export const matchPluginPage = <TPage extends { readonly path: string }>( | ||
| 60 | pages: readonly TPage[] | undefined, | ||
| 61 | target: string, | ||
| 62 | ): { readonly page: TPage; readonly params: Readonly<Record<string, string>> } | null => { | ||
| 63 | const matches = | ||
| 64 | pages | ||
| 65 | ?.map((page, index) => ({ | ||
| 66 | page, | ||
| 67 | index, | ||
| 68 | params: matchPluginPagePath(page.path, target), | ||
| 69 | score: matchScore(page.path), | ||
| 70 | })) | ||
| 71 | .filter( | ||
| 72 | ( | ||
| 73 | candidate, | ||
| 74 | ): candidate is { | ||
| 75 | readonly page: TPage; | ||
| 76 | readonly index: number; | ||
| 77 | readonly params: Readonly<Record<string, string>>; | ||
| 78 | readonly score: number; | ||
| 79 | } => candidate.params !== null, | ||
| 80 | ) | ||
| 81 | .sort((a, b) => b.score - a.score || a.index - b.index) ?? []; | ||
| 82 | const first = matches[0]; | ||
| 83 | return first ? { page: first.page, params: first.params } : null; | ||
| 84 | }; | ||
| 85 | |||
| 29 | function PluginRouteComponent() { | 86 | function PluginRouteComponent() { |
| 30 | const { pluginId, _splat: rest } = Route.useParams(); | 87 | const { pluginId, _splat: rest } = Route.useParams(); |
| 31 | const plugins = useClientPlugins(); | 88 | const plugins = useClientPlugins(); |
| @@ -34,10 +91,10 @@ function PluginRouteComponent() { · 2 unmodified lines | |||
| 34 | if (!plugin) throw notFound(); | 91 | if (!plugin) throw notFound(); |
| 35 | 92 | ||
| 36 | const target = normalizePath(rest ?? "/"); | 93 | const target = normalizePath(rest ?? "/"); |
| 37 | const page = plugin.pages?.find((p) => normalizePath(p.path) === target); | 94 | const match = matchPluginPage(plugin.pages, target); |
| 38 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound() | 95 | // 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(); | 96 | if (!match) throw notFound(); |
| 40 | 97 | ||
| 41 | const Component = page.component; | 98 | const Component = match.page.component; |
| 42 | return <Component />; | 99 | return <Component params={match.params} path={target} pluginId={pluginId} />; |
| 43 | } | 100 | } |