History

Multi-method auth across plugins (MCP joins the slugged authenticationTemplate model) (#933)

* Add self-host OAuth apps route * Add e2e harness + stubbed-dev-server test mode, mcporter fork, recordings tooling - e2e/: top-level workspace package — Effect Vitest, generic surface drivers (api/cli), self-host + cloud targets (cloud stubbed on PGlite + in-memory WorkOS). - apps/cloud: EXECUTOR_E2E_STUB gated test mode (multi-user WorkOS stub, free-plan Autumn) so vite dev runs logged-in + stubbed; production path unchanged when off. - apps/host-selfhost: fix dev API entry (src/api/api.ts) + proxy /.well-known. - vendor/mcporter: forked MCP client w/ headless consentStrategy (submodule). - testkit/: run recorder + player + dev recordings. * Rebuild e2e as scenario × target framework with evidence-first viewer - e2e/: scenarios written once against a Target interface (capabilities, fresh-identity isolation), run per target via vitest projects; cloud + selfhost targets boot their app's own dev server (cloud under EXECUTOR_E2E_STUB) or attach via E2E_<TARGET>_URL. - Every surface (typed API client, MCP via the mcporter fork's headless OAuth, Playwright browser, PTY CLI) records turns + evidence (screenshots, video slices, terminal frames) into a per-run transcript; the dark player + scenario×target matrix index make runs reviewable without running anything locally. Failures freeze the scene with a screenshot. - Scenarios: cross-target API + MCP-OAuth-execute; cloud/ billing org-limit driven fully through the web UI (onboarding page + org-switcher modal). - Cloud stub: authenticateRequest, listInvitationsByEmail, session refresh normalization (URL-encoded / already-suffixed cookies) so the protected API + SPA work over the wire under the stub. - Kill superseded harnesses: apps/cloud/e2e (Playwright server + spec + config), vitest.unit.config.ts, old e2e/tests; exclude vendor/testkit/ generated runs from oxfmt + oxlint. * Make the e2e runs gallery self-explanatory - /runs without a trailing slash broke every relative link (resolved against the site root through the tailscale mount) — the index now self-corrects the URL. This was 'clicking does nothing'. - Cells are explicit '✓ passed · ▶ watch' buttons, scenario names link to their latest run, and a hint line explains the matrix. - Players get an '← all runs' back link. - scripts/rebuild-viewer.ts re-renders all players + index from existing run.json files without rerunning tests. * Viewer v2: React SPA with mp4 session videos - Browser surface transcodes Playwright's webm to h264 mp4 on session close (ffmpeg, faststart) — plays everywhere including Safari/iOS; falls back to webm when ffmpeg is missing. - The handwritten-HTML player/index are replaced by a small Vite+React SPA (e2e/viewer/) built into runs/ with relative assets + hash routing, so the same static dir serves locally and under the /runs mount. Run data stays run.json; the suite now just refreshes runs/manifest.json (atomic write, scan tolerant of concurrent projects). - Run page: hero mp4 up top, step '▶' buttons seek it to that moment, screenshots/terminal-frames/asserts inline, optional replay mode keeps the typing-chat animation. - bun run serve rebuilds the viewer then serves; rebuild-viewer re-skins existing runs without rerunning tests. - Fix org-limit cold-start flake: wait for the post-onboarding navigation to settle before driving menus (late remount closed the modal mid-click). * Add 8 agent-authored e2e scenarios + the authoring guide - e2e/AGENTS.md: the scenario authoring guide (ctx API, run commands, isolation rules, quality bar) used by the writer agents — and by anyone writing scenarios from here on. - Cross-target (API): policies round-trip, OpenAPI spec registration exposes operations as tools (typed client incl. the openapi plugin group), integrations built-in readiness, tools metadata contract. - Cloud (browser): onboarding hands the user their org-scoped MCP URL, org switcher switches the workspace, connect-panel transport toggle. - Selfhost (MCP): a paused execution resumes after human approval (require_approval policy → execute pauses → approve → completes). - Post-review remediation: evidence-carrying asserts instead of boolean coercion, no conditional expects, recorded args match payloads, openapi scenario moved from raw fetch onto the typed client (plugin group composed; integration filter applied client-side to avoid the optional branded query param decode failure). * Drop committed run outputs; pin vendor forks to pushed commits - testkit/runs (mp4s + rendered html + run.json) were generated outputs — removed from the repo and gitignored, matching e2e/runs. - vendor/emulate: pin the Microsoft Graph OpenAPI routes commit. - vendor/mcporter: pin the bun-install fix (hoisted overrides + lockfile). Both pins now reference commits pushed to their forks. * Viewer bakeoff: 10 single-file prototype angles over shared mock run data Ten self-contained HTML prototypes (no build step, shared data.js built from two real runs + a synthetic failure) exploring distinct presentation concepts: filmstrip, video-synced split, dense log, timeline, evidence grid, assertion table, storyboard, inspector, dashboard-first, focused replay. Served at /_proto by the runs server (rebuild-viewer copies them alongside the SPA) to pick a direction for the real viewer. * Serve directory indexes + self-correct trailing slash on the bakeoff index * Viewer: per-surface rendering — MCP stays a chat, API calls wrap into actions MCP runs render as the chat they are (session messages, auth lifecycle, replay). Everything else renders as action groups: the narration sentence is the visible unit, the API calls it explains collapse behind a one-line summary (count + total ms, expandable per-call payloads), and assertions stay visible under their action. Failing groups auto-expand. Browser/CLI steps and errors stand alone between groups. * Format viewer prototypes; drop agent scratch files * Simplify e2e to standard practice: tests are the artifact, traces for browser The recording/transcript layer is gone. Tests assert with plain vitest expect and read as specs; a failing API call surfaces as a typed error in test output, like everywhere else. - Browser surface now captures a full Playwright trace per run (plus the session mp4, per-step screenshots, and a failure freeze). step() names a trace group. - scenario() keeps the cross-target machinery (E2E_TARGET projects, capability skips, fresh-identity isolation) and writes a small result.json per run for the matrix. - Viewer reduced to the matrix + a run artifact page: status, error output, video, screenshots, and an 'open trace' link into Playwright's own trace viewer (serve.ts gained CORS + zip for that). - All 12 scenarios rewritten; mcp-approve's policy cleanup is now an Effect.ensuring finalizer so failures can't leak an approval gate. - Deleted: recorder, run-transcript schema, chat/action-group rendering, the 10 viewer-bakeoff prototypes. * Run page: Monaco test-source viewer The runner writes test.ts into each run dir — that scenario's code with imports and sibling scenario() blocks stripped (module-level helpers stay). The run page renders it in a lazy-loaded read-only Monaco (vs-dark, TS colorization; diagnostics off since the excerpt intentionally has no imports), so the test can be reviewed without leaving the runs site. * Make the test-source pane fast: Monaco core only + gzip + immutable caching First open of a run page was pulling ~12 MB uncompressed: the full Monaco build dragged in the TS language-service worker (6.7 MB) plus css/html/json workers, and the server sent everything raw with no caching. - Monaco core (editor.api) + the monarch TypeScript colorizer only — a read-only pane needs highlighting, not IntelliSense. One 3.6 MB chunk. - serve.ts gzips text assets (chunk → ~920 KB on the wire) and marks vite's content-hashed /assets/ immutable, so Monaco downloads once ever; run data and index.html stay no-cache so fresh runs appear. Range requests for video are untouched. * Multi-method auth across plugins: MCP joins the slugged authenticationTemplate model An integration's auth methods are now plural everywhere. MCP's config moves from a singular `auth` template to a slugged `authenticationTemplate` array (matching OpenAPI/GraphQL); stored legacy configs are lifted in place (slug = kind, so existing connections' template slugs keep working) with no migration. SDK/API: - McpAuthMethod / McpAuthMethodInput + normalizeMcpAuthMethods; addServer accepts an authenticationTemplate array (legacy `auth` shorthand kept) - buildConnectorInput renders the method the connection's template slug references (single-method integrations keep accepting any slug) - new merge-append configureAuth extension + POST /mcp/servers/:slug/auth, mirroring GraphQL/OpenAPI — custom methods no longer clobber declared OAuth - core: ResolveToolsInput carries the connection's template slug; GraphQL now renders the exact referenced template during discovery instead of the first - shared mergeAuthTemplates in the sdk replaces the three per-plugin copies React: shared add-flow primitives in @executor-js/react — AuthMethodListEditor/useAuthMethodList (seeded multi-method list), useCustomMethodActions (accounts-panel create/remove skeleton over a small per-plugin codec), and integration-add helpers (Exit error decoding, slug collision check/alert). OpenAPI moves onto them behavior-preserving; the MCP add flow now seeds the probed method and supports declaring alternates (e.g. API key alongside detected OAuth, or a method on a no-auth server); the MCP edit screen and accounts panel edit the full method list; GraphQL's add flow gains the same multi-method list. * e2e: multi-method auth scenarios Five scenarios covering the plural auth-method model, written against the scenario × target framework so they land in the /runs matrix: - an MCP server declares OAuth and an API key side by side (catalog projection) - adding an API key method keeps a detected OAuth method (merge-append, the clobber regression) - a no-auth MCP server can declare an API key method later - a GraphQL source registers multiple auth methods at add time - selfhost: on a multi-method integration, the connection's CHOSEN method renders the credential on the wire — a real MCP test server only accepting the bearer header proves template selection by slug end-to-end * e2e: browser scenario for the multi-method add flow Drives the real add-MCP UI against a live loopback MCP test server: the probe seeds the detected method, '+ Add method' declares an API key alongside it, and after adding, the integration's connect modal offers both methods. Records session video, per-step screenshots, and a Playwright trace for the /runs viewer. * e2e: browser scenarios for OAuth-detected add + custom method in connect modal Two more recorded UI flows for the multi-method auth UX: - a detected-OAuth MCP server gets an API key declared alongside at add time (probe → OAuth row seeded → '+ add method' → connect modal offers both) - a custom API key method added from the connect modal's '+ method' keeps the existing OAuth, then connecting through it lands with the credential rendered on the wire (a real bearer-only test server proves it) Both selfhost-only (cloud has no browser identity yet and these paste a credential into the default store); the cross-target no-auth add-flow variant moves to its own concern. Supporting harness fixes, all pre-existing latent issues surfaced by pointing the instance at loopback test servers: - e2e instances set EXECUTOR_ALLOW_LOCAL_NETWORK / ALLOW_LOCAL_NETWORK so the hosted SSRF guard permits probing loopback MCP/OAuth servers - selfhost identity now carries the Better Auth session cookies too, enabling the browser surface on selfhost - cloud globalsetup wipes the stub DB per run (the WorkOS stub mints org ids from a per-process counter that collides with a persisted DB) - boot teardown signals the whole process group and waits for real exit, so a bunx-wrapped vite child can't squat the port into the next run - connection-creating selfhost scenarios clean up their integration so they don't leak into the zero-connections scenario (shared-admin tenant) ---------

Rhys Sullivan committed 5 months ago

Commit 31a30621 parent 205fe3d54 files · +2659 −982Browse files

Unified · Split

54 files
  1. .gitignore +2 −0
  2. bun.lock +2 −0
  3. e2e/package.json +2 −0
  4. e2e/scenarios/auth-methods-ui.test.ts +77 −0
  5. e2e/scenarios/auth-methods.test.ts +224 −0
  6. e2e/selfhost/auth-methods-ui.test.ts +156 −0
  7. e2e/selfhost/mcp-multi-auth.test.ts +91 −0
  8. e2e/setup/boot.ts +33 −3
  9. e2e/setup/cloud.globalsetup.ts +13 −1
  10. e2e/setup/selfhost.globalsetup.ts +4 −0
  11. packages/core/sdk/src/executor.ts +1 −0
  12. packages/core/sdk/src/index.ts +1 −0
  13. packages/core/sdk/src/integration.ts +41 −0
  14. packages/core/sdk/src/plugin.ts +4 −0
  15. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +65 −85
  16. packages/plugins/graphql/src/react/GraphqlAccountsPanel.tsx +31 −31
  17. packages/plugins/graphql/src/sdk/plugin.ts +14 −47
  18. packages/plugins/http-source/src/sdk/plugin.test.ts +1 −0
  19. packages/plugins/mcp/src/api/group.ts +30 −1
  20. packages/plugins/mcp/src/api/handlers.test.ts +1 −0
  21. packages/plugins/mcp/src/api/handlers.ts +16 −0
  22. packages/plugins/mcp/src/react/AddMcpSource.tsx +80 −132
  23. packages/plugins/mcp/src/react/EditMcpSource.tsx +88 −234
  24. packages/plugins/mcp/src/react/McpAccountsPanel.tsx +59 −101
  25. packages/plugins/mcp/src/react/McpSignInButton.tsx +19 −18
  26. packages/plugins/mcp/src/react/atoms.ts +2 −0
  27. packages/plugins/mcp/src/react/auth-method-config.test.ts +83 −9
  28. packages/plugins/mcp/src/react/auth-method-config.ts +93 −12
  29. packages/plugins/mcp/src/sdk/connection.ts +1 −1
  30. packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts +55 −11
  31. packages/plugins/mcp/src/sdk/plugin.test.ts +49 −0
  32. packages/plugins/mcp/src/sdk/plugin.ts +129 −44
  33. packages/plugins/mcp/src/sdk/types.ts +75 −6
  34. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +42 −167
  35. packages/plugins/openapi/src/react/OpenApiAccountsPanel.tsx +32 −30
  36. packages/plugins/openapi/src/sdk/plugin.ts +2 −49
  37. packages/react/package.json +1 −0
  38. packages/react/src/components/auth-method-list-editor.tsx +155 −0
  39. packages/react/src/lib/custom-auth-methods.ts +79 −0
  40. packages/react/src/lib/integration-add.tsx +91 −0
  41. testkit/README.md +50 −0
  42. testkit/package-lock.json +151 −0
  43. testkit/package.json +9 −0
  44. testkit/record-flow.mjs +59 −0
  45. testkit/run.mjs +8 −0
  46. testkit/serve.mjs +21 −0
  47. testkit/src/consent-browser.mjs +45 −0
  48. testkit/src/driver.mjs +87 −0
  49. testkit/src/player.template.html +47 −0
  50. testkit/src/recorder.mjs +30 −0
  51. testkit/src/render.mjs +60 −0
  52. testkit/src/testkit.mjs +40 −0
  53. testkit/tests/execute.test.mjs +25 −0
  54. testkit/tests/x-oauth-basic.test.mjs +83 −0
.gitignore+2−0
@@ -109,3 +109,5 @@ LEARNINGS.md · 108 unmodified lines
109109 # Throwaway UX prototype (not part of the app)
110110 ux-demo/
111111
112+# testkit run outputs (superseded by e2e/runs, also generated)
113+testkit/runs/
bun.lock+2−0
@@ -321,6 +321,8 @@ · 320 unmodified lines
321321 "version": "0.0.0",
322322 "dependencies": {
323323 "@executor-js/api": "workspace:*",
324+ "@executor-js/plugin-graphql": "workspace:*",
325+ "@executor-js/plugin-mcp": "workspace:*",
324326 "@executor-js/plugin-openapi": "workspace:*",
325327 "@executor-js/sdk": "workspace:*",
326328 "@kitlangton/terminal-control": "^0.3.0",
e2e/package.json+2−0
@@ -13,6 +13,8 @@ · 12 unmodified lines
1313 },
1414 "dependencies": {
1515 "@executor-js/api": "workspace:*",
16+ "@executor-js/plugin-graphql": "workspace:*",
17+ "@executor-js/plugin-mcp": "workspace:*",
1618 "@executor-js/plugin-openapi": "workspace:*",
1719 "@executor-js/sdk": "workspace:*",
1820 "@kitlangton/terminal-control": "^0.3.0",
e2e/scenarios/auth-methods-ui.test.ts+77−0
@@ -0,0 +1,77 @@
1+// Cross-target (browser): the multi-method auth add flow, end to end through
2+// the real web UI against a live loopback MCP test server (the target's dev
3+// server probes it). A no-auth server gets an API key declared at add time —
4+// the case where the server advertises nothing but the user knows better.
5+// The session video + per-step screenshots are the artifact.
6+//
7+// The OAuth-detected and connect-modal variants are selfhost-only (see
8+// selfhost/auth-methods-ui.test.ts): the cloud dev harness (workerd) cannot
9+// shape-probe loopback servers and its vault stub takes no pasted credentials.
10+import { randomBytes } from "node:crypto";
11+
12+import { expect } from "@effect/vitest";
13+import { Effect } from "effect";
14+import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
15+
16+import { scenario } from "../src/scenario";
17+
18+scenario(
19+ "Auth methods · the add flow declares an API key alongside the detected method",
20+ { needs: ["browser"] },
21+ (ctx) =>
22+ Effect.scoped(
23+ Effect.gen(function* () {
24+ // An OPEN server: the probe connects without auth, so the method list
25+ // seeds with the detected "no authentication" row. The server name is
26+ // unique per run — the derived integration namespace must not collide
27+ // on targets whose identities share one tenant (selfhost admin).
28+ const server = yield* serveMcpServer(() =>
29+ makeGreetingMcpServer({ name: `open-mcp-${randomBytes(3).toString("hex")}` }),
30+ );
31+ const identity = yield* ctx.target.newIdentity();
32+
33+ yield* ctx.browser.session(identity, async ({ page, step }) => {
34+ await step("Open the add-MCP flow pointed at the server", async () => {
35+ await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, {
36+ waitUntil: "networkidle",
37+ });
38+ // The URL auto-probes (debounced); the method list appears once
39+ // the probe lands.
40+ await page.getByText("How does this server authenticate?").waitFor();
41+ });
42+
43+ await step("The probe seeded the detected method", async () => {
44+ await page.getByText("Method 1 · Detected").waitFor();
45+ });
46+
47+ await step("Declare an API key method alongside it", async () => {
48+ await page.getByRole("button", { name: "Add method" }).click();
49+ await page.getByText("Method 2").waitFor();
50+ // The new row opens on the API key editor with the standard
51+ // Authorization-header placement prefilled.
52+ const headerName = page.getByPlaceholder("Authorization").last();
53+ await headerName.waitFor();
54+ });
55+
56+ await step("Add the source with both methods", async () => {
57+ await page.getByRole("button", { name: "Add source" }).click();
58+ // onComplete routes to the new integration's detail hub.
59+ await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 });
60+ await page.getByText("Connections").first().waitFor();
61+ });
62+
63+ await step("The connect modal offers both methods", async () => {
64+ await page.getByRole("button", { name: "Add connection" }).first().click();
65+ await page.getByRole("tab", { name: "No authentication" }).waitFor();
66+ await page.getByRole("tab", { name: "API key (Authorization)" }).waitFor();
67+ });
68+
69+ const tabs = await page.getByRole("tab").allInnerTexts();
70+ expect(tabs.join(", "), "both declared methods are selectable").toContain(
71+ "No authentication",
72+ );
73+ expect(tabs.join(", ")).toContain("API key (Authorization)");
74+ });
75+ }),
76+ ),
77+);
e2e/scenarios/auth-methods.test.ts+224−0
@@ -0,0 +1,224 @@
1+// Cross-target: multi-method authentication — an integration declares SEVERAL
2+// auth methods and a connection picks one by template slug. Covers the model
3+// rework: MCP's slugged `authenticationTemplate` array, the merge-append
4+// configureAuth flow (a custom API key must never displace a detected OAuth
5+// method), declaring a method on a server that advertises none, and GraphQL's
6+// multi-method add. Entirely through the typed clients — no MCP server is
7+// dialed (registration and method configuration are catalog statements).
8+import { randomBytes } from "node:crypto";
9+
10+import { expect } from "@effect/vitest";
11+import { Effect } from "effect";
12+import { composePluginApi } from "@executor-js/api/server";
13+import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
14+import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
15+import { IntegrationSlug } from "@executor-js/sdk/shared";
16+
17+import { scenario } from "../src/scenario";
18+
19+const api = composePluginApi([mcpHttpPlugin(), graphqlHttpPlugin()] as const);
20+
21+const freshSlug = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`;
22+
23+// Registration never dials the endpoint, so a closed local port is fine.
24+const MCP_ENDPOINT = "http://127.0.0.1:59998/mcp";
25+
26+scenario(
27+ "Auth methods · an MCP server can declare OAuth and an API key side by side",
28+ { needs: ["api"] },
29+ (ctx) =>
30+ Effect.gen(function* () {
31+ const identity = yield* ctx.target.newIdentity();
32+ const client = yield* ctx.api.client(api, identity);
33+ const slug = freshSlug("mcp-multiauth");
34+
35+ yield* client.mcp.addServer({
36+ payload: {
37+ transport: "remote",
38+ name: "Multi-auth MCP",
39+ endpoint: MCP_ENDPOINT,
40+ slug,
41+ authenticationTemplate: [
42+ { kind: "oauth2" },
43+ { kind: "header", headerName: "X-Api-Key", prefix: "Bearer " },
44+ ],
45+ },
46+ });
47+
48+ yield* Effect.gen(function* () {
49+ const integration = yield* client.integrations.get({
50+ params: { slug: IntegrationSlug.make(slug) },
51+ });
52+
53+ // Both methods project into the catalog, slugged by kind, so the
54+ // connect flow can offer either and a connection binds one by slug.
55+ expect(
56+ integration.authMethods.map((m) => ({ kind: m.kind, template: m.template })),
57+ "the catalog lists both declared methods",
58+ ).toEqual([
59+ { kind: "oauth", template: "oauth2" },
60+ { kind: "apikey", template: "header" },
61+ ]);
62+
63+ const apiKey = integration.authMethods.find((m) => m.kind === "apikey");
64+ expect(apiKey?.placements, "the API key method carries its header placement").toEqual([
65+ { carrier: "header", name: "X-Api-Key", prefix: "Bearer " },
66+ ]);
67+ }).pipe(
68+ Effect.ensuring(
69+ client.mcp
70+ .removeServer({ params: { slug: IntegrationSlug.make(slug) } })
71+ .pipe(Effect.ignore),
72+ ),
73+ );
74+ }),
75+);
76+
77+scenario(
78+ "Auth methods · adding an API key method keeps a detected OAuth method",
79+ { needs: ["api"] },
80+ (ctx) =>
81+ Effect.gen(function* () {
82+ const identity = yield* ctx.target.newIdentity();
83+ const client = yield* ctx.api.client(api, identity);
84+ const slug = freshSlug("mcp-oauth-plus-key");
85+
86+ // The add flow registered what the probe detected: OAuth only.
87+ yield* client.mcp.addServer({
88+ payload: {
89+ transport: "remote",
90+ name: "OAuth MCP",
91+ endpoint: MCP_ENDPOINT,
92+ slug,
93+ authenticationTemplate: [{ kind: "oauth2" }],
94+ },
95+ });
96+
97+ yield* Effect.gen(function* () {
98+ // "+ Custom method" merge-appends — it must not displace OAuth.
99+ const configured = yield* client.mcp.configureAuth({
100+ params: { slug: IntegrationSlug.make(slug) },
101+ payload: {
102+ authenticationTemplate: [{ kind: "header", headerName: "X-Api-Key" }],
103+ },
104+ });
105+ expect(
106+ configured.authenticationTemplate.map((m) => m.kind),
107+ "the declared set now holds both methods",
108+ ).toEqual(["oauth2", "header"]);
109+ expect(
110+ configured.authenticationTemplate[1]?.slug,
111+ "the custom method gets its own custom_ slug",
112+ ).toMatch(/^custom_/);
113+
114+ const integration = yield* client.integrations.get({
115+ params: { slug: IntegrationSlug.make(slug) },
116+ });
117+ expect(
118+ integration.authMethods.map((m) => m.kind),
119+ "the catalog offers OAuth and the API key",
120+ ).toEqual(["oauth", "apikey"]);
121+ }).pipe(
122+ Effect.ensuring(
123+ client.mcp
124+ .removeServer({ params: { slug: IntegrationSlug.make(slug) } })
125+ .pipe(Effect.ignore),
126+ ),
127+ );
128+ }),
129+);
130+
131+scenario(
132+ "Auth methods · a no-auth MCP server can declare an API key method later",
133+ { needs: ["api"] },
134+ (ctx) =>
135+ Effect.gen(function* () {
136+ const identity = yield* ctx.target.newIdentity();
137+ const client = yield* ctx.api.client(api, identity);
138+ const slug = freshSlug("mcp-open-plus-key");
139+
140+ // No declared auth — the server advertises nothing.
141+ yield* client.mcp.addServer({
142+ payload: { transport: "remote", name: "Open MCP", endpoint: MCP_ENDPOINT, slug },
143+ });
144+
145+ yield* Effect.gen(function* () {
146+ const before = yield* client.integrations.get({
147+ params: { slug: IntegrationSlug.make(slug) },
148+ });
149+ expect(
150+ before.authMethods.map((m) => m.kind),
151+ "an open server starts with the no-auth method",
152+ ).toEqual(["none"]);
153+
154+ yield* client.mcp.configureAuth({
155+ params: { slug: IntegrationSlug.make(slug) },
156+ payload: {
157+ authenticationTemplate: [
158+ { kind: "header", headerName: "Authorization", prefix: "Bearer " },
159+ ],
160+ },
161+ });
162+
163+ const after = yield* client.integrations.get({
164+ params: { slug: IntegrationSlug.make(slug) },
165+ });
166+ expect(
167+ after.authMethods.map((m) => m.kind),
168+ "no-auth and the declared API key coexist",
169+ ).toEqual(["none", "apikey"]);
170+ }).pipe(
171+ Effect.ensuring(
172+ client.mcp
173+ .removeServer({ params: { slug: IntegrationSlug.make(slug) } })
174+ .pipe(Effect.ignore),
175+ ),
176+ );
177+ }),
178+);
179+
180+scenario(
181+ "Auth methods · a GraphQL source registers multiple auth methods at add time",
182+ { needs: ["api"] },
183+ (ctx) =>
184+ Effect.gen(function* () {
185+ const identity = yield* ctx.target.newIdentity();
186+ const client = yield* ctx.api.client(api, identity);
187+ const slug = freshSlug("graphql-multiauth");
188+
189+ yield* client.graphql.addIntegration({
190+ payload: {
191+ endpoint: "http://127.0.0.1:59998/graphql",
192+ slug,
193+ name: "Multi-auth GraphQL",
194+ authenticationTemplate: [
195+ { kind: "apiKey", slug: "apiKey", in: "header", name: "X-Api-Key" },
196+ { kind: "apiKey", slug: "apikey-2", in: "query", name: "api_key" },
197+ ],
198+ },
199+ });
200+
201+ yield* Effect.gen(function* () {
202+ const integration = yield* client.integrations.get({
203+ params: { slug: IntegrationSlug.make(slug) },
204+ });
205+ expect(
206+ integration.authMethods.map((m) => ({ template: m.template, kind: m.kind })),
207+ "both declared methods are in the catalog",
208+ ).toEqual([
209+ { template: "apiKey", kind: "apikey" },
210+ { template: "apikey-2", kind: "apikey" },
211+ ]);
212+ expect(
213+ integration.authMethods[1]?.placements,
214+ "the second method carries its query placement",
215+ ).toEqual([{ carrier: "query", name: "api_key", prefix: "" }]);
216+ }).pipe(
217+ Effect.ensuring(
218+ client.integrations
219+ .remove({ params: { slug: IntegrationSlug.make(slug) } })
220+ .pipe(Effect.ignore),
221+ ),
222+ );
223+ }),
224+);
e2e/selfhost/auth-methods-ui.test.ts+156−0
@@ -0,0 +1,156 @@
1+// Selfhost-only (browser): the multi-method auth UX beyond the no-auth case —
2+// an OAuth-DETECTED server gets an API key declared alongside at add time, and
3+// the connect modal's "+ method" adds a custom API key to an OAuth integration
4+// without displacing it. Selfhost-only because cloud has no browser identity
5+// yet and these paste a credential into the default store; the cross-target
6+// no-auth add-flow variant lives in scenarios/auth-methods-ui.test.ts. The
7+// selfhost instance runs with EXECUTOR_ALLOW_LOCAL_NETWORK so its outbound
8+// probe/dial can reach the loopback test servers. Video is the artifact.
9+import { randomBytes } from "node:crypto";
10+
11+import { expect } from "@effect/vitest";
12+import { Effect } from "effect";
13+import { composePluginApi } from "@executor-js/api/server";
14+import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
15+import {
16+ makeGreetingMcpServer,
17+ serveMcpServer,
18+ serveMcpServerWithOAuth,
19+} from "@executor-js/plugin-mcp/testing";
20+import { OAuthTestServer } from "@executor-js/sdk/testing";
21+import { IntegrationSlug } from "@executor-js/sdk/shared";
22+
23+import { scenario } from "../src/scenario";
24+
25+const api = composePluginApi([mcpHttpPlugin()] as const);
26+
27+scenario(
28+ "Auth methods · a detected-OAuth server gets an API key declared alongside",
29+ { needs: ["browser"] },
30+ (ctx) =>
31+ Effect.scoped(
32+ Effect.gen(function* () {
33+ // An OAuth-PROTECTED server: the probe gets a 401 with protected-
34+ // resource metadata pointing at the test OAuth issuer, so the method
35+ // list seeds with the detected OAuth row.
36+ const server = yield* serveMcpServerWithOAuth(
37+ () => makeGreetingMcpServer({ name: `oauth-mcp-${randomBytes(3).toString("hex")}` }),
38+ { path: "/mcp" },
39+ );
40+ const identity = yield* ctx.target.newIdentity();
41+
42+ yield* ctx.browser.session(identity, async ({ page, step }) => {
43+ await step("Open the add-MCP flow pointed at the server", async () => {
44+ await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, {
45+ waitUntil: "networkidle",
46+ });
47+ await page.getByText("How does this server authenticate?").waitFor();
48+ });
49+
50+ await step("The probe detected OAuth", async () => {
51+ await page.getByText("Method 1 · Detected").waitFor();
52+ // The OAuth editor declares discovery-at-connect, not pasted URLs.
53+ await page.getByText("OAuth metadata is discovered from this server").waitFor();
54+ });
55+
56+ await step("Declare an API key method alongside OAuth", async () => {
57+ await page.getByRole("button", { name: "Add method" }).click();
58+ await page.getByText("Method 2").waitFor();
59+ await page.getByPlaceholder("Authorization").last().waitFor();
60+ });
61+
62+ await step("Add the source with both methods", async () => {
63+ await page.getByRole("button", { name: "Add source" }).click();
64+ await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 });
65+ await page.getByText("Connections").first().waitFor();
66+ });
67+
68+ await step("The connect modal offers OAuth and the API key", async () => {
69+ await page.getByRole("button", { name: "Add connection" }).first().click();
70+ await page.getByRole("tab", { name: "OAuth" }).waitFor();
71+ await page.getByRole("tab", { name: "API key (Authorization)" }).waitFor();
72+ });
73+ });
74+ }),
75+ ).pipe(Effect.provide(OAuthTestServer.layer())),
76+);
77+
78+scenario(
79+ "Auth methods · a custom method added in the connect modal keeps OAuth",
80+ { needs: ["browser", "api"] },
81+ (ctx) =>
82+ Effect.scoped(
83+ Effect.gen(function* () {
84+ // A server that only accepts the bearer key — the connection created
85+ // through the custom method must render it on the wire.
86+ const token = `e2e-modal-key-${randomBytes(6).toString("hex")}`;
87+ const server = yield* serveMcpServer(() => makeGreetingMcpServer(), {
88+ auth: {
89+ validateAuthorization: (authorization) =>
90+ Effect.succeed(authorization === `Bearer ${token}`),
91+ },
92+ });
93+
94+ const identity = yield* ctx.target.newIdentity();
95+ const client = yield* ctx.api.client(api, identity);
96+ const slug = `mcp-modal-key-${randomBytes(3).toString("hex")}`;
97+
98+ // The integration as the add flow would have left it: OAuth only.
99+ yield* client.mcp.addServer({
100+ payload: {
101+ transport: "remote",
102+ name: "OAuth-only MCP",
103+ endpoint: server.endpoint,
104+ slug,
105+ authenticationTemplate: [{ kind: "oauth2" }],
106+ },
107+ });
108+
109+ // Remove the integration (and the connection it creates) afterward —
110+ // selfhost identities share one tenant, so a leaked connection would
111+ // break the "fresh identity has zero connections" scenario.
112+ yield* Effect.gen(function* () {
113+ yield* ctx.browser.session(identity, async ({ page, step }) => {
114+ await step("Open the integration's connect modal", async () => {
115+ await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
116+ await page.getByRole("button", { name: "Add connection" }).first().click();
117+ await page.getByRole("tab", { name: "OAuth" }).waitFor();
118+ });
119+
120+ await step("Add a custom API key method from the modal", async () => {
121+ await page.getByRole("button", { name: "Add authentication method" }).click();
122+ await page.getByLabel("Method name").fill("Team API key");
123+ await page.getByPlaceholder("Authorization").fill("Authorization");
124+ await page.getByPlaceholder("Bearer ").fill("Bearer ");
125+ await page.getByRole("button", { name: "Add method" }).click();
126+ });
127+
128+ await step("OAuth survives next to the new method", async () => {
129+ await page.getByRole("tab", { name: "API key (Authorization)" }).waitFor();
130+ await page.getByRole("tab", { name: "OAuth" }).waitFor();
131+ });
132+
133+ await step("Connect through the new method", async () => {
134+ await page.getByPlaceholder("paste the value / token").fill(token);
135+ await page.getByRole("button", { name: "Add connection" }).click();
136+ await page.getByText("Connection added").waitFor();
137+ });
138+ });
139+
140+ // Wire proof: discovery for the new connection hit the server with
141+ // the credential rendered through the custom method.
142+ const requests = yield* server.requests;
143+ expect(
144+ requests.some((request) => request.authorization === `Bearer ${token}`),
145+ "the server saw the bearer rendered through the custom method",
146+ ).toBe(true);
147+ }).pipe(
148+ Effect.ensuring(
149+ client.mcp
150+ .removeServer({ params: { slug: IntegrationSlug.make(slug) } })
151+ .pipe(Effect.ignore),
152+ ),
153+ );
154+ }),
155+ ),
156+);
e2e/selfhost/mcp-multi-auth.test.ts+91−0
@@ -0,0 +1,91 @@
1+// Selfhost-only: on a multi-method MCP integration, the connection's CHOSEN
2+// method is what renders the credential on the wire. A real MCP test server
3+// (in this test process — the selfhost dev server dials it over loopback)
4+// only accepts `Authorization: Bearer <token>`. The integration declares BOTH
5+// OAuth and a bearer-header method; a connection created through the header
6+// method must discover the server's tools, and the recorded requests must
7+// carry the rendered header — proving template selection by slug, not by
8+// "whatever the config says".
9+import { randomBytes } from "node:crypto";
10+
11+import { expect } from "@effect/vitest";
12+import { Effect } from "effect";
13+import { composePluginApi } from "@executor-js/api/server";
14+import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
15+import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
16+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
17+
18+import { scenario } from "../src/scenario";
19+
20+const api = composePluginApi([mcpHttpPlugin()] as const);
21+
22+scenario(
23+ "Auth methods · the connection's chosen method renders the credential on the wire",
24+ { needs: ["api"] },
25+ (ctx) =>
26+ Effect.scoped(
27+ Effect.gen(function* () {
28+ const token = `e2e-key-${randomBytes(6).toString("hex")}`;
29+ const server = yield* serveMcpServer(() => makeGreetingMcpServer(), {
30+ auth: {
31+ validateAuthorization: (authorization) =>
32+ Effect.succeed(authorization === `Bearer ${token}`),
33+ },
34+ });
35+
36+ const identity = yield* ctx.target.newIdentity();
37+ const client = yield* ctx.api.client(api, identity);
38+ const slug = freshSlug("mcp-wire-auth");
39+
40+ // Two declared methods — the connection must pick the right one.
41+ yield* client.mcp.addServer({
42+ payload: {
43+ transport: "remote",
44+ name: "Wire-auth MCP",
45+ endpoint: server.endpoint,
46+ slug,
47+ authenticationTemplate: [
48+ { kind: "oauth2" },
49+ { kind: "header", headerName: "Authorization", prefix: "Bearer " },
50+ ],
51+ },
52+ });
53+
54+ yield* Effect.gen(function* () {
55+ // Create the connection through the HEADER method (template slug
56+ // "header") with a pasted key. Tool discovery dials the server with
57+ // the rendered credential at create time.
58+ yield* client.connections.create({
59+ payload: {
60+ owner: "org",
61+ name: ConnectionName.make("wire-auth-key"),
62+ integration: IntegrationSlug.make(slug),
63+ template: AuthTemplateSlug.make("header"),
64+ value: token,
65+ },
66+ });
67+
68+ const tools = yield* client.tools.list();
69+ const mine = tools.filter((tool) => String(tool.integration) === slug);
70+ expect(
71+ mine.map((tool) => String(tool.name)).join(", "),
72+ "discovery through the header method found the server's tool",
73+ ).toContain("simple_echo");
74+
75+ const requests = yield* server.requests;
76+ expect(
77+ requests.some((request) => request.authorization === `Bearer ${token}`),
78+ "the server saw the credential rendered through the chosen method",
79+ ).toBe(true);
80+ }).pipe(
81+ Effect.ensuring(
82+ client.mcp
83+ .removeServer({ params: { slug: IntegrationSlug.make(slug) } })
84+ .pipe(Effect.ignore),
85+ ),
86+ );
87+ }),
88+ ),
89+);
90+
91+const freshSlug = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`;
e2e/setup/boot.ts+33−3
@@ -24,6 +24,10 @@ export const bootProcesses = ( · 23 unmodified lines
2424 cwd: proc.cwd,
2525 env: { ...process.env, ...proc.env },
2626 stdio: process.env.E2E_VERBOSE ? "inherit" : "ignore",
27+ // Own process group, so teardown can signal the whole tree — `bunx vite`
28+ // is a wrapper whose actual server child would otherwise outlive the
29+ // kill and squat the port into the NEXT invocation's waitForHttp.
30+ detached: true,
2731 });
2832 child.on("exit", (code) => {
2933 if (code !== 0 && code !== null && !tearingDown) {
@@ -32,12 +36,38 @@ export const bootProcesses = ( · 2 unmodified lines
3236 });
3337 children.push(child);
3438 }
39+
40+ // Signal the process GROUP (negative pid); fall back to the direct child
41+ // when the group is already gone.
42+ const signalTree = (child: ChildProcess, signal: NodeJS.Signals) => {
43+ if (child.pid === undefined || child.exitCode !== null) return;
44+ try {
45+ process.kill(-child.pid, signal);
46+ } catch {
47+ child.kill(signal);
48+ }
49+ };
50+
51+ const exited = (child: ChildProcess): Promise<void> =>
52+ child.exitCode !== null || child.signalCode !== null
53+ ? Promise.resolve()
54+ : new Promise((resolve) => child.once("exit", () => resolve()));
55+
3556 return {
3657 teardown: async () => {
3758 tearingDown = true;
38− for (const child of children) child.kill("SIGTERM");
39− await new Promise((resolve) => setTimeout(resolve, 500));
40− for (const child of children) if (child.exitCode === null) child.kill("SIGKILL");
59+ const allExited = Promise.all(children.map(exited));
60+ for (const child of children) signalTree(child, "SIGTERM");
61+ // Wait for a REAL exit (not a fixed sleep) — a lingering server would
62+ // answer the next invocation's waitForHttp as a half-dead zombie.
63+ const graceful = await Promise.race([
64+ allExited.then(() => true),
65+ new Promise<false>((resolve) => setTimeout(() => resolve(false), 5_000)),
66+ ]);
67+ if (!graceful) {
68+ for (const child of children) signalTree(child, "SIGKILL");
69+ await Promise.race([allExited, new Promise((resolve) => setTimeout(resolve, 2_000))]);
70+ }
4171 },
4272 };
4373 };
e2e/setup/cloud.globalsetup.ts+13−1
@@ -3,6 +3,7 @@ · 2 unmodified lines
33 // fully-stubbed instance (multi-user WorkOS stub, free-plan Autumn, no
44 // network). Set E2E_CLOUD_URL to attach to an already-running instance
55 // instead (e.g. while iterating on a scenario).
6+import { rmSync } from "node:fs";
67 import { resolve } from "node:path";
78 import { fileURLToPath } from "node:url";
89
@@ -17,8 +18,19 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 8 unmodified lines
1718 return;
1819 }
1920
21+ // Fresh stub DB per suite run — hermetic, like the selfhost data dir. The
22+ // WorkOS stub mints org ids from a per-process counter, so a persisted DB
23+ // from a previous invocation collides with the new boot's ids (identities
24+ // land in polluted orgs / org creation 500s).
25+ const dbPath = resolve(cloudDir, ".e2e-stub-db");
26+ rmSync(dbPath, { recursive: true, force: true });
27+
2028 const env = {
2129 EXECUTOR_E2E_STUB: "1",
30+ // The harness boots loopback MCP/OAuth test servers and points the
31+ // instance at them; the hosted SSRF guard would otherwise block outbound
32+ // probes/dials to localhost. Hermetic stub instance only.
33+ ALLOW_LOCAL_NETWORK: "true",
2234 // Stub creds — never contacted; the stub layers replace the clients.
2335 WORKOS_API_KEY: "sk_e2e_stub",
2436 WORKOS_CLIENT_ID: "client_e2e_stub",
@@ -33,7 +45,7 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 8 unmodified lines
3345 MCP_RESOURCE_ORIGIN: CLOUD_BASE_URL,
3446 // Throwaway PGlite on its own port + dir so it never fights `bun dev`.
3547 DEV_DB_PORT: String(CLOUD_DB_PORT),
36− DEV_DB_PATH: resolve(cloudDir, ".e2e-stub-db"),
48+ DEV_DB_PATH: dbPath,
3749 };
3850
3951 const procs = bootProcesses(
e2e/setup/selfhost.globalsetup.ts+4−0
@@ -34,6 +34,10 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 33 unmodified lines
3434 EXECUTOR_BOOTSTRAP_ADMIN_EMAIL: SELFHOST_ADMIN.email,
3535 EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD: SELFHOST_ADMIN.password,
3636 EXECUTOR_WEB_BASE_URL: SELFHOST_BASE_URL,
37+ // The harness boots loopback MCP/OAuth test servers and points the
38+ // instance at them; the hosted SSRF guard would otherwise block
39+ // outbound probes/dials to localhost. Hermetic test instance only.
40+ EXECUTOR_ALLOW_LOCAL_NETWORK: "true",
3741 },
3842 },
3943 ],
packages/core/sdk/src/executor.ts+1−0
@@ -1808,6 +1808,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1807 unmodified lines
18081808 integration: rowToIntegration(integrationRow),
18091809 config: decodeJsonColumn(integrationRow.config),
18101810 connection: ref,
1811+ template: existingRow ? AuthTemplateSlug.make(existingRow.template) : null,
18111812 getValue: () => resolveConnectionValueByRef(ref),
18121813 })
18131814 .pipe(
packages/core/sdk/src/index.ts+1−0
@@ -79,6 +79,7 @@ export type { · 78 unmodified lines
7979 IntegrationDisplayDescriptor,
8080 RegisterIntegrationInput,
8181 } from "./integration";
82+export { freshCustomAuthSlug, mergeAuthTemplates } from "./integration";
8283 export type {
8384 Connection,
8485 ConnectionRef,
packages/core/sdk/src/integration.ts+41−0
@@ -92,6 +92,47 @@ export interface Integration { · 91 unmodified lines
9292 * render auth / produce tools. Core treats it as an opaque JSON blob. */
9393 export type IntegrationConfig = unknown;
9494
95+// ---------------------------------------------------------------------------
96+// Auth-template merge — shared by every plugin whose config carries a slugged
97+// `authenticationTemplate` array (openapi, graphql, mcp). The custom-method
98+// flow merge-appends: an incoming entry with a matching slug replaces the
99+// existing entry in place; entries lacking a slug (or colliding with another
100+// entry added in the same call) get a fresh `custom_<id>` slug.
101+// ---------------------------------------------------------------------------
102+
103+const shortId = (): string => Math.random().toString(36).slice(2, 8);
104+
105+export const freshCustomAuthSlug = (taken: ReadonlySet<string>): string => {
106+ let candidate = `custom_${shortId()}`;
107+ while (taken.has(candidate)) candidate = `custom_${shortId()}`;
108+ return candidate;
109+};
110+
111+export const mergeAuthTemplates = <T extends { readonly slug: string }>(
112+ existing: readonly T[],
113+ incoming: readonly T[],
114+): readonly T[] => {
115+ const result: T[] = existing.map((entry: T) => entry);
116+ const taken = new Set<string>(result.map((entry: T) => String(entry.slug)));
117+ for (const entry of incoming) {
118+ // `slug` may be branded-required in the plugin's schema, but JSON callers
119+ // can submit it empty/blank — read defensively and backfill so every
120+ // stored template has a stable slug.
121+ const rawSlug = (entry as { readonly slug?: unknown }).slug;
122+ const requested = typeof rawSlug === "string" ? rawSlug.trim() : "";
123+ const existingIndex = result.findIndex((current: T) => String(current.slug) === requested);
124+ if (requested.length > 0 && existingIndex >= 0) {
125+ result[existingIndex] = entry;
126+ continue;
127+ }
128+ const slug =
129+ requested.length > 0 && !taken.has(requested) ? requested : freshCustomAuthSlug(taken);
130+ taken.add(slug);
131+ result.push({ ...entry, slug } as T);
132+ }
133+ return result;
134+};
135+
95136 /** What a plugin's extension method passes to `ctx.core.integrations.register`.
96137 * The v2 analog of v1's `SourceInput`, minus the per-source tool list (tools are
97138 * produced per-connection now). */
packages/core/sdk/src/plugin.ts+4−0
@@ -194,6 +194,10 @@ export interface ResolveToolsInput { · 193 unmodified lines
194194 readonly config: IntegrationConfig;
195195 /** The connection whose tools are being resolved. */
196196 readonly connection: ConnectionRef;
197+ /** Which of the integration's declared auth methods the connection binds
198+ * (`connection.template`), so multi-method integrations render the right
199+ * one during discovery. `null` when the connection row isn't persisted yet. */
200+ readonly template: AuthTemplateSlug | null;
197201 /** Lazily resolve the connection's credential value via its provider — only
198202 * the kinds that actually call out (mcp) pay for it. */
199203 readonly getValue: () => Effect.Effect<string | null, StorageFailure>;
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+65−85
@@ -1,13 +1,7 @@
11 import { useCallback, useMemo, useState } from "react";
2−import { useAtomSet, useAtomValue } from "@effect/atom-react";
3−import { Link } from "@tanstack/react-router";
2+import { useAtomSet } from "@effect/atom-react";
43 import * as Exit from "effect/Exit";
5−import * as Option from "effect/Option";
6−import * as Predicate from "effect/Predicate";
7−import * as Schema from "effect/Schema";
8−import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
94
10−import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms";
115 import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
126 import {
137 integrationDisplayNameFromUrl,
@@ -16,41 +10,35 @@ import { · 2 unmodified lines
1610 } from "@executor-js/react/plugins/integration-identity";
1711 import { Button } from "@executor-js/react/components/button";
1812 import {
19− AuthTemplateEditor,
20− type AuthTemplateEditorValue,
21−} from "@executor-js/react/components/auth-template-editor";
22−import { FieldLabel } from "@executor-js/react/components/field";
13+ AuthMethodListEditor,
14+ useAuthMethodList,
15+ type AuthMethodRow,
16+ type AuthMethodSeed,
17+} from "@executor-js/react/components/auth-method-list-editor";
2318 import { FloatActions } from "@executor-js/react/components/float-actions";
2419 import { Spinner } from "@executor-js/react/components/spinner";
20+import {
21+ addIntegrationErrorMessage,
22+ FormErrorAlert,
23+ SlugCollisionAlert,
24+ useSlugAlreadyExists,
25+} from "@executor-js/react/lib/integration-add";
2526
2627 import { addGraphqlIntegrationOptimistic } from "./atoms";
2728 import { GraphqlSourceFields } from "./GraphqlSourceFields";
2829 import { graphqlTemplatesFromPlacements } from "./auth-method-config";
30+import { GRAPHQL_APIKEY_TEMPLATE } from "./defaults";
31+import type { AuthTemplate } from "../sdk/types";
2932
30−const ErrorMessage = Schema.Struct({ message: Schema.String });
31−const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
32−
33−const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
34− Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
35− onNone: () => fallback,
36− onSome: ({ message }) => message,
37− });
33+// v2 GraphQL add flow: register the integration with its declared auth-method
34+// LIST (the shared `AuthMethodListEditor` — GraphQL stays header/query apiKey;
35+// OAuth is hidden), then route to the integration's detail hub. Connection
36+// creation is no longer part of the add flow — accounts are added from the hub
37+// (P6: add without auth, connect later).
3838
39−const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean =>
40− Option.match(Exit.findErrorOption(exit), {
41− onNone: () => false,
42− onSome: Predicate.isTagged("IntegrationAlreadyExistsError"),
43− });
44−
45−const integrationExistsMessage = (slug: string): string =>
46− `An integration named "${slug}" already exists. To add more authentication, update your existing integration.`;
47−
48−// v2 GraphQL add flow (post-redesign): register the integration (introspects
49−// the endpoint and declares an apiKey auth template when the user configures a
50−// header), then route to the integration's detail hub. Connection creation is
51−// no longer part of the add flow — accounts are added from the hub (P6: add
52−// without auth, connect later). Auth is declared through the shared
53−// `AuthTemplateEditor` (GraphQL stays header/query apiKey — OAuth is hidden).
39+// GraphQL has no add-time detection, so the list starts empty (module constant
40+// — a fresh [] every render would re-seed the list each render).
41+const NO_SEEDS: readonly AuthMethodSeed[] = [];
5442
5543 export default function AddGraphqlSource(props: {
5644 onComplete: (slug?: string) => void;
@@ -61,7 +49,7 @@ export default function AddGraphqlSource(props: { · 4 unmodified lines
6149 const identity = useIntegrationIdentity({
6250 fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "",
6351 });
64− const [authValue, setAuthValue] = useState<AuthTemplateEditorValue>({ kind: "none" });
52+ const authMethodList = useAuthMethodList(NO_SEEDS);
6553 const [adding, setAdding] = useState(false);
6654 const [addError, setAddError] = useState<string | null>(null);
6755
@@ -69,10 +57,33 @@ export default function AddGraphqlSource(props: { · 1 unmodified line
6957 mode: "promiseExit",
7058 });
7159
72− // An apiKey method needs at least one named placement; `none` is always valid.
73− const apiKeyComplete =
74− authValue.kind !== "apikey" ||
75− authValue.placements.some((placement) => placement.name.trim().length > 0);
60+ // The templates to register: each apikey row emits one template per named
61+ // placement (GraphQL templates carry one header/query slot each). The first
62+ // keeps the primary `apikey` slug (matching the prior single-method flow);
63+ // the rest get deterministic `apikey-<n>` slugs. `none` rows register
64+ // nothing.
65+ const authenticationTemplate = useMemo<readonly AuthTemplate[]>(() => {
66+ const templates: AuthTemplate[] = [];
67+ for (const row of authMethodList.rows) {
68+ if (row.value.kind !== "apikey") continue;
69+ for (const template of graphqlTemplatesFromPlacements(row.value.placements, "")) {
70+ const index = templates.length;
71+ templates.push({
72+ ...template,
73+ slug: index === 0 ? GRAPHQL_APIKEY_TEMPLATE : `apikey-${index + 1}`,
74+ });
75+ }
76+ }
77+ return templates;
78+ }, [authMethodList.rows]);
79+
80+ // Every apikey row needs at least one named placement; `none` rows are
81+ // always valid.
82+ const apiKeyComplete = authMethodList.rows.every(
83+ (row: AuthMethodRow) =>
84+ row.value.kind !== "apikey" ||
85+ row.value.placements.some((placement) => placement.name.trim().length > 0),
86+ );
7687
7788 const resolvedSlug = useMemo(
7889 () =>
@@ -85,13 +96,7 @@ export default function AddGraphqlSource(props: { · 6 unmodified lines
8596 // Pre-empt the API's `IntegrationAlreadyExistsError`: adding an integration
8697 // whose slug already exists clobbers the existing one's connections/policies,
8798 // so the API blocks it. Surface that here from the tenant-scoped catalog list.
88− const integrationsResult = useAtomValue(integrationsOptimisticAtom);
89− const slugAlreadyExists = useMemo(
90− () =>
91− AsyncResult.isSuccess(integrationsResult) &&
92− integrationsResult.value.some((integration) => String(integration.slug) === resolvedSlug),
93− [integrationsResult, resolvedSlug],
94− );
99+ const slugAlreadyExists = useSlugAlreadyExists(resolvedSlug);
95100
96101 const canAdd = endpoint.trim().length > 0 && apiKeyComplete && !adding && !slugAlreadyExists;
97102
@@ -108,24 +113,19 @@ export default function AddGraphqlSource(props: { · 10 unmodified lines
108113 setAddError(null);
109114 const { trimmedEndpoint, slug, displayName } = sourceIdentity();
110115
111− const authenticationTemplate =
112− authValue.kind === "apikey" ? graphqlTemplatesFromPlacements(authValue.placements) : [];
113−
114116 const integrationExit = await doAddIntegration({
115117 payload: {
116118 endpoint: trimmedEndpoint,
117119 slug,
118120 name: displayName,
119− ...(authenticationTemplate.length > 0 ? { authenticationTemplate } : {}),
121+ ...(authenticationTemplate.length > 0
122+ ? { authenticationTemplate: [...authenticationTemplate] }
123+ : {}),
120124 },
121125 reactivityKeys: integrationWriteKeys,
122126 });
123127 if (Exit.isFailure(integrationExit)) {
124− setAddError(
125− isIntegrationAlreadyExistsExit(integrationExit)
126− ? integrationExistsMessage(slug)
127− : errorMessageFromExit(integrationExit, "Failed to add source"),
128− );
128+ setAddError(addIntegrationErrorMessage(integrationExit, slug, "Failed to add source"));
129129 setAdding(false);
130130 return;
131131 }
@@ -140,36 +140,16 @@ export default function AddGraphqlSource(props: { · 8 unmodified lines
140140
141141 <GraphqlSourceFields endpoint={endpoint} onEndpointChange={setEndpoint} identity={identity} />
142142
143− <section className="space-y-2.5">
144− <FieldLabel>How does this API authenticate?</FieldLabel>
145− <AuthTemplateEditor
146− value={authValue}
147− onChange={setAuthValue}
148− allowedKinds={["none", "apikey"]}
149− />
150− </section>
151−
152− {slugAlreadyExists && !adding && (
153− <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
154− <p className="text-[12px] text-destructive">
155− An integration named &quot;{resolvedSlug}&quot; already exists. To add more
156− authentication, update your existing integration.{" "}
157− <Link
158− to="/integrations/$namespace"
159− params={{ namespace: resolvedSlug }}
160− className="font-medium underline underline-offset-2"
161− >
162− Open it
163− </Link>
164− </p>
165− </div>
166− )}
167−
168− {addError && (
169− <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
170− <p className="text-[12px] text-destructive">{addError}</p>
171− </div>
172− )}
143+ <AuthMethodListEditor
144+ list={authMethodList}
145+ allowedKinds={["none", "apikey"]}
146+ emptyHint="No authentication declared. Add a method, or add the source without auth and connect an account from the integration page later."
147+ footerHint="Every method here is registered with the source. Connect an account from the integration page after adding."
148+ />
149+
150+ {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSlug} />}
151+
152+ {addError && <FormErrorAlert message={addError} />}
173153
174154 <FloatActions>
175155 <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}>
packages/plugins/graphql/src/react/GraphqlAccountsPanel.tsx+31−31
@@ -7,8 +7,12 @@ import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; · 6 unmodified lines
77
88 import { AccountsSection } from "@executor-js/react/components/accounts-section";
99 import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
10−import type { CreateCustomMethod } from "@executor-js/react/components/add-custom-method-modal";
1110 import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
11+import {
12+ useCustomMethodActions,
13+ type AuthMethodsCodec,
14+ type ConfigureAuthMethods,
15+} from "@executor-js/react/lib/custom-auth-methods";
1216
1317 import { graphqlConfigAtom, graphqlConfigure } from "./atoms";
1418 import { authMethodsFromConfig, graphqlTemplatesFromPlacements } from "./auth-method-config";
@@ -47,45 +51,41 @@ export default function GraphqlAccountsPanel(props: { · 32 unmodified lines
4751 [existingTemplate],
4852 );
4953
50− // Add a custom apiKey method: build graphql `apiKey` templates from the
51− // generic placements (slug omitted → backend backfills `custom_<id>`),
52− // merge-append (the configure endpoint merges) and persist. Returns the
53− // created `AuthMethod` so Add-account can select it immediately.
54− const createCustomMethod = useCallback<CreateCustomMethod>(
55− async (input: { readonly label: string; readonly placements: readonly Placement[] }) => {
56− const templates = graphqlTemplatesFromPlacements(input.placements, "");
57− if (templates.length === 0) return null;
54+ // Custom-method create/remove: the shared skeleton (merge-append → diff out
55+ // the created method; filter → replace) parameterized by the GraphQL codec.
56+ // Stays plugin-side only where it touches the graphql `AuthTemplate` types.
57+ const configure = useCallback<ConfigureAuthMethods<AuthTemplate>>(
58+ async (input) => {
5859 const exit = await doConfigure({
5960 params: { slug: String(slug) },
60− payload: { authenticationTemplate: templates },
61+ payload: {
62+ authenticationTemplate: input.authenticationTemplate,
63+ ...(input.mode ? { mode: input.mode } : {}),
64+ },
6165 reactivityKeys: integrationWriteKeys,
6266 });
63− if (Exit.isFailure(exit)) return null;
64− const before = new Set(existingTemplate.map((template: AuthTemplate) => template.slug));
65− const created = authMethodsFromConfig(exit.value.authenticationTemplate).find(
66− (candidate: AuthMethod) => !before.has(String(candidate.template)),
67− );
68− return created ?? null;
67+ return Exit.map(exit, (result) => result.authenticationTemplate);
6968 },
70− [doConfigure, existingTemplate, slug],
69+ [doConfigure, slug],
7170 );
7271
73− const removeCustomMethod = useCallback(
74− async (method: AuthMethod): Promise<boolean> => {
75− if (method.source !== "custom") return false;
76− const next = existingTemplate.filter(
77− (template: AuthTemplate) => template.slug !== String(method.template),
78− );
79− const exit = await doConfigure({
80− params: { slug: String(slug) },
81− payload: { authenticationTemplate: next, mode: "replace" },
82− reactivityKeys: integrationWriteKeys,
83− });
84− return Exit.isSuccess(exit);
85− },
86− [doConfigure, existingTemplate, slug],
72+ const codec = useMemo<AuthMethodsCodec<AuthTemplate>>(
73+ () => ({
74+ toAuthMethods: authMethodsFromConfig,
75+ // Slug blank → backend backfills `custom_<id>` per emitted template.
76+ templatesFromPlacements: (placements: readonly Placement[]) =>
77+ graphqlTemplatesFromPlacements(placements, ""),
78+ slugOf: (template: AuthTemplate) => template.slug,
79+ }),
80+ [],
8781 );
8882
83+ const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({
84+ existing: existingTemplate,
85+ codec,
86+ configure,
87+ });
88+
8989 return (
9090 <div className="mx-auto max-w-3xl space-y-8 px-6 py-8">
9191 <AccountsSection
packages/plugins/graphql/src/sdk/plugin.ts+14−47
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 IntegrationAlreadyExistsError,
1010 IntegrationDetectionResult,
1111 IntegrationSlug,
12+ mergeAuthTemplates,
1213 ToolName,
1314 ToolResult,
1415 type AuthMethodDescriptor,
@@ -387,14 +388,17 @@ const toStoredOperations = ( · 372 unmodified lines
387388 const introspectHeadersForConnection = (
388389 config: GraphqlIntegrationConfig,
389390 credentialValue: string | null,
391+ templateSlug: AuthTemplateSlug | null,
390392 ): RenderedAuth => {
391393 const headers: Record<string, string> = { ...(config.headers ?? {}) };
392394 const queryParams: Record<string, string> = { ...(config.queryParams ?? {}) };
393395 if (credentialValue !== null) {
394− // A connection references exactly one template; when several are declared we
395− // can't know which without the slug (not carried into `resolveTools`), so we
396− // apply the first. Most integrations declare a single auth method.
397− const template = config.authenticationTemplate[0];
396+ // Render the exact template the connection references; with no slug
397+ // (connection row not yet persisted) fall back to the first declared.
398+ const template =
399+ (templateSlug !== null
400+ ? config.authenticationTemplate.find((t: AuthTemplate) => t.slug === String(templateSlug))
401+ : undefined) ?? config.authenticationTemplate[0];
398402 if (template) {
399403 const rendered = renderAuthTemplate(template, credentialValue);
400404 Object.assign(headers, rendered.headers);
@@ -410,12 +414,13 @@ const introspectHeadersForConnection = ( · 9 unmodified lines
410414 const introspectForConnection = (
411415 config: GraphqlIntegrationConfig,
412416 credentialValue: string | null,
417+ templateSlug: AuthTemplateSlug | null,
413418 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
414419 ): Effect.Effect<IntrospectionResult, GraphqlIntrospectionError> => {
415420 if (config.introspectionJson) {
416421 return parseIntrospectionJson(config.introspectionJson);
417422 }
418− const auth = introspectHeadersForConnection(config, credentialValue);
423+ const auth = introspectHeadersForConnection(config, credentialValue, templateSlug);
419424 return introspect(
420425 config.endpoint,
421426 Object.keys(auth.headers).length > 0 ? auth.headers : undefined,
@@ -532,44 +537,6 @@ export const describeGraphqlIntegrationDisplay = ( · 110 unmodified lines
532537 return { url: config?.endpoint };
533538 };
534539
535−// ---------------------------------------------------------------------------
536−// Auth-template merge — append the incoming custom methods onto the existing
537−// `authenticationTemplate`, replacing entries whose slug matches and assigning a
538−// fresh `custom_<id>` slug to entries that omit one (or collide). Mirrors the
539−// OpenAPI plugin's `mergeAuthenticationTemplate` so the custom-method-create UX
540−// is identical across plugins (item 2A).
541−// ---------------------------------------------------------------------------
542−
543−const shortId = (): string => Math.random().toString(36).slice(2, 8);
544−
545−const freshCustomSlug = (taken: ReadonlySet<string>): string => {
546− let candidate = `custom_${shortId()}`;
547− while (taken.has(candidate)) candidate = `custom_${shortId()}`;
548− return candidate;
549−};
550−
551−const mergeGraphqlAuthTemplate = (
552− existing: readonly AuthTemplate[],
553− incoming: readonly AuthTemplate[],
554−): readonly AuthTemplate[] => {
555− const result: AuthTemplate[] = existing.map((entry: AuthTemplate) => entry);
556− const taken = new Set<string>(result.map((entry: AuthTemplate) => entry.slug));
557− for (const entry of incoming) {
558− // `slug` is a plain string; a JSON caller may submit it empty/blank — read
559− // defensively and backfill so every stored template has a stable slug.
560− const requested = entry.slug.trim();
561− const existingIndex = result.findIndex((current: AuthTemplate) => current.slug === requested);
562− if (requested.length > 0 && existingIndex >= 0) {
563− result[existingIndex] = entry;
564− continue;
565− }
566− const slug = requested.length > 0 && !taken.has(requested) ? requested : freshCustomSlug(taken);
567− taken.add(slug);
568− result.push({ ...entry, slug } as AuthTemplate);
569− }
570− return result;
571−};
572−
573540 // ---------------------------------------------------------------------------
574541 // Plugin extension
575542 // ---------------------------------------------------------------------------
@@ -735,10 +702,7 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 159 unmodified lines
735702 const merged =
736703 input.mode === "replace"
737704 ? input.authenticationTemplate
738− : mergeGraphqlAuthTemplate(
739− current.authenticationTemplate,
740− input.authenticationTemplate,
741− );
705+ : mergeAuthTemplates(current.authenticationTemplate, input.authenticationTemplate);
742706
743707 const next = GraphqlIntegrationConfig.make({
744708 ...current,
@@ -884,9 +848,11 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 139 unmodified lines
884848 // -----------------------------------------------------------------------
885849 resolveTools: ({
886850 config,
851+ template,
887852 getValue,
888853 }: {
889854 readonly config: IntegrationConfig;
855+ readonly template: AuthTemplateSlug | null;
890856 readonly getValue: () => Effect.Effect<string | null, unknown>;
891857 }) =>
892858 Effect.gen(function* () {
@@ -902,6 +868,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 9 unmodified lines
902868 const introspection = yield* introspectForConnection(
903869 graphqlConfig,
904870 credentialValue,
871+ template,
905872 options?.httpClientLayer ?? httpClientLayerFallback,
906873 ).pipe(Effect.option);
907874 if (Option.isNone(introspection)) return { tools: [] };
packages/plugins/http-source/src/sdk/plugin.test.ts+1−0
@@ -107,6 +107,7 @@ describe("httpSourcePlugin.resolveTools", () => { · 106 unmodified lines
107107 integration: IntegrationSlug.make("example"),
108108 name: ConnectionName.make("default"),
109109 },
110+ template: null,
110111 getValue: () => Effect.succeed(null),
111112 };
112113 const result = yield* plugin.resolveTools!(input);
packages/plugins/mcp/src/api/group.ts+30−1
@@ -7,7 +7,12 @@ import { · 6 unmodified lines
77 } from "@executor-js/sdk/shared";
88
99 import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors";
10−import { McpAuthTemplate, McpIntegrationConfig } from "../sdk/types";
10+import {
11+ McpAuthMethod,
12+ McpAuthMethodInput,
13+ McpAuthTemplate,
14+ McpIntegrationConfig,
15+} from "../sdk/types";
1116
1217 // ---------------------------------------------------------------------------
1318 // Params
@@ -31,6 +36,10 @@ const AddRemoteServerPayload = Schema.Struct({ · 17 unmodified lines
3136 slug: Schema.optional(Schema.String),
3237 queryParams: Schema.optional(StringMap),
3338 headers: Schema.optional(StringMap),
39+ /** Declared auth methods a connection can be applied through. */
40+ authenticationTemplate: Schema.optional(Schema.Array(McpAuthMethodInput)),
41+ /** Single-method shorthand (legacy callers); ignored when
42+ * `authenticationTemplate` is present. */
3443 auth: Schema.optional(McpAuthTemplate),
3544 });
3645
@@ -83,6 +92,18 @@ const ConfigureServerResponse = Schema.Struct({ · 46 unmodified lines
8392 config: McpIntegrationConfig,
8493 });
8594
95+// The configureAuth payload/response — custom auth methods to merge-append
96+// onto the integration's `authenticationTemplate` (or `replace` the set).
97+// Mirrors the GraphQL/OpenAPI configure endpoints.
98+const ConfigureAuthPayload = Schema.Struct({
99+ authenticationTemplate: Schema.Array(McpAuthMethodInput),
100+ mode: Schema.optional(Schema.Literals(["merge", "replace"])),
101+});
102+
103+const ConfigureAuthResponse = Schema.Struct({
104+ authenticationTemplate: Schema.Array(McpAuthMethod),
105+});
106+
86107 const GetServerResponse = Schema.NullOr(
87108 Schema.Struct({
88109 slug: IntegrationSlug,
@@ -143,4 +164,12 @@ export const McpGroup = HttpApiGroup.make("mcp") · 54 unmodified lines
143164 success: ConfigureServerResponse,
144165 error: [InternalError, McpConnectionError, McpToolDiscoveryError],
145166 }),
167+ )
168+ .add(
169+ HttpApiEndpoint.post("configureAuth", "/mcp/servers/:slug/auth", {
170+ params: SlugParams,
171+ payload: ConfigureAuthPayload,
172+ success: ConfigureAuthResponse,
173+ error: [InternalError, McpConnectionError, McpToolDiscoveryError],
174+ }),
146175 );
packages/plugins/mcp/src/api/handlers.test.ts+1−0
@@ -28,6 +28,7 @@ const failingExtension: McpPluginExtension = { · 27 unmodified lines
2828 removeServer: () => unused,
2929 getServer: () => Effect.succeed(null),
3030 configureServer: () => unused,
31+ configureAuth: () => unused,
3132 };
3233
3334 const Api = addGroup(McpGroup);
packages/plugins/mcp/src/api/handlers.ts+16−0
@@ -58,6 +58,9 @@ const toServerInput = ( · 57 unmodified lines
5858 queryParams?: Record<string, string>;
5959 headers?: Record<string, string>;
6060 slug?: string;
61+ authenticationTemplate?: McpServerInput extends { authenticationTemplate?: infer T }
62+ ? T
63+ : never;
6164 auth?: McpServerInput extends { auth?: infer A } ? A : never;
6265 };
6366
@@ -69,6 +72,7 @@ const toServerInput = ( · 5 unmodified lines
6972 queryParams: p.queryParams,
7073 headers: p.headers,
7174 slug: p.slug,
75+ authenticationTemplate: p.authenticationTemplate,
7276 auth: p.auth,
7377 };
7478 };
@@ -133,5 +137,17 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 58 unmodified lines
133137 return { config: payload.config };
134138 }),
135139 ),
140+ )
141+ .handle("configureAuth", ({ params: path, payload }) =>
142+ capture(
143+ Effect.gen(function* () {
144+ const ext = yield* McpExtensionService;
145+ const authenticationTemplate = yield* ext.configureAuth(path.slug, {
146+ authenticationTemplate: payload.authenticationTemplate,
147+ mode: payload.mode ?? "merge",
148+ });
149+ return { authenticationTemplate: [...authenticationTemplate] };
150+ }),
151+ ),
136152 ),
137153 );
packages/plugins/mcp/src/react/AddMcpSource.tsx+80−132
@@ -1,26 +1,20 @@
11 import { useReducer, useCallback, useEffect, useMemo, useRef, useState } from "react";
2−import { useAtomSet, useAtomValue } from "@effect/atom-react";
3−import { Link } from "@tanstack/react-router";
2+import { useAtomSet } from "@effect/atom-react";
43 import * as Exit from "effect/Exit";
54 import * as Match from "effect/Match";
6−import * as Option from "effect/Option";
7−import * as Predicate from "effect/Predicate";
8−import * as Schema from "effect/Schema";
9−import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
105
11−import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms";
126 import { Button } from "@executor-js/react/components/button";
137 import {
14− AuthTemplateEditor,
15− type AuthTemplateEditorKind,
16− type AuthTemplateEditorValue,
17−} from "@executor-js/react/components/auth-template-editor";
8+ AuthMethodListEditor,
9+ useAuthMethodList,
10+ type AuthMethodRow,
11+ type AuthMethodSeed,
12+} from "@executor-js/react/components/auth-method-list-editor";
1813 import {
1914 CardStack,
2015 CardStackContent,
2116 CardStackEntryField,
2217 } from "@executor-js/react/components/card-stack";
23−import { FieldLabel } from "@executor-js/react/components/field";
2418 import { FloatActions } from "@executor-js/react/components/float-actions";
2519 import { Input } from "@executor-js/react/components/input";
2620 import { Spinner } from "@executor-js/react/components/spinner";
@@ -31,20 +25,29 @@ import { · 4 unmodified lines
3125 IntegrationIdentityFields,
3226 useIntegrationIdentity,
3327 } from "@executor-js/react/plugins/integration-identity";
28+import {
29+ addIntegrationErrorMessage,
30+ errorMessageFromExit,
31+ FormErrorAlert,
32+ SlugCollisionAlert,
33+ useSlugAlreadyExists,
34+} from "@executor-js/react/lib/integration-add";
3435
3536 import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
37+import type { McpAuthMethodInput } from "../sdk/types";
3638 import { probeMcpEndpoint, addMcpServer } from "./atoms";
3739 import { McpRemoteSourceFields } from "./McpRemoteSourceFields";
38−import { mcpAuthTemplateFromEditorValue } from "./auth-method-config";
40+import { mcpAuthMethodInputFromEditorValue } from "./auth-method-config";
3941 import { mcpPresets, type McpPreset } from "../sdk/presets";
4042
41−// Post-redesign the remote add flow only REGISTERS the auth template through the
42−// shared `AuthTemplateEditor` — accounts (the API key value / OAuth sign-in) are
43−// added later from the integration's detail hub (P6: add without auth, connect
44−// later). An OAuth-only server (DCR-capable) is constrained to the OAuth tab.
43+// The remote add flow REGISTERS the server's declared auth methods through the
44+// shared `AuthMethodListEditor` — accounts (the API key value / OAuth sign-in)
45+// are added later from the integration's detail hub (P6: add without auth,
46+// connect later). The probe SEEDS the list (detected OAuth → an OAuth row; a
47+// 401 without OAuth → a bearer-header row; open server → a no-auth row) and
48+// the user can add alternate methods (e.g. an API key alongside OAuth, or a
49+// declared method on a server that advertises none).
4550
46−const ErrorMessage = Schema.Struct({ message: Schema.String });
47−const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
4851 const STDIO_ENV_ESCAPE_REPLACEMENTS: Readonly<Record<string, string>> = {
4952 "\\": "\\",
5053 n: "\n",
@@ -53,21 +56,6 @@ const STDIO_ENV_ESCAPE_REPLACEMENTS: Readonly<Record<string, string>> = { · 2 unmodified lines
5356 '"': '"',
5457 };
5558
56−const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
57− Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
58− onNone: () => fallback,
59− onSome: ({ message }) => message,
60− });
61−
62−const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean =>
63− Option.match(Exit.findErrorOption(exit), {
64− onNone: () => false,
65− onSome: Predicate.isTagged("IntegrationAlreadyExistsError"),
66− });
67−
68−const integrationExistsMessage = (slug: string): string =>
69− `An integration named "${slug}" already exists. To add more authentication, update your existing integration.`;
70−
7159 // ---------------------------------------------------------------------------
7260 // Preset lookup
7361 // ---------------------------------------------------------------------------
@@ -212,18 +200,35 @@ export default function AddMcpSource(props: { · 138 unmodified lines
212200 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });
213201 const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });
214202
215− const [authValue, setAuthValue] = useState<AuthTemplateEditorValue>({ kind: "none" });
216−
217203 const probe = "probe" in state ? state.probe : null;
218204
219− // OAuth-only servers (DCR-capable) constrain the editor to the OAuth tab; all
220− // other servers offer none / API key / OAuth (matching the prior tab set).
221− const allowedAuthKinds: readonly AuthTemplateEditorKind[] =
222− probe?.requiresOAuth && probe.supportsDynamicRegistration
223− ? ["oauth"]
224− : probe?.requiresAuthentication
225− ? ["apikey", "oauth"]
226− : ["none", "apikey", "oauth"];
205+ // The probe seeds the method list: detected OAuth → an OAuth row; a 401
206+ // without OAuth metadata → a bearer-header row; an open server → a no-auth
207+ // row. The user can edit any row or add alternate methods alongside.
208+ const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(() => {
209+ if (!probe) return [];
210+ if (probe.requiresOAuth) {
211+ return [
212+ {
213+ value: { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] },
214+ label: "Detected",
215+ },
216+ ];
217+ }
218+ if (probe.requiresAuthentication) {
219+ return [
220+ {
221+ value: {
222+ kind: "apikey",
223+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
224+ },
225+ label: "Detected",
226+ },
227+ ];
228+ }
229+ return [{ value: { kind: "none" }, label: "Detected" }];
230+ }, [probe]);
231+ const authMethodList = useAuthMethodList(authMethodSeeds);
227232
228233 const remoteIdentity = useIntegrationIdentity({
229234 fallbackName:
@@ -237,18 +242,10 @@ export default function AddMcpSource(props: { · 7 unmodified lines
237242 // so the API blocks it. Surface that here from the tenant-scoped catalog list.
238243 // A blank derived namespace lets the server assign the slug, so only flag a
239244 // collision when the user-derived slug is non-empty.
240− const integrationsResult = useAtomValue(integrationsOptimisticAtom);
241− const existingSlugs = useMemo(
242− () =>
243− AsyncResult.isSuccess(integrationsResult)
244− ? integrationsResult.value.map((integration) => String(integration.slug))
245− : [],
246− [integrationsResult],
247− );
248245 const remoteSlug = slugifyNamespace(remoteIdentity.namespace);
249246 const stdioSlug = slugifyNamespace(stdioIdentity.namespace);
250− const remoteSlugExists = remoteSlug.length > 0 && existingSlugs.includes(remoteSlug);
251− const stdioSlugExists = stdioSlug.length > 0 && existingSlugs.includes(stdioSlug);
247+ const remoteSlugExists = useSlugAlreadyExists(remoteSlug);
248+ const stdioSlugExists = useSlugAlreadyExists(stdioSlug);
252249
253250 const canAdd = Boolean(probe) && !isAdding && !remoteSlugExists;
254251 // Probe failures are shown inline on the URL field; other failures
@@ -270,16 +267,6 @@ export default function AddMcpSource(props: { · 15 unmodified lines
270267 });
271268 return;
272269 }
273− setAuthValue(
274− exit.value.requiresOAuth
275− ? { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }
276− : exit.value.requiresAuthentication
277− ? {
278− kind: "apikey",
279− placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
280− }
281− : { kind: "none" },
282− );
283270 dispatch({ type: "probe-ok", probe: exit.value });
284271 }, [state.url, doProbe]);
285272
@@ -301,15 +288,10 @@ export default function AddMcpSource(props: { · 15 unmodified lines
301288 return () => clearTimeout(handle);
302289 }, [transport, state.step, state.url]);
303290
304− // Register the integration with the chosen auth template, returning the
291+ // Register the integration with the declared auth methods, returning the
305292 // assigned slug (or null on failure — an error is dispatched in that case).
306293 const registerIntegration = useCallback(
307− async (
308− auth:
309− | { kind: "none" }
310− | { kind: "header"; headerName: string; prefix?: string }
311− | { kind: "oauth2" },
312− ): Promise<string | null> => {
294+ async (authenticationTemplate: readonly McpAuthMethodInput[]): Promise<string | null> => {
313295 const displayName = remoteIdentity.name.trim() || probe?.serverName || probe?.name || "MCP";
314296 const slug = slugifyNamespace(remoteIdentity.namespace) || undefined;
315297 const exit = await doAddServer({
@@ -318,16 +300,14 @@ export default function AddMcpSource(props: { · 2 unmodified lines
318300 name: displayName,
319301 endpoint: state.url.trim(),
320302 ...(slug ? { slug } : {}),
321− auth,
303+ authenticationTemplate,
322304 },
323305 reactivityKeys: integrationWriteKeys,
324306 });
325307 if (Exit.isFailure(exit)) {
326308 dispatch({
327309 type: "add-fail",
328− error: isIntegrationAlreadyExistsExit(exit)
329− ? integrationExistsMessage(slug ?? displayName)
330− : errorMessageFromExit(exit, "Failed to add server"),
310+ error: addIntegrationErrorMessage(exit, slug ?? displayName, "Failed to add server"),
331311 });
332312 return null;
333313 }
@@ -339,10 +319,17 @@ export default function AddMcpSource(props: { · 5 unmodified lines
339319 const handleAddRemote = useCallback(async () => {
340320 if (!probe) return;
341321 dispatch({ type: "add-start" });
342− const slug = await registerIntegration(mcpAuthTemplateFromEditorValue(authValue));
322+ // Every row registers as a declared method (a lone no-auth row registers
323+ // the open-server method). Slugs are assigned server-side by kind.
324+ const methods = authMethodList.rows.map((row: AuthMethodRow) =>
325+ mcpAuthMethodInputFromEditorValue(row.value),
326+ );
327+ const slug = await registerIntegration(
328+ methods.length > 0 ? methods : [{ kind: "none" as const }],
329+ );
343330 if (slug === null) return;
344331 props.onComplete(slug);
345− }, [probe, authValue, registerIntegration, props]);
332+ }, [probe, authMethodList.rows, registerIntegration, props]);
346333
347334 // ---- Stdio actions ----
348335
@@ -406,11 +393,7 @@ export default function AddMcpSource(props: { · 57 unmodified lines
406393 reactivityKeys: integrationWriteKeys,
407394 });
408395 if (Exit.isFailure(exit)) {
409− setStdioError(
410− isIntegrationAlreadyExistsExit(exit)
411− ? integrationExistsMessage(slug ?? displayName)
412− : errorMessageFromExit(exit, "Failed to add server"),
413− );
396+ setStdioError(addIntegrationErrorMessage(exit, slug ?? displayName, "Failed to add server"));
414397 setStdioAdding(false);
415398 return;
416399 }
@@ -470,27 +453,24 @@ export default function AddMcpSource(props: { · 53 unmodified lines
470453 onRetry={handleProbe}
471454 />
472455
473− {/* Authentication — declares the auth template to register through the
474− shared editor. The credential itself (API key value / OAuth sign-in)
475− is added from the integration's detail hub after adding. */}
456+ {/* Authentication — declares the auth methods to register through the
457+ shared list editor. The credentials themselves (API key value /
458+ OAuth sign-in) are added from the integration's detail hub after
459+ adding. */}
476460 {probe && (
477− <section className="space-y-2.5">
478− <FieldLabel>How does this server authenticate?</FieldLabel>
479− <AuthTemplateEditor
480− value={authValue}
481− onChange={setAuthValue}
482− allowedKinds={allowedAuthKinds}
483− oauthMetadata="discovered"
484− />
485− </section>
461+ <AuthMethodListEditor
462+ list={authMethodList}
463+ title="How does this server authenticate?"
464+ oauthMetadata="discovered"
465+ emptyHint="No methods declared. Add a method, or add the server without auth and connect from the integration page later."
466+ footerHint="Every method here is registered with the server. Connect an account from the integration page after adding."
467+ />
486468 )}
487469
488470 {/* Error (add server). Probe errors show inline on the field. */}
489471 {otherError && (
490472 <div className="space-y-2">
491− <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
492− <p className="text-[12px] text-destructive">{otherError}</p>
493− </div>
473+ <FormErrorAlert message={otherError} />
494474 <Button
495475 type="button"
496476 variant="outline"
@@ -503,21 +483,7 @@ export default function AddMcpSource(props: { · 6 unmodified lines
503483 </div>
504484 )}
505485
506− {remoteSlugExists && !isAdding && (
507− <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
508− <p className="text-[12px] text-destructive">
509− An integration named &quot;{remoteSlug}&quot; already exists. To add more
510− authentication, update your existing integration.{" "}
511− <Link
512− to="/integrations/$namespace"
513− params={{ namespace: remoteSlug }}
514− className="font-medium underline underline-offset-2"
515− >
516− Open it
517− </Link>
518− </p>
519− </div>
520− )}
486+ {remoteSlugExists && !isAdding && <SlugCollisionAlert slug={remoteSlug} />}
521487
522488 <FloatActions>
523489 <Button
@@ -589,27 +555,9 @@ export default function AddMcpSource(props: { · 65 unmodified lines
589555 <IntegrationIdentityFields identity={stdioIdentity} namePlaceholder="My MCP Server" />
590556
591557 {/* Stdio error */}
592− {stdioError && (
593− <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
594− <p className="text-[12px] text-destructive">{stdioError}</p>
595− </div>
596− )}
558+ {stdioError && <FormErrorAlert message={stdioError} />}
597559
598− {stdioSlugExists && !stdioAdding && (
599− <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
600− <p className="text-[12px] text-destructive">
601− An integration named &quot;{stdioSlug}&quot; already exists. To add more
602− authentication, update your existing integration.{" "}
603− <Link
604− to="/integrations/$namespace"
605− params={{ namespace: stdioSlug }}
606− className="font-medium underline underline-offset-2"
607− >
608− Open it
609− </Link>
610− </p>
611− </div>
612− )}
560+ {stdioSlugExists && !stdioAdding && <SlugCollisionAlert slug={stdioSlug} />}
613561
614562 <FloatActions>
615563 <Button
packages/plugins/mcp/src/react/EditMcpSource.tsx+88−234
@@ -1,24 +1,17 @@
1−import { useCallback, useEffect, useMemo, useState } from "react";
1+import { useCallback, useMemo, useState } from "react";
22 import { useAtomValue, useAtomSet } from "@effect/atom-react";
33 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
44 import * as Exit from "effect/Exit";
55
6−import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared";
7−import { createConnection } from "@executor-js/react/api/atoms";
8−import { connectionWriteKeys, integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
9−import { connectionIdentifier } from "@executor-js/react/lib/connection-name";
6+import { IntegrationSlug } from "@executor-js/sdk/shared";
7+import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
108 import {
11− AuthTemplateEditor,
12− type AuthTemplateEditorValue,
13−} from "@executor-js/react/components/auth-template-editor";
14−import {
15− CredentialControlField,
16− ConnectionOwnerUsageRow,
17− useConnectionOwner,
18−} from "@executor-js/react/plugins/connection-owner";
19−import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in";
9+ AuthMethodListEditor,
10+ useAuthMethodList,
11+ type AuthMethodRow,
12+ type AuthMethodSeed,
13+} from "@executor-js/react/components/auth-method-list-editor";
2014 import { Button } from "@executor-js/react/components/button";
21−import { AddAccountModal } from "@executor-js/react/components/add-account-modal";
2215 import {
2316 CardStack,
2417 CardStackContent,
@@ -27,16 +20,15 @@ import { · 2 unmodified lines
2720 CardStackEntryDescription,
2821 CardStackEntryTitle,
2922 } from "@executor-js/react/components/card-stack";
30−import { Input } from "@executor-js/react/components/input";
3123 import { Badge } from "@executor-js/react/components/badge";
32−import type { AuthMethod } from "@executor-js/react/lib/auth-placements";
33−
34−import { configureMcpServer, mcpServerAtom } from "./atoms";
35−import type { McpAuthTemplate, McpIntegrationConfig } from "../sdk/types";
36−import { mcpAuthTemplateFromEditorValue } from "./auth-method-config";
24+import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
3725
38−const HEADER_TEMPLATE = AuthTemplateSlug.make("header");
39−const OAUTH_TEMPLATE = AuthTemplateSlug.make("oauth2");
26+import { configureMcpAuth, mcpServerAtom } from "./atoms";
27+import type { McpAuthMethod, McpAuthMethodInput, McpIntegrationConfig } from "../sdk/types";
28+import {
29+ editorValueFromMcpAuthMethod,
30+ mcpAuthMethodInputFromEditorValue,
31+} from "./auth-method-config";
4032
4133 type McpServer = {
4234 readonly slug: IntegrationSlug;
@@ -47,34 +39,19 @@ type McpServer = { · 4 unmodified lines
4739 readonly config: McpIntegrationConfig;
4840 };
4941
50−const authTemplateToEditorValue = (auth: McpRemoteConfig["auth"]): AuthTemplateEditorValue => {
51− if (auth.kind === "none") return { kind: "none" };
52− if (auth.kind === "oauth2")
53− return { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] };
54− return {
55− kind: "apikey",
56− placements: [
57− {
58− carrier: "header",
59− name: auth.headerName,
60− prefix: auth.prefix ?? "",
61− },
62− ],
63− };
64−};
42+type McpRemoteConfig = Extract<McpIntegrationConfig, { transport: "remote" }>;
6543
66−const authTemplatesEqual = (left: McpAuthTemplate, right: McpAuthTemplate): boolean => {
67− if (left.kind !== right.kind) return false;
68− if (left.kind === "header" && right.kind === "header") {
69− return left.headerName === right.headerName && (left.prefix ?? "") === (right.prefix ?? "");
70− }
71− return true;
44+const methodSeedLabel = (method: McpAuthMethod): string => {
45+ if (method.kind === "oauth2") return "OAuth";
46+ if (method.kind === "header") return `API key (${method.headerName})`;
47+ return "No authentication";
7248 };
7349
7450 // ---------------------------------------------------------------------------
75−// Remote edit — v2: the integration's endpoint + auth template are part of its
76−// identity (opaque-to-core config). The editable surface is the connection: an
77−// API-key header value, or an OAuth sign-in, both owner-scoped.
51+// Remote edit — v2: the integration's endpoint is part of its identity
52+// (opaque-to-core config); the editable surface is the declared auth-method
53+// LIST, through the same shared editor as the add flow. Accounts (credentials)
54+// are managed from the integration page's accounts hub.
7855 // ---------------------------------------------------------------------------
7956
8057 function RemoteEdit(props: {
@@ -82,115 +59,81 @@ function RemoteEdit(props: { · 1 unmodified line
8259 onSave: () => void;
8360 }) {
8461 const { server } = props;
85− const auth = server.config.auth;
86− const { connectionOwner, setConnectionOwner, connectionOwnerOptions } = useConnectionOwner();
87− const doCreate = useAtomSet(createConnection, { mode: "promiseExit" });
88− const doConfigure = useAtomSet(configureMcpServer, { mode: "promiseExit" });
62+ const doConfigureAuth = useAtomSet(configureMcpAuth, { mode: "promiseExit" });
8963
90− const [authValue, setAuthValue] = useState<AuthTemplateEditorValue>(() =>
91− authTemplateToEditorValue(auth),
64+ const seeds = useMemo<readonly AuthMethodSeed[]>(
65+ () =>
66+ server.config.authenticationTemplate.map(
67+ (method: McpAuthMethod): AuthMethodSeed => ({
68+ value: editorValueFromMcpAuthMethod(method),
69+ slug: method.slug,
70+ label: methodSeedLabel(method),
71+ }),
72+ ),
73+ [server.config.authenticationTemplate],
9274 );
93− const [apiKey, setApiKey] = useState("");
75+ const list = useAuthMethodList(seeds);
76+
9477 const [saving, setSaving] = useState(false);
95− const [authSaving, setAuthSaving] = useState(false);
9678 const [error, setError] = useState<string | null>(null);
97− const [connected, setConnected] = useState(false);
98− const [oauthModalOpen, setOauthModalOpen] = useState(false);
99−
100− const editedAuth = mcpAuthTemplateFromEditorValue(authValue);
101− const authChanged = !authTemplatesEqual(editedAuth, auth);
10279
103− useEffect(() => {
104− setAuthValue(authTemplateToEditorValue(auth));
105− }, [auth]);
80+ // The edited methods, slugs preserved for seeded rows so existing
81+ // connections (bound by template slug) stay attached. New rows omit the
82+ // slug — the backend assigns kind-based ones.
83+ const editedMethods = useMemo<readonly McpAuthMethodInput[]>(
84+ () =>
85+ list.rows.map((row: AuthMethodRow): McpAuthMethodInput => {
86+ const input = mcpAuthMethodInputFromEditorValue(row.value);
87+ return row.seedSlug !== undefined ? { ...input, slug: row.seedSlug } : input;
88+ }),
89+ [list.rows],
90+ );
10691
107− const handleSaveAuth = useCallback(async () => {
108− setAuthSaving(true);
109− setError(null);
110− const nextConfig: McpRemoteConfig = {
111− ...server.config,
112− auth: mcpAuthTemplateFromEditorValue(authValue),
113− };
114− const exit = await doConfigure({
115− params: { slug: server.slug },
116− payload: { config: nextConfig },
117− reactivityKeys: integrationWriteKeys,
92+ const methodsChanged = useMemo(() => {
93+ const stored = server.config.authenticationTemplate;
94+ if (editedMethods.length !== stored.length) return true;
95+ return editedMethods.some((method: McpAuthMethodInput, index: number) => {
96+ const current = stored[index];
97+ if (!current) return true;
98+ if ((method.slug ?? "") !== current.slug) return true;
99+ if (method.kind !== current.kind) return true;
100+ if (method.kind === "header" && current.kind === "header") {
101+ return (
102+ method.headerName !== current.headerName ||
103+ (method.prefix ?? "") !== (current.prefix ?? "")
104+ );
105+ }
106+ return false;
118107 });
119− if (Exit.isFailure(exit)) {
120− setError("Failed to update authentication method");
121− setAuthSaving(false);
122− return;
123− }
124− setAuthSaving(false);
125− }, [authValue, doConfigure, server.config, server.slug]);
108+ }, [editedMethods, server.config.authenticationTemplate]);
126109
127− const handleSaveKey = useCallback(async () => {
128− if (apiKey.trim() === "") return;
110+ const handleSave = useCallback(async () => {
129111 setSaving(true);
130112 setError(null);
131− const exit = await doCreate({
113+ const exit = await doConfigureAuth({
114+ params: { slug: server.slug },
132115 payload: {
133− owner: connectionOwner,
134− name: connectionIdentifier(`${server.slug} key`),
135− integration: server.slug,
136− template: HEADER_TEMPLATE,
137− identityLabel: server.description || String(server.slug),
138− value: apiKey.trim(),
116+ authenticationTemplate:
117+ editedMethods.length > 0 ? editedMethods : [{ kind: "none" as const }],
118+ mode: "replace",
139119 },
140− reactivityKeys: connectionWriteKeys,
120+ reactivityKeys: integrationWriteKeys,
141121 });
142122 if (Exit.isFailure(exit)) {
143− setError("Failed to save credential");
123+ setError("Failed to update authentication methods");
144124 setSaving(false);
145125 return;
146126 }
147127 setSaving(false);
148− setConnected(true);
149− props.onSave();
150− }, [apiKey, connectionOwner, doCreate, server, props]);
151−
152− const handleOAuth = useCallback(() => {
153− setError(null);
154− setOauthModalOpen(true);
155− }, []);
156−
157− const oauthMethods = useMemo<readonly AuthMethod[]>(
158− () =>
159− auth.kind === "oauth2"
160− ? [
161− {
162− id: "oauth2",
163− label: "OAuth",
164− kind: "oauth",
165− source: "spec",
166− template: OAUTH_TEMPLATE,
167− placements: [],
168− oauth: { discoveryUrl: server.config.endpoint, supportsDynamicRegistration: true },
169− },
170− ]
171− : [],
172− [auth.kind, server.config.endpoint],
173− );
174− const oauthInitialState = useMemo(
175− () =>
176− oauthModalOpen && auth.kind === "oauth2"
177− ? {
178− key: `${String(server.slug)}:${connectionOwner}:oauth`,
179− owner: connectionOwner,
180− template: String(OAUTH_TEMPLATE),
181− label: `${server.description || String(server.slug)} OAuth`,
182− }
183− : null,
184− [auth.kind, connectionOwner, oauthModalOpen, server],
185− );
128+ }, [doConfigureAuth, editedMethods, server.slug]);
186129
187130 return (
188131 <div className="space-y-6">
189132 <div>
190133 <h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1>
191134 <p className="mt-1 text-sm text-muted-foreground">
192− Manage the connection for this MCP server. The endpoint is part of the server's identity —
193− remove and re-add to change it.
135+ Manage how this MCP server authenticates. The endpoint is part of the server's identity —
136+ remove and re-add to change it. Accounts are added from the integration page.
194137 </p>
195138 </div>
196139
@@ -210,110 +153,23 @@ function RemoteEdit(props: { · 13 unmodified lines
210153 </CardStackContent>
211154 </CardStack>
212155
213− <div className="space-y-3 rounded-lg border border-border bg-card p-4">
214− <div>
215− <p className="text-sm font-medium text-card-foreground">Authentication method</p>
216− <p className="mt-0.5 text-xs text-muted-foreground">
217− Choose how a connection credential is sent to this MCP server.
218− </p>
219− </div>
220− <AuthTemplateEditor
221− value={authValue}
222− onChange={setAuthValue}
223− allowedKinds={["none", "apikey", "oauth"]}
224− oauthMetadata="discovered"
225− />
226− {authChanged ? (
227− <div className="flex justify-end">
228− <Button
229− type="button"
230− size="sm"
231− onClick={() => void handleSaveAuth()}
232− disabled={authSaving}
233− >
234− {authSaving ? "Saving…" : "Save authentication method"}
235− </Button>
236− </div>
237− ) : null}
238− </div>
156+ <AuthMethodListEditor
157+ list={list}
158+ title="How does this server authenticate?"
159+ oauthMetadata="discovered"
160+ emptyHint="No methods declared. Add one, or save to mark this server as open (no authentication)."
161+ footerHint="Connections pick one of these methods. Removing a method detaches connections created against it."
162+ />
239163
240− {authChanged ? (
241− <div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
242− Save the authentication method before adding a connection.
164+ {methodsChanged ? (
165+ <div className="flex justify-end">
166+ <Button type="button" size="sm" onClick={() => void handleSave()} disabled={saving}>
167+ {saving ? "Saving…" : "Save authentication methods"}
168+ </Button>
243169 </div>
244170 ) : null}
245171
246− {!authChanged && auth.kind === "header" && (
247− <ConnectionOwnerUsageRow
248− value={connectionOwner}
249− options={connectionOwnerOptions}
250− onChange={setConnectionOwner}
251− label="Connection saved to"
252− help="Choose who can use this credential."
253− >
254− <CredentialControlField label={`${auth.headerName} value`} help="Saved as a connection.">
255− <Input
256− type="password"
257− value={apiKey}
258− onChange={(e) => setApiKey((e.target as HTMLInputElement).value)}
259− placeholder="sk-…"
260− className="font-mono text-sm"
261− autoComplete="new-password"
262− />
263− <div className="mt-2 flex justify-end">
264− <Button
265− type="button"
266− size="sm"
267− onClick={() => void handleSaveKey()}
268− disabled={apiKey.trim() === "" || saving}
269− >
270− {saving ? "Saving…" : connected ? "Saved" : "Save connection"}
271− </Button>
272− </div>
273− </CredentialControlField>
274− </ConnectionOwnerUsageRow>
275− )}
276−
277− {!authChanged && auth.kind === "oauth2" && (
278− <ConnectionOwnerUsageRow
279− value={connectionOwner}
280− options={connectionOwnerOptions}
281− onChange={setConnectionOwner}
282− label="Connection saved to"
283− help="Choose who can use the OAuth connection."
284− >
285− <CredentialControlField label="Connect via OAuth" help="Start the provider OAuth flow.">
286− <OAuthSignInButton
287− busy={false}
288− error={error}
289− isConnected={connected}
290− onSignIn={handleOAuth}
291− signingInLabel="Signing in…"
292− reconnectingLabel="Reconnecting…"
293− />
294− <AddAccountModal
295− integration={server.slug}
296− integrationName={server.description || String(server.slug)}
297− methods={oauthMethods}
298− open={oauthModalOpen}
299− onOpenChange={setOauthModalOpen}
300− initialState={oauthInitialState}
301− />
302− </CredentialControlField>
303− </ConnectionOwnerUsageRow>
304− )}
305−
306− {!authChanged && auth.kind === "none" && (
307− <div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
308− This server does not require a credential.
309− </div>
310− )}
311−
312− {error && (
313− <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
314− <p className="text-sm text-destructive">{error}</p>
315− </div>
316− )}
172+ {error && <FormErrorAlert message={error} />}
317173
318174 <div className="flex items-center justify-end border-t border-border pt-4">
319175 <Button onClick={props.onSave}>Done</Button>
@@ -322,8 +178,6 @@ function RemoteEdit(props: { · 2 unmodified lines
322178 );
323179 }
324180
325−type McpRemoteConfig = Extract<McpIntegrationConfig, { transport: "remote" }>;
326−
327181 // ---------------------------------------------------------------------------
328182 // Stdio read-only view
329183 // ---------------------------------------------------------------------------
packages/plugins/mcp/src/react/McpAccountsPanel.tsx+59−101
@@ -3,72 +3,33 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; · 2 unmodified lines
33 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
44 import * as Exit from "effect/Exit";
55
6−import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared";
6+import { IntegrationSlug } from "@executor-js/sdk/shared";
77 import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";
88 import { AccountsSection } from "@executor-js/react/components/accounts-section";
9−import type { CreateCustomMethod } from "@executor-js/react/components/add-custom-method-modal";
109 import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
10+import {
11+ useCustomMethodActions,
12+ type AuthMethodsCodec,
13+ type ConfigureAuthMethods,
14+} from "@executor-js/react/lib/custom-auth-methods";
1115 import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
1216
13−import { configureMcpServer, mcpServerAtom } from "./atoms";
14−import type { McpIntegrationConfig } from "../sdk/types";
17+import { configureMcpAuth, mcpServerAtom } from "./atoms";
18+import { authMethodsFromConfig, mcpAuthMethodInputsFromPlacements } from "./auth-method-config";
19+import type { McpAuthMethod } from "../sdk/types";
1520
16−const NONE_TEMPLATE = AuthTemplateSlug.make("none");
17−const HEADER_TEMPLATE = AuthTemplateSlug.make("header");
18−const OAUTH_TEMPLATE = AuthTemplateSlug.make("oauth2");
19−
20−const NO_AUTH_METHOD: AuthMethod = {
21− id: "none",
22− label: "No authentication",
23− kind: "none",
24− source: "spec",
25− template: NONE_TEMPLATE,
26− placements: [],
27−};
28−
29−const authMethodFromConfig = (config: McpIntegrationConfig): AuthMethod | null => {
30− if (config.transport === "stdio") return null;
31− const auth = config.auth;
32− if (auth.kind === "none") return NO_AUTH_METHOD;
33− if (auth.kind === "oauth2") {
34− return {
35− id: "oauth2",
36− label: "OAuth2",
37− kind: "oauth",
38− source: "spec",
39− template: OAUTH_TEMPLATE,
40− placements: [],
41− oauth: { discoveryUrl: config.endpoint, supportsDynamicRegistration: true },
42− };
43− }
44− return {
45− id: "header",
46− label: `API key (${auth.headerName})`,
47− kind: "apikey",
48− source: "custom",
49− template: HEADER_TEMPLATE,
50− placements: [
51− {
52− carrier: "header",
53− name: auth.headerName,
54− prefix: auth.prefix ?? "",
55− },
56− ],
57− };
58−};
59−
60−const headerAuthFromPlacements = (
61− placements: readonly Placement[],
62−): { readonly headerName: string; readonly prefix?: string } | null => {
63− const header = placements.find(
64− (placement: Placement) => placement.carrier === "header" && placement.name.trim().length > 0,
65− );
66− if (!header) return null;
67− return {
68− headerName: header.name.trim(),
69− ...(header.prefix ? { prefix: header.prefix } : {}),
70− };
71−};
21+// ---------------------------------------------------------------------------
22+// MCP Accounts hub — fills the generic detail page's `accounts` slot.
23+//
24+// Reads the integration's declared `authenticationTemplate` (via `getServer`),
25+// converts it to generic `AuthMethod[]`, and composes the generic
26+// `AccountsSection` — whose Add-account offers those methods plus a "+ Custom
27+// method" row. The custom-method create/remove is the shared skeleton
28+// (`useCustomMethodActions`) parameterized by the MCP codec and the
29+// merge-append `configureAuth` endpoint, so adding an API key method never
30+// displaces a declared OAuth method. Stdio servers have no remote credential
31+// to configure — no methods, no custom-method affordance.
32+// ---------------------------------------------------------------------------
7233
7334 export default function McpAccountsPanel(props: {
7435 readonly sourceId: string;
@@ -78,61 +39,58 @@ export default function McpAccountsPanel(props: { · 3 unmodified lines
7839 const { sourceId, integrationName, accountHandoff } = props;
7940 const slug = IntegrationSlug.make(sourceId);
8041 const serverResult = useAtomValue(mcpServerAtom(slug));
81− const doConfigure = useAtomSet(configureMcpServer, { mode: "promiseExit" });
42+ const doConfigureAuth = useAtomSet(configureMcpAuth, { mode: "promiseExit" });
8243
8344 const server = AsyncResult.isSuccess(serverResult) ? serverResult.value : null;
8445 const config = server?.config ?? null;
85− const method = config ? authMethodFromConfig(config) : null;
86− const methods = method ? [method] : [];
46+ const remote = config !== null && config.transport === "remote" ? config : null;
8747
88− const createCustomMethod = useCallback<CreateCustomMethod>(
89− async (input: { readonly label: string; readonly placements: readonly Placement[] }) => {
90− if (config === null || config.transport !== "remote") return null;
91− const header = headerAuthFromPlacements(input.placements);
92− if (header === null) return null;
93− const nextConfig: McpIntegrationConfig = {
94− ...config,
95− auth: { kind: "header", ...header },
96− };
97− const exit = await doConfigure({
98− params: { slug },
99− payload: { config: nextConfig },
100− reactivityKeys: integrationWriteKeys,
101− });
102− if (Exit.isFailure(exit)) return null;
103− return authMethodFromConfig(exit.value.config);
104− },
105− [config, doConfigure, slug],
48+ const existingTemplate = useMemo<readonly McpAuthMethod[]>(
49+ () => remote?.authenticationTemplate ?? [],
50+ [remote],
51+ );
52+
53+ const methods = useMemo<readonly AuthMethod[]>(
54+ () => (remote ? authMethodsFromConfig(existingTemplate, remote.endpoint) : []),
55+ [existingTemplate, remote],
10656 );
10757
108− const removeCustomMethod = useCallback(
109− async (methodToRemove: AuthMethod): Promise<boolean> => {
110− if (
111− config === null ||
112− config.transport !== "remote" ||
113− methodToRemove.template !== HEADER_TEMPLATE
114− ) {
115− return false;
116− }
117− const nextConfig: McpIntegrationConfig = {
118− ...config,
119− auth: { kind: "none" },
120− };
121− const exit = await doConfigure({
58+ const configure = useCallback<ConfigureAuthMethods<McpAuthMethod>>(
59+ async (input) => {
60+ const exit = await doConfigureAuth({
12261 params: { slug },
123− payload: { config: nextConfig },
62+ payload: {
63+ authenticationTemplate: input.authenticationTemplate,
64+ ...(input.mode ? { mode: input.mode } : {}),
65+ },
12466 reactivityKeys: integrationWriteKeys,
12567 });
126− return Exit.isSuccess(exit);
68+ return Exit.map(exit, (result) => result.authenticationTemplate);
12769 },
128− [config, doConfigure, slug],
70+ [doConfigureAuth, slug],
12971 );
13072
131− const canConfigureAuth = useMemo(
132− () => config !== null && config.transport === "remote",
133− [config],
73+ const codec = useMemo<AuthMethodsCodec<McpAuthMethod>>(
74+ () => ({
75+ toAuthMethods: (templates: readonly McpAuthMethod[]) =>
76+ authMethodsFromConfig(templates, remote?.endpoint ?? ""),
77+ // MCP custom methods are header credentials; the inputs omit slugs and
78+ // the backend merge backfills `custom_<id>`.
79+ templatesFromPlacements: (placements: readonly Placement[]) =>
80+ mcpAuthMethodInputsFromPlacements(placements) as readonly McpAuthMethod[],
81+ slugOf: (template: McpAuthMethod) => template.slug,
82+ }),
83+ [remote?.endpoint],
13484 );
13585
86+ const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({
87+ existing: existingTemplate,
88+ codec,
89+ configure,
90+ });
91+
92+ const canConfigureAuth = remote !== null;
93+
13694 return (
13795 <div className="mx-auto max-w-3xl space-y-8 px-6 py-8">
13896 <AccountsSection
packages/plugins/mcp/src/react/McpSignInButton.tsx+19−18
@@ -14,19 +14,18 @@ import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in"; · 13 unmodified lines
1414 import type { AuthMethod } from "@executor-js/react/lib/auth-placements";
1515
1616 import { mcpServerAtom } from "./atoms";
17−
18−const OAUTH_TEMPLATE = AuthTemplateSlug.make("oauth2");
17+import type { McpAuthMethod } from "../sdk/types";
1918
2019 // ---------------------------------------------------------------------------
2120 // McpSignInButton — top-bar action on the integration detail page (v2).
2221 //
23−// Reads the integration's auth template; for an `oauth2` server it runs the
24−// OAuth flow to mint a connection. "Connected" is derived from whether ANY
25−// owner already has a connection for this integration (the global owner toggle
26−// is retired, so the check merges both owners). The NEW connection's owner is a
27−// real create-target — chosen EXPLICITLY via the `owner` prop (default Workspace
28−// `org` on an org-scoped host, Local `org` on a non-org host like local),
29−// never read from an ambient owner.
22+// Reads the integration's declared auth methods; when one is `oauth2` it runs
23+// the OAuth flow to mint a connection through that method. "Connected" is
24+// derived from whether ANY owner already has a connection for this integration
25+// (the global owner toggle is retired, so the check merges both owners). The
26+// NEW connection's owner is a real create-target — chosen EXPLICITLY via the
27+// `owner` prop (default Workspace `org` on an org-scoped host, Local `org` on
28+// a non-org host like local), never read from an ambient owner.
3029 // ---------------------------------------------------------------------------
3130
3231 export default function McpSignInButton(props: { sourceId: string; owner?: Owner }) {
@@ -38,7 +37,9 @@ export default function McpSignInButton(props: { sourceId: string; owner?: Owner · 5 unmodified lines
3837
3938 const server = AsyncResult.isSuccess(serverResult) ? serverResult.value : null;
4039 const remote = server !== null && server.config.transport === "remote" ? server.config : null;
41− const isOAuth = remote !== null && remote.auth.kind === "oauth2";
40+ const oauthMethod =
41+ remote?.authenticationTemplate.find((method: McpAuthMethod) => method.kind === "oauth2") ??
42+ null;
4243 const connections: readonly Connection[] = AsyncResult.isSuccess(connectionsResult)
4344 ? connectionsResult.value
4445 : [];
@@ -48,35 +49,35 @@ export default function McpSignInButton(props: { sourceId: string; owner?: Owner · 3 unmodified lines
4849
4950 const methods = useMemo<readonly AuthMethod[]>(
5051 () =>
51− remote === null
52+ remote === null || oauthMethod === null
5253 ? []
5354 : [
5455 {
55− id: "oauth2",
56+ id: oauthMethod.slug,
5657 label: "OAuth",
5758 kind: "oauth",
5859 source: "spec",
59− template: OAUTH_TEMPLATE,
60+ template: AuthTemplateSlug.make(oauthMethod.slug),
6061 placements: [],
6162 oauth: { discoveryUrl: remote.endpoint, supportsDynamicRegistration: true },
6263 },
6364 ],
64− [remote],
65+ [remote, oauthMethod],
6566 );
6667 const initialState = useMemo(
6768 () =>
68− modalOpen && server
69+ modalOpen && server && oauthMethod
6970 ? {
7071 key: `${String(slug)}:${targetOwner}:oauth`,
7172 owner: targetOwner,
72− template: String(OAUTH_TEMPLATE),
73+ template: oauthMethod.slug,
7374 label: `${server.description || String(slug)} OAuth`,
7475 }
7576 : null,
76− [modalOpen, server, slug, targetOwner],
77+ [modalOpen, oauthMethod, server, slug, targetOwner],
7778 );
7879
79− if (!isOAuth) return null;
80+ if (oauthMethod === null) return null;
8081
8182 return (
8283 <>
packages/plugins/mcp/src/react/atoms.ts+2−0
@@ -26,3 +26,5 @@ export const probeMcpEndpoint = McpClient.mutation("mcp", "probeEndpoint"); · 25 unmodified lines
2626 export const addMcpServer = McpClient.mutation("mcp", "addServer");
2727 export const removeMcpServer = McpClient.mutation("mcp", "removeServer");
2828 export const configureMcpServer = McpClient.mutation("mcp", "configureServer");
29+// Merge-append auth methods onto an integration's `authenticationTemplate`.
30+export const configureMcpAuth = McpClient.mutation("mcp", "configureAuth");
packages/plugins/mcp/src/react/auth-method-config.test.ts+83−9
@@ -1,11 +1,16 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
33
4−import { mcpAuthTemplateFromEditorValue } from "./auth-method-config";
4+import {
5+ authMethodsFromConfig,
6+ editorValueFromMcpAuthMethod,
7+ mcpAuthMethodInputFromEditorValue,
8+ mcpAuthMethodInputsFromPlacements,
9+} from "./auth-method-config";
510
6−describe("mcpAuthTemplateFromEditorValue", () => {
11+describe("mcpAuthMethodInputFromEditorValue", () => {
712 it("maps 'none' → { kind: 'none' }", () => {
8− expect(mcpAuthTemplateFromEditorValue({ kind: "none" })).toEqual({ kind: "none" });
13+ expect(mcpAuthMethodInputFromEditorValue({ kind: "none" })).toEqual({ kind: "none" });
914 });
1015
1116 it("maps 'oauth' → { kind: 'oauth2' } (endpoints are resolved at connect time)", () => {
@@ -15,15 +20,15 @@ describe("mcpAuthTemplateFromEditorValue", () => { · 3 unmodified lines
1520 tokenUrl: "https://a.example.com/token",
1621 scopes: ["mcp.read"],
1722 };
18− expect(mcpAuthTemplateFromEditorValue(value)).toEqual({ kind: "oauth2" });
23+ expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ kind: "oauth2" });
1924 });
2025
21− it("maps apiKey → a header template from the first named header placement (with prefix)", () => {
26+ it("maps apiKey → a header method from the first named header placement (with prefix)", () => {
2227 const value: AuthTemplateEditorValue = {
2328 kind: "apikey",
2429 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
2530 };
26− expect(mcpAuthTemplateFromEditorValue(value)).toEqual({
31+ expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({
2732 kind: "header",
2833 headerName: "Authorization",
2934 prefix: "Bearer ",
@@ -35,7 +40,7 @@ describe("mcpAuthTemplateFromEditorValue", () => { · 5 unmodified lines
3540 kind: "apikey",
3641 placements: [{ carrier: "header", name: "X-Token", prefix: "" }],
3742 };
38− expect(mcpAuthTemplateFromEditorValue(value)).toEqual({
43+ expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({
3944 kind: "header",
4045 headerName: "X-Token",
4146 });
@@ -46,7 +51,7 @@ describe("mcpAuthTemplateFromEditorValue", () => { · 4 unmodified lines
4651 kind: "apikey",
4752 placements: [{ carrier: "query", name: "api_key", prefix: "" }],
4853 };
49− expect(mcpAuthTemplateFromEditorValue(value)).toEqual({ kind: "none" });
54+ expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ kind: "none" });
5055 });
5156
5257 it("uses the first NAMED header placement (skips unnamed)", () => {
@@ -57,9 +62,78 @@ describe("mcpAuthTemplateFromEditorValue", () => { · 4 unmodified lines
5762 { carrier: "header", name: "X-Token", prefix: "" },
5863 ],
5964 };
60− expect(mcpAuthTemplateFromEditorValue(value)).toEqual({
65+ expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({
6166 kind: "header",
6267 headerName: "X-Token",
6368 });
6469 });
6570 });
71+
72+describe("editorValueFromMcpAuthMethod", () => {
73+ it("round-trips a header method into an apikey editor value", () => {
74+ expect(
75+ editorValueFromMcpAuthMethod({
76+ slug: "header",
77+ kind: "header",
78+ headerName: "X-Api-Key",
79+ prefix: "Bearer ",
80+ }),
81+ ).toEqual({
82+ kind: "apikey",
83+ placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }],
84+ });
85+ });
86+
87+ it("maps oauth2 to an oauth editor value with no endpoints (discovered at connect)", () => {
88+ expect(editorValueFromMcpAuthMethod({ slug: "oauth2", kind: "oauth2" })).toEqual({
89+ kind: "oauth",
90+ authorizationUrl: "",
91+ tokenUrl: "",
92+ scopes: [],
93+ });
94+ });
95+});
96+
97+describe("authMethodsFromConfig", () => {
98+ it("projects every declared method and marks custom_ slugs as custom", () => {
99+ const methods = authMethodsFromConfig(
100+ [
101+ { slug: "oauth2", kind: "oauth2" },
102+ { slug: "custom_abc123", kind: "header", headerName: "X-Api-Key" },
103+ { slug: "none", kind: "none" },
104+ ],
105+ "https://mcp.example.com/mcp",
106+ );
107+
108+ expect(
109+ methods.map((method) => ({
110+ id: method.id,
111+ kind: method.kind,
112+ source: method.source,
113+ template: String(method.template),
114+ })),
115+ ).toEqual([
116+ { id: "oauth2", kind: "oauth", source: "spec", template: "oauth2" },
117+ { id: "custom_abc123", kind: "apikey", source: "custom", template: "custom_abc123" },
118+ { id: "none", kind: "none", source: "spec", template: "none" },
119+ ]);
120+ expect(methods[0]?.oauth?.discoveryUrl).toBe("https://mcp.example.com/mcp");
121+ });
122+});
123+
124+describe("mcpAuthMethodInputsFromPlacements", () => {
125+ it("builds one header method from the first named header placement", () => {
126+ expect(
127+ mcpAuthMethodInputsFromPlacements([
128+ { carrier: "query", name: "api_key", prefix: "" },
129+ { carrier: "header", name: "X-Token", prefix: "Bearer " },
130+ ]),
131+ ).toEqual([{ kind: "header", headerName: "X-Token", prefix: "Bearer " }]);
132+ });
133+
134+ it("is empty when no usable header placement exists", () => {
135+ expect(
136+ mcpAuthMethodInputsFromPlacements([{ carrier: "query", name: "api_key", prefix: "" }]),
137+ ).toEqual([]);
138+ });
139+});
packages/plugins/mcp/src/react/auth-method-config.ts+93−12
@@ -1,23 +1,27 @@
11 // ---------------------------------------------------------------------------
22 // MCP ↔ generic auth-template converter.
33 //
4−// The shared add-time `AuthTemplateEditor` speaks the plugin-agnostic
5−// `AuthTemplateEditorValue`. MCP's stored auth is a single `McpAuthTemplate`
6−// (`none` / `header` / `oauth2`) — one method, not an array. This converts the
7−// editor value to that shape and lives with the MCP plugin because it touches
8−// the transport-specific `McpAuthTemplate` type.
4+// The shared add-time `AuthTemplateEditor` / `AuthMethodListEditor` speak the
5+// plugin-agnostic `AuthTemplateEditorValue`; the hub speaks `AuthMethod`. MCP
6+// stores a slugged `authenticationTemplate` array (`none` / `header` /
7+// `oauth2`, one method per entry — a header method carries a single header,
8+// not an array). These converters live with the MCP plugin because they touch
9+// the transport-specific `McpAuthMethod` types.
910 // ---------------------------------------------------------------------------
1011
12+import { AuthTemplateSlug } from "@executor-js/sdk/shared";
1113 import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
14+import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
1215
13−import type { McpAuthTemplate } from "../sdk/types";
16+import type { McpAuthMethod, McpAuthMethodInput } from "../sdk/types";
1417
15−/** Convert a generic editor value into MCP's single `McpAuthTemplate`. An
16− * apiKey method maps to a `header` template using its FIRST header placement
17− * (MCP carries a single header, not an array); a header placement's prefix is
18− * preserved. OAuth maps to `oauth2`; `none` to `none`. An apiKey value with no
19− * usable header placement falls back to `none`. */
20−export function mcpAuthTemplateFromEditorValue(value: AuthTemplateEditorValue): McpAuthTemplate {
18+/** Convert a generic editor value into one MCP auth-method input (no slug —
19+ * the backend assigns kind-based slugs). An apiKey method maps to a `header`
20+ * method using its FIRST named header placement, preserving the prefix; an
21+ * apiKey value with no usable header placement falls back to `none`. */
22+export function mcpAuthMethodInputFromEditorValue(
23+ value: AuthTemplateEditorValue,
24+): McpAuthMethodInput {
2125 if (value.kind === "oauth") return { kind: "oauth2" };
2226 if (value.kind === "apikey") {
2327 const header = value.placements.find(
@@ -32,3 +36,80 @@ export function mcpAuthTemplateFromEditorValue(value: AuthTemplateEditorValue): · 8 unmodified lines
3236 }
3337 return { kind: "none" };
3438 }
39+
40+/** Convert one stored MCP method into the generic editor value. */
41+export function editorValueFromMcpAuthMethod(method: McpAuthMethod): AuthTemplateEditorValue {
42+ if (method.kind === "oauth2") {
43+ return { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] };
44+ }
45+ if (method.kind === "header") {
46+ return {
47+ kind: "apikey",
48+ placements: [{ carrier: "header", name: method.headerName, prefix: method.prefix ?? "" }],
49+ };
50+ }
51+ return { kind: "none" };
52+}
53+
54+/** Project the stored methods into the generic `AuthMethod[]` the hub renders.
55+ * Mirrors the server's `describeMcpAuthMethods`; `custom_` slugs mark
56+ * user-created methods (removable from the hub). `endpoint` feeds the oauth
57+ * method's probe-at-connect `discoveryUrl`. */
58+export function authMethodsFromConfig(
59+ methods: readonly McpAuthMethod[],
60+ endpoint: string,
61+): AuthMethod[] {
62+ return methods.map((method: McpAuthMethod): AuthMethod => {
63+ const source: "spec" | "custom" = method.slug.startsWith("custom_") ? "custom" : "spec";
64+ const template = AuthTemplateSlug.make(method.slug);
65+ if (method.kind === "oauth2") {
66+ return {
67+ id: method.slug,
68+ label: "OAuth",
69+ kind: "oauth",
70+ source,
71+ template,
72+ placements: [],
73+ oauth: { discoveryUrl: endpoint, supportsDynamicRegistration: true },
74+ };
75+ }
76+ if (method.kind === "header") {
77+ return {
78+ id: method.slug,
79+ label: `API key (${method.headerName})`,
80+ kind: "apikey",
81+ source,
82+ template,
83+ placements: [{ carrier: "header", name: method.headerName, prefix: method.prefix ?? "" }],
84+ };
85+ }
86+ return {
87+ id: method.slug,
88+ label: "No authentication",
89+ kind: "none",
90+ source,
91+ template,
92+ placements: [],
93+ };
94+ });
95+}
96+
97+/** Build MCP header-method inputs for a custom method from generic placements.
98+ * MCP carries one header per method, so only the FIRST named header placement
99+ * is used (query placements have no MCP credential carrier). Empty when no
100+ * usable header placement exists. */
101+export function mcpAuthMethodInputsFromPlacements(
102+ placements: readonly Placement[],
103+): McpAuthMethodInput[] {
104+ const header = placements.find(
105+ (placement: Placement) => placement.carrier === "header" && placement.name.trim().length > 0,
106+ );
107+ if (!header) return [];
108+ return [
109+ {
110+ kind: "header",
111+ headerName: header.name.trim(),
112+ ...(header.prefix ? { prefix: header.prefix } : {}),
113+ },
114+ ];
115+}
packages/plugins/mcp/src/sdk/connection.ts+1−1
@@ -33,7 +33,7 @@ export type McpConnector = Effect.Effect<McpConnection, McpConnectionError>; · 32 unmodified lines
3333
3434 export type RemoteConnectorInput = Omit<
3535 McpRemoteIntegrationConfig,
36− "auth" | "remoteTransport" | "headers" | "queryParams"
36+ "authenticationTemplate" | "remoteTransport" | "headers" | "queryParams"
3737 > & {
3838 readonly remoteTransport?: McpRemoteIntegrationConfig["remoteTransport"];
3939 readonly headers?: Record<string, string>;
packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts+55−11
@@ -5,8 +5,10 @@ import { describeMcpAuthMethods, describeMcpIntegrationDisplay } from "./plugin" · 4 unmodified lines
55
66 // ---------------------------------------------------------------------------
77 // `describeMcpAuthMethods` projects the stored MCP config into the catalog's
8−// plugin-agnostic `AuthMethodDescriptor[]`. It is pure/sync and must tolerate a
9−// malformed or foreign config blob by returning `[]`.
8+// plugin-agnostic `AuthMethodDescriptor[]`, one per declared method. It is
9+// pure/sync and must tolerate a malformed or foreign config blob by returning
10+// `[]`. Legacy configs (singular `auth`, or no auth field at all) are lifted
11+// into a one-element `authenticationTemplate` whose slug is the method kind.
1012 // ---------------------------------------------------------------------------
1113
1214 const recordWith = (config: IntegrationConfig): IntegrationRecord => ({
@@ -20,12 +22,12 @@ const recordWith = (config: IntegrationConfig): IntegrationRecord => ({ · 7 unmodified lines
2022 });
2123
2224 describe("describeMcpAuthMethods", () => {
23− it("projects a remote oauth2 config to one oauth method carrying the discovery URL", () => {
25+ it("projects an oauth2 method carrying the discovery URL", () => {
2426 const methods = describeMcpAuthMethods(
2527 recordWith({
2628 transport: "remote",
2729 endpoint: "https://x.example/oauth/mcp",
28− auth: { kind: "oauth2" },
30+ authenticationTemplate: [{ slug: "oauth2", kind: "oauth2" }],
2931 }),
3032 );
3133
@@ -43,19 +45,21 @@ describe("describeMcpAuthMethods", () => { · 11 unmodified lines
4345 ]);
4446 });
4547
46− it("projects a remote header config to one apikey method carrying the header placement", () => {
48+ it("projects a header method to an apikey method carrying the header placement", () => {
4749 const methods = describeMcpAuthMethods(
4850 recordWith({
4951 transport: "remote",
5052 endpoint: "https://x.example/mcp",
51− auth: { kind: "header", headerName: "X-Api-Key", prefix: "Bearer " },
53+ authenticationTemplate: [
54+ { slug: "header", kind: "header", headerName: "X-Api-Key", prefix: "Bearer " },
55+ ],
5256 }),
5357 );
5458
5559 expect(methods).toEqual([
5660 {
5761 id: "header",
58− label: "API key (header)",
62+ label: "API key (X-Api-Key)",
5963 kind: "apikey",
6064 template: "header",
6165 placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }],
@@ -63,12 +67,30 @@ describe("describeMcpAuthMethods", () => { · 1 unmodified line
6367 ]);
6468 });
6569
70+ it("projects every declared method (multi-method configs)", () => {
71+ const methods = describeMcpAuthMethods(
72+ recordWith({
73+ transport: "remote",
74+ endpoint: "https://x.example/mcp",
75+ authenticationTemplate: [
76+ { slug: "oauth2", kind: "oauth2" },
77+ { slug: "custom_abc123", kind: "header", headerName: "X-Api-Key" },
78+ ],
79+ }),
80+ );
81+
82+ expect(methods.map((m) => ({ id: m.id, kind: m.kind, template: m.template }))).toEqual([
83+ { id: "oauth2", kind: "oauth", template: "oauth2" },
84+ { id: "custom_abc123", kind: "apikey", template: "custom_abc123" },
85+ ]);
86+ });
87+
6688 it("defaults the header prefix to an empty string when unset", () => {
6789 const methods = describeMcpAuthMethods(
6890 recordWith({
6991 transport: "remote",
7092 endpoint: "https://x.example/mcp",
71− auth: { kind: "header", headerName: "Authorization" },
93+ authenticationTemplate: [{ slug: "header", kind: "header", headerName: "Authorization" }],
7294 }),
7395 );
7496
@@ -77,12 +99,12 @@ describe("describeMcpAuthMethods", () => { · 2 unmodified lines
7799 ]);
78100 });
79101
80− it("projects an open (none) remote server to a no-auth method", () => {
102+ it("projects an open (none) method to a no-auth method", () => {
81103 const methods = describeMcpAuthMethods(
82104 recordWith({
83105 transport: "remote",
84106 endpoint: "https://x.example/mcp",
85− auth: { kind: "none" },
107+ authenticationTemplate: [{ slug: "none", kind: "none" }],
86108 }),
87109 );
88110 expect(methods).toEqual([
@@ -95,6 +117,28 @@ describe("describeMcpAuthMethods", () => { · 6 unmodified lines
95117 ]);
96118 });
97119
120+ it("lifts a legacy singular `auth` config into one method slugged by kind", () => {
121+ const methods = describeMcpAuthMethods(
122+ recordWith({
123+ transport: "remote",
124+ endpoint: "https://x.example/oauth/mcp",
125+ auth: { kind: "oauth2" },
126+ }),
127+ );
128+ expect(methods).toEqual([
129+ {
130+ id: "oauth2",
131+ label: "OAuth",
132+ kind: "oauth",
133+ template: "oauth2",
134+ oauth: {
135+ discoveryUrl: "https://x.example/oauth/mcp",
136+ supportsDynamicRegistration: true,
137+ },
138+ },
139+ ]);
140+ });
141+
98142 it("treats legacy remote configs with no auth field as open/no-auth", () => {
99143 const methods = describeMcpAuthMethods(
100144 recordWith({
@@ -131,7 +175,7 @@ describe("describeMcpAuthMethods", () => { · 30 unmodified lines
131175 recordWith({
132176 transport: "remote",
133177 endpoint: "https://mcp.posthog.com/mcp",
134− auth: { kind: "none" },
178+ authenticationTemplate: [{ slug: "none", kind: "none" }],
135179 }),
136180 ),
137181 ).toEqual({ url: "https://mcp.posthog.com/mcp" });
packages/plugins/mcp/src/sdk/plugin.test.ts+49−0
@@ -301,6 +301,55 @@ describe("mcpPlugin", () => { · 300 unmodified lines
301301 ),
302302 );
303303
304+ // Custom-method create (configureAuth) merge-appends onto the declared set —
305+ // adding an API key to an OAuth server must NOT displace the OAuth method.
306+ it.effect("configureAuth merge-appends a custom method without clobbering oauth", () =>
307+ Effect.gen(function* () {
308+ const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
309+
310+ yield* executor.mcp.addServer({
311+ name: "OAuth MCP",
312+ endpoint: "https://mcp.example.com/mcp",
313+ slug: "oauth_mcp",
314+ auth: { kind: "oauth2" },
315+ });
316+
317+ const merged = yield* executor.mcp.configureAuth("oauth_mcp", {
318+ authenticationTemplate: [{ kind: "header", headerName: "X-Api-Key" }],
319+ });
320+
321+ expect(merged.map((method) => method.kind)).toEqual(["oauth2", "header"]);
322+ expect(merged[0]?.slug).toBe("oauth2");
323+ expect(merged[1]?.slug).toMatch(/^custom_/);
324+
325+ // The catalog projects both methods.
326+ const integration = yield* executor.integrations.get(IntegrationSlug.make("oauth_mcp"));
327+ expect(integration?.authMethods.map((method) => method.kind)).toEqual(["oauth", "apikey"]);
328+ }),
329+ );
330+
331+ it.effect("configureAuth replace mode swaps the declared set with kind-based slugs", () =>
332+ Effect.gen(function* () {
333+ const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
334+
335+ yield* executor.mcp.addServer({
336+ name: "Open MCP",
337+ endpoint: "https://mcp.example.com/mcp",
338+ slug: "open_mcp",
339+ });
340+
341+ const merged = yield* executor.mcp.configureAuth("open_mcp", {
342+ authenticationTemplate: [
343+ { kind: "oauth2" },
344+ { kind: "header", headerName: "Authorization", prefix: "Bearer " },
345+ ],
346+ mode: "replace",
347+ });
348+
349+ expect(merged.map((method) => method.slug)).toEqual(["oauth2", "header"]);
350+ }),
351+ );
352+
304353 // When discovery fails (auth, network, etc.) the connection still lands with
305354 // an empty tool set so the user can retry via `connections.refresh` once they
306355 // fix the underlying problem.
packages/plugins/mcp/src/sdk/plugin.ts+129−44
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
1010 definePlugin,
1111 IntegrationAlreadyExistsError,
1212 IntegrationSlug,
13+ mergeAuthTemplates,
1314 OAuthClientSlug,
1415 tool,
1516 ToolResult,
@@ -35,11 +36,16 @@ import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest"; · 19 unmodified lines
3536 import { mcpPresets } from "./presets";
3637 import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape";
3738 import {
39+ McpAuthMethodInput,
3840 McpAuthTemplate,
3941 McpRemoteTransport,
42+ type McpAuthMethod,
4043 type McpToolAnnotations,
44+ mcpAuthMethodFromLegacy,
45+ normalizeMcpAuthMethods,
4146 parseMcpIntegrationConfig,
4247 type McpIntegrationConfig as McpIntegrationConfigType,
48+ type McpRemoteIntegrationConfig,
4349 type McpStdioIntegrationConfig,
4450 } from "./types";
4551
@@ -75,7 +81,13 @@ const legacyMcpClientMatches = ( · 29 unmodified lines
7581 config: McpIntegrationConfigType | null,
7682 ): boolean => {
7783 if (!candidates.has(String(client.slug))) return false;
78− if (!config || config.transport !== "remote" || config.auth.kind !== "oauth2") return false;
84+ if (
85+ !config ||
86+ config.transport !== "remote" ||
87+ !config.authenticationTemplate.some((method: McpAuthMethod) => method.kind === "oauth2")
88+ ) {
89+ return false;
90+ }
7991 return client.grant === "authorization_code" && (client.resource ?? null) === config.endpoint;
8092 };
8193
@@ -129,7 +141,10 @@ const McpRemoteServerInputSchema = Schema.Struct({ · 47 unmodified lines
129141 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
130142 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
131143 slug: Schema.optional(Schema.String),
132− /** How a connection's value is applied to requests. Defaults to none. */
144+ /** Declared auth methods a connection can be applied through. */
145+ authenticationTemplate: Schema.optional(Schema.Array(McpAuthMethodInput)),
146+ /** Single-method shorthand (legacy callers). Ignored when
147+ * `authenticationTemplate` is present. Defaults to none. */
133148 auth: Schema.optional(McpAuthTemplate),
134149 });
135150
@@ -152,6 +167,15 @@ const McpAddServerOutputSchema = Schema.Struct({ · 16 unmodified lines
152167 slug: Schema.String,
153168 });
154169
170+/** Input for the custom-method-create flow. `merge` (default) appends onto the
171+ * integration's existing `authenticationTemplate`; `replace` swaps the whole
172+ * declared set. Mirrors the OpenAPI/GraphQL `configureAuth` inputs. */
173+export const McpConfigureAuthInputSchema = Schema.Struct({
174+ authenticationTemplate: Schema.Array(McpAuthMethodInput),
175+ mode: Schema.optional(Schema.Literals(["merge", "replace"])),
176+});
177+export type McpConfigureAuthInput = typeof McpConfigureAuthInputSchema.Type;
178+
155179 const McpProbeEndpointInputSchema = Schema.Struct({
156180 endpoint: Schema.String,
157181 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
@@ -240,7 +264,9 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 82 unmodified lines
240264 remoteTransport: input.remoteTransport ?? "auto",
241265 queryParams: input.queryParams,
242266 headers: input.headers,
243− auth: input.auth ?? { kind: "none" },
267+ authenticationTemplate: input.authenticationTemplate
268+ ? normalizeMcpAuthMethods(input.authenticationTemplate)
269+ : [mcpAuthMethodFromLegacy(input.auth ?? { kind: "none" })],
244270 };
245271 };
246272
@@ -380,12 +406,31 @@ const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({ · 133 unmodified lines
380406
381407 // ---------------------------------------------------------------------------
382408 // Connector input — render the integration config + the connection's resolved
383−// value through the auth template into a live `ConnectorInput`.
409+// value through the auth method the connection references (by template slug)
410+// into a live `ConnectorInput`.
384411 // ---------------------------------------------------------------------------
385412
413+/** The auth method a connection binds: its `template` slug when it matches a
414+ * declared method. Otherwise fall back to the sole declared method — single-
415+ * method integrations historically accepted any template slug (rendering was
416+ * config-driven), so existing connections keep working. Ambiguity across
417+ * several methods renders no auth rather than guessing. */
418+const selectAuthMethod = (
419+ config: McpRemoteIntegrationConfig,
420+ templateSlug: string | null,
421+): McpAuthMethod | undefined => {
422+ const methods = config.authenticationTemplate;
423+ if (templateSlug !== null) {
424+ const match = methods.find((method: McpAuthMethod) => method.slug === templateSlug);
425+ if (match) return match;
426+ }
427+ return methods.length === 1 ? methods[0] : undefined;
428+};
429+
386430 const buildConnectorInput = (
387431 config: McpIntegrationConfigType,
388432 value: string | null,
433+ templateSlug: string | null,
389434 allowStdio: boolean,
390435 ): Effect.Effect<ConnectorInput, McpConnectionError> => {
391436 if (config.transport === "stdio") {
@@ -410,10 +455,10 @@ const buildConnectorInput = ( · 18 unmodified lines
410455 const headers: Record<string, string> = { ...(config.headers ?? {}) };
411456 let authProvider: OAuthClientProvider | undefined;
412457
413− const auth = config.auth;
414− if (auth.kind === "header" && value !== null) {
458+ const auth = selectAuthMethod(config, templateSlug);
459+ if (auth?.kind === "header" && value !== null) {
415460 headers[auth.headerName] = auth.prefix ? `${auth.prefix}${value}` : value;
416− } else if (auth.kind === "oauth2" && value !== null) {
461+ } else if (auth?.kind === "oauth2" && value !== null) {
417462 authProvider = makeOAuthProvider(value);
418463 }
419464
@@ -432,13 +477,14 @@ const buildConnectorInput = ( · 12 unmodified lines
432477
433478 // ---------------------------------------------------------------------------
434479 // Declared auth methods — project the stored MCP config into the catalog's
435−// plugin-agnostic `AuthMethodDescriptor[]`. Pure and tolerant of a malformed or
436−// foreign config blob (returns `[]`). Exported for tests.
480+// plugin-agnostic `AuthMethodDescriptor[]`, one per declared method. Pure and
481+// tolerant of a malformed or foreign config blob (returns `[]`). Exported for
482+// tests.
437483 //
438−// open (`none`) → one none method carrying no credential inputs
484+// none → a no-auth method carrying no credential inputs
439485 // stdio → [] (no remote connection to configure)
440−// header → one apikey method carrying the header placement
441−// oauth2 → one oauth method carrying the MCP endpoint to probe
486+// header → an apikey method carrying the header placement
487+// oauth2 → an oauth method carrying the MCP endpoint to probe
442488 // (`discoveryUrl`); endpoints are discovered live at
443489 // connect time, so they are NOT pre-resolved here. We
444490 // mark `supportsDynamicRegistration: true` because MCP
@@ -452,40 +498,32 @@ export const describeMcpAuthMethods = ( · 7 unmodified lines
452498 const config = parseMcpIntegrationConfig(record.config);
453499 if (!config || config.transport === "stdio") return [];
454500
455− const auth = config.auth;
456− if (auth.kind === "none") {
457− return [
458− {
459− id: "none",
460− label: "No authentication",
461− kind: "none",
462− template: "none",
463− },
464− ];
465− }
466− if (auth.kind === "header") {
467− return [
468− {
469− id: "header",
470− label: "API key (header)",
501+ return config.authenticationTemplate.map((method: McpAuthMethod): AuthMethodDescriptor => {
502+ if (method.kind === "header") {
503+ return {
504+ id: method.slug,
505+ label: `API key (${method.headerName})`,
471506 kind: "apikey",
472− template: "header",
473− placements: [{ carrier: "header", name: auth.headerName, prefix: auth.prefix ?? "" }],
474− },
475− ];
476− }
477− if (auth.kind === "oauth2") {
478− return [
479− {
480− id: "oauth2",
507+ template: method.slug,
508+ placements: [{ carrier: "header", name: method.headerName, prefix: method.prefix ?? "" }],
509+ };
510+ }
511+ if (method.kind === "oauth2") {
512+ return {
513+ id: method.slug,
481514 label: "OAuth",
482515 kind: "oauth",
483− template: "oauth2",
516+ template: method.slug,
484517 oauth: { discoveryUrl: config.endpoint, supportsDynamicRegistration: true },
485− },
486− ];
487− }
488− return [];
518+ };
519+ }
520+ return {
521+ id: method.slug,
522+ label: "No authentication",
523+ kind: "none",
524+ template: method.slug,
525+ };
526+ });
489527 };
490528
491529 export const describeMcpIntegrationDisplay = (
@@ -777,12 +815,49 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 285 unmodified lines
777815 }),
778816 );
779817
818+ /** Merge-append auth methods onto the integration's existing
819+ * `authenticationTemplate` (custom-method-create flow), mirroring the
820+ * OpenAPI/GraphQL `configureAuth`. Returns the merged array. A no-op
821+ * (returns `[]`) for an unknown slug, a stdio server, or an
822+ * undecodable config. */
823+ const configureAuth = (slug: string, input: McpConfigureAuthInput) =>
824+ Effect.gen(function* () {
825+ const record = yield* ctx.core.integrations.get(slugFrom(slug));
826+ const current = record ? parseMcpIntegrationConfig(record.config) : null;
827+ if (!current || current.transport === "stdio") {
828+ return [] as readonly McpAuthMethod[];
829+ }
830+
831+ // Replace mode declares the full set — backfill kind-based slugs.
832+ // Merge mode appends: `mergeAuthTemplates` replaces on slug match and
833+ // assigns fresh `custom_<id>` slugs to slug-less entries, so a custom
834+ // method never silently displaces a declared one.
835+ const merged =
836+ input.mode === "replace"
837+ ? normalizeMcpAuthMethods(input.authenticationTemplate)
838+ : mergeAuthTemplates(
839+ current.authenticationTemplate,
840+ input.authenticationTemplate as readonly McpAuthMethod[],
841+ );
842+
843+ yield* ctx.core.integrations.update(slugFrom(slug), {
844+ config: { ...current, authenticationTemplate: merged },
845+ });
846+
847+ return merged;
848+ }).pipe(
849+ Effect.withSpan("mcp.plugin.configure_auth", {
850+ attributes: { "mcp.integration.slug": slug },
851+ }),
852+ );
853+
780854 return {
781855 probeEndpoint,
782856 addServer,
783857 removeServer,
784858 getServer,
785859 configureServer,
860+ configureAuth,
786861 };
787862 },
788863
@@ -794,14 +869,19 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 5 unmodified lines
794869 // Discovery failures (auth not ready, server down) yield an empty tool set
795870 // rather than failing — the connection still lands and can be refreshed.
796871 // -----------------------------------------------------------------------
797− resolveTools: ({ config, connection, getValue }) =>
872+ resolveTools: ({ config, connection, template, getValue }) =>
798873 Effect.gen(function* () {
799874 const parsed = parseMcpIntegrationConfig(config);
800875 if (!parsed) return { tools: [] as readonly ToolDef[] };
801876
802877 const value = yield* getValue().pipe(Effect.orElseSucceed(() => null));
803878
804− const built = yield* buildConnectorInput(parsed, value, allowStdio).pipe(
879+ const built = yield* buildConnectorInput(
880+ parsed,
881+ value,
882+ template === null ? null : String(template),
883+ allowStdio,
884+ ).pipe(
805885 Effect.map((ci) => createMcpConnector(ci)),
806886 Effect.result,
807887 );
@@ -848,6 +928,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 40 unmodified lines
848928 const connector: McpConnector = yield* buildConnectorInput(
849929 parsed,
850930 credential.value,
931+ String(credential.template),
851932 allowStdio,
852933 ).pipe(Effect.map((ci) => createMcpConnector(ci)));
853934
@@ -1078,4 +1159,8 @@ export interface McpPluginExtension { · 224 unmodified lines
10781159 slug: string,
10791160 config: McpIntegrationConfigType,
10801161 ) => Effect.Effect<void, McpExtensionFailure>;
1162+ readonly configureAuth: (
1163+ slug: string,
1164+ input: McpConfigureAuthInput,
1165+ ) => Effect.Effect<readonly McpAuthMethod[], McpExtensionFailure>;
10811166 }
packages/plugins/mcp/src/sdk/types.ts+75−6
@@ -25,7 +25,10 @@ export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", · 24 unmodified lines
2525 export type McpTransport = typeof McpTransport.Type;
2626
2727 // ---------------------------------------------------------------------------
28−// Auth template — how a connection's resolved value is applied to the request.
28+// Auth methods — the ways a connection's resolved value can be applied to the
29+// request. An integration declares zero or more methods, each with a stable
30+// `slug` a connection binds against (`connection.template`), mirroring the
31+// OpenAPI/GraphQL `authenticationTemplate` arrays.
2932 //
3033 // none — no credential (open server)
3134 // header — render the value into a request header (e.g. `Authorization:
@@ -34,6 +37,22 @@ export type McpTransport = typeof McpTransport.Type; · 2 unmodified lines
3437 // via the MCP SDK's OAuthClientProvider
3538 // ---------------------------------------------------------------------------
3639
40+export const McpAuthMethod = Schema.Union([
41+ Schema.Struct({ slug: Schema.String, kind: Schema.Literal("none") }),
42+ Schema.Struct({
43+ slug: Schema.String,
44+ kind: Schema.Literal("header"),
45+ headerName: Schema.String,
46+ prefix: Schema.optional(Schema.String),
47+ }),
48+ Schema.Struct({ slug: Schema.String, kind: Schema.Literal("oauth2") }),
49+]);
50+export type McpAuthMethod = typeof McpAuthMethod.Type;
51+
52+/** Legacy single-method template (pre-array configs and the `auth` input
53+ * shorthand on `addServer`): the method without a slug. Lifted into a
54+ * one-element `authenticationTemplate` whose slug is the method kind, which
55+ * matches the template slugs existing connections were created against. */
3756 export const McpAuthTemplate = Schema.Union([
3857 Schema.Struct({ kind: Schema.Literal("none") }),
3958 Schema.Struct({
@@ -45,6 +64,40 @@ export const McpAuthTemplate = Schema.Union([ · 5 unmodified lines
4564 ]);
4665 export type McpAuthTemplate = typeof McpAuthTemplate.Type;
4766
67+export const mcpAuthMethodFromLegacy = (auth: McpAuthTemplate): McpAuthMethod => ({
68+ ...auth,
69+ slug: auth.kind,
70+});
71+
72+/** Input variant of `McpAuthMethod` — callers (UI, agents) may omit the slug;
73+ * `normalizeMcpAuthMethods` backfills it from the kind. */
74+export const McpAuthMethodInput = Schema.Union([
75+ Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("none") }),
76+ Schema.Struct({
77+ slug: Schema.optional(Schema.String),
78+ kind: Schema.Literal("header"),
79+ headerName: Schema.String,
80+ prefix: Schema.optional(Schema.String),
81+ }),
82+ Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("oauth2") }),
83+]);
84+export type McpAuthMethodInput = typeof McpAuthMethodInput.Type;
85+
86+/** Assign each method a stable slug: a caller-provided one wins, otherwise the
87+ * kind (`oauth2`, `header`, `none`), suffixed `_2`, `_3`, … on collision. */
88+export const normalizeMcpAuthMethods = (
89+ methods: readonly McpAuthMethodInput[],
90+): readonly McpAuthMethod[] => {
91+ const taken = new Set<string>();
92+ return methods.map((method: McpAuthMethodInput): McpAuthMethod => {
93+ const requested = method.slug?.trim() || method.kind;
94+ let slug = requested;
95+ for (let n = 2; taken.has(slug); n += 1) slug = `${requested}_${n}`;
96+ taken.add(slug);
97+ return { ...method, slug };
98+ });
99+};
100+
48101 // ---------------------------------------------------------------------------
49102 // Integration config — the opaque blob stored on the integration row. A
50103 // discriminated union on transport.
@@ -65,8 +118,9 @@ export const McpRemoteIntegrationConfig = Schema.Struct({ · 14 unmodified lines
65118 queryParams: Schema.optional(StringMap),
66119 /** Static headers sent on every request (non-credential) */
67120 headers: Schema.optional(StringMap),
68− /** Auth template — how the connection's value is rendered onto requests */
69− auth: McpAuthTemplate,
121+ /** Declared auth methods — how a connection's value is rendered onto
122+ * requests. A connection's `template` picks one by slug. */
123+ authenticationTemplate: Schema.Array(McpAuthMethod),
70124 });
71125 export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type;
72126
@@ -90,18 +144,33 @@ export const McpIntegrationConfig = Schema.Union([ · 17 unmodified lines
90144 export type McpIntegrationConfig = typeof McpIntegrationConfig.Type;
91145
92146 const decodeIntegrationConfig = Schema.decodeUnknownOption(McpIntegrationConfig);
147+const decodeLegacyAuth = Schema.decodeUnknownOption(McpAuthTemplate);
93148
94149 /** Parse an opaque integration `config` blob into a typed MCP config, or null
95− * if it isn't this plugin's shape. */
150+ * if it isn't this plugin's shape. Stored legacy shapes are lifted in place:
151+ * a singular `auth` becomes a one-element `authenticationTemplate` (slug =
152+ * kind, matching the template slugs existing connections carry), and a remote
153+ * config with neither field is an open server (`[{ slug: "none", … }]`). */
96154 export const parseMcpIntegrationConfig = (config: unknown): McpIntegrationConfig | null => {
97155 if (
98156 typeof config === "object" &&
99157 config !== null &&
100158 "transport" in config &&
101159 config.transport === "remote" &&
102− !("auth" in config)
160+ !("authenticationTemplate" in config)
103161 ) {
104− return Option.getOrNull(decodeIntegrationConfig({ ...config, auth: { kind: "none" } }));
162+ const legacyAuth =
163+ "auth" in config
164+ ? Option.getOrNull(decodeLegacyAuth(config.auth))
165+ : { kind: "none" as const };
166+ if (legacyAuth === null) return null;
167+ const { auth: _auth, ...rest } = config as { auth?: unknown };
168+ return Option.getOrNull(
169+ decodeIntegrationConfig({
170+ ...rest,
171+ authenticationTemplate: [mcpAuthMethodFromLegacy(legacyAuth)],
172+ }),
173+ );
105174 }
106175 return Option.getOrNull(decodeIntegrationConfig(config));
107176 };
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+42−167
@@ -1,19 +1,14 @@
11 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2−import { useAtomSet, useAtomValue } from "@effect/atom-react";
3−import { Link } from "@tanstack/react-router";
2+import { useAtomSet } from "@effect/atom-react";
43 import * as Effect from "effect/Effect";
54 import * as Exit from "effect/Exit";
65 import * as Option from "effect/Option";
7−import * as Predicate from "effect/Predicate";
8−import * as Schema from "effect/Schema";
9−import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
106
117 import {
128 AuthTemplateSlug,
139 IntegrationSlug,
1410 type OAuthAuthentication,
1511 } from "@executor-js/sdk/shared";
16−import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms";
1712 import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
1813 import {
1914 slugifyNamespace,
@@ -21,15 +16,23 @@ import { · 1 unmodified line
2116 } from "@executor-js/react/plugins/integration-identity";
2217 import { Button } from "@executor-js/react/components/button";
2318 import {
24− AuthTemplateEditor,
25− type AuthTemplateEditorValue,
26−} from "@executor-js/react/components/auth-template-editor";
19+ AuthMethodListEditor,
20+ useAuthMethodList,
21+ type AuthMethodRow,
22+ type AuthMethodSeed,
23+} from "@executor-js/react/components/auth-method-list-editor";
2724 import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack";
2825 import { FieldLabel } from "@executor-js/react/components/field";
2926 import { FloatActions } from "@executor-js/react/components/float-actions";
3027 import { Textarea } from "@executor-js/react/components/textarea";
3128 import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
32−import { PlusIcon, XIcon } from "lucide-react";
29+import {
30+ addIntegrationErrorMessage,
31+ errorMessageFromExit,
32+ FormErrorAlert,
33+ SlugCollisionAlert,
34+ useSlugAlreadyExists,
35+} from "@executor-js/react/lib/integration-add";
3336
3437 import { authenticationFromEditorValue, editorValueFromAuthentication } from "./auth-method-config";
3538 import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";
@@ -72,24 +75,6 @@ const googleBundleUrls = ( · 36 unmodified lines
7275 return [...new Set([...fromPresets, ...customUrls])];
7376 };
7477
75−const ErrorMessage = Schema.Struct({ message: Schema.String });
76−const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
77−
78−const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
79− Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
80− onNone: () => fallback,
81− onSome: ({ message }) => message,
82− });
83−
84−const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean =>
85− Option.match(Exit.findErrorOption(exit), {
86− onNone: () => false,
87− onSome: Predicate.isTagged("IntegrationAlreadyExistsError"),
88− });
89−
90−const integrationExistsMessage = (slug: string): string =>
91− `An integration named "${slug}" already exists. To add more authentication, update your existing integration.`;
92−
9378 // ---------------------------------------------------------------------------
9479 // OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve
9580 // them against the chosen base URL so the stored auth template is absolute.
@@ -377,88 +362,46 @@ export default function AddOpenApiSource(props: { · 281 unmodified lines
377362 [preview, resolvedBaseUrl],
378363 );
379364
380− const detectedMethodLabels: readonly string[] = useMemo(
381− () => [
382− ...(preview?.headerPresets ?? []).map((preset) => preset.label),
383− ...(preview?.oauth2Presets ?? []).map((preset) => preset.label),
384− ],
385− [preview],
386− );
387−
388365 // Editable auth methods, seeded from the spec-detected templates. The add flow
389366 // registers EVERY method (P6) — so this is a LIST, preserving multi-method
390− // specs (e.g. apiKey + OAuth). Each row carries its editor value plus the
391− // detected template's original `seedSlug`, so an unedited detected method
392− // submits with its EXACT original slug (preserving behavior); added methods
393− // (no seed) get a deterministic fresh slug. The user can edit, add, or remove
394− // a method before submitting; on submit the list converts back to
395− // `Authentication[]`. Re-seeded whenever a fresh detection arrives (keyed on
396− // the detected templates, which are stable per analysis + base URL).
397− type AuthMethodRow = {
398− readonly value: AuthTemplateEditorValue;
399− readonly seedSlug?: string;
400− };
401− const [authMethods, setAuthMethods] = useState<readonly AuthMethodRow[]>([]);
402− const seededFromRef = useRef<readonly Authentication[] | null>(null);
403− useEffect(() => {
404− if (seededFromRef.current === authenticationTemplate) return;
405− seededFromRef.current = authenticationTemplate;
406− setAuthMethods(
407− authenticationTemplate.map((template: Authentication) => ({
367+ // specs (e.g. apiKey + OAuth). Each seed carries the detected template's
368+ // original slug, so an unedited detected method submits with its EXACT
369+ // original slug (preserving behavior); added methods (no seed) get a
370+ // deterministic fresh slug. Re-seeded whenever a fresh detection arrives
371+ // (keyed on the detected templates, stable per analysis + base URL).
372+ const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(() => {
373+ const labels = [
374+ ...(preview?.headerPresets ?? []).map((preset) => preset.label),
375+ ...(preview?.oauth2Presets ?? []).map((preset) => preset.label),
376+ ];
377+ return authenticationTemplate.map(
378+ (template: Authentication, index: number): AuthMethodSeed => ({
408379 value: editorValueFromAuthentication(template),
409− seedSlug: String(template.slug),
410− })),
380+ slug: String(template.slug),
381+ ...(labels[index] !== undefined ? { label: labels[index] } : {}),
382+ }),
411383 );
412− }, [authenticationTemplate]);
413−
414− const setAuthMethodAt = useCallback((index: number, next: AuthTemplateEditorValue) => {
415− setAuthMethods((current: readonly AuthMethodRow[]) =>
416− current.map((row: AuthMethodRow, i: number) => (i === index ? { ...row, value: next } : row)),
417− );
418− }, []);
419−
420− const removeAuthMethodAt = useCallback((index: number) => {
421− setAuthMethods((current: readonly AuthMethodRow[]) =>
422− current.filter((_row: AuthMethodRow, i: number) => i !== index),
423− );
424− }, []);
425−
426− const addAuthMethod = useCallback(() => {
427− setAuthMethods((current: readonly AuthMethodRow[]) => [
428− ...current,
429− {
430− value: {
431− kind: "apikey",
432− placements: [{ carrier: "header", name: "Authorization", prefix: "" }],
433− },
434− },
435− ]);
436− }, []);
384+ }, [preview, authenticationTemplate]);
385+ const authMethodList = useAuthMethodList(authMethodSeeds);
437386
438387 // The methods to register, mapped back to stored `Authentication[]`. Drops
439388 // `none` rows (nothing to register). An unedited detected method keeps its
440389 // original `seedSlug`; an added method gets a deterministic fresh slug.
441390 const editedAuthenticationTemplate: readonly Authentication[] = useMemo(() => {
442391 const templates: Authentication[] = [];
443− authMethods.forEach((row: AuthMethodRow, index: number) => {
392+ authMethodList.rows.forEach((row: AuthMethodRow, index: number) => {
444393 const slug =
445394 row.seedSlug ?? (row.value.kind === "oauth" ? `oauth-${index}` : `apikey-${index}`);
446395 const template = authenticationFromEditorValue(row.value, slug);
447396 if (template !== null) templates.push(template);
448397 });
449398 return templates;
450− }, [authMethods]);
399+ }, [authMethodList.rows]);
451400
452401 // Pre-empt the API's `IntegrationAlreadyExistsError`: adding an integration
453402 // whose slug already exists clobbers the existing one's connections/policies,
454403 // so the API blocks it. Surface that here from the tenant-scoped catalog list.
455− const integrationsResult = useAtomValue(integrationsOptimisticAtom);
456− const slugAlreadyExists = useMemo(
457− () =>
458− AsyncResult.isSuccess(integrationsResult) &&
459− integrationsResult.value.some((integration) => integration.slug === resolvedSourceId),
460− [integrationsResult, resolvedSourceId],
461− );
404+ const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId);
462405
463406 // The bundle path is ready once at least one Google API is selected (no
464407 // network preview gates it); the single/custom-spec path still requires a
@@ -521,11 +464,7 @@ export default function AddOpenApiSource(props: { · 56 unmodified lines
521464 reactivityKeys: integrationWriteKeys,
522465 });
523466 if (Exit.isFailure(exit)) {
524− setAddError(
525− isIntegrationAlreadyExistsExit(exit)
526− ? integrationExistsMessage(resolvedSourceId)
527− : errorMessageFromExit(exit, "Failed to add integration"),
528− );
467+ setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add integration"));
529468 return null;
530469 }
531470 return exit.value.slug;
@@ -644,85 +583,21 @@ export default function AddOpenApiSource(props: { · 112 unmodified lines
644583 />
645584 ) : null}
646585
647− {analyzeError && (
648− <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
649− <p className="text-[12px] text-destructive">{analyzeError}</p>
650− </div>
651− )}
586+ {analyzeError && <FormErrorAlert message={analyzeError} />}
652587
653588 {preview && !isGoogleBundlePreset && (
654− <section className="space-y-3">
655− <div className="flex items-center justify-between gap-3">
656− <FieldLabel>How does this API authenticate?</FieldLabel>
657− <Button type="button" variant="outline" size="sm" onClick={addAuthMethod}>
658− <PlusIcon />
659− Add method
660− </Button>
661− </div>
662− {authMethods.length === 0 ? (
663− <p className="text-[11px] text-muted-foreground">
664− No authentication detected. Add a method, or add the integration without auth and
665− connect an account from the integration page later.
666− </p>
667− ) : (
668− <div className="flex flex-col gap-3">
669− {authMethods.map((row: AuthMethodRow, index: number) => (
670− <div
671− key={index}
672− className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3"
673− >
674− <div className="flex items-center justify-between">
675− <span className="text-xs font-medium text-muted-foreground">
676− Method {index + 1}
677− {detectedMethodLabels[index] ? ` · ${detectedMethodLabels[index]}` : ""}
678− </span>
679− <Button
680− type="button"
681− variant="ghost"
682− size="icon-sm"
683− aria-label="Remove method"
684− className="text-muted-foreground hover:text-foreground"
685− onClick={() => removeAuthMethodAt(index)}
686− >
687− <XIcon />
688− </Button>
689− </div>
690− <AuthTemplateEditor
691− value={row.value}
692− onChange={(next: AuthTemplateEditorValue) => setAuthMethodAt(index, next)}
693− />
694− </div>
695− ))}
696− </div>
697− )}
698− <p className="text-[11px] text-muted-foreground">
699− Every method here is registered with the integration. Connect an account from the
700− integration page after adding.
701− </p>
702− </section>
589+ <AuthMethodListEditor
590+ list={authMethodList}
591+ emptyHint="No authentication detected. Add a method, or add the integration without auth and connect an account from the integration page later."
592+ footerHint="Every method here is registered with the integration. Connect an account from the integration page after adding."
593+ />
703594 )}
704595
705596 {hasPreviewOrBundle && slugAlreadyExists && !adding && (
706− <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
707− <p className="text-[12px] text-destructive">
708− An integration named &quot;{resolvedSourceId}&quot; already exists. To add more
709− authentication, update your existing integration.{" "}
710− <Link
711− to="/integrations/$namespace"
712− params={{ namespace: resolvedSourceId }}
713− className="font-medium underline underline-offset-2"
714− >
715− Open it
716− </Link>
717− </p>
718− </div>
597+ <SlugCollisionAlert slug={resolvedSourceId} />
719598 )}
720599
721− {addError && (
722− <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
723− <p className="text-[12px] text-destructive">{addError}</p>
724− </div>
725− )}
600+ {addError && <FormErrorAlert message={addError} />}
726601
727602 <FloatActions>
728603 <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}>
packages/plugins/openapi/src/react/OpenApiAccountsPanel.tsx+32−30
@@ -10,8 +10,12 @@ import { TriangleAlert } from "lucide-react"; · 9 unmodified lines
1010 import { AccountsSection } from "@executor-js/react/components/accounts-section";
1111 import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";
1212 import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
13−import type { CreateCustomMethod } from "@executor-js/react/components/add-custom-method-modal";
1413 import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
14+import {
15+ useCustomMethodActions,
16+ type AuthMethodsCodec,
17+ type ConfigureAuthMethods,
18+} from "@executor-js/react/lib/custom-auth-methods";
1519
1620 import { openApiConfigAtom, openapiConfigure } from "./atoms";
1721 import { authMethodsFromConfig, templateFromPlacements } from "./auth-method-config";
@@ -70,44 +74,42 @@ export default function OpenApiAccountsPanel(props: { · 52 unmodified lines
7074 return declared.length > 0 ? declared : [NO_AUTH_METHOD];
7175 }, [existingTemplate]);
7276
73− // Add a custom apiKey method: build an `APIKeyAuthentication` from the generic
74− // placements (slug omitted → backend backfills `custom_<id>`), merge-append it
75− // onto the existing template, and persist. Returns the created `AuthMethod`
76− // (derived from the same template) so Add-account can select it immediately.
77− const createCustomMethod = useCallback<CreateCustomMethod>(
78− async (input: { readonly label: string; readonly placements: readonly Placement[] }) => {
79− const method = templateFromPlacements(input.placements);
77+ // Custom-method create/remove: the shared skeleton (merge-append → diff out
78+ // the created method; filter → replace) parameterized by the OpenAPI codec.
79+ // Stays plugin-side only where it touches the OpenAPI `Authentication` types.
80+ const configure = useCallback<ConfigureAuthMethods<Authentication>>(
81+ async (input) => {
8082 const exit = await doConfigure({
8183 params: { slug },
82− payload: { authenticationTemplate: [...existingTemplate, method] },
84+ payload: {
85+ authenticationTemplate: input.authenticationTemplate,
86+ ...(input.mode ? { mode: input.mode } : {}),
87+ },
8388 reactivityKeys: integrationWriteKeys,
8489 });
85− if (Exit.isFailure(exit)) return null;
86− const before = new Set(existingTemplate.map((template) => String(template.slug)));
87− const created = authMethodsFromConfig(
88− exit.value.authenticationTemplate as readonly Authentication[],
89− ).find((candidate: AuthMethod) => !before.has(String(candidate.template)));
90− return created ?? null;
90+ return Exit.map(exit, (result) => result.authenticationTemplate as readonly Authentication[]);
9191 },
92− [doConfigure, slug, existingTemplate],
92+ [doConfigure, slug],
9393 );
9494
95− const removeCustomMethod = useCallback(
96− async (method: AuthMethod): Promise<boolean> => {
97− if (method.source !== "custom") return false;
98− const next = existingTemplate.filter(
99− (template: Authentication) => String(template.slug) !== String(method.template),
100− );
101− const exit = await doConfigure({
102− params: { slug },
103− payload: { authenticationTemplate: next, mode: "replace" },
104− reactivityKeys: integrationWriteKeys,
105− });
106− return Exit.isSuccess(exit);
107− },
108− [doConfigure, existingTemplate, slug],
95+ const codec = useMemo<AuthMethodsCodec<Authentication>>(
96+ () => ({
97+ toAuthMethods: authMethodsFromConfig,
98+ // Slug omitted → backend backfills `custom_<id>`.
99+ templatesFromPlacements: (placements: readonly Placement[]) => [
100+ templateFromPlacements(placements),
101+ ],
102+ slugOf: (template: Authentication) => String(template.slug),
103+ }),
104+ [],
109105 );
110106
107+ const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({
108+ existing: existingTemplate,
109+ codec,
110+ configure,
111+ });
112+
111113 // For a bundled `google` integration, surface a caution when any selected API
112114 // needs a privileged or unsupported OAuth consent the user should know about
113115 // BEFORE connecting an account. Derived from the stored Discovery URLs.
packages/plugins/openapi/src/sdk/plugin.ts+2−49
@@ -3,7 +3,6 @@ import type { Layer } from "effect"; · 2 unmodified lines
33 import { HttpClient } from "effect/unstable/http";
44
55 import {
6− AuthTemplateSlug,
76 IntegrationAlreadyExistsError,
87 IntegrationDetectionResult,
98 IntegrationSlug,
@@ -11,6 +10,7 @@ import { · 1 unmodified line
1110 ToolResult,
1211 authToolFailure,
1312 definePlugin,
13+ mergeAuthTemplates,
1414 tool,
1515 type AuthMethodDescriptor,
1616 type AuthPlacementDescriptor,
@@ -521,53 +521,6 @@ const specInputToSourceUrl = (spec: OpenApiSpecInput): string | undefined => · 504 unmodified lines
521521 const specInputToGoogleBundle = (spec: OpenApiSpecInput): readonly string[] | undefined =>
522522 spec.kind === "googleDiscoveryBundle" ? spec.urls : undefined;
523523
524−// ---------------------------------------------------------------------------
525−// Auth-template merge — `configure` appends custom methods to the integration's
526−// existing `authenticationTemplate`. A method whose slug matches an existing
527−// entry replaces it (in place); a method with no usable slug is assigned a
528−// generated `custom_<id>` slug, collision-checked against the merged set so two
529−// custom methods added in one call can't clash.
530−// ---------------------------------------------------------------------------
531−
532−const shortId = (): string => Math.random().toString(36).slice(2, 8);
533−
534−/** Generate a `custom_<id>` slug not present in `taken`. */
535−const freshCustomSlug = (taken: ReadonlySet<string>): AuthTemplateSlug => {
536− let candidate = `custom_${shortId()}`;
537− while (taken.has(candidate)) candidate = `custom_${shortId()}`;
538− return AuthTemplateSlug.make(candidate);
539−};
540−
541−/** Merge incoming auth methods into the existing template array. Entries with a
542− * matching slug replace the existing entry; otherwise they are appended.
543− * Incoming entries lacking a slug (or whose slug collides with another entry
544− * added in this same call) get a fresh `custom_<id>` slug. */
545−const mergeAuthenticationTemplate = (
546− existing: readonly Authentication[],
547− incoming: readonly Authentication[],
548−): readonly Authentication[] => {
549− const result: Authentication[] = existing.map((entry) => entry);
550− const taken = new Set<string>(result.map((entry) => String(entry.slug)));
551− for (const entry of incoming) {
552− // `slug` is branded as required, but JSON callers may omit it; read defensively.
553− const rawSlug = (entry as { readonly slug?: unknown }).slug;
554− const requested = typeof rawSlug === "string" ? rawSlug.trim() : "";
555− const existingIndex = result.findIndex((current) => String(current.slug) === requested);
556− if (requested.length > 0 && existingIndex >= 0) {
557− // Replace the matching entry in place, preserving array order.
558− result[existingIndex] = entry;
559− continue;
560− }
561− const slug =
562− requested.length > 0 && !taken.has(requested)
563− ? AuthTemplateSlug.make(requested)
564− : freshCustomSlug(taken);
565− taken.add(String(slug));
566− result.push({ ...entry, slug } as Authentication);
567− }
568− return result;
569−};
570−
571524 // ---------------------------------------------------------------------------
572525 // Declared auth methods — project the stored `authenticationTemplate` into the
573526 // catalog's plugin-agnostic `AuthMethodDescriptor[]`. This mirrors the client's
@@ -941,7 +894,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 367 unmodified lines
941894 const merged =
942895 input.mode === "replace"
943896 ? input.authenticationTemplate
944− : mergeAuthenticationTemplate(
897+ : mergeAuthTemplates(
945898 current.authenticationTemplate ?? [],
946899 input.authenticationTemplate,
947900 );
packages/react/package.json+1−0
@@ -12,6 +12,7 @@ · 11 unmodified lines
1212 "./components/*": "./src/components/*.tsx",
1313 "./hooks/*": "./src/hooks/*.ts",
1414 "./lib/auth-placements": "./src/lib/auth-placements.tsx",
15+ "./lib/integration-add": "./src/lib/integration-add.tsx",
1516 "./lib/*": "./src/lib/*.ts",
1617 "./globals.css": "./src/styles/globals.css"
1718 },
packages/react/src/components/auth-method-list-editor.tsx+155−0
@@ -0,0 +1,155 @@
1+// ---------------------------------------------------------------------------
2+// Auth-method LIST editor — the add-flow's "How does this API authenticate?"
3+// section, as a list. Every plugin registers EVERY declared method (P6: add
4+// without auth, connect later), so the add flow edits a list of generic
5+// `AuthTemplateEditorValue` rows seeded from detection (spec analysis, endpoint
6+// probe, …) with add/remove and a per-row `AuthTemplateEditor`.
7+//
8+// Composition: `useAuthMethodList` is the headless row state (seeding,
9+// edit/add/remove); `AuthMethodListEditor` is the presentation. Plugins own
10+// only the codec at the edges — seeds in (detection → editor values) and
11+// submit out (editor values → wire templates).
12+// ---------------------------------------------------------------------------
13+
14+import { useCallback, useEffect, useRef, useState } from "react";
15+import { PlusIcon, XIcon } from "lucide-react";
16+
17+import { Button } from "./button";
18+import { FieldLabel } from "./field";
19+import {
20+ AuthTemplateEditor,
21+ emptyApiKeyValue,
22+ type AuthTemplateEditorKind,
23+ type AuthTemplateEditorPreset,
24+ type AuthTemplateEditorValue,
25+} from "./auth-template-editor";
26+
27+export interface AuthMethodSeed {
28+ readonly value: AuthTemplateEditorValue;
29+ /** The detected method's stable slug — an unedited seeded row submits with
30+ * its EXACT original slug (preserving connections bound against it). */
31+ readonly slug?: string;
32+ /** Detection label (e.g. the spec's security-scheme name) shown on the row. */
33+ readonly label?: string;
34+}
35+
36+export interface AuthMethodRow {
37+ readonly value: AuthTemplateEditorValue;
38+ readonly seedSlug?: string;
39+ readonly seedLabel?: string;
40+}
41+
42+export interface AuthMethodListState {
43+ readonly rows: readonly AuthMethodRow[];
44+ readonly setRowAt: (index: number, next: AuthTemplateEditorValue) => void;
45+ readonly removeRowAt: (index: number) => void;
46+ readonly addRow: () => void;
47+}
48+
49+/** Headless row state for the method list. Re-seeds whenever `seeds` changes
50+ * identity (detection results are stable per analysis), discarding edits —
51+ * fresh detection means a fresh starting set. */
52+export function useAuthMethodList(seeds: readonly AuthMethodSeed[]): AuthMethodListState {
53+ const [rows, setRows] = useState<readonly AuthMethodRow[]>([]);
54+ const seededFromRef = useRef<readonly AuthMethodSeed[] | null>(null);
55+ useEffect(() => {
56+ if (seededFromRef.current === seeds) return;
57+ seededFromRef.current = seeds;
58+ setRows(
59+ seeds.map(
60+ (seed: AuthMethodSeed): AuthMethodRow => ({
61+ value: seed.value,
62+ ...(seed.slug !== undefined ? { seedSlug: seed.slug } : {}),
63+ ...(seed.label !== undefined ? { seedLabel: seed.label } : {}),
64+ }),
65+ ),
66+ );
67+ }, [seeds]);
68+
69+ const setRowAt = useCallback((index: number, next: AuthTemplateEditorValue) => {
70+ setRows((current: readonly AuthMethodRow[]) =>
71+ current.map((row: AuthMethodRow, i: number) => (i === index ? { ...row, value: next } : row)),
72+ );
73+ }, []);
74+
75+ const removeRowAt = useCallback((index: number) => {
76+ setRows((current: readonly AuthMethodRow[]) =>
77+ current.filter((_row: AuthMethodRow, i: number) => i !== index),
78+ );
79+ }, []);
80+
81+ const addRow = useCallback(() => {
82+ setRows((current: readonly AuthMethodRow[]) => [...current, { value: emptyApiKeyValue() }]);
83+ }, []);
84+
85+ return { rows, setRowAt, removeRowAt, addRow };
86+}
87+
88+export interface AuthMethodListEditorProps {
89+ readonly list: AuthMethodListState;
90+ readonly title?: string;
91+ /** Shown when the list is empty (e.g. "No authentication detected. …"). */
92+ readonly emptyHint?: string;
93+ /** Shown under the list when at least one row exists. */
94+ readonly footerHint?: string;
95+ /** Per-row editor restrictions — see `AuthTemplateEditorProps`. */
96+ readonly allowedKinds?: readonly AuthTemplateEditorKind[];
97+ readonly presets?: readonly AuthTemplateEditorPreset[];
98+ readonly oauthMetadata?: "editable" | "discovered";
99+}
100+
101+export function AuthMethodListEditor(props: AuthMethodListEditorProps) {
102+ const { list, allowedKinds, presets, oauthMetadata } = props;
103+ return (
104+ <section className="space-y-3">
105+ <div className="flex items-center justify-between gap-3">
106+ <FieldLabel>{props.title ?? "How does this API authenticate?"}</FieldLabel>
107+ <Button type="button" variant="outline" size="sm" onClick={list.addRow}>
108+ <PlusIcon />
109+ Add method
110+ </Button>
111+ </div>
112+ {list.rows.length === 0 ? (
113+ props.emptyHint ? (
114+ <p className="text-[11px] text-muted-foreground">{props.emptyHint}</p>
115+ ) : null
116+ ) : (
117+ <div className="flex flex-col gap-3">
118+ {list.rows.map((row: AuthMethodRow, index: number) => (
119+ <div
120+ key={index}
121+ className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3"
122+ >
123+ <div className="flex items-center justify-between">
124+ <span className="text-xs font-medium text-muted-foreground">
125+ Method {index + 1}
126+ {row.seedLabel ? ` · ${row.seedLabel}` : ""}
127+ </span>
128+ <Button
129+ type="button"
130+ variant="ghost"
131+ size="icon-sm"
132+ aria-label="Remove method"
133+ className="text-muted-foreground hover:text-foreground"
134+ onClick={() => list.removeRowAt(index)}
135+ >
136+ <XIcon />
137+ </Button>
138+ </div>
139+ <AuthTemplateEditor
140+ value={row.value}
141+ onChange={(next: AuthTemplateEditorValue) => list.setRowAt(index, next)}
142+ {...(allowedKinds ? { allowedKinds } : {})}
143+ {...(presets ? { presets } : {})}
144+ {...(oauthMetadata ? { oauthMetadata } : {})}
145+ />
146+ </div>
147+ ))}
148+ </div>
149+ )}
150+ {list.rows.length > 0 && props.footerHint ? (
151+ <p className="text-[11px] text-muted-foreground">{props.footerHint}</p>
152+ ) : null}
153+ </section>
154+ );
155+}
packages/react/src/lib/custom-auth-methods.ts+79−0
@@ -0,0 +1,79 @@
1+// ---------------------------------------------------------------------------
2+// Custom auth-method actions — the shared create/remove skeleton behind every
3+// accounts panel's "+ Custom method" flow. The shape is identical across
4+// plugins (openapi, graphql, mcp): convert the generic placements into the
5+// plugin's wire templates, merge-append via the plugin's configureAuth
6+// endpoint, then diff the response against the prior slugs to find the created
7+// method. Remove filters the set by slug and replaces.
8+//
9+// The plugin contributes only a small CODEC (wire-template ↔ generic
10+// `AuthMethod`) and its configure mutation; everything else lives here.
11+// ---------------------------------------------------------------------------
12+
13+import { useCallback } from "react";
14+import * as Exit from "effect/Exit";
15+
16+import type { AuthMethod, Placement } from "./auth-placements";
17+import type { CreateCustomMethod } from "../components/add-custom-method-modal";
18+
19+/** The plugin-specific edges of the custom-method flow. `T` is the plugin's
20+ * wire auth-template type (an object carrying a `slug`). */
21+export interface AuthMethodsCodec<T> {
22+ /** Project wire templates into the generic `AuthMethod[]` the hub renders. */
23+ readonly toAuthMethods: (templates: readonly T[]) => readonly AuthMethod[];
24+ /** Build the wire templates for a custom method from its generic placements.
25+ * Empty result ⇒ the placements declare nothing persistable. Slugs are
26+ * omitted/blank — the backend backfills `custom_<id>`. */
27+ readonly templatesFromPlacements: (placements: readonly Placement[]) => readonly T[];
28+ readonly slugOf: (template: T) => string;
29+}
30+
31+/** The plugin's configureAuth mutation, normalized: merge-append by default,
32+ * `replace` swaps the whole declared set. Resolves to the resulting template
33+ * array via an Exit (the atom mutation's `promiseExit`). */
34+export type ConfigureAuthMethods<T> = (input: {
35+ readonly authenticationTemplate: readonly T[];
36+ readonly mode?: "merge" | "replace";
37+}) => Promise<Exit.Exit<readonly T[], unknown>>;
38+
39+export interface CustomMethodActions {
40+ readonly createCustomMethod: CreateCustomMethod;
41+ readonly removeCustomMethod: (method: AuthMethod) => Promise<boolean>;
42+}
43+
44+export function useCustomMethodActions<T>(options: {
45+ readonly existing: readonly T[];
46+ readonly codec: AuthMethodsCodec<T>;
47+ readonly configure: ConfigureAuthMethods<T>;
48+}): CustomMethodActions {
49+ const { existing, codec, configure } = options;
50+
51+ const createCustomMethod = useCallback<CreateCustomMethod>(
52+ async (input: { readonly label: string; readonly placements: readonly Placement[] }) => {
53+ const templates = codec.templatesFromPlacements(input.placements);
54+ if (templates.length === 0) return null;
55+ const exit = await configure({ authenticationTemplate: templates });
56+ if (Exit.isFailure(exit)) return null;
57+ const before = new Set(existing.map((template: T) => codec.slugOf(template)));
58+ const created = codec
59+ .toAuthMethods(exit.value)
60+ .find((candidate: AuthMethod) => !before.has(String(candidate.template)));
61+ return created ?? null;
62+ },
63+ [codec, configure, existing],
64+ );
65+
66+ const removeCustomMethod = useCallback(
67+ async (method: AuthMethod): Promise<boolean> => {
68+ if (method.source !== "custom") return false;
69+ const next = existing.filter(
70+ (template: T) => codec.slugOf(template) !== String(method.template),
71+ );
72+ const exit = await configure({ authenticationTemplate: next, mode: "replace" });
73+ return Exit.isSuccess(exit);
74+ },
75+ [codec, configure, existing],
76+ );
77+
78+ return { createCustomMethod, removeCustomMethod };
79+}
packages/react/src/lib/integration-add.tsx+91−0
@@ -0,0 +1,91 @@
1+// ---------------------------------------------------------------------------
2+// Integration add-flow helpers — the plugin-agnostic pieces every Add-source
3+// form repeats: decoding a mutation Exit into a user-facing message, the
4+// IntegrationAlreadyExistsError guard, the live slug-collision check against
5+// the catalog, and the inline error/collision alerts.
6+// ---------------------------------------------------------------------------
7+
8+import { useMemo } from "react";
9+import { useAtomValue } from "@effect/atom-react";
10+import { Link } from "@tanstack/react-router";
11+import * as Exit from "effect/Exit";
12+import * as Option from "effect/Option";
13+import * as Predicate from "effect/Predicate";
14+import * as Schema from "effect/Schema";
15+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
16+
17+import { integrationsOptimisticAtom } from "../api/atoms";
18+
19+const ErrorMessage = Schema.Struct({ message: Schema.String });
20+const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
21+
22+/** The failed Exit's `message`, or `fallback` when the error carries none. */
23+export const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
24+ Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
25+ onNone: () => fallback,
26+ onSome: ({ message }) => message,
27+ });
28+
29+export const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean =>
30+ Option.match(Exit.findErrorOption(exit), {
31+ onNone: () => false,
32+ onSome: Predicate.isTagged("IntegrationAlreadyExistsError"),
33+ });
34+
35+export const integrationExistsMessage = (slug: string): string =>
36+ `An integration named "${slug}" already exists. To add more authentication, update your existing integration.`;
37+
38+/** Decode an add-integration failure into its inline message: the
39+ * already-exists guard message for a slug collision, otherwise the error's
40+ * own message (or `fallback`). */
41+export const addIntegrationErrorMessage = (
42+ exit: Exit.Exit<unknown, unknown>,
43+ slug: string,
44+ fallback: string,
45+): string =>
46+ isIntegrationAlreadyExistsExit(exit)
47+ ? integrationExistsMessage(slug)
48+ : errorMessageFromExit(exit, fallback);
49+
50+/** Whether `slug` is already taken in the tenant-scoped catalog (pre-empting
51+ * the API's `IntegrationAlreadyExistsError`). Blank slugs never collide —
52+ * they let the server assign one. */
53+export function useSlugAlreadyExists(slug: string): boolean {
54+ const integrationsResult = useAtomValue(integrationsOptimisticAtom);
55+ return useMemo(
56+ () =>
57+ slug.length > 0 &&
58+ AsyncResult.isSuccess(integrationsResult) &&
59+ integrationsResult.value.some((integration) => String(integration.slug) === slug),
60+ [integrationsResult, slug],
61+ );
62+}
63+
64+/** Inline destructive alert for a form-level error message. */
65+export function FormErrorAlert(props: { readonly message: string }) {
66+ return (
67+ <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
68+ <p className="text-[12px] text-destructive">{props.message}</p>
69+ </div>
70+ );
71+}
72+
73+/** The slug-collision alert with a link to the existing integration. Render
74+ * gated by the caller (typically `show && !adding`). */
75+export function SlugCollisionAlert(props: { readonly slug: string }) {
76+ return (
77+ <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
78+ <p className="text-[12px] text-destructive">
79+ An integration named &quot;{props.slug}&quot; already exists. To add more authentication,
80+ update your existing integration.{" "}
81+ <Link
82+ to="/integrations/$namespace"
83+ params={{ namespace: props.slug }}
84+ className="font-medium underline underline-offset-2"
85+ >
86+ Open it
87+ </Link>
88+ </p>
89+ </div>
90+ );
91+}
testkit/README.md+50−0
@@ -0,0 +1,50 @@
1+# testkit (MCP surface) — MVP
2+
3+Black-box tests through the **MCP surface**, on the vendored mcporter fork
4+(`vendor/mcporter`, with its headless `cookieConsentStrategy`). Every run emits a
5+**chat-transcript recording** (`run.json`) — the MCP surface *is* an agent-in-a-chat,
6+so that's the natural shape.
7+
8+## Write a test
9+
10+```js
11+import { mcpTarget, mcpTest, cookieConsentStrategy } from "./src/testkit.mjs";
12+
13+// the per-host seam: which MCP server + how to consent (headless, CI-safe)
14+const selfhost = mcpTarget({
15+ name: "selfhost",
16+ server: "http://localhost:5173/mcp",
17+ consent: cookieConsentStrategy({ appBaseUrl: "http://localhost:5173", email, password }),
18+});
19+
20+mcpTest("execute runs code", selfhost, async (mcp) => {
21+ mcp.say("Confirm the sandbox evaluates and returns a value"); // → reasoning turn
22+ const r = await mcp.call("execute", { code: "return 6*7;" }); // → tool turn
23+ mcp.expect(r.text).toBe("42"); // → assert turn
24+});
25+```
26+
27+## Run + watch
28+
29+```bash
30+node run.mjs # runs tests, writes runs/*.run.json, prints chat
31+node src/render.mjs runs/X.run.json # render a recording in the terminal
32+node src/render.mjs runs/X.run.json --html out.html # watchable HTML
33+node serve.mjs # serve runs/ (index + per-run HTML) on :8901
34+```
35+
36+## What a recording is
37+
38+`run.json` = a chat transcript (`turns`: user / assistant·reasoning / tool / assert /
39+error) + `asserts` + `ok`/`error`/`durationMs`. The HTML renders it as a chat: task
40+bubble → reasoning → collapsible tool cards (args + result) → inline asserts. Failures
41+record too — `ok:false` with the failing turn in place.
42+
43+## Status / next
44+
45+- **Now:** scripted brain (deterministic, no LLM). Proven against dev `/mcp`.
46+- **Next:** `agentTest` — LLM brain (real model drives via MCP, reasoning captured
47+ verbatim); raw JSON-RPC `protocol[]` layer linked to tool turns; MCP-apps as `app`
48+ turns. Productionize to TS + Effect Vitest later (see memory: effect-testing-stack-note).
49+- Self-contained ESM JS under `testkit/` (not in the bun workspace) — imports the fork's
50+ built `dist`. Throwaway-grade until productionized; the proof is real.
testkit/package-lock.json+151−0
@@ -0,0 +1,151 @@
1+{
2+ "name": "@executor-js/testkit",
3+ "lockfileVersion": 3,
4+ "requires": true,
5+ "packages": {
6+ "": {
7+ "name": "@executor-js/testkit",
8+ "dependencies": {
9+ "@kitlangton/terminal-control": "^0.3.0",
10+ "playwright": "^1.60.0"
11+ }
12+ },
13+ "node_modules/@kitlangton/terminal-control": {
14+ "version": "0.3.0",
15+ "resolved": "https://registry.npmjs.org/@kitlangton/terminal-control/-/terminal-control-0.3.0.tgz",
16+ "integrity": "sha512-YlOx9ztDInHBETgxWNRd6nq/YzpMlw1YEJ1OIrhdeWTqrQdJHIYThWGiWda9GxmQAThWNULkKR17as+QDgyKdQ==",
17+ "license": "MIT",
18+ "engines": {
19+ "node": ">=20"
20+ },
21+ "optionalDependencies": {
22+ "@kitlangton/terminal-control-darwin-arm64": "0.3.0",
23+ "@kitlangton/terminal-control-darwin-x64": "0.3.0",
24+ "@kitlangton/terminal-control-linux-arm64-gnu": "0.3.0",
25+ "@kitlangton/terminal-control-linux-x64-gnu": "0.3.0"
26+ },
27+ "peerDependencies": {
28+ "vitest": ">=3.0.0"
29+ },
30+ "peerDependenciesMeta": {
31+ "vitest": {
32+ "optional": true
33+ }
34+ }
35+ },
36+ "node_modules/@kitlangton/terminal-control-darwin-arm64": {
37+ "version": "0.3.0",
38+ "resolved": "https://registry.npmjs.org/@kitlangton/terminal-control-darwin-arm64/-/terminal-control-darwin-arm64-0.3.0.tgz",
39+ "integrity": "sha512-HydPv5if5pAjwHNbA7TnZAJYKfq9AiZITaJuFyF1HsUMedzin3qzv9WRVBqIiGsQ/FiR1i1kaGhyLzaVpZR35w==",
40+ "cpu": [
41+ "arm64"
42+ ],
43+ "license": "MIT",
44+ "optional": true,
45+ "os": [
46+ "darwin"
47+ ],
48+ "bin": {
49+ "termctrl": "bin/termctrl"
50+ }
51+ },
52+ "node_modules/@kitlangton/terminal-control-darwin-x64": {
53+ "version": "0.3.0",
54+ "resolved": "https://registry.npmjs.org/@kitlangton/terminal-control-darwin-x64/-/terminal-control-darwin-x64-0.3.0.tgz",
55+ "integrity": "sha512-jrCSlJybh9NkpAXKdvP6p4HpMDQLtUki94V6dzKMoRh/PSTufgPQ66g8HphwjxUoiB8aTPcXgN2iBfPxOFpvqA==",
56+ "cpu": [
57+ "x64"
58+ ],
59+ "license": "MIT",
60+ "optional": true,
61+ "os": [
62+ "darwin"
63+ ],
64+ "bin": {
65+ "termctrl": "bin/termctrl"
66+ }
67+ },
68+ "node_modules/@kitlangton/terminal-control-linux-arm64-gnu": {
69+ "version": "0.3.0",
70+ "resolved": "https://registry.npmjs.org/@kitlangton/terminal-control-linux-arm64-gnu/-/terminal-control-linux-arm64-gnu-0.3.0.tgz",
71+ "integrity": "sha512-hwtLlpeRCBESveYl0Z8w6TphgAU7AQH9FSKCpwYKDL/n/hECW5kQ3zEuy7uULnPmtk7vwVlBXRTJKP553OUCXg==",
72+ "cpu": [
73+ "arm64"
74+ ],
75+ "libc": [
76+ "glibc"
77+ ],
78+ "license": "MIT",
79+ "optional": true,
80+ "os": [
81+ "linux"
82+ ],
83+ "bin": {
84+ "termctrl": "bin/termctrl"
85+ }
86+ },
87+ "node_modules/@kitlangton/terminal-control-linux-x64-gnu": {
88+ "version": "0.3.0",
89+ "resolved": "https://registry.npmjs.org/@kitlangton/terminal-control-linux-x64-gnu/-/terminal-control-linux-x64-gnu-0.3.0.tgz",
90+ "integrity": "sha512-ge62a1I67X+aKX9VcMcEY92JHMQ3oxWS3ot3z85sKYEb3kse7q/hEtpgBYldYCBLxvu57U/Fa8sC6C3l5+xA5w==",
91+ "cpu": [
92+ "x64"
93+ ],
94+ "libc": [
95+ "glibc"
96+ ],
97+ "license": "MIT",
98+ "optional": true,
99+ "os": [
100+ "linux"
101+ ],
102+ "bin": {
103+ "termctrl": "bin/termctrl"
104+ }
105+ },
106+ "node_modules/fsevents": {
107+ "version": "2.3.2",
108+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
109+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
110+ "hasInstallScript": true,
111+ "license": "MIT",
112+ "optional": true,
113+ "os": [
114+ "darwin"
115+ ],
116+ "engines": {
117+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
118+ }
119+ },
120+ "node_modules/playwright": {
121+ "version": "1.60.0",
122+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
123+ "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
124+ "license": "Apache-2.0",
125+ "dependencies": {
126+ "playwright-core": "1.60.0"
127+ },
128+ "bin": {
129+ "playwright": "cli.js"
130+ },
131+ "engines": {
132+ "node": ">=18"
133+ },
134+ "optionalDependencies": {
135+ "fsevents": "2.3.2"
136+ }
137+ },
138+ "node_modules/playwright-core": {
139+ "version": "1.60.0",
140+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
141+ "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
142+ "license": "Apache-2.0",
143+ "bin": {
144+ "playwright-core": "cli.js"
145+ },
146+ "engines": {
147+ "node": ">=18"
148+ }
149+ }
150+ }
151+}
testkit/package.json+9−0
@@ -0,0 +1,9 @@
1+{
2+ "name": "@executor-js/testkit",
3+ "private": true,
4+ "type": "module",
5+ "dependencies": {
6+ "@kitlangton/terminal-control": "^0.3.0",
7+ "playwright": "^1.60.0"
8+ }
9+}
testkit/record-flow.mjs+59−0
@@ -0,0 +1,59 @@
1+// Records the full "OAuth → MCP → call" flow as one dark-themed mp4:
2+// 1) client title card (mcp add + pick OAuth)
3+// 2) REAL browser consent UI (login → connected), forced dark via colorScheme
4+// 3) chat replay of the real tool call
5+// Run from testkit/ (playwright resolves here): node record-flow.mjs
6+import { chromium } from "playwright";
7+import { execSync } from "node:child_process";
8+import { createRuntime, cookieConsentStrategy } from "../vendor/mcporter/dist/index.js";
9+import { renderPlayer } from "./src/render.mjs";
10+import { mkdtempSync, writeFileSync, readdirSync, renameSync, rmSync, mkdirSync } from "node:fs";
11+import { tmpdir, homedir } from "node:os"; import { join } from "node:path";
12+
13+// fresh OAuth so phase 2 records the real browser consent (not a cached token)
14+rmSync(join(homedir(), ".mcporter"), { recursive: true, force: true });
15+
16+const W = 1000, H = 720, APP = process.env.MCP_APP ?? "http://localhost:5173";
17+const EMAIL = process.env.MCP_EMAIL ?? "admin@demo.test", PW = process.env.MCP_PASSWORD ?? "demo-password-12345";
18+const TMP = "/tmp/rec-flow"; rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
19+const newRt = () => { const d = mkdtempSync(join(tmpdir(), "rf-")); writeFileSync(join(d, "mcporter.json"), JSON.stringify({ mcpServers: { selfhost: { url: APP + "/mcp" } } })); return createRuntime({ configPath: join(d, "mcporter.json") }); };
20+const recCtx = (b, dir) => b.newContext({ viewport: { width: W, height: H }, colorScheme: "dark", recordVideo: { dir, size: { width: W, height: H } } });
21+const grab = (dir, name) => { const f = readdirSync(dir).find(x => x.endsWith(".webm")); renameSync(join(dir, f), `${TMP}/${name}.webm`); };
22+
23+{ const html = `<!doctype html><meta charset=utf8><style>body{margin:0;background:#0b0f17;color:#d7dce5;font:18px ui-monospace,Menlo,monospace;height:100vh;display:flex;flex-direction:column;justify-content:center;padding:0 60px;box-sizing:border-box}.p{color:#7ee787}.c{color:#79c0ff}.dim{color:#6b7785}div{white-space:pre;margin:6px 0;opacity:0}.cur{background:#d7dce5;animation:b .8s steps(1) infinite}@keyframes b{50%{opacity:0}}</style><div id=l1></div><div id=l2></div><div id=l3></div><div id=l4></div><script>const s=ms=>new Promise(r=>setTimeout(r,ms));async function type(el,txt){el.style.opacity=1;for(let i=0;i<=txt.length;i++){el.innerHTML=txt.slice(0,i)+'<span class=cur> </span>';await s(38)}el.innerHTML=txt}(async()=>{await s(400);await type(l1,'<span class=p>$</span> mcp add <span class=c>'+${JSON.stringify(APP)}+'/mcp</span>');await s(500);l2.style.opacity=1;l2.innerHTML='<span class=dim>? Authentication method:</span>';await s(700);l3.style.opacity=1;l3.innerHTML=' None Bearer <span class=p>❯ OAuth</span>';await s(900);l4.style.opacity=1;l4.innerHTML='<span class=dim>→ opening browser for consent…</span>';await s(1200);window.__done=true})();</script>`;
24+ writeFileSync(`${TMP}/title.html`, html);
25+ const b = await chromium.launch(); const ctx = await recCtx(b, `${TMP}/v1`); const p = await ctx.newPage();
26+ await p.goto(`file://${TMP}/title.html`); await p.waitForFunction("window.__done===true", { timeout: 15000 }).catch(()=>{}); await p.waitForTimeout(600);
27+ await ctx.close(); await b.close(); grab(`${TMP}/v1`, "1"); console.log("phase1 ✓"); }
28+
29+{ let req; const rt = await newRt();
30+ try { await rt.listTools("selfhost", { autoAuthorize: true, oauthSessionOptions: { consentStrategy: async r => { req = r; throw new Error("x"); } } }); } catch {}
31+ const b = await chromium.launch(); const ctx = await recCtx(b, `${TMP}/v2`); const p = await ctx.newPage();
32+ await p.goto(req.authorizationUrl, { waitUntil: "domcontentloaded" });
33+ await p.waitForSelector("input[type=email]", { timeout: 10000 }); await p.waitForTimeout(700);
34+ await p.type("input[type=email]", EMAIL, { delay: 45 }); await p.waitForTimeout(250);
35+ await p.type("input[type=password]", PW, { delay: 28 }); await p.waitForTimeout(450);
36+ await p.click("button[type=submit]"); await p.waitForTimeout(2600);
37+ await p.setContent('<html><body style="font:600 34px system-ui;display:grid;place-items:center;height:100vh;margin:0;background:#0b0f17;color:#7ee787">🔓 Connected to MCP server</body></html>');
38+ await p.waitForTimeout(1700); await ctx.close(); await b.close(); grab(`${TMP}/v2`, "2"); console.log("phase2 ✓ (dark login)"); }
39+
40+{ const rt = await newRt();
41+ const opts = { autoAuthorize: true, oauthSessionOptions: { consentStrategy: cookieConsentStrategy({ appBaseUrl: APP, email: EMAIL, password: PW }) } };
42+ const tools = await rt.listTools("selfhost", opts);
43+ const res = await rt.callTool("selfhost", "execute", { args: { code: "return 6 * 7;" }, ...opts });
44+ const text = res.content.filter(c=>c.type==="text").map(c=>c.text).join("");
45+ const run = { task: "Run 6 × 7 in the sandbox using the execute tool", brain: "scripted", meta: { server: APP + "/mcp" }, ok: text==="42", turns: [
46+ { role: "user", text: "Run 6 × 7 in the sandbox using the execute tool" },
47+ { role: "assistant", kind: "reasoning", text: "Connected to the MCP server. I'll call execute with a tiny program." },
48+ { role: "tool", call: { name: "tools/list", args: {} }, result: tools.map(t=>t.name), ok: true, text: tools.map(t=>t.name).join(", ") },
49+ { role: "tool", call: { name: "execute", args: { code: "return 6 * 7;" } }, result: res.content, ok: true, text },
50+ { role: "assert", kind: "toBe", actual: text, expected: "42", ok: text === "42" } ]};
51+ writeFileSync(`${TMP}/chat.html`, renderPlayer(run));
52+ const b = await chromium.launch(); const ctx = await recCtx(b, `${TMP}/v3`); const p = await ctx.newPage();
53+ await p.goto(`file://${TMP}/chat.html`); await p.waitForFunction("window.__done===true", { timeout: 30000 }).catch(()=>{}); await p.waitForTimeout(1000);
54+ await ctx.close(); await b.close(); grab(`${TMP}/v3`, "3"); console.log("phase3 ✓ →", text); }
55+
56+for (const n of ["1", "2", "3"]) execSync(`ffmpeg -y -i ${TMP}/${n}.webm -vf "scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2:color=0x0b0f17,fps=30" -pix_fmt yuv420p -c:v libx264 -preset veryfast ${TMP}/${n}.mp4 2>/dev/null`);
57+writeFileSync(`${TMP}/list.txt`, ["1","2","3"].map(n=>`file '${TMP}/${n}.mp4'`).join("\n"));
58+execSync(`ffmpeg -y -f concat -safe 0 -i ${TMP}/list.txt -c copy ${import.meta.dirname}/runs/oauth-flow.mp4 2>/dev/null`);
59+console.log("✓ runs/oauth-flow.mp4");
testkit/run.mjs+8−0
@@ -0,0 +1,8 @@
1+import { runTests } from "./src/testkit.mjs";
2+import { renderTerminal } from "./src/render.mjs";
3+import { readFileSync } from "node:fs";
4+await import("./tests/execute.test.mjs");
5+await import("./tests/x-oauth-basic.test.mjs");
6+const { results } = await runTests();
7+console.log("\n──────── recordings ────────");
8+for (const r of results) console.log("\n" + renderTerminal(JSON.parse(readFileSync(r.file, "utf8"))));
testkit/serve.mjs+21−0
@@ -0,0 +1,21 @@
1+import http from "node:http"; import fs from "node:fs"; import path from "node:path";
2+import { fileURLToPath } from "node:url";
3+const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "runs");
4+const TYPES = { ".html": "text/html", ".json": "application/json", ".mp4": "video/mp4", ".png": "image/png" };
5+http.createServer((req, res) => {
6+ let p = decodeURIComponent((req.url || "/").split("?")[0]);
7+ if (p === "/" || p === "") p = "/index.html";
8+ const fp = path.normalize(path.join(root, p));
9+ if (!fp.startsWith(root)) { res.writeHead(403); return res.end(); }
10+ let st; try { st = fs.statSync(fp); } catch { res.writeHead(404); return res.end("not found"); }
11+ const type = TYPES[path.extname(fp)] || "application/octet-stream";
12+ const range = req.headers.range;
13+ if (range) { // range support so <video> seeks/streams
14+ const m = /bytes=(\d+)-(\d*)/.exec(range); const start = +m[1]; const end = m[2] ? +m[2] : st.size - 1;
15+ res.writeHead(206, { "content-type": type, "accept-ranges": "bytes", "content-range": `bytes ${start}-${end}/${st.size}`, "content-length": end - start + 1 });
16+ fs.createReadStream(fp, { start, end }).pipe(res);
17+ } else {
18+ res.writeHead(200, { "content-type": type, "accept-ranges": "bytes", "content-length": st.size });
19+ fs.createReadStream(fp).pipe(res);
20+ }
21+}).listen(8901, "127.0.0.1", () => console.log("testkit runs on :8901"));
testkit/src/consent-browser.mjs+45−0
@@ -0,0 +1,45 @@
1+// Browser consent: drive the real OAuth consent in a browser and record it —
2+// the Playwright "real hands" part. Returns { code, videoPath }.
3+export function browserConsent({ appBaseUrl, email, password, videoDir, headless = true, log = () => {} }) {
4+ return async ({ authorizationUrl, redirectUrl }) => {
5+ const { chromium } = await import("playwright");
6+ const browser = await chromium.launch({ headless });
7+ const context = await browser.newContext({
8+ viewport: { width: 1100, height: 760 },
9+ recordVideo: videoDir ? { dir: videoDir, size: { width: 1100, height: 760 } } : undefined,
10+ });
11+ const page = await context.newPage();
12+ let code = null;
13+ const cbBase = redirectUrl.split("?")[0];
14+ await context.route(cbBase + "**", (route) => {
15+ try { code = new URL(route.request().url()).searchParams.get("code"); } catch {}
16+ log("callback intercepted, code?", !!code);
17+ route.fulfill({ status: 200, contentType: "text/html",
18+ body: "<html><body style='font:600 30px system-ui;display:grid;place-items:center;height:100vh;margin:0;color:#15803d;background:#f0fdf4'>✓ Connected to MCP</body></html>" });
19+ });
20+ try {
21+ log("goto authorize");
22+ await page.goto(authorizationUrl, { waitUntil: "domcontentloaded" });
23+ log("wait email field");
24+ await page.waitForSelector("input[type=email]", { timeout: 10000 });
25+ await page.waitForTimeout(400);
26+ await page.fill("input[type=email]", email);
27+ await page.fill("input[type=password]", password);
28+ await page.waitForTimeout(300);
29+ log("click sign in");
30+ await page.click("button[type=submit]");
31+ log("wait for callback url");
32+ await page.waitForURL(cbBase + "**", { timeout: 25000 });
33+ log("on callback, code captured?", !!code);
34+ await page.waitForTimeout(1400); // show the "Connected" screen in the clip
35+ } finally {
36+ const videoPath = videoDir ? await page.video()?.path().catch(() => null) : null;
37+ await context.close();
38+ await browser.close();
39+ this_video = videoPath;
40+ if (!code) throw new Error("browserConsent: no authorization code captured");
41+ return { code, videoPath };
42+ }
43+ };
44+}
45+let this_video;
testkit/src/driver.mjs+87−0
@@ -0,0 +1,87 @@
1+import { mkdtempSync, writeFileSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+import { createRuntime } from "../../vendor/mcporter/dist/index.js";
5+
6+class AssertionError extends Error {}
7+const textOf = (res) =>
8+ Array.isArray(res?.content)
9+ ? res.content.filter((p) => p.type === "text").map((p) => p.text).join("\n")
10+ : typeof res === "string" ? res : JSON.stringify(res);
11+
12+// `mcp` handle passed to a scripted test. Every call is recorded as a chat turn;
13+// connect + headless OAuth (the target's consentStrategy) happen lazily on first use.
14+export class McpDriver {
15+ constructor(target, recorder) {
16+ this.target = target;
17+ this.rec = recorder;
18+ // Wrap the target's consent so the OAuth handshake is recorded as turns.
19+ const consent = target.consent;
20+ this._opts = {
21+ autoAuthorize: true,
22+ oauthSessionOptions: {
23+ consentStrategy: async (req) => {
24+ this.rec.turn({ role: "auth", phase: "authorize", text: "OAuth required → client registered (DCR), authorizing", detail: { authorizationUrl: req.authorizationUrl } });
25+ const out = await consent(req);
26+ this.rec.turn({ role: "auth", phase: "code", text: "Signed in & consented → authorization code received", ok: true });
27+ return out;
28+ },
29+ },
30+ };
31+ }
32+ async _runtime() {
33+ if (this._rt) return this._rt;
34+ const dir = mkdtempSync(join(tmpdir(), "mcp-testkit-"));
35+ const cfg = { mcpServers: { [this.target.name]: { url: this.target.server } } };
36+ writeFileSync(join(dir, "mcporter.json"), JSON.stringify(cfg));
37+ this._rt = await createRuntime({ configPath: join(dir, "mcporter.json") });
38+ return this._rt;
39+ }
40+ // Connect once, recording the OAuth lifecycle: connect → (authorize → code) → connected.
41+ // The tools/list call triggers mcporter's auth flow; the consent wrap records the middle.
42+ async _connect() {
43+ if (this._connected) return;
44+ this.rec.turn({ role: "auth", phase: "connect", text: `Connecting to ${this.target.server}` });
45+ this._toolDefs = await (await this._runtime()).listTools(this.target.name, this._opts);
46+ this.rec.turn({ role: "auth", phase: "connected", text: "Connected — access token acquired & cached for reuse", ok: true });
47+ this._connected = true;
48+ }
49+ say(text) { this.rec.say(text); }
50+ async listTools() {
51+ await this._connect();
52+ const names = this._toolDefs.map((t) => t.name);
53+ this.rec.turn({ role: "tool", call: { name: "tools/list", args: {} }, result: names, ok: true, text: names.join(", ") });
54+ return names;
55+ }
56+ async call(name, args = {}) {
57+ await this._connect();
58+ const rt = await this._runtime();
59+ const res = await rt.callTool(this.target.name, name, { args, ...this._opts });
60+ const text = textOf(res);
61+ this.rec.toolCall(name, args, res?.content ?? res, !res?.isError, text);
62+ return { raw: res, text, ok: !res?.isError };
63+ }
64+ async approvePaused(text, content = {}) {
65+ const match = /\bexecutionId:\s*(\S+)/.exec(text);
66+ if (!match) throw new AssertionError("approvePaused: executionId not found");
67+ return await this.call("resume", {
68+ executionId: match[1],
69+ action: "accept",
70+ content: JSON.stringify(content),
71+ });
72+ }
73+ // assertions record a turn (pass or fail) and throw on failure to fail the test
74+ expect(actual) {
75+ const rec = this.rec;
76+ const check = (kind, ok, expected) => {
77+ rec.assert({ kind, actual, expected, ok });
78+ if (!ok) throw new AssertionError(`expect(${JSON.stringify(actual)}).${kind}(${JSON.stringify(expected)})`);
79+ };
80+ return {
81+ toBe: (e) => check("toBe", actual === e, e),
82+ toContain: (e) => check("toContain", (Array.isArray(actual) ? actual.includes(e) : String(actual).includes(e)), e),
83+ toMatch: (re) => check("toMatch", re.test(String(actual)), String(re)),
84+ toBeGreaterThan: (e) => check("toBeGreaterThan", actual > e, e),
85+ };
86+ }
87+}
testkit/src/player.template.html+47−0
@@ -0,0 +1,47 @@
1+<!doctype html><meta charset=utf8><title>MCP run</title><style>
2+ body{font:15px/1.55 ui-sans-serif,system-ui;max-width:760px;margin:0 auto;padding:24px;color:#d7dce5;background:#0b0f17}
3+ #banner{font-weight:700;font-size:14px;margin-bottom:14px;color:#8b98a9}
4+ #banner.ok{color:#7ee787}#banner.no{color:#ff7b72}
5+ .row{opacity:0;transform:translateY(6px);transition:opacity .25s,transform .25s;margin:6px 0}
6+ .row.in{opacity:1;transform:none}
7+ .u{background:#1f6feb;color:#fff;padding:.55rem .8rem;border-radius:12px;font-weight:600}
8+ .a{color:#8b98a9;padding:.3rem .8rem;border-left:2px solid #30363d}
9+ .au{background:#1c1606;border:1px solid #5c4813;color:#e3b341;padding:.4rem .7rem;border-radius:10px;font-size:13.5px}
10+ .t{background:#0f1620;border:1px solid #21262d;border-radius:10px;padding:.5rem .75rem}
11+ .t.run{border-color:#1f6feb}.t .g{color:#6b7785}.t .sp{color:#58a6ff;font-style:italic}
12+ .t pre{background:#010409;color:#c9d1d9;padding:.5rem;border-radius:6px;margin:.4rem 0 0;font-size:12.5px;overflow:auto}
13+ .x{padding:.2rem .8rem;font-size:13.5px}.x.ok{color:#7ee787}.x.no{color:#ff7b72;font-weight:700}
14+ .e{color:#ff7b72;font-weight:700;padding:.4rem .8rem}
15+ .cur{display:inline-block;width:7px;height:15px;background:#8b98a9;vertical-align:-2px;animation:b .8s steps(1) infinite}
16+ @keyframes b{50%{opacity:0}}
17+</style>
18+<div id="banner">● recording</div><div id="chat"></div>
19+<script>
20+const RUN = __RUN__;
21+const SPEED = 1;
22+const esc = s => String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
23+const chat = document.getElementById('chat');
24+const add = html => { const d=document.createElement('div'); d.innerHTML=html.trim(); const el=d.firstElementChild; el.classList.add('row'); chat.appendChild(el); requestAnimationFrame(()=>el.classList.add('in')); el.scrollIntoView({behavior:'smooth',block:'end'}); return el; };
25+const sleep = ms => new Promise(r=>setTimeout(r, ms/SPEED));
26+async function type(el, text){ for(let i=0;i<=text.length;i++){ el.innerHTML=esc(text.slice(0,i))+'<span class=cur></span>'; await sleep(16); } el.innerHTML=esc(text); }
27+(async () => {
28+ await sleep(300);
29+ for (const t of RUN.turns){
30+ if (t.role==='auth') continue; // OAuth is its own phase in the recording
31+ if (t.role==='user'){ add('<div class="u">'+esc(t.text)+'</div>'); await sleep(700); }
32+ else if (t.role==='assistant'){ const el=add('<div class="a"></div>'); await type(el, t.text); await sleep(250); }
33+ else if (t.role==='tool'){
34+ const el=add('<div class="t run">🔧 <b>'+esc(t.call.name)+'</b> <span class="sp">running…</span></div>');
35+ await sleep(750);
36+ el.classList.remove('run');
37+ el.innerHTML='🔧 <b>'+esc(t.call.name)+'</b> '+(t.ok?'✓':'✗')+' <span class="g">→ '+esc(String(t.text).slice(0,90))+'</span><pre>'+esc(JSON.stringify({args:t.call.args,result:t.result},null,2))+'</pre>';
38+ el.scrollIntoView({behavior:'smooth',block:'end'}); await sleep(550);
39+ }
40+ else if (t.role==='assert'){ add('<div class="x '+(t.ok?'ok':'no')+'">'+(t.ok?'✅':'❌')+' expect('+esc(JSON.stringify(t.actual))+').'+t.kind+'('+esc(JSON.stringify(t.expected))+')</div>'); await sleep(450); }
41+ else if (t.role==='error'){ add('<div class="e">💥 '+esc(t.text)+'</div>'); await sleep(500); }
42+ }
43+ await sleep(400);
44+ const b=document.getElementById('banner'); b.textContent=(RUN.ok?'✓ PASSED':'✗ FAILED')+' · '+RUN.meta.server; b.className=RUN.ok?'ok':'no';
45+ window.__done=true;
46+})();
47+</script>
testkit/src/recorder.mjs+30−0
@@ -0,0 +1,30 @@
1+// A Run is the recording: a chat transcript (turns) + asserts + outcome.
2+export class Recorder {
3+ constructor(task, meta = {}) {
4+ this.run = {
5+ task, brain: meta.brain ?? "scripted", meta,
6+ startedAt: Date.now(), turns: [], asserts: [], ok: true,
7+ };
8+ this.run.turns.push({ t: Date.now(), role: "user", text: task });
9+ }
10+ turn(t) { this.run.turns.push({ t: Date.now(), ...t }); }
11+ say(text) { this.turn({ role: "assistant", kind: "reasoning", text }); }
12+ toolCall(name, args, result, ok, text) {
13+ this.turn({ role: "tool", call: { name, args }, result, ok, text });
14+ }
15+ assert(a) {
16+ this.run.asserts.push(a);
17+ this.turn({ role: "assert", ...a });
18+ if (!a.ok) this.run.ok = false;
19+ }
20+ finish(ok, error) {
21+ if (!ok) this.run.ok = false;
22+ this.run.endedAt = Date.now();
23+ this.run.durationMs = this.run.endedAt - this.run.startedAt;
24+ if (error) {
25+ this.run.error = String(error?.message ?? error);
26+ this.turn({ role: "error", text: this.run.error });
27+ }
28+ return this.run;
29+ }
30+}
testkit/src/render.mjs+60−0
@@ -0,0 +1,60 @@
1+import { readFileSync, writeFileSync } from "node:fs";
2+
3+const C = { dim: "\x1b[2m", grn: "\x1b[32m", red: "\x1b[31m", cyn: "\x1b[36m", yel: "\x1b[33m", b: "\x1b[1m", r: "\x1b[0m" };
4+const icon = { user: "🧑", assistant: "🤖", tool: "🔧", assert: " ", error: "💥" };
5+
6+export function renderTerminal(run) {
7+ const L = [];
8+ L.push(`${C.b}${run.ok ? C.grn + "✓" : C.red + "✗"} ${run.task}${C.r} ${C.dim}(${run.durationMs}ms, brain=${run.brain})${C.r}`);
9+ for (const t of run.turns) {
10+ if (t.role === "user") L.push(`\n${icon.user} ${C.b}${t.text}${C.r}`);
11+ else if (t.role === "assistant") L.push(`${icon.assistant} ${C.dim}${t.text}${C.r}`);
12+ else if (t.role === "auth") L.push(`${t.phase === "connected" ? "🔓" : "🔐"} ${C.yel}${t.text}${C.r}`);
13+ else if (t.role === "tool") L.push(`${icon.tool} ${C.cyn}${t.call.name}${C.r}(${C.dim}${JSON.stringify(t.call.args)}${C.r}) ${t.ok ? C.grn + "✓" : C.red + "✗"}${C.r} ${C.dim}→ ${String(t.text).slice(0, 80)}${C.r}`);
14+ else if (t.role === "assert") L.push(`${t.ok ? C.grn + " ✅" : C.red + " ❌"} expect(${JSON.stringify(t.actual)}).${t.kind}(${JSON.stringify(t.expected)})${C.r}`);
15+ else if (t.role === "error") L.push(`${icon.error} ${C.red}${t.text}${C.r}`);
16+ }
17+ return L.join("\n");
18+}
19+
20+export function renderHtml(run) {
21+ const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
22+ const bubble = (t) => {
23+ if (t.role === "user") return `<div class="u"><b>${esc(t.text)}</b></div>`;
24+ if (t.role === "auth") return `<div class="au">${t.phase === "connected" ? "🔓" : "🔐"} ${esc(t.text)}</div>`;
25+ if (t.role === "assistant") return `<div class="a">${esc(t.text)}</div>`;
26+ if (t.role === "tool") return `<details class="t ${t.ok ? "ok" : "no"}"><summary>🔧 <b>${esc(t.call.name)}</b> ${t.ok ? "✓" : "✗"} <span class="g">→ ${esc(String(t.text).slice(0, 100))}</span></summary><pre>${esc(JSON.stringify({ args: t.call.args, result: t.result }, null, 2))}</pre></details>`;
27+ if (t.role === "assert") return `<div class="x ${t.ok ? "ok" : "no"}">${t.ok ? "✅" : "❌"} expect(${esc(JSON.stringify(t.actual))}).${t.kind}(${esc(JSON.stringify(t.expected))})</div>`;
28+ if (t.role === "error") return `<div class="e">💥 ${esc(t.text)}</div>`;
29+ return "";
30+ };
31+ return `<!doctype html><meta charset=utf8><title>${esc(run.task)}</title><style>
32+ body{font:14px/1.5 ui-sans-serif,system-ui;max-width:760px;margin:2rem auto;color:#111;background:#fafafa}
33+ h1{font-size:16px}.hdr{color:${run.ok ? "#15803d" : "#b91c1c"};font-weight:700;margin-bottom:1rem}
34+ .u{background:#111;color:#fff;padding:.5rem .75rem;border-radius:10px;margin:.5rem 0}
35+ .a{color:#555;padding:.25rem .75rem;margin:.25rem 0;border-left:2px solid #ddd}
36+ .au{background:#fffbeb;border:1px solid #fde68a;color:#92400e;padding:.35rem .6rem;border-radius:8px;margin:.3rem 0;font-size:13px}
37+ .t{background:#fff;border:1px solid #e5e5e5;border-radius:8px;padding:.4rem .6rem;margin:.4rem 0}
38+ .t.no{border-color:#fca5a5}.t summary{cursor:pointer}.t .g{color:#888;font-weight:400}
39+ .t pre{background:#f5f5f5;padding:.5rem;border-radius:6px;overflow:auto;font-size:12px}
40+ .x{padding:.2rem .75rem;font-size:13px}.x.ok{color:#15803d}.x.no{color:#b91c1c;font-weight:700}
41+ .e{color:#b91c1c;font-weight:700;padding:.5rem .75rem}</style>
42+ <h1>MCP run · <span class=hdr>${run.ok ? "PASSED" : "FAILED"}</span></h1>
43+ ${run.turns.map(bubble).join("\n")}
44+ <p style="color:#999;font-size:12px">brain=${run.brain} · ${run.durationMs}ms · ${esc(run.meta?.server ?? "")}</p>`;
45+}
46+
47+if (process.argv[1]?.endsWith("render.mjs")) {
48+ const file = process.argv[2];
49+ const run = JSON.parse(readFileSync(file, "utf8"));
50+ const htmlArg = process.argv.indexOf("--html");
51+ if (htmlArg > -1) { const out = process.argv[htmlArg + 1]; writeFileSync(out, renderHtml(run)); console.log("wrote", out); }
52+ else console.log(renderTerminal(run));
53+}
54+
55+import { dirname, join as _join } from "node:path";
56+import { fileURLToPath } from "node:url";
57+export function renderPlayer(run) {
58+ const tpl = readFileSync(_join(dirname(fileURLToPath(import.meta.url)), "player.template.html"), "utf8");
59+ return tpl.replace("__RUN__", JSON.stringify(run));
60+}
testkit/src/testkit.mjs+40−0
@@ -0,0 +1,40 @@
1+import { mkdirSync, writeFileSync } from "node:fs";
2+import { join, dirname } from "node:path";
3+import { fileURLToPath } from "node:url";
4+import { Recorder } from "./recorder.mjs";
5+import { McpDriver } from "./driver.mjs";
6+export { cookieConsentStrategy } from "../../vendor/mcporter/dist/index.js";
7+
8+const RUNS = join(dirname(fileURLToPath(import.meta.url)), "..", "runs");
9+
10+// A target = the per-host seam: which MCP server + how to consent (+ optional boot).
11+export function mcpTarget(cfg) {
12+ return { name: cfg.name ?? "target", server: cfg.server, consent: cfg.consent, boot: cfg.boot };
13+}
14+
15+const tests = [];
16+export function mcpTest(name, target, fn) { tests.push({ name, target, fn }); }
17+
18+const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
19+
20+export async function runTests({ outDir = RUNS } = {}) {
21+ mkdirSync(outDir, { recursive: true });
22+ const results = [];
23+ for (const t of tests) {
24+ // the test name IS the task / opening "user" turn — the MCP surface is a chat
25+ const rec = new Recorder(t.name, { server: t.target.server, brain: "scripted" });
26+ const mcp = new McpDriver(t.target, rec);
27+ let ok = true, error;
28+ try { await t.fn(mcp); }
29+ catch (e) { ok = false; error = e; }
30+ const run = rec.finish(ok, error);
31+ const file = join(outDir, slug(t.name) + ".run.json");
32+ writeFileSync(file, JSON.stringify(run, null, 2));
33+ results.push({ name: t.name, ok: run.ok, file });
34+ console.log(`${run.ok ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m"} ${t.name} → ${file.replace(process.cwd() + "/", "")}`);
35+ if (!run.ok && run.error) console.log(` ${run.error}`);
36+ }
37+ const failed = results.filter((r) => !r.ok).length;
38+ console.log(`\n${results.length - failed}/${results.length} passed`);
39+ return { results, failed };
40+}
testkit/tests/execute.test.mjs+25−0
@@ -0,0 +1,25 @@
1+import { mcpTarget, mcpTest, cookieConsentStrategy } from "../src/testkit.mjs";
2+
3+// the per-host seam: self-host dev + headless OAuth (no browser, runs in CI)
4+export const selfhost = mcpTarget({
5+ name: "selfhost",
6+ server: "http://localhost:5173/mcp",
7+ consent: cookieConsentStrategy({
8+ appBaseUrl: "http://localhost:5173",
9+ email: "admin@demo.test",
10+ password: "demo-password-12345",
11+ }),
12+});
13+
14+mcpTest("execute runs code in the sandbox and returns its value", selfhost, async (mcp) => {
15+ mcp.say("Confirm the sandbox evaluates a program and returns the value.");
16+ const tools = await mcp.listTools();
17+ mcp.expect(tools).toContain("execute");
18+ const r = await mcp.call("execute", { code: "return 6 * 7;" });
19+ mcp.expect(r.text).toBe("42");
20+});
21+
22+mcpTest("execute can use string operations", selfhost, async (mcp) => {
23+ const r = await mcp.call("execute", { code: "return 'mcp'.toUpperCase();" });
24+ mcp.expect(r.text).toBe("MCP");
25+});
testkit/tests/x-oauth-basic.test.mjs+83−0
@@ -0,0 +1,83 @@
1+import { mcpTest } from "../src/testkit.mjs";
2+import { selfhost } from "./execute.test.mjs";
3+
4+const uniqueId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
5+
6+mcpTest(
7+ "x emulator OAuth client-credentials clients authenticate with HTTP Basic",
8+ selfhost,
9+ async (mcp) => {
10+ const slug = uniqueId("x-oauth-basic");
11+ const instance = slug;
12+ const baseUrl = `https://emulators.dev/x/${instance}`;
13+
14+ mcp.say(
15+ "Register the X emulator OpenAPI integration, create a confidential OAuth client, and start a client-credentials connection through Executor itself.",
16+ );
17+
18+ const started = await mcp.call("execute", {
19+ code: `
20+const slug = ${JSON.stringify(slug)};
21+const baseUrl = ${JSON.stringify(baseUrl)};
22+const authorizationUrl = baseUrl + "/2/oauth2/authorize";
23+const tokenUrl = baseUrl + "/2/oauth2/token";
24+
25+const added = await tools.executor.openapi.addSpec({
26+ spec: { kind: "url", url: "https://x.emulators.dev/2/openapi.json" },
27+ slug,
28+ baseUrl,
29+ authenticationTemplate: [{
30+ slug: "oauth2",
31+ type: "oauth",
32+ authorizationUrl,
33+ tokenUrl,
34+ scopes: [],
35+ }],
36+});
37+
38+const client = await tools.executor.coreTools.oauth.clients.create({
39+ owner: "org",
40+ slug: slug + "-client",
41+ authorizationUrl,
42+ tokenUrl,
43+ grant: "client_credentials",
44+ clientId: "x-confidential-client",
45+ clientSecret: "x-confidential-secret",
46+});
47+
48+const connection = await tools.executor.coreTools.oauth.start({
49+ client: slug + "-client",
50+ clientOwner: "org",
51+ owner: "org",
52+ name: "machine",
53+ integration: slug,
54+ template: "oauth2",
55+});
56+
57+return JSON.stringify({ added, client, connection }, null, 2);
58+`,
59+ });
60+
61+ mcp.expect(started.text).toContain("Execution paused: Add an OpenAPI integration");
62+
63+ const resumed = await mcp.approvePaused(started.text);
64+ const ledger = await fetch(`${baseUrl}/_emulate/ledger?limit=20`).then((r) => r.json());
65+ const tokenRequest = ledger.entries.find(
66+ (entry) =>
67+ entry.path === "/2/oauth2/token" &&
68+ entry.request?.body?.grant_type === "client_credentials",
69+ );
70+
71+ if (!resumed.ok) {
72+ mcp.expect(Boolean(tokenRequest)).toBe(true);
73+ mcp.expect(Object.hasOwn(tokenRequest.request.body, "client_secret")).toBe(false);
74+ }
75+
76+ mcp.expect(resumed.ok).toBe(true);
77+ mcp.expect(resumed.text).toContain('"status": "connected"');
78+
79+ mcp.expect(Boolean(tokenRequest)).toBe(true);
80+ mcp.expect(tokenRequest.request.headers.authorization).toBe("[redacted]");
81+ mcp.expect(Object.hasOwn(tokenRequest.request.body, "client_secret")).toBe(false);
82+ },
83+);