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
5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
7import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";7import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
8import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
8 9
9import { resolveSecretKey } from "./src/config";10import { resolveSecretKey } from "./src/config";
10 11
@@ -19,13 +20,14 @@ import { resolveSecretKey } from "./src/config"; · 8 unmodified lines
19// ---------------------------------------------------------------------------20// ---------------------------------------------------------------------------
20 21
21export default defineExecutorConfig({22export default defineExecutorConfig({
22 plugins: () =>23 plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) =>
23 [24 [
24 openApiHttpPlugin(),25 openApiHttpPlugin(),
25 googleHttpPlugin(),26 googleHttpPlugin(),
26 microsoftHttpPlugin(),27 microsoftHttpPlugin(),
27 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),28 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
28 graphqlHttpPlugin(),29 graphqlHttpPlugin(),
30 toolkitsPlugin({ activeToolkitSlug }),
29 // First writable secret provider -> the default for `secrets.set`.31 // First writable secret provider -> the default for `secrets.set`.
30 encryptedSecretsPlugin({ key: resolveSecretKey() }),32 encryptedSecretsPlugin({ key: resolveSecretKey() }),
31 ] as const,33 ] as const,
apps/host-selfhost/package.json+1−0
@@ -32,6 +32,7 @@ · 31 unmodified lines
32 "@executor-js/plugin-mcp": "workspace:*",32 "@executor-js/plugin-mcp": "workspace:*",
33 "@executor-js/plugin-microsoft": "workspace:*",33 "@executor-js/plugin-microsoft": "workspace:*",
34 "@executor-js/plugin-openapi": "workspace:*",34 "@executor-js/plugin-openapi": "workspace:*",
35 "@executor-js/plugin-toolkits": "workspace:*",
35 "@executor-js/react": "workspace:*",36 "@executor-js/react": "workspace:*",
36 "@executor-js/runtime-quickjs": "workspace:*",37 "@executor-js/runtime-quickjs": "workspace:*",
37 "@executor-js/sdk": "workspace:*",38 "@executor-js/sdk": "workspace:*",
apps/host-selfhost/src/app.ts+2−1
@@ -106,7 +106,8 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 105 unmodified lines
106 routes: [106 routes: [
107 // CLI device-login discovery, must precede the /api/auth/* wildcard107 // CLI device-login discovery, must precede the /api/auth/* wildcard
108 // below (Better Auth would otherwise 404 it). The verification page it108 // below (Better Auth would otherwise 404 it). The verification page it
109 // points at (/device) is a console SPA route (web/device.tsx).109 // points at (/device) is a console SPA route
110 // (web/chromeless/device-page.tsx).
110 HttpRouter.add("GET", "/api/auth/cli-login", cliLoginHandler),111 HttpRouter.add("GET", "/api/auth/cli-login", cliLoginHandler),
111 // Better Auth owns the rest of /api/auth/*, the full path reaches it.112 // Better Auth owns the rest of /api/auth/*, the full path reaches it.
112 HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)),113 HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)),
apps/host-selfhost/src/execution.ts+5−1
@@ -40,7 +40,11 @@ export { makeExecutionStack } from "@executor-js/api/server"; · 39 unmodified lines
40 40
41export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)(41export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)(
42 {42 {
43 plugins: () => executorConfig.plugins(),43 plugins: (context) =>
44 executorConfig.plugins({
45 activeToolkitSlug:
46 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
47 }),
44 },48 },
45);49);
46 50
apps/host-selfhost/src/mcp/org-path.test.ts+6−2
@@ -6,17 +6,21 @@ describe("stripMcpOrgSegment", () => { · 5 unmodified lines
6 it("strips a single org segment before /mcp", () => {6 it("strips a single org segment before /mcp", () => {
7 expect(stripMcpOrgSegment("/iI9idP7BZcWpg9wW8cit3xE4r4dFSnHj/mcp")).toBe("/mcp");7 expect(stripMcpOrgSegment("/iI9idP7BZcWpg9wW8cit3xE4r4dFSnHj/mcp")).toBe("/mcp");
8 expect(stripMcpOrgSegment("/org_123/mcp")).toBe("/mcp");8 expect(stripMcpOrgSegment("/org_123/mcp")).toBe("/mcp");
9 expect(stripMcpOrgSegment("/org_123/mcp/toolkits/deploy")).toBe("/mcp/toolkits/deploy");
9 });10 });
10 11
11 it("strips the org segment from the protected-resource discovery path", () => {12 it("strips the org segment from the protected-resource discovery path", () => {
12 expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp")).toBe(13 expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp")).toBe(
13 "/.well-known/oauth-protected-resource/mcp",14 "/.well-known/oauth-protected-resource",
14 );15 );
16 expect(
17 stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp/toolkits/deploy"),
18 ).toBe("/.well-known/oauth-protected-resource");
15 });19 });
16 20
17 it("leaves the bare paths untouched", () => {21 it("leaves the bare paths untouched", () => {
18 expect(stripMcpOrgSegment("/mcp")).toBeNull();22 expect(stripMcpOrgSegment("/mcp")).toBeNull();
19 expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/mcp")).toBeNull();23 expect(stripMcpOrgSegment("/mcp/toolkits/deploy")).toBeNull();
20 expect(stripMcpOrgSegment("/.well-known/oauth-authorization-server")).toBeNull();24 expect(stripMcpOrgSegment("/.well-known/oauth-authorization-server")).toBeNull();
21 });25 });
22 26
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
20 * applies (already bare, not an MCP path, or an OAuth endpoint like20 * applies (already bare, not an MCP path, or an OAuth endpoint like
21 * `/api/auth/mcp/authorize`).21 * `/api/auth/mcp/authorize`).
22 *22 *
23 * /<seg>/mcp -> /mcp23 * /<seg>/mcp -> /mcp
24 * /.well-known/oauth-protected-resource/<seg>/mcp -> /.well-known/oauth-protected-resource/mcp24 * /<seg>/mcp/toolkits/<toolkit> -> /mcp/toolkits/<toolkit>
25 * /.well-known/oauth-protected-resource/<seg>/mcp[...] -> /.well-known/oauth-protected-resource
25 */26 */
26export const stripMcpOrgSegment = (pathname: string): string | null => {27export const stripMcpOrgSegment = (pathname: string): string | null => {
27 if (pathname.startsWith(`${PRM_PREFIX}/`)) {28 if (pathname.startsWith(`${PRM_PREFIX}/`)) {
@@ -29,8 +30,14 @@ export const stripMcpOrgSegment = (pathname: string): string | null => { · 1 unmodified line
29 .slice(PRM_PREFIX.length + 1)30 .slice(PRM_PREFIX.length + 1)
30 .split("/")31 .split("/")
31 .filter((segment) => segment.length > 0);32 .filter((segment) => segment.length > 0);
32 return rest.length === 2 && rest[1] === "mcp" ? `${PRM_PREFIX}/mcp` : null;33 if (rest.length === 2 && rest[1] === "mcp") return PRM_PREFIX;
34 if (rest.length === 4 && rest[1] === "mcp" && rest[2] === "toolkits") return PRM_PREFIX;
35 return null;
33 }36 }
34 const segments = pathname.split("/").filter((segment) => segment.length > 0);37 const segments = pathname.split("/").filter((segment) => segment.length > 0);
35 return segments.length === 2 && segments[1] === "mcp" ? "/mcp" : null;38 if (segments.length === 2 && segments[1] === "mcp") return "/mcp";
39 if (segments.length === 4 && segments[1] === "mcp" && segments[2] === "toolkits") {
40 return `/mcp/toolkits/${segments[3]}`;
41 }
42 return null;
36};43};
apps/host-selfhost/vite.config.ts+2−2
@@ -66,8 +66,8 @@ function executorApiPlugin(): Plugin { · 65 unmodified lines
66 rawUrl = `${pathname}${original.search}`;66 rawUrl = `${pathname}${original.search}`;
67 }67 }
68 // Match on PATHNAME, not a raw-URL prefix: `/mcp` must NOT swallow the68 // Match on PATHNAME, not a raw-URL prefix: `/mcp` must NOT swallow the
69 // SPA route `/mcp-consent` (nor its source module `/mcp-consent.tsx`),69 // SPA route `/mcp-consent`, or the dev server misroutes it to the API
70 // or the dev server misroutes them to the API handler and they 404.70 // handler and returns a 404.
71 const path = new URL(rawUrl, devOrigin).pathname;71 const path = new URL(rawUrl, devOrigin).pathname;
72 const handled =72 const handled =
73 path === "/api" ||73 path === "/api" ||
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
11import { plugins as clientPlugins } from "virtual:executor/plugins-client";11import { plugins as clientPlugins } from "virtual:executor/plugins-client";
12 12
13import { authClient } from "../auth-client";13import { authClient } from "../auth-client";
14import { DevicePage } from "../device";14import { DevicePage } from "../chromeless/device-page";
15import { McpConsentPage } from "../mcp-consent";15import { McpConsentPage } from "../chromeless/mcp-consent-page";
16import { LoginPage } from "../login";16import { LoginPage } from "../login";
17import { SetupPage } from "../setup";17import { SetupPage } from "../setup";
18import { fetchNeedsSetup } from "../setup-status";18import { fetchNeedsSetup } from "../setup-status";
bun.lock+47−0
@@ -228,6 +228,7 @@ · 227 unmodified lines
228 "@executor-js/plugin-mcp": "workspace:*",228 "@executor-js/plugin-mcp": "workspace:*",
229 "@executor-js/plugin-microsoft": "workspace:*",229 "@executor-js/plugin-microsoft": "workspace:*",
230 "@executor-js/plugin-openapi": "workspace:*",230 "@executor-js/plugin-openapi": "workspace:*",
231 "@executor-js/plugin-toolkits": "workspace:*",
231 "@executor-js/react": "workspace:*",232 "@executor-js/react": "workspace:*",
232 "@executor-js/runtime-quickjs": "workspace:*",233 "@executor-js/runtime-quickjs": "workspace:*",
233 "@executor-js/sdk": "workspace:*",234 "@executor-js/sdk": "workspace:*",
@@ -346,6 +347,7 @@ · 112 unmodified lines
346 "@executor-js/plugin-mcp": "workspace:*",347 "@executor-js/plugin-mcp": "workspace:*",
347 "@executor-js/plugin-microsoft": "workspace:*",348 "@executor-js/plugin-microsoft": "workspace:*",
348 "@executor-js/plugin-openapi": "workspace:*",349 "@executor-js/plugin-openapi": "workspace:*",
350 "@executor-js/plugin-toolkits": "workspace:*",
349 "@executor-js/sdk": "workspace:*",351 "@executor-js/sdk": "workspace:*",
350 "@kitlangton/terminal-control": "^0.3.0",352 "@kitlangton/terminal-control": "^0.3.0",
351 "@modelcontextprotocol/sdk": "^1.29.0",353 "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1088,6 +1090,49 @@ · 736 unmodified lines
1088 "react",1090 "react",
1089 ],1091 ],
1090 },1092 },
1093 "packages/plugins/toolkits": {
1094 "name": "@executor-js/plugin-toolkits",
1095 "version": "1.5.12",
1096 "dependencies": {
1097 "@executor-js/sdk": "workspace:*",
1098 },
1099 "devDependencies": {
1100 "@effect/atom-react": "catalog:",
1101 "@effect/vitest": "catalog:",
1102 "@executor-js/api": "workspace:*",
1103 "@executor-js/react": "workspace:*",
1104 "@tanstack/react-router": "catalog:",
1105 "@types/node": "catalog:",
1106 "@types/react": "catalog:",
1107 "bun-types": "catalog:",
1108 "effect": "catalog:",
1109 "fractional-indexing": "^3.2.0",
1110 "lucide-react": "^1.7.0",
1111 "react": "catalog:",
1112 "tsup": "catalog:",
1113 "typescript": "catalog:",
1114 "vitest": "catalog:",
1115 },
1116 "peerDependencies": {
1117 "@effect/atom-react": "catalog:",
1118 "@executor-js/api": "workspace:*",
1119 "@executor-js/react": "workspace:*",
1120 "@tanstack/react-router": "catalog:",
1121 "effect": "catalog:",
1122 "fractional-indexing": "^3.2.0",
1123 "lucide-react": "^1.7.0",
1124 "react": "catalog:",
1125 },
1126 "optionalPeers": [
1127 "@effect/atom-react",
1128 "@executor-js/api",
1129 "@executor-js/react",
1130 "@tanstack/react-router",
1131 "fractional-indexing",
1132 "lucide-react",
1133 "react",
1134 ],
1135 },
1091 "packages/plugins/workos-vault": {1136 "packages/plugins/workos-vault": {
1092 "name": "@executor-js/plugin-workos-vault",1137 "name": "@executor-js/plugin-workos-vault",
1093 "version": "0.0.2",1138 "version": "0.0.2",
@@ -1682,6 +1727,8 @@ · 588 unmodified lines
1682 1727
1683 "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"],1728 "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"],
1684 1729
1730 "@executor-js/plugin-toolkits": ["@executor-js/plugin-toolkits@workspace:packages/plugins/toolkits"],
1731
1685 "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"],1732 "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"],
1686 1733
1687 "@executor-js/react": ["@executor-js/react@workspace:packages/react"],1734 "@executor-js/react": ["@executor-js/react@workspace:packages/react"],
e2e/package.json+1−0
@@ -27,6 +27,7 @@ · 26 unmodified lines
27 "@executor-js/plugin-mcp": "workspace:*",27 "@executor-js/plugin-mcp": "workspace:*",
28 "@executor-js/plugin-microsoft": "workspace:*",28 "@executor-js/plugin-microsoft": "workspace:*",
29 "@executor-js/plugin-openapi": "workspace:*",29 "@executor-js/plugin-openapi": "workspace:*",
30 "@executor-js/plugin-toolkits": "workspace:*",
30 "@executor-js/sdk": "workspace:*",31 "@executor-js/sdk": "workspace:*",
31 "@kitlangton/terminal-control": "^0.3.0",32 "@kitlangton/terminal-control": "^0.3.0",
32 "@modelcontextprotocol/sdk": "^1.29.0",33 "@modelcontextprotocol/sdk": "^1.29.0",
e2e/selfhost/toolkits-mcp.test.ts+146−0
@@ -0,0 +1,146 @@
1import { randomBytes } from "node:crypto";
2
3import { expect } from "@effect/vitest";
4import { Effect } from "effect";
5import { composePluginApi } from "@executor-js/api/server";
6import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
7
8import { scenario } from "../src/scenario";
9import { Api, Mcp, Target } from "../src/services";
10import type { Identity } from "../src/target";
11
12const api = composePluginApi([toolkitsPlugin()] as const);
13
14const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
15
16const allowedCode = `
17const result = await tools.executor.coreTools.integrations.list({});
18if (!result.ok) throw new Error(result.error.message);
19return result.data.integrations.map((integration) => integration.slug).includes("executor");
20`;
21
22const blockedCode = `
23const result = await tools.executor.coreTools.policies.list({});
24if (!result.ok) throw new Error(result.error.message);
25return result.data.policies.length;
26`;
27
28const 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
53const 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
70scenario(
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 @@
1import { randomBytes } from "node:crypto";
2
3import { expect } from "@effect/vitest";
4import { Effect } from "effect";
5import { composePluginApi } from "@executor-js/api/server";
6import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server";
7
8import { scenario } from "../src/scenario";
9import { Api, Browser, Target } from "../src/services";
10
11const api = composePluginApi([toolkitsPlugin()] as const);
12
13scenario(
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
160 readonly url: string;160 readonly url: string;
161 readonly session: (161 readonly session: (
162 identity: Identity,162 identity: Identity,
163 options?: { readonly elicitationMode?: McpElicitationMode },163 options?: { readonly elicitationMode?: McpElicitationMode; readonly url?: string },
164 ) => McpSession;164 ) => McpSession;
165 /**165 /**
166 * Mint a real MCP bearer headlessly: protected-resource discovery →166 * Mint a real MCP bearer headlessly: protected-resource discovery →
@@ -188,6 +188,17 @@ interface TokenResponse { · 21 unmodified lines
188 readonly access_token?: string;188 readonly access_token?: string;
189}189}
190 190
191const 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
191const mintBearerFlow = async (target: Target, email: string): Promise<string> => {202const mintBearerFlow = async (target: Target, email: string): Promise<string> => {
192 const consent = target.mcpConsent?.({203 const consent = target.mcpConsent?.({
193 label: email,204 label: email,
@@ -196,21 +207,31 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 2 unmodified lines
196 if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`);207 if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`);
197 208
198 const mcpPath = new URL(target.mcpUrl).pathname;209 const mcpPath = new URL(target.mcpUrl).pathname;
199 const resource = (await (210 let resourceResponse = await fetch(
200 await fetch(new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl))211 new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl),
201 ).json()) as { authorization_servers?: ReadonlyArray<string> };212 );
213 if (resourceResponse.status === 404) {
214 resourceResponse = await fetch(
215 new URL("/.well-known/oauth-protected-resource", target.baseUrl),
216 );
217 }
218 const resource = await jsonFrom<{ authorization_servers?: ReadonlyArray<string> }>(
219 resourceResponse,
220 "mintBearer: protected-resource metadata",
221 );
202 const issuer = resource.authorization_servers?.[0];222 const issuer = resource.authorization_servers?.[0];
203 if (!issuer) throw new Error("mintBearer: no authorization server advertised");223 if (!issuer) throw new Error("mintBearer: no authorization server advertised");
204 const metadata = (await (224 const metadata = await jsonFrom<{
205 await fetch(new URL("/.well-known/oauth-authorization-server", issuer))
206 ).json()) as {
207 readonly authorization_endpoint: string;225 readonly authorization_endpoint: string;
208 readonly token_endpoint: string;226 readonly token_endpoint: string;
209 readonly registration_endpoint: string;227 readonly registration_endpoint: string;
210 };228 }>(
229 await fetch(new URL("/.well-known/oauth-authorization-server", issuer)),
230 "mintBearer: authorization-server metadata",
231 );
211 232
212 const redirectUri = "http://127.0.0.1:9/callback";233 const redirectUri = "http://127.0.0.1:9/callback";
213 const registered = (await (234 const registered = await jsonFrom<{ readonly client_id: string }>(
214 await fetch(metadata.registration_endpoint, {235 await fetch(metadata.registration_endpoint, {
215 method: "POST",236 method: "POST",
216 headers: { "content-type": "application/json" },237 headers: { "content-type": "application/json" },
@@ -221,8 +242,9 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 4 unmodified lines
221 response_types: ["code"],242 response_types: ["code"],
222 token_endpoint_auth_method: "none",243 token_endpoint_auth_method: "none",
223 }),244 }),
224 })245 }),
225 ).json()) as { readonly client_id: string };246 "mintBearer: dynamic client registration",
247 );
226 248
227 const verifier = randomBytes(32).toString("base64url");249 const verifier = randomBytes(32).toString("base64url");
228 const authorizeUrl = new URL(metadata.authorization_endpoint);250 const authorizeUrl = new URL(metadata.authorization_endpoint);
@@ -240,7 +262,7 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 11 unmodified lines
240 redirectUrl: redirectUri,262 redirectUrl: redirectUri,
241 });263 });
242 264
243 const token = (await (265 const token = await jsonFrom<TokenResponse>(
244 await fetch(metadata.token_endpoint, {266 await fetch(metadata.token_endpoint, {
245 method: "POST",267 method: "POST",
246 headers: { "content-type": "application/x-www-form-urlencoded" },268 headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -251,8 +273,9 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 4 unmodified lines
251 client_id: registered.client_id,273 client_id: registered.client_id,
252 code_verifier: verifier,274 code_verifier: verifier,
253 }),275 }),
254 })276 }),
255 ).json()) as TokenResponse;277 "mintBearer: token exchange",
278 );
256 if (!token.access_token) throw new Error("mintBearer: token exchange returned no token");279 if (!token.access_token) throw new Error("mintBearer: token exchange returned no token");
257 return token.access_token;280 return token.access_token;
258};281};
@@ -261,7 +284,8 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 2 unmodified lines
261 url: target.mcpUrl,284 url: target.mcpUrl,
262 mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)),285 mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)),
263 session: (identity, options) => {286 session: (identity, options) => {
264 if (runDir) installTraceparentFetch(target.mcpUrl, runDir);287 const mcpUrl = options?.url ?? target.mcpUrl;
288 if (runDir) installTraceparentFetch(mcpUrl, runDir);
265 // mcporter caches OAuth tokens (and the DCR client) per server NAME, so a289 // mcporter caches OAuth tokens (and the DCR client) per server NAME, so a
266 // constant name would let a later session reuse an earlier identity's token290 // constant name would let a later session reuse an earlier identity's token
267 // — landing in the wrong org. A unique name per session keeps each291 // — landing in the wrong org. A unique name per session keeps each
@@ -272,8 +296,8 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 4 unmodified lines
272 // `?elicitation_mode=` query on the MCP endpoint — so a paused execution296 // `?elicitation_mode=` query on the MCP endpoint — so a paused execution
273 // yields an approvalUrl instead of letting the model resume inline.297 // yields an approvalUrl instead of letting the model resume inline.
274 const sessionUrl = options?.elicitationMode298 const sessionUrl = options?.elicitationMode
275 ? `${target.mcpUrl}?elicitation_mode=${options.elicitationMode}`299 ? `${mcpUrl}?elicitation_mode=${options.elicitationMode}`
276 : target.mcpUrl;300 : mcpUrl;
277 let runtimePromise: Promise<Runtime> | undefined;301 let runtimePromise: Promise<Runtime> | undefined;
278 let connected = false;302 let connected = false;
279 303
e2e/targets/selfhost.ts+8−2
@@ -83,7 +83,13 @@ const forcedMcpConsent = · 82 unmodified lines
83 if (!decision.ok) {83 if (!decision.ok) {
84 throw new Error(`forcedMcpConsent: consent grant failed (status ${decision.status})`);84 throw new Error(`forcedMcpConsent: consent grant failed (status ${decision.status})`);
85 }85 }
86 const body = (await decision.json()) as { redirectURI?: string };86 const decisionText = await decision.text();
87 if (!decisionText) {
88 throw new Error(
89 `forcedMcpConsent: consent grant returned an empty body (status ${decision.status})`,
90 );
91 }
92 const body = JSON.parse(decisionText) as { redirectURI?: string };
87 const code = body.redirectURI ? new URL(body.redirectURI).searchParams.get("code") : null;93 const code = body.redirectURI ? new URL(body.redirectURI).searchParams.get("code") : null;
88 if (!code) {94 if (!code) {
89 throw new Error(95 throw new Error(
@@ -118,6 +124,6 @@ export const selfhostTarget = (): Target => ({ · 28 unmodified lines
118 mcpConsent: (identity: Identity) =>124 mcpConsent: (identity: Identity) =>
119 forcedMcpConsent(SELFHOST_BASE_URL, {125 forcedMcpConsent(SELFHOST_BASE_URL, {
120 email: identity.credentials?.email ?? SELFHOST_ADMIN.email,126 email: identity.credentials?.email ?? SELFHOST_ADMIN.email,
121 password: identity.credentials?.password ?? SELFHOST_ADMIN.password,127 password: identity.credentials?.password || SELFHOST_ADMIN.password,
122 }),128 }),
123});129});
packages/core/api/src/server/execution-stack.ts+3−0
@@ -26,6 +26,7 @@ · 25 unmodified lines
26import { Context, Effect, Layer } from "effect";26import { Context, Effect, Layer } from "effect";
27import type * as Cause from "effect/Cause";27import type * as Cause from "effect/Cause";
28 28
29import type { McpResource } from "@executor-js/host-mcp";
29import type { AnyPlugin, Executor, StorageFailure } from "@executor-js/sdk";30import type { AnyPlugin, Executor, StorageFailure } from "@executor-js/sdk";
30import {31import {
31 createExecutionEngine,32 createExecutionEngine,
@@ -95,6 +96,7 @@ export const makeExecutionStack = < · 63 unmodified lines
95 accountId: string,96 accountId: string,
96 organizationId: string,97 organizationId: string,
97 organizationName: string,98 organizationName: string,
99 options?: { readonly mcpResource?: McpResource },
98): Effect.Effect<100): Effect.Effect<
99 { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> },101 { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> },
100 StorageFailure,102 StorageFailure,
@@ -105,6 +107,7 @@ export const makeExecutionStack = < · 4 unmodified lines
105 accountId,107 accountId,
106 organizationId,108 organizationId,
107 organizationName,109 organizationName,
110 { plugins: { mcpResource: options?.mcpResource } },
108 );111 );
109 const codeExecutor = yield* CodeExecutorProvider;112 const codeExecutor = yield* CodeExecutorProvider;
110 const { decorate } = yield* EngineDecorator;113 const { decorate } = yield* EngineDecorator;
packages/core/api/src/server/mcp-build.ts+3−5
@@ -39,11 +39,9 @@ export type McpExecutionStackLayer = Layer.Layer< · 38 unmodified lines
39export const makeMcpBuildServer =39export const makeMcpBuildServer =
40 (executionStack: McpExecutionStackLayer): McpBuildServer =>40 (executionStack: McpExecutionStackLayer): McpBuildServer =>
41 (principal: Principal, options?: McpBuildServerOptions) =>41 (principal: Principal, options?: McpBuildServerOptions) =>
42 makeExecutionStack(42 makeExecutionStack(principal.accountId, principal.organizationId, principal.organizationName, {
43 principal.accountId,43 mcpResource: options?.resource,
44 principal.organizationId,44 }).pipe(
45 principal.organizationName,
46 ).pipe(
47 Effect.map(({ engine }) => engine),45 Effect.map(({ engine }) => engine),
48 // Pin browser-handoff URLs to the principal's org slug when present;46 // Pin browser-handoff URLs to the principal's org slug when present;
49 // absent slug leaves the service unprovided and the URL stays bare.47 // absent slug leaves the service unprovided and the URL stays bare.
packages/core/api/src/server/scoped-executor.ts+8−2
@@ -33,6 +33,7 @@ · 32 unmodified lines
33 33
34import { Context, Effect, Option } from "effect";34import { Context, Effect, Option } from "effect";
35 35
36import type { McpResource } from "@executor-js/host-mcp";
36import {37import {
37 createExecutor,38 createExecutor,
38 OAUTH_CALLBACK_ORG_QUERY_PARAM,39 OAUTH_CALLBACK_ORG_QUERY_PARAM,
@@ -169,8 +170,12 @@ export const buildOAuthRedirectUri = (input: { · 130 unmodified lines
169// while a host with static plugins (self-host) just returns a constant array.170// while a host with static plugins (self-host) just returns a constant array.
170// ---------------------------------------------------------------------------171// ---------------------------------------------------------------------------
171 172
173export interface PluginsProviderContext {
174 readonly mcpResource?: McpResource;
175}
176
172export interface PluginsProviderShape {177export interface PluginsProviderShape {
173 readonly plugins: () => readonly AnyPlugin[];178 readonly plugins: (context?: PluginsProviderContext) => readonly AnyPlugin[];
174}179}
175 180
176export class PluginsProvider extends Context.Service<PluginsProvider, PluginsProviderShape>()(181export class PluginsProvider extends Context.Service<PluginsProvider, PluginsProviderShape>()(
@@ -207,6 +212,7 @@ export const makeScopedExecutor = < · 30 unmodified lines
207 // `EngineStackIdentity` (the engine decorator still wants it); not part of the212 // `EngineStackIdentity` (the engine decorator still wants it); not part of the
208 // v2 executor binding, which is `{ tenant, subject }` only.213 // v2 executor binding, which is `{ tenant, subject }` only.
209 _organizationName: string,214 _organizationName: string,
215 options?: { readonly plugins?: PluginsProviderContext },
210): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> =>216): Effect.Effect<Executor<TPlugins>, StorageFailure, DbProvider | PluginsProvider | HostConfig> =>
211 Effect.gen(function* () {217 Effect.gen(function* () {
212 const { db, blobs } = yield* DbProvider;218 const { db, blobs } = yield* DbProvider;
@@ -248,7 +254,7 @@ export const makeScopedExecutor = < · 35 unmodified lines
248 orgSlug,254 orgSlug,
249 });255 });
250 256
251 const plugins = pluginsFactory();257 const plugins = pluginsFactory(options?.plugins);
252 const hostedHttpOptions = {258 const hostedHttpOptions = {
253 allowLocalNetwork: config.allowLocalNetwork,259 allowLocalNetwork: config.allowLocalNetwork,
254 };260 };
packages/core/sdk/src/client.ts+10−1
@@ -46,10 +46,19 @@ export { useAtomValue, useAtomSet, useAtomMount, useAtomRefresh } from "@effect/ · 45 unmodified lines
46// alongside the host's own UI.46// alongside the host's own UI.
47// ---------------------------------------------------------------------------47// ---------------------------------------------------------------------------
48 48
49export 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
49export interface PageDecl {58export interface PageDecl {
50 /** Path relative to the plugin's mount point, e.g. `/`, `/edit/$id`. */59 /** Path relative to the plugin's mount point, e.g. `/`, `/edit/$id`. */
51 readonly path: string;60 readonly path: string;
52 readonly component: ComponentType;61 readonly component: ComponentType<PluginPageProps>;
53 /** Optional sidebar nav metadata — the host renders these alongside its62 /** Optional sidebar nav metadata — the host renders these alongside its
54 * own nav links. Omit to register a page without a nav entry. */63 * own nav links. Omit to register a page without a nav entry. */
55 readonly nav?: {64 readonly nav?: {
packages/core/sdk/src/executor.ts+135−19
@@ -90,6 +90,7 @@ import type { OAuthService } from "./oauth-client"; · 89 unmodified lines
90import {90import {
91 comparePolicyRow,91 comparePolicyRow,
92 isValidPattern,92 isValidPattern,
93 matchPattern,
93 resolveEffectivePolicy,94 resolveEffectivePolicy,
94 rowToToolPolicy,95 rowToToolPolicy,
95 type CreateToolPolicyInput,96 type CreateToolPolicyInput,
@@ -112,6 +113,8 @@ import type { · 16 unmodified lines
112 StaticSourceDecl,113 StaticSourceDecl,
113 StaticToolDecl,114 StaticToolDecl,
114 StorageDeps,115 StorageDeps,
116 ToolPolicyProvider,
117 ToolPolicyProviderRule,
115 ToolInvocationCredential,118 ToolInvocationCredential,
116} from "./plugin";119} from "./plugin";
117import {120import {
@@ -1282,6 +1285,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1164 unmodified lines
1282 // Populated once, never mutated after startup.1285 // Populated once, never mutated after startup.
1283 const staticTools = new Map<string, StaticTools>();1286 const staticTools = new Map<string, StaticTools>();
1284 const runtimes = new Map<string, PluginRuntime>();1287 const runtimes = new Map<string, PluginRuntime>();
1288 let activeToolPolicyProvider: ToolPolicyProvider | null = null;
1285 // Credential providers keyed by `provider.key`, in registration order.1289 // Credential providers keyed by `provider.key`, in registration order.
1286 const credentialProviders = new Map<string, CredentialProvider>();1290 const credentialProviders = new Map<string, CredentialProvider>();
1287 const credentialProviderOrder: string[] = [];1291 const credentialProviderOrder: string[] = [];
@@ -2299,8 +2303,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1011 unmodified lines
2299 readonly integration?: IntegrationSlug;2303 readonly integration?: IntegrationSlug;
2300 readonly owner?: Owner;2304 readonly owner?: Owner;
2301 }): Effect.Effect<readonly Connection[], StorageFailure> =>2305 }): Effect.Effect<readonly Connection[], StorageFailure> =>
2302 core2306 Effect.gen(function* () {
2303 .findMany("connection", {2307 const rows = yield* core.findMany("connection", {
2304 where: (b: AnyCb) =>2308 where: (b: AnyCb) =>
2305 b.and(2309 b.and(
2306 filter?.integration === undefined2310 filter?.integration === undefined
@@ -2308,8 +2312,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line
2308 : b("integration", "=", String(filter.integration)),2312 : b("integration", "=", String(filter.integration)),
2309 filter?.owner === undefined ? true : b("owner", "=", filter.owner),2313 filter?.owner === undefined ? true : b("owner", "=", filter.owner),
2310 ),2314 ),
2311 })2315 });
2312 .pipe(Effect.map((rows) => rows.map(rowToConnection)));2316 const connections = rows.map(rowToConnection);
2317 if (!activeToolPolicyProvider) return connections;
2318
2319 const visibleTools = yield* toolsList({ includeAnnotations: false });
2320 const visibleConnectionKeys = new Set(
2321 visibleTools
2322 .filter((tool) => !tool.static)
2323 .map((tool) => `${tool.owner}:${tool.integration}:${tool.connection}`),
2324 );
2325 return connections.filter((connection) =>
2326 visibleConnectionKeys.has(
2327 `${connection.owner}:${connection.integration}:${connection.name}`,
2328 ),
2329 );
2330 });
2313 2331
2314 const connectionsGet = (ref: ConnectionRef): Effect.Effect<Connection | null, StorageFailure> =>2332 const connectionsGet = (ref: ConnectionRef): Effect.Effect<Connection | null, StorageFailure> =>
2315 findConnectionRow(ref).pipe(Effect.map((row) => (row ? rowToConnection(row) : null)));2333 findConnectionRow(ref).pipe(Effect.map((row) => (row ? rowToConnection(row) : null)));
@@ -2412,6 +2430,81 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 96 unmodified lines
2412 return yield* produceConnectionTools(integrationRow, ref);2430 return yield* produceConnectionTools(integrationRow, ref);
2413 });2431 });
2414 2432
2433 // ------------------------------------------------------------------
2434 // Active policy source.
2435 // ------------------------------------------------------------------
2436
2437 type ActivePolicyRuleSet =
2438 | { readonly kind: "global"; readonly rows: readonly ToolPolicyRow[] }
2439 | {
2440 readonly kind: "provider";
2441 readonly provider: ToolPolicyProvider;
2442 readonly rules: readonly ToolPolicyProviderRule[] | null;
2443 };
2444
2445 const compareProviderPolicyRule = (
2446 a: ToolPolicyProviderRule,
2447 b: ToolPolicyProviderRule,
2448 ): number => {
2449 if (a.position < b.position) return -1;
2450 if (a.position > b.position) return 1;
2451 return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
2452 };
2453
2454 const resolveProviderPolicyFromRules = (
2455 toolId: string,
2456 rules: readonly ToolPolicyProviderRule[],
2457 ): EffectivePolicy => {
2458 for (const rule of [...rules].sort(compareProviderPolicyRule)) {
2459 if (!matchPattern(rule.pattern, toolId)) continue;
2460 return {
2461 action: rule.action,
2462 source: "user",
2463 pattern: rule.pattern,
2464 policyId: rule.id,
2465 };
2466 }
2467 // Toolkit-style providers are capability allowlists. No matching rule
2468 // means the tool is outside the capability boundary.
2469 return {
2470 action: "block",
2471 source: "user",
2472 pattern: "*",
2473 };
2474 };
2475
2476 const listActivePolicyRuleSet = (): Effect.Effect<ActivePolicyRuleSet, StorageFailure> =>
2477 activeToolPolicyProvider
2478 ? activeToolPolicyProvider.resolve
2479 ? Effect.succeed({
2480 kind: "provider" as const,
2481 provider: activeToolPolicyProvider,
2482 rules: null,
2483 })
2484 : activeToolPolicyProvider.list().pipe(
2485 Effect.map((rules) => ({
2486 kind: "provider" as const,
2487 provider: activeToolPolicyProvider!,
2488 rules,
2489 })),
2490 )
2491 : core
2492 .findMany("tool_policy", {})
2493 .pipe(Effect.map((rows) => ({ kind: "global" as const, rows })));
2494
2495 const resolvePolicyFromRuleSet = (
2496 toolId: string,
2497 ruleSet: ActivePolicyRuleSet,
2498 defaultRequiresApproval?: boolean,
2499 ): Effect.Effect<EffectivePolicy, StorageFailure> =>
2500 ruleSet.kind === "provider"
2501 ? ruleSet.provider.resolve
2502 ? ruleSet.provider.resolve({ toolId, defaultRequiresApproval })
2503 : Effect.succeed(resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? []))
2504 : Effect.succeed(
2505 resolveEffectivePolicy(toolId, ruleSet.rows, ownerRankForRow, defaultRequiresApproval),
2506 );
2507
2415 // ------------------------------------------------------------------2508 // ------------------------------------------------------------------
2416 // Tools (read surface)2509 // Tools (read surface)
2417 // ------------------------------------------------------------------2510 // ------------------------------------------------------------------
@@ -2490,16 +2583,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 72 unmodified lines
2490 select: TOOL_INVOCATION_COLUMNS,2583 select: TOOL_INVOCATION_COLUMNS,
2491 });2584 });
2492 const includeBlocked = filter?.includeBlocked ?? false;2585 const includeBlocked = filter?.includeBlocked ?? false;
2493 const policyRows = yield* core.findMany("tool_policy", {});2586 const policyRules = yield* listActivePolicyRuleSet();
2494 const tools: Tool[] = [];2587 const tools: Tool[] = [];
2495 for (const row of rows) {2588 for (const row of rows) {
2496 const tool = rowToTool(row);2589 const tool = rowToTool(row);
2497 if (!matchesToolFilter(tool, filter)) continue;2590 if (!matchesToolFilter(tool, filter)) continue;
2498 if (!includeBlocked) {2591 if (!includeBlocked) {
2499 const effective = resolveEffectivePolicy(2592 const effective = yield* resolvePolicyFromRuleSet(
2500 normalizedPolicyId(tool),2593 normalizedPolicyId(tool),
2501 policyRows,2594 policyRules,
2502 ownerRankForRow,
2503 tool.annotations?.requiresApproval,2595 tool.annotations?.requiresApproval,
2504 );2596 );
2505 if (effective.action === "block") continue;2597 if (effective.action === "block") continue;
@@ -2510,10 +2602,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 4 unmodified lines
2510 const tool = staticToolToTool(entry);2602 const tool = staticToolToTool(entry);
2511 if (!matchesToolFilter(tool, filter)) continue;2603 if (!matchesToolFilter(tool, filter)) continue;
2512 if (!includeBlocked) {2604 if (!includeBlocked) {
2513 const effective = resolveEffectivePolicy(2605 const effective = yield* resolvePolicyFromRuleSet(
2514 normalizedPolicyId(tool),2606 normalizedPolicyId(tool),
2515 policyRows,2607 policyRules,
2516 ownerRankForRow,
2517 tool.annotations?.requiresApproval,2608 tool.annotations?.requiresApproval,
2518 );2609 );
2519 if (effective.action === "block") continue;2610 if (effective.action === "block") continue;
@@ -2527,9 +2618,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines
2527 address: ToolAddress,2618 address: ToolAddress,
2528 ): Effect.Effect<ToolSchemaView | null, StorageFailure> =>2619 ): Effect.Effect<ToolSchemaView | null, StorageFailure> =>
2529 Effect.gen(function* () {2620 Effect.gen(function* () {
2621 const policyRules = yield* listActivePolicyRuleSet();
2530 const staticEntry = staticTools.get(String(address));2622 const staticEntry = staticTools.get(String(address));
2531 if (staticEntry) {2623 if (staticEntry) {
2532 const tool = staticToolToTool(staticEntry);2624 const tool = staticToolToTool(staticEntry);
2625 const effective = yield* resolvePolicyFromRuleSet(
2626 normalizedPolicyId(tool),
2627 policyRules,
2628 tool.annotations?.requiresApproval,
2629 );
2630 if (effective.action === "block") return null;
2533 const preview = yield* Effect.tryPromise({2631 const preview = yield* Effect.tryPromise({
2534 try: () =>2632 try: () =>
2535 buildToolTypeScriptPreview({2633 buildToolTypeScriptPreview({
@@ -2565,6 +2663,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines
2565 });2663 });
2566 if (!row) return null;2664 if (!row) return null;
2567 const tool = rowToTool(row);2665 const tool = rowToTool(row);
2666 const effective = yield* resolvePolicyFromRuleSet(
2667 normalizedPolicyId(tool),
2668 policyRules,
2669 tool.annotations?.requiresApproval,
2670 );
2671 if (effective.action === "block") return null;
2568 2672
2569 const definitionRows = yield* core.findMany("definition", {2673 const definitionRows = yield* core.findMany("definition", {
2570 where: (b: AnyCb) =>2674 where: (b: AnyCb) =>
@@ -2904,11 +3008,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 333 unmodified lines
2904 // not the 5-segment dynamic form.3008 // not the 5-segment dynamic form.
2905 const staticEntry = staticTools.get(String(address));3009 const staticEntry = staticTools.get(String(address));
2906 if (staticEntry) {3010 if (staticEntry) {
2907 const policyRows = yield* core.findMany("tool_policy", {});3011 const policyRules = yield* listActivePolicyRuleSet();
2908 const policy = resolveEffectivePolicy(3012 const policy = yield* resolvePolicyFromRuleSet(
2909 String(address),3013 String(address),
2910 policyRows,3014 policyRules,
2911 ownerRankForRow,
2912 staticEntry.tool.annotations?.requiresApproval,3015 staticEntry.tool.annotations?.requiresApproval,
2913 );3016 );
2914 if (policy.action === "block") {3017 if (policy.action === "block") {
@@ -2957,12 +3060,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 42 unmodified lines
2957 3060
2958 // Resolve policy (owner-ranked).3061 // Resolve policy (owner-ranked).
2959 const toolForPolicy = rowToTool(row);3062 const toolForPolicy = rowToTool(row);
2960 const policyRows = yield* core.findMany("tool_policy", {});3063 const policyRules = yield* listActivePolicyRuleSet();
2961 const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined;3064 const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined;
2962 const policy = resolveEffectivePolicy(3065 const policy = yield* resolvePolicyFromRuleSet(
2963 normalizedPolicyId(toolForPolicy),3066 normalizedPolicyId(toolForPolicy),
2964 policyRows,3067 policyRules,
2965 ownerRankForRow,
2966 annotations?.requiresApproval,3068 annotations?.requiresApproval,
2967 );3069 );
2968 if (policy.action === "block") {3070 if (policy.action === "block") {
@@ -3183,6 +3285,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 214 unmodified lines
3183 transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect),3285 transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect),
3184 };3286 };
3185 3287
3288 if (plugin.toolPolicyProvider) {
3289 const rawProvider = plugin.toolPolicyProvider(ctx);
3290 const provider = Effect.isEffect(rawProvider) ? yield* rawProvider : rawProvider;
3291 if (provider) {
3292 if (activeToolPolicyProvider) {
3293 return yield* new StorageError({
3294 message: "Only one plugin can provide the active tool policy source.",
3295 cause: undefined,
3296 });
3297 }
3298 activeToolPolicyProvider = provider;
3299 }
3300 }
3301
3186 // Build extension FIRST so it's available as `self` for staticSources.3302 // Build extension FIRST so it's available as `self` for staticSources.
3187 const extension: object = plugin.extension ? plugin.extension(ctx) : {};3303 const extension: object = plugin.extension ? plugin.extension(ctx) : {};
3188 if (plugin.extension) {3304 if (plugin.extension) {
packages/core/sdk/src/index.ts+2−0
@@ -275,6 +275,8 @@ export { · 274 unmodified lines
275 type AnyPlugin,275 type AnyPlugin,
276 type StorageDeps,276 type StorageDeps,
277 type OwnerBinding,277 type OwnerBinding,
278 type ToolPolicyProvider,
279 type ToolPolicyProviderRule,
278 type IntegrationRecord,280 type IntegrationRecord,
279 type StaticSourceDecl,281 type StaticSourceDecl,
280 type StaticToolDecl,282 type StaticToolDecl,
packages/core/sdk/src/plugin.ts+32−0
@@ -48,6 +48,7 @@ import type { CredentialProvider, ProviderEntry } from "./provider"; · 47 unmodified lines
48import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage";48import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage";
49import type {49import type {
50 CreateToolPolicyInput,50 CreateToolPolicyInput,
51 EffectivePolicy,
51 RemoveToolPolicyInput,52 RemoveToolPolicyInput,
52 ToolPolicy,53 ToolPolicy,
53 UpdateToolPolicyInput,54 UpdateToolPolicyInput,
@@ -88,6 +89,30 @@ export type Elicit = ( · 34 unmodified lines
88 request: ElicitationRequest,89 request: ElicitationRequest,
89) => Effect.Effect<ElicitationResponse, ElicitationDeclinedError>;90) => Effect.Effect<ElicitationResponse, ElicitationDeclinedError>;
90 91
92// ---------------------------------------------------------------------------
93// Active tool-policy provider.
94//
95// Normal executors resolve policies from core's owner-scoped `tool_policy`
96// table. A plugin may opt one executor instance into a different rule source
97// (for example, a toolkit-specific policy set). Core still owns enforcement;
98// the plugin owns where those policy-shaped rows are stored.
99// ---------------------------------------------------------------------------
100
101export interface ToolPolicyProviderRule {
102 readonly id: string;
103 readonly pattern: string;
104 readonly action: ToolPolicy["action"];
105 readonly position: string;
106}
107
108export interface ToolPolicyProvider {
109 readonly list: () => Effect.Effect<readonly ToolPolicyProviderRule[], StorageFailure>;
110 readonly resolve?: (input: {
111 readonly toolId: string;
112 readonly defaultRequiresApproval?: boolean;
113 }) => Effect.Effect<EffectivePolicy, StorageFailure>;
114}
115
91// ---------------------------------------------------------------------------116// ---------------------------------------------------------------------------
92// IntegrationRecord — the catalog row a plugin reads back (its own opaque117// IntegrationRecord — the catalog row a plugin reads back (its own opaque
93// `config` included). Returned by `ctx.core.integrations.get`.118// `config` included). Returned by `ctx.core.integrations.get`.
@@ -458,6 +483,13 @@ export interface PluginSpec< · 364 unmodified lines
458 /** Service tag the plugin's `handlers` layer requires. */483 /** Service tag the plugin's `handlers` layer requires. */
459 readonly extensionService?: TExtensionService;484 readonly extensionService?: TExtensionService;
460 485
486 /** Optional active policy source for this executor instance. At most one
487 * loaded plugin may return a provider. When absent, core uses the normal
488 * owner-scoped tool policies. */
489 readonly toolPolicyProvider?: (
490 ctx: PluginCtx<TStore>,
491 ) => ToolPolicyProvider | null | Effect.Effect<ToolPolicyProvider | null, StorageFailure>;
492
461 /** Produce a connection's tools (and shared $defs). The v2 successor to493 /** Produce a connection's tools (and shared $defs). The v2 successor to
462 * registering per-source tools — called by the executor at connection494 * registering per-source tools — called by the executor at connection
463 * create / refresh / oauth.complete; the result is stamped with addresses495 * create / refresh / oauth.complete; the result is stamped with addresses
packages/core/sdk/src/policies.test.ts+81−2
@@ -1,5 +1,5 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Predicate, Result } from "effect";2import { Effect, Predicate, Result, Schema } from "effect";
3 3
4import { type ToolPolicyRow } from "./core-schema";4import { type ToolPolicyRow } from "./core-schema";
5import {5import {
@@ -19,7 +19,7 @@ import { · 13 unmodified lines
19 matchPattern,19 matchPattern,
20 resolveToolPolicy,20 resolveToolPolicy,
21} from "./policies";21} from "./policies";
22import { definePlugin } from "./plugin";22import { definePlugin, tool } from "./plugin";
23import type { CredentialProvider } from "./provider";23import type { CredentialProvider } from "./provider";
24import { makeTestExecutor } from "./testing";24import { makeTestExecutor } from "./testing";
25 25
@@ -512,6 +512,85 @@ describe("blocked tools", () => { · 486 unmodified lines
512 );512 );
513});513});
514 514
515describe("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
515describe("approve / require_approval interaction with annotations", () => {594describe("approve / require_approval interaction with annotations", () => {
516 it.effect("approve skips the elicitation prompt even when plugin requires approval", () =>595 it.effect("approve skips the elicitation prompt even when plugin requires approval", () =>
517 Effect.gen(function* () {596 Effect.gen(function* () {
packages/hosts/mcp/src/envelope.test.ts+27−0
@@ -20,6 +20,7 @@ import { · 19 unmodified lines
20 McpErrorReporterNoop,20 McpErrorReporterNoop,
21 McpServingRoutes,21 McpServingRoutes,
22 McpSessionStore,22 McpSessionStore,
23 type McpResource,
23 type McpDispatchResult,24 type McpDispatchResult,
24 type Principal,25 type Principal,
25} from "./index";26} from "./index";
@@ -133,3 +134,29 @@ describe("McpServingRoutes envelope", () => { · 107 unmodified lines
133 expect(captures[0]).toContain("induced defect");134 expect(captures[0]).toContain("induced defect");
134 });135 });
135});136});
137
138it("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
2import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";2import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3 3
4import {4import {
5 defaultMcpResource,
5 McpAuthProvider,6 McpAuthProvider,
6 McpErrorReporter,7 McpErrorReporter,
7 McpSessionStore,8 McpSessionStore,
8 type AuthOutcome,9 type AuthOutcome,
9 type McpDispatchResult,10 type McpDispatchResult,
11 type McpResource,
10} from "./seams";12} from "./seams";
11 13
12// ---------------------------------------------------------------------------14// ---------------------------------------------------------------------------
@@ -14,7 +16,8 @@ import { · 1 unmodified line
14//16//
15// Routes:17// Routes:
16// GET <provider-declared discovery paths> -> McpAuthProvider metadata18// GET <provider-declared discovery paths> -> McpAuthProvider metadata
17// * /mcp -> authenticate -> dispatch19// * /mcp -> authenticate -> dispatch(default)
20// * /mcp/toolkits/:toolkitSlug -> authenticate -> dispatch(toolkit)
18//21//
19// The provider DECLARES the discovery paths it owns (at least the protected-22// The provider DECLARES the discovery paths it owns (at least the protected-
20// resource metadata document) via `McpAuthProvider.discoveryRoutes`; the23// resource metadata document) via `McpAuthProvider.discoveryRoutes`; the
@@ -24,8 +27,8 @@ import { · 3 unmodified lines
24// WorkOS, external). The envelope only needs the provider's discovery routes,27// WorkOS, external). The envelope only needs the provider's discovery routes,
25// resource-metadata URL, and authenticate.28// resource-metadata URL, and authenticate.
26//29//
27// The envelope hard-codes ONLY the `/mcp` path and CORS. Everything else —30// The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else
28// every `/.well-known/*` path, the resource-metadata URL, the authn/authz31// — every `/.well-known/*` path, the resource-metadata URL, the authn/authz
29// semantics, and the entire session lifecycle (create + forward + ownership) —32// semantics, and the entire session lifecycle (create + forward + ownership) —
30// comes from the two seams.33// comes from the two seams.
31//34//
@@ -38,6 +41,7 @@ import { · 6 unmodified lines
38// ---------------------------------------------------------------------------41// ---------------------------------------------------------------------------
39 42
40const MCP_PATH = "/mcp";43const MCP_PATH = "/mcp";
44const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug";
41 45
42/** The methods the streamable-HTTP transport accepts on `/mcp`. */46/** The methods the streamable-HTTP transport accepts on `/mcp`. */
43const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);47const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
@@ -170,69 +174,73 @@ const renderDispatchError = (lookup: "not-found" | "forbidden"): Response => · 126 unmodified lines
170 ? jsonRpcResponse(404, -32001, "Session not found")174 ? jsonRpcResponse(404, -32001, "Session not found")
171 : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");175 : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
172 176
173/** Dispatch a `/mcp` request through authenticate -> store.dispatch -> transport. */177/** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */
174const mcpDispatch = Effect.gen(function* () {178const mcpDispatch = (resource: McpResource) =>
175 const httpRequest = yield* HttpServerRequest.HttpServerRequest;179 Effect.gen(function* () {
176 const auth = yield* McpAuthProvider;180 const httpRequest = yield* HttpServerRequest.HttpServerRequest;
177 const store = yield* McpSessionStore;181 const auth = yield* McpAuthProvider;
178 const request = yield* toWebRequest(httpRequest);182 const store = yield* McpSessionStore;
183 const request = yield* toWebRequest(httpRequest);
179 184
180 // CORS preflight: answer before auth so unauthenticated clients can probe.185 // CORS preflight: answer before auth so unauthenticated clients can probe.
181 if (request.method === "OPTIONS") {186 if (request.method === "OPTIONS") {
182 return HttpServerResponse.raw(corsPreflightResponse());187 return HttpServerResponse.raw(corsPreflightResponse());
183 }188 }
184 189
185 // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other190 // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other
186 // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch —191 // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch —
187 // otherwise it would fall through and spin up a session engine for a method192 // otherwise it would fall through and spin up a session engine for a method
188 // the transport can't serve.193 // the transport can't serve.
189 if (!ALLOWED_MCP_METHODS.has(request.method)) {194 if (!ALLOWED_MCP_METHODS.has(request.method)) {
190 return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed"));195 return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed"));
191 }196 }
192 197
193 const sessionId = request.headers.get("mcp-session-id");198 const sessionId = request.headers.get("mcp-session-id");
194 199
195 // Authenticate (and, for session-aware providers, authorize) on EVERY200 // Authenticate (and, for session-aware providers, authorize) on EVERY
196 // request. On a non-Authenticated outcome:201 // request. On a non-Authenticated outcome:
197 // - Forbidden -> dispose the live session first (cloud tears down a DO202 // - Forbidden -> dispose the live session first (cloud tears down a DO
198 // whose org access was revoked), then render the 403. The203 // whose org access was revoked), then render the 403. The
199 // inbound request is forwarded so the store can propagate204 // inbound request is forwarded so the store can propagate
200 // the request's W3C trace context onto the teardown RPC.205 // the request's W3C trace context onto the teardown RPC.
201 // - other -> render directly.206 // - other -> render directly.
202 const outcome = yield* auth.authenticate(request);207 const outcome = yield* auth.authenticate(request);
203 if (!Predicate.isTagged(outcome, "Authenticated")) {208 if (!Predicate.isTagged(outcome, "Authenticated")) {
204 if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {209 if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
205 yield* store.dispose(sessionId, request);210 yield* store.dispose(sessionId, request);
211 }
212 return HttpServerResponse.raw(renderAuthError(auth, request, outcome));
206 }213 }
207 return HttpServerResponse.raw(renderAuthError(auth, request, outcome));214 const principal = outcome.principal;
208 }
209 const principal = outcome.principal;
210 215
211 // No session id: per the streamable-HTTP transport contract, only POST opens216 // 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 a217 // 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 up218 // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up
214 // an engine for a bare GET/DELETE.219 // an engine for a bare GET/DELETE.
215 if (!sessionId) {220 if (!sessionId) {
216 if (request.method === "GET") {221 if (request.method === "GET") {
217 return HttpServerResponse.raw(222 return HttpServerResponse.raw(
218 jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"),223 jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"),
219 );224 );
225 }
226 if (request.method === "DELETE") {
227 return HttpServerResponse.raw(
228 new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }),
229 );
230 }
220 }231 }
221 if (request.method === "DELETE") {
222 return HttpServerResponse.raw(
223 new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }),
224 );
225 }
226 }
227 232
228 const result: McpDispatchResult = yield* store.dispatch({233 const result: McpDispatchResult = yield* store.dispatch({
229 request,234 request,
230 principal,235 principal,
231 sessionId,236 resource,
232 method: request.method,237 sessionId,
238 method: request.method,
239 });
240 return HttpServerResponse.raw(
241 result instanceof Response ? result : renderDispatchError(result),
242 );
233 });243 });
234 return HttpServerResponse.raw(result instanceof Response ? result : renderDispatchError(result));
235});
236 244
237/**245/**
238 * The `/mcp` route. Wraps {@link mcpDispatch} in a top-level `catchCause`: a246 * The `/mcp` route. Wraps {@link mcpDispatch} in a top-level `catchCause`: a
@@ -242,15 +250,22 @@ const mcpDispatch = Effect.gen(function* () { · 3 unmodified lines
242 * otherwise, since the envelope returns a `Response`) and rendered as a stable250 * otherwise, since the envelope returns a `Response`) and rendered as a stable
243 * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body.251 * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body.
244 */252 */
245const mcpRoute = mcpDispatch.pipe(253const mcpRoute = (resource: McpResource) =>
246 Effect.catchCause((cause) =>254 mcpDispatch(resource).pipe(
247 Effect.gen(function* () {255 Effect.catchCause((cause) =>
248 const reporter = yield* McpErrorReporter;256 Effect.gen(function* () {
249 yield* reporter.report(cause);257 const reporter = yield* McpErrorReporter;
250 return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error"));258 yield* reporter.report(cause);
251 }),259 return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error"));
252 ),260 }),
253);261 ),
262 );
263
264const toolkitMcpRoute = Effect.gen(function* () {
265 const params = yield* HttpRouter.params;
266 const slug = params.toolkitSlug;
267 return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource);
268});
254 269
255/**270/**
256 * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges271 * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges
@@ -273,6 +288,7 @@ export const McpServingRoutes = HttpRouter.use((router) => · 16 unmodified lines
273 Effect.sync(() => HttpServerResponse.raw(corsPreflightResponse())),288 Effect.sync(() => HttpServerResponse.raw(corsPreflightResponse())),
274 );289 );
275 }290 }
276 yield* router.add("*", MCP_PATH, mcpRoute);291 yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource));
292 yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute);
277 }),293 }),
278);294);
packages/hosts/mcp/src/in-memory-session-store.ts+35−11
@@ -17,10 +17,13 @@ import { · 16 unmodified lines
17import { jsonRpcErrorBody } from "./envelope";17import { jsonRpcErrorBody } from "./envelope";
18import {18import {
19 McpSessionStore,19 McpSessionStore,
20 defaultMcpResource,
21 mcpResourceKey,
20 principalOwns,22 principalOwns,
21 type McpDispatchInput,23 type McpDispatchInput,
22 type McpDispatchResult,24 type McpDispatchResult,
23 type Principal,25 type Principal,
26 type McpResource,
24} from "./seams";27} from "./seams";
25import type { BrowserApprovalStore } from "./tool-server";28import type { BrowserApprovalStore } from "./tool-server";
26 29
@@ -61,6 +64,7 @@ export interface BuiltMcpServer { · 34 unmodified lines
61 64
62/** The browser-mode wiring the store hands a build call when a session opts in. */65/** The browser-mode wiring the store hands a build call when a session opts in. */
63export interface McpBuildServerOptions {66export interface McpBuildServerOptions {
67 readonly resource?: McpResource;
64 readonly elicitationMode?:68 readonly elicitationMode?:
65 | { readonly mode: "browser"; readonly approvalUrl: (executionId: string) => string }69 | { readonly mode: "browser"; readonly approvalUrl: (executionId: string) => string }
66 | { readonly mode: "model" }70 | { readonly mode: "model" }
@@ -120,6 +124,19 @@ const json = (value: unknown, status = 200): Response => · 53 unmodified lines
120const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;124const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;
121const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;125const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;
122 126
127interface SessionOwner {
128 readonly principal: Principal;
129 readonly resource: McpResource;
130}
131
132const sessionOwnerMatches = (
133 owner: SessionOwner,
134 principal: Principal,
135 resource: McpResource,
136): boolean =>
137 principalOwns(owner.principal, principal) &&
138 mcpResourceKey(owner.resource) === mcpResourceKey(resource);
139
123/**140/**
124 * Build the in-process session store plus an explicit `close()` that disposes141 * Build the in-process session store plus an explicit `close()` that disposes
125 * all live sessions. `close()` is not part of the seam — it is the host lifetime142 * all live sessions. `close()` is not part of the seam — it is the host lifetime
@@ -137,7 +154,7 @@ export const makeInMemoryMcpSessionStore = ( · 11 unmodified lines
137): InMemoryMcpSessionStore => {154): InMemoryMcpSessionStore => {
138 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();155 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
139 const servers = new Map<string, McpServer>();156 const servers = new Map<string, McpServer>();
140 const owners = new Map<string, Principal>();157 const owners = new Map<string, SessionOwner>();
141 const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>();158 const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>();
142 const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore();159 const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore();
143 160
@@ -181,12 +198,13 @@ export const makeInMemoryMcpSessionStore = ( · 37 unmodified lines
181 const forward = (198 const forward = (
182 sessionId: string,199 sessionId: string,
183 principal: Principal,200 principal: Principal,
201 resource: McpResource,
184 request: Request,202 request: Request,
185 ): Effect.Effect<McpDispatchResult> => {203 ): Effect.Effect<McpDispatchResult> => {
186 const transport = transports.get(sessionId);204 const transport = transports.get(sessionId);
187 const owner = owners.get(sessionId);205 const owner = owners.get(sessionId);
188 if (!transport || !owner) return Effect.succeed("not-found");206 if (!transport || !owner) return Effect.succeed("not-found");
189 if (!principalOwns(owner, principal)) return Effect.succeed("forbidden");207 if (!sessionOwnerMatches(owner, principal, resource)) return Effect.succeed("forbidden");
190 return runHandleRequest(transport, request);208 return runHandleRequest(transport, request);
191 };209 };
192 210
@@ -218,12 +236,16 @@ export const makeInMemoryMcpSessionStore = ( · 25 unmodified lines
218 };236 };
219 237
220 /** Open a new session: build the server, connect a transport, drive the request. */238 /** Open a new session: build the server, connect a transport, drive the request. */
221 const create = (principal: Principal, request: Request): Effect.Effect<McpDispatchResult> => {239 const create = (
240 principal: Principal,
241 resource: McpResource,
242 request: Request,
243 ): Effect.Effect<McpDispatchResult> => {
222 let createdSessionId: string | null = null;244 let createdSessionId: string | null = null;
223 return buildServer(245 return buildServer(principal, {
224 principal,246 ...buildOptionsFor(request, () => createdSessionId),
225 buildOptionsFor(request, () => createdSessionId),247 resource,
226 ).pipe(248 }).pipe(
227 Effect.flatMap(({ mcpServer, engine }) =>249 Effect.flatMap(({ mcpServer, engine }) =>
228 Effect.gen(function* () {250 Effect.gen(function* () {
229 const transport = new WebStandardStreamableHTTPServerTransport({251 const transport = new WebStandardStreamableHTTPServerTransport({
@@ -233,7 +255,7 @@ export const makeInMemoryMcpSessionStore = ( · 3 unmodified lines
233 createdSessionId = sid;255 createdSessionId = sid;
234 transports.set(sid, transport);256 transports.set(sid, transport);
235 servers.set(sid, mcpServer);257 servers.set(sid, mcpServer);
236 owners.set(sid, principal);258 owners.set(sid, { principal, resource });
237 engines.set(sid, engine);259 engines.set(sid, engine);
238 },260 },
239 onsessionclosed: (sid) => void dispose(sid, { server: true }),261 onsessionclosed: (sid) => void dispose(sid, { server: true }),
@@ -259,8 +281,10 @@ export const makeInMemoryMcpSessionStore = ( · 19 unmodified lines
259 };281 };
260 282
261 const store: McpSessionStore["Service"] = {283 const store: McpSessionStore["Service"] = {
262 dispatch: ({ request, principal, sessionId }: McpDispatchInput) =>284 dispatch: ({ request, principal, resource, sessionId }: McpDispatchInput) =>
263 sessionId ? forward(sessionId, principal, request) : create(principal, request),285 sessionId
286 ? forward(sessionId, principal, resource, request)
287 : create(principal, resource ?? defaultMcpResource, request),
264 dispose: (sessionId) =>288 dispose: (sessionId) =>
265 Effect.promise(() => dispose(sessionId, { transport: true, server: true })),289 Effect.promise(() => dispose(sessionId, { transport: true, server: true })),
266 };290 };
@@ -271,7 +295,7 @@ export const makeInMemoryMcpSessionStore = ( · 4 unmodified lines
271 ): "allowed" | "not-found" | "forbidden" => {295 ): "allowed" | "not-found" | "forbidden" => {
272 const owner = owners.get(sessionId);296 const owner = owners.get(sessionId);
273 if (!owner) return "not-found";297 if (!owner) return "not-found";
274 if (principal && !principalOwns(owner, principal)) return "forbidden";298 if (principal && !principalOwns(owner.principal, principal)) return "forbidden";
275 return "allowed";299 return "allowed";
276 };300 };
277 301
packages/hosts/mcp/src/index.ts+3−0
@@ -18,6 +18,8 @@ export { · 17 unmodified lines
18 McpSessionStore,18 McpSessionStore,
19 McpErrorReporter,19 McpErrorReporter,
20 McpErrorReporterNoop,20 McpErrorReporterNoop,
21 defaultMcpResource,
22 mcpResourceKey,
21 principalOwns,23 principalOwns,
22 authenticated,24 authenticated,
23 unauthorized,25 unauthorized,
@@ -31,6 +33,7 @@ export { · 7 unmodified lines
31 type McpDiscoveryRoute,33 type McpDiscoveryRoute,
32 type McpDispatchInput,34 type McpDispatchInput,
33 type McpDispatchResult,35 type McpDispatchResult,
36 type McpResource,
34} from "./seams";37} from "./seams";
35 38
36export { McpServingRoutes, jsonRpcErrorBody } from "./envelope";39export { 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
56export const principalOwns = (owner: Principal, principal: Principal): boolean =>56export const principalOwns = (owner: Principal, principal: Principal): boolean =>
57 owner.accountId === principal.accountId && owner.organizationId === principal.organizationId;57 owner.accountId === principal.accountId && owner.organizationId === principal.organizationId;
58 58
59// ---------------------------------------------------------------------------
60// Shared MCP resource identity.
61//
62// The default `/mcp` endpoint and named sub-resources such as
63// `/mcp/toolkits/<slug>` share auth and transport machinery, but a serving
64// session belongs to the exact resource path that minted it. This keeps a
65// leaked/reused `mcp-session-id` from crossing from one exposed capability set
66// into another.
67// ---------------------------------------------------------------------------
68
69export type McpResource =
70 | { readonly kind: "default" }
71 | { readonly kind: "toolkit"; readonly slug: string };
72
73export const defaultMcpResource: McpResource = { kind: "default" };
74
75export const mcpResourceKey = (resource: McpResource): string =>
76 resource.kind === "default" ? "default" : `toolkit:${resource.slug}`;
77
59// ---------------------------------------------------------------------------78// ---------------------------------------------------------------------------
60// AuthOutcome — the result of `McpAuthProvider.authenticate`.79// AuthOutcome — the result of `McpAuthProvider.authenticate`.
61//80//
@@ -210,6 +229,7 @@ export class McpAuthProvider extends Context.Service< · 148 unmodified lines
210export interface McpDispatchInput {229export interface McpDispatchInput {
211 readonly request: Request;230 readonly request: Request;
212 readonly principal: Principal;231 readonly principal: Principal;
232 readonly resource: McpResource;
213 readonly sessionId: string | null;233 readonly sessionId: string | null;
214 readonly method: string;234 readonly method: string;
215}235}
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 @@
1import { defineClientPlugin } from "@executor-js/sdk/client";
2
3import { ToolkitsPage } from "./page";
4
5export 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 @@
1import { useId, useMemo, useState } from "react";
2import { Link, useNavigate, useParams } from "@tanstack/react-router";
3import { useAtomSet, useAtomValue } from "@effect/atom-react";
4import * as Atom from "effect/unstable/reactivity/Atom";
5import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
6import {
7 ArrowLeftIcon,
8 BoxIcon,
9 CheckIcon,
10 PlugIcon,
11 PlusIcon,
12 SearchIcon,
13 Trash2Icon,
14} from "lucide-react";
15import {
16 createPluginAtomClient,
17 useIntegrationPlugins,
18 type IntegrationPlugin,
19 type PluginPageProps,
20} from "@executor-js/sdk/client";
21import {
22 matchPattern,
23 type EffectivePolicy,
24 type Integration,
25 type Owner,
26 type ToolAddress,
27 type ToolPolicyAction,
28} from "@executor-js/sdk/shared";
29import { integrationsOptimisticAtom, toolsAllAtom } from "@executor-js/react/api/atoms";
30import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
31import {
32 getExecutorApiBaseUrl,
33 getExecutorOrganizationHeaders,
34 getExecutorServerAuthorizationHeader,
35} from "@executor-js/react/api/server-connection";
36import { ownerLabel } from "@executor-js/react/api/owner-display";
37import { Badge } from "@executor-js/react/components/badge";
38import { Button } from "@executor-js/react/components/button";
39import { CopyButton } from "@executor-js/react/components/copy-button";
40import {
41 IntegrationFavicon,
42 integrationPresetIconUrl,
43} from "@executor-js/react/components/integration-favicon";
44import {
45 Dialog,
46 DialogContent,
47 DialogDescription,
48 DialogFooter,
49 DialogHeader,
50 DialogTitle,
51} from "@executor-js/react/components/dialog";
52import { Input } from "@executor-js/react/components/input";
53import { Label } from "@executor-js/react/components/label";
54import { Skeleton } from "@executor-js/react/components/skeleton";
55import { ToolDetail, ToolDetailEmpty } from "@executor-js/react/components/tool-detail";
56import { ToolTree, type ToolSummary } from "@executor-js/react/components/tool-tree";
57import { cn } from "@executor-js/react/lib/utils";
58
59import {
60 ToolkitsApi,
61 type ToolkitConnectionResponse,
62 type ToolkitPolicyResponse,
63 type ToolkitResponse,
64} from "./shared";
65
66const ToolkitsClient = createPluginAtomClient(ToolkitsApi, {
67 baseUrl: getExecutorApiBaseUrl,
68 authorizationHeader: getExecutorServerAuthorizationHeader,
69 headers: getExecutorOrganizationHeaders,
70});
71
72const toolkitWriteKeys = [
73 ReactivityKey.connections,
74 ReactivityKey.policies,
75 ReactivityKey.tools,
76] as const;
77
78const toolkitsAtom = ToolkitsClient.query("toolkits", "list", {
79 timeToLive: "30 seconds",
80 reactivityKeys: [ReactivityKey.policies],
81});
82
83const toolkitPoliciesAtom = Atom.family((toolkitId: string) =>
84 ToolkitsClient.query("toolkits", "listPolicies", {
85 params: { toolkitId },
86 timeToLive: "30 seconds",
87 reactivityKeys: [ReactivityKey.policies],
88 }),
89);
90
91const toolkitConnectionsAtom = Atom.family((toolkitId: string) =>
92 ToolkitsClient.query("toolkits", "listConnections", {
93 params: { toolkitId },
94 timeToLive: "30 seconds",
95 reactivityKeys: [ReactivityKey.connections],
96 }),
97);
98
99const createToolkit = ToolkitsClient.mutation("toolkits", "create");
100const removeToolkit = ToolkitsClient.mutation("toolkits", "remove");
101const createToolkitPolicy = ToolkitsClient.mutation("toolkits", "createPolicy");
102const updateToolkitPolicy = ToolkitsClient.mutation("toolkits", "updatePolicy");
103const removeToolkitPolicy = ToolkitsClient.mutation("toolkits", "removePolicy");
104const createToolkitConnection = ToolkitsClient.mutation("toolkits", "createConnection");
105
106type 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
117const 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
123const pluginDefaultPolicy = (requiresApproval: boolean | undefined): EffectivePolicy =>
124 requiresApproval
125 ? { action: "require_approval", source: "plugin-default" }
126 : { action: "approve", source: "plugin-default" };
127
128const 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
134const 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
151const toolMatchId = (tool: ToolRow): string =>
152 tool.static ? String(tool.address) : String(tool.address).replace(/^tools\./, "");
153
154const toolCanAppearInToolkit = (toolkit: ToolkitResponse, tool: ToolRow): boolean =>
155 toolkit.owner === "user" || tool.static === true || tool.owner !== "user";
156
157const 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
163const identityPattern = (displayPattern: string): string => displayPattern;
164
165const 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
173const toolkitScopeLabel = (toolkit: ToolkitResponse): string =>
174 toolkit.owner === "org" ? "Workspace tools" : "Workspace + personal tools";
175
176const 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
181const toolkitCardStyle = { minHeight: "9rem" };
182const toolkitShelfStyle = { minHeight: "28.5rem" };
183const toolkitGridContainerStyle = { maxWidth: "80rem" };
184const toolkitToolTreeStyle = { width: "24rem" };
185
186const 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
191type 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
200type IntegrationMeta = {
201 readonly name: string;
202 readonly sourceId: string;
203 readonly icon?: string | null;
204 readonly url?: string;
205};
206
207const toolOwner = (tool: ToolRow): Owner => (tool.static ? "org" : (tool.owner ?? "org"));
208
209const toolConnectionName = (tool: ToolRow): string => {
210 if (tool.connection && tool.connection.length > 0) return tool.connection;
211 return tool.static ? "built-in" : "default";
212};
213
214const 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
238const connectionPatternForTool = (tool: ToolRow): string => `${policyPrefixForTool(tool)}.*`;
239
240const 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
250const 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
257const 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
265const 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
296const compareTools = (a: ToolRow, b: ToolRow): number =>
297 String(a.name).localeCompare(String(b.name)) || toolMatchId(a).localeCompare(toolMatchId(b));
298
299const connectionTitle = (group: ToolkitConnectionGroup): string =>
300 `${group.integration} / ${group.connection}`;
301
302const connectionDisplayTitle = (group: ToolkitConnectionGroup, meta: IntegrationMeta): string =>
303 group.connection === "built-in" || group.connection === "default" ? meta.name : group.connection;
304
305const 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
310const 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
333const 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
352const 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
362function 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
403const addToolkitScopeLabel = (owner: Owner): string => (owner === "org" ? "workspace" : "personal");
404const addToolkitTitle = (owner: Owner): string =>
405 owner === "org" ? "New workspace toolkit" : "New personal toolkit";
406
407function 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
480function 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
503function 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
541function 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
572function 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
592function 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
648function 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
814function 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
866function 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
997function 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
1012function 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
1089export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Predicate, Result } from "effect";
3import { makeTestExecutor } from "@executor-js/sdk/testing";
4
5import { toolkitsPlugin } from "./server";
6
7describe("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 @@
1import {
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";
20import { addGroup, capture } from "@executor-js/api";
21import { generateKeyBetween } from "fractional-indexing";
22
23import { ToolkitError, ToolkitsApi } from "./shared";
24
25const ToolkitRecord = Schema.Struct({
26 id: Schema.String,
27 slug: Schema.String,
28 name: Schema.String,
29 createdAt: Schema.Number,
30 updatedAt: Schema.Number,
31});
32type ToolkitRecord = typeof ToolkitRecord.Type;
33
34const 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});
43type ToolkitPolicyRecord = typeof ToolkitPolicyRecord.Type;
44
45const 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});
53type ToolkitConnectionRecord = typeof ToolkitConnectionRecord.Type;
54
55const toolkitsCollection = definePluginStorageCollection("toolkits", ToolkitRecord, {
56 indexes: ["slug", "name", "updatedAt"],
57});
58
59const toolkitPoliciesCollection = definePluginStorageCollection(
60 "toolkitPolicies",
61 ToolkitPolicyRecord,
62 {
63 indexes: ["toolkitId", "pattern", "position", ["toolkitId", "position"]],
64 },
65);
66
67const toolkitConnectionsCollection = definePluginStorageCollection(
68 "toolkitConnections",
69 ToolkitConnectionRecord,
70 {
71 indexes: ["toolkitId", "pattern", "position", ["toolkitId", "position"]],
72 },
73);
74
75type ToolkitStorage = {
76 readonly toolkits: PluginStorageCollectionFacade<typeof toolkitsCollection>;
77 readonly policies: PluginStorageCollectionFacade<typeof toolkitPoliciesCollection>;
78 readonly connections: PluginStorageCollectionFacade<typeof toolkitConnectionsCollection>;
79};
80
81export interface ToolkitsPluginOptions {
82 /** When set, this executor instance enforces only the named toolkit's rules. */
83 readonly activeToolkitSlug?: string;
84}
85
86const newId = (prefix: string): string =>
87 `${prefix}_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
88
89const slugPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
90
91const slugify = (name: string): string =>
92 name
93 .trim()
94 .toLowerCase()
95 .replace(/[^a-z0-9]+/g, "-")
96 .replace(/^-+|-+$/g, "")
97 .slice(0, 63);
98
99const 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
115const fail = (message: string): Effect.Effect<never, ToolkitError> =>
116 Effect.fail(new ToolkitError({ message }));
117
118const 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
127const 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
136const blockedPolicy = (pattern = "*"): EffectivePolicy => ({
137 action: "block",
138 source: "user",
139 pattern,
140});
141
142const pluginDefaultPolicy = (defaultRequiresApproval: boolean | undefined): EffectivePolicy =>
143 defaultRequiresApproval
144 ? { action: "require_approval", source: "plugin-default" }
145 : { action: "approve", source: "plugin-default" };
146
147const 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
153const 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
182const isPersonalDynamicToolId = (toolId: string): boolean => toolId.split(".")[1] === "user";
183
184const 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
193const 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
203const 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
212const makeToolkitStorage = (pluginStorage: PluginStorageFacade): ToolkitStorage => ({
213 toolkits: pluginStorage.collection(toolkitsCollection),
214 policies: pluginStorage.collection(toolkitPoliciesCollection),
215 connections: pluginStorage.collection(toolkitConnectionsCollection),
216});
217
218const 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
513export type ToolkitsExtension = ReturnType<typeof makeToolkitsExtension>;
514
515export class ToolkitsExtensionService extends Context.Service<
516 ToolkitsExtensionService,
517 ToolkitsExtension
518>()("ToolkitsExtensionService") {}
519
520const ExecutorApiWithToolkits = addGroup(ToolkitsApi);
521
522const 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
620const 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
629export 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
653export default toolkitsPlugin;
packages/plugins/toolkits/src/shared.ts+166−0
@@ -0,0 +1,166 @@
1import { Schema } from "effect";
2import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
3import { InternalError, Owner, ToolPolicyActionSchema } from "@executor-js/sdk/shared";
4
5export class ToolkitError extends Schema.TaggedErrorClass<ToolkitError>()(
6 "ToolkitError",
7 { message: Schema.String },
8 { httpApiStatus: 400 },
9) {}
10
11const ToolkitParams = {
12 toolkitId: Schema.String,
13};
14
15const ToolkitPolicyParams = {
16 toolkitId: Schema.String,
17 policyId: Schema.String,
18};
19
20const ToolkitConnectionParams = {
21 toolkitId: Schema.String,
22 connectionId: Schema.String,
23};
24
25export 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});
33export type ToolkitResponse = typeof ToolkitResponse.Type;
34
35export 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});
44export type ToolkitPolicyResponse = typeof ToolkitPolicyResponse.Type;
45
46export 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});
54export type ToolkitConnectionResponse = typeof ToolkitConnectionResponse.Type;
55
56const CreateToolkitPayload = Schema.Struct({
57 owner: Owner,
58 name: Schema.String,
59 slug: Schema.optional(Schema.String),
60});
61
62const UpdateToolkitPayload = Schema.Struct({
63 name: Schema.optional(Schema.String),
64 slug: Schema.optional(Schema.String),
65});
66
67const CreateToolkitPolicyPayload = Schema.Struct({
68 pattern: Schema.String,
69 action: ToolPolicyActionSchema,
70 position: Schema.optional(Schema.String),
71});
72
73const UpdateToolkitPolicyPayload = Schema.Struct({
74 pattern: Schema.optional(Schema.String),
75 action: Schema.optional(ToolPolicyActionSchema),
76 position: Schema.optional(Schema.String),
77});
78
79const CreateToolkitConnectionPayload = Schema.Struct({
80 pattern: Schema.String,
81 position: Schema.optional(Schema.String),
82});
83
84const ToolkitErrors = [InternalError, ToolkitError] as const;
85
86export 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 @@
1import { defineConfig } from "tsup";
2
3export 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 @@
1import { defineConfig } from "vitest/config";
2
3export 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
172 * applies a user rule to this tool's exact id. */172 * applies a user rule to this tool's exact id. */
173 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;173 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
174 onClearPolicy?: (pattern: string) => void;174 onClearPolicy?: (pattern: string) => void;
175 patternForDisplay?: (displayPattern: string) => string;
175 /** Run-tab wiring. When `integration` + `runToolName` are provided, a third176 /** Run-tab wiring. When `integration` + `runToolName` are provided, a third
176 * "Run" tab hosts the per-connection tool tester. */177 * "Run" tab hosts the per-connection tool tester. */
177 integration?: IntegrationSlug;178 integration?: IntegrationSlug;
@@ -187,6 +188,7 @@ export function ToolDetail(props: { · 9 unmodified lines
187 const canRun = props.integration != null && props.runToolName != null;188 const canRun = props.integration != null && props.runToolName != null;
188 // Don't strand the user on the Run tab when this ToolDetail has no run wiring.189 // Don't strand the user on the Run tab when this ToolDetail has no run wiring.
189 const activeTab = tab === "run" && !canRun ? "schema" : tab;190 const activeTab = tab === "run" && !canRun ? "schema" : tab;
191 const isBlocked = props.policy?.action === "block";
190 192
191 const data = useMemo(() => {193 const data = useMemo(() => {
192 if (!AsyncResult.isSuccess(toolContract)) return null;194 if (!AsyncResult.isSuccess(toolContract)) return null;
@@ -244,6 +246,7 @@ export function ToolDetail(props: { · 51 unmodified lines
244 policy={props.policy}246 policy={props.policy}
245 onSetPolicy={props.onSetPolicy}247 onSetPolicy={props.onSetPolicy}
246 onClearPolicy={props.onClearPolicy}248 onClearPolicy={props.onClearPolicy}
249 patternForDisplay={props.patternForDisplay}
247 />250 />
248 </div>251 </div>
249 {data?.description && <ToolDescription description={data.description} />}252 {data?.description && <ToolDescription description={data.description} />}
@@ -300,48 +303,59 @@ export function ToolDetail(props: { · 50 unmodified lines
300 303
301 {/* Content */}304 {/* Content */}
302 <div className="flex-1 overflow-y-auto">305 <div className="flex-1 overflow-y-auto">
303 {AsyncResult.match(toolContract, {306 {isBlocked ? (
304 onInitial: () => <div className="p-5 text-sm text-muted-foreground">Loading…</div>,307 <div className="px-5 py-5">
305 onFailure: () => <div className="p-5 text-sm text-destructive">Something went wrong</div>,308 <EmptySection
306 onSuccess: () =>309 title="Blocked"
307 activeTab === "run" && props.integration && props.runToolName ? (310 message="This tool is not available through the current toolkit."
308 <div className="px-5 py-5">311 />
309 <ToolRunPanel312 </div>
310 integration={props.integration}313 ) : (
311 toolName={props.runToolName}314 AsyncResult.match(toolContract, {
312 connections={props.connections ?? []}315 onInitial: () => <div className="p-5 text-sm text-muted-foreground">Loading…</div>,
313 initialConnectionName={props.initialConnectionName}316 onFailure: () => (
314 />317 <div className="p-5 text-sm text-destructive">Something went wrong</div>
315 </div>
316 ) : activeTab === "schema" ? (
317 <div className="px-5 py-5 space-y-5">
318 {data?.inputSchema ? (
319 <SchemaExplorer
320 schema={data.inputSchema}
321 schemaDefinitions={data.schemaDefinitions}
322 title="Parameters"
323 />
324 ) : (
325 <EmptySection title="Parameters" message="None" />
326 )}
327 {data?.outputSchema ? (
328 <SchemaExplorer
329 schema={data.outputSchema}
330 schemaDefinitions={data.schemaDefinitions}
331 title="Response"
332 />
333 ) : (
334 <EmptySection title="Response" message="None" />
335 )}
336 </div>
337 ) : (
338 <ToolTypeScriptPanel
339 inputTypeScript={data?.inputTypeScript ?? null}
340 outputTypeScript={data?.outputTypeScript ?? null}
341 definitions={data?.definitions ?? []}
342 />
343 ),318 ),
344 })}319 onSuccess: () =>
320 activeTab === "run" && props.integration && props.runToolName ? (
321 <div className="px-5 py-5">
322 <ToolRunPanel
323 integration={props.integration}
324 toolName={props.runToolName}
325 connections={props.connections ?? []}
326 initialConnectionName={props.initialConnectionName}
327 />
328 </div>
329 ) : activeTab === "schema" ? (
330 <div className="px-5 py-5 space-y-5">
331 {data?.inputSchema ? (
332 <SchemaExplorer
333 schema={data.inputSchema}
334 schemaDefinitions={data.schemaDefinitions}
335 title="Parameters"
336 />
337 ) : (
338 <EmptySection title="Parameters" message="None" />
339 )}
340 {data?.outputSchema ? (
341 <SchemaExplorer
342 schema={data.outputSchema}
343 schemaDefinitions={data.schemaDefinitions}
344 title="Response"
345 />
346 ) : (
347 <EmptySection title="Response" message="None" />
348 )}
349 </div>
350 ) : (
351 <ToolTypeScriptPanel
352 inputTypeScript={data?.inputTypeScript ?? null}
353 outputTypeScript={data?.outputTypeScript ?? null}
354 definitions={data?.definitions ?? []}
355 />
356 ),
357 })
358 )}
345 </div>359 </div>
346 </div>360 </div>
347 );361 );
@@ -400,12 +414,15 @@ function PolicyBadgeMenu(props: { · 52 unmodified lines
400 policy?: EffectivePolicy;414 policy?: EffectivePolicy;
401 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;415 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
402 onClearPolicy?: (pattern: string) => void;416 onClearPolicy?: (pattern: string) => void;
417 patternForDisplay?: (displayPattern: string) => string;
403}) {418}) {
404 const interactive = !!props.onSetPolicy;419 const interactive = !!props.onSetPolicy;
405 // The same pattern bridge the tree rows apply — the pattern WRITTEN and the420 // The same pattern bridge the tree rows apply — the pattern WRITTEN and the
406 // pattern LOOKED UP must be the same string, or the menu authors rules that421 // pattern LOOKED UP must be the same string, or the menu authors rules that
407 // never match and can't see its own rule afterward.422 // never match and can't see its own rule afterward.
408 const pattern = props.staticTool ? props.toolName : toPolicyPattern(props.toolName);423 const pattern = props.staticTool
424 ? props.toolName
425 : (props.patternForDisplay ?? toPolicyPattern)(props.toolName);
409 // The "Clear" affordance only makes sense when there's a user rule426 // The "Clear" affordance only makes sense when there's a user rule
410 // pinned to this exact tool id — clearing a wildcard rule from a427 // pinned to this exact tool id — clearing a wildcard rule from a
411 // single tool's detail header would silently affect siblings.428 // single tool's detail header would silently affect siblings.
packages/react/src/components/tool-tree.tsx+14−4
@@ -283,6 +283,8 @@ export function ToolTree(props: { · 282 unmodified lines
283 * emit the tool's full dotted id; group rows emit `prefix.*`. */283 * emit the tool's full dotted id; group rows emit `prefix.*`. */
284 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;284 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
285 onClearPolicy?: (pattern: string) => void;285 onClearPolicy?: (pattern: string) => void;
286 /** Maps the displayed row path into the persisted policy pattern. */
287 patternForDisplay?: (displayPattern: string) => string;
286 /** Sorted user-authored policies (most-precedent first). Used to288 /** Sorted user-authored policies (most-precedent first). Used to
287 * decide whether a node has its own exact-pattern user rule today289 * decide whether a node has its own exact-pattern user rule today
288 * (so the menu can show a "Clear" option). Optional — when absent,290 * (so the menu can show a "Clear" option). Optional — when absent,
@@ -300,6 +302,7 @@ export function ToolTree(props: { · 11 unmodified lines
300 onSelect,302 onSelect,
301 onSetPolicy,303 onSetPolicy,
302 onClearPolicy,304 onClearPolicy,
305 patternForDisplay = toPolicyPattern,
303 policies,306 policies,
304 groupByConnection,307 groupByConnection,
305 } = props;308 } = props;
@@ -358,6 +361,7 @@ export function ToolTree(props: { · 52 unmodified lines
358 onSelect,361 onSelect,
359 onSetPolicy,362 onSetPolicy,
360 onClearPolicy,363 onClearPolicy,
364 patternForDisplay,
361 exactPatterns,365 exactPatterns,
362 search,366 search,
363 terms,367 terms,
@@ -433,6 +437,7 @@ function ToolTreeBody(props: { · 69 unmodified lines
433 onSelect: (toolId: string) => void;437 onSelect: (toolId: string) => void;
434 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;438 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
435 onClearPolicy?: (pattern: string) => void;439 onClearPolicy?: (pattern: string) => void;
440 patternForDisplay: (displayPattern: string) => string;
436 exactPatterns: ReadonlyMap<string, ToolPolicyAction>;441 exactPatterns: ReadonlyMap<string, ToolPolicyAction>;
437 search: string;442 search: string;
438 terms: readonly string[];443 terms: readonly string[];
@@ -444,6 +449,7 @@ function ToolTreeBody(props: { · 5 unmodified lines
444 onSelect,449 onSelect,
445 onSetPolicy,450 onSetPolicy,
446 onClearPolicy,451 onClearPolicy,
452 patternForDisplay,
447 exactPatterns,453 exactPatterns,
448 search,454 search,
449 terms,455 terms,
@@ -510,7 +516,8 @@ function ToolTreeBody(props: { · 60 unmodified lines
510 search={search}516 search={search}
511 onSetPolicy={onSetPolicy}517 onSetPolicy={onSetPolicy}
512 onClearPolicy={onClearPolicy}518 onClearPolicy={onClearPolicy}
513 exactRule={exactPatterns.get(toPolicyPattern(row.tool.name))}519 patternForDisplay={patternForDisplay}
520 exactRule={exactPatterns.get(patternForDisplay(row.tool.name))}
514 />521 />
515 ) : (522 ) : (
516 <ToolGroupRow523 <ToolGroupRow
@@ -524,7 +531,8 @@ function ToolTreeBody(props: { · 7 unmodified lines
524 search={search}531 search={search}
525 onSetPolicy={onSetPolicy}532 onSetPolicy={onSetPolicy}
526 onClearPolicy={onClearPolicy}533 onClearPolicy={onClearPolicy}
527 exactRule={exactPatterns.get(toPolicyPattern(`${row.path}.*`))}534 patternForDisplay={patternForDisplay}
535 exactRule={exactPatterns.get(patternForDisplay(`${row.path}.*`))}
528 />536 />
529 ),537 ),
530 )}538 )}
@@ -610,6 +618,7 @@ function ToolGroupRow(props: { · 79 unmodified lines
610 search: string;618 search: string;
611 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;619 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
612 onClearPolicy?: (pattern: string) => void;620 onClearPolicy?: (pattern: string) => void;
621 patternForDisplay: (displayPattern: string) => string;
613 exactRule?: ToolPolicyAction;622 exactRule?: ToolPolicyAction;
614}) {623}) {
615 const showActions = !!props.onSetPolicy;624 const showActions = !!props.onSetPolicy;
@@ -647,7 +656,7 @@ function ToolGroupRow(props: { · 31 unmodified lines
647 )}656 )}
648 >657 >
649 <PolicyActionMenu658 <PolicyActionMenu
650 pattern={toPolicyPattern(`${props.path}.*`)}659 pattern={props.patternForDisplay(`${props.path}.*`)}
651 current={props.exactRule}660 current={props.exactRule}
652 onSet={props.onSetPolicy!}661 onSet={props.onSetPolicy!}
653 onClear={props.onClearPolicy}662 onClear={props.onClearPolicy}
@@ -668,6 +677,7 @@ function ToolLeafRow(props: { · 14 unmodified lines
668 search: string;677 search: string;
669 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;678 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
670 onClearPolicy?: (pattern: string) => void;679 onClearPolicy?: (pattern: string) => void;
680 patternForDisplay: (displayPattern: string) => string;
671 exactRule?: ToolPolicyAction;681 exactRule?: ToolPolicyAction;
672}) {682}) {
673 const label = props.tool.name.split(".").pop() ?? props.tool.name;683 const label = props.tool.name.split(".").pop() ?? props.tool.name;
@@ -719,7 +729,7 @@ function ToolLeafRow(props: { · 45 unmodified lines
719 )}729 )}
720 >730 >
721 <PolicyActionMenu731 <PolicyActionMenu
722 pattern={toPolicyPattern(props.tool.name)}732 pattern={props.patternForDisplay(props.tool.name)}
723 current={props.exactRule}733 current={props.exactRule}
724 onSet={props.onSetPolicy!}734 onSet={props.onSetPolicy!}
725 onClear={props.onClearPolicy}735 onClear={props.onClearPolicy}
packages/react/src/multiplayer/shell.tsx+25−2
@@ -21,7 +21,7 @@ import { · 20 unmodified lines
21 integrationPresetIconUrl,21 integrationPresetIconUrl,
22} from "../components/integration-favicon";22} from "../components/integration-favicon";
23import { CommandPalette } from "../components/command-palette";23import { CommandPalette } from "../components/command-palette";
24import { useIntegrationPlugins } from "@executor-js/sdk/client";24import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";
25import { useAuth } from "./auth-context";25import { useAuth } from "./auth-context";
26 26
27// ---------------------------------------------------------------------------27// ---------------------------------------------------------------------------
@@ -63,6 +63,16 @@ export const DEFAULT_DOCS_URL = "https://executor.sh/docs"; · 35 unmodified lines
63// the host's own route tree, which this package can't see.63// the host's own route tree, which this package can't see.
64const orgScopedTo = (to: string): string => (to === "/" ? "/{-$orgSlug}" : `/{-$orgSlug}${to}`);64const orgScopedTo = (to: string): string => (to === "/" ? "/{-$orgSlug}" : `/{-$orgSlug}${to}`);
65 65
66const normalizePluginPagePath = (path: string): string => {
67 if (!path || path === "/") return "/";
68 return path.startsWith("/") ? path : `/${path}`;
69};
70
71const pluginPageNavPath = (pluginId: string, path: string): string => {
72 const normalized = normalizePluginPagePath(path);
73 return normalized === "/" ? `/plugins/${pluginId}/` : `/plugins/${pluginId}${normalized}`;
74};
75
66/** The pathname with the active org-slug prefix stripped, for active-state76/** The pathname with the active org-slug prefix stripped, for active-state
67 * comparisons against scope-relative paths. */77 * comparisons against scope-relative paths. */
68function useScopeRelativePathname(): string {78function useScopeRelativePathname(): string {
@@ -310,7 +320,20 @@ function UserFooter(props: Pick<ShellProps, "onSignOut" | "orgMenuSlot">) { · 241 unmodified lines
310function SidebarContent(320function SidebarContent(
311 props: ShellProps & { pathname: string; onNavigate?: () => void; showBrand?: boolean },321 props: ShellProps & { pathname: string; onNavigate?: () => void; showBrand?: boolean },
312) {322) {
313 const navItems = props.navItems ?? defaultShellNavItems;323 const plugins = useClientPlugins();
324 const pluginNavItems = plugins.flatMap((plugin) =>
325 (plugin.pages ?? []).flatMap((page) =>
326 page.nav
327 ? [
328 {
329 to: pluginPageNavPath(plugin.id, page.path),
330 label: page.nav.label,
331 },
332 ]
333 : [],
334 ),
335 );
336 const navItems = [...(props.navItems ?? defaultShellNavItems), ...pluginNavItems];
314 return (337 return (
315 <>338 <>
316 {props.showBrand !== false && (339 {props.showBrand !== false && (
packages/react/src/routes/plugin-route-match.test.ts+34−0
@@ -0,0 +1,34 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { matchPluginPage, matchPluginPagePath } from "./plugins.$pluginId.$";
4
5describe("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
11// plugin to `executor.config.ts` is sufficient for its pages to mount11// plugin to `executor.config.ts` is sufficient for its pages to mount
12// here, with no per-route imports.12// here, with no per-route imports.
13//13//
14// Match logic is intentionally tiny: exact path equality between the URL14// Plugin pages use the same lightweight route vocabulary as the rest of the
15// remainder and a `PageDecl.path`, with `""` and `/` treated as the15// console route tree: static segments and `$param` segments. The host route
16// same root. Plugins that want parameterized paths can build their own16// owns that matching so plugin detail views can be real URLs instead of
17// in-component routing for now.17// in-component state.
18// ---------------------------------------------------------------------------18// ---------------------------------------------------------------------------
19 19
20export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({20export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({
@@ -23,9 +23,66 @@ export const Route = createFileRoute("/{-$orgSlug}/plugins/$pluginId/$")({ · 2 unmodified lines
23 23
24function normalizePath(input: string): string {24function normalizePath(input: string): string {
25 if (!input || input === "/") return "/";25 if (!input || input === "/") return "/";
26 return input.startsWith("/") ? input : `/${input}`;26 const withLeadingSlash = input.startsWith("/") ? input : `/${input}`;
27 return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/, "") : "/";
27}28}
28 29
30const pathSegments = (input: string): readonly string[] =>
31 normalizePath(input)
32 .split("/")
33 .filter((segment) => segment.length > 0);
34
35export 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
56const matchScore = (pattern: string): number =>
57 pathSegments(pattern).reduce((score, segment) => score + (segment.startsWith("$") ? 1 : 2), 0);
58
59export 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
29function PluginRouteComponent() {86function PluginRouteComponent() {
30 const { pluginId, _splat: rest } = Route.useParams();87 const { pluginId, _splat: rest } = Route.useParams();
31 const plugins = useClientPlugins();88 const plugins = useClientPlugins();
@@ -34,10 +91,10 @@ function PluginRouteComponent() { · 2 unmodified lines
34 if (!plugin) throw notFound();91 if (!plugin) throw notFound();
35 92
36 const target = normalizePath(rest ?? "/");93 const target = normalizePath(rest ?? "/");
37 const page = plugin.pages?.find((p) => normalizePath(p.path) === target);94 const match = matchPluginPage(plugin.pages, target);
38 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound()95 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound()
39 if (!page) throw notFound();96 if (!match) throw notFound();
40 97
41 const Component = page.component;98 const Component = match.page.component;
42 return <Component />;99 return <Component params={match.params} path={target} pluginId={pluginId} />;
43}100}

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