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
999999 "react",
10001000 ],
10011001 },
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+ },
10021034 "packages/plugins/workos-vault": {
10031035 "name": "@executor-js/plugin-workos-vault",
10041036 "version": "0.0.2",
@@ -1589,6 +1621,8 @@ · 584 unmodified lines
15891621
15901622 "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"],
15911623
1624+ "@executor-js/plugin-toolkits": ["@executor-js/plugin-toolkits@workspace:packages/plugins/toolkits"],
1625+
15921626 "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"],
15931627
15941628 "@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+
15+import { useEffect, useMemo, useState } from "react";
16+import { useAtomSet, useAtomValue } from "@effect/atom-react";
17+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
18+import * as Exit from "effect/Exit";
19+import { usePluginNavigate, usePluginRoute } from "@executor-js/sdk/client";
20+
21+import { connectionsAllAtom, integrationsAtom } from "@executor-js/react/api/atoms";
22+import { Button } from "@executor-js/react/components/button";
23+import { Input } from "@executor-js/react/components/input";
24+import { Textarea } from "@executor-js/react/components/textarea";
25+import { Switch } from "@executor-js/react/components/switch";
26+import { Label } from "@executor-js/react/components/label";
27+import { Badge } from "@executor-js/react/components/badge";
28+import {
29+ Select,
30+ SelectContent,
31+ SelectItem,
32+ SelectTrigger,
33+ SelectValue,
34+} from "@executor-js/react/components/select";
35+import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
36+import { copyToClipboard } from "@executor-js/react/lib/clipboard";
37+import { cn } from "@executor-js/react/lib/utils";
38+
39+import type {
40+ ToolkitAccess,
41+ ToolkitPolicy,
42+ ToolkitPolicyAction,
43+ ToolkitScope,
44+ ToolkitView,
45+} from "../shared";
46+import {
47+ toolkitsAtom,
48+ createToolkit,
49+ updateToolkit,
50+ removeToolkit,
51+ toolkitWriteKeys,
52+} from "./atoms";
53+import {
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+
68+interface Integ {
69+ readonly slug: string;
70+ readonly name: string;
71+}
72+
73+const ACCESS_OPTIONS: ReadonlyArray<{ value: ToolkitAccess; label: string }> = [
74+ { value: "off", label: "Off" },
75+ { value: "read", label: "Read" },
76+ { value: "full", label: "Full" },
77+];
78+
79+const 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+
89+function 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+
112+function 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+
154+function 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+
194+interface 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+
205+function 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+
665+function 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+
681+type View =
682+ | { readonly kind: "list" }
683+ | { readonly kind: "edit"; readonly id: string }
684+ | { readonly kind: "new"; readonly scope: ToolkitScope };
685+
686+function 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+
694+export 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+
796+function 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+
814+export 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+
9+import { ReactivityKey, toolkitWriteKeys } from "@executor-js/react/api/reactivity-keys";
10+
11+import { ToolkitsClient } from "./client";
12+
13+export { toolkitWriteKeys };
14+
15+export const toolkitsAtom = ToolkitsClient.query("toolkits", "list", {
16+ timeToLive: "15 seconds",
17+ reactivityKeys: [ReactivityKey.toolkits],
18+});
19+
20+export const createToolkit = ToolkitsClient.mutation("toolkits", "create");
21+export const updateToolkit = ToolkitsClient.mutation("toolkits", "update");
22+export 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+
10+import { createPluginAtomClient } from "@executor-js/sdk/client";
11+import {
12+ getExecutorApiBaseUrl,
13+ getExecutorServerAuthorizationHeader,
14+} from "@executor-js/react/api/server-connection";
15+
16+import { ToolkitsApi } from "../shared";
17+
18+export 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+
7+import { IntegrationSlug } from "@executor-js/sdk/shared";
8+
9+import type { ToolkitAccess, ToolkitConnectionEntry, ToolkitScope, ToolkitView } from "../shared";
10+
11+/** A structural view of a core connection row — the fields the editor reads. */
12+export 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+
20+export 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. */
27+export const connKey = (integration: string, connection: string): string =>
28+ `${integration} ${connection}`;
29+
30+export 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. */
39+export 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. */
49+export 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. */
63+export 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. */
78+export 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 @@
1+export { ToolkitsPage, default as ToolkitsPageDefault } from "./ToolkitsPage";
2+export { ToolkitsClient } from "./client";
3+export {
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+
13+import { defineClientPlugin } from "@executor-js/sdk/client";
14+
15+import { ToolkitsPage } from "./ToolkitsPage";
16+
17+export 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+
15+import { Schema } from "effect";
16+import { Context, definePlugin, Effect, HttpApiBuilder } from "@executor-js/sdk/core";
17+import {
18+ definePluginStorageCollection,
19+ Owner,
20+ type PluginCtx,
21+ type RequestScope,
22+ type StorageFailure,
23+} from "@executor-js/sdk";
24+import { addGroup, capture } from "@executor-js/api";
25+
26+import {
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";
37+import {
38+ EMPTY_TOOLKIT_SCOPE,
39+ toolkitScopeToRequestScope,
40+ type ResolvedToolkitScope,
41+} from "./toolkit-scope";
42+
43+const ORG = Owner.make("org");
44+const 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+
51+const ToolkitRow = Schema.Struct({
52+ slug: Schema.String,
53+ name: Schema.String,
54+ inheritOrgPolicies: Schema.Boolean,
55+ briefing: Schema.NullOr(Schema.String),
56+});
57+
58+const 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+
66+const PolicyRow = Schema.Struct({
67+ toolkitId: Schema.String,
68+ pattern: Schema.String,
69+ action: Schema.String,
70+});
71+
72+const TOOLKITS = definePluginStorageCollection("toolkits", ToolkitRow, {
73+ indexes: ["slug"],
74+});
75+const CONNECTIONS = definePluginStorageCollection("connections", ConnectionRow, {
76+ indexes: ["toolkitId"],
77+});
78+const POLICIES = definePluginStorageCollection("policies", PolicyRow, {
79+ indexes: ["toolkitId"],
80+});
81+
82+const newId = Effect.sync(() => crypto.randomUUID());
83+const 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+
90+type ToolkitRowEntry = {
91+ readonly key: string;
92+ readonly owner: Owner;
93+ readonly data: typeof ToolkitRow.Type;
94+};
95+
96+const 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.
295+type ToolkitConnectionIntegration = ToolkitView["connections"][number]["integration"];
296+type ToolkitConnectionEntryInput = {
297+ readonly integration: ToolkitConnectionIntegration;
298+ readonly connection: string;
299+ readonly access: ToolkitAccess;
300+ readonly note?: string | undefined;
301+};
302+
303+type ToolkitsExtension = ReturnType<typeof makeToolkitsExtension>;
304+
305+export 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.
313+const ExecutorApiWithToolkits = addGroup(ToolkitsApi);
314+
315+const 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+
359+const 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+
367+export 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+
379+export 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+
11+import { Schema } from "effect";
12+import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
13+
14+import { IntegrationSlug, InternalError } from "@executor-js/sdk/shared";
15+
16+// off = invisible, read = read-only tools only, full = every tool.
17+export const ToolkitAccess = Schema.Literals(["off", "read", "full"]);
18+export type ToolkitAccess = typeof ToolkitAccess.Type;
19+
20+// workspace = org-owned (visible to every org member); personal = the caller's own.
21+export const ToolkitScope = Schema.Literals(["workspace", "personal"]);
22+export 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.
26+export const ToolkitConnectionEntry = Schema.Struct({
27+ integration: IntegrationSlug,
28+ connection: Schema.String,
29+ access: ToolkitAccess,
30+ note: Schema.optional(Schema.String),
31+});
32+export 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).
37+export const ToolkitPolicyAction = Schema.Literals(["approve", "require_approval", "block"]);
38+export type ToolkitPolicyAction = typeof ToolkitPolicyAction.Type;
39+
40+export const ToolkitPolicy = Schema.Struct({
41+ pattern: Schema.String,
42+ action: ToolkitPolicyAction,
43+});
44+export type ToolkitPolicy = typeof ToolkitPolicy.Type;
45+
46+export 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+});
56+export type ToolkitView = typeof ToolkitView.Type;
57+
58+export 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+});
67+export type CreateToolkitPayload = typeof CreateToolkitPayload.Type;
68+
69+export 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+});
76+export type UpdateToolkitPayload = typeof UpdateToolkitPayload.Type;
77+
78+// ---------------------------------------------------------------------------
79+// Errors
80+// ---------------------------------------------------------------------------
81+
82+export class ToolkitNotFound extends Schema.TaggedErrorClass<ToolkitNotFound>()("ToolkitNotFound", {
83+ id: Schema.String,
84+}) {}
85+
86+export class ToolkitForbidden extends Schema.TaggedErrorClass<ToolkitForbidden>()(
87+ "ToolkitForbidden",
88+ {
89+ reason: Schema.String,
90+ },
91+) {}
92+
93+const NotFound = ToolkitNotFound.annotate({ httpApiStatus: 404 });
94+const Forbidden = ToolkitForbidden.annotate({ httpApiStatus: 403 });
95+
96+const IdParams = { id: Schema.String };
97+
98+// ---------------------------------------------------------------------------
99+// Group
100+// ---------------------------------------------------------------------------
101+
102+export 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+
8+import type { Connection } from "@executor-js/sdk";
9+import { defaultDecideStaticTool, type RequestScope, type ScopeDecision } from "@executor-js/sdk";
10+
11+import type { ToolkitAccess, ToolkitPolicyAction } from "./shared";
12+
13+export 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+
20+export 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+
27+export 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). */
37+export 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. */
45+const 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+
59+const 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+
73+const 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. */
89+export 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 @@
1+import { defineConfig } from "tsup";
2+
3+export 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 @@
1+import { defineConfig } from "vitest/config";
2+
3+export default defineConfig({
4+ test: {
5+ include: ["src/**/*.test.ts"],
6+ passWithNoTests: true,
7+ },
8+});