History

Curate integration & connection metadata; update OpenAPI specs in place (#997)

* Curate integration & connection metadata; update OpenAPI specs in place Integrations and connections gain user-curated, agent-visible metadata, a way to refresh an OpenAPI spec without re-adding it, and lazy propagation of tool changes across every member's connections. Descriptions & name/description split - integration rows gain a `name` column distinct from `description` (name = display, description = agent-visible context); readers fall back to description on pre-split rows - connections carry a nullable, agent-visible `description` - both surface to agents: the execute tool's connection-prefix inventory, sources.list, and connections.list (slug/name echoes suppressed) - add flows prefill the description from the source (OpenAPI info.description, GraphQL schema description, MCP server instructions) - the integration Edit sheet has separate Name and Description fields with a live "what agents see" preview; per-connection Edit sheet for description + account label; the rename telemetry event is preserved updateSpec - openapi.updateSpec(slug, {spec?}) re-resolves the spec (stored source URL, Google Discovery bundle, or a pasted blob), rebuilds the tool catalog in place, and reports an added/removed tool diff; connections, credentials, policies, and curated metadata survive. Spec text is stored in the plugin blob store by content hash, matching addSpec. - POST /openapi/integrations/:slug/spec - spec fetches stay unauthenticated by design (a spec can be hosted by a third party; sending a credential there would leak it) — auth-gated specs use the paste path Plugin config in the Edit sheet - plugin-owned configuration moved into the integration Edit sheet via a new `editSheet` slot; OpenAPI contributes the spec-update controls, MCP its auth-method editor. One Save applies the metadata change then the staged plugin change. Multi-user tool convergence - integration `tools_revised_at` + connection `tools_synced_at` stamps; tools.list lazily rebuilds any visible connection whose catalog predates its integration's last config change, under the reader's own binding — so a spec update by one member reaches other members' personal connections on their next read (the owner policy prevents the updater from writing their rows directly) - the drizzle runtime schema-ensure now adds nullable columns to existing tables on boot (self-host / D1); cloud migration 0003 adds the four columns for Postgres Add-time description prefill, the metadata edit sheets, updateSpec, and the multi-user convergence are covered by new SDK tests (including a two-subject shared-database test) and e2e scenarios. * Evolve apps/local SQLite columns on boot like the other hosts apps/local stood up its schema with CREATE TABLE IF NOT EXISTS plus a hand-maintained list of ALTER TABLE ADD COLUMN statements, so columns added by a later schema (integration.name, integration.tools_revised_at, connection.description, connection.tools_synced_at) never reached databases created by an earlier baseline — the first query against one of them would 500. Route local through ensureDrizzleRuntimeSchemaFromTables, the same boot bring-up the self-host and Cloudflare hosts already use, so every nullable column the live schema declares is added in place. This removes the per-column drift between hosts; cloud Postgres keeps getting the same columns from its generated drizzle migration. Add a fumadb test proving an existing table gains the schema's new nullable columns on ensure, and that a second ensure tolerates the duplicates. * Cover metadata editing and multi-user spec convergence with e2e scenarios Two behaviors PR #997 introduced had no watchable coverage: - metadata-editing (cross-target): an integration's name and description, and a connection's description, are editable after the fact and read back — the write half that openapi-update-spec's survives-a-refresh assertion assumes. - spec-update-convergence (cloud, real multi-user org): when one member refreshes a shared integration's spec, a co-worker's OWN connection converges to the new tool catalog on the co-worker's next read, even though the editor can never write the co-worker's rows. Proves the lazy syncStaleConnectionTools path end-to-end. * Let the e2e viewer bind a specific host (HOST env) Defaults to all-interfaces as before. Setting HOST=127.0.0.1 binds loopback so the viewer can sit behind a `tailscale serve` proxy without colliding with tailscaled's own bind on the tailnet IP:port. * Rename integration.tools_revised_at → config_revised_at Tools are materialized per connection (the tool rows are keyed by owner/integration/connection); an integration has no tools, it has the config that derives them. So the integration-side staleness marker is about the config that changed, not the tools — name it for the cause. The connection keeps tools_synced_at, which is accurate (connections do have tools). The comparison now reads tools_synced_at < config_revised_at: 'this connection's tools predate the current config'. Pre-release column, so the cloud migration + snapshot are edited in place; the libSQL/D1 hosts pick up the new name via boot schema-ensure. * Regenerate the self-host route tree the merge left stale The merge commit captured bootstrap's transient regen of the self-host routes against a stale built console-routes, dropping the {-$orgSlug} prefix (admin/api-keys routes + routeTree.gen). Regenerate them so the self-host console routes match main's org-slug scope. * Split integration name from description; backfill cloud data clean Production data confirms `integration.description` was universally the display NAME (367 rows: avg 13 chars, ~0 real sentences), never a description. - description is now nullable — an actual, optional description distinct from the name. - ONE cloud migration 0005: add name + config_revised_at (+ connection description/tools_synced_at), DROP NOT NULL on description, then backfill name = description (367/367 get a real name, verified none empty) and clear description to NULL so no row carries a duplicated title. name stays column-nullable on purpose: SQLite boot-ensure hosts (self-host, Cloudflare, local) cannot add a NOT NULL column to an existing table, so the read path keeps the name ?? description ?? slug fallback.

Rhys Sullivan committed 5 months ago

Commit 2b797e21 parent aa148a275 files · +4183 −550Browse files

Unified · Split

75 files
  1. apps/cloud/drizzle/0005_integration_descriptions.sql +10 −0
  2. apps/cloud/drizzle/meta/0005_snapshot.json +1266 −0
  3. apps/cloud/drizzle/meta/_journal.json +7 −0
  4. apps/cloud/package.json +1 −0
  5. apps/cloud/src/db/executor-schema.ts +5 −1
  6. apps/local/src/db/sqlite-fumadb.ts +13 −8
  7. bun.lock +21 −18
  8. e2e/cloud/spec-update-convergence.test.ts +245 −0
  9. e2e/package.json +1 −0
  10. e2e/scenarios/metadata-editing.test.ts +120 −0
  11. e2e/scenarios/openapi-update-spec.test.ts +204 −0
  12. e2e/scenarios/tool-descriptions.test.ts +545 −0
  13. e2e/src/surfaces/mcp.ts +19 −0
  14. packages/app/src/web/shell.tsx +1 −1
  15. packages/core/api/src/connections/api.ts +16 −0
  16. packages/core/api/src/handlers/connections.ts +22 −0
  17. packages/core/api/src/handlers/integrations.ts +3 −1
  18. packages/core/api/src/integrations/api.ts +3 −0
  19. packages/core/execution/src/description.test.ts +57 −2
  20. packages/core/execution/src/description.ts +57 −9
  21. packages/core/execution/src/promise.ts +4 −0
  22. packages/core/execution/src/tool-invoker.ts +10 −1
  23. packages/core/fumadb/package.json +2 −1
  24. packages/core/fumadb/src/adapters/drizzle/runtime-ensure.test.ts +86 −0
  25. packages/core/fumadb/src/adapters/drizzle/runtime.ts +44 −0
  26. packages/core/sdk/src/client.ts +22 −1
  27. packages/core/sdk/src/connection.ts +14 −0
  28. packages/core/sdk/src/core-schema.ts +22 −1
  29. packages/core/sdk/src/core-tools.ts +2 −0
  30. packages/core/sdk/src/executor.ts +127 −5
  31. packages/core/sdk/src/index.ts +1 −0
  32. packages/core/sdk/src/integration.ts +8 −0
  33. packages/core/sdk/src/plugin.ts +16 −2
  34. packages/core/sdk/src/promise.ts +1 −0
  35. packages/core/sdk/src/shared.ts +1 −0
  36. packages/plugins/graphql/src/api/group.ts +1 −0
  37. packages/plugins/graphql/src/api/handlers.ts +1 −0
  38. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +9 −1
  39. packages/plugins/graphql/src/react/EditGraphqlSource.tsx +0 −43
  40. packages/plugins/graphql/src/react/GraphqlSourceFields.tsx +20 −0
  41. packages/plugins/graphql/src/react/atoms.ts +2 −1
  42. packages/plugins/graphql/src/react/source-plugin.ts +2 −3
  43. packages/plugins/graphql/src/sdk/plugin.ts +14 −2
  44. packages/plugins/mcp/src/api/group.ts +5 −0
  45. packages/plugins/mcp/src/api/handlers.ts +4 −0
  46. packages/plugins/mcp/src/react/AddMcpSource.tsx +11 −1
  47. packages/plugins/mcp/src/react/EditMcpSource.tsx +43 −94
  48. packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx +22 −0
  49. packages/plugins/mcp/src/react/source-plugin.tsx +4 −4
  50. packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts +1 −0
  51. packages/plugins/mcp/src/sdk/discover.ts +1 −0
  52. packages/plugins/mcp/src/sdk/manifest.ts +4 −1
  53. packages/plugins/mcp/src/sdk/plugin.ts +11 −1
  54. packages/plugins/openapi/src/api/group.ts +35 −0
  55. packages/plugins/openapi/src/api/handlers.ts +17 −0
  56. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +16 −3
  57. packages/plugins/openapi/src/react/EditOpenApiSource.tsx +0 −118
  58. packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx +39 −4
  59. packages/plugins/openapi/src/react/UpdateSpecSection.tsx +141 −0
  60. packages/plugins/openapi/src/react/atoms.ts +5 −0
  61. packages/plugins/openapi/src/react/source-plugin.ts +3 −3
  62. packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts +2 −0
  63. packages/plugins/openapi/src/sdk/extract.ts +1 −0
  64. packages/plugins/openapi/src/sdk/plugin.test.ts +212 −0
  65. packages/plugins/openapi/src/sdk/plugin.ts +148 −2
  66. packages/plugins/openapi/src/sdk/preview.ts +3 −0
  67. packages/plugins/openapi/src/sdk/spec-blob.test.ts +1 −0
  68. packages/plugins/openapi/src/sdk/types.ts +2 −0
  69. packages/react/src/api/atoms.tsx +6 −0
  70. packages/react/src/components/accounts-section.tsx +24 −2
  71. packages/react/src/components/command-palette.tsx +1 −1
  72. packages/react/src/components/metadata-edit-sheet.tsx +296 −0
  73. packages/react/src/multiplayer/shell.tsx +1 −1
  74. packages/react/src/pages/integration-detail.tsx +98 −213
  75. packages/react/src/pages/integrations.tsx +1 −1
apps/cloud/drizzle/0005_integration_descriptions.sql+10−0
@@ -0,0 +1,10 @@
1+ALTER TABLE "integration" ALTER COLUMN "description" DROP NOT NULL;--> statement-breakpoint
2+ALTER TABLE "connection" ADD COLUMN "description" text;--> statement-breakpoint
3+ALTER TABLE "connection" ADD COLUMN "tools_synced_at" bigint;--> statement-breakpoint
4+ALTER TABLE "integration" ADD COLUMN "name" text;--> statement-breakpoint
5+ALTER TABLE "integration" ADD COLUMN "config_revised_at" bigint;--> statement-breakpoint
6+-- Pre-split rows kept the display name in `description`. Move it to `name`
7+-- (its proper home), then clear `description` so it no longer carries a
8+-- duplicated title — it now means an actual, optional description.
9+UPDATE "integration" SET "name" = "description" WHERE "name" IS NULL;--> statement-breakpoint
10+UPDATE "integration" SET "description" = NULL;
apps/cloud/drizzle/meta/0005_snapshot.json+1266−0
@@ -0,0 +1,1266 @@
1+{
2+ "id": "e105c61d-2fde-4d2e-a785-a74fafd0f4ea",
3+ "prevId": "1187b7aa-fdd6-412a-bcfb-20d6ea8f8644",
4+ "version": "7",
5+ "dialect": "postgresql",
6+ "tables": {
7+ "public.accounts": {
8+ "name": "accounts",
9+ "schema": "",
10+ "columns": {
11+ "id": {
12+ "name": "id",
13+ "type": "text",
14+ "primaryKey": true,
15+ "notNull": true
16+ },
17+ "created_at": {
18+ "name": "created_at",
19+ "type": "timestamp with time zone",
20+ "primaryKey": false,
21+ "notNull": true,
22+ "default": "now()"
23+ }
24+ },
25+ "indexes": {},
26+ "foreignKeys": {},
27+ "compositePrimaryKeys": {},
28+ "uniqueConstraints": {},
29+ "policies": {},
30+ "checkConstraints": {},
31+ "isRLSEnabled": false
32+ },
33+ "public.memberships": {
34+ "name": "memberships",
35+ "schema": "",
36+ "columns": {
37+ "account_id": {
38+ "name": "account_id",
39+ "type": "text",
40+ "primaryKey": false,
41+ "notNull": true
42+ },
43+ "organization_id": {
44+ "name": "organization_id",
45+ "type": "text",
46+ "primaryKey": false,
47+ "notNull": true
48+ },
49+ "created_at": {
50+ "name": "created_at",
51+ "type": "timestamp with time zone",
52+ "primaryKey": false,
53+ "notNull": true,
54+ "default": "now()"
55+ }
56+ },
57+ "indexes": {},
58+ "foreignKeys": {
59+ "memberships_account_id_accounts_id_fk": {
60+ "name": "memberships_account_id_accounts_id_fk",
61+ "tableFrom": "memberships",
62+ "tableTo": "accounts",
63+ "columnsFrom": ["account_id"],
64+ "columnsTo": ["id"],
65+ "onDelete": "cascade",
66+ "onUpdate": "no action"
67+ },
68+ "memberships_organization_id_organizations_id_fk": {
69+ "name": "memberships_organization_id_organizations_id_fk",
70+ "tableFrom": "memberships",
71+ "tableTo": "organizations",
72+ "columnsFrom": ["organization_id"],
73+ "columnsTo": ["id"],
74+ "onDelete": "cascade",
75+ "onUpdate": "no action"
76+ }
77+ },
78+ "compositePrimaryKeys": {
79+ "memberships_account_id_organization_id_pk": {
80+ "name": "memberships_account_id_organization_id_pk",
81+ "columns": ["account_id", "organization_id"]
82+ }
83+ },
84+ "uniqueConstraints": {},
85+ "policies": {},
86+ "checkConstraints": {},
87+ "isRLSEnabled": false
88+ },
89+ "public.organizations": {
90+ "name": "organizations",
91+ "schema": "",
92+ "columns": {
93+ "id": {
94+ "name": "id",
95+ "type": "text",
96+ "primaryKey": true,
97+ "notNull": true
98+ },
99+ "name": {
100+ "name": "name",
101+ "type": "text",
102+ "primaryKey": false,
103+ "notNull": true
104+ },
105+ "slug": {
106+ "name": "slug",
107+ "type": "text",
108+ "primaryKey": false,
109+ "notNull": true
110+ },
111+ "created_at": {
112+ "name": "created_at",
113+ "type": "timestamp with time zone",
114+ "primaryKey": false,
115+ "notNull": true,
116+ "default": "now()"
117+ }
118+ },
119+ "indexes": {
120+ "organizations_slug_unique": {
121+ "name": "organizations_slug_unique",
122+ "columns": [
123+ {
124+ "expression": "slug",
125+ "isExpression": false,
126+ "asc": true,
127+ "nulls": "last"
128+ }
129+ ],
130+ "isUnique": true,
131+ "concurrently": false,
132+ "method": "btree",
133+ "with": {}
134+ }
135+ },
136+ "foreignKeys": {},
137+ "compositePrimaryKeys": {},
138+ "uniqueConstraints": {},
139+ "policies": {},
140+ "checkConstraints": {},
141+ "isRLSEnabled": false
142+ },
143+ "public.blob": {
144+ "name": "blob",
145+ "schema": "",
146+ "columns": {
147+ "namespace": {
148+ "name": "namespace",
149+ "type": "varchar(255)",
150+ "primaryKey": false,
151+ "notNull": true
152+ },
153+ "key": {
154+ "name": "key",
155+ "type": "varchar(255)",
156+ "primaryKey": false,
157+ "notNull": true
158+ },
159+ "value": {
160+ "name": "value",
161+ "type": "text",
162+ "primaryKey": false,
163+ "notNull": true
164+ },
165+ "row_id": {
166+ "name": "row_id",
167+ "type": "varchar(255)",
168+ "primaryKey": true,
169+ "notNull": true
170+ },
171+ "id": {
172+ "name": "id",
173+ "type": "varchar(255)",
174+ "primaryKey": false,
175+ "notNull": true
176+ }
177+ },
178+ "indexes": {
179+ "blob_id_uidx": {
180+ "name": "blob_id_uidx",
181+ "columns": [
182+ {
183+ "expression": "id",
184+ "isExpression": false,
185+ "asc": true,
186+ "nulls": "last"
187+ }
188+ ],
189+ "isUnique": true,
190+ "concurrently": false,
191+ "method": "btree",
192+ "with": {}
193+ }
194+ },
195+ "foreignKeys": {},
196+ "compositePrimaryKeys": {},
197+ "uniqueConstraints": {},
198+ "policies": {},
199+ "checkConstraints": {},
200+ "isRLSEnabled": false
201+ },
202+ "public.connection": {
203+ "name": "connection",
204+ "schema": "",
205+ "columns": {
206+ "integration": {
207+ "name": "integration",
208+ "type": "varchar(255)",
209+ "primaryKey": false,
210+ "notNull": true
211+ },
212+ "name": {
213+ "name": "name",
214+ "type": "varchar(255)",
215+ "primaryKey": false,
216+ "notNull": true
217+ },
218+ "template": {
219+ "name": "template",
220+ "type": "text",
221+ "primaryKey": false,
222+ "notNull": true
223+ },
224+ "provider": {
225+ "name": "provider",
226+ "type": "text",
227+ "primaryKey": false,
228+ "notNull": true
229+ },
230+ "item_ids": {
231+ "name": "item_ids",
232+ "type": "json",
233+ "primaryKey": false,
234+ "notNull": true
235+ },
236+ "identity_label": {
237+ "name": "identity_label",
238+ "type": "text",
239+ "primaryKey": false,
240+ "notNull": false
241+ },
242+ "description": {
243+ "name": "description",
244+ "type": "text",
245+ "primaryKey": false,
246+ "notNull": false
247+ },
248+ "tools_synced_at": {
249+ "name": "tools_synced_at",
250+ "type": "bigint",
251+ "primaryKey": false,
252+ "notNull": false
253+ },
254+ "oauth_client": {
255+ "name": "oauth_client",
256+ "type": "text",
257+ "primaryKey": false,
258+ "notNull": false
259+ },
260+ "oauth_client_owner": {
261+ "name": "oauth_client_owner",
262+ "type": "text",
263+ "primaryKey": false,
264+ "notNull": false
265+ },
266+ "refresh_item_id": {
267+ "name": "refresh_item_id",
268+ "type": "text",
269+ "primaryKey": false,
270+ "notNull": false
271+ },
272+ "expires_at": {
273+ "name": "expires_at",
274+ "type": "bigint",
275+ "primaryKey": false,
276+ "notNull": false
277+ },
278+ "oauth_scope": {
279+ "name": "oauth_scope",
280+ "type": "text",
281+ "primaryKey": false,
282+ "notNull": false
283+ },
284+ "provider_state": {
285+ "name": "provider_state",
286+ "type": "json",
287+ "primaryKey": false,
288+ "notNull": false
289+ },
290+ "created_at": {
291+ "name": "created_at",
292+ "type": "timestamp",
293+ "primaryKey": false,
294+ "notNull": true
295+ },
296+ "updated_at": {
297+ "name": "updated_at",
298+ "type": "timestamp",
299+ "primaryKey": false,
300+ "notNull": true
301+ },
302+ "row_id": {
303+ "name": "row_id",
304+ "type": "varchar(255)",
305+ "primaryKey": true,
306+ "notNull": true
307+ },
308+ "tenant": {
309+ "name": "tenant",
310+ "type": "varchar(255)",
311+ "primaryKey": false,
312+ "notNull": true
313+ },
314+ "owner": {
315+ "name": "owner",
316+ "type": "varchar(255)",
317+ "primaryKey": false,
318+ "notNull": true
319+ },
320+ "subject": {
321+ "name": "subject",
322+ "type": "varchar(255)",
323+ "primaryKey": false,
324+ "notNull": true
325+ }
326+ },
327+ "indexes": {
328+ "connection_uidx": {
329+ "name": "connection_uidx",
330+ "columns": [
331+ {
332+ "expression": "tenant",
333+ "isExpression": false,
334+ "asc": true,
335+ "nulls": "last"
336+ },
337+ {
338+ "expression": "owner",
339+ "isExpression": false,
340+ "asc": true,
341+ "nulls": "last"
342+ },
343+ {
344+ "expression": "subject",
345+ "isExpression": false,
346+ "asc": true,
347+ "nulls": "last"
348+ },
349+ {
350+ "expression": "integration",
351+ "isExpression": false,
352+ "asc": true,
353+ "nulls": "last"
354+ },
355+ {
356+ "expression": "name",
357+ "isExpression": false,
358+ "asc": true,
359+ "nulls": "last"
360+ }
361+ ],
362+ "isUnique": true,
363+ "concurrently": false,
364+ "method": "btree",
365+ "with": {}
366+ }
367+ },
368+ "foreignKeys": {},
369+ "compositePrimaryKeys": {},
370+ "uniqueConstraints": {},
371+ "policies": {},
372+ "checkConstraints": {},
373+ "isRLSEnabled": false
374+ },
375+ "public.definition": {
376+ "name": "definition",
377+ "schema": "",
378+ "columns": {
379+ "integration": {
380+ "name": "integration",
381+ "type": "varchar(255)",
382+ "primaryKey": false,
383+ "notNull": true
384+ },
385+ "connection": {
386+ "name": "connection",
387+ "type": "varchar(255)",
388+ "primaryKey": false,
389+ "notNull": true
390+ },
391+ "plugin_id": {
392+ "name": "plugin_id",
393+ "type": "text",
394+ "primaryKey": false,
395+ "notNull": true
396+ },
397+ "name": {
398+ "name": "name",
399+ "type": "text",
400+ "primaryKey": false,
401+ "notNull": true
402+ },
403+ "schema": {
404+ "name": "schema",
405+ "type": "json",
406+ "primaryKey": false,
407+ "notNull": true
408+ },
409+ "created_at": {
410+ "name": "created_at",
411+ "type": "timestamp",
412+ "primaryKey": false,
413+ "notNull": true
414+ },
415+ "row_id": {
416+ "name": "row_id",
417+ "type": "varchar(255)",
418+ "primaryKey": true,
419+ "notNull": true
420+ },
421+ "tenant": {
422+ "name": "tenant",
423+ "type": "varchar(255)",
424+ "primaryKey": false,
425+ "notNull": true
426+ },
427+ "owner": {
428+ "name": "owner",
429+ "type": "varchar(255)",
430+ "primaryKey": false,
431+ "notNull": true
432+ },
433+ "subject": {
434+ "name": "subject",
435+ "type": "varchar(255)",
436+ "primaryKey": false,
437+ "notNull": true
438+ }
439+ },
440+ "indexes": {
441+ "definition_uidx": {
442+ "name": "definition_uidx",
443+ "columns": [
444+ {
445+ "expression": "tenant",
446+ "isExpression": false,
447+ "asc": true,
448+ "nulls": "last"
449+ },
450+ {
451+ "expression": "owner",
452+ "isExpression": false,
453+ "asc": true,
454+ "nulls": "last"
455+ },
456+ {
457+ "expression": "subject",
458+ "isExpression": false,
459+ "asc": true,
460+ "nulls": "last"
461+ },
462+ {
463+ "expression": "integration",
464+ "isExpression": false,
465+ "asc": true,
466+ "nulls": "last"
467+ },
468+ {
469+ "expression": "connection",
470+ "isExpression": false,
471+ "asc": true,
472+ "nulls": "last"
473+ },
474+ {
475+ "expression": "name",
476+ "isExpression": false,
477+ "asc": true,
478+ "nulls": "last"
479+ }
480+ ],
481+ "isUnique": true,
482+ "concurrently": false,
483+ "method": "btree",
484+ "with": {}
485+ }
486+ },
487+ "foreignKeys": {},
488+ "compositePrimaryKeys": {},
489+ "uniqueConstraints": {},
490+ "policies": {},
491+ "checkConstraints": {},
492+ "isRLSEnabled": false
493+ },
494+ "public.integration": {
495+ "name": "integration",
496+ "schema": "",
497+ "columns": {
498+ "slug": {
499+ "name": "slug",
500+ "type": "varchar(255)",
501+ "primaryKey": false,
502+ "notNull": true
503+ },
504+ "plugin_id": {
505+ "name": "plugin_id",
506+ "type": "text",
507+ "primaryKey": false,
508+ "notNull": true
509+ },
510+ "name": {
511+ "name": "name",
512+ "type": "text",
513+ "primaryKey": false,
514+ "notNull": false
515+ },
516+ "description": {
517+ "name": "description",
518+ "type": "text",
519+ "primaryKey": false,
520+ "notNull": false
521+ },
522+ "config_revised_at": {
523+ "name": "config_revised_at",
524+ "type": "bigint",
525+ "primaryKey": false,
526+ "notNull": false
527+ },
528+ "config": {
529+ "name": "config",
530+ "type": "json",
531+ "primaryKey": false,
532+ "notNull": false
533+ },
534+ "can_remove": {
535+ "name": "can_remove",
536+ "type": "boolean",
537+ "primaryKey": false,
538+ "notNull": true,
539+ "default": true
540+ },
541+ "can_refresh": {
542+ "name": "can_refresh",
543+ "type": "boolean",
544+ "primaryKey": false,
545+ "notNull": true,
546+ "default": false
547+ },
548+ "created_at": {
549+ "name": "created_at",
550+ "type": "timestamp",
551+ "primaryKey": false,
552+ "notNull": true
553+ },
554+ "updated_at": {
555+ "name": "updated_at",
556+ "type": "timestamp",
557+ "primaryKey": false,
558+ "notNull": true
559+ },
560+ "row_id": {
561+ "name": "row_id",
562+ "type": "varchar(255)",
563+ "primaryKey": true,
564+ "notNull": true
565+ },
566+ "tenant": {
567+ "name": "tenant",
568+ "type": "varchar(255)",
569+ "primaryKey": false,
570+ "notNull": true
571+ }
572+ },
573+ "indexes": {
574+ "integration_uidx": {
575+ "name": "integration_uidx",
576+ "columns": [
577+ {
578+ "expression": "tenant",
579+ "isExpression": false,
580+ "asc": true,
581+ "nulls": "last"
582+ },
583+ {
584+ "expression": "slug",
585+ "isExpression": false,
586+ "asc": true,
587+ "nulls": "last"
588+ }
589+ ],
590+ "isUnique": true,
591+ "concurrently": false,
592+ "method": "btree",
593+ "with": {}
594+ }
595+ },
596+ "foreignKeys": {},
597+ "compositePrimaryKeys": {},
598+ "uniqueConstraints": {},
599+ "policies": {},
600+ "checkConstraints": {},
601+ "isRLSEnabled": false
602+ },
603+ "public.oauth_client": {
604+ "name": "oauth_client",
605+ "schema": "",
606+ "columns": {
607+ "slug": {
608+ "name": "slug",
609+ "type": "varchar(255)",
610+ "primaryKey": false,
611+ "notNull": true
612+ },
613+ "authorization_url": {
614+ "name": "authorization_url",
615+ "type": "text",
616+ "primaryKey": false,
617+ "notNull": true
618+ },
619+ "token_url": {
620+ "name": "token_url",
621+ "type": "text",
622+ "primaryKey": false,
623+ "notNull": true
624+ },
625+ "grant": {
626+ "name": "grant",
627+ "type": "text",
628+ "primaryKey": false,
629+ "notNull": true
630+ },
631+ "client_id": {
632+ "name": "client_id",
633+ "type": "text",
634+ "primaryKey": false,
635+ "notNull": true
636+ },
637+ "client_secret_item_id": {
638+ "name": "client_secret_item_id",
639+ "type": "text",
640+ "primaryKey": false,
641+ "notNull": false
642+ },
643+ "resource": {
644+ "name": "resource",
645+ "type": "text",
646+ "primaryKey": false,
647+ "notNull": false
648+ },
649+ "origin_kind": {
650+ "name": "origin_kind",
651+ "type": "text",
652+ "primaryKey": false,
653+ "notNull": false
654+ },
655+ "origin_integration": {
656+ "name": "origin_integration",
657+ "type": "text",
658+ "primaryKey": false,
659+ "notNull": false
660+ },
661+ "created_at": {
662+ "name": "created_at",
663+ "type": "timestamp",
664+ "primaryKey": false,
665+ "notNull": true
666+ },
667+ "row_id": {
668+ "name": "row_id",
669+ "type": "varchar(255)",
670+ "primaryKey": true,
671+ "notNull": true
672+ },
673+ "tenant": {
674+ "name": "tenant",
675+ "type": "varchar(255)",
676+ "primaryKey": false,
677+ "notNull": true
678+ },
679+ "owner": {
680+ "name": "owner",
681+ "type": "varchar(255)",
682+ "primaryKey": false,
683+ "notNull": true
684+ },
685+ "subject": {
686+ "name": "subject",
687+ "type": "varchar(255)",
688+ "primaryKey": false,
689+ "notNull": true
690+ }
691+ },
692+ "indexes": {
693+ "oauth_client_uidx": {
694+ "name": "oauth_client_uidx",
695+ "columns": [
696+ {
697+ "expression": "tenant",
698+ "isExpression": false,
699+ "asc": true,
700+ "nulls": "last"
701+ },
702+ {
703+ "expression": "owner",
704+ "isExpression": false,
705+ "asc": true,
706+ "nulls": "last"
707+ },
708+ {
709+ "expression": "subject",
710+ "isExpression": false,
711+ "asc": true,
712+ "nulls": "last"
713+ },
714+ {
715+ "expression": "slug",
716+ "isExpression": false,
717+ "asc": true,
718+ "nulls": "last"
719+ }
720+ ],
721+ "isUnique": true,
722+ "concurrently": false,
723+ "method": "btree",
724+ "with": {}
725+ }
726+ },
727+ "foreignKeys": {},
728+ "compositePrimaryKeys": {},
729+ "uniqueConstraints": {},
730+ "policies": {},
731+ "checkConstraints": {},
732+ "isRLSEnabled": false
733+ },
734+ "public.oauth_session": {
735+ "name": "oauth_session",
736+ "schema": "",
737+ "columns": {
738+ "state": {
739+ "name": "state",
740+ "type": "varchar(255)",
741+ "primaryKey": false,
742+ "notNull": true
743+ },
744+ "client_slug": {
745+ "name": "client_slug",
746+ "type": "text",
747+ "primaryKey": false,
748+ "notNull": true
749+ },
750+ "integration": {
751+ "name": "integration",
752+ "type": "text",
753+ "primaryKey": false,
754+ "notNull": true
755+ },
756+ "name": {
757+ "name": "name",
758+ "type": "text",
759+ "primaryKey": false,
760+ "notNull": true
761+ },
762+ "template": {
763+ "name": "template",
764+ "type": "text",
765+ "primaryKey": false,
766+ "notNull": true
767+ },
768+ "redirect_url": {
769+ "name": "redirect_url",
770+ "type": "text",
771+ "primaryKey": false,
772+ "notNull": true
773+ },
774+ "pkce_verifier": {
775+ "name": "pkce_verifier",
776+ "type": "text",
777+ "primaryKey": false,
778+ "notNull": false
779+ },
780+ "identity_label": {
781+ "name": "identity_label",
782+ "type": "text",
783+ "primaryKey": false,
784+ "notNull": false
785+ },
786+ "payload": {
787+ "name": "payload",
788+ "type": "json",
789+ "primaryKey": false,
790+ "notNull": true
791+ },
792+ "expires_at": {
793+ "name": "expires_at",
794+ "type": "bigint",
795+ "primaryKey": false,
796+ "notNull": true
797+ },
798+ "created_at": {
799+ "name": "created_at",
800+ "type": "timestamp",
801+ "primaryKey": false,
802+ "notNull": true
803+ },
804+ "row_id": {
805+ "name": "row_id",
806+ "type": "varchar(255)",
807+ "primaryKey": true,
808+ "notNull": true
809+ },
810+ "tenant": {
811+ "name": "tenant",
812+ "type": "varchar(255)",
813+ "primaryKey": false,
814+ "notNull": true
815+ },
816+ "owner": {
817+ "name": "owner",
818+ "type": "varchar(255)",
819+ "primaryKey": false,
820+ "notNull": true
821+ },
822+ "subject": {
823+ "name": "subject",
824+ "type": "varchar(255)",
825+ "primaryKey": false,
826+ "notNull": true
827+ }
828+ },
829+ "indexes": {
830+ "oauth_session_uidx": {
831+ "name": "oauth_session_uidx",
832+ "columns": [
833+ {
834+ "expression": "tenant",
835+ "isExpression": false,
836+ "asc": true,
837+ "nulls": "last"
838+ },
839+ {
840+ "expression": "state",
841+ "isExpression": false,
842+ "asc": true,
843+ "nulls": "last"
844+ }
845+ ],
846+ "isUnique": true,
847+ "concurrently": false,
848+ "method": "btree",
849+ "with": {}
850+ }
851+ },
852+ "foreignKeys": {},
853+ "compositePrimaryKeys": {},
854+ "uniqueConstraints": {},
855+ "policies": {},
856+ "checkConstraints": {},
857+ "isRLSEnabled": false
858+ },
859+ "public.plugin_storage": {
860+ "name": "plugin_storage",
861+ "schema": "",
862+ "columns": {
863+ "plugin_id": {
864+ "name": "plugin_id",
865+ "type": "varchar(255)",
866+ "primaryKey": false,
867+ "notNull": true
868+ },
869+ "collection": {
870+ "name": "collection",
871+ "type": "varchar(255)",
872+ "primaryKey": false,
873+ "notNull": true
874+ },
875+ "key": {
876+ "name": "key",
877+ "type": "varchar(255)",
878+ "primaryKey": false,
879+ "notNull": true
880+ },
881+ "data": {
882+ "name": "data",
883+ "type": "json",
884+ "primaryKey": false,
885+ "notNull": true
886+ },
887+ "created_at": {
888+ "name": "created_at",
889+ "type": "timestamp",
890+ "primaryKey": false,
891+ "notNull": true
892+ },
893+ "updated_at": {
894+ "name": "updated_at",
895+ "type": "timestamp",
896+ "primaryKey": false,
897+ "notNull": true
898+ },
899+ "row_id": {
900+ "name": "row_id",
901+ "type": "varchar(255)",
902+ "primaryKey": true,
903+ "notNull": true
904+ },
905+ "tenant": {
906+ "name": "tenant",
907+ "type": "varchar(255)",
908+ "primaryKey": false,
909+ "notNull": true
910+ },
911+ "owner": {
912+ "name": "owner",
913+ "type": "varchar(255)",
914+ "primaryKey": false,
915+ "notNull": true
916+ },
917+ "subject": {
918+ "name": "subject",
919+ "type": "varchar(255)",
920+ "primaryKey": false,
921+ "notNull": true
922+ }
923+ },
924+ "indexes": {
925+ "plugin_storage_uidx": {
926+ "name": "plugin_storage_uidx",
927+ "columns": [
928+ {
929+ "expression": "tenant",
930+ "isExpression": false,
931+ "asc": true,
932+ "nulls": "last"
933+ },
934+ {
935+ "expression": "owner",
936+ "isExpression": false,
937+ "asc": true,
938+ "nulls": "last"
939+ },
940+ {
941+ "expression": "subject",
942+ "isExpression": false,
943+ "asc": true,
944+ "nulls": "last"
945+ },
946+ {
947+ "expression": "plugin_id",
948+ "isExpression": false,
949+ "asc": true,
950+ "nulls": "last"
951+ },
952+ {
953+ "expression": "collection",
954+ "isExpression": false,
955+ "asc": true,
956+ "nulls": "last"
957+ },
958+ {
959+ "expression": "key",
960+ "isExpression": false,
961+ "asc": true,
962+ "nulls": "last"
963+ }
964+ ],
965+ "isUnique": true,
966+ "concurrently": false,
967+ "method": "btree",
968+ "with": {}
969+ }
970+ },
971+ "foreignKeys": {},
972+ "compositePrimaryKeys": {},
973+ "uniqueConstraints": {},
974+ "policies": {},
975+ "checkConstraints": {},
976+ "isRLSEnabled": false
977+ },
978+ "public.private_executor_cloud_settings": {
979+ "name": "private_executor_cloud_settings",
980+ "schema": "",
981+ "columns": {
982+ "id": {
983+ "name": "id",
984+ "type": "varchar(255)",
985+ "primaryKey": true,
986+ "notNull": true
987+ },
988+ "version": {
989+ "name": "version",
990+ "type": "varchar(255)",
991+ "primaryKey": false,
992+ "notNull": true,
993+ "default": "'1.0.0'"
994+ }
995+ },
996+ "indexes": {},
997+ "foreignKeys": {},
998+ "compositePrimaryKeys": {},
999+ "uniqueConstraints": {},
1000+ "policies": {},
1001+ "checkConstraints": {},
1002+ "isRLSEnabled": false
1003+ },
1004+ "public.tool": {
1005+ "name": "tool",
1006+ "schema": "",
1007+ "columns": {
1008+ "integration": {
1009+ "name": "integration",
1010+ "type": "varchar(255)",
1011+ "primaryKey": false,
1012+ "notNull": true
1013+ },
1014+ "connection": {
1015+ "name": "connection",
1016+ "type": "varchar(255)",
1017+ "primaryKey": false,
1018+ "notNull": true
1019+ },
1020+ "plugin_id": {
1021+ "name": "plugin_id",
1022+ "type": "text",
1023+ "primaryKey": false,
1024+ "notNull": true
1025+ },
1026+ "name": {
1027+ "name": "name",
1028+ "type": "varchar(255)",
1029+ "primaryKey": false,
1030+ "notNull": true
1031+ },
1032+ "description": {
1033+ "name": "description",
1034+ "type": "text",
1035+ "primaryKey": false,
1036+ "notNull": true
1037+ },
1038+ "input_schema": {
1039+ "name": "input_schema",
1040+ "type": "json",
1041+ "primaryKey": false,
1042+ "notNull": false
1043+ },
1044+ "output_schema": {
1045+ "name": "output_schema",
1046+ "type": "json",
1047+ "primaryKey": false,
1048+ "notNull": false
1049+ },
1050+ "annotations": {
1051+ "name": "annotations",
1052+ "type": "json",
1053+ "primaryKey": false,
1054+ "notNull": false
1055+ },
1056+ "created_at": {
1057+ "name": "created_at",
1058+ "type": "timestamp",
1059+ "primaryKey": false,
1060+ "notNull": true
1061+ },
1062+ "updated_at": {
1063+ "name": "updated_at",
1064+ "type": "timestamp",
1065+ "primaryKey": false,
1066+ "notNull": true
1067+ },
1068+ "row_id": {
1069+ "name": "row_id",
1070+ "type": "varchar(255)",
1071+ "primaryKey": true,
1072+ "notNull": true
1073+ },
1074+ "tenant": {
1075+ "name": "tenant",
1076+ "type": "varchar(255)",
1077+ "primaryKey": false,
1078+ "notNull": true
1079+ },
1080+ "owner": {
1081+ "name": "owner",
1082+ "type": "varchar(255)",
1083+ "primaryKey": false,
1084+ "notNull": true
1085+ },
1086+ "subject": {
1087+ "name": "subject",
1088+ "type": "varchar(255)",
1089+ "primaryKey": false,
1090+ "notNull": true
1091+ }
1092+ },
1093+ "indexes": {
1094+ "tool_uidx": {
1095+ "name": "tool_uidx",
1096+ "columns": [
1097+ {
1098+ "expression": "tenant",
1099+ "isExpression": false,
1100+ "asc": true,
1101+ "nulls": "last"
1102+ },
1103+ {
1104+ "expression": "owner",
1105+ "isExpression": false,
1106+ "asc": true,
1107+ "nulls": "last"
1108+ },
1109+ {
1110+ "expression": "subject",
1111+ "isExpression": false,
1112+ "asc": true,
1113+ "nulls": "last"
1114+ },
1115+ {
1116+ "expression": "integration",
1117+ "isExpression": false,
1118+ "asc": true,
1119+ "nulls": "last"
1120+ },
1121+ {
1122+ "expression": "connection",
1123+ "isExpression": false,
1124+ "asc": true,
1125+ "nulls": "last"
1126+ },
1127+ {
1128+ "expression": "name",
1129+ "isExpression": false,
1130+ "asc": true,
1131+ "nulls": "last"
1132+ }
1133+ ],
1134+ "isUnique": true,
1135+ "concurrently": false,
1136+ "method": "btree",
1137+ "with": {}
1138+ }
1139+ },
1140+ "foreignKeys": {},
1141+ "compositePrimaryKeys": {},
1142+ "uniqueConstraints": {},
1143+ "policies": {},
1144+ "checkConstraints": {},
1145+ "isRLSEnabled": false
1146+ },
1147+ "public.tool_policy": {
1148+ "name": "tool_policy",
1149+ "schema": "",
1150+ "columns": {
1151+ "id": {
1152+ "name": "id",
1153+ "type": "varchar(255)",
1154+ "primaryKey": false,
1155+ "notNull": true
1156+ },
1157+ "pattern": {
1158+ "name": "pattern",
1159+ "type": "text",
1160+ "primaryKey": false,
1161+ "notNull": true
1162+ },
1163+ "action": {
1164+ "name": "action",
1165+ "type": "text",
1166+ "primaryKey": false,
1167+ "notNull": true
1168+ },
1169+ "position": {
1170+ "name": "position",
1171+ "type": "text",
1172+ "primaryKey": false,
1173+ "notNull": true
1174+ },
1175+ "created_at": {
1176+ "name": "created_at",
1177+ "type": "timestamp",
1178+ "primaryKey": false,
1179+ "notNull": true
1180+ },
1181+ "updated_at": {
1182+ "name": "updated_at",
1183+ "type": "timestamp",
1184+ "primaryKey": false,
1185+ "notNull": true
1186+ },
1187+ "row_id": {
1188+ "name": "row_id",
1189+ "type": "varchar(255)",
1190+ "primaryKey": true,
1191+ "notNull": true
1192+ },
1193+ "tenant": {
1194+ "name": "tenant",
1195+ "type": "varchar(255)",
1196+ "primaryKey": false,
1197+ "notNull": true
1198+ },
1199+ "owner": {
1200+ "name": "owner",
1201+ "type": "varchar(255)",
1202+ "primaryKey": false,
1203+ "notNull": true
1204+ },
1205+ "subject": {
1206+ "name": "subject",
1207+ "type": "varchar(255)",
1208+ "primaryKey": false,
1209+ "notNull": true
1210+ }
1211+ },
1212+ "indexes": {
1213+ "tool_policy_uidx": {
1214+ "name": "tool_policy_uidx",
1215+ "columns": [
1216+ {
1217+ "expression": "tenant",
1218+ "isExpression": false,
1219+ "asc": true,
1220+ "nulls": "last"
1221+ },
1222+ {
1223+ "expression": "owner",
1224+ "isExpression": false,
1225+ "asc": true,
1226+ "nulls": "last"
1227+ },
1228+ {
1229+ "expression": "subject",
1230+ "isExpression": false,
1231+ "asc": true,
1232+ "nulls": "last"
1233+ },
1234+ {
1235+ "expression": "id",
1236+ "isExpression": false,
1237+ "asc": true,
1238+ "nulls": "last"
1239+ }
1240+ ],
1241+ "isUnique": true,
1242+ "concurrently": false,
1243+ "method": "btree",
1244+ "with": {}
1245+ }
1246+ },
1247+ "foreignKeys": {},
1248+ "compositePrimaryKeys": {},
1249+ "uniqueConstraints": {},
1250+ "policies": {},
1251+ "checkConstraints": {},
1252+ "isRLSEnabled": false
1253+ }
1254+ },
1255+ "enums": {},
1256+ "schemas": {},
1257+ "sequences": {},
1258+ "roles": {},
1259+ "policies": {},
1260+ "views": {},
1261+ "_meta": {
1262+ "columns": {},
1263+ "schemas": {},
1264+ "tables": {}
1265+ }
1266+}
apps/cloud/drizzle/meta/_journal.json+7−0
@@ -36,6 +36,13 @@ · 35 unmodified lines
3636 "when": 1781375837041,
3737 "tag": "0004_nervous_quasar",
3838 "breakpoints": true
39+ },
40+ {
41+ "idx": 5,
42+ "version": "7",
43+ "when": 1781491100009,
44+ "tag": "0005_integration_descriptions",
45+ "breakpoints": true
3946 }
4047 ]
4148 }
apps/cloud/package.json+1−0
@@ -76,6 +76,7 @@ · 75 unmodified lines
7676 "sonner": "^2.0.7"
7777 },
7878 "devDependencies": {
79+ "@cloudflare/vitest-pool-workers": "^0.15.0",
7980 "@cloudflare/workers-types": "^4.20250620.0",
8081 "@effect/platform-node": "catalog:",
8182 "@effect/vitest": "catalog:",
apps/cloud/src/db/executor-schema.ts+5−1
@@ -15,7 +15,9 @@ export const integration = pgTable( · 14 unmodified lines
1515 {
1616 slug: varchar("slug", { length: 255 }).notNull(),
1717 plugin_id: text("plugin_id").notNull(),
18− description: text("description").notNull(),
18+ name: text("name"),
19+ description: text("description"),
20+ config_revised_at: bigint("config_revised_at", { mode: "bigint" }),
1921 config: json("config"),
2022 can_remove: boolean("can_remove").notNull().default(true),
2123 can_refresh: boolean("can_refresh").notNull().default(false),
@@ -39,6 +41,8 @@ export const connection = pgTable( · 17 unmodified lines
3941 provider: text("provider").notNull(),
4042 item_ids: json("item_ids").notNull(),
4143 identity_label: text("identity_label"),
44+ description: text("description"),
45+ tools_synced_at: bigint("tools_synced_at", { mode: "bigint" }),
4246 oauth_client: text("oauth_client"),
4347 oauth_client_owner: text("oauth_client_owner"),
4448 refresh_item_id: text("refresh_item_id"),
apps/local/src/db/sqlite-fumadb.ts+13−8
@@ -4,7 +4,7 @@ import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql"; · 3 unmodified lines
44 import { type FumaDB } from "@executor-js/fumadb";
55 import {
66 createDrizzleRuntimeSchemaFromTables,
7− createDrizzleRuntimeSchemaSqlFromTables,
7+ ensureDrizzleRuntimeSchemaFromTables,
88 } from "@executor-js/fumadb/adapters/drizzle";
99 import { type schema as fumaSchema, type RelationsMap } from "@executor-js/fumadb/schema";
1010
@@ -49,18 +49,23 @@ export const createSqliteFumaDb = async <const TTables extends FumaTables>( · 38 unmodified lines
4949 });
5050 const drizzleDb = drizzle({ client, schema });
5151
52− for (const statement of createDrizzleRuntimeSchemaSqlFromTables({
52+ // CREATE TABLE IF NOT EXISTS for fresh files, plus ALTER TABLE ADD COLUMN for
53+ // every nullable column the running schema declares — so files created by an
54+ // earlier baseline gain new columns on boot instead of 500ing on first query.
55+ // This is the same bring-up the self-host and Cloudflare hosts run; keeping
56+ // local on it stops per-column drift between hosts. Idempotent.
57+ await ensureDrizzleRuntimeSchemaFromTables(drizzleDb, {
5358 tables: options.tables,
5459 namespace: options.namespace,
5560 version,
5661 provider: "sqlite",
57− })) {
58− await client.execute(statement);
59− }
62+ });
6063
61− // Defensive column adds for libSQL files created by earlier v2 baselines —
62− // the bring-up above is CREATE TABLE IF NOT EXISTS and won't add a column to
63− // an already-created table. Idempotent.
64+ // Defensive column adds for libSQL files created by earlier v2 baselines.
65+ // The generic bring-up above already evolves every nullable column the live
66+ // schema declares, so these are mostly redundant now; they are kept as an
67+ // explicit safety net and to cover any column the schema no longer declares.
68+ // Idempotent.
6469 const connectionColumns = await client.execute("PRAGMA table_info('connection')");
6570 if (
6671 connectionColumns.rows.length > 0 &&
bun.lock+21−18
@@ -100,6 +100,7 @@ · 99 unmodified lines
100100 "sonner": "^2.0.7",
101101 },
102102 "devDependencies": {
103+ "@cloudflare/vitest-pool-workers": "^0.15.0",
103104 "@cloudflare/workers-types": "^4.20250620.0",
104105 "@effect/platform-node": "catalog:",
105106 "@effect/vitest": "catalog:",
@@ -349,6 +350,7 @@ · 243 unmodified lines
349350 "@types/react": "catalog:",
350351 "@types/react-dom": "catalog:",
351352 "@vitejs/plugin-react": "catalog:",
353+ "graphql": "^16.12.0",
352354 "iron-webcrypto": "^2.0.0",
353355 "typescript": "catalog:",
354356 "vite": "catalog:",
@@ -522,6 +524,7 @@ · 167 unmodified lines
522524 },
523525 "devDependencies": {
524526 "@effect/vitest": "catalog:",
527+ "@libsql/client": "catalog:",
525528 "@types/better-sqlite3": "^7.6.13",
526529 "@types/node": "catalog:",
527530 "@types/pg": "^8.20.0",
@@ -4300,7 +4303,7 @@ · 3772 unmodified lines
43004303
43014304 "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
43024305
4303− "miniflare": ["miniflare@4.20260526.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260526.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA=="],
4306+ "miniflare": ["miniflare@4.20260424.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260424.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw=="],
43044307
43054308 "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
43064309
@@ -5400,8 +5403,6 @@ · 1093 unmodified lines
54005403
54015404 "@cloudflare/vitest-pool-workers/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
54025405
5403− "@cloudflare/vitest-pool-workers/miniflare": ["miniflare@4.20260424.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260424.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw=="],
5404−
54055406 "@cloudflare/vitest-pool-workers/wrangler": ["wrangler@4.85.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260424.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260424.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-93cwt2RPb1qdcmEgPzH7ybiLN4BIKoWpscIX6SywjHrQOeIZrQk2haoc3XMLKtQTmzapxza9OuDD+kMHpsuuhg=="],
54065407
54075408 "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -6020,7 +6021,7 @@ · 612 unmodified lines
60206021
60216022 "miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="],
60226023
6023− "miniflare/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
6024+ "miniflare/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="],
60246025
60256026 "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
60266027
@@ -6144,6 +6145,8 @@ · 117 unmodified lines
61446145
61456146 "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
61466147
6148+ "wrangler/miniflare": ["miniflare@4.20260526.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260526.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA=="],
6149+
61476150 "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
61486151
61496152 "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
@@ -6224,10 +6227,6 @@ · 74 unmodified lines
62246227
62256228 "@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
62266229
6227− "@cloudflare/vitest-pool-workers/miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="],
6228−
6229− "@cloudflare/vitest-pool-workers/miniflare/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="],
6230−
62316230 "@cloudflare/vitest-pool-workers/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
62326231
62336232 "@cloudflare/vitest-pool-workers/wrangler/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="],
@@ -6678,6 +6677,16 @@ · 444 unmodified lines
66786677
66796678 "mimetext/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
66806679
6680+ "miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="],
6681+
6682+ "miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="],
6683+
6684+ "miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="],
6685+
6686+ "miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="],
6687+
6688+ "miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="],
6689+
66816690 "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
66826691
66836692 "ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
@@ -6774,6 +6783,10 @@ · 90 unmodified lines
67746783
67756784 "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
67766785
6786+ "wrangler/miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="],
6787+
6788+ "wrangler/miniflare/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
6789+
67776790 "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
67786791
67796792 "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
@@ -6854,16 +6867,6 @@ · 74 unmodified lines
68546867
68556868 "@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260415.1", "", { "os": "win32", "cpu": "x64" }, "sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ=="],
68566869
6857− "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="],
6858−
6859− "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="],
6860−
6861− "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="],
6862−
6863− "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="],
6864−
6865− "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="],
6866−
68676870 "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="],
68686871
68696872 "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="],
e2e/cloud/spec-update-convergence.test.ts+245−0
@@ -0,0 +1,245 @@
1+// Cloud-only (needs real multi-user organizations): when one member refreshes
2+// a shared integration's spec, a DIFFERENT member's OWN connection converges to
3+// the new tool catalog on that member's next read — not just the editor's.
4+//
5+// This is the lazy-convergence design. Tools are stored per connection. The
6+// editor stamps the integration's `config_revised_at`, but the owner policy lets
7+// them rebuild catalogs only in their own partition — they cannot write a
8+// co-worker's personal connection rows. So each member's connection carries a
9+// `tools_synced_at` and rebuilds itself the next time THAT member lists tools.
10+// Without it, a colleague keeps calling tools that the API no longer serves.
11+import { randomBytes } from "node:crypto";
12+import { createServer } from "node:http";
13+
14+import { expect } from "@effect/vitest";
15+import { Effect } from "effect";
16+import type { HttpApiClient } from "effect/unstable/httpapi";
17+import { composePluginApi } from "@executor-js/api/server";
18+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
19+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
20+
21+import { scenario } from "../src/scenario";
22+import { Api, Target } from "../src/services";
23+import type { Identity, Target as TargetShape } from "../src/target";
24+
25+const api = composePluginApi([openApiHttpPlugin()] as const);
26+type Client = HttpApiClient.ForApi<typeof api>;
27+
28+// Same evolving spec the single-user openapi-update-spec scenario uses, so the
29+// expected tool names below are already an independently-verified contract.
30+const specV1 = JSON.stringify({
31+ openapi: "3.0.3",
32+ info: { title: "Evolving API", version: "1.0.0" },
33+ paths: {
34+ "/ping": {
35+ get: {
36+ operationId: "ping",
37+ summary: "Return a pong",
38+ responses: { "200": { description: "pong" } },
39+ },
40+ },
41+ "/legacy": {
42+ get: {
43+ operationId: "legacyOp",
44+ summary: "Soon to be removed",
45+ responses: { "200": { description: "ok" } },
46+ },
47+ },
48+ },
49+});
50+
51+const specV2 = JSON.stringify({
52+ openapi: "3.0.3",
53+ info: { title: "Evolving API", version: "2.0.0" },
54+ paths: {
55+ "/ping": {
56+ get: {
57+ operationId: "ping",
58+ summary: "Return a pong",
59+ responses: { "200": { description: "pong" } },
60+ },
61+ },
62+ "/widgets": {
63+ get: {
64+ operationId: "listWidgets",
65+ summary: "List widgets",
66+ responses: { "200": { description: "widgets" } },
67+ },
68+ },
69+ },
70+});
71+
72+const V1_TOOLS = ["legacy.legacyOp", "ping.getOperation"];
73+const V2_TOOLS = ["ping.getOperation", "widgets.listWidgets"];
74+
75+/** A real 127.0.0.1 server whose served spec can be swapped mid-scenario. */
76+const serveMutableSpec = (initial: string) =>
77+ Effect.acquireRelease(
78+ Effect.callback<{
79+ readonly url: string;
80+ readonly setBody: (body: string) => void;
81+ readonly close: () => void;
82+ }>((resume) => {
83+ let body = initial;
84+ const server = createServer((_request, response) => {
85+ response.writeHead(200, { "content-type": "application/json" });
86+ response.end(body);
87+ });
88+ server.listen(0, "127.0.0.1", () => {
89+ const address = server.address();
90+ const port = typeof address === "object" && address ? address.port : 0;
91+ resume(
92+ Effect.succeed({
93+ url: `http://127.0.0.1:${port}/spec.json`,
94+ setBody: (next: string) => {
95+ body = next;
96+ },
97+ close: () => {
98+ server.close();
99+ server.closeAllConnections();
100+ },
101+ }),
102+ );
103+ });
104+ }),
105+ (server) => Effect.sync(server.close),
106+ );
107+
108+// ── Session plumbing over the real auth endpoints (mirrors the web app) ──────
109+const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";
110+
111+const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) =>
112+ Effect.promise(async () => {
113+ const response = await fetch(new URL(path, target.baseUrl), {
114+ method: "POST",
115+ headers: {
116+ "content-type": "application/json",
117+ origin: new URL(target.baseUrl).origin,
118+ cookie: cookieOf(identity),
119+ },
120+ body: JSON.stringify(body),
121+ });
122+ if (!response.ok) {
123+ throw new Error(`${path} failed (${response.status}): ${await response.text()}`);
124+ }
125+ return response;
126+ });
127+
128+const withRefreshedSession = (identity: Identity, response: Response): Identity => {
129+ const refreshed = (response.headers.getSetCookie?.() ?? [])
130+ .find((header) => header.startsWith("wos-session="))
131+ ?.split(";")[0];
132+ if (!refreshed) throw new Error("response did not refresh the session cookie");
133+ return { ...identity, headers: { cookie: refreshed } };
134+};
135+
136+/** Invite `member` into `admin`'s org and accept — the real invite flow. */
137+const joinOrg = (target: TargetShape, admin: Identity, member: Identity) =>
138+ Effect.gen(function* () {
139+ const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, {
140+ email: member.credentials?.email,
141+ });
142+ const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string };
143+ const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {
144+ invitationId: invitation.id,
145+ });
146+ return withRefreshedSession(member, acceptResponse);
147+ });
148+
149+const apiKeyTemplate = [
150+ {
151+ slug: "apiKey",
152+ type: "apiKey" as const,
153+ headers: { authorization: ["Bearer ", { type: "variable" as const, name: "token" }] },
154+ },
155+];
156+
157+const personalConnection = (client: Client, integration: IntegrationSlug, name: ConnectionName) =>
158+ client.connections.create({
159+ payload: {
160+ owner: "user",
161+ name,
162+ integration,
163+ template: AuthTemplateSlug.make("apiKey"),
164+ value: `tok-${randomBytes(8).toString("hex")}`,
165+ },
166+ });
167+
168+const ownToolNames = (client: Client, integration: IntegrationSlug) =>
169+ Effect.map(client.tools.list({ query: { integration, owner: "user" } }), (tools) =>
170+ tools.map((tool) => tool.name).sort(),
171+ );
172+
173+scenario(
174+ "Convergence · a spec refresh reaches a co-worker's own connection on their next read",
175+ {},
176+ Effect.scoped(
177+ Effect.gen(function* () {
178+ const target = yield* Target;
179+ const { client } = yield* Api;
180+
181+ const admin = yield* target.newIdentity();
182+ const invitee = yield* target.newIdentity({ org: false });
183+ const colleague = yield* joinOrg(target, admin, invitee);
184+
185+ const adminClient = yield* client(api, admin);
186+ const colleagueClient = yield* client(api, colleague);
187+
188+ const slug = IntegrationSlug.make(`converge-${randomBytes(4).toString("hex")}`);
189+ const adminConn = ConnectionName.make(`admin${randomBytes(3).toString("hex")}`);
190+ const colleagueConn = ConnectionName.make(`peer${randomBytes(3).toString("hex")}`);
191+ const specServer = yield* serveMutableSpec(specV1);
192+
193+ yield* Effect.ensuring(
194+ Effect.gen(function* () {
195+ // The admin registers a SHARED (org) integration from the live spec.
196+ yield* adminClient.openapi.addSpec({
197+ payload: {
198+ spec: { kind: "url", url: specServer.url },
199+ slug,
200+ baseUrl: "http://127.0.0.1:59999", // tools are never invoked here
201+ authenticationTemplate: apiKeyTemplate,
202+ },
203+ });
204+
205+ // Each member binds their OWN personal connection to it.
206+ yield* personalConnection(adminClient, slug, adminConn);
207+ yield* personalConnection(colleagueClient, slug, colleagueConn);
208+
209+ // Both see the v1 catalog on their own connection to start.
210+ expect(yield* ownToolNames(adminClient, slug), "admin starts on v1").toEqual(V1_TOOLS);
211+ expect(yield* ownToolNames(colleagueClient, slug), "the co-worker starts on v1").toEqual(
212+ V1_TOOLS,
213+ );
214+
215+ // The upstream API ships v2; the admin refreshes the shared spec.
216+ specServer.setBody(specV2);
217+ const updated = yield* adminClient.openapi.updateSpec({ params: { slug }, payload: {} });
218+ expect(updated.addedTools, "the diff names the new tool").toEqual([
219+ "widgets.listWidgets",
220+ ]);
221+
222+ // The editor's own connection follows immediately.
223+ expect(yield* ownToolNames(adminClient, slug), "admin converged to v2").toEqual(V2_TOOLS);
224+
225+ // The co-worker did nothing but read — yet their personal connection
226+ // has converged to v2, even though the admin could never write the
227+ // co-worker's rows. This is the lazy sync on the colleague's read.
228+ expect(
229+ yield* ownToolNames(colleagueClient, slug),
230+ "the co-worker converged to v2 on their next read",
231+ ).toEqual(V2_TOOLS);
232+ }),
233+ Effect.gen(function* () {
234+ yield* colleagueClient.connections
235+ .remove({ params: { owner: "user", integration: slug, name: colleagueConn } })
236+ .pipe(Effect.ignore);
237+ yield* adminClient.connections
238+ .remove({ params: { owner: "user", integration: slug, name: adminConn } })
239+ .pipe(Effect.ignore);
240+ yield* adminClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
241+ }),
242+ );
243+ }),
244+ ),
245+);
e2e/package.json+1−0
@@ -43,6 +43,7 @@ · 42 unmodified lines
4343 "@types/react": "catalog:",
4444 "@types/react-dom": "catalog:",
4545 "@vitejs/plugin-react": "catalog:",
46+ "graphql": "^16.12.0",
4647 "iron-webcrypto": "^2.0.0",
4748 "typescript": "catalog:",
4849 "vite": "catalog:",
e2e/scenarios/metadata-editing.test.ts+120−0
@@ -0,0 +1,120 @@
1+// Cross-target: the curate-your-catalog promise. An integration's display
2+// name and description, and a connection's description, are user-editable
3+// metadata — set them after the fact and they persist and read back. This is
4+// the write half of the descriptions feature; openapi-update-spec covers the
5+// half where a curated description SURVIVES a spec refresh.
6+import { randomBytes, randomUUID } from "node:crypto";
7+
8+import { expect } from "@effect/vitest";
9+import { Effect } from "effect";
10+import { composePluginApi } from "@executor-js/api/server";
11+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
12+import {
13+ AuthTemplateSlug,
14+ ConnectionName,
15+ IntegrationSlug,
16+ ProviderItemId,
17+} from "@executor-js/sdk/shared";
18+
19+import { scenario } from "../src/scenario";
20+import { Api, Target } from "../src/services";
21+
22+const api = composePluginApi([openApiHttpPlugin()] as const);
23+
24+/** Minimal apiKey-authenticated spec — a connection can bind to it; the single
25+ * operation is never invoked here. */
26+const pingSpec = JSON.stringify({
27+ openapi: "3.0.3",
28+ info: { title: "Ping API", version: "1.0.0" },
29+ paths: {
30+ "/ping": {
31+ get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } },
32+ },
33+ },
34+});
35+
36+scenario(
37+ "Metadata · an integration's name/description and a connection's description are editable",
38+ {},
39+ Effect.gen(function* () {
40+ const target = yield* Target;
41+ const { client } = yield* Api;
42+ const apiClient = yield* client(api, yield* target.newIdentity());
43+
44+ const slug = IntegrationSlug.make(`meta-edit-${randomBytes(4).toString("hex")}`);
45+ const connectionName = ConnectionName.make(`conn${randomBytes(4).toString("hex")}`);
46+
47+ yield* Effect.ensuring(
48+ Effect.gen(function* () {
49+ yield* apiClient.openapi.addSpec({
50+ payload: {
51+ spec: { kind: "blob", value: pingSpec },
52+ slug,
53+ baseUrl: "http://127.0.0.1:59999", // never contacted
54+ description: "Auto-derived at add time.",
55+ authenticationTemplate: [
56+ {
57+ slug: "apiKey",
58+ type: "apiKey",
59+ headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
60+ },
61+ ],
62+ },
63+ });
64+
65+ // The add-time description is what the user first sees.
66+ const initial = yield* apiClient.integrations.get({ params: { slug } });
67+ expect(initial.description, "the add-time description is stored").toBe(
68+ "Auto-derived at add time.",
69+ );
70+
71+ // Rename and re-describe the integration — the curate step.
72+ const renamed = yield* apiClient.integrations.update({
73+ params: { slug },
74+ payload: { name: "Acme Ping", description: "Hand-curated for the team." },
75+ });
76+ expect(renamed.name, "the update response carries the new name").toBe("Acme Ping");
77+ expect(renamed.description, "the update response carries the new description").toBe(
78+ "Hand-curated for the team.",
79+ );
80+
81+ // It persists for the next reader, not just in the response.
82+ const reread = yield* apiClient.integrations.get({ params: { slug } });
83+ expect(reread.name, "the name persisted").toBe("Acme Ping");
84+ expect(reread.description, "the description persisted").toBe("Hand-curated for the team.");
85+
86+ // A connection carries its own editable description.
87+ const providers = yield* apiClient.providers.list();
88+ yield* apiClient.connections.create({
89+ payload: {
90+ owner: "org",
91+ name: connectionName,
92+ integration: slug,
93+ template: AuthTemplateSlug.make("apiKey"),
94+ from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) },
95+ },
96+ });
97+ const describedConnection = yield* apiClient.connections.update({
98+ params: { owner: "org", integration: slug, name: connectionName },
99+ payload: { description: "Prod key, rotates quarterly." },
100+ });
101+ expect(describedConnection.description, "the connection description was set").toBe(
102+ "Prod key, rotates quarterly.",
103+ );
104+
105+ const connectionReread = yield* apiClient.connections.get({
106+ params: { owner: "org", integration: slug, name: connectionName },
107+ });
108+ expect(connectionReread.description, "the connection description persisted").toBe(
109+ "Prod key, rotates quarterly.",
110+ );
111+ }),
112+ Effect.gen(function* () {
113+ yield* apiClient.connections
114+ .remove({ params: { owner: "org", integration: slug, name: connectionName } })
115+ .pipe(Effect.ignore);
116+ yield* apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
117+ }),
118+ );
119+ }),
120+);
e2e/scenarios/openapi-update-spec.test.ts+204−0
@@ -0,0 +1,204 @@
1+// Cross-target: updating an OpenAPI spec IN PLACE — the "our API changed,
2+// don't make me remove/re-add the integration" promise. A real 127.0.0.1
3+// server serves the spec; after registration + connection the spec evolves
4+// (one operation removed, one added) and `openapi.updateSpec` re-fetches it.
5+// The tool catalog follows, the response reports the diff, and everything
6+// user-curated (connection, description, auth template) survives.
7+import { randomBytes, randomUUID } from "node:crypto";
8+import { createServer } from "node:http";
9+
10+import { expect } from "@effect/vitest";
11+import { Effect } from "effect";
12+import { composePluginApi } from "@executor-js/api/server";
13+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
14+import {
15+ AuthTemplateSlug,
16+ ConnectionName,
17+ IntegrationSlug,
18+ ProviderItemId,
19+} from "@executor-js/sdk/shared";
20+
21+import { scenario } from "../src/scenario";
22+import { Api, Target } from "../src/services";
23+
24+const api = composePluginApi([openApiHttpPlugin()] as const);
25+
26+const specV1 = JSON.stringify({
27+ openapi: "3.0.3",
28+ info: { title: "Evolving API", version: "1.0.0" },
29+ paths: {
30+ "/ping": {
31+ get: {
32+ operationId: "ping",
33+ summary: "Return a pong",
34+ responses: { "200": { description: "pong" } },
35+ },
36+ },
37+ "/legacy": {
38+ get: {
39+ operationId: "legacyOp",
40+ summary: "Soon to be removed",
41+ responses: { "200": { description: "ok" } },
42+ },
43+ },
44+ },
45+});
46+
47+const specV2 = JSON.stringify({
48+ openapi: "3.0.3",
49+ info: { title: "Evolving API", version: "2.0.0" },
50+ paths: {
51+ "/ping": {
52+ get: {
53+ operationId: "ping",
54+ summary: "Return a pong",
55+ responses: { "200": { description: "pong" } },
56+ },
57+ },
58+ "/widgets": {
59+ get: {
60+ operationId: "listWidgets",
61+ summary: "List widgets",
62+ responses: { "200": { description: "widgets" } },
63+ },
64+ },
65+ },
66+});
67+
68+/** A real 127.0.0.1 server whose served spec can be swapped mid-scenario. */
69+const serveMutableSpec = (initial: string) =>
70+ Effect.acquireRelease(
71+ Effect.callback<{
72+ readonly url: string;
73+ readonly setBody: (body: string) => void;
74+ readonly close: () => void;
75+ }>((resume) => {
76+ let body = initial;
77+ const server = createServer((_request, response) => {
78+ response.writeHead(200, { "content-type": "application/json" });
79+ response.end(body);
80+ });
81+ server.listen(0, "127.0.0.1", () => {
82+ const address = server.address();
83+ const port = typeof address === "object" && address ? address.port : 0;
84+ resume(
85+ Effect.succeed({
86+ url: `http://127.0.0.1:${port}/spec.json`,
87+ setBody: (next: string) => {
88+ body = next;
89+ },
90+ close: () => {
91+ server.close();
92+ server.closeAllConnections();
93+ },
94+ }),
95+ );
96+ });
97+ }),
98+ (server) => Effect.sync(server.close),
99+ );
100+
101+scenario(
102+ "OpenAPI · updating the spec rebuilds tools without re-adding the integration",
103+ {},
104+ Effect.scoped(
105+ Effect.gen(function* () {
106+ const target = yield* Target;
107+ const { client } = yield* Api;
108+ const identity = yield* target.newIdentity();
109+ const apiClient = yield* client(api, identity);
110+
111+ const slug = `update-spec-${randomBytes(4).toString("hex")}`;
112+ const specServer = yield* serveMutableSpec(specV1);
113+
114+ yield* Effect.ensuring(
115+ Effect.gen(function* () {
116+ const added = yield* apiClient.openapi.addSpec({
117+ payload: {
118+ spec: { kind: "url", url: specServer.url },
119+ slug,
120+ baseUrl: "http://127.0.0.1:59999", // tools are never invoked here
121+ description: "Curated description that must survive the update.",
122+ authenticationTemplate: [
123+ {
124+ slug: "apiKey",
125+ type: "apiKey",
126+ headers: { "x-api-key": [{ type: "variable", name: "token" }] },
127+ },
128+ ],
129+ },
130+ });
131+ expect(added.toolCount, "v1 spec has two operations").toBe(2);
132+
133+ const providers = yield* apiClient.providers.list();
134+ yield* apiClient.connections.create({
135+ payload: {
136+ owner: "org",
137+ name: ConnectionName.make("main"),
138+ integration: IntegrationSlug.make(slug),
139+ template: AuthTemplateSlug.make("apiKey"),
140+ from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) },
141+ },
142+ });
143+ const toolNames = (filter: { integration: string }) =>
144+ Effect.map(
145+ apiClient.tools.list({
146+ query: { integration: IntegrationSlug.make(filter.integration) },
147+ }),
148+ (tools) => tools.map((tool) => tool.name).sort(),
149+ );
150+ expect(yield* toolNames({ integration: slug }), "v1 tools are live").toEqual([
151+ "legacy.legacyOp",
152+ "ping.getOperation",
153+ ]);
154+
155+ // The upstream API ships v2: legacyOp is gone, listWidgets appears.
156+ specServer.setBody(specV2);
157+ const updated = yield* apiClient.openapi.updateSpec({
158+ params: { slug },
159+ payload: {},
160+ });
161+
162+ expect(updated.addedTools, "the diff names the new tool").toEqual([
163+ "widgets.listWidgets",
164+ ]);
165+ expect(updated.removedTools, "the diff names the removed tool").toEqual([
166+ "legacy.legacyOp",
167+ ]);
168+
169+ // The connection's catalog follows the new spec — same connection,
170+ // no re-add.
171+ expect(yield* toolNames({ integration: slug }), "v2 tools are live").toEqual([
172+ "ping.getOperation",
173+ "widgets.listWidgets",
174+ ]);
175+ const connections = yield* apiClient.connections.list({
176+ query: { integration: IntegrationSlug.make(slug) },
177+ });
178+ expect(
179+ connections.map((connection) => String(connection.name)),
180+ "the connection survived",
181+ ).toEqual(["main"]);
182+ const integration = yield* apiClient.integrations.get({
183+ params: { slug: IntegrationSlug.make(slug) },
184+ });
185+ expect(integration.description, "the curated description survived").toBe(
186+ "Curated description that must survive the update.",
187+ );
188+ }),
189+ Effect.gen(function* () {
190+ yield* apiClient.connections
191+ .remove({
192+ params: {
193+ owner: "org",
194+ integration: IntegrationSlug.make(slug),
195+ name: ConnectionName.make("main"),
196+ },
197+ })
198+ .pipe(Effect.ignore);
199+ yield* apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
200+ }),
201+ );
202+ }),
203+ ),
204+);
e2e/scenarios/tool-descriptions.test.ts+545−0
@@ -0,0 +1,545 @@
1+// Cross-target: the agent-visible description surface, captured as a
2+// reviewable artifact. Registers description-rich OpenAPI and GraphQL
3+// fixtures (every description channel a spec author can use: operation
4+// summary/description, parameter descriptions, body property descriptions,
5+// response descriptions, GraphQL field/arg/type docstrings), then dumps what
6+// an agent actually sees — tools.list entries and the tools.schema view
7+// (the same compiled TypeScript previews `tools.describe.tool()` returns in
8+// the sandbox) — into `descriptions.md` / `descriptions.json` in the run dir.
9+//
10+// The artifact is the point: change anything in the spec→tool description
11+// pipeline, run this scenario, and read (or diff) one file instead of
12+// spinning up an app and clicking around. Fixture slugs are randomized for
13+// catalog isolation but normalized back out of the artifact so two runs of
14+// the same code produce identical files.
15+import { randomBytes, randomUUID } from "node:crypto";
16+import { writeFileSync } from "node:fs";
17+import { join } from "node:path";
18+
19+import { expect } from "@effect/vitest";
20+import { Effect } from "effect";
21+import { buildSchema, introspectionFromSchema } from "graphql";
22+import { composePluginApi } from "@executor-js/api/server";
23+import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
24+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
25+import {
26+ AuthTemplateSlug,
27+ ConnectionName,
28+ IntegrationSlug,
29+ ProviderItemId,
30+} from "@executor-js/sdk/shared";
31+
32+import { scenario } from "../src/scenario";
33+import { Api, Mcp, RunDir, Target } from "../src/services";
34+
35+const api = composePluginApi([openApiHttpPlugin(), graphqlHttpPlugin()] as const);
36+
37+// ---------------------------------------------------------------------------
38+// Fixtures — every description channel populated, so a drop anywhere in the
39+// pipeline is visible as "the fixture says X, the artifact doesn't".
40+// ---------------------------------------------------------------------------
41+
42+const ordersOpenApiSpec = (baseUrl: string): string =>
43+ JSON.stringify({
44+ openapi: "3.0.3",
45+ info: {
46+ title: "Orders API",
47+ version: "1.0.0",
48+ description: "A fixture API exercising every OpenAPI description channel.",
49+ },
50+ servers: [{ url: baseUrl }],
51+ paths: {
52+ "/orders/{orderId}": {
53+ get: {
54+ operationId: "getOrder",
55+ summary: "Fetch a single order",
56+ description:
57+ "Fetch one order by id, including line items and the current fulfillment status.",
58+ parameters: [
59+ {
60+ name: "orderId",
61+ in: "path",
62+ required: true,
63+ description: "Unique order identifier (ULID).",
64+ schema: { type: "string" },
65+ },
66+ {
67+ name: "include",
68+ in: "query",
69+ description: "Related records to embed in the response.",
70+ schema: { type: "string", enum: ["items", "customer", "shipments"] },
71+ },
72+ ],
73+ responses: {
74+ "200": {
75+ description: "The order, with any requested embeds.",
76+ content: {
77+ "application/json": {
78+ schema: {
79+ type: "object",
80+ properties: {
81+ id: { type: "string", description: "Unique order identifier (ULID)." },
82+ status: {
83+ type: "string",
84+ description: "Current fulfillment status.",
85+ enum: ["pending", "shipped", "delivered"],
86+ },
87+ total: {
88+ type: "number",
89+ description: "Order total in minor currency units (cents).",
90+ },
91+ },
92+ },
93+ },
94+ },
95+ },
96+ },
97+ },
98+ },
99+ "/orders": {
100+ post: {
101+ operationId: "createOrder",
102+ summary: "Create an order",
103+ // No `description` on purpose: the tool description must fall back
104+ // to the summary, and the artifact shows which one won.
105+ requestBody: {
106+ required: true,
107+ description: "The order to create.",
108+ content: {
109+ "application/json": {
110+ schema: {
111+ type: "object",
112+ required: ["items"],
113+ properties: {
114+ items: {
115+ type: "array",
116+ description: "Line items to order.",
117+ items: {
118+ type: "object",
119+ properties: {
120+ sku: { type: "string", description: "Product SKU." },
121+ quantity: {
122+ type: "integer",
123+ description: "How many units of this SKU.",
124+ },
125+ },
126+ },
127+ },
128+ note: {
129+ type: "string",
130+ description: "Free-form note shown to the warehouse packer.",
131+ },
132+ },
133+ },
134+ },
135+ },
136+ },
137+ responses: { "201": { description: "The created order." } },
138+ },
139+ },
140+ },
141+ });
142+
143+const ordersGraphqlSdl = /* GraphQL */ `
144+ """
145+ A customer order.
146+ """
147+ type Order {
148+ """
149+ Unique order identifier (ULID).
150+ """
151+ id: ID!
152+
153+ """
154+ Current fulfillment status.
155+ """
156+ status: String!
157+ }
158+
159+ type Query {
160+ """
161+ Fetch one order by id, including the current fulfillment status.
162+ """
163+ order(
164+ """
165+ Unique order identifier (ULID).
166+ """
167+ id: ID!
168+ ): Order
169+ }
170+
171+ type Mutation {
172+ """
173+ Cancel an order that has not shipped yet.
174+ """
175+ cancelOrder(
176+ """
177+ Unique order identifier (ULID).
178+ """
179+ id: ID!
180+ """
181+ Human-readable reason, shown to the customer.
182+ """
183+ reason: String
184+ ): Order
185+ }
186+`;
187+
188+const ordersIntrospectionJson = (): string =>
189+ JSON.stringify({ data: introspectionFromSchema(buildSchema(ordersGraphqlSdl)) });
190+
191+// ---------------------------------------------------------------------------
192+// Artifact rendering
193+// ---------------------------------------------------------------------------
194+
195+interface ToolSnapshot {
196+ readonly name: string;
197+ readonly address: string;
198+ readonly listDescription: string;
199+ readonly schemaDescription?: string;
200+ readonly inputTypeScript?: string;
201+ readonly outputTypeScript?: string;
202+ readonly typeScriptDefinitions?: Record<string, string>;
203+ readonly inputSchema?: unknown;
204+}
205+
206+const codeBlock = (lang: string, body: string): string => `\`\`\`${lang}\n${body}\n\`\`\``;
207+
208+const renderTool = (tool: ToolSnapshot): string => {
209+ const lines: string[] = [`### \`${tool.name}\``, "", `> ${tool.listDescription || "(empty)"}`];
210+ if (tool.schemaDescription && tool.schemaDescription !== tool.listDescription) {
211+ lines.push("", `tools.schema description differs:`, "", `> ${tool.schemaDescription}`);
212+ }
213+ if (tool.inputTypeScript) {
214+ lines.push("", "**Input**", "", codeBlock("ts", tool.inputTypeScript));
215+ }
216+ if (tool.outputTypeScript) {
217+ lines.push("", "**Output**", "", codeBlock("ts", tool.outputTypeScript));
218+ }
219+ const definitions = Object.entries(tool.typeScriptDefinitions ?? {});
220+ if (definitions.length > 0) {
221+ lines.push("", "**Definitions**", "");
222+ for (const [name, body] of definitions) {
223+ lines.push(codeBlock("ts", `type ${name} = ${body}`));
224+ }
225+ }
226+ if (tool.inputSchema !== undefined) {
227+ lines.push(
228+ "",
229+ "<details><summary>Raw inputSchema</summary>",
230+ "",
231+ codeBlock("json", JSON.stringify(tool.inputSchema, null, 2)),
232+ "",
233+ "</details>",
234+ );
235+ }
236+ return lines.join("\n");
237+};
238+
239+scenario(
240+ "Tools · agent-visible descriptions snapshot",
241+ {},
242+ Effect.gen(function* () {
243+ const target = yield* Target;
244+ const { client } = yield* Api;
245+ const mcp = yield* Mcp;
246+ const runDir = yield* RunDir;
247+ const identity = yield* target.newIdentity();
248+ const apiClient = yield* client(api, identity);
249+
250+ // Unique slugs per run keep repeated/parallel runs out of each other's
251+ // catalogs (selfhost shares the bootstrap-admin identity).
252+ const nonce = randomBytes(4).toString("hex");
253+ const openapiSlug = `descr-openapi-${nonce}`;
254+ const graphqlSlug = `descr-graphql-${nonce}`;
255+ const specBaseUrl = "http://127.0.0.1:59999"; // never contacted
256+
257+ const apiKeyTemplate = [
258+ {
259+ slug: "apiKey",
260+ type: "apiKey",
261+ headers: { "x-api-key": [{ type: "variable", name: "token" }] },
262+ },
263+ ] as const;
264+
265+ const connect = (slug: string) =>
266+ Effect.gen(function* () {
267+ const providers = yield* apiClient.providers.list();
268+ yield* apiClient.connections.create({
269+ payload: {
270+ owner: "org",
271+ name: ConnectionName.make("main"),
272+ integration: IntegrationSlug.make(slug),
273+ template: AuthTemplateSlug.make("apiKey"),
274+ from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) },
275+ },
276+ });
277+ });
278+
279+ const cleanup = (slug: string) =>
280+ Effect.gen(function* () {
281+ yield* apiClient.connections
282+ .remove({
283+ params: {
284+ owner: "org",
285+ integration: IntegrationSlug.make(slug),
286+ name: ConnectionName.make("main"),
287+ },
288+ })
289+ .pipe(Effect.ignore);
290+ yield* apiClient.integrations
291+ .remove({ params: { slug: IntegrationSlug.make(slug) } })
292+ .pipe(Effect.ignore);
293+ });
294+
295+ yield* Effect.ensuring(
296+ Effect.gen(function* () {
297+ const added = yield* apiClient.openapi.addSpec({
298+ payload: {
299+ spec: { kind: "blob", value: ordersOpenApiSpec(specBaseUrl) },
300+ slug: openapiSlug,
301+ baseUrl: specBaseUrl,
302+ authenticationTemplate: apiKeyTemplate,
303+ },
304+ });
305+ expect(added.toolCount, "the OpenAPI fixture's operations became tools").toBe(2);
306+
307+ // Description set AT ADD (the add form's field) — no PATCH needed.
308+ yield* apiClient.graphql.addIntegration({
309+ payload: {
310+ endpoint: `${specBaseUrl}/graphql`,
311+ slug: graphqlSlug,
312+ description: "Order management over GraphQL.",
313+ introspectionJson: ordersIntrospectionJson(),
314+ authenticationTemplate: apiKeyTemplate,
315+ },
316+ });
317+
318+ yield* connect(openapiSlug);
319+ yield* connect(graphqlSlug);
320+
321+ // Add-time prefill: with neither passed, the spec's title becomes the
322+ // display name and its `info.description` the description.
323+ const openapiIntegration = yield* apiClient.integrations.get({
324+ params: { slug: IntegrationSlug.make(openapiSlug) },
325+ });
326+ expect(openapiIntegration.name, "the spec title prefills the name").toBe("Orders API");
327+ expect(
328+ openapiIntegration.description,
329+ "the spec's info.description prefills the description",
330+ ).toBe("A fixture API exercising every OpenAPI description channel.");
331+
332+ // Post-add curation the way the console's edit sheets do: a
333+ // connection-level description on the OpenAPI connection (its prefix
334+ // line shows it; the GraphQL connection has none, so its line falls
335+ // back to the integration description set at add).
336+ yield* apiClient.connections.update({
337+ params: {
338+ owner: "org",
339+ integration: IntegrationSlug.make(openapiSlug),
340+ name: ConnectionName.make("main"),
341+ },
342+ payload: { description: "Staging orders — safe to create test orders." },
343+ });
344+
345+ // The agent-visible surface: catalog entry + schema view (the same
346+ // data `tools.search()` / `tools.describe.tool()` serve the sandbox).
347+ const snapshotFor = (slug: string) =>
348+ Effect.gen(function* () {
349+ const tools = yield* apiClient.tools.list({
350+ query: { integration: IntegrationSlug.make(slug) },
351+ });
352+ return yield* Effect.forEach(
353+ [...tools].sort((a, b) => a.name.localeCompare(b.name)),
354+ (tool) =>
355+ Effect.gen(function* () {
356+ const schema = yield* apiClient.tools.schema({
357+ query: { address: tool.address },
358+ });
359+ return {
360+ name: tool.name,
361+ address: String(tool.address),
362+ listDescription: tool.description,
363+ schemaDescription: schema.description,
364+ inputTypeScript: schema.inputTypeScript,
365+ outputTypeScript: schema.outputTypeScript,
366+ typeScriptDefinitions: schema.typeScriptDefinitions,
367+ inputSchema: schema.inputSchema,
368+ } satisfies ToolSnapshot;
369+ }),
370+ );
371+ });
372+
373+ const openapiTools = yield* snapshotFor(openapiSlug);
374+ const graphqlTools = yield* snapshotFor(graphqlSlug);
375+
376+ // The execute tool's description over the real MCP surface — the
377+ // connection-prefix inventory an MCP client (and its model) reads.
378+ // Only this run's lines: the shared selfhost admin may have other
379+ // connections in the inventory.
380+ const readInventory = () =>
381+ Effect.map(mcp.session(identity).describeTools(), (mcpTools) =>
382+ (mcpTools.find((tool) => tool.name === "execute")?.description ?? "")
383+ .split("## Available connection prefixes")[1]
384+ ?.split("\n")
385+ .filter(
386+ (line) =>
387+ line.startsWith("- ") &&
388+ (line.includes(openapiSlug) || line.includes(graphqlSlug)),
389+ )
390+ .join("\n"),
391+ );
392+ const inventory = yield* readInventory();
393+
394+ // Normalize the per-run randomness so artifacts diff cleanly across
395+ // runs: slugs become stable tokens, the owner segment a placeholder.
396+ const owner = (yield* apiClient.tools.list({ query: {} }))
397+ .map((tool) => String(tool.owner))
398+ .find((value) => value.length > 0);
399+ const normalize = (text: string): string => {
400+ let out = text
401+ .replaceAll(openapiSlug, "openapi-fixture")
402+ .replaceAll(graphqlSlug, "graphql-fixture");
403+ if (owner) out = out.replaceAll(owner, "<owner>");
404+ return out;
405+ };
406+
407+ const sections = [
408+ ["OpenAPI fixture (`Orders API`)", openapiTools],
409+ ["GraphQL fixture (orders schema)", graphqlTools],
410+ ] as const;
411+ const markdown = normalize(
412+ [
413+ `# Agent-visible tool descriptions — ${target.name}`,
414+ "",
415+ "What an agent sees for tools derived from description-rich fixtures:",
416+ "the `tools.list` catalog entry (quoted) and the `tools.schema` view —",
417+ "the same compiled TypeScript previews `tools.describe.tool()` returns",
418+ "inside the sandbox. Anything written in the fixture sources (bottom)",
419+ "but absent here was dropped by the spec→tool pipeline.",
420+ "",
421+ ...sections.flatMap(([title, tools]) => [
422+ `## ${title}`,
423+ "",
424+ tools.map(renderTool).join("\n\n"),
425+ "",
426+ ]),
427+ "## Execute-tool inventory (over MCP)",
428+ "",
429+ "The connection-prefix lines from the `execute` tool's description,",
430+ "as an MCP client reads them. Connection descriptions ride their",
431+ "prefix; a connection without one falls back to its integration's.",
432+ "",
433+ codeBlock("md", inventory ?? "(no inventory section found)"),
434+ "",
435+ "## Fixture sources",
436+ "",
437+ "<details><summary>OpenAPI spec</summary>",
438+ "",
439+ codeBlock("json", JSON.stringify(JSON.parse(ordersOpenApiSpec(specBaseUrl)), null, 2)),
440+ "",
441+ "</details>",
442+ "",
443+ "<details><summary>GraphQL SDL</summary>",
444+ "",
445+ codeBlock("graphql", ordersGraphqlSdl.trim()),
446+ "",
447+ "</details>",
448+ "",
449+ ].join("\n"),
450+ );
451+ writeFileSync(join(runDir, "descriptions.md"), markdown);
452+ writeFileSync(
453+ join(runDir, "descriptions.json"),
454+ normalize(
455+ JSON.stringify(
456+ {
457+ target: target.name,
458+ openapi: openapiTools,
459+ graphql: graphqlTools,
460+ executeInventory: inventory ?? null,
461+ },
462+ null,
463+ 2,
464+ ),
465+ ),
466+ );
467+
468+ // Lock the channels that flow today; the artifact is the review
469+ // surface for the ones that don't (yet).
470+ const byName = (tools: readonly ToolSnapshot[], name: string) =>
471+ tools.find((tool) => tool.name === name);
472+
473+ const getOrder = byName(openapiTools, "orders.getOrder");
474+ expect(getOrder?.listDescription, "operation description reaches the tool").toBe(
475+ "Fetch one order by id, including line items and the current fulfillment status.",
476+ );
477+ const createOrder = byName(openapiTools, "orders.createOrder");
478+ expect(createOrder?.listDescription, "summary is the fallback description").toBe(
479+ "Create an order",
480+ );
481+ expect(getOrder?.inputTypeScript, "input shape is compiled to TypeScript").toContain(
482+ "orderId",
483+ );
484+
485+ const orderQuery = byName(graphqlTools, "query.order");
486+ expect(orderQuery?.listDescription, "GraphQL field docstring reaches the tool").toBe(
487+ "Fetch one order by id, including the current fulfillment status.",
488+ );
489+ const cancelOrder = byName(graphqlTools, "mutation.cancelOrder");
490+ expect(cancelOrder?.inputTypeScript, "GraphQL args are compiled to TypeScript").toContain(
491+ "reason",
492+ );
493+
494+ // The curated descriptions reach the model: the connection's own
495+ // description rides its prefix line; the connection without one falls
496+ // back to its integration's description.
497+ expect(inventory, "connection description reaches the MCP inventory").toContain(
498+ `- \`${openapiSlug}.org.main\` — Staging orders — safe to create test orders.`,
499+ );
500+ expect(inventory, "integration description is the fallback").toContain(
501+ `- \`${graphqlSlug}.org.main\` — Order management over GraphQL.`,
502+ );
503+
504+ // EDIT PROPAGATION — the loop the edit sheets exist for: an agent has
505+ // already read the inventory above; the user now edits both
506+ // descriptions (the exact PATCHes the sheets make); a NEW agent
507+ // session must see the new text. (Within one session the execute
508+ // description is computed at session build and stays as-is — the
509+ // re-read below is a fresh session, which is also what a reconnecting
510+ // client gets.)
511+ yield* apiClient.connections.update({
512+ params: {
513+ owner: "org",
514+ integration: IntegrationSlug.make(openapiSlug),
515+ name: ConnectionName.make("main"),
516+ },
517+ payload: { description: "EDITED: production orders — do not create test data." },
518+ });
519+ yield* apiClient.integrations.update({
520+ params: { slug: IntegrationSlug.make(graphqlSlug) },
521+ payload: { description: "EDITED: order admin over GraphQL." },
522+ });
523+
524+ const inventoryAfterEdit = yield* readInventory();
525+ expect(
526+ inventoryAfterEdit,
527+ "an edited connection description reaches a fresh agent session",
528+ ).toContain(
529+ `- \`${openapiSlug}.org.main\` — EDITED: production orders — do not create test data.`,
530+ );
531+ expect(
532+ inventoryAfterEdit,
533+ "an edited integration description reaches a fresh agent session",
534+ ).toContain(`- \`${graphqlSlug}.org.main\` — EDITED: order admin over GraphQL.`);
535+ expect(inventoryAfterEdit, "the pre-edit connection text is gone").not.toContain(
536+ "Staging orders",
537+ );
538+ }),
539+ Effect.gen(function* () {
540+ yield* cleanup(openapiSlug);
541+ yield* cleanup(graphqlSlug);
542+ }),
543+ );
544+ }),
545+);
e2e/src/surfaces/mcp.ts+19−0
@@ -95,6 +95,11 @@ export interface McpCallResult { · 94 unmodified lines
9595 readonly ok: boolean;
9696 }
9797
98+export interface McpToolDef {
99+ readonly name: string;
100+ readonly description: string;
101+}
102+
98103 /** How a connection surfaces a paused (approval-gated) execution. `browser` is
99104 * what the browser-approval scenarios drive: the pause yields an `approvalUrl`
100105 * for a human to open instead of letting the model resume inline. */
@@ -133,6 +138,9 @@ export const parseBrowserApproval = (result: McpCallResult): McpBrowserApproval · 32 unmodified lines
133138
134139 export interface McpSession {
135140 readonly listTools: () => Effect.Effect<ReadonlyArray<string>>;
141+ /** Full advertised tool definitions — for asserting on the description text
142+ * an MCP client actually reads (e.g. the execute tool's inventory). */
143+ readonly describeTools: () => Effect.Effect<ReadonlyArray<McpToolDef>>;
136144 readonly call: (name: string, args?: Record<string, unknown>) => Effect.Effect<McpCallResult>;
137145 /** Find the paused executionId in `text` and resume it with approval. */
138146 readonly approvePaused: (
@@ -298,6 +306,16 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 159 unmodified lines
298306 return defs.map((tool: { name: string }) => tool.name);
299307 });
300308
309+ const describeTools = () =>
310+ Effect.promise(async (): Promise<ReadonlyArray<McpToolDef>> => {
311+ const defs = await (await runtime()).listTools(serverName, callOptions);
312+ connected = true;
313+ return defs.map((tool: { name: string; description?: string }) => ({
314+ name: tool.name,
315+ description: tool.description ?? "",
316+ }));
317+ });
318+
301319 const call = (name: string, args: Record<string, unknown> = {}) =>
302320 Effect.promise(async (): Promise<McpCallResult> => {
303321 if (!connected) {
@@ -311,6 +329,7 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 7 unmodified lines
311329
312330 return {
313331 listTools,
332+ describeTools,
314333 call,
315334 approvePaused: (text, content = {}) =>
316335 Effect.suspend(() => {
packages/app/src/web/shell.tsx+1−1
@@ -295,7 +295,7 @@ function IntegrationList(props: { pathname: string; onNavigate?: () => void }) { · 294 unmodified lines
295295 <div className="flex flex-col gap-px">
296296 {value.map((integration: Integration) => {
297297 const slug = String(integration.slug);
298− const name = integration.description || slug;
298+ const name = integration.name || slug;
299299 const detailPath = `/integrations/${slug}`;
300300 const active =
301301 props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`);
packages/core/api/src/connections/api.ts+16−0
@@ -48,6 +48,7 @@ const ConnectionResponse = Schema.Struct({ · 47 unmodified lines
4848 provider: ProviderKey,
4949 address: ConnectionAddress,
5050 identityLabel: Schema.NullOr(Schema.String),
51+ description: Schema.NullOr(Schema.String),
5152 expiresAt: Schema.NullOr(Schema.Number),
5253 // The OAuth app that minted this connection (its `oauth_client` slug), or null
5354 // for static credentials. Lets the UI map a connection back to its app. Just a
@@ -80,8 +81,15 @@ const CommonCreateFields = { · 26 unmodified lines
8081 integration: IntegrationSlug,
8182 template: AuthTemplateSlug,
8283 identityLabel: Schema.optional(Schema.NullOr(Schema.String)),
84+ description: Schema.optional(Schema.NullOr(Schema.String)),
8385 } as const;
8486
87+// User-curated metadata edits. Absent field = unchanged; null = cleared.
88+const UpdateConnectionPayload = Schema.Struct({
89+ description: Schema.optional(Schema.NullOr(Schema.String)),
90+ identityLabel: Schema.optional(Schema.NullOr(Schema.String)),
91+});
92+
8593 const CreateConnectionPayload = Schema.Struct({
8694 ...CommonCreateFields,
8795 value: Schema.optional(Schema.String),
@@ -157,6 +165,14 @@ export const ConnectionsApi = HttpApiGroup.make("connections") · 69 unmodified lines
157165 error: [InternalError, ConnectionNotFound],
158166 }),
159167 )
168+ .add(
169+ HttpApiEndpoint.patch("update", "/connections/:owner/:integration/:name", {
170+ params: ConnectionParams,
171+ payload: UpdateConnectionPayload,
172+ success: ConnectionResponse,
173+ error: [InternalError, ConnectionNotFound],
174+ }),
175+ )
160176 .add(
161177 HttpApiEndpoint.delete("remove", "/connections/:owner/:integration/:name", {
162178 params: ConnectionParams,
packages/core/api/src/handlers/connections.ts+22−0
@@ -21,6 +21,7 @@ const toResponse = (c: Connection) => ({ · 20 unmodified lines
2121 provider: c.provider,
2222 address: c.address,
2323 identityLabel: c.identityLabel ?? null,
24+ description: c.description ?? null,
2425 expiresAt: c.expiresAt ?? null,
2526 oauthClient: c.oauthClient ?? null,
2627 oauthClientOwner: c.oauthClientOwner ?? null,
@@ -83,6 +84,27 @@ export const ConnectionsHandlers = HttpApiBuilder.group(ExecutorApi, "connection · 56 unmodified lines
8384 }),
8485 ),
8586 )
87+ .handle("update", ({ params: path, payload }) =>
88+ capture(
89+ Effect.gen(function* () {
90+ const executor = yield* ExecutorService;
91+ const updated = yield* executor.connections.update(
92+ {
93+ owner: path.owner,
94+ integration: path.integration,
95+ name: path.name,
96+ },
97+ {
98+ ...(payload.description !== undefined ? { description: payload.description } : {}),
99+ ...(payload.identityLabel !== undefined
100+ ? { identityLabel: payload.identityLabel }
101+ : {}),
102+ },
103+ );
104+ return toResponse(updated);
105+ }),
106+ ),
107+ )
86108 .handle("remove", ({ params: path }) =>
87109 capture(
88110 Effect.gen(function* () {
packages/core/api/src/handlers/integrations.ts+3−1
@@ -8,6 +8,7 @@ import { capture } from "@executor-js/api"; · 7 unmodified lines
88
99 const toResponse = (i: Integration) => ({
1010 slug: i.slug,
11+ name: i.name,
1112 description: i.description,
1213 kind: i.kind,
1314 canRemove: i.canRemove,
@@ -44,7 +45,8 @@ export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrati · 30 unmodified lines
4445 Effect.gen(function* () {
4546 const executor = yield* ExecutorService;
4647 yield* executor.integrations.update(path.slug, {
47− description: payload.description,
48+ ...(payload.name !== undefined ? { name: payload.name } : {}),
49+ ...(payload.description !== undefined ? { description: payload.description } : {}),
4850 });
4951 const integration = yield* executor.integrations.get(path.slug);
5052 if (integration === null) {
packages/core/api/src/integrations/api.ts+3−0
@@ -65,6 +65,8 @@ const AuthMethodDescriptorSchema = Schema.Struct({ · 64 unmodified lines
6565 /** Public projection of an integration — mirrors the SDK's `Integration`. */
6666 const IntegrationResponse = Schema.Struct({
6767 slug: IntegrationSlug,
68+ /** Display name. */
69+ name: Schema.String,
6870 description: Schema.String,
6971 /** The plugin that owns this integration kind (e.g. "openapi", "mcp"). */
7072 kind: Schema.String,
@@ -78,6 +80,7 @@ const IntegrationResponse = Schema.Struct({ · 7 unmodified lines
7880 });
7981
8082 const UpdateIntegrationPayload = Schema.Struct({
83+ name: Schema.optional(Schema.String),
8184 description: Schema.optional(Schema.String),
8285 });
8386
packages/core/execution/src/description.test.ts+57−2
@@ -61,7 +61,8 @@ const slackPlugin = definePlugin(() => ({ · 60 unmodified lines
6161 seed: () =>
6262 ctx.core.integrations.register({
6363 slug: SLACK,
64− description: "Slack Workspace",
64+ name: "Slack",
65+ description: "Send and read workspace messages.",
6566 config: {},
6667 }),
6768 }),
@@ -98,7 +99,7 @@ describe("buildExecuteDescription", () => { · 30 unmodified lines
9899 expect(description).toContain("- `github.org.prod`");
99100 expect(description).toContain("- `github.user.personal`");
100101 expect(description).not.toContain("## Available namespaces");
101− expect(description).not.toContain("Slack Workspace");
102+ expect(description).not.toContain("workspace messages");
102103 expect(description).not.toContain("`github-plugin`");
103104 expect(description).not.toContain("`slack-plugin`");
104105 expect(description).not.toContain("- `github`");
@@ -111,6 +112,60 @@ describe("buildExecuteDescription", () => { · 6 unmodified lines
111112 }),
112113 );
113114
115+ it.effect("annotates prefixes with connection or integration descriptions", () =>
116+ Effect.gen(function* () {
117+ const executor = yield* createExecutor(
118+ makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }),
119+ );
120+ yield* executor["slack-plugin"].seed();
121+ yield* executor["github-plugin"].seed();
122+ yield* executor.connections.create({
123+ owner: "org",
124+ name: ConnectionName.make("prod"),
125+ integration: GITHUB,
126+ template: TEMPLATE,
127+ value: "org-token",
128+ description: "Production org — issues and PRs only.",
129+ });
130+ yield* executor.connections.create({
131+ owner: "user",
132+ name: ConnectionName.make("personal"),
133+ integration: GITHUB,
134+ template: TEMPLATE,
135+ value: "user-token",
136+ });
137+
138+ const description = yield* buildExecuteDescription(executor);
139+
140+ // The curated connection description rides its prefix line.
141+ expect(description).toContain("- `github.org.prod` — Production org — issues and PRs only.");
142+ // No connection description and the integration description ("GitHub")
143+ // just restates the slug — the line stays bare.
144+ expect(description).toContain("- `github.user.personal`");
145+ expect(description).not.toContain("- `github.user.personal` —");
146+ }),
147+ );
148+
149+ it.effect("falls back to a meaningful integration description", () =>
150+ Effect.gen(function* () {
151+ const executor = yield* createExecutor(
152+ makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }),
153+ );
154+ yield* executor["slack-plugin"].seed();
155+ yield* executor.connections.create({
156+ owner: "org",
157+ name: ConnectionName.make("main"),
158+ integration: SLACK,
159+ template: TEMPLATE,
160+ value: "slack-token",
161+ });
162+
163+ const description = yield* buildExecuteDescription(executor);
164+
165+ expect(description).toContain("- `slack.org.main` — Send and read workspace messages.");
166+ }),
167+ );
168+
114169 it.effect("omits the Available connection prefixes section when no connections exist", () =>
115170 Effect.gen(function* () {
116171 const executor = yield* createExecutor(makeTestConfig({ plugins: [] as const }));
packages/core/execution/src/description.ts+57−9
@@ -1,5 +1,5 @@
11 import { Effect } from "effect";
2−import type { Connection, Executor } from "@executor-js/sdk/core";
2+import type { Connection, Integration, Executor } from "@executor-js/sdk/core";
33
44 /**
55 * Builds a tool description dynamically.
@@ -19,9 +19,14 @@ export const buildExecuteDescription = (executor: Executor): Effect.Effect<strin · 13 unmodified lines
1919 Effect.orDie,
2020 Effect.withSpan("executor.connections.list"),
2121 );
22+ const integrations: readonly Integration[] = yield* executor.integrations.list().pipe(
23+ // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: same getDescription error-channel constraint as connections.list above
24+ Effect.orDie,
25+ Effect.withSpan("executor.integrations.list"),
26+ );
2227
2328 const description = yield* Effect.sync(() =>
24− formatDescription(connections.map((connection) => connectionPath(connection))),
29+ formatDescription(connections.map((connection) => connectionEntry(connection, integrations))),
2530 ).pipe(
2631 Effect.withSpan("schema.compile.description", {
2732 attributes: { "executor.connection_count": connections.length },
@@ -53,7 +58,44 @@ const connectionPath = (connection: Connection): string => { · 25 unmodified lines
5358 return address.startsWith("tools.") ? address.slice("tools.".length) : address;
5459 };
5560
56−const formatDescription = (connectionPrefixes: readonly string[]): string => {
61+/** One inventory line: the callable prefix plus the best available context.
62+ * Connection description wins (the user wrote it about THIS credential);
63+ * otherwise the integration description, unless it is just the slug again. */
64+interface ConnectionInventoryEntry {
65+ readonly prefix: string;
66+ readonly description?: string;
67+}
68+
69+const inventoryNote = (
70+ text: string | null | undefined,
71+ identityEchoes: readonly string[],
72+): string | undefined => {
73+ const firstLine = (text ?? "").split("\n", 1)[0]!.trim();
74+ if (firstLine.length === 0) return undefined;
75+ // A description that just restates the slug or display name carries no
76+ // information beyond identity — drop it from the inventory line.
77+ if (identityEchoes.some((echo) => firstLine.toLowerCase() === echo.toLowerCase())) {
78+ return undefined;
79+ }
80+ return firstLine.length > 140 ? `${firstLine.slice(0, 139)}…` : firstLine;
81+};
82+
83+const connectionEntry = (
84+ connection: Connection,
85+ integrations: readonly Integration[],
86+): ConnectionInventoryEntry => {
87+ const slug = String(connection.integration);
88+ const integration = integrations.find((candidate) => String(candidate.slug) === slug);
89+ const identityEchoes = [slug, ...(integration ? [integration.name] : [])];
90+ return {
91+ prefix: connectionPath(connection),
92+ description:
93+ inventoryNote(connection.description, identityEchoes) ??
94+ inventoryNote(integration?.description, identityEchoes),
95+ };
96+};
97+
98+const formatDescription = (connectionEntries: readonly ConnectionInventoryEntry[]): string => {
5799 const lines: string[] = [
58100 "Execute TypeScript in a sandboxed runtime with access to configured API tools.",
59101 "",
@@ -84,17 +126,23 @@ const formatDescription = (connectionPrefixes: readonly string[]): string => { · 24 unmodified lines
84126 "- TypeScript type syntax (`: T`, `as T`, generics, interfaces, type aliases) is stripped before execution — feel free to write idiomatic TypeScript using the shapes from `tools.describe.tool()`. Decorators and `enum` are not supported.",
85127 ];
86128
87− if (connectionPrefixes.length > 0) {
129+ if (connectionEntries.length > 0) {
88130 lines.push("");
89131 lines.push("## Available connection prefixes");
90132 lines.push("");
91133 lines.push("These are paths under `tools.`; append the final tool segment.");
92− const sorted = [...connectionPrefixes].sort((a, b) => a.localeCompare(b)).slice(0, 50);
93− for (const prefix of sorted) {
94− lines.push(`- \`${prefix}\``);
134+ const sorted = [...connectionEntries]
135+ .sort((a, b) => a.prefix.localeCompare(b.prefix))
136+ .slice(0, 50);
137+ for (const entry of sorted) {
138+ lines.push(
139+ entry.description
140+ ? `- \`${entry.prefix}\` — ${entry.description}`
141+ : `- \`${entry.prefix}\``,
142+ );
95143 }
96− if (connectionPrefixes.length > sorted.length) {
97− lines.push(`- ... ${connectionPrefixes.length - sorted.length} more`);
144+ if (connectionEntries.length > sorted.length) {
145+ lines.push(`- ... ${connectionEntries.length - sorted.length} more`);
98146 }
99147 }
100148
packages/core/execution/src/promise.ts+4−0
@@ -90,6 +90,10 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => { · 89 unmodified lines
9090 fromPromise(() => pe.connections.list(filter)),
9191 get: (ref: Parameters<PromiseExecutor["connections"]["get"]>[0]) =>
9292 fromPromise(() => pe.connections.get(ref)),
93+ update: (
94+ ref: Parameters<PromiseExecutor["connections"]["update"]>[0],
95+ input: Parameters<PromiseExecutor["connections"]["update"]>[1],
96+ ) => fromPromise(() => pe.connections.update(ref, input)),
9397 remove: (ref: Parameters<PromiseExecutor["connections"]["remove"]>[0]) =>
9498 fromPromise(() => pe.connections.remove(ref)),
9599 refresh: (ref: Parameters<PromiseExecutor["connections"]["refresh"]>[0]) =>
packages/core/execution/src/tool-invoker.ts+10−1
@@ -111,7 +111,7 @@ const BUILTIN_TOOL_DESCRIPTIONS: ReadonlyMap<string, DescribedTool> = new Map< · 110 unmodified lines
111111 "{ items: ExecutorSourceListItem[]; total: number; hasMore: boolean; nextOffset: number | null; }",
112112 typeScriptDefinitions: {
113113 ExecutorSourceListItem:
114− "{ id: string; name: string; kind: string; canRemove?: boolean; canRefresh?: boolean; toolCount: number; }",
114+ "{ id: string; name: string; description?: string; kind: string; canRemove?: boolean; canRefresh?: boolean; toolCount: number; }",
115115 },
116116 },
117117 ],
@@ -320,6 +320,7 @@ export type ToolDiscoveryResult = { · 202 unmodified lines
320320 export type ExecutorSourceListItem = {
321321 readonly id: string;
322322 readonly name: string;
323+ readonly description?: string;
323324 readonly kind: string;
324325 readonly canRemove?: boolean;
325326 readonly canRefresh?: boolean;
@@ -682,6 +683,14 @@ export const listExecutorSources = Effect.fn("executor.sources.list")(function* · 356 unmodified lines
682683 ({
683684 id: String(integration.slug),
684685 name: String(integration.slug),
686+ // The integration's catalog description — user-editable context the
687+ // agent can use to pick a source. Omitted when it just repeats the
688+ // slug or display name (no information beyond identity).
689+ ...(integration.description &&
690+ integration.description.toLowerCase() !== String(integration.slug).toLowerCase() &&
691+ integration.description.toLowerCase() !== integration.name.toLowerCase()
692+ ? { description: integration.description }
693+ : {}),
685694 kind: integration.kind,
686695 canRemove: integration.canRemove,
687696 canRefresh: integration.canRefresh,
packages/core/fumadb/package.json+2−1
@@ -88,11 +88,12 @@ · 87 unmodified lines
8888 "build": "tsup && tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src",
8989 "typecheck": "tsgo --noEmit",
9090 "typecheck:slow": "tsc --noEmit",
91− "test": "vitest run src/**/*.test.ts test/generate.test.ts test/uuid.test.ts --passWithNoTests",
91+ "test": "vitest run src test/generate.test.ts test/uuid.test.ts --passWithNoTests",
9292 "test:integration": "vitest run test/**/*.test.ts --passWithNoTests"
9393 },
9494 "devDependencies": {
9595 "@effect/vitest": "catalog:",
96+ "@libsql/client": "catalog:",
9697 "@types/better-sqlite3": "^7.6.13",
9798 "@types/node": "catalog:",
9899 "@types/pg": "^8.20.0",
packages/core/fumadb/src/adapters/drizzle/runtime-ensure.test.ts+86−0
@@ -0,0 +1,86 @@
1+import { mkdtempSync, rmSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+
5+import { expect, test } from "@effect/vitest";
6+import { createClient } from "@libsql/client";
7+import { drizzle } from "drizzle-orm/libsql";
8+
9+import {
10+ createDrizzleRuntimeSchemaSqlFromTables,
11+ ensureDrizzleRuntimeSchemaFromTables,
12+} from "./index";
13+import { column, idColumn, table } from "../../schema";
14+
15+const NS = "executor";
16+
17+// An older database baseline: `integration` has only id + slug.
18+const v1Tables = {
19+ integration: table("integration", {
20+ id: idColumn("id", "varchar(255)"),
21+ slug: column("slug", "varchar(255)"),
22+ }),
23+};
24+
25+// The running schema adds two nullable columns the old file predates — the
26+// shape of the integration-descriptions feature (name + config_revised_at).
27+const v2Tables = {
28+ integration: table("integration", {
29+ id: idColumn("id", "varchar(255)"),
30+ slug: column("slug", "varchar(255)"),
31+ name: column("name", "string").nullable(),
32+ config_revised_at: column("config_revised_at", "bigint").nullable(),
33+ }),
34+};
35+
36+const columnNames = async (client: ReturnType<typeof createClient>): Promise<readonly string[]> => {
37+ const info = await client.execute("PRAGMA table_info('integration')");
38+ return info.rows.map((row) => String(row["name"]));
39+};
40+
41+// This is the boot bring-up that backs apps/local, apps/host-selfhost, and
42+// apps/host-cloudflare: `CREATE TABLE IF NOT EXISTS` never alters an existing
43+// table, so without column evolution a file from an earlier baseline would
44+// 500 on the first query against a new column. (Cloud Postgres gets the same
45+// columns from a generated drizzle migration instead.)
46+test("ensure evolves an existing table with the schema's new nullable columns", async () => {
47+ const dir = mkdtempSync(join(tmpdir(), "fumadb-ensure-"));
48+ const client = createClient({ url: `file:${join(dir, "test.db")}` });
49+ const db = drizzle({ client });
50+
51+ try {
52+ // Stand up the old database baseline directly (CREATE TABLE only).
53+ for (const statement of createDrizzleRuntimeSchemaSqlFromTables({
54+ tables: v1Tables,
55+ namespace: NS,
56+ version: "1.0.0",
57+ provider: "sqlite",
58+ })) {
59+ await client.execute(statement);
60+ }
61+ expect(await columnNames(client)).not.toContain("name");
62+
63+ // Boot with the new schema → the missing columns are added in place.
64+ await ensureDrizzleRuntimeSchemaFromTables(db, {
65+ tables: v2Tables,
66+ namespace: NS,
67+ version: "1.0.0",
68+ provider: "sqlite",
69+ });
70+ const evolved = await columnNames(client);
71+ expect(evolved).toContain("name");
72+ expect(evolved).toContain("config_revised_at");
73+
74+ // Idempotent: a second boot tolerates the now-duplicate columns.
75+ await ensureDrizzleRuntimeSchemaFromTables(db, {
76+ tables: v2Tables,
77+ namespace: NS,
78+ version: "1.0.0",
79+ provider: "sqlite",
80+ });
81+ expect(await columnNames(client)).toEqual(evolved);
82+ } finally {
83+ client.close();
84+ rmSync(dir, { recursive: true, force: true });
85+ }
86+});
packages/core/fumadb/src/adapters/drizzle/runtime.ts+44−0
@@ -330,6 +330,39 @@ export const createDrizzleRuntimeSchemaSql = ( · 329 unmodified lines
330330 createSettingsTableSql(options.namespace, options.schema.version, options.provider),
331331 ];
332332
333+/** `ALTER TABLE … ADD COLUMN` statements for every NULLABLE column, one per
334+ * column. `CREATE TABLE IF NOT EXISTS` never touches an existing table, so a
335+ * schema-evolution column would otherwise only exist in fresh databases. Each
336+ * statement fails with a duplicate-column error when the column already
337+ * exists — callers run them individually and swallow exactly that error,
338+ * which keeps this idempotent without provider-specific catalog probes.
339+ * Non-nullable additions can't be backfilled blindly and still need a real
340+ * migration. */
341+const addNullableColumnsSql = (options: DrizzleRuntimeSchemaOptions): readonly string[] =>
342+ Object.values(options.schema.tables).flatMap((table) =>
343+ Object.values(table.columns)
344+ .filter((column) => column.isNullable && !(column instanceof IdColumn))
345+ .map((column) =>
346+ [
347+ "ALTER TABLE",
348+ quoteIdent(table.names.sql),
349+ "ADD COLUMN",
350+ columnDefinitionSql(column, options.provider),
351+ ].join(" "),
352+ ),
353+ );
354+
355+const isDuplicateColumnError = (error: unknown): boolean => {
356+ // Drivers wrap the SQL error (e.g. DrizzleQueryError → LibsqlError), so
357+ // match anywhere down the cause chain.
358+ for (let current = error, depth = 0; current != null && depth < 8; depth += 1) {
359+ const message = current instanceof Error ? current.message : String(current);
360+ if (/duplicate column|already exists/i.test(message)) return true;
361+ current = current instanceof Error ? current.cause : null;
362+ }
363+ return false;
364+};
365+
333366 export const createDrizzleRuntimeSchemaSqlFromTables = (
334367 options: DrizzleRuntimeTablesOptions,
335368 ): readonly string[] =>
@@ -370,6 +403,17 @@ export const ensureDrizzleRuntimeSchema = async ( · 34 unmodified lines
370403 } else {
371404 await run(db);
372405 }
406+
407+ // Evolve existing tables: add any nullable columns they predate. Outside
408+ // the transaction — each statement individually either succeeds (column was
409+ // missing) or fails with a duplicate-column error (column exists, ignored).
410+ for (const statement of addNullableColumnsSql(options)) {
411+ try {
412+ await runStatement(db, statement);
413+ } catch (error) {
414+ if (!isDuplicateColumnError(error)) throw error;
415+ }
416+ }
373417 };
374418
375419 export const ensureDrizzleRuntimeSchemaFromTables = async (
packages/core/sdk/src/client.ts+22−1
@@ -115,6 +115,18 @@ export interface IntegrationAccountHandoff { · 114 unmodified lines
115115 readonly label?: string;
116116 }
117117
118+/** Outcome of applying an edit-sheet section's staged change. `summary` is
119+ * toasted on success; `ok: false` keeps the sheet open (the section renders
120+ * its own error inline). */
121+export type EditSheetApplyResult =
122+ | { readonly ok: true; readonly summary: string | null }
123+ | { readonly ok: false };
124+
125+export interface EditSheetSectionProps {
126+ readonly sourceId: string;
127+ readonly onPendingChange?: (apply: (() => Promise<EditSheetApplyResult>) | null) => void;
128+}
129+
118130 export interface IntegrationPlugin {
119131 /** Unique key matching the SDK plugin id (e.g. "openapi"). */
120132 readonly key: string;
@@ -130,10 +142,19 @@ export interface IntegrationPlugin { · 9 unmodified lines
130142 readonly initialPreset?: string;
131143 readonly initialNamespace?: string;
132144 }>;
133− readonly edit: ComponentType<{
145+ /** Legacy full-page edit surface. No host renders this anymore — plugin
146+ * configuration lives in the integration Edit sheet via `editSheet`. */
147+ readonly edit?: ComponentType<{
134148 readonly sourceId: string;
135149 readonly onSave: () => void;
136150 }>;
151+ /** Plugin-owned configuration rendered inside the integration's Edit sheet,
152+ * below the shared metadata fields (e.g. the OpenAPI spec-update controls).
153+ * The sheet has ONE Save: the section stages its pending change locally and
154+ * reports it through `onPendingChange` — a thunk that applies the staged
155+ * change. Save runs the metadata update, then the staged apply; a failed
156+ * apply keeps the sheet open with the section showing its own error. */
157+ readonly editSheet?: ComponentType<EditSheetSectionProps>;
137158 readonly summary?: ComponentType<{
138159 readonly sourceId: string;
139160 readonly variant?: "badge" | "panel";
packages/core/sdk/src/connection.ts+14−0
@@ -32,6 +32,11 @@ export interface Connection { · 31 unmodified lines
3232 readonly address: ConnectionAddress;
3333 /** Optional human label (which account). Not load-bearing. */
3434 readonly identityLabel?: string | null;
35+ /** User-curated description of what this connection is for. Agent-visible:
36+ * surfaces next to the connection's prefix in the execute-tool inventory
37+ * and in `connections.list`, so it is the place to give agents context a
38+ * spec can't (e.g. "the staging CRM — reads only"). */
39+ readonly description?: string | null;
3540 /** Epoch ms when an OAuth access token expires; null/absent for static creds. */
3641 readonly expiresAt?: number | null;
3742 /** The OAuth app (`oauth_client` slug) that minted this connection, when it
@@ -87,4 +92,13 @@ export type CreateConnectionInput = { · 49 unmodified lines
8792 readonly integration: IntegrationSlug;
8893 readonly template: AuthTemplateSlug;
8994 readonly identityLabel?: string | null;
95+ readonly description?: string | null;
9096 } & ConnectionValueInput;
97+
98+/** Edit a connection's user-curated metadata. Only the provided fields change;
99+ * credentials and OAuth lifecycle are untouchable here (recreate or refresh
100+ * instead). */
101+export interface UpdateConnectionInput {
102+ readonly description?: string | null;
103+ readonly identityLabel?: string | null;
104+}
packages/core/sdk/src/core-schema.ts+22−1
@@ -120,8 +120,23 @@ export const coreTables = defineTables({ · 119 unmodified lines
120120 {
121121 slug: keyColumn("slug"),
122122 plugin_id: textColumn("plugin_id"),
123− description: textColumn("description"),
123+ // Display name. The pre-split field: `description` used to hold the
124+ // name, so cloud backfills `name` from it (migration 0006) and other
125+ // hosts fall back at read time (see rowToIntegration). Nullable because
126+ // SQLite boot-ensure hosts cannot add a NOT NULL column to existing
127+ // tables, so the column stays nullable even though it is always present
128+ // in practice.
129+ name: nullableTextColumn("name"),
130+ // Actual prose description, now distinct from the name. Nullable: absent
131+ // until a user/spec supplies one (cloud clears the old duplicated title
132+ // to NULL in 0006).
133+ description: nullableTextColumn("description"),
124134 config: nullableJsonColumn("config"),
135+ // Epoch ms of the last tool-affecting config change (spec update, auth
136+ // template edit). Compared against each connection's `tools_synced_at`
137+ // so OTHER subjects' connections — whose tool rows the updater cannot
138+ // write under the owner policy — lazily rebuild on their next read.
139+ config_revised_at: nullableBigintColumn("config_revised_at"),
125140 can_remove: boolColumn("can_remove", true),
126141 can_refresh: boolColumn("can_refresh", false),
127142 created_at: dateColumn("created_at"),
@@ -144,6 +159,12 @@ export const coreTables = defineTables({ · 16 unmodified lines
144159 provider: textColumn("provider"),
145160 item_ids: jsonColumn("item_ids"),
146161 identity_label: nullableTextColumn("identity_label"),
162+ // User-curated, agent-visible "what is this connection for". Settable at
163+ // create, editable after; never reset by OAuth re-mints.
164+ description: nullableTextColumn("description"),
165+ // Epoch ms of the last tool (re)production for this connection. Stale
166+ // vs the integration's `config_revised_at` → re-produced on next read.
167+ tools_synced_at: nullableBigintColumn("tools_synced_at"),
147168 oauth_client: nullableTextColumn("oauth_client"),
148169 // The OWNER of `oauth_client` (a Personal connection may be minted through
149170 // a shared Workspace app), set together with `oauth_client`; null for
packages/core/sdk/src/core-tools.ts+2−0
@@ -70,6 +70,7 @@ const ConnectionOutput = Schema.Struct({ · 69 unmodified lines
7070 provider: Schema.String,
7171 address: Schema.String,
7272 identityLabel: Schema.optional(Schema.NullOr(Schema.String)),
73+ description: Schema.optional(Schema.NullOr(Schema.String)),
7374 expiresAt: Schema.NullOr(Schema.Number),
7475 oauthClient: Schema.NullOr(Schema.String),
7576 oauthClientOwner: Schema.NullOr(OwnerSchema),
@@ -303,6 +304,7 @@ const connectionToOutput = (connection: Connection) => ({ · 227 unmodified lines
303304 provider: String(connection.provider),
304305 address: String(connection.address),
305306 identityLabel: connection.identityLabel ?? null,
307+ description: connection.description ?? null,
306308 expiresAt: connection.expiresAt ?? null,
307309 oauthClient: connection.oauthClient == null ? null : String(connection.oauthClient),
308310 oauthClientOwner: connection.oauthClientOwner ?? null,
packages/core/sdk/src/executor.ts+127−5
@@ -24,6 +24,7 @@ import type { · 23 unmodified lines
2424 ConnectionRef,
2525 CreateConnectionInput,
2626 ConnectionValueInput,
27+ UpdateConnectionInput,
2728 } from "./connection";
2829 import {
2930 coreSchema,
@@ -246,7 +247,7 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 216 unmodified lines
246247 readonly get: (slug: IntegrationSlug) => Effect.Effect<Integration | null, StorageFailure>;
247248 readonly update: (
248249 slug: IntegrationSlug,
249− patch: { readonly description?: string },
250+ patch: { readonly name?: string; readonly description?: string },
250251 ) => Effect.Effect<void, IntegrationNotFoundError | StorageFailure>;
251252 readonly remove: (
252253 slug: IntegrationSlug,
@@ -271,6 +272,12 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 18 unmodified lines
271272 readonly owner?: Owner;
272273 }) => Effect.Effect<readonly Connection[], StorageFailure>;
273274 readonly get: (ref: ConnectionRef) => Effect.Effect<Connection | null, StorageFailure>;
275+ /** Edit user-curated metadata (description, identityLabel). Credentials and
276+ * OAuth lifecycle fields are not editable here. */
277+ readonly update: (
278+ ref: ConnectionRef,
279+ input: UpdateConnectionInput,
280+ ) => Effect.Effect<Connection, ConnectionNotFoundError | StorageFailure>;
274281 readonly remove: (
275282 ref: ConnectionRef,
276283 ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>;
@@ -441,7 +448,11 @@ const rowToIntegration = ( · 164 unmodified lines
441448 displayUrl?: string,
442449 ): Integration => ({
443450 slug: IntegrationSlug.make(row.slug),
444− description: row.description,
451+ // Pre-split rows have no `name`; their description WAS the display name.
452+ name: row.name ?? row.description ?? row.slug,
453+ // `description` is now nullable (cleared where it only held a duplicated
454+ // title); present it as "" so the public Integration type stays a string.
455+ description: row.description ?? "",
445456 kind: row.plugin_id,
446457 canRemove: Boolean(row.can_remove),
447458 canRefresh: Boolean(row.can_refresh),
@@ -469,6 +480,7 @@ const rowToConnection = (row: ConnectionRow): Connection => { · 21 unmodified lines
469480 provider: ProviderKey.make(row.provider),
470481 address: connectionAddress(owner, integration, name),
471482 identityLabel: row.identity_label ?? null,
483+ description: row.description ?? null,
472484 expiresAt: row.expires_at == null ? null : Number(row.expires_at),
473485 oauthClient: row.oauth_client == null ? null : OAuthClientSlug.make(String(row.oauth_client)),
474486 oauthClientOwner:
@@ -1270,6 +1282,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 795 unmodified lines
12701282
12711283 const staticSourceToIntegration = (source: StaticSourceDecl): Integration => ({
12721284 slug: IntegrationSlug.make(source.id),
1285+ name: source.name,
12731286 description: source.name,
12741287 kind: source.kind,
12751288 canRemove: source.canRemove ?? false,
@@ -1705,6 +1718,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 429 unmodified lines
17051718 where: (b: AnyCb) => b("slug", "=", String(input.slug)),
17061719 set: {
17071720 plugin_id: pluginId,
1721+ name: input.name ?? existing.name ?? null,
17081722 description: input.description,
17091723 config,
17101724 can_remove: input.canRemove ?? Boolean(existing.can_remove),
@@ -1718,6 +1732,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines
17181732 tenant,
17191733 slug: String(input.slug),
17201734 plugin_id: pluginId,
1735+ name: input.name ?? null,
17211736 description: input.description,
17221737 config,
17231738 can_remove: input.canRemove ?? true,
@@ -1731,14 +1746,24 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines
17311746 const integrationsUpdate = (
17321747 slug: IntegrationSlug,
17331748 patch: {
1749+ readonly name?: string;
17341750 readonly description?: string;
17351751 readonly config?: IntegrationConfig;
17361752 },
17371753 ): Effect.Effect<void, StorageFailure> =>
17381754 Effect.gen(function* () {
1739− const set: Record<string, unknown> = { updated_at: new Date() };
1755+ const now = new Date();
1756+ const set: Record<string, unknown> = { updated_at: now };
1757+ if (patch.name !== undefined) set.name = patch.name;
17401758 if (patch.description !== undefined) set.description = patch.description;
1741− if (patch.config !== undefined) set.config = patch.config;
1759+ if (patch.config !== undefined) {
1760+ set.config = patch.config;
1761+ // A config change can change the derived tools. The writer can only
1762+ // rebuild catalogs in its own partition (owner policy), so revise
1763+ // the integration: other subjects' connections compare this stamp
1764+ // against their `tools_synced_at` and lazily rebuild on next read.
1765+ set.config_revised_at = now.getTime();
1766+ }
17421767 yield* core.updateMany("integration", {
17431768 where: (b: AnyCb) => b("slug", "=", String(slug)),
17441769 set,
@@ -1747,7 +1772,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines
17471772
17481773 const integrationsUpdatePublic = (
17491774 slug: IntegrationSlug,
1750− patch: { readonly description?: string },
1775+ patch: { readonly name?: string; readonly description?: string },
17511776 ): Effect.Effect<void, IntegrationNotFoundError | StorageFailure> =>
17521777 Effect.gen(function* () {
17531778 const existing = yield* findIntegrationRow(slug);
@@ -1814,6 +1839,18 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 60 unmodified lines
18141839 b("integration", "=", String(ref.integration)),
18151840 b("connection", "=", String(ref.name)),
18161841 );
1842+ // Every exit stamps the sync time — including the cleanup paths that
1843+ // produce zero tools — so the stale-catalog check (`config_revised_at`
1844+ // vs `tools_synced_at`) doesn't re-attempt this connection per read.
1845+ const stampSynced = core.updateMany("connection", {
1846+ where: (b: AnyCb) =>
1847+ b.and(
1848+ byOwner(owner)(b),
1849+ b("integration", "=", String(ref.integration)),
1850+ b("name", "=", String(ref.name)),
1851+ ),
1852+ set: { tools_synced_at: Date.now() },
1853+ });
18171854
18181855 // Defense in depth (and cleanup for rows created before the create-time
18191856 // guard, or emptied by an external edit): a credentialed non-OAuth
@@ -1834,6 +1871,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 14 unmodified lines
18341871 Effect.gen(function* () {
18351872 yield* core.deleteMany("tool", { where });
18361873 yield* core.deleteMany("definition", { where });
1874+ yield* stampSynced;
18371875 }),
18381876 );
18391877 return [];
@@ -1845,6 +1883,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
18451883 Effect.gen(function* () {
18461884 yield* core.deleteMany("tool", { where });
18471885 yield* core.deleteMany("definition", { where });
1886+ yield* stampSynced;
18481887 }),
18491888 );
18501889 return [];
@@ -1901,6 +1940,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 50 unmodified lines
19011940 yield* core.deleteMany("definition", { where });
19021941 yield* core.createMany("tool", toolRows);
19031942 yield* core.createMany("definition", definitionRows);
1943+ yield* stampSynced;
19041944 }),
19051945 );
19061946
@@ -2041,6 +2081,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 134 unmodified lines
20412081 provider: providerKey,
20422082 item_ids: itemIds,
20432083 identity_label: input.identityLabel ?? null,
2084+ // Re-saving a credential keeps an existing curated description
2085+ // unless the caller explicitly provides one.
2086+ ...(input.description !== undefined ? { description: input.description } : {}),
20442087 updated_at: now,
20452088 };
20462089 if (existing) {
@@ -2064,6 +2107,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 17 unmodified lines
20642107 provider: providerKey,
20652108 item_ids: itemIds,
20662109 identity_label: input.identityLabel ?? null,
2110+ description: input.description ?? null,
20672111 oauth_client: null,
20682112 refresh_item_id: null,
20692113 expires_at: null,
@@ -2099,6 +2143,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines
20992143 provider: providerKey,
21002144 item_ids: itemIds,
21012145 identity_label: input.identityLabel ?? null,
2146+ description: input.description ?? null,
21022147 oauth_client: null,
21032148 refresh_item_id: null,
21042149 expires_at: null,
@@ -2172,6 +2217,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 67 unmodified lines
21722217 provider: input.provider,
21732218 item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId },
21742219 identity_label: input.identityLabel ?? null,
2220+ // Curated description: never stamped by a mint — a reconnect
2221+ // or token refresh must not erase what the user wrote.
2222+ description: null,
21752223 oauth_client: String(input.oauthClient),
21762224 oauth_client_owner: input.oauthClientOwner,
21772225 refresh_item_id: input.refreshItemId,
@@ -2204,6 +2252,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 26 unmodified lines
22042252 provider: input.provider,
22052253 item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId },
22062254 identity_label: input.identityLabel ?? null,
2255+ description: null,
22072256 oauth_client: String(input.oauthClient),
22082257 oauth_client_owner: input.oauthClientOwner,
22092258 refresh_item_id: input.refreshItemId,
@@ -2234,6 +2283,35 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 24 unmodified lines
22342283 const connectionsGet = (ref: ConnectionRef): Effect.Effect<Connection | null, StorageFailure> =>
22352284 findConnectionRow(ref).pipe(Effect.map((row) => (row ? rowToConnection(row) : null)));
22362285
2286+ const connectionsUpdate = (
2287+ ref: ConnectionRef,
2288+ input: UpdateConnectionInput,
2289+ ): Effect.Effect<Connection, ConnectionNotFoundError | StorageFailure> =>
2290+ Effect.gen(function* () {
2291+ const row = yield* findConnectionRow(ref);
2292+ if (!row) {
2293+ return yield* new ConnectionNotFoundError({
2294+ owner: ref.owner,
2295+ integration: ref.integration,
2296+ name: ref.name,
2297+ });
2298+ }
2299+ const set: Record<string, unknown> = { updated_at: new Date() };
2300+ if (input.description !== undefined) set.description = input.description;
2301+ if (input.identityLabel !== undefined) set.identity_label = input.identityLabel;
2302+ yield* core.updateMany("connection", {
2303+ where: (b: AnyCb) =>
2304+ b.and(
2305+ byOwner(ref.owner)(b),
2306+ b("integration", "=", String(ref.integration)),
2307+ b("name", "=", String(ref.name)),
2308+ ),
2309+ set,
2310+ });
2311+ const updated = yield* findConnectionRow(ref);
2312+ return rowToConnection(updated ?? row);
2313+ });
2314+
22372315 const connectionsRemove = (
22382316 ref: ConnectionRef,
22392317 ): Effect.Effect<void, ConnectionNotFoundError | StorageFailure> =>
@@ -2320,8 +2398,50 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 80 unmodified lines
23202398 return true;
23212399 };
23222400
2401+ // Rebuild any visible connection whose tool catalog predates its
2402+ // integration's last tool-affecting config change. The change's author
2403+ // could only rewrite catalogs in their own partition (owner policy);
2404+ // every other subject converges here, on their own read, under their own
2405+ // binding. Best-effort: a failed rebuild leaves the stale-but-working
2406+ // catalog in place and retries on the next read.
2407+ const syncStaleConnectionTools = Effect.gen(function* () {
2408+ const revised = yield* core.findMany("integration", {
2409+ where: (b: AnyCb) => b.isNotNull("config_revised_at"),
2410+ });
2411+ if (revised.length === 0) return;
2412+ const revisedAt = new Map(
2413+ revised.map((row) => [row.slug, Number(row.config_revised_at)] as const),
2414+ );
2415+ const connections = yield* core.findMany("connection", {
2416+ where: (b: AnyCb) => b.or(...revised.map((row) => b("integration", "=", row.slug))),
2417+ });
2418+ for (const connection of connections) {
2419+ const revisedTime = revisedAt.get(connection.integration);
2420+ if (revisedTime === undefined) continue;
2421+ const syncedAt =
2422+ connection.tools_synced_at == null ? 0 : Number(connection.tools_synced_at);
2423+ if (syncedAt >= revisedTime) continue;
2424+ const integrationRow = revised.find((row) => row.slug === connection.integration);
2425+ if (!integrationRow) continue;
2426+ yield* produceConnectionTools(integrationRow, {
2427+ owner: connection.owner as Owner,
2428+ integration: IntegrationSlug.make(connection.integration),
2429+ name: ConnectionName.make(connection.name),
2430+ }).pipe(
2431+ Effect.catch(() => Effect.succeed([] as readonly Tool[])),
2432+ Effect.withSpan("executor.tools.sync_stale", {
2433+ attributes: {
2434+ "executor.integration": connection.integration,
2435+ "executor.connection": connection.name,
2436+ },
2437+ }),
2438+ );
2439+ }
2440+ });
2441+
23232442 const toolsList = (filter?: ToolListFilter): Effect.Effect<readonly Tool[], StorageFailure> =>
23242443 Effect.gen(function* () {
2444+ yield* syncStaleConnectionTools;
23252445 // Projected: the list surface is metadata (address, description,
23262446 // annotations) — loading every tool's input/output schema JSON made
23272447 // an unbounded list scale with schema bytes, not tool count.
@@ -3018,6 +3138,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 690 unmodified lines
30183138 create: (input) => connectionsCreate(input),
30193139 list: (filter) => connectionsList(filter),
30203140 get: (ref) => connectionsGet(ref),
3141+ update: (ref, input) => connectionsUpdate(ref, input),
30213142 remove: (ref) => connectionsRemove(ref),
30223143 refresh: (ref) => connectionsRefresh(ref),
30233144 resolveValue: (ref) => resolveConnectionValueByRef(ref),
@@ -3124,6 +3245,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 100 unmodified lines
31243245 create: connectionsCreate,
31253246 list: connectionsList,
31263247 get: connectionsGet,
3248+ update: connectionsUpdate,
31273249 remove: connectionsRemove,
31283250 refresh: connectionsRefresh,
31293251 },
packages/core/sdk/src/index.ts+1−0
@@ -91,6 +91,7 @@ export type { · 90 unmodified lines
9191 ConnectionRef,
9292 ConnectionValueInput,
9393 CreateConnectionInput,
94+ UpdateConnectionInput,
9495 } from "./connection";
9596 export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool";
9697
packages/core/sdk/src/integration.ts+8−0
@@ -74,6 +74,11 @@ export interface AuthMethodDescriptor { · 73 unmodified lines
7474 * Carries no credentials and no plugin-internal config. */
7575 export interface Integration {
7676 readonly slug: IntegrationSlug;
77+ /** Display name. Pre-split rows stored the name in `description`; readers
78+ * fall back, so this is always populated. */
79+ readonly name: string;
80+ /** Agent-visible context ("what this API is and when to reach for it").
81+ * Distinct from the display name; may equal it on legacy rows. */
7782 readonly description: string;
7883 /** The plugin that owns this integration kind (e.g. "openapi", "mcp"). */
7984 readonly kind: string;
@@ -142,6 +147,9 @@ export const mergeAuthTemplates = <T extends { readonly slug: string }>( · 62 unmodified lines
142147 * produced per-connection now). */
143148 export interface RegisterIntegrationInput {
144149 readonly slug: IntegrationSlug;
150+ /** Display name. Falls back to `description` then the slug when omitted
151+ * (legacy callers registered with description-as-name). */
152+ readonly name?: string;
145153 readonly description: string;
146154 /** Opaque plugin config (auth templates, spec ref, mcp url, …). */
147155 readonly config: IntegrationConfig;
packages/core/sdk/src/plugin.ts+16−2
@@ -6,7 +6,12 @@ import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/sp · 5 unmodified lines
66 import type { StorageFailure } from "./fuma-runtime";
77
88 import type { PluginBlobStore } from "./blob";
9−import type { Connection, ConnectionRef, CreateConnectionInput } from "./connection";
9+import type {
10+ Connection,
11+ ConnectionRef,
12+ CreateConnectionInput,
13+ UpdateConnectionInput,
14+} from "./connection";
1015 import type {
1116 AuthMethodDescriptor,
1217 Integration,
@@ -111,7 +116,11 @@ export interface PluginCtx<TStore = unknown> { · 98 unmodified lines
111116 readonly register: (input: RegisterIntegrationInput) => Effect.Effect<void, StorageFailure>;
112117 readonly update: (
113118 slug: IntegrationSlug,
114− patch: { readonly description?: string; readonly config?: IntegrationConfig },
119+ patch: {
120+ readonly name?: string;
121+ readonly description?: string;
122+ readonly config?: IntegrationConfig;
123+ },
115124 ) => Effect.Effect<void, StorageFailure>;
116125 readonly list: () => Effect.Effect<readonly Integration[], StorageFailure>;
117126 readonly get: (
@@ -151,6 +160,11 @@ export interface PluginCtx<TStore = unknown> { · 33 unmodified lines
151160 readonly owner?: Owner;
152161 }) => Effect.Effect<readonly Connection[], StorageFailure>;
153162 readonly get: (ref: ConnectionRef) => Effect.Effect<Connection | null, StorageFailure>;
163+ /** Edit user-curated metadata (description, identityLabel). */
164+ readonly update: (
165+ ref: ConnectionRef,
166+ input: UpdateConnectionInput,
167+ ) => Effect.Effect<Connection, ConnectionNotFoundError | StorageFailure>;
154168 readonly remove: (
155169 ref: ConnectionRef,
156170 ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>;
packages/core/sdk/src/promise.ts+1−0
@@ -32,6 +32,7 @@ export type { · 31 unmodified lines
3232 Connection,
3333 ConnectionRef,
3434 CreateConnectionInput,
35+ UpdateConnectionInput,
3536 ConnectionValueInput,
3637 } from "./connection";
3738 // Credential providers are Effect-native (their `get`/`set` return `Effect`s),
packages/core/sdk/src/shared.ts+1−0
@@ -42,6 +42,7 @@ export type { · 41 unmodified lines
4242 ConnectionRef,
4343 ConnectionValueInput,
4444 CreateConnectionInput,
45+ UpdateConnectionInput,
4546 } from "./connection";
4647 export type { CredentialProvider, ProviderEntry } from "./provider";
4748 export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool";
packages/plugins/graphql/src/api/group.ts+1−0
@@ -21,6 +21,7 @@ const AddIntegrationPayload = Schema.Struct({ · 20 unmodified lines
2121 endpoint: Schema.String,
2222 slug: Schema.optional(Schema.String),
2323 name: Schema.optional(Schema.String),
24+ description: Schema.optional(Schema.String),
2425 introspectionJson: Schema.optional(Schema.String),
2526 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
2627 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
packages/plugins/graphql/src/api/handlers.ts+1−0
@@ -40,6 +40,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 39 unmodified lines
4040 endpoint: payload.endpoint,
4141 slug: payload.slug,
4242 name: payload.name,
43+ description: payload.description,
4344 introspectionJson: payload.introspectionJson,
4445 headers: payload.headers,
4546 queryParams: payload.queryParams,
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+9−1
@@ -45,6 +45,7 @@ export default function AddGraphqlSource(props: { · 44 unmodified lines
4545 initialUrl?: string;
4646 }) {
4747 const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");
48+ const [description, setDescription] = useState("");
4849 const identity = useIntegrationIdentity({
4950 fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "",
5051 });
@@ -111,6 +112,7 @@ export default function AddGraphqlSource(props: { · 60 unmodified lines
111112 endpoint: trimmedEndpoint,
112113 slug,
113114 name: displayName,
115+ ...(description.trim().length > 0 ? { description: description.trim() } : {}),
114116 ...(authenticationTemplate.length > 0
115117 ? { authenticationTemplate: [...authenticationTemplate] }
116118 : {}),
@@ -131,7 +133,13 @@ export default function AddGraphqlSource(props: { · 14 unmodified lines
131133 <div className="flex flex-1 flex-col gap-6">
132134 <h1 className="text-xl font-semibold text-foreground">Add GraphQL Source</h1>
133135
134− <GraphqlSourceFields endpoint={endpoint} onEndpointChange={setEndpoint} identity={identity} />
136+ <GraphqlSourceFields
137+ endpoint={endpoint}
138+ onEndpointChange={setEndpoint}
139+ identity={identity}
140+ description={description}
141+ onDescriptionChange={setDescription}
142+ />
135143
136144 <AuthMethodListEditor
137145 list={authMethodList}
packages/plugins/graphql/src/react/EditGraphqlSource.tsx+0−43
@@ -1,43 +0,0 @@
1−import { useAtomValue } from "@effect/atom-react";
2−import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3−import * as Option from "effect/Option";
4−
5−import { IntegrationSlug } from "@executor-js/sdk/shared";
6−import { decodeGraphqlIntegrationConfigOption } from "@executor-js/plugin-graphql";
7−
8−import { graphqlIntegrationConfigAtom } from "./atoms";
9−import GraphqlAccountsPanel from "./GraphqlAccountsPanel";
10−
11−// ---------------------------------------------------------------------------
12−// Edit form — v2: custom authentication methods and connections live in the
13−// shared accounts hub. Keep edit and accounts on the same component path so
14−// GraphQL gets the same "+ Custom method" behavior as OpenAPI.
15−// ---------------------------------------------------------------------------
16−
17−export default function EditGraphqlSource(props: {
18− readonly sourceId: string;
19− readonly onSave: () => void;
20−}) {
21− const slug = IntegrationSlug.make(props.sourceId);
22− const configResult = useAtomValue(graphqlIntegrationConfigAtom(slug));
23− const config = AsyncResult.isSuccess(configResult)
24− ? Option.getOrNull(decodeGraphqlIntegrationConfigOption(configResult.value))
25− : null;
26−
27− if (!AsyncResult.isSuccess(configResult) || !config) {
28− return (
29− <div className="space-y-3">
30− <h1 className="text-xl font-semibold text-foreground">GraphQL Source</h1>
31− <p className="text-sm text-muted-foreground">Loading configuration…</p>
32− </div>
33− );
34− }
35−
36− return (
37− <GraphqlAccountsPanel
38− sourceId={props.sourceId}
39− integrationName={config.name || String(slug)}
40− accountHandoff={null}
41− />
42− );
43−}
packages/plugins/graphql/src/react/GraphqlSourceFields.tsx+20−0
@@ -4,6 +4,7 @@ import { · 3 unmodified lines
44 CardStackEntryField,
55 } from "@executor-js/react/components/card-stack";
66 import { Input } from "@executor-js/react/components/input";
7+import { Textarea } from "@executor-js/react/components/textarea";
78 import {
89 IntegrationIdentityFieldRows,
910 type IntegrationIdentity,
@@ -13,6 +14,10 @@ export function GraphqlSourceFields(props: { · 3 unmodified lines
1314 readonly endpoint: string;
1415 readonly onEndpointChange: (endpoint: string) => void;
1516 readonly identity: IntegrationIdentity;
17+ /** The integration's agent-visible description. Blank = the backend falls
18+ * back to the introspected schema's own description, then the name. */
19+ readonly description?: string;
20+ readonly onDescriptionChange?: (value: string) => void;
1621 readonly endpointDisabled?: boolean;
1722 readonly namespaceReadOnly?: boolean;
1823 }) {
@@ -36,6 +41,21 @@ export function GraphqlSourceFields(props: { · 17 unmodified lines
3641 namePlaceholder="e.g. Shopify API"
3742 namespaceReadOnly={props.namespaceReadOnly}
3843 />
44+ {props.onDescriptionChange && (
45+ <CardStackEntryField label="Description">
46+ <Textarea
47+ value={props.description ?? ""}
48+ onChange={(e) => props.onDescriptionChange?.((e.target as HTMLTextAreaElement).value)}
49+ placeholder="What this API is and when to reach for it"
50+ rows={2}
51+ maxRows={6}
52+ className="text-sm"
53+ />
54+ <p className="text-[11px] text-muted-foreground">
55+ Agent-visible. Leave blank to use the schema's own description when it has one.
56+ </p>
57+ </CardStackEntryField>
58+ )}
3959 </CardStackContent>
4060 </CardStack>
4161 );
packages/plugins/graphql/src/react/atoms.ts+2−1
@@ -59,13 +59,14 @@ export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( · 58 unmodified lines
5959 const integration = {
6060 slug,
6161 kind: "graphql",
62+ name: arg.payload.name ?? arg.payload.endpoint,
6263 description: arg.payload.name ?? arg.payload.endpoint,
6364 canRemove: false,
6465 canRefresh: false,
6566 authMethods: [],
6667 };
6768 return [integration, ...rows.filter((row) => row.slug !== slug)].sort((a, b) =>
68− a.description.localeCompare(b.description),
69+ a.name.localeCompare(b.name),
6970 );
7071 }),
7172 fn: addGraphqlIntegration,
packages/plugins/graphql/src/react/source-plugin.ts+2−3
@@ -3,19 +3,18 @@ import type { IntegrationPlugin } from "@executor-js/sdk/client"; · 2 unmodified lines
33 import { graphqlPresets } from "../sdk/presets";
44
55 const importAdd = () => import("./AddGraphqlSource");
6−const importEdit = () => import("./EditGraphqlSource");
76 const importAccounts = () => import("./GraphqlAccountsPanel");
87
8+// No `editSheet`: GraphQL has no plugin-specific configuration beyond auth
9+// methods, which the Accounts hub already owns ("+ Custom method").
910 export const graphqlIntegrationPlugin: IntegrationPlugin = {
1011 key: "graphql",
1112 label: "GraphQL",
1213 add: lazy(importAdd),
13− edit: lazy(importEdit),
1414 accounts: lazy(importAccounts),
1515 presets: graphqlPresets,
1616 preload: () => {
1717 void importAdd();
18− void importEdit();
1918 void importAccounts();
2019 },
2120 };
packages/plugins/graphql/src/sdk/plugin.ts+14−2
@@ -95,6 +95,9 @@ const GraphqlAddIntegrationInputSchema = Schema.Struct({ · 94 unmodified lines
9595 endpoint: Schema.String,
9696 slug: Schema.optional(Schema.String),
9797 name: Schema.optional(Schema.String),
98+ /** Agent-visible catalog description. Falls back to the introspected
99+ * schema's own description, then the display name. */
100+ description: Schema.optional(Schema.String),
98101 introspectionJson: Schema.optional(Schema.String),
99102 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
100103 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
@@ -603,7 +606,8 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 502 unmodified lines
603606 yield* ctx.transaction(
604607 ctx.core.integrations.register({
605608 slug,
606− description: baseConfig.name,
609+ name: baseConfig.name,
610+ description: input.description?.trim() || baseConfig.name,
607611 config: baseConfig,
608612 canRemove: true,
609613 canRefresh: true,
@@ -637,9 +641,17 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 27 unmodified lines
637641 Effect.gen(function* () {
638642 yield* ctx.storage.replaceOperations(String(slug), toStoredOperations(slug, prepared));
639643
644+ // Prefill order: caller's description, then the schema's own
645+ // description (present when introspection ran with schema
646+ // descriptions), then the display name.
647+ const schemaDescription =
648+ typeof (introspection as { description?: unknown }).description === "string"
649+ ? ((introspection as { description?: string }).description ?? "").trim()
650+ : "";
640651 yield* ctx.core.integrations.register({
641652 slug,
642− description: config.name,
653+ name: config.name,
654+ description: input.description?.trim() || schemaDescription || config.name,
643655 config,
644656 canRemove: true,
645657 canRefresh: true,
packages/plugins/mcp/src/api/group.ts+5−0
@@ -31,6 +31,8 @@ const StringMap = Schema.Record(Schema.String, Schema.String); · 30 unmodified lines
3131 const AddRemoteServerPayload = Schema.Struct({
3232 transport: Schema.optional(Schema.Literal("remote")),
3333 name: Schema.String,
34+ /** Agent-visible catalog description. Defaults to the display name. */
35+ description: Schema.optional(Schema.String),
3436 endpoint: Schema.String,
3537 remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])),
3638 slug: Schema.optional(Schema.String),
@@ -46,6 +48,7 @@ const AddRemoteServerPayload = Schema.Struct({ · 9 unmodified lines
4648 const AddStdioServerPayload = Schema.Struct({
4749 transport: Schema.Literal("stdio"),
4850 name: Schema.String,
51+ description: Schema.optional(Schema.String),
4952 command: Schema.String,
5053 args: Schema.optional(Schema.Array(Schema.String)),
5154 env: Schema.optional(StringMap),
@@ -70,6 +73,8 @@ const ProbeEndpointResponse = Schema.Struct({ · 18 unmodified lines
7073 slug: Schema.String,
7174 toolCount: Schema.NullOr(Schema.Number),
7275 serverName: Schema.NullOr(Schema.String),
76+ /** Server `instructions` from initialize — prefills the description field. */
77+ instructions: Schema.NullOr(Schema.String),
7378 });
7479
7580 // ---------------------------------------------------------------------------
packages/plugins/mcp/src/api/handlers.ts+4−0
@@ -33,6 +33,7 @@ const toServerInput = ( · 32 unmodified lines
3333 const p = payload as {
3434 transport: "stdio";
3535 name: string;
36+ description?: string;
3637 command: string;
3738 args?: readonly string[];
3839 env?: Record<string, string>;
@@ -42,6 +43,7 @@ const toServerInput = ( · 3 unmodified lines
4243 return {
4344 transport: "stdio",
4445 name: p.name,
46+ description: p.description,
4547 command: p.command,
4648 args: p.args ? [...p.args] : undefined,
4749 env: p.env,
@@ -53,6 +55,7 @@ const toServerInput = ( · 5 unmodified lines
5355 const p = payload as {
5456 transport?: "remote";
5557 name: string;
58+ description?: string;
5659 endpoint: string;
5760 remoteTransport?: "streamable-http" | "sse" | "auto";
5861 queryParams?: Record<string, string>;
@@ -67,6 +70,7 @@ const toServerInput = ( · 8 unmodified lines
6770 return {
6871 transport: "remote",
6972 name: p.name,
73+ description: p.description,
7074 endpoint: p.endpoint,
7175 remoteTransport: p.remoteTransport,
7276 queryParams: p.queryParams,
packages/plugins/mcp/src/react/AddMcpSource.tsx+11−1
@@ -78,6 +78,7 @@ type ProbeResult = { · 77 unmodified lines
7878 slug: string;
7979 toolCount: number | null;
8080 serverName: string | null;
81+ instructions: string | null;
8182 };
8283
8384 type State =
@@ -234,6 +235,10 @@ export default function AddMcpSource(props: { · 150 unmodified lines
234235 fallbackName:
235236 integrationDisplayNameFromUrl(state.url, "MCP") ?? probe?.serverName ?? probe?.name ?? "",
236237 });
238+ // Agent-visible description: prefilled from the server's `instructions`
239+ // until the user types (null = untouched, keep deriving from the probe).
240+ const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null);
241+ const resolvedDescription = descriptionDraft ?? probe?.instructions ?? "";
237242 const isProbing = state.step === "probing";
238243 const isAdding = state.step === "adding";
239244
@@ -298,6 +303,9 @@ export default function AddMcpSource(props: { · 58 unmodified lines
298303 payload: {
299304 transport: "remote" as const,
300305 name: displayName,
306+ ...(resolvedDescription.trim().length > 0
307+ ? { description: resolvedDescription.trim() }
308+ : {}),
301309 endpoint: state.url.trim(),
302310 ...(slug ? { slug } : {}),
303311 authenticationTemplate,
@@ -313,7 +321,7 @@ export default function AddMcpSource(props: { · 9 unmodified lines
313321 }
314322 return exit.value.slug;
315323 },
316− [doAddServer, probe, remoteIdentity, state.url],
324+ [doAddServer, probe, remoteIdentity, resolvedDescription, state.url],
317325 );
318326
319327 const handleAddRemote = useCallback(async () => {
@@ -447,6 +455,8 @@ export default function AddMcpSource(props: { · 127 unmodified lines
447455 url={state.url}
448456 onUrlChange={(url) => dispatch({ type: "set-url", url })}
449457 identity={remoteIdentity}
458+ description={resolvedDescription}
459+ onDescriptionChange={setDescriptionDraft}
450460 preview={probe}
451461 probing={isProbing}
452462 error={probeError}
packages/plugins/mcp/src/react/EditMcpSource.tsx+43−94
@@ -1,9 +1,10 @@
1−import { useCallback, useMemo, useState } from "react";
1+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22 import { useAtomValue, useAtomSet } from "@effect/atom-react";
33 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
44 import * as Exit from "effect/Exit";
55
66 import { IntegrationSlug } from "@executor-js/sdk/shared";
7+import type { EditSheetApplyResult, EditSheetSectionProps } from "@executor-js/sdk/client";
78 import { apiKeyMethodLabel, type AuthPlacement } from "@executor-js/sdk/http-auth";
89 import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
910 import {
@@ -12,15 +13,6 @@ import { · 2 unmodified lines
1213 type AuthMethodRow,
1314 type AuthMethodSeed,
1415 } from "@executor-js/react/components/auth-method-list-editor";
15−import { Button } from "@executor-js/react/components/button";
16−import {
17− CardStack,
18− CardStackContent,
19− CardStackEntry,
20− CardStackEntryContent,
21− CardStackEntryDescription,
22− CardStackEntryTitle,
23−} from "@executor-js/react/components/card-stack";
2416 import { Badge } from "@executor-js/react/components/badge";
2517 import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
2618
@@ -77,12 +69,13 @@ const samePlacements = ( · 50 unmodified lines
7769 // Remote edit — v2: the integration's endpoint is part of its identity
7870 // (opaque-to-core config); the editable surface is the declared auth-method
7971 // LIST, through the same shared editor as the add flow. Accounts (credentials)
80−// are managed from the integration page's accounts hub.
72+// are managed from the integration page's accounts hub. Rendered inside the
73+// integration Edit sheet (plugin `editSheet` slot).
8174 // ---------------------------------------------------------------------------
8275
8376 function RemoteEdit(props: {
8477 server: McpServer & { config: McpRemoteConfig };
85− onSave: () => void;
78+ onPendingChange?: EditSheetSectionProps["onPendingChange"];
8679 }) {
8780 const { server } = props;
8881 const doConfigureAuth = useAtomSet(configureMcpAuth, { mode: "promiseExit" });
@@ -100,7 +93,6 @@ function RemoteEdit(props: { · 11 unmodified lines
10093 );
10194 const list = useAuthMethodList(seeds);
10295
103− const [saving, setSaving] = useState(false);
10496 const [error, setError] = useState<string | null>(null);
10597
10698 // The edited methods, slugs preserved for seeded rows so existing
@@ -130,8 +122,8 @@ function RemoteEdit(props: { · 23 unmodified lines
130122 });
131123 }, [editedMethods, server.config.authenticationTemplate]);
132124
133− const handleSave = useCallback(async () => {
134− setSaving(true);
125+ // Staged apply, run by the sheet's Save when the method list changed.
126+ const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => {
135127 setError(null);
136128 const exit = await doConfigureAuth({
137129 params: { slug: server.slug },
@@ -146,59 +138,37 @@ function RemoteEdit(props: { · 8 unmodified lines
146138 });
147139 if (Exit.isFailure(exit)) {
148140 setError("Failed to update authentication methods");
149− setSaving(false);
150− return;
141+ return { ok: false };
151142 }
152− setSaving(false);
143+ return { ok: true, summary: "Authentication methods updated." };
153144 }, [doConfigureAuth, editedMethods, server.slug]);
154145
146+ const onPendingChangeRef = useRef(props.onPendingChange);
147+ onPendingChangeRef.current = props.onPendingChange;
148+ useEffect(() => {
149+ onPendingChangeRef.current?.(methodsChanged ? applyStaged : null);
150+ return () => onPendingChangeRef.current?.(null);
151+ }, [methodsChanged, applyStaged]);
152+
155153 return (
156− <div className="space-y-6">
157− <div>
158− <h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1>
159− <p className="mt-1 text-sm text-muted-foreground">
160− Manage how this MCP server authenticates. The endpoint is part of the server's identity —
161− remove and re-add to change it. Accounts are added from the integration page.
154+ <div className="space-y-4 border-t border-border/60 pt-5">
155+ <div className="space-y-1">
156+ <p className="text-sm font-medium text-foreground">Authentication methods</p>
157+ <p className="text-xs text-muted-foreground">
158+ Changes apply when you save. The endpoint (
159+ <span className="font-mono">{server.config.endpoint}</span>) is part of the server's
160+ identity — remove and re-add to change it.
162161 </p>
163162 </div>
164163
165− <CardStack>
166− <CardStackContent className="border-t-0">
167− <CardStackEntry>
168− <CardStackEntryContent>
169− <CardStackEntryTitle>{server.description || String(server.slug)}</CardStackEntryTitle>
170− <CardStackEntryDescription className="font-mono text-xs">
171− {server.config.endpoint}
172− </CardStackEntryDescription>
173− </CardStackEntryContent>
174− <Badge variant="secondary" className="text-xs">
175− remote
176− </Badge>
177− </CardStackEntry>
178− </CardStackContent>
179− </CardStack>
180−
181164 <AuthMethodListEditor
182165 list={list}
183− title="How does this server authenticate?"
184166 oauthMetadata="discovered"
185167 emptyHint="No methods declared. Add one, or save to mark this server as open (no authentication)."
186168 footerHint="Connections pick one of these methods. Removing a method detaches connections created against it."
187169 />
188170
189− {methodsChanged ? (
190− <div className="flex justify-end">
191− <Button type="button" size="sm" onClick={() => void handleSave()} disabled={saving}>
192− {saving ? "Saving…" : "Save authentication methods"}
193− </Button>
194− </div>
195− ) : null}
196−
197171 {error && <FormErrorAlert message={error} />}
198−
199− <div className="flex items-center justify-end border-t border-border pt-4">
200− <Button onClick={props.onSave}>Done</Button>
201− </div>
202172 </div>
203173 );
204174 }
@@ -209,65 +179,40 @@ function RemoteEdit(props: { · 4 unmodified lines
209179
210180 function StdioReadOnly(props: {
211181 server: McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> };
212− onSave: () => void;
213182 }) {
214183 const { command, args } = props.server.config;
215184 return (
216− <div className="space-y-6">
217− <div>
218− <h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1>
219− <p className="mt-1 text-sm text-muted-foreground">
220− Stdio MCP sources cannot be edited in the UI. Remove and recreate the source with the
221− updated command.
185+ <div className="space-y-3 border-t border-border/60 pt-5">
186+ <div className="space-y-1">
187+ <p className="text-sm font-medium text-foreground">Server command</p>
188+ <p className="text-xs text-muted-foreground">
189+ Stdio MCP sources cannot be edited. Remove and recreate the source with the updated
190+ command.
222191 </p>
223192 </div>
224−
225− <div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
226− <div className="min-w-0 flex-1">
227− <p className="truncate text-sm font-semibold text-card-foreground">
228− {String(props.server.slug)}
229− </p>
230− <p className="mt-0.5 text-xs text-muted-foreground font-mono">
231− {command} {(args ?? []).join(" ")}
232− </p>
233− </div>
193+ <div className="flex items-center gap-3 rounded-md border border-border/60 bg-muted/40 px-3 py-2">
194+ <p className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
195+ {command} {(args ?? []).join(" ")}
196+ </p>
234197 <Badge variant="secondary" className="text-xs">
235198 stdio
236199 </Badge>
237200 </div>
238−
239− <div className="flex items-center justify-end border-t border-border pt-4">
240− <Button onClick={props.onSave}>Done</Button>
241− </div>
242201 </div>
243202 );
244203 }
245204
246205 // ---------------------------------------------------------------------------
247−// Main component — `sourceId` is the integration slug (v2).
206+// Main component — the mcp plugin's section of the integration Edit sheet.
207+// `sourceId` is the integration slug (v2).
248208 // ---------------------------------------------------------------------------
249209
250−export default function EditMcpSource({
251− sourceId,
252− onSave,
253−}: {
254− readonly sourceId: string;
255− readonly onSave: () => void;
256−}) {
210+export default function EditMcpSource({ sourceId, onPendingChange }: EditSheetSectionProps) {
257211 const slug = IntegrationSlug.make(sourceId);
258212 const serverResult = useAtomValue(mcpServerAtom(slug));
259213 const server = AsyncResult.isSuccess(serverResult) ? serverResult.value : null;
260214
261− if (!AsyncResult.isSuccess(serverResult) || server === null) {
262− return (
263− <div className="space-y-6">
264− <div>
265− <h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1>
266− <p className="mt-1 text-sm text-muted-foreground">Loading configuration…</p>
267− </div>
268− </div>
269− );
270− }
215+ if (!AsyncResult.isSuccess(serverResult) || server === null) return null;
271216
272217 if (server.config.transport === "stdio") {
273218 return (
@@ -275,10 +220,14 @@ export default function EditMcpSource({ · 1 unmodified line
275220 server={
276221 server as McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> }
277222 }
278− onSave={onSave}
279223 />
280224 );
281225 }
282226
283− return <RemoteEdit server={server as McpServer & { config: McpRemoteConfig }} onSave={onSave} />;
227+ return (
228+ <RemoteEdit
229+ server={server as McpServer & { config: McpRemoteConfig }}
230+ {...(onPendingChange ? { onPendingChange } : {})}
231+ />
232+ );
284233 }
packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx+22−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
1212 } from "@executor-js/react/components/card-stack";
1313 import { FieldError } from "@executor-js/react/components/field";
1414 import { Input } from "@executor-js/react/components/input";
15+import { Textarea } from "@executor-js/react/components/textarea";
1516 import { Skeleton } from "@executor-js/react/components/skeleton";
1617 import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
1718 import { IOSSpinner } from "@executor-js/react/components/spinner";
@@ -34,6 +35,10 @@ export function McpRemoteSourceFields(props: { · 16 unmodified lines
3435 readonly url: string;
3536 readonly onUrlChange: (url: string) => void;
3637 readonly identity: IntegrationIdentity;
38+ /** The integration's agent-visible description (prefilled from the server's
39+ * `instructions` when the probe connected). */
40+ readonly description?: string;
41+ readonly onDescriptionChange?: (value: string) => void;
3742 readonly preview: McpRemoteSourcePreview | null;
3843 readonly probing?: boolean;
3944 readonly error?: string | null;
@@ -99,6 +104,23 @@ export function McpRemoteSourceFields(props: { · 59 unmodified lines
99104 namePlaceholder="e.g. Linear"
100105 namespaceReadOnly={props.namespaceReadOnly}
101106 />
107+ {props.onDescriptionChange && (
108+ <CardStackEntryField label="Description">
109+ <Textarea
110+ value={props.description ?? ""}
111+ onChange={(e) =>
112+ props.onDescriptionChange?.((e.target as HTMLTextAreaElement).value)
113+ }
114+ placeholder="What this server offers and when to reach for it"
115+ rows={2}
116+ maxRows={6}
117+ className="text-sm"
118+ />
119+ <p className="text-[11px] text-muted-foreground">
120+ Agent-visible. Prefilled from the server's instructions when it sends any.
121+ </p>
122+ </CardStackEntryField>
123+ )}
102124 <CardStackEntryField label="Server URL">
103125 <Input
104126 value={props.url}
packages/plugins/mcp/src/react/source-plugin.tsx+4−4
@@ -3,11 +3,11 @@ import type { IntegrationPlugin } from "@executor-js/sdk/client"; · 2 unmodified lines
33 import { mcpPresets } from "../sdk/presets";
44
55 const importAdd = () => import("./AddMcpSource");
6−const importEdit = () => import("./EditMcpSource");
6+const importEditSheet = () => import("./EditMcpSource");
77 const importAccounts = () => import("./McpAccountsPanel");
88
99 const LazyAddMcpSource = lazy(importAdd);
10−const LazyEditMcpSource = lazy(importEdit);
10+const LazyEditMcpSheet = lazy(importEditSheet);
1111 const LazyMcpAccountsPanel = lazy(importAccounts);
1212
1313 type AddProps = ComponentProps<IntegrationPlugin["add"]>;
@@ -42,12 +42,12 @@ export const createMcpIntegrationPlugin = ( · 28 unmodified lines
4242 key: "mcp",
4343 label: "MCP",
4444 add: AddWithFlag,
45− edit: LazyEditMcpSource,
45+ editSheet: LazyEditMcpSheet,
4646 accounts: LazyMcpAccountsPanel,
4747 presets,
4848 preload: () => {
4949 void importAdd();
50− void importEdit();
50+ void importEditSheet();
5151 void importAccounts();
5252 },
5353 };
packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts+1−0
@@ -14,6 +14,7 @@ import { describeMcpAuthMethods, describeMcpIntegrationDisplay } from "./plugin" · 13 unmodified lines
1414
1515 const recordWith = (config: IntegrationConfig): IntegrationRecord => ({
1616 slug: IntegrationSlug.make("server"),
17+ name: "Server",
1718 description: "Server",
1819 kind: "mcp",
1920 canRemove: true,
packages/plugins/mcp/src/sdk/discover.ts+1−0
@@ -55,6 +55,7 @@ export const discoverTools = ( · 54 unmodified lines
5555
5656 const manifest = extractManifestFromListToolsResult(listResult, {
5757 serverInfo: connection.client.getServerVersion?.(),
58+ instructions: connection.client.getInstructions?.(),
5859 });
5960
6061 // Close the connection after discovery
packages/plugins/mcp/src/sdk/manifest.ts+4−1
@@ -18,6 +18,8 @@ export interface McpToolManifestEntry { · 17 unmodified lines
1818 export interface McpServerMetadata {
1919 readonly name: string | null;
2020 readonly version: string | null;
21+ /** The server's `instructions` from initialize, when it sent any. */
22+ readonly instructions: string | null;
2123 }
2224
2325 export interface McpToolManifest {
@@ -82,7 +84,7 @@ export const joinToolPath = (namespace: string | undefined, toolId: string): str · 58 unmodified lines
8284
8385 export const extractManifestFromListToolsResult = (
8486 listToolsResult: unknown,
85− metadata?: { serverInfo?: unknown },
87+ metadata?: { serverInfo?: unknown; instructions?: string | undefined },
8688 ): McpToolManifest => {
8789 const seen = new Map<string, number>();
8890
@@ -96,6 +98,7 @@ export const extractManifestFromListToolsResult = ( · 7 unmodified lines
9698 (info): McpServerMetadata => ({
9799 name: info.name ?? null,
98100 version: info.version ?? null,
101+ instructions: metadata?.instructions ?? null,
99102 }),
100103 ),
101104 Option.getOrNull,
packages/plugins/mcp/src/sdk/plugin.ts+11−1
@@ -145,6 +145,8 @@ const readStamp = (annotations: unknown): McpToolStamp | null => · 144 unmodified lines
145145 const McpRemoteServerInputSchema = Schema.Struct({
146146 transport: Schema.optional(Schema.Literal("remote")),
147147 name: Schema.String,
148+ /** Agent-visible catalog description. Defaults to the display name. */
149+ description: Schema.optional(Schema.String),
148150 endpoint: Schema.String,
149151 remoteTransport: Schema.optional(McpRemoteTransport),
150152 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
@@ -160,6 +162,7 @@ const McpRemoteServerInputSchema = Schema.Struct({ · 9 unmodified lines
160162 const McpStdioServerInputSchema = Schema.Struct({
161163 transport: Schema.Literal("stdio"),
162164 name: Schema.String,
165+ description: Schema.optional(Schema.String),
163166 command: Schema.String,
164167 args: Schema.optional(Schema.Array(Schema.String)),
165168 env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
@@ -200,6 +203,9 @@ const McpProbeEndpointOutputSchema = Schema.Struct({ · 34 unmodified lines
200203 slug: Schema.String,
201204 toolCount: Schema.NullOr(Schema.Number),
202205 serverName: Schema.NullOr(Schema.String),
206+ /** The server's `instructions` from initialize — prefill for the add form's
207+ * description. Only available when the probe connected unauthenticated. */
208+ instructions: Schema.NullOr(Schema.String),
203209 });
204210
205211 // ---------------------------------------------------------------------------
@@ -626,6 +632,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 420 unmodified lines
626632 slug,
627633 toolCount: result.manifest.tools.length,
628634 serverName: result.manifest.server?.name ?? null,
635+ instructions: result.manifest.server?.instructions ?? null,
629636 } satisfies McpProbeResult;
630637 }
631638
@@ -660,6 +667,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 28 unmodified lines
660667 slug,
661668 toolCount: null,
662669 serverName: null,
670+ instructions: null,
663671 } satisfies McpProbeResult;
664672 }
665673
@@ -673,6 +681,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 7 unmodified lines
673681 slug,
674682 toolCount: null,
675683 serverName: null,
684+ instructions: null,
676685 } satisfies McpProbeResult;
677686 }
678687
@@ -705,7 +714,8 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 26 unmodified lines
705714 yield* ctx.core.integrations
706715 .register({
707716 slug: slugFrom(slug),
708− description: input.name,
717+ name: input.name,
718+ description: input.description?.trim() || input.name,
709719 config,
710720 canRemove: true,
711721 canRefresh: true,
packages/plugins/openapi/src/api/group.ts+35−0
@@ -4,6 +4,7 @@ import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth · 3 unmodified lines
44 import {
55 InternalError,
66 IntegrationAlreadyExistsError,
7+ IntegrationNotFoundError,
78 IntegrationSlug,
89 } from "@executor-js/sdk/shared";
910
@@ -26,6 +27,16 @@ const DomainErrors = [ · 16 unmodified lines
2627 IntegrationAlreadyExistsError,
2728 ] as const;
2829
30+const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 });
31+
32+const UpdateSpecErrors = [
33+ InternalError,
34+ OpenApiParseError,
35+ OpenApiExtractionError,
36+ OpenApiOAuthError,
37+ IntegrationNotFound,
38+] as const;
39+
2940 const SlugParams = {
3041 slug: Schema.String,
3142 };
@@ -64,6 +75,7 @@ const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMet · 32 unmodified lines
6475 const AddSpecPayload = Schema.Struct({
6576 spec: OpenApiSpecInputPayload,
6677 slug: Schema.String,
78+ name: Schema.optional(Schema.String),
6779 description: Schema.optional(Schema.String),
6880 baseUrl: Schema.optional(Schema.String),
6981 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
@@ -92,6 +104,21 @@ const AddSpecResponse = Schema.Struct({ · 22 unmodified lines
92104 toolCount: Schema.Number,
93105 });
94106
107+// Update the spec in place. Body optional fields only: an empty payload means
108+// "re-fetch from the stored source URL".
109+const UpdateSpecPayload = Schema.Struct({
110+ spec: Schema.optional(OpenApiSpecInputPayload),
111+});
112+
113+const UpdateSpecResponse = Schema.Struct({
114+ slug: IntegrationSlug,
115+ toolCount: Schema.Number,
116+ /** Tool names new in this spec version (same diff for every connection). */
117+ addedTools: Schema.Array(Schema.String),
118+ /** Tool names the new spec no longer defines. */
119+ removedTools: Schema.Array(Schema.String),
120+});
121+
95122 const IntegrationView = Schema.Struct({
96123 slug: IntegrationSlug,
97124 description: Schema.String,
@@ -161,6 +188,14 @@ export const OpenApiGroup = HttpApiGroup.make("openapi") · 63 unmodified lines
161188 error: DomainErrors,
162189 }),
163190 )
191+ .add(
192+ HttpApiEndpoint.post("updateSpec", "/openapi/integrations/:slug/spec", {
193+ params: SlugParams,
194+ payload: UpdateSpecPayload,
195+ success: UpdateSpecResponse,
196+ error: UpdateSpecErrors,
197+ }),
198+ )
164199 .add(
165200 HttpApiEndpoint.delete("removeSpec", "/openapi/integrations/:slug", {
166201 params: SlugParams,
packages/plugins/openapi/src/api/handlers.ts+17−0
@@ -54,6 +54,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 53 unmodified lines
5454 return yield* ext.addSpec({
5555 spec: payload.spec,
5656 slug: payload.slug,
57+ name: payload.name,
5758 description: payload.description,
5859 baseUrl: payload.baseUrl,
5960 headers: payload.headers ? { ...payload.headers } : undefined,
@@ -114,6 +115,22 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 54 unmodified lines
114115 }),
115116 ),
116117 )
118+ .handle("updateSpec", ({ params, payload }) =>
119+ capture(
120+ Effect.gen(function* () {
121+ const ext = yield* OpenApiExtensionService;
122+ const result = yield* ext.updateSpec(params.slug, {
123+ ...(payload.spec !== undefined ? { spec: payload.spec } : {}),
124+ });
125+ return {
126+ slug: result.slug,
127+ toolCount: result.toolCount,
128+ addedTools: [...result.addedTools],
129+ removedTools: [...result.removedTools],
130+ };
131+ }),
132+ ),
133+ )
117134 .handle("removeSpec", ({ params }) =>
118135 capture(
119136 Effect.gen(function* () {
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+16−3
@@ -133,6 +133,9 @@ export default function AddOpenApiSource(props: { · 132 unmodified lines
133133 // After analysis
134134 const [preview, setPreview] = useState<SpecPreview | null>(null);
135135 const [baseUrl, setBaseUrl] = useState("");
136+ // Agent-visible description: prefilled from the spec's `info.description`
137+ // until the user types (null = untouched, keep deriving from the preview).
138+ const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null);
136139 const identityFallbackName = isGoogleBundlePreset
137140 ? "Google"
138141 : preview
@@ -220,6 +223,8 @@ export default function AddOpenApiSource(props: { · 81 unmodified lines
220223 const resolvedDisplayName =
221224 identity.name.trim() ||
222225 (preview ? Option.getOrElse(preview.title, () => resolvedSourceId) : resolvedSourceId);
226+ const resolvedDescription =
227+ descriptionDraft ?? (preview ? Option.getOrElse(preview.description, () => "") : "");
223228
224229 // Register EVERY spec-detected auth method, not just a single selected one.
225230 // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the
@@ -327,7 +332,10 @@ export default function AddOpenApiSource(props: { · 101 unmodified lines
327332 payload: {
328333 spec: specForAdd,
329334 slug: resolvedSourceId,
330− description: resolvedDisplayName,
335+ name: resolvedDisplayName,
336+ ...(resolvedDescription.trim().length > 0
337+ ? { description: resolvedDescription.trim() }
338+ : {}),
331339 baseUrl: resolvedBaseUrl,
332340 // Always send the edited method list (even empty) when the user has
333341 // inspected a preview: an explicit [] means "no auth methods", while
@@ -356,6 +364,7 @@ export default function AddOpenApiSource(props: { · 22 unmodified lines
356364 doAdd,
357365 resolvedSourceId,
358366 resolvedDisplayName,
367+ resolvedDescription,
359368 resolvedBaseUrl,
360369 editedAuthenticationTemplate,
361370 ]);
@@ -428,10 +437,12 @@ export default function AddOpenApiSource(props: { · 66 unmodified lines
428437 {isGoogleBundlePreset ? (
429438 <OpenApiSourceDetailsFields
430439 title="Google"
431− description={`${bundleDiscoveryUrls.length} Google API${
440+ subtitle={`${bundleDiscoveryUrls.length} Google API${
432441 bundleDiscoveryUrls.length !== 1 ? "s" : ""
433442 } · one shared OAuth consent`}
434443 identity={identity}
444+ description={resolvedDescription}
445+ onDescriptionChange={setDescriptionDraft}
435446 baseUrl={resolvedBaseUrl}
436447 onBaseUrlChange={setBaseUrl}
437448 baseUrlLabel="Base URL override (optional)"
@@ -441,7 +452,7 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
441452 ) : preview ? (
442453 <OpenApiSourceDetailsFields
443454 title={Option.getOrElse(preview.title, () => "API")}
444− description={`${Option.getOrElse(preview.version, () => "")}${
455+ subtitle={`${Option.getOrElse(preview.version, () => "")}${
445456 Option.isSome(preview.version) ? " · " : ""
446457 }${preview.operationCount} operation${preview.operationCount !== 1 ? "s" : ""}${
447458 preview.tags.length > 0
@@ -449,6 +460,8 @@ export default function AddOpenApiSource(props: { · 1 unmodified line
449460 : ""
450461 }`}
451462 identity={identity}
463+ description={resolvedDescription}
464+ onDescriptionChange={setDescriptionDraft}
452465 baseUrl={resolvedBaseUrl}
453466 onBaseUrlChange={setBaseUrl}
454467 baseUrlOptions={baseUrlOptions}
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+0−118
@@ -1,118 +0,0 @@
1−import { useAtomValue } from "@effect/atom-react";
2−import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3−
4−import { connectionsAllAtom } from "@executor-js/react/api/atoms";
5−import { ownerLabel, useOwnerDisplay } from "@executor-js/react/api/owner-display";
6−import { IntegrationSlug, type Connection } from "@executor-js/sdk/shared";
7−import { Badge } from "@executor-js/react/components/badge";
8−import { Button } from "@executor-js/react/components/button";
9−import {
10− CardStack,
11− CardStackContent,
12− CardStackEntry,
13− CardStackEntryContent,
14− CardStackEntryDescription,
15− CardStackEntryTitle,
16−} from "@executor-js/react/components/card-stack";
17−
18−import { openApiIntegrationAtom } from "./atoms";
19−
20−// ---------------------------------------------------------------------------
21−// v2 edit — the integration's spec, base URL, and auth templates are part of
22−// its catalog config, which core treats as opaque and the API exposes as a
23−// read-only `getIntegration`. There are no per-scope source rows, no credential
24−// slots, and no binding writers to configure here anymore. Editing is reduced
25−// to surfacing the integration's identity and its current connections; new
26−// credentials are created on the integration's detail/connections surface.
27−// ---------------------------------------------------------------------------
28−
29−export default function EditOpenApiSource(props: {
30− readonly sourceId: string;
31− readonly onSave: () => void;
32−}) {
33− const slug = IntegrationSlug.make(props.sourceId);
34− const integrationResult = useAtomValue(openApiIntegrationAtom(slug));
35− const ownerDisplay = useOwnerDisplay();
36− // Connections across BOTH owners (omit-owner read); each row keeps its owner
37− // for the per-connection badge below.
38− const connectionsResult = useAtomValue(connectionsAllAtom);
39−
40− const integration =
41− AsyncResult.isSuccess(integrationResult) && integrationResult.value
42− ? integrationResult.value
43− : null;
44− const connections: readonly Connection[] = AsyncResult.isSuccess(connectionsResult)
45− ? connectionsResult.value.filter((candidate: Connection) => candidate.integration === slug)
46− : [];
47−
48− if (!integration) {
49− return (
50− <div className="space-y-3">
51− <h1 className="text-xl font-semibold text-foreground">OpenAPI Integration</h1>
52− <p className="text-sm text-muted-foreground">Loading…</p>
53− </div>
54− );
55− }
56−
57− return (
58− <div className="space-y-6">
59− <div>
60− <h1 className="text-xl font-semibold text-foreground">OpenAPI Integration</h1>
61− </div>
62−
63− <CardStack>
64− <CardStackContent className="border-t-0">
65− <CardStackEntry>
66− <CardStackEntryContent>
67− <CardStackEntryTitle>{integration.description || String(slug)}</CardStackEntryTitle>
68− <CardStackEntryDescription>{String(slug)}</CardStackEntryDescription>
69− </CardStackEntryContent>
70− <Badge variant="secondary">{integration.kind}</Badge>
71− </CardStackEntry>
72− </CardStackContent>
73− </CardStack>
74−
75− <CardStack>
76− <CardStackContent className="border-t-0">
77− <CardStackEntry>
78− <CardStackEntryContent>
79− <CardStackEntryTitle>Connections</CardStackEntryTitle>
80− <CardStackEntryDescription>
81− Credentials this integration uses to call its API.
82− </CardStackEntryDescription>
83− </CardStackEntryContent>
84− </CardStackEntry>
85− {connections.length === 0 ? (
86− <CardStackEntry>
87− <CardStackEntryContent>
88− <CardStackEntryDescription>No connections yet.</CardStackEntryDescription>
89− </CardStackEntryContent>
90− </CardStackEntry>
91− ) : (
92− connections.map((connection: Connection) => (
93− <CardStackEntry key={`${connection.owner}:${connection.name}`}>
94− <CardStackEntryContent>
95− <CardStackEntryTitle>
96− {connection.identityLabel ?? String(connection.name)}
97− </CardStackEntryTitle>
98− <CardStackEntryDescription>
99− {String(connection.template)} · {String(connection.provider)}
100− </CardStackEntryDescription>
101− </CardStackEntryContent>
102− {ownerDisplay.showOwnerLabels ? (
103− <Badge variant="outline">{ownerLabel(connection.owner)}</Badge>
104− ) : null}
105− </CardStackEntry>
106− ))
107− )}
108− </CardStackContent>
109− </CardStack>
110−
111− <div className="flex items-center justify-start border-t border-border pt-4">
112− <Button variant="ghost" onClick={props.onSave}>
113− Back
114− </Button>
115− </div>
116− </div>
117− );
118−}
packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx+39−4
@@ -12,16 +12,24 @@ import { · 11 unmodified lines
1212 type FreeformComboboxOption,
1313 } from "@executor-js/react/components/combobox";
1414 import { Input } from "@executor-js/react/components/input";
15+import { Textarea } from "@executor-js/react/components/textarea";
1516 import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
1617 import {
1718 IntegrationIdentityFieldRows,
1819 type IntegrationIdentity,
1920 } from "@executor-js/react/plugins/integration-identity";
2021
22+/** The spec input is shown as a one-line "Spec URL" field only when it IS a
23+ * URL; pasted document content gets a multi-line editor instead. */
24+const isUrlInput = (value: string): boolean => URL.canParse(value.trim());
25+
2126 export function OpenApiSourceDetailsFields(props: {
2227 readonly title: string;
23− readonly description?: string;
28+ readonly subtitle?: string;
2429 readonly identity: IntegrationIdentity;
30+ /** The integration's agent-visible description (prefilled from the spec). */
31+ readonly description?: string;
32+ readonly onDescriptionChange?: (value: string) => void;
2533 readonly baseUrl: string;
2634 readonly onBaseUrlChange: (value: string) => void;
2735 readonly baseUrlOptions?: readonly FreeformComboboxOption[];
@@ -38,6 +46,8 @@ export function OpenApiSourceDetailsFields(props: { · 10 unmodified lines
3846 readonly baseUrlMissingMessage?: string;
3947 readonly footer?: string;
4048 }) {
49+ const specIsUrl = props.specUrl !== undefined && isUrlInput(props.specUrl);
50+
4151 return (
4252 <CardStack>
4353 <CardStackContent className="border-t-0">
@@ -47,8 +57,8 @@ export function OpenApiSourceDetailsFields(props: { · 3 unmodified lines
4757 )}
4858 <CardStackEntryContent>
4959 <CardStackEntryTitle>{props.title}</CardStackEntryTitle>
50− {props.description && (
51− <CardStackEntryDescription>{props.description}</CardStackEntryDescription>
60+ {props.subtitle && (
61+ <CardStackEntryDescription>{props.subtitle}</CardStackEntryDescription>
5262 )}
5363 </CardStackEntryContent>
5464 {props.saveState && props.saveState !== "idle" && (
@@ -61,6 +71,18 @@ export function OpenApiSourceDetailsFields(props: { · 6 unmodified lines
6171 identity={props.identity}
6272 namespaceReadOnly={props.namespaceReadOnly}
6373 />
74+ {props.onDescriptionChange && (
75+ <CardStackEntryField label="Description">
76+ <Textarea
77+ value={props.description ?? ""}
78+ onChange={(e) => props.onDescriptionChange?.((e.target as HTMLTextAreaElement).value)}
79+ placeholder="What this API is and when to reach for it"
80+ rows={2}
81+ maxRows={6}
82+ className="text-sm"
83+ />
84+ </CardStackEntryField>
85+ )}
6486 <div className="grid grid-cols-1 md:grid-cols-2">
6587 <CardStackEntryField label={props.baseUrlLabel ?? "Base URL"}>
6688 {props.baseUrlOptions && props.baseUrlOptions.length > 0 ? (
@@ -90,7 +112,7 @@ export function OpenApiSourceDetailsFields(props: { · 23 unmodified lines
90112 <p className="text-[11px] text-muted-foreground">{props.baseUrlHint}</p>
91113 )}
92114 </CardStackEntryField>
93− {props.specUrl !== undefined && props.onSpecUrlChange && (
115+ {specIsUrl && props.onSpecUrlChange && (
94116 <CardStackEntryField label="Spec URL">
95117 <Input
96118 value={props.specUrl}
@@ -102,6 +124,19 @@ export function OpenApiSourceDetailsFields(props: { · 5 unmodified lines
102124 </CardStackEntryField>
103125 )}
104126 </div>
127+ {props.specUrl !== undefined && !specIsUrl && props.onSpecUrlChange && (
128+ <CardStackEntryField label="Spec">
129+ <Textarea
130+ value={props.specUrl}
131+ onChange={(e) => props.onSpecUrlChange?.((e.target as HTMLTextAreaElement).value)}
132+ placeholder="Pasted OpenAPI JSON/YAML"
133+ rows={4}
134+ maxRows={12}
135+ className="font-mono text-xs"
136+ disabled={props.specUrlDisabled}
137+ />
138+ </CardStackEntryField>
139+ )}
105140 {props.footer && (
106141 <CardStackEntry>
107142 <CardStackEntryContent>
packages/plugins/openapi/src/react/UpdateSpecSection.tsx+141−0
@@ -0,0 +1,141 @@
1+import { useCallback, useEffect, useRef, useState } from "react";
2+import { useAtomSet, useAtomValue } from "@effect/atom-react";
3+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4+import * as Exit from "effect/Exit";
5+
6+import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
7+import { messageFromExit } from "@executor-js/react/api/error-reporting";
8+import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
9+import { IntegrationSlug } from "@executor-js/sdk/shared";
10+import type { EditSheetApplyResult, EditSheetSectionProps } from "@executor-js/sdk/client";
11+import { Checkbox } from "@executor-js/react/components/checkbox";
12+import { Label } from "@executor-js/react/components/label";
13+import { Textarea } from "@executor-js/react/components/textarea";
14+
15+import { openApiConfigAtom, updateOpenApiSpec } from "./atoms";
16+
17+// ---------------------------------------------------------------------------
18+// Update spec — the openapi plugin's section of the integration Edit sheet.
19+// The user STAGES a spec update here (check re-fetch, or paste new content);
20+// nothing runs until the sheet's Save. The staged apply re-resolves the spec
21+// and rebuilds the tool catalog in place — connections, credentials, policies,
22+// and the curated description all survive.
23+// ---------------------------------------------------------------------------
24+
25+type UpdateOutcome = {
26+ readonly toolCount: number;
27+ readonly addedTools: readonly string[];
28+ readonly removedTools: readonly string[];
29+};
30+
31+const outcomeSummary = (outcome: UpdateOutcome): string => {
32+ const added = outcome.addedTools.length;
33+ const removed = outcome.removedTools.length;
34+ if (added === 0 && removed === 0) {
35+ return `Spec updated — ${outcome.toolCount} tools, no changes to the tool list.`;
36+ }
37+ const parts = [
38+ ...(added > 0 ? [`+${added} tool${added !== 1 ? "s" : ""}`] : []),
39+ ...(removed > 0 ? [`−${removed} tool${removed !== 1 ? "s" : ""}`] : []),
40+ ];
41+ return `Spec updated — ${parts.join(", ")} (${outcome.toolCount} total).`;
42+};
43+
44+export default function UpdateSpecSection(props: EditSheetSectionProps) {
45+ const slug = IntegrationSlug.make(props.sourceId);
46+ const configResult = useAtomValue(openApiConfigAtom(slug));
47+ const doUpdate = useAtomSet(updateOpenApiSpec, { mode: "promiseExit" });
48+ const [refetchStaged, setRefetchStaged] = useState(false);
49+ const [pasted, setPasted] = useState("");
50+ const [error, setError] = useState<string | null>(null);
51+
52+ const config =
53+ AsyncResult.isSuccess(configResult) && configResult.value ? configResult.value : null;
54+
55+ const sourceUrl = config
56+ ? config.googleDiscoveryUrls?.length
57+ ? `${config.googleDiscoveryUrls.length} Google Discovery document${
58+ config.googleDiscoveryUrls.length !== 1 ? "s" : ""
59+ }`
60+ : config.sourceUrl
61+ : undefined;
62+
63+ // The staged apply, rebuilt whenever the staged inputs change. Reported to
64+ // the sheet through a ref-stable callback so Save can run it.
65+ const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => {
66+ const spec = pasted.trim().length > 0 ? { kind: "blob" as const, value: pasted } : undefined;
67+ if (!spec && !refetchStaged) return { ok: true, summary: null };
68+ setError(null);
69+ const exit = await doUpdate({
70+ params: { slug },
71+ payload: spec ? { spec } : {},
72+ reactivityKeys: integrationWriteKeys,
73+ });
74+ if (Exit.isFailure(exit)) {
75+ setError(messageFromExit(exit, "Failed to update spec"));
76+ return { ok: false };
77+ }
78+ setRefetchStaged(false);
79+ setPasted("");
80+ return { ok: true, summary: outcomeSummary(exit.value) };
81+ }, [doUpdate, pasted, refetchStaged, slug]);
82+
83+ const onPendingChangeRef = useRef(props.onPendingChange);
84+ onPendingChangeRef.current = props.onPendingChange;
85+ const hasStagedChange = refetchStaged || pasted.trim().length > 0;
86+ useEffect(() => {
87+ onPendingChangeRef.current?.(hasStagedChange ? applyStaged : null);
88+ // Clear the staged apply if this section unmounts mid-edit.
89+ return () => onPendingChangeRef.current?.(null);
90+ }, [hasStagedChange, applyStaged]);
91+
92+ if (!config) return null;
93+
94+ return (
95+ <div className="space-y-3 border-t border-border/60 pt-5">
96+ <div className="space-y-1">
97+ <p className="text-sm font-medium text-foreground">Update spec</p>
98+ <p className="text-xs text-muted-foreground">
99+ Rebuild this integration's tools from the latest spec when you save. Connections,
100+ credentials, and policies are kept.
101+ </p>
102+ </div>
103+
104+ {sourceUrl ? (
105+ <div className="space-y-2">
106+ <Label className="flex items-start gap-2 text-sm font-normal">
107+ <Checkbox
108+ checked={refetchStaged}
109+ onCheckedChange={(checked: boolean | "indeterminate") =>
110+ setRefetchStaged(checked === true)
111+ }
112+ />
113+ <span className="space-y-0.5">
114+ <span className="block">Re-fetch the spec on save</span>
115+ <span className="block break-all font-mono text-xs text-muted-foreground">
116+ {sourceUrl}
117+ </span>
118+ </span>
119+ </Label>
120+ </div>
121+ ) : (
122+ <div className="space-y-2">
123+ <Label htmlFor="update-spec-content" className="text-xs text-muted-foreground">
124+ Updated spec content
125+ </Label>
126+ <Textarea
127+ id="update-spec-content"
128+ value={pasted}
129+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setPasted(e.target.value)}
130+ placeholder="Paste the updated OpenAPI JSON/YAML — applied when you save. This spec was added inline, so there is no URL to re-fetch."
131+ rows={4}
132+ maxRows={10}
133+ className="font-mono text-xs"
134+ />
135+ </div>
136+ )}
137+
138+ {error && <FormErrorAlert message={error} />}
139+ </div>
140+ );
141+}
packages/plugins/openapi/src/react/atoms.ts+5−0
@@ -36,6 +36,11 @@ export const addOpenApiSpec = OpenApiClient.mutation("openapi", "addSpec"); · 35 unmodified lines
3636
3737 export const removeOpenApiSpec = OpenApiClient.mutation("openapi", "removeSpec");
3838
39+/** Re-fetch (or replace) the integration's spec and rebuild its tools in
40+ * place — connections, policies, and the description survive. Pass
41+ * `reactivityKeys: integrationWriteKeys` at the call site. */
42+export const updateOpenApiSpec = OpenApiClient.mutation("openapi", "updateSpec");
43+
3944 // Add / merge custom auth methods onto an integration's `authenticationTemplate`.
4045 export const openapiConfigure = OpenApiClient.mutation("openapi", "configure");
4146
packages/plugins/openapi/src/react/source-plugin.ts+3−3
@@ -3,19 +3,19 @@ import type { IntegrationPlugin } from "@executor-js/sdk/client"; · 2 unmodified lines
33 import { openApiPresets } from "../sdk/presets";
44
55 const importAdd = () => import("./AddOpenApiSource");
6−const importEdit = () => import("./EditOpenApiSource");
6+const importEditSheet = () => import("./UpdateSpecSection");
77 const importAccounts = () => import("./OpenApiAccountsPanel");
88
99 export const openApiIntegrationPlugin: IntegrationPlugin = {
1010 key: "openapi",
1111 label: "OpenAPI",
1212 add: lazy(importAdd),
13− edit: lazy(importEdit),
13+ editSheet: lazy(importEditSheet),
1414 accounts: lazy(importAccounts),
1515 presets: openApiPresets,
1616 preload: () => {
1717 void importAdd();
18− void importEdit();
18+ void importEditSheet();
1919 void importAccounts();
2020 },
2121 };
packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts+2−0
@@ -19,6 +19,7 @@ import { type Authentication } from "./types"; · 18 unmodified lines
1919
2020 const recordWith = (templates: readonly Authentication[]): IntegrationRecord => ({
2121 slug: IntegrationSlug.make("petstore"),
22+ name: "Petstore",
2223 description: "Petstore",
2324 kind: "openapi",
2425 canRemove: true,
@@ -84,6 +85,7 @@ describe("describeOpenApiAuthMethods", () => { · 59 unmodified lines
8485 expect(
8586 describeOpenApiAuthMethods({
8687 slug: IntegrationSlug.make("x"),
88+ name: "x",
8789 description: "x",
8890 kind: "openapi",
8991 canRemove: true,
packages/plugins/openapi/src/sdk/extract.ts+1−0
@@ -405,6 +405,7 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume · 404 unmodified lines
405405
406406 return ExtractionResult.make({
407407 title: Option.fromNullishOr(doc.info?.title),
408+ description: Option.fromNullishOr(doc.info?.description),
408409 version: Option.fromNullishOr(doc.info?.version),
409410 servers: docServers,
410411 operations,
packages/plugins/openapi/src/sdk/plugin.test.ts+212−0
@@ -25,8 +25,13 @@ import { · 24 unmodified lines
2525 IntegrationSlug,
2626 ToolAddress,
2727 } from "@executor-js/sdk";
28+import { mkdtempSync } from "node:fs";
29+import { tmpdir } from "node:os";
30+import { join } from "node:path";
31+
2832 import {
2933 makeTestConfig,
34+ makeTestWorkspaceHarness,
3035 memoryCredentialsPlugin,
3136 typeCheckOutputTypeScript,
3237 } from "@executor-js/sdk/testing";
@@ -36,6 +41,7 @@ import { type AuthenticationInput } from "./types"; · 3 unmodified lines
3641 import {
3742 addOpenApiTestConnection,
3843 makeOpenApiHttpApiTestSourceConfig,
44+ serveMutableOpenApiSpecTestServer,
3945 serveOpenApiHttpApiTestServer,
4046 unwrapInvocation,
4147 } from "../testing";
@@ -704,6 +710,212 @@ describe("OpenAPI Plugin", () => { · 662 unmodified lines
704710 ),
705711 );
706712
713+ it.effect("updateSpec re-fetches the source URL and rebuilds tools in place", () =>
714+ Effect.scoped(
715+ Effect.gen(function* () {
716+ // A spec server whose document can change between fetches — the
717+ // "upstream API shipped a new version" scenario.
718+ // The mutable spec server is a real 127.0.0.1 listener — reach it over
719+ // the default fetch-based client, like production would.
720+ const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi });
721+ const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
722+
723+ const added = yield* executor.openapi.addSpec({
724+ spec: { kind: "url", url: specServer.specUrl },
725+ slug: "evolving",
726+ baseUrl: specServer.baseUrl,
727+ description: "curated by hand",
728+ authenticationTemplate: [apiKeyTemplate],
729+ });
730+ yield* executor.connections.create({
731+ owner: "org",
732+ name: ConnectionName.make("main"),
733+ integration: IntegrationSlug.make("evolving"),
734+ template: AuthTemplateSlug.make("apiKey"),
735+ value: "secret-key-123",
736+ });
737+ const before = (yield* executor.tools.list())
738+ .filter((t) => String(t.address).startsWith("tools.evolving."))
739+ .map((t) => String(t.name));
740+ expect(before).toContain("items.listItems");
741+ expect(before).toContain("items.queryRows");
742+
743+ // Upstream evolves: queryRows is gone, a new widgets group appears.
744+ const EvolvedItemsGroup = HttpApiGroup.make("items")
745+ .add(HttpApiEndpoint.get("listItems", "/items", { success: Schema.Array(Item) }))
746+ .add(
747+ HttpApiEndpoint.post("createItem", "/items", {
748+ payload: Schema.Struct({ name: Schema.String }),
749+ success: Item,
750+ }),
751+ )
752+ .add(
753+ HttpApiEndpoint.get("getItem", "/items/:itemId", {
754+ params: Schema.Struct({ itemId: Schema.NumberFromString }),
755+ success: Item,
756+ }),
757+ )
758+ .add(HttpApiEndpoint.get("echoHeaders", "/echo-headers", { success: EchoHeaders }));
759+ const WidgetsGroup = HttpApiGroup.make("widgets").add(
760+ HttpApiEndpoint.get("listWidgets", "/widgets", { success: Schema.Array(Item) }),
761+ );
762+ const EvolvedApi = HttpApi.make("testApi").add(EvolvedItemsGroup).add(WidgetsGroup);
763+ yield* specServer.setApi(EvolvedApi);
764+
765+ const result = yield* executor.openapi.updateSpec("evolving");
766+
767+ expect(result.addedTools).toEqual(["widgets.listWidgets"]);
768+ expect(result.removedTools).toEqual(["items.queryRows"]);
769+ expect(result.toolCount).toBe(added.toolCount); // -1 +1
770+
771+ // The connection's tool catalog reflects the new spec without any
772+ // remove/re-add: new tool present, removed tool gone.
773+ const after = (yield* executor.tools.list())
774+ .filter((t) => String(t.address).startsWith("tools.evolving."))
775+ .map((t) => String(t.name));
776+ expect(after).toContain("widgets.listWidgets");
777+ expect(after).not.toContain("items.queryRows");
778+
779+ // Everything user-curated survives: description, auth template, and
780+ // the connection itself.
781+ const integration = yield* executor.openapi.getIntegration("evolving");
782+ expect(integration?.description).toBe("curated by hand");
783+ const config = yield* executor.openapi.getConfig("evolving");
784+ expect(config?.authenticationTemplate?.map((a) => String(a.slug))).toEqual(["apiKey"]);
785+ expect(config?.baseUrl).toBe(specServer.baseUrl);
786+ const connections = yield* executor.connections.list({
787+ integration: IntegrationSlug.make("evolving"),
788+ });
789+ expect(connections.map((c) => String(c.name))).toEqual(["main"]);
790+ }),
791+ ),
792+ );
793+
794+ it.effect("updateSpec accepts new inline content for blob-sourced integrations", () =>
795+ Effect.scoped(
796+ Effect.gen(function* () {
797+ const server = yield* servePluginTestApi();
798+ const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
799+ yield* addOpenApiTestConnection(executor, server, { slug: "pasted" });
800+
801+ // A pasted-blob integration has no source URL — re-fetch must say so.
802+ const refetchError = yield* executor.openapi.updateSpec("pasted").pipe(Effect.flip);
803+ expect(Predicate.isTagged(refetchError, "OpenApiParseError")).toBe(true);
804+
805+ // But providing the updated content works, and the catalog follows.
806+ const SpecJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown));
807+ const parsed = yield* Schema.decodeUnknownEffect(SpecJson)(server.specJson);
808+ const evolved = {
809+ ...parsed,
810+ paths: {
811+ ...(parsed.paths as Record<string, unknown>),
812+ "/widgets": {
813+ get: {
814+ operationId: "widgets/list",
815+ responses: { "200": { description: "ok" } },
816+ },
817+ },
818+ },
819+ };
820+ const result = yield* executor.openapi.updateSpec("pasted", {
821+ spec: {
822+ kind: "blob",
823+ value: yield* Schema.encodeUnknownEffect(SpecJson)(evolved),
824+ },
825+ });
826+ expect(result.addedTools).toEqual(["widgets.list"]);
827+ expect(result.removedTools).toEqual([]);
828+
829+ const after = (yield* executor.tools.list())
830+ .filter((t) => String(t.address).startsWith("tools.pasted."))
831+ .map((t) => String(t.name));
832+ expect(after).toContain("widgets.list");
833+ }),
834+ ),
835+ );
836+
837+ it.effect("updateSpec propagates to OTHER subjects' personal connections", () =>
838+ Effect.scoped(
839+ Effect.gen(function* () {
840+ // Two members of one workspace, each with a PERSONAL connection on
841+ // the shared integration, against one on-disk database. The owner
842+ // policy stops A's updateSpec from rewriting B's tool rows directly —
843+ // B's catalog must converge lazily on B's own next read.
844+ const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi });
845+ const dataDir = mkdtempSync(join(tmpdir(), "openapi-update-spec-multiuser-"));
846+ const tenant = "shared-tenant";
847+ const plugins = testPlugins();
848+
849+ const alice = yield* makeTestWorkspaceHarness({
850+ plugins,
851+ tenant,
852+ subject: "alice",
853+ dataDir,
854+ });
855+ yield* alice.executor.openapi.addSpec({
856+ spec: { kind: "url", url: specServer.specUrl },
857+ slug: "shared",
858+ baseUrl: specServer.baseUrl,
859+ authenticationTemplate: [apiKeyTemplate],
860+ });
861+ yield* alice.executor.connections.create({
862+ owner: "user",
863+ name: ConnectionName.make("mine"),
864+ integration: IntegrationSlug.make("shared"),
865+ template: AuthTemplateSlug.make("apiKey"),
866+ value: "alice-key",
867+ });
868+
869+ const bob = yield* makeTestWorkspaceHarness({
870+ plugins,
871+ tenant,
872+ subject: "bob",
873+ dataDir,
874+ });
875+ yield* bob.executor.connections.create({
876+ owner: "user",
877+ name: ConnectionName.make("mine"),
878+ integration: IntegrationSlug.make("shared"),
879+ template: AuthTemplateSlug.make("apiKey"),
880+ value: "bob-key",
881+ });
882+ const bobToolNames = () =>
883+ Effect.map(bob.executor.tools.list(), (tools) =>
884+ tools
885+ .filter((t) => String(t.address).startsWith("tools.shared.user.mine."))
886+ .map((t) => String(t.name))
887+ .sort(),
888+ );
889+ expect(yield* bobToolNames()).toContain("items.queryRows");
890+
891+ // Alice updates the spec; queryRows disappears, widgets appears. Her
892+ // update can only rebuild HER visible connections.
893+ const EvolvedItems = HttpApiGroup.make("items").add(
894+ HttpApiEndpoint.get("listItems", "/items", { success: Schema.Array(Item) }),
895+ );
896+ const Widgets = HttpApiGroup.make("widgets").add(
897+ HttpApiEndpoint.get("listWidgets", "/widgets", { success: Schema.Array(Item) }),
898+ );
899+ yield* specServer.setApi(HttpApi.make("testApi").add(EvolvedItems).add(Widgets));
900+ yield* alice.executor.openapi.updateSpec("shared");
901+
902+ // Bob's next ordinary read converges his personal catalog — no
903+ // remove/re-add, no action from Bob.
904+ const bobAfter = yield* bobToolNames();
905+ expect(bobAfter).toContain("widgets.listWidgets");
906+ expect(bobAfter).not.toContain("items.queryRows");
907+ }),
908+ ),
909+ );
910+
911+ it.effect("updateSpec on an unknown slug fails with IntegrationNotFoundError", () =>
912+ Effect.gen(function* () {
913+ const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
914+ const error = yield* executor.openapi.updateSpec("missing").pipe(Effect.flip);
915+ expect(Predicate.isTagged(error, "IntegrationNotFoundError")).toBe(true);
916+ }),
917+ );
918+
707919 // removed: the v1-only behaviours below have no v2 equivalent —
708920 // - "adds an org source whose direct credentials are owned by the user scope"
709921 // - "sources.configure removes bindings for credential slots no longer present"
packages/plugins/openapi/src/sdk/plugin.ts+148−2
@@ -5,6 +5,7 @@ import { HttpClient } from "effect/unstable/http"; · 4 unmodified lines
55 import {
66 IntegrationAlreadyExistsError,
77 IntegrationDetectionResult,
8+ IntegrationNotFoundError,
89 IntegrationSlug,
910 ToolName,
1011 ToolResult,
@@ -149,7 +150,10 @@ export interface OpenApiSpecConfig { · 138 unmodified lines
149150 readonly spec: OpenApiSpecInput;
150151 /** The catalog slug for the new integration (the `<integration>` segment). */
151152 readonly slug: string;
152− /** Human description (defaults to the spec title). */
153+ /** Display name (defaults to the spec title). */
154+ readonly name?: string;
155+ /** Agent-visible description (defaults to the spec's `info.description`,
156+ * then the title). */
153157 readonly description?: string;
154158 readonly baseUrl?: string;
155159 /** Static headers applied to every request (no secret material). */
@@ -176,6 +180,21 @@ export interface OpenApiConfigureInput { · 20 unmodified lines
176180 readonly mode?: "merge" | "replace";
177181 }
178182
183+/** What changed in the tool catalog when a spec was updated in place. Tool
184+ * names, not addresses — the same diff applies to every connection. */
185+export interface UpdateSpecResult {
186+ readonly slug: IntegrationSlug;
187+ readonly toolCount: number;
188+ readonly addedTools: readonly string[];
189+ readonly removedTools: readonly string[];
190+}
191+
192+export interface OpenApiUpdateSpecInput {
193+ /** New spec source. Omit to re-fetch from the integration's stored
194+ * `sourceUrl` / Google Discovery bundle URLs. */
195+ readonly spec?: OpenApiSpecInput;
196+}
197+
179198 export interface OpenApiPluginExtension {
180199 readonly previewSpec: (
181200 input: string | OpenApiPreviewInput,
@@ -193,6 +212,20 @@ export interface OpenApiPluginExtension { · 11 unmodified lines
193212 | IntegrationAlreadyExistsError
194213 | StorageFailure
195214 >;
215+ /** Re-resolve the integration's spec (from its stored source URL, or the
216+ * provided input) and rebuild its tools IN PLACE — connections,
217+ * credentials, policies, and the curated description are untouched. */
218+ readonly updateSpec: (
219+ slug: string,
220+ input?: OpenApiUpdateSpecInput,
221+ ) => Effect.Effect<
222+ UpdateSpecResult,
223+ | OpenApiParseError
224+ | OpenApiExtractionError
225+ | OpenApiOAuthError
226+ | IntegrationNotFoundError
227+ | StorageFailure
228+ >;
196229 readonly removeSpec: (slug: string) => Effect.Effect<void, StorageFailure>;
197230 readonly getIntegration: (slug: string) => Effect.Effect<Integration | null, StorageFailure>;
198231 /** Read the integration's full opaque config, including its
@@ -560,6 +593,8 @@ interface CompiledSpec { · 361 unmodified lines
560593 readonly definitions: readonly ToolDefinition[];
561594 readonly hoistedDefs: Record<string, unknown>;
562595 readonly title: string | undefined;
596+ /** The spec's `info.description`. */
597+ readonly description: string | undefined;
563598 }
564599
565600 const compileSpec = (
@@ -578,6 +613,7 @@ const compileSpec = ( · 12 unmodified lines
578613 definitions: compileToolDefinitions(result.operations),
579614 hoistedDefs,
580615 title: Option.getOrUndefined(result.title),
616+ description: Option.getOrUndefined(result.description),
581617 };
582618 });
583619
@@ -791,7 +827,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 207 unmodified lines
791827 Effect.gen(function* () {
792828 yield* ctx.core.integrations.register({
793829 slug,
794− description: config.description ?? compiled.title ?? config.slug,
830+ name: config.name?.trim() || compiled.title || config.slug,
831+ description:
832+ config.description ?? compiled.description ?? compiled.title ?? config.slug,
795833 config: integrationConfig satisfies OpenApiIntegrationConfig as IntegrationConfig,
796834 canRemove: true,
797835 canRefresh:
@@ -808,6 +846,112 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 10 unmodified lines
808846 return { slug, toolCount: compiled.definitions.length };
809847 });
810848
849+ // Update the spec IN PLACE: re-resolve (stored source URL / bundle, or a
850+ // caller-supplied new input), recompile, swap the stored operations, and
851+ // rebuild every connection's tools. Auth templates, base URL, headers,
852+ // the curated description, connections, and policies are all untouched —
853+ // this is the "spec changed upstream" path, not a re-add.
854+ const updateSpec = (rawSlug: string, input?: OpenApiUpdateSpecInput) =>
855+ Effect.gen(function* () {
856+ const slug = IntegrationSlug.make(rawSlug);
857+ const record = yield* ctx.core.integrations.get(slug);
858+ const current = record ? decodeOpenApiIntegrationConfig(record.config) : null;
859+ if (!record || !current) {
860+ return yield* new IntegrationNotFoundError({ slug });
861+ }
862+
863+ // The new spec source: explicit input wins; otherwise re-fetch from
864+ // where the spec originally came from. A pasted-blob integration has
865+ // no origin, so updating it requires a new input.
866+ const specInput: OpenApiSpecInput | null =
867+ input?.spec ??
868+ (current.googleDiscoveryUrls
869+ ? { kind: "googleDiscoveryBundle", urls: current.googleDiscoveryUrls }
870+ : current.sourceUrl
871+ ? isGoogleDiscoveryUrl(current.sourceUrl)
872+ ? { kind: "googleDiscovery", url: current.sourceUrl }
873+ : { kind: "url", url: current.sourceUrl }
874+ : null);
875+ if (specInput === null) {
876+ return yield* new OpenApiParseError({
877+ message:
878+ "This integration's spec was pasted inline and has no source URL to re-fetch. Provide the updated spec content.",
879+ });
880+ }
881+
882+ // Resolve + compile BEFORE the transaction (same Hyperdrive-deadlock
883+ // rule as addSpec: never hold BEGIN across a network fetch).
884+ const resolved = yield* resolveSpecForInput(specInput, httpClientLayer);
885+ const compiled = yield* compileSpec(resolved.specText);
886+
887+ const previousOperations = yield* ctx.storage.listOperations(rawSlug);
888+ const previousNames = new Set(previousOperations.map((op) => op.toolName));
889+ const nextNames = new Set(compiled.definitions.map((def) => def.toolPath));
890+
891+ // The resolved spec text lives in the plugin blob store keyed by its
892+ // content hash (`spec/<hash>`); the config carries only the hash. Put
893+ // the blob outside the transaction — re-puts are idempotent and an
894+ // aborted config update just leaves an unreferenced blob.
895+ const specHash = yield* sha256Hex(resolved.specText);
896+ yield* ctx.storage.putSpec(specHash, resolved.specText);
897+
898+ const nextConfig: OpenApiIntegrationConfig = {
899+ ...current,
900+ specHash,
901+ ...(specInputToSourceUrl(specInput) !== undefined
902+ ? { sourceUrl: specInputToSourceUrl(specInput) }
903+ : {}),
904+ ...(specInputToGoogleBundle(specInput) !== undefined
905+ ? { googleDiscoveryUrls: specInputToGoogleBundle(specInput) }
906+ : {}),
907+ };
908+
909+ yield* ctx.transaction(
910+ Effect.gen(function* () {
911+ yield* ctx.core.integrations.update(slug, {
912+ config: nextConfig satisfies OpenApiIntegrationConfig as IntegrationConfig,
913+ });
914+ yield* ctx.storage.putOperations(
915+ rawSlug,
916+ storedOperationsFromCompiled(rawSlug, compiled),
917+ );
918+ }),
919+ );
920+
921+ // Rebuild each connection's tool rows from the new spec. Outside the
922+ // transaction: refresh opens its own, and a half-refreshed catalog
923+ // self-heals on the next refresh anyway.
924+ const connections = yield* ctx.connections.list({ integration: slug });
925+ yield* Effect.forEach(
926+ connections,
927+ (connection) =>
928+ ctx.connections
929+ .refresh({
930+ owner: connection.owner,
931+ integration: connection.integration,
932+ name: connection.name,
933+ })
934+ .pipe(Effect.catchTag("ConnectionNotFoundError", () => Effect.succeed([]))),
935+ { discard: true },
936+ ).pipe(
937+ Effect.catchTag("IntegrationNotFoundError", () => Effect.void),
938+ Effect.withSpan("openapi.plugin.update_spec.refresh_connections", {
939+ attributes: { "openapi.connection_count": connections.length },
940+ }),
941+ );
942+
943+ return {
944+ slug,
945+ toolCount: compiled.definitions.length,
946+ addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(),
947+ removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(),
948+ } satisfies UpdateSpecResult;
949+ }).pipe(
950+ Effect.withSpan("openapi.plugin.update_spec", {
951+ attributes: { "openapi.integration.slug": rawSlug },
952+ }),
953+ );
954+
811955 return {
812956 previewSpec: (input: string | OpenApiPreviewInput) =>
813957 Effect.gen(function* () {
@@ -829,6 +973,8 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 15 unmodified lines
829973
830974 addSpec,
831975
976+ updateSpec,
977+
832978 removeSpec: (slug: string) =>
833979 ctx.transaction(
834980 Effect.gen(function* () {
packages/plugins/openapi/src/sdk/preview.ts+3−0
@@ -135,6 +135,8 @@ export type PreviewOperation = typeof PreviewOperation.Type; · 134 unmodified lines
135135
136136 export const SpecPreview = Schema.Struct({
137137 title: Schema.OptionFromOptional(Schema.String),
138+ /** The spec's `info.description` — prefills the add form's description field. */
139+ description: Schema.OptionFromOptional(Schema.String),
138140 version: Schema.OptionFromOptional(Schema.String),
139141 /** Reuses ServerInfo from extraction */
140142 servers: Schema.Array(ServerInfo),
@@ -396,6 +398,7 @@ export const previewSpecText = Effect.fn("OpenApi.previewSpecText")(function* (s · 255 unmodified lines
396398
397399 return SpecPreview.make({
398400 title: result.title,
401+ description: result.description,
399402 version: result.version,
400403 servers: result.servers,
401404 operationCount: result.operations.length,
packages/plugins/openapi/src/sdk/spec-blob.test.ts+1−0
@@ -144,6 +144,7 @@ describe("OpenAPI plugin — spec blob storage", () => { · 143 unmodified lines
144144 plugin.resolveTools!({
145145 integration: {
146146 slug: IntegrationSlug.make("pointer_api"),
147+ name: "pointer",
147148 description: "pointer",
148149 kind: "openapi",
149150 canRemove: true,
packages/plugins/openapi/src/sdk/types.ts+2−0
@@ -168,6 +168,8 @@ export type ExtractedOperation = typeof ExtractedOperation.Type; · 167 unmodified lines
168168
169169 export const ExtractionResult = Schema.Struct({
170170 title: Schema.OptionFromOptional(Schema.String),
171+ /** The spec's `info.description` — the author's own summary of the API. */
172+ description: Schema.OptionFromOptional(Schema.String),
171173 version: Schema.OptionFromOptional(Schema.String),
172174 servers: Schema.Array(ServerInfo),
173175 operations: Schema.Array(ExtractedOperation),
packages/react/src/api/atoms.tsx+6−0
@@ -141,6 +141,10 @@ export const createConnection = ExecutorApiClient.mutation("connections", "creat · 140 unmodified lines
141141
142142 export const removeConnection = ExecutorApiClient.mutation("connections", "remove");
143143
144+/** Edit a connection's user-curated metadata (description, identityLabel).
145+ * Pass `reactivityKeys: connectionWriteKeys` at the call site. */
146+export const updateConnection = ExecutorApiClient.mutation("connections", "update");
147+
144148 export const refreshConnection = ExecutorApiClient.mutation("connections", "refresh");
145149
146150 export const updateIntegration = ExecutorApiClient.mutation("integrations", "update");
@@ -279,6 +283,7 @@ type CreateConnectionArg = { · 132 unmodified lines
279283 readonly integration: IntegrationSlug;
280284 readonly template: AuthTemplateSlug;
281285 readonly identityLabel?: string | null;
286+ readonly description?: string | null;
282287 } & (
283288 | { readonly value: string }
284289 | { readonly values: Record<string, string> }
@@ -314,6 +319,7 @@ export const addConnectionOptimistic = Atom.family((owner: Owner) => · 29 unmodified lines
314319 `tools.${payload.integration}.${payload.owner}.${payload.name}`,
315320 ),
316321 identityLabel: payload.identityLabel ?? null,
322+ description: payload.description ?? null,
317323 expiresAt: null,
318324 // Optimistic placeholder predates the server resolving which app (if
319325 // any) minted this connection; reconciled on refresh. Matches the
packages/react/src/components/accounts-section.tsx+24−2
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
2525 } from "../plugins/oauth-reconnect";
2626 import { useOAuthPopupFlow } from "../plugins/oauth-sign-in";
2727 import { AddAccountModal } from "./add-account-modal";
28+import { ConnectionEditSheet } from "./metadata-edit-sheet";
2829 import type { CreateCustomMethod } from "./add-custom-method-modal";
2930 import { Badge } from "./badge";
3031 import { Button } from "./button";
@@ -48,8 +49,9 @@ import { · 17 unmodified lines
4849 // ---------------------------------------------------------------------------
4950 // Accounts section — the integration's connections, grouped by owner.
5051 //
51−// Accounts are IMMUTABLE: the per-row menu is Reconnect + Remove only (no
52−// rename, no method switch). "+ Add connection" opens the create modal. When both
52+// Credentials are IMMUTABLE (no method switch); the editable surface is the
53+// user-curated metadata — description (agent-visible) and account label — via
54+// the per-row Edit sheet. "+ Add connection" opens the create modal. When both
5355 // owners have zero accounts, the section collapses to a single empty CTA.
5456 // ---------------------------------------------------------------------------
5557
@@ -61,6 +63,7 @@ function AccountRow(props: { · 5 unmodified lines
6163 * reconnect to grant the newly-needed access (e.g. after a service was added). */
6264 readonly needsReconsent: boolean;
6365 readonly showOwnerLabel: boolean;
66+ readonly onEdit: () => void;
6467 readonly onReconnect: () => void;
6568 readonly onRemove: () => void;
6669 }) {
@@ -84,6 +87,11 @@ function AccountRow(props: { · 17 unmodified lines
8487 </Badge>
8588 ) : null}
8689 </CardStackEntryTitle>
90+ {connection.description && connection.description.length > 0 ? (
91+ <CardStackEntryDescription className="mt-1 text-xs">
92+ {connection.description}
93+ </CardStackEntryDescription>
94+ ) : null}
8795 {needsReconsent ? (
8896 <CardStackEntryDescription className="mt-1 text-xs text-amber-700 dark:text-amber-400">
8997 This integration now needs access this connection wasn't granted.
@@ -109,6 +117,9 @@ function AccountRow(props: { · 19 unmodified lines
109117 </Button>
110118 </DropdownMenuTrigger>
111119 <DropdownMenuContent align="end" className="w-40">
120+ <DropdownMenuItem className="text-sm" onClick={props.onEdit}>
121+ Edit
122+ </DropdownMenuItem>
112123 <DropdownMenuItem className="text-sm" onClick={props.onReconnect}>
113124 Reconnect
114125 </DropdownMenuItem>
@@ -127,6 +138,7 @@ function OwnerAccounts(props: { · 12 unmodified lines
127138 readonly owner: Owner;
128139 readonly showOwnerLabels: boolean;
129140 readonly methods: readonly AuthMethod[];
141+ readonly onEdit: (connection: Connection) => void;
130142 readonly onDcrReconnect: (connection: Connection) => void;
131143 /** The integration's declared oauth scopes — compared against each connection's
132144 * granted `oauthScope` to flag connections that must reconnect for new access. */
@@ -274,6 +286,7 @@ function OwnerAccounts(props: { · 141 unmodified lines
274286 connection={connection}
275287 needsReconsent={connectionNeedsReconsent(connection, props.declaredScopes)}
276288 showOwnerLabel={props.showOwnerLabels}
289+ onEdit={() => props.onEdit(connection)}
277290 onReconnect={() => void handleReconnect(connection)}
278291 onRemove={() => void handleRemove(connection)}
279292 />
@@ -302,6 +315,7 @@ export function AccountsSection(props: { · 22 unmodified lines
302315 removeCustomMethod,
303316 } = props;
304317 const [adding, setAdding] = useState(false);
318+ const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
305319 const [reconnectHandoff, setReconnectHandoff] = useState<IntegrationAccountHandoff | null>(null);
306320 const ownerDisplay = useOwnerDisplay();
307321 const canAddConnection = methods.length > 0 || createCustomMethod !== undefined;
@@ -419,6 +433,7 @@ export function AccountsSection(props: { · 111 unmodified lines
419433 owner={owner}
420434 showOwnerLabels={ownerDisplay.showOwnerLabels}
421435 methods={methods}
436+ onEdit={setEditingConnection}
422437 onDcrReconnect={(connection: Connection) => {
423438 setReconnectHandoff({
424439 key: `reconnect:${connection.owner}:${String(connection.integration)}:${String(
@@ -436,6 +451,13 @@ export function AccountsSection(props: { · 11 unmodified lines
436451 )}
437452
438453 {modal}
454+
455+ <ConnectionEditSheet
456+ connection={editingConnection}
457+ onOpenChange={(open: boolean) => {
458+ if (!open) setEditingConnection(null);
459+ }}
460+ />
439461 </section>
440462 );
441463 }
packages/react/src/components/command-palette.tsx+1−1
@@ -54,7 +54,7 @@ export function CommandPalette() { · 53 unmodified lines
5454 onSuccess: ({ value }) =>
5555 value.map((integration: Integration) => ({
5656 id: String(integration.slug),
57− name: integration.description || String(integration.slug),
57+ name: integration.name || String(integration.slug),
5858 kind: integration.kind,
5959 url: integration.displayUrl,
6060 })),
packages/react/src/components/metadata-edit-sheet.tsx+296−0
@@ -0,0 +1,296 @@
1+import { Suspense, useCallback, useEffect, useRef, useState, type ComponentType } from "react";
2+import { useAtomSet } from "@effect/atom-react";
3+import * as Exit from "effect/Exit";
4+import { type Connection, type IntegrationSlug } from "@executor-js/sdk/shared";
5+import type { EditSheetApplyResult, EditSheetSectionProps } from "@executor-js/sdk/client";
6+import { toast } from "sonner";
7+
8+import { updateConnection, updateIntegration } from "../api/atoms";
9+import { connectionWriteKeys, integrationWriteKeys } from "../api/reactivity-keys";
10+import { trackEvent } from "../api/analytics";
11+import { messageFromExit } from "../api/error-reporting";
12+import { Button } from "./button";
13+import { Input } from "./input";
14+import { Label } from "./label";
15+import {
16+ Sheet,
17+ SheetContent,
18+ SheetDescription,
19+ SheetFooter,
20+ SheetHeader,
21+ SheetTitle,
22+} from "./sheet";
23+import { Textarea } from "./textarea";
24+
25+// ---------------------------------------------------------------------------
26+// Metadata edit sheets — the user-curated, AGENT-VISIBLE metadata for an
27+// integration and for a connection. The point of the sheet (vs. an inline
28+// input) is the preview: it shows the exact inventory line an agent reads, so
29+// editing the description is editing what the model sees.
30+// ---------------------------------------------------------------------------
31+
32+/** The connection's callable prefix under `tools.` — the line item agents see
33+ * in the execute tool's "Available connection prefixes" inventory. */
34+const connectionPrefix = (connection: Connection): string => {
35+ const address = String(connection.address);
36+ return address.startsWith("tools.") ? address.slice("tools.".length) : address;
37+};
38+
39+function AgentPreview(props: { readonly label: string; readonly line: string }) {
40+ return (
41+ <div className="space-y-1.5">
42+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
43+ {props.label}
44+ </p>
45+ <pre className="overflow-x-auto rounded-md border border-border/60 bg-muted/40 px-3 py-2 font-mono text-xs leading-relaxed whitespace-pre-wrap text-foreground">
46+ {props.line}
47+ </pre>
48+ </div>
49+ );
50+}
51+
52+export function ConnectionEditSheet(props: {
53+ readonly connection: Connection | null;
54+ readonly onOpenChange: (open: boolean) => void;
55+}) {
56+ const { connection } = props;
57+ const doUpdate = useAtomSet(updateConnection, { mode: "promiseExit" });
58+ const [description, setDescription] = useState("");
59+ const [identityLabel, setIdentityLabel] = useState("");
60+ const [saving, setSaving] = useState(false);
61+
62+ useEffect(() => {
63+ setDescription(connection?.description ?? "");
64+ setIdentityLabel(connection?.identityLabel ?? "");
65+ }, [connection]);
66+
67+ const handleSave = async () => {
68+ if (!connection) return;
69+ setSaving(true);
70+ const exit = await doUpdate({
71+ params: {
72+ owner: connection.owner,
73+ integration: connection.integration,
74+ name: connection.name,
75+ },
76+ payload: {
77+ description: description.trim().length > 0 ? description.trim() : null,
78+ identityLabel: identityLabel.trim().length > 0 ? identityLabel.trim() : null,
79+ },
80+ reactivityKeys: connectionWriteKeys,
81+ });
82+ setSaving(false);
83+ if (Exit.isFailure(exit)) {
84+ toast.error(messageFromExit(exit, "Failed to update connection"));
85+ return;
86+ }
87+ props.onOpenChange(false);
88+ };
89+
90+ const previewDescription = description.trim().split("\n", 1)[0];
91+ const prefix = connection ? connectionPrefix(connection) : "";
92+
93+ return (
94+ <Sheet open={connection !== null} onOpenChange={props.onOpenChange}>
95+ <SheetContent side="right">
96+ <SheetHeader>
97+ <SheetTitle>Edit connection</SheetTitle>
98+ <SheetDescription>
99+ The description is agent-visible: it rides this connection's prefix in the tool
100+ inventory, so it is the place to say what this credential reaches and how to use it.
101+ </SheetDescription>
102+ </SheetHeader>
103+
104+ <div className="flex-1 space-y-5 overflow-y-auto px-4">
105+ <div className="space-y-1.5">
106+ <Label htmlFor="connection-description">Description</Label>
107+ <Textarea
108+ id="connection-description"
109+ value={description}
110+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
111+ setDescription(e.target.value)
112+ }
113+ placeholder="e.g. Production CRM — reads only; use the sandbox connection for writes."
114+ disabled={saving}
115+ />
116+ </div>
117+
118+ <div className="space-y-1.5">
119+ <Label htmlFor="connection-label">Account label</Label>
120+ <Input
121+ id="connection-label"
122+ value={identityLabel}
123+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
124+ setIdentityLabel(e.target.value)
125+ }
126+ placeholder="Which account this credential belongs to"
127+ disabled={saving}
128+ />
129+ <p className="text-xs text-muted-foreground">
130+ Display-only; shown in the accounts list instead of the connection name.
131+ </p>
132+ </div>
133+
134+ {connection ? (
135+ <AgentPreview
136+ label="What agents see"
137+ line={
138+ previewDescription ? `- \`${prefix}\` — ${previewDescription}` : `- \`${prefix}\``
139+ }
140+ />
141+ ) : null}
142+ </div>
143+
144+ <SheetFooter>
145+ <Button onClick={() => void handleSave()} disabled={saving || !connection}>
146+ {saving ? "Saving..." : "Save"}
147+ </Button>
148+ <Button variant="outline" onClick={() => props.onOpenChange(false)} disabled={saving}>
149+ Cancel
150+ </Button>
151+ </SheetFooter>
152+ </SheetContent>
153+ </Sheet>
154+ );
155+}
156+
157+export function IntegrationEditSheet(props: {
158+ readonly slug: IntegrationSlug;
159+ readonly open: boolean;
160+ /** The integration's display name. */
161+ readonly name: string;
162+ /** The integration's agent-visible description. */
163+ readonly description: string;
164+ /** Plugin-owned configuration (e.g. OpenAPI spec update) rendered below the
165+ * shared fields — the same surface the plugin's add flow configures. The
166+ * section STAGES its change and reports an apply thunk via
167+ * `onPendingChange`; the sheet's one Save runs it after the metadata
168+ * update. */
169+ readonly pluginSection?: ComponentType<EditSheetSectionProps>;
170+ readonly onOpenChange: (open: boolean) => void;
171+}) {
172+ const doUpdate = useAtomSet(updateIntegration, { mode: "promiseExit" });
173+ const [nameDraft, setNameDraft] = useState(props.name);
174+ const [descriptionDraft, setDescriptionDraft] = useState(props.description);
175+ const [saving, setSaving] = useState(false);
176+ // The plugin section's staged change — a thunk that applies it and resolves
177+ // to a summary line. Held in a ref: it changes per keystroke in the section
178+ // and is only read at Save.
179+ const pendingPluginApply = useRef<(() => Promise<EditSheetApplyResult>) | null>(null);
180+ const handlePendingChange = useCallback((apply: (() => Promise<EditSheetApplyResult>) | null) => {
181+ pendingPluginApply.current = apply;
182+ }, []);
183+
184+ useEffect(() => {
185+ if (props.open) {
186+ setNameDraft(props.name);
187+ setDescriptionDraft(props.description);
188+ }
189+ }, [props.open, props.name, props.description]);
190+
191+ const handleSave = async () => {
192+ const nextName = nameDraft.trim();
193+ if (nextName.length === 0) return;
194+ setSaving(true);
195+ const exit = await doUpdate({
196+ params: { slug: props.slug },
197+ payload: { name: nextName, description: descriptionDraft.trim() },
198+ reactivityKeys: integrationWriteKeys,
199+ });
200+ trackEvent("integration_renamed", {
201+ integration_slug: String(props.slug),
202+ success: Exit.isSuccess(exit),
203+ });
204+ if (Exit.isFailure(exit)) {
205+ setSaving(false);
206+ toast.error(messageFromExit(exit, "Failed to update integration"));
207+ return;
208+ }
209+ // Then the plugin section's staged change (e.g. the spec update). On
210+ // failure the section shows its own error inline — keep the sheet open.
211+ const apply = pendingPluginApply.current;
212+ if (apply) {
213+ const result = await apply();
214+ if (!result.ok) {
215+ setSaving(false);
216+ return;
217+ }
218+ if (result.summary) toast.success(result.summary);
219+ }
220+ setSaving(false);
221+ props.onOpenChange(false);
222+ };
223+
224+ const slug = String(props.slug);
225+ const preview = descriptionDraft.trim().split("\n", 1)[0];
226+
227+ return (
228+ <Sheet open={props.open} onOpenChange={props.onOpenChange}>
229+ <SheetContent side="right">
230+ <SheetHeader>
231+ <SheetTitle>Edit integration</SheetTitle>
232+ <SheetDescription>
233+ The name is what people see; the description is agent-visible context — agents read it
234+ when browsing sources and as fallback context on connections without one of their own.
235+ </SheetDescription>
236+ </SheetHeader>
237+
238+ <div className="flex-1 space-y-5 overflow-y-auto px-4">
239+ <div className="space-y-1.5">
240+ <Label htmlFor="integration-name">Name</Label>
241+ <Input
242+ id="integration-name"
243+ value={nameDraft}
244+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNameDraft(e.target.value)}
245+ placeholder="e.g. GitHub"
246+ disabled={saving}
247+ />
248+ </div>
249+
250+ <div className="space-y-1.5">
251+ <Label htmlFor="integration-description">Description</Label>
252+ <Textarea
253+ id="integration-description"
254+ value={descriptionDraft}
255+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
256+ setDescriptionDraft(e.target.value)
257+ }
258+ onKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
259+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void handleSave();
260+ }}
261+ placeholder="What this API is and when to reach for it"
262+ disabled={saving}
263+ />
264+ </div>
265+
266+ <AgentPreview
267+ label="What agents see"
268+ line={
269+ preview && preview.toLowerCase() !== slug.toLowerCase()
270+ ? `{ id: "${slug}", description: "${preview}", ... }`
271+ : `{ id: "${slug}", ... }`
272+ }
273+ />
274+
275+ {props.pluginSection ? (
276+ <Suspense fallback={null}>
277+ <props.pluginSection sourceId={slug} onPendingChange={handlePendingChange} />
278+ </Suspense>
279+ ) : null}
280+ </div>
281+
282+ <SheetFooter>
283+ <Button
284+ onClick={() => void handleSave()}
285+ disabled={saving || nameDraft.trim().length === 0}
286+ >
287+ {saving ? "Saving..." : "Save"}
288+ </Button>
289+ <Button variant="outline" onClick={() => props.onOpenChange(false)} disabled={saving}>
290+ Cancel
291+ </Button>
292+ </SheetFooter>
293+ </SheetContent>
294+ </Sheet>
295+ );
296+}
packages/react/src/multiplayer/shell.tsx+1−1
@@ -142,7 +142,7 @@ function IntegrationList(props: { pathname: string; onNavigate?: () => void }) { · 141 unmodified lines
142142 <div className="flex flex-col gap-px">
143143 {value.map((integration: Integration) => {
144144 const slug = String(integration.slug);
145− const name = integration.description || slug;
145+ const name = integration.name || slug;
146146 const detailPath = `/integrations/${slug}`;
147147 const active =
148148 props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`);
packages/react/src/pages/integration-detail.tsx+98−213
@@ -20,19 +20,18 @@ import { · 19 unmodified lines
2020 policiesOptimisticAtom,
2121 refreshConnection,
2222 removeIntegrationOptimistic,
23− updateIntegration,
2423 } from "../api/atoms";
2524 import { connectionWriteKeys, integrationWriteKeys } from "../api/reactivity-keys";
2625 import { ToolTree } from "../components/tool-tree";
2726 import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail";
2827 import type { ToolSummary } from "../components/tool-tree";
2928 import { AccountsSection } from "../components/accounts-section";
29+import { IntegrationEditSheet } from "../components/metadata-edit-sheet";
3030 import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs";
3131 import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements";
3232 import { usePolicyActions } from "../hooks/use-policy-actions";
3333 import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client";
3434 import { Button } from "../components/button";
35−import { Input } from "../components/input";
3635 import { Skeleton } from "../components/skeleton";
3736
3837 // v2: the route's `namespace` param is the integration slug. Tools belong to
@@ -64,7 +63,6 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 25 unmodified lines
6463 const refreshTools = useAtomRefresh(integrationToolsAllAtom(slug));
6564 const doRemove = useAtomSet(removeIntegrationOptimistic, { mode: "promiseExit" });
6665 const doRefresh = useAtomSet(refreshConnection, { mode: "promiseExit" });
67− const doUpdate = useAtomSet(updateIntegration, { mode: "promiseExit" });
6866 // Policies are owner-partitioned on write; the integration policy menu writes
6967 // Workspace (org) rules, preserving the prior default behavior.
7068 const policyActions = usePolicyActions("org");
@@ -87,10 +85,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 16 unmodified lines
8785 const [confirmDelete, setConfirmDelete] = useState(false);
8886 const [deleting, setDeleting] = useState(false);
8987 const [refreshing, setRefreshing] = useState(false);
90− const [editing, setEditing] = useState(false);
91− const [renaming, setRenaming] = useState(false);
92− const [nameDraft, setNameDraft] = useState("");
93− const [savingName, setSavingName] = useState(false);
88+ const [editSheetOpen, setEditSheetOpen] = useState(false);
9489 const [activeTab, setActiveTab] = useState<"accounts" | "tools">("accounts");
9590 const [locationSearch] = useState(() =>
9691 typeof window === "undefined" ? "" : window.location.search,
@@ -98,7 +93,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 1 unmodified line
9893
9994 useEffect(() => {
10095 setConfirmDelete(false);
101− setRenaming(false);
96+ setEditSheetOpen(false);
10297 }, [namespace]);
10398
10499 const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null;
@@ -319,68 +314,15 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 214 unmodified lines
319314 setRefreshing(false);
320315 };
321316
322− const handleEditSave = () => {
323− setEditing(false);
324− };
325−
326− const startRename = () => {
327− setNameDraft(integrationData?.description || namespace);
328− setRenaming(true);
329− };
330−
331− const cancelRename = () => {
332− setRenaming(false);
333− setNameDraft("");
334− };
335−
336− // Inline rename of the integration's description. Plugin-agnostic: writes the
337− // core `integrations.update` mutation; the catalog list query refreshes via
338− // `integrationWriteKeys` so the derived `integrationAtom` reflects the change.
339− const handleRenameSave = async () => {
340− const next = nameDraft.trim();
341− if (!integrationData || next.length === 0 || next === integrationData.description) {
342− cancelRename();
343− return;
344− }
345− setSavingName(true);
346− const exit = await doUpdate({
347− params: { slug },
348− payload: { description: next },
349− reactivityKeys: integrationWriteKeys,
350− });
351− setSavingName(false);
352− trackEvent("integration_renamed", {
353− integration_slug: String(slug),
354− success: Exit.isSuccess(exit),
355− });
356− if (Exit.isFailure(exit)) return;
357− cancelRename();
358− };
359−
360317 return (
361318 <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
362319 {/* Header bar */}
363320 <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm">
364321 <div className="flex min-w-0 items-center gap-3">
365− {renaming ? (
366− <Input
367− autoFocus
368− value={nameDraft}
369− onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNameDraft(e.target.value)}
370− onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
371− if (e.key === "Enter") void handleRenameSave();
372− if (e.key === "Escape") cancelRename();
373− }}
374− disabled={savingName}
375− aria-label="Integration name"
376− className="h-7 w-56 text-sm"
377− />
378− ) : (
379− <h2 className="truncate text-sm font-semibold text-foreground">
380− {integrationData?.description || namespace}
381− </h2>
382− )}
383− {AsyncResult.isSuccess(tools) && !editing && !renaming && (
322+ <h2 className="truncate text-sm font-semibold text-foreground">
323+ {integrationData?.name || namespace}
324+ </h2>
325+ {AsyncResult.isSuccess(tools) && (
384326 <span className="hidden text-xs tabular-nums text-muted-foreground sm:block">
385327 {distinctToolCount} {distinctToolCount === 1 ? "tool" : "tools"}
386328 </span>
@@ -388,33 +330,13 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 1 unmodified line
388330 </div>
389331
390332 <div className="flex shrink-0 items-center gap-2">
391− {renaming ? (
392− <>
393− <Button variant="outline" size="sm" onClick={cancelRename} disabled={savingName}>
394− Cancel
395− </Button>
396− <Button size="sm" onClick={() => void handleRenameSave()} disabled={savingName}>
397− {savingName ? "Saving..." : "Save"}
398− </Button>
399− </>
400− ) : (
401− !editing &&
402− !confirmDelete &&
403− !isBuiltInIntegration &&
404− integrationData && (
405− <Button variant="outline" size="sm" onClick={startRename}>
406− Edit
407− </Button>
408− )
409− )}
410−
411− {editing && (
412− <Button variant="outline" size="sm" onClick={() => setEditing(false)}>
413− Back to tools
333+ {!confirmDelete && !isBuiltInIntegration && integrationData && (
334+ <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}>
335+ Edit
414336 </Button>
415337 )}
416338
417− {canRefresh && !editing && !renaming && (
339+ {canRefresh && (
418340 <Button
419341 variant="outline"
420342 size="sm"
@@ -426,8 +348,6 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 5 unmodified lines
426348 )}
427349
428350 {canRemove &&
429− !editing &&
430− !renaming &&
431351 (confirmDelete ? (
432352 <div className="flex items-center gap-2">
433353 <Button
@@ -460,118 +380,106 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 26 unmodified lines
460380 </div>
461381 </div>
462382
463− {/* Edit view */}
464− {editing && editPlugin ? (
465− <div className="min-h-0 flex-1 overflow-y-auto">
466− <div className="mx-auto max-w-2xl px-6 py-8">
467− <Suspense fallback={<EditFormSkeleton />}>
468− <editPlugin.edit sourceId={namespace} onSave={handleEditSave} />
469− </Suspense>
470− </div>
383+ <Tabs
384+ value={currentTab}
385+ onValueChange={(value: string) => setActiveTab(value as "accounts" | "tools")}
386+ className="min-h-0 flex-1 gap-0 overflow-hidden"
387+ >
388+ <div className="shrink-0 border-b border-border/60 px-4 py-2">
389+ <TabsList variant="line">
390+ {!isBuiltInIntegration && <TabsTrigger value="accounts">Accounts</TabsTrigger>}
391+ <TabsTrigger value="tools">Tools</TabsTrigger>
392+ </TabsList>
471393 </div>
472− ) : (
473− <Tabs
474− value={currentTab}
475− onValueChange={(value: string) => setActiveTab(value as "accounts" | "tools")}
476− className="min-h-0 flex-1 gap-0 overflow-hidden"
477− >
478− <div className="shrink-0 border-b border-border/60 px-4 py-2">
479− <TabsList variant="line">
480− {!isBuiltInIntegration && <TabsTrigger value="accounts">Accounts</TabsTrigger>}
481− <TabsTrigger value="tools">Tools</TabsTrigger>
482− </TabsList>
483− </div>
484394
485− {/* Hub: integration-level auth methods + accounts. Plugins that
395+ {/* Hub: integration-level auth methods + accounts. Plugins that
486396 declare auth methods fill the `accounts` slot (real methods from
487397 the plugin's config); otherwise we render the generic fallback. */}
488− {!isBuiltInIntegration && (
489− <TabsContent value="accounts" className="min-h-0 overflow-y-auto">
490− {editPlugin?.accounts ? (
491− <Suspense fallback={<AccountsSkeleton />}>
492− <editPlugin.accounts
493− sourceId={namespace}
494− integrationName={integrationData?.description || namespace}
495− accountHandoff={accountHandoff}
496− />
497− </Suspense>
498− ) : (
499− <div className="mx-auto max-w-3xl space-y-8 px-6 py-8">
500− <AccountsSection
501− integration={slug}
502− integrationName={integrationData?.description || namespace}
503− methods={accountsMethods}
504− accountHandoff={accountHandoff}
505− />
506− </div>
507− )}
508− </TabsContent>
509− )}
510−
511− {/* Tools -- split pane (unchanged behavior) */}
512− <TabsContent
513− value="tools"
514− className="flex min-h-0 flex-col overflow-hidden data-[state=inactive]:hidden"
515− >
516− {editPlugin?.summary && (
517− <Suspense fallback={null}>
518− <editPlugin.summary
398+ {!isBuiltInIntegration && (
399+ <TabsContent value="accounts" className="min-h-0 overflow-y-auto">
400+ {editPlugin?.accounts ? (
401+ <Suspense fallback={<AccountsSkeleton />}>
402+ <editPlugin.accounts
519403 sourceId={namespace}
520− variant="panel"
521− onAction={() => setEditing(true)}
404+ integrationName={integrationData?.name || namespace}
405+ accountHandoff={accountHandoff}
522406 />
523407 </Suspense>
408+ ) : (
409+ <div className="mx-auto max-w-3xl space-y-8 px-6 py-8">
410+ <AccountsSection
411+ integration={slug}
412+ integrationName={integrationData?.name || namespace}
413+ methods={accountsMethods}
414+ accountHandoff={accountHandoff}
415+ />
416+ </div>
524417 )}
418+ </TabsContent>
419+ )}
420+
421+ {/* Tools -- split pane (unchanged behavior) */}
422+ <TabsContent
423+ value="tools"
424+ className="flex min-h-0 flex-col overflow-hidden data-[state=inactive]:hidden"
425+ >
426+ {AsyncResult.match(tools, {
427+ onInitial: () => <IntegrationDetailSkeleton />,
428+ onFailure: () => (
429+ <div className="p-6 text-sm text-destructive">Failed to load tools</div>
430+ ),
431+ onSuccess: () => (
432+ <div className="flex min-h-0 flex-1 overflow-hidden">
433+ {/* Left: tool tree */}
434+ <div className="flex w-72 shrink-0 flex-col border-r border-border/60 lg:w-80 xl:w-[22rem]">
435+ <ToolTree
436+ tools={integrationTools}
437+ selectedToolId={selectedToolId}
438+ onSelect={setSelectedToolId}
439+ onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)}
440+ onClearPolicy={(pattern) => void policyActions.clear(pattern)}
441+ policies={sortedPolicies}
442+ groupByConnection={!isBuiltInIntegration}
443+ />
444+ </div>
525445
526− {AsyncResult.match(tools, {
527− onInitial: () => <IntegrationDetailSkeleton />,
528− onFailure: () => (
529− <div className="p-6 text-sm text-destructive">Failed to load tools</div>
530− ),
531− onSuccess: () => (
532− <div className="flex min-h-0 flex-1 overflow-hidden">
533− {/* Left: tool tree */}
534− <div className="flex w-72 shrink-0 flex-col border-r border-border/60 lg:w-80 xl:w-[22rem]">
535− <ToolTree
536− tools={integrationTools}
537− selectedToolId={selectedToolId}
538− onSelect={setSelectedToolId}
446+ {/* Right: tool detail with Schema · TypeScript · Run tabs */}
447+ <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
448+ {selectedTool && selectedAddress && selectedBareName ? (
449+ <ToolDetail
450+ address={selectedAddress}
451+ toolName={selectedTool.name}
452+ staticTool={selection?.static}
453+ policy={selectedTool.policy}
539454 onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)}
540455 onClearPolicy={(pattern) => void policyActions.clear(pattern)}
541− policies={sortedPolicies}
542− groupByConnection={!isBuiltInIntegration}
456+ {...(!selection?.static && selectedBareName
457+ ? {
458+ integration: slug,
459+ runToolName: selectedBareName,
460+ connections: integrationConnections,
461+ initialConnectionName: selection?.connection ?? null,
462+ }
463+ : {})}
543464 />
544− </div>
545−
546− {/* Right: tool detail with Schema · TypeScript · Run tabs */}
547− <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
548− {selectedTool && selectedAddress && selectedBareName ? (
549− <ToolDetail
550− address={selectedAddress}
551− toolName={selectedTool.name}
552− staticTool={selection?.static}
553− policy={selectedTool.policy}
554− onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)}
555− onClearPolicy={(pattern) => void policyActions.clear(pattern)}
556− {...(!selection?.static && selectedBareName
557− ? {
558− integration: slug,
559− runToolName: selectedBareName,
560− connections: integrationConnections,
561− initialConnectionName: selection?.connection ?? null,
562− }
563− : {})}
564− />
565− ) : (
566− <ToolDetailEmpty hasTools={integrationTools.length > 0} />
567− )}
568− </div>
465+ ) : (
466+ <ToolDetailEmpty hasTools={integrationTools.length > 0} />
467+ )}
569468 </div>
570− ),
571− })}
572− </TabsContent>
573− </Tabs>
574− )}
469+ </div>
470+ ),
471+ })}
472+ </TabsContent>
473+ </Tabs>
474+
475+ <IntegrationEditSheet
476+ slug={slug}
477+ open={editSheetOpen}
478+ name={integrationData?.name || namespace}
479+ description={integrationData?.description ?? ""}
480+ {...(editPlugin?.editSheet ? { pluginSection: editPlugin.editSheet } : {})}
481+ onOpenChange={setEditSheetOpen}
482+ />
575483 </div>
576484 );
577485 }
@@ -623,26 +531,3 @@ function AccountsSkeleton() { · 45 unmodified lines
623531 </div>
624532 );
625533 }
626−
627−function EditFormSkeleton() {
628− return (
629− <div className="flex flex-col gap-5 p-6">
630− <div className="flex flex-col gap-2">
631− <Skeleton className="h-4 w-20" />
632− <Skeleton className="h-9 w-full rounded-md" />
633− </div>
634− <div className="flex flex-col gap-2">
635− <Skeleton className="h-4 w-24" />
636− <Skeleton className="h-9 w-full rounded-md" />
637− </div>
638− <div className="flex flex-col gap-2">
639− <Skeleton className="h-4 w-28" />
640− <Skeleton className="h-24 w-full rounded-md" />
641− </div>
642− <div className="flex justify-end gap-2 pt-2">
643− <Skeleton className="h-9 w-20 rounded-md" />
644− <Skeleton className="h-9 w-24 rounded-md" />
645− </div>
646− </div>
647− );
648−}
packages/react/src/pages/integrations.tsx+1−1
@@ -435,7 +435,7 @@ function IntegrationGrid(props: { integrations: readonly Integration[] }) { · 434 unmodified lines
435435 const plugin = pluginByKind.get(pluginKey);
436436 const SummaryComponent = plugin?.summary;
437437 const slug = String(integration.slug);
438− const name = integration.description || slug;
438+ const name = integration.name || slug;
439439 return (
440440 <CardStackEntry key={slug} asChild searchText={`${name} ${slug} ${integration.kind}`}>
441441 <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: slug }}>