History

feat(toolkits): the toolkits plugin (@executor-js/plugin-toolkits)

A toolkit is a named slice of an account's connections (off/read/full) with its own MCP endpoint, scoped workspace (org) or personal (user). Owner-scoped plugin storage + typed CRUD API; resolveRequestScope maps a toolkit to core's RequestScope (off->block, read->read-only, per-toolkit block/require_approval composed with org policies, stricter-wins); URL-routed console page (list + editor). Pure plugin — no core or host changes here.

Rhys Sullivan committed 5 months ago

Commit 4c01bd61 parent 128e9a615 files · +1798 −0Browse files

Unified · Split

15 files
  1. bun.lock +34 −0
  2. packages/plugins/toolkits/CHANGELOG.md +1 −0
  3. packages/plugins/toolkits/package.json +93 −0
  4. packages/plugins/toolkits/src/react/ToolkitsPage.tsx +814 −0
  5. packages/plugins/toolkits/src/react/atoms.ts +22 −0
  6. packages/plugins/toolkits/src/react/client.ts +21 −0
  7. packages/plugins/toolkits/src/react/form.ts +100 −0
  8. packages/plugins/toolkits/src/react/index.ts +9 −0
  9. packages/plugins/toolkits/src/react/plugin-client.tsx +26 −0
  10. packages/plugins/toolkits/src/server.ts +379 −0
  11. packages/plugins/toolkits/src/shared.ts +137 −0
  12. packages/plugins/toolkits/src/toolkit-scope.ts +116 −0
  13. packages/plugins/toolkits/tsconfig.json +25 −0
  14. packages/plugins/toolkits/tsup.config.ts +13 −0
  15. packages/plugins/toolkits/vitest.config.ts +8 −0
bun.lock+34−0
@@ -999,6 +999,38 @@ · 998 unmodified lines
999 "react",999 "react",
1000 ],1000 ],
1001 },1001 },
1002 "packages/plugins/toolkits": {
1003 "name": "@executor-js/plugin-toolkits",
1004 "version": "1.5.12",
1005 "dependencies": {
1006 "@effect/atom-react": "catalog:",
1007 "@executor-js/api": "workspace:*",
1008 "@executor-js/sdk": "workspace:*",
1009 },
1010 "devDependencies": {
1011 "@effect/vitest": "catalog:",
1012 "@executor-js/react": "workspace:*",
1013 "@types/node": "catalog:",
1014 "@types/react": "catalog:",
1015 "bun-types": "catalog:",
1016 "effect": "catalog:",
1017 "react": "catalog:",
1018 "tsup": "catalog:",
1019 "typescript": "catalog:",
1020 "vitest": "catalog:",
1021 },
1022 "peerDependencies": {
1023 "@effect/atom-react": "catalog:",
1024 "@executor-js/react": "workspace:*",
1025 "effect": "catalog:",
1026 "react": ">=18",
1027 },
1028 "optionalPeers": [
1029 "@effect/atom-react",
1030 "@executor-js/react",
1031 "react",
1032 ],
1033 },
1002 "packages/plugins/workos-vault": {1034 "packages/plugins/workos-vault": {
1003 "name": "@executor-js/plugin-workos-vault",1035 "name": "@executor-js/plugin-workos-vault",
1004 "version": "0.0.2",1036 "version": "0.0.2",
@@ -1589,6 +1621,8 @@ · 584 unmodified lines
1589 1621
1590 "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"],1622 "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"],
1591 1623
1624 "@executor-js/plugin-toolkits": ["@executor-js/plugin-toolkits@workspace:packages/plugins/toolkits"],
1625
1592 "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"],1626 "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"],
1593 1627
1594 "@executor-js/react": ["@executor-js/react@workspace:packages/react"],1628 "@executor-js/react": ["@executor-js/react@workspace:packages/react"],
packages/plugins/toolkits/CHANGELOG.md+1−0
@@ -0,0 +1 @@
1# @executor-js/plugin-toolkits
packages/plugins/toolkits/package.json+93−0
@@ -0,0 +1,93 @@
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 "./shared": "./src/shared.ts",
21 "./react": "./src/react/index.ts",
22 "./client": "./src/react/plugin-client.tsx"
23 },
24 "publishConfig": {
25 "access": "public",
26 "exports": {
27 "./server": {
28 "import": {
29 "types": "./dist/server.d.ts",
30 "default": "./dist/server.js"
31 }
32 },
33 "./shared": {
34 "import": {
35 "types": "./dist/shared.d.ts",
36 "default": "./dist/shared.js"
37 }
38 },
39 "./react": {
40 "import": {
41 "types": "./dist/react/index.d.ts",
42 "default": "./dist/react/index.js"
43 }
44 },
45 "./client": {
46 "import": {
47 "types": "./dist/react/plugin-client.d.ts",
48 "default": "./dist/react/plugin-client.js"
49 }
50 }
51 }
52 },
53 "scripts": {
54 "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)",
55 "typecheck": "tsgo --noEmit",
56 "test": "vitest run",
57 "typecheck:slow": "tsc --noEmit"
58 },
59 "dependencies": {
60 "@effect/atom-react": "catalog:",
61 "@executor-js/api": "workspace:*",
62 "@executor-js/sdk": "workspace:*"
63 },
64 "devDependencies": {
65 "@effect/vitest": "catalog:",
66 "@executor-js/react": "workspace:*",
67 "@types/node": "catalog:",
68 "@types/react": "catalog:",
69 "bun-types": "catalog:",
70 "effect": "catalog:",
71 "react": "catalog:",
72 "tsup": "catalog:",
73 "typescript": "catalog:",
74 "vitest": "catalog:"
75 },
76 "peerDependencies": {
77 "@effect/atom-react": "catalog:",
78 "@executor-js/react": "workspace:*",
79 "effect": "catalog:",
80 "react": ">=18"
81 },
82 "peerDependenciesMeta": {
83 "@effect/atom-react": {
84 "optional": true
85 },
86 "@executor-js/react": {
87 "optional": true
88 },
89 "react": {
90 "optional": true
91 }
92 }
93}
packages/plugins/toolkits/src/react/ToolkitsPage.tsx+814−0
@@ -0,0 +1,814 @@
1// ---------------------------------------------------------------------------
2// @executor-js/plugin-toolkits/react — the Toolkits console page.
3//
4// A toolkit is a named slice of the caller's connections (each off/read/full)
5// with its own MCP endpoint; an agent connected to one can't see anything
6// outside it. List and editor views are URL-routed via `usePluginRoute` —
7// `/plugins/toolkits/`, `/plugins/toolkits/<id>`, `/plugins/toolkits/new/<scope>`.
8// The `new/` prefix is reserved for the create flow; toolkit ids are server UUIDs.
9//
10// Workspace toolkits draw on org-owned connections; personal toolkits can use
11// org + the caller's own. The list query returns every visible toolkit and we
12// split by `scope`.
13// ---------------------------------------------------------------------------
14
15import { useEffect, useMemo, useState } from "react";
16import { useAtomSet, useAtomValue } from "@effect/atom-react";
17import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
18import * as Exit from "effect/Exit";
19import { usePluginNavigate, usePluginRoute } from "@executor-js/sdk/client";
20
21import { connectionsAllAtom, integrationsAtom } from "@executor-js/react/api/atoms";
22import { Button } from "@executor-js/react/components/button";
23import { Input } from "@executor-js/react/components/input";
24import { Textarea } from "@executor-js/react/components/textarea";
25import { Switch } from "@executor-js/react/components/switch";
26import { Label } from "@executor-js/react/components/label";
27import { Badge } from "@executor-js/react/components/badge";
28import {
29 Select,
30 SelectContent,
31 SelectItem,
32 SelectTrigger,
33 SelectValue,
34} from "@executor-js/react/components/select";
35import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
36import { copyToClipboard } from "@executor-js/react/lib/clipboard";
37import { cn } from "@executor-js/react/lib/utils";
38
39import type {
40 ToolkitAccess,
41 ToolkitPolicy,
42 ToolkitPolicyAction,
43 ToolkitScope,
44 ToolkitView,
45} from "../shared";
46import {
47 toolkitsAtom,
48 createToolkit,
49 updateToolkit,
50 removeToolkit,
51 toolkitWriteKeys,
52} from "./atoms";
53import {
54 type FormConn as Conn,
55 accessFromToolkit,
56 connKey,
57 entriesFromAccess,
58 slugify,
59 tiersForScope,
60 uniqueSlug,
61} from "./form";
62
63// ---------------------------------------------------------------------------
64// Local shapes — structural views of the core connection/integration rows we
65// read, so this file doesn't pin a specific API success-type name.
66// ---------------------------------------------------------------------------
67
68interface Integ {
69 readonly slug: string;
70 readonly name: string;
71}
72
73const ACCESS_OPTIONS: ReadonlyArray<{ value: ToolkitAccess; label: string }> = [
74 { value: "off", label: "Off" },
75 { value: "read", label: "Read" },
76 { value: "full", label: "Full" },
77];
78
79const ACTION_LABEL: Record<ToolkitPolicyAction, string> = {
80 approve: "Auto-approve",
81 require_approval: "Needs approval",
82 block: "Block",
83};
84
85// ---------------------------------------------------------------------------
86// Access segmented control (off / read / full)
87// ---------------------------------------------------------------------------
88
89function AccessSeg(props: { value: ToolkitAccess; onChange: (v: ToolkitAccess) => void }) {
90 return (
91 <div className="inline-flex shrink-0 gap-0.5 rounded-md border border-input p-0.5">
92 {ACCESS_OPTIONS.map((o) => (
93 <Button
94 key={o.value}
95 type="button"
96 size="sm"
97 variant={props.value === o.value ? "secondary" : "ghost"}
98 className="h-6 px-2.5 text-[11px] font-medium"
99 onClick={() => props.onChange(o.value)}
100 >
101 {o.label}
102 </Button>
103 ))}
104 </div>
105 );
106}
107
108// ---------------------------------------------------------------------------
109// List
110// ---------------------------------------------------------------------------
111
112function ToolkitCard(props: {
113 toolkit: ToolkitView;
114 integrationName: (slug: string) => string;
115 onOpen: () => void;
116}) {
117 const active = props.toolkit.connections.filter((c) => c.access !== "off");
118 const integrations = [...new Set(active.map((c) => c.integration))];
119 return (
120 // oxlint-disable-next-line react/forbid-elements -- a card-sized clickable surface; a real <button> is the correct accessible primitive
121 <button
122 type="button"
123 onClick={props.onOpen}
124 className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4 text-left transition-colors hover:border-ring"
125 >
126 <div className="flex items-center justify-between gap-2">
127 <span className="text-[15px] font-semibold text-foreground">{props.toolkit.name}</span>
128 <span className="text-[12px] text-muted-foreground">
129 {active.length} {active.length === 1 ? "connection" : "connections"}
130 </span>
131 </div>
132 <div className="flex flex-wrap gap-1.5">
133 {integrations.length === 0 ? (
134 <span className="text-[12px] text-muted-foreground">Empty — nothing granted yet</span>
135 ) : (
136 integrations.map((slug) => (
137 <span
138 key={slug}
139 className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-0.5 text-[12px]"
140 >
141 <IntegrationFavicon sourceId={slug} size={14} />
142 {props.integrationName(slug)}
143 </span>
144 ))
145 )}
146 </div>
147 <span className="font-mono text-[10.5px] text-muted-foreground">
148 /mcp?toolkit={props.toolkit.slug}
149 </span>
150 </button>
151 );
152}
153
154function ListSection(props: {
155 title: string;
156 empty: string;
157 toolkits: ReadonlyArray<ToolkitView>;
158 integrationName: (slug: string) => string;
159 onOpen: (id: string) => void;
160 onCreate: () => void;
161}) {
162 return (
163 <section className="mt-10">
164 <div className="mb-3 flex items-center justify-between border-b border-border/50 pb-2">
165 <h2 className="font-display text-xl tracking-tight text-foreground">{props.title}</h2>
166 <Button type="button" size="sm" onClick={props.onCreate}>
167 New toolkit
168 </Button>
169 </div>
170 {props.toolkits.length === 0 ? (
171 <div className="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
172 {props.empty}
173 </div>
174 ) : (
175 <div className="grid gap-3 sm:grid-cols-2">
176 {props.toolkits.map((tk) => (
177 <ToolkitCard
178 key={tk.id}
179 toolkit={tk}
180 integrationName={props.integrationName}
181 onOpen={() => props.onOpen(tk.id)}
182 />
183 ))}
184 </div>
185 )}
186 </section>
187 );
188}
189
190// ---------------------------------------------------------------------------
191// Editor
192// ---------------------------------------------------------------------------
193
194interface EditorProps {
195 readonly existing: ToolkitView | null;
196 readonly scope: ToolkitScope;
197 readonly takenSlugs: ReadonlySet<string>;
198 readonly connections: ReadonlyArray<Conn>;
199 readonly integrationName: (slug: string) => string;
200 readonly onBack: () => void;
201 readonly onCreated: () => void;
202 readonly onRemoved: () => void;
203}
204
205function ToolkitEditor(props: EditorProps) {
206 const { existing, scope } = props;
207
208 const [name, setName] = useState(existing?.name ?? "Untitled");
209 const [briefing, setBriefing] = useState(existing?.briefing ?? "");
210 const [inheritOrg, setInheritOrg] = useState(existing?.inheritOrgPolicies ?? true);
211 const [access, setAccess] = useState<Record<string, ToolkitAccess>>(
212 () => accessFromToolkit(existing).access,
213 );
214 const [notes, setNotes] = useState<Record<string, string>>(
215 () => accessFromToolkit(existing).notes,
216 );
217 const [policies, setPolicies] = useState<ReadonlyArray<ToolkitPolicy>>(
218 () => existing?.policies.map((p) => ({ ...p })) ?? [],
219 );
220
221 const [dirty, setDirty] = useState(false);
222 const [saving, setSaving] = useState(false);
223 const [error, setError] = useState<string | null>(null);
224 const [copied, setCopied] = useState(false);
225 const [origin, setOrigin] = useState("");
226 useEffect(() => {
227 if (typeof window !== "undefined") setOrigin(window.location.origin);
228 }, []);
229
230 const doCreate = useAtomSet(createToolkit, { mode: "promiseExit" });
231 const doUpdate = useAtomSet(updateToolkit, { mode: "promiseExit" });
232 const doRemove = useAtomSet(removeToolkit, { mode: "promiseExit" });
233
234 const mark = () => {
235 setDirty(true);
236 setError(null);
237 };
238 const setMode = (key: string, v: ToolkitAccess) => {
239 setAccess((m) => ({ ...m, [key]: v }));
240 mark();
241 };
242 const setNote = (key: string, v: string) => {
243 setNotes((m) => ({ ...m, [key]: v }));
244 mark();
245 };
246 const setPolicy = (i: number, patch: Partial<ToolkitPolicy>) => {
247 setPolicies((ps) => ps.map((p, j) => (j === i ? { ...p, ...patch } : p)));
248 mark();
249 };
250
251 // The connections this toolkit may draw on: workspace = org-owned only;
252 // personal = org + the caller's own.
253 const tiers = useMemo(() => tiersForScope(props.connections, scope), [props.connections, scope]);
254
255 const buildEntries = () => entriesFromAccess(props.connections, scope, access, notes);
256
257 const cleanPolicies = (): ReadonlyArray<ToolkitPolicy> =>
258 policies.filter((p) => p.pattern.trim()).map((p) => ({ ...p, pattern: p.pattern.trim() }));
259
260 const handleSave = async () => {
261 setSaving(true);
262 setError(null);
263 const connections = buildEntries();
264 const cleaned = cleanPolicies();
265 if (existing) {
266 const exit = await doUpdate({
267 params: { id: existing.id },
268 payload: {
269 name: name.trim() || "Untitled",
270 briefing: briefing.trim() ? briefing.trim() : null,
271 inheritOrgPolicies: inheritOrg,
272 connections,
273 policies: cleaned,
274 },
275 reactivityKeys: toolkitWriteKeys,
276 });
277 setSaving(false);
278 if (Exit.isFailure(exit)) {
279 setError("Couldn't save changes. Try again.");
280 return;
281 }
282 setDirty(false);
283 return;
284 }
285 const exit = await doCreate({
286 payload: {
287 slug: uniqueSlug(slugify(name) || "toolkit", props.takenSlugs),
288 name: name.trim() || "Untitled",
289 scope,
290 inheritOrgPolicies: inheritOrg,
291 briefing: briefing.trim() ? briefing.trim() : undefined,
292 connections,
293 policies: cleaned,
294 },
295 reactivityKeys: toolkitWriteKeys,
296 });
297 setSaving(false);
298 if (Exit.isFailure(exit)) {
299 setError("Couldn't create the toolkit. Try again.");
300 return;
301 }
302 props.onCreated();
303 };
304
305 const handleDelete = async () => {
306 if (!existing) {
307 props.onBack();
308 return;
309 }
310 // oxlint-disable-next-line no-alert -- intentional destructive confirmation
311 if (!window.confirm("Delete this toolkit? Connected agents lose access immediately.")) return;
312 const exit = await doRemove({
313 params: { id: existing.id },
314 reactivityKeys: toolkitWriteKeys,
315 });
316 if (Exit.isSuccess(exit)) props.onRemoved();
317 else setError("Couldn't delete the toolkit. Try again.");
318 };
319
320 const slug = existing?.slug ?? (slugify(name) || "toolkit");
321 const endpoint = `${origin}/mcp?toolkit=${encodeURIComponent(slug)}`;
322
323 // Live "what the agent sees" — derived from the in-progress form.
324 const previewGroups = useMemo(() => {
325 const byInt = new Map<string, Array<{ name: string; access: ToolkitAccess; note?: string }>>();
326 for (const tier of tiers) {
327 for (const c of tier.conns) {
328 const key = connKey(c.integration, c.name);
329 const a = access[key] ?? "off";
330 if (a === "off") continue;
331 const list = byInt.get(c.integration) ?? [];
332 list.push({
333 name: c.name,
334 access: a,
335 note: notes[key]?.trim() || undefined,
336 });
337 byInt.set(c.integration, list);
338 }
339 }
340 return [...byInt.entries()];
341 }, [tiers, access, notes]);
342 const previewCount = previewGroups.reduce((n, [, conns]) => n + conns.length, 0);
343
344 return (
345 <div className="min-h-0 flex-1 overflow-y-auto">
346 <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14">
347 {/* oxlint-disable-next-line react/forbid-elements -- a subtle text breadcrumb; intentionally not the styled design-system Button */}
348 <button
349 type="button"
350 onClick={props.onBack}
351 className="mb-4 text-[12.5px] font-medium text-muted-foreground hover:text-foreground"
352 >
353 ← Toolkits
354 </button>
355
356 <div className="flex items-start justify-between gap-4">
357 <div className="min-w-0">
358 <Input
359 value={name}
360 onChange={(e) => {
361 setName((e.target as HTMLInputElement).value);
362 mark();
363 }}
364 className="h-auto w-full max-w-[420px] border-transparent bg-transparent px-1 font-display text-3xl tracking-tight text-foreground shadow-none focus-visible:border-input"
365 />
366 <p className="mt-1 px-1 text-[13px] text-muted-foreground">
367 {scope === "workspace"
368 ? "Workspace toolkit — shared with your org, draws on workspace connections."
369 : "Personal toolkit — only you, can use workspace and your own connections."}
370 </p>
371 </div>
372 <Button
373 type="button"
374 size="sm"
375 variant="ghost"
376 className="shrink-0 text-destructive/80 hover:text-destructive"
377 onClick={handleDelete}
378 >
379 Delete
380 </Button>
381 </div>
382
383 <Textarea
384 value={briefing}
385 onChange={(e) => {
386 setBriefing((e.target as HTMLTextAreaElement).value);
387 mark();
388 }}
389 placeholder="What is this toolkit for? This leads the agent's briefing."
390 className="mt-1 min-h-[2.25rem] resize-none border-transparent bg-transparent px-0 text-sm text-muted-foreground shadow-none focus-visible:border-input focus-visible:bg-card focus-visible:px-3"
391 />
392
393 <div className="mt-6 grid grid-cols-1 items-start gap-7 lg:grid-cols-[minmax(0,1fr)_340px]">
394 {/* Main column */}
395 <div>
396 {/* Access */}
397 <h3 className="mb-2.5 text-[13.5px] font-semibold text-foreground">Access</h3>
398 <div className="overflow-hidden rounded-xl border border-border bg-card">
399 {tiers.map((tier, ti) => (
400 <div key={tier.label} className={cn(ti > 0 && "border-t border-border")}>
401 <div className="flex items-center justify-between px-4 pb-1 pt-3">
402 <span className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
403 {tier.label}
404 </span>
405 <span className="text-[11px] text-muted-foreground">
406 {tier.conns.length} available
407 </span>
408 </div>
409 {tier.conns.length === 0 ? (
410 <p className="px-4 pb-3 text-[12px] text-muted-foreground">
411 No connections in this tier yet.
412 </p>
413 ) : (
414 groupByIntegration(tier.conns).map(([intSlug, conns]) => (
415 <div key={intSlug} className="px-4 pb-2 pt-1.5">
416 <div className="flex items-center gap-2 pb-1">
417 <IntegrationFavicon sourceId={intSlug} size={16} />
418 <span className="text-[12.5px] font-semibold text-foreground">
419 {props.integrationName(intSlug)}
420 </span>
421 {conns.length > 1 && (
422 <span className="ml-auto text-[11px] text-muted-foreground">
423 {conns.length} accounts
424 </span>
425 )}
426 </div>
427 {conns.map((c) => {
428 const key = connKey(c.integration, c.name);
429 const a = access[key] ?? "off";
430 return (
431 <div key={key} data-testid={`tk-conn ${key}`} className="pl-7">
432 <div className="flex items-center gap-3 py-1">
433 <div className="min-w-0 flex-1">
434 <div className="truncate text-[13px] font-semibold text-foreground">
435 {c.name}
436 </div>
437 {(c.identityLabel || c.description) && (
438 <div className="truncate text-[11.5px] text-muted-foreground">
439 {c.identityLabel || c.description}
440 </div>
441 )}
442 </div>
443 <AccessSeg value={a} onChange={(v) => setMode(key, v)} />
444 </div>
445 {a !== "off" && (
446 <Input
447 value={notes[key] ?? ""}
448 onChange={(e) =>
449 setNote(key, (e.target as HTMLInputElement).value)
450 }
451 placeholder={`Note to the agent about ${c.name} (optional)`}
452 className="mb-1.5 h-7 border-transparent bg-transparent px-0 text-[12px] italic shadow-none focus-visible:border-input"
453 />
454 )}
455 </div>
456 );
457 })}
458 </div>
459 ))
460 )}
461 </div>
462 ))}
463 </div>
464 <p className="mt-2.5 text-[12px] text-muted-foreground">
465 New tools and new connections are included automatically, by mode.
466 </p>
467
468 {/* Approvals & policies */}
469 <div className="mb-2.5 mt-6 flex items-center justify-between">
470 <h3 className="text-[13.5px] font-semibold text-foreground">
471 Approvals &amp; policies
472 </h3>
473 <Label className="flex cursor-pointer items-center gap-2">
474 <Switch
475 checked={inheritOrg}
476 onCheckedChange={(v) => {
477 setInheritOrg(v);
478 mark();
479 }}
480 />
481 <span className="text-[12px] font-medium text-muted-foreground">
482 Inherit org policies
483 </span>
484 </Label>
485 </div>
486 <div className="overflow-hidden rounded-xl border border-border bg-card">
487 <div className="px-4 py-3">
488 <p className="text-[12px] text-muted-foreground">
489 {inheritOrg
490 ? "Your workspace's policies apply on top of this toolkit. Where an org rule and a toolkit rule overlap, the stricter one wins."
491 : "Workspace guardrails are off — this toolkit is governed only by its own rules below."}
492 </p>
493 </div>
494 <div className="border-t border-border px-4 py-2.5">
495 <div className="flex items-center gap-2 pb-1">
496 <span className="text-[12.5px] font-semibold text-foreground">Toolkit rules</span>
497 <span className="ml-auto text-[11px] text-muted-foreground">
498 {policies.length} {policies.length === 1 ? "rule" : "rules"}
499 </span>
500 </div>
501 {policies.map((p, i) => (
502 <div key={i} className="flex flex-wrap items-center gap-2 py-1.5">
503 <Input
504 value={p.pattern}
505 onChange={(e) =>
506 setPolicy(i, {
507 pattern: (e.target as HTMLInputElement).value,
508 })
509 }
510 placeholder="slack.*.post_message"
511 className="h-8 min-w-[190px] flex-1 font-mono text-[12px]"
512 />
513 <Select
514 value={p.action}
515 onValueChange={(v) => setPolicy(i, { action: v as ToolkitPolicyAction })}
516 >
517 <SelectTrigger className="h-8 w-[150px] text-[12px]">
518 <SelectValue />
519 </SelectTrigger>
520 <SelectContent>
521 <SelectItem value="approve">{ACTION_LABEL.approve}</SelectItem>
522 <SelectItem value="require_approval">
523 {ACTION_LABEL.require_approval}
524 </SelectItem>
525 <SelectItem value="block">{ACTION_LABEL.block}</SelectItem>
526 </SelectContent>
527 </Select>
528 <Button
529 type="button"
530 size="sm"
531 variant="ghost"
532 className="h-8 px-2 text-destructive/70 hover:text-destructive"
533 onClick={() => {
534 setPolicies((ps) => ps.filter((_, j) => j !== i));
535 mark();
536 }}
537 >
538 ✕
539 </Button>
540 </div>
541 ))}
542 <div className="flex items-center gap-3 pt-1">
543 <Button
544 type="button"
545 size="sm"
546 variant="ghost"
547 onClick={() => {
548 setPolicies((ps) => [...ps, { pattern: "", action: "approve" }]);
549 mark();
550 }}
551 >
552 + Add rule
553 </Button>
554 <span className="text-[11px] text-muted-foreground">
555 Block hides matching tools entirely · approve / needs-approval set gating.
556 </span>
557 </div>
558 </div>
559 </div>
560
561 {/* Connect */}
562 <h3 className="mb-2.5 mt-6 text-[13.5px] font-semibold text-foreground">Connect</h3>
563 <div className="rounded-xl border border-border bg-card p-4">
564 <div className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
565 MCP endpoint
566 </div>
567 {/* oxlint-disable-next-line react/forbid-elements -- a full-width copy affordance; the design-system Button doesn't fit this row layout */}
568 <button
569 type="button"
570 onClick={async () => {
571 if (await copyToClipboard(endpoint)) {
572 setCopied(true);
573 setTimeout(() => setCopied(false), 1100);
574 }
575 }}
576 className="mt-2 flex w-full items-center justify-between gap-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-left"
577 >
578 <span className="truncate font-mono text-[12px] text-foreground">{endpoint}</span>
579 <span className="shrink-0 text-[11px] text-muted-foreground">
580 {copied ? "copied" : "copy"}
581 </span>
582 </button>
583 <p className="mt-2.5 text-[11.5px] text-muted-foreground">
584 Point an agent at this URL with any of your account's API keys — it sees only this
585 toolkit's slice, never the management API.
586 </p>
587 </div>
588
589 {/* Save bar */}
590 <div className="mt-6 flex items-center gap-3">
591 <Button
592 type="button"
593 onClick={handleSave}
594 disabled={saving || (!!existing && !dirty)}
595 >
596 {saving ? "Saving…" : existing ? "Save changes" : "Create toolkit"}
597 </Button>
598 {error ? (
599 <span className="text-[12px] text-destructive">{error}</span>
600 ) : existing && !dirty ? (
601 <span className="text-[12px] text-muted-foreground">All changes saved</span>
602 ) : null}
603 </div>
604 </div>
605
606 {/* Preview rail */}
607 <aside className="lg:sticky lg:top-6">
608 <div className="rounded-xl border border-border bg-card p-4">
609 <div className="flex items-center justify-between">
610 <span className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
611 What the agent sees
612 </span>
613 <span className="text-[12px] text-muted-foreground">
614 {previewCount} {previewCount === 1 ? "connection" : "connections"}
615 </span>
616 </div>
617 {briefing.trim() && (
618 <p className="mt-3 whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">
619 {briefing.trim()}
620 </p>
621 )}
622 {previewGroups.length === 0 ? (
623 <p className="mt-3 text-[12px] text-muted-foreground">
624 Nothing granted yet — set a connection to Read or Full to give the agent something
625 to work with.
626 </p>
627 ) : (
628 <div className="mt-3 flex flex-col gap-3">
629 {previewGroups.map(([intSlug, conns]) => (
630 <div key={intSlug}>
631 <div className="flex items-center gap-2">
632 <IntegrationFavicon sourceId={intSlug} size={15} />
633 <span className="text-[12.5px] font-semibold text-foreground">
634 {props.integrationName(intSlug)}
635 </span>
636 </div>
637 {conns.map((c) => (
638 <div key={c.name} className="mt-1 pl-6">
639 <div className="flex items-center gap-2">
640 <span className="text-[12px] text-foreground">{c.name}</span>
641 <Badge
642 variant={c.access === "full" ? "default" : "secondary"}
643 className="px-1.5 py-0 text-[10px]"
644 >
645 {c.access === "full" ? "full" : "read-only"}
646 </Badge>
647 </div>
648 {c.note && (
649 <p className="text-[11px] italic text-muted-foreground">{c.note}</p>
650 )}
651 </div>
652 ))}
653 </div>
654 ))}
655 </div>
656 )}
657 </div>
658 </aside>
659 </div>
660 </div>
661 </div>
662 );
663}
664
665function groupByIntegration(
666 conns: ReadonlyArray<Conn>,
667): ReadonlyArray<readonly [string, ReadonlyArray<Conn>]> {
668 const byInt = new Map<string, Conn[]>();
669 for (const c of conns) {
670 const list = byInt.get(c.integration) ?? [];
671 list.push(c);
672 byInt.set(c.integration, list);
673 }
674 return [...byInt.entries()];
675}
676
677// ---------------------------------------------------------------------------
678// Page — list <-> editor switch
679// ---------------------------------------------------------------------------
680
681type View =
682 | { readonly kind: "list" }
683 | { readonly kind: "edit"; readonly id: string }
684 | { readonly kind: "new"; readonly scope: ToolkitScope };
685
686function viewFromSubpath(subpath: string): View {
687 const normalized = subpath === "/" ? "" : subpath;
688 if (normalized === "") return { kind: "list" };
689 if (normalized === "new/workspace") return { kind: "new", scope: "workspace" };
690 if (normalized === "new/personal") return { kind: "new", scope: "personal" };
691 return { kind: "edit", id: normalized };
692}
693
694export function ToolkitsPage() {
695 const { subpath } = usePluginRoute();
696 const navigate = usePluginNavigate();
697 const view = viewFromSubpath(subpath);
698 const toolkitsResult = useAtomValue(toolkitsAtom);
699 const connectionsResult = useAtomValue(connectionsAllAtom);
700 const integrationsResult = useAtomValue(integrationsAtom);
701
702 const toolkits = AsyncResult.match(
703 toolkitsResult as AsyncResult.AsyncResult<ReadonlyArray<ToolkitView>, unknown>,
704 {
705 onInitial: () => null,
706 onFailure: () => [] as ReadonlyArray<ToolkitView>,
707 onSuccess: ({ value }) => value,
708 },
709 );
710 const connections = AsyncResult.match(
711 connectionsResult as AsyncResult.AsyncResult<ReadonlyArray<Conn>, unknown>,
712 {
713 onInitial: () => [] as ReadonlyArray<Conn>,
714 onFailure: () => [] as ReadonlyArray<Conn>,
715 onSuccess: ({ value }) => value,
716 },
717 );
718 const integrations = AsyncResult.match(
719 integrationsResult as AsyncResult.AsyncResult<ReadonlyArray<Integ>, unknown>,
720 {
721 onInitial: () => [] as ReadonlyArray<Integ>,
722 onFailure: () => [] as ReadonlyArray<Integ>,
723 onSuccess: ({ value }) => value,
724 },
725 );
726
727 const integrationName = useMemo(() => {
728 const bySlug = new Map(integrations.map((i) => [i.slug, i.name]));
729 return (slug: string) => bySlug.get(slug) ?? slug;
730 }, [integrations]);
731
732 const takenSlugs = useMemo(() => new Set((toolkits ?? []).map((t) => t.slug)), [toolkits]);
733
734 if (toolkits === null) {
735 return <div className="px-1 py-8 text-[13px] text-muted-foreground">Loading toolkits…</div>;
736 }
737
738 if (view.kind === "edit" || view.kind === "new") {
739 const existing = view.kind === "edit" ? (toolkits.find((t) => t.id === view.id) ?? null) : null;
740 // The toolkit was deleted out from under us (or never resolved) — fall back.
741 if (view.kind === "edit" && !existing) {
742 return <ToolkitEditorFallback onBack={() => navigate("")} />;
743 }
744 const scope = view.kind === "new" ? view.scope : (existing as ToolkitView).scope;
745 return (
746 <ToolkitEditor
747 key={view.kind === "edit" ? view.id : `new-${view.scope}`}
748 existing={existing}
749 scope={scope}
750 takenSlugs={takenSlugs}
751 connections={connections}
752 integrationName={integrationName}
753 onBack={() => navigate("")}
754 onCreated={() => navigate("")}
755 onRemoved={() => navigate("")}
756 />
757 );
758 }
759
760 const workspace = toolkits.filter((t) => t.scope === "workspace");
761 const personal = toolkits.filter((t) => t.scope === "personal");
762
763 return (
764 <div className="min-h-0 flex-1 overflow-y-auto">
765 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
766 <header className="mb-2">
767 <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">
768 Toolkits
769 </h1>
770 <p className="mt-1.5 max-w-[640px] text-sm text-muted-foreground">
771 A toolkit is a slice of your connections with its own MCP endpoint. An agent connected
772 to one can't see anything outside it.
773 </p>
774 </header>
775 <ListSection
776 title="Workspace toolkits"
777 empty="No workspace toolkits yet — these draw on shared workspace connections."
778 toolkits={workspace}
779 integrationName={integrationName}
780 onOpen={(id) => navigate(id)}
781 onCreate={() => navigate("new/workspace")}
782 />
783 <ListSection
784 title="Personal toolkits"
785 empty="No personal toolkits yet — these can use workspace and personal connections."
786 toolkits={personal}
787 integrationName={integrationName}
788 onOpen={(id) => navigate(id)}
789 onCreate={() => navigate("new/personal")}
790 />
791 </div>
792 </div>
793 );
794}
795
796function ToolkitEditorFallback(props: { onBack: () => void }) {
797 return (
798 <div className="min-h-0 flex-1 overflow-y-auto">
799 <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14">
800 {/* oxlint-disable-next-line react/forbid-elements -- a subtle text breadcrumb; intentionally not the styled design-system Button */}
801 <button
802 type="button"
803 onClick={props.onBack}
804 className="mb-4 text-[12.5px] font-medium text-muted-foreground hover:text-foreground"
805 >
806 ← Toolkits
807 </button>
808 <p className="text-sm text-muted-foreground">This toolkit no longer exists.</p>
809 </div>
810 </div>
811 );
812}
813
814export default ToolkitsPage;
packages/plugins/toolkits/src/react/atoms.ts+22−0
@@ -0,0 +1,22 @@
1// ---------------------------------------------------------------------------
2// Query + mutation atoms for the toolkits console page.
3//
4// The `list` query returns every toolkit the caller can see (workspace +
5// personal); the page splits by `scope`. Mutations pass `toolkitWriteKeys` at
6// the call site so the list refreshes after a create / update / remove.
7// ---------------------------------------------------------------------------
8
9import { ReactivityKey, toolkitWriteKeys } from "@executor-js/react/api/reactivity-keys";
10
11import { ToolkitsClient } from "./client";
12
13export { toolkitWriteKeys };
14
15export const toolkitsAtom = ToolkitsClient.query("toolkits", "list", {
16 timeToLive: "15 seconds",
17 reactivityKeys: [ReactivityKey.toolkits],
18});
19
20export const createToolkit = ToolkitsClient.mutation("toolkits", "create");
21export const updateToolkit = ToolkitsClient.mutation("toolkits", "update");
22export const removeToolkit = ToolkitsClient.mutation("toolkits", "remove");
packages/plugins/toolkits/src/react/client.ts+21−0
@@ -0,0 +1,21 @@
1// ---------------------------------------------------------------------------
2// @executor-js/plugin-toolkits/react — the typed reactive client
3//
4// `createPluginAtomClient` builds an AtomHttpApi service keyed to the toolkits
5// group, reusing the host's active Executor Server Connection for the base URL
6// and (self-host) authorization header — the same wiring every first-party
7// plugin client uses.
8// ---------------------------------------------------------------------------
9
10import { createPluginAtomClient } from "@executor-js/sdk/client";
11import {
12 getExecutorApiBaseUrl,
13 getExecutorServerAuthorizationHeader,
14} from "@executor-js/react/api/server-connection";
15
16import { ToolkitsApi } from "../shared";
17
18export const ToolkitsClient = createPluginAtomClient(ToolkitsApi, {
19 baseUrl: getExecutorApiBaseUrl,
20 authorizationHeader: getExecutorServerAuthorizationHeader,
21});
packages/plugins/toolkits/src/react/form.ts+100−0
@@ -0,0 +1,100 @@
1// ---------------------------------------------------------------------------
2// Pure form <-> wire transforms for the toolkit editor. No React / DOM here so
3// the bug-prone bits (the access-map ⇄ ToolkitConnectionEntry round-trip, scope
4// tiering, slug derivation) are unit-testable in isolation.
5// ---------------------------------------------------------------------------
6
7import { IntegrationSlug } from "@executor-js/sdk/shared";
8
9import type { ToolkitAccess, ToolkitConnectionEntry, ToolkitScope, ToolkitView } from "../shared";
10
11/** A structural view of a core connection row — the fields the editor reads. */
12export interface FormConn {
13 readonly owner: "org" | "user";
14 readonly name: string;
15 readonly integration: string;
16 readonly identityLabel?: string | null;
17 readonly description?: string | null;
18}
19
20export interface AccessTier {
21 readonly label: string;
22 readonly conns: ReadonlyArray<FormConn>;
23}
24
25/** Stable key for the per-connection access/notes maps. A space can't appear in
26 * an integration slug, so `"<integration> <connection>"` is unambiguous. */
27export const connKey = (integration: string, connection: string): string =>
28 `${integration} ${connection}`;
29
30export const slugify = (s: string): string =>
31 s
32 .toLowerCase()
33 .trim()
34 .replace(/[^a-z0-9]+/g, "-")
35 .replace(/^-+|-+$/g, "")
36 .slice(0, 48);
37
38/** A slug not already in `taken`; appends `-2`, `-3`, … on collision. */
39export const uniqueSlug = (base: string, taken: ReadonlySet<string>): string => {
40 const root = base || "toolkit";
41 if (!taken.has(root)) return root;
42 let i = 2;
43 while (taken.has(`${root}-${i}`)) i += 1;
44 return `${root}-${i}`;
45};
46
47/** Which connections a toolkit may draw on: workspace = org-owned only; personal
48 * = org + the caller's own, split into two tiers. */
49export const tiersForScope = (
50 connections: ReadonlyArray<FormConn>,
51 scope: ToolkitScope,
52): ReadonlyArray<AccessTier> => {
53 const org = connections.filter((c) => c.owner === "org");
54 if (scope === "workspace") return [{ label: "Workspace connections", conns: org }];
55 const user = connections.filter((c) => c.owner === "user");
56 return [
57 { label: "Workspace connections", conns: org },
58 { label: "Personal connections", conns: user },
59 ];
60};
61
62/** Seed the editor's access + notes maps from an existing toolkit. */
63export const accessFromToolkit = (
64 toolkit: ToolkitView | null,
65): { access: Record<string, ToolkitAccess>; notes: Record<string, string> } => {
66 const access: Record<string, ToolkitAccess> = {};
67 const notes: Record<string, string> = {};
68 for (const e of toolkit?.connections ?? []) {
69 const key = connKey(e.integration, e.connection);
70 access[key] = e.access;
71 if (e.note) notes[key] = e.note;
72 }
73 return { access, notes };
74};
75
76/** Collapse the editor's access + notes maps back into the wire entries —
77 * only connections set to read/full (off = absent), notes trimmed. */
78export const entriesFromAccess = (
79 connections: ReadonlyArray<FormConn>,
80 scope: ToolkitScope,
81 access: Readonly<Record<string, ToolkitAccess>>,
82 notes: Readonly<Record<string, string>>,
83): ReadonlyArray<ToolkitConnectionEntry> => {
84 const entries: Array<ToolkitConnectionEntry> = [];
85 for (const tier of tiersForScope(connections, scope)) {
86 for (const c of tier.conns) {
87 const key = connKey(c.integration, c.name);
88 const a = access[key] ?? "off";
89 if (a === "off") continue;
90 const note = notes[key]?.trim();
91 entries.push({
92 integration: IntegrationSlug.make(c.integration),
93 connection: c.name,
94 access: a,
95 ...(note ? { note } : {}),
96 });
97 }
98 }
99 return entries;
100};
packages/plugins/toolkits/src/react/index.ts+9−0
@@ -0,0 +1,9 @@
1export { ToolkitsPage, default as ToolkitsPageDefault } from "./ToolkitsPage";
2export { ToolkitsClient } from "./client";
3export {
4 toolkitsAtom,
5 createToolkit,
6 updateToolkit,
7 removeToolkit,
8 toolkitWriteKeys,
9} from "./atoms";
packages/plugins/toolkits/src/react/plugin-client.tsx+26−0
@@ -0,0 +1,26 @@
1// ---------------------------------------------------------------------------
2// @executor-js/plugin-toolkits/client — the frontend half.
3//
4// The Vite plugin resolves `${packageName}/client` for every server plugin
5// registered in `executor.config.ts` and feeds the default export into
6// `<ExecutorPluginsProvider>`, so registering `toolkitsPlugin()` server-side is
7// all it takes for this page (and its "Toolkits" nav entry) to mount.
8//
9// Server-only deps (Effect runtime, Node, executor.config) MUST NOT be imported
10// here — this entry is bundled into the frontend.
11// ---------------------------------------------------------------------------
12
13import { defineClientPlugin } from "@executor-js/sdk/client";
14
15import { ToolkitsPage } from "./ToolkitsPage";
16
17export default defineClientPlugin({
18 id: "toolkits" as const,
19 pages: [
20 {
21 path: "/",
22 component: ToolkitsPage,
23 nav: { label: "Toolkits" },
24 },
25 ],
26});
packages/plugins/toolkits/src/server.ts+379−0
@@ -0,0 +1,379 @@
1// ---------------------------------------------------------------------------
2// @executor-js/plugin-toolkits/server
3//
4// The server half of the toolkits plugin. A toolkit is a named slice of the
5// caller's connections, persisted as owner-scoped plugin storage:
6// - workspace toolkit -> owner "org" (visible to every org member)
7// - personal toolkit -> owner "user" (visible only to its creator)
8// The pluginStorage facade is already request-bound to (tenant, subject), so
9// the org/user owner literal is the entire workspace-vs-personal visibility
10// story. Slice 1 ships data + CRUD only — no MCP narrowing yet.
11//
12// React and other browser-only deps live in `./client` — never here.
13// ---------------------------------------------------------------------------
14
15import { Schema } from "effect";
16import { Context, definePlugin, Effect, HttpApiBuilder } from "@executor-js/sdk/core";
17import {
18 definePluginStorageCollection,
19 Owner,
20 type PluginCtx,
21 type RequestScope,
22 type StorageFailure,
23} from "@executor-js/sdk";
24import { addGroup, capture } from "@executor-js/api";
25
26import {
27 ToolkitForbidden,
28 ToolkitNotFound,
29 ToolkitsApi,
30 type CreateToolkitPayload,
31 type ToolkitAccess,
32 type ToolkitPolicyAction,
33 type ToolkitScope,
34 type ToolkitView,
35 type UpdateToolkitPayload,
36} from "./shared";
37import {
38 EMPTY_TOOLKIT_SCOPE,
39 toolkitScopeToRequestScope,
40 type ResolvedToolkitScope,
41} from "./toolkit-scope";
42
43const ORG = Owner.make("org");
44const USER = Owner.make("user");
45
46// ---------------------------------------------------------------------------
47// Storage rows (internal — not the HTTP contract). Keyed by uuid, never by a
48// user-supplied value (long keyed values hit a fumadb keyed-lookup bug).
49// ---------------------------------------------------------------------------
50
51const ToolkitRow = Schema.Struct({
52 slug: Schema.String,
53 name: Schema.String,
54 inheritOrgPolicies: Schema.Boolean,
55 briefing: Schema.NullOr(Schema.String),
56});
57
58const ConnectionRow = Schema.Struct({
59 toolkitId: Schema.String,
60 integration: Schema.String,
61 connection: Schema.String,
62 access: Schema.String,
63 note: Schema.NullOr(Schema.String),
64});
65
66const PolicyRow = Schema.Struct({
67 toolkitId: Schema.String,
68 pattern: Schema.String,
69 action: Schema.String,
70});
71
72const TOOLKITS = definePluginStorageCollection("toolkits", ToolkitRow, {
73 indexes: ["slug"],
74});
75const CONNECTIONS = definePluginStorageCollection("connections", ConnectionRow, {
76 indexes: ["toolkitId"],
77});
78const POLICIES = definePluginStorageCollection("policies", PolicyRow, {
79 indexes: ["toolkitId"],
80});
81
82const newId = Effect.sync(() => crypto.randomUUID());
83const scopeOfOwner = (owner: Owner): ToolkitScope => (owner === ORG ? "workspace" : "personal");
84
85// ---------------------------------------------------------------------------
86// Extension — the canonical implementation. Handlers (and later the MCP
87// contribution) all hit this same code path.
88// ---------------------------------------------------------------------------
89
90type ToolkitRowEntry = {
91 readonly key: string;
92 readonly owner: Owner;
93 readonly data: typeof ToolkitRow.Type;
94};
95
96const makeToolkitsExtension = (ctx: PluginCtx) => {
97 const toolkits = ctx.pluginStorage.collection(TOOLKITS);
98 const connections = ctx.pluginStorage.collection(CONNECTIONS);
99 const policies = ctx.pluginStorage.collection(POLICIES);
100
101 const viewFromRow = (row: ToolkitRowEntry) =>
102 Effect.gen(function* () {
103 const conns = yield* connections.query({ where: { toolkitId: row.key } });
104 const pols = yield* policies.query({ where: { toolkitId: row.key } });
105 return {
106 id: row.key,
107 slug: row.data.slug,
108 name: row.data.name,
109 scope: scopeOfOwner(row.owner),
110 inheritOrgPolicies: row.data.inheritOrgPolicies,
111 briefing: row.data.briefing,
112 connections: conns.map((c) => ({
113 integration: c.data.integration as ToolkitConnectionIntegration,
114 connection: c.data.connection,
115 access: c.data.access as ToolkitAccess,
116 ...(c.data.note == null ? {} : { note: c.data.note }),
117 })),
118 policies: pols.map((p) => ({
119 pattern: p.data.pattern,
120 action: p.data.action as ToolkitPolicyAction,
121 })),
122 } satisfies ToolkitView;
123 });
124
125 // candidate connections honoring scope: a workspace toolkit may use only
126 // org connections; a personal toolkit may use org + the caller's own.
127 const candidateConnections = (scope: ToolkitScope) =>
128 scope === "workspace" ? ctx.connections.list({ owner: ORG }) : ctx.connections.list();
129
130 const validate = (scope: ToolkitScope, entries: ReadonlyArray<ToolkitConnectionEntryInput>) =>
131 Effect.gen(function* () {
132 const allowed = yield* candidateConnections(scope);
133 const pairs = new Set(allowed.map((c) => `${c.integration}/${c.name}`));
134 const integrations = new Set(allowed.map((c) => c.integration));
135 for (const e of entries) {
136 const ok =
137 e.connection === "*"
138 ? integrations.has(e.integration)
139 : pairs.has(`${e.integration}/${e.connection}`);
140 if (!ok) {
141 return yield* new ToolkitForbidden({
142 reason: `connection ${e.integration}/${e.connection} is not available to a ${scope} toolkit`,
143 });
144 }
145 }
146 });
147
148 const putConnections = (
149 toolkitId: string,
150 owner: Owner,
151 entries: ReadonlyArray<ToolkitConnectionEntryInput>,
152 ) =>
153 Effect.forEach(entries, (e) =>
154 newId.pipe(
155 Effect.flatMap((cid) =>
156 connections.put({
157 key: cid,
158 owner,
159 data: {
160 toolkitId,
161 integration: e.integration,
162 connection: e.connection,
163 access: e.access,
164 note: e.note ?? null,
165 },
166 }),
167 ),
168 ),
169 );
170
171 const putPolicies = (
172 toolkitId: string,
173 owner: Owner,
174 rules: ReadonlyArray<{ readonly pattern: string; readonly action: string }>,
175 ) =>
176 Effect.forEach(rules, (r) =>
177 newId.pipe(
178 Effect.flatMap((pid) =>
179 policies.put({
180 key: pid,
181 owner,
182 data: { toolkitId, pattern: r.pattern, action: r.action },
183 }),
184 ),
185 ),
186 );
187
188 const create = (input: CreateToolkitPayload) =>
189 Effect.gen(function* () {
190 if (input.scope === "personal" && ctx.owner.subject == null) {
191 return yield* new ToolkitForbidden({
192 reason: "personal toolkits require a signed-in user",
193 });
194 }
195 const owner = input.scope === "workspace" ? ORG : USER;
196 const entries = input.connections ?? [];
197 yield* validate(input.scope, entries);
198 const id = yield* newId;
199 const data = {
200 slug: input.slug,
201 name: input.name,
202 inheritOrgPolicies: input.inheritOrgPolicies ?? true,
203 briefing: input.briefing ?? null,
204 };
205 yield* toolkits.put({ key: id, owner, data });
206 yield* putConnections(id, owner, entries);
207 yield* putPolicies(id, owner, input.policies ?? []);
208 return yield* viewFromRow({ key: id, owner, data });
209 });
210
211 const get = (id: string) =>
212 Effect.gen(function* () {
213 const row = yield* toolkits.get({ key: id });
214 if (row == null) return yield* new ToolkitNotFound({ id });
215 return yield* viewFromRow(row);
216 });
217
218 const list = () =>
219 toolkits.list().pipe(Effect.flatMap((rows) => Effect.forEach(rows, viewFromRow)));
220
221 const update = (id: string, patch: UpdateToolkitPayload) =>
222 Effect.gen(function* () {
223 const row = yield* toolkits.get({ key: id });
224 if (row == null) return yield* new ToolkitNotFound({ id });
225 const data = {
226 slug: row.data.slug,
227 name: patch.name ?? row.data.name,
228 inheritOrgPolicies: patch.inheritOrgPolicies ?? row.data.inheritOrgPolicies,
229 briefing: patch.briefing === undefined ? row.data.briefing : patch.briefing,
230 };
231 yield* toolkits.put({ key: id, owner: row.owner, data });
232 if (patch.connections !== undefined) {
233 yield* validate(scopeOfOwner(row.owner), patch.connections);
234 const existing = yield* connections.query({ where: { toolkitId: id } });
235 yield* Effect.forEach(existing, (c) =>
236 connections.remove({ key: c.key, owner: row.owner }),
237 );
238 yield* putConnections(id, row.owner, patch.connections);
239 }
240 if (patch.policies !== undefined) {
241 const existing = yield* policies.query({ where: { toolkitId: id } });
242 yield* Effect.forEach(existing, (p) => policies.remove({ key: p.key, owner: row.owner }));
243 yield* putPolicies(id, row.owner, patch.policies);
244 }
245 return yield* viewFromRow({ key: id, owner: row.owner, data });
246 });
247
248 const remove = (id: string) =>
249 Effect.gen(function* () {
250 const row = yield* toolkits.get({ key: id });
251 if (row == null) return yield* new ToolkitNotFound({ id });
252 const conns = yield* connections.query({ where: { toolkitId: id } });
253 yield* Effect.forEach(conns, (c) => connections.remove({ key: c.key, owner: row.owner }));
254 const pols = yield* policies.query({ where: { toolkitId: id } });
255 yield* Effect.forEach(pols, (p) => policies.remove({ key: p.key, owner: row.owner }));
256 yield* toolkits.remove({ key: id, owner: row.owner });
257 return { removed: true };
258 });
259
260 // Resolve a selector (slug, then id) to the scope the MCP narrowing seam
261 // applies. Request-scoped, so it only finds toolkits visible to the caller —
262 // a cross-tenant/personal-of-another-user selector returns null (the seam
263 // then fails closed to an empty slice).
264 const resolveScope = (
265 selector: string,
266 ): Effect.Effect<ResolvedToolkitScope | null, StorageFailure> =>
267 Effect.gen(function* () {
268 const bySlug = yield* toolkits.query({
269 where: { slug: selector },
270 limit: 1,
271 });
272 const row = bySlug[0] ?? (yield* toolkits.get({ key: selector }));
273 if (row == null) return null;
274 const conns = yield* connections.query({ where: { toolkitId: row.key } });
275 const pols = yield* policies.query({ where: { toolkitId: row.key } });
276 return {
277 entries: conns.map((c) => ({
278 integration: c.data.integration,
279 connection: c.data.connection,
280 access: c.data.access as ToolkitAccess,
281 })),
282 policies: pols.map((p) => ({
283 pattern: p.data.pattern,
284 action: p.data.action as ToolkitPolicyAction,
285 })),
286 inheritOrgPolicies: row.data.inheritOrgPolicies,
287 };
288 });
289
290 return { create, get, list, update, remove, resolveScope };
291};
292
293// Local aliases so the storage/extension layer stays decoupled from the wire
294// schema's branded types without re-importing them everywhere.
295type ToolkitConnectionIntegration = ToolkitView["connections"][number]["integration"];
296type ToolkitConnectionEntryInput = {
297 readonly integration: ToolkitConnectionIntegration;
298 readonly connection: string;
299 readonly access: ToolkitAccess;
300 readonly note?: string | undefined;
301};
302
303type ToolkitsExtension = ReturnType<typeof makeToolkitsExtension>;
304
305export class ToolkitsExtensionService extends Context.Service<
306 ToolkitsExtensionService,
307 ToolkitsExtension
308>()("ToolkitsExtensionService") {}
309
310// Build handlers against the core executor API with the toolkits group added,
311// so the handler layer satisfies `ApiGroup<"executor", "toolkits">` (the marker
312// the host's composed API requires) — not a standalone single-group bundle.
313const ExecutorApiWithToolkits = addGroup(ToolkitsApi);
314
315const ToolkitsHandlers = HttpApiBuilder.group(ExecutorApiWithToolkits, "toolkits", (h) =>
316 h
317 .handle("list", () =>
318 capture(
319 Effect.gen(function* () {
320 const ext = yield* ToolkitsExtensionService;
321 return yield* ext.list();
322 }),
323 ),
324 )
325 .handle("create", ({ payload }) =>
326 capture(
327 Effect.gen(function* () {
328 const ext = yield* ToolkitsExtensionService;
329 return yield* ext.create(payload);
330 }),
331 ),
332 )
333 .handle("get", ({ params }) =>
334 capture(
335 Effect.gen(function* () {
336 const ext = yield* ToolkitsExtensionService;
337 return yield* ext.get(params.id);
338 }),
339 ),
340 )
341 .handle("update", ({ params, payload }) =>
342 capture(
343 Effect.gen(function* () {
344 const ext = yield* ToolkitsExtensionService;
345 return yield* ext.update(params.id, payload);
346 }),
347 ),
348 )
349 .handle("remove", ({ params }) =>
350 capture(
351 Effect.gen(function* () {
352 const ext = yield* ToolkitsExtensionService;
353 return yield* ext.remove(params.id);
354 }),
355 ),
356 ),
357);
358
359const resolveToolkitRequestScope = (
360 ctx: PluginCtx,
361 selector: string,
362): Effect.Effect<RequestScope, StorageFailure> =>
363 makeToolkitsExtension(ctx)
364 .resolveScope(selector)
365 .pipe(Effect.map((scope) => toolkitScopeToRequestScope(scope ?? EMPTY_TOOLKIT_SCOPE)));
366
367export const toolkitsPlugin = definePlugin(() => ({
368 id: "toolkits" as const,
369 packageName: "@executor-js/plugin-toolkits",
370 storage: () => ({}),
371 pluginStorage: { toolkits: TOOLKITS, connections: CONNECTIONS },
372 extension: makeToolkitsExtension,
373 resolveRequestScope: resolveToolkitRequestScope,
374 routes: () => ToolkitsApi,
375 handlers: () => ToolkitsHandlers,
376 extensionService: ToolkitsExtensionService,
377}));
378
379export default toolkitsPlugin;
packages/plugins/toolkits/src/shared.ts+137−0
@@ -0,0 +1,137 @@
1// ---------------------------------------------------------------------------
2// @executor-js/plugin-toolkits/shared
3//
4// Schemas + the HttpApiGroup shared between server and client. A "toolkit" is a
5// named slice of the caller's connections (each off/read/full), scoped to the
6// workspace (org-owned connections only) or the caller personally (org + own).
7//
8// No React or Node imports here — server and client both import this.
9// ---------------------------------------------------------------------------
10
11import { Schema } from "effect";
12import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
13
14import { IntegrationSlug, InternalError } from "@executor-js/sdk/shared";
15
16// off = invisible, read = read-only tools only, full = every tool.
17export const ToolkitAccess = Schema.Literals(["off", "read", "full"]);
18export type ToolkitAccess = typeof ToolkitAccess.Type;
19
20// workspace = org-owned (visible to every org member); personal = the caller's own.
21export const ToolkitScope = Schema.Literals(["workspace", "personal"]);
22export type ToolkitScope = typeof ToolkitScope.Type;
23
24// One connection in a toolkit's slice. `connection` is a pinned account name,
25// or "*" to track every connection of the integration.
26export const ToolkitConnectionEntry = Schema.Struct({
27 integration: IntegrationSlug,
28 connection: Schema.String,
29 access: ToolkitAccess,
30 note: Schema.optional(Schema.String),
31});
32export type ToolkitConnectionEntry = typeof ToolkitConnectionEntry.Type;
33
34// off/read/full is the per-connection access mode; policies are pattern rules
35// layered on top. v1 enforces `block` (the tool is excluded); `approve` /
36// `require_approval` are persisted + briefed (org approval policies still apply).
37export const ToolkitPolicyAction = Schema.Literals(["approve", "require_approval", "block"]);
38export type ToolkitPolicyAction = typeof ToolkitPolicyAction.Type;
39
40export const ToolkitPolicy = Schema.Struct({
41 pattern: Schema.String,
42 action: ToolkitPolicyAction,
43});
44export type ToolkitPolicy = typeof ToolkitPolicy.Type;
45
46export const ToolkitView = Schema.Struct({
47 id: Schema.String,
48 slug: Schema.String,
49 name: Schema.String,
50 scope: ToolkitScope,
51 inheritOrgPolicies: Schema.Boolean,
52 briefing: Schema.NullOr(Schema.String),
53 connections: Schema.Array(ToolkitConnectionEntry),
54 policies: Schema.Array(ToolkitPolicy),
55});
56export type ToolkitView = typeof ToolkitView.Type;
57
58export const CreateToolkitPayload = Schema.Struct({
59 slug: Schema.String,
60 name: Schema.String,
61 scope: ToolkitScope,
62 inheritOrgPolicies: Schema.optional(Schema.Boolean),
63 briefing: Schema.optional(Schema.String),
64 connections: Schema.optional(Schema.Array(ToolkitConnectionEntry)),
65 policies: Schema.optional(Schema.Array(ToolkitPolicy)),
66});
67export type CreateToolkitPayload = typeof CreateToolkitPayload.Type;
68
69export const UpdateToolkitPayload = Schema.Struct({
70 name: Schema.optional(Schema.String),
71 briefing: Schema.optional(Schema.NullOr(Schema.String)),
72 inheritOrgPolicies: Schema.optional(Schema.Boolean),
73 connections: Schema.optional(Schema.Array(ToolkitConnectionEntry)),
74 policies: Schema.optional(Schema.Array(ToolkitPolicy)),
75});
76export type UpdateToolkitPayload = typeof UpdateToolkitPayload.Type;
77
78// ---------------------------------------------------------------------------
79// Errors
80// ---------------------------------------------------------------------------
81
82export class ToolkitNotFound extends Schema.TaggedErrorClass<ToolkitNotFound>()("ToolkitNotFound", {
83 id: Schema.String,
84}) {}
85
86export class ToolkitForbidden extends Schema.TaggedErrorClass<ToolkitForbidden>()(
87 "ToolkitForbidden",
88 {
89 reason: Schema.String,
90 },
91) {}
92
93const NotFound = ToolkitNotFound.annotate({ httpApiStatus: 404 });
94const Forbidden = ToolkitForbidden.annotate({ httpApiStatus: 403 });
95
96const IdParams = { id: Schema.String };
97
98// ---------------------------------------------------------------------------
99// Group
100// ---------------------------------------------------------------------------
101
102export const ToolkitsApi = HttpApiGroup.make("toolkits")
103 .add(
104 HttpApiEndpoint.get("list", "/toolkits", {
105 success: Schema.Array(ToolkitView),
106 error: InternalError,
107 }),
108 )
109 .add(
110 HttpApiEndpoint.post("create", "/toolkits", {
111 payload: CreateToolkitPayload,
112 success: ToolkitView,
113 error: [InternalError, Forbidden],
114 }),
115 )
116 .add(
117 HttpApiEndpoint.get("get", "/toolkits/:id", {
118 params: IdParams,
119 success: ToolkitView,
120 error: [InternalError, NotFound],
121 }),
122 )
123 .add(
124 HttpApiEndpoint.patch("update", "/toolkits/:id", {
125 params: IdParams,
126 payload: UpdateToolkitPayload,
127 success: ToolkitView,
128 error: [InternalError, NotFound, Forbidden],
129 }),
130 )
131 .add(
132 HttpApiEndpoint.delete("remove", "/toolkits/:id", {
133 params: IdParams,
134 success: Schema.Struct({ removed: Schema.Boolean }),
135 error: [InternalError, NotFound],
136 }),
137 );
packages/plugins/toolkits/src/toolkit-scope.ts+116−0
@@ -0,0 +1,116 @@
1// ---------------------------------------------------------------------------
2// Toolkit scope — pure data mapping from a resolved toolkit slice to core's
3// vocabulary-neutral `RequestScope`. Core owns enforcement; this module only
4// derives allow/block/require_approval decisions from toolkit entries and
5// per-toolkit policy rules.
6// ---------------------------------------------------------------------------
7
8import type { Connection } from "@executor-js/sdk";
9import { defaultDecideStaticTool, type RequestScope, type ScopeDecision } from "@executor-js/sdk";
10
11import type { ToolkitAccess, ToolkitPolicyAction } from "./shared";
12
13export interface ToolkitPolicyRule {
14 /** Glob over `<integration>.<connection>.<tool>` — `*` matches one segment,
15 * a trailing `*` matches the rest, and `*` inside a segment globs. */
16 readonly pattern: string;
17 readonly action: ToolkitPolicyAction;
18}
19
20export interface ToolkitScopeEntry {
21 readonly integration: string;
22 /** A pinned connection name, or "*" to track every connection of the integration. */
23 readonly connection: string;
24 readonly access: ToolkitAccess;
25}
26
27export interface ResolvedToolkitScope {
28 readonly entries: readonly ToolkitScopeEntry[];
29 /** Per-toolkit policy rules — `block` excludes tools; `require_approval`
30 * tightens execution via core's stricter-wins merge with org policies. */
31 readonly policies: readonly ToolkitPolicyRule[];
32 readonly inheritOrgPolicies: boolean;
33}
34
35/** Empty slice — exposes no connection tools. Applied fail-closed when a
36 * selector resolves to no toolkit (unknown or not visible to the caller). */
37export const EMPTY_TOOLKIT_SCOPE: ResolvedToolkitScope = {
38 entries: [],
39 policies: [],
40 inheritOrgPolicies: true,
41};
42
43/** Glob match over dot segments: `*` matches one segment, a trailing bare `*`
44 * matches the rest, and `*` inside a segment globs within it. */
45const matchPattern = (pattern: string, target: string): boolean => {
46 const p = pattern.toLowerCase().trim().split(".");
47 const a = target.toLowerCase().split(".");
48 for (let i = 0; i < p.length; i++) {
49 if (p[i] === "*" && i === p.length - 1) return true;
50 if (a[i] === undefined) return false;
51 const re = new RegExp(
52 "^" + p[i].replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$",
53 );
54 if (!re.test(a[i])) return false;
55 }
56 return p.length === a.length;
57};
58
59const accessFor = (
60 scope: ResolvedToolkitScope,
61 integration: string,
62 connection: string,
63): ToolkitAccess => {
64 for (const e of scope.entries) {
65 if (e.integration === integration && e.connection === connection) return e.access;
66 }
67 for (const e of scope.entries) {
68 if (e.integration === integration && e.connection === "*") return e.access;
69 }
70 return "off";
71};
72
73const policyDecisionForTool = (
74 scope: ResolvedToolkitScope,
75 integration: string,
76 connection: string,
77 name: string,
78): ScopeDecision | null => {
79 const target = `${integration}.${connection}.${name}`;
80 for (const rule of scope.policies) {
81 if (!matchPattern(rule.pattern, target)) continue;
82 if (rule.action === "block") return "block";
83 if (rule.action === "require_approval") return "require_approval";
84 }
85 return null;
86};
87
88/** Map a resolved toolkit slice to core's `RequestScope` overlay. */
89export const toolkitScopeToRequestScope = (scope: ResolvedToolkitScope): RequestScope => {
90 const allowedIntegrations = new Set(
91 scope.entries.filter((e) => e.access !== "off").map((e) => e.integration),
92 );
93
94 return {
95 inheritOrgPolicies: scope.inheritOrgPolicies,
96 allowsIntegration: (integration) => allowedIntegrations.has(String(integration.slug)),
97 allowsConnection: (connection: Connection) =>
98 accessFor(scope, String(connection.integration), String(connection.name)) !== "off",
99 decideTool: (tool) => {
100 const integration = String(tool.integration);
101 const connection = String(tool.connection);
102 const name = String(tool.name);
103
104 const fromPolicy = policyDecisionForTool(scope, integration, connection, name);
105 if (fromPolicy === "block") return "block";
106
107 const access = accessFor(scope, integration, connection);
108 if (access === "off") return "block";
109 if (access === "read" && tool.annotations?.readOnly !== true) return "block";
110
111 if (fromPolicy === "require_approval") return "require_approval";
112 return "allow";
113 },
114 decideStaticTool: defaultDecideStaticTool,
115 };
116};
packages/plugins/toolkits/tsconfig.json+25−0
@@ -0,0 +1,25 @@
1{
2 "compilerOptions": {
3 "target": "ES2022",
4 "module": "ESNext",
5 "moduleResolution": "Bundler",
6 "strict": true,
7 "skipLibCheck": true,
8 "jsx": "react-jsx",
9 "lib": ["ES2022", "DOM"],
10 "types": ["bun-types", "node"],
11 "noUnusedLocals": true,
12 "noImplicitOverride": true,
13 "plugins": [
14 {
15 "name": "@effect/language-service",
16 "ignoreEffectSuggestionsInTscExitCode": true,
17 "ignoreEffectWarningsInTscExitCode": true,
18 "diagnosticSeverity": {
19 "preferSchemaOverJson": "off"
20 }
21 }
22 ]
23 },
24 "include": ["src/**/*.ts", "src/**/*.tsx"]
25}
packages/plugins/toolkits/tsup.config.ts+13−0
@@ -0,0 +1,13 @@
1import { defineConfig } from "tsup";
2
3export default defineConfig({
4 entry: {
5 server: "src/server.ts",
6 shared: "src/shared.ts",
7 },
8 format: ["esm"],
9 dts: false,
10 sourcemap: true,
11 clean: true,
12 external: [/^@executor-js\//, /^effect/, /^@effect\//],
13});
packages/plugins/toolkits/vitest.config.ts+8−0
@@ -0,0 +1,8 @@
1import { defineConfig } from "vitest/config";
2
3export default defineConfig({
4 test: {
5 include: ["src/**/*.test.ts"],
6 passWithNoTests: true,
7 },
8});