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.
Unified · Split
| @@ -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"], |
| @@ -0,0 +1 @@ | |||
| 1 | # @executor-js/plugin-toolkits | ||
| @@ -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 | } | ||
| @@ -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 & 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; | ||
| @@ -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"); | ||
| @@ -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 | }); | ||
| @@ -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 | }; | ||
| @@ -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"; | ||
| @@ -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 | }); | ||
| @@ -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; | ||
| @@ -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 | ); | ||
| @@ -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 | }; | ||
| @@ -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 | } | ||
| @@ -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 | }); | ||
| @@ -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 | }); | ||