Unified · Split
| @@ -5,6 +5,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; · 4 unmodified lines | ||
| 5 | 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| 8 | +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; | |
| 8 | 9 | |
| 9 | 10 | import { resolveSecretKey } from "./src/config"; |
| 10 | 11 | |
| @@ -19,13 +20,14 @@ import { resolveSecretKey } from "./src/config"; · 8 unmodified lines | ||
| 19 | 20 | // --------------------------------------------------------------------------- |
| 20 | 21 | |
| 21 | 22 | export default defineExecutorConfig({ |
| 22 | − plugins: () => | |
| 23 | + plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) => | |
| 23 | 24 | [ |
| 24 | 25 | openApiHttpPlugin(), |
| 25 | 26 | googleHttpPlugin(), |
| 26 | 27 | microsoftHttpPlugin(), |
| 27 | 28 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 28 | 29 | graphqlHttpPlugin(), |
| 30 | + toolkitsPlugin({ activeToolkitSlug }), | |
| 29 | 31 | // First writable secret provider -> the default for `secrets.set`. |
| 30 | 32 | encryptedSecretsPlugin({ key: resolveSecretKey() }), |
| 31 | 33 | ] as const, |
| @@ -32,6 +32,7 @@ · 31 unmodified lines | ||
| 32 | 32 | "@executor-js/plugin-mcp": "workspace:*", |
| 33 | 33 | "@executor-js/plugin-microsoft": "workspace:*", |
| 34 | 34 | "@executor-js/plugin-openapi": "workspace:*", |
| 35 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 35 | 36 | "@executor-js/react": "workspace:*", |
| 36 | 37 | "@executor-js/runtime-quickjs": "workspace:*", |
| 37 | 38 | "@executor-js/sdk": "workspace:*", |
| @@ -106,7 +106,8 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 105 unmodified lines | ||
| 106 | 106 | routes: [ |
| 107 | 107 | // CLI device-login discovery, must precede the /api/auth/* wildcard |
| 108 | 108 | // below (Better Auth would otherwise 404 it). The verification page it |
| 109 | − // points at (/device) is a console SPA route (web/device.tsx). | |
| 109 | + // points at (/device) is a console SPA route | |
| 110 | + // (web/chromeless/device-page.tsx). | |
| 110 | 111 | HttpRouter.add("GET", "/api/auth/cli-login", cliLoginHandler), |
| 111 | 112 | // Better Auth owns the rest of /api/auth/*, the full path reaches it. |
| 112 | 113 | HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)), |
| @@ -40,7 +40,11 @@ export { makeExecutionStack } from "@executor-js/api/server"; · 39 unmodified lines | ||
| 40 | 40 | |
| 41 | 41 | export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)( |
| 42 | 42 | { |
| 43 | − plugins: () => executorConfig.plugins(), | |
| 43 | + plugins: (context) => | |
| 44 | + executorConfig.plugins({ | |
| 45 | + activeToolkitSlug: | |
| 46 | + context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, | |
| 47 | + }), | |
| 44 | 48 | }, |
| 45 | 49 | ); |
| 46 | 50 | |
| @@ -6,17 +6,21 @@ describe("stripMcpOrgSegment", () => { · 5 unmodified lines | ||
| 6 | 6 | it("strips a single org segment before /mcp", () => { |
| 7 | 7 | expect(stripMcpOrgSegment("/iI9idP7BZcWpg9wW8cit3xE4r4dFSnHj/mcp")).toBe("/mcp"); |
| 8 | 8 | expect(stripMcpOrgSegment("/org_123/mcp")).toBe("/mcp"); |
| 9 | + expect(stripMcpOrgSegment("/org_123/mcp/toolkits/deploy")).toBe("/mcp/toolkits/deploy"); | |
| 9 | 10 | }); |
| 10 | 11 | |
| 11 | 12 | it("strips the org segment from the protected-resource discovery path", () => { |
| 12 | 13 | expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp")).toBe( |
| 13 | − "/.well-known/oauth-protected-resource/mcp", | |
| 14 | + "/.well-known/oauth-protected-resource", | |
| 14 | 15 | ); |
| 16 | + expect( | |
| 17 | + stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp/toolkits/deploy"), | |
| 18 | + ).toBe("/.well-known/oauth-protected-resource"); | |
| 15 | 19 | }); |
| 16 | 20 | |
| 17 | 21 | it("leaves the bare paths untouched", () => { |
| 18 | 22 | expect(stripMcpOrgSegment("/mcp")).toBeNull(); |
| 19 | − expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/mcp")).toBeNull(); | |
| 23 | + expect(stripMcpOrgSegment("/mcp/toolkits/deploy")).toBeNull(); | |
| 20 | 24 | expect(stripMcpOrgSegment("/.well-known/oauth-authorization-server")).toBeNull(); |
| 21 | 25 | }); |
| 22 | 26 | |
| @@ -20,8 +20,9 @@ const PRM_PREFIX = "/.well-known/oauth-protected-resource"; · 19 unmodified lines | ||
| 20 | 20 | * applies (already bare, not an MCP path, or an OAuth endpoint like |
| 21 | 21 | * `/api/auth/mcp/authorize`). |
| 22 | 22 | * |
| 23 | − * /<seg>/mcp -> /mcp | |
| 24 | − * /.well-known/oauth-protected-resource/<seg>/mcp -> /.well-known/oauth-protected-resource/mcp | |
| 23 | + * /<seg>/mcp -> /mcp | |
| 24 | + * /<seg>/mcp/toolkits/<toolkit> -> /mcp/toolkits/<toolkit> | |
| 25 | + * /.well-known/oauth-protected-resource/<seg>/mcp[...] -> /.well-known/oauth-protected-resource | |
| 25 | 26 | */ |
| 26 | 27 | export const stripMcpOrgSegment = (pathname: string): string | null => { |
| 27 | 28 | if (pathname.startsWith(`${PRM_PREFIX}/`)) { |
| @@ -29,8 +30,14 @@ export const stripMcpOrgSegment = (pathname: string): string | null => { · 1 unmodified line | ||
| 29 | 30 | .slice(PRM_PREFIX.length + 1) |
| 30 | 31 | .split("/") |
| 31 | 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 | 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 | 66 | rawUrl = `${pathname}${original.search}`; |
| 67 | 67 | } |
| 68 | 68 | // Match on PATHNAME, not a raw-URL prefix: `/mcp` must NOT swallow the |
| 69 | − // SPA route `/mcp-consent` (nor its source module `/mcp-consent.tsx`), | |
| 70 | − // or the dev server misroutes them to the API handler and they 404. | |
| 69 | + // SPA route `/mcp-consent`, or the dev server misroutes it to the API | |
| 70 | + // handler and returns a 404. | |
| 71 | 71 | const path = new URL(rawUrl, devOrigin).pathname; |
| 72 | 72 | const handled = |
| 73 | 73 | path === "/api" || |
File renamed without changes.
File renamed without changes.
| @@ -11,8 +11,8 @@ import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shel · 10 unmodified lines | ||
| 11 | 11 | import { plugins as clientPlugins } from "virtual:executor/plugins-client"; |
| 12 | 12 | |
| 13 | 13 | import { authClient } from "../auth-client"; |
| 14 | −import { DevicePage } from "../device"; | |
| 15 | −import { McpConsentPage } from "../mcp-consent"; | |
| 14 | +import { DevicePage } from "../chromeless/device-page"; | |
| 15 | +import { McpConsentPage } from "../chromeless/mcp-consent-page"; | |
| 16 | 16 | import { LoginPage } from "../login"; |
| 17 | 17 | import { SetupPage } from "../setup"; |
| 18 | 18 | import { fetchNeedsSetup } from "../setup-status"; |
| @@ -228,6 +228,7 @@ · 227 unmodified lines | ||
| 228 | 228 | "@executor-js/plugin-mcp": "workspace:*", |
| 229 | 229 | "@executor-js/plugin-microsoft": "workspace:*", |
| 230 | 230 | "@executor-js/plugin-openapi": "workspace:*", |
| 231 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 231 | 232 | "@executor-js/react": "workspace:*", |
| 232 | 233 | "@executor-js/runtime-quickjs": "workspace:*", |
| 233 | 234 | "@executor-js/sdk": "workspace:*", |
| @@ -346,6 +347,7 @@ · 112 unmodified lines | ||
| 346 | 347 | "@executor-js/plugin-mcp": "workspace:*", |
| 347 | 348 | "@executor-js/plugin-microsoft": "workspace:*", |
| 348 | 349 | "@executor-js/plugin-openapi": "workspace:*", |
| 350 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 349 | 351 | "@executor-js/sdk": "workspace:*", |
| 350 | 352 | "@kitlangton/terminal-control": "^0.3.0", |
| 351 | 353 | "@modelcontextprotocol/sdk": "^1.29.0", |
| @@ -1088,6 +1090,49 @@ · 736 unmodified lines | ||
| 1088 | 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 | 1136 | "packages/plugins/workos-vault": { |
| 1092 | 1137 | "name": "@executor-js/plugin-workos-vault", |
| 1093 | 1138 | "version": "0.0.2", |
| @@ -1682,6 +1727,8 @@ · 588 unmodified lines | ||
| 1682 | 1727 | |
| 1683 | 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 | 1732 | "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"], |
| 1686 | 1733 | |
| 1687 | 1734 | "@executor-js/react": ["@executor-js/react@workspace:packages/react"], |
| @@ -27,6 +27,7 @@ · 26 unmodified lines | ||
| 27 | 27 | "@executor-js/plugin-mcp": "workspace:*", |
| 28 | 28 | "@executor-js/plugin-microsoft": "workspace:*", |
| 29 | 29 | "@executor-js/plugin-openapi": "workspace:*", |
| 30 | + "@executor-js/plugin-toolkits": "workspace:*", | |
| 30 | 31 | "@executor-js/sdk": "workspace:*", |
| 31 | 32 | "@kitlangton/terminal-control": "^0.3.0", |
| 32 | 33 | "@modelcontextprotocol/sdk": "^1.29.0", |
| @@ -0,0 +1,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 | 160 | readonly url: string; |
| 161 | 161 | readonly session: ( |
| 162 | 162 | identity: Identity, |
| 163 | − options?: { readonly elicitationMode?: McpElicitationMode }, | |
| 163 | + options?: { readonly elicitationMode?: McpElicitationMode; readonly url?: string }, | |
| 164 | 164 | ) => McpSession; |
| 165 | 165 | /** |
| 166 | 166 | * Mint a real MCP bearer headlessly: protected-resource discovery → |
| @@ -188,6 +188,17 @@ interface TokenResponse { · 21 unmodified lines | ||
| 188 | 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 | 202 | const mintBearerFlow = async (target: Target, email: string): Promise<string> => { |
| 192 | 203 | const consent = target.mcpConsent?.({ |
| 193 | 204 | label: email, |
| @@ -196,21 +207,31 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 2 unmodified lines | ||
| 196 | 207 | if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`); |
| 197 | 208 | |
| 198 | 209 | const mcpPath = new URL(target.mcpUrl).pathname; |
| 199 | − const resource = (await ( | |
| 200 | − await fetch(new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl)) | |
| 201 | − ).json()) as { authorization_servers?: ReadonlyArray<string> }; | |
| 210 | + let resourceResponse = await fetch( | |
| 211 | + new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl), | |
| 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 | 222 | const issuer = resource.authorization_servers?.[0]; |
| 203 | 223 | if (!issuer) throw new Error("mintBearer: no authorization server advertised"); |
| 204 | − const metadata = (await ( | |
| 205 | − await fetch(new URL("/.well-known/oauth-authorization-server", issuer)) | |
| 206 | − ).json()) as { | |
| 224 | + const metadata = await jsonFrom<{ | |
| 207 | 225 | readonly authorization_endpoint: string; |
| 208 | 226 | readonly token_endpoint: string; |
| 209 | 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 | 233 | const redirectUri = "http://127.0.0.1:9/callback"; |
| 213 | − const registered = (await ( | |
| 234 | + const registered = await jsonFrom<{ readonly client_id: string }>( | |
| 214 | 235 | await fetch(metadata.registration_endpoint, { |
| 215 | 236 | method: "POST", |
| 216 | 237 | headers: { "content-type": "application/json" }, |
| @@ -221,8 +242,9 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 4 unmodified lines | ||
| 221 | 242 | response_types: ["code"], |
| 222 | 243 | token_endpoint_auth_method: "none", |
| 223 | 244 | }), |
| 224 | − }) | |
| 225 | − ).json()) as { readonly client_id: string }; | |
| 245 | + }), | |
| 246 | + "mintBearer: dynamic client registration", | |
| 247 | + ); | |
| 226 | 248 | |
| 227 | 249 | const verifier = randomBytes(32).toString("base64url"); |
| 228 | 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 | 262 | redirectUrl: redirectUri, |
| 241 | 263 | }); |
| 242 | 264 | |
| 243 | − const token = (await ( | |
| 265 | + const token = await jsonFrom<TokenResponse>( | |
| 244 | 266 | await fetch(metadata.token_endpoint, { |
| 245 | 267 | method: "POST", |
| 246 | 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 | 273 | client_id: registered.client_id, |
| 252 | 274 | code_verifier: verifier, |
| 253 | 275 | }), |
| 254 | − }) | |
| 255 | − ).json()) as TokenResponse; | |
| 276 | + }), | |
| 277 | + "mintBearer: token exchange", | |
| 278 | + ); | |
| 256 | 279 | if (!token.access_token) throw new Error("mintBearer: token exchange returned no token"); |
| 257 | 280 | return token.access_token; |
| 258 | 281 | }; |
| @@ -261,7 +284,8 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 2 unmodified lines | ||
| 261 | 284 | url: target.mcpUrl, |
| 262 | 285 | mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)), |
| 263 | 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 | 289 | // mcporter caches OAuth tokens (and the DCR client) per server NAME, so a |
| 266 | 290 | // constant name would let a later session reuse an earlier identity's token |
| 267 | 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 | 296 | // `?elicitation_mode=` query on the MCP endpoint — so a paused execution |
| 273 | 297 | // yields an approvalUrl instead of letting the model resume inline. |
| 274 | 298 | const sessionUrl = options?.elicitationMode |
| 275 | − ? `${target.mcpUrl}?elicitation_mode=${options.elicitationMode}` | |
| 276 | − : target.mcpUrl; | |
| 299 | + ? `${mcpUrl}?elicitation_mode=${options.elicitationMode}` | |
| 300 | + : mcpUrl; | |
| 277 | 301 | let runtimePromise: Promise<Runtime> | undefined; |
| 278 | 302 | let connected = false; |
| 279 | 303 | |
| @@ -83,7 +83,13 @@ const forcedMcpConsent = · 82 unmodified lines | ||
| 83 | 83 | if (!decision.ok) { |
| 84 | 84 | throw new Error(`forcedMcpConsent: consent grant failed (status ${decision.status})`); |
| 85 | 85 | } |
| 86 | − const body = (await decision.json()) as { redirectURI?: string }; | |
| 86 | + const decisionText = await decision.text(); | |
| 87 | + if (!decisionText) { | |
| 88 | + throw new Error( | |
| 89 | + `forcedMcpConsent: consent grant returned an empty body (status ${decision.status})`, | |
| 90 | + ); | |
| 91 | + } | |
| 92 | + const body = JSON.parse(decisionText) as { redirectURI?: string }; | |
| 87 | 93 | const code = body.redirectURI ? new URL(body.redirectURI).searchParams.get("code") : null; |
| 88 | 94 | if (!code) { |
| 89 | 95 | throw new Error( |
| @@ -118,6 +124,6 @@ export const selfhostTarget = (): Target => ({ · 28 unmodified lines | ||
| 118 | 124 | mcpConsent: (identity: Identity) => |
| 119 | 125 | forcedMcpConsent(SELFHOST_BASE_URL, { |
| 120 | 126 | email: identity.credentials?.email ?? SELFHOST_ADMIN.email, |
| 121 | − password: identity.credentials?.password ?? SELFHOST_ADMIN.password, | |
| 127 | + password: identity.credentials?.password || SELFHOST_ADMIN.password, | |
| 122 | 128 | }), |
| 123 | 129 | }); |
| @@ -26,6 +26,7 @@ · 25 unmodified lines | ||
| 26 | 26 | import { Context, Effect, Layer } from "effect"; |
| 27 | 27 | import type * as Cause from "effect/Cause"; |
| 28 | 28 | |
| 29 | +import type { McpResource } from "@executor-js/host-mcp"; | |
| 29 | 30 | import type { AnyPlugin, Executor, StorageFailure } from "@executor-js/sdk"; |
| 30 | 31 | import { |
| 31 | 32 | createExecutionEngine, |
| @@ -95,6 +96,7 @@ export const makeExecutionStack = < · 63 unmodified lines | ||
| 95 | 96 | accountId: string, |
| 96 | 97 | organizationId: string, |
| 97 | 98 | organizationName: string, |
| 99 | + options?: { readonly mcpResource?: McpResource }, | |
| 98 | 100 | ): Effect.Effect< |
| 99 | 101 | { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> }, |
| 100 | 102 | StorageFailure, |
| @@ -105,6 +107,7 @@ export const makeExecutionStack = < · 4 unmodified lines | ||
| 105 | 107 | accountId, |
| 106 | 108 | organizationId, |
| 107 | 109 | organizationName, |
| 110 | + { plugins: { mcpResource: options?.mcpResource } }, | |
| 108 | 111 | ); |
| 109 | 112 | const codeExecutor = yield* CodeExecutorProvider; |
| 110 | 113 | const { decorate } = yield* EngineDecorator; |
| @@ -39,11 +39,9 @@ export type McpExecutionStackLayer = Layer.Layer< · 38 unmodified lines | ||
| 39 | 39 | export const makeMcpBuildServer = |
| 40 | 40 | (executionStack: McpExecutionStackLayer): McpBuildServer => |
| 41 | 41 | (principal: Principal, options?: McpBuildServerOptions) => |
| 42 | − makeExecutionStack( | |
| 43 | − principal.accountId, | |
| 44 | − principal.organizationId, | |
| 45 | − principal.organizationName, | |
| 46 | − ).pipe( | |
| 42 | + makeExecutionStack(principal.accountId, principal.organizationId, principal.organizationName, { | |
| 43 | + mcpResource: options?.resource, | |
| 44 | + }).pipe( | |
| 47 | 45 | Effect.map(({ engine }) => engine), |
| 48 | 46 | // Pin browser-handoff URLs to the principal's org slug when present; |
| 49 | 47 | // absent slug leaves the service unprovided and the URL stays bare. |
| @@ -33,6 +33,7 @@ · 32 unmodified lines | ||
| 33 | 33 | |
| 34 | 34 | import { Context, Effect, Option } from "effect"; |
| 35 | 35 | |
| 36 | +import type { McpResource } from "@executor-js/host-mcp"; | |
| 36 | 37 | import { |
| 37 | 38 | createExecutor, |
| 38 | 39 | OAUTH_CALLBACK_ORG_QUERY_PARAM, |
| @@ -169,8 +170,12 @@ export const buildOAuthRedirectUri = (input: { · 130 unmodified lines | ||
| 169 | 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 | 177 | export interface PluginsProviderShape { |
| 173 | − readonly plugins: () => readonly AnyPlugin[]; | |
| 178 | + readonly plugins: (context?: PluginsProviderContext) => readonly AnyPlugin[]; | |
| 174 | 179 | } |
| 175 | 180 | |
| 176 | 181 | export class PluginsProvider extends Context.Service<PluginsProvider, PluginsProviderShape>()( |
| @@ -207,6 +212,7 @@ export const makeScopedExecutor = < · 30 unmodified lines | ||
| 207 | 212 | // `EngineStackIdentity` (the engine decorator still wants it); not part of the |
| 208 | 213 | // v2 executor binding, which is `{ tenant, subject }` only. |
| 209 | 214 | _organizationName: string, |
| 215 | + options?: { readonly plugins?: PluginsProviderContext }, | |
| 210 | 216 | ): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> => |
| 211 | 217 | Effect.gen(function* () { |
| 212 | 218 | const { db, blobs } = yield* DbProvider; |
| @@ -248,7 +254,7 @@ export const makeScopedExecutor = < · 35 unmodified lines | ||
| 248 | 254 | orgSlug, |
| 249 | 255 | }); |
| 250 | 256 | |
| 251 | − const plugins = pluginsFactory(); | |
| 257 | + const plugins = pluginsFactory(options?.plugins); | |
| 252 | 258 | const hostedHttpOptions = { |
| 253 | 259 | allowLocalNetwork: config.allowLocalNetwork, |
| 254 | 260 | }; |
| @@ -46,10 +46,19 @@ export { useAtomValue, useAtomSet, useAtomMount, useAtomRefresh } from "@effect/ · 45 unmodified lines | ||
| 46 | 46 | // alongside the host's own UI. |
| 47 | 47 | // --------------------------------------------------------------------------- |
| 48 | 48 | |
| 49 | +export interface PluginPageProps { | |
| 50 | + /** Plugin-relative route params captured from `PageDecl.path` segments. */ | |
| 51 | + readonly params: Readonly<Record<string, string>>; | |
| 52 | + /** The normalized plugin-relative URL path that matched this page. */ | |
| 53 | + readonly path: string; | |
| 54 | + /** The plugin id from `/plugins/$pluginId/...`. */ | |
| 55 | + readonly pluginId: string; | |
| 56 | +} | |
| 57 | + | |
| 49 | 58 | export interface PageDecl { |
| 50 | 59 | /** Path relative to the plugin's mount point, e.g. `/`, `/edit/$id`. */ |
| 51 | 60 | readonly path: string; |
| 52 | − readonly component: ComponentType; | |
| 61 | + readonly component: ComponentType<PluginPageProps>; | |
| 53 | 62 | /** Optional sidebar nav metadata — the host renders these alongside its |
| 54 | 63 | * own nav links. Omit to register a page without a nav entry. */ |
| 55 | 64 | readonly nav?: { |
| @@ -90,6 +90,7 @@ import type { OAuthService } from "./oauth-client"; · 89 unmodified lines | ||
| 90 | 90 | import { |
| 91 | 91 | comparePolicyRow, |
| 92 | 92 | isValidPattern, |
| 93 | + matchPattern, | |
| 93 | 94 | resolveEffectivePolicy, |
| 94 | 95 | rowToToolPolicy, |
| 95 | 96 | type CreateToolPolicyInput, |
| @@ -112,6 +113,8 @@ import type { · 16 unmodified lines | ||
| 112 | 113 | StaticSourceDecl, |
| 113 | 114 | StaticToolDecl, |
| 114 | 115 | StorageDeps, |
| 116 | + ToolPolicyProvider, | |
| 117 | + ToolPolicyProviderRule, | |
| 115 | 118 | ToolInvocationCredential, |
| 116 | 119 | } from "./plugin"; |
| 117 | 120 | import { |
| @@ -1282,6 +1285,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1164 unmodified lines | ||
| 1282 | 1285 | // Populated once, never mutated after startup. |
| 1283 | 1286 | const staticTools = new Map<string, StaticTools>(); |
| 1284 | 1287 | const runtimes = new Map<string, PluginRuntime>(); |
| 1288 | + let activeToolPolicyProvider: ToolPolicyProvider | null = null; | |
| 1285 | 1289 | // Credential providers keyed by `provider.key`, in registration order. |
| 1286 | 1290 | const credentialProviders = new Map<string, CredentialProvider>(); |
| 1287 | 1291 | const credentialProviderOrder: string[] = []; |
| @@ -2299,8 +2303,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1011 unmodified lines | ||
| 2299 | 2303 | readonly integration?: IntegrationSlug; |
| 2300 | 2304 | readonly owner?: Owner; |
| 2301 | 2305 | }): Effect.Effect<readonly Connection[], StorageFailure> => |
| 2302 | − core | |
| 2303 | − .findMany("connection", { | |
| 2306 | + Effect.gen(function* () { | |
| 2307 | + const rows = yield* core.findMany("connection", { | |
| 2304 | 2308 | where: (b: AnyCb) => |
| 2305 | 2309 | b.and( |
| 2306 | 2310 | filter?.integration === undefined |
| @@ -2308,8 +2312,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line | ||
| 2308 | 2312 | : b("integration", "=", String(filter.integration)), |
| 2309 | 2313 | filter?.owner === undefined ? true : b("owner", "=", filter.owner), |
| 2310 | 2314 | ), |
| 2311 | − }) | |
| 2312 | − .pipe(Effect.map((rows) => rows.map(rowToConnection))); | |
| 2315 | + }); | |
| 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 | 2332 | const connectionsGet = (ref: ConnectionRef): Effect.Effect<Connection | null, StorageFailure> => |
| 2315 | 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 | 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 | 2509 | // Tools (read surface) |
| 2417 | 2510 | // ------------------------------------------------------------------ |
| @@ -2490,16 +2583,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 72 unmodified lines | ||
| 2490 | 2583 | select: TOOL_INVOCATION_COLUMNS, |
| 2491 | 2584 | }); |
| 2492 | 2585 | const includeBlocked = filter?.includeBlocked ?? false; |
| 2493 | − const policyRows = yield* core.findMany("tool_policy", {}); | |
| 2586 | + const policyRules = yield* listActivePolicyRuleSet(); | |
| 2494 | 2587 | const tools: Tool[] = []; |
| 2495 | 2588 | for (const row of rows) { |
| 2496 | 2589 | const tool = rowToTool(row); |
| 2497 | 2590 | if (!matchesToolFilter(tool, filter)) continue; |
| 2498 | 2591 | if (!includeBlocked) { |
| 2499 | − const effective = resolveEffectivePolicy( | |
| 2592 | + const effective = yield* resolvePolicyFromRuleSet( | |
| 2500 | 2593 | normalizedPolicyId(tool), |
| 2501 | − policyRows, | |
| 2502 | − ownerRankForRow, | |
| 2594 | + policyRules, | |
| 2503 | 2595 | tool.annotations?.requiresApproval, |
| 2504 | 2596 | ); |
| 2505 | 2597 | if (effective.action === "block") continue; |
| @@ -2510,10 +2602,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 4 unmodified lines | ||
| 2510 | 2602 | const tool = staticToolToTool(entry); |
| 2511 | 2603 | if (!matchesToolFilter(tool, filter)) continue; |
| 2512 | 2604 | if (!includeBlocked) { |
| 2513 | − const effective = resolveEffectivePolicy( | |
| 2605 | + const effective = yield* resolvePolicyFromRuleSet( | |
| 2514 | 2606 | normalizedPolicyId(tool), |
| 2515 | − policyRows, | |
| 2516 | − ownerRankForRow, | |
| 2607 | + policyRules, | |
| 2517 | 2608 | tool.annotations?.requiresApproval, |
| 2518 | 2609 | ); |
| 2519 | 2610 | if (effective.action === "block") continue; |
| @@ -2527,9 +2618,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines | ||
| 2527 | 2618 | address: ToolAddress, |
| 2528 | 2619 | ): Effect.Effect<ToolSchemaView | null, StorageFailure> => |
| 2529 | 2620 | Effect.gen(function* () { |
| 2621 | + const policyRules = yield* listActivePolicyRuleSet(); | |
| 2530 | 2622 | const staticEntry = staticTools.get(String(address)); |
| 2531 | 2623 | if (staticEntry) { |
| 2532 | 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 | 2631 | const preview = yield* Effect.tryPromise({ |
| 2534 | 2632 | try: () => |
| 2535 | 2633 | buildToolTypeScriptPreview({ |
| @@ -2565,6 +2663,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines | ||
| 2565 | 2663 | }); |
| 2566 | 2664 | if (!row) return null; |
| 2567 | 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 | 2673 | const definitionRows = yield* core.findMany("definition", { |
| 2570 | 2674 | where: (b: AnyCb) => |
| @@ -2904,11 +3008,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 333 unmodified lines | ||
| 2904 | 3008 | // not the 5-segment dynamic form. |
| 2905 | 3009 | const staticEntry = staticTools.get(String(address)); |
| 2906 | 3010 | if (staticEntry) { |
| 2907 | − const policyRows = yield* core.findMany("tool_policy", {}); | |
| 2908 | − const policy = resolveEffectivePolicy( | |
| 3011 | + const policyRules = yield* listActivePolicyRuleSet(); | |
| 3012 | + const policy = yield* resolvePolicyFromRuleSet( | |
| 2909 | 3013 | String(address), |
| 2910 | − policyRows, | |
| 2911 | − ownerRankForRow, | |
| 3014 | + policyRules, | |
| 2912 | 3015 | staticEntry.tool.annotations?.requiresApproval, |
| 2913 | 3016 | ); |
| 2914 | 3017 | if (policy.action === "block") { |
| @@ -2957,12 +3060,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 42 unmodified lines | ||
| 2957 | 3060 | |
| 2958 | 3061 | // Resolve policy (owner-ranked). |
| 2959 | 3062 | const toolForPolicy = rowToTool(row); |
| 2960 | − const policyRows = yield* core.findMany("tool_policy", {}); | |
| 3063 | + const policyRules = yield* listActivePolicyRuleSet(); | |
| 2961 | 3064 | const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined; |
| 2962 | − const policy = resolveEffectivePolicy( | |
| 3065 | + const policy = yield* resolvePolicyFromRuleSet( | |
| 2963 | 3066 | normalizedPolicyId(toolForPolicy), |
| 2964 | − policyRows, | |
| 2965 | − ownerRankForRow, | |
| 3067 | + policyRules, | |
| 2966 | 3068 | annotations?.requiresApproval, |
| 2967 | 3069 | ); |
| 2968 | 3070 | if (policy.action === "block") { |
| @@ -3183,6 +3285,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 214 unmodified lines | ||
| 3183 | 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 | 3302 | // Build extension FIRST so it's available as `self` for staticSources. |
| 3187 | 3303 | const extension: object = plugin.extension ? plugin.extension(ctx) : {}; |
| 3188 | 3304 | if (plugin.extension) { |
| @@ -275,6 +275,8 @@ export { · 274 unmodified lines | ||
| 275 | 275 | type AnyPlugin, |
| 276 | 276 | type StorageDeps, |
| 277 | 277 | type OwnerBinding, |
| 278 | + type ToolPolicyProvider, | |
| 279 | + type ToolPolicyProviderRule, | |
| 278 | 280 | type IntegrationRecord, |
| 279 | 281 | type StaticSourceDecl, |
| 280 | 282 | type StaticToolDecl, |
| @@ -48,6 +48,7 @@ import type { CredentialProvider, ProviderEntry } from "./provider"; · 47 unmodified lines | ||
| 48 | 48 | import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage"; |
| 49 | 49 | import type { |
| 50 | 50 | CreateToolPolicyInput, |
| 51 | + EffectivePolicy, | |
| 51 | 52 | RemoveToolPolicyInput, |
| 52 | 53 | ToolPolicy, |
| 53 | 54 | UpdateToolPolicyInput, |
| @@ -88,6 +89,30 @@ export type Elicit = ( · 34 unmodified lines | ||
| 88 | 89 | request: ElicitationRequest, |
| 89 | 90 | ) => Effect.Effect<ElicitationResponse, ElicitationDeclinedError>; |
| 90 | 91 | |
| 92 | +// --------------------------------------------------------------------------- | |
| 93 | +// Active tool-policy provider. | |
| 94 | +// | |
| 95 | +// Normal executors resolve policies from core's owner-scoped `tool_policy` | |
| 96 | +// table. A plugin may opt one executor instance into a different rule source | |
| 97 | +// (for example, a toolkit-specific policy set). Core still owns enforcement; | |
| 98 | +// the plugin owns where those policy-shaped rows are stored. | |
| 99 | +// --------------------------------------------------------------------------- | |
| 100 | + | |
| 101 | +export interface ToolPolicyProviderRule { | |
| 102 | + readonly id: string; | |
| 103 | + readonly pattern: string; | |
| 104 | + readonly action: ToolPolicy["action"]; | |
| 105 | + readonly position: string; | |
| 106 | +} | |
| 107 | + | |
| 108 | +export interface ToolPolicyProvider { | |
| 109 | + readonly list: () => Effect.Effect<readonly ToolPolicyProviderRule[], StorageFailure>; | |
| 110 | + readonly resolve?: (input: { | |
| 111 | + readonly toolId: string; | |
| 112 | + readonly defaultRequiresApproval?: boolean; | |
| 113 | + }) => Effect.Effect<EffectivePolicy, StorageFailure>; | |
| 114 | +} | |
| 115 | + | |
| 91 | 116 | // --------------------------------------------------------------------------- |
| 92 | 117 | // IntegrationRecord — the catalog row a plugin reads back (its own opaque |
| 93 | 118 | // `config` included). Returned by `ctx.core.integrations.get`. |
| @@ -458,6 +483,13 @@ export interface PluginSpec< · 364 unmodified lines | ||
| 458 | 483 | /** Service tag the plugin's `handlers` layer requires. */ |
| 459 | 484 | readonly extensionService?: TExtensionService; |
| 460 | 485 | |
| 486 | + /** Optional active policy source for this executor instance. At most one | |
| 487 | + * loaded plugin may return a provider. When absent, core uses the normal | |
| 488 | + * owner-scoped tool policies. */ | |
| 489 | + readonly toolPolicyProvider?: ( | |
| 490 | + ctx: PluginCtx<TStore>, | |
| 491 | + ) => ToolPolicyProvider | null | Effect.Effect<ToolPolicyProvider | null, StorageFailure>; | |
| 492 | + | |
| 461 | 493 | /** Produce a connection's tools (and shared $defs). The v2 successor to |
| 462 | 494 | * registering per-source tools — called by the executor at connection |
| 463 | 495 | * create / refresh / oauth.complete; the result is stamped with addresses |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | −import { Effect, Predicate, Result } from "effect"; | |
| 2 | +import { Effect, Predicate, Result, Schema } from "effect"; | |
| 3 | 3 | |
| 4 | 4 | import { type ToolPolicyRow } from "./core-schema"; |
| 5 | 5 | import { |
| @@ -19,7 +19,7 @@ import { · 13 unmodified lines | ||
| 19 | 19 | matchPattern, |
| 20 | 20 | resolveToolPolicy, |
| 21 | 21 | } from "./policies"; |
| 22 | −import { definePlugin } from "./plugin"; | |
| 22 | +import { definePlugin, tool } from "./plugin"; | |
| 23 | 23 | import type { CredentialProvider } from "./provider"; |
| 24 | 24 | import { makeTestExecutor } from "./testing"; |
| 25 | 25 | |
| @@ -512,6 +512,85 @@ describe("blocked tools", () => { · 486 unmodified lines | ||
| 512 | 512 | ); |
| 513 | 513 | }); |
| 514 | 514 | |
| 515 | +describe("active tool-policy provider", () => { | |
| 516 | + const staticPlugin = definePlugin(() => ({ | |
| 517 | + id: "toolkit-fixture" as const, | |
| 518 | + storage: () => ({}), | |
| 519 | + staticSources: () => [ | |
| 520 | + { | |
| 521 | + id: "toolkit-fixture.ctl", | |
| 522 | + kind: "control" as const, | |
| 523 | + name: "Toolkit Fixture", | |
| 524 | + tools: [ | |
| 525 | + tool({ | |
| 526 | + name: "allowed", | |
| 527 | + description: "allowed", | |
| 528 | + inputSchema: Schema.toStandardSchemaV1( | |
| 529 | + Schema.toStandardJSONSchemaV1(Schema.Struct({})), | |
| 530 | + ), | |
| 531 | + execute: () => Effect.succeed("allowed"), | |
| 532 | + }), | |
| 533 | + tool({ | |
| 534 | + name: "hidden", | |
| 535 | + description: "hidden", | |
| 536 | + inputSchema: Schema.toStandardSchemaV1( | |
| 537 | + Schema.toStandardJSONSchemaV1(Schema.Struct({})), | |
| 538 | + ), | |
| 539 | + execute: () => Effect.succeed("hidden"), | |
| 540 | + }), | |
| 541 | + ], | |
| 542 | + }, | |
| 543 | + ], | |
| 544 | + }))(); | |
| 545 | + | |
| 546 | + const policyProviderPlugin = definePlugin(() => ({ | |
| 547 | + id: "toolkit-policy-provider" as const, | |
| 548 | + storage: () => ({}), | |
| 549 | + toolPolicyProvider: () => ({ | |
| 550 | + list: () => | |
| 551 | + Effect.succeed([ | |
| 552 | + { | |
| 553 | + id: "allow-static", | |
| 554 | + pattern: "toolkit-fixture.ctl.allowed", | |
| 555 | + action: "approve" as const, | |
| 556 | + position: "a0", | |
| 557 | + }, | |
| 558 | + ]), | |
| 559 | + }), | |
| 560 | + }))(); | |
| 561 | + | |
| 562 | + it.effect("uses provider rules as an allowlist for list, schema, and execute", () => | |
| 563 | + Effect.gen(function* () { | |
| 564 | + const executor = yield* makeTestExecutor({ | |
| 565 | + plugins: [staticPlugin, policyProviderPlugin] as const, | |
| 566 | + }); | |
| 567 | + | |
| 568 | + const tools = yield* executor.tools.list(); | |
| 569 | + expect(tools.map((t) => String(t.address)).sort()).toEqual(["toolkit-fixture.ctl.allowed"]); | |
| 570 | + | |
| 571 | + const allowedSchema = yield* executor.tools.schema( | |
| 572 | + ToolAddress.make("toolkit-fixture.ctl.allowed"), | |
| 573 | + ); | |
| 574 | + expect(allowedSchema?.name).toBe("allowed"); | |
| 575 | + | |
| 576 | + const hiddenSchema = yield* executor.tools.schema( | |
| 577 | + ToolAddress.make("toolkit-fixture.ctl.hidden"), | |
| 578 | + ); | |
| 579 | + expect(hiddenSchema).toBeNull(); | |
| 580 | + | |
| 581 | + const allowed = yield* executor.execute(ToolAddress.make("toolkit-fixture.ctl.allowed"), {}); | |
| 582 | + expect(allowed).toBe("allowed"); | |
| 583 | + | |
| 584 | + const blocked = yield* Effect.result( | |
| 585 | + executor.execute(ToolAddress.make("toolkit-fixture.ctl.hidden"), {}), | |
| 586 | + ); | |
| 587 | + expect(Result.isFailure(blocked)).toBe(true); | |
| 588 | + if (!Result.isFailure(blocked)) return; | |
| 589 | + expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true); | |
| 590 | + }), | |
| 591 | + ); | |
| 592 | +}); | |
| 593 | + | |
| 515 | 594 | describe("approve / require_approval interaction with annotations", () => { |
| 516 | 595 | it.effect("approve skips the elicitation prompt even when plugin requires approval", () => |
| 517 | 596 | Effect.gen(function* () { |
| @@ -20,6 +20,7 @@ import { · 19 unmodified lines | ||
| 20 | 20 | McpErrorReporterNoop, |
| 21 | 21 | McpServingRoutes, |
| 22 | 22 | McpSessionStore, |
| 23 | + type McpResource, | |
| 23 | 24 | type McpDispatchResult, |
| 24 | 25 | type Principal, |
| 25 | 26 | } from "./index"; |
| @@ -133,3 +134,29 @@ describe("McpServingRoutes envelope", () => { · 107 unmodified lines | ||
| 133 | 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 | 2 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | + defaultMcpResource, | |
| 5 | 6 | McpAuthProvider, |
| 6 | 7 | McpErrorReporter, |
| 7 | 8 | McpSessionStore, |
| 8 | 9 | type AuthOutcome, |
| 9 | 10 | type McpDispatchResult, |
| 11 | + type McpResource, | |
| 10 | 12 | } from "./seams"; |
| 11 | 13 | |
| 12 | 14 | // --------------------------------------------------------------------------- |
| @@ -14,7 +16,8 @@ import { · 1 unmodified line | ||
| 14 | 16 | // |
| 15 | 17 | // Routes: |
| 16 | 18 | // GET <provider-declared discovery paths> -> McpAuthProvider metadata |
| 17 | −// * /mcp -> authenticate -> dispatch | |
| 19 | +// * /mcp -> authenticate -> dispatch(default) | |
| 20 | +// * /mcp/toolkits/:toolkitSlug -> authenticate -> dispatch(toolkit) | |
| 18 | 21 | // |
| 19 | 22 | // The provider DECLARES the discovery paths it owns (at least the protected- |
| 20 | 23 | // resource metadata document) via `McpAuthProvider.discoveryRoutes`; the |
| @@ -24,8 +27,8 @@ import { · 3 unmodified lines | ||
| 24 | 27 | // WorkOS, external). The envelope only needs the provider's discovery routes, |
| 25 | 28 | // resource-metadata URL, and authenticate. |
| 26 | 29 | // |
| 27 | −// The envelope hard-codes ONLY the `/mcp` path and CORS. Everything else — | |
| 28 | −// every `/.well-known/*` path, the resource-metadata URL, the authn/authz | |
| 30 | +// The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else | |
| 31 | +// — every `/.well-known/*` path, the resource-metadata URL, the authn/authz | |
| 29 | 32 | // semantics, and the entire session lifecycle (create + forward + ownership) — |
| 30 | 33 | // comes from the two seams. |
| 31 | 34 | // |
| @@ -38,6 +41,7 @@ import { · 6 unmodified lines | ||
| 38 | 41 | // --------------------------------------------------------------------------- |
| 39 | 42 | |
| 40 | 43 | const MCP_PATH = "/mcp"; |
| 44 | +const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug"; | |
| 41 | 45 | |
| 42 | 46 | /** The methods the streamable-HTTP transport accepts on `/mcp`. */ |
| 43 | 47 | const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); |
| @@ -170,69 +174,73 @@ const renderDispatchError = (lookup: "not-found" | "forbidden"): Response => · 126 unmodified lines | ||
| 170 | 174 | ? jsonRpcResponse(404, -32001, "Session not found") |
| 171 | 175 | : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); |
| 172 | 176 | |
| 173 | −/** Dispatch a `/mcp` request through authenticate -> store.dispatch -> transport. */ | |
| 174 | −const mcpDispatch = Effect.gen(function* () { | |
| 175 | − const httpRequest = yield* HttpServerRequest.HttpServerRequest; | |
| 176 | − const auth = yield* McpAuthProvider; | |
| 177 | − const store = yield* McpSessionStore; | |
| 178 | − const request = yield* toWebRequest(httpRequest); | |
| 177 | +/** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */ | |
| 178 | +const mcpDispatch = (resource: McpResource) => | |
| 179 | + Effect.gen(function* () { | |
| 180 | + const httpRequest = yield* HttpServerRequest.HttpServerRequest; | |
| 181 | + const auth = yield* McpAuthProvider; | |
| 182 | + const store = yield* McpSessionStore; | |
| 183 | + const request = yield* toWebRequest(httpRequest); | |
| 179 | 184 | |
| 180 | − // CORS preflight: answer before auth so unauthenticated clients can probe. | |
| 181 | − if (request.method === "OPTIONS") { | |
| 182 | − return HttpServerResponse.raw(corsPreflightResponse()); | |
| 183 | − } | |
| 185 | + // CORS preflight: answer before auth so unauthenticated clients can probe. | |
| 186 | + if (request.method === "OPTIONS") { | |
| 187 | + return HttpServerResponse.raw(corsPreflightResponse()); | |
| 188 | + } | |
| 184 | 189 | |
| 185 | − // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other | |
| 186 | − // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch — | |
| 187 | − // otherwise it would fall through and spin up a session engine for a method | |
| 188 | − // the transport can't serve. | |
| 189 | − if (!ALLOWED_MCP_METHODS.has(request.method)) { | |
| 190 | − return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed")); | |
| 191 | − } | |
| 190 | + // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other | |
| 191 | + // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch — | |
| 192 | + // otherwise it would fall through and spin up a session engine for a method | |
| 193 | + // the transport can't serve. | |
| 194 | + if (!ALLOWED_MCP_METHODS.has(request.method)) { | |
| 195 | + return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed")); | |
| 196 | + } | |
| 192 | 197 | |
| 193 | − const sessionId = request.headers.get("mcp-session-id"); | |
| 198 | + const sessionId = request.headers.get("mcp-session-id"); | |
| 194 | 199 | |
| 195 | − // Authenticate (and, for session-aware providers, authorize) on EVERY | |
| 196 | − // request. On a non-Authenticated outcome: | |
| 197 | − // - Forbidden -> dispose the live session first (cloud tears down a DO | |
| 198 | − // whose org access was revoked), then render the 403. The | |
| 199 | − // inbound request is forwarded so the store can propagate | |
| 200 | − // the request's W3C trace context onto the teardown RPC. | |
| 201 | − // - other -> render directly. | |
| 202 | − const outcome = yield* auth.authenticate(request); | |
| 203 | − if (!Predicate.isTagged(outcome, "Authenticated")) { | |
| 204 | − if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { | |
| 205 | − yield* store.dispose(sessionId, request); | |
| 200 | + // Authenticate (and, for session-aware providers, authorize) on EVERY | |
| 201 | + // request. On a non-Authenticated outcome: | |
| 202 | + // - Forbidden -> dispose the live session first (cloud tears down a DO | |
| 203 | + // whose org access was revoked), then render the 403. The | |
| 204 | + // inbound request is forwarded so the store can propagate | |
| 205 | + // the request's W3C trace context onto the teardown RPC. | |
| 206 | + // - other -> render directly. | |
| 207 | + const outcome = yield* auth.authenticate(request); | |
| 208 | + if (!Predicate.isTagged(outcome, "Authenticated")) { | |
| 209 | + if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { | |
| 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)); | |
| 208 | − } | |
| 209 | − const principal = outcome.principal; | |
| 214 | + const principal = outcome.principal; | |
| 210 | 215 | |
| 211 | − // No session id: per the streamable-HTTP transport contract, only POST opens | |
| 212 | − // a session. A GET needs an existing id (400); a DELETE on nothing is a | |
| 213 | − // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up | |
| 214 | − // an engine for a bare GET/DELETE. | |
| 215 | − if (!sessionId) { | |
| 216 | − if (request.method === "GET") { | |
| 217 | − return HttpServerResponse.raw( | |
| 218 | − jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"), | |
| 219 | − ); | |
| 216 | + // No session id: per the streamable-HTTP transport contract, only POST opens | |
| 217 | + // a session. A GET needs an existing id (400); a DELETE on nothing is a | |
| 218 | + // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up | |
| 219 | + // an engine for a bare GET/DELETE. | |
| 220 | + if (!sessionId) { | |
| 221 | + if (request.method === "GET") { | |
| 222 | + return HttpServerResponse.raw( | |
| 223 | + jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"), | |
| 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({ | |
| 229 | − request, | |
| 230 | − principal, | |
| 231 | − sessionId, | |
| 232 | − method: request.method, | |
| 233 | + const result: McpDispatchResult = yield* store.dispatch({ | |
| 234 | + request, | |
| 235 | + principal, | |
| 236 | + resource, | |
| 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 | 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 | 250 | * otherwise, since the envelope returns a `Response`) and rendered as a stable |
| 243 | 251 | * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body. |
| 244 | 252 | */ |
| 245 | −const mcpRoute = mcpDispatch.pipe( | |
| 246 | − Effect.catchCause((cause) => | |
| 247 | − Effect.gen(function* () { | |
| 248 | − const reporter = yield* McpErrorReporter; | |
| 249 | − yield* reporter.report(cause); | |
| 250 | − return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error")); | |
| 251 | − }), | |
| 252 | − ), | |
| 253 | −); | |
| 253 | +const mcpRoute = (resource: McpResource) => | |
| 254 | + mcpDispatch(resource).pipe( | |
| 255 | + Effect.catchCause((cause) => | |
| 256 | + Effect.gen(function* () { | |
| 257 | + const reporter = yield* McpErrorReporter; | |
| 258 | + yield* reporter.report(cause); | |
| 259 | + return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error")); | |
| 260 | + }), | |
| 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 | 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 | 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 | 17 | import { jsonRpcErrorBody } from "./envelope"; |
| 18 | 18 | import { |
| 19 | 19 | McpSessionStore, |
| 20 | + defaultMcpResource, | |
| 21 | + mcpResourceKey, | |
| 20 | 22 | principalOwns, |
| 21 | 23 | type McpDispatchInput, |
| 22 | 24 | type McpDispatchResult, |
| 23 | 25 | type Principal, |
| 26 | + type McpResource, | |
| 24 | 27 | } from "./seams"; |
| 25 | 28 | import type { BrowserApprovalStore } from "./tool-server"; |
| 26 | 29 | |
| @@ -61,6 +64,7 @@ export interface BuiltMcpServer { · 34 unmodified lines | ||
| 61 | 64 | |
| 62 | 65 | /** The browser-mode wiring the store hands a build call when a session opts in. */ |
| 63 | 66 | export interface McpBuildServerOptions { |
| 67 | + readonly resource?: McpResource; | |
| 64 | 68 | readonly elicitationMode?: |
| 65 | 69 | | { readonly mode: "browser"; readonly approvalUrl: (executionId: string) => string } |
| 66 | 70 | | { readonly mode: "model" } |
| @@ -120,6 +124,19 @@ const json = (value: unknown, status = 200): Response => · 53 unmodified lines | ||
| 120 | 124 | const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; |
| 121 | 125 | const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; |
| 122 | 126 | |
| 127 | +interface SessionOwner { | |
| 128 | + readonly principal: Principal; | |
| 129 | + readonly resource: McpResource; | |
| 130 | +} | |
| 131 | + | |
| 132 | +const sessionOwnerMatches = ( | |
| 133 | + owner: SessionOwner, | |
| 134 | + principal: Principal, | |
| 135 | + resource: McpResource, | |
| 136 | +): boolean => | |
| 137 | + principalOwns(owner.principal, principal) && | |
| 138 | + mcpResourceKey(owner.resource) === mcpResourceKey(resource); | |
| 139 | + | |
| 123 | 140 | /** |
| 124 | 141 | * Build the in-process session store plus an explicit `close()` that disposes |
| 125 | 142 | * all live sessions. `close()` is not part of the seam — it is the host lifetime |
| @@ -137,7 +154,7 @@ export const makeInMemoryMcpSessionStore = ( · 11 unmodified lines | ||
| 137 | 154 | ): InMemoryMcpSessionStore => { |
| 138 | 155 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); |
| 139 | 156 | const servers = new Map<string, McpServer>(); |
| 140 | − const owners = new Map<string, Principal>(); | |
| 157 | + const owners = new Map<string, SessionOwner>(); | |
| 141 | 158 | const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>(); |
| 142 | 159 | const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore(); |
| 143 | 160 | |
| @@ -181,12 +198,13 @@ export const makeInMemoryMcpSessionStore = ( · 37 unmodified lines | ||
| 181 | 198 | const forward = ( |
| 182 | 199 | sessionId: string, |
| 183 | 200 | principal: Principal, |
| 201 | + resource: McpResource, | |
| 184 | 202 | request: Request, |
| 185 | 203 | ): Effect.Effect<McpDispatchResult> => { |
| 186 | 204 | const transport = transports.get(sessionId); |
| 187 | 205 | const owner = owners.get(sessionId); |
| 188 | 206 | if (!transport || !owner) return Effect.succeed("not-found"); |
| 189 | − if (!principalOwns(owner, principal)) return Effect.succeed("forbidden"); | |
| 207 | + if (!sessionOwnerMatches(owner, principal, resource)) return Effect.succeed("forbidden"); | |
| 190 | 208 | return runHandleRequest(transport, request); |
| 191 | 209 | }; |
| 192 | 210 | |
| @@ -218,12 +236,16 @@ export const makeInMemoryMcpSessionStore = ( · 25 unmodified lines | ||
| 218 | 236 | }; |
| 219 | 237 | |
| 220 | 238 | /** Open a new session: build the server, connect a transport, drive the request. */ |
| 221 | − const create = (principal: Principal, request: Request): Effect.Effect<McpDispatchResult> => { | |
| 239 | + const create = ( | |
| 240 | + principal: Principal, | |
| 241 | + resource: McpResource, | |
| 242 | + request: Request, | |
| 243 | + ): Effect.Effect<McpDispatchResult> => { | |
| 222 | 244 | let createdSessionId: string | null = null; |
| 223 | − return buildServer( | |
| 224 | − principal, | |
| 225 | − buildOptionsFor(request, () => createdSessionId), | |
| 226 | − ).pipe( | |
| 245 | + return buildServer(principal, { | |
| 246 | + ...buildOptionsFor(request, () => createdSessionId), | |
| 247 | + resource, | |
| 248 | + }).pipe( | |
| 227 | 249 | Effect.flatMap(({ mcpServer, engine }) => |
| 228 | 250 | Effect.gen(function* () { |
| 229 | 251 | const transport = new WebStandardStreamableHTTPServerTransport({ |
| @@ -233,7 +255,7 @@ export const makeInMemoryMcpSessionStore = ( · 3 unmodified lines | ||
| 233 | 255 | createdSessionId = sid; |
| 234 | 256 | transports.set(sid, transport); |
| 235 | 257 | servers.set(sid, mcpServer); |
| 236 | − owners.set(sid, principal); | |
| 258 | + owners.set(sid, { principal, resource }); | |
| 237 | 259 | engines.set(sid, engine); |
| 238 | 260 | }, |
| 239 | 261 | onsessionclosed: (sid) => void dispose(sid, { server: true }), |
| @@ -259,8 +281,10 @@ export const makeInMemoryMcpSessionStore = ( · 19 unmodified lines | ||
| 259 | 281 | }; |
| 260 | 282 | |
| 261 | 283 | const store: McpSessionStore["Service"] = { |
| 262 | − dispatch: ({ request, principal, sessionId }: McpDispatchInput) => | |
| 263 | − sessionId ? forward(sessionId, principal, request) : create(principal, request), | |
| 284 | + dispatch: ({ request, principal, resource, sessionId }: McpDispatchInput) => | |
| 285 | + sessionId | |
| 286 | + ? forward(sessionId, principal, resource, request) | |
| 287 | + : create(principal, resource ?? defaultMcpResource, request), | |
| 264 | 288 | dispose: (sessionId) => |
| 265 | 289 | Effect.promise(() => dispose(sessionId, { transport: true, server: true })), |
| 266 | 290 | }; |
| @@ -271,7 +295,7 @@ export const makeInMemoryMcpSessionStore = ( · 4 unmodified lines | ||
| 271 | 295 | ): "allowed" | "not-found" | "forbidden" => { |
| 272 | 296 | const owner = owners.get(sessionId); |
| 273 | 297 | if (!owner) return "not-found"; |
| 274 | − if (principal && !principalOwns(owner, principal)) return "forbidden"; | |
| 298 | + if (principal && !principalOwns(owner.principal, principal)) return "forbidden"; | |
| 275 | 299 | return "allowed"; |
| 276 | 300 | }; |
| 277 | 301 | |
| @@ -18,6 +18,8 @@ export { · 17 unmodified lines | ||
| 18 | 18 | McpSessionStore, |
| 19 | 19 | McpErrorReporter, |
| 20 | 20 | McpErrorReporterNoop, |
| 21 | + defaultMcpResource, | |
| 22 | + mcpResourceKey, | |
| 21 | 23 | principalOwns, |
| 22 | 24 | authenticated, |
| 23 | 25 | unauthorized, |
| @@ -31,6 +33,7 @@ export { · 7 unmodified lines | ||
| 31 | 33 | type McpDiscoveryRoute, |
| 32 | 34 | type McpDispatchInput, |
| 33 | 35 | type McpDispatchResult, |
| 36 | + type McpResource, | |
| 34 | 37 | } from "./seams"; |
| 35 | 38 | |
| 36 | 39 | export { McpServingRoutes, jsonRpcErrorBody } from "./envelope"; |
| @@ -56,6 +56,25 @@ export type Principal = Schema.Schema.Type<typeof Principal>; · 55 unmodified lines | ||
| 56 | 56 | export const principalOwns = (owner: Principal, principal: Principal): boolean => |
| 57 | 57 | owner.accountId === principal.accountId && owner.organizationId === principal.organizationId; |
| 58 | 58 | |
| 59 | +// --------------------------------------------------------------------------- | |
| 60 | +// Shared MCP resource identity. | |
| 61 | +// | |
| 62 | +// The default `/mcp` endpoint and named sub-resources such as | |
| 63 | +// `/mcp/toolkits/<slug>` share auth and transport machinery, but a serving | |
| 64 | +// session belongs to the exact resource path that minted it. This keeps a | |
| 65 | +// leaked/reused `mcp-session-id` from crossing from one exposed capability set | |
| 66 | +// into another. | |
| 67 | +// --------------------------------------------------------------------------- | |
| 68 | + | |
| 69 | +export type McpResource = | |
| 70 | + | { readonly kind: "default" } | |
| 71 | + | { readonly kind: "toolkit"; readonly slug: string }; | |
| 72 | + | |
| 73 | +export const defaultMcpResource: McpResource = { kind: "default" }; | |
| 74 | + | |
| 75 | +export const mcpResourceKey = (resource: McpResource): string => | |
| 76 | + resource.kind === "default" ? "default" : `toolkit:${resource.slug}`; | |
| 77 | + | |
| 59 | 78 | // --------------------------------------------------------------------------- |
| 60 | 79 | // AuthOutcome — the result of `McpAuthProvider.authenticate`. |
| 61 | 80 | // |
| @@ -210,6 +229,7 @@ export class McpAuthProvider extends Context.Service< · 148 unmodified lines | ||
| 210 | 229 | export interface McpDispatchInput { |
| 211 | 230 | readonly request: Request; |
| 212 | 231 | readonly principal: Principal; |
| 232 | + readonly resource: McpResource; | |
| 213 | 233 | readonly sessionId: string | null; |
| 214 | 234 | readonly method: string; |
| 215 | 235 | } |
| @@ -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 | 172 | * applies a user rule to this tool's exact id. */ |
| 173 | 173 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 174 | 174 | onClearPolicy?: (pattern: string) => void; |
| 175 | + patternForDisplay?: (displayPattern: string) => string; | |
| 175 | 176 | /** Run-tab wiring. When `integration` + `runToolName` are provided, a third |
| 176 | 177 | * "Run" tab hosts the per-connection tool tester. */ |
| 177 | 178 | integration?: IntegrationSlug; |
| @@ -187,6 +188,7 @@ export function ToolDetail(props: { · 9 unmodified lines | ||
| 187 | 188 | const canRun = props.integration != null && props.runToolName != null; |
| 188 | 189 | // Don't strand the user on the Run tab when this ToolDetail has no run wiring. |
| 189 | 190 | const activeTab = tab === "run" && !canRun ? "schema" : tab; |
| 191 | + const isBlocked = props.policy?.action === "block"; | |
| 190 | 192 | |
| 191 | 193 | const data = useMemo(() => { |
| 192 | 194 | if (!AsyncResult.isSuccess(toolContract)) return null; |
| @@ -244,6 +246,7 @@ export function ToolDetail(props: { · 51 unmodified lines | ||
| 244 | 246 | policy={props.policy} |
| 245 | 247 | onSetPolicy={props.onSetPolicy} |
| 246 | 248 | onClearPolicy={props.onClearPolicy} |
| 249 | + patternForDisplay={props.patternForDisplay} | |
| 247 | 250 | /> |
| 248 | 251 | </div> |
| 249 | 252 | {data?.description && <ToolDescription description={data.description} />} |
| @@ -300,48 +303,59 @@ export function ToolDetail(props: { · 50 unmodified lines | ||
| 300 | 303 | |
| 301 | 304 | {/* Content */} |
| 302 | 305 | <div className="flex-1 overflow-y-auto"> |
| 303 | − {AsyncResult.match(toolContract, { | |
| 304 | − onInitial: () => <div className="p-5 text-sm text-muted-foreground">Loading…</div>, | |
| 305 | − onFailure: () => <div className="p-5 text-sm text-destructive">Something went wrong</div>, | |
| 306 | − onSuccess: () => | |
| 307 | − activeTab === "run" && props.integration && props.runToolName ? ( | |
| 308 | − <div className="px-5 py-5"> | |
| 309 | − <ToolRunPanel | |
| 310 | − integration={props.integration} | |
| 311 | − toolName={props.runToolName} | |
| 312 | − connections={props.connections ?? []} | |
| 313 | − initialConnectionName={props.initialConnectionName} | |
| 314 | − /> | |
| 315 | − </div> | |
| 316 | − ) : activeTab === "schema" ? ( | |
| 317 | − <div className="px-5 py-5 space-y-5"> | |
| 318 | − {data?.inputSchema ? ( | |
| 319 | − <SchemaExplorer | |
| 320 | − schema={data.inputSchema} | |
| 321 | − schemaDefinitions={data.schemaDefinitions} | |
| 322 | − title="Parameters" | |
| 323 | − /> | |
| 324 | − ) : ( | |
| 325 | − <EmptySection title="Parameters" message="None" /> | |
| 326 | − )} | |
| 327 | − {data?.outputSchema ? ( | |
| 328 | − <SchemaExplorer | |
| 329 | − schema={data.outputSchema} | |
| 330 | − schemaDefinitions={data.schemaDefinitions} | |
| 331 | − title="Response" | |
| 332 | − /> | |
| 333 | − ) : ( | |
| 334 | − <EmptySection title="Response" message="None" /> | |
| 335 | − )} | |
| 336 | − </div> | |
| 337 | − ) : ( | |
| 338 | − <ToolTypeScriptPanel | |
| 339 | − inputTypeScript={data?.inputTypeScript ?? null} | |
| 340 | − outputTypeScript={data?.outputTypeScript ?? null} | |
| 341 | − definitions={data?.definitions ?? []} | |
| 342 | − /> | |
| 306 | + {isBlocked ? ( | |
| 307 | + <div className="px-5 py-5"> | |
| 308 | + <EmptySection | |
| 309 | + title="Blocked" | |
| 310 | + message="This tool is not available through the current toolkit." | |
| 311 | + /> | |
| 312 | + </div> | |
| 313 | + ) : ( | |
| 314 | + AsyncResult.match(toolContract, { | |
| 315 | + onInitial: () => <div className="p-5 text-sm text-muted-foreground">Loading…</div>, | |
| 316 | + onFailure: () => ( | |
| 317 | + <div className="p-5 text-sm text-destructive">Something went wrong</div> | |
| 343 | 318 | ), |
| 344 | − })} | |
| 319 | + onSuccess: () => | |
| 320 | + activeTab === "run" && props.integration && props.runToolName ? ( | |
| 321 | + <div className="px-5 py-5"> | |
| 322 | + <ToolRunPanel | |
| 323 | + integration={props.integration} | |
| 324 | + toolName={props.runToolName} | |
| 325 | + connections={props.connections ?? []} | |
| 326 | + initialConnectionName={props.initialConnectionName} | |
| 327 | + /> | |
| 328 | + </div> | |
| 329 | + ) : activeTab === "schema" ? ( | |
| 330 | + <div className="px-5 py-5 space-y-5"> | |
| 331 | + {data?.inputSchema ? ( | |
| 332 | + <SchemaExplorer | |
| 333 | + schema={data.inputSchema} | |
| 334 | + schemaDefinitions={data.schemaDefinitions} | |
| 335 | + title="Parameters" | |
| 336 | + /> | |
| 337 | + ) : ( | |
| 338 | + <EmptySection title="Parameters" message="None" /> | |
| 339 | + )} | |
| 340 | + {data?.outputSchema ? ( | |
| 341 | + <SchemaExplorer | |
| 342 | + schema={data.outputSchema} | |
| 343 | + schemaDefinitions={data.schemaDefinitions} | |
| 344 | + title="Response" | |
| 345 | + /> | |
| 346 | + ) : ( | |
| 347 | + <EmptySection title="Response" message="None" /> | |
| 348 | + )} | |
| 349 | + </div> | |
| 350 | + ) : ( | |
| 351 | + <ToolTypeScriptPanel | |
| 352 | + inputTypeScript={data?.inputTypeScript ?? null} | |
| 353 | + outputTypeScript={data?.outputTypeScript ?? null} | |
| 354 | + definitions={data?.definitions ?? []} | |
| 355 | + /> | |
| 356 | + ), | |
| 357 | + }) | |
| 358 | + )} | |
| 345 | 359 | </div> |
| 346 | 360 | </div> |
| 347 | 361 | ); |
| @@ -400,12 +414,15 @@ function PolicyBadgeMenu(props: { · 52 unmodified lines | ||
| 400 | 414 | policy?: EffectivePolicy; |
| 401 | 415 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 402 | 416 | onClearPolicy?: (pattern: string) => void; |
| 417 | + patternForDisplay?: (displayPattern: string) => string; | |
| 403 | 418 | }) { |
| 404 | 419 | const interactive = !!props.onSetPolicy; |
| 405 | 420 | // The same pattern bridge the tree rows apply — the pattern WRITTEN and the |
| 406 | 421 | // pattern LOOKED UP must be the same string, or the menu authors rules that |
| 407 | 422 | // never match and can't see its own rule afterward. |
| 408 | − const pattern = props.staticTool ? props.toolName : toPolicyPattern(props.toolName); | |
| 423 | + const pattern = props.staticTool | |
| 424 | + ? props.toolName | |
| 425 | + : (props.patternForDisplay ?? toPolicyPattern)(props.toolName); | |
| 409 | 426 | // The "Clear" affordance only makes sense when there's a user rule |
| 410 | 427 | // pinned to this exact tool id — clearing a wildcard rule from a |
| 411 | 428 | // single tool's detail header would silently affect siblings. |
| @@ -283,6 +283,8 @@ export function ToolTree(props: { · 282 unmodified lines | ||
| 283 | 283 | * emit the tool's full dotted id; group rows emit `prefix.*`. */ |
| 284 | 284 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 285 | 285 | onClearPolicy?: (pattern: string) => void; |
| 286 | + /** Maps the displayed row path into the persisted policy pattern. */ | |
| 287 | + patternForDisplay?: (displayPattern: string) => string; | |
| 286 | 288 | /** Sorted user-authored policies (most-precedent first). Used to |
| 287 | 289 | * decide whether a node has its own exact-pattern user rule today |
| 288 | 290 | * (so the menu can show a "Clear" option). Optional — when absent, |
| @@ -300,6 +302,7 @@ export function ToolTree(props: { · 11 unmodified lines | ||
| 300 | 302 | onSelect, |
| 301 | 303 | onSetPolicy, |
| 302 | 304 | onClearPolicy, |
| 305 | + patternForDisplay = toPolicyPattern, | |
| 303 | 306 | policies, |
| 304 | 307 | groupByConnection, |
| 305 | 308 | } = props; |
| @@ -358,6 +361,7 @@ export function ToolTree(props: { · 52 unmodified lines | ||
| 358 | 361 | onSelect, |
| 359 | 362 | onSetPolicy, |
| 360 | 363 | onClearPolicy, |
| 364 | + patternForDisplay, | |
| 361 | 365 | exactPatterns, |
| 362 | 366 | search, |
| 363 | 367 | terms, |
| @@ -433,6 +437,7 @@ function ToolTreeBody(props: { · 69 unmodified lines | ||
| 433 | 437 | onSelect: (toolId: string) => void; |
| 434 | 438 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 435 | 439 | onClearPolicy?: (pattern: string) => void; |
| 440 | + patternForDisplay: (displayPattern: string) => string; | |
| 436 | 441 | exactPatterns: ReadonlyMap<string, ToolPolicyAction>; |
| 437 | 442 | search: string; |
| 438 | 443 | terms: readonly string[]; |
| @@ -444,6 +449,7 @@ function ToolTreeBody(props: { · 5 unmodified lines | ||
| 444 | 449 | onSelect, |
| 445 | 450 | onSetPolicy, |
| 446 | 451 | onClearPolicy, |
| 452 | + patternForDisplay, | |
| 447 | 453 | exactPatterns, |
| 448 | 454 | search, |
| 449 | 455 | terms, |
| @@ -510,7 +516,8 @@ function ToolTreeBody(props: { · 60 unmodified lines | ||
| 510 | 516 | search={search} |
| 511 | 517 | onSetPolicy={onSetPolicy} |
| 512 | 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 | 523 | <ToolGroupRow |
| @@ -524,7 +531,8 @@ function ToolTreeBody(props: { · 7 unmodified lines | ||
| 524 | 531 | search={search} |
| 525 | 532 | onSetPolicy={onSetPolicy} |
| 526 | 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 | 618 | search: string; |
| 611 | 619 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 612 | 620 | onClearPolicy?: (pattern: string) => void; |
| 621 | + patternForDisplay: (displayPattern: string) => string; | |
| 613 | 622 | exactRule?: ToolPolicyAction; |
| 614 | 623 | }) { |
| 615 | 624 | const showActions = !!props.onSetPolicy; |
| @@ -647,7 +656,7 @@ function ToolGroupRow(props: { · 31 unmodified lines | ||
| 647 | 656 | )} |
| 648 | 657 | > |
| 649 | 658 | <PolicyActionMenu |
| 650 | − pattern={toPolicyPattern(`${props.path}.*`)} | |
| 659 | + pattern={props.patternForDisplay(`${props.path}.*`)} | |
| 651 | 660 | current={props.exactRule} |
| 652 | 661 | onSet={props.onSetPolicy!} |
| 653 | 662 | onClear={props.onClearPolicy} |
| @@ -668,6 +677,7 @@ function ToolLeafRow(props: { · 14 unmodified lines | ||
| 668 | 677 | search: string; |
| 669 | 678 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 670 | 679 | onClearPolicy?: (pattern: string) => void; |
| 680 | + patternForDisplay: (displayPattern: string) => string; | |
| 671 | 681 | exactRule?: ToolPolicyAction; |
| 672 | 682 | }) { |
| 673 | 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 | 731 | <PolicyActionMenu |
| 722 | − pattern={toPolicyPattern(props.tool.name)} | |
| 732 | + pattern={props.patternForDisplay(props.tool.name)} | |
| 723 | 733 | current={props.exactRule} |
| 724 | 734 | onSet={props.onSetPolicy!} |
| 725 | 735 | onClear={props.onClearPolicy} |
| @@ -21,7 +21,7 @@ import { · 20 unmodified lines | ||
| 21 | 21 | integrationPresetIconUrl, |
| 22 | 22 | } from "../components/integration-favicon"; |
| 23 | 23 | import { CommandPalette } from "../components/command-palette"; |
| 24 | −import { useIntegrationPlugins } from "@executor-js/sdk/client"; | |
| 24 | +import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; | |
| 25 | 25 | import { useAuth } from "./auth-context"; |
| 26 | 26 | |
| 27 | 27 | // --------------------------------------------------------------------------- |
| @@ -63,6 +63,16 @@ export const DEFAULT_DOCS_URL = "https://executor.sh/docs"; · 35 unmodified lines | ||
| 63 | 63 | // the host's own route tree, which this package can't see. |
| 64 | 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 | 76 | /** The pathname with the active org-slug prefix stripped, for active-state |
| 67 | 77 | * comparisons against scope-relative paths. */ |
| 68 | 78 | function useScopeRelativePathname(): string { |
| @@ -310,7 +320,20 @@ function UserFooter(props: Pick<ShellProps, "onSignOut" | "orgMenuSlot">) { · 241 unmodified lines | ||
| 310 | 320 | function SidebarContent( |
| 311 | 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 | 337 | return ( |
| 315 | 338 | <> |
| 316 | 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 | 11 | // plugin to `executor.config.ts` is sufficient for its pages to mount |
| 12 | 12 | // here, with no per-route imports. |
| 13 | 13 | // |
| 14 | −// Match logic is intentionally tiny: exact path equality between the URL | |
| 15 | −// remainder and a `PageDecl.path`, with `""` and `/` treated as the | |
| 16 | −// same root. Plugins that want parameterized paths can build their own | |
| 17 | −// in-component routing for now. | |
| 14 | +// Plugin pages use the same lightweight route vocabulary as the rest of the | |
| 15 | +// console route tree: static segments and `$param` segments. The host route | |
| 16 | +// owns that matching so plugin detail views can be real URLs instead of | |
| 17 | +// in-component state. | |
| 18 | 18 | // --------------------------------------------------------------------------- |
| 19 | 19 | |
| 20 | 20 | export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({ |
| @@ -23,9 +23,66 @@ export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({ · 2 unmodified lines | ||
| 23 | 23 | |
| 24 | 24 | function normalizePath(input: string): string { |
| 25 | 25 | if (!input || input === "/") return "/"; |
| 26 | − return input.startsWith("/") ? input : `/${input}`; | |
| 26 | + const withLeadingSlash = input.startsWith("/") ? input : `/${input}`; | |
| 27 | + return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/, "") : "/"; | |
| 27 | 28 | } |
| 28 | 29 | |
| 30 | +const pathSegments = (input: string): readonly string[] => | |
| 31 | + normalizePath(input) | |
| 32 | + .split("/") | |
| 33 | + .filter((segment) => segment.length > 0); | |
| 34 | + | |
| 35 | +export const matchPluginPagePath = ( | |
| 36 | + pattern: string, | |
| 37 | + target: string, | |
| 38 | +): Readonly<Record<string, string>> | null => { | |
| 39 | + const patternSegments = pathSegments(pattern); | |
| 40 | + const targetSegments = pathSegments(target); | |
| 41 | + if (patternSegments.length !== targetSegments.length) return null; | |
| 42 | + | |
| 43 | + const params: Record<string, string> = {}; | |
| 44 | + for (let index = 0; index < patternSegments.length; index += 1) { | |
| 45 | + const patternSegment = patternSegments[index]!; | |
| 46 | + const targetSegment = targetSegments[index]!; | |
| 47 | + if (patternSegment.startsWith("$") && patternSegment.length > 1) { | |
| 48 | + params[patternSegment.slice(1)] = decodeURIComponent(targetSegment); | |
| 49 | + continue; | |
| 50 | + } | |
| 51 | + if (patternSegment !== targetSegment) return null; | |
| 52 | + } | |
| 53 | + return params; | |
| 54 | +}; | |
| 55 | + | |
| 56 | +const matchScore = (pattern: string): number => | |
| 57 | + pathSegments(pattern).reduce((score, segment) => score + (segment.startsWith("$") ? 1 : 2), 0); | |
| 58 | + | |
| 59 | +export const matchPluginPage = <TPage extends { readonly path: string }>( | |
| 60 | + pages: readonly TPage[] | undefined, | |
| 61 | + target: string, | |
| 62 | +): { readonly page: TPage; readonly params: Readonly<Record<string, string>> } | null => { | |
| 63 | + const matches = | |
| 64 | + pages | |
| 65 | + ?.map((page, index) => ({ | |
| 66 | + page, | |
| 67 | + index, | |
| 68 | + params: matchPluginPagePath(page.path, target), | |
| 69 | + score: matchScore(page.path), | |
| 70 | + })) | |
| 71 | + .filter( | |
| 72 | + ( | |
| 73 | + candidate, | |
| 74 | + ): candidate is { | |
| 75 | + readonly page: TPage; | |
| 76 | + readonly index: number; | |
| 77 | + readonly params: Readonly<Record<string, string>>; | |
| 78 | + readonly score: number; | |
| 79 | + } => candidate.params !== null, | |
| 80 | + ) | |
| 81 | + .sort((a, b) => b.score - a.score || a.index - b.index) ?? []; | |
| 82 | + const first = matches[0]; | |
| 83 | + return first ? { page: first.page, params: first.params } : null; | |
| 84 | +}; | |
| 85 | + | |
| 29 | 86 | function PluginRouteComponent() { |
| 30 | 87 | const { pluginId, _splat: rest } = Route.useParams(); |
| 31 | 88 | const plugins = useClientPlugins(); |
| @@ -34,10 +91,10 @@ function PluginRouteComponent() { · 2 unmodified lines | ||
| 34 | 91 | if (!plugin) throw notFound(); |
| 35 | 92 | |
| 36 | 93 | const target = normalizePath(rest ?? "/"); |
| 37 | − const page = plugin.pages?.find((p) => normalizePath(p.path) === target); | |
| 94 | + const match = matchPluginPage(plugin.pages, target); | |
| 38 | 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; | |
| 42 | − return <Component />; | |
| 98 | + const Component = match.page.component; | |
| 99 | + return <Component params={match.params} path={target} pluginId={pluginId} />; | |
| 43 | 100 | } |