History

Add scoped MCP toolkits

Rhys Sullivan committed 4 months ago

Commit 0a7c6a81 parent 606f39b43 files · +3417 −196Browse files

Unified · Split

43 files
  1. apps/host-selfhost/executor.config.ts +3 −1
  2. apps/host-selfhost/package.json +1 −0
  3. apps/host-selfhost/src/app.ts +2 −1
  4. apps/host-selfhost/src/execution.ts +5 −1
  5. apps/host-selfhost/src/mcp/org-path.test.ts +6 −2
  6. apps/host-selfhost/src/mcp/org-path.ts +11 −4
  7. apps/host-selfhost/vite.config.ts +2 −2
  8. apps/host-selfhost/web/chromeless/device-page.tsx +0 −0
  9. apps/host-selfhost/web/chromeless/mcp-consent-page.tsx +0 −0
  10. apps/host-selfhost/web/routes/__root.tsx +2 −2
  11. bun.lock +47 −0
  12. e2e/package.json +1 −0
  13. e2e/selfhost/toolkits-mcp.test.ts +146 −0
  14. e2e/selfhost/toolkits-ui.test.ts +193 −0
  15. e2e/src/surfaces/mcp.ts +41 −17
  16. e2e/targets/selfhost.ts +8 −2
  17. packages/core/api/src/server/execution-stack.ts +3 −0
  18. packages/core/api/src/server/mcp-build.ts +3 −5
  19. packages/core/api/src/server/scoped-executor.ts +8 −2
  20. packages/core/sdk/src/client.ts +10 −1
  21. packages/core/sdk/src/executor.ts +135 −19
  22. packages/core/sdk/src/index.ts +2 −0
  23. packages/core/sdk/src/plugin.ts +32 −0
  24. packages/core/sdk/src/policies.test.ts +81 −2
  25. packages/hosts/mcp/src/envelope.test.ts +27 −0
  26. packages/hosts/mcp/src/envelope.ts +83 −67
  27. packages/hosts/mcp/src/in-memory-session-store.ts +35 −11
  28. packages/hosts/mcp/src/index.ts +3 −0
  29. packages/hosts/mcp/src/seams.ts +20 −0
  30. packages/plugins/toolkits/package.json +105 −0
  31. packages/plugins/toolkits/src/client.tsx +18 −0
  32. packages/plugins/toolkits/src/page.tsx +1189 −0
  33. packages/plugins/toolkits/src/server.test.ts +134 −0
  34. packages/plugins/toolkits/src/server.ts +653 −0
  35. packages/plugins/toolkits/src/shared.ts +166 −0
  36. packages/plugins/toolkits/tsconfig.json +23 −0
  37. packages/plugins/toolkits/tsup.config.ts +14 −0
  38. packages/plugins/toolkits/vitest.config.ts +7 −0
  39. packages/react/src/components/tool-detail.tsx +59 −42
  40. packages/react/src/components/tool-tree.tsx +14 −4
  41. packages/react/src/multiplayer/shell.tsx +25 −2
  42. packages/react/src/routes/plugin-route-match.test.ts +34 −0
  43. packages/react/src/routes/plugins.$pluginId.$.tsx +66 −9
apps/host-selfhost/executor.config.ts+3−1
@@ -5,6 +5,7 @@ import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; · 4 unmodified lines
55 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
66 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
77 import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
8+import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
89
910 import { resolveSecretKey } from "./src/config";
1011
@@ -19,13 +20,14 @@ import { resolveSecretKey } from "./src/config"; · 8 unmodified lines
1920 // ---------------------------------------------------------------------------
2021
2122 export default defineExecutorConfig({
22− plugins: () =>
23+ plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) =>
2324 [
2425 openApiHttpPlugin(),
2526 googleHttpPlugin(),
2627 microsoftHttpPlugin(),
2728 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
2829 graphqlHttpPlugin(),
30+ toolkitsPlugin({ activeToolkitSlug }),
2931 // First writable secret provider -> the default for `secrets.set`.
3032 encryptedSecretsPlugin({ key: resolveSecretKey() }),
3133 ] as const,
apps/host-selfhost/package.json+1−0
@@ -32,6 +32,7 @@ · 31 unmodified lines
3232 "@executor-js/plugin-mcp": "workspace:*",
3333 "@executor-js/plugin-microsoft": "workspace:*",
3434 "@executor-js/plugin-openapi": "workspace:*",
35+ "@executor-js/plugin-toolkits": "workspace:*",
3536 "@executor-js/react": "workspace:*",
3637 "@executor-js/runtime-quickjs": "workspace:*",
3738 "@executor-js/sdk": "workspace:*",
apps/host-selfhost/src/app.ts+2−1
@@ -106,7 +106,8 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 105 unmodified lines
106106 routes: [
107107 // CLI device-login discovery, must precede the /api/auth/* wildcard
108108 // below (Better Auth would otherwise 404 it). The verification page it
109− // points at (/device) is a console SPA route (web/device.tsx).
109+ // points at (/device) is a console SPA route
110+ // (web/chromeless/device-page.tsx).
110111 HttpRouter.add("GET", "/api/auth/cli-login", cliLoginHandler),
111112 // Better Auth owns the rest of /api/auth/*, the full path reaches it.
112113 HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)),
apps/host-selfhost/src/execution.ts+5−1
@@ -40,7 +40,11 @@ export { makeExecutionStack } from "@executor-js/api/server"; · 39 unmodified lines
4040
4141 export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)(
4242 {
43− plugins: () => executorConfig.plugins(),
43+ plugins: (context) =>
44+ executorConfig.plugins({
45+ activeToolkitSlug:
46+ context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
47+ }),
4448 },
4549 );
4650
apps/host-selfhost/src/mcp/org-path.test.ts+6−2
@@ -6,17 +6,21 @@ describe("stripMcpOrgSegment", () => { · 5 unmodified lines
66 it("strips a single org segment before /mcp", () => {
77 expect(stripMcpOrgSegment("/iI9idP7BZcWpg9wW8cit3xE4r4dFSnHj/mcp")).toBe("/mcp");
88 expect(stripMcpOrgSegment("/org_123/mcp")).toBe("/mcp");
9+ expect(stripMcpOrgSegment("/org_123/mcp/toolkits/deploy")).toBe("/mcp/toolkits/deploy");
910 });
1011
1112 it("strips the org segment from the protected-resource discovery path", () => {
1213 expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp")).toBe(
13− "/.well-known/oauth-protected-resource/mcp",
14+ "/.well-known/oauth-protected-resource",
1415 );
16+ expect(
17+ stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp/toolkits/deploy"),
18+ ).toBe("/.well-known/oauth-protected-resource");
1519 });
1620
1721 it("leaves the bare paths untouched", () => {
1822 expect(stripMcpOrgSegment("/mcp")).toBeNull();
19− expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/mcp")).toBeNull();
23+ expect(stripMcpOrgSegment("/mcp/toolkits/deploy")).toBeNull();
2024 expect(stripMcpOrgSegment("/.well-known/oauth-authorization-server")).toBeNull();
2125 });
2226
apps/host-selfhost/src/mcp/org-path.ts+11−4
@@ -20,8 +20,9 @@ const PRM_PREFIX = "/.well-known/oauth-protected-resource"; · 19 unmodified lines
2020 * applies (already bare, not an MCP path, or an OAuth endpoint like
2121 * `/api/auth/mcp/authorize`).
2222 *
23− * /<seg>/mcp -> /mcp
24− * /.well-known/oauth-protected-resource/<seg>/mcp -> /.well-known/oauth-protected-resource/mcp
23+ * /<seg>/mcp -> /mcp
24+ * /<seg>/mcp/toolkits/<toolkit> -> /mcp/toolkits/<toolkit>
25+ * /.well-known/oauth-protected-resource/<seg>/mcp[...] -> /.well-known/oauth-protected-resource
2526 */
2627 export const stripMcpOrgSegment = (pathname: string): string | null => {
2728 if (pathname.startsWith(`${PRM_PREFIX}/`)) {
@@ -29,8 +30,14 @@ export const stripMcpOrgSegment = (pathname: string): string | null => { · 1 unmodified line
2930 .slice(PRM_PREFIX.length + 1)
3031 .split("/")
3132 .filter((segment) => segment.length > 0);
32− return rest.length === 2 && rest[1] === "mcp" ? `${PRM_PREFIX}/mcp` : null;
33+ if (rest.length === 2 && rest[1] === "mcp") return PRM_PREFIX;
34+ if (rest.length === 4 && rest[1] === "mcp" && rest[2] === "toolkits") return PRM_PREFIX;
35+ return null;
3336 }
3437 const segments = pathname.split("/").filter((segment) => segment.length > 0);
35− return segments.length === 2 && segments[1] === "mcp" ? "/mcp" : null;
38+ if (segments.length === 2 && segments[1] === "mcp") return "/mcp";
39+ if (segments.length === 4 && segments[1] === "mcp" && segments[2] === "toolkits") {
40+ return `/mcp/toolkits/${segments[3]}`;
41+ }
42+ return null;
3643 };
apps/host-selfhost/vite.config.ts+2−2
@@ -66,8 +66,8 @@ function executorApiPlugin(): Plugin { · 65 unmodified lines
6666 rawUrl = `${pathname}${original.search}`;
6767 }
6868 // Match on PATHNAME, not a raw-URL prefix: `/mcp` must NOT swallow the
69− // SPA route `/mcp-consent` (nor its source module `/mcp-consent.tsx`),
70− // or the dev server misroutes them to the API handler and they 404.
69+ // SPA route `/mcp-consent`, or the dev server misroutes it to the API
70+ // handler and returns a 404.
7171 const path = new URL(rawUrl, devOrigin).pathname;
7272 const handled =
7373 path === "/api" ||
apps/host-selfhost/web/chromeless/device-page.tsxrenamed from apps/host-selfhost/web/device.tsx+0−0

File renamed without changes.

apps/host-selfhost/web/chromeless/mcp-consent-page.tsxrenamed from apps/host-selfhost/web/mcp-consent.tsx+0−0

File renamed without changes.

apps/host-selfhost/web/routes/__root.tsx+2−2
@@ -11,8 +11,8 @@ import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shel · 10 unmodified lines
1111 import { plugins as clientPlugins } from "virtual:executor/plugins-client";
1212
1313 import { authClient } from "../auth-client";
14−import { DevicePage } from "../device";
15−import { McpConsentPage } from "../mcp-consent";
14+import { DevicePage } from "../chromeless/device-page";
15+import { McpConsentPage } from "../chromeless/mcp-consent-page";
1616 import { LoginPage } from "../login";
1717 import { SetupPage } from "../setup";
1818 import { fetchNeedsSetup } from "../setup-status";
bun.lock+47−0
@@ -228,6 +228,7 @@ · 227 unmodified lines
228228 "@executor-js/plugin-mcp": "workspace:*",
229229 "@executor-js/plugin-microsoft": "workspace:*",
230230 "@executor-js/plugin-openapi": "workspace:*",
231+ "@executor-js/plugin-toolkits": "workspace:*",
231232 "@executor-js/react": "workspace:*",
232233 "@executor-js/runtime-quickjs": "workspace:*",
233234 "@executor-js/sdk": "workspace:*",
@@ -346,6 +347,7 @@ · 112 unmodified lines
346347 "@executor-js/plugin-mcp": "workspace:*",
347348 "@executor-js/plugin-microsoft": "workspace:*",
348349 "@executor-js/plugin-openapi": "workspace:*",
350+ "@executor-js/plugin-toolkits": "workspace:*",
349351 "@executor-js/sdk": "workspace:*",
350352 "@kitlangton/terminal-control": "^0.3.0",
351353 "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1088,6 +1090,49 @@ · 736 unmodified lines
10881090 "react",
10891091 ],
10901092 },
1093+ "packages/plugins/toolkits": {
1094+ "name": "@executor-js/plugin-toolkits",
1095+ "version": "1.5.12",
1096+ "dependencies": {
1097+ "@executor-js/sdk": "workspace:*",
1098+ },
1099+ "devDependencies": {
1100+ "@effect/atom-react": "catalog:",
1101+ "@effect/vitest": "catalog:",
1102+ "@executor-js/api": "workspace:*",
1103+ "@executor-js/react": "workspace:*",
1104+ "@tanstack/react-router": "catalog:",
1105+ "@types/node": "catalog:",
1106+ "@types/react": "catalog:",
1107+ "bun-types": "catalog:",
1108+ "effect": "catalog:",
1109+ "fractional-indexing": "^3.2.0",
1110+ "lucide-react": "^1.7.0",
1111+ "react": "catalog:",
1112+ "tsup": "catalog:",
1113+ "typescript": "catalog:",
1114+ "vitest": "catalog:",
1115+ },
1116+ "peerDependencies": {
1117+ "@effect/atom-react": "catalog:",
1118+ "@executor-js/api": "workspace:*",
1119+ "@executor-js/react": "workspace:*",
1120+ "@tanstack/react-router": "catalog:",
1121+ "effect": "catalog:",
1122+ "fractional-indexing": "^3.2.0",
1123+ "lucide-react": "^1.7.0",
1124+ "react": "catalog:",
1125+ },
1126+ "optionalPeers": [
1127+ "@effect/atom-react",
1128+ "@executor-js/api",
1129+ "@executor-js/react",
1130+ "@tanstack/react-router",
1131+ "fractional-indexing",
1132+ "lucide-react",
1133+ "react",
1134+ ],
1135+ },
10911136 "packages/plugins/workos-vault": {
10921137 "name": "@executor-js/plugin-workos-vault",
10931138 "version": "0.0.2",
@@ -1682,6 +1727,8 @@ · 588 unmodified lines
16821727
16831728 "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"],
16841729
1730+ "@executor-js/plugin-toolkits": ["@executor-js/plugin-toolkits@workspace:packages/plugins/toolkits"],
1731+
16851732 "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"],
16861733
16871734 "@executor-js/react": ["@executor-js/react@workspace:packages/react"],
e2e/package.json+1−0
@@ -27,6 +27,7 @@ · 26 unmodified lines
2727 "@executor-js/plugin-mcp": "workspace:*",
2828 "@executor-js/plugin-microsoft": "workspace:*",
2929 "@executor-js/plugin-openapi": "workspace:*",
30+ "@executor-js/plugin-toolkits": "workspace:*",
3031 "@executor-js/sdk": "workspace:*",
3132 "@kitlangton/terminal-control": "^0.3.0",
3233 "@modelcontextprotocol/sdk": "^1.29.0",
e2e/selfhost/toolkits-mcp.test.ts+146−0
@@ -0,0 +1,146 @@
1+import { randomBytes } from "node:crypto";
2+
3+import { expect } from "@effect/vitest";
4+import { Effect } from "effect";
5+import { composePluginApi } from "@executor-js/api/server";
6+import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
7+
8+import { scenario } from "../src/scenario";
9+import { Api, Mcp, Target } from "../src/services";
10+import type { Identity } from "../src/target";
11+
12+const api = composePluginApi([toolkitsPlugin()] as const);
13+
14+const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
15+
16+const allowedCode = `
17+const result = await tools.executor.coreTools.integrations.list({});
18+if (!result.ok) throw new Error(result.error.message);
19+return result.data.integrations.map((integration) => integration.slug).includes("executor");
20+`;
21+
22+const blockedCode = `
23+const result = await tools.executor.coreTools.policies.list({});
24+if (!result.ok) throw new Error(result.error.message);
25+return result.data.policies.length;
26+`;
27+
28+const initializeSession = async (url: string, bearer: string): Promise<string> => {
29+ const response = await fetch(url, {
30+ method: "POST",
31+ headers: {
32+ authorization: `Bearer ${bearer}`,
33+ "content-type": "application/json",
34+ accept: "application/json, text/event-stream",
35+ },
36+ body: JSON.stringify({
37+ jsonrpc: "2.0",
38+ id: 1,
39+ method: "initialize",
40+ params: {
41+ protocolVersion: "2025-03-26",
42+ capabilities: {},
43+ clientInfo: { name: "toolkit-e2e", version: "1" },
44+ },
45+ }),
46+ });
47+ expect(response.status, "toolkit initialize succeeds").toBe(200);
48+ const sessionId = response.headers.get("mcp-session-id");
49+ expect(sessionId, "initialize returns a session id").toEqual(expect.any(String));
50+ return sessionId!;
51+};
52+
53+const callToolsListWithSession = async (
54+ url: string,
55+ bearer: string,
56+ sessionId: string,
57+): Promise<Response> =>
58+ fetch(url, {
59+ method: "POST",
60+ headers: {
61+ authorization: `Bearer ${bearer}`,
62+ "mcp-session-id": sessionId,
63+ "mcp-protocol-version": "2025-03-26",
64+ "content-type": "application/json",
65+ accept: "application/json, text/event-stream",
66+ },
67+ body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }),
68+ });
69+
70+scenario(
71+ "Toolkits · self-host MCP exposes only the toolkit's allowed tools",
72+ { timeout: 180_000 },
73+ Effect.gen(function* () {
74+ const target = yield* Target;
75+ const { client: makeApiClient } = yield* Api;
76+ const mcp = yield* Mcp;
77+ const identity = yield* target.newIdentity();
78+ const client = yield* makeApiClient(api, identity);
79+ const suffix = randomBytes(4).toString("hex");
80+
81+ const toolkit = yield* client.toolkits.create({
82+ payload: {
83+ owner: "org",
84+ name: `toolkits-e2e-${suffix}`,
85+ },
86+ });
87+
88+ yield* Effect.gen(function* () {
89+ yield* client.toolkits.createConnection({
90+ params: { toolkitId: toolkit.id },
91+ payload: {
92+ pattern: "executor.*",
93+ },
94+ });
95+ yield* client.toolkits.createPolicy({
96+ params: { toolkitId: toolkit.id },
97+ payload: {
98+ pattern: "executor.coreTools.integrations.list",
99+ action: "approve",
100+ },
101+ });
102+ yield* client.toolkits.createPolicy({
103+ params: { toolkitId: toolkit.id },
104+ payload: {
105+ pattern: "executor.coreTools.policies.list",
106+ action: "block",
107+ },
108+ });
109+
110+ const toolkitUrl = new URL(
111+ `/e2e-org/mcp/toolkits/${toolkit.slug}`,
112+ target.baseUrl,
113+ ).toString();
114+ const toolkitSession = mcp.session(identity, { url: toolkitUrl });
115+ const toolkitTools = yield* toolkitSession.listTools();
116+ expect(toolkitTools, "the toolkit endpoint still advertises execute").toContain("execute");
117+
118+ const allowed = yield* toolkitSession.call("execute", { code: allowedCode });
119+ expect(allowed.ok, `allowed toolkit tool call succeeds; response:\n${allowed.text}`).toBe(
120+ true,
121+ );
122+ expect(allowed.text, "allowed call returns the integration result").toContain("true");
123+
124+ const blocked = yield* toolkitSession.call("execute", { code: blockedCode });
125+ expect(blocked.ok, `blocked toolkit tool call fails; response:\n${blocked.text}`).toBe(false);
126+ expect(blocked.text, "blocked call explains that the tool is unavailable").toMatch(
127+ /blocked|not found|not available/i,
128+ );
129+
130+ const normalSession = mcp.session(identity);
131+ const normal = yield* normalSession.call("execute", { code: blockedCode });
132+ expect(normal.ok, "normal MCP is not scoped by the toolkit rules").toBe(true);
133+
134+ const bearer = yield* mcp.mintBearer(emailOf(identity));
135+ const sessionId = yield* Effect.promise(() => initializeSession(toolkitUrl, bearer));
136+ const reusedOnDefault = yield* Effect.promise(() =>
137+ callToolsListWithSession(target.mcpUrl, bearer, sessionId),
138+ );
139+ expect(reusedOnDefault.status, "toolkit session id cannot be reused on /mcp").toBe(403);
140+ }).pipe(
141+ Effect.ensuring(
142+ client.toolkits.remove({ params: { toolkitId: toolkit.id } }).pipe(Effect.ignore),
143+ ),
144+ );
145+ }),
146+);
e2e/selfhost/toolkits-ui.test.ts+193−0
@@ -0,0 +1,193 @@
1+import { randomBytes } from "node:crypto";
2+
3+import { expect } from "@effect/vitest";
4+import { Effect } from "effect";
5+import { composePluginApi } from "@executor-js/api/server";
6+import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
7+
8+import { scenario } from "../src/scenario";
9+import { Api, Browser, Target } from "../src/services";
10+
11+const api = composePluginApi([toolkitsPlugin()] as const);
12+
13+scenario(
14+ "Toolkits · self-host UI creates a toolkit and configures tools",
15+ { timeout: 180_000 },
16+ Effect.gen(function* () {
17+ const target = yield* Target;
18+ const browser = yield* Browser;
19+ const { client: makeApiClient } = yield* Api;
20+ const identity = yield* target.newIdentity();
21+ const client = yield* makeApiClient(api, identity);
22+
23+ const suffix = randomBytes(4).toString("hex");
24+ const prefix = `toolkits-ui-${suffix}`;
25+ const name = `${prefix}-created`;
26+ const slug = name;
27+ const seededToolkits = [
28+ { owner: "org" as const, name: `${prefix}-workspace-a` },
29+ { owner: "org" as const, name: `${prefix}-workspace-b` },
30+ { owner: "user" as const, name: `${prefix}-personal-a` },
31+ { owner: "user" as const, name: `${prefix}-personal-b` },
32+ { owner: "user" as const, name: `${prefix}-personal-c` },
33+ ];
34+ let addedConnectionPattern = "";
35+ const blockPattern = "executor.coreTools.policies.list";
36+
37+ const cleanup = Effect.gen(function* () {
38+ const listed = yield* client.toolkits.list();
39+ yield* Effect.forEach(
40+ listed.toolkits.filter((row) => row.slug.startsWith(prefix)),
41+ (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }),
42+ { discard: true },
43+ );
44+ }).pipe(Effect.ignore);
45+
46+ yield* Effect.gen(function* () {
47+ yield* Effect.forEach(
48+ seededToolkits,
49+ (toolkit) => client.toolkits.create({ payload: toolkit }),
50+ { discard: true },
51+ );
52+
53+ yield* browser.session(identity, async ({ page, step }) => {
54+ await step("Open the Toolkits plugin page", async () => {
55+ await page.goto("/plugins/toolkits/", { waitUntil: "networkidle" });
56+ await page.getByRole("heading", { name: "Toolkits" }).waitFor();
57+ await page.getByRole("heading", { name: "Workspace" }).waitFor();
58+ await page.getByRole("heading", { name: "Personal" }).waitFor();
59+ expect(await page.getByLabel("New toolkit").count()).toBe(0);
60+ });
61+
62+ await step("Create a workspace toolkit from the add card", async () => {
63+ const workspaceSection = page.locator("section").filter({
64+ has: page.getByRole("heading", { name: "Workspace" }),
65+ });
66+ await workspaceSection.getByRole("button", { name: "Add workspace toolkit" }).click();
67+ await page.getByRole("dialog", { name: "New workspace toolkit" }).waitFor();
68+ await page.getByLabel("Toolkit name").fill(name);
69+ await page.getByRole("button", { name: "Create toolkit" }).click();
70+ await page.getByRole("link", { name: `Open toolkit ${name}` }).waitFor();
71+ });
72+
73+ await step("Validate owner sections render as three-column grids", async () => {
74+ const workspaceSection = page.locator("section").filter({
75+ has: page.getByRole("heading", { name: "Workspace" }),
76+ });
77+ const personalSection = page.locator("section").filter({
78+ has: page.getByRole("heading", { name: "Personal" }),
79+ });
80+
81+ const workspaceColumns = await workspaceSection
82+ .getByRole("link", { name: /^Open toolkit/ })
83+ .evaluateAll((nodes) =>
84+ nodes.slice(0, 3).map((node) => Math.round(node.getBoundingClientRect().left)),
85+ );
86+ const personalColumns = await personalSection
87+ .getByRole("link", { name: /^Open toolkit/ })
88+ .evaluateAll((nodes) =>
89+ nodes.slice(0, 3).map((node) => Math.round(node.getBoundingClientRect().left)),
90+ );
91+
92+ expect(new Set(workspaceColumns).size).toBe(3);
93+ expect(new Set(personalColumns).size).toBe(3);
94+ });
95+
96+ await step("Open the created toolkit from the grid", async () => {
97+ await page.getByRole("link", { name: `Open toolkit ${name}` }).click();
98+ await page.waitForURL(new RegExp(`/plugins/toolkits/${slug}$`));
99+ expect(page.url()).toMatch(new RegExp(`/plugins/toolkits/${slug}$`));
100+ await page
101+ .locator("code")
102+ .filter({ hasText: `/mcp/toolkits/${slug}` })
103+ .waitFor();
104+ await page.getByText("No connections added").waitFor();
105+ expect(await page.getByLabel("New toolkit").count()).toBe(0);
106+ });
107+
108+ await step("Return to the toolkit grid with browser-visible routing", async () => {
109+ await page.getByRole("button", { name: "Toolkits" }).click();
110+ await page.waitForURL(/\/plugins\/toolkits\/?$/);
111+ expect(page.url()).toMatch(/\/plugins\/toolkits\/?$/);
112+ await page.getByRole("heading", { name: "Workspace" }).waitFor();
113+ await page.getByRole("link", { name: `Open toolkit ${name}` }).waitFor();
114+ });
115+
116+ await step("Open the created toolkit from a direct URL", async () => {
117+ await page.goto(`/plugins/toolkits/${slug}`, { waitUntil: "networkidle" });
118+ expect(page.url()).toMatch(new RegExp(`/plugins/toolkits/${slug}$`));
119+ await page
120+ .locator("code")
121+ .filter({ hasText: `/mcp/toolkits/${slug}` })
122+ .waitFor();
123+ await page.getByText("No connections added").waitFor();
124+ expect(await page.getByLabel("New toolkit").count()).toBe(0);
125+ });
126+
127+ await step("Add a connection to the toolkit", async () => {
128+ await page.getByRole("button", { name: "Add connection to toolkit" }).click();
129+ const dialog = page.getByRole("dialog", { name: "Add connection" });
130+ await dialog.waitFor();
131+ await dialog.getByLabel("Search connections and tools").fill("policies.list");
132+ expect(await dialog.getByRole("button", { name: /^Add tool/ }).count()).toBe(0);
133+ addedConnectionPattern = "executor.*";
134+ expect(await dialog.getByText(addedConnectionPattern, { exact: true }).count()).toBe(0);
135+ await dialog
136+ .getByRole("button", { name: /^Add connection / })
137+ .first()
138+ .click();
139+ await dialog.waitFor({ state: "hidden" });
140+ const toolkitTools = page.getByRole("region", { name: "Toolkit tools" });
141+ await toolkitTools.waitFor();
142+ await toolkitTools.getByLabel("Filter tools").fill("policies.list");
143+ await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().waitFor();
144+ await toolkitTools.getByLabel("Filter tools").clear();
145+ });
146+
147+ await step("The add connection list reflects the saved toolkit connection", async () => {
148+ await page.getByRole("button", { name: "Add connection to toolkit" }).click();
149+ const dialog = page.getByRole("dialog", { name: "Add connection" });
150+ await dialog.waitFor();
151+ await dialog.getByLabel("Search connections and tools").fill("policies.list");
152+ await dialog.getByRole("button", { name: /^Connection added / }).waitFor();
153+ expect(await dialog.getByRole("button", { name: /^Add connection / }).count()).toBe(0);
154+ await page.keyboard.press("Escape");
155+ await dialog.waitFor({ state: "hidden" });
156+ });
157+
158+ await step("Block one tool from the toolkit tools list", async () => {
159+ const toolkitTools = page.getByRole("region", { name: "Toolkit tools" });
160+ await toolkitTools.getByLabel("Filter tools").fill("policies.list");
161+ await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().click();
162+ await page.getByRole("button", { name: "Set policy", exact: true }).click();
163+ await page.getByText(blockPattern, { exact: true }).waitFor();
164+ await page.getByRole("menuitem", { name: "Block" }).click();
165+ await toolkitTools.getByRole("button").filter({ hasText: "list" }).last().waitFor();
166+ await page.getByText("This tool is not available through the current toolkit.").waitFor();
167+ });
168+ });
169+
170+ const listed = yield* client.toolkits.list();
171+ const toolkit = listed.toolkits.find((row) => row.slug === slug);
172+ expect(toolkit, "the UI-created toolkit persisted").toBeDefined();
173+ if (!toolkit) return;
174+ expect(toolkit.owner).toBe("org");
175+
176+ const { policies } = yield* client.toolkits.listPolicies({
177+ params: { toolkitId: toolkit.id },
178+ });
179+ const { connections } = yield* client.toolkits.listConnections({
180+ params: { toolkitId: toolkit.id },
181+ });
182+ expect(addedConnectionPattern.length, "the UI selected a connection").toBeGreaterThan(0);
183+ expect(
184+ connections.map((connection) => connection.pattern),
185+ "the UI-authored toolkit connection persisted",
186+ ).toContain(addedConnectionPattern);
187+ expect(
188+ policies.map((policy) => `${policy.pattern} ${policy.action}`).sort(),
189+ "the UI-authored toolkit access persisted with its action",
190+ ).toEqual([`${blockPattern} block`]);
191+ }).pipe(Effect.ensuring(cleanup));
192+ }),
193+);
e2e/src/surfaces/mcp.ts+41−17
@@ -160,7 +160,7 @@ export interface McpSurface { · 159 unmodified lines
160160 readonly url: string;
161161 readonly session: (
162162 identity: Identity,
163− options?: { readonly elicitationMode?: McpElicitationMode },
163+ options?: { readonly elicitationMode?: McpElicitationMode; readonly url?: string },
164164 ) => McpSession;
165165 /**
166166 * Mint a real MCP bearer headlessly: protected-resource discovery →
@@ -188,6 +188,17 @@ interface TokenResponse { · 21 unmodified lines
188188 readonly access_token?: string;
189189 }
190190
191+const jsonFrom = async <T>(response: Response, label: string): Promise<T> => {
192+ const text = await response.text();
193+ if (!text) {
194+ throw new Error(`${label}: empty response body (status ${response.status})`);
195+ }
196+ if (!response.ok) {
197+ throw new Error(`${label}: request failed (status ${response.status}): ${text}`);
198+ }
199+ return JSON.parse(text) as T;
200+};
201+
191202 const mintBearerFlow = async (target: Target, email: string): Promise<string> => {
192203 const consent = target.mcpConsent?.({
193204 label: email,
@@ -196,21 +207,31 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 2 unmodified lines
196207 if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`);
197208
198209 const mcpPath = new URL(target.mcpUrl).pathname;
199− const resource = (await (
200− await fetch(new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl))
201− ).json()) as { authorization_servers?: ReadonlyArray<string> };
210+ let resourceResponse = await fetch(
211+ new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl),
212+ );
213+ if (resourceResponse.status === 404) {
214+ resourceResponse = await fetch(
215+ new URL("/.well-known/oauth-protected-resource", target.baseUrl),
216+ );
217+ }
218+ const resource = await jsonFrom<{ authorization_servers?: ReadonlyArray<string> }>(
219+ resourceResponse,
220+ "mintBearer: protected-resource metadata",
221+ );
202222 const issuer = resource.authorization_servers?.[0];
203223 if (!issuer) throw new Error("mintBearer: no authorization server advertised");
204− const metadata = (await (
205− await fetch(new URL("/.well-known/oauth-authorization-server", issuer))
206− ).json()) as {
224+ const metadata = await jsonFrom<{
207225 readonly authorization_endpoint: string;
208226 readonly token_endpoint: string;
209227 readonly registration_endpoint: string;
210− };
228+ }>(
229+ await fetch(new URL("/.well-known/oauth-authorization-server", issuer)),
230+ "mintBearer: authorization-server metadata",
231+ );
211232
212233 const redirectUri = "http://127.0.0.1:9/callback";
213− const registered = (await (
234+ const registered = await jsonFrom<{ readonly client_id: string }>(
214235 await fetch(metadata.registration_endpoint, {
215236 method: "POST",
216237 headers: { "content-type": "application/json" },
@@ -221,8 +242,9 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 4 unmodified lines
221242 response_types: ["code"],
222243 token_endpoint_auth_method: "none",
223244 }),
224− })
225− ).json()) as { readonly client_id: string };
245+ }),
246+ "mintBearer: dynamic client registration",
247+ );
226248
227249 const verifier = randomBytes(32).toString("base64url");
228250 const authorizeUrl = new URL(metadata.authorization_endpoint);
@@ -240,7 +262,7 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 11 unmodified lines
240262 redirectUrl: redirectUri,
241263 });
242264
243− const token = (await (
265+ const token = await jsonFrom<TokenResponse>(
244266 await fetch(metadata.token_endpoint, {
245267 method: "POST",
246268 headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -251,8 +273,9 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 4 unmodified lines
251273 client_id: registered.client_id,
252274 code_verifier: verifier,
253275 }),
254− })
255− ).json()) as TokenResponse;
276+ }),
277+ "mintBearer: token exchange",
278+ );
256279 if (!token.access_token) throw new Error("mintBearer: token exchange returned no token");
257280 return token.access_token;
258281 };
@@ -261,7 +284,8 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 2 unmodified lines
261284 url: target.mcpUrl,
262285 mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)),
263286 session: (identity, options) => {
264− if (runDir) installTraceparentFetch(target.mcpUrl, runDir);
287+ const mcpUrl = options?.url ?? target.mcpUrl;
288+ if (runDir) installTraceparentFetch(mcpUrl, runDir);
265289 // mcporter caches OAuth tokens (and the DCR client) per server NAME, so a
266290 // constant name would let a later session reuse an earlier identity's token
267291 // — landing in the wrong org. A unique name per session keeps each
@@ -272,8 +296,8 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 4 unmodified lines
272296 // `?elicitation_mode=` query on the MCP endpoint — so a paused execution
273297 // yields an approvalUrl instead of letting the model resume inline.
274298 const sessionUrl = options?.elicitationMode
275− ? `${target.mcpUrl}?elicitation_mode=${options.elicitationMode}`
276− : target.mcpUrl;
299+ ? `${mcpUrl}?elicitation_mode=${options.elicitationMode}`
300+ : mcpUrl;
277301 let runtimePromise: Promise<Runtime> | undefined;
278302 let connected = false;
279303
e2e/targets/selfhost.ts+8−2
@@ -83,7 +83,13 @@ const forcedMcpConsent = · 82 unmodified lines
8383 if (!decision.ok) {
8484 throw new Error(`forcedMcpConsent: consent grant failed (status ${decision.status})`);
8585 }
86− const body = (await decision.json()) as { redirectURI?: string };
86+ const decisionText = await decision.text();
87+ if (!decisionText) {
88+ throw new Error(
89+ `forcedMcpConsent: consent grant returned an empty body (status ${decision.status})`,
90+ );
91+ }
92+ const body = JSON.parse(decisionText) as { redirectURI?: string };
8793 const code = body.redirectURI ? new URL(body.redirectURI).searchParams.get("code") : null;
8894 if (!code) {
8995 throw new Error(
@@ -118,6 +124,6 @@ export const selfhostTarget = (): Target => ({ · 28 unmodified lines
118124 mcpConsent: (identity: Identity) =>
119125 forcedMcpConsent(SELFHOST_BASE_URL, {
120126 email: identity.credentials?.email ?? SELFHOST_ADMIN.email,
121− password: identity.credentials?.password ?? SELFHOST_ADMIN.password,
127+ password: identity.credentials?.password || SELFHOST_ADMIN.password,
122128 }),
123129 });
packages/core/api/src/server/execution-stack.ts+3−0
@@ -26,6 +26,7 @@ · 25 unmodified lines
2626 import { Context, Effect, Layer } from "effect";
2727 import type * as Cause from "effect/Cause";
2828
29+import type { McpResource } from "@executor-js/host-mcp";
2930 import type { AnyPlugin, Executor, StorageFailure } from "@executor-js/sdk";
3031 import {
3132 createExecutionEngine,
@@ -95,6 +96,7 @@ export const makeExecutionStack = < · 63 unmodified lines
9596 accountId: string,
9697 organizationId: string,
9798 organizationName: string,
99+ options?: { readonly mcpResource?: McpResource },
98100 ): Effect.Effect<
99101 { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> },
100102 StorageFailure,
@@ -105,6 +107,7 @@ export const makeExecutionStack = < · 4 unmodified lines
105107 accountId,
106108 organizationId,
107109 organizationName,
110+ { plugins: { mcpResource: options?.mcpResource } },
108111 );
109112 const codeExecutor = yield* CodeExecutorProvider;
110113 const { decorate } = yield* EngineDecorator;
packages/core/api/src/server/mcp-build.ts+3−5
@@ -39,11 +39,9 @@ export type McpExecutionStackLayer = Layer.Layer< · 38 unmodified lines
3939 export const makeMcpBuildServer =
4040 (executionStack: McpExecutionStackLayer): McpBuildServer =>
4141 (principal: Principal, options?: McpBuildServerOptions) =>
42− makeExecutionStack(
43− principal.accountId,
44− principal.organizationId,
45− principal.organizationName,
46− ).pipe(
42+ makeExecutionStack(principal.accountId, principal.organizationId, principal.organizationName, {
43+ mcpResource: options?.resource,
44+ }).pipe(
4745 Effect.map(({ engine }) => engine),
4846 // Pin browser-handoff URLs to the principal's org slug when present;
4947 // absent slug leaves the service unprovided and the URL stays bare.
packages/core/api/src/server/scoped-executor.ts+8−2
@@ -33,6 +33,7 @@ · 32 unmodified lines
3333
3434 import { Context, Effect, Option } from "effect";
3535
36+import type { McpResource } from "@executor-js/host-mcp";
3637 import {
3738 createExecutor,
3839 OAUTH_CALLBACK_ORG_QUERY_PARAM,
@@ -169,8 +170,12 @@ export const buildOAuthRedirectUri = (input: { · 130 unmodified lines
169170 // while a host with static plugins (self-host) just returns a constant array.
170171 // ---------------------------------------------------------------------------
171172
173+export interface PluginsProviderContext {
174+ readonly mcpResource?: McpResource;
175+}
176+
172177 export interface PluginsProviderShape {
173− readonly plugins: () => readonly AnyPlugin[];
178+ readonly plugins: (context?: PluginsProviderContext) => readonly AnyPlugin[];
174179 }
175180
176181 export class PluginsProvider extends Context.Service<PluginsProvider, PluginsProviderShape>()(
@@ -207,6 +212,7 @@ export const makeScopedExecutor = < · 30 unmodified lines
207212 // `EngineStackIdentity` (the engine decorator still wants it); not part of the
208213 // v2 executor binding, which is `{ tenant, subject }` only.
209214 _organizationName: string,
215+ options?: { readonly plugins?: PluginsProviderContext },
210216 ): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> =>
211217 Effect.gen(function* () {
212218 const { db, blobs } = yield* DbProvider;
@@ -248,7 +254,7 @@ export const makeScopedExecutor = < · 35 unmodified lines
248254 orgSlug,
249255 });
250256
251− const plugins = pluginsFactory();
257+ const plugins = pluginsFactory(options?.plugins);
252258 const hostedHttpOptions = {
253259 allowLocalNetwork: config.allowLocalNetwork,
254260 };
packages/core/sdk/src/client.ts+10−1
@@ -46,10 +46,19 @@ export { useAtomValue, useAtomSet, useAtomMount, useAtomRefresh } from "@effect/ · 45 unmodified lines
4646 // alongside the host's own UI.
4747 // ---------------------------------------------------------------------------
4848
49+export interface PluginPageProps {
50+ /** Plugin-relative route params captured from `PageDecl.path` segments. */
51+ readonly params: Readonly<Record<string, string>>;
52+ /** The normalized plugin-relative URL path that matched this page. */
53+ readonly path: string;
54+ /** The plugin id from `/plugins/$pluginId/...`. */
55+ readonly pluginId: string;
56+}
57+
4958 export interface PageDecl {
5059 /** Path relative to the plugin's mount point, e.g. `/`, `/edit/$id`. */
5160 readonly path: string;
52− readonly component: ComponentType;
61+ readonly component: ComponentType<PluginPageProps>;
5362 /** Optional sidebar nav metadata — the host renders these alongside its
5463 * own nav links. Omit to register a page without a nav entry. */
5564 readonly nav?: {
packages/core/sdk/src/executor.ts+135−19
@@ -90,6 +90,7 @@ import type { OAuthService } from "./oauth-client"; · 89 unmodified lines
9090 import {
9191 comparePolicyRow,
9292 isValidPattern,
93+ matchPattern,
9394 resolveEffectivePolicy,
9495 rowToToolPolicy,
9596 type CreateToolPolicyInput,
@@ -112,6 +113,8 @@ import type { · 16 unmodified lines
112113 StaticSourceDecl,
113114 StaticToolDecl,
114115 StorageDeps,
116+ ToolPolicyProvider,
117+ ToolPolicyProviderRule,
115118 ToolInvocationCredential,
116119 } from "./plugin";
117120 import {
@@ -1282,6 +1285,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1164 unmodified lines
12821285 // Populated once, never mutated after startup.
12831286 const staticTools = new Map<string, StaticTools>();
12841287 const runtimes = new Map<string, PluginRuntime>();
1288+ let activeToolPolicyProvider: ToolPolicyProvider | null = null;
12851289 // Credential providers keyed by `provider.key`, in registration order.
12861290 const credentialProviders = new Map<string, CredentialProvider>();
12871291 const credentialProviderOrder: string[] = [];
@@ -2299,8 +2303,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1011 unmodified lines
22992303 readonly integration?: IntegrationSlug;
23002304 readonly owner?: Owner;
23012305 }): Effect.Effect<readonly Connection[], StorageFailure> =>
2302− core
2303− .findMany("connection", {
2306+ Effect.gen(function* () {
2307+ const rows = yield* core.findMany("connection", {
23042308 where: (b: AnyCb) =>
23052309 b.and(
23062310 filter?.integration === undefined
@@ -2308,8 +2312,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line
23082312 : b("integration", "=", String(filter.integration)),
23092313 filter?.owner === undefined ? true : b("owner", "=", filter.owner),
23102314 ),
2311− })
2312− .pipe(Effect.map((rows) => rows.map(rowToConnection)));
2315+ });
2316+ const connections = rows.map(rowToConnection);
2317+ if (!activeToolPolicyProvider) return connections;
2318+
2319+ const visibleTools = yield* toolsList({ includeAnnotations: false });
2320+ const visibleConnectionKeys = new Set(
2321+ visibleTools
2322+ .filter((tool) => !tool.static)
2323+ .map((tool) => `${tool.owner}:${tool.integration}:${tool.connection}`),
2324+ );
2325+ return connections.filter((connection) =>
2326+ visibleConnectionKeys.has(
2327+ `${connection.owner}:${connection.integration}:${connection.name}`,
2328+ ),
2329+ );
2330+ });
23132331
23142332 const connectionsGet = (ref: ConnectionRef): Effect.Effect<Connection | null, StorageFailure> =>
23152333 findConnectionRow(ref).pipe(Effect.map((row) => (row ? rowToConnection(row) : null)));
@@ -2412,6 +2430,81 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 96 unmodified lines
24122430 return yield* produceConnectionTools(integrationRow, ref);
24132431 });
24142432
2433+ // ------------------------------------------------------------------
2434+ // Active policy source.
2435+ // ------------------------------------------------------------------
2436+
2437+ type ActivePolicyRuleSet =
2438+ | { readonly kind: "global"; readonly rows: readonly ToolPolicyRow[] }
2439+ | {
2440+ readonly kind: "provider";
2441+ readonly provider: ToolPolicyProvider;
2442+ readonly rules: readonly ToolPolicyProviderRule[] | null;
2443+ };
2444+
2445+ const compareProviderPolicyRule = (
2446+ a: ToolPolicyProviderRule,
2447+ b: ToolPolicyProviderRule,
2448+ ): number => {
2449+ if (a.position < b.position) return -1;
2450+ if (a.position > b.position) return 1;
2451+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
2452+ };
2453+
2454+ const resolveProviderPolicyFromRules = (
2455+ toolId: string,
2456+ rules: readonly ToolPolicyProviderRule[],
2457+ ): EffectivePolicy => {
2458+ for (const rule of [...rules].sort(compareProviderPolicyRule)) {
2459+ if (!matchPattern(rule.pattern, toolId)) continue;
2460+ return {
2461+ action: rule.action,
2462+ source: "user",
2463+ pattern: rule.pattern,
2464+ policyId: rule.id,
2465+ };
2466+ }
2467+ // Toolkit-style providers are capability allowlists. No matching rule
2468+ // means the tool is outside the capability boundary.
2469+ return {
2470+ action: "block",
2471+ source: "user",
2472+ pattern: "*",
2473+ };
2474+ };
2475+
2476+ const listActivePolicyRuleSet = (): Effect.Effect<ActivePolicyRuleSet, StorageFailure> =>
2477+ activeToolPolicyProvider
2478+ ? activeToolPolicyProvider.resolve
2479+ ? Effect.succeed({
2480+ kind: "provider" as const,
2481+ provider: activeToolPolicyProvider,
2482+ rules: null,
2483+ })
2484+ : activeToolPolicyProvider.list().pipe(
2485+ Effect.map((rules) => ({
2486+ kind: "provider" as const,
2487+ provider: activeToolPolicyProvider!,
2488+ rules,
2489+ })),
2490+ )
2491+ : core
2492+ .findMany("tool_policy", {})
2493+ .pipe(Effect.map((rows) => ({ kind: "global" as const, rows })));
2494+
2495+ const resolvePolicyFromRuleSet = (
2496+ toolId: string,
2497+ ruleSet: ActivePolicyRuleSet,
2498+ defaultRequiresApproval?: boolean,
2499+ ): Effect.Effect<EffectivePolicy, StorageFailure> =>
2500+ ruleSet.kind === "provider"
2501+ ? ruleSet.provider.resolve
2502+ ? ruleSet.provider.resolve({ toolId, defaultRequiresApproval })
2503+ : Effect.succeed(resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? []))
2504+ : Effect.succeed(
2505+ resolveEffectivePolicy(toolId, ruleSet.rows, ownerRankForRow, defaultRequiresApproval),
2506+ );
2507+
24152508 // ------------------------------------------------------------------
24162509 // Tools (read surface)
24172510 // ------------------------------------------------------------------
@@ -2490,16 +2583,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 72 unmodified lines
24902583 select: TOOL_INVOCATION_COLUMNS,
24912584 });
24922585 const includeBlocked = filter?.includeBlocked ?? false;
2493− const policyRows = yield* core.findMany("tool_policy", {});
2586+ const policyRules = yield* listActivePolicyRuleSet();
24942587 const tools: Tool[] = [];
24952588 for (const row of rows) {
24962589 const tool = rowToTool(row);
24972590 if (!matchesToolFilter(tool, filter)) continue;
24982591 if (!includeBlocked) {
2499− const effective = resolveEffectivePolicy(
2592+ const effective = yield* resolvePolicyFromRuleSet(
25002593 normalizedPolicyId(tool),
2501− policyRows,
2502− ownerRankForRow,
2594+ policyRules,
25032595 tool.annotations?.requiresApproval,
25042596 );
25052597 if (effective.action === "block") continue;
@@ -2510,10 +2602,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 4 unmodified lines
25102602 const tool = staticToolToTool(entry);
25112603 if (!matchesToolFilter(tool, filter)) continue;
25122604 if (!includeBlocked) {
2513− const effective = resolveEffectivePolicy(
2605+ const effective = yield* resolvePolicyFromRuleSet(
25142606 normalizedPolicyId(tool),
2515− policyRows,
2516− ownerRankForRow,
2607+ policyRules,
25172608 tool.annotations?.requiresApproval,
25182609 );
25192610 if (effective.action === "block") continue;
@@ -2527,9 +2618,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines
25272618 address: ToolAddress,
25282619 ): Effect.Effect<ToolSchemaView | null, StorageFailure> =>
25292620 Effect.gen(function* () {
2621+ const policyRules = yield* listActivePolicyRuleSet();
25302622 const staticEntry = staticTools.get(String(address));
25312623 if (staticEntry) {
25322624 const tool = staticToolToTool(staticEntry);
2625+ const effective = yield* resolvePolicyFromRuleSet(
2626+ normalizedPolicyId(tool),
2627+ policyRules,
2628+ tool.annotations?.requiresApproval,
2629+ );
2630+ if (effective.action === "block") return null;
25332631 const preview = yield* Effect.tryPromise({
25342632 try: () =>
25352633 buildToolTypeScriptPreview({
@@ -2565,6 +2663,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines
25652663 });
25662664 if (!row) return null;
25672665 const tool = rowToTool(row);
2666+ const effective = yield* resolvePolicyFromRuleSet(
2667+ normalizedPolicyId(tool),
2668+ policyRules,
2669+ tool.annotations?.requiresApproval,
2670+ );
2671+ if (effective.action === "block") return null;
25682672
25692673 const definitionRows = yield* core.findMany("definition", {
25702674 where: (b: AnyCb) =>
@@ -2904,11 +3008,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 333 unmodified lines
29043008 // not the 5-segment dynamic form.
29053009 const staticEntry = staticTools.get(String(address));
29063010 if (staticEntry) {
2907− const policyRows = yield* core.findMany("tool_policy", {});
2908− const policy = resolveEffectivePolicy(
3011+ const policyRules = yield* listActivePolicyRuleSet();
3012+ const policy = yield* resolvePolicyFromRuleSet(
29093013 String(address),
2910− policyRows,
2911− ownerRankForRow,
3014+ policyRules,
29123015 staticEntry.tool.annotations?.requiresApproval,
29133016 );
29143017 if (policy.action === "block") {
@@ -2957,12 +3060,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 42 unmodified lines
29573060
29583061 // Resolve policy (owner-ranked).
29593062 const toolForPolicy = rowToTool(row);
2960− const policyRows = yield* core.findMany("tool_policy", {});
3063+ const policyRules = yield* listActivePolicyRuleSet();
29613064 const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined;
2962− const policy = resolveEffectivePolicy(
3065+ const policy = yield* resolvePolicyFromRuleSet(
29633066 normalizedPolicyId(toolForPolicy),
2964− policyRows,
2965− ownerRankForRow,
3067+ policyRules,
29663068 annotations?.requiresApproval,
29673069 );
29683070 if (policy.action === "block") {
@@ -3183,6 +3285,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 214 unmodified lines
31833285 transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect),
31843286 };
31853287
3288+ if (plugin.toolPolicyProvider) {
3289+ const rawProvider = plugin.toolPolicyProvider(ctx);
3290+ const provider = Effect.isEffect(rawProvider) ? yield* rawProvider : rawProvider;
3291+ if (provider) {
3292+ if (activeToolPolicyProvider) {
3293+ return yield* new StorageError({
3294+ message: "Only one plugin can provide the active tool policy source.",
3295+ cause: undefined,
3296+ });
3297+ }
3298+ activeToolPolicyProvider = provider;
3299+ }
3300+ }
3301+
31863302 // Build extension FIRST so it's available as `self` for staticSources.
31873303 const extension: object = plugin.extension ? plugin.extension(ctx) : {};
31883304 if (plugin.extension) {
packages/core/sdk/src/index.ts+2−0
@@ -275,6 +275,8 @@ export { · 274 unmodified lines
275275 type AnyPlugin,
276276 type StorageDeps,
277277 type OwnerBinding,
278+ type ToolPolicyProvider,
279+ type ToolPolicyProviderRule,
278280 type IntegrationRecord,
279281 type StaticSourceDecl,
280282 type StaticToolDecl,
packages/core/sdk/src/plugin.ts+32−0
@@ -48,6 +48,7 @@ import type { CredentialProvider, ProviderEntry } from "./provider"; · 47 unmodified lines
4848 import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage";
4949 import type {
5050 CreateToolPolicyInput,
51+ EffectivePolicy,
5152 RemoveToolPolicyInput,
5253 ToolPolicy,
5354 UpdateToolPolicyInput,
@@ -88,6 +89,30 @@ export type Elicit = ( · 34 unmodified lines
8889 request: ElicitationRequest,
8990 ) => Effect.Effect<ElicitationResponse, ElicitationDeclinedError>;
9091
92+// ---------------------------------------------------------------------------
93+// Active tool-policy provider.
94+//
95+// Normal executors resolve policies from core's owner-scoped `tool_policy`
96+// table. A plugin may opt one executor instance into a different rule source
97+// (for example, a toolkit-specific policy set). Core still owns enforcement;
98+// the plugin owns where those policy-shaped rows are stored.
99+// ---------------------------------------------------------------------------
100+
101+export interface ToolPolicyProviderRule {
102+ readonly id: string;
103+ readonly pattern: string;
104+ readonly action: ToolPolicy["action"];
105+ readonly position: string;
106+}
107+
108+export interface ToolPolicyProvider {
109+ readonly list: () => Effect.Effect<readonly ToolPolicyProviderRule[], StorageFailure>;
110+ readonly resolve?: (input: {
111+ readonly toolId: string;
112+ readonly defaultRequiresApproval?: boolean;
113+ }) => Effect.Effect<EffectivePolicy, StorageFailure>;
114+}
115+
91116 // ---------------------------------------------------------------------------
92117 // IntegrationRecord — the catalog row a plugin reads back (its own opaque
93118 // `config` included). Returned by `ctx.core.integrations.get`.
@@ -458,6 +483,13 @@ export interface PluginSpec< · 364 unmodified lines
458483 /** Service tag the plugin's `handlers` layer requires. */
459484 readonly extensionService?: TExtensionService;
460485
486+ /** Optional active policy source for this executor instance. At most one
487+ * loaded plugin may return a provider. When absent, core uses the normal
488+ * owner-scoped tool policies. */
489+ readonly toolPolicyProvider?: (
490+ ctx: PluginCtx<TStore>,
491+ ) => ToolPolicyProvider | null | Effect.Effect<ToolPolicyProvider | null, StorageFailure>;
492+
461493 /** Produce a connection's tools (and shared $defs). The v2 successor to
462494 * registering per-source tools — called by the executor at connection
463495 * create / refresh / oauth.complete; the result is stamped with addresses
packages/core/sdk/src/policies.test.ts+81−2
@@ -1,5 +1,5 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { Effect, Predicate, Result } from "effect";
2+import { Effect, Predicate, Result, Schema } from "effect";
33
44 import { type ToolPolicyRow } from "./core-schema";
55 import {
@@ -19,7 +19,7 @@ import { · 13 unmodified lines
1919 matchPattern,
2020 resolveToolPolicy,
2121 } from "./policies";
22−import { definePlugin } from "./plugin";
22+import { definePlugin, tool } from "./plugin";
2323 import type { CredentialProvider } from "./provider";
2424 import { makeTestExecutor } from "./testing";
2525
@@ -512,6 +512,85 @@ describe("blocked tools", () => { · 486 unmodified lines
512512 );
513513 });
514514
515+describe("active tool-policy provider", () => {
516+ const staticPlugin = definePlugin(() => ({
517+ id: "toolkit-fixture" as const,
518+ storage: () => ({}),
519+ staticSources: () => [
520+ {
521+ id: "toolkit-fixture.ctl",
522+ kind: "control" as const,
523+ name: "Toolkit Fixture",
524+ tools: [
525+ tool({
526+ name: "allowed",
527+ description: "allowed",
528+ inputSchema: Schema.toStandardSchemaV1(
529+ Schema.toStandardJSONSchemaV1(Schema.Struct({})),
530+ ),
531+ execute: () => Effect.succeed("allowed"),
532+ }),
533+ tool({
534+ name: "hidden",
535+ description: "hidden",
536+ inputSchema: Schema.toStandardSchemaV1(
537+ Schema.toStandardJSONSchemaV1(Schema.Struct({})),
538+ ),
539+ execute: () => Effect.succeed("hidden"),
540+ }),
541+ ],
542+ },
543+ ],
544+ }))();
545+
546+ const policyProviderPlugin = definePlugin(() => ({
547+ id: "toolkit-policy-provider" as const,
548+ storage: () => ({}),
549+ toolPolicyProvider: () => ({
550+ list: () =>
551+ Effect.succeed([
552+ {
553+ id: "allow-static",
554+ pattern: "toolkit-fixture.ctl.allowed",
555+ action: "approve" as const,
556+ position: "a0",
557+ },
558+ ]),
559+ }),
560+ }))();
561+
562+ it.effect("uses provider rules as an allowlist for list, schema, and execute", () =>
563+ Effect.gen(function* () {
564+ const executor = yield* makeTestExecutor({
565+ plugins: [staticPlugin, policyProviderPlugin] as const,
566+ });
567+
568+ const tools = yield* executor.tools.list();
569+ expect(tools.map((t) => String(t.address)).sort()).toEqual(["toolkit-fixture.ctl.allowed"]);
570+
571+ const allowedSchema = yield* executor.tools.schema(
572+ ToolAddress.make("toolkit-fixture.ctl.allowed"),
573+ );
574+ expect(allowedSchema?.name).toBe("allowed");
575+
576+ const hiddenSchema = yield* executor.tools.schema(
577+ ToolAddress.make("toolkit-fixture.ctl.hidden"),
578+ );
579+ expect(hiddenSchema).toBeNull();
580+
581+ const allowed = yield* executor.execute(ToolAddress.make("toolkit-fixture.ctl.allowed"), {});
582+ expect(allowed).toBe("allowed");
583+
584+ const blocked = yield* Effect.result(
585+ executor.execute(ToolAddress.make("toolkit-fixture.ctl.hidden"), {}),
586+ );
587+ expect(Result.isFailure(blocked)).toBe(true);
588+ if (!Result.isFailure(blocked)) return;
589+ expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true);
590+ }),
591+ );
592+});
593+
515594 describe("approve / require_approval interaction with annotations", () => {
516595 it.effect("approve skips the elicitation prompt even when plugin requires approval", () =>
517596 Effect.gen(function* () {
packages/hosts/mcp/src/envelope.test.ts+27−0
@@ -20,6 +20,7 @@ import { · 19 unmodified lines
2020 McpErrorReporterNoop,
2121 McpServingRoutes,
2222 McpSessionStore,
23+ type McpResource,
2324 type McpDispatchResult,
2425 type Principal,
2526 } from "./index";
@@ -133,3 +134,29 @@ describe("McpServingRoutes envelope", () => { · 107 unmodified lines
133134 expect(captures[0]).toContain("induced defect");
134135 });
135136 });
137+
138+it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => {
139+ const seen = await Effect.runPromise(Ref.make<McpResource | null>(null));
140+ const RecordingStoreLive = Layer.succeed(McpSessionStore)({
141+ dispatch: ({ resource }): Effect.Effect<McpDispatchResult> =>
142+ Ref.set(seen, resource).pipe(
143+ Effect.as(new Response(JSON.stringify({ jsonrpc: "2.0", id: 1 }), { status: 200 })),
144+ ),
145+ dispose: () => Effect.void,
146+ });
147+
148+ const handler = buildHandler(RecordingStoreLive, McpErrorReporterNoop);
149+ const response = await handler(
150+ new Request("https://host.test/mcp/toolkits/deploy", {
151+ method: "POST",
152+ headers: { authorization: "Bearer x", "content-type": "application/json" },
153+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
154+ }),
155+ );
156+
157+ expect(response.status).toBe(200);
158+ expect(await Effect.runPromise(Ref.get(seen))).toEqual({
159+ kind: "toolkit",
160+ slug: "deploy",
161+ });
162+});
packages/hosts/mcp/src/envelope.ts+83−67
@@ -2,11 +2,13 @@ import { Effect, Match, Predicate } from "effect"; · 1 unmodified line
22 import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
33
44 import {
5+ defaultMcpResource,
56 McpAuthProvider,
67 McpErrorReporter,
78 McpSessionStore,
89 type AuthOutcome,
910 type McpDispatchResult,
11+ type McpResource,
1012 } from "./seams";
1113
1214 // ---------------------------------------------------------------------------
@@ -14,7 +16,8 @@ import { · 1 unmodified line
1416 //
1517 // Routes:
1618 // GET <provider-declared discovery paths> -> McpAuthProvider metadata
17−// * /mcp -> authenticate -> dispatch
19+// * /mcp -> authenticate -> dispatch(default)
20+// * /mcp/toolkits/:toolkitSlug -> authenticate -> dispatch(toolkit)
1821 //
1922 // The provider DECLARES the discovery paths it owns (at least the protected-
2023 // resource metadata document) via `McpAuthProvider.discoveryRoutes`; the
@@ -24,8 +27,8 @@ import { · 3 unmodified lines
2427 // WorkOS, external). The envelope only needs the provider's discovery routes,
2528 // resource-metadata URL, and authenticate.
2629 //
27−// The envelope hard-codes ONLY the `/mcp` path and CORS. Everything else —
28−// every `/.well-known/*` path, the resource-metadata URL, the authn/authz
30+// The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else
31+// — every `/.well-known/*` path, the resource-metadata URL, the authn/authz
2932 // semantics, and the entire session lifecycle (create + forward + ownership) —
3033 // comes from the two seams.
3134 //
@@ -38,6 +41,7 @@ import { · 6 unmodified lines
3841 // ---------------------------------------------------------------------------
3942
4043 const MCP_PATH = "/mcp";
44+const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug";
4145
4246 /** The methods the streamable-HTTP transport accepts on `/mcp`. */
4347 const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
@@ -170,69 +174,73 @@ const renderDispatchError = (lookup: "not-found" | "forbidden"): Response => · 126 unmodified lines
170174 ? jsonRpcResponse(404, -32001, "Session not found")
171175 : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
172176
173−/** Dispatch a `/mcp` request through authenticate -> store.dispatch -> transport. */
174−const mcpDispatch = Effect.gen(function* () {
175− const httpRequest = yield* HttpServerRequest.HttpServerRequest;
176− const auth = yield* McpAuthProvider;
177− const store = yield* McpSessionStore;
178− const request = yield* toWebRequest(httpRequest);
177+/** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */
178+const mcpDispatch = (resource: McpResource) =>
179+ Effect.gen(function* () {
180+ const httpRequest = yield* HttpServerRequest.HttpServerRequest;
181+ const auth = yield* McpAuthProvider;
182+ const store = yield* McpSessionStore;
183+ const request = yield* toWebRequest(httpRequest);
179184
180− // CORS preflight: answer before auth so unauthenticated clients can probe.
181− if (request.method === "OPTIONS") {
182− return HttpServerResponse.raw(corsPreflightResponse());
183− }
185+ // CORS preflight: answer before auth so unauthenticated clients can probe.
186+ if (request.method === "OPTIONS") {
187+ return HttpServerResponse.raw(corsPreflightResponse());
188+ }
184189
185− // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other
186− // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch —
187− // otherwise it would fall through and spin up a session engine for a method
188− // the transport can't serve.
189− if (!ALLOWED_MCP_METHODS.has(request.method)) {
190− return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed"));
191− }
190+ // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other
191+ // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch —
192+ // otherwise it would fall through and spin up a session engine for a method
193+ // the transport can't serve.
194+ if (!ALLOWED_MCP_METHODS.has(request.method)) {
195+ return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed"));
196+ }
192197
193− const sessionId = request.headers.get("mcp-session-id");
198+ const sessionId = request.headers.get("mcp-session-id");
194199
195− // Authenticate (and, for session-aware providers, authorize) on EVERY
196− // request. On a non-Authenticated outcome:
197− // - Forbidden -> dispose the live session first (cloud tears down a DO
198− // whose org access was revoked), then render the 403. The
199− // inbound request is forwarded so the store can propagate
200− // the request's W3C trace context onto the teardown RPC.
201− // - other -> render directly.
202− const outcome = yield* auth.authenticate(request);
203− if (!Predicate.isTagged(outcome, "Authenticated")) {
204− if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
205− yield* store.dispose(sessionId, request);
200+ // Authenticate (and, for session-aware providers, authorize) on EVERY
201+ // request. On a non-Authenticated outcome:
202+ // - Forbidden -> dispose the live session first (cloud tears down a DO
203+ // whose org access was revoked), then render the 403. The
204+ // inbound request is forwarded so the store can propagate
205+ // the request's W3C trace context onto the teardown RPC.
206+ // - other -> render directly.
207+ const outcome = yield* auth.authenticate(request);
208+ if (!Predicate.isTagged(outcome, "Authenticated")) {
209+ if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
210+ yield* store.dispose(sessionId, request);
211+ }
212+ return HttpServerResponse.raw(renderAuthError(auth, request, outcome));
206213 }
207− return HttpServerResponse.raw(renderAuthError(auth, request, outcome));
208− }
209− const principal = outcome.principal;
214+ const principal = outcome.principal;
210215
211− // No session id: per the streamable-HTTP transport contract, only POST opens
212− // a session. A GET needs an existing id (400); a DELETE on nothing is a
213− // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up
214− // an engine for a bare GET/DELETE.
215− if (!sessionId) {
216− if (request.method === "GET") {
217− return HttpServerResponse.raw(
218− jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"),
219− );
216+ // No session id: per the streamable-HTTP transport contract, only POST opens
217+ // a session. A GET needs an existing id (400); a DELETE on nothing is a
218+ // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up
219+ // an engine for a bare GET/DELETE.
220+ if (!sessionId) {
221+ if (request.method === "GET") {
222+ return HttpServerResponse.raw(
223+ jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"),
224+ );
225+ }
226+ if (request.method === "DELETE") {
227+ return HttpServerResponse.raw(
228+ new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }),
229+ );
230+ }
220231 }
221− if (request.method === "DELETE") {
222− return HttpServerResponse.raw(
223− new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }),
224− );
225− }
226− }
227232
228− const result: McpDispatchResult = yield* store.dispatch({
229− request,
230− principal,
231− sessionId,
232− method: request.method,
233+ const result: McpDispatchResult = yield* store.dispatch({
234+ request,
235+ principal,
236+ resource,
237+ sessionId,
238+ method: request.method,
239+ });
240+ return HttpServerResponse.raw(
241+ result instanceof Response ? result : renderDispatchError(result),
242+ );
233243 });
234− return HttpServerResponse.raw(result instanceof Response ? result : renderDispatchError(result));
235−});
236244
237245 /**
238246 * The `/mcp` route. Wraps {@link mcpDispatch} in a top-level `catchCause`: a
@@ -242,15 +250,22 @@ const mcpDispatch = Effect.gen(function* () { · 3 unmodified lines
242250 * otherwise, since the envelope returns a `Response`) and rendered as a stable
243251 * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body.
244252 */
245−const mcpRoute = mcpDispatch.pipe(
246− Effect.catchCause((cause) =>
247− Effect.gen(function* () {
248− const reporter = yield* McpErrorReporter;
249− yield* reporter.report(cause);
250− return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error"));
251− }),
252− ),
253−);
253+const mcpRoute = (resource: McpResource) =>
254+ mcpDispatch(resource).pipe(
255+ Effect.catchCause((cause) =>
256+ Effect.gen(function* () {
257+ const reporter = yield* McpErrorReporter;
258+ yield* reporter.report(cause);
259+ return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error"));
260+ }),
261+ ),
262+ );
263+
264+const toolkitMcpRoute = Effect.gen(function* () {
265+ const params = yield* HttpRouter.params;
266+ const slug = params.toolkitSlug;
267+ return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource);
268+});
254269
255270 /**
256271 * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges
@@ -273,6 +288,7 @@ export const McpServingRoutes = HttpRouter.use((router) => · 16 unmodified lines
273288 Effect.sync(() => HttpServerResponse.raw(corsPreflightResponse())),
274289 );
275290 }
276− yield* router.add("*", MCP_PATH, mcpRoute);
291+ yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource));
292+ yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute);
277293 }),
278294 );
packages/hosts/mcp/src/in-memory-session-store.ts+35−11
@@ -17,10 +17,13 @@ import { · 16 unmodified lines
1717 import { jsonRpcErrorBody } from "./envelope";
1818 import {
1919 McpSessionStore,
20+ defaultMcpResource,
21+ mcpResourceKey,
2022 principalOwns,
2123 type McpDispatchInput,
2224 type McpDispatchResult,
2325 type Principal,
26+ type McpResource,
2427 } from "./seams";
2528 import type { BrowserApprovalStore } from "./tool-server";
2629
@@ -61,6 +64,7 @@ export interface BuiltMcpServer { · 34 unmodified lines
6164
6265 /** The browser-mode wiring the store hands a build call when a session opts in. */
6366 export interface McpBuildServerOptions {
67+ readonly resource?: McpResource;
6468 readonly elicitationMode?:
6569 | { readonly mode: "browser"; readonly approvalUrl: (executionId: string) => string }
6670 | { readonly mode: "model" }
@@ -120,6 +124,19 @@ const json = (value: unknown, status = 200): Response => · 53 unmodified lines
120124 const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;
121125 const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;
122126
127+interface SessionOwner {
128+ readonly principal: Principal;
129+ readonly resource: McpResource;
130+}
131+
132+const sessionOwnerMatches = (
133+ owner: SessionOwner,
134+ principal: Principal,
135+ resource: McpResource,
136+): boolean =>
137+ principalOwns(owner.principal, principal) &&
138+ mcpResourceKey(owner.resource) === mcpResourceKey(resource);
139+
123140 /**
124141 * Build the in-process session store plus an explicit `close()` that disposes
125142 * all live sessions. `close()` is not part of the seam — it is the host lifetime
@@ -137,7 +154,7 @@ export const makeInMemoryMcpSessionStore = ( · 11 unmodified lines
137154 ): InMemoryMcpSessionStore => {
138155 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
139156 const servers = new Map<string, McpServer>();
140− const owners = new Map<string, Principal>();
157+ const owners = new Map<string, SessionOwner>();
141158 const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>();
142159 const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore();
143160
@@ -181,12 +198,13 @@ export const makeInMemoryMcpSessionStore = ( · 37 unmodified lines
181198 const forward = (
182199 sessionId: string,
183200 principal: Principal,
201+ resource: McpResource,
184202 request: Request,
185203 ): Effect.Effect<McpDispatchResult> => {
186204 const transport = transports.get(sessionId);
187205 const owner = owners.get(sessionId);
188206 if (!transport || !owner) return Effect.succeed("not-found");
189− if (!principalOwns(owner, principal)) return Effect.succeed("forbidden");
207+ if (!sessionOwnerMatches(owner, principal, resource)) return Effect.succeed("forbidden");
190208 return runHandleRequest(transport, request);
191209 };
192210
@@ -218,12 +236,16 @@ export const makeInMemoryMcpSessionStore = ( · 25 unmodified lines
218236 };
219237
220238 /** Open a new session: build the server, connect a transport, drive the request. */
221− const create = (principal: Principal, request: Request): Effect.Effect<McpDispatchResult> => {
239+ const create = (
240+ principal: Principal,
241+ resource: McpResource,
242+ request: Request,
243+ ): Effect.Effect<McpDispatchResult> => {
222244 let createdSessionId: string | null = null;
223− return buildServer(
224− principal,
225− buildOptionsFor(request, () => createdSessionId),
226− ).pipe(
245+ return buildServer(principal, {
246+ ...buildOptionsFor(request, () => createdSessionId),
247+ resource,
248+ }).pipe(
227249 Effect.flatMap(({ mcpServer, engine }) =>
228250 Effect.gen(function* () {
229251 const transport = new WebStandardStreamableHTTPServerTransport({
@@ -233,7 +255,7 @@ export const makeInMemoryMcpSessionStore = ( · 3 unmodified lines
233255 createdSessionId = sid;
234256 transports.set(sid, transport);
235257 servers.set(sid, mcpServer);
236− owners.set(sid, principal);
258+ owners.set(sid, { principal, resource });
237259 engines.set(sid, engine);
238260 },
239261 onsessionclosed: (sid) => void dispose(sid, { server: true }),
@@ -259,8 +281,10 @@ export const makeInMemoryMcpSessionStore = ( · 19 unmodified lines
259281 };
260282
261283 const store: McpSessionStore["Service"] = {
262− dispatch: ({ request, principal, sessionId }: McpDispatchInput) =>
263− sessionId ? forward(sessionId, principal, request) : create(principal, request),
284+ dispatch: ({ request, principal, resource, sessionId }: McpDispatchInput) =>
285+ sessionId
286+ ? forward(sessionId, principal, resource, request)
287+ : create(principal, resource ?? defaultMcpResource, request),
264288 dispose: (sessionId) =>
265289 Effect.promise(() => dispose(sessionId, { transport: true, server: true })),
266290 };
@@ -271,7 +295,7 @@ export const makeInMemoryMcpSessionStore = ( · 4 unmodified lines
271295 ): "allowed" | "not-found" | "forbidden" => {
272296 const owner = owners.get(sessionId);
273297 if (!owner) return "not-found";
274− if (principal && !principalOwns(owner, principal)) return "forbidden";
298+ if (principal && !principalOwns(owner.principal, principal)) return "forbidden";
275299 return "allowed";
276300 };
277301
packages/hosts/mcp/src/index.ts+3−0
@@ -18,6 +18,8 @@ export { · 17 unmodified lines
1818 McpSessionStore,
1919 McpErrorReporter,
2020 McpErrorReporterNoop,
21+ defaultMcpResource,
22+ mcpResourceKey,
2123 principalOwns,
2224 authenticated,
2325 unauthorized,
@@ -31,6 +33,7 @@ export { · 7 unmodified lines
3133 type McpDiscoveryRoute,
3234 type McpDispatchInput,
3335 type McpDispatchResult,
36+ type McpResource,
3437 } from "./seams";
3538
3639 export { McpServingRoutes, jsonRpcErrorBody } from "./envelope";
packages/hosts/mcp/src/seams.ts+20−0
@@ -56,6 +56,25 @@ export type Principal = Schema.Schema.Type<typeof Principal>; · 55 unmodified lines
5656 export const principalOwns = (owner: Principal, principal: Principal): boolean =>
5757 owner.accountId === principal.accountId && owner.organizationId === principal.organizationId;
5858
59+// ---------------------------------------------------------------------------
60+// Shared MCP resource identity.
61+//
62+// The default `/mcp` endpoint and named sub-resources such as
63+// `/mcp/toolkits/<slug>` share auth and transport machinery, but a serving
64+// session belongs to the exact resource path that minted it. This keeps a
65+// leaked/reused `mcp-session-id` from crossing from one exposed capability set
66+// into another.
67+// ---------------------------------------------------------------------------
68+
69+export type McpResource =
70+ | { readonly kind: "default" }
71+ | { readonly kind: "toolkit"; readonly slug: string };
72+
73+export const defaultMcpResource: McpResource = { kind: "default" };
74+
75+export const mcpResourceKey = (resource: McpResource): string =>
76+ resource.kind === "default" ? "default" : `toolkit:${resource.slug}`;
77+
5978 // ---------------------------------------------------------------------------
6079 // AuthOutcome — the result of `McpAuthProvider.authenticate`.
6180 //
@@ -210,6 +229,7 @@ export class McpAuthProvider extends Context.Service< · 148 unmodified lines
210229 export interface McpDispatchInput {
211230 readonly request: Request;
212231 readonly principal: Principal;
232+ readonly resource: McpResource;
213233 readonly sessionId: string | null;
214234 readonly method: string;
215235 }
packages/plugins/toolkits/package.json+105−0
@@ -0,0 +1,105 @@
1+{
2+ "name": "@executor-js/plugin-toolkits",
3+ "version": "1.5.12",
4+ "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/toolkits",
5+ "bugs": {
6+ "url": "https://github.com/RhysSullivan/executor/issues"
7+ },
8+ "license": "MIT",
9+ "repository": {
10+ "type": "git",
11+ "url": "git+https://github.com/RhysSullivan/executor.git",
12+ "directory": "packages/plugins/toolkits"
13+ },
14+ "files": [
15+ "dist"
16+ ],
17+ "type": "module",
18+ "exports": {
19+ "./server": "./src/server.ts",
20+ "./client": "./src/client.tsx",
21+ "./shared": "./src/shared.ts"
22+ },
23+ "publishConfig": {
24+ "access": "public",
25+ "exports": {
26+ "./server": {
27+ "import": {
28+ "types": "./dist/server.d.ts",
29+ "default": "./dist/server.js"
30+ }
31+ },
32+ "./client": {
33+ "import": {
34+ "types": "./dist/client.d.ts",
35+ "default": "./dist/client.js"
36+ }
37+ },
38+ "./shared": {
39+ "import": {
40+ "types": "./dist/shared.d.ts",
41+ "default": "./dist/shared.js"
42+ }
43+ }
44+ }
45+ },
46+ "scripts": {
47+ "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)",
48+ "typecheck": "tsgo --noEmit",
49+ "test": "vitest run",
50+ "typecheck:slow": "tsc --noEmit"
51+ },
52+ "dependencies": {
53+ "@executor-js/sdk": "workspace:*"
54+ },
55+ "devDependencies": {
56+ "@effect/atom-react": "catalog:",
57+ "@effect/vitest": "catalog:",
58+ "@executor-js/api": "workspace:*",
59+ "@executor-js/react": "workspace:*",
60+ "@tanstack/react-router": "catalog:",
61+ "@types/node": "catalog:",
62+ "@types/react": "catalog:",
63+ "bun-types": "catalog:",
64+ "effect": "catalog:",
65+ "fractional-indexing": "^3.2.0",
66+ "lucide-react": "^1.7.0",
67+ "react": "catalog:",
68+ "tsup": "catalog:",
69+ "typescript": "catalog:",
70+ "vitest": "catalog:"
71+ },
72+ "peerDependencies": {
73+ "@effect/atom-react": "catalog:",
74+ "@executor-js/api": "workspace:*",
75+ "@executor-js/react": "workspace:*",
76+ "@tanstack/react-router": "catalog:",
77+ "effect": "catalog:",
78+ "fractional-indexing": "^3.2.0",
79+ "lucide-react": "^1.7.0",
80+ "react": "catalog:"
81+ },
82+ "peerDependenciesMeta": {
83+ "@effect/atom-react": {
84+ "optional": true
85+ },
86+ "@executor-js/api": {
87+ "optional": true
88+ },
89+ "@executor-js/react": {
90+ "optional": true
91+ },
92+ "@tanstack/react-router": {
93+ "optional": true
94+ },
95+ "fractional-indexing": {
96+ "optional": true
97+ },
98+ "lucide-react": {
99+ "optional": true
100+ },
101+ "react": {
102+ "optional": true
103+ }
104+ }
105+}
packages/plugins/toolkits/src/client.tsx+18−0
@@ -0,0 +1,18 @@
1+import { defineClientPlugin } from "@executor-js/sdk/client";
2+
3+import { ToolkitsPage } from "./page";
4+
5+export default defineClientPlugin({
6+ id: "toolkits" as const,
7+ pages: [
8+ {
9+ path: "/",
10+ component: ToolkitsPage,
11+ nav: { label: "Toolkits" },
12+ },
13+ {
14+ path: "/$toolkitSlug",
15+ component: ToolkitsPage,
16+ },
17+ ],
18+});
packages/plugins/toolkits/src/page.tsx+1189−0
@@ -0,0 +1,1189 @@
1+import { useId, useMemo, useState } from "react";
2+import { Link, useNavigate, useParams } from "@tanstack/react-router";
3+import { useAtomSet, useAtomValue } from "@effect/atom-react";
4+import * as Atom from "effect/unstable/reactivity/Atom";
5+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
6+import {
7+ ArrowLeftIcon,
8+ BoxIcon,
9+ CheckIcon,
10+ PlugIcon,
11+ PlusIcon,
12+ SearchIcon,
13+ Trash2Icon,
14+} from "lucide-react";
15+import {
16+ createPluginAtomClient,
17+ useIntegrationPlugins,
18+ type IntegrationPlugin,
19+ type PluginPageProps,
20+} from "@executor-js/sdk/client";
21+import {
22+ matchPattern,
23+ type EffectivePolicy,
24+ type Integration,
25+ type Owner,
26+ type ToolAddress,
27+ type ToolPolicyAction,
28+} from "@executor-js/sdk/shared";
29+import { integrationsOptimisticAtom, toolsAllAtom } from "@executor-js/react/api/atoms";
30+import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
31+import {
32+ getExecutorApiBaseUrl,
33+ getExecutorOrganizationHeaders,
34+ getExecutorServerAuthorizationHeader,
35+} from "@executor-js/react/api/server-connection";
36+import { ownerLabel } from "@executor-js/react/api/owner-display";
37+import { Badge } from "@executor-js/react/components/badge";
38+import { Button } from "@executor-js/react/components/button";
39+import { CopyButton } from "@executor-js/react/components/copy-button";
40+import {
41+ IntegrationFavicon,
42+ integrationPresetIconUrl,
43+} from "@executor-js/react/components/integration-favicon";
44+import {
45+ Dialog,
46+ DialogContent,
47+ DialogDescription,
48+ DialogFooter,
49+ DialogHeader,
50+ DialogTitle,
51+} from "@executor-js/react/components/dialog";
52+import { Input } from "@executor-js/react/components/input";
53+import { Label } from "@executor-js/react/components/label";
54+import { Skeleton } from "@executor-js/react/components/skeleton";
55+import { ToolDetail, ToolDetailEmpty } from "@executor-js/react/components/tool-detail";
56+import { ToolTree, type ToolSummary } from "@executor-js/react/components/tool-tree";
57+import { cn } from "@executor-js/react/lib/utils";
58+
59+import {
60+ ToolkitsApi,
61+ type ToolkitConnectionResponse,
62+ type ToolkitPolicyResponse,
63+ type ToolkitResponse,
64+} from "./shared";
65+
66+const ToolkitsClient = createPluginAtomClient(ToolkitsApi, {
67+ baseUrl: getExecutorApiBaseUrl,
68+ authorizationHeader: getExecutorServerAuthorizationHeader,
69+ headers: getExecutorOrganizationHeaders,
70+});
71+
72+const toolkitWriteKeys = [
73+ ReactivityKey.connections,
74+ ReactivityKey.policies,
75+ ReactivityKey.tools,
76+] as const;
77+
78+const toolkitsAtom = ToolkitsClient.query("toolkits", "list", {
79+ timeToLive: "30 seconds",
80+ reactivityKeys: [ReactivityKey.policies],
81+});
82+
83+const toolkitPoliciesAtom = Atom.family((toolkitId: string) =>
84+ ToolkitsClient.query("toolkits", "listPolicies", {
85+ params: { toolkitId },
86+ timeToLive: "30 seconds",
87+ reactivityKeys: [ReactivityKey.policies],
88+ }),
89+);
90+
91+const toolkitConnectionsAtom = Atom.family((toolkitId: string) =>
92+ ToolkitsClient.query("toolkits", "listConnections", {
93+ params: { toolkitId },
94+ timeToLive: "30 seconds",
95+ reactivityKeys: [ReactivityKey.connections],
96+ }),
97+);
98+
99+const createToolkit = ToolkitsClient.mutation("toolkits", "create");
100+const removeToolkit = ToolkitsClient.mutation("toolkits", "remove");
101+const createToolkitPolicy = ToolkitsClient.mutation("toolkits", "createPolicy");
102+const updateToolkitPolicy = ToolkitsClient.mutation("toolkits", "updatePolicy");
103+const removeToolkitPolicy = ToolkitsClient.mutation("toolkits", "removePolicy");
104+const createToolkitConnection = ToolkitsClient.mutation("toolkits", "createConnection");
105+
106+type ToolRow = {
107+ readonly address: ToolAddress;
108+ readonly integration: string;
109+ readonly owner?: Owner;
110+ readonly connection?: string;
111+ readonly name: string;
112+ readonly description?: string;
113+ readonly requiresApproval?: boolean;
114+ readonly static?: boolean;
115+};
116+
117+const comparePolicy = (a: ToolkitPolicyResponse, b: ToolkitPolicyResponse): number => {
118+ if (a.position < b.position) return -1;
119+ if (a.position > b.position) return 1;
120+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
121+};
122+
123+const pluginDefaultPolicy = (requiresApproval: boolean | undefined): EffectivePolicy =>
124+ requiresApproval
125+ ? { action: "require_approval", source: "plugin-default" }
126+ : { action: "approve", source: "plugin-default" };
127+
128+const isLegacyConnectionPolicy = (policy: ToolkitPolicyResponse): boolean => {
129+ if (policy.action !== "approve") return false;
130+ const parts = policy.pattern.split(".");
131+ return parts.at(-1) === "*" && (parts.length === 3 || parts.length === 4);
132+};
133+
134+const resolveToolkitPolicy = (
135+ matchId: string,
136+ policies: readonly ToolkitPolicyResponse[],
137+ requiresApproval?: boolean,
138+): EffectivePolicy => {
139+ for (const policy of [...policies].sort(comparePolicy)) {
140+ if (!matchPattern(policy.pattern, matchId)) continue;
141+ return {
142+ action: policy.action,
143+ source: "user",
144+ pattern: policy.pattern,
145+ policyId: policy.id,
146+ };
147+ }
148+ return pluginDefaultPolicy(requiresApproval);
149+};
150+
151+const toolMatchId = (tool: ToolRow): string =>
152+ tool.static ? String(tool.address) : String(tool.address).replace(/^tools\./, "");
153+
154+const toolCanAppearInToolkit = (toolkit: ToolkitResponse, tool: ToolRow): boolean =>
155+ toolkit.owner === "user" || tool.static === true || tool.owner !== "user";
156+
157+const toolkitUrlFor = (orgSlug: string | undefined, slug: string): string => {
158+ const path = orgSlug ? `/${orgSlug}/mcp/toolkits/${slug}` : `/mcp/toolkits/${slug}`;
159+ if (typeof window === "undefined") return path;
160+ return `${window.location.origin}${path}`;
161+};
162+
163+const identityPattern = (displayPattern: string): string => displayPattern;
164+
165+const formatUpdatedAt = (value: number): string =>
166+ new Intl.DateTimeFormat(undefined, {
167+ month: "short",
168+ day: "numeric",
169+ hour: "numeric",
170+ minute: "2-digit",
171+ }).format(new Date(value));
172+
173+const toolkitScopeLabel = (toolkit: ToolkitResponse): string =>
174+ toolkit.owner === "org" ? "Workspace tools" : "Workspace + personal tools";
175+
176+const compareToolkitRows = (a: ToolkitResponse, b: ToolkitResponse): number => {
177+ if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt;
178+ return a.name.localeCompare(b.name);
179+};
180+
181+const toolkitCardStyle = { minHeight: "9rem" };
182+const toolkitShelfStyle = { minHeight: "28.5rem" };
183+const toolkitGridContainerStyle = { maxWidth: "80rem" };
184+const toolkitToolTreeStyle = { width: "24rem" };
185+
186+const ownerAccentClass = (owner: Owner): string =>
187+ owner === "org"
188+ ? "border-sky-500/25 bg-sky-500/10 text-sky-700 dark:text-sky-300"
189+ : "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
190+
191+type ToolkitConnectionGroup = {
192+ readonly id: string;
193+ readonly owner: Owner;
194+ readonly integration: string;
195+ readonly connection: string;
196+ readonly patterns: readonly string[];
197+ readonly tools: readonly ToolRow[];
198+};
199+
200+type IntegrationMeta = {
201+ readonly name: string;
202+ readonly sourceId: string;
203+ readonly icon?: string | null;
204+ readonly url?: string;
205+};
206+
207+const toolOwner = (tool: ToolRow): Owner => (tool.static ? "org" : (tool.owner ?? "org"));
208+
209+const toolConnectionName = (tool: ToolRow): string => {
210+ if (tool.connection && tool.connection.length > 0) return tool.connection;
211+ return tool.static ? "built-in" : "default";
212+};
213+
214+const policyPrefixForTool = (tool: ToolRow): string => {
215+ const matchId = toolMatchId(tool);
216+ const integration = String(tool.integration);
217+ if (tool.connection && tool.connection.length > 0) {
218+ const connection = String(tool.connection);
219+ if (tool.owner) {
220+ const ownerConnectionPrefix = `${integration}.${tool.owner}.${connection}`;
221+ if (matchId === ownerConnectionPrefix || matchId.startsWith(`${ownerConnectionPrefix}.`)) {
222+ return ownerConnectionPrefix;
223+ }
224+ }
225+ const connectionPrefix = `${integration}.${connection}`;
226+ if (matchId === connectionPrefix || matchId.startsWith(`${connectionPrefix}.`)) {
227+ return connectionPrefix;
228+ }
229+ }
230+ const name = String(tool.name);
231+ if (name.length > 0 && matchId.endsWith(`.${name}`)) {
232+ return matchId.slice(0, -name.length - 1);
233+ }
234+ const segments = matchId.split(".");
235+ return segments.length > 1 ? segments.slice(0, -1).join(".") : matchId;
236+};
237+
238+const connectionPatternForTool = (tool: ToolRow): string => `${policyPrefixForTool(tool)}.*`;
239+
240+const compareConnectionGroups = (a: ToolkitConnectionGroup, b: ToolkitConnectionGroup): number => {
241+ const ownerRank = (owner: Owner) => (owner === "org" ? 0 : 1);
242+ return (
243+ ownerRank(a.owner) - ownerRank(b.owner) ||
244+ a.integration.localeCompare(b.integration) ||
245+ a.connection.localeCompare(b.connection) ||
246+ a.id.localeCompare(b.id)
247+ );
248+};
249+
250+const patternSubsumes = (candidate: string, covered: string): boolean => {
251+ if (candidate === covered) return true;
252+ if (!candidate.endsWith(".*")) return false;
253+ const prefix = candidate.slice(0, -1);
254+ return covered.startsWith(prefix);
255+};
256+
257+const reducePolicyPatterns = (patterns: readonly string[]): readonly string[] => {
258+ const unique = [...new Set(patterns)].sort((a, b) => a.localeCompare(b));
259+ return unique.filter(
260+ (pattern) =>
261+ !unique.some((candidate) => candidate !== pattern && patternSubsumes(candidate, pattern)),
262+ );
263+};
264+
265+const buildConnectionGroups = (tools: readonly ToolRow[]): readonly ToolkitConnectionGroup[] => {
266+ const groups = new Map<string, ToolkitConnectionGroup & { tools: ToolRow[] }>();
267+ for (const tool of tools) {
268+ const owner = toolOwner(tool);
269+ const key = `${owner}:${tool.integration}:${toolConnectionName(tool)}`;
270+ const existing = groups.get(key);
271+ if (existing) {
272+ existing.tools.push(tool);
273+ continue;
274+ }
275+ groups.set(key, {
276+ id: key,
277+ owner,
278+ integration: String(tool.integration),
279+ connection: toolConnectionName(tool),
280+ patterns: [],
281+ tools: [tool],
282+ });
283+ }
284+ return [...groups.values()]
285+ .map((group) => {
286+ const sortedTools = [...group.tools].sort(compareTools);
287+ return {
288+ ...group,
289+ patterns: reducePolicyPatterns(sortedTools.map(connectionPatternForTool)),
290+ tools: sortedTools,
291+ };
292+ })
293+ .sort(compareConnectionGroups);
294+};
295+
296+const compareTools = (a: ToolRow, b: ToolRow): number =>
297+ String(a.name).localeCompare(String(b.name)) || toolMatchId(a).localeCompare(toolMatchId(b));
298+
299+const connectionTitle = (group: ToolkitConnectionGroup): string =>
300+ `${group.integration} / ${group.connection}`;
301+
302+const connectionDisplayTitle = (group: ToolkitConnectionGroup, meta: IntegrationMeta): string =>
303+ group.connection === "built-in" || group.connection === "default" ? meta.name : group.connection;
304+
305+const connectionDisplaySubtitle = (group: ToolkitConnectionGroup, meta: IntegrationMeta): string =>
306+ group.connection === "built-in" || group.connection === "default"
307+ ? `${group.tools.length} ${group.tools.length === 1 ? "tool" : "tools"}`
308+ : `${meta.name} · ${group.tools.length} ${group.tools.length === 1 ? "tool" : "tools"}`;
309+
310+const integrationMetaFor = (
311+ group: ToolkitConnectionGroup,
312+ integrations: readonly Integration[],
313+ integrationPlugins: readonly IntegrationPlugin[],
314+): IntegrationMeta => {
315+ const integration = integrations.find((row) => String(row.slug) === group.integration);
316+ const fallbackName = group.integration
317+ .replace(/[_-]/g, " ")
318+ .replace(/\b\w/g, (character) => character.toUpperCase());
319+ const source = {
320+ id: group.integration,
321+ kind: integration?.kind ?? group.integration,
322+ name: integration?.name ?? fallbackName,
323+ url: integration?.displayUrl,
324+ };
325+ return {
326+ name: source.name,
327+ sourceId: group.integration,
328+ icon: integrationPresetIconUrl(source, integrationPlugins),
329+ ...(source.url ? { url: source.url } : {}),
330+ };
331+};
332+
333+const legacyConnectionPolicyIds = (
334+ policies: readonly ToolkitPolicyResponse[],
335+ connectionGroups: readonly ToolkitConnectionGroup[],
336+ connections: readonly ToolkitConnectionResponse[],
337+): ReadonlySet<string> => {
338+ const persistedPatterns = new Set(connections.map((connection) => connection.pattern));
339+ const connectionPatterns = new Set(connectionGroups.flatMap((group) => group.patterns));
340+ return new Set(
341+ policies
342+ .filter(
343+ (policy) =>
344+ isLegacyConnectionPolicy(policy) &&
345+ connectionPatterns.has(policy.pattern) &&
346+ !persistedPatterns.has(policy.pattern),
347+ )
348+ .map((policy) => policy.id),
349+ );
350+};
351+
352+const configuredConnectionPatterns = (
353+ connections: readonly ToolkitConnectionResponse[],
354+ policies: readonly ToolkitPolicyResponse[],
355+ legacyPolicyIds: ReadonlySet<string>,
356+): ReadonlySet<string> =>
357+ new Set([
358+ ...connections.map((connection) => connection.pattern),
359+ ...policies.filter((policy) => legacyPolicyIds.has(policy.id)).map((policy) => policy.pattern),
360+ ]);
361+
362+function ToolkitTile(props: { pluginId: string; toolkit: ToolkitResponse }) {
363+ const toolkit = props.toolkit;
364+ const scope = toolkitScopeLabel(toolkit);
365+ return (
366+ <Link
367+ to="/{-$orgSlug}/plugins/$pluginId/$"
368+ params={{ pluginId: props.pluginId, _splat: toolkit.slug }}
369+ aria-label={`Open toolkit ${toolkit.name}`}
370+ className="group flex min-h-36 min-w-0 self-start flex-col justify-between rounded-md border border-border/70 bg-card p-3.5 text-left text-card-foreground shadow-xs transition-[border-color,background-color,box-shadow] hover:border-foreground/25 hover:bg-muted/20 hover:shadow-sm focus-visible:ring-[3px] focus-visible:ring-ring/30 focus-visible:outline-none"
371+ style={toolkitCardStyle}
372+ >
373+ <div className="flex min-w-0 items-start gap-3">
374+ <div
375+ className={cn(
376+ "flex size-9 shrink-0 items-center justify-center rounded-md border",
377+ ownerAccentClass(toolkit.owner),
378+ )}
379+ >
380+ <BoxIcon className="size-4" />
381+ </div>
382+ <div className="min-w-0 flex-1">
383+ <div className="truncate text-sm font-semibold text-foreground">{toolkit.name}</div>
384+ <div className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
385+ {toolkit.slug}
386+ </div>
387+ </div>
388+ </div>
389+
390+ <div className="mt-5 min-w-0 space-y-3">
391+ <code className="block truncate rounded-md border border-border/60 bg-muted/30 px-2 py-1.5 font-mono text-[11px] text-muted-foreground transition-colors group-hover:border-border">
392+ /mcp/toolkits/{toolkit.slug}
393+ </code>
394+ <div className="flex min-w-0 items-center justify-between gap-3 text-xs text-muted-foreground">
395+ <span className="truncate">{scope}</span>
396+ <span className="shrink-0 tabular-nums">{formatUpdatedAt(toolkit.updatedAt)}</span>
397+ </div>
398+ </div>
399+ </Link>
400+ );
401+}
402+
403+const addToolkitScopeLabel = (owner: Owner): string => (owner === "org" ? "workspace" : "personal");
404+const addToolkitTitle = (owner: Owner): string =>
405+ owner === "org" ? "New workspace toolkit" : "New personal toolkit";
406+
407+function CreateToolkitDialog(props: {
408+ owner: Owner;
409+ open: boolean;
410+ onOpenChange: (open: boolean) => void;
411+ onCreate: (input: { owner: Owner; name: string }) => Promise<void>;
412+}) {
413+ const inputId = useId();
414+ const [name, setName] = useState("");
415+ const trimmed = name.trim();
416+ const title = addToolkitTitle(props.owner);
417+
418+ const submit = async () => {
419+ if (!trimmed) return;
420+ await props.onCreate({ owner: props.owner, name: trimmed });
421+ setName("");
422+ props.onOpenChange(false);
423+ };
424+
425+ return (
426+ <Dialog
427+ open={props.open}
428+ onOpenChange={(nextOpen) => {
429+ props.onOpenChange(nextOpen);
430+ if (!nextOpen) setName("");
431+ }}
432+ >
433+ <DialogContent
434+ className="flex flex-col gap-0 overflow-hidden rounded-md border-border/80 bg-card p-0 shadow-2xl"
435+ style={{ width: "min(24rem, calc(100vw - 2rem))" }}
436+ >
437+ <form
438+ className="grid"
439+ onSubmit={(event) => {
440+ event.preventDefault();
441+ void submit();
442+ }}
443+ >
444+ <DialogHeader className="px-4 pb-1 pt-4">
445+ <DialogTitle className="text-sm">{title}</DialogTitle>
446+ <DialogDescription className="text-xs">
447+ Group tools and expose them at a dedicated MCP URL.
448+ </DialogDescription>
449+ </DialogHeader>
450+ <div className="grid gap-1.5 px-4 py-3">
451+ <Label htmlFor={inputId} className="text-xs text-muted-foreground">
452+ Toolkit name
453+ </Label>
454+ <Input
455+ id={inputId}
456+ value={name}
457+ onChange={(event) => setName(event.target.value)}
458+ placeholder="e.g. Support tools"
459+ autoFocus
460+ className="h-9"
461+ />
462+ </div>
463+ <DialogFooter className="px-4 pb-4 pt-1">
464+ <Button
465+ type="submit"
466+ size="sm"
467+ disabled={!trimmed}
468+ aria-label="Create toolkit"
469+ className="h-8"
470+ >
471+ Create toolkit
472+ </Button>
473+ </DialogFooter>
474+ </form>
475+ </DialogContent>
476+ </Dialog>
477+ );
478+}
479+
480+function AddToolkitCard(props: { owner: Owner; onClick: () => void }) {
481+ const scopeLabel = addToolkitScopeLabel(props.owner);
482+ return (
483+ <button
484+ type="button"
485+ onClick={props.onClick}
486+ aria-label={`Add ${scopeLabel} toolkit`}
487+ className="group flex min-h-36 min-w-0 self-start items-center justify-center rounded-md border border-dashed border-border/75 bg-card/40 text-muted-foreground transition-[border-color,background-color,box-shadow,color] hover:border-foreground/25 hover:bg-muted/20 hover:text-foreground hover:shadow-sm focus-visible:ring-[3px] focus-visible:ring-ring/30 focus-visible:outline-none"
488+ style={toolkitCardStyle}
489+ >
490+ <span
491+ className={cn(
492+ "flex size-12 items-center justify-center rounded-md border transition-[border-color,background-color,color,transform]",
493+ ownerAccentClass(props.owner),
494+ "group-hover:scale-105",
495+ )}
496+ >
497+ <PlusIcon className="size-6" />
498+ </span>
499+ </button>
500+ );
501+}
502+
503+function ToolkitSection(props: {
504+ pluginId: string;
505+ owner: Owner;
506+ title: string;
507+ toolkits: readonly ToolkitResponse[];
508+ onCreate: (input: { owner: Owner; name: string }) => Promise<void>;
509+}) {
510+ const rows = [...props.toolkits].sort(compareToolkitRows);
511+ const [createOpen, setCreateOpen] = useState(false);
512+ const openCreate = () => setCreateOpen(true);
513+ return (
514+ <section className="space-y-3">
515+ <div className="border-b border-border/60 pb-2">
516+ <h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
517+ {props.title}
518+ </h2>
519+ </div>
520+
521+ <div
522+ className="grid content-start grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3"
523+ style={toolkitShelfStyle}
524+ >
525+ {rows.map((toolkit) => (
526+ <ToolkitTile key={toolkit.id} pluginId={props.pluginId} toolkit={toolkit} />
527+ ))}
528+ <AddToolkitCard owner={props.owner} onClick={openCreate} />
529+ </div>
530+
531+ <CreateToolkitDialog
532+ owner={props.owner}
533+ open={createOpen}
534+ onOpenChange={setCreateOpen}
535+ onCreate={props.onCreate}
536+ />
537+ </section>
538+ );
539+}
540+
541+function ToolkitGrid(props: {
542+ pluginId: string;
543+ toolkits: readonly ToolkitResponse[];
544+ onCreate: (input: { owner: Owner; name: string }) => Promise<void>;
545+}) {
546+ // Toolkit shelves are grouped by owner; the selected toolkit owns the page.
547+ const workspaceToolkits = props.toolkits.filter((toolkit) => toolkit.owner === "org");
548+ const personalToolkits = props.toolkits.filter((toolkit) => toolkit.owner === "user");
549+
550+ return (
551+ <div className="min-h-0 flex-1 overflow-y-auto">
552+ <div className="w-full space-y-7 px-4 py-4" style={toolkitGridContainerStyle}>
553+ <ToolkitSection
554+ pluginId={props.pluginId}
555+ owner="org"
556+ title="Workspace"
557+ toolkits={workspaceToolkits}
558+ onCreate={props.onCreate}
559+ />
560+ <ToolkitSection
561+ pluginId={props.pluginId}
562+ owner="user"
563+ title="Personal"
564+ toolkits={personalToolkits}
565+ onCreate={props.onCreate}
566+ />
567+ </div>
568+ </div>
569+ );
570+}
571+
572+function ToolkitContentsEmpty(props: { onAddConnection: () => void }) {
573+ return (
574+ <div className="flex h-full items-center justify-center px-6 py-10">
575+ <div className="max-w-sm text-center">
576+ <div className="mx-auto flex size-11 items-center justify-center rounded-md border border-border/70 bg-muted/30 text-muted-foreground">
577+ <PlugIcon className="size-5" />
578+ </div>
579+ <h3 className="mt-4 text-sm font-semibold text-foreground">No connections added</h3>
580+ <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
581+ Add a connection to decide what this toolkit exposes.
582+ </p>
583+ <Button type="button" size="sm" onClick={props.onAddConnection} className="mt-4">
584+ <PlusIcon className="size-3.5" />
585+ Add connection
586+ </Button>
587+ </div>
588+ </div>
589+ );
590+}
591+
592+function ToolkitToolsPanel(props: {
593+ tools: readonly ToolSummary[];
594+ selectedToolId: string | null;
595+ policies: readonly ToolkitPolicyResponse[];
596+ onAddConnection: () => void;
597+ onSelectTool: (toolId: string) => void;
598+ onSetPolicy: (pattern: string, action: ToolPolicyAction) => void;
599+ onClearPolicy: (pattern: string) => void;
600+}) {
601+ return (
602+ <div
603+ role="region"
604+ aria-label="Toolkit tools"
605+ className="flex min-h-0 shrink-0 flex-col border-r border-border/60"
606+ style={toolkitToolTreeStyle}
607+ >
608+ <div className="border-b border-border/60 px-3 py-3">
609+ <div className="flex items-center justify-between gap-3">
610+ <div className="min-w-0">
611+ <h3 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
612+ Toolkit tools
613+ </h3>
614+ <p className="mt-1 text-xs text-muted-foreground">
615+ {props.tools.length} {props.tools.length === 1 ? "tool" : "tools"}
616+ </p>
617+ </div>
618+ <Button
619+ type="button"
620+ size="sm"
621+ aria-label="Add connection to toolkit"
622+ onClick={props.onAddConnection}
623+ className="h-8"
624+ >
625+ <PlusIcon className="size-3.5" />
626+ Add
627+ </Button>
628+ </div>
629+ </div>
630+ {props.tools.length === 0 ? (
631+ <ToolkitContentsEmpty onAddConnection={props.onAddConnection} />
632+ ) : (
633+ <ToolTree
634+ tools={props.tools}
635+ selectedToolId={props.selectedToolId}
636+ onSelect={props.onSelectTool}
637+ onSetPolicy={props.onSetPolicy}
638+ onClearPolicy={props.onClearPolicy}
639+ patternForDisplay={identityPattern}
640+ policies={props.policies}
641+ groupByConnection
642+ />
643+ )}
644+ </div>
645+ );
646+}
647+
648+function AddConnectionDialog(props: {
649+ open: boolean;
650+ groups: readonly ToolkitConnectionGroup[];
651+ configuredToolIds: ReadonlySet<string>;
652+ integrations: readonly Integration[];
653+ integrationPlugins: readonly IntegrationPlugin[];
654+ onOpenChange: (open: boolean) => void;
655+ onAddPatterns: (patterns: readonly string[]) => Promise<void> | void;
656+}) {
657+ const searchId = useId();
658+ const [query, setQuery] = useState("");
659+ const trimmedQuery = query.trim().toLowerCase();
660+ const filteredGroups = useMemo(() => {
661+ if (!trimmedQuery) return props.groups;
662+ return props.groups.filter((group) => {
663+ const corpus = [
664+ connectionTitle(group),
665+ ...group.patterns,
666+ ownerLabel(group.owner),
667+ ...group.tools.flatMap((tool) => [tool.name, tool.description ?? ""]),
668+ ]
669+ .join(" ")
670+ .toLowerCase();
671+ return corpus.includes(trimmedQuery);
672+ });
673+ }, [props.groups, trimmedQuery]);
674+
675+ const addAndClose = async (patterns: readonly string[]) => {
676+ await props.onAddPatterns(patterns);
677+ props.onOpenChange(false);
678+ };
679+
680+ return (
681+ <Dialog
682+ open={props.open}
683+ onOpenChange={(nextOpen) => {
684+ props.onOpenChange(nextOpen);
685+ if (!nextOpen) {
686+ setQuery("");
687+ }
688+ }}
689+ >
690+ <DialogContent
691+ className="gap-0 overflow-hidden rounded-md border-border/80 bg-card p-0 shadow-2xl"
692+ style={{
693+ width: "min(36rem, calc(100vw - 2rem))",
694+ maxWidth: "min(36rem, calc(100vw - 2rem))",
695+ maxHeight: "min(42rem, calc(100vh - 4rem))",
696+ }}
697+ >
698+ <DialogHeader className="border-b border-border/60 px-5 py-4">
699+ <div className="flex items-start gap-3">
700+ <span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border/70 bg-muted/30 text-muted-foreground">
701+ <PlugIcon className="size-4" />
702+ </span>
703+ <div className="min-w-0">
704+ <DialogTitle className="text-sm">Add connection</DialogTitle>
705+ <DialogDescription className="mt-1 text-xs">
706+ Choose which connected account this toolkit can use.
707+ </DialogDescription>
708+ </div>
709+ </div>
710+ </DialogHeader>
711+
712+ <div className="flex min-h-0 flex-col overflow-hidden">
713+ <div className="border-b border-border/50 p-3">
714+ <Label htmlFor={searchId} className="sr-only">
715+ Search connections and tools
716+ </Label>
717+ <div className="flex items-center gap-2 rounded-md border border-border/70 bg-muted/20 px-3 transition-colors focus-within:border-primary/35 focus-within:bg-muted/25 focus-within:ring-1 focus-within:ring-primary/15">
718+ <SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
719+ <Input
720+ id={searchId}
721+ value={query}
722+ onChange={(event) => setQuery(event.target.value)}
723+ placeholder="Search connections"
724+ className="h-8 min-w-0 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0"
725+ />
726+ </div>
727+ </div>
728+ <div className="min-h-0 overflow-y-auto p-3" style={{ maxHeight: "32rem" }}>
729+ <div className="mb-2 flex items-center justify-between px-1">
730+ <span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
731+ Connections
732+ </span>
733+ <span className="text-xs tabular-nums text-muted-foreground">
734+ {filteredGroups.length}
735+ </span>
736+ </div>
737+ {filteredGroups.length === 0 ? (
738+ <div className="rounded-md border border-dashed border-border/70 px-4 py-8 text-center text-xs text-muted-foreground">
739+ No connections match.
740+ </div>
741+ ) : (
742+ <div className="overflow-hidden rounded-md border border-border/70 bg-background/30">
743+ {filteredGroups.map((group) => {
744+ const title = connectionTitle(group);
745+ const added = group.tools.every((tool) =>
746+ props.configuredToolIds.has(toolMatchId(tool)),
747+ );
748+ const meta = integrationMetaFor(
749+ group,
750+ props.integrations,
751+ props.integrationPlugins,
752+ );
753+ return (
754+ <div
755+ key={group.id}
756+ className="flex min-w-0 items-center gap-3 border-b border-border/50 px-3 py-2.5 last:border-b-0 hover:bg-muted/20"
757+ >
758+ <span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border/70 bg-muted/30">
759+ <IntegrationFavicon
760+ icon={meta.icon}
761+ sourceId={meta.sourceId}
762+ url={meta.url}
763+ size={18}
764+ />
765+ </span>
766+ <div className="min-w-0 flex-1 space-y-1">
767+ <div className="flex min-w-0 items-center gap-2">
768+ <span className="truncate text-xs font-medium text-foreground">
769+ {connectionDisplayTitle(group, meta)}
770+ </span>
771+ <Badge variant="outline" className="shrink-0 text-[10px]">
772+ {ownerLabel(group.owner)}
773+ </Badge>
774+ </div>
775+ <div className="flex min-w-0 items-center gap-2">
776+ <span className="truncate text-[11px] text-muted-foreground">
777+ {connectionDisplaySubtitle(group, meta)}
778+ </span>
779+ </div>
780+ </div>
781+ <Button
782+ type="button"
783+ variant={added ? "outline" : "default"}
784+ size="sm"
785+ disabled={added}
786+ aria-label={`${added ? "Connection added" : "Add connection"} ${title}`}
787+ onClick={() => void addAndClose(group.patterns)}
788+ className={cn(
789+ "h-7 shrink-0 px-2.5 text-xs",
790+ added
791+ ? "border-border/70 bg-transparent text-muted-foreground"
792+ : "bg-primary/10 text-primary hover:bg-primary/15",
793+ )}
794+ >
795+ {added ? (
796+ <CheckIcon className="size-3.5" />
797+ ) : (
798+ <PlusIcon className="size-3.5" />
799+ )}
800+ {added ? "Added" : "Add"}
801+ </Button>
802+ </div>
803+ );
804+ })}
805+ </div>
806+ )}
807+ </div>
808+ </div>
809+ </DialogContent>
810+ </Dialog>
811+ );
812+}
813+
814+function ToolkitHeader(props: {
815+ toolkit: ToolkitResponse;
816+ toolCount: number;
817+ mcpUrl: string;
818+ onBack: () => void;
819+ onRemove: () => void;
820+}) {
821+ return (
822+ <div className="shrink-0 border-b border-border/60 bg-background/95 px-4 py-3">
823+ <Button
824+ type="button"
825+ variant="ghost"
826+ size="xs"
827+ onClick={props.onBack}
828+ className="mb-2 -ml-1 text-muted-foreground"
829+ >
830+ <ArrowLeftIcon className="size-3.5" />
831+ Toolkits
832+ </Button>
833+ <div className="flex min-w-0 items-start justify-between gap-3">
834+ <div className="min-w-0">
835+ <div className="flex min-w-0 items-center gap-2">
836+ <h2 className="truncate text-sm font-semibold text-foreground">{props.toolkit.name}</h2>
837+ <Badge variant="outline" className="text-[10px]">
838+ {ownerLabel(props.toolkit.owner)}
839+ </Badge>
840+ <span className="text-xs tabular-nums text-muted-foreground">
841+ {props.toolCount} {props.toolCount === 1 ? "tool" : "tools"}
842+ </span>
843+ </div>
844+ <div className="mt-1 flex min-w-0 items-center gap-1.5">
845+ <code className="min-w-0 truncate rounded border border-border bg-muted/40 px-1.5 py-0.5 text-[11px] text-muted-foreground">
846+ {props.mcpUrl}
847+ </code>
848+ <CopyButton value={props.mcpUrl} label="Copy toolkit MCP URL" />
849+ </div>
850+ </div>
851+ <Button
852+ type="button"
853+ variant="ghost"
854+ size="icon-sm"
855+ aria-label="Delete toolkit"
856+ onClick={props.onRemove}
857+ className="text-muted-foreground hover:text-destructive"
858+ >
859+ <Trash2Icon className="size-3.5" />
860+ </Button>
861+ </div>
862+ </div>
863+ );
864+}
865+
866+function ToolkitWorkspace(props: {
867+ toolkit: ToolkitResponse;
868+ policies: readonly ToolkitPolicyResponse[];
869+ connections: readonly ToolkitConnectionResponse[];
870+ tools: readonly ToolRow[];
871+ integrations: readonly Integration[];
872+ integrationPlugins: readonly IntegrationPlugin[];
873+ mcpUrl: string;
874+ onBack: () => void;
875+ onRemoveToolkit: () => void;
876+ onAddConnection: (pattern: string) => Promise<void> | void;
877+ onSetPolicy: (pattern: string, action: ToolPolicyAction) => Promise<void> | void;
878+ onClearPolicy: (pattern: string) => Promise<void> | void;
879+}) {
880+ const [addOpen, setAddOpen] = useState(false);
881+ const [selectedToolId, setSelectedToolId] = useState<string | null>(null);
882+ const visibleTools = useMemo(
883+ () => props.tools.filter((tool) => toolCanAppearInToolkit(props.toolkit, tool)),
884+ [props.toolkit, props.tools],
885+ );
886+ const connectionGroups = useMemo(() => buildConnectionGroups(visibleTools), [visibleTools]);
887+ const legacyPolicyIds = useMemo(
888+ () => legacyConnectionPolicyIds(props.policies, connectionGroups, props.connections),
889+ [connectionGroups, props.connections, props.policies],
890+ );
891+ const accessPolicies = useMemo(
892+ () => props.policies.filter((policy) => !legacyPolicyIds.has(policy.id)),
893+ [legacyPolicyIds, props.policies],
894+ );
895+ const connectionPatterns = useMemo(
896+ () => configuredConnectionPatterns(props.connections, props.policies, legacyPolicyIds),
897+ [legacyPolicyIds, props.connections, props.policies],
898+ );
899+ const configuredToolIds = useMemo(() => {
900+ const ids = new Set<string>();
901+ for (const tool of visibleTools) {
902+ const id = toolMatchId(tool);
903+ if ([...connectionPatterns].some((pattern) => matchPattern(pattern, id))) ids.add(id);
904+ }
905+ return ids;
906+ }, [connectionPatterns, visibleTools]);
907+ const configuredTools = useMemo(
908+ () =>
909+ visibleTools.filter((tool) => configuredToolIds.has(toolMatchId(tool))).sort(compareTools),
910+ [visibleTools, configuredToolIds],
911+ );
912+ const toolkitTools: ToolSummary[] = useMemo(
913+ () =>
914+ configuredTools.map((tool) => {
915+ const id = toolMatchId(tool);
916+ return {
917+ id,
918+ name: id,
919+ description: tool.description,
920+ policy: resolveToolkitPolicy(id, accessPolicies, tool.requiresApproval),
921+ owner: toolOwner(tool),
922+ connection: toolConnectionName(tool),
923+ };
924+ }),
925+ [accessPolicies, configuredTools],
926+ );
927+ const exposedToolIds = useMemo(
928+ () =>
929+ new Set(
930+ toolkitTools
931+ .filter((tool) => tool.policy.action !== "block")
932+ .map((tool) => tool.id),
933+ ),
934+ [toolkitTools],
935+ );
936+ const selectedTool = selectedToolId
937+ ? (configuredTools.find((tool) => toolMatchId(tool) === selectedToolId) ?? null)
938+ : null;
939+ const selectedToolPolicy = selectedTool
940+ ? (toolkitTools.find((tool) => tool.id === selectedToolId)?.policy ?? null)
941+ : null;
942+
943+ return (
944+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
945+ <ToolkitHeader
946+ toolkit={props.toolkit}
947+ toolCount={exposedToolIds.size}
948+ mcpUrl={props.mcpUrl}
949+ onBack={props.onBack}
950+ onRemove={props.onRemoveToolkit}
951+ />
952+
953+ <div className="flex min-h-0 flex-1 overflow-hidden">
954+ <ToolkitToolsPanel
955+ tools={toolkitTools}
956+ selectedToolId={selectedToolId}
957+ policies={accessPolicies}
958+ onAddConnection={() => setAddOpen(true)}
959+ onSelectTool={setSelectedToolId}
960+ onSetPolicy={(pattern, action) => void props.onSetPolicy(pattern, action)}
961+ onClearPolicy={(pattern) => void props.onClearPolicy(pattern)}
962+ />
963+ <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
964+ {selectedTool && selectedToolPolicy ? (
965+ <ToolDetail
966+ address={selectedTool.address}
967+ toolName={toolMatchId(selectedTool)}
968+ staticTool={selectedTool.static === true}
969+ policy={selectedToolPolicy}
970+ onSetPolicy={props.onSetPolicy}
971+ onClearPolicy={props.onClearPolicy}
972+ patternForDisplay={identityPattern}
973+ />
974+ ) : (
975+ <ToolDetailEmpty hasTools={toolkitTools.length > 0} />
976+ )}
977+ </div>
978+ </div>
979+
980+ <AddConnectionDialog
981+ open={addOpen}
982+ onOpenChange={setAddOpen}
983+ groups={connectionGroups}
984+ configuredToolIds={configuredToolIds}
985+ integrations={props.integrations}
986+ integrationPlugins={props.integrationPlugins}
987+ onAddPatterns={async (patterns) => {
988+ for (const pattern of patterns) {
989+ await props.onAddConnection(pattern);
990+ }
991+ }}
992+ />
993+ </div>
994+ );
995+}
996+
997+function ToolkitsPageSkeleton() {
998+ return (
999+ <div className="flex min-h-0 flex-1 overflow-hidden">
1000+ <div className="flex-1 p-4">
1001+ <Skeleton className="h-8 w-full" />
1002+ <div className="grid grid-cols-1 gap-3 pt-4 sm:grid-cols-2 md:grid-cols-3">
1003+ {Array.from({ length: 6 }).map((_, index) => (
1004+ <Skeleton key={index} className="h-36 w-full rounded-md" />
1005+ ))}
1006+ </div>
1007+ </div>
1008+ </div>
1009+ );
1010+}
1011+
1012+function ToolkitDetailView(props: {
1013+ toolkit: ToolkitResponse;
1014+ tools: readonly ToolRow[];
1015+ integrations: readonly Integration[];
1016+ integrationPlugins: readonly IntegrationPlugin[];
1017+ orgSlug?: string;
1018+ onBack: () => void;
1019+ onRemoveToolkit: (toolkit: ToolkitResponse) => void;
1020+}) {
1021+ const policies = useAtomValue(toolkitPoliciesAtom(props.toolkit.id));
1022+ const connections = useAtomValue(toolkitConnectionsAtom(props.toolkit.id));
1023+ const doCreatePolicy = useAtomSet(createToolkitPolicy, { mode: "promiseExit" });
1024+ const doUpdatePolicy = useAtomSet(updateToolkitPolicy, { mode: "promiseExit" });
1025+ const doRemovePolicy = useAtomSet(removeToolkitPolicy, { mode: "promiseExit" });
1026+ const doCreateConnection = useAtomSet(createToolkitConnection, { mode: "promiseExit" });
1027+ const policyRows = AsyncResult.isSuccess(policies) ? policies.value.policies : [];
1028+ const connectionRows = AsyncResult.isSuccess(connections) ? connections.value.connections : [];
1029+
1030+ const setPolicyHandler = async (pattern: string, action: ToolPolicyAction) => {
1031+ const existing = policyRows.find((policy) => policy.pattern === pattern);
1032+ if (existing) {
1033+ await doUpdatePolicy({
1034+ params: { toolkitId: props.toolkit.id, policyId: existing.id },
1035+ payload: { action },
1036+ reactivityKeys: toolkitWriteKeys,
1037+ });
1038+ return;
1039+ }
1040+ await doCreatePolicy({
1041+ params: { toolkitId: props.toolkit.id },
1042+ payload: { pattern, action },
1043+ reactivityKeys: toolkitWriteKeys,
1044+ });
1045+ };
1046+
1047+ const addConnectionHandler = async (pattern: string) => {
1048+ await doCreateConnection({
1049+ params: { toolkitId: props.toolkit.id },
1050+ payload: { pattern },
1051+ reactivityKeys: toolkitWriteKeys,
1052+ });
1053+ };
1054+
1055+ const clearPolicyHandler = async (pattern: string) => {
1056+ const existing = policyRows.find((policy) => policy.pattern === pattern);
1057+ if (!existing) return;
1058+ await doRemovePolicy({
1059+ params: { toolkitId: props.toolkit.id, policyId: existing.id },
1060+ reactivityKeys: toolkitWriteKeys,
1061+ });
1062+ };
1063+
1064+ if (AsyncResult.isFailure(policies) || AsyncResult.isFailure(connections)) {
1065+ return <div className="p-6 text-sm text-destructive">Failed to load toolkit</div>;
1066+ }
1067+ if (!AsyncResult.isSuccess(policies) || !AsyncResult.isSuccess(connections)) {
1068+ return <ToolkitsPageSkeleton />;
1069+ }
1070+
1071+ return (
1072+ <ToolkitWorkspace
1073+ toolkit={props.toolkit}
1074+ policies={policyRows}
1075+ connections={connectionRows}
1076+ tools={props.tools}
1077+ integrations={props.integrations}
1078+ integrationPlugins={props.integrationPlugins}
1079+ mcpUrl={toolkitUrlFor(props.orgSlug, props.toolkit.slug)}
1080+ onBack={props.onBack}
1081+ onRemoveToolkit={() => props.onRemoveToolkit(props.toolkit)}
1082+ onAddConnection={addConnectionHandler}
1083+ onSetPolicy={setPolicyHandler}
1084+ onClearPolicy={clearPolicyHandler}
1085+ />
1086+ );
1087+}
1088+
1089+export function ToolkitsPage(props: PluginPageProps) {
1090+ const params = useParams({ strict: false }) as { orgSlug?: string };
1091+ const navigate = useNavigate();
1092+ const integrationPlugins = useIntegrationPlugins();
1093+ const toolkits = useAtomValue(toolkitsAtom);
1094+ const tools = useAtomValue(toolsAllAtom);
1095+ const integrations = useAtomValue(integrationsOptimisticAtom);
1096+ const doCreateToolkit = useAtomSet(createToolkit, { mode: "promiseExit" });
1097+ const doRemoveToolkit = useAtomSet(removeToolkit, { mode: "promiseExit" });
1098+ const selectedToolkitSlug = props.params.toolkitSlug ?? null;
1099+
1100+ const toolkitRows = AsyncResult.isSuccess(toolkits) ? toolkits.value.toolkits : [];
1101+ const selectedToolkit =
1102+ selectedToolkitSlug === null
1103+ ? null
1104+ : (toolkitRows.find((toolkit) => toolkit.slug === selectedToolkitSlug) ?? null);
1105+
1106+ const toolRows = AsyncResult.isSuccess(tools) ? (tools.value as readonly ToolRow[]) : [];
1107+ const integrationRows = AsyncResult.isSuccess(integrations)
1108+ ? (integrations.value as readonly Integration[])
1109+ : [];
1110+ const navigateToIndex = () =>
1111+ navigate({
1112+ to: "/{-$orgSlug}/plugins/$pluginId/$",
1113+ params: { pluginId: props.pluginId, _splat: "" },
1114+ });
1115+
1116+ const createToolkitHandler = async (input: { owner: Owner; name: string }) => {
1117+ await doCreateToolkit({
1118+ payload: input,
1119+ reactivityKeys: toolkitWriteKeys,
1120+ });
1121+ };
1122+
1123+ const removeToolkitHandler = async (toolkit: ToolkitResponse) => {
1124+ await doRemoveToolkit({
1125+ params: { toolkitId: toolkit.id },
1126+ reactivityKeys: toolkitWriteKeys,
1127+ });
1128+ await navigateToIndex();
1129+ };
1130+
1131+ return (
1132+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
1133+ {selectedToolkitSlug === null ? (
1134+ <div className="flex min-h-12 shrink-0 items-center gap-3 border-b border-border bg-background/95 px-4 py-2 backdrop-blur-sm">
1135+ <div className="flex min-w-0 items-center gap-3">
1136+ <h1 className="truncate text-sm font-semibold text-foreground">Toolkits</h1>
1137+ {AsyncResult.isSuccess(toolkits) && (
1138+ <span className="hidden text-xs tabular-nums text-muted-foreground sm:block">
1139+ {toolkitRows.length} {toolkitRows.length === 1 ? "toolkit" : "toolkits"}
1140+ </span>
1141+ )}
1142+ </div>
1143+ </div>
1144+ ) : null}
1145+
1146+ {!AsyncResult.isSuccess(toolkits) || !AsyncResult.isSuccess(tools) ? (
1147+ AsyncResult.isFailure(toolkits) || AsyncResult.isFailure(tools) ? (
1148+ <div className="p-6 text-sm text-destructive">Failed to load toolkits</div>
1149+ ) : (
1150+ <ToolkitsPageSkeleton />
1151+ )
1152+ ) : (
1153+ <>
1154+ {selectedToolkit ? (
1155+ <ToolkitDetailView
1156+ toolkit={selectedToolkit}
1157+ tools={toolRows}
1158+ integrations={integrationRows}
1159+ integrationPlugins={integrationPlugins}
1160+ orgSlug={params.orgSlug}
1161+ onBack={() => void navigateToIndex()}
1162+ onRemoveToolkit={removeToolkitHandler}
1163+ />
1164+ ) : selectedToolkitSlug !== null ? (
1165+ <div className="flex min-h-0 flex-1 flex-col p-6">
1166+ <Button
1167+ type="button"
1168+ variant="ghost"
1169+ size="xs"
1170+ onClick={() => void navigateToIndex()}
1171+ className="mb-4 w-fit -ml-1 text-muted-foreground"
1172+ >
1173+ <ArrowLeftIcon className="size-3.5" />
1174+ Toolkits
1175+ </Button>
1176+ <div className="text-sm text-muted-foreground">Toolkit not found</div>
1177+ </div>
1178+ ) : (
1179+ <ToolkitGrid
1180+ pluginId={props.pluginId}
1181+ toolkits={toolkitRows}
1182+ onCreate={createToolkitHandler}
1183+ />
1184+ )}
1185+ </>
1186+ )}
1187+ </div>
1188+ );
1189+}
packages/plugins/toolkits/src/server.test.ts+134−0
@@ -0,0 +1,134 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Predicate, Result } from "effect";
3+import { makeTestExecutor } from "@executor-js/sdk/testing";
4+
5+import { toolkitsPlugin } from "./server";
6+
7+describe("toolkitsPlugin", () => {
8+ it.effect("creates toolkits and manages ordered policy rules", () =>
9+ Effect.gen(function* () {
10+ const executor = yield* makeTestExecutor({
11+ plugins: [toolkitsPlugin()] as const,
12+ });
13+
14+ const toolkit = yield* executor.toolkits.create({
15+ owner: "org",
16+ name: "Deploy Kit",
17+ });
18+ expect(toolkit.slug).toBe("deploy-kit");
19+
20+ const connection = yield* executor.toolkits.createConnection(toolkit.id, {
21+ pattern: "github.org.main.*",
22+ });
23+ const duplicateConnection = yield* executor.toolkits.createConnection(toolkit.id, {
24+ pattern: "github.org.main.*",
25+ });
26+ expect(duplicateConnection.id).toBe(connection.id);
27+
28+ const first = yield* executor.toolkits.createPolicy(toolkit.id, {
29+ pattern: "github.org.main.repos.*",
30+ action: "approve",
31+ });
32+ const second = yield* executor.toolkits.createPolicy(toolkit.id, {
33+ pattern: "github.*",
34+ action: "block",
35+ });
36+
37+ const policies = yield* executor.toolkits.listPolicies(toolkit.id);
38+ expect(policies.map((policy) => policy.id)).toEqual([second.id, first.id]);
39+
40+ yield* executor.toolkits.updatePolicy(toolkit.id, first.id, {
41+ action: "require_approval",
42+ });
43+ const rules = yield* executor.toolkits.policyRulesForSlug("deploy-kit");
44+ expect(rules.find((rule) => rule.id === first.id)?.action).toBe("require_approval");
45+
46+ const connections = yield* executor.toolkits.listConnections(toolkit.id);
47+ expect(connections.map((row) => row.pattern)).toEqual(["github.org.main.*"]);
48+ }),
49+ );
50+
51+ it.effect("rejects duplicate visible slugs", () =>
52+ Effect.gen(function* () {
53+ const executor = yield* makeTestExecutor({
54+ plugins: [toolkitsPlugin()] as const,
55+ });
56+ yield* executor.toolkits.create({ owner: "org", name: "Deploy Kit" });
57+
58+ const duplicate = yield* Effect.result(
59+ executor.toolkits.create({ owner: "user", name: "Deploy Kit" }),
60+ );
61+ expect(Result.isFailure(duplicate)).toBe(true);
62+ if (!Result.isFailure(duplicate)) return;
63+ expect(Predicate.isTagged("ToolkitError")(duplicate.failure)).toBe(true);
64+ }),
65+ );
66+
67+ it.effect("resolves toolkit policies with implicit deny and workspace owner limits", () =>
68+ Effect.gen(function* () {
69+ const executor = yield* makeTestExecutor({
70+ plugins: [toolkitsPlugin()] as const,
71+ });
72+
73+ const workspace = yield* executor.toolkits.create({
74+ owner: "org",
75+ name: "Workspace Kit",
76+ });
77+ yield* executor.toolkits.createConnection(workspace.id, {
78+ pattern: "github.org.main.*",
79+ });
80+
81+ const workspaceTool = yield* executor.toolkits.resolvePolicyForSlug(
82+ workspace.slug,
83+ "github.org.main.repos.list",
84+ );
85+ expect(workspaceTool.action).toBe("approve");
86+ expect(workspaceTool.source).toBe("plugin-default");
87+
88+ const defaultApprovalTool = yield* executor.toolkits.resolvePolicyForSlug(
89+ workspace.slug,
90+ "github.org.main.repos.delete",
91+ true,
92+ );
93+ expect(defaultApprovalTool.action).toBe("require_approval");
94+ expect(defaultApprovalTool.source).toBe("plugin-default");
95+
96+ yield* executor.toolkits.createPolicy(workspace.id, {
97+ pattern: "github.org.main.repos.delete",
98+ action: "approve",
99+ });
100+ const explicitTool = yield* executor.toolkits.resolvePolicyForSlug(
101+ workspace.slug,
102+ "github.org.main.repos.delete",
103+ true,
104+ );
105+ expect(explicitTool.action).toBe("approve");
106+ expect(explicitTool.source).toBe("user");
107+
108+ const personalTool = yield* executor.toolkits.resolvePolicyForSlug(
109+ workspace.slug,
110+ "github.user.main.repos.list",
111+ );
112+ expect(personalTool.action).toBe("block");
113+
114+ const missingTool = yield* executor.toolkits.resolvePolicyForSlug(
115+ workspace.slug,
116+ "slack.org.main.chat.post",
117+ );
118+ expect(missingTool.action).toBe("block");
119+
120+ const personal = yield* executor.toolkits.create({
121+ owner: "user",
122+ name: "Personal Kit",
123+ });
124+ yield* executor.toolkits.createConnection(personal.id, {
125+ pattern: "github.user.main.*",
126+ });
127+ const personalToolkitTool = yield* executor.toolkits.resolvePolicyForSlug(
128+ personal.slug,
129+ "github.user.main.repos.list",
130+ );
131+ expect(personalToolkitTool.action).toBe("approve");
132+ }),
133+ );
134+});
packages/plugins/toolkits/src/server.ts+653−0
@@ -0,0 +1,653 @@
1+import {
2+ Context,
3+ definePlugin,
4+ definePluginStorageCollection,
5+ Effect,
6+ HttpApiBuilder,
7+ isValidPattern,
8+ matchPattern,
9+ Schema,
10+ type EffectivePolicy,
11+ type Owner,
12+ type PluginCtx,
13+ type PluginStorageFacade,
14+ type PluginStorageCollectionFacade,
15+ type StorageFailure,
16+ type ToolPolicyAction,
17+ type ToolPolicyProvider,
18+ type ToolPolicyProviderRule,
19+} from "@executor-js/sdk/core";
20+import { addGroup, capture } from "@executor-js/api";
21+import { generateKeyBetween } from "fractional-indexing";
22+
23+import { ToolkitError, ToolkitsApi } from "./shared";
24+
25+const ToolkitRecord = Schema.Struct({
26+ id: Schema.String,
27+ slug: Schema.String,
28+ name: Schema.String,
29+ createdAt: Schema.Number,
30+ updatedAt: Schema.Number,
31+});
32+type ToolkitRecord = typeof ToolkitRecord.Type;
33+
34+const ToolkitPolicyRecord = Schema.Struct({
35+ id: Schema.String,
36+ toolkitId: Schema.String,
37+ pattern: Schema.String,
38+ action: Schema.Literals(["approve", "require_approval", "block"]),
39+ position: Schema.String,
40+ createdAt: Schema.Number,
41+ updatedAt: Schema.Number,
42+});
43+type ToolkitPolicyRecord = typeof ToolkitPolicyRecord.Type;
44+
45+const ToolkitConnectionRecord = Schema.Struct({
46+ id: Schema.String,
47+ toolkitId: Schema.String,
48+ pattern: Schema.String,
49+ position: Schema.String,
50+ createdAt: Schema.Number,
51+ updatedAt: Schema.Number,
52+});
53+type ToolkitConnectionRecord = typeof ToolkitConnectionRecord.Type;
54+
55+const toolkitsCollection = definePluginStorageCollection("toolkits", ToolkitRecord, {
56+ indexes: ["slug", "name", "updatedAt"],
57+});
58+
59+const toolkitPoliciesCollection = definePluginStorageCollection(
60+ "toolkitPolicies",
61+ ToolkitPolicyRecord,
62+ {
63+ indexes: ["toolkitId", "pattern", "position", ["toolkitId", "position"]],
64+ },
65+);
66+
67+const toolkitConnectionsCollection = definePluginStorageCollection(
68+ "toolkitConnections",
69+ ToolkitConnectionRecord,
70+ {
71+ indexes: ["toolkitId", "pattern", "position", ["toolkitId", "position"]],
72+ },
73+);
74+
75+type ToolkitStorage = {
76+ readonly toolkits: PluginStorageCollectionFacade<typeof toolkitsCollection>;
77+ readonly policies: PluginStorageCollectionFacade<typeof toolkitPoliciesCollection>;
78+ readonly connections: PluginStorageCollectionFacade<typeof toolkitConnectionsCollection>;
79+};
80+
81+export interface ToolkitsPluginOptions {
82+ /** When set, this executor instance enforces only the named toolkit's rules. */
83+ readonly activeToolkitSlug?: string;
84+}
85+
86+const newId = (prefix: string): string =>
87+ `${prefix}_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
88+
89+const slugPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
90+
91+const slugify = (name: string): string =>
92+ name
93+ .trim()
94+ .toLowerCase()
95+ .replace(/[^a-z0-9]+/g, "-")
96+ .replace(/^-+|-+$/g, "")
97+ .slice(0, 63);
98+
99+const normalizeSlugEffect = (input: {
100+ readonly name: string;
101+ readonly slug?: string;
102+}): Effect.Effect<string, ToolkitError> => {
103+ const slug = (input.slug ?? slugify(input.name)).trim().toLowerCase();
104+ if (!slugPattern.test(slug)) {
105+ return Effect.fail(
106+ new ToolkitError({
107+ message:
108+ "Toolkit slug must be 1-63 lowercase letters, numbers, or hyphens, and cannot start or end with a hyphen.",
109+ }),
110+ );
111+ }
112+ return Effect.succeed(slug);
113+};
114+
115+const fail = (message: string): Effect.Effect<never, ToolkitError> =>
116+ Effect.fail(new ToolkitError({ message }));
117+
118+const validatePolicyPattern = (pattern: string): Effect.Effect<void, ToolkitError> =>
119+ isValidPattern(pattern)
120+ ? Effect.void
121+ : Effect.fail(
122+ new ToolkitError({
123+ message: `Invalid toolkit policy pattern: ${pattern}`,
124+ }),
125+ );
126+
127+const comparePositioned = (
128+ a: Pick<ToolkitPolicyRecord, "id" | "position">,
129+ b: Pick<ToolkitPolicyRecord, "id" | "position">,
130+): number => {
131+ if (a.position < b.position) return -1;
132+ if (a.position > b.position) return 1;
133+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
134+};
135+
136+const blockedPolicy = (pattern = "*"): EffectivePolicy => ({
137+ action: "block",
138+ source: "user",
139+ pattern,
140+});
141+
142+const pluginDefaultPolicy = (defaultRequiresApproval: boolean | undefined): EffectivePolicy =>
143+ defaultRequiresApproval
144+ ? { action: "require_approval", source: "plugin-default" }
145+ : { action: "approve", source: "plugin-default" };
146+
147+const isLegacyConnectionPolicy = (policy: ToolkitPolicyRecord): boolean => {
148+ if (policy.action !== "approve") return false;
149+ const parts = policy.pattern.split(".");
150+ return parts.at(-1) === "*" && (parts.length === 3 || parts.length === 4);
151+};
152+
153+const resolveToolkitPolicy = (
154+ toolId: string,
155+ connections: readonly ToolkitConnectionRecord[],
156+ policies: readonly ToolkitPolicyRecord[],
157+ defaultRequiresApproval?: boolean,
158+): EffectivePolicy => {
159+ const legacyConnectionPolicyIds = new Set(
160+ policies.filter(isLegacyConnectionPolicy).map((policy) => policy.id),
161+ );
162+ const connected =
163+ connections.some((connection) => matchPattern(connection.pattern, toolId)) ||
164+ policies.some(
165+ (policy) => legacyConnectionPolicyIds.has(policy.id) && matchPattern(policy.pattern, toolId),
166+ );
167+ if (!connected) return blockedPolicy();
168+
169+ for (const policy of [...policies].sort(comparePositioned)) {
170+ if (legacyConnectionPolicyIds.has(policy.id)) continue;
171+ if (!matchPattern(policy.pattern, toolId)) continue;
172+ return {
173+ action: policy.action,
174+ source: "user",
175+ pattern: policy.pattern,
176+ policyId: policy.id,
177+ };
178+ }
179+ return pluginDefaultPolicy(defaultRequiresApproval);
180+};
181+
182+const isPersonalDynamicToolId = (toolId: string): boolean => toolId.split(".")[1] === "user";
183+
184+const toolkitToResponse = (entry: { readonly owner: Owner; readonly data: ToolkitRecord }) => ({
185+ id: entry.data.id,
186+ owner: entry.owner,
187+ slug: entry.data.slug,
188+ name: entry.data.name,
189+ createdAt: entry.data.createdAt,
190+ updatedAt: entry.data.updatedAt,
191+});
192+
193+const policyToResponse = (policy: ToolkitPolicyRecord) => ({
194+ id: policy.id,
195+ toolkitId: policy.toolkitId,
196+ pattern: policy.pattern,
197+ action: policy.action,
198+ position: policy.position,
199+ createdAt: policy.createdAt,
200+ updatedAt: policy.updatedAt,
201+});
202+
203+const connectionToResponse = (connection: ToolkitConnectionRecord) => ({
204+ id: connection.id,
205+ toolkitId: connection.toolkitId,
206+ pattern: connection.pattern,
207+ position: connection.position,
208+ createdAt: connection.createdAt,
209+ updatedAt: connection.updatedAt,
210+});
211+
212+const makeToolkitStorage = (pluginStorage: PluginStorageFacade): ToolkitStorage => ({
213+ toolkits: pluginStorage.collection(toolkitsCollection),
214+ policies: pluginStorage.collection(toolkitPoliciesCollection),
215+ connections: pluginStorage.collection(toolkitConnectionsCollection),
216+});
217+
218+const makeToolkitsExtension = (ctx: PluginCtx<ToolkitStorage>) => {
219+ const storage = ctx.storage;
220+
221+ const list = () =>
222+ storage.toolkits
223+ .query({ orderBy: [{ field: "name" }] })
224+ .pipe(
225+ Effect.map((entries) =>
226+ entries
227+ .map(toolkitToResponse)
228+ .sort(
229+ (a, b) =>
230+ (a.owner === b.owner ? 0 : a.owner === "org" ? -1 : 1) ||
231+ a.name.localeCompare(b.name) ||
232+ a.slug.localeCompare(b.slug),
233+ ),
234+ ),
235+ );
236+
237+ const getEntry = (toolkitId: string) => storage.toolkits.get({ key: toolkitId });
238+
239+ const getBySlugEntry = (slug: string) =>
240+ storage.toolkits.query({ where: { slug } }).pipe(Effect.map((entries) => entries[0] ?? null));
241+
242+ const requireToolkit = (toolkitId: string) =>
243+ getEntry(toolkitId).pipe(
244+ Effect.flatMap((entry) => (entry ? Effect.succeed(entry) : fail("Toolkit not found."))),
245+ );
246+
247+ const assertSlugAvailable = (slug: string, ignoreToolkitId?: string) =>
248+ storage.toolkits.query({ where: { slug } }).pipe(
249+ Effect.flatMap((entries) => {
250+ const collision = entries.find((entry) => entry.data.id !== ignoreToolkitId);
251+ return collision ? fail(`Toolkit slug "${slug}" is already in use.`) : Effect.void;
252+ }),
253+ );
254+
255+ const listPoliciesForRecord = (toolkitId: string) =>
256+ storage.policies
257+ .query({ where: { toolkitId } })
258+ .pipe(Effect.map((entries) => entries.map((entry) => entry.data).sort(comparePositioned)));
259+
260+ const listConnectionsForRecord = (toolkitId: string) =>
261+ storage.connections
262+ .query({ where: { toolkitId } })
263+ .pipe(Effect.map((entries) => entries.map((entry) => entry.data).sort(comparePositioned)));
264+
265+ const requirePolicy = (toolkitId: string, policyId: string, owner: Owner) =>
266+ storage.policies
267+ .getForOwner({ owner, key: policyId })
268+ .pipe(
269+ Effect.flatMap((entry) =>
270+ entry && entry.data.toolkitId === toolkitId
271+ ? Effect.succeed(entry)
272+ : fail("Toolkit policy not found."),
273+ ),
274+ );
275+
276+ const requireConnection = (toolkitId: string, connectionId: string, owner: Owner) =>
277+ storage.connections
278+ .getForOwner({ owner, key: connectionId })
279+ .pipe(
280+ Effect.flatMap((entry) =>
281+ entry && entry.data.toolkitId === toolkitId
282+ ? Effect.succeed(entry)
283+ : fail("Toolkit connection not found."),
284+ ),
285+ );
286+
287+ const create = (input: {
288+ readonly owner: Owner;
289+ readonly name: string;
290+ readonly slug?: string;
291+ }) =>
292+ Effect.gen(function* () {
293+ const name = input.name.trim();
294+ if (!name) return yield* fail("Toolkit name is required.");
295+ const slug = yield* normalizeSlugEffect({ name, slug: input.slug });
296+ yield* assertSlugAvailable(slug);
297+ const now = Date.now();
298+ const id = newId("tk");
299+ const entry = yield* storage.toolkits.put({
300+ owner: input.owner,
301+ key: id,
302+ data: { id, slug, name, createdAt: now, updatedAt: now },
303+ });
304+ return toolkitToResponse(entry);
305+ });
306+
307+ const update = (toolkitId: string, input: { readonly name?: string; readonly slug?: string }) =>
308+ Effect.gen(function* () {
309+ const existing = yield* requireToolkit(toolkitId);
310+ const name = input.name === undefined ? existing.data.name : input.name.trim();
311+ if (!name) return yield* fail("Toolkit name is required.");
312+ const slug =
313+ input.slug === undefined
314+ ? existing.data.slug
315+ : yield* normalizeSlugEffect({ name, slug: input.slug });
316+ yield* assertSlugAvailable(slug, toolkitId);
317+ const entry = yield* storage.toolkits.put({
318+ owner: existing.owner,
319+ key: toolkitId,
320+ data: { ...existing.data, name, slug, updatedAt: Date.now() },
321+ });
322+ return toolkitToResponse(entry);
323+ });
324+
325+ const remove = (toolkitId: string) =>
326+ Effect.gen(function* () {
327+ const toolkit = yield* requireToolkit(toolkitId);
328+ const policies = yield* listPoliciesForRecord(toolkitId);
329+ const connections = yield* listConnectionsForRecord(toolkitId);
330+ yield* ctx.pluginStorage.removeMany({
331+ owner: toolkit.owner,
332+ entries: [
333+ { collection: toolkitsCollection.name, key: toolkitId },
334+ ...policies.map((policy) => ({
335+ collection: toolkitPoliciesCollection.name,
336+ key: policy.id,
337+ })),
338+ ...connections.map((connection) => ({
339+ collection: toolkitConnectionsCollection.name,
340+ key: connection.id,
341+ })),
342+ ],
343+ });
344+ });
345+
346+ const listPolicies = (toolkitId: string) =>
347+ requireToolkit(toolkitId).pipe(Effect.flatMap(() => listPoliciesForRecord(toolkitId)));
348+
349+ const createPolicy = (
350+ toolkitId: string,
351+ input: {
352+ readonly pattern: string;
353+ readonly action: ToolPolicyAction;
354+ readonly position?: string;
355+ },
356+ ) =>
357+ Effect.gen(function* () {
358+ yield* validatePolicyPattern(input.pattern);
359+ const toolkit = yield* requireToolkit(toolkitId);
360+ const existing = yield* listPoliciesForRecord(toolkitId);
361+ const minPosition = existing
362+ .map((row) => row.position)
363+ .sort()
364+ .at(0);
365+ const now = Date.now();
366+ const id = newId("tkpol");
367+ const entry = yield* storage.policies.put({
368+ owner: toolkit.owner,
369+ key: id,
370+ data: {
371+ id,
372+ toolkitId,
373+ pattern: input.pattern,
374+ action: input.action,
375+ position: input.position ?? generateKeyBetween(null, minPosition ?? null),
376+ createdAt: now,
377+ updatedAt: now,
378+ },
379+ });
380+ return policyToResponse(entry.data);
381+ });
382+
383+ const updatePolicy = (
384+ toolkitId: string,
385+ policyId: string,
386+ input: {
387+ readonly pattern?: string;
388+ readonly action?: ToolPolicyAction;
389+ readonly position?: string;
390+ },
391+ ) =>
392+ Effect.gen(function* () {
393+ if (input.pattern !== undefined) yield* validatePolicyPattern(input.pattern);
394+ const toolkit = yield* requireToolkit(toolkitId);
395+ const existing = yield* requirePolicy(toolkitId, policyId, toolkit.owner);
396+ const entry = yield* storage.policies.put({
397+ owner: toolkit.owner,
398+ key: policyId,
399+ data: {
400+ ...existing.data,
401+ ...(input.pattern !== undefined ? { pattern: input.pattern } : {}),
402+ ...(input.action !== undefined ? { action: input.action } : {}),
403+ ...(input.position !== undefined ? { position: input.position } : {}),
404+ updatedAt: Date.now(),
405+ },
406+ });
407+ return policyToResponse(entry.data);
408+ });
409+
410+ const removePolicy = (toolkitId: string, policyId: string) =>
411+ Effect.gen(function* () {
412+ const toolkit = yield* requireToolkit(toolkitId);
413+ yield* requirePolicy(toolkitId, policyId, toolkit.owner);
414+ yield* storage.policies.remove({ owner: toolkit.owner, key: policyId });
415+ });
416+
417+ const listConnections = (toolkitId: string) =>
418+ requireToolkit(toolkitId).pipe(Effect.flatMap(() => listConnectionsForRecord(toolkitId)));
419+
420+ const createConnection = (
421+ toolkitId: string,
422+ input: {
423+ readonly pattern: string;
424+ readonly position?: string;
425+ },
426+ ) =>
427+ Effect.gen(function* () {
428+ yield* validatePolicyPattern(input.pattern);
429+ const toolkit = yield* requireToolkit(toolkitId);
430+ const existing = yield* listConnectionsForRecord(toolkitId);
431+ const duplicate = existing.find((connection) => connection.pattern === input.pattern);
432+ if (duplicate) return connectionToResponse(duplicate);
433+ const minPosition = existing
434+ .map((row) => row.position)
435+ .sort()
436+ .at(0);
437+ const now = Date.now();
438+ const id = newId("tkconn");
439+ const entry = yield* storage.connections.put({
440+ owner: toolkit.owner,
441+ key: id,
442+ data: {
443+ id,
444+ toolkitId,
445+ pattern: input.pattern,
446+ position: input.position ?? generateKeyBetween(null, minPosition ?? null),
447+ createdAt: now,
448+ updatedAt: now,
449+ },
450+ });
451+ return connectionToResponse(entry.data);
452+ });
453+
454+ const removeConnection = (toolkitId: string, connectionId: string) =>
455+ Effect.gen(function* () {
456+ const toolkit = yield* requireToolkit(toolkitId);
457+ yield* requireConnection(toolkitId, connectionId, toolkit.owner);
458+ yield* storage.connections.remove({ owner: toolkit.owner, key: connectionId });
459+ });
460+
461+ const policyRulesForSlug = (
462+ slug: string,
463+ ): Effect.Effect<readonly ToolPolicyProviderRule[], StorageFailure> =>
464+ Effect.gen(function* () {
465+ const toolkit = yield* getBySlugEntry(slug);
466+ if (!toolkit) return [];
467+ const policies = yield* listPoliciesForRecord(toolkit.data.id);
468+ return policies
469+ .filter((policy) => !isLegacyConnectionPolicy(policy))
470+ .map((policy) => ({
471+ id: policy.id,
472+ pattern: policy.pattern,
473+ action: policy.action,
474+ position: policy.position,
475+ }));
476+ });
477+
478+ const resolvePolicyForSlug = (
479+ slug: string,
480+ toolId: string,
481+ defaultRequiresApproval?: boolean,
482+ ): Effect.Effect<EffectivePolicy, StorageFailure> =>
483+ Effect.gen(function* () {
484+ const toolkit = yield* getBySlugEntry(slug);
485+ if (!toolkit) return blockedPolicy();
486+ if (toolkit.owner === "org" && isPersonalDynamicToolId(toolId)) return blockedPolicy();
487+ const policies = yield* listPoliciesForRecord(toolkit.data.id);
488+ const connections = yield* listConnectionsForRecord(toolkit.data.id);
489+ return resolveToolkitPolicy(toolId, connections, policies, defaultRequiresApproval);
490+ });
491+
492+ return {
493+ list,
494+ create,
495+ update,
496+ remove,
497+ listPolicies: (toolkitId: string) =>
498+ listPolicies(toolkitId).pipe(Effect.map((policies) => policies.map(policyToResponse))),
499+ createPolicy,
500+ updatePolicy,
501+ removePolicy,
502+ listConnections: (toolkitId: string) =>
503+ listConnections(toolkitId).pipe(
504+ Effect.map((connections) => connections.map(connectionToResponse)),
505+ ),
506+ createConnection,
507+ removeConnection,
508+ policyRulesForSlug,
509+ resolvePolicyForSlug,
510+ };
511+};
512+
513+export type ToolkitsExtension = ReturnType<typeof makeToolkitsExtension>;
514+
515+export class ToolkitsExtensionService extends Context.Service<
516+ ToolkitsExtensionService,
517+ ToolkitsExtension
518+>()("ToolkitsExtensionService") {}
519+
520+const ExecutorApiWithToolkits = addGroup(ToolkitsApi);
521+
522+const ToolkitsHandlers = HttpApiBuilder.group(ExecutorApiWithToolkits, "toolkits", (handlers) =>
523+ handlers
524+ .handle("list", () =>
525+ capture(
526+ Effect.gen(function* () {
527+ const ext = yield* ToolkitsExtensionService;
528+ const toolkits = yield* ext.list();
529+ return { toolkits };
530+ }),
531+ ),
532+ )
533+ .handle("create", ({ payload }) =>
534+ capture(
535+ Effect.gen(function* () {
536+ const ext = yield* ToolkitsExtensionService;
537+ return yield* ext.create(payload);
538+ }),
539+ ),
540+ )
541+ .handle("update", ({ params, payload }) =>
542+ capture(
543+ Effect.gen(function* () {
544+ const ext = yield* ToolkitsExtensionService;
545+ return yield* ext.update(params.toolkitId, payload);
546+ }),
547+ ),
548+ )
549+ .handle("remove", ({ params }) =>
550+ capture(
551+ Effect.gen(function* () {
552+ const ext = yield* ToolkitsExtensionService;
553+ yield* ext.remove(params.toolkitId);
554+ return { removed: true };
555+ }),
556+ ),
557+ )
558+ .handle("listPolicies", ({ params }) =>
559+ capture(
560+ Effect.gen(function* () {
561+ const ext = yield* ToolkitsExtensionService;
562+ const policies = yield* ext.listPolicies(params.toolkitId);
563+ return { policies };
564+ }),
565+ ),
566+ )
567+ .handle("createPolicy", ({ params, payload }) =>
568+ capture(
569+ Effect.gen(function* () {
570+ const ext = yield* ToolkitsExtensionService;
571+ return yield* ext.createPolicy(params.toolkitId, payload);
572+ }),
573+ ),
574+ )
575+ .handle("updatePolicy", ({ params, payload }) =>
576+ capture(
577+ Effect.gen(function* () {
578+ const ext = yield* ToolkitsExtensionService;
579+ return yield* ext.updatePolicy(params.toolkitId, params.policyId, payload);
580+ }),
581+ ),
582+ )
583+ .handle("removePolicy", ({ params }) =>
584+ capture(
585+ Effect.gen(function* () {
586+ const ext = yield* ToolkitsExtensionService;
587+ yield* ext.removePolicy(params.toolkitId, params.policyId);
588+ return { removed: true };
589+ }),
590+ ),
591+ )
592+ .handle("listConnections", ({ params }) =>
593+ capture(
594+ Effect.gen(function* () {
595+ const ext = yield* ToolkitsExtensionService;
596+ const connections = yield* ext.listConnections(params.toolkitId);
597+ return { connections };
598+ }),
599+ ),
600+ )
601+ .handle("createConnection", ({ params, payload }) =>
602+ capture(
603+ Effect.gen(function* () {
604+ const ext = yield* ToolkitsExtensionService;
605+ return yield* ext.createConnection(params.toolkitId, payload);
606+ }),
607+ ),
608+ )
609+ .handle("removeConnection", ({ params }) =>
610+ capture(
611+ Effect.gen(function* () {
612+ const ext = yield* ToolkitsExtensionService;
613+ yield* ext.removeConnection(params.toolkitId, params.connectionId);
614+ return { removed: true };
615+ }),
616+ ),
617+ ),
618+);
619+
620+const makePolicyProvider = (
621+ extension: Pick<ToolkitsExtension, "policyRulesForSlug" | "resolvePolicyForSlug">,
622+ slug: string,
623+): ToolPolicyProvider => ({
624+ list: () => extension.policyRulesForSlug(slug),
625+ resolve: ({ toolId, defaultRequiresApproval }) =>
626+ extension.resolvePolicyForSlug(slug, toolId, defaultRequiresApproval),
627+});
628+
629+export const toolkitsPlugin = definePlugin((options: ToolkitsPluginOptions = {}) => {
630+ const activeToolkitSlug = options.activeToolkitSlug;
631+ return {
632+ id: "toolkits" as const,
633+ packageName: "@executor-js/plugin-toolkits",
634+ pluginStorage: {
635+ toolkits: toolkitsCollection,
636+ toolkitPolicies: toolkitPoliciesCollection,
637+ toolkitConnections: toolkitConnectionsCollection,
638+ },
639+ storage: ({ pluginStorage }) => makeToolkitStorage(pluginStorage),
640+ extension: makeToolkitsExtension,
641+ routes: () => ToolkitsApi,
642+ handlers: () => ToolkitsHandlers,
643+ extensionService: ToolkitsExtensionService,
644+ ...(activeToolkitSlug
645+ ? {
646+ toolPolicyProvider: (ctx: PluginCtx<ToolkitStorage>) =>
647+ makePolicyProvider(makeToolkitsExtension(ctx), activeToolkitSlug),
648+ }
649+ : {}),
650+ };
651+});
652+
653+export default toolkitsPlugin;
packages/plugins/toolkits/src/shared.ts+166−0
@@ -0,0 +1,166 @@
1+import { Schema } from "effect";
2+import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
3+import { InternalError, Owner, ToolPolicyActionSchema } from "@executor-js/sdk/shared";
4+
5+export class ToolkitError extends Schema.TaggedErrorClass<ToolkitError>()(
6+ "ToolkitError",
7+ { message: Schema.String },
8+ { httpApiStatus: 400 },
9+) {}
10+
11+const ToolkitParams = {
12+ toolkitId: Schema.String,
13+};
14+
15+const ToolkitPolicyParams = {
16+ toolkitId: Schema.String,
17+ policyId: Schema.String,
18+};
19+
20+const ToolkitConnectionParams = {
21+ toolkitId: Schema.String,
22+ connectionId: Schema.String,
23+};
24+
25+export const ToolkitResponse = Schema.Struct({
26+ id: Schema.String,
27+ owner: Owner,
28+ slug: Schema.String,
29+ name: Schema.String,
30+ createdAt: Schema.Number,
31+ updatedAt: Schema.Number,
32+});
33+export type ToolkitResponse = typeof ToolkitResponse.Type;
34+
35+export const ToolkitPolicyResponse = Schema.Struct({
36+ id: Schema.String,
37+ toolkitId: Schema.String,
38+ pattern: Schema.String,
39+ action: ToolPolicyActionSchema,
40+ position: Schema.String,
41+ createdAt: Schema.Number,
42+ updatedAt: Schema.Number,
43+});
44+export type ToolkitPolicyResponse = typeof ToolkitPolicyResponse.Type;
45+
46+export const ToolkitConnectionResponse = Schema.Struct({
47+ id: Schema.String,
48+ toolkitId: Schema.String,
49+ pattern: Schema.String,
50+ position: Schema.String,
51+ createdAt: Schema.Number,
52+ updatedAt: Schema.Number,
53+});
54+export type ToolkitConnectionResponse = typeof ToolkitConnectionResponse.Type;
55+
56+const CreateToolkitPayload = Schema.Struct({
57+ owner: Owner,
58+ name: Schema.String,
59+ slug: Schema.optional(Schema.String),
60+});
61+
62+const UpdateToolkitPayload = Schema.Struct({
63+ name: Schema.optional(Schema.String),
64+ slug: Schema.optional(Schema.String),
65+});
66+
67+const CreateToolkitPolicyPayload = Schema.Struct({
68+ pattern: Schema.String,
69+ action: ToolPolicyActionSchema,
70+ position: Schema.optional(Schema.String),
71+});
72+
73+const UpdateToolkitPolicyPayload = Schema.Struct({
74+ pattern: Schema.optional(Schema.String),
75+ action: Schema.optional(ToolPolicyActionSchema),
76+ position: Schema.optional(Schema.String),
77+});
78+
79+const CreateToolkitConnectionPayload = Schema.Struct({
80+ pattern: Schema.String,
81+ position: Schema.optional(Schema.String),
82+});
83+
84+const ToolkitErrors = [InternalError, ToolkitError] as const;
85+
86+export const ToolkitsApi = HttpApiGroup.make("toolkits")
87+ .add(
88+ HttpApiEndpoint.get("list", "/toolkits", {
89+ success: Schema.Struct({ toolkits: Schema.Array(ToolkitResponse) }),
90+ error: ToolkitErrors,
91+ }),
92+ )
93+ .add(
94+ HttpApiEndpoint.post("create", "/toolkits", {
95+ payload: CreateToolkitPayload,
96+ success: ToolkitResponse,
97+ error: ToolkitErrors,
98+ }),
99+ )
100+ .add(
101+ HttpApiEndpoint.patch("update", "/toolkits/:toolkitId", {
102+ params: ToolkitParams,
103+ payload: UpdateToolkitPayload,
104+ success: ToolkitResponse,
105+ error: ToolkitErrors,
106+ }),
107+ )
108+ .add(
109+ HttpApiEndpoint.delete("remove", "/toolkits/:toolkitId", {
110+ params: ToolkitParams,
111+ success: Schema.Struct({ removed: Schema.Boolean }),
112+ error: ToolkitErrors,
113+ }),
114+ )
115+ .add(
116+ HttpApiEndpoint.get("listPolicies", "/toolkits/:toolkitId/policies", {
117+ params: ToolkitParams,
118+ success: Schema.Struct({ policies: Schema.Array(ToolkitPolicyResponse) }),
119+ error: ToolkitErrors,
120+ }),
121+ )
122+ .add(
123+ HttpApiEndpoint.post("createPolicy", "/toolkits/:toolkitId/policies", {
124+ params: ToolkitParams,
125+ payload: CreateToolkitPolicyPayload,
126+ success: ToolkitPolicyResponse,
127+ error: ToolkitErrors,
128+ }),
129+ )
130+ .add(
131+ HttpApiEndpoint.patch("updatePolicy", "/toolkits/:toolkitId/policies/:policyId", {
132+ params: ToolkitPolicyParams,
133+ payload: UpdateToolkitPolicyPayload,
134+ success: ToolkitPolicyResponse,
135+ error: ToolkitErrors,
136+ }),
137+ )
138+ .add(
139+ HttpApiEndpoint.delete("removePolicy", "/toolkits/:toolkitId/policies/:policyId", {
140+ params: ToolkitPolicyParams,
141+ success: Schema.Struct({ removed: Schema.Boolean }),
142+ error: ToolkitErrors,
143+ }),
144+ )
145+ .add(
146+ HttpApiEndpoint.get("listConnections", "/toolkits/:toolkitId/connections", {
147+ params: ToolkitParams,
148+ success: Schema.Struct({ connections: Schema.Array(ToolkitConnectionResponse) }),
149+ error: ToolkitErrors,
150+ }),
151+ )
152+ .add(
153+ HttpApiEndpoint.post("createConnection", "/toolkits/:toolkitId/connections", {
154+ params: ToolkitParams,
155+ payload: CreateToolkitConnectionPayload,
156+ success: ToolkitConnectionResponse,
157+ error: ToolkitErrors,
158+ }),
159+ )
160+ .add(
161+ HttpApiEndpoint.delete("removeConnection", "/toolkits/:toolkitId/connections/:connectionId", {
162+ params: ToolkitConnectionParams,
163+ success: Schema.Struct({ removed: Schema.Boolean }),
164+ error: ToolkitErrors,
165+ }),
166+ );
packages/plugins/toolkits/tsconfig.json+23−0
@@ -0,0 +1,23 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "lib": ["ES2022", "DOM"],
9+ "types": ["bun-types", "node"],
10+ "noUnusedLocals": true,
11+ "noImplicitOverride": true,
12+ "jsx": "react-jsx",
13+ "plugins": [
14+ {
15+ "name": "@effect/language-service",
16+ "ignoreEffectSuggestionsInTscExitCode": true,
17+ "ignoreEffectWarningsInTscExitCode": true,
18+ "diagnosticSeverity": {}
19+ }
20+ ]
21+ },
22+ "include": ["src/**/*.ts", "src/**/*.tsx"]
23+}
packages/plugins/toolkits/tsup.config.ts+14−0
@@ -0,0 +1,14 @@
1+import { defineConfig } from "tsup";
2+
3+export default defineConfig({
4+ entry: {
5+ server: "src/server.ts",
6+ client: "src/client.tsx",
7+ shared: "src/shared.ts",
8+ },
9+ format: ["esm"],
10+ dts: true,
11+ clean: true,
12+ sourcemap: true,
13+ external: ["react"],
14+});
packages/plugins/toolkits/vitest.config.ts+7−0
@@ -0,0 +1,7 @@
1+import { defineConfig } from "vitest/config";
2+
3+export default defineConfig({
4+ test: {
5+ globals: false,
6+ },
7+});
packages/react/src/components/tool-detail.tsx+59−42
@@ -172,6 +172,7 @@ export function ToolDetail(props: { · 171 unmodified lines
172172 * applies a user rule to this tool's exact id. */
173173 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
174174 onClearPolicy?: (pattern: string) => void;
175+ patternForDisplay?: (displayPattern: string) => string;
175176 /** Run-tab wiring. When `integration` + `runToolName` are provided, a third
176177 * "Run" tab hosts the per-connection tool tester. */
177178 integration?: IntegrationSlug;
@@ -187,6 +188,7 @@ export function ToolDetail(props: { · 9 unmodified lines
187188 const canRun = props.integration != null && props.runToolName != null;
188189 // Don't strand the user on the Run tab when this ToolDetail has no run wiring.
189190 const activeTab = tab === "run" && !canRun ? "schema" : tab;
191+ const isBlocked = props.policy?.action === "block";
190192
191193 const data = useMemo(() => {
192194 if (!AsyncResult.isSuccess(toolContract)) return null;
@@ -244,6 +246,7 @@ export function ToolDetail(props: { · 51 unmodified lines
244246 policy={props.policy}
245247 onSetPolicy={props.onSetPolicy}
246248 onClearPolicy={props.onClearPolicy}
249+ patternForDisplay={props.patternForDisplay}
247250 />
248251 </div>
249252 {data?.description && <ToolDescription description={data.description} />}
@@ -300,48 +303,59 @@ export function ToolDetail(props: { · 50 unmodified lines
300303
301304 {/* Content */}
302305 <div className="flex-1 overflow-y-auto">
303− {AsyncResult.match(toolContract, {
304− onInitial: () => <div className="p-5 text-sm text-muted-foreground">Loading…</div>,
305− onFailure: () => <div className="p-5 text-sm text-destructive">Something went wrong</div>,
306− onSuccess: () =>
307− activeTab === "run" && props.integration && props.runToolName ? (
308− <div className="px-5 py-5">
309− <ToolRunPanel
310− integration={props.integration}
311− toolName={props.runToolName}
312− connections={props.connections ?? []}
313− initialConnectionName={props.initialConnectionName}
314− />
315− </div>
316− ) : activeTab === "schema" ? (
317− <div className="px-5 py-5 space-y-5">
318− {data?.inputSchema ? (
319− <SchemaExplorer
320− schema={data.inputSchema}
321− schemaDefinitions={data.schemaDefinitions}
322− title="Parameters"
323− />
324− ) : (
325− <EmptySection title="Parameters" message="None" />
326− )}
327− {data?.outputSchema ? (
328− <SchemaExplorer
329− schema={data.outputSchema}
330− schemaDefinitions={data.schemaDefinitions}
331− title="Response"
332− />
333− ) : (
334− <EmptySection title="Response" message="None" />
335− )}
336− </div>
337− ) : (
338− <ToolTypeScriptPanel
339− inputTypeScript={data?.inputTypeScript ?? null}
340− outputTypeScript={data?.outputTypeScript ?? null}
341− definitions={data?.definitions ?? []}
342− />
306+ {isBlocked ? (
307+ <div className="px-5 py-5">
308+ <EmptySection
309+ title="Blocked"
310+ message="This tool is not available through the current toolkit."
311+ />
312+ </div>
313+ ) : (
314+ AsyncResult.match(toolContract, {
315+ onInitial: () => <div className="p-5 text-sm text-muted-foreground">Loading…</div>,
316+ onFailure: () => (
317+ <div className="p-5 text-sm text-destructive">Something went wrong</div>
343318 ),
344− })}
319+ onSuccess: () =>
320+ activeTab === "run" && props.integration && props.runToolName ? (
321+ <div className="px-5 py-5">
322+ <ToolRunPanel
323+ integration={props.integration}
324+ toolName={props.runToolName}
325+ connections={props.connections ?? []}
326+ initialConnectionName={props.initialConnectionName}
327+ />
328+ </div>
329+ ) : activeTab === "schema" ? (
330+ <div className="px-5 py-5 space-y-5">
331+ {data?.inputSchema ? (
332+ <SchemaExplorer
333+ schema={data.inputSchema}
334+ schemaDefinitions={data.schemaDefinitions}
335+ title="Parameters"
336+ />
337+ ) : (
338+ <EmptySection title="Parameters" message="None" />
339+ )}
340+ {data?.outputSchema ? (
341+ <SchemaExplorer
342+ schema={data.outputSchema}
343+ schemaDefinitions={data.schemaDefinitions}
344+ title="Response"
345+ />
346+ ) : (
347+ <EmptySection title="Response" message="None" />
348+ )}
349+ </div>
350+ ) : (
351+ <ToolTypeScriptPanel
352+ inputTypeScript={data?.inputTypeScript ?? null}
353+ outputTypeScript={data?.outputTypeScript ?? null}
354+ definitions={data?.definitions ?? []}
355+ />
356+ ),
357+ })
358+ )}
345359 </div>
346360 </div>
347361 );
@@ -400,12 +414,15 @@ function PolicyBadgeMenu(props: { · 52 unmodified lines
400414 policy?: EffectivePolicy;
401415 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
402416 onClearPolicy?: (pattern: string) => void;
417+ patternForDisplay?: (displayPattern: string) => string;
403418 }) {
404419 const interactive = !!props.onSetPolicy;
405420 // The same pattern bridge the tree rows apply — the pattern WRITTEN and the
406421 // pattern LOOKED UP must be the same string, or the menu authors rules that
407422 // never match and can't see its own rule afterward.
408− const pattern = props.staticTool ? props.toolName : toPolicyPattern(props.toolName);
423+ const pattern = props.staticTool
424+ ? props.toolName
425+ : (props.patternForDisplay ?? toPolicyPattern)(props.toolName);
409426 // The "Clear" affordance only makes sense when there's a user rule
410427 // pinned to this exact tool id — clearing a wildcard rule from a
411428 // single tool's detail header would silently affect siblings.
packages/react/src/components/tool-tree.tsx+14−4
@@ -283,6 +283,8 @@ export function ToolTree(props: { · 282 unmodified lines
283283 * emit the tool's full dotted id; group rows emit `prefix.*`. */
284284 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
285285 onClearPolicy?: (pattern: string) => void;
286+ /** Maps the displayed row path into the persisted policy pattern. */
287+ patternForDisplay?: (displayPattern: string) => string;
286288 /** Sorted user-authored policies (most-precedent first). Used to
287289 * decide whether a node has its own exact-pattern user rule today
288290 * (so the menu can show a "Clear" option). Optional — when absent,
@@ -300,6 +302,7 @@ export function ToolTree(props: { · 11 unmodified lines
300302 onSelect,
301303 onSetPolicy,
302304 onClearPolicy,
305+ patternForDisplay = toPolicyPattern,
303306 policies,
304307 groupByConnection,
305308 } = props;
@@ -358,6 +361,7 @@ export function ToolTree(props: { · 52 unmodified lines
358361 onSelect,
359362 onSetPolicy,
360363 onClearPolicy,
364+ patternForDisplay,
361365 exactPatterns,
362366 search,
363367 terms,
@@ -433,6 +437,7 @@ function ToolTreeBody(props: { · 69 unmodified lines
433437 onSelect: (toolId: string) => void;
434438 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
435439 onClearPolicy?: (pattern: string) => void;
440+ patternForDisplay: (displayPattern: string) => string;
436441 exactPatterns: ReadonlyMap<string, ToolPolicyAction>;
437442 search: string;
438443 terms: readonly string[];
@@ -444,6 +449,7 @@ function ToolTreeBody(props: { · 5 unmodified lines
444449 onSelect,
445450 onSetPolicy,
446451 onClearPolicy,
452+ patternForDisplay,
447453 exactPatterns,
448454 search,
449455 terms,
@@ -510,7 +516,8 @@ function ToolTreeBody(props: { · 60 unmodified lines
510516 search={search}
511517 onSetPolicy={onSetPolicy}
512518 onClearPolicy={onClearPolicy}
513− exactRule={exactPatterns.get(toPolicyPattern(row.tool.name))}
519+ patternForDisplay={patternForDisplay}
520+ exactRule={exactPatterns.get(patternForDisplay(row.tool.name))}
514521 />
515522 ) : (
516523 <ToolGroupRow
@@ -524,7 +531,8 @@ function ToolTreeBody(props: { · 7 unmodified lines
524531 search={search}
525532 onSetPolicy={onSetPolicy}
526533 onClearPolicy={onClearPolicy}
527− exactRule={exactPatterns.get(toPolicyPattern(`${row.path}.*`))}
534+ patternForDisplay={patternForDisplay}
535+ exactRule={exactPatterns.get(patternForDisplay(`${row.path}.*`))}
528536 />
529537 ),
530538 )}
@@ -610,6 +618,7 @@ function ToolGroupRow(props: { · 79 unmodified lines
610618 search: string;
611619 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
612620 onClearPolicy?: (pattern: string) => void;
621+ patternForDisplay: (displayPattern: string) => string;
613622 exactRule?: ToolPolicyAction;
614623 }) {
615624 const showActions = !!props.onSetPolicy;
@@ -647,7 +656,7 @@ function ToolGroupRow(props: { · 31 unmodified lines
647656 )}
648657 >
649658 <PolicyActionMenu
650− pattern={toPolicyPattern(`${props.path}.*`)}
659+ pattern={props.patternForDisplay(`${props.path}.*`)}
651660 current={props.exactRule}
652661 onSet={props.onSetPolicy!}
653662 onClear={props.onClearPolicy}
@@ -668,6 +677,7 @@ function ToolLeafRow(props: { · 14 unmodified lines
668677 search: string;
669678 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
670679 onClearPolicy?: (pattern: string) => void;
680+ patternForDisplay: (displayPattern: string) => string;
671681 exactRule?: ToolPolicyAction;
672682 }) {
673683 const label = props.tool.name.split(".").pop() ?? props.tool.name;
@@ -719,7 +729,7 @@ function ToolLeafRow(props: { · 45 unmodified lines
719729 )}
720730 >
721731 <PolicyActionMenu
722− pattern={toPolicyPattern(props.tool.name)}
732+ pattern={props.patternForDisplay(props.tool.name)}
723733 current={props.exactRule}
724734 onSet={props.onSetPolicy!}
725735 onClear={props.onClearPolicy}
packages/react/src/multiplayer/shell.tsx+25−2
@@ -21,7 +21,7 @@ import { · 20 unmodified lines
2121 integrationPresetIconUrl,
2222 } from "../components/integration-favicon";
2323 import { CommandPalette } from "../components/command-palette";
24−import { useIntegrationPlugins } from "@executor-js/sdk/client";
24+import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";
2525 import { useAuth } from "./auth-context";
2626
2727 // ---------------------------------------------------------------------------
@@ -63,6 +63,16 @@ export const DEFAULT_DOCS_URL = "https://executor.sh/docs"; · 35 unmodified lines
6363 // the host's own route tree, which this package can't see.
6464 const orgScopedTo = (to: string): string => (to === "/" ? "/{-$orgSlug}" : `/{-$orgSlug}${to}`);
6565
66+const normalizePluginPagePath = (path: string): string => {
67+ if (!path || path === "/") return "/";
68+ return path.startsWith("/") ? path : `/${path}`;
69+};
70+
71+const pluginPageNavPath = (pluginId: string, path: string): string => {
72+ const normalized = normalizePluginPagePath(path);
73+ return normalized === "/" ? `/plugins/${pluginId}/` : `/plugins/${pluginId}${normalized}`;
74+};
75+
6676 /** The pathname with the active org-slug prefix stripped, for active-state
6777 * comparisons against scope-relative paths. */
6878 function useScopeRelativePathname(): string {
@@ -310,7 +320,20 @@ function UserFooter(props: Pick<ShellProps, "onSignOut" | "orgMenuSlot">) { · 241 unmodified lines
310320 function SidebarContent(
311321 props: ShellProps & { pathname: string; onNavigate?: () => void; showBrand?: boolean },
312322 ) {
313− const navItems = props.navItems ?? defaultShellNavItems;
323+ const plugins = useClientPlugins();
324+ const pluginNavItems = plugins.flatMap((plugin) =>
325+ (plugin.pages ?? []).flatMap((page) =>
326+ page.nav
327+ ? [
328+ {
329+ to: pluginPageNavPath(plugin.id, page.path),
330+ label: page.nav.label,
331+ },
332+ ]
333+ : [],
334+ ),
335+ );
336+ const navItems = [...(props.navItems ?? defaultShellNavItems), ...pluginNavItems];
314337 return (
315338 <>
316339 {props.showBrand !== false && (
packages/react/src/routes/plugin-route-match.test.ts+34−0
@@ -0,0 +1,34 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { matchPluginPage, matchPluginPagePath } from "./plugins.$pluginId.$";
4+
5+describe("plugin page route matching", () => {
6+ it("matches plugin roots", () => {
7+ expect(matchPluginPagePath("/", "/")).toEqual({});
8+ expect(matchPluginPagePath("/", "")).toEqual({});
9+ });
10+
11+ it("matches static plugin subpages", () => {
12+ expect(matchPluginPagePath("/settings", "/settings")).toEqual({});
13+ expect(matchPluginPagePath("/settings", "/other")).toBeNull();
14+ });
15+
16+ it("captures $params from plugin subpages", () => {
17+ expect(matchPluginPagePath("/$toolkitSlug", "/deploy-tools")).toEqual({
18+ toolkitSlug: "deploy-tools",
19+ });
20+ });
21+
22+ it("does not let parameter pages swallow deeper paths", () => {
23+ expect(matchPluginPagePath("/$toolkitSlug", "/deploy-tools/rules")).toBeNull();
24+ });
25+
26+ it("prefers static plugin pages over parameter pages", () => {
27+ const match = matchPluginPage([{ path: "/$id" }, { path: "/settings" }], "/settings");
28+
29+ expect(match).toEqual({
30+ page: { path: "/settings" },
31+ params: {},
32+ });
33+ });
34+});
packages/react/src/routes/plugins.$pluginId.$.tsx+66−9
@@ -11,10 +11,10 @@ import { useClientPlugins } from "@executor-js/sdk/client"; · 10 unmodified lines
1111 // plugin to `executor.config.ts` is sufficient for its pages to mount
1212 // here, with no per-route imports.
1313 //
14−// Match logic is intentionally tiny: exact path equality between the URL
15−// remainder and a `PageDecl.path`, with `""` and `/` treated as the
16−// same root. Plugins that want parameterized paths can build their own
17−// in-component routing for now.
14+// Plugin pages use the same lightweight route vocabulary as the rest of the
15+// console route tree: static segments and `$param` segments. The host route
16+// owns that matching so plugin detail views can be real URLs instead of
17+// in-component state.
1818 // ---------------------------------------------------------------------------
1919
2020 export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({
@@ -23,9 +23,66 @@ export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({ · 2 unmodified lines
2323
2424 function normalizePath(input: string): string {
2525 if (!input || input === "/") return "/";
26− return input.startsWith("/") ? input : `/${input}`;
26+ const withLeadingSlash = input.startsWith("/") ? input : `/${input}`;
27+ return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/, "") : "/";
2728 }
2829
30+const pathSegments = (input: string): readonly string[] =>
31+ normalizePath(input)
32+ .split("/")
33+ .filter((segment) => segment.length > 0);
34+
35+export const matchPluginPagePath = (
36+ pattern: string,
37+ target: string,
38+): Readonly<Record<string, string>> | null => {
39+ const patternSegments = pathSegments(pattern);
40+ const targetSegments = pathSegments(target);
41+ if (patternSegments.length !== targetSegments.length) return null;
42+
43+ const params: Record<string, string> = {};
44+ for (let index = 0; index < patternSegments.length; index += 1) {
45+ const patternSegment = patternSegments[index]!;
46+ const targetSegment = targetSegments[index]!;
47+ if (patternSegment.startsWith("$") && patternSegment.length > 1) {
48+ params[patternSegment.slice(1)] = decodeURIComponent(targetSegment);
49+ continue;
50+ }
51+ if (patternSegment !== targetSegment) return null;
52+ }
53+ return params;
54+};
55+
56+const matchScore = (pattern: string): number =>
57+ pathSegments(pattern).reduce((score, segment) => score + (segment.startsWith("$") ? 1 : 2), 0);
58+
59+export const matchPluginPage = <TPage extends { readonly path: string }>(
60+ pages: readonly TPage[] | undefined,
61+ target: string,
62+): { readonly page: TPage; readonly params: Readonly<Record<string, string>> } | null => {
63+ const matches =
64+ pages
65+ ?.map((page, index) => ({
66+ page,
67+ index,
68+ params: matchPluginPagePath(page.path, target),
69+ score: matchScore(page.path),
70+ }))
71+ .filter(
72+ (
73+ candidate,
74+ ): candidate is {
75+ readonly page: TPage;
76+ readonly index: number;
77+ readonly params: Readonly<Record<string, string>>;
78+ readonly score: number;
79+ } => candidate.params !== null,
80+ )
81+ .sort((a, b) => b.score - a.score || a.index - b.index) ?? [];
82+ const first = matches[0];
83+ return first ? { page: first.page, params: first.params } : null;
84+};
85+
2986 function PluginRouteComponent() {
3087 const { pluginId, _splat: rest } = Route.useParams();
3188 const plugins = useClientPlugins();
@@ -34,10 +91,10 @@ function PluginRouteComponent() { · 2 unmodified lines
3491 if (!plugin) throw notFound();
3592
3693 const target = normalizePath(rest ?? "/");
37− const page = plugin.pages?.find((p) => normalizePath(p.path) === target);
94+ const match = matchPluginPage(plugin.pages, target);
3895 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound()
39− if (!page) throw notFound();
96+ if (!match) throw notFound();
4097
41− const Component = page.component;
42− return <Component />;
98+ const Component = match.page.component;
99+ return <Component params={match.params} path={target} pluginId={pluginId} />;
43100 }

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t