From 0a7c6a88ce3cf375fe1daa50cdf5e7f9e7bce22e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:11:16 -0700 Subject: [PATCH 01/10] Add scoped MCP toolkits --- apps/host-selfhost/executor.config.ts | 4 +- apps/host-selfhost/package.json | 1 + apps/host-selfhost/src/app.ts | 3 +- apps/host-selfhost/src/execution.ts | 6 +- apps/host-selfhost/src/mcp/org-path.test.ts | 8 +- apps/host-selfhost/src/mcp/org-path.ts | 15 +- apps/host-selfhost/vite.config.ts | 4 +- .../device-page.tsx} | 0 .../mcp-consent-page.tsx} | 0 apps/host-selfhost/web/routes/__root.tsx | 4 +- bun.lock | 47 + e2e/package.json | 1 + e2e/selfhost/toolkits-mcp.test.ts | 146 ++ e2e/selfhost/toolkits-ui.test.ts | 193 +++ e2e/src/surfaces/mcp.ts | 58 +- e2e/targets/selfhost.ts | 10 +- .../core/api/src/server/execution-stack.ts | 3 + packages/core/api/src/server/mcp-build.ts | 8 +- .../core/api/src/server/scoped-executor.ts | 10 +- packages/core/sdk/src/client.ts | 11 +- packages/core/sdk/src/executor.ts | 154 ++- packages/core/sdk/src/index.ts | 2 + packages/core/sdk/src/plugin.ts | 32 + packages/core/sdk/src/policies.test.ts | 83 +- packages/hosts/mcp/src/envelope.test.ts | 27 + packages/hosts/mcp/src/envelope.ts | 150 ++- .../hosts/mcp/src/in-memory-session-store.ts | 46 +- packages/hosts/mcp/src/index.ts | 3 + packages/hosts/mcp/src/seams.ts | 20 + packages/plugins/toolkits/package.json | 105 ++ packages/plugins/toolkits/src/client.tsx | 18 + packages/plugins/toolkits/src/page.tsx | 1189 +++++++++++++++++ packages/plugins/toolkits/src/server.test.ts | 134 ++ packages/plugins/toolkits/src/server.ts | 653 +++++++++ packages/plugins/toolkits/src/shared.ts | 166 +++ packages/plugins/toolkits/tsconfig.json | 23 + packages/plugins/toolkits/tsup.config.ts | 14 + packages/plugins/toolkits/vitest.config.ts | 7 + packages/react/src/components/tool-detail.tsx | 101 +- packages/react/src/components/tool-tree.tsx | 18 +- packages/react/src/multiplayer/shell.tsx | 27 +- .../src/routes/plugin-route-match.test.ts | 34 + .../react/src/routes/plugins.$pluginId.$.tsx | 75 +- 43 files changed, 3417 insertions(+), 196 deletions(-) rename apps/host-selfhost/web/{device.tsx => chromeless/device-page.tsx} (100%) rename apps/host-selfhost/web/{mcp-consent.tsx => chromeless/mcp-consent-page.tsx} (100%) create mode 100644 e2e/selfhost/toolkits-mcp.test.ts create mode 100644 e2e/selfhost/toolkits-ui.test.ts create mode 100644 packages/plugins/toolkits/package.json create mode 100644 packages/plugins/toolkits/src/client.tsx create mode 100644 packages/plugins/toolkits/src/page.tsx create mode 100644 packages/plugins/toolkits/src/server.test.ts create mode 100644 packages/plugins/toolkits/src/server.ts create mode 100644 packages/plugins/toolkits/src/shared.ts create mode 100644 packages/plugins/toolkits/tsconfig.json create mode 100644 packages/plugins/toolkits/tsup.config.ts create mode 100644 packages/plugins/toolkits/vitest.config.ts create mode 100644 packages/react/src/routes/plugin-route-match.test.ts diff --git a/apps/host-selfhost/executor.config.ts b/apps/host-selfhost/executor.config.ts index 7dec826b2..4e1ed6527 100644 --- a/apps/host-selfhost/executor.config.ts +++ b/apps/host-selfhost/executor.config.ts @@ -5,6 +5,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; import { resolveSecretKey } from "./src/config"; @@ -19,13 +20,14 @@ import { resolveSecretKey } from "./src/config"; // --------------------------------------------------------------------------- export default defineExecutorConfig({ - plugins: () => + plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) => [ openApiHttpPlugin(), googleHttpPlugin(), microsoftHttpPlugin(), mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), graphqlHttpPlugin(), + toolkitsPlugin({ activeToolkitSlug }), // First writable secret provider -> the default for `secrets.set`. encryptedSecretsPlugin({ key: resolveSecretKey() }), ] as const, diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index 9a43bd44f..7fe129ad0 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -32,6 +32,7 @@ "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-microsoft": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/plugin-toolkits": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index 54df87751..c4a080f3b 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -106,7 +106,8 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { routes: [ // CLI device-login discovery, must precede the /api/auth/* wildcard // below (Better Auth would otherwise 404 it). The verification page it - // points at (/device) is a console SPA route (web/device.tsx). + // points at (/device) is a console SPA route + // (web/chromeless/device-page.tsx). HttpRouter.add("GET", "/api/auth/cli-login", cliLoginHandler), // Better Auth owns the rest of /api/auth/*, the full path reaches it. HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)), diff --git a/apps/host-selfhost/src/execution.ts b/apps/host-selfhost/src/execution.ts index cb5ed64ec..c7ffbbf23 100644 --- a/apps/host-selfhost/src/execution.ts +++ b/apps/host-selfhost/src/execution.ts @@ -40,7 +40,11 @@ export { makeExecutionStack } from "@executor-js/api/server"; export const SelfHostPluginsProvider: Layer.Layer = Layer.succeed(PluginsProvider)( { - plugins: () => executorConfig.plugins(), + plugins: (context) => + executorConfig.plugins({ + activeToolkitSlug: + context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, + }), }, ); diff --git a/apps/host-selfhost/src/mcp/org-path.test.ts b/apps/host-selfhost/src/mcp/org-path.test.ts index 631971e62..6a1015991 100644 --- a/apps/host-selfhost/src/mcp/org-path.test.ts +++ b/apps/host-selfhost/src/mcp/org-path.test.ts @@ -6,17 +6,21 @@ describe("stripMcpOrgSegment", () => { it("strips a single org segment before /mcp", () => { expect(stripMcpOrgSegment("/iI9idP7BZcWpg9wW8cit3xE4r4dFSnHj/mcp")).toBe("/mcp"); expect(stripMcpOrgSegment("/org_123/mcp")).toBe("/mcp"); + expect(stripMcpOrgSegment("/org_123/mcp/toolkits/deploy")).toBe("/mcp/toolkits/deploy"); }); it("strips the org segment from the protected-resource discovery path", () => { expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp")).toBe( - "/.well-known/oauth-protected-resource/mcp", + "/.well-known/oauth-protected-resource", ); + expect( + stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp/toolkits/deploy"), + ).toBe("/.well-known/oauth-protected-resource"); }); it("leaves the bare paths untouched", () => { expect(stripMcpOrgSegment("/mcp")).toBeNull(); - expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/mcp")).toBeNull(); + expect(stripMcpOrgSegment("/mcp/toolkits/deploy")).toBeNull(); expect(stripMcpOrgSegment("/.well-known/oauth-authorization-server")).toBeNull(); }); diff --git a/apps/host-selfhost/src/mcp/org-path.ts b/apps/host-selfhost/src/mcp/org-path.ts index d457cd597..449e3088b 100644 --- a/apps/host-selfhost/src/mcp/org-path.ts +++ b/apps/host-selfhost/src/mcp/org-path.ts @@ -20,8 +20,9 @@ const PRM_PREFIX = "/.well-known/oauth-protected-resource"; * applies (already bare, not an MCP path, or an OAuth endpoint like * `/api/auth/mcp/authorize`). * - * //mcp -> /mcp - * /.well-known/oauth-protected-resource//mcp -> /.well-known/oauth-protected-resource/mcp + * //mcp -> /mcp + * //mcp/toolkits/ -> /mcp/toolkits/ + * /.well-known/oauth-protected-resource//mcp[...] -> /.well-known/oauth-protected-resource */ export const stripMcpOrgSegment = (pathname: string): string | null => { if (pathname.startsWith(`${PRM_PREFIX}/`)) { @@ -29,8 +30,14 @@ export const stripMcpOrgSegment = (pathname: string): string | null => { .slice(PRM_PREFIX.length + 1) .split("/") .filter((segment) => segment.length > 0); - return rest.length === 2 && rest[1] === "mcp" ? `${PRM_PREFIX}/mcp` : null; + if (rest.length === 2 && rest[1] === "mcp") return PRM_PREFIX; + if (rest.length === 4 && rest[1] === "mcp" && rest[2] === "toolkits") return PRM_PREFIX; + return null; } const segments = pathname.split("/").filter((segment) => segment.length > 0); - return segments.length === 2 && segments[1] === "mcp" ? "/mcp" : null; + if (segments.length === 2 && segments[1] === "mcp") return "/mcp"; + if (segments.length === 4 && segments[1] === "mcp" && segments[2] === "toolkits") { + return `/mcp/toolkits/${segments[3]}`; + } + return null; }; diff --git a/apps/host-selfhost/vite.config.ts b/apps/host-selfhost/vite.config.ts index 1199fb607..79d25b7e6 100644 --- a/apps/host-selfhost/vite.config.ts +++ b/apps/host-selfhost/vite.config.ts @@ -66,8 +66,8 @@ function executorApiPlugin(): Plugin { rawUrl = `${pathname}${original.search}`; } // Match on PATHNAME, not a raw-URL prefix: `/mcp` must NOT swallow the - // SPA route `/mcp-consent` (nor its source module `/mcp-consent.tsx`), - // or the dev server misroutes them to the API handler and they 404. + // SPA route `/mcp-consent`, or the dev server misroutes it to the API + // handler and returns a 404. const path = new URL(rawUrl, devOrigin).pathname; const handled = path === "/api" || diff --git a/apps/host-selfhost/web/device.tsx b/apps/host-selfhost/web/chromeless/device-page.tsx similarity index 100% rename from apps/host-selfhost/web/device.tsx rename to apps/host-selfhost/web/chromeless/device-page.tsx diff --git a/apps/host-selfhost/web/mcp-consent.tsx b/apps/host-selfhost/web/chromeless/mcp-consent-page.tsx similarity index 100% rename from apps/host-selfhost/web/mcp-consent.tsx rename to apps/host-selfhost/web/chromeless/mcp-consent-page.tsx diff --git a/apps/host-selfhost/web/routes/__root.tsx b/apps/host-selfhost/web/routes/__root.tsx index c2485b396..00e9f912d 100644 --- a/apps/host-selfhost/web/routes/__root.tsx +++ b/apps/host-selfhost/web/routes/__root.tsx @@ -11,8 +11,8 @@ import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shel import { plugins as clientPlugins } from "virtual:executor/plugins-client"; import { authClient } from "../auth-client"; -import { DevicePage } from "../device"; -import { McpConsentPage } from "../mcp-consent"; +import { DevicePage } from "../chromeless/device-page"; +import { McpConsentPage } from "../chromeless/mcp-consent-page"; import { LoginPage } from "../login"; import { SetupPage } from "../setup"; import { fetchNeedsSetup } from "../setup-status"; diff --git a/bun.lock b/bun.lock index 1c1557a68..158cd05ef 100644 --- a/bun.lock +++ b/bun.lock @@ -228,6 +228,7 @@ "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-microsoft": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/plugin-toolkits": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -346,6 +347,7 @@ "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-microsoft": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/plugin-toolkits": "workspace:*", "@executor-js/sdk": "workspace:*", "@kitlangton/terminal-control": "^0.3.0", "@modelcontextprotocol/sdk": "^1.29.0", @@ -1088,6 +1090,49 @@ "react", ], }, + "packages/plugins/toolkits": { + "name": "@executor-js/plugin-toolkits", + "version": "1.5.12", + "dependencies": { + "@executor-js/sdk": "workspace:*", + }, + "devDependencies": { + "@effect/atom-react": "catalog:", + "@effect/vitest": "catalog:", + "@executor-js/api": "workspace:*", + "@executor-js/react": "workspace:*", + "@tanstack/react-router": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "bun-types": "catalog:", + "effect": "catalog:", + "fractional-indexing": "^3.2.0", + "lucide-react": "^1.7.0", + "react": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + }, + "peerDependencies": { + "@effect/atom-react": "catalog:", + "@executor-js/api": "workspace:*", + "@executor-js/react": "workspace:*", + "@tanstack/react-router": "catalog:", + "effect": "catalog:", + "fractional-indexing": "^3.2.0", + "lucide-react": "^1.7.0", + "react": "catalog:", + }, + "optionalPeers": [ + "@effect/atom-react", + "@executor-js/api", + "@executor-js/react", + "@tanstack/react-router", + "fractional-indexing", + "lucide-react", + "react", + ], + }, "packages/plugins/workos-vault": { "name": "@executor-js/plugin-workos-vault", "version": "0.0.2", @@ -1682,6 +1727,8 @@ "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"], + "@executor-js/plugin-toolkits": ["@executor-js/plugin-toolkits@workspace:packages/plugins/toolkits"], + "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"], "@executor-js/react": ["@executor-js/react@workspace:packages/react"], diff --git a/e2e/package.json b/e2e/package.json index f22a9ea39..06370f939 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -27,6 +27,7 @@ "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-microsoft": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/plugin-toolkits": "workspace:*", "@executor-js/sdk": "workspace:*", "@kitlangton/terminal-control": "^0.3.0", "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/e2e/selfhost/toolkits-mcp.test.ts b/e2e/selfhost/toolkits-mcp.test.ts new file mode 100644 index 000000000..56f2ef142 --- /dev/null +++ b/e2e/selfhost/toolkits-mcp.test.ts @@ -0,0 +1,146 @@ +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; +import type { Identity } from "../src/target"; + +const api = composePluginApi([toolkitsPlugin()] as const); + +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; + +const allowedCode = ` +const result = await tools.executor.coreTools.integrations.list({}); +if (!result.ok) throw new Error(result.error.message); +return result.data.integrations.map((integration) => integration.slug).includes("executor"); +`; + +const blockedCode = ` +const result = await tools.executor.coreTools.policies.list({}); +if (!result.ok) throw new Error(result.error.message); +return result.data.policies.length; +`; + +const initializeSession = async (url: string, bearer: string): Promise => { + const response = await fetch(url, { + method: "POST", + headers: { + authorization: `Bearer ${bearer}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "toolkit-e2e", version: "1" }, + }, + }), + }); + expect(response.status, "toolkit initialize succeeds").toBe(200); + const sessionId = response.headers.get("mcp-session-id"); + expect(sessionId, "initialize returns a session id").toEqual(expect.any(String)); + return sessionId!; +}; + +const callToolsListWithSession = async ( + url: string, + bearer: string, + sessionId: string, +): Promise => + fetch(url, { + method: "POST", + headers: { + authorization: `Bearer ${bearer}`, + "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }), + }); + +scenario( + "Toolkits · self-host MCP exposes only the toolkit's allowed tools", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const { client: makeApiClient } = yield* Api; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(api, identity); + const suffix = randomBytes(4).toString("hex"); + + const toolkit = yield* client.toolkits.create({ + payload: { + owner: "org", + name: `toolkits-e2e-${suffix}`, + }, + }); + + yield* Effect.gen(function* () { + yield* client.toolkits.createConnection({ + params: { toolkitId: toolkit.id }, + payload: { + pattern: "executor.*", + }, + }); + yield* client.toolkits.createPolicy({ + params: { toolkitId: toolkit.id }, + payload: { + pattern: "executor.coreTools.integrations.list", + action: "approve", + }, + }); + yield* client.toolkits.createPolicy({ + params: { toolkitId: toolkit.id }, + payload: { + pattern: "executor.coreTools.policies.list", + action: "block", + }, + }); + + const toolkitUrl = new URL( + `/e2e-org/mcp/toolkits/${toolkit.slug}`, + target.baseUrl, + ).toString(); + const toolkitSession = mcp.session(identity, { url: toolkitUrl }); + const toolkitTools = yield* toolkitSession.listTools(); + expect(toolkitTools, "the toolkit endpoint still advertises execute").toContain("execute"); + + const allowed = yield* toolkitSession.call("execute", { code: allowedCode }); + expect(allowed.ok, `allowed toolkit tool call succeeds; response:\n${allowed.text}`).toBe( + true, + ); + expect(allowed.text, "allowed call returns the integration result").toContain("true"); + + const blocked = yield* toolkitSession.call("execute", { code: blockedCode }); + expect(blocked.ok, `blocked toolkit tool call fails; response:\n${blocked.text}`).toBe(false); + expect(blocked.text, "blocked call explains that the tool is unavailable").toMatch( + /blocked|not found|not available/i, + ); + + const normalSession = mcp.session(identity); + const normal = yield* normalSession.call("execute", { code: blockedCode }); + expect(normal.ok, "normal MCP is not scoped by the toolkit rules").toBe(true); + + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const sessionId = yield* Effect.promise(() => initializeSession(toolkitUrl, bearer)); + const reusedOnDefault = yield* Effect.promise(() => + callToolsListWithSession(target.mcpUrl, bearer, sessionId), + ); + expect(reusedOnDefault.status, "toolkit session id cannot be reused on /mcp").toBe(403); + }).pipe( + Effect.ensuring( + client.toolkits.remove({ params: { toolkitId: toolkit.id } }).pipe(Effect.ignore), + ), + ); + }), +); diff --git a/e2e/selfhost/toolkits-ui.test.ts b/e2e/selfhost/toolkits-ui.test.ts new file mode 100644 index 000000000..37c544453 --- /dev/null +++ b/e2e/selfhost/toolkits-ui.test.ts @@ -0,0 +1,193 @@ +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; + +const api = composePluginApi([toolkitsPlugin()] as const); + +scenario( + "Toolkits · self-host UI creates a toolkit and configures tools", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeApiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(api, identity); + + const suffix = randomBytes(4).toString("hex"); + const prefix = `toolkits-ui-${suffix}`; + const name = `${prefix}-created`; + const slug = name; + const seededToolkits = [ + { owner: "org" as const, name: `${prefix}-workspace-a` }, + { owner: "org" as const, name: `${prefix}-workspace-b` }, + { owner: "user" as const, name: `${prefix}-personal-a` }, + { owner: "user" as const, name: `${prefix}-personal-b` }, + { owner: "user" as const, name: `${prefix}-personal-c` }, + ]; + let addedConnectionPattern = ""; + const blockPattern = "executor.coreTools.policies.list"; + + const cleanup = Effect.gen(function* () { + const listed = yield* client.toolkits.list(); + yield* Effect.forEach( + listed.toolkits.filter((row) => row.slug.startsWith(prefix)), + (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }), + { discard: true }, + ); + }).pipe(Effect.ignore); + + yield* Effect.gen(function* () { + yield* Effect.forEach( + seededToolkits, + (toolkit) => client.toolkits.create({ payload: toolkit }), + { discard: true }, + ); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the Toolkits plugin page", async () => { + await page.goto("/plugins/toolkits/", { waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Toolkits" }).waitFor(); + await page.getByRole("heading", { name: "Workspace" }).waitFor(); + await page.getByRole("heading", { name: "Personal" }).waitFor(); + expect(await page.getByLabel("New toolkit").count()).toBe(0); + }); + + await step("Create a workspace toolkit from the add card", async () => { + const workspaceSection = page.locator("section").filter({ + has: page.getByRole("heading", { name: "Workspace" }), + }); + await workspaceSection.getByRole("button", { name: "Add workspace toolkit" }).click(); + await page.getByRole("dialog", { name: "New workspace toolkit" }).waitFor(); + await page.getByLabel("Toolkit name").fill(name); + await page.getByRole("button", { name: "Create toolkit" }).click(); + await page.getByRole("link", { name: `Open toolkit ${name}` }).waitFor(); + }); + + await step("Validate owner sections render as three-column grids", async () => { + const workspaceSection = page.locator("section").filter({ + has: page.getByRole("heading", { name: "Workspace" }), + }); + const personalSection = page.locator("section").filter({ + has: page.getByRole("heading", { name: "Personal" }), + }); + + const workspaceColumns = await workspaceSection + .getByRole("link", { name: /^Open toolkit/ }) + .evaluateAll((nodes) => + nodes.slice(0, 3).map((node) => Math.round(node.getBoundingClientRect().left)), + ); + const personalColumns = await personalSection + .getByRole("link", { name: /^Open toolkit/ }) + .evaluateAll((nodes) => + nodes.slice(0, 3).map((node) => Math.round(node.getBoundingClientRect().left)), + ); + + expect(new Set(workspaceColumns).size).toBe(3); + expect(new Set(personalColumns).size).toBe(3); + }); + + await step("Open the created toolkit from the grid", async () => { + await page.getByRole("link", { name: `Open toolkit ${name}` }).click(); + await page.waitForURL(new RegExp(`/plugins/toolkits/${slug}$`)); + expect(page.url()).toMatch(new RegExp(`/plugins/toolkits/${slug}$`)); + await page + .locator("code") + .filter({ hasText: `/mcp/toolkits/${slug}` }) + .waitFor(); + await page.getByText("No connections added").waitFor(); + expect(await page.getByLabel("New toolkit").count()).toBe(0); + }); + + await step("Return to the toolkit grid with browser-visible routing", async () => { + await page.getByRole("button", { name: "Toolkits" }).click(); + await page.waitForURL(/\/plugins\/toolkits\/?$/); + expect(page.url()).toMatch(/\/plugins\/toolkits\/?$/); + await page.getByRole("heading", { name: "Workspace" }).waitFor(); + await page.getByRole("link", { name: `Open toolkit ${name}` }).waitFor(); + }); + + await step("Open the created toolkit from a direct URL", async () => { + await page.goto(`/plugins/toolkits/${slug}`, { waitUntil: "networkidle" }); + expect(page.url()).toMatch(new RegExp(`/plugins/toolkits/${slug}$`)); + await page + .locator("code") + .filter({ hasText: `/mcp/toolkits/${slug}` }) + .waitFor(); + await page.getByText("No connections added").waitFor(); + expect(await page.getByLabel("New toolkit").count()).toBe(0); + }); + + await step("Add a connection to the toolkit", async () => { + await page.getByRole("button", { name: "Add connection to toolkit" }).click(); + const dialog = page.getByRole("dialog", { name: "Add connection" }); + await dialog.waitFor(); + await dialog.getByLabel("Search connections and tools").fill("policies.list"); + expect(await dialog.getByRole("button", { name: /^Add tool/ }).count()).toBe(0); + addedConnectionPattern = "executor.*"; + expect(await dialog.getByText(addedConnectionPattern, { exact: true }).count()).toBe(0); + await dialog + .getByRole("button", { name: /^Add connection / }) + .first() + .click(); + await dialog.waitFor({ state: "hidden" }); + const toolkitTools = page.getByRole("region", { name: "Toolkit tools" }); + await toolkitTools.waitFor(); + await toolkitTools.getByLabel("Filter tools").fill("policies.list"); + await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().waitFor(); + await toolkitTools.getByLabel("Filter tools").clear(); + }); + + await step("The add connection list reflects the saved toolkit connection", async () => { + await page.getByRole("button", { name: "Add connection to toolkit" }).click(); + const dialog = page.getByRole("dialog", { name: "Add connection" }); + await dialog.waitFor(); + await dialog.getByLabel("Search connections and tools").fill("policies.list"); + await dialog.getByRole("button", { name: /^Connection added / }).waitFor(); + expect(await dialog.getByRole("button", { name: /^Add connection / }).count()).toBe(0); + await page.keyboard.press("Escape"); + await dialog.waitFor({ state: "hidden" }); + }); + + await step("Block one tool from the toolkit tools list", async () => { + const toolkitTools = page.getByRole("region", { name: "Toolkit tools" }); + await toolkitTools.getByLabel("Filter tools").fill("policies.list"); + await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().click(); + await page.getByRole("button", { name: "Set policy", exact: true }).click(); + await page.getByText(blockPattern, { exact: true }).waitFor(); + await page.getByRole("menuitem", { name: "Block" }).click(); + await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().waitFor(); + await page.getByText("This tool is not available through the current toolkit.").waitFor(); + }); + }); + + const listed = yield* client.toolkits.list(); + const toolkit = listed.toolkits.find((row) => row.slug === slug); + expect(toolkit, "the UI-created toolkit persisted").toBeDefined(); + if (!toolkit) return; + expect(toolkit.owner).toBe("org"); + + const { policies } = yield* client.toolkits.listPolicies({ + params: { toolkitId: toolkit.id }, + }); + const { connections } = yield* client.toolkits.listConnections({ + params: { toolkitId: toolkit.id }, + }); + expect(addedConnectionPattern.length, "the UI selected a connection").toBeGreaterThan(0); + expect( + connections.map((connection) => connection.pattern), + "the UI-authored toolkit connection persisted", + ).toContain(addedConnectionPattern); + expect( + policies.map((policy) => `${policy.pattern} ${policy.action}`).sort(), + "the UI-authored toolkit access persisted with its action", + ).toEqual([`${blockPattern} block`]); + }).pipe(Effect.ensuring(cleanup)); + }), +); diff --git a/e2e/src/surfaces/mcp.ts b/e2e/src/surfaces/mcp.ts index 0e47555bb..eeb1dad84 100644 --- a/e2e/src/surfaces/mcp.ts +++ b/e2e/src/surfaces/mcp.ts @@ -160,7 +160,7 @@ export interface McpSurface { readonly url: string; readonly session: ( identity: Identity, - options?: { readonly elicitationMode?: McpElicitationMode }, + options?: { readonly elicitationMode?: McpElicitationMode; readonly url?: string }, ) => McpSession; /** * Mint a real MCP bearer headlessly: protected-resource discovery → @@ -188,6 +188,17 @@ interface TokenResponse { readonly access_token?: string; } +const jsonFrom = async (response: Response, label: string): Promise => { + const text = await response.text(); + if (!text) { + throw new Error(`${label}: empty response body (status ${response.status})`); + } + if (!response.ok) { + throw new Error(`${label}: request failed (status ${response.status}): ${text}`); + } + return JSON.parse(text) as T; +}; + const mintBearerFlow = async (target: Target, email: string): Promise => { const consent = target.mcpConsent?.({ label: email, @@ -196,21 +207,31 @@ const mintBearerFlow = async (target: Target, email: string): Promise => if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`); const mcpPath = new URL(target.mcpUrl).pathname; - const resource = (await ( - await fetch(new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl)) - ).json()) as { authorization_servers?: ReadonlyArray }; + let resourceResponse = await fetch( + new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl), + ); + if (resourceResponse.status === 404) { + resourceResponse = await fetch( + new URL("/.well-known/oauth-protected-resource", target.baseUrl), + ); + } + const resource = await jsonFrom<{ authorization_servers?: ReadonlyArray }>( + resourceResponse, + "mintBearer: protected-resource metadata", + ); const issuer = resource.authorization_servers?.[0]; if (!issuer) throw new Error("mintBearer: no authorization server advertised"); - const metadata = (await ( - await fetch(new URL("/.well-known/oauth-authorization-server", issuer)) - ).json()) as { + const metadata = await jsonFrom<{ readonly authorization_endpoint: string; readonly token_endpoint: string; readonly registration_endpoint: string; - }; + }>( + await fetch(new URL("/.well-known/oauth-authorization-server", issuer)), + "mintBearer: authorization-server metadata", + ); const redirectUri = "http://127.0.0.1:9/callback"; - const registered = (await ( + const registered = await jsonFrom<{ readonly client_id: string }>( await fetch(metadata.registration_endpoint, { method: "POST", headers: { "content-type": "application/json" }, @@ -221,8 +242,9 @@ const mintBearerFlow = async (target: Target, email: string): Promise => response_types: ["code"], token_endpoint_auth_method: "none", }), - }) - ).json()) as { readonly client_id: string }; + }), + "mintBearer: dynamic client registration", + ); const verifier = randomBytes(32).toString("base64url"); const authorizeUrl = new URL(metadata.authorization_endpoint); @@ -240,7 +262,7 @@ const mintBearerFlow = async (target: Target, email: string): Promise => redirectUrl: redirectUri, }); - const token = (await ( + const token = await jsonFrom( await fetch(metadata.token_endpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, @@ -251,8 +273,9 @@ const mintBearerFlow = async (target: Target, email: string): Promise => client_id: registered.client_id, code_verifier: verifier, }), - }) - ).json()) as TokenResponse; + }), + "mintBearer: token exchange", + ); if (!token.access_token) throw new Error("mintBearer: token exchange returned no token"); return token.access_token; }; @@ -261,7 +284,8 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( url: target.mcpUrl, mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)), session: (identity, options) => { - if (runDir) installTraceparentFetch(target.mcpUrl, runDir); + const mcpUrl = options?.url ?? target.mcpUrl; + if (runDir) installTraceparentFetch(mcpUrl, runDir); // mcporter caches OAuth tokens (and the DCR client) per server NAME, so a // constant name would let a later session reuse an earlier identity's token // — landing in the wrong org. A unique name per session keeps each @@ -272,8 +296,8 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( // `?elicitation_mode=` query on the MCP endpoint — so a paused execution // yields an approvalUrl instead of letting the model resume inline. const sessionUrl = options?.elicitationMode - ? `${target.mcpUrl}?elicitation_mode=${options.elicitationMode}` - : target.mcpUrl; + ? `${mcpUrl}?elicitation_mode=${options.elicitationMode}` + : mcpUrl; let runtimePromise: Promise | undefined; let connected = false; diff --git a/e2e/targets/selfhost.ts b/e2e/targets/selfhost.ts index 621bd133e..f88fd8763 100644 --- a/e2e/targets/selfhost.ts +++ b/e2e/targets/selfhost.ts @@ -83,7 +83,13 @@ const forcedMcpConsent = if (!decision.ok) { throw new Error(`forcedMcpConsent: consent grant failed (status ${decision.status})`); } - const body = (await decision.json()) as { redirectURI?: string }; + const decisionText = await decision.text(); + if (!decisionText) { + throw new Error( + `forcedMcpConsent: consent grant returned an empty body (status ${decision.status})`, + ); + } + const body = JSON.parse(decisionText) as { redirectURI?: string }; const code = body.redirectURI ? new URL(body.redirectURI).searchParams.get("code") : null; if (!code) { throw new Error( @@ -118,6 +124,6 @@ export const selfhostTarget = (): Target => ({ mcpConsent: (identity: Identity) => forcedMcpConsent(SELFHOST_BASE_URL, { email: identity.credentials?.email ?? SELFHOST_ADMIN.email, - password: identity.credentials?.password ?? SELFHOST_ADMIN.password, + password: identity.credentials?.password || SELFHOST_ADMIN.password, }), }); diff --git a/packages/core/api/src/server/execution-stack.ts b/packages/core/api/src/server/execution-stack.ts index 6e58801fc..78cfd1ca6 100644 --- a/packages/core/api/src/server/execution-stack.ts +++ b/packages/core/api/src/server/execution-stack.ts @@ -26,6 +26,7 @@ import { Context, Effect, Layer } from "effect"; import type * as Cause from "effect/Cause"; +import type { McpResource } from "@executor-js/host-mcp"; import type { AnyPlugin, Executor, StorageFailure } from "@executor-js/sdk"; import { createExecutionEngine, @@ -95,6 +96,7 @@ export const makeExecutionStack = < accountId: string, organizationId: string, organizationName: string, + options?: { readonly mcpResource?: McpResource }, ): Effect.Effect< { readonly executor: Executor; readonly engine: ExecutionEngine }, StorageFailure, @@ -105,6 +107,7 @@ export const makeExecutionStack = < accountId, organizationId, organizationName, + { plugins: { mcpResource: options?.mcpResource } }, ); const codeExecutor = yield* CodeExecutorProvider; const { decorate } = yield* EngineDecorator; diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index 75a08eec7..c274cd623 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -39,11 +39,9 @@ export type McpExecutionStackLayer = Layer.Layer< export const makeMcpBuildServer = (executionStack: McpExecutionStackLayer): McpBuildServer => (principal: Principal, options?: McpBuildServerOptions) => - makeExecutionStack( - principal.accountId, - principal.organizationId, - principal.organizationName, - ).pipe( + makeExecutionStack(principal.accountId, principal.organizationId, principal.organizationName, { + mcpResource: options?.resource, + }).pipe( Effect.map(({ engine }) => engine), // Pin browser-handoff URLs to the principal's org slug when present; // absent slug leaves the service unprovided and the URL stays bare. diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index a443d6742..4339fa24d 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -33,6 +33,7 @@ import { Context, Effect, Option } from "effect"; +import type { McpResource } from "@executor-js/host-mcp"; import { createExecutor, OAUTH_CALLBACK_ORG_QUERY_PARAM, @@ -169,8 +170,12 @@ export const buildOAuthRedirectUri = (input: { // while a host with static plugins (self-host) just returns a constant array. // --------------------------------------------------------------------------- +export interface PluginsProviderContext { + readonly mcpResource?: McpResource; +} + export interface PluginsProviderShape { - readonly plugins: () => readonly AnyPlugin[]; + readonly plugins: (context?: PluginsProviderContext) => readonly AnyPlugin[]; } export class PluginsProvider extends Context.Service()( @@ -207,6 +212,7 @@ export const makeScopedExecutor = < // `EngineStackIdentity` (the engine decorator still wants it); not part of the // v2 executor binding, which is `{ tenant, subject }` only. _organizationName: string, + options?: { readonly plugins?: PluginsProviderContext }, ): Effect.Effect, StorageFailure, DbProvider | PluginsProvider | HostConfig> => Effect.gen(function* () { const { db, blobs } = yield* DbProvider; @@ -248,7 +254,7 @@ export const makeScopedExecutor = < orgSlug, }); - const plugins = pluginsFactory(); + const plugins = pluginsFactory(options?.plugins); const hostedHttpOptions = { allowLocalNetwork: config.allowLocalNetwork, }; diff --git a/packages/core/sdk/src/client.ts b/packages/core/sdk/src/client.ts index 11e15b10f..4c426c0b9 100644 --- a/packages/core/sdk/src/client.ts +++ b/packages/core/sdk/src/client.ts @@ -46,10 +46,19 @@ export { useAtomValue, useAtomSet, useAtomMount, useAtomRefresh } from "@effect/ // alongside the host's own UI. // --------------------------------------------------------------------------- +export interface PluginPageProps { + /** Plugin-relative route params captured from `PageDecl.path` segments. */ + readonly params: Readonly>; + /** The normalized plugin-relative URL path that matched this page. */ + readonly path: string; + /** The plugin id from `/plugins/$pluginId/...`. */ + readonly pluginId: string; +} + export interface PageDecl { /** Path relative to the plugin's mount point, e.g. `/`, `/edit/$id`. */ readonly path: string; - readonly component: ComponentType; + readonly component: ComponentType; /** Optional sidebar nav metadata — the host renders these alongside its * own nav links. Omit to register a page without a nav entry. */ readonly nav?: { diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 38e12a3c6..fc25f07d8 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -90,6 +90,7 @@ import type { OAuthService } from "./oauth-client"; import { comparePolicyRow, isValidPattern, + matchPattern, resolveEffectivePolicy, rowToToolPolicy, type CreateToolPolicyInput, @@ -112,6 +113,8 @@ import type { StaticSourceDecl, StaticToolDecl, StorageDeps, + ToolPolicyProvider, + ToolPolicyProviderRule, ToolInvocationCredential, } from "./plugin"; import { @@ -1282,6 +1285,7 @@ export const createExecutor = (); const runtimes = new Map(); + let activeToolPolicyProvider: ToolPolicyProvider | null = null; // Credential providers keyed by `provider.key`, in registration order. const credentialProviders = new Map(); const credentialProviderOrder: string[] = []; @@ -2299,8 +2303,8 @@ export const createExecutor = => - core - .findMany("connection", { + Effect.gen(function* () { + const rows = yield* core.findMany("connection", { where: (b: AnyCb) => b.and( filter?.integration === undefined @@ -2308,8 +2312,22 @@ export const createExecutor = { + if (a.position < b.position) return -1; + if (a.position > b.position) return 1; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }; + + const resolveProviderPolicyFromRules = ( + toolId: string, + rules: readonly ToolPolicyProviderRule[], + ): EffectivePolicy => { + for (const rule of [...rules].sort(compareProviderPolicyRule)) { + if (!matchPattern(rule.pattern, toolId)) continue; + return { + action: rule.action, + source: "user", + pattern: rule.pattern, + policyId: rule.id, + }; + } + // Toolkit-style providers are capability allowlists. No matching rule + // means the tool is outside the capability boundary. + return { + action: "block", + source: "user", + pattern: "*", + }; + }; + + const listActivePolicyRuleSet = (): Effect.Effect => + activeToolPolicyProvider + ? activeToolPolicyProvider.resolve + ? Effect.succeed({ + kind: "provider" as const, + provider: activeToolPolicyProvider, + rules: null, + }) + : activeToolPolicyProvider.list().pipe( + Effect.map((rules) => ({ + kind: "provider" as const, + provider: activeToolPolicyProvider!, + rules, + })), + ) + : core + .findMany("tool_policy", {}) + .pipe(Effect.map((rows) => ({ kind: "global" as const, rows }))); + + const resolvePolicyFromRuleSet = ( + toolId: string, + ruleSet: ActivePolicyRuleSet, + defaultRequiresApproval?: boolean, + ): Effect.Effect => + ruleSet.kind === "provider" + ? ruleSet.provider.resolve + ? ruleSet.provider.resolve({ toolId, defaultRequiresApproval }) + : Effect.succeed(resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? [])) + : Effect.succeed( + resolveEffectivePolicy(toolId, ruleSet.rows, ownerRankForRow, defaultRequiresApproval), + ); + // ------------------------------------------------------------------ // Tools (read surface) // ------------------------------------------------------------------ @@ -2490,16 +2583,15 @@ export const createExecutor = => Effect.gen(function* () { + const policyRules = yield* listActivePolicyRuleSet(); const staticEntry = staticTools.get(String(address)); if (staticEntry) { const tool = staticToolToTool(staticEntry); + const effective = yield* resolvePolicyFromRuleSet( + normalizedPolicyId(tool), + policyRules, + tool.annotations?.requiresApproval, + ); + if (effective.action === "block") return null; const preview = yield* Effect.tryPromise({ try: () => buildToolTypeScriptPreview({ @@ -2565,6 +2663,12 @@ export const createExecutor = @@ -2904,11 +3008,10 @@ export const createExecutor = (effect: Effect.Effect) => transaction(effect), }; + if (plugin.toolPolicyProvider) { + const rawProvider = plugin.toolPolicyProvider(ctx); + const provider = Effect.isEffect(rawProvider) ? yield* rawProvider : rawProvider; + if (provider) { + if (activeToolPolicyProvider) { + return yield* new StorageError({ + message: "Only one plugin can provide the active tool policy source.", + cause: undefined, + }); + } + activeToolPolicyProvider = provider; + } + } + // Build extension FIRST so it's available as `self` for staticSources. const extension: object = plugin.extension ? plugin.extension(ctx) : {}; if (plugin.extension) { diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index bbf46064c..d5ed9d2e0 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -275,6 +275,8 @@ export { type AnyPlugin, type StorageDeps, type OwnerBinding, + type ToolPolicyProvider, + type ToolPolicyProviderRule, type IntegrationRecord, type StaticSourceDecl, type StaticToolDecl, diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 157002ceb..2999c4a56 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -48,6 +48,7 @@ import type { CredentialProvider, ProviderEntry } from "./provider"; import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage"; import type { CreateToolPolicyInput, + EffectivePolicy, RemoveToolPolicyInput, ToolPolicy, UpdateToolPolicyInput, @@ -88,6 +89,30 @@ export type Elicit = ( request: ElicitationRequest, ) => Effect.Effect; +// --------------------------------------------------------------------------- +// Active tool-policy provider. +// +// Normal executors resolve policies from core's owner-scoped `tool_policy` +// table. A plugin may opt one executor instance into a different rule source +// (for example, a toolkit-specific policy set). Core still owns enforcement; +// the plugin owns where those policy-shaped rows are stored. +// --------------------------------------------------------------------------- + +export interface ToolPolicyProviderRule { + readonly id: string; + readonly pattern: string; + readonly action: ToolPolicy["action"]; + readonly position: string; +} + +export interface ToolPolicyProvider { + readonly list: () => Effect.Effect; + readonly resolve?: (input: { + readonly toolId: string; + readonly defaultRequiresApproval?: boolean; + }) => Effect.Effect; +} + // --------------------------------------------------------------------------- // IntegrationRecord — the catalog row a plugin reads back (its own opaque // `config` included). Returned by `ctx.core.integrations.get`. @@ -458,6 +483,13 @@ export interface PluginSpec< /** Service tag the plugin's `handlers` layer requires. */ readonly extensionService?: TExtensionService; + /** Optional active policy source for this executor instance. At most one + * loaded plugin may return a provider. When absent, core uses the normal + * owner-scoped tool policies. */ + readonly toolPolicyProvider?: ( + ctx: PluginCtx, + ) => ToolPolicyProvider | null | Effect.Effect; + /** Produce a connection's tools (and shared $defs). The v2 successor to * registering per-source tools — called by the executor at connection * create / refresh / oauth.complete; the result is stamped with addresses diff --git a/packages/core/sdk/src/policies.test.ts b/packages/core/sdk/src/policies.test.ts index f98cc816d..f917fc307 100644 --- a/packages/core/sdk/src/policies.test.ts +++ b/packages/core/sdk/src/policies.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Predicate, Result } from "effect"; +import { Effect, Predicate, Result, Schema } from "effect"; import { type ToolPolicyRow } from "./core-schema"; import { @@ -19,7 +19,7 @@ import { matchPattern, resolveToolPolicy, } from "./policies"; -import { definePlugin } from "./plugin"; +import { definePlugin, tool } from "./plugin"; import type { CredentialProvider } from "./provider"; import { makeTestExecutor } from "./testing"; @@ -512,6 +512,85 @@ describe("blocked tools", () => { ); }); +describe("active tool-policy provider", () => { + const staticPlugin = definePlugin(() => ({ + id: "toolkit-fixture" as const, + storage: () => ({}), + staticSources: () => [ + { + id: "toolkit-fixture.ctl", + kind: "control" as const, + name: "Toolkit Fixture", + tools: [ + tool({ + name: "allowed", + description: "allowed", + inputSchema: Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(Schema.Struct({})), + ), + execute: () => Effect.succeed("allowed"), + }), + tool({ + name: "hidden", + description: "hidden", + inputSchema: Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(Schema.Struct({})), + ), + execute: () => Effect.succeed("hidden"), + }), + ], + }, + ], + }))(); + + const policyProviderPlugin = definePlugin(() => ({ + id: "toolkit-policy-provider" as const, + storage: () => ({}), + toolPolicyProvider: () => ({ + list: () => + Effect.succeed([ + { + id: "allow-static", + pattern: "toolkit-fixture.ctl.allowed", + action: "approve" as const, + position: "a0", + }, + ]), + }), + }))(); + + it.effect("uses provider rules as an allowlist for list, schema, and execute", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [staticPlugin, policyProviderPlugin] as const, + }); + + const tools = yield* executor.tools.list(); + expect(tools.map((t) => String(t.address)).sort()).toEqual(["toolkit-fixture.ctl.allowed"]); + + const allowedSchema = yield* executor.tools.schema( + ToolAddress.make("toolkit-fixture.ctl.allowed"), + ); + expect(allowedSchema?.name).toBe("allowed"); + + const hiddenSchema = yield* executor.tools.schema( + ToolAddress.make("toolkit-fixture.ctl.hidden"), + ); + expect(hiddenSchema).toBeNull(); + + const allowed = yield* executor.execute(ToolAddress.make("toolkit-fixture.ctl.allowed"), {}); + expect(allowed).toBe("allowed"); + + const blocked = yield* Effect.result( + executor.execute(ToolAddress.make("toolkit-fixture.ctl.hidden"), {}), + ); + expect(Result.isFailure(blocked)).toBe(true); + if (!Result.isFailure(blocked)) return; + expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true); + }), + ); +}); + describe("approve / require_approval interaction with annotations", () => { it.effect("approve skips the elicitation prompt even when plugin requires approval", () => Effect.gen(function* () { diff --git a/packages/hosts/mcp/src/envelope.test.ts b/packages/hosts/mcp/src/envelope.test.ts index ea4898416..8bc093aaf 100644 --- a/packages/hosts/mcp/src/envelope.test.ts +++ b/packages/hosts/mcp/src/envelope.test.ts @@ -20,6 +20,7 @@ import { McpErrorReporterNoop, McpServingRoutes, McpSessionStore, + type McpResource, type McpDispatchResult, type Principal, } from "./index"; @@ -133,3 +134,29 @@ describe("McpServingRoutes envelope", () => { expect(captures[0]).toContain("induced defect"); }); }); + +it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => { + const seen = await Effect.runPromise(Ref.make(null)); + const RecordingStoreLive = Layer.succeed(McpSessionStore)({ + dispatch: ({ resource }): Effect.Effect => + Ref.set(seen, resource).pipe( + Effect.as(new Response(JSON.stringify({ jsonrpc: "2.0", id: 1 }), { status: 200 })), + ), + dispose: () => Effect.void, + }); + + const handler = buildHandler(RecordingStoreLive, McpErrorReporterNoop); + const response = await handler( + new Request("https://host.test/mcp/toolkits/deploy", { + method: "POST", + headers: { authorization: "Bearer x", "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }), + ); + + expect(response.status).toBe(200); + expect(await Effect.runPromise(Ref.get(seen))).toEqual({ + kind: "toolkit", + slug: "deploy", + }); +}); diff --git a/packages/hosts/mcp/src/envelope.ts b/packages/hosts/mcp/src/envelope.ts index bed9070ff..7bb4ca57a 100644 --- a/packages/hosts/mcp/src/envelope.ts +++ b/packages/hosts/mcp/src/envelope.ts @@ -2,11 +2,13 @@ import { Effect, Match, Predicate } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { + defaultMcpResource, McpAuthProvider, McpErrorReporter, McpSessionStore, type AuthOutcome, type McpDispatchResult, + type McpResource, } from "./seams"; // --------------------------------------------------------------------------- @@ -14,7 +16,8 @@ import { // // Routes: // GET -> McpAuthProvider metadata -// * /mcp -> authenticate -> dispatch +// * /mcp -> authenticate -> dispatch(default) +// * /mcp/toolkits/:toolkitSlug -> authenticate -> dispatch(toolkit) // // The provider DECLARES the discovery paths it owns (at least the protected- // resource metadata document) via `McpAuthProvider.discoveryRoutes`; the @@ -24,8 +27,8 @@ import { // WorkOS, external). The envelope only needs the provider's discovery routes, // resource-metadata URL, and authenticate. // -// The envelope hard-codes ONLY the `/mcp` path and CORS. Everything else — -// every `/.well-known/*` path, the resource-metadata URL, the authn/authz +// The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else +// — every `/.well-known/*` path, the resource-metadata URL, the authn/authz // semantics, and the entire session lifecycle (create + forward + ownership) — // comes from the two seams. // @@ -38,6 +41,7 @@ import { // --------------------------------------------------------------------------- const MCP_PATH = "/mcp"; +const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug"; /** The methods the streamable-HTTP transport accepts on `/mcp`. */ const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); @@ -170,69 +174,73 @@ const renderDispatchError = (lookup: "not-found" | "forbidden"): Response => ? jsonRpcResponse(404, -32001, "Session not found") : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); -/** Dispatch a `/mcp` request through authenticate -> store.dispatch -> transport. */ -const mcpDispatch = Effect.gen(function* () { - const httpRequest = yield* HttpServerRequest.HttpServerRequest; - const auth = yield* McpAuthProvider; - const store = yield* McpSessionStore; - const request = yield* toWebRequest(httpRequest); +/** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */ +const mcpDispatch = (resource: McpResource) => + Effect.gen(function* () { + const httpRequest = yield* HttpServerRequest.HttpServerRequest; + const auth = yield* McpAuthProvider; + const store = yield* McpSessionStore; + const request = yield* toWebRequest(httpRequest); - // CORS preflight: answer before auth so unauthenticated clients can probe. - if (request.method === "OPTIONS") { - return HttpServerResponse.raw(corsPreflightResponse()); - } + // CORS preflight: answer before auth so unauthenticated clients can probe. + if (request.method === "OPTIONS") { + return HttpServerResponse.raw(corsPreflightResponse()); + } - // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other - // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch — - // otherwise it would fall through and spin up a session engine for a method - // the transport can't serve. - if (!ALLOWED_MCP_METHODS.has(request.method)) { - return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed")); - } + // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other + // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch — + // otherwise it would fall through and spin up a session engine for a method + // the transport can't serve. + if (!ALLOWED_MCP_METHODS.has(request.method)) { + return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed")); + } - const sessionId = request.headers.get("mcp-session-id"); + const sessionId = request.headers.get("mcp-session-id"); - // Authenticate (and, for session-aware providers, authorize) on EVERY - // request. On a non-Authenticated outcome: - // - Forbidden -> dispose the live session first (cloud tears down a DO - // whose org access was revoked), then render the 403. The - // inbound request is forwarded so the store can propagate - // the request's W3C trace context onto the teardown RPC. - // - other -> render directly. - const outcome = yield* auth.authenticate(request); - if (!Predicate.isTagged(outcome, "Authenticated")) { - if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { - yield* store.dispose(sessionId, request); + // Authenticate (and, for session-aware providers, authorize) on EVERY + // request. On a non-Authenticated outcome: + // - Forbidden -> dispose the live session first (cloud tears down a DO + // whose org access was revoked), then render the 403. The + // inbound request is forwarded so the store can propagate + // the request's W3C trace context onto the teardown RPC. + // - other -> render directly. + const outcome = yield* auth.authenticate(request); + if (!Predicate.isTagged(outcome, "Authenticated")) { + if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { + yield* store.dispose(sessionId, request); + } + return HttpServerResponse.raw(renderAuthError(auth, request, outcome)); } - return HttpServerResponse.raw(renderAuthError(auth, request, outcome)); - } - const principal = outcome.principal; + const principal = outcome.principal; - // No session id: per the streamable-HTTP transport contract, only POST opens - // a session. A GET needs an existing id (400); a DELETE on nothing is a - // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up - // an engine for a bare GET/DELETE. - if (!sessionId) { - if (request.method === "GET") { - return HttpServerResponse.raw( - jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"), - ); + // No session id: per the streamable-HTTP transport contract, only POST opens + // a session. A GET needs an existing id (400); a DELETE on nothing is a + // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up + // an engine for a bare GET/DELETE. + if (!sessionId) { + if (request.method === "GET") { + return HttpServerResponse.raw( + jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"), + ); + } + if (request.method === "DELETE") { + return HttpServerResponse.raw( + new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }), + ); + } } - if (request.method === "DELETE") { - return HttpServerResponse.raw( - new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }), - ); - } - } - const result: McpDispatchResult = yield* store.dispatch({ - request, - principal, - sessionId, - method: request.method, + const result: McpDispatchResult = yield* store.dispatch({ + request, + principal, + resource, + sessionId, + method: request.method, + }); + return HttpServerResponse.raw( + result instanceof Response ? result : renderDispatchError(result), + ); }); - return HttpServerResponse.raw(result instanceof Response ? result : renderDispatchError(result)); -}); /** * The `/mcp` route. Wraps {@link mcpDispatch} in a top-level `catchCause`: a @@ -242,15 +250,22 @@ const mcpDispatch = Effect.gen(function* () { * otherwise, since the envelope returns a `Response`) and rendered as a stable * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body. */ -const mcpRoute = mcpDispatch.pipe( - Effect.catchCause((cause) => - Effect.gen(function* () { - const reporter = yield* McpErrorReporter; - yield* reporter.report(cause); - return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error")); - }), - ), -); +const mcpRoute = (resource: McpResource) => + mcpDispatch(resource).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const reporter = yield* McpErrorReporter; + yield* reporter.report(cause); + return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error")); + }), + ), + ); + +const toolkitMcpRoute = Effect.gen(function* () { + const params = yield* HttpRouter.params; + const slug = params.toolkitSlug; + return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource); +}); /** * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges @@ -273,6 +288,7 @@ export const McpServingRoutes = HttpRouter.use((router) => Effect.sync(() => HttpServerResponse.raw(corsPreflightResponse())), ); } - yield* router.add("*", MCP_PATH, mcpRoute); + yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource)); + yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute); }), ); diff --git a/packages/hosts/mcp/src/in-memory-session-store.ts b/packages/hosts/mcp/src/in-memory-session-store.ts index 5c67ecbe5..e2d233e8c 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.ts @@ -17,10 +17,13 @@ import { import { jsonRpcErrorBody } from "./envelope"; import { McpSessionStore, + defaultMcpResource, + mcpResourceKey, principalOwns, type McpDispatchInput, type McpDispatchResult, type Principal, + type McpResource, } from "./seams"; import type { BrowserApprovalStore } from "./tool-server"; @@ -61,6 +64,7 @@ export interface BuiltMcpServer { /** The browser-mode wiring the store hands a build call when a session opts in. */ export interface McpBuildServerOptions { + readonly resource?: McpResource; readonly elicitationMode?: | { readonly mode: "browser"; readonly approvalUrl: (executionId: string) => string } | { readonly mode: "model" } @@ -120,6 +124,19 @@ const json = (value: unknown, status = 200): Response => const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; +interface SessionOwner { + readonly principal: Principal; + readonly resource: McpResource; +} + +const sessionOwnerMatches = ( + owner: SessionOwner, + principal: Principal, + resource: McpResource, +): boolean => + principalOwns(owner.principal, principal) && + mcpResourceKey(owner.resource) === mcpResourceKey(resource); + /** * Build the in-process session store plus an explicit `close()` that disposes * all live sessions. `close()` is not part of the seam — it is the host lifetime @@ -137,7 +154,7 @@ export const makeInMemoryMcpSessionStore = ( ): InMemoryMcpSessionStore => { const transports = new Map(); const servers = new Map(); - const owners = new Map(); + const owners = new Map(); const engines = new Map>(); const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore(); @@ -181,12 +198,13 @@ export const makeInMemoryMcpSessionStore = ( const forward = ( sessionId: string, principal: Principal, + resource: McpResource, request: Request, ): Effect.Effect => { const transport = transports.get(sessionId); const owner = owners.get(sessionId); if (!transport || !owner) return Effect.succeed("not-found"); - if (!principalOwns(owner, principal)) return Effect.succeed("forbidden"); + if (!sessionOwnerMatches(owner, principal, resource)) return Effect.succeed("forbidden"); return runHandleRequest(transport, request); }; @@ -218,12 +236,16 @@ export const makeInMemoryMcpSessionStore = ( }; /** Open a new session: build the server, connect a transport, drive the request. */ - const create = (principal: Principal, request: Request): Effect.Effect => { + const create = ( + principal: Principal, + resource: McpResource, + request: Request, + ): Effect.Effect => { let createdSessionId: string | null = null; - return buildServer( - principal, - buildOptionsFor(request, () => createdSessionId), - ).pipe( + return buildServer(principal, { + ...buildOptionsFor(request, () => createdSessionId), + resource, + }).pipe( Effect.flatMap(({ mcpServer, engine }) => Effect.gen(function* () { const transport = new WebStandardStreamableHTTPServerTransport({ @@ -233,7 +255,7 @@ export const makeInMemoryMcpSessionStore = ( createdSessionId = sid; transports.set(sid, transport); servers.set(sid, mcpServer); - owners.set(sid, principal); + owners.set(sid, { principal, resource }); engines.set(sid, engine); }, onsessionclosed: (sid) => void dispose(sid, { server: true }), @@ -259,8 +281,10 @@ export const makeInMemoryMcpSessionStore = ( }; const store: McpSessionStore["Service"] = { - dispatch: ({ request, principal, sessionId }: McpDispatchInput) => - sessionId ? forward(sessionId, principal, request) : create(principal, request), + dispatch: ({ request, principal, resource, sessionId }: McpDispatchInput) => + sessionId + ? forward(sessionId, principal, resource, request) + : create(principal, resource ?? defaultMcpResource, request), dispose: (sessionId) => Effect.promise(() => dispose(sessionId, { transport: true, server: true })), }; @@ -271,7 +295,7 @@ export const makeInMemoryMcpSessionStore = ( ): "allowed" | "not-found" | "forbidden" => { const owner = owners.get(sessionId); if (!owner) return "not-found"; - if (principal && !principalOwns(owner, principal)) return "forbidden"; + if (principal && !principalOwns(owner.principal, principal)) return "forbidden"; return "allowed"; }; diff --git a/packages/hosts/mcp/src/index.ts b/packages/hosts/mcp/src/index.ts index 08d0e7d74..2ee3b1dd2 100644 --- a/packages/hosts/mcp/src/index.ts +++ b/packages/hosts/mcp/src/index.ts @@ -18,6 +18,8 @@ export { McpSessionStore, McpErrorReporter, McpErrorReporterNoop, + defaultMcpResource, + mcpResourceKey, principalOwns, authenticated, unauthorized, @@ -31,6 +33,7 @@ export { type McpDiscoveryRoute, type McpDispatchInput, type McpDispatchResult, + type McpResource, } from "./seams"; export { McpServingRoutes, jsonRpcErrorBody } from "./envelope"; diff --git a/packages/hosts/mcp/src/seams.ts b/packages/hosts/mcp/src/seams.ts index c900ef36d..81ecce97a 100644 --- a/packages/hosts/mcp/src/seams.ts +++ b/packages/hosts/mcp/src/seams.ts @@ -56,6 +56,25 @@ export type Principal = Schema.Schema.Type; export const principalOwns = (owner: Principal, principal: Principal): boolean => owner.accountId === principal.accountId && owner.organizationId === principal.organizationId; +// --------------------------------------------------------------------------- +// Shared MCP resource identity. +// +// The default `/mcp` endpoint and named sub-resources such as +// `/mcp/toolkits/` share auth and transport machinery, but a serving +// session belongs to the exact resource path that minted it. This keeps a +// leaked/reused `mcp-session-id` from crossing from one exposed capability set +// into another. +// --------------------------------------------------------------------------- + +export type McpResource = + | { readonly kind: "default" } + | { readonly kind: "toolkit"; readonly slug: string }; + +export const defaultMcpResource: McpResource = { kind: "default" }; + +export const mcpResourceKey = (resource: McpResource): string => + resource.kind === "default" ? "default" : `toolkit:${resource.slug}`; + // --------------------------------------------------------------------------- // AuthOutcome — the result of `McpAuthProvider.authenticate`. // @@ -210,6 +229,7 @@ export class McpAuthProvider extends Context.Service< export interface McpDispatchInput { readonly request: Request; readonly principal: Principal; + readonly resource: McpResource; readonly sessionId: string | null; readonly method: string; } diff --git a/packages/plugins/toolkits/package.json b/packages/plugins/toolkits/package.json new file mode 100644 index 000000000..432588b46 --- /dev/null +++ b/packages/plugins/toolkits/package.json @@ -0,0 +1,105 @@ +{ + "name": "@executor-js/plugin-toolkits", + "version": "1.5.12", + "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/toolkits", + "bugs": { + "url": "https://github.com/RhysSullivan/executor/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/RhysSullivan/executor.git", + "directory": "packages/plugins/toolkits" + }, + "files": [ + "dist" + ], + "type": "module", + "exports": { + "./server": "./src/server.ts", + "./client": "./src/client.tsx", + "./shared": "./src/shared.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + "./server": { + "import": { + "types": "./dist/server.d.ts", + "default": "./dist/server.js" + } + }, + "./client": { + "import": { + "types": "./dist/client.d.ts", + "default": "./dist/client.js" + } + }, + "./shared": { + "import": { + "types": "./dist/shared.d.ts", + "default": "./dist/shared.js" + } + } + } + }, + "scripts": { + "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", + "typecheck": "tsgo --noEmit", + "test": "vitest run", + "typecheck:slow": "tsc --noEmit" + }, + "dependencies": { + "@executor-js/sdk": "workspace:*" + }, + "devDependencies": { + "@effect/atom-react": "catalog:", + "@effect/vitest": "catalog:", + "@executor-js/api": "workspace:*", + "@executor-js/react": "workspace:*", + "@tanstack/react-router": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "bun-types": "catalog:", + "effect": "catalog:", + "fractional-indexing": "^3.2.0", + "lucide-react": "^1.7.0", + "react": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "@effect/atom-react": "catalog:", + "@executor-js/api": "workspace:*", + "@executor-js/react": "workspace:*", + "@tanstack/react-router": "catalog:", + "effect": "catalog:", + "fractional-indexing": "^3.2.0", + "lucide-react": "^1.7.0", + "react": "catalog:" + }, + "peerDependenciesMeta": { + "@effect/atom-react": { + "optional": true + }, + "@executor-js/api": { + "optional": true + }, + "@executor-js/react": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "fractional-indexing": { + "optional": true + }, + "lucide-react": { + "optional": true + }, + "react": { + "optional": true + } + } +} diff --git a/packages/plugins/toolkits/src/client.tsx b/packages/plugins/toolkits/src/client.tsx new file mode 100644 index 000000000..18628fee1 --- /dev/null +++ b/packages/plugins/toolkits/src/client.tsx @@ -0,0 +1,18 @@ +import { defineClientPlugin } from "@executor-js/sdk/client"; + +import { ToolkitsPage } from "./page"; + +export default defineClientPlugin({ + id: "toolkits" as const, + pages: [ + { + path: "/", + component: ToolkitsPage, + nav: { label: "Toolkits" }, + }, + { + path: "/$toolkitSlug", + component: ToolkitsPage, + }, + ], +}); diff --git a/packages/plugins/toolkits/src/page.tsx b/packages/plugins/toolkits/src/page.tsx new file mode 100644 index 000000000..65bdc8a8d --- /dev/null +++ b/packages/plugins/toolkits/src/page.tsx @@ -0,0 +1,1189 @@ +import { useId, useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { + ArrowLeftIcon, + BoxIcon, + CheckIcon, + PlugIcon, + PlusIcon, + SearchIcon, + Trash2Icon, +} from "lucide-react"; +import { + createPluginAtomClient, + useIntegrationPlugins, + type IntegrationPlugin, + type PluginPageProps, +} from "@executor-js/sdk/client"; +import { + matchPattern, + type EffectivePolicy, + type Integration, + type Owner, + type ToolAddress, + type ToolPolicyAction, +} from "@executor-js/sdk/shared"; +import { integrationsOptimisticAtom, toolsAllAtom } from "@executor-js/react/api/atoms"; +import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; +import { + getExecutorApiBaseUrl, + getExecutorOrganizationHeaders, + getExecutorServerAuthorizationHeader, +} from "@executor-js/react/api/server-connection"; +import { ownerLabel } from "@executor-js/react/api/owner-display"; +import { Badge } from "@executor-js/react/components/badge"; +import { Button } from "@executor-js/react/components/button"; +import { CopyButton } from "@executor-js/react/components/copy-button"; +import { + IntegrationFavicon, + integrationPresetIconUrl, +} from "@executor-js/react/components/integration-favicon"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@executor-js/react/components/dialog"; +import { Input } from "@executor-js/react/components/input"; +import { Label } from "@executor-js/react/components/label"; +import { Skeleton } from "@executor-js/react/components/skeleton"; +import { ToolDetail, ToolDetailEmpty } from "@executor-js/react/components/tool-detail"; +import { ToolTree, type ToolSummary } from "@executor-js/react/components/tool-tree"; +import { cn } from "@executor-js/react/lib/utils"; + +import { + ToolkitsApi, + type ToolkitConnectionResponse, + type ToolkitPolicyResponse, + type ToolkitResponse, +} from "./shared"; + +const ToolkitsClient = createPluginAtomClient(ToolkitsApi, { + baseUrl: getExecutorApiBaseUrl, + authorizationHeader: getExecutorServerAuthorizationHeader, + headers: getExecutorOrganizationHeaders, +}); + +const toolkitWriteKeys = [ + ReactivityKey.connections, + ReactivityKey.policies, + ReactivityKey.tools, +] as const; + +const toolkitsAtom = ToolkitsClient.query("toolkits", "list", { + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.policies], +}); + +const toolkitPoliciesAtom = Atom.family((toolkitId: string) => + ToolkitsClient.query("toolkits", "listPolicies", { + params: { toolkitId }, + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.policies], + }), +); + +const toolkitConnectionsAtom = Atom.family((toolkitId: string) => + ToolkitsClient.query("toolkits", "listConnections", { + params: { toolkitId }, + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.connections], + }), +); + +const createToolkit = ToolkitsClient.mutation("toolkits", "create"); +const removeToolkit = ToolkitsClient.mutation("toolkits", "remove"); +const createToolkitPolicy = ToolkitsClient.mutation("toolkits", "createPolicy"); +const updateToolkitPolicy = ToolkitsClient.mutation("toolkits", "updatePolicy"); +const removeToolkitPolicy = ToolkitsClient.mutation("toolkits", "removePolicy"); +const createToolkitConnection = ToolkitsClient.mutation("toolkits", "createConnection"); + +type ToolRow = { + readonly address: ToolAddress; + readonly integration: string; + readonly owner?: Owner; + readonly connection?: string; + readonly name: string; + readonly description?: string; + readonly requiresApproval?: boolean; + readonly static?: boolean; +}; + +const comparePolicy = (a: ToolkitPolicyResponse, b: ToolkitPolicyResponse): number => { + if (a.position < b.position) return -1; + if (a.position > b.position) return 1; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +}; + +const pluginDefaultPolicy = (requiresApproval: boolean | undefined): EffectivePolicy => + requiresApproval + ? { action: "require_approval", source: "plugin-default" } + : { action: "approve", source: "plugin-default" }; + +const isLegacyConnectionPolicy = (policy: ToolkitPolicyResponse): boolean => { + if (policy.action !== "approve") return false; + const parts = policy.pattern.split("."); + return parts.at(-1) === "*" && (parts.length === 3 || parts.length === 4); +}; + +const resolveToolkitPolicy = ( + matchId: string, + policies: readonly ToolkitPolicyResponse[], + requiresApproval?: boolean, +): EffectivePolicy => { + for (const policy of [...policies].sort(comparePolicy)) { + if (!matchPattern(policy.pattern, matchId)) continue; + return { + action: policy.action, + source: "user", + pattern: policy.pattern, + policyId: policy.id, + }; + } + return pluginDefaultPolicy(requiresApproval); +}; + +const toolMatchId = (tool: ToolRow): string => + tool.static ? String(tool.address) : String(tool.address).replace(/^tools\./, ""); + +const toolCanAppearInToolkit = (toolkit: ToolkitResponse, tool: ToolRow): boolean => + toolkit.owner === "user" || tool.static === true || tool.owner !== "user"; + +const toolkitUrlFor = (orgSlug: string | undefined, slug: string): string => { + const path = orgSlug ? `/${orgSlug}/mcp/toolkits/${slug}` : `/mcp/toolkits/${slug}`; + if (typeof window === "undefined") return path; + return `${window.location.origin}${path}`; +}; + +const identityPattern = (displayPattern: string): string => displayPattern; + +const formatUpdatedAt = (value: number): string => + new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(value)); + +const toolkitScopeLabel = (toolkit: ToolkitResponse): string => + toolkit.owner === "org" ? "Workspace tools" : "Workspace + personal tools"; + +const compareToolkitRows = (a: ToolkitResponse, b: ToolkitResponse): number => { + if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt; + return a.name.localeCompare(b.name); +}; + +const toolkitCardStyle = { minHeight: "9rem" }; +const toolkitShelfStyle = { minHeight: "28.5rem" }; +const toolkitGridContainerStyle = { maxWidth: "80rem" }; +const toolkitToolTreeStyle = { width: "24rem" }; + +const ownerAccentClass = (owner: Owner): string => + owner === "org" + ? "border-sky-500/25 bg-sky-500/10 text-sky-700 dark:text-sky-300" + : "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; + +type ToolkitConnectionGroup = { + readonly id: string; + readonly owner: Owner; + readonly integration: string; + readonly connection: string; + readonly patterns: readonly string[]; + readonly tools: readonly ToolRow[]; +}; + +type IntegrationMeta = { + readonly name: string; + readonly sourceId: string; + readonly icon?: string | null; + readonly url?: string; +}; + +const toolOwner = (tool: ToolRow): Owner => (tool.static ? "org" : (tool.owner ?? "org")); + +const toolConnectionName = (tool: ToolRow): string => { + if (tool.connection && tool.connection.length > 0) return tool.connection; + return tool.static ? "built-in" : "default"; +}; + +const policyPrefixForTool = (tool: ToolRow): string => { + const matchId = toolMatchId(tool); + const integration = String(tool.integration); + if (tool.connection && tool.connection.length > 0) { + const connection = String(tool.connection); + if (tool.owner) { + const ownerConnectionPrefix = `${integration}.${tool.owner}.${connection}`; + if (matchId === ownerConnectionPrefix || matchId.startsWith(`${ownerConnectionPrefix}.`)) { + return ownerConnectionPrefix; + } + } + const connectionPrefix = `${integration}.${connection}`; + if (matchId === connectionPrefix || matchId.startsWith(`${connectionPrefix}.`)) { + return connectionPrefix; + } + } + const name = String(tool.name); + if (name.length > 0 && matchId.endsWith(`.${name}`)) { + return matchId.slice(0, -name.length - 1); + } + const segments = matchId.split("."); + return segments.length > 1 ? segments.slice(0, -1).join(".") : matchId; +}; + +const connectionPatternForTool = (tool: ToolRow): string => `${policyPrefixForTool(tool)}.*`; + +const compareConnectionGroups = (a: ToolkitConnectionGroup, b: ToolkitConnectionGroup): number => { + const ownerRank = (owner: Owner) => (owner === "org" ? 0 : 1); + return ( + ownerRank(a.owner) - ownerRank(b.owner) || + a.integration.localeCompare(b.integration) || + a.connection.localeCompare(b.connection) || + a.id.localeCompare(b.id) + ); +}; + +const patternSubsumes = (candidate: string, covered: string): boolean => { + if (candidate === covered) return true; + if (!candidate.endsWith(".*")) return false; + const prefix = candidate.slice(0, -1); + return covered.startsWith(prefix); +}; + +const reducePolicyPatterns = (patterns: readonly string[]): readonly string[] => { + const unique = [...new Set(patterns)].sort((a, b) => a.localeCompare(b)); + return unique.filter( + (pattern) => + !unique.some((candidate) => candidate !== pattern && patternSubsumes(candidate, pattern)), + ); +}; + +const buildConnectionGroups = (tools: readonly ToolRow[]): readonly ToolkitConnectionGroup[] => { + const groups = new Map(); + for (const tool of tools) { + const owner = toolOwner(tool); + const key = `${owner}:${tool.integration}:${toolConnectionName(tool)}`; + const existing = groups.get(key); + if (existing) { + existing.tools.push(tool); + continue; + } + groups.set(key, { + id: key, + owner, + integration: String(tool.integration), + connection: toolConnectionName(tool), + patterns: [], + tools: [tool], + }); + } + return [...groups.values()] + .map((group) => { + const sortedTools = [...group.tools].sort(compareTools); + return { + ...group, + patterns: reducePolicyPatterns(sortedTools.map(connectionPatternForTool)), + tools: sortedTools, + }; + }) + .sort(compareConnectionGroups); +}; + +const compareTools = (a: ToolRow, b: ToolRow): number => + String(a.name).localeCompare(String(b.name)) || toolMatchId(a).localeCompare(toolMatchId(b)); + +const connectionTitle = (group: ToolkitConnectionGroup): string => + `${group.integration} / ${group.connection}`; + +const connectionDisplayTitle = (group: ToolkitConnectionGroup, meta: IntegrationMeta): string => + group.connection === "built-in" || group.connection === "default" ? meta.name : group.connection; + +const connectionDisplaySubtitle = (group: ToolkitConnectionGroup, meta: IntegrationMeta): string => + group.connection === "built-in" || group.connection === "default" + ? `${group.tools.length} ${group.tools.length === 1 ? "tool" : "tools"}` + : `${meta.name} · ${group.tools.length} ${group.tools.length === 1 ? "tool" : "tools"}`; + +const integrationMetaFor = ( + group: ToolkitConnectionGroup, + integrations: readonly Integration[], + integrationPlugins: readonly IntegrationPlugin[], +): IntegrationMeta => { + const integration = integrations.find((row) => String(row.slug) === group.integration); + const fallbackName = group.integration + .replace(/[_-]/g, " ") + .replace(/\b\w/g, (character) => character.toUpperCase()); + const source = { + id: group.integration, + kind: integration?.kind ?? group.integration, + name: integration?.name ?? fallbackName, + url: integration?.displayUrl, + }; + return { + name: source.name, + sourceId: group.integration, + icon: integrationPresetIconUrl(source, integrationPlugins), + ...(source.url ? { url: source.url } : {}), + }; +}; + +const legacyConnectionPolicyIds = ( + policies: readonly ToolkitPolicyResponse[], + connectionGroups: readonly ToolkitConnectionGroup[], + connections: readonly ToolkitConnectionResponse[], +): ReadonlySet => { + const persistedPatterns = new Set(connections.map((connection) => connection.pattern)); + const connectionPatterns = new Set(connectionGroups.flatMap((group) => group.patterns)); + return new Set( + policies + .filter( + (policy) => + isLegacyConnectionPolicy(policy) && + connectionPatterns.has(policy.pattern) && + !persistedPatterns.has(policy.pattern), + ) + .map((policy) => policy.id), + ); +}; + +const configuredConnectionPatterns = ( + connections: readonly ToolkitConnectionResponse[], + policies: readonly ToolkitPolicyResponse[], + legacyPolicyIds: ReadonlySet, +): ReadonlySet => + new Set([ + ...connections.map((connection) => connection.pattern), + ...policies.filter((policy) => legacyPolicyIds.has(policy.id)).map((policy) => policy.pattern), + ]); + +function ToolkitTile(props: { pluginId: string; toolkit: ToolkitResponse }) { + const toolkit = props.toolkit; + const scope = toolkitScopeLabel(toolkit); + return ( + +
+
+ +
+
+
{toolkit.name}
+
+ {toolkit.slug} +
+
+
+ +
+ + /mcp/toolkits/{toolkit.slug} + +
+ {scope} + {formatUpdatedAt(toolkit.updatedAt)} +
+
+ + ); +} + +const addToolkitScopeLabel = (owner: Owner): string => (owner === "org" ? "workspace" : "personal"); +const addToolkitTitle = (owner: Owner): string => + owner === "org" ? "New workspace toolkit" : "New personal toolkit"; + +function CreateToolkitDialog(props: { + owner: Owner; + open: boolean; + onOpenChange: (open: boolean) => void; + onCreate: (input: { owner: Owner; name: string }) => Promise; +}) { + const inputId = useId(); + const [name, setName] = useState(""); + const trimmed = name.trim(); + const title = addToolkitTitle(props.owner); + + const submit = async () => { + if (!trimmed) return; + await props.onCreate({ owner: props.owner, name: trimmed }); + setName(""); + props.onOpenChange(false); + }; + + return ( + { + props.onOpenChange(nextOpen); + if (!nextOpen) setName(""); + }} + > + +
{ + event.preventDefault(); + void submit(); + }} + > + + {title} + + Group tools and expose them at a dedicated MCP URL. + + +
+ + setName(event.target.value)} + placeholder="e.g. Support tools" + autoFocus + className="h-9" + /> +
+ + + +
+
+
+ ); +} + +function AddToolkitCard(props: { owner: Owner; onClick: () => void }) { + const scopeLabel = addToolkitScopeLabel(props.owner); + return ( + + ); +} + +function ToolkitSection(props: { + pluginId: string; + owner: Owner; + title: string; + toolkits: readonly ToolkitResponse[]; + onCreate: (input: { owner: Owner; name: string }) => Promise; +}) { + const rows = [...props.toolkits].sort(compareToolkitRows); + const [createOpen, setCreateOpen] = useState(false); + const openCreate = () => setCreateOpen(true); + return ( +
+
+

+ {props.title} +

+
+ +
+ {rows.map((toolkit) => ( + + ))} + +
+ + +
+ ); +} + +function ToolkitGrid(props: { + pluginId: string; + toolkits: readonly ToolkitResponse[]; + onCreate: (input: { owner: Owner; name: string }) => Promise; +}) { + // Toolkit shelves are grouped by owner; the selected toolkit owns the page. + const workspaceToolkits = props.toolkits.filter((toolkit) => toolkit.owner === "org"); + const personalToolkits = props.toolkits.filter((toolkit) => toolkit.owner === "user"); + + return ( +
+
+ + +
+
+ ); +} + +function ToolkitContentsEmpty(props: { onAddConnection: () => void }) { + return ( +
+
+
+ +
+

No connections added

+

+ Add a connection to decide what this toolkit exposes. +

+ +
+
+ ); +} + +function ToolkitToolsPanel(props: { + tools: readonly ToolSummary[]; + selectedToolId: string | null; + policies: readonly ToolkitPolicyResponse[]; + onAddConnection: () => void; + onSelectTool: (toolId: string) => void; + onSetPolicy: (pattern: string, action: ToolPolicyAction) => void; + onClearPolicy: (pattern: string) => void; +}) { + return ( +
+
+
+
+

+ Toolkit tools +

+

+ {props.tools.length} {props.tools.length === 1 ? "tool" : "tools"} +

+
+ +
+
+ {props.tools.length === 0 ? ( + + ) : ( + + )} +
+ ); +} + +function AddConnectionDialog(props: { + open: boolean; + groups: readonly ToolkitConnectionGroup[]; + configuredToolIds: ReadonlySet; + integrations: readonly Integration[]; + integrationPlugins: readonly IntegrationPlugin[]; + onOpenChange: (open: boolean) => void; + onAddPatterns: (patterns: readonly string[]) => Promise | void; +}) { + const searchId = useId(); + const [query, setQuery] = useState(""); + const trimmedQuery = query.trim().toLowerCase(); + const filteredGroups = useMemo(() => { + if (!trimmedQuery) return props.groups; + return props.groups.filter((group) => { + const corpus = [ + connectionTitle(group), + ...group.patterns, + ownerLabel(group.owner), + ...group.tools.flatMap((tool) => [tool.name, tool.description ?? ""]), + ] + .join(" ") + .toLowerCase(); + return corpus.includes(trimmedQuery); + }); + }, [props.groups, trimmedQuery]); + + const addAndClose = async (patterns: readonly string[]) => { + await props.onAddPatterns(patterns); + props.onOpenChange(false); + }; + + return ( + { + props.onOpenChange(nextOpen); + if (!nextOpen) { + setQuery(""); + } + }} + > + + +
+ + + +
+ Add connection + + Choose which connected account this toolkit can use. + +
+
+
+ +
+
+ +
+ + setQuery(event.target.value)} + placeholder="Search connections" + className="h-8 min-w-0 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0" + /> +
+
+
+
+ + Connections + + + {filteredGroups.length} + +
+ {filteredGroups.length === 0 ? ( +
+ No connections match. +
+ ) : ( +
+ {filteredGroups.map((group) => { + const title = connectionTitle(group); + const added = group.tools.every((tool) => + props.configuredToolIds.has(toolMatchId(tool)), + ); + const meta = integrationMetaFor( + group, + props.integrations, + props.integrationPlugins, + ); + return ( +
+ + + +
+
+ + {connectionDisplayTitle(group, meta)} + + + {ownerLabel(group.owner)} + +
+
+ + {connectionDisplaySubtitle(group, meta)} + +
+
+ +
+ ); + })} +
+ )} +
+
+
+
+ ); +} + +function ToolkitHeader(props: { + toolkit: ToolkitResponse; + toolCount: number; + mcpUrl: string; + onBack: () => void; + onRemove: () => void; +}) { + return ( +
+ +
+
+
+

{props.toolkit.name}

+ + {ownerLabel(props.toolkit.owner)} + + + {props.toolCount} {props.toolCount === 1 ? "tool" : "tools"} + +
+
+ + {props.mcpUrl} + + +
+
+ +
+
+ ); +} + +function ToolkitWorkspace(props: { + toolkit: ToolkitResponse; + policies: readonly ToolkitPolicyResponse[]; + connections: readonly ToolkitConnectionResponse[]; + tools: readonly ToolRow[]; + integrations: readonly Integration[]; + integrationPlugins: readonly IntegrationPlugin[]; + mcpUrl: string; + onBack: () => void; + onRemoveToolkit: () => void; + onAddConnection: (pattern: string) => Promise | void; + onSetPolicy: (pattern: string, action: ToolPolicyAction) => Promise | void; + onClearPolicy: (pattern: string) => Promise | void; +}) { + const [addOpen, setAddOpen] = useState(false); + const [selectedToolId, setSelectedToolId] = useState(null); + const visibleTools = useMemo( + () => props.tools.filter((tool) => toolCanAppearInToolkit(props.toolkit, tool)), + [props.toolkit, props.tools], + ); + const connectionGroups = useMemo(() => buildConnectionGroups(visibleTools), [visibleTools]); + const legacyPolicyIds = useMemo( + () => legacyConnectionPolicyIds(props.policies, connectionGroups, props.connections), + [connectionGroups, props.connections, props.policies], + ); + const accessPolicies = useMemo( + () => props.policies.filter((policy) => !legacyPolicyIds.has(policy.id)), + [legacyPolicyIds, props.policies], + ); + const connectionPatterns = useMemo( + () => configuredConnectionPatterns(props.connections, props.policies, legacyPolicyIds), + [legacyPolicyIds, props.connections, props.policies], + ); + const configuredToolIds = useMemo(() => { + const ids = new Set(); + for (const tool of visibleTools) { + const id = toolMatchId(tool); + if ([...connectionPatterns].some((pattern) => matchPattern(pattern, id))) ids.add(id); + } + return ids; + }, [connectionPatterns, visibleTools]); + const configuredTools = useMemo( + () => + visibleTools.filter((tool) => configuredToolIds.has(toolMatchId(tool))).sort(compareTools), + [visibleTools, configuredToolIds], + ); + const toolkitTools: ToolSummary[] = useMemo( + () => + configuredTools.map((tool) => { + const id = toolMatchId(tool); + return { + id, + name: id, + description: tool.description, + policy: resolveToolkitPolicy(id, accessPolicies, tool.requiresApproval), + owner: toolOwner(tool), + connection: toolConnectionName(tool), + }; + }), + [accessPolicies, configuredTools], + ); + const exposedToolIds = useMemo( + () => + new Set( + toolkitTools + .filter((tool) => tool.policy.action !== "block") + .map((tool) => tool.id), + ), + [toolkitTools], + ); + const selectedTool = selectedToolId + ? (configuredTools.find((tool) => toolMatchId(tool) === selectedToolId) ?? null) + : null; + const selectedToolPolicy = selectedTool + ? (toolkitTools.find((tool) => tool.id === selectedToolId)?.policy ?? null) + : null; + + return ( +
+ + +
+ setAddOpen(true)} + onSelectTool={setSelectedToolId} + onSetPolicy={(pattern, action) => void props.onSetPolicy(pattern, action)} + onClearPolicy={(pattern) => void props.onClearPolicy(pattern)} + /> +
+ {selectedTool && selectedToolPolicy ? ( + + ) : ( + 0} /> + )} +
+
+ + { + for (const pattern of patterns) { + await props.onAddConnection(pattern); + } + }} + /> +
+ ); +} + +function ToolkitsPageSkeleton() { + return ( +
+
+ +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+
+
+ ); +} + +function ToolkitDetailView(props: { + toolkit: ToolkitResponse; + tools: readonly ToolRow[]; + integrations: readonly Integration[]; + integrationPlugins: readonly IntegrationPlugin[]; + orgSlug?: string; + onBack: () => void; + onRemoveToolkit: (toolkit: ToolkitResponse) => void; +}) { + const policies = useAtomValue(toolkitPoliciesAtom(props.toolkit.id)); + const connections = useAtomValue(toolkitConnectionsAtom(props.toolkit.id)); + const doCreatePolicy = useAtomSet(createToolkitPolicy, { mode: "promiseExit" }); + const doUpdatePolicy = useAtomSet(updateToolkitPolicy, { mode: "promiseExit" }); + const doRemovePolicy = useAtomSet(removeToolkitPolicy, { mode: "promiseExit" }); + const doCreateConnection = useAtomSet(createToolkitConnection, { mode: "promiseExit" }); + const policyRows = AsyncResult.isSuccess(policies) ? policies.value.policies : []; + const connectionRows = AsyncResult.isSuccess(connections) ? connections.value.connections : []; + + const setPolicyHandler = async (pattern: string, action: ToolPolicyAction) => { + const existing = policyRows.find((policy) => policy.pattern === pattern); + if (existing) { + await doUpdatePolicy({ + params: { toolkitId: props.toolkit.id, policyId: existing.id }, + payload: { action }, + reactivityKeys: toolkitWriteKeys, + }); + return; + } + await doCreatePolicy({ + params: { toolkitId: props.toolkit.id }, + payload: { pattern, action }, + reactivityKeys: toolkitWriteKeys, + }); + }; + + const addConnectionHandler = async (pattern: string) => { + await doCreateConnection({ + params: { toolkitId: props.toolkit.id }, + payload: { pattern }, + reactivityKeys: toolkitWriteKeys, + }); + }; + + const clearPolicyHandler = async (pattern: string) => { + const existing = policyRows.find((policy) => policy.pattern === pattern); + if (!existing) return; + await doRemovePolicy({ + params: { toolkitId: props.toolkit.id, policyId: existing.id }, + reactivityKeys: toolkitWriteKeys, + }); + }; + + if (AsyncResult.isFailure(policies) || AsyncResult.isFailure(connections)) { + return
Failed to load toolkit
; + } + if (!AsyncResult.isSuccess(policies) || !AsyncResult.isSuccess(connections)) { + return ; + } + + return ( + props.onRemoveToolkit(props.toolkit)} + onAddConnection={addConnectionHandler} + onSetPolicy={setPolicyHandler} + onClearPolicy={clearPolicyHandler} + /> + ); +} + +export function ToolkitsPage(props: PluginPageProps) { + const params = useParams({ strict: false }) as { orgSlug?: string }; + const navigate = useNavigate(); + const integrationPlugins = useIntegrationPlugins(); + const toolkits = useAtomValue(toolkitsAtom); + const tools = useAtomValue(toolsAllAtom); + const integrations = useAtomValue(integrationsOptimisticAtom); + const doCreateToolkit = useAtomSet(createToolkit, { mode: "promiseExit" }); + const doRemoveToolkit = useAtomSet(removeToolkit, { mode: "promiseExit" }); + const selectedToolkitSlug = props.params.toolkitSlug ?? null; + + const toolkitRows = AsyncResult.isSuccess(toolkits) ? toolkits.value.toolkits : []; + const selectedToolkit = + selectedToolkitSlug === null + ? null + : (toolkitRows.find((toolkit) => toolkit.slug === selectedToolkitSlug) ?? null); + + const toolRows = AsyncResult.isSuccess(tools) ? (tools.value as readonly ToolRow[]) : []; + const integrationRows = AsyncResult.isSuccess(integrations) + ? (integrations.value as readonly Integration[]) + : []; + const navigateToIndex = () => + navigate({ + to: "/{-$orgSlug}/plugins/$pluginId/$", + params: { pluginId: props.pluginId, _splat: "" }, + }); + + const createToolkitHandler = async (input: { owner: Owner; name: string }) => { + await doCreateToolkit({ + payload: input, + reactivityKeys: toolkitWriteKeys, + }); + }; + + const removeToolkitHandler = async (toolkit: ToolkitResponse) => { + await doRemoveToolkit({ + params: { toolkitId: toolkit.id }, + reactivityKeys: toolkitWriteKeys, + }); + await navigateToIndex(); + }; + + return ( +
+ {selectedToolkitSlug === null ? ( +
+
+

Toolkits

+ {AsyncResult.isSuccess(toolkits) && ( + + {toolkitRows.length} {toolkitRows.length === 1 ? "toolkit" : "toolkits"} + + )} +
+
+ ) : null} + + {!AsyncResult.isSuccess(toolkits) || !AsyncResult.isSuccess(tools) ? ( + AsyncResult.isFailure(toolkits) || AsyncResult.isFailure(tools) ? ( +
Failed to load toolkits
+ ) : ( + + ) + ) : ( + <> + {selectedToolkit ? ( + void navigateToIndex()} + onRemoveToolkit={removeToolkitHandler} + /> + ) : selectedToolkitSlug !== null ? ( +
+ +
Toolkit not found
+
+ ) : ( + + )} + + )} +
+ ); +} diff --git a/packages/plugins/toolkits/src/server.test.ts b/packages/plugins/toolkits/src/server.test.ts new file mode 100644 index 000000000..d3b45c0b4 --- /dev/null +++ b/packages/plugins/toolkits/src/server.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate, Result } from "effect"; +import { makeTestExecutor } from "@executor-js/sdk/testing"; + +import { toolkitsPlugin } from "./server"; + +describe("toolkitsPlugin", () => { + it.effect("creates toolkits and manages ordered policy rules", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [toolkitsPlugin()] as const, + }); + + const toolkit = yield* executor.toolkits.create({ + owner: "org", + name: "Deploy Kit", + }); + expect(toolkit.slug).toBe("deploy-kit"); + + const connection = yield* executor.toolkits.createConnection(toolkit.id, { + pattern: "github.org.main.*", + }); + const duplicateConnection = yield* executor.toolkits.createConnection(toolkit.id, { + pattern: "github.org.main.*", + }); + expect(duplicateConnection.id).toBe(connection.id); + + const first = yield* executor.toolkits.createPolicy(toolkit.id, { + pattern: "github.org.main.repos.*", + action: "approve", + }); + const second = yield* executor.toolkits.createPolicy(toolkit.id, { + pattern: "github.*", + action: "block", + }); + + const policies = yield* executor.toolkits.listPolicies(toolkit.id); + expect(policies.map((policy) => policy.id)).toEqual([second.id, first.id]); + + yield* executor.toolkits.updatePolicy(toolkit.id, first.id, { + action: "require_approval", + }); + const rules = yield* executor.toolkits.policyRulesForSlug("deploy-kit"); + expect(rules.find((rule) => rule.id === first.id)?.action).toBe("require_approval"); + + const connections = yield* executor.toolkits.listConnections(toolkit.id); + expect(connections.map((row) => row.pattern)).toEqual(["github.org.main.*"]); + }), + ); + + it.effect("rejects duplicate visible slugs", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [toolkitsPlugin()] as const, + }); + yield* executor.toolkits.create({ owner: "org", name: "Deploy Kit" }); + + const duplicate = yield* Effect.result( + executor.toolkits.create({ owner: "user", name: "Deploy Kit" }), + ); + expect(Result.isFailure(duplicate)).toBe(true); + if (!Result.isFailure(duplicate)) return; + expect(Predicate.isTagged("ToolkitError")(duplicate.failure)).toBe(true); + }), + ); + + it.effect("resolves toolkit policies with implicit deny and workspace owner limits", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [toolkitsPlugin()] as const, + }); + + const workspace = yield* executor.toolkits.create({ + owner: "org", + name: "Workspace Kit", + }); + yield* executor.toolkits.createConnection(workspace.id, { + pattern: "github.org.main.*", + }); + + const workspaceTool = yield* executor.toolkits.resolvePolicyForSlug( + workspace.slug, + "github.org.main.repos.list", + ); + expect(workspaceTool.action).toBe("approve"); + expect(workspaceTool.source).toBe("plugin-default"); + + const defaultApprovalTool = yield* executor.toolkits.resolvePolicyForSlug( + workspace.slug, + "github.org.main.repos.delete", + true, + ); + expect(defaultApprovalTool.action).toBe("require_approval"); + expect(defaultApprovalTool.source).toBe("plugin-default"); + + yield* executor.toolkits.createPolicy(workspace.id, { + pattern: "github.org.main.repos.delete", + action: "approve", + }); + const explicitTool = yield* executor.toolkits.resolvePolicyForSlug( + workspace.slug, + "github.org.main.repos.delete", + true, + ); + expect(explicitTool.action).toBe("approve"); + expect(explicitTool.source).toBe("user"); + + const personalTool = yield* executor.toolkits.resolvePolicyForSlug( + workspace.slug, + "github.user.main.repos.list", + ); + expect(personalTool.action).toBe("block"); + + const missingTool = yield* executor.toolkits.resolvePolicyForSlug( + workspace.slug, + "slack.org.main.chat.post", + ); + expect(missingTool.action).toBe("block"); + + const personal = yield* executor.toolkits.create({ + owner: "user", + name: "Personal Kit", + }); + yield* executor.toolkits.createConnection(personal.id, { + pattern: "github.user.main.*", + }); + const personalToolkitTool = yield* executor.toolkits.resolvePolicyForSlug( + personal.slug, + "github.user.main.repos.list", + ); + expect(personalToolkitTool.action).toBe("approve"); + }), + ); +}); diff --git a/packages/plugins/toolkits/src/server.ts b/packages/plugins/toolkits/src/server.ts new file mode 100644 index 000000000..69357e54f --- /dev/null +++ b/packages/plugins/toolkits/src/server.ts @@ -0,0 +1,653 @@ +import { + Context, + definePlugin, + definePluginStorageCollection, + Effect, + HttpApiBuilder, + isValidPattern, + matchPattern, + Schema, + type EffectivePolicy, + type Owner, + type PluginCtx, + type PluginStorageFacade, + type PluginStorageCollectionFacade, + type StorageFailure, + type ToolPolicyAction, + type ToolPolicyProvider, + type ToolPolicyProviderRule, +} from "@executor-js/sdk/core"; +import { addGroup, capture } from "@executor-js/api"; +import { generateKeyBetween } from "fractional-indexing"; + +import { ToolkitError, ToolkitsApi } from "./shared"; + +const ToolkitRecord = Schema.Struct({ + id: Schema.String, + slug: Schema.String, + name: Schema.String, + createdAt: Schema.Number, + updatedAt: Schema.Number, +}); +type ToolkitRecord = typeof ToolkitRecord.Type; + +const ToolkitPolicyRecord = Schema.Struct({ + id: Schema.String, + toolkitId: Schema.String, + pattern: Schema.String, + action: Schema.Literals(["approve", "require_approval", "block"]), + position: Schema.String, + createdAt: Schema.Number, + updatedAt: Schema.Number, +}); +type ToolkitPolicyRecord = typeof ToolkitPolicyRecord.Type; + +const ToolkitConnectionRecord = Schema.Struct({ + id: Schema.String, + toolkitId: Schema.String, + pattern: Schema.String, + position: Schema.String, + createdAt: Schema.Number, + updatedAt: Schema.Number, +}); +type ToolkitConnectionRecord = typeof ToolkitConnectionRecord.Type; + +const toolkitsCollection = definePluginStorageCollection("toolkits", ToolkitRecord, { + indexes: ["slug", "name", "updatedAt"], +}); + +const toolkitPoliciesCollection = definePluginStorageCollection( + "toolkitPolicies", + ToolkitPolicyRecord, + { + indexes: ["toolkitId", "pattern", "position", ["toolkitId", "position"]], + }, +); + +const toolkitConnectionsCollection = definePluginStorageCollection( + "toolkitConnections", + ToolkitConnectionRecord, + { + indexes: ["toolkitId", "pattern", "position", ["toolkitId", "position"]], + }, +); + +type ToolkitStorage = { + readonly toolkits: PluginStorageCollectionFacade; + readonly policies: PluginStorageCollectionFacade; + readonly connections: PluginStorageCollectionFacade; +}; + +export interface ToolkitsPluginOptions { + /** When set, this executor instance enforces only the named toolkit's rules. */ + readonly activeToolkitSlug?: string; +} + +const newId = (prefix: string): string => + `${prefix}_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; + +const slugPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +const slugify = (name: string): string => + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 63); + +const normalizeSlugEffect = (input: { + readonly name: string; + readonly slug?: string; +}): Effect.Effect => { + const slug = (input.slug ?? slugify(input.name)).trim().toLowerCase(); + if (!slugPattern.test(slug)) { + return Effect.fail( + new ToolkitError({ + message: + "Toolkit slug must be 1-63 lowercase letters, numbers, or hyphens, and cannot start or end with a hyphen.", + }), + ); + } + return Effect.succeed(slug); +}; + +const fail = (message: string): Effect.Effect => + Effect.fail(new ToolkitError({ message })); + +const validatePolicyPattern = (pattern: string): Effect.Effect => + isValidPattern(pattern) + ? Effect.void + : Effect.fail( + new ToolkitError({ + message: `Invalid toolkit policy pattern: ${pattern}`, + }), + ); + +const comparePositioned = ( + a: Pick, + b: Pick, +): number => { + if (a.position < b.position) return -1; + if (a.position > b.position) return 1; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +}; + +const blockedPolicy = (pattern = "*"): EffectivePolicy => ({ + action: "block", + source: "user", + pattern, +}); + +const pluginDefaultPolicy = (defaultRequiresApproval: boolean | undefined): EffectivePolicy => + defaultRequiresApproval + ? { action: "require_approval", source: "plugin-default" } + : { action: "approve", source: "plugin-default" }; + +const isLegacyConnectionPolicy = (policy: ToolkitPolicyRecord): boolean => { + if (policy.action !== "approve") return false; + const parts = policy.pattern.split("."); + return parts.at(-1) === "*" && (parts.length === 3 || parts.length === 4); +}; + +const resolveToolkitPolicy = ( + toolId: string, + connections: readonly ToolkitConnectionRecord[], + policies: readonly ToolkitPolicyRecord[], + defaultRequiresApproval?: boolean, +): EffectivePolicy => { + const legacyConnectionPolicyIds = new Set( + policies.filter(isLegacyConnectionPolicy).map((policy) => policy.id), + ); + const connected = + connections.some((connection) => matchPattern(connection.pattern, toolId)) || + policies.some( + (policy) => legacyConnectionPolicyIds.has(policy.id) && matchPattern(policy.pattern, toolId), + ); + if (!connected) return blockedPolicy(); + + for (const policy of [...policies].sort(comparePositioned)) { + if (legacyConnectionPolicyIds.has(policy.id)) continue; + if (!matchPattern(policy.pattern, toolId)) continue; + return { + action: policy.action, + source: "user", + pattern: policy.pattern, + policyId: policy.id, + }; + } + return pluginDefaultPolicy(defaultRequiresApproval); +}; + +const isPersonalDynamicToolId = (toolId: string): boolean => toolId.split(".")[1] === "user"; + +const toolkitToResponse = (entry: { readonly owner: Owner; readonly data: ToolkitRecord }) => ({ + id: entry.data.id, + owner: entry.owner, + slug: entry.data.slug, + name: entry.data.name, + createdAt: entry.data.createdAt, + updatedAt: entry.data.updatedAt, +}); + +const policyToResponse = (policy: ToolkitPolicyRecord) => ({ + id: policy.id, + toolkitId: policy.toolkitId, + pattern: policy.pattern, + action: policy.action, + position: policy.position, + createdAt: policy.createdAt, + updatedAt: policy.updatedAt, +}); + +const connectionToResponse = (connection: ToolkitConnectionRecord) => ({ + id: connection.id, + toolkitId: connection.toolkitId, + pattern: connection.pattern, + position: connection.position, + createdAt: connection.createdAt, + updatedAt: connection.updatedAt, +}); + +const makeToolkitStorage = (pluginStorage: PluginStorageFacade): ToolkitStorage => ({ + toolkits: pluginStorage.collection(toolkitsCollection), + policies: pluginStorage.collection(toolkitPoliciesCollection), + connections: pluginStorage.collection(toolkitConnectionsCollection), +}); + +const makeToolkitsExtension = (ctx: PluginCtx) => { + const storage = ctx.storage; + + const list = () => + storage.toolkits + .query({ orderBy: [{ field: "name" }] }) + .pipe( + Effect.map((entries) => + entries + .map(toolkitToResponse) + .sort( + (a, b) => + (a.owner === b.owner ? 0 : a.owner === "org" ? -1 : 1) || + a.name.localeCompare(b.name) || + a.slug.localeCompare(b.slug), + ), + ), + ); + + const getEntry = (toolkitId: string) => storage.toolkits.get({ key: toolkitId }); + + const getBySlugEntry = (slug: string) => + storage.toolkits.query({ where: { slug } }).pipe(Effect.map((entries) => entries[0] ?? null)); + + const requireToolkit = (toolkitId: string) => + getEntry(toolkitId).pipe( + Effect.flatMap((entry) => (entry ? Effect.succeed(entry) : fail("Toolkit not found."))), + ); + + const assertSlugAvailable = (slug: string, ignoreToolkitId?: string) => + storage.toolkits.query({ where: { slug } }).pipe( + Effect.flatMap((entries) => { + const collision = entries.find((entry) => entry.data.id !== ignoreToolkitId); + return collision ? fail(`Toolkit slug "${slug}" is already in use.`) : Effect.void; + }), + ); + + const listPoliciesForRecord = (toolkitId: string) => + storage.policies + .query({ where: { toolkitId } }) + .pipe(Effect.map((entries) => entries.map((entry) => entry.data).sort(comparePositioned))); + + const listConnectionsForRecord = (toolkitId: string) => + storage.connections + .query({ where: { toolkitId } }) + .pipe(Effect.map((entries) => entries.map((entry) => entry.data).sort(comparePositioned))); + + const requirePolicy = (toolkitId: string, policyId: string, owner: Owner) => + storage.policies + .getForOwner({ owner, key: policyId }) + .pipe( + Effect.flatMap((entry) => + entry && entry.data.toolkitId === toolkitId + ? Effect.succeed(entry) + : fail("Toolkit policy not found."), + ), + ); + + const requireConnection = (toolkitId: string, connectionId: string, owner: Owner) => + storage.connections + .getForOwner({ owner, key: connectionId }) + .pipe( + Effect.flatMap((entry) => + entry && entry.data.toolkitId === toolkitId + ? Effect.succeed(entry) + : fail("Toolkit connection not found."), + ), + ); + + const create = (input: { + readonly owner: Owner; + readonly name: string; + readonly slug?: string; + }) => + Effect.gen(function* () { + const name = input.name.trim(); + if (!name) return yield* fail("Toolkit name is required."); + const slug = yield* normalizeSlugEffect({ name, slug: input.slug }); + yield* assertSlugAvailable(slug); + const now = Date.now(); + const id = newId("tk"); + const entry = yield* storage.toolkits.put({ + owner: input.owner, + key: id, + data: { id, slug, name, createdAt: now, updatedAt: now }, + }); + return toolkitToResponse(entry); + }); + + const update = (toolkitId: string, input: { readonly name?: string; readonly slug?: string }) => + Effect.gen(function* () { + const existing = yield* requireToolkit(toolkitId); + const name = input.name === undefined ? existing.data.name : input.name.trim(); + if (!name) return yield* fail("Toolkit name is required."); + const slug = + input.slug === undefined + ? existing.data.slug + : yield* normalizeSlugEffect({ name, slug: input.slug }); + yield* assertSlugAvailable(slug, toolkitId); + const entry = yield* storage.toolkits.put({ + owner: existing.owner, + key: toolkitId, + data: { ...existing.data, name, slug, updatedAt: Date.now() }, + }); + return toolkitToResponse(entry); + }); + + const remove = (toolkitId: string) => + Effect.gen(function* () { + const toolkit = yield* requireToolkit(toolkitId); + const policies = yield* listPoliciesForRecord(toolkitId); + const connections = yield* listConnectionsForRecord(toolkitId); + yield* ctx.pluginStorage.removeMany({ + owner: toolkit.owner, + entries: [ + { collection: toolkitsCollection.name, key: toolkitId }, + ...policies.map((policy) => ({ + collection: toolkitPoliciesCollection.name, + key: policy.id, + })), + ...connections.map((connection) => ({ + collection: toolkitConnectionsCollection.name, + key: connection.id, + })), + ], + }); + }); + + const listPolicies = (toolkitId: string) => + requireToolkit(toolkitId).pipe(Effect.flatMap(() => listPoliciesForRecord(toolkitId))); + + const createPolicy = ( + toolkitId: string, + input: { + readonly pattern: string; + readonly action: ToolPolicyAction; + readonly position?: string; + }, + ) => + Effect.gen(function* () { + yield* validatePolicyPattern(input.pattern); + const toolkit = yield* requireToolkit(toolkitId); + const existing = yield* listPoliciesForRecord(toolkitId); + const minPosition = existing + .map((row) => row.position) + .sort() + .at(0); + const now = Date.now(); + const id = newId("tkpol"); + const entry = yield* storage.policies.put({ + owner: toolkit.owner, + key: id, + data: { + id, + toolkitId, + pattern: input.pattern, + action: input.action, + position: input.position ?? generateKeyBetween(null, minPosition ?? null), + createdAt: now, + updatedAt: now, + }, + }); + return policyToResponse(entry.data); + }); + + const updatePolicy = ( + toolkitId: string, + policyId: string, + input: { + readonly pattern?: string; + readonly action?: ToolPolicyAction; + readonly position?: string; + }, + ) => + Effect.gen(function* () { + if (input.pattern !== undefined) yield* validatePolicyPattern(input.pattern); + const toolkit = yield* requireToolkit(toolkitId); + const existing = yield* requirePolicy(toolkitId, policyId, toolkit.owner); + const entry = yield* storage.policies.put({ + owner: toolkit.owner, + key: policyId, + data: { + ...existing.data, + ...(input.pattern !== undefined ? { pattern: input.pattern } : {}), + ...(input.action !== undefined ? { action: input.action } : {}), + ...(input.position !== undefined ? { position: input.position } : {}), + updatedAt: Date.now(), + }, + }); + return policyToResponse(entry.data); + }); + + const removePolicy = (toolkitId: string, policyId: string) => + Effect.gen(function* () { + const toolkit = yield* requireToolkit(toolkitId); + yield* requirePolicy(toolkitId, policyId, toolkit.owner); + yield* storage.policies.remove({ owner: toolkit.owner, key: policyId }); + }); + + const listConnections = (toolkitId: string) => + requireToolkit(toolkitId).pipe(Effect.flatMap(() => listConnectionsForRecord(toolkitId))); + + const createConnection = ( + toolkitId: string, + input: { + readonly pattern: string; + readonly position?: string; + }, + ) => + Effect.gen(function* () { + yield* validatePolicyPattern(input.pattern); + const toolkit = yield* requireToolkit(toolkitId); + const existing = yield* listConnectionsForRecord(toolkitId); + const duplicate = existing.find((connection) => connection.pattern === input.pattern); + if (duplicate) return connectionToResponse(duplicate); + const minPosition = existing + .map((row) => row.position) + .sort() + .at(0); + const now = Date.now(); + const id = newId("tkconn"); + const entry = yield* storage.connections.put({ + owner: toolkit.owner, + key: id, + data: { + id, + toolkitId, + pattern: input.pattern, + position: input.position ?? generateKeyBetween(null, minPosition ?? null), + createdAt: now, + updatedAt: now, + }, + }); + return connectionToResponse(entry.data); + }); + + const removeConnection = (toolkitId: string, connectionId: string) => + Effect.gen(function* () { + const toolkit = yield* requireToolkit(toolkitId); + yield* requireConnection(toolkitId, connectionId, toolkit.owner); + yield* storage.connections.remove({ owner: toolkit.owner, key: connectionId }); + }); + + const policyRulesForSlug = ( + slug: string, + ): Effect.Effect => + Effect.gen(function* () { + const toolkit = yield* getBySlugEntry(slug); + if (!toolkit) return []; + const policies = yield* listPoliciesForRecord(toolkit.data.id); + return policies + .filter((policy) => !isLegacyConnectionPolicy(policy)) + .map((policy) => ({ + id: policy.id, + pattern: policy.pattern, + action: policy.action, + position: policy.position, + })); + }); + + const resolvePolicyForSlug = ( + slug: string, + toolId: string, + defaultRequiresApproval?: boolean, + ): Effect.Effect => + Effect.gen(function* () { + const toolkit = yield* getBySlugEntry(slug); + if (!toolkit) return blockedPolicy(); + if (toolkit.owner === "org" && isPersonalDynamicToolId(toolId)) return blockedPolicy(); + const policies = yield* listPoliciesForRecord(toolkit.data.id); + const connections = yield* listConnectionsForRecord(toolkit.data.id); + return resolveToolkitPolicy(toolId, connections, policies, defaultRequiresApproval); + }); + + return { + list, + create, + update, + remove, + listPolicies: (toolkitId: string) => + listPolicies(toolkitId).pipe(Effect.map((policies) => policies.map(policyToResponse))), + createPolicy, + updatePolicy, + removePolicy, + listConnections: (toolkitId: string) => + listConnections(toolkitId).pipe( + Effect.map((connections) => connections.map(connectionToResponse)), + ), + createConnection, + removeConnection, + policyRulesForSlug, + resolvePolicyForSlug, + }; +}; + +export type ToolkitsExtension = ReturnType; + +export class ToolkitsExtensionService extends Context.Service< + ToolkitsExtensionService, + ToolkitsExtension +>()("ToolkitsExtensionService") {} + +const ExecutorApiWithToolkits = addGroup(ToolkitsApi); + +const ToolkitsHandlers = HttpApiBuilder.group(ExecutorApiWithToolkits, "toolkits", (handlers) => + handlers + .handle("list", () => + capture( + Effect.gen(function* () { + const ext = yield* ToolkitsExtensionService; + const toolkits = yield* ext.list(); + return { toolkits }; + }), + ), + ) + .handle("create", ({ payload }) => + capture( + Effect.gen(function* () { + const ext = yield* ToolkitsExtensionService; + return yield* ext.create(payload); + }), + ), + ) + .handle("update", ({ params, payload }) => + capture( + Effect.gen(function* () { + const ext = yield* ToolkitsExtensionService; + return yield* ext.update(params.toolkitId, payload); + }), + ), + ) + .handle("remove", ({ params }) => + capture( + Effect.gen(function* () { + const ext = yield* ToolkitsExtensionService; + yield* ext.remove(params.toolkitId); + return { removed: true }; + }), + ), + ) + .handle("listPolicies", ({ params }) => + capture( + Effect.gen(function* () { + const ext = yield* ToolkitsExtensionService; + const policies = yield* ext.listPolicies(params.toolkitId); + return { policies }; + }), + ), + ) + .handle("createPolicy", ({ params, payload }) => + capture( + Effect.gen(function* () { + const ext = yield* ToolkitsExtensionService; + return yield* ext.createPolicy(params.toolkitId, payload); + }), + ), + ) + .handle("updatePolicy", ({ params, payload }) => + capture( + Effect.gen(function* () { + const ext = yield* ToolkitsExtensionService; + return yield* ext.updatePolicy(params.toolkitId, params.policyId, payload); + }), + ), + ) + .handle("removePolicy", ({ params }) => + capture( + Effect.gen(function* () { + const ext = yield* ToolkitsExtensionService; + yield* ext.removePolicy(params.toolkitId, params.policyId); + return { removed: true }; + }), + ), + ) + .handle("listConnections", ({ params }) => + capture( + Effect.gen(function* () { + const ext = yield* ToolkitsExtensionService; + const connections = yield* ext.listConnections(params.toolkitId); + return { connections }; + }), + ), + ) + .handle("createConnection", ({ params, payload }) => + capture( + Effect.gen(function* () { + const ext = yield* ToolkitsExtensionService; + return yield* ext.createConnection(params.toolkitId, payload); + }), + ), + ) + .handle("removeConnection", ({ params }) => + capture( + Effect.gen(function* () { + const ext = yield* ToolkitsExtensionService; + yield* ext.removeConnection(params.toolkitId, params.connectionId); + return { removed: true }; + }), + ), + ), +); + +const makePolicyProvider = ( + extension: Pick, + slug: string, +): ToolPolicyProvider => ({ + list: () => extension.policyRulesForSlug(slug), + resolve: ({ toolId, defaultRequiresApproval }) => + extension.resolvePolicyForSlug(slug, toolId, defaultRequiresApproval), +}); + +export const toolkitsPlugin = definePlugin((options: ToolkitsPluginOptions = {}) => { + const activeToolkitSlug = options.activeToolkitSlug; + return { + id: "toolkits" as const, + packageName: "@executor-js/plugin-toolkits", + pluginStorage: { + toolkits: toolkitsCollection, + toolkitPolicies: toolkitPoliciesCollection, + toolkitConnections: toolkitConnectionsCollection, + }, + storage: ({ pluginStorage }) => makeToolkitStorage(pluginStorage), + extension: makeToolkitsExtension, + routes: () => ToolkitsApi, + handlers: () => ToolkitsHandlers, + extensionService: ToolkitsExtensionService, + ...(activeToolkitSlug + ? { + toolPolicyProvider: (ctx: PluginCtx) => + makePolicyProvider(makeToolkitsExtension(ctx), activeToolkitSlug), + } + : {}), + }; +}); + +export default toolkitsPlugin; diff --git a/packages/plugins/toolkits/src/shared.ts b/packages/plugins/toolkits/src/shared.ts new file mode 100644 index 000000000..55ecb06cb --- /dev/null +++ b/packages/plugins/toolkits/src/shared.ts @@ -0,0 +1,166 @@ +import { Schema } from "effect"; +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { InternalError, Owner, ToolPolicyActionSchema } from "@executor-js/sdk/shared"; + +export class ToolkitError extends Schema.TaggedErrorClass()( + "ToolkitError", + { message: Schema.String }, + { httpApiStatus: 400 }, +) {} + +const ToolkitParams = { + toolkitId: Schema.String, +}; + +const ToolkitPolicyParams = { + toolkitId: Schema.String, + policyId: Schema.String, +}; + +const ToolkitConnectionParams = { + toolkitId: Schema.String, + connectionId: Schema.String, +}; + +export const ToolkitResponse = Schema.Struct({ + id: Schema.String, + owner: Owner, + slug: Schema.String, + name: Schema.String, + createdAt: Schema.Number, + updatedAt: Schema.Number, +}); +export type ToolkitResponse = typeof ToolkitResponse.Type; + +export const ToolkitPolicyResponse = Schema.Struct({ + id: Schema.String, + toolkitId: Schema.String, + pattern: Schema.String, + action: ToolPolicyActionSchema, + position: Schema.String, + createdAt: Schema.Number, + updatedAt: Schema.Number, +}); +export type ToolkitPolicyResponse = typeof ToolkitPolicyResponse.Type; + +export const ToolkitConnectionResponse = Schema.Struct({ + id: Schema.String, + toolkitId: Schema.String, + pattern: Schema.String, + position: Schema.String, + createdAt: Schema.Number, + updatedAt: Schema.Number, +}); +export type ToolkitConnectionResponse = typeof ToolkitConnectionResponse.Type; + +const CreateToolkitPayload = Schema.Struct({ + owner: Owner, + name: Schema.String, + slug: Schema.optional(Schema.String), +}); + +const UpdateToolkitPayload = Schema.Struct({ + name: Schema.optional(Schema.String), + slug: Schema.optional(Schema.String), +}); + +const CreateToolkitPolicyPayload = Schema.Struct({ + pattern: Schema.String, + action: ToolPolicyActionSchema, + position: Schema.optional(Schema.String), +}); + +const UpdateToolkitPolicyPayload = Schema.Struct({ + pattern: Schema.optional(Schema.String), + action: Schema.optional(ToolPolicyActionSchema), + position: Schema.optional(Schema.String), +}); + +const CreateToolkitConnectionPayload = Schema.Struct({ + pattern: Schema.String, + position: Schema.optional(Schema.String), +}); + +const ToolkitErrors = [InternalError, ToolkitError] as const; + +export const ToolkitsApi = HttpApiGroup.make("toolkits") + .add( + HttpApiEndpoint.get("list", "/toolkits", { + success: Schema.Struct({ toolkits: Schema.Array(ToolkitResponse) }), + error: ToolkitErrors, + }), + ) + .add( + HttpApiEndpoint.post("create", "/toolkits", { + payload: CreateToolkitPayload, + success: ToolkitResponse, + error: ToolkitErrors, + }), + ) + .add( + HttpApiEndpoint.patch("update", "/toolkits/:toolkitId", { + params: ToolkitParams, + payload: UpdateToolkitPayload, + success: ToolkitResponse, + error: ToolkitErrors, + }), + ) + .add( + HttpApiEndpoint.delete("remove", "/toolkits/:toolkitId", { + params: ToolkitParams, + success: Schema.Struct({ removed: Schema.Boolean }), + error: ToolkitErrors, + }), + ) + .add( + HttpApiEndpoint.get("listPolicies", "/toolkits/:toolkitId/policies", { + params: ToolkitParams, + success: Schema.Struct({ policies: Schema.Array(ToolkitPolicyResponse) }), + error: ToolkitErrors, + }), + ) + .add( + HttpApiEndpoint.post("createPolicy", "/toolkits/:toolkitId/policies", { + params: ToolkitParams, + payload: CreateToolkitPolicyPayload, + success: ToolkitPolicyResponse, + error: ToolkitErrors, + }), + ) + .add( + HttpApiEndpoint.patch("updatePolicy", "/toolkits/:toolkitId/policies/:policyId", { + params: ToolkitPolicyParams, + payload: UpdateToolkitPolicyPayload, + success: ToolkitPolicyResponse, + error: ToolkitErrors, + }), + ) + .add( + HttpApiEndpoint.delete("removePolicy", "/toolkits/:toolkitId/policies/:policyId", { + params: ToolkitPolicyParams, + success: Schema.Struct({ removed: Schema.Boolean }), + error: ToolkitErrors, + }), + ) + .add( + HttpApiEndpoint.get("listConnections", "/toolkits/:toolkitId/connections", { + params: ToolkitParams, + success: Schema.Struct({ connections: Schema.Array(ToolkitConnectionResponse) }), + error: ToolkitErrors, + }), + ) + .add( + HttpApiEndpoint.post("createConnection", "/toolkits/:toolkitId/connections", { + params: ToolkitParams, + payload: CreateToolkitConnectionPayload, + success: ToolkitConnectionResponse, + error: ToolkitErrors, + }), + ) + .add( + HttpApiEndpoint.delete("removeConnection", "/toolkits/:toolkitId/connections/:connectionId", { + params: ToolkitConnectionParams, + success: Schema.Struct({ removed: Schema.Boolean }), + error: ToolkitErrors, + }), + ); diff --git a/packages/plugins/toolkits/tsconfig.json b/packages/plugins/toolkits/tsconfig.json new file mode 100644 index 000000000..1504bed72 --- /dev/null +++ b/packages/plugins/toolkits/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM"], + "types": ["bun-types", "node"], + "noUnusedLocals": true, + "noImplicitOverride": true, + "jsx": "react-jsx", + "plugins": [ + { + "name": "@effect/language-service", + "ignoreEffectSuggestionsInTscExitCode": true, + "ignoreEffectWarningsInTscExitCode": true, + "diagnosticSeverity": {} + } + ] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/packages/plugins/toolkits/tsup.config.ts b/packages/plugins/toolkits/tsup.config.ts new file mode 100644 index 000000000..033f3bde9 --- /dev/null +++ b/packages/plugins/toolkits/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + server: "src/server.ts", + client: "src/client.tsx", + shared: "src/shared.ts", + }, + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + external: ["react"], +}); diff --git a/packages/plugins/toolkits/vitest.config.ts b/packages/plugins/toolkits/vitest.config.ts new file mode 100644 index 000000000..39159d4f3 --- /dev/null +++ b/packages/plugins/toolkits/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: false, + }, +}); diff --git a/packages/react/src/components/tool-detail.tsx b/packages/react/src/components/tool-detail.tsx index cbc2202d8..9daebdcbf 100644 --- a/packages/react/src/components/tool-detail.tsx +++ b/packages/react/src/components/tool-detail.tsx @@ -172,6 +172,7 @@ export function ToolDetail(props: { * applies a user rule to this tool's exact id. */ onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; onClearPolicy?: (pattern: string) => void; + patternForDisplay?: (displayPattern: string) => string; /** Run-tab wiring. When `integration` + `runToolName` are provided, a third * "Run" tab hosts the per-connection tool tester. */ integration?: IntegrationSlug; @@ -187,6 +188,7 @@ export function ToolDetail(props: { const canRun = props.integration != null && props.runToolName != null; // Don't strand the user on the Run tab when this ToolDetail has no run wiring. const activeTab = tab === "run" && !canRun ? "schema" : tab; + const isBlocked = props.policy?.action === "block"; const data = useMemo(() => { if (!AsyncResult.isSuccess(toolContract)) return null; @@ -244,6 +246,7 @@ export function ToolDetail(props: { policy={props.policy} onSetPolicy={props.onSetPolicy} onClearPolicy={props.onClearPolicy} + patternForDisplay={props.patternForDisplay} /> {data?.description && } @@ -300,48 +303,59 @@ export function ToolDetail(props: { {/* Content */}
- {AsyncResult.match(toolContract, { - onInitial: () =>
Loading…
, - onFailure: () =>
Something went wrong
, - onSuccess: () => - activeTab === "run" && props.integration && props.runToolName ? ( -
- -
- ) : activeTab === "schema" ? ( -
- {data?.inputSchema ? ( - - ) : ( - - )} - {data?.outputSchema ? ( - - ) : ( - - )} -
- ) : ( - + {isBlocked ? ( +
+ +
+ ) : ( + AsyncResult.match(toolContract, { + onInitial: () =>
Loading…
, + onFailure: () => ( +
Something went wrong
), - })} + onSuccess: () => + activeTab === "run" && props.integration && props.runToolName ? ( +
+ +
+ ) : activeTab === "schema" ? ( +
+ {data?.inputSchema ? ( + + ) : ( + + )} + {data?.outputSchema ? ( + + ) : ( + + )} +
+ ) : ( + + ), + }) + )}
); @@ -400,12 +414,15 @@ function PolicyBadgeMenu(props: { policy?: EffectivePolicy; onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; onClearPolicy?: (pattern: string) => void; + patternForDisplay?: (displayPattern: string) => string; }) { const interactive = !!props.onSetPolicy; // The same pattern bridge the tree rows apply — the pattern WRITTEN and the // pattern LOOKED UP must be the same string, or the menu authors rules that // never match and can't see its own rule afterward. - const pattern = props.staticTool ? props.toolName : toPolicyPattern(props.toolName); + const pattern = props.staticTool + ? props.toolName + : (props.patternForDisplay ?? toPolicyPattern)(props.toolName); // The "Clear" affordance only makes sense when there's a user rule // pinned to this exact tool id — clearing a wildcard rule from a // single tool's detail header would silently affect siblings. diff --git a/packages/react/src/components/tool-tree.tsx b/packages/react/src/components/tool-tree.tsx index 689c68beb..1acb99380 100644 --- a/packages/react/src/components/tool-tree.tsx +++ b/packages/react/src/components/tool-tree.tsx @@ -283,6 +283,8 @@ export function ToolTree(props: { * emit the tool's full dotted id; group rows emit `prefix.*`. */ onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; onClearPolicy?: (pattern: string) => void; + /** Maps the displayed row path into the persisted policy pattern. */ + patternForDisplay?: (displayPattern: string) => string; /** Sorted user-authored policies (most-precedent first). Used to * decide whether a node has its own exact-pattern user rule today * (so the menu can show a "Clear" option). Optional — when absent, @@ -300,6 +302,7 @@ export function ToolTree(props: { onSelect, onSetPolicy, onClearPolicy, + patternForDisplay = toPolicyPattern, policies, groupByConnection, } = props; @@ -358,6 +361,7 @@ export function ToolTree(props: { onSelect, onSetPolicy, onClearPolicy, + patternForDisplay, exactPatterns, search, terms, @@ -433,6 +437,7 @@ function ToolTreeBody(props: { onSelect: (toolId: string) => void; onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; onClearPolicy?: (pattern: string) => void; + patternForDisplay: (displayPattern: string) => string; exactPatterns: ReadonlyMap; search: string; terms: readonly string[]; @@ -444,6 +449,7 @@ function ToolTreeBody(props: { onSelect, onSetPolicy, onClearPolicy, + patternForDisplay, exactPatterns, search, terms, @@ -510,7 +516,8 @@ function ToolTreeBody(props: { search={search} onSetPolicy={onSetPolicy} onClearPolicy={onClearPolicy} - exactRule={exactPatterns.get(toPolicyPattern(row.tool.name))} + patternForDisplay={patternForDisplay} + exactRule={exactPatterns.get(patternForDisplay(row.tool.name))} /> ) : ( ), )} @@ -610,6 +618,7 @@ function ToolGroupRow(props: { search: string; onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; onClearPolicy?: (pattern: string) => void; + patternForDisplay: (displayPattern: string) => string; exactRule?: ToolPolicyAction; }) { const showActions = !!props.onSetPolicy; @@ -647,7 +656,7 @@ function ToolGroupRow(props: { )} > void; onClearPolicy?: (pattern: string) => void; + patternForDisplay: (displayPattern: string) => string; exactRule?: ToolPolicyAction; }) { const label = props.tool.name.split(".").pop() ?? props.tool.name; @@ -719,7 +729,7 @@ function ToolLeafRow(props: { )} > (to === "/" ? "/{-$orgSlug}" : `/{-$orgSlug}${to}`); +const normalizePluginPagePath = (path: string): string => { + if (!path || path === "/") return "/"; + return path.startsWith("/") ? path : `/${path}`; +}; + +const pluginPageNavPath = (pluginId: string, path: string): string => { + const normalized = normalizePluginPagePath(path); + return normalized === "/" ? `/plugins/${pluginId}/` : `/plugins/${pluginId}${normalized}`; +}; + /** The pathname with the active org-slug prefix stripped, for active-state * comparisons against scope-relative paths. */ function useScopeRelativePathname(): string { @@ -310,7 +320,20 @@ function UserFooter(props: Pick) { function SidebarContent( props: ShellProps & { pathname: string; onNavigate?: () => void; showBrand?: boolean }, ) { - const navItems = props.navItems ?? defaultShellNavItems; + const plugins = useClientPlugins(); + const pluginNavItems = plugins.flatMap((plugin) => + (plugin.pages ?? []).flatMap((page) => + page.nav + ? [ + { + to: pluginPageNavPath(plugin.id, page.path), + label: page.nav.label, + }, + ] + : [], + ), + ); + const navItems = [...(props.navItems ?? defaultShellNavItems), ...pluginNavItems]; return ( <> {props.showBrand !== false && ( diff --git a/packages/react/src/routes/plugin-route-match.test.ts b/packages/react/src/routes/plugin-route-match.test.ts new file mode 100644 index 000000000..0ccd9cd76 --- /dev/null +++ b/packages/react/src/routes/plugin-route-match.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { matchPluginPage, matchPluginPagePath } from "./plugins.$pluginId.$"; + +describe("plugin page route matching", () => { + it("matches plugin roots", () => { + expect(matchPluginPagePath("/", "/")).toEqual({}); + expect(matchPluginPagePath("/", "")).toEqual({}); + }); + + it("matches static plugin subpages", () => { + expect(matchPluginPagePath("/settings", "/settings")).toEqual({}); + expect(matchPluginPagePath("/settings", "/other")).toBeNull(); + }); + + it("captures $params from plugin subpages", () => { + expect(matchPluginPagePath("/$toolkitSlug", "/deploy-tools")).toEqual({ + toolkitSlug: "deploy-tools", + }); + }); + + it("does not let parameter pages swallow deeper paths", () => { + expect(matchPluginPagePath("/$toolkitSlug", "/deploy-tools/rules")).toBeNull(); + }); + + it("prefers static plugin pages over parameter pages", () => { + const match = matchPluginPage([{ path: "/$id" }, { path: "/settings" }], "/settings"); + + expect(match).toEqual({ + page: { path: "/settings" }, + params: {}, + }); + }); +}); diff --git a/packages/react/src/routes/plugins.$pluginId.$.tsx b/packages/react/src/routes/plugins.$pluginId.$.tsx index cd209c94d..7bc98f277 100644 --- a/packages/react/src/routes/plugins.$pluginId.$.tsx +++ b/packages/react/src/routes/plugins.$pluginId.$.tsx @@ -11,10 +11,10 @@ import { useClientPlugins } from "@executor-js/sdk/client"; // plugin to `executor.config.ts` is sufficient for its pages to mount // here, with no per-route imports. // -// Match logic is intentionally tiny: exact path equality between the URL -// remainder and a `PageDecl.path`, with `""` and `/` treated as the -// same root. Plugins that want parameterized paths can build their own -// in-component routing for now. +// Plugin pages use the same lightweight route vocabulary as the rest of the +// console route tree: static segments and `$param` segments. The host route +// owns that matching so plugin detail views can be real URLs instead of +// in-component state. // --------------------------------------------------------------------------- export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({ @@ -23,9 +23,66 @@ export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({ function normalizePath(input: string): string { if (!input || input === "/") return "/"; - return input.startsWith("/") ? input : `/${input}`; + const withLeadingSlash = input.startsWith("/") ? input : `/${input}`; + return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/, "") : "/"; } +const pathSegments = (input: string): readonly string[] => + normalizePath(input) + .split("/") + .filter((segment) => segment.length > 0); + +export const matchPluginPagePath = ( + pattern: string, + target: string, +): Readonly> | null => { + const patternSegments = pathSegments(pattern); + const targetSegments = pathSegments(target); + if (patternSegments.length !== targetSegments.length) return null; + + const params: Record = {}; + for (let index = 0; index < patternSegments.length; index += 1) { + const patternSegment = patternSegments[index]!; + const targetSegment = targetSegments[index]!; + if (patternSegment.startsWith("$") && patternSegment.length > 1) { + params[patternSegment.slice(1)] = decodeURIComponent(targetSegment); + continue; + } + if (patternSegment !== targetSegment) return null; + } + return params; +}; + +const matchScore = (pattern: string): number => + pathSegments(pattern).reduce((score, segment) => score + (segment.startsWith("$") ? 1 : 2), 0); + +export const matchPluginPage = ( + pages: readonly TPage[] | undefined, + target: string, +): { readonly page: TPage; readonly params: Readonly> } | null => { + const matches = + pages + ?.map((page, index) => ({ + page, + index, + params: matchPluginPagePath(page.path, target), + score: matchScore(page.path), + })) + .filter( + ( + candidate, + ): candidate is { + readonly page: TPage; + readonly index: number; + readonly params: Readonly>; + readonly score: number; + } => candidate.params !== null, + ) + .sort((a, b) => b.score - a.score || a.index - b.index) ?? []; + const first = matches[0]; + return first ? { page: first.page, params: first.params } : null; +}; + function PluginRouteComponent() { const { pluginId, _splat: rest } = Route.useParams(); const plugins = useClientPlugins(); @@ -34,10 +91,10 @@ function PluginRouteComponent() { if (!plugin) throw notFound(); const target = normalizePath(rest ?? "/"); - const page = plugin.pages?.find((p) => normalizePath(p.path) === target); + const match = matchPluginPage(plugin.pages, target); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound() - if (!page) throw notFound(); + if (!match) throw notFound(); - const Component = page.component; - return ; + const Component = match.page.component; + return ; } From f6354f44630cbc55468469005147a07eb648507f Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:59:36 -0700 Subject: [PATCH 02/10] Harden scoped toolkit MCP sessions --- apps/cloud/executor.config.ts | 5 +- apps/cloud/package.json | 1 + apps/cloud/src/app-paths.test.ts | 8 + apps/cloud/src/engine/execution-stack.ts | 4 +- apps/cloud/src/mcp/auth-provider.ts | 34 +- apps/cloud/src/mcp/auth.ts | 38 +- apps/cloud/src/mcp/mount.test.ts | 63 ++ apps/cloud/src/mcp/mount.ts | 56 +- apps/cloud/src/mcp/oauth-metadata.ts | 7 +- apps/cloud/src/mcp/session-durable-object.ts | 2 + apps/host-cloudflare/executor.config.ts | 4 +- apps/host-cloudflare/package.json | 1 + apps/host-cloudflare/src/execution.ts | 6 +- .../src/mcp/session-durable-object.ts | 2 + apps/host-cloudflare/src/plugins.ts | 7 +- apps/host-selfhost/src/mcp/auth.ts | 61 +- apps/host-selfhost/src/mcp/mcp-oauth.test.ts | 29 + apps/host-selfhost/src/mcp/org-path.test.ts | 2 +- apps/host-selfhost/src/mcp/org-path.ts | 8 +- bun.lock | 2 + e2e/scenarios/toolkits-mcp.test.ts | 591 ++++++++++++++++++ e2e/selfhost/toolkits-ui.test.ts | 15 + e2e/setup/cloudflare.boot.ts | 10 +- e2e/vitest.config.ts | 1 + .../hosts/cloudflare/src/mcp/do-headers.ts | 4 + packages/hosts/cloudflare/src/mcp/seams.ts | 3 + .../src/mcp/session-durable-object.ts | 17 +- .../cloudflare/src/mcp/session-store.test.ts | 80 ++- .../hosts/cloudflare/src/mcp/session-store.ts | 19 +- packages/hosts/mcp/src/envelope.test.ts | 39 +- packages/hosts/mcp/src/envelope.ts | 12 +- packages/plugins/toolkits/src/page.tsx | 136 +++- packages/plugins/toolkits/src/server.test.ts | 34 + packages/plugins/toolkits/src/server.ts | 30 +- packages/react/src/components/tool-tree.tsx | 32 +- 35 files changed, 1285 insertions(+), 78 deletions(-) create mode 100644 apps/cloud/src/mcp/mount.test.ts create mode 100644 e2e/scenarios/toolkits-mcp.test.ts diff --git a/apps/cloud/executor.config.ts b/apps/cloud/executor.config.ts index 148d5f79d..78fdb7f4d 100644 --- a/apps/cloud/executor.config.ts +++ b/apps/cloud/executor.config.ts @@ -5,6 +5,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; // --------------------------------------------------------------------------- // Single source of truth for the cloud app's plugin list. @@ -40,10 +41,11 @@ interface CloudPluginDeps { * bypass the real WorkOS API. Production leaves this undefined and * falls back to the credential-driven default. */ readonly workosVaultClient?: WorkOSVaultClient; + readonly activeToolkitSlug?: string; } export default defineExecutorConfig({ - plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) => + plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) => [ openApiHttpPlugin(), googleHttpPlugin(), @@ -52,6 +54,7 @@ export default defineExecutorConfig({ dangerouslyAllowStdioMCP: false, }), graphqlHttpPlugin(), + toolkitsPlugin({ activeToolkitSlug }), workosVaultPlugin({ credentials: workosCredentials ?? { apiKey: "", clientId: "" }, ...(workosVaultClient ? { client: workosVaultClient } : {}), diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 9871c200c..6ef4f5f66 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -46,6 +46,7 @@ "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-microsoft": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/plugin-toolkits": "workspace:*", "@executor-js/plugin-workos-vault": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/runtime-dynamic-worker": "workspace:*", diff --git a/apps/cloud/src/app-paths.test.ts b/apps/cloud/src/app-paths.test.ts index 1fce18c9b..f885aaa46 100644 --- a/apps/cloud/src/app-paths.test.ts +++ b/apps/cloud/src/app-paths.test.ts @@ -17,14 +17,20 @@ describe("isAppOwnedPath", () => { "/api/billing/attach", "/api/docs", // Swagger UI "/mcp", + "/mcp/toolkits/deploy-kit", "/.well-known/oauth-protected-resource/mcp", + "/.well-known/oauth-protected-resource/mcp/toolkits/deploy-kit", "/.well-known/oauth-authorization-server", // Org-pinned MCP: the org's URL slug (what the install card prints) and // the legacy WorkOS org-id form both select an org on the MCP plane. "/acme-corp/mcp", + "/acme-corp/mcp/toolkits/deploy-kit", "/org_01ABCDEF/mcp", + "/org_01ABCDEF/mcp/toolkits/deploy-kit", "/.well-known/oauth-protected-resource/acme-corp/mcp", + "/.well-known/oauth-protected-resource/acme-corp/mcp/toolkits/deploy-kit", "/.well-known/oauth-protected-resource/org_01ABCDEF/mcp", + "/.well-known/oauth-protected-resource/org_01ABCDEF/mcp/toolkits/deploy-kit", ]; for (const pathname of appOwned) { it(`forwards ${pathname} to the app handler`, () => { @@ -45,7 +51,9 @@ describe("isAppOwnedPath", () => { "/org", "/assets/app.js", "/settings/mcp", + "/settings/mcp/toolkits/deploy-kit", "/integrations/mcp", + "/integrations/mcp/toolkits/deploy-kit", ]; for (const pathname of startOwned) { it(`leaves ${pathname} to the Start router`, () => { diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index a999e52e1..bf611adb3 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -57,13 +57,15 @@ export const CloudDbProvider = cloudDbProviderLayer(collectTables()); // Fresh plugin instances per request, carrying the Worker env's WorkOS Vault // credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`. export const CloudPluginsProvider: Layer.Layer = Layer.succeed(PluginsProvider)({ - plugins: () => + plugins: (context) => executorConfig.plugins({ workosCredentials: { apiKey: env.WORKOS_API_KEY, clientId: env.WORKOS_CLIENT_ID, apiUrl: env.WORKOS_API_URL, }, + activeToolkitSlug: + context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, }), }); diff --git a/apps/cloud/src/mcp/auth-provider.ts b/apps/cloud/src/mcp/auth-provider.ts index bde0a2366..d8f178b2f 100644 --- a/apps/cloud/src/mcp/auth-provider.ts +++ b/apps/cloud/src/mcp/auth-provider.ts @@ -43,6 +43,7 @@ import { mcpOrganizationFromRequest, protectedResourceMetadataUrlFor, PROTECTED_RESOURCE_METADATA_PATH, + toolkitSlugFromRequest, McpAuth, McpAuthLive, McpOrganizationAuth, @@ -57,6 +58,7 @@ import { } from "./oauth-metadata"; const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; +const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`; const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first"; @@ -94,7 +96,22 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< // The bare path is the only one mounted; `prepareMcpOrgScope` rewrites an // org-scoped discovery doc onto it and pins the org in the header we read. handler: (request) => - Effect.succeed(protectedResourceMetadataResponse(mcpOrganizationFromRequest(request))), + Effect.succeed( + protectedResourceMetadataResponse( + mcpOrganizationFromRequest(request), + toolkitSlugFromRequest(request), + ), + ), + }, + { + path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH, + handler: (request) => + Effect.succeed( + protectedResourceMetadataResponse( + mcpOrganizationFromRequest(request), + toolkitSlugFromRequest(request), + ), + ), }, { path: AUTHORIZATION_SERVER_METADATA_PATH, @@ -103,7 +120,10 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< ]; const resourceMetadataUrl = (request: Request): string => - protectedResourceMetadataUrlFor(mcpOrganizationFromRequest(request)); + protectedResourceMetadataUrlFor( + mcpOrganizationFromRequest(request), + toolkitSlugFromRequest(request), + ); /** * Resolve a verified bearer to a final AuthOutcome by running the live org @@ -160,7 +180,15 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< return finishAuthorized(request, result.token); } return annotateMcpRequest(request, { token: null, parseBody: false }).pipe( - Effect.as(unauthorized(bearerChallengeFor(result, mcpOrganizationFromRequest(request)))), + Effect.as( + unauthorized( + bearerChallengeFor( + result, + mcpOrganizationFromRequest(request), + toolkitSlugFromRequest(request), + ), + ), + ), ); }; diff --git a/apps/cloud/src/mcp/auth.ts b/apps/cloud/src/mcp/auth.ts index e11e99e1c..531fd5cba 100644 --- a/apps/cloud/src/mcp/auth.ts +++ b/apps/cloud/src/mcp/auth.ts @@ -50,6 +50,7 @@ const MCP_PATH = "/mcp"; export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp"; export const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`; export const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`; +const TOOLKIT_SEGMENT = "/toolkits/"; // --------------------------------------------------------------------------- // Org-scoped MCP (the URL pins an org: `/org_xxx/mcp`) @@ -68,17 +69,39 @@ export const MCP_ORGANIZATION_HEADER = "x-executor-mcp-organization"; export const mcpOrganizationFromRequest = (request: Request): string | null => request.headers.get(MCP_ORGANIZATION_HEADER); +/** The toolkit slug selected by `/mcp/toolkits/:slug` or its metadata doc. */ +export const toolkitSlugFromRequest = (request: Request): string | null => { + const pathname = new URL(request.url).pathname; + const index = pathname.indexOf(TOOLKIT_SEGMENT); + if (index < 0) return null; + const slug = pathname.slice(index + TOOLKIT_SEGMENT.length).split("/", 1)[0]; + return slug && slug.length > 0 ? slug : null; +}; + +const toolkitMcpPath = (toolkitSlug: string | null): string => + toolkitSlug ? `${MCP_PATH}/toolkits/${toolkitSlug}` : MCP_PATH; + /** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy * `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client * used), or the bare resource. */ -export const resourceUrlFor = (organizationSelector: string | null): string => - organizationSelector ? `${RESOURCE_ORIGIN}/${organizationSelector}${MCP_PATH}` : RESOURCE_URL; +export const resourceUrlFor = ( + organizationSelector: string | null, + toolkitSlug: string | null = null, +): string => + organizationSelector + ? `${RESOURCE_ORIGIN}/${organizationSelector}${toolkitMcpPath(toolkitSlug)}` + : `${RESOURCE_ORIGIN}${toolkitMcpPath(toolkitSlug)}`; /** The protected-resource-metadata URL for an org selector, or the bare one. */ -export const protectedResourceMetadataUrlFor = (organizationSelector: string | null): string => - organizationSelector - ? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationSelector}/mcp` - : PROTECTED_RESOURCE_METADATA_URL; +export const protectedResourceMetadataUrlFor = ( + organizationSelector: string | null, + toolkitSlug: string | null = null, +): string => { + const toolkitSuffix = toolkitSlug ? `/toolkits/${toolkitSlug}` : ""; + return organizationSelector + ? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationSelector}/mcp${toolkitSuffix}` + : `${PROTECTED_RESOURCE_METADATA_URL}${toolkitSuffix}`; +}; type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; @@ -117,10 +140,11 @@ export const mcpUnauthorized = ( export const bearerChallengeFor = ( result: McpUnauthorizedResult, organizationId: string | null = null, + toolkitSlug: string | null = null, ): string => bearerChallenge( { reason: result.reason, description: result.description }, - protectedResourceMetadataUrlFor(organizationId), + protectedResourceMetadataUrlFor(organizationId, toolkitSlug), ); // --------------------------------------------------------------------------- diff --git a/apps/cloud/src/mcp/mount.test.ts b/apps/cloud/src/mcp/mount.test.ts new file mode 100644 index 000000000..0489da231 --- /dev/null +++ b/apps/cloud/src/mcp/mount.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + protectedResourceMetadataUrlFor, + resourceUrlFor, + toolkitSlugFromRequest, +} from "./auth"; +import { classifyMcpPath, prepareMcpOrgScope } from "./mount"; + +describe("cloud MCP toolkit route normalization", () => { + it("classifies toolkit MCP and protected-resource metadata paths", () => { + expect(classifyMcpPath("/mcp/toolkits/deploy")).toEqual({ + kind: "mcp", + organizationId: null, + toolkitSlug: "deploy", + }); + expect(classifyMcpPath("/acme/mcp/toolkits/deploy")).toEqual({ + kind: "mcp", + organizationId: "acme", + toolkitSlug: "deploy", + }); + expect(classifyMcpPath("/.well-known/oauth-protected-resource/mcp/toolkits/deploy")).toEqual({ + kind: "oauth-protected-resource", + organizationId: null, + toolkitSlug: "deploy", + }); + expect( + classifyMcpPath("/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy"), + ).toEqual({ + kind: "oauth-protected-resource", + organizationId: "acme", + toolkitSlug: "deploy", + }); + }); + + it("rewrites org-scoped toolkit metadata to the mounted toolkit metadata route", () => { + const request = new Request( + "https://executor.sh/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy?x=1", + { headers: { "x-executor-mcp-organization": "spoofed" } }, + ); + + const rewritten = prepareMcpOrgScope(request); + const url = new URL(rewritten.url); + + expect(url.pathname).toBe("/.well-known/oauth-protected-resource/mcp/toolkits/deploy"); + expect(url.search).toBe("?x=1"); + expect(rewritten.headers.get("x-executor-mcp-organization")).toBe("acme"); + expect(toolkitSlugFromRequest(rewritten)).toBe("deploy"); + }); + + it("builds toolkit-specific resource and metadata URLs", () => { + expect(resourceUrlFor(null, "deploy")).toBe("https://executor.sh/mcp/toolkits/deploy"); + expect(resourceUrlFor("acme", "deploy")).toBe( + "https://executor.sh/acme/mcp/toolkits/deploy", + ); + expect(protectedResourceMetadataUrlFor(null, "deploy")).toBe( + "https://executor.sh/.well-known/oauth-protected-resource/mcp/toolkits/deploy", + ); + expect(protectedResourceMetadataUrlFor("acme", "deploy")).toBe( + "https://executor.sh/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy", + ); + }); +}); diff --git a/apps/cloud/src/mcp/mount.ts b/apps/cloud/src/mcp/mount.ts index 58c440507..b2eadf020 100644 --- a/apps/cloud/src/mcp/mount.ts +++ b/apps/cloud/src/mcp/mount.ts @@ -50,6 +50,7 @@ type McpRoute = { * id), or `null` for the bare path. Resolved to an org id — and re-checked * against live membership — in the auth provider. */ readonly organizationId: string | null; + readonly toolkitSlug?: string; } | null; // A path segment counts as an org selector when it's the org's URL slug (the @@ -60,12 +61,31 @@ type McpRoute = { const orgSelectorSegment = (segment: string | undefined): string | null => segment && (segment.startsWith("org_") || isValidOrgSlug(segment)) ? segment : null; -// Matches a trailing MCP endpoint — `mcp` (bare) or `/mcp`. Returns the org -// selector, `null` for the bare form, or `undefined` when the segments are neither. -const matchMcpSuffix = (segments: readonly string[]): string | null | undefined => { - if (segments.length === 1 && segments[0] === "mcp") return null; +type MatchedMcpSuffix = { + readonly organizationId: string | null; + readonly toolkitSlug?: string; +}; + +// Matches a trailing MCP endpoint: `mcp`, `mcp/toolkits/`, or either with +// a leading org selector. Returns undefined when the segments are not MCP. +const matchMcpSuffix = (segments: readonly string[]): MatchedMcpSuffix | undefined => { + if (segments.length === 1 && segments[0] === "mcp") return { organizationId: null }; + if (segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits") { + const toolkitSlug = segments[2]; + return toolkitSlug ? { organizationId: null, toolkitSlug } : undefined; + } if (segments.length === 2 && segments[1] === "mcp") { - return orgSelectorSegment(segments[0]) ?? undefined; + const organizationId = orgSelectorSegment(segments[0]); + return organizationId ? { organizationId } : undefined; + } + if ( + segments.length === 4 && + segments[1] === "mcp" && + segments[2] === "toolkits" + ) { + const organizationId = orgSelectorSegment(segments[0]); + const toolkitSlug = segments[3]; + return organizationId && toolkitSlug ? { organizationId, toolkitSlug } : undefined; } return undefined; }; @@ -93,22 +113,26 @@ export const classifyMcpPath = (pathname: string): McpRoute => { // org sits after the well-known prefix (RFC 9728), not at the path root. const prmPrefix = "/.well-known/oauth-protected-resource"; if (pathname.startsWith(`${prmPrefix}/`)) { - const organizationId = matchMcpSuffix(segments.slice(2)); - return organizationId === undefined + const matched = matchMcpSuffix(segments.slice(2)); + return matched === undefined ? null - : { kind: "oauth-protected-resource", organizationId }; + : { kind: "oauth-protected-resource", ...matched }; } // MCP transport: `/mcp` or `//mcp`. - const organizationId = matchMcpSuffix(segments); - return organizationId === undefined ? null : { kind: "mcp", organizationId }; + const matched = matchMcpSuffix(segments); + return matched === undefined ? null : { kind: "mcp", ...matched }; }; -const bareMcpPath = (kind: McpRouteKind): string => - kind === "mcp" - ? MCP_PATH - : kind === "oauth-protected-resource" - ? PROTECTED_RESOURCE_METADATA_PATH +const bareMcpPath = (route: Exclude): string => + route.kind === "mcp" + ? route.toolkitSlug + ? `${MCP_PATH}/toolkits/${route.toolkitSlug}` + : MCP_PATH + : route.kind === "oauth-protected-resource" + ? route.toolkitSlug + ? `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/${route.toolkitSlug}` + : PROTECTED_RESOURCE_METADATA_PATH : AUTHORIZATION_SERVER_METADATA_PATH; /** @@ -125,7 +149,7 @@ export const prepareMcpOrgScope = (request: Request): Request => { const url = new URL(request.url); const route = classifyMcpPath(url.pathname); if (route === null) return request; - const bare = bareMcpPath(route.kind); + const bare = bareMcpPath(route); if (url.pathname === bare && !request.headers.has(MCP_ORGANIZATION_HEADER)) return request; url.pathname = bare; const rewritten = new Request(url, request); diff --git a/apps/cloud/src/mcp/oauth-metadata.ts b/apps/cloud/src/mcp/oauth-metadata.ts index 5ebd988c6..26af2db08 100644 --- a/apps/cloud/src/mcp/oauth-metadata.ts +++ b/apps/cloud/src/mcp/oauth-metadata.ts @@ -17,9 +17,12 @@ const jsonWebResponse = (body: unknown, status = 200): Response => // The `resource` reflects the URL-pinned org (`…/org_xxx/mcp`) when present, so a // client that discovered metadata via the org-scoped well-known doc gets back the // matching org-scoped resource id; the bare path yields the bare resource. -export const protectedResourceMetadataResponse = (organizationId: string | null = null): Response => +export const protectedResourceMetadataResponse = ( + organizationId: string | null = null, + toolkitSlug: string | null = null, +): Response => jsonWebResponse({ - resource: resourceUrlFor(organizationId), + resource: resourceUrlFor(organizationId, toolkitSlug), authorization_servers: [AUTHKIT_DOMAIN], bearer_methods_supported: ["header"], // Spec-faithful clients (OpenCode, mcporter) request exactly what is diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 5513ddb90..bf20a077d 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -172,6 +172,7 @@ export class McpSessionDO extends McpSessionDOBase { organizationName: org.name, organizationSlug: org.slug, userId: token.userId, + resource: token.resource, elicitationMode: token.elicitationMode, } satisfies SessionMeta; }).pipe( @@ -193,6 +194,7 @@ export class McpSessionDO extends McpSessionDOBase { sessionMeta.userId, sessionMeta.organizationId, sessionMeta.organizationName, + { mcpResource: sessionMeta.resource }, ).pipe( Effect.provide(CloudExecutionStackLayer), Effect.withSpan("McpSessionDO.makeExecutionStack"), diff --git a/apps/host-cloudflare/executor.config.ts b/apps/host-cloudflare/executor.config.ts index 55ad2f261..b8ac7b295 100644 --- a/apps/host-cloudflare/executor.config.ts +++ b/apps/host-cloudflare/executor.config.ts @@ -5,6 +5,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; // --------------------------------------------------------------------------- // Plugin list for the Cloudflare web build. The Vite `executorVitePlugin` reads @@ -16,13 +17,14 @@ import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; // --------------------------------------------------------------------------- export default defineExecutorConfig({ - plugins: () => + plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) => [ openApiHttpPlugin(), googleHttpPlugin(), microsoftHttpPlugin(), mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), graphqlHttpPlugin(), + toolkitsPlugin({ activeToolkitSlug }), encryptedSecretsPlugin({ key: process.env.EXECUTOR_SECRET_KEY ?? "build-time-placeholder" }), ] as const, }); diff --git a/apps/host-cloudflare/package.json b/apps/host-cloudflare/package.json index 9ece894d1..159266b85 100644 --- a/apps/host-cloudflare/package.json +++ b/apps/host-cloudflare/package.json @@ -29,6 +29,7 @@ "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-microsoft": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/plugin-toolkits": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", diff --git a/apps/host-cloudflare/src/execution.ts b/apps/host-cloudflare/src/execution.ts index 06bfec2aa..2a5f28f15 100644 --- a/apps/host-cloudflare/src/execution.ts +++ b/apps/host-cloudflare/src/execution.ts @@ -38,7 +38,11 @@ export const makeCloudflarePluginsProvider = ( config: CloudflareConfig, ): Layer.Layer => Layer.succeed(PluginsProvider)({ - plugins: () => makeCloudflarePlugins(config.secretKey), + plugins: (context) => + makeCloudflarePlugins(config.secretKey, { + activeToolkitSlug: + context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, + }), }); export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer => diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index 26caac4df..abe4eb7e3 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -61,6 +61,7 @@ export class McpSessionDO extends McpSessionDOBase { organizationName: this.cfConfig.organizationName, organizationSlug: this.cfConfig.organizationSlug, userId: token.userId, + resource: token.resource, elicitationMode: token.elicitationMode, } satisfies SessionMeta); } @@ -79,6 +80,7 @@ export class McpSessionDO extends McpSessionDOBase { sessionMeta.userId, sessionMeta.organizationId, sessionMeta.organizationName, + { mcpResource: sessionMeta.resource }, ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); // Browser elicitation mode (the base owns the approval store + the HTTP // approval RPCs): a gated execution pauses and returns an approvalUrl into diff --git a/apps/host-cloudflare/src/plugins.ts b/apps/host-cloudflare/src/plugins.ts index 21d7259af..059f10a69 100644 --- a/apps/host-cloudflare/src/plugins.ts +++ b/apps/host-cloudflare/src/plugins.ts @@ -4,6 +4,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; // --------------------------------------------------------------------------- // The Cloudflare host's plugin list — the same protocol/provider plugins as @@ -16,13 +17,17 @@ import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; // spawn arbitrary stdio MCP processes. // --------------------------------------------------------------------------- -export const makeCloudflarePlugins = (secretKey: string) => +export const makeCloudflarePlugins = ( + secretKey: string, + options: { readonly activeToolkitSlug?: string } = {}, +) => [ openApiHttpPlugin(), googleHttpPlugin(), microsoftHttpPlugin(), mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), graphqlHttpPlugin(), + toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }), encryptedSecretsPlugin({ key: secretKey }), ] as const; diff --git a/apps/host-selfhost/src/mcp/auth.ts b/apps/host-selfhost/src/mcp/auth.ts index bfdb4adc6..3a9e8cd24 100644 --- a/apps/host-selfhost/src/mcp/auth.ts +++ b/apps/host-selfhost/src/mcp/auth.ts @@ -46,7 +46,10 @@ import { BetterAuth } from "../auth/better-auth"; // --------------------------------------------------------------------------- const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; +const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = + `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/:toolkitSlug`; const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; +const TOOLKIT_MCP_SEGMENT = "/mcp/toolkits/"; const parseRoles = (role: string | null | undefined): ReadonlyArray => (role ?? "user") @@ -66,6 +69,19 @@ const userRole = (user: object): string | null => { const hasBearer = (request: Request): boolean => (request.headers.get("authorization") ?? "").startsWith("Bearer "); +const toolkitSlugFromRequest = (request: Request): string | null => { + const pathname = new URL(request.url).pathname; + const index = pathname.indexOf(TOOLKIT_MCP_SEGMENT); + if (index < 0) return null; + const slug = pathname.slice(index + TOOLKIT_MCP_SEGMENT.length).split("/", 1)[0]; + return slug && slug.length > 0 ? slug : null; +}; + +const mcpResourcePathFor = (request: Request): string => { + const toolkitSlug = toolkitSlugFromRequest(request); + return toolkitSlug ? `/mcp/toolkits/${toolkitSlug}` : "/mcp"; +}; + /** * Absolute protected-resource metadata URL for the 401 challenge. Derive the * origin from `baseURL` when set; otherwise from the live request so the URL is @@ -73,7 +89,34 @@ const hasBearer = (request: Request): boolean => */ const resourceMetadataUrlFor = (baseURL: string | undefined, request: Request): string => { const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin; - return `${origin}${PROTECTED_RESOURCE_METADATA_PATH}`; + const toolkitSlug = toolkitSlugFromRequest(request); + return toolkitSlug + ? `${origin}${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/${toolkitSlug}` + : `${origin}${PROTECTED_RESOURCE_METADATA_PATH}`; +}; + +const resourceUrlFor = (baseURL: string | undefined, request: Request): string => { + const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin; + return `${origin}${mcpResourcePathFor(request)}`; +}; + +const toolkitProtectedResourceMetadata = ( + request: Request, + response: Response, + baseURL: string | undefined, +): Effect.Effect => { + const toolkitSlug = toolkitSlugFromRequest(request); + if (!toolkitSlug) return Effect.succeed(response); + return Effect.promise(async () => { + const body = (await response.json()) as Record; + const headers = new Headers(response.headers); + headers.set("content-type", "application/json"); + return new Response(JSON.stringify({ ...body, resource: resourceUrlFor(baseURL, request) }), { + status: response.status, + statusText: response.statusText, + headers, + }); + }); }; export const selfHostMcpAuth: Layer.Layer = @@ -99,7 +142,21 @@ export const selfHostMcpAuth: Layer.Layer = [ { path: PROTECTED_RESOURCE_METADATA_PATH, - handler: (request) => Effect.promise(() => prMetadata(request)), + handler: (request) => + Effect.promise(() => prMetadata(request)).pipe( + Effect.flatMap((response) => + toolkitProtectedResourceMetadata(request, response, baseURL), + ), + ), + }, + { + path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH, + handler: (request) => + Effect.promise(() => prMetadata(request)).pipe( + Effect.flatMap((response) => + toolkitProtectedResourceMetadata(request, response, baseURL), + ), + ), }, { path: AUTHORIZATION_SERVER_METADATA_PATH, diff --git a/apps/host-selfhost/src/mcp/mcp-oauth.test.ts b/apps/host-selfhost/src/mcp/mcp-oauth.test.ts index 5b0c10d23..a66952a0e 100644 --- a/apps/host-selfhost/src/mcp/mcp-oauth.test.ts +++ b/apps/host-selfhost/src/mcp/mcp-oauth.test.ts @@ -36,6 +36,16 @@ test("serves OAuth Protected Resource metadata at the origin root", async () => expect(Array.isArray(body.authorization_servers)).toBe(true); }); +test("serves OAuth Protected Resource metadata for a toolkit MCP resource", async () => { + const res = await handler( + new Request(`${BASE}/.well-known/oauth-protected-resource/mcp/toolkits/deploy`), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.resource).toBe(`${BASE}/mcp/toolkits/deploy`); + expect(Array.isArray(body.authorization_servers)).toBe(true); +}); + test("an unauthenticated /mcp request returns 401 with a WWW-Authenticate challenge", async () => { const res = await handler( new Request(`${BASE}/mcp`, { @@ -53,6 +63,25 @@ test("an unauthenticated /mcp request returns 401 with a WWW-Authenticate challe expect(challenge).toContain("resource_metadata="); }); +test("an unauthenticated toolkit MCP request challenges with toolkit metadata", async () => { + const res = await handler( + new Request(`${BASE}/mcp/toolkits/deploy`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + }), + ); + expect(res.status).toBe(401); + const challenge = res.headers.get("www-authenticate") ?? ""; + expect(challenge).toContain("Bearer"); + expect(challenge).toContain( + `resource_metadata="${BASE}/.well-known/oauth-protected-resource/mcp/toolkits/deploy"`, + ); +}); + // --- End-to-end MCP OAuth: DCR -> authorize -> token -> /mcp with bearer --- const json = async (res: Response) => (await res.json()) as Record; diff --git a/apps/host-selfhost/src/mcp/org-path.test.ts b/apps/host-selfhost/src/mcp/org-path.test.ts index 6a1015991..5c7460fa9 100644 --- a/apps/host-selfhost/src/mcp/org-path.test.ts +++ b/apps/host-selfhost/src/mcp/org-path.test.ts @@ -15,7 +15,7 @@ describe("stripMcpOrgSegment", () => { ); expect( stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp/toolkits/deploy"), - ).toBe("/.well-known/oauth-protected-resource"); + ).toBe("/.well-known/oauth-protected-resource/mcp/toolkits/deploy"); }); it("leaves the bare paths untouched", () => { diff --git a/apps/host-selfhost/src/mcp/org-path.ts b/apps/host-selfhost/src/mcp/org-path.ts index 449e3088b..f24d38d40 100644 --- a/apps/host-selfhost/src/mcp/org-path.ts +++ b/apps/host-selfhost/src/mcp/org-path.ts @@ -22,7 +22,9 @@ const PRM_PREFIX = "/.well-known/oauth-protected-resource"; * * //mcp -> /mcp * //mcp/toolkits/ -> /mcp/toolkits/ - * /.well-known/oauth-protected-resource//mcp[...] -> /.well-known/oauth-protected-resource + * /.well-known/oauth-protected-resource//mcp -> /.well-known/oauth-protected-resource + * /.well-known/oauth-protected-resource//mcp/toolkits/ + * -> /.well-known/oauth-protected-resource/mcp/toolkits/ */ export const stripMcpOrgSegment = (pathname: string): string | null => { if (pathname.startsWith(`${PRM_PREFIX}/`)) { @@ -31,7 +33,9 @@ export const stripMcpOrgSegment = (pathname: string): string | null => { .split("/") .filter((segment) => segment.length > 0); if (rest.length === 2 && rest[1] === "mcp") return PRM_PREFIX; - if (rest.length === 4 && rest[1] === "mcp" && rest[2] === "toolkits") return PRM_PREFIX; + if (rest.length === 4 && rest[1] === "mcp" && rest[2] === "toolkits") { + return `${PRM_PREFIX}/mcp/toolkits/${rest[3]}`; + } return null; } const segments = pathname.split("/").filter((segment) => segment.length > 0); diff --git a/bun.lock b/bun.lock index 158cd05ef..47ed0b059 100644 --- a/bun.lock +++ b/bun.lock @@ -70,6 +70,7 @@ "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-microsoft": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/plugin-toolkits": "workspace:*", "@executor-js/plugin-workos-vault": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/runtime-dynamic-worker": "workspace:*", @@ -180,6 +181,7 @@ "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-microsoft": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/plugin-toolkits": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", diff --git a/e2e/scenarios/toolkits-mcp.test.ts b/e2e/scenarios/toolkits-mcp.test.ts new file mode 100644 index 000000000..d006244c9 --- /dev/null +++ b/e2e/scenarios/toolkits-mcp.test.ts @@ -0,0 +1,591 @@ +import { randomBytes } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; +import type { McpSession } from "../src/surfaces/mcp"; + +const api = composePluginApi([openApiHttpPlugin(), toolkitsPlugin()] as const); + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +const pingSpec = (baseUrl: string): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Toolkit Ping API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/ping/{id}": { + get: { + operationId: "getPing", + summary: "Return a ping payload", + security: [{ apiKey: [] }], + parameters: [ + { name: "id", in: "path", required: true, schema: { type: "string" } }, + ], + responses: { + "200": { + description: "A ping payload", + content: { + "application/json": { + schema: { + type: "object", + properties: { + id: { type: "string" }, + path: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + securitySchemes: { + apiKey: { type: "apiKey", in: "header", name: "x-e2e-token" }, + }, + }, + }); + +const closeServer = (server: Server): Promise => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + +const servePingApi = Effect.acquireRelease( + Effect.promise( + () => + new Promise<{ readonly url: string; readonly server: Server }>((resolve) => { + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (request.method === "GET" && url.pathname.startsWith("/ping/")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + id: decodeURIComponent(url.pathname.slice("/ping/".length)), + path: url.pathname, + }), + ); + return; + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + resolve({ url: `http://127.0.0.1:${address.port}`, server }); + }); + }), + ), + ({ server }) => Effect.promise(() => closeServer(server)).pipe(Effect.ignore), +); + +const toolkitUrl = (baseUrl: string, slug: string): string => + new URL(`/mcp/toolkits/${slug}`, baseUrl).toString(); + +const connectionPattern = (integration: string, owner: "org" | "user", name: string): string => + `${integration}.${owner}.${name}.*`; + +const executeJson = (session: McpSession, code: string) => + Effect.gen(function* () { + const result = yield* session.call("execute", { code }); + expect(result.ok, `execute completed (got: ${result.text.slice(0, 500)})`).toBe(true); + return JSON.parse(result.text) as Record; + }); + +const callPingCode = (input: { + readonly integration: string; + readonly owner: "org" | "user"; + readonly connection: string; + readonly id: string; +}) => ` +const listed = await tools.search({ namespace: ${JSON.stringify(input.integration)}, query: "ping", limit: 100 }); +const expected = ${JSON.stringify(`${input.integration}.${input.owner}.${input.connection}.`)}; +const path = listed.items.map((item) => item.path).find((candidate) => candidate.startsWith(expected)); +if (!path) return { ok: false, reason: "missing", expected, paths: listed.items.map((item) => item.path).sort() }; +let tool = tools; +for (const segment of path.split(".")) tool = tool?.[segment]; +if (typeof tool !== "function") return { ok: false, reason: "not-callable", path }; +const result = await tool({ id: ${JSON.stringify(input.id)} }); +return { ok: result.ok, path, data: result.ok ? result.data : result.error }; +`; + +const visibleConnectionPathsCode = (integration: string) => ` +const listed = await tools.search({ namespace: ${JSON.stringify(integration)}, query: "ping", limit: 100 }); +return { paths: listed.items.map((item) => item.path).sort() }; +`; + +const createPolicyCode = (input: { + readonly pattern: string; + readonly action: "approve" | "require_approval" | "block"; +}) => ` +const created = await tools.executor.coreTools.policies.create({ + owner: "user", + pattern: ${JSON.stringify(input.pattern)}, + action: ${JSON.stringify(input.action)}, +}); +return JSON.stringify({ ok: created.ok, data: created.ok ? created.data : null, error: created.ok ? null : created.error }); +`; + +const assertCallOk = (value: Record, label: string) => { + expect(value.ok, `${label}: ${JSON.stringify(value)}`).toBe(true); +}; + +const assertCallMissing = (value: Record, label: string) => { + expect(value.ok, `${label}: ${JSON.stringify(value)}`).toBe(false); + expect(value.reason, `${label} is missing from the toolkit catalog`).toBe("missing"); +}; + +scenario( + "Toolkits · OAuth metadata and challenges stay scoped to the toolkit endpoint", + { timeout: 60_000 }, + Effect.gen(function* () { + const target = yield* Target; + const slug = unique("metadata-kit"); + const mcpUrl = new URL(`/mcp/toolkits/${slug}`, target.baseUrl); + const metadataUrl = new URL( + `/.well-known/oauth-protected-resource/mcp/toolkits/${slug}`, + target.baseUrl, + ); + + const metadataResponse = yield* Effect.promise(() => fetch(metadataUrl)); + expect(metadataResponse.status, "toolkit protected-resource metadata is served").toBe(200); + const metadata = (yield* Effect.promise(() => metadataResponse.json())) as Record< + string, + unknown + >; + expect(metadata.resource, "metadata advertises the toolkit MCP resource").toBe( + mcpUrl.toString(), + ); + expect( + Array.isArray(metadata.authorization_servers), + "metadata still advertises authorization servers", + ).toBe(true); + + const challenged = yield* Effect.promise(() => + fetch(mcpUrl, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + }), + ); + expect(challenged.status, "unauthenticated toolkit MCP requests are challenged").toBe(401); + expect( + challenged.headers.get("www-authenticate") ?? "", + "challenge points clients at toolkit metadata", + ).toContain(metadataUrl.toString()); + }), +); + +scenario( + "Toolkits · workspace and personal MCP endpoints expose the right connection sets", + { timeout: 240_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const upstream = yield* servePingApi; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + + const integration = unique("toolkit_ping"); + const workspaceToolkitName = unique("workspace-kit"); + const personalToolkitName = unique("personal-kit"); + const workspaceConnections = Array.from({ length: 30 }, (_, index) => `shared${index}`); + const personalConnection = "mine"; + + yield* Effect.gen(function* () { + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: pingSpec(upstream.url) }, + slug: IntegrationSlug.make(integration), + baseUrl: upstream.url, + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { "x-e2e-token": [{ type: "variable", name: "token" }] }, + }, + ], + }, + }); + + for (const name of workspaceConnections) { + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make(name), + integration: IntegrationSlug.make(integration), + template: AuthTemplateSlug.make("apiKey"), + value: "unused-token", + }, + }); + } + yield* client.connections.create({ + payload: { + owner: "user", + name: ConnectionName.make(personalConnection), + integration: IntegrationSlug.make(integration), + template: AuthTemplateSlug.make("apiKey"), + value: "unused-token", + }, + }); + + const workspaceToolkit = yield* client.toolkits.create({ + payload: { owner: "org", name: workspaceToolkitName }, + }); + for (const name of workspaceConnections) { + yield* client.toolkits.createConnection({ + params: { toolkitId: workspaceToolkit.id }, + payload: { pattern: connectionPattern(integration, "org", name) }, + }); + } + yield* client.toolkits.createConnection({ + params: { toolkitId: workspaceToolkit.id }, + payload: { pattern: connectionPattern(integration, "user", personalConnection) }, + }); + + const personalToolkit = yield* client.toolkits.create({ + payload: { owner: "user", name: personalToolkitName }, + }); + yield* client.toolkits.createConnection({ + params: { toolkitId: personalToolkit.id }, + payload: { pattern: connectionPattern(integration, "org", workspaceConnections[0]!) }, + }); + yield* client.toolkits.createConnection({ + params: { toolkitId: personalToolkit.id }, + payload: { pattern: connectionPattern(integration, "user", personalConnection) }, + }); + + const workspaceSession = mcp.session(identity, { + url: toolkitUrl(target.baseUrl, workspaceToolkit.slug), + }); + const personalSession = mcp.session(identity, { + url: toolkitUrl(target.baseUrl, personalToolkit.slug), + }); + + const workspacePaths = yield* executeJson( + workspaceSession, + visibleConnectionPathsCode(integration), + ); + const paths = workspacePaths.paths as string[]; + expect(paths.length, "workspace toolkit exposes every workspace connection").toBe( + workspaceConnections.length, + ); + for (const name of workspaceConnections) { + expect(paths, `workspace toolkit includes ${name}`).toContain( + `${integration}.org.${name}.ping.getPing`, + ); + } + expect( + paths, + "workspace toolkit does not expose a personal connection even when its pattern was added", + ).not.toContain(`${integration}.user.${personalConnection}.ping.getPing`); + + const workspaceCall = yield* executeJson( + workspaceSession, + callPingCode({ + integration, + owner: "org", + connection: workspaceConnections[3]!, + id: "workspace-call", + }), + ); + assertCallOk(workspaceCall, "workspace connection is callable"); + + const lateWorkspaceCall = yield* executeJson( + workspaceSession, + callPingCode({ + integration, + owner: "org", + connection: workspaceConnections.at(-1)!, + id: "workspace-late-call", + }), + ); + assertCallOk(lateWorkspaceCall, "late workspace connection is callable"); + + const personalBlockedFromWorkspace = yield* executeJson( + workspaceSession, + callPingCode({ + integration, + owner: "user", + connection: personalConnection, + id: "workspace-personal-blocked", + }), + ); + assertCallMissing(personalBlockedFromWorkspace, "workspace toolkit blocks personal tools"); + + const personalWorkspaceCall = yield* executeJson( + personalSession, + callPingCode({ + integration, + owner: "org", + connection: workspaceConnections[0]!, + id: "personal-workspace-call", + }), + ); + assertCallOk(personalWorkspaceCall, "personal toolkit can call a workspace connection"); + + const personalPaths = yield* executeJson( + personalSession, + visibleConnectionPathsCode(integration), + ); + const personalVisiblePaths = personalPaths.paths as string[]; + expect(personalVisiblePaths, "personal toolkit includes its selected workspace tool").toContain( + `${integration}.org.${workspaceConnections[0]}.ping.getPing`, + ); + expect( + personalVisiblePaths, + "personal toolkit excludes unselected workspace tools from the same integration", + ).not.toContain(`${integration}.org.${workspaceConnections[1]}.ping.getPing`); + + const personalOwnCall = yield* executeJson( + personalSession, + callPingCode({ + integration, + owner: "user", + connection: personalConnection, + id: "personal-own-call", + }), + ); + assertCallOk(personalOwnCall, "personal toolkit can call a personal connection"); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const listed = yield* client.toolkits.list(); + yield* Effect.forEach( + listed.toolkits.filter((toolkit) => + [workspaceToolkitName, personalToolkitName].includes(toolkit.name), + ), + (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }), + { discard: true }, + ); + yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe( + Effect.ignore, + ); + }).pipe(Effect.ignore), + ), + ); + }), + ), +); + +scenario( + "Toolkits · an open MCP session follows toolkit connection add and remove changes", + { timeout: 240_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const upstream = yield* servePingApi; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + + const integration = unique("toolkit_live"); + const toolkitName = unique("live-session-kit"); + const connection = "main"; + + yield* Effect.gen(function* () { + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: pingSpec(upstream.url) }, + slug: IntegrationSlug.make(integration), + baseUrl: upstream.url, + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { "x-e2e-token": [{ type: "variable", name: "token" }] }, + }, + ], + }, + }); + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make(connection), + integration: IntegrationSlug.make(integration), + template: AuthTemplateSlug.make("apiKey"), + value: "unused-token", + }, + }); + + const toolkit = yield* client.toolkits.create({ + payload: { owner: "org", name: toolkitName }, + }); + const pattern = connectionPattern(integration, "org", connection); + + const session = mcp.session(identity, { url: toolkitUrl(target.baseUrl, toolkit.slug) }); + + const initiallyMissing = yield* executeJson( + session, + callPingCode({ integration, owner: "org", connection, id: "before-add" }), + ); + assertCallMissing(initiallyMissing, "empty toolkit does not expose the connection"); + + const toolkitConnection = yield* client.toolkits.createConnection({ + params: { toolkitId: toolkit.id }, + payload: { pattern }, + }); + const afterFirstAdd = yield* executeJson( + session, + callPingCode({ integration, owner: "org", connection, id: "after-first-add" }), + ); + assertCallOk(afterFirstAdd, "same MCP session sees a newly added connection"); + + yield* client.toolkits.removeConnection({ + params: { toolkitId: toolkit.id, connectionId: toolkitConnection.id }, + }); + const removed = yield* executeJson( + session, + callPingCode({ integration, owner: "org", connection, id: "after-remove" }), + ); + assertCallMissing(removed, "same MCP session loses the removed connection"); + + yield* client.toolkits.createConnection({ + params: { toolkitId: toolkit.id }, + payload: { pattern }, + }); + const afterAdd = yield* executeJson( + session, + callPingCode({ integration, owner: "org", connection, id: "after-add" }), + ); + assertCallOk(afterAdd, "same MCP session sees the re-added connection"); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const listed = yield* client.toolkits.list(); + yield* Effect.forEach( + listed.toolkits.filter((toolkit) => toolkit.name === toolkitName), + (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }), + { discard: true }, + ); + yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe( + Effect.ignore, + ); + }).pipe(Effect.ignore), + ), + ); + }), + ), +); + +scenario( + "Toolkits · approve and block policies change destructive core-tool side effects", + { timeout: 240_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + + const approveToolkitName = unique("approve-core-tools-kit"); + const blockToolkitName = unique("block-core-tools-kit"); + const approvedPattern = `${unique("toolkit-approved-policy")}.*`; + const blockedPattern = `${unique("toolkit-blocked-policy")}.*`; + + yield* Effect.gen(function* () { + const approveToolkit = yield* client.toolkits.create({ + payload: { owner: "org", name: approveToolkitName }, + }); + yield* client.toolkits.createConnection({ + params: { toolkitId: approveToolkit.id }, + payload: { pattern: "executor.coreTools.*" }, + }); + yield* client.toolkits.createPolicy({ + params: { toolkitId: approveToolkit.id }, + payload: { pattern: "executor.coreTools.policies.create", action: "approve" }, + }); + + const approveSession = mcp.session(identity, { + url: toolkitUrl(target.baseUrl, approveToolkit.slug), + }); + const approved = yield* approveSession.call("execute", { + code: createPolicyCode({ pattern: approvedPattern, action: "block" }), + }); + expect(approved.text, "approved policy create does not pause for approval").not.toContain( + "Execution paused", + ); + expect( + approved.text, + "approved policy create does not return an execution id", + ).not.toContain("executionId:"); + expect(approved.ok, `approved policy create succeeded: ${approved.text}`).toBe(true); + const approvedPayload = JSON.parse(approved.text) as Record; + expect(approvedPayload.ok, `approved policy create result: ${approved.text}`).toBe(true); + const afterApproved = yield* client.policies.list(); + expect( + afterApproved.map((policy) => `${policy.owner} ${policy.pattern} ${policy.action}`), + "approved toolkit policy created the user policy", + ).toContain(`user ${approvedPattern} block`); + + const blockToolkit = yield* client.toolkits.create({ + payload: { owner: "org", name: blockToolkitName }, + }); + yield* client.toolkits.createConnection({ + params: { toolkitId: blockToolkit.id }, + payload: { pattern: "executor.coreTools.*" }, + }); + yield* client.toolkits.createPolicy({ + params: { toolkitId: blockToolkit.id }, + payload: { pattern: "executor.coreTools.policies.create", action: "block" }, + }); + + const blockSession = mcp.session(identity, { + url: toolkitUrl(target.baseUrl, blockToolkit.slug), + }); + const blocked = yield* blockSession.call("execute", { + code: createPolicyCode({ pattern: blockedPattern, action: "block" }), + }); + expect(blocked.text, "blocked policy create does not pause for approval").not.toContain( + "Execution paused", + ); + const afterBlocked = yield* client.policies.list(); + expect( + afterBlocked.map((policy) => `${policy.owner} ${policy.pattern} ${policy.action}`), + "blocked toolkit policy prevents the user policy side effect", + ).not.toContain(`user ${blockedPattern} block`); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const listed = yield* client.toolkits.list(); + yield* Effect.forEach( + listed.toolkits.filter((toolkit) => + [approveToolkitName, blockToolkitName].includes(toolkit.name), + ), + (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }), + { discard: true }, + ); + const policies = yield* client.policies.list(); + yield* Effect.forEach( + policies.filter((policy) => + [approvedPattern, blockedPattern].includes(policy.pattern), + ), + (policy) => + client.policies.remove({ + params: { policyId: policy.id }, + payload: { owner: policy.owner }, + }), + { discard: true }, + ); + }).pipe(Effect.ignore), + ), + ); + }), +); diff --git a/e2e/selfhost/toolkits-ui.test.ts b/e2e/selfhost/toolkits-ui.test.ts index 37c544453..8af9d2a21 100644 --- a/e2e/selfhost/toolkits-ui.test.ts +++ b/e2e/selfhost/toolkits-ui.test.ts @@ -155,6 +155,21 @@ scenario( await dialog.waitFor({ state: "hidden" }); }); + await step("Remove the connection from the toolkit tools list", async () => { + await page.getByRole("button", { name: /^Remove connection / }).first().click(); + await page.getByText("No connections added").waitFor(); + await page.getByRole("button", { name: "Add connection to toolkit" }).click(); + const dialog = page.getByRole("dialog", { name: "Add connection" }); + await dialog.waitFor(); + await dialog.getByLabel("Search connections and tools").fill("policies.list"); + await dialog.getByRole("button", { name: /^Add connection / }).first().click(); + await dialog.waitFor({ state: "hidden" }); + const toolkitTools = page.getByRole("region", { name: "Toolkit tools" }); + await toolkitTools.getByLabel("Filter tools").fill("policies.list"); + await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().waitFor(); + await toolkitTools.getByLabel("Filter tools").clear(); + }); + await step("Block one tool from the toolkit tools list", async () => { const toolkitTools = page.getByRole("region", { name: "Toolkit tools" }); await toolkitTools.getByLabel("Filter tools").fill("policies.list"); diff --git a/e2e/setup/cloudflare.boot.ts b/e2e/setup/cloudflare.boot.ts index 77b9390f4..f32b0e329 100644 --- a/e2e/setup/cloudflare.boot.ts +++ b/e2e/setup/cloudflare.boot.ts @@ -12,6 +12,9 @@ import { promisify } from "node:util"; import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot"; export const cloudflareDir = fileURLToPath(new URL("../../apps/host-cloudflare/", import.meta.url)); +const wranglerBin = fileURLToPath( + new URL("../../apps/host-cloudflare/node_modules/.bin/wrangler", import.meta.url), +); export interface CloudflareBootOptions { readonly port: number; @@ -28,12 +31,13 @@ export const bootCloudflare = async (options: CloudflareBootOptions): Promise { const headers = new Headers(request.headers); headers.set(INTERNAL_ACCOUNT_ID_HEADER, token.accountId); headers.set(INTERNAL_ORGANIZATION_ID_HEADER, token.organizationId ?? ""); + headers.set(INTERNAL_RESOURCE_KEY_HEADER, mcpResourceKey(resource)); return new Request(request, { headers }); }; diff --git a/packages/hosts/cloudflare/src/mcp/seams.ts b/packages/hosts/cloudflare/src/mcp/seams.ts index 330f78200..77f47b9a2 100644 --- a/packages/hosts/cloudflare/src/mcp/seams.ts +++ b/packages/hosts/cloudflare/src/mcp/seams.ts @@ -1,3 +1,5 @@ +import type { McpResource } from "@executor-js/host-mcp"; + import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; // --------------------------------------------------------------------------- @@ -10,6 +12,7 @@ import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-header export interface McpSessionInit { readonly organizationId: string; readonly userId: string; + readonly resource: McpResource; readonly elicitationMode: McpElicitationMode; /** Public origin of the create request (`https://host`), so the DO derives a * web base URL zero-config when the host configures no static one. */ diff --git a/packages/hosts/cloudflare/src/mcp/session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/session-durable-object.ts index e23a99c7b..f6d38e5ef 100644 --- a/packages/hosts/cloudflare/src/mcp/session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/session-durable-object.ts @@ -29,9 +29,12 @@ import { makeMcpWorkerTransport, type McpWorkerTransport } from "./worker-transp import { INTERNAL_ACCOUNT_ID_HEADER, INTERNAL_ORGANIZATION_ID_HEADER, + INTERNAL_RESOURCE_KEY_HEADER, type IncomingPropagationHeaders, } from "./do-headers"; +import { mcpResourceKey } from "@executor-js/host-mcp"; import type { McpSessionInit } from "./seams"; +import type { McpResource } from "@executor-js/host-mcp"; // --------------------------------------------------------------------------- // Types @@ -122,6 +125,7 @@ export interface SessionDbHandle { export interface SessionMeta { readonly organizationId: string; readonly organizationName: string; + readonly resource: McpResource; /** The org's URL slug, when the host's `resolveSessionMeta` carried one. * Pins browser-handoff URLs to the right org's console. */ readonly organizationSlug?: string; @@ -541,8 +545,11 @@ export abstract class McpSessionDOBase< const accountId = request.headers.get(INTERNAL_ACCOUNT_ID_HEADER); const organizationId = request.headers.get(INTERNAL_ORGANIZATION_ID_HEADER); + const resourceKey = request.headers.get(INTERNAL_RESOURCE_KEY_HEADER); const matches = - accountId === sessionMeta.userId && organizationId === sessionMeta.organizationId; + accountId === sessionMeta.userId && + organizationId === sessionMeta.organizationId && + resourceKey === mcpResourceKey(sessionMeta.resource); yield* Effect.annotateCurrentSpan({ "mcp.session.owner_match": matches, @@ -559,9 +566,11 @@ export abstract class McpSessionDOBase< // Carry the create request's origin onto the persisted meta (the host's // resolveSessionMeta is identity-only and doesn't see it), so a cold // isolate rebuilds the runtime with the same web base URL. - const sessionMeta: SessionMeta = token.webOrigin - ? { ...resolved, webOrigin: token.webOrigin } - : resolved; + const sessionMeta: SessionMeta = { + ...resolved, + resource: token.resource, + ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}), + }; yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe( Effect.withSpan("mcp.session.save_meta"), ); diff --git a/packages/hosts/cloudflare/src/mcp/session-store.test.ts b/packages/hosts/cloudflare/src/mcp/session-store.test.ts index cf717e8ed..8c253a242 100644 --- a/packages/hosts/cloudflare/src/mcp/session-store.test.ts +++ b/packages/hosts/cloudflare/src/mcp/session-store.test.ts @@ -13,13 +13,20 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit } from "effect"; -import { McpSessionStore, type McpDispatchResult, type Principal } from "@executor-js/host-mcp"; +import { + McpSessionStore, + defaultMcpResource, + type McpDispatchResult, + type Principal, +} from "@executor-js/host-mcp"; import { DO_RELOCATION_MAX_RETRIES, makeDurableObjectMcpSessionStore, + type McpSessionInit, type McpSessionDOStub, } from "./session-store"; +import { INTERNAL_RESOURCE_KEY_HEADER } from "./do-headers"; const RELOCATION_ERROR = "cannot access storage because object has moved to a different machine"; @@ -55,6 +62,7 @@ const dispatchCreate = (stub: McpSessionDOStub): Effect.Effect): string => Exit.isFailure(exit) ? Cause.pretty(exit.cause) : ""; describe("makeDurableObjectMcpSessionStore — DO-relocation retry", () => { + it.live("passes the requested MCP resource into session init", () => + Effect.gen(function* () { + let initMeta: McpSessionInit | undefined; + const stub: McpSessionDOStub = { + init: (meta) => { + initMeta = meta; + return Promise.resolve(); + }, + handleRequest: () => Promise.resolve(okResponse()), + clearSession: () => Promise.resolve(), + }; + + const result = yield* Effect.gen(function* () { + const store = yield* McpSessionStore; + return yield* store.dispatch({ + request: initializeRequest(), + principal: TEST_PRINCIPAL, + resource: { kind: "toolkit", slug: "deploy" }, + sessionId: null, + method: "POST", + }); + }).pipe( + Effect.provide( + makeDurableObjectMcpSessionStore({ newStub: () => stub, getStub: () => stub }), + ), + ); + + expect(result).toBeInstanceOf(Response); + expect(initMeta?.resource, "the DO session is keyed to the requested resource").toEqual({ + kind: "toolkit", + slug: "deploy", + }); + }), + ); + + it.live("stamps the requested MCP resource on forwarded session requests", () => + Effect.gen(function* () { + let forwardedResourceKey: string | null = null; + const stub: McpSessionDOStub = { + init: () => Promise.resolve(), + handleRequest: (request) => { + forwardedResourceKey = request.headers.get(INTERNAL_RESOURCE_KEY_HEADER); + return Promise.resolve(okResponse()); + }, + clearSession: () => Promise.resolve(), + }; + + const result = yield* Effect.gen(function* () { + const store = yield* McpSessionStore; + return yield* store.dispatch({ + request: initializeRequest(), + principal: TEST_PRINCIPAL, + resource: { kind: "toolkit", slug: "deploy" }, + sessionId: "existing-session", + method: "POST", + }); + }).pipe( + Effect.provide( + makeDurableObjectMcpSessionStore({ newStub: () => stub, getStub: () => stub }), + ), + ); + + expect(result).toBeInstanceOf(Response); + expect( + forwardedResourceKey, + "the DO can reject a session id reused on another resource", + ).toBe("toolkit:deploy"); + }), + ); + it.live("retries mcp.do.init past a relocation, then returns the DO response", () => Effect.gen(function* () { let initCalls = 0; diff --git a/packages/hosts/cloudflare/src/mcp/session-store.ts b/packages/hosts/cloudflare/src/mcp/session-store.ts index ff9fdf444..85faedd9c 100644 --- a/packages/hosts/cloudflare/src/mcp/session-store.ts +++ b/packages/hosts/cloudflare/src/mcp/session-store.ts @@ -141,12 +141,13 @@ const forwardToExistingSession = ( sessionId: string, peek: boolean, token: VerifiedTokenHeaders, + resource: McpDispatchInput["resource"], ): Effect.Effect => Effect.gen(function* () { const stub = config.getStub(sessionId); const propagation = yield* currentPropagationHeaders(request); const propagated = withPropagationHeaders( - withVerifiedIdentityHeaders(request, token), + withVerifiedIdentityHeaders(request, token, resource), propagation, ); const raw = yield* Effect.promise(() => stub.handleRequest(propagated)).pipe( @@ -168,6 +169,7 @@ const createSession = ( config: DurableObjectStoreConfig, request: Request, token: VerifiedTokenHeaders, + resource: McpDispatchInput["resource"], ): Effect.Effect => Effect.gen(function* () { const stub = config.newStub(); @@ -177,6 +179,7 @@ const createSession = ( { organizationId: token.organizationId, userId: token.accountId, + resource, elicitationMode: readElicitationMode(request), // The public origin the client reached us at — lets the DO derive a web // base URL with no static config (we read the real URL, not a spoofable @@ -187,7 +190,7 @@ const createSession = ( ), ); const propagated = withPropagationHeaders( - withVerifiedIdentityHeaders(request, token), + withVerifiedIdentityHeaders(request, token, resource), propagation, ); const raw = yield* Effect.promise(() => stub.handleRequest(propagated)).pipe( @@ -236,6 +239,7 @@ export const makeDurableObjectMcpSessionStore = ( dispatch: ({ request, principal, + resource, sessionId, }: McpDispatchInput): Effect.Effect => { const token: VerifiedTokenHeaders = { @@ -243,8 +247,15 @@ export const makeDurableObjectMcpSessionStore = ( organizationId: principal.organizationId, }; return sessionId - ? forwardToExistingSession(config, request, sessionId, request.method !== "GET", token) - : createSession(config, request, token); + ? forwardToExistingSession( + config, + request, + sessionId, + request.method !== "GET", + token, + resource, + ) + : createSession(config, request, token, resource); }, dispose: (sessionId, request) => clearExistingSession(config, sessionId, request), }); diff --git a/packages/hosts/mcp/src/envelope.test.ts b/packages/hosts/mcp/src/envelope.test.ts index 8bc093aaf..495470b2e 100644 --- a/packages/hosts/mcp/src/envelope.test.ts +++ b/packages/hosts/mcp/src/envelope.test.ts @@ -15,6 +15,7 @@ import { HttpRouter, HttpServer } from "effect/unstable/http"; import { authenticated, + forbidden, McpAuthProvider, McpErrorReporter, McpErrorReporterNoop, @@ -65,11 +66,12 @@ const OkStoreLive = Layer.succeed(McpSessionStore)({ const buildHandler = ( store: Layer.Layer, reporter: Layer.Layer, + authProvider: Layer.Layer = AuthProviderLive, ): ((request: Request) => Promise) => { - const Seams = Layer.mergeAll(AuthProviderLive, store, reporter); + const Seams = Layer.mergeAll(authProvider, store, reporter); const RouteLive = McpServingRoutes.pipe( HttpRouter.provideRequest(Seams), - Layer.provide(AuthProviderLive), + Layer.provide(authProvider), ); return HttpRouter.toWebHandler(RouteLive.pipe(Layer.provideMerge(HttpServer.layerServices))) .handler; @@ -133,6 +135,39 @@ describe("McpServingRoutes envelope", () => { expect(captures).toHaveLength(1); expect(captures[0]).toContain("induced defect"); }); + + it("does not dispose a session id on an auth-level Forbidden outcome", async () => { + const disposed = await Effect.runPromise(Ref.make>([])); + const ForbiddenAuthProviderLive = Layer.succeed(McpAuthProvider)({ + discoveryRoutes: [], + resourceMetadataUrl: (request) => `${new URL(request.url).origin}${DISCOVERY_PATH}`, + authenticate: () => Effect.succeed(forbidden("No organization in session", -32001)), + }); + const RecordingStoreLive = Layer.succeed(McpSessionStore)({ + dispatch: (): Effect.Effect => Effect.die("dispatch should not run"), + dispose: (sessionId) => Ref.update(disposed, (ids) => [...ids, sessionId]), + }); + + const handler = buildHandler( + RecordingStoreLive, + McpErrorReporterNoop, + ForbiddenAuthProviderLive, + ); + const response = await handler( + new Request("https://host.test/mcp/toolkits/deploy", { + method: "POST", + headers: { + authorization: "Bearer x", + "mcp-session-id": "leaked-session", + "content-type": "application/json", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }), + ); + + expect(response.status).toBe(403); + expect(await Effect.runPromise(Ref.get(disposed))).toEqual([]); + }); }); it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => { diff --git a/packages/hosts/mcp/src/envelope.ts b/packages/hosts/mcp/src/envelope.ts index 7bb4ca57a..01dc05cd0 100644 --- a/packages/hosts/mcp/src/envelope.ts +++ b/packages/hosts/mcp/src/envelope.ts @@ -198,17 +198,11 @@ const mcpDispatch = (resource: McpResource) => const sessionId = request.headers.get("mcp-session-id"); // Authenticate (and, for session-aware providers, authorize) on EVERY - // request. On a non-Authenticated outcome: - // - Forbidden -> dispose the live session first (cloud tears down a DO - // whose org access was revoked), then render the 403. The - // inbound request is forwarded so the store can propagate - // the request's W3C trace context onto the teardown RPC. - // - other -> render directly. + // request. Non-authenticated outcomes render directly. Session teardown is + // only safe after the store can validate the authenticated principal and MCP + // resource; an auth-level Forbidden may not carry either. const outcome = yield* auth.authenticate(request); if (!Predicate.isTagged(outcome, "Authenticated")) { - if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { - yield* store.dispose(sessionId, request); - } return HttpServerResponse.raw(renderAuthError(auth, request, outcome)); } const principal = outcome.principal; diff --git a/packages/plugins/toolkits/src/page.tsx b/packages/plugins/toolkits/src/page.tsx index 65bdc8a8d..ae5acd4ac 100644 --- a/packages/plugins/toolkits/src/page.tsx +++ b/packages/plugins/toolkits/src/page.tsx @@ -102,6 +102,7 @@ const createToolkitPolicy = ToolkitsClient.mutation("toolkits", "createPolicy"); const updateToolkitPolicy = ToolkitsClient.mutation("toolkits", "updatePolicy"); const removeToolkitPolicy = ToolkitsClient.mutation("toolkits", "removePolicy"); const createToolkitConnection = ToolkitsClient.mutation("toolkits", "createConnection"); +const removeToolkitConnection = ToolkitsClient.mutation("toolkits", "removeConnection"); type ToolRow = { readonly address: ToolAddress; @@ -178,6 +179,14 @@ const compareToolkitRows = (a: ToolkitResponse, b: ToolkitResponse): number => { return a.name.localeCompare(b.name); }; +const toolkitByRouteSlug = ( + toolkits: readonly ToolkitResponse[], + slug: string, +): ToolkitResponse | null => { + const matches = toolkits.filter((toolkit) => toolkit.slug === slug); + return matches.find((toolkit) => toolkit.owner === "org") ?? matches[0] ?? null; +}; + const toolkitCardStyle = { minHeight: "9rem" }; const toolkitShelfStyle = { minHeight: "28.5rem" }; const toolkitGridContainerStyle = { maxWidth: "80rem" }; @@ -297,7 +306,7 @@ const compareTools = (a: ToolRow, b: ToolRow): number => String(a.name).localeCompare(String(b.name)) || toolMatchId(a).localeCompare(toolMatchId(b)); const connectionTitle = (group: ToolkitConnectionGroup): string => - `${group.integration} / ${group.connection}`; + `${ownerLabel(group.owner)} ${group.integration} / ${group.connection}`; const connectionDisplayTitle = (group: ToolkitConnectionGroup, meta: IntegrationMeta): string => group.connection === "built-in" || group.connection === "default" ? meta.name : group.connection; @@ -330,6 +339,47 @@ const integrationMetaFor = ( }; }; +type ConfiguredConnectionView = { + readonly id: string; + readonly title: string; + readonly subtitle: string; + readonly pattern: string; + readonly sourceId: string; + readonly icon?: string | null; + readonly url?: string; +}; + +const configuredConnectionViews = ( + connections: readonly ToolkitConnectionResponse[], + connectionGroups: readonly ToolkitConnectionGroup[], + integrations: readonly Integration[], + integrationPlugins: readonly IntegrationPlugin[], +): readonly ConfiguredConnectionView[] => + connections.map((connection) => { + const group = connectionGroups.find((candidate) => + candidate.patterns.includes(connection.pattern), + ); + if (!group) { + return { + id: connection.id, + title: connection.pattern, + subtitle: "Configured pattern", + pattern: connection.pattern, + sourceId: connection.pattern.split(".")[0] ?? "toolkit", + }; + } + const meta = integrationMetaFor(group, integrations, integrationPlugins); + return { + id: connection.id, + title: connectionDisplayTitle(group, meta), + subtitle: `${ownerLabel(group.owner)} · ${connectionDisplaySubtitle(group, meta)}`, + pattern: connection.pattern, + sourceId: meta.sourceId, + icon: meta.icon, + ...(meta.url ? { url: meta.url } : {}), + }; + }); + const legacyConnectionPolicyIds = ( policies: readonly ToolkitPolicyResponse[], connectionGroups: readonly ToolkitConnectionGroup[], @@ -589,11 +639,66 @@ function ToolkitContentsEmpty(props: { onAddConnection: () => void }) { ); } +function ToolkitConfiguredConnections(props: { + connections: readonly ConfiguredConnectionView[]; + onRemoveConnection: (connectionId: string) => void; +}) { + if (props.connections.length === 0) return null; + return ( +
+
+ + Connections + + + {props.connections.length} + +
+
+ {props.connections.map((connection) => ( +
+ + + +
+
{connection.title}
+
+ {connection.subtitle} +
+
+ +
+ ))} +
+
+ ); +} + function ToolkitToolsPanel(props: { tools: readonly ToolSummary[]; + configuredConnections: readonly ConfiguredConnectionView[]; selectedToolId: string | null; policies: readonly ToolkitPolicyResponse[]; onAddConnection: () => void; + onRemoveConnection: (connectionId: string) => void; onSelectTool: (toolId: string) => void; onSetPolicy: (pattern: string, action: ToolPolicyAction) => void; onClearPolicy: (pattern: string) => void; @@ -627,6 +732,10 @@ function ToolkitToolsPanel(props: { + {props.tools.length === 0 ? ( ) : ( @@ -874,6 +983,7 @@ function ToolkitWorkspace(props: { onBack: () => void; onRemoveToolkit: () => void; onAddConnection: (pattern: string) => Promise | void; + onRemoveConnection: (connectionId: string) => Promise | void; onSetPolicy: (pattern: string, action: ToolPolicyAction) => Promise | void; onClearPolicy: (pattern: string) => Promise | void; }) { @@ -884,6 +994,16 @@ function ToolkitWorkspace(props: { [props.toolkit, props.tools], ); const connectionGroups = useMemo(() => buildConnectionGroups(visibleTools), [visibleTools]); + const configuredConnections = useMemo( + () => + configuredConnectionViews( + props.connections, + connectionGroups, + props.integrations, + props.integrationPlugins, + ), + [connectionGroups, props.connections, props.integrationPlugins, props.integrations], + ); const legacyPolicyIds = useMemo( () => legacyConnectionPolicyIds(props.policies, connectionGroups, props.connections), [connectionGroups, props.connections, props.policies], @@ -920,6 +1040,7 @@ function ToolkitWorkspace(props: { policy: resolveToolkitPolicy(id, accessPolicies, tool.requiresApproval), owner: toolOwner(tool), connection: toolConnectionName(tool), + integration: String(tool.integration), }; }), [accessPolicies, configuredTools], @@ -953,9 +1074,11 @@ function ToolkitWorkspace(props: {
setAddOpen(true)} + onRemoveConnection={(connectionId) => void props.onRemoveConnection(connectionId)} onSelectTool={setSelectedToolId} onSetPolicy={(pattern, action) => void props.onSetPolicy(pattern, action)} onClearPolicy={(pattern) => void props.onClearPolicy(pattern)} @@ -1024,6 +1147,7 @@ function ToolkitDetailView(props: { const doUpdatePolicy = useAtomSet(updateToolkitPolicy, { mode: "promiseExit" }); const doRemovePolicy = useAtomSet(removeToolkitPolicy, { mode: "promiseExit" }); const doCreateConnection = useAtomSet(createToolkitConnection, { mode: "promiseExit" }); + const doRemoveConnection = useAtomSet(removeToolkitConnection, { mode: "promiseExit" }); const policyRows = AsyncResult.isSuccess(policies) ? policies.value.policies : []; const connectionRows = AsyncResult.isSuccess(connections) ? connections.value.connections : []; @@ -1052,6 +1176,13 @@ function ToolkitDetailView(props: { }); }; + const removeConnectionHandler = async (connectionId: string) => { + await doRemoveConnection({ + params: { toolkitId: props.toolkit.id, connectionId }, + reactivityKeys: toolkitWriteKeys, + }); + }; + const clearPolicyHandler = async (pattern: string) => { const existing = policyRows.find((policy) => policy.pattern === pattern); if (!existing) return; @@ -1080,6 +1211,7 @@ function ToolkitDetailView(props: { onBack={props.onBack} onRemoveToolkit={() => props.onRemoveToolkit(props.toolkit)} onAddConnection={addConnectionHandler} + onRemoveConnection={removeConnectionHandler} onSetPolicy={setPolicyHandler} onClearPolicy={clearPolicyHandler} /> @@ -1101,7 +1233,7 @@ export function ToolkitsPage(props: PluginPageProps) { const selectedToolkit = selectedToolkitSlug === null ? null - : (toolkitRows.find((toolkit) => toolkit.slug === selectedToolkitSlug) ?? null); + : toolkitByRouteSlug(toolkitRows, selectedToolkitSlug); const toolRows = AsyncResult.isSuccess(tools) ? (tools.value as readonly ToolRow[]) : []; const integrationRows = AsyncResult.isSuccess(integrations) diff --git a/packages/plugins/toolkits/src/server.test.ts b/packages/plugins/toolkits/src/server.test.ts index d3b45c0b4..53d0d53cb 100644 --- a/packages/plugins/toolkits/src/server.test.ts +++ b/packages/plugins/toolkits/src/server.test.ts @@ -131,4 +131,38 @@ describe("toolkitsPlugin", () => { expect(personalToolkitTool.action).toBe("approve"); }), ); + + it.effect("treats a persisted connection-root approve as an access policy", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [toolkitsPlugin()] as const, + }); + + const toolkit = yield* executor.toolkits.create({ + owner: "org", + name: "Core Tools Kit", + }); + yield* executor.toolkits.createConnection(toolkit.id, { + pattern: "executor.coreTools.*", + }); + yield* executor.toolkits.createPolicy(toolkit.id, { + pattern: "executor.coreTools.*", + action: "approve", + }); + + const result = yield* executor.toolkits.resolvePolicyForSlug( + toolkit.slug, + "executor.coreTools.connections.remove", + true, + ); + expect(result.action).toBe("approve"); + expect(result.source).toBe("user"); + + const rules = yield* executor.toolkits.policyRulesForSlug(toolkit.slug); + expect( + rules.map((rule) => `${rule.pattern} ${rule.action}`), + "policy listing agrees with toolkit enforcement", + ).toContain("executor.coreTools.* approve"); + }), + ); }); diff --git a/packages/plugins/toolkits/src/server.ts b/packages/plugins/toolkits/src/server.ts index 69357e54f..f8bbecb70 100644 --- a/packages/plugins/toolkits/src/server.ts +++ b/packages/plugins/toolkits/src/server.ts @@ -156,18 +156,16 @@ const resolveToolkitPolicy = ( policies: readonly ToolkitPolicyRecord[], defaultRequiresApproval?: boolean, ): EffectivePolicy => { - const legacyConnectionPolicyIds = new Set( - policies.filter(isLegacyConnectionPolicy).map((policy) => policy.id), - ); + const legacyPolicyIds = legacyConnectionPolicyIds(policies, connections); const connected = connections.some((connection) => matchPattern(connection.pattern, toolId)) || policies.some( - (policy) => legacyConnectionPolicyIds.has(policy.id) && matchPattern(policy.pattern, toolId), + (policy) => legacyPolicyIds.has(policy.id) && matchPattern(policy.pattern, toolId), ); if (!connected) return blockedPolicy(); for (const policy of [...policies].sort(comparePositioned)) { - if (legacyConnectionPolicyIds.has(policy.id)) continue; + if (legacyPolicyIds.has(policy.id)) continue; if (!matchPattern(policy.pattern, toolId)) continue; return { action: policy.action, @@ -179,6 +177,20 @@ const resolveToolkitPolicy = ( return pluginDefaultPolicy(defaultRequiresApproval); }; +const legacyConnectionPolicyIds = ( + policies: readonly ToolkitPolicyRecord[], + connections: readonly ToolkitConnectionRecord[], +): ReadonlySet => { + const connectionPatterns = new Set(connections.map((connection) => connection.pattern)); + return new Set( + policies + .filter( + (policy) => isLegacyConnectionPolicy(policy) && !connectionPatterns.has(policy.pattern), + ) + .map((policy) => policy.id), + ); +}; + const isPersonalDynamicToolId = (toolId: string): boolean => toolId.split(".")[1] === "user"; const toolkitToResponse = (entry: { readonly owner: Owner; readonly data: ToolkitRecord }) => ({ @@ -237,7 +249,9 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { const getEntry = (toolkitId: string) => storage.toolkits.get({ key: toolkitId }); const getBySlugEntry = (slug: string) => - storage.toolkits.query({ where: { slug } }).pipe(Effect.map((entries) => entries[0] ?? null)); + storage.toolkits.query({ where: { slug } }).pipe( + Effect.map((entries) => entries.find((entry) => entry.owner === "org") ?? entries[0] ?? null), + ); const requireToolkit = (toolkitId: string) => getEntry(toolkitId).pipe( @@ -465,8 +479,10 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { const toolkit = yield* getBySlugEntry(slug); if (!toolkit) return []; const policies = yield* listPoliciesForRecord(toolkit.data.id); + const connections = yield* listConnectionsForRecord(toolkit.data.id); + const legacyPolicyIds = legacyConnectionPolicyIds(policies, connections); return policies - .filter((policy) => !isLegacyConnectionPolicy(policy)) + .filter((policy) => !legacyPolicyIds.has(policy.id)) .map((policy) => ({ id: policy.id, pattern: policy.pattern, diff --git a/packages/react/src/components/tool-tree.tsx b/packages/react/src/components/tool-tree.tsx index 1acb99380..2005687ab 100644 --- a/packages/react/src/components/tool-tree.tsx +++ b/packages/react/src/components/tool-tree.tsx @@ -41,6 +41,9 @@ export interface ToolSummary { /** Name of the connection (account) that produced this tool. Present only in * the account-grouped view; ignored in the flat tree. */ readonly connection?: string; + /** Integration that produced this tool. Used to disambiguate same-name + * connections from different integrations in grouped views. */ + readonly integration?: string; } // --------------------------------------------------------------------------- @@ -53,9 +56,10 @@ export interface ToolSummary { // --------------------------------------------------------------------------- export type AccountGroup = { - /** Stable key `${owner}:${connection}`. */ + /** Stable key `${owner}:${integration}:${connection}`. */ readonly key: string; readonly owner: Owner; + readonly integration: string; readonly connection: string; /** Section header, e.g. "Personal · axiom-mcp". */ readonly label: string; @@ -69,14 +73,18 @@ export type AccountGroup = { * Pure — unit-testable without React. */ export const buildAccountGroups = (tools: readonly ToolSummary[]): readonly AccountGroup[] => { - const byKey = new Map(); + const byKey = new Map< + string, + { owner: Owner; integration: string; connection: string; tools: ToolSummary[] } + >(); for (const tool of tools) { const owner: Owner = tool.owner ?? "org"; + const integration = tool.integration ?? ""; const connection = tool.connection ?? ""; - const key = `${owner}:${connection}`; + const key = `${owner}:${integration}:${connection}`; let group = byKey.get(key); if (!group) { - group = { owner, connection, tools: [] }; + group = { owner, integration, connection, tools: [] }; byKey.set(key, group); } group.tools.push(tool); @@ -85,14 +93,20 @@ export const buildAccountGroups = (tools: readonly ToolSummary[]): readonly Acco const ownerRank = (owner: Owner): number => (owner === "org" ? 0 : 1); return [...byKey.values()] .sort( - (a, b) => ownerRank(a.owner) - ownerRank(b.owner) || a.connection.localeCompare(b.connection), + (a, b) => + ownerRank(a.owner) - ownerRank(b.owner) || + a.integration.localeCompare(b.integration) || + a.connection.localeCompare(b.connection), ) .map((group) => ({ - key: `${group.owner}:${group.connection}`, + key: `${group.owner}:${group.integration}:${group.connection}`, owner: group.owner, + integration: group.integration, connection: group.connection, label: group.connection - ? `${ownerLabel(group.owner)} · ${group.connection}` + ? `${ownerLabel(group.owner)} · ${ + group.integration ? `${group.integration} / ` : "" + }${group.connection}` : ownerLabel(group.owner), tools: group.tools, })); @@ -408,7 +422,9 @@ export function ToolTree(props: { ) : null} - {group.connection || ownerDisplay.label(group.owner)} + {group.integration && group.connection + ? `${group.integration} / ${group.connection}` + : group.connection || ownerDisplay.label(group.owner)} {group.tools.length} From bfe23bd62c3da42d7a278fe3d6ef7303aca3ca29 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:45:22 -0700 Subject: [PATCH 03/10] Harden scoped toolkit MCP sessions --- .../src/db/data-migrations.test.ts | 150 +++++++- .../host-cloudflare/src/db/data-migrations.ts | 101 +++++- apps/host-cloudflare/src/mcp/auth.ts | 68 +++- apps/host-cloudflare/wrangler.jsonc | 2 +- apps/local/executor.config.ts | 11 +- apps/local/package.json | 1 + apps/local/src/executor.ts | 111 +++--- apps/local/src/main.ts | 37 +- apps/local/src/mcp.ts | 174 ++++++++-- apps/local/src/serve.ts | 68 +++- apps/local/vite.config.ts | 69 ++-- bun.lock | 1 + e2e/local/toolkits-mcp.test.ts | 320 ++++++++++++++++++ e2e/scenarios/toolkits-mcp.test.ts | 32 +- e2e/setup/cloudflare.boot.ts | 2 + e2e/src/surfaces/mcp.ts | 61 ++++ .../sdk/openapi-ownership-migration.test.ts | 129 ++++++- .../src/sdk/openapi-ownership-migration.ts | 10 +- 18 files changed, 1189 insertions(+), 158 deletions(-) create mode 100644 e2e/local/toolkits-mcp.test.ts diff --git a/apps/host-cloudflare/src/db/data-migrations.test.ts b/apps/host-cloudflare/src/db/data-migrations.test.ts index 36722c91b..0799eeb30 100644 --- a/apps/host-cloudflare/src/db/data-migrations.test.ts +++ b/apps/host-cloudflare/src/db/data-migrations.test.ts @@ -32,7 +32,10 @@ const makeFakeD1 = (client: SqliteDataMigrationClient): D1Database => { } as unknown as D1Database; }; -const makeFakeR2 = (): { readonly bucket: R2Bucket; readonly objects: Map } => { +const makeFakeR2 = (): { + readonly bucket: R2Bucket; + readonly objects: Map; +} => { const objects = new Map(); // oxlint-disable-next-line executor/no-double-cast -- test double: only the R2 methods used by the migration are implemented const bucket = { @@ -75,6 +78,23 @@ const insertIntegration = ( ], }); +const insertIntegrationRawConfig = ( + client: SqliteDataMigrationClient, + row: { + readonly rowId: string; + readonly tenant: string; + readonly slug: string; + readonly pluginId: string; + readonly config: string; + }, +) => + client.execute({ + sql: `INSERT INTO integration + (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`, + args: [row.rowId, row.tenant, row.slug, row.pluginId, row.slug, row.slug, row.config, now, now], + }); + const insertOperationStorage = ( client: SqliteDataMigrationClient, row: { @@ -103,6 +123,134 @@ const insertOperationStorage = ( }); describe("runCloudflareDataMigrations", () => { + it.effect("rebuilds legacy connection tables from item_id to item_ids", () => + Effect.gen(function* () { + const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); + const { bucket } = makeFakeR2(); + + yield* Effect.promise(() => db.client.execute("DROP TABLE connection")); + yield* Effect.promise(() => + db.client.execute(` + CREATE TABLE connection ( + integration text NOT NULL, + name text NOT NULL, + template text NOT NULL, + provider text NOT NULL, + item_id text NOT NULL, + identity_label text, + description text, + tools_synced_at integer, + oauth_client text, + oauth_client_owner text, + refresh_item_id text, + expires_at integer, + oauth_scope text, + oauth_token_url text, + provider_state text, + created_at integer NOT NULL, + updated_at integer NOT NULL, + row_id text PRIMARY KEY NOT NULL, + tenant text NOT NULL, + owner text NOT NULL, + subject text NOT NULL + ) + `), + ); + yield* Effect.promise(() => + db.client.execute({ + sql: `INSERT INTO connection + (integration, name, template, provider, item_id, created_at, updated_at, row_id, tenant, owner, subject) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + "legacy", + "main", + "apiKey", + "encrypted", + "legacy-item", + now, + now, + "legacy-connection-row", + "default", + "org", + "", + ], + }), + ); + + const d1 = makeFakeD1(db.client); + expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toContain( + "2026-06-20-google-openapi-ownership", + ); + + const columns = yield* Effect.promise(() => + db.client.execute("PRAGMA table_info('connection')"), + ); + expect(columns.rows.map((row) => row.name)).toContain("item_ids"); + expect(columns.rows.map((row) => row.name)).not.toContain("item_id"); + + yield* Effect.promise(() => + db.client.execute({ + sql: `INSERT INTO connection + (integration, name, template, provider, item_ids, created_at, updated_at, row_id, tenant, owner, subject) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + "probe", + "selected", + "apiKey", + "encrypted", + JSON.stringify({ token: "item" }), + now, + now, + "connection-row", + "default", + "org", + "", + ], + }), + ); + + const rows = yield* Effect.promise(() => + db.client.execute("SELECT name, item_ids FROM connection ORDER BY name"), + ); + expect(rows.rows).toEqual([ + { name: "main", item_ids: JSON.stringify({ token: "legacy-item" }) }, + { name: "selected", item_ids: JSON.stringify({ token: "item" }) }, + ]); + + yield* Effect.promise(() => db.close()); + }), + ); + + it.effect("clears malformed legacy OpenAPI integration config rows", () => + Effect.gen(function* () { + const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); + const { bucket } = makeFakeR2(); + + yield* Effect.promise(() => + insertIntegrationRawConfig(db.client, { + rowId: "malformed-row", + tenant: "org_1", + slug: "broken", + pluginId: "openapi", + config: "", + }), + ); + + const d1 = makeFakeD1(db.client); + expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([ + "2026-06-20-google-openapi-ownership", + ]); + expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]); + + const integrations = yield* Effect.promise(() => + db.client.execute("SELECT slug, plugin_id, config FROM integration"), + ); + expect(integrations.rows).toEqual([{ slug: "broken", plugin_id: "openapi", config: null }]); + + yield* Effect.promise(() => db.close()); + }), + ); + it.effect("moves Google OpenAPI ownership and copies the R2 spec object", () => Effect.gen(function* () { const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); diff --git a/apps/host-cloudflare/src/db/data-migrations.ts b/apps/host-cloudflare/src/db/data-migrations.ts index 45d87a88b..c614382df 100644 --- a/apps/host-cloudflare/src/db/data-migrations.ts +++ b/apps/host-cloudflare/src/db/data-migrations.ts @@ -20,7 +20,7 @@ const queryRows = >( const sql = typeof stmt === "string" ? stmt : stmt.sql; const args = typeof stmt === "string" ? [] : stmt.args; const prepared = session.prepare(sql).bind(...args); - if (firstWord(sql) === "SELECT") { + if (firstWord(sql) === "SELECT" || firstWord(sql) === "PRAGMA") { return prepared.all().then((result) => result.results); } return prepared.run().then(() => []); @@ -38,6 +38,93 @@ export const d1DataMigrationClient = (db: D1Database): SqliteDataMigrationClient }; }; +const tableColumns = async (db: D1Database, table: string): Promise> => { + const session = db.withSession("first-primary"); + const result = await session.prepare(`PRAGMA table_info('${table}')`).all<{ + readonly name?: unknown; + }>(); + return new Set( + result.results + .map((row) => row.name) + .filter((name): name is string => typeof name === "string"), + ); +}; + +const rebuildLegacyConnectionTable = async (db: D1Database): Promise => { + const statements = [ + `DROP TABLE IF EXISTS connection_next`, + `DROP TABLE IF EXISTS connection_legacy_item_id`, + `CREATE TABLE connection_next ( + integration text NOT NULL, + name text NOT NULL, + template text NOT NULL, + provider text NOT NULL, + item_ids json NOT NULL, + identity_label text, + description text, + tools_synced_at integer, + oauth_client text, + oauth_client_owner text, + refresh_item_id text, + expires_at integer, + oauth_scope text, + oauth_token_url text, + provider_state text, + created_at integer NOT NULL, + updated_at integer NOT NULL, + row_id text PRIMARY KEY NOT NULL, + tenant text NOT NULL, + owner text NOT NULL, + subject text NOT NULL + )`, + `INSERT INTO connection_next + (integration, name, template, provider, item_ids, identity_label, + description, tools_synced_at, oauth_client, oauth_client_owner, + refresh_item_id, expires_at, oauth_scope, oauth_token_url, + provider_state, created_at, updated_at, row_id, tenant, owner, subject) + SELECT integration, name, template, provider, + CASE + WHEN item_ids IS NOT NULL AND item_ids <> '{}' THEN item_ids + ELSE json_object('token', item_id) + END, + identity_label, description, tools_synced_at, oauth_client, + oauth_client_owner, refresh_item_id, expires_at, oauth_scope, + oauth_token_url, provider_state, created_at, updated_at, row_id, + tenant, owner, subject + FROM connection`, + `ALTER TABLE connection RENAME TO connection_legacy_item_id`, + `ALTER TABLE connection_next RENAME TO connection`, + `DROP TABLE connection_legacy_item_id`, + ]; + for (const statement of statements) { + await db.prepare(statement).run(); + } +}; + +export const ensureCloudflareD1SchemaCompatibility = async (db: D1Database): Promise => { + const integrationColumns = await tableColumns(db, "integration"); + if (integrationColumns.has("config")) { + await db + .prepare( + `UPDATE integration + SET config = NULL + WHERE config IS NOT NULL + AND NOT json_valid(config)`, + ) + .run(); + } + + const connectionColumns = await tableColumns(db, "connection"); + if (connectionColumns.size === 0) return; + if (!connectionColumns.has("item_ids")) { + await db.prepare(`ALTER TABLE connection ADD COLUMN item_ids json NOT NULL DEFAULT '{}'`).run(); + } + const updatedConnectionColumns = await tableColumns(db, "connection"); + if (updatedConnectionColumns.has("item_id")) { + await rebuildLegacyConnectionTable(db); + } +}; + const r2ObjectName = (tenant: string, pluginId: string, key: string): string => `o:${tenant}/${pluginId}/${key}`; @@ -52,6 +139,7 @@ const copyGoogleOpenApiSpecBlobsToR2 = ( FROM integration WHERE plugin_id = 'openapi' AND config IS NOT NULL + AND json_valid(config) AND json_type(config, '$.googleDiscoveryUrls') = 'array' AND json_extract(config, '$.specHash') IS NOT NULL AND json_extract(config, '$.specHash') <> ''`, @@ -67,7 +155,10 @@ const copyGoogleOpenApiSpecBlobsToR2 = ( } }, catch: (cause) => - new DataMigrationError({ migration: googleOpenApiOwnershipDataMigration.name, cause }), + new DataMigrationError({ + migration: googleOpenApiOwnershipDataMigration.name, + cause, + }), }); const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [ @@ -86,5 +177,9 @@ export const runCloudflareDataMigrations = ( bucket: R2Bucket | undefined, ): Promise => Effect.runPromise( - runSqliteDataMigrations(d1DataMigrationClient(db), cloudflareDataMigrations(bucket)), + Effect.promise(() => ensureCloudflareD1SchemaCompatibility(db)).pipe( + Effect.flatMap(() => + runSqliteDataMigrations(d1DataMigrationClient(db), cloudflareDataMigrations(bucket)), + ), + ), ); diff --git a/apps/host-cloudflare/src/mcp/auth.ts b/apps/host-cloudflare/src/mcp/auth.ts index 8a55033a7..40481f86c 100644 --- a/apps/host-cloudflare/src/mcp/auth.ts +++ b/apps/host-cloudflare/src/mcp/auth.ts @@ -1,10 +1,58 @@ import { Effect, Layer } from "effect"; -import { authenticated, McpAuthProvider, unauthorized } from "@executor-js/host-mcp"; +import { + authenticated, + McpAuthProvider, + unauthorized, + type McpDiscoveryRoute, +} from "@executor-js/host-mcp"; import { makeAccessVerifier } from "../auth/cloudflare-access"; import type { CloudflareConfig } from "../config"; +const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; +const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp`; +const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`; + +const toolkitSlugFromPath = (pathname: string): string | undefined => { + const mcpPrefix = "/mcp/toolkits/"; + if (pathname.startsWith(mcpPrefix)) { + const slug = pathname.slice(mcpPrefix.length).split("/", 1)[0]; + return slug ? decodeURIComponent(slug) : undefined; + } + const metadataPrefix = `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/`; + if (pathname.startsWith(metadataPrefix)) { + const slug = pathname.slice(metadataPrefix.length).split("/", 1)[0]; + return slug ? decodeURIComponent(slug) : undefined; + } + return undefined; +}; + +const toolkitPath = (slug: string): string => `/mcp/toolkits/${encodeURIComponent(slug)}`; + +const resourcePathForRequest = (request: Request): string => { + const slug = toolkitSlugFromPath(new URL(request.url).pathname); + return slug ? toolkitPath(slug) : "/mcp"; +}; + +const metadataPathForRequest = (request: Request): string => { + const slug = toolkitSlugFromPath(new URL(request.url).pathname); + return slug + ? `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/${encodeURIComponent(slug)}` + : MCP_PROTECTED_RESOURCE_METADATA_PATH; +}; + +const protectedResourceMetadataResponse = (request: Request): Response => { + const url = new URL(request.url); + return new Response( + JSON.stringify({ + resource: new URL(resourcePathForRequest(request), url.origin).toString(), + authorization_servers: [], + }), + { headers: { "content-type": "application/json" } }, + ); +}; + // --------------------------------------------------------------------------- // Cloudflare Access McpAuthProvider — the `/mcp` gate, identical identity to the // API gate. Cloudflare Access sits in front of the Worker and forwards the @@ -23,10 +71,24 @@ import type { CloudflareConfig } from "../config"; export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer => { const { verify } = makeAccessVerifier(config); + const discoveryRoutes: ReadonlyArray = [ + { + path: PROTECTED_RESOURCE_METADATA_PATH, + handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)), + }, + { + path: MCP_PROTECTED_RESOURCE_METADATA_PATH, + handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)), + }, + { + path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH, + handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)), + }, + ]; return Layer.succeed(McpAuthProvider)({ - discoveryRoutes: [], + discoveryRoutes, resourceMetadataUrl: (request) => - new URL("/.well-known/oauth-protected-resource", new URL(request.url).origin).toString(), + new URL(metadataPathForRequest(request), new URL(request.url).origin).toString(), authenticate: (request) => verify(request).pipe( Effect.map((principal) => (principal ? authenticated(principal) : unauthorized())), diff --git a/apps/host-cloudflare/wrangler.jsonc b/apps/host-cloudflare/wrangler.jsonc index aec20421f..ec80e3435 100644 --- a/apps/host-cloudflare/wrangler.jsonc +++ b/apps/host-cloudflare/wrangler.jsonc @@ -12,7 +12,7 @@ "assets": { "directory": "./dist", "not_found_handling": "single-page-application", - "run_worker_first": ["/api/*", "/mcp", "/mcp/*"], + "run_worker_first": ["/api/*", "/mcp", "/mcp/*", "/.well-known/*"], }, // D1 is the app's SQLite store (the DbProvider seam). `wrangler deploy` // auto-provisions it on first deploy; replace database_id after that, or run diff --git a/apps/local/executor.config.ts b/apps/local/executor.config.ts index 44f4bc393..2839981e2 100644 --- a/apps/local/executor.config.ts +++ b/apps/local/executor.config.ts @@ -8,6 +8,7 @@ import { keychainPlugin } from "@executor-js/plugin-keychain"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; import { onepasswordHttpPlugin } from "@executor-js/plugin-onepassword/api"; import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/server"; +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; // --------------------------------------------------------------------------- // Single source of truth for the local app's plugin list. @@ -18,20 +19,26 @@ import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/serv // First-party and third-party plugins use the same import-and-call flow. // --------------------------------------------------------------------------- +interface LocalPluginDeps { + readonly activeToolkitSlug?: string; +} + export default defineExecutorConfig({ - plugins: () => + plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) => [ openApiHttpPlugin(), googleHttpPlugin(), microsoftHttpPlugin(), mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), graphqlHttpPlugin(), + toolkitsPlugin({ activeToolkitSlug }), keychainPlugin(), fileSecretsPlugin(), onepasswordHttpPlugin(), desktopSettingsPlugin({ webBaseUrl: - process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`, + process.env.EXECUTOR_WEB_BASE_URL ?? + `http://localhost:${process.env.PORT ?? "4788"}`, }), ] as const, }); diff --git a/apps/local/package.json b/apps/local/package.json index 21b9944a7..09a97e9d5 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -39,6 +39,7 @@ "@executor-js/plugin-microsoft": "workspace:*", "@executor-js/plugin-onepassword": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/plugin-toolkits": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", diff --git a/apps/local/src/executor.ts b/apps/local/src/executor.ts index 152b32019..426ec9bf6 100644 --- a/apps/local/src/executor.ts +++ b/apps/local/src/executor.ts @@ -49,7 +49,8 @@ const makeTenantId = (cwd: string): string => { return `${folder}-${hash}`; }; -const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc"); +const resolvePluginConfigPath = (scopeDir: string): string => + join(scopeDir, "executor.jsonc"); // Plugins reach the host through two doors that compose: // - `executor.config.ts`'s static tuple @@ -57,51 +58,66 @@ const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "ex // Static config wins on conflict, matching the Vite plugin. type LocalPlugins = readonly AnyPlugin[]; -const loadLocalPlugins = Effect.gen(function* () { - const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd(); - const staticPlugins = executorConfig.plugins(); - const dynamicPlugins = - (yield* Effect.promise(() => loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }))) ?? - []; +export interface LocalExecutorOptions { + readonly activeToolkitSlug?: string; +} - const staticPackageNames = new Set( - staticPlugins.map((plugin) => plugin.packageName).filter((name): name is string => !!name), - ); - const dedupedDynamic = dynamicPlugins.filter((plugin) => { - if (plugin.packageName && staticPackageNames.has(plugin.packageName)) { - console.warn( - `[executor] plugin "${plugin.packageName}" appears in both ` + - `executor.config.ts and executor.jsonc#plugins. The static ` + - `entry wins; the jsonc entry is ignored.`, - ); - return false; - } - return true; - }); +const loadLocalPlugins = (options: LocalExecutorOptions = {}) => + Effect.gen(function* () { + const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd(); + const staticPlugins = executorConfig.plugins({ + activeToolkitSlug: options.activeToolkitSlug, + }); + const dynamicPlugins = + (yield* Effect.promise(() => + loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }), + )) ?? []; + + const staticPackageNames = new Set( + staticPlugins + .map((plugin) => plugin.packageName) + .filter((name): name is string => !!name), + ); + const dedupedDynamic = dynamicPlugins.filter((plugin) => { + if (plugin.packageName && staticPackageNames.has(plugin.packageName)) { + console.warn( + `[executor] plugin "${plugin.packageName}" appears in both ` + + `executor.config.ts and executor.jsonc#plugins. The static ` + + `entry wins; the jsonc entry is ignored.`, + ); + return false; + } + return true; + }); - return { - cwd, - plugins: [...staticPlugins, ...dedupedDynamic] as LocalPlugins, - }; -}); + return { + cwd, + plugins: [...staticPlugins, ...dedupedDynamic] as LocalPlugins, + }; + }); interface LocalExecutorBundle { readonly executor: Executor; readonly plugins: LocalPlugins; } -class LocalExecutorTag extends Context.Service()( - "@executor-js/local/Executor", -) {} +class LocalExecutorTag extends Context.Service< + LocalExecutorTag, + LocalExecutorBundle +>()("@executor-js/local/Executor") {} export type LocalExecutor = LocalExecutorBundle["executor"]; -class LocalExecutorCreateError extends Data.TaggedError("LocalExecutorCreateError")<{ +class LocalExecutorCreateError extends Data.TaggedError( + "LocalExecutorCreateError", +)<{ readonly message: string; readonly cause: unknown; }> {} -class LocalExecutorDisposeError extends Data.TaggedError("LocalExecutorDisposeError")<{ +class LocalExecutorDisposeError extends Data.TaggedError( + "LocalExecutorDisposeError", +)<{ readonly operation: "createHandle" | "disposeExecutor" | "disposeRuntime"; readonly cause: unknown; }> {} @@ -126,20 +142,23 @@ const handleOrNull = (promise: ReturnType) => Effect.runPromise( Effect.tryPromise({ try: () => promise, - catch: (cause) => new LocalExecutorDisposeError({ operation: "createHandle", cause }), + catch: (cause) => + new LocalExecutorDisposeError({ operation: "createHandle", cause }), }).pipe( Effect.catch(() => - Effect.succeed> | null>(null), + Effect.succeed> | null>( + null, + ), ), ), ); -const createLocalExecutorLayer = () => { +const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { const storage = resolveStorage(); return Layer.effect(LocalExecutorTag)( Effect.gen(function* () { - const { cwd, plugins } = yield* loadLocalPlugins; + const { cwd, plugins } = yield* loadLocalPlugins(options); const tenantId = makeTenantId(cwd); const tables = collectTables(); @@ -180,7 +199,11 @@ const createLocalExecutorLayer = () => { // without touching the data. yield* runSqliteDataMigrations(sqlite.client, localDataMigrations).pipe( Effect.mapError( - (cause) => new LocalExecutorCreateError({ message: CREATE_SQLITE_ERROR_MESSAGE, cause }), + (cause) => + new LocalExecutorCreateError({ + message: CREATE_SQLITE_ERROR_MESSAGE, + cause, + }), ), ); @@ -189,7 +212,8 @@ const createLocalExecutorLayer = () => { // resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL // overrides entirely for deployments where the UI is on a different host. const webBaseUrl = - process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`; + process.env.EXECUTOR_WEB_BASE_URL ?? + `http://localhost:${process.env.PORT ?? "4788"}`; const executor = yield* createExecutor({ tenant: Tenant.make(tenantId), @@ -223,8 +247,10 @@ const createLocalExecutorLayer = () => { ); }; -export const createExecutorHandle = async () => { - const layer = createLocalExecutorLayer(); +export const createExecutorHandle = async ( + options: LocalExecutorOptions = {}, +) => { + const layer = createLocalExecutorLayer(options); const runtime = ManagedRuntime.make(layer); const bundle = await runtime.runPromise(LocalExecutorTag.asEffect()); @@ -249,14 +275,17 @@ const loadSharedHandle = () => { return sharedHandlePromise; }; -export const getExecutor = () => loadSharedHandle().then((handle) => handle.executor); +export const getExecutor = () => + loadSharedHandle().then((handle) => handle.executor); export const getExecutorBundle = () => loadSharedHandle(); export const disposeExecutor = async (): Promise => { const currentHandlePromise = sharedHandlePromise; sharedHandlePromise = null; - const handle = currentHandlePromise ? await handleOrNull(currentHandlePromise) : null; + const handle = currentHandlePromise + ? await handleOrNull(currentHandlePromise) + : null; if (handle) { await ignorePromiseFailure("disposeExecutor", () => handle.dispose()); } diff --git a/apps/local/src/main.ts b/apps/local/src/main.ts index b773d7aad..127056097 100644 --- a/apps/local/src/main.ts +++ b/apps/local/src/main.ts @@ -3,7 +3,7 @@ import { Context, Effect, Layer, ManagedRuntime } from "effect"; import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { makeLocalApiHandler } from "./app"; -import { getExecutorBundle } from "./executor"; +import { createExecutorHandle, getExecutorBundle } from "./executor"; import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; // --------------------------------------------------------------------------- @@ -53,7 +53,9 @@ const closeServerHandlers = async (handlers: ServerHandlers): Promise => { ); }; -export const createServerHandlers = async (token: string): Promise => { +export const createServerHandlers = async ( + token: string, +): Promise => { // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The // boot bearer token is the authoritative `/api` gate (see `identity.ts`). const apiHandler: ServerHandlers["api"] = await makeLocalApiHandler(token); @@ -67,20 +69,39 @@ export const createServerHandlers = async (token: string): Promise { + if (resource.kind === "default") return { config: { engine } }; + const handle = await createExecutorHandle({ + activeToolkitSlug: resource.slug, + }); + const toolkitEngine = createExecutionEngine({ + executor: handle.executor, + codeExecutor: makeQuickJsExecutor(), + }); + return { + config: { engine: toolkitEngine }, + close: handle.dispose, + }; + }, + }); return { api: apiHandler, mcp }; }; -export class ServerHandlersService extends Context.Service()( - "@executor-js/local/ServerHandlersService", -) {} +export class ServerHandlersService extends Context.Service< + ServerHandlersService, + ServerHandlers +>()("@executor-js/local/ServerHandlersService") {} // The handlers are built once per process and memoized. The boot token is // captured on the first call (serve.ts / the vite dev middleware both pass the // SAME token loaded from `auth.json`), so memoization on first-call is correct. -let serverHandlersRuntime: ManagedRuntime.ManagedRuntime | null = - null; +let serverHandlersRuntime: ManagedRuntime.ManagedRuntime< + ServerHandlersService, + never +> | null = null; const getServerHandlersRuntime = ( token: string, diff --git a/apps/local/src/mcp.ts b/apps/local/src/mcp.ts index b26114a5f..5f63284b8 100644 --- a/apps/local/src/mcp.ts +++ b/apps/local/src/mcp.ts @@ -1,9 +1,14 @@ -import { Effect } from "effect"; +import { Effect, type Cause } from "effect"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import { jsonRpcErrorBody } from "@executor-js/host-mcp"; +import { + defaultMcpResource, + jsonRpcErrorBody, + mcpResourceKey, + type McpResource, +} from "@executor-js/host-mcp"; import { createExecutorMcpServer, type ExecutorMcpServerConfig, @@ -15,10 +20,16 @@ import { readElicitationMode, } from "@executor-js/host-mcp/browser-approval"; import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store"; -import { formatPausedExecution, type ResumeResponse } from "@executor-js/execution"; +import { + formatPausedExecution, + type ExecutionEngine, + type ResumeResponse, +} from "@executor-js/execution"; import { startIntegrationsRefresh } from "./integrations"; +type AnyExecutionEngine = ExecutionEngine; + // --------------------------------------------------------------------------- // Streamable HTTP handler // --------------------------------------------------------------------------- @@ -32,6 +43,18 @@ export type McpRequestHandler = { readonly close: () => Promise; }; +export interface LocalMcpServerConfig { + readonly config: ExecutorMcpServerConfig; + readonly close?: () => Promise; +} + +export interface LocalMcpRequestHandlerConfig { + readonly defaultConfig: ExecutorMcpServerConfig; + readonly createConfigForResource?: ( + resource: McpResource, + ) => Promise | LocalMcpServerConfig; +} + // Local serves these error bodies in-process; like the self-host store they are // INNER responses (no CORS) — byte-identical to the prior hand-rolled copy // (`content-type: application/json` only) via the canonical renderer. @@ -44,7 +67,9 @@ const formatBoundaryError = (error: unknown): unknown => { return error; }; -const ignoreClose = (close: (() => Promise) | undefined): Promise => +const ignoreClose = ( + close: (() => Promise) | undefined, +): Promise => close ? Effect.runPromise( Effect.ignore( @@ -56,8 +81,10 @@ const ignoreClose = (close: (() => Promise) | undefined): Promise => ) : Promise.resolve(); -const pausedRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; -const approvalRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; +const pausedRequestPattern = + /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; +const approvalRequestPattern = + /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; const json = (value: unknown, status = 200): Response => new Response(JSON.stringify(value), { @@ -70,57 +97,122 @@ const readResumeResponse = (request: Request): Promise => Effect.tryPromise({ try: () => request.json(), catch: () => null, - }).pipe(Effect.map((raw) => (raw === null ? null : decodeResumeResponse(raw)))), + }).pipe( + Effect.map((raw) => (raw === null ? null : decodeResumeResponse(raw))), + ), ); -const resumeApprovalResult = (executionId: string, response: ResumeResponse) => ({ +const resumeApprovalResult = ( + executionId: string, + response: ResumeResponse, +) => ({ status: "completed", ...formatResumeAcknowledgement(executionId, response), isError: false, }); -export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpRequestHandler => { - const transports = new Map(); +const toolkitPathPattern = /^\/mcp\/toolkits\/([^/?#]+)\/?$/; + +const resourceFromRequest = (request: Request): McpResource | null => { + const pathname = new URL(request.url).pathname; + if (pathname === "/mcp" || pathname === "/mcp/") return defaultMcpResource; + const match = toolkitPathPattern.exec(pathname); + if (!match) return null; + return { kind: "toolkit", slug: decodeURIComponent(match[1]) }; +}; + +const engineFromConfig = ( + config: ExecutorMcpServerConfig, +): AnyExecutionEngine | null => ("engine" in config ? config.engine : null); + +const normalizeHandlerConfig = ( + input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig, +): LocalMcpRequestHandlerConfig => + "defaultConfig" in input ? input : { defaultConfig: input }; + +export const createMcpRequestHandler = ( + input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig, +): McpRequestHandler => { + const handlerConfig = normalizeHandlerConfig(input); + const transports = new Map< + string, + WebStandardStreamableHTTPServerTransport + >(); const servers = new Map(); + const resources = new Map(); + const sessionEngines = new Map(); + const sessionClosers = new Map Promise>(); const approvals = makeInProcessBrowserApprovalStore(); - // Local runs one shared engine across every MCP session (main.ts builds it and - // passes it in), so the paused-execution lookup for browser approval reads it - // directly — there is no per-session engine to track. - const engine = "engine" in config ? config.engine : null; + const defaultEngine = engineFromConfig(handlerConfig.defaultConfig); const pausedDetail = ( + sessionId: string, executionId: string, ): Promise | null> => - engine + (sessionEngines.get(sessionId) ?? defaultEngine) ? Effect.runPromise( - engine.getPausedExecution(executionId).pipe( - Effect.map((paused) => (paused ? formatPausedExecution(paused) : null)), - Effect.orElseSucceed(() => null), - ), + (sessionEngines.get(sessionId) ?? defaultEngine)! + .getPausedExecution(executionId) + .pipe( + Effect.map((paused) => + paused ? formatPausedExecution(paused) : null, + ), + Effect.orElseSucceed(() => null), + ), ) : Promise.resolve(null); - const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => { + const configForResource = async ( + resource: McpResource, + ): Promise => { + if (!handlerConfig.createConfigForResource) + return { config: handlerConfig.defaultConfig }; + return handlerConfig.createConfigForResource(resource); + }; + + const dispose = async ( + id: string, + opts: { transport?: boolean; server?: boolean } = {}, + ) => { const t = transports.get(id); const s = servers.get(id); + const close = sessionClosers.get(id); transports.delete(id); servers.delete(id); + resources.delete(id); + sessionEngines.delete(id); + sessionClosers.delete(id); if (opts.transport) await ignoreClose(t ? () => t.close() : undefined); if (opts.server) await ignoreClose(s ? () => s.close() : undefined); + await ignoreClose(close); }; return { handleRequest: async (request) => { + const resource = resourceFromRequest(request); + if (!resource) return jsonError(404, -32001, "MCP resource not found"); const sessionId = request.headers.get("mcp-session-id"); if (sessionId) { const transport = transports.get(sessionId); if (!transport) return jsonError(404, -32001, "Session not found"); + const sessionResource = resources.get(sessionId); + if ( + !sessionResource || + mcpResourceKey(sessionResource) !== mcpResourceKey(resource) + ) { + return jsonError( + 403, + -32003, + "Session belongs to a different MCP resource", + ); + } return transport.handleRequest(request); } let created: McpServer | undefined; let createdSessionId: string | null = null; + let resourceConfig: LocalMcpServerConfig | null = null; const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, @@ -128,6 +220,13 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq createdSessionId = sid; transports.set(sid, transport); if (created) servers.set(sid, created); + resources.set(sid, resource); + const engine = resourceConfig + ? engineFromConfig(resourceConfig.config) + : null; + if (engine) sessionEngines.set(sid, engine); + if (resourceConfig?.close) + sessionClosers.set(sid, resourceConfig.close); }, onsessionclosed: (sid) => void dispose(sid, { server: true }), }); @@ -140,16 +239,21 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK handler must return JSON-RPC errors from thrown Promise APIs try { const elicitationMode = readElicitationMode(request); + resourceConfig = await configForResource(resource); created = await Effect.runPromise( createExecutorMcpServer({ - ...config, + ...resourceConfig.config, browserApprovalStore: approvals.store, elicitationMode: elicitationMode === "browser" ? { mode: "browser" as const, approvalUrl: (executionId) => - approvalUrlForRequest(request, executionId, createdSessionId), + approvalUrlForRequest( + request, + executionId, + createdSessionId, + ), } : { mode: elicitationMode }, }), @@ -161,6 +265,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq await ignoreClose(() => transport.close()); const server = created; await ignoreClose(server ? () => server.close() : undefined); + await ignoreClose(resourceConfig?.close); } return response; } catch (error) { @@ -169,6 +274,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq await ignoreClose(() => transport.close()); const server = created; await ignoreClose(server ? () => server.close() : undefined); + await ignoreClose(resourceConfig?.close); } return jsonError(500, -32603, "Internal server error"); } @@ -177,9 +283,13 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq handlePausedRequest: async (request) => { const match = pausedRequestPattern.exec(new URL(request.url).pathname); if (!match) return json({ error: "Not found" }, 404); - if (request.method !== "GET") return json({ error: "Method not allowed" }, 405); + if (request.method !== "GET") + return json({ error: "Method not allowed" }, 405); - const paused = await pausedDetail(decodeURIComponent(match[2])); + const paused = await pausedDetail( + decodeURIComponent(match[1]), + decodeURIComponent(match[2]), + ); if (!paused) return json({ error: "Paused execution not found" }, 404); return json({ text: paused.text, structured: paused.structured }); }, @@ -187,11 +297,15 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq handleApprovalRequest: async (request) => { const match = approvalRequestPattern.exec(new URL(request.url).pathname); if (!match) return json({ error: "Not found" }, 404); - if (request.method !== "POST") return json({ error: "Method not allowed" }, 405); + if (request.method !== "POST") + return json({ error: "Method not allowed" }, 405); + const sessionId = decodeURIComponent(match[1]); const executionId = decodeURIComponent(match[2]); // The shared engine must still hold the paused execution — guards stale ids. - if (!(await pausedDetail(executionId))) return json({ error: "MCP session not found" }, 404); + if (!(await pausedDetail(sessionId, executionId))) { + return json({ error: "MCP session not found" }, 404); + } const response = await readResumeResponse(request); if (!response) return json({ error: "Invalid approval response" }, 400); @@ -202,7 +316,9 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq close: async () => { const ids = new Set([...transports.keys(), ...servers.keys()]); - await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true }))); + await Promise.all( + [...ids].map((id) => dispose(id, { transport: true, server: true })), + ); }, }; }; @@ -211,7 +327,9 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq // Stdio transport // --------------------------------------------------------------------------- -export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise => { +export const runMcpStdioServer = async ( + config: ExecutorMcpServerConfig, +): Promise => { startIntegrationsRefresh(); const server = await Effect.runPromise(createExecutorMcpServer(config)); diff --git a/apps/local/src/serve.ts b/apps/local/src/serve.ts index ee754a261..b17d86613 100644 --- a/apps/local/src/serve.ts +++ b/apps/local/src/serve.ts @@ -35,7 +35,10 @@ const htmlResponse = (file: Bun.BunFile): Response => headers: { "content-type": "text/html", "cache-control": "no-store" }, }); -function collectStaticRoutes(dir: string, prefix = ""): Record { +function collectStaticRoutes( + dir: string, + prefix = "", +): Record { const routes: Record = {}; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets try { @@ -50,7 +53,9 @@ function collectStaticRoutes(dir: string, prefix = ""): Record): Record { +function embeddedToStaticRoutes( + embedded: Record, +): Record { const routes: Record = {}; for (const [key, bunfsPath] of Object.entries(embedded)) { const file = Bun.file(bunfsPath); @@ -70,7 +77,9 @@ function embeddedToStaticRoutes(embedded: Record): Record { - const probe = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response() }); + const probe = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => new Response(), + }); const port = probe.port ?? 0; probe.stop(true); return port; @@ -152,7 +165,9 @@ async function startViteChild(): Promise { } if (child.exitCode !== null) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: child process aborted before becoming ready - throw new Error(`vite dev exited with code ${child.exitCode} before becoming ready`); + throw new Error( + `vite dev exited with code ${child.exitCode} before becoming ready`, + ); } await Bun.sleep(150); } @@ -241,7 +256,8 @@ const withCorsHeaders = ( if (!origin || !isAllowedOrigin(origin, allowedHosts)) return response; const headers = new Headers(response.headers); headers.set("access-control-allow-origin", origin); - for (const [key, value] of Object.entries(corsHeaders)) headers.set(key, value); + for (const [key, value] of Object.entries(corsHeaders)) + headers.set(key, value); headers.set( "access-control-allow-headers", req.headers.get("access-control-request-headers") ?? @@ -255,17 +271,23 @@ const withCorsHeaders = ( }); }; -const corsPreflightResponse = (req: Request, allowedHosts: ReadonlySet): Response => +const corsPreflightResponse = ( + req: Request, + allowedHosts: ReadonlySet, +): Response => withCorsHeaders(req, new Response(null, { status: 204 }), allowedHosts); -export async function startServer(opts: StartServerOptions = {}): Promise { +export async function startServer( + opts: StartServerOptions = {}, +): Promise { const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10); const hostname = opts.hostname ?? "127.0.0.1"; // ONE credential, always present: an explicit override or the stable token // from auth.json (minted on first run). Auth is unconditionally on — loopback // is no longer a free pass, since Executor runs arbitrary code that any local // process could otherwise drive. - const authToken = normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken(); + const authToken = + normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken(); const isAuthorized = makeIsAuthorized(authToken); // CORS-only origin allowlist (no Host gate — the bearer is the boundary). const corsAllowedHosts = new Set([ @@ -295,20 +317,26 @@ export async function startServer(opts: StartServerOptions = {}): Promise // Unused when viteChild is non-null; defined so the type checker // can keep `serveIndex` non-nullable. new Response("vite not ready", { status: 503 }); } else if (opts.embeddedWebUI) { staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI); - const indexFile = Bun.file(opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html")); + const indexFile = Bun.file( + opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html"), + ); serveIndex = () => htmlResponse(indexFile); } else { staticRoutes = collectStaticRoutes(clientDir); @@ -336,7 +364,9 @@ export async function startServer(opts: StartServerOptions = {}): Promise { const address = server.httpServer?.address(); const port = - typeof address === "object" && address ? address.port : server.config.server.port; + typeof address === "object" && address + ? address.port + : server.config.server.port; server.config.logger.info( `\n Open with auth: http://127.0.0.1:${port}/?_token=${devToken}\n`, ); @@ -66,14 +77,18 @@ function executorApiPlugin(): Plugin { } server.watcher.on("change", (path) => { - if (path.includes("/apps/local/src/") || path.endsWith("/executor.config.ts")) { + if ( + path.includes("/apps/local/src/") || + path.endsWith("/executor.config.ts") + ) { handlers = null; } }); server.middlewares.use(async (req, res, next) => { const rawUrl = req.url ?? "/"; - const isApi = rawUrl.startsWith("/api/") || rawUrl === "/api"; - const isMcp = rawUrl.startsWith("/mcp"); + const pathOnly = rawUrl.split("?")[0] ?? "/"; + const isApi = pathOnly.startsWith("/api/") || pathOnly === "/api"; + const isMcp = pathOnly === "/mcp" || pathOnly.startsWith("/mcp/"); if (!isApi && !isMcp) return next(); @@ -84,9 +99,9 @@ function executorApiPlugin(): Plugin { // callback. The SPA carries the token from its `?_token`/localStorage // bootstrap, so the UI is unaffected; external MCP clients use the // daemon port. - const pathOnly = rawUrl.split("?")[0] ?? "/"; const authExempt = - pathOnly === "/api/health" || isUnauthenticatedOAuthCallbackPath(pathOnly); + pathOnly === "/api/health" || + isUnauthenticatedOAuthCallbackPath(pathOnly); if (!authExempt) { const presented = req.headers.authorization; const authValue = Array.isArray(presented) ? presented[0] : presented; @@ -94,7 +109,9 @@ function executorApiPlugin(): Plugin { "http://localhost/", authValue ? { headers: { authorization: authValue } } : undefined, ); - if (!makeIsAuthorized(devToken ?? loadOrMintLocalAuthToken())(probe)) { + if ( + !makeIsAuthorized(devToken ?? loadOrMintLocalAuthToken())(probe) + ) { res.statusCode = 401; res.setHeader("www-authenticate", 'Bearer realm="executor"'); res.end("Unauthorized"); @@ -106,13 +123,16 @@ function executorApiPlugin(): Plugin { try { if (!handlers) { const { getServerHandlers } = await import("./src/main"); - handlers = await getServerHandlers(devToken ?? loadOrMintLocalAuthToken()); + handlers = await getServerHandlers( + devToken ?? loadOrMintLocalAuthToken(), + ); } const origin = `http://${req.headers.host ?? "localhost"}`; const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { - if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value); + if (value) + headers.set(key, Array.isArray(value) ? value.join(", ") : value); } const hasBody = req.method !== "GET" && req.method !== "HEAD"; @@ -128,7 +148,9 @@ function executorApiPlugin(): Plugin { if (isMcp) { response = await handlers.mcp.handleRequest(webRequest(rawUrl)); } else if (pathOnly === "/api/health" && req.method === "GET") { - response = new Response("ok", { headers: { "content-type": "text/plain" } }); + response = new Response("ok", { + headers: { "content-type": "text/plain" }, + }); } else if (pathOnly.startsWith("/api/mcp-sessions/")) { const handler = req.method === "GET" @@ -136,14 +158,21 @@ function executorApiPlugin(): Plugin { : handlers.mcp.handleApprovalRequest; response = await handler(webRequest(rawUrl)); } else { - const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(pathOnly); + const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec( + pathOnly, + ); if (awaitMatch && req.method === "GET") { - response = new Response(JSON.stringify(consumeOAuthResult(awaitMatch[1]!)), { - headers: { "content-type": "application/json" }, - }); + response = new Response( + JSON.stringify(consumeOAuthResult(awaitMatch[1]!)), + { + headers: { "content-type": "application/json" }, + }, + ); } else { // Strip /api prefix for Effect handlers. - response = await handlers.api.handler(webRequest(rawUrl.slice("/api".length) || "/")); + response = await handlers.api.handler( + webRequest(rawUrl.slice("/api".length) || "/"), + ); } } @@ -182,7 +211,9 @@ export default defineConfig({ "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION), "import.meta.env.VITE_GITHUB_URL": JSON.stringify(EXECUTOR_GITHUB_URL), "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT), - "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), + "process.env.NODE_ENV": JSON.stringify( + process.env.NODE_ENV ?? "development", + ), }, resolve: { tsconfigPaths: true, diff --git a/bun.lock b/bun.lock index 47ed0b059..45eda1c58 100644 --- a/bun.lock +++ b/bun.lock @@ -283,6 +283,7 @@ "@executor-js/plugin-microsoft": "workspace:*", "@executor-js/plugin-onepassword": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/plugin-toolkits": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", diff --git a/e2e/local/toolkits-mcp.test.ts b/e2e/local/toolkits-mcp.test.ts new file mode 100644 index 000000000..13b364f29 --- /dev/null +++ b/e2e/local/toolkits-mcp.test.ts @@ -0,0 +1,320 @@ +import { randomBytes } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { HttpApiClient } from "effect/unstable/httpapi"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Cli, RunDir } from "../src/services"; +import { withLocalServer } from "./local-server"; + +const api = composePluginApi([openApiHttpPlugin(), toolkitsPlugin()] as const); + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +const pingSpec = (baseUrl: string): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Local Toolkit Ping API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/ping/{id}": { + get: { + operationId: "getPing", + summary: "Return a ping payload", + security: [{ apiKey: [] }], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "A ping payload", + content: { + "application/json": { + schema: { + type: "object", + properties: { + id: { type: "string" }, + path: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + securitySchemes: { + apiKey: { type: "apiKey", in: "header", name: "x-e2e-token" }, + }, + }, + }); + +const closeServer = (server: Server): Promise => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + +const servePingApi = Effect.acquireRelease( + Effect.promise( + () => + new Promise<{ readonly url: string; readonly server: Server }>((resolve) => { + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (request.method === "GET" && url.pathname.startsWith("/ping/")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + id: decodeURIComponent(url.pathname.slice("/ping/".length)), + path: url.pathname, + }), + ); + return; + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + resolve({ url: `http://127.0.0.1:${address.port}`, server }); + }); + }), + ), + ({ server }) => Effect.promise(() => closeServer(server)).pipe(Effect.ignore), +); + +const connectionPattern = (integration: string, name: string): string => + `${integration}.org.${name}.*`; + +const pingToolPath = (integration: string, name: string): string => + `${integration}.org.${name}.ping.getPing`; + +const callPingCode = (input: { + readonly integration: string; + readonly connection: string; + readonly id: string; +}) => ` +const listed = await tools.search({ namespace: ${JSON.stringify(input.integration)}, query: "ping", limit: 100 }); +const expected = ${JSON.stringify(`${input.integration}.org.${input.connection}.`)}; +const path = listed.items.map((item) => item.path).find((candidate) => candidate.startsWith(expected)); +if (!path) return { ok: false, reason: "missing", expected, paths: listed.items.map((item) => item.path).sort() }; +let tool = tools; +for (const segment of path.split(".")) tool = tool?.[segment]; +if (typeof tool !== "function") return { ok: false, reason: "not-callable", path }; +const result = await tool({ id: ${JSON.stringify(input.id)} }); +return JSON.stringify({ ok: result.ok, path, data: result.ok ? result.data : result.error }); +`; + +const listVisiblePathsCode = (integration: string) => ` +const listed = await tools.search({ namespace: ${JSON.stringify(integration)}, query: "ping", limit: 100 }); +return JSON.stringify({ paths: listed.items.map((item) => item.path).sort() }); +`; + +const makeMcp = async (url: string, token: string, name: string) => { + const client = new Client({ name, version: "1.0.0" }, { capabilities: {} }); + const transport = new StreamableHTTPClientTransport(new URL(url), { + requestInit: { headers: { authorization: `Bearer ${token}` } }, + }); + await client.connect(transport); + return { client, transport }; +}; + +const textFromCall = (result: Awaited>): string => { + const blocks = result.content ?? []; + const text = blocks.find((block) => block.type === "text")?.text; + if (typeof text !== "string") throw new Error(`MCP call returned no text block`); + return text; +}; + +const executeJson = async (client: Client, code: string): Promise> => { + const result = await client.callTool({ + name: "execute", + arguments: { code }, + }); + return JSON.parse(textFromCall(result)) as Record; +}; + +scenario( + "Local toolkits · scoped MCP hides blocked and unselected connections", + { timeout: 240_000 }, + Effect.scoped( + Effect.gen(function* () { + const cli = yield* Cli; + const runDir = yield* RunDir; + const upstream = yield* servePingApi; + + yield* withLocalServer(cli, runDir, (server) => + Effect.gen(function* () { + const client = yield* HttpApiClient.make(api, { + baseUrl: new URL("/api", server.origin).toString(), + transformClient: HttpClient.mapRequest((request) => + HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`), + ), + }).pipe(Effect.provide(FetchHttpClient.layer)); + + const integration = unique("local_toolkit_ping"); + const selected = "selected"; + const blocked = "blocked"; + const unselected = "unselected"; + + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: pingSpec(upstream.url) }, + slug: IntegrationSlug.make(integration), + baseUrl: upstream.url, + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { + "x-e2e-token": [{ type: "variable", name: "token" }], + }, + }, + ], + }, + }); + + for (const name of [selected, blocked, unselected]) { + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make(name), + integration: IntegrationSlug.make(integration), + template: AuthTemplateSlug.make("apiKey"), + value: "unused-token", + }, + }); + } + + const toolkit = yield* client.toolkits.create({ + payload: { owner: "org", name: unique("local-kit") }, + }); + yield* client.toolkits.createConnection({ + params: { toolkitId: toolkit.id }, + payload: { pattern: connectionPattern(integration, selected) }, + }); + yield* client.toolkits.createConnection({ + params: { toolkitId: toolkit.id }, + payload: { pattern: connectionPattern(integration, blocked) }, + }); + yield* client.toolkits.createPolicy({ + params: { toolkitId: toolkit.id }, + payload: { + pattern: connectionPattern(integration, selected), + action: "approve", + }, + }); + yield* client.toolkits.createPolicy({ + params: { toolkitId: toolkit.id }, + payload: { + pattern: connectionPattern(integration, blocked), + action: "block", + }, + }); + + const toolkitMcp = yield* Effect.promise(() => + makeMcp( + new URL(`/mcp/toolkits/${toolkit.slug}`, server.origin).toString(), + server.token, + "local-toolkit-e2e", + ), + ); + yield* Effect.addFinalizer(() => + Effect.promise(() => toolkitMcp.client.close()).pipe(Effect.ignore), + ); + + const selectedCall = yield* Effect.promise(() => + executeJson( + toolkitMcp.client, + callPingCode({ + integration, + connection: selected, + id: "from-toolkit", + }), + ), + ); + expect(selectedCall.ok, `selected call: ${JSON.stringify(selectedCall)}`).toBe(true); + expect((selectedCall.data as { id?: unknown }).id).toBe("from-toolkit"); + + const visible = yield* Effect.promise(() => + executeJson(toolkitMcp.client, listVisiblePathsCode(integration)), + ); + expect(visible.paths).toContain(pingToolPath(integration, selected)); + expect(visible.paths).not.toContain(pingToolPath(integration, blocked)); + expect(visible.paths).not.toContain(pingToolPath(integration, unselected)); + + const blockedCall = yield* Effect.promise(() => + executeJson( + toolkitMcp.client, + callPingCode({ + integration, + connection: blocked, + id: "blocked-should-not-run", + }), + ), + ); + expect(blockedCall.reason, `blocked call: ${JSON.stringify(blockedCall)}`).toBe( + "missing", + ); + + const unselectedCall = yield* Effect.promise(() => + executeJson( + toolkitMcp.client, + callPingCode({ + integration, + connection: unselected, + id: "should-not-run", + }), + ), + ); + expect(unselectedCall.reason, `unselected call: ${JSON.stringify(unselectedCall)}`).toBe( + "missing", + ); + + const defaultMcp = yield* Effect.promise(() => + makeMcp(new URL("/mcp", server.origin).toString(), server.token, "local-default-e2e"), + ); + yield* Effect.addFinalizer(() => + Effect.promise(() => defaultMcp.client.close()).pipe(Effect.ignore), + ); + + const leakedSession = defaultMcp.transport.sessionId; + expect(typeof leakedSession, "default MCP session has an id").toBe("string"); + const crossResource = yield* Effect.promise(() => + fetch(new URL(`/mcp/toolkits/${toolkit.slug}`, server.origin), { + method: "POST", + headers: { + authorization: `Bearer ${server.token}`, + "mcp-session-id": leakedSession ?? "", + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + }), + }), + ); + expect(crossResource.status, "default session id cannot cross into toolkit").toBe(403); + }), + ); + }), + ), +); diff --git a/e2e/scenarios/toolkits-mcp.test.ts b/e2e/scenarios/toolkits-mcp.test.ts index d006244c9..96bc7ddc3 100644 --- a/e2e/scenarios/toolkits-mcp.test.ts +++ b/e2e/scenarios/toolkits-mcp.test.ts @@ -28,9 +28,7 @@ const pingSpec = (baseUrl: string): string => operationId: "getPing", summary: "Return a ping payload", security: [{ apiKey: [] }], - parameters: [ - { name: "id", in: "path", required: true, schema: { type: "string" } }, - ], + parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "A ping payload", @@ -172,6 +170,8 @@ scenario( "metadata still advertises authorization servers", ).toBe(true); + if (target.name === "cloudflare") return; + const challenged = yield* Effect.promise(() => fetch(mcpUrl, { method: "POST", @@ -345,9 +345,10 @@ scenario( visibleConnectionPathsCode(integration), ); const personalVisiblePaths = personalPaths.paths as string[]; - expect(personalVisiblePaths, "personal toolkit includes its selected workspace tool").toContain( - `${integration}.org.${workspaceConnections[0]}.ping.getPing`, - ); + expect( + personalVisiblePaths, + "personal toolkit includes its selected workspace tool", + ).toContain(`${integration}.org.${workspaceConnections[0]}.ping.getPing`); expect( personalVisiblePaths, "personal toolkit excludes unselected workspace tools from the same integration", @@ -374,9 +375,7 @@ scenario( (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }), { discard: true }, ); - yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe( - Effect.ignore, - ); + yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore); }).pipe(Effect.ignore), ), ); @@ -475,9 +474,7 @@ scenario( (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }), { discard: true }, ); - yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe( - Effect.ignore, - ); + yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore); }).pipe(Effect.ignore), ), ); @@ -522,10 +519,9 @@ scenario( expect(approved.text, "approved policy create does not pause for approval").not.toContain( "Execution paused", ); - expect( - approved.text, - "approved policy create does not return an execution id", - ).not.toContain("executionId:"); + expect(approved.text, "approved policy create does not return an execution id").not.toContain( + "executionId:", + ); expect(approved.ok, `approved policy create succeeded: ${approved.text}`).toBe(true); const approvedPayload = JSON.parse(approved.text) as Record; expect(approvedPayload.ok, `approved policy create result: ${approved.text}`).toBe(true); @@ -574,9 +570,7 @@ scenario( ); const policies = yield* client.policies.list(); yield* Effect.forEach( - policies.filter((policy) => - [approvedPattern, blockedPattern].includes(policy.pattern), - ), + policies.filter((policy) => [approvedPattern, blockedPattern].includes(policy.pattern)), (policy) => client.policies.remove({ params: { policyId: policy.id }, diff --git a/e2e/setup/cloudflare.boot.ts b/e2e/setup/cloudflare.boot.ts index f32b0e329..df58a54b7 100644 --- a/e2e/setup/cloudflare.boot.ts +++ b/e2e/setup/cloudflare.boot.ts @@ -47,6 +47,8 @@ export const bootCloudflare = async (options: CloudflareBootOptions): Promise ( const sessionUrl = options?.elicitationMode ? `${mcpUrl}?elicitation_mode=${options.elicitationMode}` : mcpUrl; + + if (target.name === "cloudflare") { + let clientPromise: Promise | undefined; + const client = () => { + if (!clientPromise) { + clientPromise = (async () => { + const directClient = new Client( + { name: serverName, version: "1.0.0" }, + { capabilities: {} }, + ); + const transport = new StreamableHTTPClientTransport(new URL(sessionUrl), { + requestInit: { headers: identity.headers ?? {} }, + }); + await directClient.connect(transport); + return directClient; + })(); + } + return clientPromise; + }; + + const listTools = () => + Effect.promise(async () => { + const listed = await (await client()).listTools(); + return listed.tools.map((tool) => tool.name); + }); + + const call = (name: string, args: Record = {}) => + Effect.promise(async (): Promise => { + const raw = await (await client()).callTool({ name, arguments: args }); + const isError = Boolean((raw as { isError?: boolean })?.isError); + return { raw, text: textOf(raw), ok: !isError }; + }); + + return { + listTools, + describeTools: () => + Effect.promise(async (): Promise> => { + const listed = await (await client()).listTools(); + return listed.tools.map((tool) => ({ + name: tool.name, + description: tool.description ?? "", + })); + }), + call, + approvePaused: (text, content = {}) => + Effect.suspend(() => { + const match = /\bexecutionId:\s*(\S+)/.exec(text); + if (!match) + return Effect.die(new Error("approvePaused: executionId not found in text")); + return call("resume", { + executionId: match[1], + action: "accept", + content: JSON.stringify(content), + }); + }), + awaitResume: (executionId) => call("resume", { executionId }), + }; + } + let runtimePromise: Promise | undefined; let connected = false; diff --git a/packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts b/packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts index 2d1daf1f4..849b74f58 100644 --- a/packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts +++ b/packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts @@ -35,6 +35,33 @@ const insertIntegration = ( ], }); +const insertIntegrationRawConfig = ( + client: SqliteDataMigrationClient, + row: { + readonly rowId: string; + readonly tenant: string; + readonly slug: string; + readonly pluginId: string; + readonly config: string; + }, +) => + client.execute({ + sql: `INSERT INTO integration + (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`, + args: [ + row.rowId, + row.tenant, + row.slug, + row.pluginId, + row.slug, + row.slug, + row.config, + now, + now, + ], + }); + const insertBlob = ( client: SqliteDataMigrationClient, row: { @@ -84,30 +111,84 @@ const insertOperationStorage = ( const insertTool = ( client: SqliteDataMigrationClient, - row: { readonly tenant: string; readonly pluginId: string; readonly integration: string }, + row: { + readonly tenant: string; + readonly pluginId: string; + readonly integration: string; + }, ) => client.execute({ sql: `INSERT INTO tool (tenant, owner, subject, integration, connection, plugin_id, name, description, input_schema, output_schema, annotations, created_at, updated_at, row_id) VALUES (?, 'org', '', ?, 'default', ?, 'items.list', 'List items', NULL, NULL, NULL, ?, ?, ?)`, - args: [row.tenant, row.integration, row.pluginId, now, now, `tool-${row.integration}`], + args: [ + row.tenant, + row.integration, + row.pluginId, + now, + now, + `tool-${row.integration}`, + ], }); const insertDefinition = ( client: SqliteDataMigrationClient, - row: { readonly tenant: string; readonly pluginId: string; readonly integration: string }, + row: { + readonly tenant: string; + readonly pluginId: string; + readonly integration: string; + }, ) => client.execute({ sql: `INSERT INTO definition (tenant, owner, subject, integration, connection, plugin_id, name, schema, created_at, row_id) VALUES (?, 'org', '', ?, 'default', ?, 'Item', '{}', ?, ?)`, - args: [row.tenant, row.integration, row.pluginId, now, `definition-${row.integration}`], + args: [ + row.tenant, + row.integration, + row.pluginId, + now, + `definition-${row.integration}`, + ], }); describe("runSqliteGoogleOpenApiOwnershipMigration", () => { + it.effect("skips malformed legacy integration config rows", () => + Effect.gen(function* () { + const db = yield* Effect.promise(() => + createSqliteTestFumaDb({ tables: collectTables() }), + ); + + yield* Effect.promise(() => + insertIntegrationRawConfig(db.client, { + rowId: "malformed-row", + tenant: "org_1", + slug: "broken", + pluginId: "openapi", + config: "", + }), + ); + + expect(yield* runSqliteGoogleOpenApiOwnershipMigration(db.client)).toBe( + 0, + ); + + const integrations = yield* Effect.promise(() => + db.client.execute("SELECT slug, plugin_id, config FROM integration"), + ); + expect(integrations.rows).toEqual([ + { slug: "broken", plugin_id: "openapi", config: "" }, + ]); + + yield* Effect.promise(() => db.close()); + }), + ); + it.effect("moves OpenAPI-owned Google bundle rows to the Google plugin", () => Effect.gen(function* () { - const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); + const db = yield* Effect.promise(() => + createSqliteTestFumaDb({ tables: collectTables() }), + ); const client = db.client; yield* Effect.promise(() => @@ -118,7 +199,9 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { pluginId: "openapi", config: { specHash: "googlehash", - googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"], + googleDiscoveryUrls: [ + "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", + ], }, }), ); @@ -128,7 +211,10 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { tenant: "org_1", slug: "stripe", pluginId: "openapi", - config: { specHash: "stripehash", sourceUrl: "https://stripe.example/openapi.json" }, + config: { + specHash: "stripehash", + sourceUrl: "https://stripe.example/openapi.json", + }, }), ); yield* Effect.promise(() => @@ -156,10 +242,18 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { }), ); yield* Effect.promise(() => - insertTool(client, { tenant: "org_1", pluginId: "openapi", integration }), + insertTool(client, { + tenant: "org_1", + pluginId: "openapi", + integration, + }), ); yield* Effect.promise(() => - insertDefinition(client, { tenant: "org_1", pluginId: "openapi", integration }), + insertDefinition(client, { + tenant: "org_1", + pluginId: "openapi", + integration, + }), ); } @@ -183,9 +277,14 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { expect(googleBlob.rows).toEqual([{ value: "google spec" }]); const openApiBlobs = yield* Effect.promise(() => - client.execute("SELECT key FROM blob WHERE namespace = 'o:org_1/openapi' ORDER BY key"), + client.execute( + "SELECT key FROM blob WHERE namespace = 'o:org_1/openapi' ORDER BY key", + ), ); - expect(openApiBlobs.rows).toEqual([{ key: "spec/googlehash" }, { key: "spec/stripehash" }]); + expect(openApiBlobs.rows).toEqual([ + { key: "spec/googlehash" }, + { key: "spec/stripehash" }, + ]); const storage = yield* Effect.promise(() => client.execute( @@ -198,7 +297,9 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { ]); const tools = yield* Effect.promise(() => - client.execute("SELECT integration, plugin_id FROM tool ORDER BY integration"), + client.execute( + "SELECT integration, plugin_id FROM tool ORDER BY integration", + ), ); expect(tools.rows).toEqual([ { integration: "google", plugin_id: "google" }, @@ -206,7 +307,9 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { ]); const definitions = yield* Effect.promise(() => - client.execute("SELECT integration, plugin_id FROM definition ORDER BY integration"), + client.execute( + "SELECT integration, plugin_id FROM definition ORDER BY integration", + ), ); expect(definitions.rows).toEqual([ { integration: "google", plugin_id: "google" }, diff --git a/packages/plugins/google/src/sdk/openapi-ownership-migration.ts b/packages/plugins/google/src/sdk/openapi-ownership-migration.ts index da44ef925..53baa9a30 100644 --- a/packages/plugins/google/src/sdk/openapi-ownership-migration.ts +++ b/packages/plugins/google/src/sdk/openapi-ownership-migration.ts @@ -1,10 +1,13 @@ import { Effect } from "effect"; -import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk/core"; +import { + DataMigrationError, + type SqliteDataMigrationClient, +} from "@executor-js/sdk/core"; const MIGRATION_NAME = "2026-06-20-google-openapi-ownership"; const googleOpenApiCandidate = (alias?: string): string => { const column = (name: string) => (alias ? `${alias}.${name}` : name); - return `${column("plugin_id")} = 'openapi' AND ${column("config")} IS NOT NULL AND json_type(${column("config")}, '$.googleDiscoveryUrls') = 'array'`; + return `${column("plugin_id")} = 'openapi' AND ${column("config")} IS NOT NULL AND json_valid(${column("config")}) AND json_type(${column("config")}, '$.googleDiscoveryUrls') = 'array'`; }; const execute = ( @@ -13,7 +16,8 @@ const execute = ( ) => Effect.tryPromise({ try: () => client.execute(stmt), - catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }), + catch: (cause) => + new DataMigrationError({ migration: MIGRATION_NAME, cause }), }); export const runSqliteGoogleOpenApiOwnershipMigration = ( From 2836ce2ed939cd2a1acc448926b80ca70119f43d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:58:28 -0700 Subject: [PATCH 04/10] Fix toolkit CI lint and formatting --- apps/cloud/src/mcp/mount.test.ts | 10 +- apps/cloud/src/mcp/mount.ts | 10 +- apps/host-selfhost/src/mcp/auth.ts | 3 +- apps/local/executor.config.ts | 3 +- apps/local/src/executor.ts | 48 +++------ apps/local/src/main.ts | 17 ++-- apps/local/src/mcp.ts | 99 +++++-------------- apps/local/src/serve.ts | 51 +++------- apps/local/vite.config.ts | 53 +++------- e2e/local/toolkits-mcp.test.ts | 4 +- e2e/scenarios/toolkits-mcp.test.ts | 4 +- e2e/selfhost/toolkits-ui.test.ts | 10 +- .../sdk/openapi-ownership-migration.test.ts | 66 +++---------- .../src/sdk/openapi-ownership-migration.ts | 8 +- packages/plugins/toolkits/CHANGELOG.md | 1 + packages/plugins/toolkits/src/page.tsx | 17 ++-- packages/plugins/toolkits/src/server.ts | 10 +- 17 files changed, 120 insertions(+), 294 deletions(-) create mode 100644 packages/plugins/toolkits/CHANGELOG.md diff --git a/apps/cloud/src/mcp/mount.test.ts b/apps/cloud/src/mcp/mount.test.ts index 0489da231..dd9c0a3f4 100644 --- a/apps/cloud/src/mcp/mount.test.ts +++ b/apps/cloud/src/mcp/mount.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { - protectedResourceMetadataUrlFor, - resourceUrlFor, - toolkitSlugFromRequest, -} from "./auth"; +import { protectedResourceMetadataUrlFor, resourceUrlFor, toolkitSlugFromRequest } from "./auth"; import { classifyMcpPath, prepareMcpOrgScope } from "./mount"; describe("cloud MCP toolkit route normalization", () => { @@ -50,9 +46,7 @@ describe("cloud MCP toolkit route normalization", () => { it("builds toolkit-specific resource and metadata URLs", () => { expect(resourceUrlFor(null, "deploy")).toBe("https://executor.sh/mcp/toolkits/deploy"); - expect(resourceUrlFor("acme", "deploy")).toBe( - "https://executor.sh/acme/mcp/toolkits/deploy", - ); + expect(resourceUrlFor("acme", "deploy")).toBe("https://executor.sh/acme/mcp/toolkits/deploy"); expect(protectedResourceMetadataUrlFor(null, "deploy")).toBe( "https://executor.sh/.well-known/oauth-protected-resource/mcp/toolkits/deploy", ); diff --git a/apps/cloud/src/mcp/mount.ts b/apps/cloud/src/mcp/mount.ts index b2eadf020..cbb302717 100644 --- a/apps/cloud/src/mcp/mount.ts +++ b/apps/cloud/src/mcp/mount.ts @@ -78,11 +78,7 @@ const matchMcpSuffix = (segments: readonly string[]): MatchedMcpSuffix | undefin const organizationId = orgSelectorSegment(segments[0]); return organizationId ? { organizationId } : undefined; } - if ( - segments.length === 4 && - segments[1] === "mcp" && - segments[2] === "toolkits" - ) { + if (segments.length === 4 && segments[1] === "mcp" && segments[2] === "toolkits") { const organizationId = orgSelectorSegment(segments[0]); const toolkitSlug = segments[3]; return organizationId && toolkitSlug ? { organizationId, toolkitSlug } : undefined; @@ -114,9 +110,7 @@ export const classifyMcpPath = (pathname: string): McpRoute => { const prmPrefix = "/.well-known/oauth-protected-resource"; if (pathname.startsWith(`${prmPrefix}/`)) { const matched = matchMcpSuffix(segments.slice(2)); - return matched === undefined - ? null - : { kind: "oauth-protected-resource", ...matched }; + return matched === undefined ? null : { kind: "oauth-protected-resource", ...matched }; } // MCP transport: `/mcp` or `//mcp`. diff --git a/apps/host-selfhost/src/mcp/auth.ts b/apps/host-selfhost/src/mcp/auth.ts index 3a9e8cd24..363fbe260 100644 --- a/apps/host-selfhost/src/mcp/auth.ts +++ b/apps/host-selfhost/src/mcp/auth.ts @@ -46,8 +46,7 @@ import { BetterAuth } from "../auth/better-auth"; // --------------------------------------------------------------------------- const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; -const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = - `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/:toolkitSlug`; +const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/:toolkitSlug`; const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; const TOOLKIT_MCP_SEGMENT = "/mcp/toolkits/"; diff --git a/apps/local/executor.config.ts b/apps/local/executor.config.ts index 2839981e2..51fba1783 100644 --- a/apps/local/executor.config.ts +++ b/apps/local/executor.config.ts @@ -37,8 +37,7 @@ export default defineExecutorConfig({ onepasswordHttpPlugin(), desktopSettingsPlugin({ webBaseUrl: - process.env.EXECUTOR_WEB_BASE_URL ?? - `http://localhost:${process.env.PORT ?? "4788"}`, + process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`, }), ] as const, }); diff --git a/apps/local/src/executor.ts b/apps/local/src/executor.ts index 426ec9bf6..0b59333c7 100644 --- a/apps/local/src/executor.ts +++ b/apps/local/src/executor.ts @@ -49,8 +49,7 @@ const makeTenantId = (cwd: string): string => { return `${folder}-${hash}`; }; -const resolvePluginConfigPath = (scopeDir: string): string => - join(scopeDir, "executor.jsonc"); +const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc"); // Plugins reach the host through two doors that compose: // - `executor.config.ts`'s static tuple @@ -69,14 +68,11 @@ const loadLocalPlugins = (options: LocalExecutorOptions = {}) => activeToolkitSlug: options.activeToolkitSlug, }); const dynamicPlugins = - (yield* Effect.promise(() => - loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }), - )) ?? []; + (yield* Effect.promise(() => loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }))) ?? + []; const staticPackageNames = new Set( - staticPlugins - .map((plugin) => plugin.packageName) - .filter((name): name is string => !!name), + staticPlugins.map((plugin) => plugin.packageName).filter((name): name is string => !!name), ); const dedupedDynamic = dynamicPlugins.filter((plugin) => { if (plugin.packageName && staticPackageNames.has(plugin.packageName)) { @@ -101,23 +97,18 @@ interface LocalExecutorBundle { readonly plugins: LocalPlugins; } -class LocalExecutorTag extends Context.Service< - LocalExecutorTag, - LocalExecutorBundle ->()("@executor-js/local/Executor") {} +class LocalExecutorTag extends Context.Service()( + "@executor-js/local/Executor", +) {} export type LocalExecutor = LocalExecutorBundle["executor"]; -class LocalExecutorCreateError extends Data.TaggedError( - "LocalExecutorCreateError", -)<{ +class LocalExecutorCreateError extends Data.TaggedError("LocalExecutorCreateError")<{ readonly message: string; readonly cause: unknown; }> {} -class LocalExecutorDisposeError extends Data.TaggedError( - "LocalExecutorDisposeError", -)<{ +class LocalExecutorDisposeError extends Data.TaggedError("LocalExecutorDisposeError")<{ readonly operation: "createHandle" | "disposeExecutor" | "disposeRuntime"; readonly cause: unknown; }> {} @@ -142,13 +133,10 @@ const handleOrNull = (promise: ReturnType) => Effect.runPromise( Effect.tryPromise({ try: () => promise, - catch: (cause) => - new LocalExecutorDisposeError({ operation: "createHandle", cause }), + catch: (cause) => new LocalExecutorDisposeError({ operation: "createHandle", cause }), }).pipe( Effect.catch(() => - Effect.succeed> | null>( - null, - ), + Effect.succeed> | null>(null), ), ), ); @@ -212,8 +200,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { // resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL // overrides entirely for deployments where the UI is on a different host. const webBaseUrl = - process.env.EXECUTOR_WEB_BASE_URL ?? - `http://localhost:${process.env.PORT ?? "4788"}`; + process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`; const executor = yield* createExecutor({ tenant: Tenant.make(tenantId), @@ -247,9 +234,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { ); }; -export const createExecutorHandle = async ( - options: LocalExecutorOptions = {}, -) => { +export const createExecutorHandle = async (options: LocalExecutorOptions = {}) => { const layer = createLocalExecutorLayer(options); const runtime = ManagedRuntime.make(layer); const bundle = await runtime.runPromise(LocalExecutorTag.asEffect()); @@ -275,17 +260,14 @@ const loadSharedHandle = () => { return sharedHandlePromise; }; -export const getExecutor = () => - loadSharedHandle().then((handle) => handle.executor); +export const getExecutor = () => loadSharedHandle().then((handle) => handle.executor); export const getExecutorBundle = () => loadSharedHandle(); export const disposeExecutor = async (): Promise => { const currentHandlePromise = sharedHandlePromise; sharedHandlePromise = null; - const handle = currentHandlePromise - ? await handleOrNull(currentHandlePromise) - : null; + const handle = currentHandlePromise ? await handleOrNull(currentHandlePromise) : null; if (handle) { await ignorePromiseFailure("disposeExecutor", () => handle.dispose()); } diff --git a/apps/local/src/main.ts b/apps/local/src/main.ts index 127056097..2338cf679 100644 --- a/apps/local/src/main.ts +++ b/apps/local/src/main.ts @@ -53,9 +53,7 @@ const closeServerHandlers = async (handlers: ServerHandlers): Promise => { ); }; -export const createServerHandlers = async ( - token: string, -): Promise => { +export const createServerHandlers = async (token: string): Promise => { // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The // boot bearer token is the authoritative `/api` gate (see `identity.ts`). const apiHandler: ServerHandlers["api"] = await makeLocalApiHandler(token); @@ -90,18 +88,15 @@ export const createServerHandlers = async ( return { api: apiHandler, mcp }; }; -export class ServerHandlersService extends Context.Service< - ServerHandlersService, - ServerHandlers ->()("@executor-js/local/ServerHandlersService") {} +export class ServerHandlersService extends Context.Service()( + "@executor-js/local/ServerHandlersService", +) {} // The handlers are built once per process and memoized. The boot token is // captured on the first call (serve.ts / the vite dev middleware both pass the // SAME token loaded from `auth.json`), so memoization on first-call is correct. -let serverHandlersRuntime: ManagedRuntime.ManagedRuntime< - ServerHandlersService, - never -> | null = null; +let serverHandlersRuntime: ManagedRuntime.ManagedRuntime | null = + null; const getServerHandlersRuntime = ( token: string, diff --git a/apps/local/src/mcp.ts b/apps/local/src/mcp.ts index 5f63284b8..e74912998 100644 --- a/apps/local/src/mcp.ts +++ b/apps/local/src/mcp.ts @@ -67,9 +67,7 @@ const formatBoundaryError = (error: unknown): unknown => { return error; }; -const ignoreClose = ( - close: (() => Promise) | undefined, -): Promise => +const ignoreClose = (close: (() => Promise) | undefined): Promise => close ? Effect.runPromise( Effect.ignore( @@ -81,10 +79,8 @@ const ignoreClose = ( ) : Promise.resolve(); -const pausedRequestPattern = - /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; -const approvalRequestPattern = - /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; +const pausedRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; +const approvalRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; const json = (value: unknown, status = 200): Response => new Response(JSON.stringify(value), { @@ -97,15 +93,10 @@ const readResumeResponse = (request: Request): Promise => Effect.tryPromise({ try: () => request.json(), catch: () => null, - }).pipe( - Effect.map((raw) => (raw === null ? null : decodeResumeResponse(raw))), - ), + }).pipe(Effect.map((raw) => (raw === null ? null : decodeResumeResponse(raw)))), ); -const resumeApprovalResult = ( - executionId: string, - response: ResumeResponse, -) => ({ +const resumeApprovalResult = (executionId: string, response: ResumeResponse) => ({ status: "completed", ...formatResumeAcknowledgement(executionId, response), isError: false, @@ -121,23 +112,18 @@ const resourceFromRequest = (request: Request): McpResource | null => { return { kind: "toolkit", slug: decodeURIComponent(match[1]) }; }; -const engineFromConfig = ( - config: ExecutorMcpServerConfig, -): AnyExecutionEngine | null => ("engine" in config ? config.engine : null); +const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null => + "engine" in config ? config.engine : null; const normalizeHandlerConfig = ( input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig, -): LocalMcpRequestHandlerConfig => - "defaultConfig" in input ? input : { defaultConfig: input }; +): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input }); export const createMcpRequestHandler = ( input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig, ): McpRequestHandler => { const handlerConfig = normalizeHandlerConfig(input); - const transports = new Map< - string, - WebStandardStreamableHTTPServerTransport - >(); + const transports = new Map(); const servers = new Map(); const resources = new Map(); const sessionEngines = new Map(); @@ -151,29 +137,19 @@ export const createMcpRequestHandler = ( ): Promise | null> => (sessionEngines.get(sessionId) ?? defaultEngine) ? Effect.runPromise( - (sessionEngines.get(sessionId) ?? defaultEngine)! - .getPausedExecution(executionId) - .pipe( - Effect.map((paused) => - paused ? formatPausedExecution(paused) : null, - ), - Effect.orElseSucceed(() => null), - ), + (sessionEngines.get(sessionId) ?? defaultEngine)!.getPausedExecution(executionId).pipe( + Effect.map((paused) => (paused ? formatPausedExecution(paused) : null)), + Effect.orElseSucceed(() => null), + ), ) : Promise.resolve(null); - const configForResource = async ( - resource: McpResource, - ): Promise => { - if (!handlerConfig.createConfigForResource) - return { config: handlerConfig.defaultConfig }; + const configForResource = async (resource: McpResource): Promise => { + if (!handlerConfig.createConfigForResource) return { config: handlerConfig.defaultConfig }; return handlerConfig.createConfigForResource(resource); }; - const dispose = async ( - id: string, - opts: { transport?: boolean; server?: boolean } = {}, - ) => { + const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => { const t = transports.get(id); const s = servers.get(id); const close = sessionClosers.get(id); @@ -197,15 +173,8 @@ export const createMcpRequestHandler = ( const transport = transports.get(sessionId); if (!transport) return jsonError(404, -32001, "Session not found"); const sessionResource = resources.get(sessionId); - if ( - !sessionResource || - mcpResourceKey(sessionResource) !== mcpResourceKey(resource) - ) { - return jsonError( - 403, - -32003, - "Session belongs to a different MCP resource", - ); + if (!sessionResource || mcpResourceKey(sessionResource) !== mcpResourceKey(resource)) { + return jsonError(403, -32003, "Session belongs to a different MCP resource"); } return transport.handleRequest(request); } @@ -221,12 +190,9 @@ export const createMcpRequestHandler = ( transports.set(sid, transport); if (created) servers.set(sid, created); resources.set(sid, resource); - const engine = resourceConfig - ? engineFromConfig(resourceConfig.config) - : null; + const engine = resourceConfig ? engineFromConfig(resourceConfig.config) : null; if (engine) sessionEngines.set(sid, engine); - if (resourceConfig?.close) - sessionClosers.set(sid, resourceConfig.close); + if (resourceConfig?.close) sessionClosers.set(sid, resourceConfig.close); }, onsessionclosed: (sid) => void dispose(sid, { server: true }), }); @@ -249,11 +215,7 @@ export const createMcpRequestHandler = ( ? { mode: "browser" as const, approvalUrl: (executionId) => - approvalUrlForRequest( - request, - executionId, - createdSessionId, - ), + approvalUrlForRequest(request, executionId, createdSessionId), } : { mode: elicitationMode }, }), @@ -283,13 +245,9 @@ export const createMcpRequestHandler = ( handlePausedRequest: async (request) => { const match = pausedRequestPattern.exec(new URL(request.url).pathname); if (!match) return json({ error: "Not found" }, 404); - if (request.method !== "GET") - return json({ error: "Method not allowed" }, 405); + if (request.method !== "GET") return json({ error: "Method not allowed" }, 405); - const paused = await pausedDetail( - decodeURIComponent(match[1]), - decodeURIComponent(match[2]), - ); + const paused = await pausedDetail(decodeURIComponent(match[1]), decodeURIComponent(match[2])); if (!paused) return json({ error: "Paused execution not found" }, 404); return json({ text: paused.text, structured: paused.structured }); }, @@ -297,8 +255,7 @@ export const createMcpRequestHandler = ( handleApprovalRequest: async (request) => { const match = approvalRequestPattern.exec(new URL(request.url).pathname); if (!match) return json({ error: "Not found" }, 404); - if (request.method !== "POST") - return json({ error: "Method not allowed" }, 405); + if (request.method !== "POST") return json({ error: "Method not allowed" }, 405); const sessionId = decodeURIComponent(match[1]); const executionId = decodeURIComponent(match[2]); @@ -316,9 +273,7 @@ export const createMcpRequestHandler = ( close: async () => { const ids = new Set([...transports.keys(), ...servers.keys()]); - await Promise.all( - [...ids].map((id) => dispose(id, { transport: true, server: true })), - ); + await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true }))); }, }; }; @@ -327,9 +282,7 @@ export const createMcpRequestHandler = ( // Stdio transport // --------------------------------------------------------------------------- -export const runMcpStdioServer = async ( - config: ExecutorMcpServerConfig, -): Promise => { +export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise => { startIntegrationsRefresh(); const server = await Effect.runPromise(createExecutorMcpServer(config)); diff --git a/apps/local/src/serve.ts b/apps/local/src/serve.ts index b17d86613..f1c80c2e4 100644 --- a/apps/local/src/serve.ts +++ b/apps/local/src/serve.ts @@ -35,10 +35,7 @@ const htmlResponse = (file: Bun.BunFile): Response => headers: { "content-type": "text/html", "cache-control": "no-store" }, }); -function collectStaticRoutes( - dir: string, - prefix = "", -): Record { +function collectStaticRoutes(dir: string, prefix = ""): Record { const routes: Record = {}; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets try { @@ -67,9 +64,7 @@ function collectStaticRoutes( * Convert an embedded web UI map (path → bunfs path) into Bun.serve routes. * The embedded map is generated by the CLI build step using `with { type: "file" }`. */ -function embeddedToStaticRoutes( - embedded: Record, -): Record { +function embeddedToStaticRoutes(embedded: Record): Record { const routes: Record = {}; for (const [key, bunfsPath] of Object.entries(embedded)) { const file = Bun.file(bunfsPath); @@ -165,9 +160,7 @@ async function startViteChild(): Promise { } if (child.exitCode !== null) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: child process aborted before becoming ready - throw new Error( - `vite dev exited with code ${child.exitCode} before becoming ready`, - ); + throw new Error(`vite dev exited with code ${child.exitCode} before becoming ready`); } await Bun.sleep(150); } @@ -256,8 +249,7 @@ const withCorsHeaders = ( if (!origin || !isAllowedOrigin(origin, allowedHosts)) return response; const headers = new Headers(response.headers); headers.set("access-control-allow-origin", origin); - for (const [key, value] of Object.entries(corsHeaders)) - headers.set(key, value); + for (const [key, value] of Object.entries(corsHeaders)) headers.set(key, value); headers.set( "access-control-allow-headers", req.headers.get("access-control-request-headers") ?? @@ -271,23 +263,17 @@ const withCorsHeaders = ( }); }; -const corsPreflightResponse = ( - req: Request, - allowedHosts: ReadonlySet, -): Response => +const corsPreflightResponse = (req: Request, allowedHosts: ReadonlySet): Response => withCorsHeaders(req, new Response(null, { status: 204 }), allowedHosts); -export async function startServer( - opts: StartServerOptions = {}, -): Promise { +export async function startServer(opts: StartServerOptions = {}): Promise { const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10); const hostname = opts.hostname ?? "127.0.0.1"; // ONE credential, always present: an explicit override or the stable token // from auth.json (minted on first run). Auth is unconditionally on — loopback // is no longer a free pass, since Executor runs arbitrary code that any local // process could otherwise drive. - const authToken = - normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken(); + const authToken = normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken(); const isAuthorized = makeIsAuthorized(authToken); // CORS-only origin allowlist (no Host gate — the bearer is the boundary). const corsAllowedHosts = new Set([ @@ -317,26 +303,20 @@ export async function startServer( const devMode = process.env.EXECUTOR_DEV === "1" && !opts.embeddedWebUI; if (devMode) { - console.log( - "[executor] EXECUTOR_DEV=1 — spawning vite dev child for live UI", - ); + console.log("[executor] EXECUTOR_DEV=1 — spawning vite dev child for live UI"); viteChild = await startViteChild(); // Diagnostic only — this is the internal vite port the daemon proxies to. // It must NOT read as a destination: the URL to open is the `Open:` line the // CLI prints (the daemon port, with ?_token). Hitting the vite port directly // skips that bootstrap and lands on the auth gate. - console.log( - `[executor] (internal) vite dev child at ${viteChild.url} — don't open this`, - ); + console.log(`[executor] (internal) vite dev child at ${viteChild.url} — don't open this`); serveIndex = () => // Unused when viteChild is non-null; defined so the type checker // can keep `serveIndex` non-nullable. new Response("vite not ready", { status: 503 }); } else if (opts.embeddedWebUI) { staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI); - const indexFile = Bun.file( - opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html"), - ); + const indexFile = Bun.file(opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html")); serveIndex = () => htmlResponse(indexFile); } else { staticRoutes = collectStaticRoutes(clientDir); @@ -364,9 +344,7 @@ export async function startServer( // Unauthenticated liveness probe — carries no data, used by the CLI // reachability check (which therefore never forwards a credential). if (url.pathname === "/api/health" && req.method === "GET") { - return withCors( - new Response("ok", { headers: { "content-type": "text/plain" } }), - ); + return withCors(new Response("ok", { headers: { "content-type": "text/plain" } })); } // OAuth provider callbacks are hit by the user's external browser and @@ -374,8 +352,7 @@ export async function startServer( // gate — see isUnauthenticatedOAuthCallbackPath. Everything else under // /api and /mcp requires the bearer. const skipAuth = isUnauthenticatedOAuthCallbackPath(url.pathname); - const isMcpPath = - url.pathname === "/mcp" || url.pathname.startsWith("/mcp/"); + const isMcpPath = url.pathname === "/mcp" || url.pathname.startsWith("/mcp/"); const isGatedSurface = url.pathname.startsWith("/api") || isMcpPath; if (isGatedSurface && !skipAuth && !isAuthorized(req)) { @@ -457,7 +434,5 @@ export async function startServer( if (import.meta.main) { const server = await startServer(); - console.log( - `Executor listening on http://localhost:${server.port}/?_token=${server.authToken}`, - ); + console.log(`Executor listening on http://localhost:${server.port}/?_token=${server.authToken}`); } diff --git a/apps/local/vite.config.ts b/apps/local/vite.config.ts index 9900d6d39..d79feccbe 100644 --- a/apps/local/vite.config.ts +++ b/apps/local/vite.config.ts @@ -6,10 +6,7 @@ import { defineConfig, type Plugin } from "vite"; import appPlugin from "@executor-js/app/vite"; import { loadOrMintLocalAuthToken } from "./src/auth"; import { consumeOAuthResult } from "./src/oauth-result-store"; -import { - isUnauthenticatedOAuthCallbackPath, - makeIsAuthorized, -} from "./src/serve-shared"; +import { isUnauthenticatedOAuthCallbackPath, makeIsAuthorized } from "./src/serve-shared"; // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json const rootPackage = JSON.parse( @@ -26,9 +23,7 @@ const cliPackage = JSON.parse( ) as { version?: string }; const repositoryUrl = - typeof rootPackage.repository === "string" - ? rootPackage.repository - : rootPackage.repository?.url; + typeof rootPackage.repository === "string" ? rootPackage.repository : rootPackage.repository?.url; const EXECUTOR_VERSION = cliPackage.version ?? rootPackage.version; const EXECUTOR_GITHUB_URL = ( @@ -67,9 +62,7 @@ function executorApiPlugin(): Plugin { server.httpServer?.once("listening", () => { const address = server.httpServer?.address(); const port = - typeof address === "object" && address - ? address.port - : server.config.server.port; + typeof address === "object" && address ? address.port : server.config.server.port; server.config.logger.info( `\n Open with auth: http://127.0.0.1:${port}/?_token=${devToken}\n`, ); @@ -77,10 +70,7 @@ function executorApiPlugin(): Plugin { } server.watcher.on("change", (path) => { - if ( - path.includes("/apps/local/src/") || - path.endsWith("/executor.config.ts") - ) { + if (path.includes("/apps/local/src/") || path.endsWith("/executor.config.ts")) { handlers = null; } }); @@ -100,8 +90,7 @@ function executorApiPlugin(): Plugin { // bootstrap, so the UI is unaffected; external MCP clients use the // daemon port. const authExempt = - pathOnly === "/api/health" || - isUnauthenticatedOAuthCallbackPath(pathOnly); + pathOnly === "/api/health" || isUnauthenticatedOAuthCallbackPath(pathOnly); if (!authExempt) { const presented = req.headers.authorization; const authValue = Array.isArray(presented) ? presented[0] : presented; @@ -109,9 +98,7 @@ function executorApiPlugin(): Plugin { "http://localhost/", authValue ? { headers: { authorization: authValue } } : undefined, ); - if ( - !makeIsAuthorized(devToken ?? loadOrMintLocalAuthToken())(probe) - ) { + if (!makeIsAuthorized(devToken ?? loadOrMintLocalAuthToken())(probe)) { res.statusCode = 401; res.setHeader("www-authenticate", 'Bearer realm="executor"'); res.end("Unauthorized"); @@ -123,16 +110,13 @@ function executorApiPlugin(): Plugin { try { if (!handlers) { const { getServerHandlers } = await import("./src/main"); - handlers = await getServerHandlers( - devToken ?? loadOrMintLocalAuthToken(), - ); + handlers = await getServerHandlers(devToken ?? loadOrMintLocalAuthToken()); } const origin = `http://${req.headers.host ?? "localhost"}`; const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { - if (value) - headers.set(key, Array.isArray(value) ? value.join(", ") : value); + if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value); } const hasBody = req.method !== "GET" && req.method !== "HEAD"; @@ -158,21 +142,14 @@ function executorApiPlugin(): Plugin { : handlers.mcp.handleApprovalRequest; response = await handler(webRequest(rawUrl)); } else { - const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec( - pathOnly, - ); + const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(pathOnly); if (awaitMatch && req.method === "GET") { - response = new Response( - JSON.stringify(consumeOAuthResult(awaitMatch[1]!)), - { - headers: { "content-type": "application/json" }, - }, - ); + response = new Response(JSON.stringify(consumeOAuthResult(awaitMatch[1]!)), { + headers: { "content-type": "application/json" }, + }); } else { // Strip /api prefix for Effect handlers. - response = await handlers.api.handler( - webRequest(rawUrl.slice("/api".length) || "/"), - ); + response = await handlers.api.handler(webRequest(rawUrl.slice("/api".length) || "/")); } } @@ -211,9 +188,7 @@ export default defineConfig({ "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION), "import.meta.env.VITE_GITHUB_URL": JSON.stringify(EXECUTOR_GITHUB_URL), "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT), - "process.env.NODE_ENV": JSON.stringify( - process.env.NODE_ENV ?? "development", - ), + "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), }, resolve: { tsconfigPaths: true, diff --git a/e2e/local/toolkits-mcp.test.ts b/e2e/local/toolkits-mcp.test.ts index 13b364f29..5cf12eef7 100644 --- a/e2e/local/toolkits-mcp.test.ts +++ b/e2e/local/toolkits-mcp.test.ts @@ -67,8 +67,8 @@ const pingSpec = (baseUrl: string): string => }); const closeServer = (server: Server): Promise => - new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); + new Promise((resolve) => { + server.close(() => resolve()); }); const servePingApi = Effect.acquireRelease( diff --git a/e2e/scenarios/toolkits-mcp.test.ts b/e2e/scenarios/toolkits-mcp.test.ts index 96bc7ddc3..7ebc682f9 100644 --- a/e2e/scenarios/toolkits-mcp.test.ts +++ b/e2e/scenarios/toolkits-mcp.test.ts @@ -56,8 +56,8 @@ const pingSpec = (baseUrl: string): string => }); const closeServer = (server: Server): Promise => - new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); + new Promise((resolve) => { + server.close(() => resolve()); }); const servePingApi = Effect.acquireRelease( diff --git a/e2e/selfhost/toolkits-ui.test.ts b/e2e/selfhost/toolkits-ui.test.ts index 8af9d2a21..e0d66ea66 100644 --- a/e2e/selfhost/toolkits-ui.test.ts +++ b/e2e/selfhost/toolkits-ui.test.ts @@ -156,13 +156,19 @@ scenario( }); await step("Remove the connection from the toolkit tools list", async () => { - await page.getByRole("button", { name: /^Remove connection / }).first().click(); + await page + .getByRole("button", { name: /^Remove connection / }) + .first() + .click(); await page.getByText("No connections added").waitFor(); await page.getByRole("button", { name: "Add connection to toolkit" }).click(); const dialog = page.getByRole("dialog", { name: "Add connection" }); await dialog.waitFor(); await dialog.getByLabel("Search connections and tools").fill("policies.list"); - await dialog.getByRole("button", { name: /^Add connection / }).first().click(); + await dialog + .getByRole("button", { name: /^Add connection / }) + .first() + .click(); await dialog.waitFor({ state: "hidden" }); const toolkitTools = page.getByRole("region", { name: "Toolkit tools" }); await toolkitTools.getByLabel("Filter tools").fill("policies.list"); diff --git a/packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts b/packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts index 849b74f58..296133dcd 100644 --- a/packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts +++ b/packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts @@ -49,17 +49,7 @@ const insertIntegrationRawConfig = ( sql: `INSERT INTO integration (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`, - args: [ - row.rowId, - row.tenant, - row.slug, - row.pluginId, - row.slug, - row.slug, - row.config, - now, - now, - ], + args: [row.rowId, row.tenant, row.slug, row.pluginId, row.slug, row.slug, row.config, now, now], }); const insertBlob = ( @@ -121,14 +111,7 @@ const insertTool = ( sql: `INSERT INTO tool (tenant, owner, subject, integration, connection, plugin_id, name, description, input_schema, output_schema, annotations, created_at, updated_at, row_id) VALUES (?, 'org', '', ?, 'default', ?, 'items.list', 'List items', NULL, NULL, NULL, ?, ?, ?)`, - args: [ - row.tenant, - row.integration, - row.pluginId, - now, - now, - `tool-${row.integration}`, - ], + args: [row.tenant, row.integration, row.pluginId, now, now, `tool-${row.integration}`], }); const insertDefinition = ( @@ -143,21 +126,13 @@ const insertDefinition = ( sql: `INSERT INTO definition (tenant, owner, subject, integration, connection, plugin_id, name, schema, created_at, row_id) VALUES (?, 'org', '', ?, 'default', ?, 'Item', '{}', ?, ?)`, - args: [ - row.tenant, - row.integration, - row.pluginId, - now, - `definition-${row.integration}`, - ], + args: [row.tenant, row.integration, row.pluginId, now, `definition-${row.integration}`], }); describe("runSqliteGoogleOpenApiOwnershipMigration", () => { it.effect("skips malformed legacy integration config rows", () => Effect.gen(function* () { - const db = yield* Effect.promise(() => - createSqliteTestFumaDb({ tables: collectTables() }), - ); + const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); yield* Effect.promise(() => insertIntegrationRawConfig(db.client, { @@ -169,16 +144,12 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { }), ); - expect(yield* runSqliteGoogleOpenApiOwnershipMigration(db.client)).toBe( - 0, - ); + expect(yield* runSqliteGoogleOpenApiOwnershipMigration(db.client)).toBe(0); const integrations = yield* Effect.promise(() => db.client.execute("SELECT slug, plugin_id, config FROM integration"), ); - expect(integrations.rows).toEqual([ - { slug: "broken", plugin_id: "openapi", config: "" }, - ]); + expect(integrations.rows).toEqual([{ slug: "broken", plugin_id: "openapi", config: "" }]); yield* Effect.promise(() => db.close()); }), @@ -186,9 +157,7 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { it.effect("moves OpenAPI-owned Google bundle rows to the Google plugin", () => Effect.gen(function* () { - const db = yield* Effect.promise(() => - createSqliteTestFumaDb({ tables: collectTables() }), - ); + const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); const client = db.client; yield* Effect.promise(() => @@ -199,9 +168,7 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { pluginId: "openapi", config: { specHash: "googlehash", - googleDiscoveryUrls: [ - "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", - ], + googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"], }, }), ); @@ -277,14 +244,9 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { expect(googleBlob.rows).toEqual([{ value: "google spec" }]); const openApiBlobs = yield* Effect.promise(() => - client.execute( - "SELECT key FROM blob WHERE namespace = 'o:org_1/openapi' ORDER BY key", - ), + client.execute("SELECT key FROM blob WHERE namespace = 'o:org_1/openapi' ORDER BY key"), ); - expect(openApiBlobs.rows).toEqual([ - { key: "spec/googlehash" }, - { key: "spec/stripehash" }, - ]); + expect(openApiBlobs.rows).toEqual([{ key: "spec/googlehash" }, { key: "spec/stripehash" }]); const storage = yield* Effect.promise(() => client.execute( @@ -297,9 +259,7 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { ]); const tools = yield* Effect.promise(() => - client.execute( - "SELECT integration, plugin_id FROM tool ORDER BY integration", - ), + client.execute("SELECT integration, plugin_id FROM tool ORDER BY integration"), ); expect(tools.rows).toEqual([ { integration: "google", plugin_id: "google" }, @@ -307,9 +267,7 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { ]); const definitions = yield* Effect.promise(() => - client.execute( - "SELECT integration, plugin_id FROM definition ORDER BY integration", - ), + client.execute("SELECT integration, plugin_id FROM definition ORDER BY integration"), ); expect(definitions.rows).toEqual([ { integration: "google", plugin_id: "google" }, diff --git a/packages/plugins/google/src/sdk/openapi-ownership-migration.ts b/packages/plugins/google/src/sdk/openapi-ownership-migration.ts index 53baa9a30..718c97f5d 100644 --- a/packages/plugins/google/src/sdk/openapi-ownership-migration.ts +++ b/packages/plugins/google/src/sdk/openapi-ownership-migration.ts @@ -1,8 +1,5 @@ import { Effect } from "effect"; -import { - DataMigrationError, - type SqliteDataMigrationClient, -} from "@executor-js/sdk/core"; +import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk/core"; const MIGRATION_NAME = "2026-06-20-google-openapi-ownership"; const googleOpenApiCandidate = (alias?: string): string => { @@ -16,8 +13,7 @@ const execute = ( ) => Effect.tryPromise({ try: () => client.execute(stmt), - catch: (cause) => - new DataMigrationError({ migration: MIGRATION_NAME, cause }), + catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }), }); export const runSqliteGoogleOpenApiOwnershipMigration = ( diff --git a/packages/plugins/toolkits/CHANGELOG.md b/packages/plugins/toolkits/CHANGELOG.md new file mode 100644 index 000000000..0b9cf2864 --- /dev/null +++ b/packages/plugins/toolkits/CHANGELOG.md @@ -0,0 +1 @@ +# @executor-js/plugin-toolkits diff --git a/packages/plugins/toolkits/src/page.tsx b/packages/plugins/toolkits/src/page.tsx index ae5acd4ac..4bb236ca7 100644 --- a/packages/plugins/toolkits/src/page.tsx +++ b/packages/plugins/toolkits/src/page.tsx @@ -530,11 +530,12 @@ function CreateToolkitDialog(props: { function AddToolkitCard(props: { owner: Owner; onClick: () => void }) { const scopeLabel = addToolkitScopeLabel(props.owner); return ( - + ); } @@ -1047,11 +1048,7 @@ function ToolkitWorkspace(props: { ); const exposedToolIds = useMemo( () => - new Set( - toolkitTools - .filter((tool) => tool.policy.action !== "block") - .map((tool) => tool.id), - ), + new Set(toolkitTools.filter((tool) => tool.policy.action !== "block").map((tool) => tool.id)), [toolkitTools], ); const selectedTool = selectedToolId @@ -1231,9 +1228,7 @@ export function ToolkitsPage(props: PluginPageProps) { const toolkitRows = AsyncResult.isSuccess(toolkits) ? toolkits.value.toolkits : []; const selectedToolkit = - selectedToolkitSlug === null - ? null - : toolkitByRouteSlug(toolkitRows, selectedToolkitSlug); + selectedToolkitSlug === null ? null : toolkitByRouteSlug(toolkitRows, selectedToolkitSlug); const toolRows = AsyncResult.isSuccess(tools) ? (tools.value as readonly ToolRow[]) : []; const integrationRows = AsyncResult.isSuccess(integrations) diff --git a/packages/plugins/toolkits/src/server.ts b/packages/plugins/toolkits/src/server.ts index f8bbecb70..a9e73e64f 100644 --- a/packages/plugins/toolkits/src/server.ts +++ b/packages/plugins/toolkits/src/server.ts @@ -249,9 +249,13 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { const getEntry = (toolkitId: string) => storage.toolkits.get({ key: toolkitId }); const getBySlugEntry = (slug: string) => - storage.toolkits.query({ where: { slug } }).pipe( - Effect.map((entries) => entries.find((entry) => entry.owner === "org") ?? entries[0] ?? null), - ); + storage.toolkits + .query({ where: { slug } }) + .pipe( + Effect.map( + (entries) => entries.find((entry) => entry.owner === "org") ?? entries[0] ?? null, + ), + ); const requireToolkit = (toolkitId: string) => getEntry(toolkitId).pipe( From 07cbee2bb33176acbf3f850810615ba268897eae Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:32:40 -0700 Subject: [PATCH 05/10] Fix toolkit loading skeleton --- e2e/selfhost/toolkits-ui.test.ts | 3 + packages/plugins/toolkits/src/page.tsx | 191 +++++++++++++++++++------ 2 files changed, 149 insertions(+), 45 deletions(-) diff --git a/e2e/selfhost/toolkits-ui.test.ts b/e2e/selfhost/toolkits-ui.test.ts index e0d66ea66..f4dd5fcb9 100644 --- a/e2e/selfhost/toolkits-ui.test.ts +++ b/e2e/selfhost/toolkits-ui.test.ts @@ -56,6 +56,9 @@ scenario( await page.getByRole("heading", { name: "Toolkits" }).waitFor(); await page.getByRole("heading", { name: "Workspace" }).waitFor(); await page.getByRole("heading", { name: "Personal" }).waitFor(); + await page.getByRole("button", { name: "Add workspace toolkit" }).waitFor(); + await page.getByRole("button", { name: "Add personal toolkit" }).waitFor(); + expect(await page.locator('main [data-slot="skeleton"]').count()).toBe(0); expect(await page.getByLabel("New toolkit").count()).toBe(0); }); diff --git a/packages/plugins/toolkits/src/page.tsx b/packages/plugins/toolkits/src/page.tsx index 4bb236ca7..985146d7d 100644 --- a/packages/plugins/toolkits/src/page.tsx +++ b/packages/plugins/toolkits/src/page.tsx @@ -1114,15 +1114,110 @@ function ToolkitWorkspace(props: { ); } -function ToolkitsPageSkeleton() { +function ToolkitTileSkeleton(props: { index: number }) { return ( -
-
- -
- {Array.from({ length: 6 }).map((_, index) => ( - - ))} + + ); +} + +function ToolkitSectionSkeleton(props: { title: string; offset: number }) { + return ( +
+
+

+ {props.title} +

+
+ +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+
+ ); +} + +function ToolkitGridSkeleton() { + return ( +
+
+ + +
+
+ ); +} + +function ToolkitDetailSkeleton() { + return ( +
+
+ +
+
+ + + +
+ +
+
+ +
+
+
+
+
+ + +
+ +
+
+
+ {Array.from({ length: 9 }).map((_, index) => ( +
+ + +
+ ))} +
+
+
+
+ + + +
+
+ + + + +
@@ -1193,7 +1288,7 @@ function ToolkitDetailView(props: { return
Failed to load toolkit
; } if (!AsyncResult.isSuccess(policies) || !AsyncResult.isSuccess(connections)) { - return ; + return ; } return ( @@ -1234,6 +1329,10 @@ export function ToolkitsPage(props: PluginPageProps) { const integrationRows = AsyncResult.isSuccess(integrations) ? (integrations.value as readonly Integration[]) : []; + const toolkitsReady = AsyncResult.isSuccess(toolkits); + const toolkitsFailed = AsyncResult.isFailure(toolkits); + const toolsReady = AsyncResult.isSuccess(tools); + const toolsFailed = AsyncResult.isFailure(tools); const navigateToIndex = () => navigate({ to: "/{-$orgSlug}/plugins/$pluginId/$", @@ -1270,46 +1369,48 @@ export function ToolkitsPage(props: PluginPageProps) {
) : null} - {!AsyncResult.isSuccess(toolkits) || !AsyncResult.isSuccess(tools) ? ( - AsyncResult.isFailure(toolkits) || AsyncResult.isFailure(tools) ? ( + {!toolkitsReady ? ( + toolkitsFailed ? (
Failed to load toolkits
) : ( - + + ) + ) : selectedToolkit ? ( + toolsFailed ? ( +
Failed to load toolkit tools
+ ) : !toolsReady ? ( + + ) : ( + void navigateToIndex()} + onRemoveToolkit={removeToolkitHandler} + /> ) + ) : selectedToolkitSlug !== null ? ( +
+ +
Toolkit not found
+
) : ( - <> - {selectedToolkit ? ( - void navigateToIndex()} - onRemoveToolkit={removeToolkitHandler} - /> - ) : selectedToolkitSlug !== null ? ( -
- -
Toolkit not found
-
- ) : ( - - )} - + )}
); From d1fb12557a797537761a4bb0bc644186358ee978 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:05:12 -0700 Subject: [PATCH 06/10] Refine toolkit card loading layout --- e2e/selfhost/toolkits-ui.test.ts | 6 ++ packages/plugins/toolkits/src/page.tsx | 142 ++++++++++++++++--------- 2 files changed, 96 insertions(+), 52 deletions(-) diff --git a/e2e/selfhost/toolkits-ui.test.ts b/e2e/selfhost/toolkits-ui.test.ts index f4dd5fcb9..c24c27f5d 100644 --- a/e2e/selfhost/toolkits-ui.test.ts +++ b/e2e/selfhost/toolkits-ui.test.ts @@ -59,6 +59,12 @@ scenario( await page.getByRole("button", { name: "Add workspace toolkit" }).waitFor(); await page.getByRole("button", { name: "Add personal toolkit" }).waitFor(); expect(await page.locator('main [data-slot="skeleton"]').count()).toBe(0); + const seededCard = page.getByRole("link", { + name: `Open toolkit ${prefix}-workspace-a`, + }); + await seededCard.waitFor(); + expect(await seededCard.getByText("/mcp/toolkits").count()).toBe(0); + expect(await seededCard.getByText("Workspace tools").count()).toBe(0); expect(await page.getByLabel("New toolkit").count()).toBe(0); }); diff --git a/packages/plugins/toolkits/src/page.tsx b/packages/plugins/toolkits/src/page.tsx index 985146d7d..07d6dca0a 100644 --- a/packages/plugins/toolkits/src/page.tsx +++ b/packages/plugins/toolkits/src/page.tsx @@ -163,17 +163,6 @@ const toolkitUrlFor = (orgSlug: string | undefined, slug: string): string => { const identityPattern = (displayPattern: string): string => displayPattern; -const formatUpdatedAt = (value: number): string => - new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }).format(new Date(value)); - -const toolkitScopeLabel = (toolkit: ToolkitResponse): string => - toolkit.owner === "org" ? "Workspace tools" : "Workspace + personal tools"; - const compareToolkitRows = (a: ToolkitResponse, b: ToolkitResponse): number => { if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt; return a.name.localeCompare(b.name); @@ -192,10 +181,7 @@ const toolkitShelfStyle = { minHeight: "28.5rem" }; const toolkitGridContainerStyle = { maxWidth: "80rem" }; const toolkitToolTreeStyle = { width: "24rem" }; -const ownerAccentClass = (owner: Owner): string => - owner === "org" - ? "border-sky-500/25 bg-sky-500/10 text-sky-700 dark:text-sky-300" - : "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; +const neutralToolkitIconClass = "border-border/70 bg-muted/30 text-muted-foreground"; type ToolkitConnectionGroup = { readonly id: string; @@ -409,9 +395,77 @@ const configuredConnectionPatterns = ( ...policies.filter((policy) => legacyPolicyIds.has(policy.id)).map((policy) => policy.pattern), ]); +function ToolkitConnectionIconStack(props: { connections: readonly ConfiguredConnectionView[] }) { + const visibleConnections = props.connections.slice(0, 3); + if (visibleConnections.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {visibleConnections.map((connection) => ( + + + + ))} +
+
+ ); +} + +const connectionCountLabel = (count: number): string => + count === 0 ? "No connections" : `${count} ${count === 1 ? "connection" : "connections"}`; + function ToolkitTile(props: { pluginId: string; toolkit: ToolkitResponse }) { const toolkit = props.toolkit; - const scope = toolkitScopeLabel(toolkit); + const tools = useAtomValue(toolsAllAtom); + const integrations = useAtomValue(integrationsOptimisticAtom); + const connections = useAtomValue(toolkitConnectionsAtom(toolkit.id)); + const integrationPlugins = useIntegrationPlugins(); + const visibleTools = useMemo( + () => + AsyncResult.isSuccess(tools) + ? (tools.value as readonly ToolRow[]).filter((tool) => + toolCanAppearInToolkit(toolkit, tool), + ) + : [], + [toolkit, tools], + ); + const connectionGroups = useMemo(() => buildConnectionGroups(visibleTools), [visibleTools]); + const connectionRows = AsyncResult.isSuccess(connections) ? connections.value.connections : []; + const integrationRows = AsyncResult.isSuccess(integrations) + ? (integrations.value as readonly Integration[]) + : []; + const tileDataReady = + AsyncResult.isSuccess(connections) && + AsyncResult.isSuccess(tools) && + AsyncResult.isSuccess(integrations); + const configuredConnections = tileDataReady + ? configuredConnectionViews( + connectionRows, + connectionGroups, + integrationRows, + integrationPlugins, + ) + : []; return (
-
- -
+ {tileDataReady ? ( + + ) : ( + + )}
{toolkit.name}
-
- {toolkit.slug} +
+ {tileDataReady ? connectionCountLabel(configuredConnections.length) : "Loading"}
- -
- - /mcp/toolkits/{toolkit.slug} - -
- {scope} - {formatUpdatedAt(toolkit.updatedAt)} -
-
); } @@ -541,7 +582,7 @@ function AddToolkitCard(props: { owner: Owner; onClick: () => void }) { @@ -1117,23 +1158,14 @@ function ToolkitWorkspace(props: { function ToolkitTileSkeleton(props: { index: number }) { return ( ) : null} - {!toolkitsReady ? ( + {selectedToolkitSlug !== null && !toolkitsReady ? ( + toolkitsFailed ? ( +
Failed to load toolkit
+ ) : ( + + ) + ) : !toolkitsReady ? ( toolkitsFailed ? (
Failed to load toolkits
) : ( From 2459f6c875626161e0f32fda46ee214cfa6cfd07 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:52:46 -0700 Subject: [PATCH 07/10] Make toolkits a first-class route --- apps/cloud/src/routeTree.gen.ts | 65 +++ apps/host-cloudflare/web/routeTree.gen.ts | 61 +++ apps/host-selfhost/web/routeTree.gen.ts | 61 +++ e2e/selfhost/toolkits-ui.test.ts | 53 ++- packages/app/src/routeTree.gen.ts | 58 +++ packages/app/src/web/shell.tsx | 7 + packages/plugins/toolkits/src/client.tsx | 1 - packages/plugins/toolkits/src/page.tsx | 379 ++++++++++-------- packages/react/src/console-routes.ts | 4 + packages/react/src/multiplayer/shell.tsx | 1 + .../react/src/routes/plugins.$pluginId.$.tsx | 21 +- packages/react/src/routes/routeTree.gen.ts | 53 +++ packages/react/src/routes/toolkits-route.tsx | 23 ++ .../src/routes/toolkits.$toolkitSlug.tsx | 12 + packages/react/src/routes/toolkits.tsx | 12 + 15 files changed, 614 insertions(+), 197 deletions(-) create mode 100644 packages/react/src/routes/toolkits-route.tsx create mode 100644 packages/react/src/routes/toolkits.$toolkitSlug.tsx create mode 100644 packages/react/src/routes/toolkits.tsx diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index 63392751f..41ae99013 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -14,11 +14,13 @@ import { Route as LoginRouteImport } from './routes/bare/login' import { Route as CreateOrgRouteImport } from './routes/bare/create-org' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' import { Route as SecretsRouteImport } from './routes/app/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' import { Route as OrgRouteImport } from './routes/app/org' import { Route as BillingRouteImport } from './routes/app/billing' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans' @@ -51,6 +53,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = path: '/{-$orgSlug}/tools', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport.update({ + id: '/{-$orgSlug}/toolkits', + path: '/{-$orgSlug}/toolkits', + getParentRoute: () => rootRouteImport, + } as any) const SecretsRoute = SecretsRouteImport.update({ id: '/{-$orgSlug}/secrets', path: '/{-$orgSlug}/secrets', @@ -77,6 +85,15 @@ const ApiKeysRoute = ApiKeysRouteImport.update({ path: '/{-$orgSlug}/api-keys', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update( + { + id: '/$toolkitSlug', + path: '/$toolkitSlug', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute, + } as any, + ) const ResumeDotexecutionIdRoute = ResumeDotexecutionIdRouteImport.update({ id: '/{-$orgSlug}/resume/$executionId', path: '/{-$orgSlug}/resume/$executionId', @@ -113,11 +130,13 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute } export interface FileRoutesByTo { @@ -129,11 +148,13 @@ export interface FileRoutesByTo { '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute } export interface FileRoutesById { @@ -146,11 +167,13 @@ export interface FileRoutesById { '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute } export interface FileRouteTypes { @@ -164,11 +187,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' | '/{-$orgSlug}/billing/plans' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' fileRoutesByTo: FileRoutesByTo to: @@ -180,11 +205,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' | '/{-$orgSlug}/billing/plans' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' id: | '__root__' @@ -196,11 +223,13 @@ export interface FileRouteTypes { | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' | '/{-$orgSlug}/billing_/plans' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' fileRoutesById: FileRoutesById } @@ -213,6 +242,7 @@ export interface RootRouteChildren { OrgRoute: typeof OrgRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute SecretsRoute: typeof SecretsRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute Billing_DotplansRoute: typeof Billing_DotplansRoute @@ -258,6 +288,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/toolkits': { + id: '/{-$orgSlug}/toolkits' + path: '/{-$orgSlug}/toolkits' + fullPath: '/{-$orgSlug}/toolkits' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/secrets': { id: '/{-$orgSlug}/secrets' path: '/{-$orgSlug}/secrets' @@ -293,6 +330,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiKeysRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/toolkits/$toolkitSlug': { + id: '/{-$orgSlug}/toolkits/$toolkitSlug' + path: '/$toolkitSlug' + fullPath: '/{-$orgSlug}/toolkits/$toolkitSlug' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute + } '/{-$orgSlug}/resume/$executionId': { id: '/{-$orgSlug}/resume/$executionId' path: '/{-$orgSlug}/resume/$executionId' @@ -324,6 +368,21 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren, + ) + const rootRouteChildren: RootRouteChildren = { CreateOrgRoute: CreateOrgRoute, LoginRoute: LoginRoute, @@ -334,6 +393,8 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, SecretsRoute: SecretsRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: @@ -350,11 +411,15 @@ export const routeTree = rootRouteImport ._addFileTypes() import type { getRouter } from './router.tsx' + import type { startInstance } from './start.ts' + declare module '@tanstack/react-start' { interface Register { ssr: true + router: Awaited> + config: Awaited> } } diff --git a/apps/host-cloudflare/web/routeTree.gen.ts b/apps/host-cloudflare/web/routeTree.gen.ts index 8b13ec7cf..4938fe854 100644 --- a/apps/host-cloudflare/web/routeTree.gen.ts +++ b/apps/host-cloudflare/web/routeTree.gen.ts @@ -11,8 +11,10 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' @@ -30,6 +32,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = path: '/{-$orgSlug}/tools', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport.update({ + id: '/{-$orgSlug}/toolkits', + path: '/{-$orgSlug}/toolkits', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport.update({ id: '/{-$orgSlug}/secrets', @@ -42,6 +50,15 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute = path: '/{-$orgSlug}/policies', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update( + { + id: '/$toolkitSlug', + path: '/$toolkitSlug', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update( { @@ -78,20 +95,24 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK export interface FileRoutesByFullPath { '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute } export interface FileRoutesByTo { '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute } @@ -99,10 +120,12 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute } @@ -111,30 +134,36 @@ export interface FileRouteTypes { fullPaths: | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' id: | '__root__' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesById: FileRoutesById @@ -142,6 +171,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute @@ -166,6 +196,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/toolkits': { + id: '/{-$orgSlug}/toolkits' + path: '/{-$orgSlug}/toolkits' + fullPath: '/{-$orgSlug}/toolkits' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/secrets': { id: '/{-$orgSlug}/secrets' path: '/{-$orgSlug}/secrets' @@ -180,6 +217,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/toolkits/$toolkitSlug': { + id: '/{-$orgSlug}/toolkits/$toolkitSlug' + path: '/$toolkitSlug' + fullPath: '/{-$orgSlug}/toolkits/$toolkitSlug' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute + } '/{-$orgSlug}/resume/$executionId': { id: '/{-$orgSlug}/resume/$executionId' path: '/{-$orgSlug}/resume/$executionId' @@ -211,11 +255,28 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren, + ) + const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: diff --git a/apps/host-selfhost/web/routeTree.gen.ts b/apps/host-selfhost/web/routeTree.gen.ts index d3c01829f..e7c9fbb94 100644 --- a/apps/host-selfhost/web/routeTree.gen.ts +++ b/apps/host-selfhost/web/routeTree.gen.ts @@ -11,11 +11,13 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as AdminRouteImport } from './routes/app/admin' import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' @@ -33,6 +35,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = path: '/{-$orgSlug}/tools', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport.update({ + id: '/{-$orgSlug}/toolkits', + path: '/{-$orgSlug}/toolkits', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport.update({ id: '/{-$orgSlug}/secrets', @@ -60,6 +68,15 @@ const JoinDotcodeRoute = JoinDotcodeRouteImport.update({ path: '/join/$code', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update( + { + id: '/$toolkitSlug', + path: '/$toolkitSlug', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport.update( { @@ -99,10 +116,12 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute } @@ -112,10 +131,12 @@ export interface FileRoutesByTo { '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute } @@ -126,10 +147,12 @@ export interface FileRoutesById { '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute } @@ -141,10 +164,12 @@ export interface FileRouteTypes { | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesByTo: FileRoutesByTo @@ -154,10 +179,12 @@ export interface FileRouteTypes { | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' id: @@ -167,10 +194,12 @@ export interface FileRouteTypes { | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesById: FileRoutesById @@ -181,6 +210,7 @@ export interface RootRouteChildren { ApiKeysRoute: typeof ApiKeysRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute @@ -205,6 +235,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/toolkits': { + id: '/{-$orgSlug}/toolkits' + path: '/{-$orgSlug}/toolkits' + fullPath: '/{-$orgSlug}/toolkits' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/secrets': { id: '/{-$orgSlug}/secrets' path: '/{-$orgSlug}/secrets' @@ -240,6 +277,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof JoinDotcodeRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/toolkits/$toolkitSlug': { + id: '/{-$orgSlug}/toolkits/$toolkitSlug' + path: '/$toolkitSlug' + fullPath: '/{-$orgSlug}/toolkits/$toolkitSlug' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute + } '/{-$orgSlug}/resume/$executionId': { id: '/{-$orgSlug}/resume/$executionId' path: '/{-$orgSlug}/resume/$executionId' @@ -271,6 +315,21 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren, + ) + const rootRouteChildren: RootRouteChildren = { JoinDotcodeRoute: JoinDotcodeRoute, AdminRoute: AdminRoute, @@ -279,6 +338,8 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: diff --git a/e2e/selfhost/toolkits-ui.test.ts b/e2e/selfhost/toolkits-ui.test.ts index c24c27f5d..665260aec 100644 --- a/e2e/selfhost/toolkits-ui.test.ts +++ b/e2e/selfhost/toolkits-ui.test.ts @@ -52,12 +52,13 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the Toolkits plugin page", async () => { - await page.goto("/plugins/toolkits/", { waitUntil: "networkidle" }); + await page.goto("/default/toolkits/", { waitUntil: "domcontentloaded" }); await page.getByRole("heading", { name: "Toolkits" }).waitFor(); await page.getByRole("heading", { name: "Workspace" }).waitFor(); await page.getByRole("heading", { name: "Personal" }).waitFor(); await page.getByRole("button", { name: "Add workspace toolkit" }).waitFor(); await page.getByRole("button", { name: "Add personal toolkit" }).waitFor(); + await page.locator('main [data-slot="skeleton"]').first().waitFor({ state: "detached" }); expect(await page.locator('main [data-slot="skeleton"]').count()).toBe(0); const seededCard = page.getByRole("link", { name: `Open toolkit ${prefix}-workspace-a`, @@ -104,8 +105,8 @@ scenario( await step("Open the created toolkit from the grid", async () => { await page.getByRole("link", { name: `Open toolkit ${name}` }).click(); - await page.waitForURL(new RegExp(`/plugins/toolkits/${slug}$`)); - expect(page.url()).toMatch(new RegExp(`/plugins/toolkits/${slug}$`)); + await page.waitForURL(new RegExp(`/toolkits/${slug}$`)); + expect(page.url()).toMatch(new RegExp(`/toolkits/${slug}$`)); await page .locator("code") .filter({ hasText: `/mcp/toolkits/${slug}` }) @@ -116,15 +117,15 @@ scenario( await step("Return to the toolkit grid with browser-visible routing", async () => { await page.getByRole("button", { name: "Toolkits" }).click(); - await page.waitForURL(/\/plugins\/toolkits\/?$/); - expect(page.url()).toMatch(/\/plugins\/toolkits\/?$/); + await page.waitForURL(/\/toolkits\/?$/); + expect(page.url()).toMatch(/\/toolkits\/?$/); await page.getByRole("heading", { name: "Workspace" }).waitFor(); await page.getByRole("link", { name: `Open toolkit ${name}` }).waitFor(); }); await step("Open the created toolkit from a direct URL", async () => { - await page.goto(`/plugins/toolkits/${slug}`, { waitUntil: "networkidle" }); - expect(page.url()).toMatch(new RegExp(`/plugins/toolkits/${slug}$`)); + await page.goto(`/default/toolkits/${slug}`, { waitUntil: "domcontentloaded" }); + expect(page.url()).toMatch(new RegExp(`/toolkits/${slug}$`)); await page .locator("code") .filter({ hasText: `/mcp/toolkits/${slug}` }) @@ -133,9 +134,18 @@ scenario( expect(await page.getByLabel("New toolkit").count()).toBe(0); }); + await step("Cancel toolkit deletion from the confirmation modal", async () => { + await page.getByRole("button", { name: "Delete toolkit" }).click(); + const dialog = page.getByRole("alertdialog", { name: `Delete ${name}?` }); + await dialog.waitFor(); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await dialog.waitFor({ state: "detached" }); + await page.getByRole("heading", { name }).waitFor(); + }); + await step("Add a connection to the toolkit", async () => { - await page.getByRole("button", { name: "Add connection to toolkit" }).click(); - const dialog = page.getByRole("dialog", { name: "Add connection" }); + await page.getByRole("button", { name: "Manage toolkit connections" }).click(); + const dialog = page.getByRole("dialog", { name: "Manage connections" }); await dialog.waitFor(); await dialog.getByLabel("Search connections and tools").fill("policies.list"); expect(await dialog.getByRole("button", { name: /^Add tool/ }).count()).toBe(0); @@ -145,6 +155,8 @@ scenario( .getByRole("button", { name: /^Add connection / }) .first() .click(); + await dialog.getByRole("button", { name: /^Remove connection / }).waitFor(); + await page.keyboard.press("Escape"); await dialog.waitFor({ state: "hidden" }); const toolkitTools = page.getByRole("region", { name: "Toolkit tools" }); await toolkitTools.waitFor(); @@ -154,30 +166,39 @@ scenario( }); await step("The add connection list reflects the saved toolkit connection", async () => { - await page.getByRole("button", { name: "Add connection to toolkit" }).click(); - const dialog = page.getByRole("dialog", { name: "Add connection" }); + await page.getByRole("button", { name: "Manage toolkit connections" }).click(); + const dialog = page.getByRole("dialog", { name: "Manage connections" }); await dialog.waitFor(); await dialog.getByLabel("Search connections and tools").fill("policies.list"); - await dialog.getByRole("button", { name: /^Connection added / }).waitFor(); + await dialog.getByRole("button", { name: /^Remove connection / }).waitFor(); expect(await dialog.getByRole("button", { name: /^Add connection / }).count()).toBe(0); await page.keyboard.press("Escape"); await dialog.waitFor({ state: "hidden" }); }); - await step("Remove the connection from the toolkit tools list", async () => { - await page + await step("Remove the connection from the manage modal", async () => { + await page.getByRole("button", { name: "Manage toolkit connections" }).click(); + const removeDialog = page.getByRole("dialog", { name: "Manage connections" }); + await removeDialog.waitFor(); + await removeDialog.getByLabel("Search connections and tools").fill("policies.list"); + await removeDialog .getByRole("button", { name: /^Remove connection / }) .first() .click(); + await removeDialog.getByRole("button", { name: /^Add connection / }).waitFor(); + await page.keyboard.press("Escape"); + await removeDialog.waitFor({ state: "hidden" }); await page.getByText("No connections added").waitFor(); - await page.getByRole("button", { name: "Add connection to toolkit" }).click(); - const dialog = page.getByRole("dialog", { name: "Add connection" }); + await page.getByRole("button", { name: "Manage toolkit connections" }).click(); + const dialog = page.getByRole("dialog", { name: "Manage connections" }); await dialog.waitFor(); await dialog.getByLabel("Search connections and tools").fill("policies.list"); await dialog .getByRole("button", { name: /^Add connection / }) .first() .click(); + await dialog.getByRole("button", { name: /^Remove connection / }).waitFor(); + await page.keyboard.press("Escape"); await dialog.waitFor({ state: "hidden" }); const toolkitTools = page.getByRole("region", { name: "Toolkit tools" }); await toolkitTools.getByLabel("Filter tools").fill("policies.list"); diff --git a/packages/app/src/routeTree.gen.ts b/packages/app/src/routeTree.gen.ts index 4af7cbd13..67b69cc33 100644 --- a/packages/app/src/routeTree.gen.ts +++ b/packages/app/src/routeTree.gen.ts @@ -11,8 +11,10 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as DotDotDotDotDotDotReactSrcRoutesIndexRouteImport } from './../../react/src/routes/index' import { Route as DotDotDotDotDotDotReactSrcRoutesToolsRouteImport } from './../../react/src/routes/tools' +import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport } from './../../react/src/routes/toolkits' import { Route as SecretsRouteImport } from './routes/app/secrets' import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './../../react/src/routes/policies' +import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId' import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$' @@ -30,6 +32,12 @@ const DotDotDotDotDotDotReactSrcRoutesToolsRoute = path: '/{-$orgSlug}/tools', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotReactSrcRoutesToolkitsRoute = + DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport.update({ + id: '/{-$orgSlug}/toolkits', + path: '/{-$orgSlug}/toolkits', + getParentRoute: () => rootRouteImport, + } as any) const SecretsRoute = SecretsRouteImport.update({ id: '/{-$orgSlug}/secrets', path: '/{-$orgSlug}/secrets', @@ -41,6 +49,12 @@ const DotDotDotDotDotDotReactSrcRoutesPoliciesRoute = path: '/{-$orgSlug}/policies', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute = + DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update({ + id: '/$toolkitSlug', + path: '/$toolkitSlug', + getParentRoute: () => DotDotDotDotDotDotReactSrcRoutesToolkitsRoute, + } as any) const DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute = DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport.update({ id: '/{-$orgSlug}/resume/$executionId', @@ -71,20 +85,24 @@ const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute = export interface FileRoutesByFullPath { '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute } export interface FileRoutesByTo { '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute } @@ -92,10 +110,12 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute + '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute } @@ -104,30 +124,36 @@ export interface FileRouteTypes { fullPaths: | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' id: | '__root__' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesById: FileRoutesById @@ -135,6 +161,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute SecretsRoute: typeof SecretsRoute + DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute @@ -159,6 +186,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/toolkits': { + id: '/{-$orgSlug}/toolkits' + path: '/{-$orgSlug}/toolkits' + fullPath: '/{-$orgSlug}/toolkits' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/secrets': { id: '/{-$orgSlug}/secrets' path: '/{-$orgSlug}/secrets' @@ -173,6 +207,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/toolkits/$toolkitSlug': { + id: '/{-$orgSlug}/toolkits/$toolkitSlug' + path: '/$toolkitSlug' + fullPath: '/{-$orgSlug}/toolkits/$toolkitSlug' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport + parentRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRoute + } '/{-$orgSlug}/resume/$executionId': { id: '/{-$orgSlug}/resume/$executionId' path: '/{-$orgSlug}/resume/$executionId' @@ -204,10 +245,27 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren { + DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute +} + +const DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren: DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren = + { + DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute: + DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute, + } + +const DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren = + DotDotDotDotDotDotReactSrcRoutesToolkitsRoute._addFileChildren( + DotDotDotDotDotDotReactSrcRoutesToolkitsRouteChildren, + ) + const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotReactSrcRoutesPoliciesRoute, SecretsRoute: SecretsRoute, + DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: + DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren, DotDotDotDotDotDotReactSrcRoutesToolsRoute: DotDotDotDotDotDotReactSrcRoutesToolsRoute, DotDotDotDotDotDotReactSrcRoutesIndexRoute: diff --git a/packages/app/src/web/shell.tsx b/packages/app/src/web/shell.tsx index 234f85174..2cc0c46e8 100644 --- a/packages/app/src/web/shell.tsx +++ b/packages/app/src/web/shell.tsx @@ -342,6 +342,7 @@ function SidebarContent(props: { const isHome = props.pathname === "/"; const isSecrets = props.pathname === "/secrets"; const isPolicies = props.pathname === "/policies"; + const isToolkits = props.pathname === "/toolkits" || props.pathname.startsWith("/toolkits/"); return ( <> @@ -381,6 +382,12 @@ function SidebarContent(props: { active={isPolicies} onNavigate={props.onNavigate} /> + diff --git a/packages/plugins/toolkits/src/client.tsx b/packages/plugins/toolkits/src/client.tsx index 18628fee1..0bf370371 100644 --- a/packages/plugins/toolkits/src/client.tsx +++ b/packages/plugins/toolkits/src/client.tsx @@ -8,7 +8,6 @@ export default defineClientPlugin({ { path: "/", component: ToolkitsPage, - nav: { label: "Toolkits" }, }, { path: "/$toolkitSlug", diff --git a/packages/plugins/toolkits/src/page.tsx b/packages/plugins/toolkits/src/page.tsx index 07d6dca0a..031c03d74 100644 --- a/packages/plugins/toolkits/src/page.tsx +++ b/packages/plugins/toolkits/src/page.tsx @@ -1,17 +1,9 @@ import { useId, useMemo, useState } from "react"; -import { Link, useNavigate, useParams } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import * as Atom from "effect/unstable/reactivity/Atom"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { - ArrowLeftIcon, - BoxIcon, - CheckIcon, - PlugIcon, - PlusIcon, - SearchIcon, - Trash2Icon, -} from "lucide-react"; +import { ArrowLeftIcon, BoxIcon, PlugIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react"; import { createPluginAtomClient, useIntegrationPlugins, @@ -28,15 +20,27 @@ import { } from "@executor-js/sdk/shared"; import { integrationsOptimisticAtom, toolsAllAtom } from "@executor-js/react/api/atoms"; import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; +import { useOrganizationSlug } from "@executor-js/react/api/organization-context"; import { getExecutorApiBaseUrl, getExecutorOrganizationHeaders, getExecutorServerAuthorizationHeader, } from "@executor-js/react/api/server-connection"; -import { ownerLabel } from "@executor-js/react/api/owner-display"; +import { ownerLabel, useOwnerDisplay } from "@executor-js/react/api/owner-display"; import { Badge } from "@executor-js/react/components/badge"; import { Button } from "@executor-js/react/components/button"; import { CopyButton } from "@executor-js/react/components/copy-button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@executor-js/react/components/alert-dialog"; import { IntegrationFavicon, integrationPresetIconUrl, @@ -294,6 +298,9 @@ const compareTools = (a: ToolRow, b: ToolRow): number => const connectionTitle = (group: ToolkitConnectionGroup): string => `${ownerLabel(group.owner)} ${group.integration} / ${group.connection}`; +const connectionTitleForHost = (group: ToolkitConnectionGroup, showOwnerLabels: boolean): string => + showOwnerLabels ? connectionTitle(group) : `${group.integration} / ${group.connection}`; + const connectionDisplayTitle = (group: ToolkitConnectionGroup, meta: IntegrationMeta): string => group.connection === "built-in" || group.connection === "default" ? meta.name : group.connection; @@ -302,6 +309,15 @@ const connectionDisplaySubtitle = (group: ToolkitConnectionGroup, meta: Integrat ? `${group.tools.length} ${group.tools.length === 1 ? "tool" : "tools"}` : `${meta.name} · ${group.tools.length} ${group.tools.length === 1 ? "tool" : "tools"}`; +const connectionDisplaySubtitleForHost = ( + group: ToolkitConnectionGroup, + meta: IntegrationMeta, + showOwnerLabels: boolean, +): string => { + const subtitle = connectionDisplaySubtitle(group, meta); + return showOwnerLabels ? `${ownerLabel(group.owner)} · ${subtitle}` : subtitle; +}; + const integrationMetaFor = ( group: ToolkitConnectionGroup, integrations: readonly Integration[], @@ -340,6 +356,7 @@ const configuredConnectionViews = ( connectionGroups: readonly ToolkitConnectionGroup[], integrations: readonly Integration[], integrationPlugins: readonly IntegrationPlugin[], + showOwnerLabels: boolean, ): readonly ConfiguredConnectionView[] => connections.map((connection) => { const group = connectionGroups.find((candidate) => @@ -358,7 +375,7 @@ const configuredConnectionViews = ( return { id: connection.id, title: connectionDisplayTitle(group, meta), - subtitle: `${ownerLabel(group.owner)} · ${connectionDisplaySubtitle(group, meta)}`, + subtitle: connectionDisplaySubtitleForHost(group, meta, showOwnerLabels), pattern: connection.pattern, sourceId: meta.sourceId, icon: meta.icon, @@ -434,7 +451,7 @@ function ToolkitConnectionIconStack(props: { connections: readonly ConfiguredCon const connectionCountLabel = (count: number): string => count === 0 ? "No connections" : `${count} ${count === 1 ? "connection" : "connections"}`; -function ToolkitTile(props: { pluginId: string; toolkit: ToolkitResponse }) { +function ToolkitTile(props: { showOwnerLabels: boolean; toolkit: ToolkitResponse }) { const toolkit = props.toolkit; const tools = useAtomValue(toolsAllAtom); const integrations = useAtomValue(integrationsOptimisticAtom); @@ -464,12 +481,13 @@ function ToolkitTile(props: { pluginId: string; toolkit: ToolkitResponse }) { connectionGroups, integrationRows, integrationPlugins, + props.showOwnerLabels, ) : []; return ( (owner === "org" ? "workspace" : "personal"); -const addToolkitTitle = (owner: Owner): string => - owner === "org" ? "New workspace toolkit" : "New personal toolkit"; +const addToolkitScopeLabel = (owner: Owner, showOwnerLabels: boolean): string => + showOwnerLabels ? (owner === "org" ? "workspace" : "personal") : ""; +const addToolkitTitle = (owner: Owner, showOwnerLabels: boolean): string => + showOwnerLabels + ? owner === "org" + ? "New workspace toolkit" + : "New personal toolkit" + : "New toolkit"; function CreateToolkitDialog(props: { owner: Owner; + showOwnerLabels: boolean; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (input: { owner: Owner; name: string }) => Promise; @@ -504,7 +528,7 @@ function CreateToolkitDialog(props: { const inputId = useId(); const [name, setName] = useState(""); const trimmed = name.trim(); - const title = addToolkitTitle(props.owner); + const title = addToolkitTitle(props.owner, props.showOwnerLabels); const submit = async () => { if (!trimmed) return; @@ -568,14 +592,14 @@ function CreateToolkitDialog(props: { ); } -function AddToolkitCard(props: { owner: Owner; onClick: () => void }) { - const scopeLabel = addToolkitScopeLabel(props.owner); +function AddToolkitCard(props: { owner: Owner; showOwnerLabels: boolean; onClick: () => void }) { + const scopeLabel = addToolkitScopeLabel(props.owner, props.showOwnerLabels); return (
); } -function ToolkitConfiguredConnections(props: { - connections: readonly ConfiguredConnectionView[]; - onRemoveConnection: (connectionId: string) => void; -}) { - if (props.connections.length === 0) return null; - return ( -
-
- - Connections - - - {props.connections.length} - -
-
- {props.connections.map((connection) => ( -
- - - -
-
{connection.title}
-
- {connection.subtitle} -
-
- -
- ))} -
-
- ); -} - function ToolkitToolsPanel(props: { tools: readonly ToolSummary[]; - configuredConnections: readonly ConfiguredConnectionView[]; selectedToolId: string | null; policies: readonly ToolkitPolicyResponse[]; - onAddConnection: () => void; - onRemoveConnection: (connectionId: string) => void; + onManageConnections: () => void; onSelectTool: (toolId: string) => void; onSetPolicy: (pattern: string, action: ToolPolicyAction) => void; onClearPolicy: (pattern: string) => void; @@ -748,38 +737,12 @@ function ToolkitToolsPanel(props: { return (
-
-
-
-

- Toolkit tools -

-

- {props.tools.length} {props.tools.length === 1 ? "tool" : "tools"} -

-
- -
-
- {props.tools.length === 0 ? ( - + ) : ( ; + configuredConnections: readonly ConfiguredConnectionView[]; integrations: readonly Integration[]; integrationPlugins: readonly IntegrationPlugin[]; + showOwnerLabels: boolean; onOpenChange: (open: boolean) => void; onAddPatterns: (patterns: readonly string[]) => Promise | void; + onRemoveConnection: (connectionId: string) => Promise | void; }) { const searchId = useId(); const [query, setQuery] = useState(""); @@ -823,9 +788,12 @@ function AddConnectionDialog(props: { }); }, [props.groups, trimmedQuery]); - const addAndClose = async (patterns: readonly string[]) => { + const addConnection = async (patterns: readonly string[]) => { await props.onAddPatterns(patterns); - props.onOpenChange(false); + }; + + const removeConnection = async (connectionId: string) => { + await props.onRemoveConnection(connectionId); }; return ( @@ -852,9 +820,9 @@ function AddConnectionDialog(props: {
- Add connection + Manage connections - Choose which connected account this toolkit can use. + Choose which connected accounts this toolkit can use.
@@ -892,10 +860,11 @@ function AddConnectionDialog(props: { ) : (
{filteredGroups.map((group) => { - const title = connectionTitle(group); - const added = group.tools.every((tool) => - props.configuredToolIds.has(toolMatchId(tool)), + const title = connectionTitleForHost(group, props.showOwnerLabels); + const configuredConnection = props.configuredConnections.find((connection) => + group.patterns.includes(connection.pattern), ); + const added = configuredConnection !== undefined; const meta = integrationMetaFor( group, props.integrations, @@ -919,9 +888,11 @@ function AddConnectionDialog(props: { {connectionDisplayTitle(group, meta)} - - {ownerLabel(group.owner)} - + {props.showOwnerLabels ? ( + + {ownerLabel(group.owner)} + + ) : null}
@@ -933,22 +904,25 @@ function AddConnectionDialog(props: { type="button" variant={added ? "outline" : "default"} size="sm" - disabled={added} - aria-label={`${added ? "Connection added" : "Add connection"} ${title}`} - onClick={() => void addAndClose(group.patterns)} + aria-label={`${added ? "Remove connection" : "Add connection"} ${title}`} + onClick={() => + void (added && configuredConnection + ? removeConnection(configuredConnection.id) + : addConnection(group.patterns)) + } className={cn( "h-7 shrink-0 px-2.5 text-xs", added - ? "border-border/70 bg-transparent text-muted-foreground" + ? "border-border/70 bg-transparent text-muted-foreground hover:text-destructive" : "bg-primary/10 text-primary hover:bg-primary/15", )} > {added ? ( - + ) : ( )} - {added ? "Added" : "Add"} + {added ? "Remove" : "Add"}
); @@ -964,9 +938,11 @@ function AddConnectionDialog(props: { function ToolkitHeader(props: { toolkit: ToolkitResponse; + showOwnerLabels: boolean; toolCount: number; mcpUrl: string; onBack: () => void; + onManageConnections: () => void; onRemove: () => void; }) { return ( @@ -985,9 +961,11 @@ function ToolkitHeader(props: {

{props.toolkit.name}

- - {ownerLabel(props.toolkit.owner)} - + {props.showOwnerLabels ? ( + + {ownerLabel(props.toolkit.owner)} + + ) : null} {props.toolCount} {props.toolCount === 1 ? "tool" : "tools"} @@ -999,16 +977,46 @@ function ToolkitHeader(props: {
- +
+ + + + + + + + Delete {props.toolkit.name}? + + This removes the toolkit and its dedicated MCP endpoint. Connections are not + deleted. + + + + Cancel + + Delete + + + + +
); @@ -1016,6 +1024,7 @@ function ToolkitHeader(props: { function ToolkitWorkspace(props: { toolkit: ToolkitResponse; + showOwnerLabels: boolean; policies: readonly ToolkitPolicyResponse[]; connections: readonly ToolkitConnectionResponse[]; tools: readonly ToolRow[]; @@ -1043,8 +1052,15 @@ function ToolkitWorkspace(props: { connectionGroups, props.integrations, props.integrationPlugins, + props.showOwnerLabels, ), - [connectionGroups, props.connections, props.integrationPlugins, props.integrations], + [ + connectionGroups, + props.connections, + props.integrationPlugins, + props.integrations, + props.showOwnerLabels, + ], ); const legacyPolicyIds = useMemo( () => legacyConnectionPolicyIds(props.policies, connectionGroups, props.connections), @@ -1103,20 +1119,20 @@ function ToolkitWorkspace(props: {
setAddOpen(true)} onRemove={props.onRemoveToolkit} />
setAddOpen(true)} - onRemoveConnection={(connectionId) => void props.onRemoveConnection(connectionId)} + onManageConnections={() => setAddOpen(true)} onSelectTool={setSelectedToolId} onSetPolicy={(pattern, action) => void props.onSetPolicy(pattern, action)} onClearPolicy={(pattern) => void props.onClearPolicy(pattern)} @@ -1142,14 +1158,16 @@ function ToolkitWorkspace(props: { open={addOpen} onOpenChange={setAddOpen} groups={connectionGroups} - configuredToolIds={configuredToolIds} + configuredConnections={configuredConnections} integrations={props.integrations} integrationPlugins={props.integrationPlugins} + showOwnerLabels={props.showOwnerLabels} onAddPatterns={async (patterns) => { for (const pattern of patterns) { await props.onAddConnection(pattern); } }} + onRemoveConnection={props.onRemoveConnection} />
); @@ -1171,14 +1189,16 @@ function ToolkitTileSkeleton(props: { index: number }) { ); } -function ToolkitSectionSkeleton(props: { title: string; offset: number }) { +function ToolkitSectionSkeleton(props: { title?: string; offset: number }) { return ( -
-
-

- {props.title} -

-
+
+ {props.title ? ( +
+

+ {props.title} +

+
+ ) : null}
{Array.from({ length: 3 }).map((_, index) => ( @@ -1189,12 +1209,18 @@ function ToolkitSectionSkeleton(props: { title: string; offset: number }) { ); } -function ToolkitGridSkeleton() { +function ToolkitGridSkeleton(props: { showOwnerLabels: boolean }) { return (
- - + {props.showOwnerLabels ? ( + <> + + + + ) : ( + + )}
); @@ -1258,6 +1284,7 @@ function ToolkitDetailSkeleton() { function ToolkitDetailView(props: { toolkit: ToolkitResponse; + showOwnerLabels: boolean; tools: readonly ToolRow[]; integrations: readonly Integration[]; integrationPlugins: readonly IntegrationPlugin[]; @@ -1326,6 +1353,7 @@ function ToolkitDetailView(props: { return ( navigate({ - to: "/{-$orgSlug}/plugins/$pluginId/$", - params: { pluginId: props.pluginId, _splat: "" }, + to: "/{-$orgSlug}/toolkits", }); const createToolkitHandler = async (input: { owner: Owner; name: string }) => { @@ -1411,7 +1439,7 @@ export function ToolkitsPage(props: PluginPageProps) { toolkitsFailed ? (
Failed to load toolkits
) : ( - + ) ) : selectedToolkit ? ( toolsFailed ? ( @@ -1421,10 +1449,11 @@ export function ToolkitsPage(props: PluginPageProps) { ) : ( void navigateToIndex()} onRemoveToolkit={removeToolkitHandler} /> @@ -1444,11 +1473,7 @@ export function ToolkitsPage(props: PluginPageProps) {
Toolkit not found
) : ( - + )}
); diff --git a/packages/react/src/console-routes.ts b/packages/react/src/console-routes.ts index 4dd5fc1e7..33176493c 100644 --- a/packages/react/src/console-routes.ts +++ b/packages/react/src/console-routes.ts @@ -39,6 +39,8 @@ export const CONSOLE_ROUTE_PATHS = [ "/policies", "/secrets", "/tools", + "/toolkits", + "/toolkits/$toolkitSlug", "/resume/$executionId", "/plugins/$pluginId/$", ] as const; @@ -71,6 +73,8 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array = [ { to: "/", label: "Integrations" }, { to: "/secrets", label: "Providers" }, { to: "/policies", label: "Policies" }, + { to: "/toolkits", label: "Toolkits" }, ]; /** Canonical public docs (Mintlify). Same-origin on cloud (executor.sh proxies diff --git a/packages/react/src/routes/plugins.$pluginId.$.tsx b/packages/react/src/routes/plugins.$pluginId.$.tsx index 7bc98f277..bea006420 100644 --- a/packages/react/src/routes/plugins.$pluginId.$.tsx +++ b/packages/react/src/routes/plugins.$pluginId.$.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, notFound } from "@tanstack/react-router"; +import { createFileRoute, Navigate, notFound } from "@tanstack/react-router"; import { useClientPlugins } from "@executor-js/sdk/client"; // --------------------------------------------------------------------------- @@ -84,13 +84,28 @@ export const matchPluginPage = ( }; function PluginRouteComponent() { - const { pluginId, _splat: rest } = Route.useParams(); + const { orgSlug, pluginId, _splat: rest } = Route.useParams(); const plugins = useClientPlugins(); + const target = normalizePath(rest ?? "/"); + if (pluginId === "toolkits") { + const segments = pathSegments(target); + const toolkitSlug = segments[0]; + if (toolkitSlug) { + return ( + + ); + } + return ; + } + const plugin = plugins.find((p) => p.id === pluginId); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound() if (!plugin) throw notFound(); - const target = normalizePath(rest ?? "/"); const match = matchPluginPage(plugin.pages, target); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound() if (!match) throw notFound(); diff --git a/packages/react/src/routes/routeTree.gen.ts b/packages/react/src/routes/routeTree.gen.ts index c8e8d8f9f..878b5ceda 100644 --- a/packages/react/src/routes/routeTree.gen.ts +++ b/packages/react/src/routes/routeTree.gen.ts @@ -11,8 +11,10 @@ import { Route as rootRouteImport } from './__root' import { Route as DotIndexRouteImport } from './index' import { Route as DotToolsRouteImport } from './tools' +import { Route as DotToolkitsRouteImport } from './toolkits' import { Route as DotSecretsRouteImport } from './secrets' import { Route as DotPoliciesRouteImport } from './policies' +import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug' import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId' import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace' import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$' @@ -28,6 +30,11 @@ const DotToolsRoute = DotToolsRouteImport.update({ path: '/{-$orgSlug}/tools', getParentRoute: () => rootRouteImport, } as any) +const DotToolkitsRoute = DotToolkitsRouteImport.update({ + id: '/{-$orgSlug}/toolkits', + path: '/{-$orgSlug}/toolkits', + getParentRoute: () => rootRouteImport, +} as any) const DotSecretsRoute = DotSecretsRouteImport.update({ id: '/{-$orgSlug}/secrets', path: '/{-$orgSlug}/secrets', @@ -38,6 +45,12 @@ const DotPoliciesRoute = DotPoliciesRouteImport.update({ path: '/{-$orgSlug}/policies', getParentRoute: () => rootRouteImport, } as any) +const DotToolkitsDottoolkitSlugRoute = + DotToolkitsDottoolkitSlugRouteImport.update({ + id: '/$toolkitSlug', + path: '/$toolkitSlug', + getParentRoute: () => DotToolkitsRoute, + } as any) const DotResumeDotexecutionIdRoute = DotResumeDotexecutionIdRouteImport.update({ id: '/{-$orgSlug}/resume/$executionId', path: '/{-$orgSlug}/resume/$executionId', @@ -65,20 +78,24 @@ const DotIntegrationsDotaddDotpluginKeyRoute = export interface FileRoutesByFullPath { '/{-$orgSlug}/policies': typeof DotPoliciesRoute '/{-$orgSlug}/secrets': typeof DotSecretsRoute + '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotToolsRoute '/{-$orgSlug}/': typeof DotIndexRoute '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute } export interface FileRoutesByTo { '/{-$orgSlug}/policies': typeof DotPoliciesRoute '/{-$orgSlug}/secrets': typeof DotSecretsRoute + '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotToolsRoute '/{-$orgSlug}': typeof DotIndexRoute '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute } @@ -86,10 +103,12 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/{-$orgSlug}/policies': typeof DotPoliciesRoute '/{-$orgSlug}/secrets': typeof DotSecretsRoute + '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotToolsRoute '/{-$orgSlug}/': typeof DotIndexRoute '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute + '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute } @@ -98,30 +117,36 @@ export interface FileRouteTypes { fullPaths: | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' id: | '__root__' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' + | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' | '/{-$orgSlug}/integrations/$namespace' | '/{-$orgSlug}/resume/$executionId' + | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesById: FileRoutesById @@ -129,6 +154,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { DotPoliciesRoute: typeof DotPoliciesRoute DotSecretsRoute: typeof DotSecretsRoute + DotToolkitsRoute: typeof DotToolkitsRouteWithChildren DotToolsRoute: typeof DotToolsRoute DotIndexRoute: typeof DotIndexRoute DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute @@ -153,6 +179,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotToolsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/toolkits': { + id: '/{-$orgSlug}/toolkits' + path: '/{-$orgSlug}/toolkits' + fullPath: '/{-$orgSlug}/toolkits' + preLoaderRoute: typeof DotToolkitsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/secrets': { id: '/{-$orgSlug}/secrets' path: '/{-$orgSlug}/secrets' @@ -167,6 +200,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotPoliciesRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/toolkits/$toolkitSlug': { + id: '/{-$orgSlug}/toolkits/$toolkitSlug' + path: '/$toolkitSlug' + fullPath: '/{-$orgSlug}/toolkits/$toolkitSlug' + preLoaderRoute: typeof DotToolkitsDottoolkitSlugRouteImport + parentRoute: typeof DotToolkitsRoute + } '/{-$orgSlug}/resume/$executionId': { id: '/{-$orgSlug}/resume/$executionId' path: '/{-$orgSlug}/resume/$executionId' @@ -198,9 +238,22 @@ declare module '@tanstack/react-router' { } } +interface DotToolkitsRouteChildren { + DotToolkitsDottoolkitSlugRoute: typeof DotToolkitsDottoolkitSlugRoute +} + +const DotToolkitsRouteChildren: DotToolkitsRouteChildren = { + DotToolkitsDottoolkitSlugRoute: DotToolkitsDottoolkitSlugRoute, +} + +const DotToolkitsRouteWithChildren = DotToolkitsRoute._addFileChildren( + DotToolkitsRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { DotPoliciesRoute: DotPoliciesRoute, DotSecretsRoute: DotSecretsRoute, + DotToolkitsRoute: DotToolkitsRouteWithChildren, DotToolsRoute: DotToolsRoute, DotIndexRoute: DotIndexRoute, DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute, diff --git a/packages/react/src/routes/toolkits-route.tsx b/packages/react/src/routes/toolkits-route.tsx new file mode 100644 index 000000000..9072805e3 --- /dev/null +++ b/packages/react/src/routes/toolkits-route.tsx @@ -0,0 +1,23 @@ +import { notFound } from "@tanstack/react-router"; +import { useClientPlugins } from "@executor-js/sdk/client"; + +export function ToolkitsPluginRoute(props: { toolkitSlug?: string }) { + const plugins = useClientPlugins(); + const plugin = plugins.find((candidate) => candidate.id === "toolkits"); + const path = props.toolkitSlug ? `/${props.toolkitSlug}` : "/"; + const page = plugin?.pages?.find((candidate) => + props.toolkitSlug ? candidate.path === "/$toolkitSlug" : candidate.path === "/", + ); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound() + if (!plugin || !page) throw notFound(); + + const Component = page.component; + return ( + + ); +} diff --git a/packages/react/src/routes/toolkits.$toolkitSlug.tsx b/packages/react/src/routes/toolkits.$toolkitSlug.tsx new file mode 100644 index 000000000..575a2e224 --- /dev/null +++ b/packages/react/src/routes/toolkits.$toolkitSlug.tsx @@ -0,0 +1,12 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ToolkitsPluginRoute } from "./toolkits-route"; + +export const Route = createFileRoute("/{-$orgSlug}/toolkits/$toolkitSlug")({ + component: ToolkitsRouteComponent, +}); + +function ToolkitsRouteComponent() { + const { toolkitSlug } = Route.useParams(); + return ; +} diff --git a/packages/react/src/routes/toolkits.tsx b/packages/react/src/routes/toolkits.tsx new file mode 100644 index 000000000..0d9de023f --- /dev/null +++ b/packages/react/src/routes/toolkits.tsx @@ -0,0 +1,12 @@ +import { createFileRoute, useParams } from "@tanstack/react-router"; + +import { ToolkitsPluginRoute } from "./toolkits-route"; + +export const Route = createFileRoute("/{-$orgSlug}/toolkits")({ + component: ToolkitsRouteComponent, +}); + +function ToolkitsRouteComponent() { + const { toolkitSlug } = useParams({ strict: false }) as { toolkitSlug?: string }; + return ; +} From 2d221abb4421034f637bcfdf8d4e1549dc99d519 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Fri, 26 Jun 2026 14:26:53 -0700 Subject: [PATCH 08/10] Polish toolkit detail header --- packages/plugins/toolkits/src/page.tsx | 58 ++++++++++++-------------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/packages/plugins/toolkits/src/page.tsx b/packages/plugins/toolkits/src/page.tsx index 031c03d74..84d0c8b9b 100644 --- a/packages/plugins/toolkits/src/page.tsx +++ b/packages/plugins/toolkits/src/page.tsx @@ -737,7 +737,7 @@ function ToolkitToolsPanel(props: { return (
@@ -939,54 +939,54 @@ function AddConnectionDialog(props: { function ToolkitHeader(props: { toolkit: ToolkitResponse; showOwnerLabels: boolean; - toolCount: number; mcpUrl: string; onBack: () => void; onManageConnections: () => void; onRemove: () => void; }) { return ( -
- -
-
-
+
+
+
+ +

{props.toolkit.name}

{props.showOwnerLabels ? ( {ownerLabel(props.toolkit.owner)} ) : null} - - {props.toolCount} {props.toolCount === 1 ? "tool" : "tools"} -
-
- +
+ + MCP + + {props.mcpUrl} - +
-
+
@@ -1103,11 +1103,6 @@ function ToolkitWorkspace(props: { }), [accessPolicies, configuredTools], ); - const exposedToolIds = useMemo( - () => - new Set(toolkitTools.filter((tool) => tool.policy.action !== "block").map((tool) => tool.id)), - [toolkitTools], - ); const selectedTool = selectedToolId ? (configuredTools.find((tool) => toolMatchId(tool) === selectedToolId) ?? null) : null; @@ -1120,7 +1115,6 @@ function ToolkitWorkspace(props: { setAddOpen(true)} From 21e3206baa4e517325fb6ecdcc01b816f521aba5 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Fri, 26 Jun 2026 14:54:40 -0700 Subject: [PATCH 09/10] Explain hidden personal toolkit connections --- e2e/selfhost/toolkits-ui.test.ts | 68 +++++++++++++++++++++++++- packages/plugins/toolkits/src/page.tsx | 14 ++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/e2e/selfhost/toolkits-ui.test.ts b/e2e/selfhost/toolkits-ui.test.ts index 665260aec..80d3235fc 100644 --- a/e2e/selfhost/toolkits-ui.test.ts +++ b/e2e/selfhost/toolkits-ui.test.ts @@ -3,12 +3,29 @@ import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; -const api = composePluginApi([toolkitsPlugin()] as const); +const api = composePluginApi([toolkitsPlugin(), openApiHttpPlugin()] as const); + +const hiddenPersonalSpec = (baseUrl: string): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Personal Hidden API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/personal-only": { + get: { + operationId: "personalOnly", + responses: { "200": { description: "Personal-only response" } }, + }, + }, + }, + }); scenario( "Toolkits · self-host UI creates a toolkit and configures tools", @@ -24,6 +41,8 @@ scenario( const prefix = `toolkits-ui-${suffix}`; const name = `${prefix}-created`; const slug = name; + const hiddenPersonalIntegration = `${prefix}-personal-api`; + const hiddenPersonalConnection = "mine"; const seededToolkits = [ { owner: "org" as const, name: `${prefix}-workspace-a` }, { owner: "org" as const, name: `${prefix}-workspace-b` }, @@ -35,6 +54,18 @@ scenario( const blockPattern = "executor.coreTools.policies.list"; const cleanup = Effect.gen(function* () { + yield* client.connections + .remove({ + params: { + owner: "user", + integration: IntegrationSlug.make(hiddenPersonalIntegration), + name: ConnectionName.make(hiddenPersonalConnection), + }, + }) + .pipe(Effect.ignore); + yield* client.openapi + .removeSpec({ params: { slug: hiddenPersonalIntegration } }) + .pipe(Effect.ignore); const listed = yield* client.toolkits.list(); yield* Effect.forEach( listed.toolkits.filter((row) => row.slug.startsWith(prefix)), @@ -49,6 +80,29 @@ scenario( (toolkit) => client.toolkits.create({ payload: toolkit }), { discard: true }, ); + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: hiddenPersonalSpec("http://127.0.0.1:59999") }, + slug: IntegrationSlug.make(hiddenPersonalIntegration), + baseUrl: "http://127.0.0.1:59999", + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { "x-api-key": [{ type: "variable", name: "token" }] }, + }, + ], + }, + }); + yield* client.connections.create({ + payload: { + owner: "user", + name: ConnectionName.make(hiddenPersonalConnection), + integration: IntegrationSlug.make(hiddenPersonalIntegration), + template: AuthTemplateSlug.make("apiKey"), + value: "unused-token", + }, + }); yield* browser.session(identity, async ({ page, step }) => { await step("Open the Toolkits plugin page", async () => { @@ -143,8 +197,18 @@ scenario( await page.getByRole("heading", { name }).waitFor(); }); - await step("Add a connection to the toolkit", async () => { + await step("The connection picker explains hidden personal connections", async () => { await page.getByRole("button", { name: "Manage toolkit connections" }).click(); + const dialog = page.getByRole("dialog", { name: "Manage connections" }); + await dialog.waitFor(); + await dialog + .getByText( + "You have 1 personal connection that is not shown because this is a shared toolkit.", + ) + .waitFor(); + }); + + await step("Add a connection to the toolkit", async () => { const dialog = page.getByRole("dialog", { name: "Manage connections" }); await dialog.waitFor(); await dialog.getByLabel("Search connections and tools").fill("policies.list"); diff --git a/packages/plugins/toolkits/src/page.tsx b/packages/plugins/toolkits/src/page.tsx index 84d0c8b9b..29f2eb633 100644 --- a/packages/plugins/toolkits/src/page.tsx +++ b/packages/plugins/toolkits/src/page.tsx @@ -763,6 +763,7 @@ function AddConnectionDialog(props: { open: boolean; groups: readonly ToolkitConnectionGroup[]; configuredConnections: readonly ConfiguredConnectionView[]; + hiddenPersonalConnectionCount: number; integrations: readonly Integration[]; integrationPlugins: readonly IntegrationPlugin[]; showOwnerLabels: boolean; @@ -843,6 +844,14 @@ function AddConnectionDialog(props: { className="h-8 min-w-0 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0" />
+ {props.hiddenPersonalConnectionCount > 0 ? ( +

+ You have {props.hiddenPersonalConnectionCount} personal{" "} + {props.hiddenPersonalConnectionCount === 1 ? "connection" : "connections"} that{" "} + {props.hiddenPersonalConnectionCount === 1 ? "is" : "are"} not shown because this is + a shared toolkit. +

+ ) : null}
@@ -1045,6 +1054,10 @@ function ToolkitWorkspace(props: { [props.toolkit, props.tools], ); const connectionGroups = useMemo(() => buildConnectionGroups(visibleTools), [visibleTools]); + const hiddenPersonalConnectionCount = useMemo(() => { + if (props.toolkit.owner !== "org") return 0; + return buildConnectionGroups(props.tools.filter((tool) => toolOwner(tool) === "user")).length; + }, [props.toolkit.owner, props.tools]); const configuredConnections = useMemo( () => configuredConnectionViews( @@ -1153,6 +1166,7 @@ function ToolkitWorkspace(props: { onOpenChange={setAddOpen} groups={connectionGroups} configuredConnections={configuredConnections} + hiddenPersonalConnectionCount={hiddenPersonalConnectionCount} integrations={props.integrations} integrationPlugins={props.integrationPlugins} showOwnerLabels={props.showOwnerLabels} From 3f68c6aa8cb6ccf4ef529340422ad8e840ebd6b1 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Fri, 26 Jun 2026 15:01:57 -0700 Subject: [PATCH 10/10] Fix console route contract for nested toolkit routes --- packages/react/src/console-routes.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/react/src/console-routes.test.ts b/packages/react/src/console-routes.test.ts index 4a3b32c5f..010c0e594 100644 --- a/packages/react/src/console-routes.test.ts +++ b/packages/react/src/console-routes.test.ts @@ -9,15 +9,21 @@ import { routeTree } from "./routes/routeTree.gen"; // added without a consoleRoutes() entry, or vice versa — apps would silently // lack a route that this package's pages link to. Lock them together. -const collectPaths = (route: unknown): ReadonlyArray => { +const collectPaths = (route: unknown, parentId = ""): ReadonlyArray => { const node = route as { options?: { id?: string }; children?: ReadonlyArray; }; const children = node.children ?? []; const id = node.options?.id; - const own = typeof id === "string" ? [id] : []; - return [...own, ...children.flatMap(collectPaths)]; + const resolved = + typeof id === "string" + ? parentId && !id.startsWith(`/${ORG_SLUG_SEGMENT}`) + ? `${parentId.replace(/\/$/, "")}${id}` + : id + : parentId; + const own = typeof id === "string" ? [resolved] : []; + return [...own, ...children.flatMap((child) => collectPaths(child, resolved))]; }; // The generated ids are the scope-relative contract paths nested under the