History

Merge remote-tracking branch 'origin/main' into connection-create-conflict

Rhys Sullivan committed 2 months ago

Commit 14e82e52 parents 641aa73 ecb87de93 files · +3675 −178Browse files

Unified · Split

93 files
  1. .changeset/dialog-outside-click-keeps-form-state.md +11 −0
  2. .changeset/foreign-org-slug-not-found.md +13 −0
  3. .changeset/legacy-bigint-storage-class.md +11 −0
  4. .changeset/mcp-add-url-typing-probe.md +11 −0
  5. .changeset/mcp-integration-families.md +5 −0
  6. .changeset/mcp-remote-request-headers.md +15 −0
  7. .changeset/mcp-stdio-edit-ui.md +26 −0
  8. .changeset/mcp-tool-meta-passthrough.md +11 −0
  9. .changeset/selfhost-additional-trusted-origins.md +10 −0
  10. .changeset/sidebar-integration-connect.md +15 −0
  11. .changeset/tools-read-stale-sync-grace.md +11 −0
  12. .changeset/update-check-suppress-false-nags.md +14 −0
  13. .github/ISSUE_TEMPLATE/bug_report.yml +100 −0
  14. .github/ISSUE_TEMPLATE/feature_request.yml +51 −0
  15. .github/PULL_REQUEST_TEMPLATE.md +30 −0
  16. apps/cloud/src/engine/execution-stack.ts +5 −1
  17. apps/cloud/src/routes/__root.tsx +25 −7
  18. apps/cloud/src/routes/app/billing_.plans.tsx +4 −2
  19. apps/cloud/src/web/components/support-slot.tsx +2 −1
  20. apps/docs/hosted/docker.mdx +17 −0
  21. apps/host-selfhost/.env.example +5 −0
  22. apps/host-selfhost/src/auth/better-auth.test.ts +79 −0
  23. apps/host-selfhost/src/auth/better-auth.ts +33 −9
  24. apps/host-selfhost/src/auth/invalid-origin-help.test.ts +2 −0
  25. apps/host-selfhost/src/auth/invalid-origin-help.ts +4 −3
  26. apps/host-selfhost/src/auth/origin-resolution.test.ts +53 −1
  27. apps/host-selfhost/src/config.ts +56 −1
  28. apps/host-selfhost/src/db/data-migrations.ts +10 −1
  29. apps/local/src/db/data-migrations.ts +6 −0
  30. apps/local/src/db/legacy-bigint-boot.test.ts +120 −0
  31. apps/local/vite.config.ts +35 −1
  32. bun.lock +1 −0
  33. e2e/local/stdio-mcp-edit-ui.test.ts +138 −0
  34. e2e/local/stdio-mcp.test.ts +42 −0
  35. e2e/scenarios/graphql-introspection-health.test.ts +62 −0
  36. e2e/scenarios/integration-families.test.ts +69 −0
  37. e2e/scenarios/mcp-add-url-typing-probe.test.ts +48 −0
  38. e2e/scenarios/org-slug-routing.test.ts +59 −13
  39. e2e/scenarios/policy-remove-confirm.test.ts +85 −0
  40. e2e/scenarios/provider-plugins-ui.test.ts +5 −3
  41. e2e/selfhost/execute-emit-envelope.test.ts +94 −0
  42. e2e/selfhost/mcp-request-headers-add.test.ts +133 −0
  43. packages/core/api/src/index.ts +3 −0
  44. packages/core/api/src/server/scoped-executor.ts +10 −0
  45. packages/core/api/src/update-check.test.ts +103 −2
  46. packages/core/api/src/update-check.ts +58 −3
  47. packages/core/sdk/src/executor.ts +66 −1
  48. packages/core/sdk/src/index.ts +9 −0
  49. packages/core/sdk/src/sqlite-bigint-storage-class-migration.test.ts +202 −0
  50. packages/core/sdk/src/sqlite-bigint-storage-class-migration.ts +166 −0
  51. packages/plugins/graphql/src/sdk/plugin.test.ts +5 −1
  52. packages/plugins/graphql/src/sdk/plugin.ts +4 −4
  53. packages/plugins/mcp/package.json +1 −0
  54. packages/plugins/mcp/src/api/group.ts +2 −0
  55. packages/plugins/mcp/src/api/handlers.ts +4 −0
  56. packages/plugins/mcp/src/react/AddMcpIntegration.tsx +41 −19
  57. packages/plugins/mcp/src/react/EditMcpIntegration.tsx +158 −22
  58. packages/plugins/mcp/src/react/McpRequestHeadersEditor.tsx +118 −0
  59. packages/plugins/mcp/src/react/probe-url.test.ts +63 −0
  60. packages/plugins/mcp/src/react/probe-url.ts +25 −0
  61. packages/plugins/mcp/src/react/request-headers.test.ts +83 −0
  62. packages/plugins/mcp/src/react/request-headers.ts +58 −0
  63. packages/plugins/mcp/src/react/stdio-fields.test.ts +70 −0
  64. packages/plugins/mcp/src/react/stdio-fields.ts +51 −0
  65. packages/plugins/mcp/src/sdk/catalog-sync.test.ts +104 −1
  66. packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts +18 −0
  67. packages/plugins/mcp/src/sdk/index.ts +1 −0
  68. packages/plugins/mcp/src/sdk/manifest.ts +16 −1
  69. packages/plugins/mcp/src/sdk/plugin.test.ts +81 −0
  70. packages/plugins/mcp/src/sdk/plugin.ts +26 −8
  71. packages/plugins/mcp/src/sdk/probe-shape.test.ts +90 −0
  72. packages/plugins/mcp/src/sdk/probe-shape.ts +28 −17
  73. packages/plugins/mcp/src/sdk/types.ts +14 −0
  74. packages/plugins/mcp/src/testing/server.ts +12 −0
  75. packages/react/src/components/add-account-modal.tsx +3 −6
  76. packages/react/src/components/command.tsx +6 −0
  77. packages/react/src/components/dialog.tsx +15 −12
  78. packages/react/src/components/remove-oauth-app-dialog.tsx +2 −1
  79. packages/react/src/components/sheet.tsx +13 −10
  80. packages/react/src/components/sidebar.tsx +3 −0
  81. packages/react/src/components/update-card.test.ts +58 −0
  82. packages/react/src/components/update-card.tsx +33 −11
  83. packages/react/src/lib/integration-grouping.test.ts +14 −1
  84. packages/react/src/lib/integration-grouping.ts +1 −3
  85. packages/react/src/lib/outside-dismiss.test.ts +77 −0
  86. packages/react/src/lib/outside-dismiss.ts +53 −0
  87. packages/react/src/multiplayer/org-slug-gate.tsx +5 −1
  88. packages/react/src/multiplayer/shell.tsx +26 −2
  89. packages/react/src/pages/admin-users.tsx +2 −1
  90. packages/react/src/pages/api-keys.tsx +45 −4
  91. packages/react/src/pages/integrations.tsx +1 −1
  92. packages/react/src/pages/org.tsx +52 −2
  93. packages/react/src/pages/policies.tsx +50 −1
.changeset/dialog-outside-click-keeps-form-state.md+11−0
@@ -0,0 +1,11 @@
1+---
2+"@executor-js/react": patch
3+---
4+
5+**A click outside a dialog or sheet no longer discards the form inside it**
6+
7+Radix dismisses an overlay surface on any un-prevented outside interaction, and `DialogContent` and `SheetContent` only prevented that for clicks landing in a portaled combobox or select popup. Every other outside click fell through to dismissal, so a stray click on the page behind a form — after switching windows to copy an ID, for example — closed the surface and destroyed what the user had typed. These surfaces unmount their state on close by design, so nothing was recoverable.
8+
9+The default is now the opposite: an outside interaction keeps the surface open. Escape and the close button are unchanged and still close it. `DialogContent` and `SheetContent` take a new `dismissOnOutsideClick` prop for surfaces with nothing to lose — confirmations, pickers, and read-only panels — and the portaled-popup guard still applies there, so choosing a combobox option never dismisses.
10+
11+`CommandDialog` sets `dismissOnOutsideClick` on by default, because a command palette holds only a search string and clicking away is the expected way to leave it.
.changeset/foreign-org-slug-not-found.md+13−0
@@ -0,0 +1,13 @@
1+---
2+"executor": patch
3+---
4+
5+**A console URL naming an organization you cannot see is a not-found page, every time**
6+
7+Opening `/<some-other-slug>/policies` sometimes rendered the full authenticated console — sidebar, org switcher showing your OWN organization, page chrome — under an address naming an organization you are not a member of. The page body was a failing org-scoped query with a Retry button, so the workspace on screen belonged to nobody and the URL belonged to someone else.
8+
9+The shell's not-found only fired once `/account/me` had answered for the URL's slug. Until then the console read its identity from the auth-hint cookie, which always names the organization the session last landed in, never the one in the address bar. So the first paint answered a question about a different organization and built a whole workspace out of it, and whether you ever saw that depended on how fast the server replied.
10+
11+The shell is now built only from an answer that names the organization the URL names. A slug the current answer does not cover renders nothing at all until `/account/me` resolves for that slug, and then either the workspace or — for an organization this session cannot see — the not-found page. The URL is never rewritten: a wrong address stays a wrong address.
12+
13+The ordinary cold load is untouched. The hint names the slug already in the URL, so it matches on the very first paint and the shell renders with no round trip. Only a slug the hint does not name waits: a foreign one, and the single frame after switching organizations, which now paints the organization the URL asked for instead of briefly showing the previous one.
.changeset/legacy-bigint-storage-class.md+11−0
@@ -0,0 +1,11 @@
1+---
2+"executor": patch
3+---
4+
5+**Saved integrations come back after an upgrade left an OAuth expiry in the old number format**
6+
7+Some installs lost every saved integration from the MCP gateway at once. The credentials were never deleted — the gateway simply could not read the table they live in, so it served an empty tool list and restarting did not help.
8+
9+The `connection.expires_at` column records when an OAuth access token expires. It used to be a plain number; it now holds the value's digits, because a millisecond timestamp is larger than a 32-bit integer. SQLite does not rewrite rows when a column's type changes, so a connection saved by an older build still held the old form. Reading one back failed, and because the failure happened while mapping the row, it failed the whole query rather than that one field — one stale row was enough to hide every integration.
10+
11+A boot-time migration now converts those values to the current form. It runs before anything reads the table, so the integrations are back on the first restart after upgrading. It only touches values still in the old numeric form: rows already written by a current build are left exactly as they are, and it runs once.
.changeset/mcp-add-url-typing-probe.md+11−0
@@ -0,0 +1,11 @@
1+---
2+"@executor-js/plugin-mcp": patch
3+---
4+
5+**The add-MCP form stops dialling the server URL while it is still being typed**
6+
7+The Server URL field auto-probes the endpoint after a 400ms pause. The only condition on that probe was that the trimmed value was non-empty, so every pause in typing dialled whatever was in the field: "h", "http://", the "a" in "http://a". Each of those probes failed, and the field dropped into a loading state and then an error with a retry button, for a value the user never meant to submit.
8+
9+The probe now runs only when the value looks like a finished endpoint: it parses as a URL, its scheme is http or https, and its hostname is either a local development host or has a dot with a label on each side. The debounce is unchanged, so a completed URL is still probed without the user having to submit.
10+
11+A probe that is superseded is also no longer allowed to answer. The field could previously report the outcome of a request for a URL that had since been edited, because each probe dispatched its result unconditionally. Editing the URL now invalidates any probe already in flight, and its reply is discarded rather than applied to the current value.
.changeset/mcp-integration-families.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"@executor-js/plugin-mcp": patch
3+---
4+
5+Allow MCP integrations to declare a catalog family and group any family with multiple services in the integrations UI.
.changeset/mcp-remote-request-headers.md+15−0
@@ -0,0 +1,15 @@
1+---
2+"executor": patch
3+---
4+
5+**Fix: add remote MCP servers that sit behind an authenticating proxy**
6+
7+The add-MCP form now carries an optional request headers editor. The name/value
8+pairs are sent on the connection check and on every later request, so an
9+endpoint gated by an edge authenticator — a Cloudflare Access service token,
10+for example — can be discovered and added.
11+
12+A `403` from such a gate is also no longer read as an unreachable server. It is
13+classified the same way a `401` is: the endpoint needs credentials, so the add
14+flow continues to the auth step instead of stopping on "Couldn't reach this
15+URL".
.changeset/mcp-stdio-edit-ui.md+26−0
@@ -0,0 +1,26 @@
1+---
2+"executor": patch
3+---
4+
5+**Stdio MCP integrations can be edited from the UI**
6+
7+The integration Edit sheet showed stdio servers as read-only text and told you
8+to remove and recreate the integration to change its command. Fixing a typo in
9+an argument, moving a server to a new path, or adding a static environment
10+variable meant editing `executor.jsonc` by hand, or losing the integration's
11+connections and tool policies to a delete-and-re-add.
12+
13+The sheet now edits the command, its arguments, the working directory, and the
14+declared environment map, staged and applied by the sheet's own Save like the
15+remote editor beside it. Arguments use the same quote-aware parsing as the add
16+flow, so an argument containing spaces survives a round trip.
17+
18+The environment field edits the DECLARED static variables only. A stdio server
19+receives those plus a small fixed base set — it does not inherit executor's
20+environment — and secret values still belong to the connection, entered per
21+account against the server's declared `stdio_env` method.
22+
23+Saving revises the integration config, which is already enough to rebuild the
24+tool catalog: connections whose catalog predates the revision re-list on their
25+next read, so an edited command's tools are correct without an explicit
26+refresh.
.changeset/mcp-tool-meta-passthrough.md+11−0
@@ -0,0 +1,11 @@
1+---
2+"@executor-js/plugin-mcp": patch
3+---
4+
5+**An MCP tool's reserved `_meta` map survives `tools/list` decoding and reaches the persisted catalog**
6+
7+The MCP spec reserves `_meta` on `Tool` for implementation-defined data, and servers use it for host-only routing and policy hints that do not belong in the closed `annotations` set. The plugin decoded each listed tool with a closed struct that did not declare the field, so `_meta` was discarded before the manifest entry was built. A host that embeds the plugin as its MCP client had no way to recover it: no hook exposes the raw `tools/list` result, and `connections.refresh()` answers with already-built tools.
8+
9+The listed-tool decode now declares `_meta`, and the manifest entry carries it through. Executor's own `Tool` has no `_meta` field, so `toToolDef` stamps the map into the `mcp` envelope the plugin already persists in each tool row's annotations, next to the real MCP tool name. The stamp schema declares it too, so it is not stripped a second time when a row is read back at invoke time. A host reads it from `annotations.mcp._meta`.
10+
11+The map stays opaque. Nothing in the plugin interprets its contents, and it is never merged into anything the model sees. Because it is entirely server-controlled, it is decoded permissively: a `_meta` that is not the spec's map shape is ignored for that tool rather than failing the whole-list decode, which would otherwise drop every tool the server advertises.
.changeset/selfhost-additional-trusted-origins.md+10−0
@@ -0,0 +1,10 @@
1+---
2+"executor": patch
3+---
4+
5+Self-hosted instances can now allow additional browser origins without changing
6+their canonical public URL. Set `EXECUTOR_TRUSTED_ORIGINS` to a comma-separated
7+list of exact HTTP or HTTPS origins when one instance is intentionally reachable
8+through multiple hostnames or addresses. OAuth callbacks, MCP metadata, approval
9+links, and other generated URLs remain pinned to `EXECUTOR_WEB_BASE_URL`, and
10+origins are never inferred from request headers.
.changeset/sidebar-integration-connect.md+15−0
@@ -0,0 +1,15 @@
1+---
2+"executor": patch
3+---
4+
5+**Connect an integration from the sidebar**
6+
7+The sidebar lists your integrations on every console route, but connecting
8+another one meant navigating back to the integrations page to reach its Connect
9+action — the picker state was owned by that page, so the shared shell could
10+render the list without being able to open the flow behind it.
11+
12+The connect dialog now belongs to the shell. A labelled plus button sits beside
13+the sidebar's Integrations heading and opens the same picker, records the same
14+event, and leaves the current route in place behind it. On mobile the navigation
15+drawer closes first so the dialog gets the full viewport.
.changeset/tools-read-stale-sync-grace.md+11−0
@@ -0,0 +1,11 @@
1+---
2+"executor": patch
3+---
4+
5+**Tools reads stop waiting on slow upstream servers**
6+
7+A tools read rebuilds every connection whose catalog has gone stale before answering. The rebuilds already ran concurrently, but the read still waited for all of them, so one slow or unreachable MCP server gated every catalog read behind its network timeout — a tools listing could take tens of seconds while healthy connections sat ready.
8+
9+A read now waits at most a short grace budget (2 seconds by default) for the rebuilds, then answers from the persisted catalog. The rebuilds keep running after the read returns and land on a later read, so the catalog still converges — it just no longer holds the reader hostage while it does. Overlapping reads share one in-flight rebuild per connection instead of stacking new ones.
10+
11+The budget is `toolsSyncGraceMs` on the SDK config. Pass `null` to restore the strict behavior, where a read blocks until every rebuild finishes and always reflects a fully converged catalog.
.changeset/update-check-suppress-false-nags.md+14−0
@@ -0,0 +1,14 @@
1+---
2+"executor": patch
3+---
4+
5+**Fix: stop the update check from claiming a newer version is available on builds it cannot compare**
6+
7+A build stamped with the placeholder 0.0.0 version always compared as older
8+than the latest release, and a prerelease on a channel with no matching
9+dist-tag (rc, alpha, and similar) always lost the comparison too. Both cases
10+now short-circuit to "no update available" before the check reaches the
11+registry.
12+
13+This applies wherever the update check runs, so the CLI check and the sidebar
14+update card both stop showing an update prompt that a user could never act on.
.github/ISSUE_TEMPLATE/bug_report.yml+100−0
@@ -0,0 +1,100 @@
1+name: Bug report
2+description: Something in Executor does not work as expected.
3+title: "[bug] "
4+labels: ["bug"]
5+body:
6+ - type: markdown
7+ attributes:
8+ value: |
9+ Please update to the latest version first — the bug may already be fixed.
10+ If the desktop app crashes, will not start, or shows a blank window, use the
11+ **[Desktop app crash](?template=desktop-crash.yml)** template instead.
12+ - type: input
13+ id: version
14+ attributes:
15+ label: Executor version
16+ description: Run `executor --version`, or open the About menu in the desktop app.
17+ placeholder: "1.5.4"
18+ validations:
19+ required: true
20+ - type: dropdown
21+ id: surface
22+ attributes:
23+ label: How do you run Executor?
24+ options:
25+ - Executor Cloud
26+ - CLI
27+ - Desktop app
28+ - Self-host (Docker)
29+ - Self-host (Cloudflare)
30+ validations:
31+ required: true
32+ - type: dropdown
33+ id: os
34+ attributes:
35+ label: Operating system
36+ description: For Executor Cloud, select the system you browse from.
37+ options:
38+ - macOS (Apple Silicon)
39+ - macOS (Intel)
40+ - Windows
41+ - Linux
42+ validations:
43+ required: true
44+ - type: input
45+ id: integration
46+ attributes:
47+ label: Integration involved
48+ description: The integration or source type, if the bug is specific to one.
49+ placeholder: "MCP server, OpenAPI spec, GraphQL API, …"
50+ - type: textarea
51+ id: what-happened
52+ attributes:
53+ label: What happened
54+ description: Describe the behaviour you saw.
55+ validations:
56+ required: true
57+ - type: textarea
58+ id: expected
59+ attributes:
60+ label: What you expected
61+ description: Describe the behaviour you wanted instead.
62+ validations:
63+ required: true
64+ - type: textarea
65+ id: repro
66+ attributes:
67+ label: Steps to reproduce
68+ description: The smallest sequence that shows the bug. Include the commands you ran.
69+ value: |
70+ 1.
71+ 2.
72+ 3.
73+ validations:
74+ required: true
75+ - type: textarea
76+ id: logs
77+ attributes:
78+ label: Diagnostics / logs
79+ description: |
80+ In the desktop app, open the **Executor** menu → **Export Diagnostics…** and attach
81+ the zip — it has everything we need and no secrets.
82+
83+ Otherwise attach the relevant log:
84+ - CLI / self-host: `~/.executor/logs/` (run `executor service status` for the exact path)
85+ - macOS desktop: `~/Library/Logs/Executor/main.log`
86+ - Windows desktop: `%APPDATA%\Executor\logs\main.log`
87+ - Linux desktop: `~/.config/Executor/logs/main.log`
88+
89+ (Please don't attach anything else from `~/.executor` — that's where your data and
90+ secrets live. Remove API keys and tokens from anything you paste.)
91+ placeholder: Drag the diagnostics zip or log file here.
92+ - type: checkboxes
93+ id: checks
94+ attributes:
95+ label: Before you submit
96+ options:
97+ - label: I searched the open issues for a duplicate.
98+ required: true
99+ - label: I removed all keys, tokens, and credentials from this report.
100+ required: true
.github/ISSUE_TEMPLATE/feature_request.yml+51−0
@@ -0,0 +1,51 @@
1+name: Feature request
2+description: Suggest a capability or an improvement for Executor.
3+title: "[feature] "
4+labels: ["enhancement"]
5+body:
6+ - type: markdown
7+ attributes:
8+ value: |
9+ Tell us the task you want to do and why Executor cannot do it today.
10+ A concrete example helps more than a general proposal.
11+ - type: textarea
12+ id: problem
13+ attributes:
14+ label: The problem
15+ description: What are you trying to do, and where does Executor stop you?
16+ validations:
17+ required: true
18+ - type: textarea
19+ id: proposal
20+ attributes:
21+ label: Proposed shape
22+ description: |
23+ The change you want, as concretely as you can describe it. Name the command,
24+ the setting, or the screen where it would live.
25+ validations:
26+ required: true
27+ - type: textarea
28+ id: alternatives
29+ attributes:
30+ label: Alternatives
31+ description: Workarounds you tried, and why they are not sufficient.
32+ - type: dropdown
33+ id: surface
34+ attributes:
35+ label: Where it belongs
36+ description: Select every form of Executor this applies to.
37+ options:
38+ - Executor Cloud
39+ - CLI
40+ - Desktop app
41+ - Self-host (Docker)
42+ - Self-host (Cloudflare)
43+ - Not sure
44+ multiple: true
45+ - type: checkboxes
46+ id: checks
47+ attributes:
48+ label: Before you submit
49+ options:
50+ - label: I searched the open issues for a duplicate.
51+ required: true
.github/PULL_REQUEST_TEMPLATE.md+30−0
@@ -0,0 +1,30 @@
1+<!-- Keep this short and plain. Delete the sections that do not apply. -->
2+
3+## Summary
4+
5+<!-- What does this change do, and why? One or two sentences. -->
6+
7+## Linked issue
8+
9+Fixes #
10+
11+## Verification
12+
13+<!--
14+Say what you ran and what it showed. File paths alone are not evidence.
15+User-visible work needs a specific e2e run or a browsable dev instance, plus
16+the recording or trace that shows what to inspect. Add an e2e scenario when
17+none covers the changed behaviour.
18+-->
19+
20+- [ ] `bun run format:check`
21+- [ ] `bun run lint`
22+- [ ] `bun run typecheck`
23+- [ ] `bun run test`
24+- [ ] e2e — name the scenario, and link the recording or trace:
25+
26+## Checklist
27+
28+- [ ] Added a changeset (`bun run changeset`), or this change needs none.
29+- [ ] Added or updated tests for the new behaviour.
30+- [ ] No secrets, credentials, or private data in the diff.
apps/cloud/src/engine/execution-stack.ts+5−1
@@ -32,7 +32,7 @@ · 31 unmodified lines
3232 // seams module; the decorator is composed on top.
3333 // ---------------------------------------------------------------------------
3434
35−import { env } from "cloudflare:workers";
35+import { env, waitUntil } from "cloudflare:workers";
3636 import { Layer } from "effect";
3737
3838 import {
@@ -199,6 +199,10 @@ export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, ( · 160 unmodified lines
199199 // user-selectable provider surface.
200200 exposeCredentialProviders: false,
201201 firstPartyOAuthClients: cloudFirstPartyOAuthClients(),
202+ // Workers cancel request-scoped I/O once the response settles; the ambient
203+ // `waitUntil` binds to the in-flight invocation (HTTP request or DO call),
204+ // so stale tool-catalog rebuilds that outlive a read still converge.
205+ waitUntil,
202206 // Enterprise-managed authorization ships behind a PostHog flag. Cloud is the
203207 // one host with a flag service, so cloud is the one host that installs a
204208 // gate; everywhere else the seam stays empty and the profile is attempted as
apps/cloud/src/routes/__root.tsx+25−7
@@ -262,15 +262,33 @@ function AuthGate() { · 261 unmodified lines
262262 return urlOrgSlug ? <NotFoundPage /> : <BlankScreen />;
263263 }
264264
265+ // The authenticated answer must NAME the org the URL names before any shell
266+ // is built from it. `auth.organization` is the auth-hint cookie until
267+ // `/account/me` lands, and the hint always names the session's OWN org — so
268+ // on a foreign slug it is an answer about a different organization, and
269+ // rendering the shell from it puts the user in a workspace the URL never
270+ // named. `/account/me` is scoped by the URL's slug (getActiveOrgSlug), so
271+ // once it resolves this can only agree or be null; a disagreement is
272+ // therefore always an unresolved answer, never a verdict. Blank, not
273+ // not-found: the 404 above is the only thing entitled to declare a wrong
274+ // address, and it waits for the server.
275+ //
276+ // The legitimate cold load is untouched: the hint names the slug in the URL,
277+ // so this matches on the very first paint and the shell renders with no
278+ // round trip. Only a slug the hint does not name pays the wait — a foreign
279+ // slug (which then 404s) and the frame after an org switch (which then
280+ // renders the org the URL asked for, instead of flashing the previous one).
281+ if (pathnameOrgSlug != null && auth.organization.slug !== pathnameOrgSlug) {
282+ return <BlankScreen />;
283+ }
284+
265285 const activeSlug = auth.organization.slug;
266286 // The org context's slug feeds the connect card's `/<slug>/mcp` install URL.
267− // Prefer the URL's slug over the session's: on first paint `auth.organization`
268− // comes from the SSR auth-hint (the COOKIE's org), so a multi-org user viewing
269− // /<orgB> while their cookie still points at orgA would briefly render orgA's
270− // slug in the copyable URL before /account/me (URL-scoped) corrects it. The
271− // URL slug is the actual request scope and is correct on the very first paint,
272− // so sourcing it from there removes that flash. VALIDATED (pathnameOrgSlug,
273− // not the raw route param): the `{-$orgSlug}` param also captures reserved
287+ // Source it from the URL, which is the actual request scope and is correct on
288+ // the very first paint. The gate above has already made the two agree
289+ // whenever the URL names a slug at all, so this is the same value stated in
290+ // the terms the rest of the tree is keyed on. VALIDATED (pathnameOrgSlug, not
291+ // the raw route param): the `{-$orgSlug}` param also captures reserved
274292 // console roots ("/integrations" → orgSlug "integrations"), which are not
275293 // org scopes. Falls back to the auth org on a bare/reserved URL (which
276294 // OrgSlugGate canonicalizes onto it below).
apps/cloud/src/routes/app/billing_.plans.tsx+4−2
@@ -401,7 +401,8 @@ function EnterpriseContactDialog() { · 400 unmodified lines
401401 Contact us
402402 </Button>
403403 </DialogTrigger>
404− <DialogContent>
404+ {/* Static contact details only: clicking away dismisses it. */}
405+ <DialogContent dismissOnOutsideClick>
405406 <DialogHeader>
406407 <DialogTitle>Talk to us about Enterprise</DialogTitle>
407408 <DialogDescription>
@@ -449,7 +450,8 @@ function SlackContactCta() { · 41 unmodified lines
449450 <span aria-hidden>→</span>
450451 </Button>
451452 </DialogTrigger>
452− <DialogContent>
453+ {/* Static contact details only: clicking away dismisses it. */}
454+ <DialogContent dismissOnOutsideClick>
453455 <DialogHeader>
454456 <DialogTitle>Get in touch on Slack</DialogTitle>
455457 <DialogDescription>
apps/cloud/src/web/components/support-slot.tsx+2−1
@@ -44,7 +44,8 @@ export function SupportSlot() { · 43 unmodified lines
4444 <HelpIcon className="size-3.5 text-muted-foreground" />
4545 Get support
4646 </Button>
47− <DialogContent className="sm:max-w-[440px]">
47+ {/* Static links only: clicking away dismisses it. */}
48+ <DialogContent dismissOnOutsideClick className="sm:max-w-[440px]">
4849 <DialogHeader>
4950 <DialogTitle className="font-display text-xl">Get support</DialogTitle>
5051 <DialogDescription className="text-sm leading-relaxed">
apps/docs/hosted/docker.mdx+17−0
@@ -60,6 +60,7 @@ the container defaults. · 59 unmodified lines
6060 | `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. |
6161 | `EXECUTOR_DB_PATH` | `<data dir>/data.db` | SQLite database file. |
6262 | `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). |
63+| `EXECUTOR_TRUSTED_ORIGINS` | unset | Comma-separated browser aliases allowed to authenticate without changing the public URL. |
6364 | `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. |
6465 | `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. |
6566 | `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. |
@@ -91,6 +92,22 @@ For a headless deploy (CI or infra-as-code), set `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL · 25 unmodified lines
9192 and `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` to create the admin without the browser
9293 setup screen.
9394
95+### Additional browser aliases
96+
97+Keep `EXECUTOR_WEB_BASE_URL` pinned to the canonical public HTTPS origin used for
98+OAuth callbacks and generated links. If the same instance is intentionally
99+available through additional browser addresses, allow those exact origins
100+separately:
101+
102+```bash
103+-e EXECUTOR_WEB_BASE_URL=https://executor.example.com \
104+-e EXECUTOR_TRUSTED_ORIGINS=http://executor.home.arpa:4788,http://192.0.2.10:4788
105+```
106+
107+Only cookie-authenticated browser requests use this allowlist. OAuth callbacks,
108+MCP metadata, approval links, and other absolute URLs remain pinned to
109+`EXECUTOR_WEB_BASE_URL`. Origins are never inferred from request headers.
110+
94111 ## Connect an agent
95112
96113 The server exposes a streamable-HTTP MCP endpoint at `/mcp`. Point your client at
apps/host-selfhost/.env.example+5−0
@@ -8,6 +8,11 @@ · 7 unmodified lines
88 # rejected. Behind a reverse proxy / TLS, set this to your public https URL.
99 # EXECUTOR_WEB_BASE_URL=https://executor.example.com
1010
11+# Additional browser origins allowed to authenticate with this instance, as a
12+# comma-separated list. These aliases do not change OAuth callbacks or generated
13+# links, which remain pinned to EXECUTOR_WEB_BASE_URL. Use exact origins only.
14+# EXECUTOR_TRUSTED_ORIGINS=http://executor.home.arpa:4788,http://192.0.2.10:4788
15+
1116 # --- Session secret -----------------------------------------------------------
1217 # Generated and persisted under the data volume on first boot if unset. Set this
1318 # to manage it yourself (must be at least 32 characters). Rotating it signs every
apps/host-selfhost/src/auth/better-auth.test.ts+79−0
@@ -7,10 +7,16 @@ import { afterAll, expect, test } from "@effect/vitest"; · 6 unmodified lines
77 import { mintInviteCode } from "../testing/mint-invite";
88
99 // Real Better Auth path: set a secret + bootstrap admin before importing.
10+// Better Auth skips origin checks in test mode by default; this suite exercises
11+// the production check so the trusted-origin cases below cover the real path.
12+process.env.NODE_ENV = "production";
13+process.env.TEST = "false";
1014 process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-auth-"));
1115 process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv";
1216 process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local";
1317 process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123";
18+process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com";
19+process.env.EXECUTOR_TRUSTED_ORIGINS = "http://executor.home.arpa:4788";
1420
1521 const { makeSelfHostApiHandler } = await import("../app");
1622
@@ -19,6 +25,79 @@ afterAll(() => dispose()); · 2 unmodified lines
1925
2026 const BASE = "http://localhost:4788";
2127
28+test("an HTTP trusted alias receives a usable session cookie with an HTTPS canonical URL", async () => {
29+ const alias = "http://executor.home.arpa:4788";
30+ const signIn = await handler(
31+ new Request(`${alias}/api/auth/sign-in/email`, {
32+ method: "POST",
33+ headers: {
34+ "content-type": "application/json",
35+ origin: alias,
36+ "sec-fetch-dest": "empty",
37+ "sec-fetch-mode": "cors",
38+ "sec-fetch-site": "same-origin",
39+ },
40+ body: JSON.stringify({
41+ email: "admin@test.local",
42+ password: "admin-password-123",
43+ }),
44+ }),
45+ );
46+ expect(signIn.status).toBe(200);
47+ const sessionCookie = signIn.headers.get("set-cookie");
48+ expect(sessionCookie).toContain("better-auth.session_token=");
49+ expect(sessionCookie).not.toMatch(/(?:^|;\s*)Secure(?:;|$)/i);
50+ expect(sessionCookie).not.toContain("__Secure-");
51+});
52+
53+test("an explicitly trusted browser alias can sign up without changing the canonical base URL", async () => {
54+ const alias = "http://executor.home.arpa:4788";
55+ const inviteCode = await mintInviteCode(handler);
56+ const signUp = await handler(
57+ new Request(`${alias}/api/auth/sign-up/email`, {
58+ method: "POST",
59+ headers: {
60+ "content-type": "application/json",
61+ origin: alias,
62+ "sec-fetch-dest": "empty",
63+ "sec-fetch-mode": "cors",
64+ "sec-fetch-site": "same-origin",
65+ },
66+ body: JSON.stringify({
67+ email: "trusted-alias@test.local",
68+ password: "member-password-123",
69+ name: "Trusted Alias",
70+ inviteCode,
71+ }),
72+ }),
73+ );
74+ expect(signUp.status).toBe(200);
75+});
76+
77+test("an unlisted browser alias remains blocked", async () => {
78+ const alias = "http://untrusted.home.arpa:4788";
79+ const inviteCode = await mintInviteCode(handler);
80+ const signUp = await handler(
81+ new Request(`${alias}/api/auth/sign-up/email`, {
82+ method: "POST",
83+ headers: {
84+ "content-type": "application/json",
85+ origin: alias,
86+ "sec-fetch-dest": "empty",
87+ "sec-fetch-mode": "cors",
88+ "sec-fetch-site": "same-origin",
89+ },
90+ body: JSON.stringify({
91+ email: "untrusted-alias@test.local",
92+ password: "member-password-123",
93+ name: "Untrusted Alias",
94+ inviteCode,
95+ }),
96+ }),
97+ );
98+ expect(signUp.status).toBe(403);
99+});
100+
22101 test("migrations create both the Better Auth and FumaDB executor schema regions", async () => {
23102 // Open a SEPARATE libSQL connection to the same file Better Auth (via its own
24103 // LibsqlDialect connection) and the FumaDB drizzle client wrote to. That this
apps/host-selfhost/src/auth/better-auth.ts+33−9
@@ -25,6 +25,8 @@ interface SignupGate { · 24 unmodified lines
2525 // creation (the seed, or a future admin "add user") flows through other paths.
2626 const SIGNUP_PATH = "/sign-up/email";
2727
28+let warnedInsecureTrustedOrigin = false;
29+
2830 // ---------------------------------------------------------------------------
2931 // Better Auth instance over the SAME libSQL CONNECTION as the FumaDB executor
3032 // tables ("one connection, two schema regions").
@@ -63,6 +65,27 @@ const SIGNUP_PATH = "/sign-up/email"; · 32 unmodified lines
6365
6466 const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => {
6567 const config = loadConfig();
68+ // A `Secure` session cookie is never sent back over plain HTTP, so an HTTP
69+ // alias can sign in and then look signed out on every later request. Drop the
70+ // attribute when ANY trusted origin is HTTP. This is not a new relaxation for
71+ // the common cases: Better Auth already infers `useSecureCookies` from the
72+ // baseURL scheme, so an all-HTTPS instance still gets `true` and the plain
73+ // `http://localhost` default still gets `false`. It only changes the mixed
74+ // case an operator opts into with EXECUTOR_TRUSTED_ORIGINS.
75+ const hasInsecureTrustedOrigin = config.trustedOrigins.some((origin) =>
76+ origin.startsWith("http://"),
77+ );
78+ // Warn only for that mixed case. An HTTP-only instance (local dev, a LAN
79+ // deploy) never had Secure cookies to lose, and warning there would fire on
80+ // every default boot.
81+ const downgradesCanonicalCookies =
82+ hasInsecureTrustedOrigin && config.webBaseUrl.startsWith("https://");
83+ if (downgradesCanonicalCookies && !warnedInsecureTrustedOrigin) {
84+ warnedInsecureTrustedOrigin = true;
85+ console.warn(
86+ "[executor] EXECUTOR_TRUSTED_ORIGINS contains an http:// origin, so session cookies drop the Secure attribute for every origin — including the https:// canonical URL. Use https:// aliases to keep session cookies transport-secure.",
87+ );
88+ }
6689 // Always resolved (generated + persisted when no env is set); this guards only
6790 // an explicitly-set env secret that is too weak.
6891 const secret = config.authSecret;
@@ -88,16 +111,17 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 19 unmodified lines
88111 type: "sqlite" as const,
89112 },
90113 secret,
91− // The browser Origin must match this exactly; CLI/MCP bearer requests carry
92− // no Origin and are unaffected. `config.webBaseUrl` resolves from an explicit
93− // EXECUTOR_WEB_BASE_URL, else a platform-injected origin (Railway/Render/Fly/
94− // …), else localhost — so a PaaS deploy is zero-config and any other host
95− // sets the one variable (a loud warning fires on the localhost fallback).
96− // See config.ts. We deliberately do NOT derive this from the request `Host`:
97− // matching the ecosystem (Windmill `BASE_URL`, n8n `WEBHOOK_URL`), a pinned
98− // origin keeps host-header injection out of OAuth redirects and links.
114+ // The canonical browser Origin is config.webBaseUrl; explicitly configured
115+ // aliases may also send cookie-authenticated requests. CLI/MCP bearer
116+ // requests carry no Origin and are unaffected. We deliberately do NOT derive
117+ // either value from the request `Host`: matching the ecosystem (Windmill
118+ // `BASE_URL`, n8n `WEBHOOK_URL`), a pinned origin keeps host-header injection
119+ // out of OAuth redirects and links. Additional trusted origins affect only
120+ // Better Auth's request validation; generated links and OAuth callbacks stay
121+ // pinned to config.webBaseUrl.
99122 baseURL: config.webBaseUrl,
100− trustedOrigins: [config.webBaseUrl],
123+ trustedOrigins: [...config.trustedOrigins],
124+ advanced: { useSecureCookies: !hasInsecureTrustedOrigin },
101125 emailAndPassword: { enabled: true },
102126 // `apiKey` issues long-lived personal keys (the API-keys page). With
103127 // `enableSessionForAPIKeys`, presenting a key resolves to its owner's
apps/host-selfhost/src/auth/invalid-origin-help.test.ts+2−0
@@ -16,6 +16,7 @@ test("originOf prefers Origin, then x-forwarded-host, then host", () => { · 15 unmodified lines
1616 test("the help message names the URL to set", () => {
1717 const msg = invalidOriginHelp("https://app.example.com", "http://localhost:4788");
1818 expect(msg).toContain("EXECUTOR_WEB_BASE_URL");
19+ expect(msg).toContain("EXECUTOR_TRUSTED_ORIGINS");
1920 expect(msg).toContain("https://app.example.com");
2021 expect(msg).toContain("http://localhost:4788");
2122 });
@@ -38,6 +39,7 @@ test("rewriteInvalidOrigin replaces a 403 'Invalid origin' with the setup messag · 16 unmodified lines
3839 const body = (await rewritten!.json()) as { code: string; message: string };
3940 expect(body.code).toBe("INVALID_ORIGIN");
4041 expect(body.message).toContain("EXECUTOR_WEB_BASE_URL");
42+ expect(body.message).toContain("EXECUTOR_TRUSTED_ORIGINS");
4143 expect(body.message).toContain("https://app.example.com");
4244 });
4345
apps/host-selfhost/src/auth/invalid-origin-help.ts+4−3
@@ -21,10 +21,11 @@ export const originOf = (request: Request): string | null => { · 20 unmodified lines
2121 export const invalidOriginHelp = (requestOrigin: string | null, webBaseUrl: string): string =>
2222 requestOrigin
2323 ? `This Executor instance is configured for ${webBaseUrl}, but you're connecting from ${requestOrigin}. ` +
24− `Set the EXECUTOR_WEB_BASE_URL environment variable to ${requestOrigin} and restart the server, then try again. ` +
25− `(Railway, Render, Fly, Vercel, and similar hosts are detected automatically; set it explicitly for a custom domain or other host.)`
24+ `Set EXECUTOR_WEB_BASE_URL to ${requestOrigin} if it should be the canonical public URL, or add it to ` +
25+ `EXECUTOR_TRUSTED_ORIGINS if it is an intentional browser alias, then restart the server. ` +
26+ `(Railway, Render, Fly, Vercel, and similar hosts detect the canonical public URL automatically.)`
2627 : `This Executor instance is configured for ${webBaseUrl}. If you're reaching it at a different address, ` +
27− `set EXECUTOR_WEB_BASE_URL to that address and restart the server.`;
28+ `set EXECUTOR_WEB_BASE_URL to that canonical address or add the alias to EXECUTOR_TRUSTED_ORIGINS, then restart the server.`;
2829
2930 /**
3031 * If `response` is Better Auth's 403 "Invalid origin", return a friendlier copy
apps/host-selfhost/src/auth/origin-resolution.test.ts+53−1
@@ -20,6 +20,7 @@ process.env.EXECUTOR_DATA_DIR ??= mkdtempSync(join(tmpdir(), "origin-cfg-")); · 19 unmodified lines
2020 // try/finally — these are the only env vars these cases read).
2121 const PLATFORM_VARS = [
2222 "EXECUTOR_WEB_BASE_URL",
23+ "EXECUTOR_TRUSTED_ORIGINS",
2324 "RAILWAY_PUBLIC_DOMAIN",
2425 "RENDER_EXTERNAL_URL",
2526 "RENDER_EXTERNAL_HOSTNAME",
@@ -33,7 +34,9 @@ const resetOriginEnv = (): void => { · 7 unmodified lines
3334
3435 test("webBaseUrl falls back to localhost with no public origin", () => {
3536 resetOriginEnv();
36− expect(loadConfig().webBaseUrl).toBe("http://localhost:4788");
37+ const config = loadConfig();
38+ expect(config.webBaseUrl).toBe("http://localhost:4788");
39+ expect(config.trustedOrigins).toEqual(["http://localhost:4788"]);
3740 });
3841
3942 test("webBaseUrl auto-resolves from a platform host var (Railway, host only → https)", () => {
@@ -60,3 +63,52 @@ test("an explicit EXECUTOR_WEB_BASE_URL always wins over a platform var", () => · 20 unmodified lines
6063 process.env.EXECUTOR_WEB_BASE_URL = "https://pinned.example.com";
6164 expect(loadConfig().webBaseUrl).toBe("https://pinned.example.com");
6265 });
66+
67+test("additional trusted origins are trimmed, normalized, and deduplicated", () => {
68+ resetOriginEnv();
69+ process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com";
70+ process.env.EXECUTOR_TRUSTED_ORIGINS =
71+ " http://executor.home.arpa:4788/, https://executor.example.com, http://192.0.2.10:4788 ";
72+ expect(loadConfig().trustedOrigins).toEqual([
73+ "https://executor.example.com",
74+ "http://executor.home.arpa:4788",
75+ "http://192.0.2.10:4788",
76+ ]);
77+});
78+
79+test("an empty or blank trusted-origins list leaves the canonical origin alone", () => {
80+ resetOriginEnv();
81+ process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com";
82+ process.env.EXECUTOR_TRUSTED_ORIGINS = " , , ";
83+ expect(loadConfig().trustedOrigins).toEqual(["https://executor.example.com"]);
84+});
85+
86+// Every rejected shape is one an operator could plausibly type and then believe
87+// was in force. A wildcard host is the dangerous one: accepting it as a literal
88+// hostname would silently allow nothing while reading like it allows a whole
89+// domain. A path/query/fragment reads like a scoped grant that origins cannot
90+// express, and credentials in the URL are almost always a copy-paste mistake.
91+test.each([
92+ "https://executor.example.com/login",
93+ "https://executor.example.com/?next=/",
94+ "https://executor.example.com/#top",
95+ "https://user:pass@executor.example.com",
96+ "https://*.example.com",
97+ "file:///tmp/executor",
98+ "ftp://executor.example.com",
99+])("a trusted origin that is not an exact http(s) origin (%s) refuses to boot", (raw) => {
100+ resetOriginEnv();
101+ process.env.EXECUTOR_TRUSTED_ORIGINS = raw;
102+ expect(() => loadConfig()).toThrow(/exact http\(s\) origin/);
103+});
104+
105+// A bare hostname is the most common typo, and it never parses as a URL at all,
106+// so it gets the other message. Both name the variable.
107+test.each(["executor.example.com", "//executor.example.com", "not a url"])(
108+ "a trusted origin that is not a URL (%s) refuses to boot",
109+ (raw) => {
110+ resetOriginEnv();
111+ process.env.EXECUTOR_TRUSTED_ORIGINS = raw;
112+ expect(() => loadConfig()).toThrow(/EXECUTOR_TRUSTED_ORIGINS/);
113+ },
114+);
apps/host-selfhost/src/config.ts+56−1
@@ -26,6 +26,8 @@ export interface SelfHostConfig { · 25 unmodified lines
2626 readonly dbPath: string;
2727 /** Public base URL used by core tools that build absolute links. */
2828 readonly webBaseUrl: string;
29+ /** Browser origins allowed to send cookie-authenticated requests. */
30+ readonly trustedOrigins: readonly string[];
2931 /**
3032 * Whether sandboxed code may reach loopback/private network addresses.
3133 * Defaults to false — adversarial LLM code should not hit the host's
@@ -155,11 +157,13 @@ const resolveWebBaseUrl = (port: number): string => { · 123 unmodified lines
155157 export const loadConfig = (): SelfHostConfig => {
156158 const port = Number.parseInt(process.env.PORT ?? "4788", 10);
157159 const dataDir = resolveDataDir();
160+ const webBaseUrl = resolveWebBaseUrl(port);
158161 return {
159162 host: process.env.EXECUTOR_HOST ?? "127.0.0.1",
160163 port,
161164 dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"),
162− webBaseUrl: resolveWebBaseUrl(port),
165+ webBaseUrl,
166+ trustedOrigins: resolveTrustedOrigins(webBaseUrl),
163167 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
164168 authSecret: resolveAuthSecret(),
165169 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,
@@ -206,6 +210,57 @@ const resolveMcpSessionIdleTtlMs = (): number | undefined => { · 40 unmodified lines
206210 return Math.floor(parsed);
207211 };
208212
213+// EXECUTOR_TRUSTED_ORIGINS — extra browser origins allowed to send
214+// cookie-authenticated requests when one instance is deliberately reachable
215+// under more than one address (a LAN IP as well as a domain, say).
216+//
217+// This list widens ONLY Better Auth's origin/CSRF check. `webBaseUrl` stays the
218+// single canonical origin for OAuth callbacks, MCP metadata, and every other
219+// generated link, so an alias can never redirect a callback somewhere else.
220+//
221+// Entries must be exact origins. A path, query, fragment, credential, wildcard
222+// host, or non-http(s) scheme is refused rather than trimmed off: an operator
223+// who writes `https://*.example.com` means a pattern, and silently accepting it
224+// as the literal host would leave them believing a wildcard is in force. Like
225+// the other knobs here, a malformed value refuses to boot instead of quietly
226+// leaving the browser locked out with an "Invalid origin" page.
227+const normalizeTrustedOrigin = (value: string): string => {
228+ if (!URL.canParse(value)) {
229+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob
230+ throw new Error(
231+ `EXECUTOR_TRUSTED_ORIGINS contains ${JSON.stringify(value)}, which is not a valid URL origin`,
232+ );
233+ }
234+ const url = new URL(value);
235+ if (
236+ (url.protocol !== "http:" && url.protocol !== "https:") ||
237+ url.username.length > 0 ||
238+ url.password.length > 0 ||
239+ url.hostname.includes("*") ||
240+ (url.pathname !== "" && url.pathname !== "/") ||
241+ url.search.length > 0 ||
242+ url.hash.length > 0
243+ ) {
244+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob
245+ throw new Error(
246+ `EXECUTOR_TRUSTED_ORIGINS entry ${JSON.stringify(value)} must be an exact http(s) origin (scheme, host, and optional port only)`,
247+ );
248+ }
249+ return url.origin;
250+};
251+
252+// The canonical origin always leads the list, so the unset case reproduces the
253+// previous `[webBaseUrl]` exactly and an operator who repeats it in the env var
254+// does not get a duplicate.
255+const resolveTrustedOrigins = (webBaseUrl: string): readonly string[] => {
256+ const additional = (process.env.EXECUTOR_TRUSTED_ORIGINS ?? "")
257+ .split(",")
258+ .map((value) => value.trim())
259+ .filter((value) => value.length > 0)
260+ .map(normalizeTrustedOrigin);
261+ return [...new Set([webBaseUrl, ...additional])];
262+};
263+
209264 // The org slug doubles as a URL segment (`/<slug>/policies`), so an
210265 // operator-set value must fit the shared grammar and avoid reserved root
211266 // segments (api, mcp, login, …) — a colliding slug would shadow real routes.
apps/host-selfhost/src/db/data-migrations.ts+10−1
@@ -5,7 +5,11 @@ · 4 unmodified lines
55 // renamed.
66 // ---------------------------------------------------------------------------
77
8−import { sqliteDataMigration, type SqliteDataMigration } from "@executor-js/sdk";
8+import {
9+ bigintStorageClassSqliteMigration,
10+ sqliteDataMigration,
11+ type SqliteDataMigration,
12+} from "@executor-js/sdk";
913 import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";
1014 import {
1115 openApiNdjsonOutputDataMigration,
@@ -20,6 +24,11 @@ import { encryptedSecretsRepartitionDataMigration } from "@executor-js/plugin-en · 8 unmodified lines
2024 import { authConfigTransforms } from "./auth-config-migration";
2125
2226 export const selfHostDataMigrations: readonly SqliteDataMigration[] = [
27+ // FIRST, because it un-bricks reads every later migration and the whole app
28+ // depend on: `bigint` columns an older build left in SQLite's INTEGER storage
29+ // class cannot be read by the bigint row mapper, so a single legacy
30+ // `connection.expires_at` failed every catalog read (issue #1771).
31+ bigintStorageClassSqliteMigration,
2332 // Rewrite pre-canonical integration auth configs into the shared
2433 // placements model.
2534 sqliteDataMigration("2026-06-05-auth-config-placements", (client) =>
apps/local/src/db/data-migrations.ts+6−0
@@ -7,6 +7,7 @@ · 6 unmodified lines
77
88 import {
99 Effect,
10+ bigintStorageClassSqliteMigration,
1011 oauthClientGcSqliteMigration,
1112 sqliteDataMigration,
1213 type SqliteDataMigration,
@@ -30,6 +31,11 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [ · 17 unmodified lines
3031 // stamped atomically inside the staged v2 build; fresh/pre-v2-native DBs get
3132 // the same stamp here so future boots skip legacy shape probing.
3233 { name: LOCAL_V1_V2_LEDGER_NAME, run: () => Effect.void },
34+ // FIRST, because it un-bricks reads every later migration and the whole app
35+ // depend on: `bigint` columns an older build left in SQLite's INTEGER storage
36+ // class cannot be read by the bigint row mapper, so a single legacy
37+ // `connection.expires_at` failed every catalog read (issue #1771).
38+ bigintStorageClassSqliteMigration,
3339 // Rewrite pre-canonical integration auth configs (incl. v1→v2 outputs)
3440 // into the shared placements model.
3541 sqliteDataMigration("2026-06-05-auth-config-placements", (client) =>
apps/local/src/db/legacy-bigint-boot.test.ts+120−0
@@ -0,0 +1,120 @@
1+// ---------------------------------------------------------------------------
2+// Boot-level proof for issue #1771: a local database holding a legacy INTEGER
3+// `connection.expires_at` is unreadable, and the local boot sequence heals it.
4+//
5+// The migration body is unit-tested in the SDK. What this pins is the WIRING —
6+// that `localDataMigrations` actually carries the entry, early enough that the
7+// catalog reads which follow the ledger run see repaired rows. That wiring is
8+// the part that recovers a user's install; a correct migration nobody runs
9+// would leave the gateway just as empty.
10+// ---------------------------------------------------------------------------
11+
12+import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
13+import { mkdtempSync, rmSync } from "node:fs";
14+import { tmpdir } from "node:os";
15+import { join } from "node:path";
16+import { Effect } from "effect";
17+import { withQueryContext } from "@executor-js/fumadb/query";
18+
19+import { collectTables } from "@executor-js/api/server";
20+import { runSqliteDataMigrations } from "@executor-js/sdk";
21+
22+import { localDataMigrations } from "./data-migrations";
23+import { createSqliteFumaDb } from "./sqlite-fumadb";
24+
25+const TENANT = "executor-workspace-1771";
26+const SUBJECT = "local";
27+// Epoch millis, the shape an OAuth token expiry takes.
28+const LEGACY_EXPIRES_AT = 1787321623456;
29+
30+let workDir: string;
31+
32+beforeEach(() => {
33+ workDir = mkdtempSync(join(tmpdir(), "executor-legacy-bigint-"));
34+});
35+
36+afterEach(() => {
37+ rmSync(workDir, { recursive: true, force: true });
38+});
39+
40+const openDb = (dbPath: string) =>
41+ createSqliteFumaDb({
42+ tables: collectTables(),
43+ namespace: "executor_local",
44+ path: dbPath,
45+ });
46+
47+/** Write the row a pre-`bigint` build left behind: `expires_at` as a bare
48+ * integer literal, which SQLite keeps in the INTEGER storage class. */
49+const seedLegacyConnection = async (dbPath: string): Promise<void> => {
50+ const sqlite = await openDb(dbPath);
51+ await sqlite.client.execute({
52+ sql: `INSERT INTO connection
53+ (row_id, tenant, owner, subject, integration, name, template, provider, item_ids,
54+ expires_at, created_at, updated_at)
55+ VALUES ('c1', ?, 'user', ?, 'acme', 'default', 'oauth2', 'file', ?,
56+ ${LEGACY_EXPIRES_AT}, ?, ?)`,
57+ args: [
58+ TENANT,
59+ SUBJECT,
60+ JSON.stringify({ token: "item_1" }),
61+ Math.floor(Date.now() / 1000),
62+ Math.floor(Date.now() / 1000),
63+ ],
64+ });
65+ await sqlite.close();
66+};
67+
68+describe("local boot over a legacy bigint database", () => {
69+ it("cannot read the connection table before the migrations run", async () => {
70+ const dbPath = join(workDir, "data.db");
71+ await seedLegacyConnection(dbPath);
72+
73+ const sqlite = await openDb(dbPath);
74+ const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT });
75+ // The reported symptom: not a wrong value, a throw — so the gateway lost
76+ // every saved integration at once.
77+ await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/);
78+ await sqlite.close();
79+ });
80+
81+ it("heals it through the local data-migration registry", async () => {
82+ const dbPath = join(workDir, "data.db");
83+ await seedLegacyConnection(dbPath);
84+
85+ const sqlite = await openDb(dbPath);
86+ const applied = await Effect.runPromise(
87+ runSqliteDataMigrations(sqlite.client, localDataMigrations),
88+ );
89+ expect(applied).toContain("2026-08-28-bigint-storage-class");
90+
91+ const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT });
92+ const rows = await scoped.findMany("connection", {});
93+ expect(rows.map((row) => [row.name, Number(row.expires_at)])).toEqual([
94+ ["default", LEGACY_EXPIRES_AT],
95+ ]);
96+ await sqlite.close();
97+ });
98+
99+ it("stays readable across a reboot", async () => {
100+ const dbPath = join(workDir, "data.db");
101+ await seedLegacyConnection(dbPath);
102+
103+ const first = await openDb(dbPath);
104+ await Effect.runPromise(runSqliteDataMigrations(first.client, localDataMigrations));
105+ await first.close();
106+
107+ // Second boot: the entry is stamped, so it is skipped — the data must
108+ // already be in the shape the mapper reads.
109+ const second = await openDb(dbPath);
110+ const applied = await Effect.runPromise(
111+ runSqliteDataMigrations(second.client, localDataMigrations),
112+ );
113+ expect(applied).not.toContain("2026-08-28-bigint-storage-class");
114+
115+ const scoped = withQueryContext(second.db, { tenant: TENANT, subject: SUBJECT });
116+ const rows = await scoped.findMany("connection", {});
117+ expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]);
118+ await second.close();
119+ });
120+});
apps/local/vite.config.ts+35−1
@@ -1,5 +1,6 @@
11 import { readFileSync } from "node:fs";
2−import { resolve } from "node:path";
2+import { createRequire } from "node:module";
3+import { dirname, resolve } from "node:path";
34 import { Readable } from "node:stream";
45 import { fileURLToPath } from "node:url";
56 import { defineConfig, type Plugin } from "vite";
@@ -42,6 +43,19 @@ const EXECUTOR_GITHUB_URL = ( · 36 unmodified lines
4243 const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
4344 const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url));
4445
46+// js-yaml reaches the browser through @executor-js/plugin-openapi's UI, so it
47+// belongs in the pre-bundle below. Vite resolves `optimizeDeps.include` from
48+// `root`, which here is packages/app, and bun's isolated install keeps js-yaml
49+// in the openapi plugin's own node_modules — out of reach of both a plain
50+// "js-yaml" specifier and the "<pkg> > <dep>" form from that directory. Point
51+// the bare specifier at the package the plugin already resolves, so `root` can
52+// name it. It is the same copy either way, so nothing about resolution changes.
53+const JS_YAML_DIR = dirname(
54+ createRequire(fileURLToPath(new URL("../../packages/plugins/openapi/", import.meta.url))).resolve(
55+ "js-yaml",
56+ ),
57+);
58+
4559 const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response =>
4660 new Response(
4761 JSON.stringify(
@@ -215,6 +229,25 @@ export default defineConfig({ · 167 unmodified lines
215229 outDir: resolve(import.meta.dirname, "dist"),
216230 emptyOutDir: true,
217231 },
232+ // Deps vite only discovers once a lazy-loaded React chunk actually renders
233+ // (e.g. opening the integration Edit sheet, or the MCP/OpenAPI add-source
234+ // flow). Discovering one mid-run forces a re-optimize and a full page
235+ // reload, which throws away whatever the person was doing — an open sheet
236+ // and its form state included. Pre-bundle them at boot so vite never
237+ // discovers them mid-run. Keep in sync with apps/host-selfhost and
238+ // apps/cloud.
239+ optimizeDeps: {
240+ include: [
241+ "effect/Match",
242+ "effect/Predicate",
243+ "effect/Exit",
244+ "effect/Option",
245+ "effect/Cause",
246+ "effect/Data",
247+ "effect/Schema",
248+ "js-yaml",
249+ ],
250+ },
218251 define: {
219252 "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION),
220253 // The local app IS the npm-installed CLI, so its update card shows the npm
@@ -229,6 +262,7 @@ export default defineConfig({ · 8 unmodified lines
229262 },
230263 resolve: {
231264 tsconfigPaths: true,
265+ alias: { "js-yaml": JS_YAML_DIR },
232266 },
233267 server: {
234268 port: parseInt(process.env.PORT ?? "5173", 10),
bun.lock+1−0
@@ -999,6 +999,7 @@ · 998 unmodified lines
999999 "@modelcontextprotocol/client": "2.0.0",
10001000 "@modelcontextprotocol/core": "2.0.0",
10011001 "@modelcontextprotocol/sdk": "^1.29.0",
1002+ "lucide-react": "^1.7.0",
10021003 "zod": "4.3.6",
10031004 },
10041005 "devDependencies": {
e2e/local/stdio-mcp-edit-ui.test.ts+138−0
@@ -0,0 +1,138 @@
1+// Local-only — the integration Edit sheet for a STDIO MCP server, driven in a
2+// real browser. `local` is the only surface that enables stdio MCP
3+// (`dangerouslyAllowStdioMCP: true`), so it is the only place this sheet can be
4+// exercised end to end.
5+//
6+// The sheet used to say "Stdio MCP integrations cannot be edited. Remove and
7+// recreate the integration with the updated command." (#812) — changing a
8+// command meant editing `executor.jsonc` by hand, or losing the integration's
9+// connections and policies to a delete-and-re-add.
10+//
11+// The assertion is deliberately not "the field accepted my text". The scenario
12+// edits the DECLARED env map through the form and then reads the tool catalog:
13+// the fixture advertises `saw_declared_env` only when that variable is present
14+// in the spawned child's environment, so the tool appearing proves the edited
15+// config travelled the whole way — form, config write, respawn, rediscovery.
16+import { fileURLToPath } from "node:url";
17+
18+import { expect } from "@effect/vitest";
19+import { Effect } from "effect";
20+import { HttpApiClient } from "effect/unstable/httpapi";
21+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
22+import { composePluginApi } from "@executor-js/api/server";
23+import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
24+
25+import { scenario } from "../src/scenario";
26+import { Browser, Cli, RunDir, Target } from "../src/services";
27+import { withLocalServer } from "./local-server";
28+
29+const api = composePluginApi([mcpHttpPlugin()] as const);
30+
31+const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url));
32+
33+const SECRET = "s3cr3t-typed-into-the-edit-sheet";
34+
35+scenario(
36+ "Local · a stdio MCP server's command and environment are editable from the integration Edit sheet",
37+ { timeout: 300_000 },
38+ Effect.gen(function* () {
39+ const cli = yield* Cli;
40+ const browser = yield* Browser;
41+ const target = yield* Target;
42+ const runDir = yield* RunDir;
43+ const identity = yield* target.newIdentity();
44+
45+ yield* withLocalServer(cli, runDir, (server) =>
46+ Effect.gen(function* () {
47+ const client = yield* HttpApiClient.make(api, {
48+ baseUrl: new URL("/api", server.origin).toString(),
49+ transformClient: HttpClient.mapRequest((request) =>
50+ HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
51+ ),
52+ }).pipe(Effect.provide(FetchHttpClient.layer));
53+
54+ const slug = "e2e-stdio-editable";
55+
56+ // A plain stdio server: no declared env, so the env-gated tool is absent
57+ // until the edit adds the variable.
58+ yield* client.mcp.addServer({
59+ payload: {
60+ transport: "stdio",
61+ name: "E2E Stdio Editable",
62+ command: "node",
63+ args: [FIXTURE],
64+ slug,
65+ },
66+ });
67+
68+ const before = yield* client.tools.list({ query: { integration: slug } });
69+ expect(
70+ before.map((t) => t.name),
71+ "the server starts with its base tool and no declared env",
72+ ).toContain("echo_tool");
73+ expect(
74+ before.map((t) => t.name),
75+ "the env-gated tool is absent before the edit",
76+ ).not.toContain("saw_declared_env");
77+
78+ yield* browser.session(identity, async ({ page, step }) => {
79+ await step("Open the stdio integration from the console", async () => {
80+ await page.goto(server.url, { waitUntil: "domcontentloaded" });
81+ await page.getByTestId(`integration-entry-${slug}`).first().click();
82+ // Wait for the detail page's own data, not just its shell: clicking
83+ // Edit while the accounts panel is still a skeleton races the layout
84+ // shift that lands when it resolves.
85+ await page.getByText("default").first().waitFor({ timeout: 30_000 });
86+ await page.getByRole("button", { name: "Edit" }).waitFor({ timeout: 30_000 });
87+ });
88+
89+ await step("Open the Edit sheet — the stdio command is editable", async () => {
90+ await page.getByRole("button", { name: "Edit" }).click();
91+ await page.getByText("Edit integration").waitFor({ timeout: 30_000 });
92+ await page.getByText("Server command").waitFor({ timeout: 30_000 });
93+ // The read-only dead end this replaces.
94+ expect(
95+ await page.getByText("Stdio MCP integrations cannot be edited").count(),
96+ "the read-only message is gone",
97+ ).toBe(0);
98+ expect(
99+ await page.getByRole("textbox", { name: "Command" }).inputValue(),
100+ "the stored command is loaded into the form",
101+ ).toBe("node");
102+ });
103+
104+ await step("Declare an environment variable and save", async () => {
105+ await page
106+ .getByRole("textbox", { name: "Environment variables" })
107+ .fill(`EXECUTOR_E2E_SECRET=${SECRET}`);
108+ await page.getByRole("button", { name: "Save" }).click();
109+ await page.getByText("Server command").waitFor({ state: "hidden", timeout: 30_000 });
110+ });
111+ });
112+
113+ // The edit persisted as the DECLARED static env map on the config.
114+ const stored = yield* client.mcp.getServer({ params: { slug } });
115+ expect(
116+ stored?.config.transport === "stdio" ? stored.config.env : undefined,
117+ "the sheet wrote the declared env map",
118+ ).toEqual({ EXECUTOR_E2E_SECRET: SECRET });
119+ expect(
120+ stored?.config.transport === "stdio" ? stored.config.command : undefined,
121+ "the command it did not touch is unchanged",
122+ ).toBe("node");
123+
124+ // And it reached the spawned server: the fixture gates this tool's very
125+ // existence on that variable being in its own environment.
126+ const after = yield* client.tools.list({ query: { integration: slug } });
127+ expect(
128+ after.map((t) => t.name),
129+ "the edited environment reached the respawned server and the catalog was rebuilt",
130+ ).toContain("saw_declared_env");
131+ expect(
132+ after.map((t) => t.name),
133+ "the rebuild kept the tools that still exist",
134+ ).toContain("echo_tool");
135+ }),
136+ );
137+ }),
138+);
e2e/local/stdio-mcp.test.ts+42−0
@@ -222,6 +222,48 @@ scenario( · 221 unmodified lines
222222 autoTools.map((t) => t.name),
223223 "auto negotiation falls back to legacy and still discovers tools",
224224 ).toContain("echo_tool");
225+ expect(
226+ autoTools.map((t) => t.name),
227+ "this server declared no env, so the env-gated tool is absent to begin with",
228+ ).not.toContain("saw_declared_env");
229+
230+ // --- Editing a stdio server's config (what the integration Edit sheet
231+ // now does instead of telling you to remove and recreate). The tool
232+ // catalog is persisted per connection, so a plain config replace is
233+ // only enough because core stamps `config_revised_at` on a config
234+ // write and every connection whose catalog predates that stamp is
235+ // rebuilt on the next read. The edit path therefore needs NO explicit
236+ // refresh of its own — this asserts that, so nobody adds one back.
237+ //
238+ // Adding a declared env var is the lever: the fixture advertises
239+ // `saw_declared_env` only when that variable reached the child, so the
240+ // tool appearing with no further action proves both halves — the new
241+ // config reached the spawn, and the catalog was rebuilt from it. ---
242+ const editedConfig = {
243+ ...autoStored!.config,
244+ env: { EXECUTOR_E2E_SECRET: SECRET },
245+ } as typeof autoStored.config;
246+
247+ yield* client.mcp.configureServer({
248+ params: { slug: autoSlug },
249+ payload: { config: editedConfig },
250+ });
251+
252+ const editedStored = yield* client.mcp.getServer({ params: { slug: autoSlug } });
253+ expect(
254+ JSON.stringify(editedStored?.config ?? {}),
255+ "the edit persisted, and left the untouched negotiation mode alone",
256+ ).toContain('"versionNegotiation":"auto"');
257+
258+ const editedTools = yield* client.tools.list({ query: { integration: autoSlug } });
259+ expect(
260+ editedTools.map((t) => t.name),
261+ "the edited config reached the spawn and the catalog was rediscovered",
262+ ).toContain("saw_declared_env");
263+ expect(
264+ editedTools.map((t) => t.name),
265+ "rediscovery replaced the catalog rather than dropping what still exists",
266+ ).toContain("echo_tool");
225267 }),
226268 { env: DAEMON_ENV },
227269 );
e2e/scenarios/graphql-introspection-health.test.ts+62−0
@@ -8,6 +8,11 @@ import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; · 7 unmodified lines
88 import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
99 import { variable } from "@executor-js/sdk/http-auth";
1010
11+import {
12+ makeGreetingGraphqlSchema,
13+ serveGraphqlTestServer,
14+} from "@executor-js/plugin-graphql/testing";
15+
1116 import { scenario } from "../src/scenario";
1217 import { Api, Browser, Target } from "../src/services";
1318 import { visit } from "../src/surfaces/browser";
@@ -144,3 +149,60 @@ scenario( · 130 unmodified lines
144149 }),
145150 ),
146151 );
152+
153+scenario(
154+ "GraphQL · a healthy account row keeps the connection's name",
155+ {},
156+ Effect.scoped(
157+ Effect.gen(function* () {
158+ const target = yield* Target;
159+ const browser = yield* Browser;
160+ const { client: makeApiClient } = yield* Api;
161+ const identity = yield* target.newIdentity();
162+ const client = yield* makeApiClient(api, identity);
163+ const slug = unique("graphql_label");
164+ const upstream = yield* serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() });
165+
166+ yield* client.graphql.addIntegration({
167+ payload: {
168+ endpoint: upstream.endpoint,
169+ slug,
170+ name: "Greeting API",
171+ },
172+ });
173+
174+ yield* Effect.gen(function* () {
175+ yield* client.connections.create({
176+ payload: {
177+ owner: "org",
178+ name: ConnectionName.make("workspace"),
179+ integration: IntegrationSlug.make(slug),
180+ template: AuthTemplateSlug.make("none"),
181+ value: "unused",
182+ },
183+ });
184+
185+ yield* browser.session(identity, async ({ page, step }) => {
186+ await step("The healthy account row is labeled with the connection name", async () => {
187+ await visit(page, `/integrations/${slug}`);
188+ await page.getByRole("tab", { name: "Accounts" }).click();
189+ // Wait for the automatic health probe to land so a probe identity
190+ // would have replaced the label if the plugin still reported one.
191+ await page.getByLabel("Status: Healthy").waitFor();
192+ await page.getByText("workspace", { exact: true }).waitFor();
193+ expect(
194+ await page.getByText("GraphQL schema", { exact: false }).count(),
195+ "the schema root type never masquerades as the account label",
196+ ).toBe(0);
197+ });
198+ });
199+ }).pipe(
200+ Effect.ensuring(
201+ client.integrations
202+ .remove({ params: { slug: IntegrationSlug.make(slug) } })
203+ .pipe(Effect.ignore),
204+ ),
205+ );
206+ }),
207+ ),
208+);
e2e/scenarios/integration-families.test.ts+69−0
@@ -0,0 +1,69 @@
1+import { randomBytes } from "node:crypto";
2+
3+import { expect } from "@effect/vitest";
4+import { Effect } from "effect";
5+import type { HttpApiClient } from "effect/unstable/httpapi";
6+import { composePluginApi } from "@executor-js/api/server";
7+import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
8+import { IntegrationSlug } from "@executor-js/sdk/shared";
9+
10+import { scenario } from "../src/scenario";
11+import { Api, Browser, Target } from "../src/services";
12+
13+const api = composePluginApi([mcpHttpPlugin()] as const);
14+type Client = HttpApiClient.ForApi<typeof api>;
15+
16+scenario(
17+ "Integrations · related MCP services collapse into their declared family",
18+ {},
19+ Effect.gen(function* () {
20+ const target = yield* Target;
21+ const { client: makeClient } = yield* Api;
22+ const browser = yield* Browser;
23+ const identity = yield* target.newIdentity();
24+ const client: Client = yield* makeClient(api, identity);
25+ const suffix = randomBytes(4).toString("hex");
26+ const family = `cloudflare-${suffix}`;
27+ const apiSlug = IntegrationSlug.make(`family-api-${suffix}`);
28+ const docsSlug = IntegrationSlug.make(`family-docs-${suffix}`);
29+
30+ yield* Effect.ensuring(
31+ Effect.gen(function* () {
32+ yield* client.mcp.addServer({
33+ payload: {
34+ name: "Cloudflare API",
35+ family,
36+ endpoint: "https://api.example.com/mcp",
37+ slug: String(apiSlug),
38+ },
39+ });
40+ yield* client.mcp.addServer({
41+ payload: {
42+ name: "Cloudflare Docs",
43+ family,
44+ endpoint: "https://docs.example.com/mcp",
45+ slug: String(docsSlug),
46+ },
47+ });
48+
49+ yield* browser.session(identity, async ({ page, step }) => {
50+ await step("Open the integrations catalog", async () => {
51+ await page.goto("/", { waitUntil: "networkidle" });
52+ await page.getByText("Integrations").first().waitFor();
53+ });
54+
55+ await step("The related MCP services share one family card", async () => {
56+ const group = page.getByTestId(`integration-group-${family}`);
57+ await group.waitFor();
58+ expect(await group.innerText()).toContain("Cloudflare API");
59+ expect(await group.innerText()).toContain("Cloudflare Docs");
60+ });
61+ });
62+ }),
63+ Effect.gen(function* () {
64+ yield* client.mcp.removeServer({ params: { slug: apiSlug } }).pipe(Effect.ignore);
65+ yield* client.mcp.removeServer({ params: { slug: docsSlug } }).pipe(Effect.ignore);
66+ }),
67+ );
68+ }),
69+);
e2e/scenarios/mcp-add-url-typing-probe.test.ts+48−0
@@ -0,0 +1,48 @@
1+// The add-MCP flow probes the Server URL as it is typed. Every keystroke is a
2+// prefix of the next one, so a probe on a half-typed value dials something the
3+// user never meant to submit and drops the field into a loading and then an
4+// error state. This guards that an incomplete URL is left alone and that a
5+// finished one is still probed without being submitted.
6+import { Effect } from "effect";
7+
8+import { scenario } from "../src/scenario";
9+import { Browser, Target } from "../src/services";
10+
11+const urlField = "https://mcp.example.com";
12+const retry = "Try again";
13+
14+scenario(
15+ "MCP add flow · a half-typed Server URL is not probed",
16+ {},
17+ Effect.gen(function* () {
18+ const target = yield* Target;
19+ const browser = yield* Browser;
20+ const identity = yield* target.newIdentity();
21+
22+ yield* browser.session(identity, async ({ page, step }) => {
23+ await step("Open the add-MCP flow", async () => {
24+ await page.goto("/integrations/add/mcp", { waitUntil: "networkidle" });
25+ await page.getByPlaceholder(urlField).waitFor();
26+ });
27+
28+ await step("A URL still being typed is left alone", async () => {
29+ // Well past the 400ms debounce. The field must stay editable — the
30+ // probing state replaces it with a skeleton — and report no failure.
31+ for (const partial of ["h", "http://", "http://a", "https://mcp"]) {
32+ await page.getByPlaceholder(urlField).fill(partial);
33+ await page.waitForTimeout(700);
34+ await page.getByPlaceholder(urlField).waitFor({ state: "visible" });
35+ await page.getByRole("button", { name: retry }).waitFor({ state: "hidden" });
36+ }
37+ });
38+
39+ await step("A finished URL is still probed", async () => {
40+ // `.invalid` is reserved and never resolves, so the probe fails and the
41+ // retry affordance appears. That it appears at all is the assertion:
42+ // the shape gate lets a complete URL through.
43+ await page.getByPlaceholder(urlField).fill("https://mcp.notareal.invalid/mcp");
44+ await page.getByRole("button", { name: retry }).waitFor();
45+ });
46+ });
47+ }),
48+);
e2e/scenarios/org-slug-routing.test.ts+59−13
@@ -5,9 +5,10 @@ · 4 unmodified lines
55 //
66 // - /account/me advertises the org's URL slug (valid grammar)
77 // - a bare deep link (/policies) canonicalizes to /<slug>/policies
8−// - an unknown slug (/zz-no-such-org/policies) is a wrong address — a
9−// not-found page, never a silent redirect into a workspace the URL
10−// didn't name
8+// - an unknown slug (/zz-no-such-org/policies) is a wrong address on a
9+// multi-tenant host — a not-found page with the console shell never built
10+// at any point, never a silent redirect into a workspace the URL didn't
11+// name (a single-tenant host canonicalizes it instead; both are pinned)
1112 // - in-shell navigation keeps the slug prefix on every link
1213 //
1314 // Cloud's switch-into-another-org-by-URL behavior is covered separately by
@@ -20,6 +21,14 @@ import { scenario } from "../src/scenario"; · 6 unmodified lines
2021 import { Api, Browser, Target } from "../src/services";
2122 import { visit } from "../src/surfaces/browser";
2223
24+declare global {
25+ interface Window {
26+ /** Set by this scenario's init script the first time a console shell
27+ * sidebar is attached to the document. Test-only. */
28+ __executorShellEverMounted?: boolean;
29+ }
30+}
31+
2332 scenario(
2433 "Org URLs · console paths carry the organization slug",
2534 {},
@@ -46,17 +55,54 @@ scenario( · 20 unmodified lines
4655 await page.getByText("Policies").first().waitFor();
4756 });
4857
49− // The "unknown slug is a 404" contract is multi-tenant only. Selfhost is
50− // single-tenant: /account/me always returns the instance org regardless of
51− // the URL segment, so the slug is cosmetic and an unknown one canonicalizes
52− // onto the shell rather than 404ing. Cloud enforces the not-found; selfhost
53− // legitimately does not.
54− if (!target.name.startsWith("selfhost")) {
55− await step("An unknown org slug is a wrong address, not a redirect", async () => {
56− await visit(page, "/zz-no-such-org/policies");
58+ // What an unknown slug MEANS depends on tenancy, so both answers are
59+ // pinned rather than one being skipped:
60+ //
61+ // multi-tenant (cloud) — the slug is a real request scope. /account/me
62+ // returns no organization for one this session cannot see, so the
63+ // address is simply wrong: a not-found page, the URL left exactly as
64+ // typed, and no console shell built at any point along the way.
65+ // single-tenant (selfhost) — /account/me always returns the instance
66+ // org regardless of the URL segment, so the slug is cosmetic and
67+ // OrgSlugGate canonicalizes it onto the shell instead.
68+ const singleTenant = target.name.startsWith("selfhost");
69+
70+ // Waiting for the not-found text on its own is not enough. The shell used
71+ // to render first from the auth-hint cookie — which names the session's
72+ // OWN org, never the URL's — and correct itself only once /account/me
73+ // answered, so a run that flashed an entire workspace under a foreign
74+ // address still ended on "Page not found" and passed. Record the shell
75+ // from before the page's own scripts run, so a sidebar that exists for a
76+ // single frame is still caught long after it is gone.
77+ await page.addInitScript(() => {
78+ window.__executorShellEverMounted = false;
79+ const look = () => {
80+ if (document.querySelector('aside, button[aria-label="Open navigation"]')) {
81+ window.__executorShellEverMounted = true;
82+ }
83+ };
84+ new MutationObserver(look).observe(document, { childList: true, subtree: true });
85+ look();
86+ });
87+
88+ await step("An unknown org slug resolves by tenancy, never by redirect", async () => {
89+ await visit(page, "/zz-no-such-org/policies");
90+ if (singleTenant) {
91+ await page.waitForURL((url) => url.pathname === `/${slug}/policies`, { timeout: 30_000 });
92+ } else {
5793 await page.getByText("Page not found").waitFor({ timeout: 30_000 });
58− });
59− }
94+ }
95+
96+ const landed = {
97+ pathname: new URL(page.url()).pathname,
98+ shellEverMounted: await page.evaluate(() => window.__executorShellEverMounted === true),
99+ };
100+ expect(landed, "an unknown slug never lands in a workspace the URL did not name").toEqual(
101+ singleTenant
102+ ? { pathname: `/${slug}/policies`, shellEverMounted: true }
103+ : { pathname: "/zz-no-such-org/policies", shellEverMounted: false },
104+ );
105+ });
60106
61107 await step("In-shell navigation keeps the slug prefix", async () => {
62108 await visit(page, `/${slug}`);
e2e/scenarios/policy-remove-confirm.test.ts+85−0
@@ -0,0 +1,85 @@
1+// Cross-target (browser): removing a tool-access policy asks for confirmation
2+// first. Remove fires from a row's dropdown menu and is irreversible — the
3+// rule is gone and every tool it governed silently falls back to the default
4+// policy — so the menu item must open a confirm dialog rather than firing the
5+// mutation directly. Cancel keeps the rule; confirming removes it for real
6+// (asserted through the API, not just the rendered list).
7+import { randomBytes } from "node:crypto";
8+
9+import { expect } from "@effect/vitest";
10+import { Effect } from "effect";
11+import { composePluginApi } from "@executor-js/api/server";
12+
13+import { scenario } from "../src/scenario";
14+import { Api, Browser, Target } from "../src/services";
15+import { visit } from "../src/surfaces/browser";
16+
17+const coreApi = composePluginApi([] as const);
18+
19+scenario(
20+ "Policies (UI) · Remove asks for confirmation; cancel keeps, confirm removes",
21+ {},
22+ Effect.gen(function* () {
23+ const target = yield* Target;
24+ const browser = yield* Browser;
25+ const { client: makeClient } = yield* Api;
26+ const identity = yield* target.newIdentity();
27+ const client = yield* makeClient(coreApi, identity);
28+
29+ // Selfhost scenarios share one workspace, so the pattern is unique to this
30+ // run and the row lookup below cannot match another scenario's rule.
31+ const pattern = `rmconfirm-${randomBytes(4).toString("hex")}.*`;
32+
33+ const created = yield* client.policies.create({
34+ payload: { owner: "org", pattern, action: "block" },
35+ });
36+
37+ yield* Effect.ensuring(
38+ Effect.gen(function* () {
39+ yield* browser.session(identity, async ({ page, step }) => {
40+ const row = page.locator("[data-slot='card-stack-entry']").filter({ hasText: pattern });
41+ const menuTrigger = row.locator('button[aria-haspopup="menu"]');
42+ const confirm = page.getByRole("alertdialog");
43+
44+ await step("Open the policies page", async () => {
45+ await visit(page, "/policies");
46+ await row.waitFor();
47+ });
48+
49+ await step("Remove asks for confirmation instead of firing", async () => {
50+ await menuTrigger.click();
51+ await page.getByRole("menuitem", { name: "Remove" }).click();
52+ await confirm.getByText("Remove policy?").waitFor();
53+ // The dialog names the exact rule being destroyed.
54+ await confirm.getByText(pattern, { exact: false }).waitFor();
55+ });
56+
57+ await step("Cancel keeps the policy", async () => {
58+ await confirm.getByRole("button", { name: "Cancel" }).click();
59+ await confirm.waitFor({ state: "detached" });
60+ await row.waitFor();
61+ });
62+
63+ await step("Confirming actually removes it", async () => {
64+ await menuTrigger.click();
65+ await page.getByRole("menuitem", { name: "Remove" }).click();
66+ await confirm.getByRole("button", { name: "Remove policy" }).click();
67+ await confirm.waitFor({ state: "detached" });
68+ await row.waitFor({ state: "detached" });
69+ });
70+ });
71+
72+ // Cancel left the removal un-fired and confirm fired it for real: the
73+ // rule is gone from the API, not merely from the rendered list.
74+ const remaining = yield* client.policies.list();
75+ expect(
76+ remaining.map((policy) => policy.pattern),
77+ "the removed policy is gone from the API",
78+ ).not.toContain(pattern);
79+ }),
80+ client.policies
81+ .remove({ params: { policyId: created.id }, payload: { owner: "org" } })
82+ .pipe(Effect.ignore),
83+ );
84+ }),
85+);
e2e/scenarios/provider-plugins-ui.test.ts+5−3
@@ -14,10 +14,12 @@ scenario( · 13 unmodified lines
1414 const identity = yield* target.newIdentity();
1515
1616 yield* browser.session(identity, async ({ page, step }) => {
17− await step("Open the integrations picker", async () => {
18− await visit(page, "/integrations");
17+ await step("Open the integrations picker from the sidebar", async () => {
18+ await visit(page, "/policies");
1919 await clickToReveal(
20− page.getByRole("button", { name: "Connect" }),
20+ page
21+ .getByRole("navigation")
22+ .getByRole("button", { name: "Browse integrations", exact: true }),
2123 page.getByRole("dialog", { name: "Connect an integration" }),
2224 );
2325 });
e2e/selfhost/execute-emit-envelope.test.ts+94−0
@@ -0,0 +1,94 @@
1+// Selfhost: emit() output actually reaches the MCP client's content blocks,
2+// not merely the `emitted` count in the envelope. Regression coverage for
3+// issue #1592 — a self-hosted user saw a non-zero `emitted` count but no
4+// content blocks in the tool result, only `logs` and the return value. There
5+// is cloud coverage for the envelope count
6+// (`e2e/cloud/execute-emit-envelope.test.ts`), but nothing that asserts on
7+// the wire-level `content` array on self-host, and nothing on either target
8+// that covers all three emit shapes from the report: a plain string, a
9+// ToolFile, and a structured/object value.
10+import { expect } from "@effect/vitest";
11+import { Effect } from "effect";
12+
13+import { scenario } from "../src/scenario";
14+import { Mcp, Target } from "../src/services";
15+
16+interface ContentBlock {
17+ readonly type: string;
18+ readonly text?: string;
19+}
20+
21+// Mirrors the issue's repro exactly: a plain string emit, a ToolFile emit, a
22+// structured/object emit, then a return.
23+const EXECUTE_CODE = `
24+const attachment = {
25+ _tag: "ToolFile",
26+ name: "report.txt",
27+ mimeType: "text/plain",
28+ encoding: "base64",
29+ data: "aGVsbG8gZmlsZQ==",
30+ byteLength: 10,
31+};
32+emit("plain string emit");
33+emit(attachment);
34+emit({ hello: "object emit" });
35+return { returnedValue: "this is the return" };
36+`;
37+
38+scenario(
39+ "Execute · emit() output reaches the MCP client's content blocks",
40+ {},
41+ Effect.gen(function* () {
42+ const target = yield* Target;
43+ const mcp = yield* Mcp;
44+ const identity = yield* target.newIdentity();
45+ const session = mcp.session(identity);
46+
47+ const result = yield* session.call("execute", { code: EXECUTE_CODE });
48+ expect(result.ok, `execute completed (got: ${result.text.slice(0, 300)})`).toBe(true);
49+
50+ const structured = (result.raw as { structuredContent?: Record<string, unknown> })
51+ .structuredContent;
52+ expect(structured?.status, "the run completed").toBe("completed");
53+ expect(structured?.emitted, "the envelope counts all three emitted items").toBe(3);
54+ expect(structured?.result, "the return value still comes back to the caller").toEqual({
55+ returnedValue: "this is the return",
56+ });
57+
58+ // The real assertion: the JSON-RPC tool result's `content` array — what
59+ // an MCP client actually renders — carries all three emitted items, not
60+ // just the `emitted` count.
61+ const content = (result.raw as { content?: ContentBlock[] }).content ?? [];
62+ expect(Array.isArray(content) && content.length > 0, "the wire result carries content").toBe(
63+ true,
64+ );
65+
66+ expect(
67+ content.some((block) => block.type === "text" && block.text === "plain string emit"),
68+ `content carries the plain string emit; got: ${JSON.stringify(content)}`,
69+ ).toBe(true);
70+
71+ // A text-mimetype ToolFile renders as a summary line plus its decoded
72+ // text content (see outputFileContent/toolFileContent in tool-server.ts).
73+ expect(
74+ content.some(
75+ (block) =>
76+ block.type === "text" &&
77+ typeof block.text === "string" &&
78+ block.text.includes("report.txt"),
79+ ),
80+ `content carries the ToolFile emit's summary line; got: ${JSON.stringify(content)}`,
81+ ).toBe(true);
82+ expect(
83+ content.some((block) => block.type === "text" && block.text === "hello file"),
84+ `content carries the ToolFile emit's decoded text; got: ${JSON.stringify(content)}`,
85+ ).toBe(true);
86+
87+ // A plain object emit is not a ToolFile or an MCP content block, so it
88+ // falls back to a JSON text block.
89+ expect(
90+ content.some((block) => block.type === "text" && block.text === '{"hello":"object emit"}'),
91+ `content carries the structured/object emit; got: ${JSON.stringify(content)}`,
92+ ).toBe(true);
93+ }),
94+);
e2e/selfhost/mcp-request-headers-add.test.ts+133−0
@@ -0,0 +1,133 @@
1+// Regression guard for adding a remote MCP server that sits behind an edge
2+// authenticator. Cloudflare Access answers an unauthenticated request with a
3+// `403` HTML sign-in page, so the MCP server itself is never reached: no
4+// Bearer challenge, no RFC 9728 metadata, no JSON-RPC body. That used to read
5+// as "Couldn't reach this URL", which is wrong and offered no way forward.
6+//
7+// Now the 403 classifies as auth-required, and the add flow carries a request
8+// headers editor whose values ride along on the connection check. Both halves
9+// are asserted here: the flow reaches the auth editor, and the service-token
10+// headers actually reach the server.
11+//
12+// Selfhost-only because the probe must reach a loopback server: the selfhost
13+// instance runs with EXECUTOR_ALLOW_LOCAL_NETWORK. Video is the artifact.
14+import { randomBytes } from "node:crypto";
15+
16+import { expect } from "@effect/vitest";
17+import { Effect, Ref } from "effect";
18+import { HttpServerResponse } from "effect/unstable/http";
19+import { composePluginApi } from "@executor-js/api/server";
20+import { deriveMcpNamespace } from "@executor-js/plugin-mcp";
21+import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
22+import { IntegrationSlug } from "@executor-js/sdk/shared";
23+import { serveTestHttpApp } from "@executor-js/sdk/testing";
24+
25+import { scenario } from "../src/scenario";
26+import { Api, Browser, Target } from "../src/services";
27+import { visit } from "../src/surfaces/browser";
28+
29+const api = composePluginApi([mcpHttpPlugin()] as const);
30+
31+const CLIENT_ID_HEADER = "CF-Access-Client-Id";
32+const CLIENT_SECRET_HEADER = "CF-Access-Client-Secret";
33+const CLIENT_ID = "e2e-service-token-id";
34+const CLIENT_SECRET = "e2e-service-token-secret";
35+
36+scenario(
37+ "MCP headers · a 403 edge gate is addable with service-token headers",
38+ {},
39+ Effect.scoped(
40+ Effect.gen(function* () {
41+ const target = yield* Target;
42+ const browser = yield* Browser;
43+ const { client: makeApiClient } = yield* Api;
44+
45+ // Cloudflare Access shape: every request is answered with a 403 HTML
46+ // sign-in page. The headers each request carried are recorded so the
47+ // scenario can prove the connection check sent the configured pair.
48+ const seen = yield* Ref.make<readonly Readonly<Record<string, string>>[]>([]);
49+ const server = yield* serveTestHttpApp((request) =>
50+ Effect.gen(function* () {
51+ yield* Ref.update(seen, (all) => [...all, request.headers]);
52+ if ((request.url ?? "").includes("/.well-known/")) {
53+ return HttpServerResponse.text("missing", { status: 404 });
54+ }
55+ return HttpServerResponse.text("<html><body>Sign in</body></html>", {
56+ status: 403,
57+ contentType: "text/html",
58+ });
59+ }),
60+ );
61+
62+ const endpoint = server.url("/mcp");
63+ // The gate reports no server name, so the probe cannot seed a unique
64+ // identity. Selfhost identities share one tenant, so name the
65+ // integration uniquely to keep the derived slug stable across runs.
66+ const name = `edge-gated-403-${randomBytes(3).toString("hex")}`;
67+ const slug = IntegrationSlug.make(deriveMcpNamespace({ name }));
68+ const identity = yield* target.newIdentity();
69+ const client = yield* makeApiClient(api, identity);
70+
71+ yield* Effect.gen(function* () {
72+ yield* browser.session(identity, async ({ page, step }) => {
73+ await step("Open the add-MCP flow pointed at the 403-gated server", async () => {
74+ await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(endpoint)}`);
75+ // Before the fix the 403 read as unreachable and the flow stopped
76+ // on "Couldn't reach this URL". Now it continues.
77+ await page.getByText("How does this server authenticate?").waitFor();
78+ await page.getByText("Auth required").first().waitFor();
79+ });
80+
81+ await step("Configure the Cloudflare Access service-token headers", async () => {
82+ await page.getByRole("button", { name: "Add header" }).click();
83+ await page.getByLabel("Header name").nth(0).fill(CLIENT_ID_HEADER);
84+ await page.getByLabel("Header value").nth(0).fill(CLIENT_ID);
85+ await page.getByRole("button", { name: "Add header" }).click();
86+ await page.getByLabel("Header name").nth(1).fill(CLIENT_SECRET_HEADER);
87+ await page.getByLabel("Header value").nth(1).fill(CLIENT_SECRET);
88+ });
89+
90+ await step("Test connection sends the headers to the server", async () => {
91+ await page.getByRole("button", { name: "Test connection" }).click();
92+ // The button reports the in-flight probe with `data-loading`.
93+ // Wait for it to clear so the re-probe has landed before we add.
94+ await page.locator("button[data-loading]").waitFor({ state: "detached" });
95+ await page.getByText("Auth required").first().waitFor();
96+ });
97+
98+ await step("Add the integration", async () => {
99+ await page.getByPlaceholder("e.g. Linear").fill(name);
100+ await page.getByRole("button", { name: "Add integration" }).click();
101+ await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 });
102+ const landedSlug = new URL(page.url()).pathname.split("/").filter(Boolean).at(-1);
103+ expect(landedSlug, "the add flow lands on the created integration").toBe(String(slug));
104+ });
105+ });
106+
107+ const requests = yield* Ref.get(seen);
108+ const authenticated = requests.filter(
109+ (headers) => headers[CLIENT_ID_HEADER.toLowerCase()] === CLIENT_ID,
110+ );
111+ expect(
112+ authenticated.length,
113+ "the connection check reaches the server with the configured headers",
114+ ).toBeGreaterThan(0);
115+ expect(
116+ authenticated.some(
117+ (headers) => headers[CLIENT_SECRET_HEADER.toLowerCase()] === CLIENT_SECRET,
118+ ),
119+ "both halves of the service token are sent together",
120+ ).toBe(true);
121+
122+ const stored = yield* client.mcp.getServer({ params: { slug } });
123+ expect(stored?.config, "the headers persist on the integration").toMatchObject({
124+ transport: "remote",
125+ headers: {
126+ [CLIENT_ID_HEADER]: CLIENT_ID,
127+ [CLIENT_SECRET_HEADER]: CLIENT_SECRET,
128+ },
129+ });
130+ }).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore)));
131+ }),
132+ ),
133+);
packages/core/api/src/index.ts+3−0
@@ -3,7 +3,10 @@ export { · 2 unmodified lines
33 checkForUpdate,
44 resolveDistTags,
55 resolveUpdateChannel,
6+ resolveComparisonChannel,
67 compareVersions,
8+ isUnstampedVersion,
9+ isUpdateAvailable,
710 EXECUTOR_PACKAGE_NAME,
811 type UpdateStatus,
912 type UpdateChannel,
packages/core/api/src/server/scoped-executor.ts+10−0
@@ -123,6 +123,15 @@ export interface HostConfigShape { · 122 unmodified lines
123123 * operator knob.
124124 */
125125 readonly toolsSyncTtlMs?: number | null;
126+ /**
127+ * Forwarded verbatim to `ExecutorConfig.waitUntil`: the host's keep-alive
128+ * for background work that outlives a request (stale tool-catalog rebuilds
129+ * that keep running after a read stops waiting). Cloud supplies the
130+ * platform `waitUntil` from `cloudflare:workers`, which binds to the
131+ * in-flight invocation ambiently; long-lived hosts (self-host, local,
132+ * tests) omit it and detached fibers simply run to completion in-process.
133+ */
134+ readonly waitUntil?: (promise: Promise<unknown>) => void;
126135 }
127136
128137 export class HostConfig extends Context.Service<HostConfig, HostConfigShape>()(
@@ -305,6 +314,7 @@ export const makeScopedExecutor = < · 176 unmodified lines
305314 fetch: hostedFetch,
306315 onIntegrationChange: config.onIntegrationChange,
307316 ...(config.toolsSyncTtlMs !== undefined ? { toolsSyncTtlMs: config.toolsSyncTtlMs } : {}),
317+ ...(config.waitUntil !== undefined ? { waitUntil: config.waitUntil } : {}),
308318 onElicitation: "accept-all",
309319 redirectUri,
310320 oauthCallbackStateOrgSlug: orgSlug,
packages/core/api/src/update-check.test.ts+103−2
@@ -10,6 +10,9 @@ import { · 9 unmodified lines
1010 __resetDistTagsCache,
1111 checkForUpdate,
1212 compareVersions,
13+ isUnstampedVersion,
14+ isUpdateAvailable,
15+ resolveComparisonChannel,
1316 resolveDistTags,
1417 resolveUpdateChannel,
1518 } from "./update-check";
@@ -53,6 +56,73 @@ describe("resolveUpdateChannel", () => { · 37 unmodified lines
5356 });
5457 });
5558
59+describe("isUnstampedVersion", () => {
60+ it("flags 0.0.0, with or without a prerelease suffix", () => {
61+ expect(isUnstampedVersion("0.0.0")).toBe(true);
62+ expect(isUnstampedVersion("0.0.0-dev")).toBe(true);
63+ });
64+
65+ it("leaves any published version alone", () => {
66+ expect(isUnstampedVersion("1.5.22")).toBe(false);
67+ expect(isUnstampedVersion("0.0.1")).toBe(false);
68+ });
69+
70+ it("is false for unparseable input", () => {
71+ expect(isUnstampedVersion("not-a-version")).toBe(false);
72+ });
73+});
74+
75+describe("resolveComparisonChannel", () => {
76+ it("suppresses unstamped build-time fallback versions", () => {
77+ expect(resolveComparisonChannel("0.0.0")).toBeNull();
78+ expect(resolveComparisonChannel("0.0.0-dev")).toBeNull();
79+ });
80+
81+ it("suppresses unparseable input", () => {
82+ expect(resolveComparisonChannel("not-a-version")).toBeNull();
83+ });
84+
85+ it("routes a release version to the latest tag", () => {
86+ expect(resolveComparisonChannel("1.6.0")).toBe("latest");
87+ });
88+
89+ it("routes a beta prerelease to the beta tag", () => {
90+ expect(resolveComparisonChannel("1.6.0-beta.1")).toBe("beta");
91+ });
92+
93+ it("suppresses prereleases with no matching dist-tag", () => {
94+ // rc, alpha, next, dev, ... — none of these publish a dist-tag, so
95+ // comparing against `latest` would nag forever.
96+ expect(resolveComparisonChannel("1.6.0-rc.1")).toBeNull();
97+ expect(resolveComparisonChannel("1.6.0-alpha.1")).toBeNull();
98+ expect(resolveComparisonChannel("1.6.0-next.1")).toBeNull();
99+ });
100+});
101+
102+describe("isUpdateAvailable", () => {
103+ it("is false with no current version", () => {
104+ expect(isUpdateAvailable(undefined, "1.6.0")).toBe(false);
105+ });
106+
107+ it("is false with no comparison channel", () => {
108+ expect(isUpdateAvailable("0.0.0-dev", "1.6.0")).toBe(false);
109+ expect(isUpdateAvailable("1.6.0-rc.1", "1.6.0")).toBe(false);
110+ });
111+
112+ it("is false with no published tag", () => {
113+ expect(isUpdateAvailable("1.5.22", null)).toBe(false);
114+ });
115+
116+ it("is false when already current", () => {
117+ expect(isUpdateAvailable("1.6.0", "1.6.0")).toBe(false);
118+ });
119+
120+ it("is true when a newer version is published on the matching channel", () => {
121+ expect(isUpdateAvailable("1.6.0", "1.6.1")).toBe(true);
122+ expect(isUpdateAvailable("1.6.0-beta.1", "1.6.0-beta.2")).toBe(true);
123+ });
124+});
125+
56126 describe("resolveDistTags", () => {
57127 it("returns nothing when the check is disabled", async () => {
58128 const tags = await resolveDistTags({
@@ -126,6 +196,14 @@ describe("checkForUpdate", () => { · 67 unmodified lines
126196 expect(status.updateAvailable).toBe(false);
127197 });
128198
199+ it("flags a patch release on the latest channel", async () => {
200+ const status = await checkForUpdate("1.6.0", {
201+ env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.6.1" }) },
202+ });
203+ expect(status.updateAvailable).toBe(true);
204+ expect(status.latestVersion).toBe("1.6.1");
205+ });
206+
129207 it("compares a beta build against the beta tag", async () => {
130208 const status = await checkForUpdate("1.6.0-beta.1", {
131209 env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.5.22", beta: "1.6.0-beta.2" }) },
@@ -136,10 +214,33 @@ describe("checkForUpdate", () => { · 4 unmodified lines
136214 expect(status.command).toBe("npm i -g executor@beta");
137215 });
138216
139− it("treats the dev build as upgradeable to any release", async () => {
217+ it("stays quiet on an unstamped dev build", async () => {
218+ // 0.0.0-dev is the build-time fallback, not a real release. It must never
219+ // claim an update is available, and — because there is no dist-tag it
220+ // could legitimately compare against — must not even hit the registry.
140221 const status = await checkForUpdate("0.0.0-dev", {
141222 env: { EXECUTOR_FORCE_LATEST_VERSION: "1.5.22" },
223+ fetchImpl: fetchThatFails(),
142224 });
143− expect(status.updateAvailable).toBe(true);
225+ expect(status.updateAvailable).toBe(false);
226+ expect(status.latestVersion).toBeNull();
227+ });
228+
229+ it("stays quiet on the desktop's unstamped fallback (plain 0.0.0)", async () => {
230+ const status = await checkForUpdate("0.0.0", {
231+ env: { EXECUTOR_FORCE_LATEST_VERSION: "1.5.22" },
232+ fetchImpl: fetchThatFails(),
233+ });
234+ expect(status.updateAvailable).toBe(false);
235+ expect(status.latestVersion).toBeNull();
236+ });
237+
238+ it("stays quiet on a prerelease channel with no matching dist-tag", async () => {
239+ const status = await checkForUpdate("1.6.0-rc.1", {
240+ env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.6.0" }) },
241+ fetchImpl: fetchThatFails(),
242+ });
243+ expect(status.updateAvailable).toBe(false);
244+ expect(status.latestVersion).toBeNull();
144245 });
145246 });
packages/core/api/src/update-check.ts+58−3
@@ -102,6 +102,53 @@ export const compareVersions = (left: string, right: string): number | null => { · 101 unmodified lines
102102 return comparePrereleaseIdentifiers(lv.prerelease, rv.prerelease);
103103 };
104104
105+/**
106+ * True when `version` parses as `0.0.0`, with or without a prerelease suffix
107+ * (e.g. `0.0.0-dev`). `0.0.0` is never a published release — it is the
108+ * build-time fallback baked in when the real version was unavailable at
109+ * build time — so a build carrying it must never claim an update.
110+ */
111+export const isUnstampedVersion = (version: string): boolean => {
112+ const parsed = parseVersion(version);
113+ return parsed !== null && parsed.major === 0 && parsed.minor === 0 && parsed.patch === 0;
114+};
115+
116+/**
117+ * The dist-tag channel `version` can legitimately be compared against, or
118+ * `null` when there is none (the check should be suppressed, not run):
119+ * - an unstamped build (0.0.0*) never shipped, so there is nothing to
120+ * compare it against
121+ * - unparseable input has no meaningful channel
122+ * - no prerelease -> the `latest` tag
123+ * - a prerelease whose first identifier is `beta` -> the `beta` tag
124+ * - any other prerelease (rc, alpha, next, dev, ...) has no matching
125+ * dist-tag, so suppress rather than nag forever against a channel it can
126+ * never catch up to
127+ */
128+export const resolveComparisonChannel = (version: string): UpdateChannel | null => {
129+ if (isUnstampedVersion(version)) return null;
130+ const parsed = parseVersion(version);
131+ if (!parsed) return null;
132+ if (parsed.prerelease === null) return "latest";
133+ return parsed.prerelease[0] === "beta" ? "beta" : null;
134+};
135+
136+/**
137+ * The single "should we nag?" verdict both the CLI and the web UpdateCard
138+ * read. False whenever there is nothing to compare: no current version, no
139+ * dist-tag channel this version could match (see `resolveComparisonChannel`),
140+ * or no published tag for that channel.
141+ */
142+export const isUpdateAvailable = (
143+ currentVersion: string | undefined,
144+ latestVersion: string | null,
145+): boolean => {
146+ if (currentVersion === undefined) return false;
147+ if (resolveComparisonChannel(currentVersion) === null) return false;
148+ if (latestVersion === null) return false;
149+ return compareVersions(currentVersion, latestVersion) === -1;
150+};
151+
105152 // ── dist-tags resolution ──────────────────────────────────────────────────
106153
107154 export type DistTags = Partial<Record<UpdateChannel, string>>;
@@ -223,9 +270,17 @@ export const checkForUpdate = async ( · 115 unmodified lines
223270 ): Promise<UpdateStatus> => {
224271 const channel = resolveUpdateChannel(currentVersion);
225272 const command = `npm i -g ${EXECUTOR_PACKAGE_NAME}@${channel}`;
273+
274+ // No dist-tag can legitimately be compared against this version (unstamped
275+ // dev build, or a prerelease channel that never gets published) — do not
276+ // even hit the registry, since the answer is already known.
277+ const comparisonChannel = resolveComparisonChannel(currentVersion);
278+ if (comparisonChannel === null) {
279+ return { updateAvailable: false, currentVersion, latestVersion: null, channel, command };
280+ }
281+
226282 const tags = await resolveDistTags(options);
227− const latestVersion = tags[channel] ?? null;
228− const updateAvailable =
229− latestVersion !== null && compareVersions(currentVersion, latestVersion) === -1;
283+ const latestVersion = tags[comparisonChannel] ?? null;
284+ const updateAvailable = isUpdateAvailable(currentVersion, latestVersion);
230285 return { updateAvailable, currentVersion, latestVersion, channel, command };
231286 };
packages/core/sdk/src/executor.ts+66−1
@@ -2,6 +2,7 @@ import { · 1 unmodified line
22 Deferred,
33 Duration,
44 Effect,
5+ Fiber,
56 Inspectable,
67 Layer,
78 Option,
@@ -696,6 +697,23 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 688 unmodified lines
696697 * config-revision re-sync still apply).
697698 */
698699 readonly toolsSyncTtlMs?: number | null;
700+ /**
701+ * How long a tools read WAITS for stale-catalog rebuilds before answering
702+ * from the persisted rows. Rebuilds keep running past the deadline (see
703+ * `waitUntil`) and land on a later read; the read itself never pays more
704+ * than this for upstream listings it did not ask for. Defaults to 2
705+ * seconds; pass `null` to block until every rebuild finishes (the strict
706+ * mode: a read then always reflects a fully converged catalog).
707+ */
708+ readonly toolsSyncGraceMs?: number | null;
709+ /**
710+ * Host keep-alive for background work that outlives a request — the
711+ * platform `waitUntil` on Cloudflare Workers, where I/O started inside a
712+ * request is cancelled once the response settles unless a host holds the
713+ * context open. Long-lived processes (self-host, CLI, tests) omit it;
714+ * their detached fibers simply run to completion.
715+ */
716+ readonly waitUntil?: (promise: Promise<unknown>) => void;
699717 /**
700718 * Notified after a durable integration-catalog change commits (a row
701719 * created or removed). Best-effort observation only: the notification runs
@@ -729,6 +747,13 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 27 unmodified lines
729747 * `ExecutorConfig.toolsSyncTtlMs`). */
730748 export const DEFAULT_TOOLS_SYNC_TTL_MS = 15 * 60 * 1000;
731749
750+/** Default wait budget a tools read spends on stale-catalog rebuilds before
751+ * answering from the persisted rows (see `ExecutorConfig.toolsSyncGraceMs`).
752+ * Sized to cover a healthy upstream re-list (one handshake + one
753+ * `tools/list`) while keeping a read gated on a slow or dead server bounded
754+ * well under any per-connection network timeout. */
755+export const DEFAULT_TOOLS_SYNC_GRACE_MS = 2000;
756+
732757 /** How many stale connection catalogs are DISCOVERED at once on a tools read.
733758 * Bounded so a host with a large stale set cannot open an unbounded number of
734759 * upstream listings from a single read. Only the discovery phase runs at this
@@ -4219,9 +4244,49 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3484 unmodified lines
42194244 yield* Effect.all(rebuilds, { concurrency: STALE_TOOLS_SYNC_CONCURRENCY });
42204245 });
42214246
4247+ // How long a tools read waits for the stale sync before answering from
4248+ // the persisted rows (`ExecutorConfig.toolsSyncGraceMs`; `null` blocks
4249+ // until convergence).
4250+ const toolsSyncGraceMs =
4251+ config.toolsSyncGraceMs === undefined ? DEFAULT_TOOLS_SYNC_GRACE_MS : config.toolsSyncGraceMs;
4252+
4253+ // Run the stale sync with a bounded wait: a read pays at most the grace
4254+ // budget for upstream listings it did not ask for, then serves the
4255+ // persisted catalog while the rebuilds finish on their detached fibers
4256+ // (deduplicated per connection by `produceConnectionTools`, so overlapping
4257+ // reads share one rebuild instead of stacking new ones). One slow or dead
4258+ // MCP server must never gate every catalog read behind its network
4259+ // timeout. Past the deadline the sync is best-effort by construction —
4260+ // failures log and the stale-but-working catalog stays — so the fork
4261+ // swallows its scan errors the same way each rebuild already swallows its
4262+ // own.
4263+ const awaitStaleSyncWithinGrace = (graceMs: number) =>
4264+ Effect.gen(function* () {
4265+ const fiber = yield* Effect.forkDetach(
4266+ syncStaleConnectionTools.pipe(
4267+ Effect.catch((error) =>
4268+ Effect.logWarning("executor stale tool sync scan failed", {
4269+ error: describeSyncFailure(error),
4270+ }),
4271+ ),
4272+ ),
4273+ );
4274+ // On hosts that cancel request-scoped I/O once the response settles
4275+ // (Cloudflare Workers), hand the host the rebuilds' completion so the
4276+ // catalog still converges after the read stops waiting.
4277+ config.waitUntil?.(
4278+ new Promise<void>((resolve) => fiber.addObserver(() => resolve(undefined))),
4279+ );
4280+ yield* Fiber.await(fiber).pipe(Effect.timeoutOption(graceMs), Effect.asVoid);
4281+ });
4282+
42224283 const toolsList = (filter?: ToolListFilter): Effect.Effect<readonly Tool[], StorageFailure> =>
42234284 Effect.gen(function* () {
4224− yield* syncStaleConnectionTools;
4285+ if (toolsSyncGraceMs === null) {
4286+ yield* syncStaleConnectionTools;
4287+ } else {
4288+ yield* awaitStaleSyncWithinGrace(toolsSyncGraceMs);
4289+ }
42254290 // Projected: the list surface is metadata (address, description,
42264291 // annotations) — loading every tool's input/output schema JSON made
42274292 // an unbounded list scale with schema bytes, not tool count.
packages/core/sdk/src/index.ts+9−0
@@ -425,6 +425,7 @@ export { · 424 unmodified lines
425425 type ExecutorDbFactory,
426426 type ExecutorDbInput,
427427 type ParsedToolAddress,
428+ DEFAULT_TOOLS_SYNC_GRACE_MS,
428429 STALE_TOOLS_SYNC_CONCURRENCY,
429430 createExecutor,
430431 collectTables,
@@ -488,6 +489,14 @@ export { · 57 unmodified lines
488489 oauthClientGcSqliteMigration,
489490 runSqliteOAuthClientGcMigration,
490491 } from "./sqlite-oauth-client-gc-migration";
492+// Rewrite `bigint` columns an earlier build left in SQLite's INTEGER storage
493+// class, which the bigint row mapper cannot read (issue #1771).
494+export {
495+ bigintStorageClassSqliteMigration,
496+ runSqliteBigintStorageClassMigration,
497+ LEGACY_BIGINT_STORAGE_CLASS_COLUMNS,
498+ type BigintStorageClassColumn,
499+} from "./sqlite-bigint-storage-class-migration";
491500 export {
492501 authToolFailure,
493502 isUnauthorizedToolFailure,
packages/core/sdk/src/sqlite-bigint-storage-class-migration.test.ts+202−0
@@ -0,0 +1,202 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+import { withQueryContext } from "@executor-js/fumadb/query";
4+
5+import { collectTables } from "./executor";
6+import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";
7+import {
8+ LEGACY_BIGINT_STORAGE_CLASS_COLUMNS,
9+ bigintStorageClassSqliteMigration,
10+ runSqliteBigintStorageClassMigration,
11+} from "./sqlite-bigint-storage-class-migration";
12+
13+// A `bigint` column is stored on SQLite as a blob holding the decimal digits
14+// (drizzle's `blob({ mode: "bigint" })`). Before the columns below carried the
15+// `bigint` flag they were plain numbers, so SQLite kept them in the INTEGER
16+// storage class — and an install written by that build still holds integers
17+// today. Reading one back through the bigint mapper reaches
18+// `Buffer.from(<number>)`, which throws `ERR_INVALID_ARG_TYPE`, and because the
19+// throw is on the ROW mapper it takes down the whole `findMany`, not one field.
20+//
21+// That is issue #1771: `connection.expires_at` held an epoch-millis integer, so
22+// every catalog read threw and the MCP gateway served an empty tool list even
23+// though the integrations were still saved.
24+
25+const TENANT = "t1";
26+const SUBJECT = "user_a";
27+const LEGACY_EXPIRES_AT = 1787321623456;
28+const HEALTHY_EXPIRES_AT = 1787321699999;
29+
30+const withDb = <A>(body: (db: SqliteTestFumaDb) => Promise<A>): Promise<A> =>
31+ Effect.runPromise(
32+ Effect.acquireUseRelease(
33+ Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })),
34+ (db) => Effect.promise(() => body(db)),
35+ (db) => Effect.promise(() => db.close()),
36+ ),
37+ );
38+
39+const seconds = (ms: number) => Math.floor(ms / 1000);
40+
41+/** Insert a connection row whose `expires_at` is set by a raw SQL expression,
42+ * so the test controls its SQLite storage class exactly: an integer literal
43+ * lands as INTEGER — what a build that declared the column `integer` wrote —
44+ * while `CAST(... AS BLOB)` lands as BLOB, what the ORM writes today. A bound
45+ * JS number would land as REAL under the column's current BLOB affinity, which
46+ * is a different (also broken) shape. */
47+const insertConnection = (
48+ db: SqliteTestFumaDb,
49+ row: { readonly rowId: string; readonly name: string; readonly expiresAtSql: string },
50+): Promise<unknown> =>
51+ db.client.execute({
52+ sql: `INSERT INTO connection
53+ (row_id, tenant, owner, subject, integration, name, template, provider, item_ids,
54+ expires_at, created_at, updated_at)
55+ VALUES (?, ?, 'user', ?, 'acme', ?, 'oauth2', 'file', ?, ${row.expiresAtSql}, ?, ?)`,
56+ args: [
57+ row.rowId,
58+ TENANT,
59+ SUBJECT,
60+ row.name,
61+ JSON.stringify({ token: "item_1" }),
62+ seconds(Date.now()),
63+ seconds(Date.now()),
64+ ],
65+ });
66+
67+/** The legacy shape: a bare integer literal, stored in the INTEGER class. */
68+const legacyInteger = String(LEGACY_EXPIRES_AT);
69+/** The current shape: the decimal digits as bytes. */
70+const currentBlob = `CAST('${HEALTHY_EXPIRES_AT}' AS BLOB)`;
71+
72+const storageClassOf = async (db: SqliteTestFumaDb, rowId: string): Promise<string> => {
73+ const result = await db.client.execute({
74+ sql: "SELECT typeof(expires_at) AS kind FROM connection WHERE row_id = ?",
75+ args: [rowId],
76+ });
77+ return String(result.rows[0]?.["kind"]);
78+};
79+
80+describe("legacy bigint storage class migration", () => {
81+ it.effect("reproduces the catalog read failure on a legacy integer row", () =>
82+ Effect.promise(() =>
83+ withDb(async (db) => {
84+ await insertConnection(db, {
85+ rowId: "c_legacy",
86+ name: "legacy",
87+ expiresAtSql: legacyInteger,
88+ });
89+ expect(await storageClassOf(db, "c_legacy")).toBe("integer");
90+
91+ const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });
92+ // Not "returns a bad value" — the read THROWS, which is why the gateway
93+ // lost every saved integration rather than one field of one row.
94+ await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/);
95+ }),
96+ ),
97+ );
98+
99+ it.effect("converts legacy integers so the catalog reads again", () =>
100+ Effect.promise(() =>
101+ withDb(async (db) => {
102+ await insertConnection(db, {
103+ rowId: "c_legacy",
104+ name: "legacy",
105+ expiresAtSql: legacyInteger,
106+ });
107+ await insertConnection(db, {
108+ rowId: "c_healthy",
109+ name: "healthy",
110+ expiresAtSql: currentBlob,
111+ });
112+ await insertConnection(db, { rowId: "c_null", name: "null", expiresAtSql: "NULL" });
113+
114+ const converted = await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client));
115+ expect(converted).toBe(1);
116+
117+ expect(await storageClassOf(db, "c_legacy")).toBe("blob");
118+ expect(await storageClassOf(db, "c_healthy")).toBe("blob");
119+ expect(await storageClassOf(db, "c_null")).toBe("null");
120+
121+ const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });
122+ const rows = await scoped.findMany("connection", {});
123+ expect(
124+ rows.map((row) => [row.name, row.expires_at == null ? null : Number(row.expires_at)]),
125+ ).toEqual([
126+ ["healthy", HEALTHY_EXPIRES_AT],
127+ ["legacy", LEGACY_EXPIRES_AT],
128+ ["null", null],
129+ ]);
130+ }),
131+ ),
132+ );
133+
134+ it.effect("is idempotent", () =>
135+ Effect.promise(() =>
136+ withDb(async (db) => {
137+ await insertConnection(db, {
138+ rowId: "c_legacy",
139+ name: "legacy",
140+ expiresAtSql: legacyInteger,
141+ });
142+
143+ expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(1);
144+ expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(0);
145+
146+ const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });
147+ const rows = await scoped.findMany("connection", {});
148+ expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]);
149+ }),
150+ ),
151+ );
152+
153+ it.effect("converts every bigint column that predates the flag", () =>
154+ Effect.promise(() =>
155+ withDb(async (db) => {
156+ // `oauth_session.expires_at` was re-typed in the same change, and
157+ // `connection.tools_synced_at` / `integration.config_revised_at` /
158+ // `subject.last_seen_at` share the representation.
159+ await db.client.execute({
160+ sql: `INSERT INTO integration
161+ (row_id, tenant, slug, plugin_id, description, config_revised_at, created_at, updated_at)
162+ VALUES ('i1', ?, 'acme', 'openapi', '', ${legacyInteger}, ?, ?)`,
163+ args: [TENANT, seconds(Date.now()), seconds(Date.now())],
164+ });
165+ await insertConnection(db, { rowId: "c1", name: "c1", expiresAtSql: "NULL" });
166+ await db.client.execute(
167+ `UPDATE connection SET tools_synced_at = ${legacyInteger} WHERE row_id = 'c1'`,
168+ );
169+
170+ expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(2);
171+
172+ const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });
173+ const integrations = await scoped.findMany("integration", {});
174+ expect(integrations.map((row) => Number(row.config_revised_at))).toEqual([
175+ LEGACY_EXPIRES_AT,
176+ ]);
177+ const connections = await scoped.findMany("connection", {});
178+ expect(connections.map((row) => Number(row.tools_synced_at))).toEqual([LEGACY_EXPIRES_AT]);
179+ }),
180+ ),
181+ );
182+
183+ it("covers every bigint column the core schema declares", () => {
184+ const tables = collectTables() as Record<string, { readonly columns: Record<string, unknown> }>;
185+ const declared: string[] = [];
186+ for (const [tableName, table] of Object.entries(tables)) {
187+ for (const [columnName, column] of Object.entries(table.columns)) {
188+ if ((column as { readonly type?: string }).type === "bigint") {
189+ declared.push(`${tableName}.${columnName}`);
190+ }
191+ }
192+ }
193+ const covered = LEGACY_BIGINT_STORAGE_CLASS_COLUMNS.map(
194+ (entry) => `${entry.table}.${entry.column}`,
195+ );
196+ expect(covered.slice().sort()).toEqual(declared.sort());
197+ });
198+
199+ it("is registered under a stable, date-prefixed name", () => {
200+ expect(bigintStorageClassSqliteMigration.name).toBe("2026-08-28-bigint-storage-class");
201+ });
202+});
packages/core/sdk/src/sqlite-bigint-storage-class-migration.ts+166−0
@@ -0,0 +1,166 @@
1+// ---------------------------------------------------------------------------
2+// libSQL boot migration: rewrite `bigint` columns that an earlier build left in
3+// SQLite's INTEGER storage class (issue #1771).
4+//
5+// A `bigint` column is stored on SQLite as a BLOB holding the value's decimal
6+// digits — that is what drizzle's `blob({ mode: "bigint" })` writes, and its
7+// row mapper reads the blob back with `Buffer.from(...)`. Before these columns
8+// carried the `bigint` flag they were plain numbers, which SQLite kept in the
9+// INTEGER storage class. SQLite does not rewrite existing rows when a column's
10+// declared type changes, so an install written by that build still holds
11+// integers today.
12+//
13+// Reading one back now reaches `Buffer.from(<number>)`, which throws
14+// `ERR_INVALID_ARG_TYPE`. The throw is in the ROW mapper, so it fails the whole
15+// `findMany` rather than one field: on a database whose `connection` rows
16+// carried an OAuth `expires_at` from that era, EVERY catalog read threw and the
17+// MCP gateway served an empty tool list even though the integrations were all
18+// still saved.
19+//
20+// The rewrite is deliberately narrow. It touches only the columns the schema
21+// declares `bigint`, and within them only values whose storage class is
22+// `integer` or `real` — the shapes the mapper cannot read. Values already
23+// stored as BLOB or TEXT (TEXT is what the v1→v2 migration writes, and the
24+// mapper reads it fine) are left exactly as they are. `CAST(x AS BLOB)`
25+// converts through TEXT, so the stored bytes end up identical to what the ORM
26+// writes today. Idempotent: after a run no column is in a numeric storage
27+// class, so a second run updates nothing.
28+// ---------------------------------------------------------------------------
29+
30+import { Effect } from "effect";
31+
32+import { coreSchema } from "./core-schema";
33+import {
34+ DataMigrationError,
35+ type SqliteDataMigration,
36+ type SqliteDataMigrationClient,
37+} from "./sqlite-data-migrations";
38+
39+const MIGRATION_NAME = "2026-08-28-bigint-storage-class";
40+
41+export interface BigintStorageClassColumn {
42+ /** SQL table name. */
43+ readonly table: string;
44+ /** SQL column name. */
45+ readonly column: string;
46+}
47+
48+/**
49+ * Every `bigint` column in the core schema, by SQL name.
50+ *
51+ * Derived from the schema rather than hand-listed so a column added later can
52+ * never be silently missed. Scanning a column that never held an integer is a
53+ * no-op, so over-coverage costs one indexed-free table scan at first boot.
54+ */
55+export const LEGACY_BIGINT_STORAGE_CLASS_COLUMNS: readonly BigintStorageClassColumn[] =
56+ Object.values(coreSchema).flatMap((table) =>
57+ Object.values(table.columns)
58+ .filter((column) => column.type === "bigint")
59+ .map((column) => ({ table: table.names.sql, column: column.names.sql })),
60+ );
61+
62+const execute = (
63+ client: SqliteDataMigrationClient,
64+ stmt: string | { readonly sql: string; readonly args: readonly unknown[] },
65+) =>
66+ Effect.tryPromise({
67+ try: () => client.execute(stmt),
68+ catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }),
69+ });
70+
71+/** SQLite identifiers are quoted, not parameterized. Every name here comes from
72+ * the compiled-in schema, so this always matches; anything else is refused
73+ * rather than interpolated. */
74+const quoteIdentifier = (name: string): Effect.Effect<string, DataMigrationError> =>
75+ /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)
76+ ? Effect.succeed(`"${name}"`)
77+ : Effect.fail(
78+ new DataMigrationError({
79+ migration: MIGRATION_NAME,
80+ cause: `Refusing to interpolate SQL identifier: ${name}`,
81+ }),
82+ );
83+
84+const hasColumn = (
85+ client: SqliteDataMigrationClient,
86+ table: string,
87+ quotedTable: string,
88+ column: string,
89+): Effect.Effect<boolean, DataMigrationError> =>
90+ execute(client, {
91+ sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
92+ args: [table],
93+ }).pipe(
94+ Effect.flatMap((tables) =>
95+ tables.rows.length === 0
96+ ? Effect.succeed(false)
97+ : execute(client, `PRAGMA table_info(${quotedTable})`).pipe(
98+ Effect.map((info) => info.rows.some((row) => row["name"] === column)),
99+ ),
100+ ),
101+ );
102+
103+/**
104+ * Convert legacy INTEGER/REAL values in the schema's `bigint` columns to the
105+ * blob representation the ORM reads.
106+ *
107+ * Returns the number of rows rewritten. Wrapped in BEGIN…COMMIT so a mid-run
108+ * failure leaves the database untouched and the (unstamped) migration re-runs
109+ * cleanly on the next boot.
110+ */
111+export const runSqliteBigintStorageClassMigration = (
112+ client: SqliteDataMigrationClient,
113+): Effect.Effect<number, DataMigrationError> =>
114+ Effect.gen(function* () {
115+ const pending: { readonly sql: string; readonly count: number }[] = [];
116+
117+ for (const target of LEGACY_BIGINT_STORAGE_CLASS_COLUMNS) {
118+ const table = yield* quoteIdentifier(target.table);
119+ const column = yield* quoteIdentifier(target.column);
120+ if (!(yield* hasColumn(client, target.table, table, target.column))) continue;
121+
122+ // `typeof()` reports the STORAGE class, which is what the mapper trips
123+ // over — the declared column type is irrelevant to SQLite here.
124+ const predicate = `typeof(${column}) IN ('integer', 'real')`;
125+
126+ const counted = yield* execute(
127+ client,
128+ `SELECT COUNT(*) AS n FROM ${table} WHERE ${predicate}`,
129+ );
130+ const count = Number(counted.rows[0]?.["n"] ?? 0);
131+ if (count === 0) continue;
132+
133+ pending.push({
134+ sql: `UPDATE ${table} SET ${column} = CAST(CAST(${column} AS INTEGER) AS BLOB) WHERE ${predicate}`,
135+ count,
136+ });
137+ }
138+
139+ if (pending.length === 0) return 0;
140+
141+ const applyAll = Effect.gen(function* () {
142+ let converted = 0;
143+ for (const statement of pending) {
144+ yield* execute(client, statement.sql);
145+ converted += statement.count;
146+ }
147+ yield* execute(client, "COMMIT");
148+ return converted;
149+ });
150+
151+ yield* execute(client, "BEGIN");
152+ return yield* applyAll.pipe(
153+ Effect.tapError(() => execute(client, "ROLLBACK").pipe(Effect.ignore)),
154+ // `tapError` only fires on a typed failure, not on fiber interruption
155+ // (e.g. the boot sequence timing out mid-migration) — so without this an
156+ // interrupted run can leave the transaction open. Never fires on success:
157+ // COMMIT has already run by then.
158+ Effect.onInterrupt(() => execute(client, "ROLLBACK").pipe(Effect.ignore)),
159+ );
160+ });
161+
162+/** Registry entry for the SQLite hosts' boot-time data-migration ledger. */
163+export const bigintStorageClassSqliteMigration: SqliteDataMigration = {
164+ name: MIGRATION_NAME,
165+ run: (client) => runSqliteBigintStorageClassMigration(client).pipe(Effect.asVoid),
166+};
packages/plugins/graphql/src/sdk/plugin.test.ts+5−1
@@ -729,8 +729,12 @@ describe("graphqlPlugin real protocol server", () => { · 728 unmodified lines
729729 expect(result).toMatchObject({
730730 status: "healthy",
731731 httpStatus: 200,
732− identity: "GraphQL schema: Query",
732+ responseSample: [{ path: "__schema.queryType.name", value: "Query" }],
733733 });
734+ // The schema's root type name is not an account identity; reporting it
735+ // as one made the accounts UI label every GraphQL connection
736+ // "GraphQL schema: Query".
737+ expect(result).not.toHaveProperty("identity");
734738 }),
735739 );
736740
packages/plugins/graphql/src/sdk/plugin.ts+4−4
@@ -737,16 +737,16 @@ const checkGraphqlHealth = (input: { · 736 unmodified lines
737737 : { ...verdict, detail: scrubCredentialValues(verdict.detail, input.credential.values) };
738738 }
739739
740+ // No `identity`: the schema's root type name ("Query" almost everywhere)
741+ // identifies nothing, and the accounts UI would show it as the account
742+ // label. Introspection proves reachability, not who the credential is.
740743 const queryType = result.introspection.__schema.queryType?.name;
741744 return {
742745 status: "healthy",
743746 httpStatus: 200,
744747 checkedAt,
745748 ...(queryType != null
746− ? {
747− identity: `GraphQL schema: ${queryType}`,
748− responseSample: [{ path: "__schema.queryType.name", value: queryType }],
749− }
749+ ? { responseSample: [{ path: "__schema.queryType.name", value: queryType }] }
750750 : {}),
751751 } satisfies HealthCheckResult;
752752 });
packages/plugins/mcp/package.json+1−0
@@ -69,6 +69,7 @@ · 68 unmodified lines
6969 "@modelcontextprotocol/client": "2.0.0",
7070 "@modelcontextprotocol/core": "2.0.0",
7171 "@modelcontextprotocol/sdk": "^1.29.0",
72+ "lucide-react": "^1.7.0",
7273 "zod": "4.3.6"
7374 },
7475 "devDependencies": {
packages/plugins/mcp/src/api/group.ts+2−0
@@ -31,6 +31,7 @@ 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+ family: Schema.optional(Schema.String),
3435 /** Agent-visible catalog description. Defaults to the display name. */
3536 description: Schema.optional(Schema.String),
3637 endpoint: Schema.String,
@@ -48,6 +49,7 @@ const AddRemoteServerPayload = Schema.Struct({ · 11 unmodified lines
4849 const AddStdioServerPayload = Schema.Struct({
4950 transport: Schema.Literal("stdio"),
5051 name: Schema.String,
52+ family: Schema.optional(Schema.String),
5153 description: Schema.optional(Schema.String),
5254 command: Schema.String,
5355 args: Schema.optional(Schema.Array(Schema.String)),
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+ family?: string;
3637 description?: string;
3738 command: string;
3839 args?: readonly string[];
@@ -45,6 +46,7 @@ const toServerInput = ( · 6 unmodified lines
4546 return {
4647 transport: "stdio",
4748 name: p.name,
49+ family: p.family,
4850 description: p.description,
4951 command: p.command,
5052 args: p.args ? [...p.args] : undefined,
@@ -59,6 +61,7 @@ const toServerInput = ( · 8 unmodified lines
5961 const p = payload as {
6062 transport?: "remote";
6163 name: string;
64+ family?: string;
6265 description?: string;
6366 endpoint: string;
6467 remoteTransport?: "streamable-http" | "sse" | "auto";
@@ -74,6 +77,7 @@ const toServerInput = ( · 9 unmodified lines
7477 return {
7578 transport: "remote",
7679 name: p.name,
80+ family: p.family,
7781 description: p.description,
7882 endpoint: p.endpoint,
7983 remoteTransport: p.remoteTransport,
packages/plugins/mcp/src/react/AddMcpIntegration.tsx+41−19
@@ -38,7 +38,11 @@ import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; · 37 unmodified lines
3838 import type { McpAuthMethodInput } from "../sdk/types";
3939 import { probeMcpEndpoint, addMcpServer } from "./atoms";
4040 import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields";
41+import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";
42+import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers";
4143 import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";
44+import { parseStdioArgs } from "./stdio-fields";
45+import { isProbableMcpEndpoint } from "./probe-url";
4246 import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode";
4347 import { mcpPresets, type McpPreset } from "../sdk/presets";
4448
@@ -59,19 +63,6 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 14 unmodified lines
5963 return mcpPresets.find((p) => p.id === id);
6064 }
6165
62−// Splits the raw args field into tokens, honoring double-quoted groups so an
63−// argument with spaces stays intact.
64−function parseStdioArgs(raw: string): string[] {
65− if (!raw.trim()) return [];
66− const args: string[] = [];
67− const regex = /[^\s"]+|"([^"]*)"/g;
68− let match;
69− while ((match = regex.exec(raw)) !== null) {
70− args.push(match[1] ?? match[0]);
71− }
72− return args;
73−}
74−
7566 // ---------------------------------------------------------------------------
7667 // State machine (remote flow)
7768 // ---------------------------------------------------------------------------
@@ -208,6 +199,12 @@ export default function AddMcpIntegration(props: { · 130 unmodified lines
208199 remoteUrl ? { step: "url" as const, url: remoteUrl } : init,
209200 );
210201
202+ // Static request headers for the endpoint (e.g. a Cloudflare Access service
203+ // token). They gate the probe as much as the live traffic, so the same
204+ // values feed both.
205+ const [headerRows, setHeaderRows] = useState<readonly McpHeaderRow[]>([]);
206+ const headers = useMemo(() => mcpHeadersFromRows(headerRows), [headerRows]);
207+
211208 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });
212209 const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });
213210
@@ -270,11 +267,23 @@ export default function AddMcpIntegration(props: { · 56 unmodified lines
270267
271268 // ---- Remote actions ----
272269
270+ // Each probe run takes a token. Editing the URL invalidates it, so a reply
271+ // that lands after the user has moved on is dropped instead of reporting on
272+ // a URL that is no longer in the field. The probe atom exposes no abort
273+ // signal, so the request itself still finishes; only its answer is ignored.
274+ const probeRunRef = useRef(0);
275+
276+ useEffect(() => {
277+ probeRunRef.current += 1;
278+ }, [state.url]);
279+
273280 const handleProbe = useCallback(async () => {
281+ const run = (probeRunRef.current += 1);
274282 dispatch({ type: "probe-start" });
275283 const exit = await doProbe({
276− payload: { endpoint: state.url.trim() },
284+ payload: { endpoint: state.url.trim(), ...(headers ? { headers } : {}) },
277285 });
286+ if (run !== probeRunRef.current) return;
278287 if (Exit.isFailure(exit)) {
279288 dispatch({
280289 type: "probe-fail",
@@ -283,7 +292,7 @@ export default function AddMcpIntegration(props: { · 2 unmodified lines
283292 return;
284293 }
285294 dispatch({ type: "probe-ok", probe: exit.value });
286− }, [state.url, doProbe]);
295+ }, [state.url, headers, doProbe]);
287296
288297 // Keep the latest handleProbe in a ref so the debounced effect can call it
289298 // without depending on its identity (which changes every render).
@@ -291,12 +300,14 @@ export default function AddMcpIntegration(props: { · 1 unmodified line
291300 handleProbeRef.current = handleProbe;
292301
293302 // Auto-probe whenever the URL changes (debounced) while we're on the
294− // remote transport and not already probing/probed.
303+ // remote transport and not already probing/probed. The shape gate keeps a
304+ // half-typed URL from being dialled: without it every keystroke that is a
305+ // non-empty string gets probed, and the field flashes through a loading and
306+ // then an error state for values the user never meant to submit.
295307 useEffect(() => {
296308 if (transport !== "remote") return;
297309 if (state.step !== "url") return;
298− const trimmed = state.url.trim();
299− if (!trimmed) return;
310+ if (!isProbableMcpEndpoint(state.url)) return;
300311 const handle = setTimeout(() => {
301312 handleProbeRef.current();
302313 }, 400);
@@ -318,6 +329,7 @@ export default function AddMcpIntegration(props: { · 15 unmodified lines
318329 : {}),
319330 endpoint: state.url.trim(),
320331 ...(slug ? { slug } : {}),
332+ ...(headers ? { headers } : {}),
321333 authenticationTemplate,
322334 },
323335 reactivityKeys: integrationWriteKeys,
@@ -331,7 +343,7 @@ export default function AddMcpIntegration(props: { · 7 unmodified lines
331343 }
332344 return exit.value.slug;
333345 },
334− [doAddServer, probe, remoteIdentity, resolvedDescription, state.url],
346+ [doAddServer, headers, probe, remoteIdentity, resolvedDescription, state.url],
335347 );
336348
337349 const handleAddRemote = useCallback(async () => {
@@ -446,6 +458,16 @@ export default function AddMcpIntegration(props: { · 108 unmodified lines
446458 </Info>
447459 )}
448460
461+ {/* Static request headers. Shown in every remote state, because an
462+ endpoint behind an edge authenticator (Cloudflare Access) fails
463+ the very first probe until its service-token headers are set. */}
464+ <McpRequestHeadersEditor
465+ rows={headerRows}
466+ onChange={setHeaderRows}
467+ onTest={handleProbe}
468+ testing={isProbing}
469+ />
470+
449471 {/* Authentication — declares the auth methods to register through the
450472 shared list editor. The credentials themselves (API key value /
451473 OAuth sign-in) are added from the integration's detail hub after
packages/plugins/mcp/src/react/EditMcpIntegration.tsx+158−22
@@ -13,10 +13,16 @@ import { · 12 unmodified lines
1313 type AuthMethodRow,
1414 type AuthMethodSeed,
1515 } from "@executor-js/react/components/auth-method-list-editor";
16−import { Badge } from "@executor-js/react/components/badge";
17−import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
16+import {
17+ CardStack,
18+ CardStackContent,
19+ CardStackEntryField,
20+} from "@executor-js/react/components/card-stack";
21+import { Input } from "@executor-js/react/components/input";
22+import { Textarea } from "@executor-js/react/components/textarea";
23+import { errorMessageFromExit, FormErrorAlert } from "@executor-js/react/lib/integration-add";
1824
19−import { configureMcpAuth, mcpServerAtom } from "./atoms";
25+import { configureMcpAuth, configureMcpServer, mcpServerAtom } from "./atoms";
2026 import type {
2127 McpAuthMethod,
2228 McpCanonicalAuthMethodInput,
@@ -27,6 +33,7 @@ import { · 4 unmodified lines
2733 mcpAuthMethodInputFromEditorValue,
2834 mcpWireAuthInput,
2935 } from "./auth-method-config";
36+import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields";
3037
3138 type McpServer = {
3239 readonly slug: IntegrationSlug;
@@ -174,30 +181,160 @@ function RemoteEdit(props: { · 141 unmodified lines
174181 }
175182
176183 // ---------------------------------------------------------------------------
177−// Stdio read-only view
184+// Stdio edit — the command, its arguments, the working directory, and the
185+// DECLARED static environment. Secret env vars are not edited here: a stdio
186+// server declares them as a `stdio_env` auth method and their values live on
187+// the connection, managed from the integration page's accounts hub. Changes
188+// are staged and applied by the sheet's Save, like the remote editor above.
178189 // ---------------------------------------------------------------------------
179190
180−function StdioReadOnly(props: {
181− server: McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> };
191+type McpStdioConfig = Extract<McpIntegrationConfig, { transport: "stdio" }>;
192+
193+/** Order-independent identity for a declared env map, so re-ordering the lines
194+ * in the field is not reported as a change. */
195+const envIdentity = (env: Readonly<Record<string, string>> | undefined): string =>
196+ Object.entries(env ?? {})
197+ .sort(([a], [b]) => a.localeCompare(b))
198+ .map(([key, value]) => `${key}=${value}`)
199+ .join("\n");
200+
201+function StdioEdit(props: {
202+ server: McpServer & { config: McpStdioConfig };
203+ onPendingChange?: EditSheetSectionProps["onPendingChange"];
182204 }) {
183− const { command, args } = props.server.config;
205+ const { server } = props;
206+ const doConfigure = useAtomSet(configureMcpServer, { mode: "promiseExit" });
207+
208+ const [command, setCommand] = useState(server.config.command);
209+ const [args, setArgs] = useState(() => formatStdioArgs(server.config.args));
210+ const [cwd, setCwd] = useState(server.config.cwd ?? "");
211+ const [env, setEnv] = useState(() => formatStdioEnv(server.config.env));
212+ const [error, setError] = useState<string | null>(null);
213+
214+ // The edited config. `configureServer` replaces the whole blob, so anything
215+ // this form does not surface (`versionNegotiation`, `authenticationTemplate`)
216+ // is carried through untouched. Empty optional fields are omitted rather than
217+ // written as `undefined`.
218+ const edited = useMemo<McpStdioConfig>(() => {
219+ const { args: _args, cwd: _cwd, env: _env, ...rest } = server.config;
220+ const nextArgs = parseStdioArgs(args);
221+ const nextEnv = parseStdioEnv(env);
222+ const nextCwd = cwd.trim();
223+ return {
224+ ...rest,
225+ command: command.trim(),
226+ ...(nextArgs.length > 0 ? { args: nextArgs } : {}),
227+ ...(Object.keys(nextEnv).length > 0 ? { env: nextEnv } : {}),
228+ ...(nextCwd !== "" ? { cwd: nextCwd } : {}),
229+ };
230+ }, [args, command, cwd, env, server.config]);
231+
232+ const changed =
233+ edited.command !== server.config.command ||
234+ formatStdioArgs(edited.args) !== formatStdioArgs(server.config.args) ||
235+ (edited.cwd ?? "") !== (server.config.cwd ?? "") ||
236+ envIdentity(edited.env) !== envIdentity(server.config.env);
237+
238+ // Staged apply, run by the sheet's Save. Persisting the config re-runs
239+ // discovery on every connection, so the tool catalog matches the new command.
240+ const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => {
241+ setError(null);
242+ if (edited.command === "") {
243+ setError("A command is required.");
244+ return { ok: false };
245+ }
246+ const exit = await doConfigure({
247+ params: { slug: server.slug },
248+ payload: { config: edited },
249+ reactivityKeys: integrationWriteKeys,
250+ });
251+ if (Exit.isFailure(exit)) {
252+ setError(errorMessageFromExit(exit, "Failed to update the server command"));
253+ return { ok: false };
254+ }
255+ return { ok: true, summary: "Server command updated." };
256+ }, [doConfigure, edited, server.slug]);
257+
258+ const onPendingChangeRef = useRef(props.onPendingChange);
259+ onPendingChangeRef.current = props.onPendingChange;
260+ useEffect(() => {
261+ onPendingChangeRef.current?.(changed ? applyStaged : null);
262+ return () => onPendingChangeRef.current?.(null);
263+ }, [changed, applyStaged]);
264+
184265 return (
185− <div className="space-y-3 border-t border-border/60 pt-5">
266+ <div className="space-y-4 border-t border-border/60 pt-5">
186267 <div className="space-y-1">
187268 <p className="text-sm font-medium text-foreground">Server command</p>
188269 <p className="text-xs text-muted-foreground">
189− Stdio MCP integrations cannot be edited. Remove and recreate the integration with the
190− updated command.
270+ Changes apply when you save. The server's tools are then rediscovered with the new
271+ command.
191272 </p>
192273 </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>
197− <Badge variant="secondary" className="text-xs">
198− stdio
199− </Badge>
200− </div>
274+
275+ <CardStack>
276+ <CardStackContent className="border-t-0">
277+ <CardStackEntryField
278+ label="Command"
279+ description="- The executable to run (e.g. npx, uvx, node)."
280+ >
281+ <Input
282+ value={command}
283+ aria-label="Command"
284+ onChange={(e) => setCommand((e.target as HTMLInputElement).value)}
285+ placeholder="npx"
286+ className="font-mono text-sm"
287+ />
288+ </CardStackEntryField>
289+
290+ <CardStackEntryField
291+ label="Arguments"
292+ description="- Space-separated arguments passed to the command."
293+ >
294+ <Input
295+ value={args}
296+ aria-label="Arguments"
297+ onChange={(e) => setArgs((e.target as HTMLInputElement).value)}
298+ placeholder="-y chrome-devtools-mcp@latest"
299+ className="font-mono text-sm"
300+ />
301+ </CardStackEntryField>
302+
303+ <CardStackEntryField
304+ label="Working directory"
305+ description="- Optional. Where the command runs."
306+ >
307+ <Input
308+ value={cwd}
309+ aria-label="Working directory"
310+ onChange={(e) => setCwd((e.target as HTMLInputElement).value)}
311+ placeholder="/path/to/server"
312+ className="font-mono text-sm"
313+ />
314+ </CardStackEntryField>
315+
316+ <CardStackEntryField
317+ label="Environment variables"
318+ description="- One KEY=value per line. The server receives these plus a small base set; it does not inherit executor's environment."
319+ >
320+ <Textarea
321+ value={env}
322+ aria-label="Environment variables"
323+ onChange={(e) => setEnv((e.target as HTMLTextAreaElement).value)}
324+ placeholder={"LOG_LEVEL=debug\nREGION=eu-west-1"}
325+ className="font-mono text-sm"
326+ />
327+ </CardStackEntryField>
328+ </CardStackContent>
329+ </CardStack>
330+
331+ <p className="text-xs text-muted-foreground">
332+ Secret values do not belong here. A server that needs an API key declares its variable name
333+ as an authentication method, and the value is entered per connection from the accounts
334+ panel.
335+ </p>
336+
337+ {error && <FormErrorAlert message={error} />}
201338 </div>
202339 );
203340 }
@@ -219,10 +356,9 @@ export default function EditMcpIntegration({ · 15 unmodified lines
219356
220357 if (server.config.transport === "stdio") {
221358 return (
222− <StdioReadOnly
223− server={
224− server as McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> }
225− }
359+ <StdioEdit
360+ server={server as McpServer & { config: McpStdioConfig }}
361+ {...(onPendingChange ? { onPendingChange } : {})}
226362 />
227363 );
228364 }
packages/plugins/mcp/src/react/McpRequestHeadersEditor.tsx+118−0
@@ -0,0 +1,118 @@
1+import { PlusIcon, XIcon } from "lucide-react";
2+
3+import { Button } from "@executor-js/react/components/button";
4+import {
5+ CardStack,
6+ CardStackContent,
7+ CardStackEntryField,
8+} from "@executor-js/react/components/card-stack";
9+import { FieldError } from "@executor-js/react/components/field";
10+import { Input } from "@executor-js/react/components/input";
11+
12+import { emptyHeaderRow, isValidHeaderName, type McpHeaderRow } from "./request-headers";
13+
14+// ---------------------------------------------------------------------------
15+// Request headers editor — the name/value pairs sent on every request to a
16+// remote MCP server, including the connection check.
17+//
18+// Deliberately plain: two mono fields and a remove control per row, matching
19+// the metadata voice the rest of the add flow uses. Rows are keyed by index
20+// because the values are fully controlled, exactly as the shared placement
21+// editor does it.
22+// ---------------------------------------------------------------------------
23+
24+export function McpRequestHeadersEditor(props: {
25+ readonly rows: readonly McpHeaderRow[];
26+ readonly onChange: (rows: McpHeaderRow[]) => void;
27+ /** Re-run the connection check with the headers as typed. */
28+ readonly onTest?: () => void;
29+ readonly testing?: boolean;
30+}) {
31+ const { rows, onChange } = props;
32+
33+ const set = (index: number, patch: Partial<McpHeaderRow>): void =>
34+ onChange(rows.map((row, j) => (j === index ? { ...row, ...patch } : row)));
35+
36+ const remove = (index: number): void => onChange(rows.filter((_row, j) => j !== index));
37+
38+ const hasInvalidName = rows.some((row) => !isValidHeaderName(row.name));
39+
40+ return (
41+ <CardStack>
42+ <CardStackContent className="border-t-0">
43+ <CardStackEntryField
44+ label="Request headers"
45+ description="- Optional. Sent on every request, including the connection check."
46+ >
47+ {rows.length > 0 && (
48+ <div className="flex flex-col gap-2">
49+ {rows.map((row, index) => (
50+ <div key={index} className="flex items-center gap-2">
51+ <Input
52+ aria-label="Header name"
53+ value={row.name}
54+ onChange={(e) => set(index, { name: (e.target as HTMLInputElement).value })}
55+ placeholder="CF-Access-Client-Id"
56+ className="h-8 min-w-0 flex-1 font-mono text-xs"
57+ aria-invalid={isValidHeaderName(row.name) ? undefined : true}
58+ />
59+ <Input
60+ aria-label="Header value"
61+ value={row.value}
62+ onChange={(e) => set(index, { value: (e.target as HTMLInputElement).value })}
63+ placeholder="Value"
64+ className="h-8 min-w-0 flex-1 font-mono text-xs"
65+ />
66+ <Button
67+ type="button"
68+ variant="ghost"
69+ size="icon-sm"
70+ aria-label="Remove header"
71+ className="shrink-0 text-muted-foreground hover:text-foreground"
72+ onClick={() => remove(index)}
73+ >
74+ <XIcon />
75+ </Button>
76+ </div>
77+ ))}
78+ </div>
79+ )}
80+
81+ {hasInvalidName && (
82+ <FieldError>A header name cannot contain spaces or a colon.</FieldError>
83+ )}
84+
85+ <div className="flex flex-wrap items-center gap-2">
86+ <Button
87+ type="button"
88+ variant="outline"
89+ size="sm"
90+ className="w-fit border-dashed"
91+ onClick={() => onChange([...rows, emptyHeaderRow()])}
92+ >
93+ <PlusIcon />
94+ Add header
95+ </Button>
96+ {props.onTest && rows.length > 0 && (
97+ <Button
98+ type="button"
99+ variant="ghost"
100+ size="sm"
101+ onClick={props.onTest}
102+ loading={props.testing}
103+ >
104+ Test connection
105+ </Button>
106+ )}
107+ </div>
108+
109+ <p className="text-[11px] text-muted-foreground">
110+ Stored with the integration and sent verbatim. Use these for endpoint-level access
111+ tokens, such as a Cloudflare Access service token. A per-account credential belongs in
112+ an auth method instead.
113+ </p>
114+ </CardStackEntryField>
115+ </CardStackContent>
116+ </CardStack>
117+ );
118+}
packages/plugins/mcp/src/react/probe-url.test.ts+63−0
@@ -0,0 +1,63 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { isProbableMcpEndpoint } from "./probe-url";
4+
5+describe("isProbableMcpEndpoint", () => {
6+ it("rejects the prefixes a URL passes through while it is typed", () => {
7+ for (const typed of [
8+ "",
9+ " ",
10+ "h",
11+ "ht",
12+ "htt",
13+ "http",
14+ "http:",
15+ "http:/",
16+ "http://",
17+ "http://a",
18+ "http://example",
19+ "https://mcp",
20+ "https://mcp.",
21+ "https://.com",
22+ ]) {
23+ expect(isProbableMcpEndpoint(typed), typed).toBe(false);
24+ }
25+ });
26+
27+ it("accepts a finished http(s) endpoint", () => {
28+ for (const finished of [
29+ "https://mcp.example.com",
30+ "https://mcp.example.com/mcp",
31+ "http://example.com:4789/mcp?codemode=false",
32+ "https://example.co.uk/sse",
33+ " https://mcp.example.com/mcp ",
34+ "HTTPS://MCP.EXAMPLE.COM/mcp",
35+ ]) {
36+ expect(isProbableMcpEndpoint(finished), finished).toBe(true);
37+ }
38+ });
39+
40+ it("accepts local development hosts, which have no dot to wait for", () => {
41+ for (const local of [
42+ "http://localhost",
43+ "http://localhost:4789/mcp",
44+ "http://app.localhost:4789/mcp",
45+ "http://127.0.0.1:4789/mcp",
46+ "http://[::1]:4789/mcp",
47+ ]) {
48+ expect(isProbableMcpEndpoint(local), local).toBe(true);
49+ }
50+ });
51+
52+ it("rejects schemes the MCP remote transport cannot dial", () => {
53+ for (const wrongScheme of [
54+ "ftp://mcp.example.com",
55+ "ws://mcp.example.com",
56+ "file:///tmp/mcp",
57+ "mailto:someone@example.com",
58+ "npx @example/mcp-server",
59+ ]) {
60+ expect(isProbableMcpEndpoint(wrongScheme), wrongScheme).toBe(false);
61+ }
62+ });
63+});
packages/plugins/mcp/src/react/probe-url.ts+25−0
@@ -0,0 +1,25 @@
1+// The remote add form probes the endpoint as the user types, so the gate below
2+// decides which intermediate strings are worth dialling. Every keystroke is a
3+// prefix of the next one: "h", "ht", "http://a" all parse or fail in ways that
4+// tell us nothing, and probing them puts the field into a loading state and
5+// then an error state for a URL the user never meant to submit.
6+
7+/** Whether `value` looks like a finished MCP endpoint, and so is worth dialling
8+ * while the user is still typing. Format-only: it says nothing about whether a
9+ * server answers there. */
10+export const isProbableMcpEndpoint = (value: string): boolean => {
11+ const trimmed = value.trim();
12+ if (!URL.canParse(trimmed)) return false;
13+
14+ const url = new URL(trimmed);
15+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
16+
17+ const hostname = url.hostname.toLowerCase();
18+ // Local development servers have no dot to wait for.
19+ if (hostname === "localhost" || hostname.endsWith(".localhost")) return true;
20+ // An IPv6 literal arrives bracketed and is complete once it parses.
21+ if (hostname.startsWith("[")) return true;
22+ // Otherwise wait for a dot with a label on each side. A bare "example" is
23+ // still being typed; "example.com" is something we can dial.
24+ return hostname.includes(".") && !hostname.startsWith(".") && !hostname.endsWith(".");
25+};
packages/plugins/mcp/src/react/request-headers.test.ts+83−0
@@ -0,0 +1,83 @@
1+// ---------------------------------------------------------------------------
2+// The row -> wire conversion behind the request-headers editor. What matters
3+// is that a half-typed row never reaches the transport and that a pasted
4+// token still works, so both are pinned here.
5+// ---------------------------------------------------------------------------
6+
7+import { describe, expect, it } from "@effect/vitest";
8+
9+import { emptyHeaderRow, isValidHeaderName, mcpHeadersFromRows } from "./request-headers";
10+
11+describe("mcpHeadersFromRows", () => {
12+ it("returns undefined when nothing is configured", () => {
13+ expect(mcpHeadersFromRows([])).toBeUndefined();
14+ expect(mcpHeadersFromRows([emptyHeaderRow()])).toBeUndefined();
15+ });
16+
17+ it("builds the wire map from completed rows", () => {
18+ expect(
19+ mcpHeadersFromRows([
20+ { name: "CF-Access-Client-Id", value: "client-id" },
21+ { name: "CF-Access-Client-Secret", value: "client-secret" },
22+ ]),
23+ ).toEqual({
24+ "CF-Access-Client-Id": "client-id",
25+ "CF-Access-Client-Secret": "client-secret",
26+ });
27+ });
28+
29+ it("drops a row the user has not finished naming", () => {
30+ expect(
31+ mcpHeadersFromRows([
32+ { name: "X-Api-Key", value: "k" },
33+ { name: " ", value: "orphaned" },
34+ ]),
35+ ).toEqual({ "X-Api-Key": "k" });
36+ });
37+
38+ it("trims surrounding whitespace off a pasted token", () => {
39+ expect(mcpHeadersFromRows([{ name: " X-Token ", value: " abc123\n" }])).toEqual({
40+ "X-Token": "abc123",
41+ });
42+ });
43+
44+ it("drops a name that cannot be put on the wire", () => {
45+ expect(
46+ mcpHeadersFromRows([
47+ { name: "Bad Header", value: "v" },
48+ { name: "Also:Bad", value: "v" },
49+ { name: "Good-Header", value: "v" },
50+ ]),
51+ ).toEqual({ "Good-Header": "v" });
52+ });
53+
54+ it("keeps a header whose value is deliberately empty", () => {
55+ expect(mcpHeadersFromRows([{ name: "X-Empty", value: "" }])).toEqual({ "X-Empty": "" });
56+ });
57+
58+ it("lets a later row win a duplicated name", () => {
59+ expect(
60+ mcpHeadersFromRows([
61+ { name: "X-Token", value: "old" },
62+ { name: "X-Token", value: "new" },
63+ ]),
64+ ).toEqual({ "X-Token": "new" });
65+ });
66+});
67+
68+describe("isValidHeaderName", () => {
69+ it("accepts an RFC 7230 token", () => {
70+ expect(isValidHeaderName("CF-Access-Client-Id")).toBe(true);
71+ expect(isValidHeaderName("X_Api_Key")).toBe(true);
72+ });
73+
74+ it("treats a blank name as unfinished rather than invalid", () => {
75+ expect(isValidHeaderName("")).toBe(true);
76+ expect(isValidHeaderName(" ")).toBe(true);
77+ });
78+
79+ it("rejects a name with a space or a colon", () => {
80+ expect(isValidHeaderName("Bad Header")).toBe(false);
81+ expect(isValidHeaderName("Also:Bad")).toBe(false);
82+ });
83+});
packages/plugins/mcp/src/react/request-headers.ts+58−0
@@ -0,0 +1,58 @@
1+// ---------------------------------------------------------------------------
2+// Static request headers for a remote MCP server.
3+//
4+// Some MCP endpoints sit behind an edge authenticator rather than the MCP
5+// authorization spec — Cloudflare Access, for example, wants a
6+// `CF-Access-Client-Id` / `CF-Access-Client-Secret` pair on every request and
7+// answers 403 without them. Those are properties of the ENDPOINT, not of an
8+// account: every caller sends the same pair, and the server behind them still
9+// does its own MCP-level auth. They therefore belong in the integration's
10+// static `headers` config, which the probe and the live transport already
11+// read, and NOT in an auth method (an auth method carries one per-account
12+// credential value, so it cannot express a two-part token at all).
13+//
14+// The editor keeps rows rather than a map so a half-typed row survives a
15+// re-render. This module owns the row -> wire conversion.
16+// ---------------------------------------------------------------------------
17+
18+export type McpHeaderRow = {
19+ readonly name: string;
20+ readonly value: string;
21+};
22+
23+export const emptyHeaderRow = (): McpHeaderRow => ({ name: "", value: "" });
24+
25+/** RFC 7230 `token`: the only characters a header field name may contain.
26+ * A name outside this set makes the whole request unsendable, so it is
27+ * rejected in the editor instead of being discovered at connect time. */
28+const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
29+
30+/** Whether a row's name can be put on the wire. A blank name is not
31+ * "invalid" — it is an unfinished row, which the editor leaves alone. */
32+export const isValidHeaderName = (name: string): boolean => {
33+ const trimmed = name.trim();
34+ return trimmed.length === 0 || HEADER_NAME_PATTERN.test(trimmed);
35+};
36+
37+/**
38+ * Collapse editor rows into the wire `headers` map.
39+ *
40+ * Blank and malformed names are dropped: the first is a row the user has not
41+ * finished, the second cannot be sent at all. Names and values are trimmed —
42+ * RFC 7230 treats surrounding whitespace as no part of a field value, so a
43+ * token pasted with a trailing newline still works.
44+ *
45+ * Returns `undefined` when nothing is configured, so callers omit the field
46+ * rather than sending an empty object.
47+ */
48+export const mcpHeadersFromRows = (
49+ rows: readonly McpHeaderRow[],
50+): Record<string, string> | undefined => {
51+ const headers: Record<string, string> = {};
52+ for (const row of rows) {
53+ const name = row.name.trim();
54+ if (!name || !HEADER_NAME_PATTERN.test(name)) continue;
55+ headers[name] = row.value.trim();
56+ }
57+ return Object.keys(headers).length > 0 ? headers : undefined;
58+};
packages/plugins/mcp/src/react/stdio-fields.test.ts+70−0
@@ -0,0 +1,70 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields";
4+
5+describe("parseStdioArgs", () => {
6+ it("splits on whitespace", () => {
7+ expect(parseStdioArgs("-y chrome-devtools-mcp@latest")).toEqual([
8+ "-y",
9+ "chrome-devtools-mcp@latest",
10+ ]);
11+ });
12+
13+ it("keeps a double-quoted group intact", () => {
14+ expect(parseStdioArgs('--dir "/Users/a b/mcp" --port 3000')).toEqual([
15+ "--dir",
16+ "/Users/a b/mcp",
17+ "--port",
18+ "3000",
19+ ]);
20+ });
21+
22+ it("returns nothing for a blank field", () => {
23+ expect(parseStdioArgs(" ")).toEqual([]);
24+ });
25+});
26+
27+describe("formatStdioArgs", () => {
28+ it("re-quotes tokens containing whitespace so the round trip is lossless", () => {
29+ const args = ["--dir", "/Users/a b/mcp", "--port", "3000"];
30+ expect(parseStdioArgs(formatStdioArgs(args))).toEqual(args);
31+ });
32+
33+ it("renders an absent list as an empty field", () => {
34+ expect(formatStdioArgs(undefined)).toBe("");
35+ });
36+});
37+
38+describe("parseStdioEnv", () => {
39+ it("reads one KEY=value per line", () => {
40+ expect(parseStdioEnv("LOG_LEVEL=debug\nREGION=eu-west-1")).toEqual({
41+ LOG_LEVEL: "debug",
42+ REGION: "eu-west-1",
43+ });
44+ });
45+
46+ it("splits on the first = only, so a value may contain =", () => {
47+ expect(parseStdioEnv("DSN=postgres://u:p@host/db?a=b")).toEqual({
48+ DSN: "postgres://u:p@host/db?a=b",
49+ });
50+ });
51+
52+ it("keeps a declared-but-empty value", () => {
53+ expect(parseStdioEnv("NO_COLOR=")).toEqual({ NO_COLOR: "" });
54+ });
55+
56+ it("drops blank lines, comments, and lines with no key", () => {
57+ expect(parseStdioEnv("\n# a comment\n=orphan\nA=1\n")).toEqual({ A: "1" });
58+ });
59+});
60+
61+describe("formatStdioEnv", () => {
62+ it("round-trips a declared map", () => {
63+ const env = { LOG_LEVEL: "debug", DSN: "postgres://u:p@host/db?a=b" };
64+ expect(parseStdioEnv(formatStdioEnv(env))).toEqual(env);
65+ });
66+
67+ it("renders an absent map as an empty field", () => {
68+ expect(formatStdioEnv(undefined)).toBe("");
69+ });
70+});
packages/plugins/mcp/src/react/stdio-fields.ts+51−0
@@ -0,0 +1,51 @@
1+// ---------------------------------------------------------------------------
2+// Text ↔ config codecs for the stdio fields, shared by the add flow
3+// (`AddMcpIntegration`) and the edit sheet (`EditMcpIntegration`). Both render
4+// the same single-line arguments field and the same KEY=value environment
5+// block, so the parsing lives in one place and round-trips: a config that is
6+// formatted and re-parsed is unchanged.
7+// ---------------------------------------------------------------------------
8+
9+/** Split the raw arguments field into tokens, honoring double-quoted groups so
10+ * an argument with spaces stays intact. */
11+export function parseStdioArgs(raw: string): string[] {
12+ if (!raw.trim()) return [];
13+ const args: string[] = [];
14+ const regex = /[^\s"]+|"([^"]*)"/g;
15+ let match;
16+ while ((match = regex.exec(raw)) !== null) {
17+ args.push(match[1] ?? match[0]);
18+ }
19+ return args;
20+}
21+
22+/** Render stored arguments back into the single-line field, re-quoting any
23+ * token that contains whitespace so `parseStdioArgs` recovers it. */
24+export function formatStdioArgs(args: readonly string[] | undefined): string {
25+ return (args ?? []).map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" ");
26+}
27+
28+/** Parse the KEY=value block into the declared static env map. Splits on the
29+ * FIRST `=` only, so a value may itself contain `=`. Blank lines, `#`
30+ * comments, and lines without a key are dropped; an empty value is kept
31+ * (declaring a variable as empty is meaningful to a subprocess). */
32+export function parseStdioEnv(raw: string): Record<string, string> {
33+ const env: Record<string, string> = {};
34+ for (const line of raw.split("\n")) {
35+ const trimmed = line.trim();
36+ if (trimmed === "" || trimmed.startsWith("#")) continue;
37+ const separator = trimmed.indexOf("=");
38+ if (separator <= 0) continue;
39+ const key = trimmed.slice(0, separator).trim();
40+ if (key === "") continue;
41+ env[key] = trimmed.slice(separator + 1).trim();
42+ }
43+ return env;
44+}
45+
46+/** Render the stored env map back into the KEY=value block. */
47+export function formatStdioEnv(env: Readonly<Record<string, string>> | undefined): string {
48+ return Object.entries(env ?? {})
49+ .map(([key, value]) => `${key}=${value}`)
50+ .join("\n");
51+}
packages/plugins/mcp/src/sdk/catalog-sync.test.ts+104−1
@@ -42,11 +42,17 @@ const TEMPLATE = AuthTemplateSlug.make("none"); · 41 unmodified lines
4242
4343 const makeCatalogTestExecutor = (
4444 serverUrl: string,
45− options?: { readonly toolsSyncTtlMs?: number | null },
45+ options?: {
46+ readonly toolsSyncTtlMs?: number | null;
47+ readonly toolsSyncGraceMs?: number | null;
48+ },
4649 ) =>
4750 createExecutor({
4851 ...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
4952 ...(options?.toolsSyncTtlMs === undefined ? {} : { toolsSyncTtlMs: options.toolsSyncTtlMs }),
53+ ...(options?.toolsSyncGraceMs === undefined
54+ ? {}
55+ : { toolsSyncGraceMs: options.toolsSyncGraceMs }),
5056 }).pipe(
5157 Effect.tap((executor) =>
5258 Effect.gen(function* () {
@@ -355,6 +361,11 @@ describe("MCP stale-catalog refresh", () => { · 302 unmodified lines
355361 // Everything is expired on every read, so a single tools read has the
356362 // whole set to rebuild.
357363 toolsSyncTtlMs: 0,
364+ // Strict mode: the assertions below synchronize on the read fiber
365+ // completing only after every rebuild has finished. With a grace
366+ // budget the read would return early and `Fiber.join` would no longer
367+ // order the final listing before the count assertion.
368+ toolsSyncGraceMs: null,
358369 });
359370
360371 for (let index = 0; index < STALE_CONNECTIONS; index++) {
@@ -401,3 +412,95 @@ describe("MCP stale-catalog refresh", () => { · 40 unmodified lines
401412 }),
402413 );
403414 });
415+
416+// ---------------------------------------------------------------------------
417+// Stale-refresh grace budget.
418+//
419+// A tools read waits at most `toolsSyncGraceMs` for stale rebuilds, then
420+// answers from the persisted catalog while the rebuilds finish detached. One
421+// slow upstream server must bound neither the read nor convergence: the read
422+// serves the stale-but-working rows now, and a later read reflects the
423+// re-listed catalog once the server finally answers.
424+// ---------------------------------------------------------------------------
425+
426+const serveLatchedMutableServer = () =>
427+ Effect.gen(function* () {
428+ const catalog = yield* Ref.make("alpha");
429+ const armed = yield* Ref.make(false);
430+ const release = yield* Deferred.make<void>();
431+
432+ const server = yield* serveTestHttpApp((request) =>
433+ Effect.gen(function* () {
434+ if (request.method === "GET") {
435+ return HttpServerResponse.text("SSE disabled", { status: 405 });
436+ }
437+ const body = yield* request.text.pipe(Effect.orDie);
438+ const rpc = Option.getOrUndefined(decodeJsonRpcRequest(body));
439+ if (!rpc) {
440+ return HttpServerResponse.text("Invalid JSON-RPC fixture request", { status: 400 });
441+ }
442+ if (rpc.method === "initialize") {
443+ return jsonRpcResult(rpc, {
444+ protocolVersion: "2025-06-18",
445+ capabilities: { tools: { listChanged: true } },
446+ serverInfo: { name: "latched-mutable-fixture", version: "1.0.0" },
447+ });
448+ }
449+ if (rpc.method === "notifications/initialized") {
450+ return HttpServerResponse.text("", { status: 202 });
451+ }
452+ if (rpc.method !== "tools/list") {
453+ return HttpServerResponse.text("Unexpected JSON-RPC method", { status: 400 });
454+ }
455+ // Once armed, park every listing until released — the "slow server".
456+ if (yield* Ref.get(armed)) {
457+ yield* Deferred.await(release);
458+ }
459+ return jsonRpcResult(rpc, { tools: [pageTool(yield* Ref.get(catalog))] });
460+ }),
461+ );
462+
463+ return {
464+ url: server.url("/mcp"),
465+ rename: Ref.set(catalog, "beta"),
466+ arm: Ref.set(armed, true),
467+ release: Deferred.succeed(release, undefined),
468+ } as const;
469+ });
470+
471+describe("MCP stale-refresh grace budget", () => {
472+ // `it.live` (real clock): the grace timeout must actually fire while a real
473+ // HTTP listing stays parked.
474+ it.live("a read outlasting the grace serves the stored catalog, then converges", () =>
475+ Effect.gen(function* () {
476+ const fixture = yield* serveLatchedMutableServer();
477+ const executor = yield* makeCatalogTestExecutor(fixture.url, {
478+ // Every read finds the catalog expired, and waits at most 100ms.
479+ toolsSyncTtlMs: 0,
480+ toolsSyncGraceMs: 100,
481+ });
482+
483+ expect(toolNames(yield* executor.tools.list())).toContain("alpha");
484+
485+ // The server's catalog changes AND the server stops answering listings.
486+ yield* fixture.rename;
487+ yield* fixture.arm;
488+
489+ // The re-list is parked, so only the grace path can produce an answer —
490+ // and it is the persisted (stale) catalog, not a failure or a hang.
491+ expect(toolNames(yield* executor.tools.list())).toContain("alpha");
492+
493+ // Once the server answers, the detached rebuild lands and a later read
494+ // reflects the re-listed catalog.
495+ yield* fixture.release;
496+ const converged = yield* Effect.gen(function* () {
497+ while (true) {
498+ const names = toolNames(yield* executor.tools.list());
499+ if (names.includes("beta")) return names;
500+ yield* Effect.sleep("100 millis");
501+ }
502+ }).pipe(Effect.timeoutOption("10 seconds"));
503+ expect(Option.isSome(converged)).toBe(true);
504+ }),
505+ );
506+});
packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts+18−0
@@ -260,6 +260,24 @@ describe("describeMcpAuthMethods", () => { · 259 unmodified lines
260260 ).toEqual({ url: "https://mcp.posthog.com/mcp" });
261261 });
262262
263+ it("projects catalog family for remote and stdio integrations", () => {
264+ expect(
265+ describeMcpIntegrationDisplay(
266+ recordWith({
267+ transport: "remote",
268+ family: "cloudflare",
269+ endpoint: "https://mcp.cloudflare.com/mcp",
270+ authenticationTemplate: [{ slug: "none", kind: "none" }],
271+ }),
272+ ),
273+ ).toEqual({ url: "https://mcp.cloudflare.com/mcp", family: "cloudflare" });
274+ expect(
275+ describeMcpIntegrationDisplay(
276+ recordWith({ transport: "stdio", family: "design", command: "design-mcp" }),
277+ ),
278+ ).toEqual({ family: "design" });
279+ });
280+
263281 it("does not expose display metadata for stdio or malformed configs", () => {
264282 expect(
265283 describeMcpIntegrationDisplay(recordWith({ transport: "stdio", command: "run" })),
packages/plugins/mcp/src/sdk/index.ts+1−0
@@ -22,6 +22,7 @@ export { · 21 unmodified lines
2222 McpTransport,
2323 McpToolAnnotations,
2424 McpToolBinding,
25+ McpToolMeta,
2526 parseMcpIntegrationConfig,
2627 } from "./types";
2728
packages/plugins/mcp/src/sdk/manifest.ts+16−1
@@ -1,6 +1,6 @@
11 import { Option, Schema } from "effect";
22
3−import { McpToolAnnotations } from "./types";
3+import { McpToolAnnotations, McpToolMeta } from "./types";
44
55 // ---------------------------------------------------------------------------
66 // Output types
@@ -13,6 +13,8 @@ export interface McpToolManifestEntry { · 6 unmodified lines
1313 readonly inputSchema?: unknown;
1414 readonly outputSchema?: unknown;
1515 readonly annotations?: McpToolAnnotations;
16+ /** The tool's reserved MCP `_meta` map, carried through verbatim. */
17+ readonly _meta?: McpToolMeta;
1618 }
1719
1820 export interface McpServerMetadata {
@@ -38,6 +40,11 @@ const ListedTool = Schema.Struct({ · 19 unmodified lines
3840 parameters: Schema.optional(Schema.Unknown),
3941 outputSchema: Schema.optional(Schema.Unknown),
4042 annotations: Schema.optional(McpToolAnnotations),
43+ // `_meta` is opaque and entirely server-controlled, so it stays `Unknown`
44+ // here and is narrowed to the spec's map shape per entry. Declaring the map
45+ // inline would make one server's malformed `_meta` fail the whole-list
46+ // decode and drop every tool it advertises.
47+ _meta: Schema.optional(Schema.Unknown),
4148 });
4249
4350 const ListToolsResult = Schema.Struct({
@@ -62,6 +69,13 @@ const ServerInfo = Schema.Struct({ · 18 unmodified lines
6269 version: Schema.optional(Schema.String),
6370 });
6471
72+const decodeMcpToolMeta = Schema.decodeUnknownOption(McpToolMeta);
73+
74+/** Narrow a listed tool's `_meta` to the spec's map shape, dropping anything
75+ * else. The contents stay unknown and uninterpreted. */
76+const readToolMeta = (value: unknown): McpToolMeta | undefined =>
77+ value === undefined ? undefined : Option.getOrUndefined(decodeMcpToolMeta(value));
78+
6579 const decodeListToolsResult = Schema.decodeUnknownOption(ListToolsResult);
6680 const decodeListToolsPageOption = Schema.decodeUnknownOption(ListToolsPage);
6781 const decodeServerInfo = Schema.decodeUnknownOption(ServerInfo);
@@ -130,6 +144,7 @@ export const extractManifestFromListToolsResult = ( · 62 unmodified lines
130144 inputSchema: tool.inputSchema ?? tool.parameters,
131145 outputSchema: tool.outputSchema,
132146 annotations: tool.annotations,
147+ _meta: readToolMeta(tool._meta),
133148 },
134149 ];
135150 });
packages/plugins/mcp/src/sdk/plugin.test.ts+81−0
@@ -239,6 +239,45 @@ describe("extractManifestFromListToolsResult", () => { · 238 unmodified lines
239239 expect(result.tools[2]!.annotations).toBeUndefined();
240240 }),
241241 );
242+
243+ it.effect("carries the reserved `_meta` map through verbatim", () =>
244+ Effect.sync(() => {
245+ const meta = {
246+ serverName: "time",
247+ shortDescription: "Current time",
248+ defer_loading: false,
249+ nested: { any: ["shape"] },
250+ };
251+
252+ const result = extractManifestFromListToolsResult({
253+ tools: [
254+ {
255+ name: "time_get_current_time",
256+ description: "Get the current time",
257+ inputSchema: { type: "object" },
258+ _meta: meta,
259+ },
260+ { name: "no_meta", description: "Has no _meta" },
261+ ],
262+ });
263+
264+ expect(result.tools[0]!._meta).toEqual(meta);
265+ expect(result.tools[1]!._meta).toBeUndefined();
266+ }),
267+ );
268+
269+ // `_meta` is opaque and server-controlled, so a value that does not match the
270+ // spec's map shape must not take the rest of the list down with it.
271+ it.effect("ignores a malformed `_meta` without dropping the tool", () =>
272+ Effect.sync(() => {
273+ const result = extractManifestFromListToolsResult({
274+ tools: [{ name: "odd_meta", _meta: "not-a-map" }, { name: "plain" }],
275+ });
276+
277+ expect(result.tools.map((tool) => tool.toolName)).toEqual(["odd_meta", "plain"]);
278+ expect(result.tools[0]!._meta).toBeUndefined();
279+ }),
280+ );
242281 });
243282
244283 // ---------------------------------------------------------------------------
@@ -346,6 +385,23 @@ describe("mcpPlugin", () => { · 101 unmodified lines
346385 }),
347386 );
348387
388+ it.effect("projects an MCP server family into the integration catalog", () =>
389+ Effect.gen(function* () {
390+ const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
391+ yield* executor.mcp.addServer({
392+ name: "Cloudflare Docs",
393+ family: "cloudflare",
394+ endpoint: "https://example.com/mcp",
395+ slug: "cloudflare_docs",
396+ });
397+
398+ const integrations = yield* executor.integrations.list();
399+ expect(integrations.find((item) => item.slug === "cloudflare_docs")?.family).toBe(
400+ "cloudflare",
401+ );
402+ }),
403+ );
404+
349405 it.effect("connection tools list is empty until a connection is created", () =>
350406 Effect.gen(function* () {
351407 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
@@ -1006,6 +1062,31 @@ describe("MCP destructiveHint → requiresApproval", () => { · 654 unmodified lines
10061062 expect(deleteTitled?.annotations?.approvalDescription).toBe("Delete dataset");
10071063 }),
10081064 );
1065+
1066+ // Executor's `Tool` has no `_meta` field, so the reserved MCP map rides in
1067+ // the `mcp` stamp the plugin persists into the tool row's annotations. A host
1068+ // embedding the plugin reads it back from there.
1069+ it.effect("persists the tool's reserved `_meta` into the catalog stamp", () =>
1070+ Effect.gen(function* () {
1071+ const server = yield* serveAnnotationsTestServer;
1072+ const executor = yield* seedAnnotationsExecutor(server.url);
1073+
1074+ const tools = yield* executor.tools.list();
1075+
1076+ const stamped = tools.find((t) => String(t.name) === "meta_stamped");
1077+ expect(stamped?.annotations).toMatchObject({
1078+ mcp: {
1079+ toolName: "meta_stamped",
1080+ _meta: { serverName: "time", shortDescription: "Current time", defer_loading: false },
1081+ },
1082+ });
1083+
1084+ const ping = tools.find((t) => String(t.name) === "ping");
1085+ expect(
1086+ (ping?.annotations as { readonly mcp?: { readonly _meta?: unknown } })?.mcp?._meta,
1087+ ).toBeUndefined();
1088+ }),
1089+ );
10091090 });
10101091
10111092 describe("userFacingProbeMessage", () => {
packages/plugins/mcp/src/sdk/plugin.ts+26−8
@@ -60,6 +60,7 @@ import { · 59 unmodified lines
6060 McpRemoteTransport,
6161 type McpAuthMethod,
6262 type McpToolAnnotations,
63+ McpToolMeta,
6364 expandMcpAuthMethodInputs,
6465 mcpAuthMethodFromShorthand,
6566 normalizeMcpAuthMethods,
@@ -139,14 +140,17 @@ const legacyMcpClientMatches = ( · 73 unmodified lines
139140 // ---------------------------------------------------------------------------
140141 // Tool annotations carry an `mcp` envelope alongside the executor's policy
141142 // hints. The executor persists `ToolDef.annotations` verbatim into the tool
142−// row's JSON column, so the real MCP tool name + upstream annotations survive
143−// to `invokeTool` / `resolveAnnotations` with no plugin-side store (resolveTools
144−// has no ctx to write one anyway). The envelope is opaque to core.
143+// row's JSON column, so the real MCP tool name, the upstream annotations, and
144+// the tool's reserved `_meta` map survive to `invokeTool` /
145+// `resolveAnnotations` with no plugin-side store (resolveTools has no ctx to
146+// write one anyway). The envelope is opaque to core.
145147 // ---------------------------------------------------------------------------
146148
147149 interface McpToolStamp {
148150 readonly toolName: string;
149151 readonly upstream?: McpToolAnnotations;
152+ /** The tool's reserved MCP `_meta` map, opaque to core and to the model. */
153+ readonly _meta?: McpToolMeta;
150154 }
151155
152156 type StampedAnnotations = ToolAnnotations & { readonly mcp: McpToolStamp };
@@ -162,6 +166,7 @@ const McpStampSchema = Schema.Struct({ · 9 unmodified lines
162166 openWorldHint: Schema.optional(Schema.Boolean),
163167 }),
164168 ),
169+ _meta: Schema.optional(McpToolMeta),
165170 });
166171 const AnnotationsWithStamp = Schema.Struct({ mcp: McpStampSchema });
167172 const decodeStamp = Schema.decodeUnknownOption(AnnotationsWithStamp);
@@ -181,6 +186,8 @@ const readStamp = (annotations: unknown): McpToolStamp | null => · 13 unmodified lines
181186 const McpRemoteServerInputSchema = Schema.Struct({
182187 transport: Schema.optional(Schema.Literal("remote")),
183188 name: Schema.String,
189+ /** Optional catalog family used to group related integrations. */
190+ family: Schema.optional(Schema.String),
184191 /** Agent-visible catalog description. Defaults to the display name. */
185192 description: Schema.optional(Schema.String),
186193 endpoint: Schema.String,
@@ -198,6 +205,8 @@ const McpRemoteServerInputSchema = Schema.Struct({ · 11 unmodified lines
198205 const McpStdioServerInputSchema = Schema.Struct({
199206 transport: Schema.Literal("stdio"),
200207 name: Schema.String,
208+ /** Optional catalog family used to group related integrations. */
209+ family: Schema.optional(Schema.String),
201210 description: Schema.optional(Schema.String),
202211 command: Schema.String,
203212 args: Schema.optional(Schema.Array(Schema.String)),
@@ -375,6 +384,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 171 unmodified lines
375384 const vars = stdioEnvVarNames(input);
376385 return {
377386 transport: "stdio",
387+ family: input.family?.trim() || undefined,
378388 command: input.command,
379389 args: input.args ? [...input.args] : undefined,
380390 cwd: input.cwd,
@@ -387,6 +397,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 6 unmodified lines
387397 }
388398 return {
389399 transport: "remote",
400+ family: input.family?.trim() || undefined,
390401 endpoint: input.endpoint,
391402 remoteTransport: input.remoteTransport ?? "auto",
392403 queryParams: input.queryParams,
@@ -427,13 +438,16 @@ const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonS · 34 unmodified lines
427438 };
428439
429440 /** Build the executor-facing ToolDef for one discovered MCP tool, stamping the
430− * real MCP tool name + upstream annotations into the persisted annotations so
431− * they survive to invokeTool with no plugin-side store. */
441+ * real MCP tool name, the upstream annotations, and the tool's `_meta` map
442+ * into the persisted annotations so they survive to invokeTool with no
443+ * plugin-side store. Executor's `ToolDef` has no `_meta` field of its own, so
444+ * the stamp is where a host reads it back. */
432445 const toToolDef = (entry: McpToolManifestEntry): ToolDef => {
433446 const destructive = entry.annotations?.destructiveHint === true;
434447 const stamp: McpToolStamp = {
435448 toolName: entry.toolName,
436449 ...(entry.annotations ? { upstream: entry.annotations } : {}),
450+ ...(entry._meta ? { _meta: entry._meta } : {}),
437451 };
438452 const annotations: StampedAnnotations = {
439453 requiresApproval: destructive,
@@ -753,10 +767,14 @@ export const describeMcpAuthMethods = ( · 313 unmodified lines
753767
754768 export const describeMcpIntegrationDisplay = (
755769 record: IntegrationRecord,
756−): { readonly url?: string } => {
770+): { readonly url?: string; readonly family?: string } => {
757771 const config = parseMcpIntegrationConfig(record.config);
758− if (!config || config.transport === "stdio") return {};
759− return { url: config.endpoint };
772+ if (!config) return {};
773+ const family = config.family?.trim();
774+ return {
775+ ...(config.transport === "remote" ? { url: config.endpoint } : {}),
776+ ...(family ? { family } : {}),
777+ };
760778 };
761779
762780 // ---------------------------------------------------------------------------
packages/plugins/mcp/src/sdk/probe-shape.test.ts+90−0
@@ -267,6 +267,96 @@ describe("probeMcpEndpointShape", () => { · 266 unmodified lines
267267 ),
268268 );
269269
270+ // Cloudflare Access shape: an edge authenticator in front of the MCP
271+ // server answers an unauthenticated request with `403` and an HTML
272+ // login page. The MCP server is never reached, so there is no Bearer
273+ // challenge and no JSON-RPC body. This must read as "supply
274+ // credentials", not as "this URL is not MCP" — the latter told users
275+ // the endpoint was unreachable when it was merely protected.
276+ it.effect("classifies a 403 HTML edge challenge as auth-required", () =>
277+ withServer(
278+ () =>
279+ HttpServerResponse.text("<html><body>Sign in</body></html>", {
280+ status: 403,
281+ contentType: "text/html",
282+ }),
283+ (endpoint) =>
284+ Effect.gen(function* () {
285+ const result = yield* probeMcpEndpointShape(endpoint);
286+ expect(result).toMatchObject({ kind: "not-mcp", category: "auth-required" });
287+ }),
288+ ),
289+ );
290+
291+ it.effect("classifies 403 with Bearer + JSON-RPC error envelope as MCP+auth", () =>
292+ withServer(
293+ () =>
294+ HttpServerResponse.jsonUnsafe(
295+ {
296+ jsonrpc: "2.0",
297+ id: null,
298+ error: { code: -32000, message: "Forbidden" },
299+ },
300+ { status: 403, headers: { "www-authenticate": "Bearer" } },
301+ ),
302+ (endpoint) =>
303+ Effect.gen(function* () {
304+ const result = yield* probeMcpEndpointShape(endpoint);
305+ expect(result).toEqual({ kind: "mcp", requiresAuth: true });
306+ }),
307+ ),
308+ );
309+
310+ it.effect("rejects a 403 whose Bearer challenge carries a GraphQL body", () =>
311+ withServer(
312+ () =>
313+ HttpServerResponse.jsonUnsafe(
314+ { errors: [{ message: "Forbidden" }] },
315+ { status: 403, headers: { "www-authenticate": "Bearer" } },
316+ ),
317+ (endpoint) =>
318+ Effect.gen(function* () {
319+ const result = yield* probeMcpEndpointShape(endpoint);
320+ expect(result).toMatchObject({ kind: "not-mcp", category: "auth-required" });
321+ }),
322+ ),
323+ );
324+
325+ // The other half of the Cloudflare Access story: once the service-token
326+ // headers are configured, the same endpoint answers normally. Proves the
327+ // probe actually puts `options.headers` on the wire.
328+ it.effect("sends configured request headers and clears an edge challenge", () =>
329+ Effect.scoped(
330+ Effect.gen(function* () {
331+ const server = yield* serveProbeEndpoint((request) => {
332+ if (request.headers["cf-access-client-id"] !== "client-id") {
333+ return HttpServerResponse.text("<html>Sign in</html>", {
334+ status: 403,
335+ contentType: "text/html",
336+ });
337+ }
338+ return HttpServerResponse.jsonUnsafe({
339+ jsonrpc: "2.0",
340+ id: 1,
341+ result: {
342+ protocolVersion: "2025-06-18",
343+ capabilities: {},
344+ serverInfo: { name: "t", version: "0" },
345+ },
346+ });
347+ });
348+
349+ const blocked = yield* probeMcpEndpointShape(server.endpoint);
350+ expect(blocked).toMatchObject({ kind: "not-mcp", category: "auth-required" });
351+
352+ const allowed = yield* probeMcpEndpointShape(server.endpoint, {
353+ headers: { "CF-Access-Client-Id": "client-id" },
354+ });
355+ expect(allowed).toEqual({ kind: "mcp", requiresAuth: false });
356+ }),
357+ ),
358+ );
359+
270360 it.effect("falls back to GET for OAuth-protected SSE endpoints", () =>
271361 withServer(
272362 (request) => {
packages/plugins/mcp/src/sdk/probe-shape.ts+28−17
@@ -20,10 +20,12 @@ · 19 unmodified lines
2020 // transport, body is an SSE stream we don't consume.
2121 // - 2xx with `Content-Type: application/json` whose body parses as a
2222 // JSON-RPC 2.0 envelope (`{jsonrpc:"2.0", result|error|method,...}`).
23−// - 401 with `WWW-Authenticate: Bearer` AND a JSON-RPC error envelope
24−// in the body. The body shape is what separates a real MCP server
25−// from an unrelated OAuth-protected API: GraphQL/REST/HTML 401s
26−// don't shape themselves as JSON-RPC.
23+// - 401 (or 403) with `WWW-Authenticate: Bearer` AND a JSON-RPC error
24+// envelope in the body. The body shape is what separates a real MCP
25+// server from an unrelated OAuth-protected API: GraphQL/REST/HTML
26+// 401s don't shape themselves as JSON-RPC. 403 travels the same path
27+// because an edge authenticator (Cloudflare Access) answers with 403
28+// and the endpoint is not therefore unreachable.
2729 //
2830 // When POST returns 404/405/406/415 we retry with GET + `Accept:
2931 // text/event-stream` to support legacy SSE-only servers; that path
@@ -82,6 +84,16 @@ const readHeader = (headers: Readonly<Record<string, string>>, name: string): st · 52 unmodified lines
8284 return null;
8385 };
8486
87+/** Statuses that mean "you are not authenticated for this resource".
88+ *
89+ * 401 is what the MCP authorization spec mandates. 403 is what an edge
90+ * authenticator in front of the server returns instead — Cloudflare
91+ * Access, for example, answers an unauthenticated request with 403 and
92+ * never reaches the MCP server at all. Both say the same thing to the
93+ * user: supply credentials and retry. Treating 403 as a wrong shape
94+ * told them the URL was wrong, which it was not. */
95+const isAuthStatus = (status: number): boolean => status === 401 || status === 403;
96+
8597 class ProbeTransportError extends Data.TaggedError("ProbeTransportError")<{
8698 readonly reason: string;
8799 readonly cause: unknown;
@@ -213,9 +225,9 @@ const reasonFromBoundaryCause = (cause: unknown): string => { · 125 unmodified lines
213225
214226 /** Why the probe rejected an endpoint as not-MCP.
215227 *
216− * - `auth-required` — server returned 401. We don't know for sure it's
217− * an MCP server (no spec-compliant Bearer challenge or the body
218− * isn't JSON-RPC), but the right next step for the user is the same
228+ * - `auth-required` — server returned 401 or 403. We don't know for
229+ * sure it's an MCP server (no spec-compliant Bearer challenge or the
230+ * body isn't JSON-RPC), but the right next step for the user is the same
219231 * either way: provide credentials and retry. This is what
220232 * misclassifies real MCP servers like cubic.dev (no
221233 * resource_metadata) or ref.tools (no WWW-Authenticate at all)
@@ -227,7 +239,7 @@ export type McpProbeRejectCategory = "auth-required" | "wrong-shape"; · 5 unmodified lines
227239
228240 export type McpShapeProbeResult =
229241 /** Server answered initialize successfully — either a 2xx with a
230− * JSON-RPC payload, or a 401 + WWW-Authenticate: Bearer (RFC 6750
242+ * JSON-RPC payload, or a 401/403 + WWW-Authenticate: Bearer (RFC 6750
231243 * challenge) that the MCP auth spec requires. */
232244 | { readonly kind: "mcp"; readonly requiresAuth: boolean }
233245 /** Endpoint is reachable but the response does not look like MCP. */
@@ -253,7 +265,7 @@ export interface ProbeOptions { · 19 unmodified lines
253265 *
254266 * Returns `{kind: "mcp"}` only when the endpoint either:
255267 * - answers with 2xx (unauth-OK MCP server), or
256− * - responds 401 with a `Bearer` WWW-Authenticate challenge.
268+ * - responds 401 or 403 with a `Bearer` WWW-Authenticate challenge.
257269 *
258270 * Anything else (400, 404, 200-with-HTML, 200-with-GraphQL-errors, ...)
259271 * is classified `not-mcp`. Transport errors surface as `unreachable`.
@@ -287,20 +299,20 @@ export const probeMcpEndpointShape = ( · 27 unmodified lines
287299 const contentType = readHeader(response.headers, "content-type") ?? "";
288300 const isSse = /^\s*text\/event-stream\b/i.test(contentType);
289301
290− if (response.status === 401) {
302+ if (isAuthStatus(response.status)) {
291303 const wwwAuth = readHeader(response.headers, "www-authenticate");
292304 if (!wwwAuth || !/^\s*bearer\b/i.test(wwwAuth)) {
293− // Spec-non-compliant 401 (no `Bearer` challenge). Before
294− // giving up, check whether the server still publishes
295− // RFC 9728 protected-resource metadata for this path —
296− // some real MCP servers (Datadog) do exactly this.
305+ // Spec-non-compliant challenge (no `Bearer`). Before giving
306+ // up, check whether the server still publishes RFC 9728
307+ // protected-resource metadata for this path — some real MCP
308+ // servers (Datadog) do exactly this.
297309 if (yield* probeProtectedResourceMetadata(client, url, timeoutMs)) {
298310 return { kind: "mcp", requiresAuth: true } as const;
299311 }
300312 return {
301313 kind: "not-mcp",
302314 category: "auth-required",
303− reason: "401 without Bearer WWW-Authenticate — not an MCP auth challenge",
315+ reason: `${response.status} without Bearer WWW-Authenticate — not an MCP auth challenge`,
304316 } as const;
305317 }
306318 // Spec-compliant MCP signal: the auth spec mandates a
@@ -347,8 +359,7 @@ export const probeMcpEndpointShape = ( · 40 unmodified lines
347359 return {
348360 kind: "not-mcp",
349361 category: "auth-required",
350− reason:
351− "401 + Bearer without resource_metadata, JSON-RPC body, or OAuth error body",
362+ reason: `${response.status} + Bearer without resource_metadata, JSON-RPC body, or OAuth error body`,
352363 } as const;
353364 }
354365 return { kind: "mcp", requiresAuth: true } as const;
packages/plugins/mcp/src/sdk/types.ts+14−0
@@ -215,6 +215,8 @@ const StringMap = Schema.Record(Schema.String, Schema.String); · 214 unmodified lines
215215
216216 export const McpRemoteIntegrationConfig = Schema.Struct({
217217 transport: Schema.Literal("remote"),
218+ /** Optional catalog family used to group related integrations. */
219+ family: Schema.optional(Schema.String),
218220 /** The MCP server endpoint URL */
219221 endpoint: Schema.String,
220222 /** Transport preference for this remote server */
@@ -234,6 +236,8 @@ export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type; · 13 unmodified lines
234236
235237 export const McpStdioIntegrationConfig = Schema.Struct({
236238 transport: Schema.Literal("stdio"),
239+ /** Optional catalog family used to group related integrations. */
240+ family: Schema.optional(Schema.String),
237241 /** The command to run */
238242 command: Schema.String,
239243 /** Arguments to the command */
@@ -287,6 +291,16 @@ export const McpToolAnnotations = Schema.Struct({ · 47 unmodified lines
287291 });
288292 export type McpToolAnnotations = typeof McpToolAnnotations.Type;
289293
294+// ---------------------------------------------------------------------------
295+// Tool `_meta` — the reserved, implementation-defined map the MCP spec puts on
296+// `Tool`. It is opaque to the executor and to the model: servers use it for
297+// host-only routing and policy hints that do not belong in the closed
298+// `annotations` set. It is carried through verbatim, never interpreted.
299+// ---------------------------------------------------------------------------
300+
301+export const McpToolMeta = Schema.Record(Schema.String, Schema.Unknown);
302+export type McpToolMeta = typeof McpToolMeta.Type;
303+
290304 // ---------------------------------------------------------------------------
291305 // Tool binding — maps a persisted (sanitized) tool name back to its real MCP
292306 // tool name and upstream annotations, persisted per-connection so invokeTool
packages/plugins/mcp/src/testing/server.ts+12−0
@@ -634,5 +634,17 @@ export const makeAnnotationsMcpServer = () => { · 633 unmodified lines
634634 async () => ({ content: [] }),
635635 );
636636
637+ // Host-only routing/policy hints the MCP spec reserves on `Tool._meta`. They
638+ // are not part of the closed `annotations` set and are never shown to a model.
639+ server.registerTool(
640+ "meta_stamped",
641+ {
642+ description: "A tool carrying reserved `_meta`",
643+ inputSchema: {},
644+ _meta: { serverName: "time", shortDescription: "Current time", defer_loading: false },
645+ },
646+ async () => ({ content: [] }),
647+ );
648+
637649 return server;
638650 };
packages/react/src/components/add-account-modal.tsx+3−6
@@ -2382,15 +2382,12 @@ function AddAccountModalView(props: AddAccountModalProps) { · 2381 unmodified lines
23822382 // Non-modal for the same reason as the health-check editor sheet: a modal
23832383 // dialog's react-remove-scroll locks the wheel to the dialog subtree, so
23842384 // the operation combobox's PORTALED popup (and the modal body while it is
2385− // open) cannot scroll. The overlay still dims and outside-click still
2386− // closes; the portaled-popup guard in DialogContent keeps option clicks
2387− // from dismissing.
2385+ // open) cannot scroll. The overlay still dims, so the dialog keeps its
2386+ // modal look. This form holds credentials and a half-built auth method, so
2387+ // it takes DialogContent's default: an outside click does not dismiss it.
23882388 <Dialog open={open} onOpenChange={onOpenChange} modal={false}>
23892389 <DialogContent
23902390 forceOverlay
2391− onInteractOutside={
2392− oauthClientHandoff?.action === "reconnect" ? (event) => event.preventDefault() : undefined
2393− }
23942391 className={cn(
23952392 "max-h-[85vh] overflow-x-hidden overflow-y-auto",
23962393 (addingMethod && createCustomMethod) || oauthRegistering || oauthEditing
packages/react/src/components/command.tsx+6−0
@@ -26,12 +26,17 @@ function CommandDialog({ · 25 unmodified lines
2626 children,
2727 className,
2828 showCloseButton = true,
29+ dismissOnOutsideClick = true,
2930 ...props
3031 }: React.ComponentProps<typeof Dialog> & {
3132 title?: string;
3233 description?: string;
3334 className?: string;
3435 showCloseButton?: boolean;
36+ /** A command palette holds nothing but a search string, and clicking away is
37+ * the expected way to leave it, so this stays on — unlike `DialogContent`,
38+ * whose default protects form state. */
39+ dismissOnOutsideClick?: boolean;
3540 }) {
3641 return (
3742 <Dialog {...props}>
@@ -42,6 +47,7 @@ function CommandDialog({ · 4 unmodified lines
4247 <DialogContent
4348 className={cn("overflow-hidden p-0", className)}
4449 showCloseButton={showCloseButton}
50+ dismissOnOutsideClick={dismissOnOutsideClick}
4551 >
4652 <Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
4753 {children}
packages/react/src/components/dialog.tsx+15−12
@@ -2,6 +2,7 @@ import * as React from "react"; · 1 unmodified line
22 import { XIcon } from "lucide-react";
33 import { Dialog as DialogPrimitive } from "radix-ui";
44
5+import { applyOutsideDismissPolicy } from "../lib/outside-dismiss";
56 import { cn } from "../lib/utils";
67 import { Button } from "./button";
78
@@ -37,25 +38,26 @@ function DialogOverlay({ · 29 unmodified lines
3738 );
3839 }
3940
40−// base-ui popups (combobox/select) portal their list OUTSIDE the dialog content,
41−// so clicking an option reads as an interaction outside the dialog and would
42−// dismiss it before the selection lands. Keep the dialog open for interactions
43−// that originate inside such a popup.
44−const PORTALED_POPUP_SELECTOR = "[data-slot='combobox-content'],[data-slot='select-content']";
45−
4641 function DialogContent({
4742 className,
4843 children,
4944 showCloseButton = true,
45+ dismissOnOutsideClick = false,
5046 onInteractOutside,
47+ onPointerDownOutside,
5148 forceOverlay = false,
5249 ...props
5350 }: React.ComponentProps<typeof DialogPrimitive.Content> & {
5451 showCloseButton?: boolean;
52+ /** Let a click outside the dialog close it. Off by default: a stray click on
53+ * the page behind a form must not discard what the user typed. Turn it on
54+ * for a dialog with nothing to lose — a confirmation, a picker, a read-only
55+ * panel. Escape and the close button close either way. */
56+ dismissOnOutsideClick?: boolean;
5557 /** Pair with `<Dialog modal={false}>`: Radix renders no overlay in non-modal
5658 * mode, so this renders a plain dim layer instead. It still eats outside
57− * clicks (which dismiss via onInteractOutside), so the dialog keeps its
58− * modal look while the wheel stays free for portaled popups. */
59+ * clicks, so the dialog keeps its modal look while the wheel stays free for
60+ * portaled popups. */
5961 forceOverlay?: boolean;
6062 }) {
6163 return (
@@ -66,12 +68,13 @@ function DialogContent({ · 4 unmodified lines
6668 ) : null}
6769 <DialogPrimitive.Content
6870 data-slot="dialog-content"
71+ onPointerDownOutside={(event) => {
72+ onPointerDownOutside?.(event);
73+ applyOutsideDismissPolicy(event, dismissOnOutsideClick);
74+ }}
6975 onInteractOutside={(event) => {
70− const target = event.detail.originalEvent.target;
71− if (target instanceof Element && target.closest(PORTALED_POPUP_SELECTOR)) {
72− event.preventDefault();
73− }
7476 onInteractOutside?.(event);
77+ applyOutsideDismissPolicy(event, dismissOnOutsideClick);
7578 }}
7679 className={cn(
7780 "fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
packages/react/src/components/remove-oauth-app-dialog.tsx+2−1
@@ -29,7 +29,8 @@ export function RemoveOAuthAppDialog(props: { · 28 unmodified lines
2929 const inUse = connections.length > 0;
3030 return (
3131 <Dialog open onOpenChange={(open: boolean) => (open ? undefined : props.onClose())}>
32− <DialogContent>
32+ {/* A confirmation with nothing to lose: clicking away cancels it. */}
33+ <DialogContent dismissOnOutsideClick>
3334 <DialogHeader>
3435 <DialogTitle>Remove {String(client.slug)}?</DialogTitle>
3536 <DialogDescription>
packages/react/src/components/sheet.tsx+13−10
@@ -4,6 +4,7 @@ import * as React from "react"; · 3 unmodified lines
44 import { XIcon } from "lucide-react";
55 import { Dialog as SheetPrimitive } from "radix-ui";
66
7+import { applyOutsideDismissPolicy } from "../lib/outside-dismiss";
78 import { cn } from "../lib/utils";
89
910 function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
@@ -38,34 +39,36 @@ function SheetOverlay({ · 28 unmodified lines
3839 );
3940 }
4041
41−// base-ui popups (combobox/select) portal their list OUTSIDE the dialog content,
42−// so clicking an option reads as an interaction outside the sheet and would
43−// dismiss it before the selection lands. Keep the sheet open for interactions
44−// that originate inside such a popup.
45−const PORTALED_POPUP_SELECTOR = "[data-slot='combobox-content'],[data-slot='select-content']";
46−
4742 function SheetContent({
4843 className,
4944 children,
5045 side = "right",
5146 showCloseButton = true,
47+ dismissOnOutsideClick = false,
5248 onInteractOutside,
49+ onPointerDownOutside,
5350 ...props
5451 }: React.ComponentProps<typeof SheetPrimitive.Content> & {
5552 side?: "top" | "right" | "bottom" | "left";
5653 showCloseButton?: boolean;
54+ /** Let a click outside the sheet close it. Off by default: a stray click on
55+ * the page behind a form must not discard what the user typed. Turn it on
56+ * for a sheet with nothing to lose — navigation, a picker, a read-only
57+ * panel. Escape and the close button close either way. */
58+ dismissOnOutsideClick?: boolean;
5759 }) {
5860 return (
5961 <SheetPortal>
6062 <SheetOverlay />
6163 <SheetPrimitive.Content
6264 data-slot="sheet-content"
65+ onPointerDownOutside={(event) => {
66+ onPointerDownOutside?.(event);
67+ applyOutsideDismissPolicy(event, dismissOnOutsideClick);
68+ }}
6369 onInteractOutside={(event) => {
64− const target = event.detail.originalEvent.target;
65− if (target instanceof Element && target.closest(PORTALED_POPUP_SELECTOR)) {
66− event.preventDefault();
67− }
6870 onInteractOutside?.(event);
71+ applyOutsideDismissPolicy(event, dismissOnOutsideClick);
6972 }}
7073 className={cn(
7174 "fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
packages/react/src/components/sidebar.tsx+3−0
@@ -168,6 +168,9 @@ function Sidebar({ · 167 unmodified lines
168168 return (
169169 <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
170170 <SheetContent
171+ // Navigation only. Tapping the page behind it is the normal way to
172+ // put the mobile nav away, and nothing is lost.
173+ dismissOnOutsideClick
171174 data-sidebar="sidebar"
172175 data-slot="sidebar"
173176 data-mobile="true"
packages/react/src/components/update-card.test.ts+58−0
@@ -0,0 +1,58 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { isUpdateAvailable } from "@executor-js/api";
4+
5+import { updateFetchChannel } from "./update-card";
6+
7+/**
8+ * `updateFetchChannel` is what lets the hook skip the network call for a
9+ * build that can never legitimately claim an update — the dev build's
10+ * unstamped fallback, or a prerelease channel with no published dist-tag.
11+ * Pinning it here is what keeps that decision testable without rendering.
12+ */
13+describe("updateFetchChannel", () => {
14+ it("fetches nothing for the unstamped dev build", () => {
15+ expect(updateFetchChannel("0.0.0-dev")).toBeNull();
16+ });
17+
18+ it("fetches nothing for the desktop's plain unstamped fallback", () => {
19+ expect(updateFetchChannel("0.0.0")).toBeNull();
20+ });
21+
22+ it("fetches nothing when there is no version yet", () => {
23+ expect(updateFetchChannel(undefined)).toBeNull();
24+ });
25+
26+ it("fetches the latest tag for a release build", () => {
27+ expect(updateFetchChannel("1.6.0")).toBe("latest");
28+ });
29+
30+ it("fetches the beta tag for a beta prerelease", () => {
31+ expect(updateFetchChannel("1.6.0-beta.2")).toBe("beta");
32+ });
33+
34+ it("fetches nothing for a prerelease channel with no matching dist-tag", () => {
35+ expect(updateFetchChannel("1.6.0-rc.1")).toBeNull();
36+ });
37+});
38+
39+/**
40+ * The card's verdict is the shared `isUpdateAvailable`, so the fetch decision
41+ * above and the "should we show the card?" decision can never disagree.
42+ */
43+describe("isUpdateAvailable through the card's fetch channel", () => {
44+ it("stays quiet on the unstamped dev build even if a tag comes back", () => {
45+ expect(updateFetchChannel("0.0.0-dev")).toBeNull();
46+ expect(isUpdateAvailable("0.0.0-dev", "1.6.0")).toBe(false);
47+ });
48+
49+ it("flags a real update on the latest channel", () => {
50+ expect(updateFetchChannel("1.6.0")).toBe("latest");
51+ expect(isUpdateAvailable("1.6.0", "1.6.1")).toBe(true);
52+ });
53+
54+ it("flags a real update on the beta channel", () => {
55+ expect(updateFetchChannel("1.6.0-beta.1")).toBe("beta");
56+ expect(isUpdateAvailable("1.6.0-beta.1", "1.6.0-beta.2")).toBe(true);
57+ });
58+});
packages/react/src/components/update-card.tsx+33−11
@@ -13,11 +13,17 @@ · 12 unmodified lines
1313 // - managed cloud (`"managed"`): nothing, it deploys itself.
1414 //
1515 // The "is a newer version published?" verdict comes from the same resolver as
16−// the CLI notice (@executor-js/api) so the two can never disagree.
16+// the CLI notice (@executor-js/api) — `isUpdateAvailable` — so the two can
17+// never disagree.
1718 import { useCallback, useEffect, useState } from "react";
1819
1920 import { Effect, Exit } from "effect";
20−import { compareVersions, resolveUpdateChannel, type UpdateChannel } from "@executor-js/api";
21+import {
22+ isUpdateAvailable,
23+ resolveComparisonChannel,
24+ resolveUpdateChannel,
25+ type UpdateChannel,
26+} from "@executor-js/api";
2127
2228 import { Button } from "./button";
2329 import { toast } from "./sonner";
@@ -47,12 +53,31 @@ const UPGRADE_DOCS_URL: Partial<Record<UpgradeHint, string>> = { · 23 unmodified lines
4753
4854 // ── useLatestVersion ────────────────────────────────────────────────────
4955
56+/**
57+ * The dist-tag channel to fetch for `currentVersion`, or `null` when there is
58+ * nothing to check — no version baked in yet, or a build the check should
59+ * stay quiet on (an unstamped `0.0.0*` fallback, or a prerelease channel with
60+ * no published dist-tag; see `resolveComparisonChannel`). Returning `null`
61+ * here is what lets the hook skip the network call entirely, so a dev build
62+ * never even hits `/v1/app/npm/dist-tags`.
63+ */
64+export function updateFetchChannel(currentVersion: string | undefined): UpdateChannel | null {
65+ if (currentVersion === undefined) return null;
66+ return resolveComparisonChannel(currentVersion);
67+}
68+
5069 function useLatestVersion(currentVersion: string | undefined) {
51− const channel: UpdateChannel = currentVersion ? resolveUpdateChannel(currentVersion) : "latest";
70+ // The channel shown in the upgrade command differs from the channel
71+ // fetched for comparison: the command always names a real channel, while
72+ // the fetch is skipped entirely for a version with nothing to compare.
73+ const displayChannel: UpdateChannel = currentVersion
74+ ? resolveUpdateChannel(currentVersion)
75+ : "latest";
76+ const fetchChannel = updateFetchChannel(currentVersion);
5277 const [latestVersion, setLatestVersion] = useState<string | null>(null);
5378
5479 useEffect(() => {
55− if (!currentVersion) return;
80+ if (fetchChannel === null) return;
5681 let cancelled = false;
5782 void Effect.runPromiseExit(
5883 Effect.tryPromise({
@@ -65,20 +90,17 @@ function useLatestVersion(currentVersion: string | undefined) { · 6 unmodified lines
6590 }),
6691 ).then((exit) => {
6792 if (!cancelled && Exit.isSuccess(exit)) {
68− setLatestVersion(exit.value?.[channel] ?? null);
93+ setLatestVersion(exit.value?.[fetchChannel] ?? null);
6994 }
7095 });
7196 return () => {
7297 cancelled = true;
7398 };
74− }, [channel, currentVersion]);
99+ }, [fetchChannel]);
75100
76− const updateAvailable =
77− currentVersion !== undefined &&
78− latestVersion !== null &&
79− compareVersions(currentVersion, latestVersion) === -1;
101+ const updateAvailable = isUpdateAvailable(currentVersion, latestVersion);
80102
81− return { latestVersion, updateAvailable, channel };
103+ return { latestVersion, updateAvailable, channel: displayChannel };
82104 }
83105
84106 // ── Card chrome ──────────────────────────────────────────────────────────
packages/react/src/lib/integration-grouping.test.ts+14−1
@@ -38,6 +38,19 @@ describe("groupIntegrations", () => { · 37 unmodified lines
3838 ]);
3939 });
4040
41+ it("groups arbitrary provider families", () => {
42+ const items = groupIntegrations([
43+ integration("cloudflare_api", "mcp", "Cloudflare API", "cloudflare"),
44+ integration("cloudflare_docs", "mcp", "Cloudflare Docs", "cloudflare"),
45+ ]);
46+
47+ expect(items).toHaveLength(1);
48+ const group = items[0] as IntegrationFamilyGroup;
49+ expect(group.type).toBe("group");
50+ expect(group.family).toBe("cloudflare");
51+ expect(group.label).toBe("Cloudflare");
52+ });
53+
4154 it("leaves integrations without config family ungrouped", () => {
4255 const items = groupIntegrations([
4356 integration("stripe", "openapi", "Stripe"),
@@ -72,7 +85,7 @@ describe("groupIntegrations", () => { · 28 unmodified lines
7285 ).toEqual(["single", "group:google", "group:microsoft", "single"]);
7386 });
7487
75− it("does not group from integration kind alone", () => {
88+ it("does not infer a family from integration kind alone", () => {
7689 expect(
7790 integrationFamily(integration("google_calendar", "google", "Google Calendar")),
7891 ).toBeNull();
packages/react/src/lib/integration-grouping.ts+1−3
@@ -1,7 +1,5 @@
11 import type { Integration } from "@executor-js/sdk/shared";
22
3−export const MULTI_SERVICE_FAMILIES: ReadonlySet<string> = new Set(["google", "microsoft"]);
4−
53 const FAMILY_LABELS: Record<string, string> = {
64 google: "Google",
75 microsoft: "Microsoft",
@@ -12,7 +10,7 @@ export const familyLabel = (family: string): string => · 4 unmodified lines
1210
1311 export const integrationFamily = (integration: Integration): string | null => {
1412 const family = integration.family?.trim();
15− return family && MULTI_SERVICE_FAMILIES.has(family) ? family : null;
13+ return family || null;
1614 };
1715
1816 export interface IntegrationFamilyGroup {
packages/react/src/lib/outside-dismiss.test.ts+77−0
@@ -0,0 +1,77 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import {
4+ applyOutsideDismissPolicy,
5+ dismissesOnOutsideInteraction,
6+ PORTALED_POPUP_SELECTOR,
7+} from "./outside-dismiss";
8+
9+/**
10+ * The property under test: a dialog or sheet keeps what the user typed.
11+ *
12+ * Radix closes an overlay surface whenever an outside interaction reaches it
13+ * un-prevented, so "does not dismiss" is the thing that has to be asserted, not
14+ * assumed. Escape is Radix's own `onEscapeKeyDown` path and never passes
15+ * through here, which is why no case below can close a surface with a key.
16+ */
17+describe("dismissesOnOutsideInteraction", () => {
18+ it("keeps the surface open by default", () => {
19+ expect(
20+ dismissesOnOutsideInteraction({
21+ dismissOnOutsideClick: false,
22+ insidePortaledPopup: false,
23+ }),
24+ ).toBe(false);
25+ });
26+
27+ it("closes the surface when it opts in", () => {
28+ expect(
29+ dismissesOnOutsideInteraction({ dismissOnOutsideClick: true, insidePortaledPopup: false }),
30+ ).toBe(true);
31+ });
32+
33+ it("never closes on a click inside a portaled popup, even when it opts in", () => {
34+ // A combobox or select renders its list outside the surface, so choosing an
35+ // option arrives as an outside interaction. Dismissing there would drop the
36+ // selection before it lands.
37+ expect(
38+ dismissesOnOutsideInteraction({ dismissOnOutsideClick: true, insidePortaledPopup: true }),
39+ ).toBe(false);
40+ });
41+});
42+
43+/** A stand-in for the event Radix dispatches, recording whether it was blocked. */
44+const outsideEvent = () => {
45+ let prevented = false;
46+ return {
47+ event: {
48+ detail: { originalEvent: { target: { nodeName: "DIV" } } },
49+ preventDefault: () => {
50+ prevented = true;
51+ },
52+ },
53+ prevented: () => prevented,
54+ };
55+};
56+
57+describe("applyOutsideDismissPolicy", () => {
58+ it("blocks a plain outside click by default", () => {
59+ const outside = outsideEvent();
60+ applyOutsideDismissPolicy(outside.event, false);
61+ expect(outside.prevented()).toBe(true);
62+ });
63+
64+ it("lets a plain outside click through when the surface opts in", () => {
65+ const outside = outsideEvent();
66+ applyOutsideDismissPolicy(outside.event, true);
67+ expect(outside.prevented()).toBe(false);
68+ });
69+});
70+
71+describe("PORTALED_POPUP_SELECTOR", () => {
72+ it("covers both popup slots that portal out of a surface", () => {
73+ expect(PORTALED_POPUP_SELECTOR).toBe(
74+ "[data-slot='combobox-content'],[data-slot='select-content']",
75+ );
76+ });
77+});
packages/react/src/lib/outside-dismiss.ts+53−0
@@ -0,0 +1,53 @@
1+/**
2+ * Outside-interaction policy shared by `DialogContent` and `SheetContent`.
3+ *
4+ * Radix dismisses an overlay surface when an outside interaction is not
5+ * default-prevented. That loses whatever the user typed, and a stray click on
6+ * the page behind a form is easy to make. So the default here is the opposite
7+ * of Radix's: an outside interaction keeps the surface open. Escape and the
8+ * close button are unaffected — they still close.
9+ *
10+ * A surface with nothing to lose (a confirmation, a picker, a read-only panel)
11+ * opts back in with `dismissOnOutsideClick`.
12+ */
13+
14+/** base-ui popups (combobox/select) portal their list OUTSIDE the surface, so a
15+ * click on an option reads as an interaction outside it. Such a click must
16+ * never dismiss, even when the surface opts in. */
17+export const PORTALED_POPUP_SELECTOR =
18+ "[data-slot='combobox-content'],[data-slot='select-content']";
19+
20+/** True when the interaction started inside a popup this surface portals out. */
21+export const isInsidePortaledPopup = (target: unknown): boolean =>
22+ typeof Element !== "undefined" &&
23+ target instanceof Element &&
24+ target.closest(PORTALED_POPUP_SELECTOR) !== null;
25+
26+/**
27+ * Whether an outside interaction should close the surface.
28+ *
29+ * Pure so the decision is testable without a DOM: the caller does the element
30+ * lookup and passes the answer in.
31+ */
32+export const dismissesOnOutsideInteraction = (input: {
33+ readonly dismissOnOutsideClick: boolean;
34+ readonly insidePortaledPopup: boolean;
35+}): boolean => input.dismissOnOutsideClick && !input.insidePortaledPopup;
36+
37+/** The shape Radix hands to `onInteractOutside` and `onPointerDownOutside`. */
38+type OutsideInteractionEvent = {
39+ readonly detail: { readonly originalEvent: { readonly target: unknown } };
40+ readonly preventDefault: () => void;
41+};
42+
43+/** Apply the policy to a Radix outside-interaction event. */
44+export const applyOutsideDismissPolicy = (
45+ event: OutsideInteractionEvent,
46+ dismissOnOutsideClick: boolean,
47+): void => {
48+ const dismisses = dismissesOnOutsideInteraction({
49+ dismissOnOutsideClick,
50+ insidePortaledPopup: isInsidePortaledPopup(event.detail.originalEvent.target),
51+ });
52+ if (!dismisses) event.preventDefault();
53+};
packages/react/src/multiplayer/org-slug-gate.tsx+5−1
@@ -16,7 +16,11 @@ import { useNavigate, useParams, useRouterState } from "@tanstack/react-router"; · 15 unmodified lines
1616 // membership and resolves data for that org — same as the MCP URL-pinned org.
1717 // So a foreign slug never reaches this gate as "active" on a multi-org host:
1818 // the server returns no organization for an org the caller can't see, and the
19−// shell 404s upstream. That makes two browser tabs on different orgs fully
19+// shell 404s upstream. The host is what enforces that — it must not build this
20+// subtree until the authenticated answer NAMES the slug in the URL, or an
21+// optimistic answer about the session's own org (cloud's auth-hint cookie)
22+// would mount the shell under a foreign address before the server ever
23+// replies. That makes two browser tabs on different orgs fully
2024 // independent — no shared "active org" to steal. On a single-org host (e.g.
2125 // self-host) every slug resolves to the same org server-side, so a bogus slug
2226 // (e.g. `/totally-bogus`) would otherwise fuzzy-match a route and render
packages/react/src/multiplayer/shell.tsx+26−2
@@ -2,7 +2,7 @@ import { Link, Outlet, useLocation, useParams } from "@tanstack/react-router"; · 1 unmodified line
22 import { useEffect, useRef, useState, type ReactNode } from "react";
33 import { useAtomValue } from "@effect/atom-react";
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5−import { BookOpen, Command, ExternalLink } from "lucide-react";
5+import { BookOpen, Command, ExternalLink, PlusIcon } from "lucide-react";
66 import type { Integration } from "@executor-js/sdk/shared";
77 import { integrationsOptimisticAtom } from "../api/atoms";
88 import { trackEvent } from "../api/analytics";
@@ -23,6 +23,7 @@ import { · 14 unmodified lines
2323 } from "../components/integration-favicon";
2424 import { CommandPalette } from "../components/command-palette";
2525 import { Wordmark } from "../components/wordmark";
26+import { ConnectDialog } from "../pages/integrations";
2627 import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";
2728 import { useAuth } from "./auth-context";
2829
@@ -347,6 +348,7 @@ function SidebarContent( · 318 unmodified lines
347348 onNavigate?: () => void;
348349 showBrand?: boolean;
349350 onOpenCommands: () => void;
351+ onOpenIntegrationConnect: () => void;
350352 },
351353 ) {
352354 const plugins = useClientPlugins();
@@ -382,8 +384,19 @@ function SidebarContent( · 29 unmodified lines
382384 />
383385 ))}
384386
385− <div className="mt-5 mb-1 px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">
387+ <div className="mt-5 mb-1 flex items-center justify-between px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">
386388 <span>Integrations</span>
389+ <Button
390+ type="button"
391+ variant="ghost"
392+ size="icon-xs"
393+ aria-label="Browse integrations"
394+ title="Browse integrations"
395+ onClick={props.onOpenIntegrationConnect}
396+ className="-my-1 text-muted-foreground hover:bg-sidebar-active/60 hover:text-foreground"
397+ >
398+ <PlusIcon className="size-3.5" />
399+ </Button>
387400 </div>
388401
389402 <IntegrationList pathname={props.pathname} onNavigate={props.onNavigate} />
@@ -415,6 +428,7 @@ export function Shell(props: ShellProps) { · 25 unmodified lines
415428 const lastPathname = useRef(pathname);
416429 const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
417430 const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
431+ const [connectIntegrationOpen, setConnectIntegrationOpen] = useState(false);
418432 if (lastPathname.current !== pathname) {
419433 lastPathname.current = pathname;
420434 if (mobileSidebarOpen) setMobileSidebarOpen(false);
@@ -432,12 +446,17 @@ export function Shell(props: ShellProps) { · 11 unmodified lines
432446 return (
433447 <div className="flex h-screen overflow-hidden">
434448 <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
449+ <ConnectDialog open={connectIntegrationOpen} onOpenChange={setConnectIntegrationOpen} />
435450 {/* Desktop sidebar */}
436451 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
437452 <SidebarContent
438453 {...props}
439454 pathname={pathname}
440455 onOpenCommands={() => setCommandPaletteOpen(true)}
456+ onOpenIntegrationConnect={() => {
457+ setConnectIntegrationOpen(true);
458+ trackEvent("integration_connect_dialog_opened");
459+ }}
441460 />
442461 </aside>
443462
@@ -481,6 +500,11 @@ export function Shell(props: ShellProps) { · 37 unmodified lines
481500 setMobileSidebarOpen(false);
482501 setCommandPaletteOpen(true);
483502 }}
503+ onOpenIntegrationConnect={() => {
504+ setMobileSidebarOpen(false);
505+ setConnectIntegrationOpen(true);
506+ trackEvent("integration_connect_dialog_opened");
507+ }}
484508 />
485509 </div>
486510 </div>
packages/react/src/pages/admin-users.tsx+2−1
@@ -670,7 +670,8 @@ export function AdminUsersPage() { · 669 unmodified lines
670670 })}
671671
672672 <Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
673− <SheetContent className="w-full gap-0 p-0 sm:max-w-xl">
673+ {/* A read-only detail panel: clicking away closes it. */}
674+ <SheetContent dismissOnOutsideClick className="w-full gap-0 p-0 sm:max-w-xl">
674675 {selected && (
675676 <>
676677 <SheetHeader className="border-b border-border">
packages/react/src/pages/api-keys.tsx+45−4
@@ -233,6 +233,7 @@ export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode }) { · 232 unmodified lines
233233 // stays mounted for Radix's exit animation (see CreateKeyDialogBody).
234234 const [openCount, setOpenCount] = useState(0);
235235 const [revokingId, setRevokingId] = useState<string | null>(null);
236+ const [confirmRevoke, setConfirmRevoke] = useState<ApiKeySummary | null>(null);
236237
237238 const handleCreate = async (name: string): Promise<CreatedKey | null> => {
238239 const exit = await doCreate({ payload: { name }, reactivityKeys: apiKeyWriteKeys });
@@ -312,7 +313,11 @@ export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode }) { · 73 unmodified lines
312313 </p>
313314 </div>
314315 ) : (
315− <KeyTable keys={value.apiKeys} revokingId={revokingId} onRevoke={handleRevoke} />
316+ <KeyTable
317+ keys={value.apiKeys}
318+ revokingId={revokingId}
319+ onRevoke={(key) => setConfirmRevoke(key)}
320+ />
316321 ),
317322 })
318323 )}
@@ -333,6 +338,42 @@ export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode }) { · 14 unmodified lines
333338 />
334339 </DialogContent>
335340 </Dialog>
341+
342+ {/* A personal key revoke breaks any script or tool using it, so it asks
343+ first — same as the org-key revoke. */}
344+ <Dialog
345+ open={confirmRevoke !== null}
346+ onOpenChange={(open) => {
347+ if (!open) setConfirmRevoke(null);
348+ }}
349+ >
350+ <DialogContent className="sm:max-w-[480px]">
351+ <DialogHeader>
352+ <DialogTitle className="font-display text-xl">Revoke API key</DialogTitle>
353+ <DialogDescription className="text-sm leading-relaxed">
354+ {confirmRevoke
355+ ? `Revoke ${confirmRevoke.name}? Any script or tool authenticating with it loses access immediately. This cannot be undone.`
356+ : ""}
357+ </DialogDescription>
358+ </DialogHeader>
359+ <DialogFooter>
360+ <DialogClose asChild>
361+ <Button variant="ghost">Cancel</Button>
362+ </DialogClose>
363+ <Button
364+ variant="destructive"
365+ onClick={() => {
366+ if (confirmRevoke) {
367+ void handleRevoke(confirmRevoke);
368+ setConfirmRevoke(null);
369+ }
370+ }}
371+ >
372+ Revoke key
373+ </Button>
374+ </DialogFooter>
375+ </DialogContent>
376+ </Dialog>
336377 </PageContainer>
337378 );
338379 }
@@ -492,15 +533,15 @@ function OrgApiKeysSectionBody() { · 153 unmodified lines
492533 </DialogContent>
493534 </Dialog>
494535
495− {/* Revoking an org key breaks every backend using it, so it is the one
496− revoke on this page that asks first. */}
536+ {/* Revoking an org key breaks every backend using it, so it asks first. */}
497537 <Dialog
498538 open={confirmRevoke !== null}
499539 onOpenChange={(open) => {
500540 if (!open) setConfirmRevoke(null);
501541 }}
502542 >
503− <DialogContent className="sm:max-w-[480px]">
543+ {/* A confirmation with nothing to lose: clicking away cancels it. */}
544+ <DialogContent dismissOnOutsideClick className="sm:max-w-[480px]">
504545 <DialogHeader>
505546 <DialogTitle className="font-display text-xl">Revoke organization key</DialogTitle>
506547 <DialogDescription className="text-sm leading-relaxed">
packages/react/src/pages/integrations.tsx+1−1
@@ -163,7 +163,7 @@ const looksLikeUrl = (raw: string): boolean => { · 162 unmodified lines
163163 return false;
164164 };
165165
166−function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) {
166+export function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) {
167167 const integrationPlugins = useIntegrationPlugins();
168168 const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" });
169169 const navigate = useNavigate();
packages/react/src/pages/org.tsx+52−2
@@ -14,6 +14,16 @@ import { · 13 unmodified lines
1414 DialogFooter,
1515 DialogClose,
1616 } from "../components/dialog";
17+import {
18+ AlertDialog,
19+ AlertDialogAction,
20+ AlertDialogCancel,
21+ AlertDialogContent,
22+ AlertDialogDescription,
23+ AlertDialogFooter,
24+ AlertDialogHeader,
25+ AlertDialogTitle,
26+} from "../components/alert-dialog";
1727 import { Button } from "../components/button";
1828 import { PageContainer, PageHeader } from "../components/page";
1929 import { Badge } from "../components/badge";
@@ -153,6 +163,7 @@ export function OrgPage(props: { · 133 unmodified lines
153163 const refreshMembers = useAtomRefresh(orgMembersAtom);
154164 const rolesResult = useAtomValue(orgRolesAtom);
155165 const doRemove = useAtomSet(removeMember, { mode: "promiseExit" });
166+ const [removingMember, setRemovingMember] = useState<{ id: string; name: string } | null>(null);
156167 const doUpdateRole = useAtomSet(updateMemberRole, { mode: "promiseExit" });
157168 const doUpdateOrgName = useAtomSet(updateOrgName, { mode: "promiseExit" });
158169 const [inviteOpen, setInviteOpen] = useState(false);
@@ -179,6 +190,7 @@ export function OrgPage(props: { · 20 unmodified lines
179190 const showUpgradeOnInvite = atSeatLimit && !!props.upgradeAction;
180191
181192 const handleRemove = async (membershipId: string, name: string) => {
193+ setRemovingMember(null);
182194 const exit = await doRemove({
183195 params: { membershipId },
184196 reactivityKeys: orgMemberWriteKeys,
@@ -406,7 +418,12 @@ export function OrgPage(props: { · 221 unmodified lines
406418 )}
407419 <DropdownMenuItem
408420 className="text-destructive focus:text-destructive text-sm"
409− onClick={() => handleRemove(member.id, member.name ?? member.email)}
421+ onClick={() =>
422+ setRemovingMember({
423+ id: member.id,
424+ name: member.name ?? member.email,
425+ })
426+ }
410427 >
411428 Remove member
412429 </DropdownMenuItem>
@@ -426,6 +443,38 @@ export function OrgPage(props: { · 13 unmodified lines
426443
427444 {props.dangerZoneSection}
428445
446+ <AlertDialog
447+ open={removingMember !== null}
448+ onOpenChange={(open: boolean) => {
449+ if (!open) setRemovingMember(null);
450+ }}
451+ >
452+ <AlertDialogContent size="sm">
453+ <AlertDialogHeader>
454+ <AlertDialogTitle>
455+ {removingMember ? `Remove ${removingMember.name}?` : "Remove member?"}
456+ </AlertDialogTitle>
457+ <AlertDialogDescription>
458+ They lose access to this organization immediately. This cannot be undone; you would
459+ need to invite them again.
460+ </AlertDialogDescription>
461+ </AlertDialogHeader>
462+ <AlertDialogFooter>
463+ <AlertDialogCancel>Cancel</AlertDialogCancel>
464+ <AlertDialogAction
465+ variant="destructive"
466+ onClick={() => {
467+ if (removingMember !== null) {
468+ void handleRemove(removingMember.id, removingMember.name);
469+ }
470+ }}
471+ >
472+ Remove member
473+ </AlertDialogAction>
474+ </AlertDialogFooter>
475+ </AlertDialogContent>
476+ </AlertDialog>
477+
429478 <InviteDialog open={inviteOpen} onOpenChange={setInviteOpen} roles={roles} />
430479 <UpgradeDialog
431480 open={upgradeOpen}
@@ -447,7 +496,8 @@ function UpgradeDialog(props: { · 15 unmodified lines
447496 }) {
448497 return (
449498 <Dialog open={props.open} onOpenChange={props.onOpenChange}>
450− <DialogContent className="sm:max-w-[400px]">
499+ {/* Informational only: nothing to lose, so clicking away dismisses it. */}
500+ <DialogContent dismissOnOutsideClick className="sm:max-w-[400px]">
451501 <DialogHeader>
452502 <DialogTitle className="font-display text-xl">You are at your member limit</DialogTitle>
453503 <DialogDescription className="text-sm leading-relaxed">
packages/react/src/pages/policies.tsx+50−1
@@ -30,6 +30,16 @@ import { · 29 unmodified lines
3030 } from "../lib/policy-display";
3131 import { Button } from "../components/button";
3232 import { PageContainer, PageHeader } from "../components/page";
33+import {
34+ AlertDialog,
35+ AlertDialogAction,
36+ AlertDialogCancel,
37+ AlertDialogContent,
38+ AlertDialogDescription,
39+ AlertDialogFooter,
40+ AlertDialogHeader,
41+ AlertDialogTitle,
42+} from "../components/alert-dialog";
3343 import {
3444 CardStack,
3545 CardStackContent,
@@ -291,6 +301,11 @@ export function PoliciesPage() { · 255 unmodified lines
291301 const doUpdate = useAtomSet(updatePolicyOptimistic, { mode: "promiseExit" });
292302 const doRemove = useAtomSet(removePolicyOptimistic, { mode: "promiseExit" });
293303 const [busy, setBusy] = useState(false);
304+ const [removingPolicy, setRemovingPolicy] = useState<{
305+ id: string;
306+ owner: Owner;
307+ pattern: string;
308+ } | null>(null);
294309 const ownerDisplay = useOwnerDisplay();
295310 // Policies default to org/workspace. On local this is the hidden Local owner
296311 // that v1 local data migrates into.
@@ -336,6 +351,7 @@ export function PoliciesPage() { · 39 unmodified lines
336351 };
337352
338353 const handleRemove = async (policy: { id: string; owner: Owner }) => {
354+ setRemovingPolicy(null);
339355 const exit = await doRemove({
340356 params: { policyId: PolicyId.make(policy.id) },
341357 payload: { owner: policy.owner },
@@ -462,7 +478,9 @@ export function PoliciesPage() { · 120 unmodified lines
462478 isFirst={!reorderable || j === 0}
463479 isLast={!reorderable || j === committed.length - 1}
464480 showOwnerLabel={ownerDisplay.showOwnerLabels}
465− onRemove={() => handleRemove({ id: p.id, owner: p.owner })}
481+ onRemove={() =>
482+ setRemovingPolicy({ id: p.id, owner: p.owner, pattern: p.pattern })
483+ }
466484 onChangeAction={(action) =>
467485 handleUpdate({ id: p.id, owner: p.owner }, action)
468486 }
@@ -490,6 +508,37 @@ export function PoliciesPage() { · 21 unmodified lines
490508 },
491509 })
492510 )}
511+
512+ <AlertDialog
513+ open={removingPolicy !== null}
514+ onOpenChange={(open: boolean) => {
515+ if (!open) setRemovingPolicy(null);
516+ }}
517+ >
518+ <AlertDialogContent size="sm">
519+ <AlertDialogHeader>
520+ <AlertDialogTitle>Remove policy?</AlertDialogTitle>
521+ <AlertDialogDescription>
522+ {removingPolicy
523+ ? `The rule for "${removingPolicy.pattern}" will be deleted. Tools it governs fall back to the default policy. This cannot be undone.`
524+ : ""}
525+ </AlertDialogDescription>
526+ </AlertDialogHeader>
527+ <AlertDialogFooter>
528+ <AlertDialogCancel>Cancel</AlertDialogCancel>
529+ <AlertDialogAction
530+ variant="destructive"
531+ onClick={() => {
532+ if (removingPolicy !== null) {
533+ void handleRemove({ id: removingPolicy.id, owner: removingPolicy.owner });
534+ }
535+ }}
536+ >
537+ Remove policy
538+ </AlertDialogAction>
539+ </AlertDialogFooter>
540+ </AlertDialogContent>
541+ </AlertDialog>
493542 </PageContainer>
494543 );
495544 }