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
109# Throwaway UX prototype (not part of the app)109# Throwaway UX prototype (not part of the app)
110ux-demo/110ux-demo/
111 111
112# testkit run outputs (superseded by e2e/runs, also generated)
113testkit/runs/
bun.lock+2−0
@@ -321,6 +321,8 @@ · 320 unmodified lines
321 "version": "0.0.0",321 "version": "0.0.0",
322 "dependencies": {322 "dependencies": {
323 "@executor-js/api": "workspace:*",323 "@executor-js/api": "workspace:*",
324 "@executor-js/plugin-graphql": "workspace:*",
325 "@executor-js/plugin-mcp": "workspace:*",
324 "@executor-js/plugin-openapi": "workspace:*",326 "@executor-js/plugin-openapi": "workspace:*",
325 "@executor-js/sdk": "workspace:*",327 "@executor-js/sdk": "workspace:*",
326 "@kitlangton/terminal-control": "^0.3.0",328 "@kitlangton/terminal-control": "^0.3.0",
e2e/package.json+2−0
@@ -13,6 +13,8 @@ · 12 unmodified lines
13 },13 },
14 "dependencies": {14 "dependencies": {
15 "@executor-js/api": "workspace:*",15 "@executor-js/api": "workspace:*",
16 "@executor-js/plugin-graphql": "workspace:*",
17 "@executor-js/plugin-mcp": "workspace:*",
16 "@executor-js/plugin-openapi": "workspace:*",18 "@executor-js/plugin-openapi": "workspace:*",
17 "@executor-js/sdk": "workspace:*",19 "@executor-js/sdk": "workspace:*",
18 "@kitlangton/terminal-control": "^0.3.0",20 "@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.
10import { randomBytes } from "node:crypto";
11
12import { expect } from "@effect/vitest";
13import { Effect } from "effect";
14import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
15
16import { scenario } from "../src/scenario";
17
18scenario(
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).
8import { randomBytes } from "node:crypto";
9
10import { expect } from "@effect/vitest";
11import { Effect } from "effect";
12import { composePluginApi } from "@executor-js/api/server";
13import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
14import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
15import { IntegrationSlug } from "@executor-js/sdk/shared";
16
17import { scenario } from "../src/scenario";
18
19const api = composePluginApi([mcpHttpPlugin(), graphqlHttpPlugin()] as const);
20
21const freshSlug = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`;
22
23// Registration never dials the endpoint, so a closed local port is fine.
24const MCP_ENDPOINT = "http://127.0.0.1:59998/mcp";
25
26scenario(
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
77scenario(
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
131scenario(
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
180scenario(
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.
9import { randomBytes } from "node:crypto";
10
11import { expect } from "@effect/vitest";
12import { Effect } from "effect";
13import { composePluginApi } from "@executor-js/api/server";
14import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
15import {
16 makeGreetingMcpServer,
17 serveMcpServer,
18 serveMcpServerWithOAuth,
19} from "@executor-js/plugin-mcp/testing";
20import { OAuthTestServer } from "@executor-js/sdk/testing";
21import { IntegrationSlug } from "@executor-js/sdk/shared";
22
23import { scenario } from "../src/scenario";
24
25const api = composePluginApi([mcpHttpPlugin()] as const);
26
27scenario(
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
78scenario(
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".
9import { randomBytes } from "node:crypto";
10
11import { expect } from "@effect/vitest";
12import { Effect } from "effect";
13import { composePluginApi } from "@executor-js/api/server";
14import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
15import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
16import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
17
18import { scenario } from "../src/scenario";
19
20const api = composePluginApi([mcpHttpPlugin()] as const);
21
22scenario(
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
91const 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
24 cwd: proc.cwd,24 cwd: proc.cwd,
25 env: { ...process.env, ...proc.env },25 env: { ...process.env, ...proc.env },
26 stdio: process.env.E2E_VERBOSE ? "inherit" : "ignore",26 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,
27 });31 });
28 child.on("exit", (code) => {32 child.on("exit", (code) => {
29 if (code !== 0 && code !== null && !tearingDown) {33 if (code !== 0 && code !== null && !tearingDown) {
@@ -32,12 +36,38 @@ export const bootProcesses = ( · 2 unmodified lines
32 });36 });
33 children.push(child);37 children.push(child);
34 }38 }
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
35 return {56 return {
36 teardown: async () => {57 teardown: async () => {
37 tearingDown = true;58 tearingDown = true;
38 for (const child of children) child.kill("SIGTERM");59 const allExited = Promise.all(children.map(exited));
39 await new Promise((resolve) => setTimeout(resolve, 500));60 for (const child of children) signalTree(child, "SIGTERM");
40 for (const child of children) if (child.exitCode === null) child.kill("SIGKILL");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 }
41 },71 },
42 };72 };
43};73};
e2e/setup/cloud.globalsetup.ts+13−1
@@ -3,6 +3,7 @@ · 2 unmodified lines
3// fully-stubbed instance (multi-user WorkOS stub, free-plan Autumn, no3// fully-stubbed instance (multi-user WorkOS stub, free-plan Autumn, no
4// network). Set E2E_CLOUD_URL to attach to an already-running instance4// network). Set E2E_CLOUD_URL to attach to an already-running instance
5// instead (e.g. while iterating on a scenario).5// instead (e.g. while iterating on a scenario).
6import { rmSync } from "node:fs";
6import { resolve } from "node:path";7import { resolve } from "node:path";
7import { fileURLToPath } from "node:url";8import { fileURLToPath } from "node:url";
8 9
@@ -17,8 +18,19 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 8 unmodified lines
17 return;18 return;
18 }19 }
19 20
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
20 const env = {28 const env = {
21 EXECUTOR_E2E_STUB: "1",29 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",
22 // Stub creds — never contacted; the stub layers replace the clients.34 // Stub creds — never contacted; the stub layers replace the clients.
23 WORKOS_API_KEY: "sk_e2e_stub",35 WORKOS_API_KEY: "sk_e2e_stub",
24 WORKOS_CLIENT_ID: "client_e2e_stub",36 WORKOS_CLIENT_ID: "client_e2e_stub",
@@ -33,7 +45,7 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 8 unmodified lines
33 MCP_RESOURCE_ORIGIN: CLOUD_BASE_URL,45 MCP_RESOURCE_ORIGIN: CLOUD_BASE_URL,
34 // Throwaway PGlite on its own port + dir so it never fights `bun dev`.46 // Throwaway PGlite on its own port + dir so it never fights `bun dev`.
35 DEV_DB_PORT: String(CLOUD_DB_PORT),47 DEV_DB_PORT: String(CLOUD_DB_PORT),
36 DEV_DB_PATH: resolve(cloudDir, ".e2e-stub-db"),48 DEV_DB_PATH: dbPath,
37 };49 };
38 50
39 const procs = bootProcesses(51 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
34 EXECUTOR_BOOTSTRAP_ADMIN_EMAIL: SELFHOST_ADMIN.email,34 EXECUTOR_BOOTSTRAP_ADMIN_EMAIL: SELFHOST_ADMIN.email,
35 EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD: SELFHOST_ADMIN.password,35 EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD: SELFHOST_ADMIN.password,
36 EXECUTOR_WEB_BASE_URL: SELFHOST_BASE_URL,36 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",
37 },41 },
38 },42 },
39 ],43 ],
packages/core/sdk/src/executor.ts+1−0
@@ -1808,6 +1808,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1807 unmodified lines
1808 integration: rowToIntegration(integrationRow),1808 integration: rowToIntegration(integrationRow),
1809 config: decodeJsonColumn(integrationRow.config),1809 config: decodeJsonColumn(integrationRow.config),
1810 connection: ref,1810 connection: ref,
1811 template: existingRow ? AuthTemplateSlug.make(existingRow.template) : null,
1811 getValue: () => resolveConnectionValueByRef(ref),1812 getValue: () => resolveConnectionValueByRef(ref),
1812 })1813 })
1813 .pipe(1814 .pipe(
packages/core/sdk/src/index.ts+1−0
@@ -79,6 +79,7 @@ export type { · 78 unmodified lines
79 IntegrationDisplayDescriptor,79 IntegrationDisplayDescriptor,
80 RegisterIntegrationInput,80 RegisterIntegrationInput,
81} from "./integration";81} from "./integration";
82export { freshCustomAuthSlug, mergeAuthTemplates } from "./integration";
82export type {83export type {
83 Connection,84 Connection,
84 ConnectionRef,85 ConnectionRef,
packages/core/sdk/src/integration.ts+41−0
@@ -92,6 +92,47 @@ export interface Integration { · 91 unmodified lines
92 * render auth / produce tools. Core treats it as an opaque JSON blob. */92 * render auth / produce tools. Core treats it as an opaque JSON blob. */
93export type IntegrationConfig = unknown;93export type IntegrationConfig = unknown;
94 94
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
103const shortId = (): string => Math.random().toString(36).slice(2, 8);
104
105export const freshCustomAuthSlug = (taken: ReadonlySet<string>): string => {
106 let candidate = `custom_${shortId()}`;
107 while (taken.has(candidate)) candidate = `custom_${shortId()}`;
108 return candidate;
109};
110
111export 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
95/** What a plugin's extension method passes to `ctx.core.integrations.register`.136/** What a plugin's extension method passes to `ctx.core.integrations.register`.
96 * The v2 analog of v1's `SourceInput`, minus the per-source tool list (tools are137 * The v2 analog of v1's `SourceInput`, minus the per-source tool list (tools are
97 * produced per-connection now). */138 * produced per-connection now). */
packages/core/sdk/src/plugin.ts+4−0
@@ -194,6 +194,10 @@ export interface ResolveToolsInput { · 193 unmodified lines
194 readonly config: IntegrationConfig;194 readonly config: IntegrationConfig;
195 /** The connection whose tools are being resolved. */195 /** The connection whose tools are being resolved. */
196 readonly connection: ConnectionRef;196 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;
197 /** Lazily resolve the connection's credential value via its provider — only201 /** Lazily resolve the connection's credential value via its provider — only
198 * the kinds that actually call out (mcp) pay for it. */202 * the kinds that actually call out (mcp) pay for it. */
199 readonly getValue: () => Effect.Effect<string | null, StorageFailure>;203 readonly getValue: () => Effect.Effect<string | null, StorageFailure>;
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+65−85
@@ -1,13 +1,7 @@
1import { useCallback, useMemo, useState } from "react";1import { useCallback, useMemo, useState } from "react";
2import { useAtomSet, useAtomValue } from "@effect/atom-react";2import { useAtomSet } from "@effect/atom-react";
3import { Link } from "@tanstack/react-router";
4import * as Exit from "effect/Exit";3import * as Exit from "effect/Exit";
5import * as Option from "effect/Option";
6import * as Predicate from "effect/Predicate";
7import * as Schema from "effect/Schema";
8import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
9 4
10import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms";
11import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";5import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
12import {6import {
13 integrationDisplayNameFromUrl,7 integrationDisplayNameFromUrl,
@@ -16,41 +10,35 @@ import { · 2 unmodified lines
16} from "@executor-js/react/plugins/integration-identity";10} from "@executor-js/react/plugins/integration-identity";
17import { Button } from "@executor-js/react/components/button";11import { Button } from "@executor-js/react/components/button";
18import {12import {
19 AuthTemplateEditor,13 AuthMethodListEditor,
20 type AuthTemplateEditorValue,14 useAuthMethodList,
21} from "@executor-js/react/components/auth-template-editor";15 type AuthMethodRow,
22import { FieldLabel } from "@executor-js/react/components/field";16 type AuthMethodSeed,
17} from "@executor-js/react/components/auth-method-list-editor";
23import { FloatActions } from "@executor-js/react/components/float-actions";18import { FloatActions } from "@executor-js/react/components/float-actions";
24import { Spinner } from "@executor-js/react/components/spinner";19import { Spinner } from "@executor-js/react/components/spinner";
20import {
21 addIntegrationErrorMessage,
22 FormErrorAlert,
23 SlugCollisionAlert,
24 useSlugAlreadyExists,
25} from "@executor-js/react/lib/integration-add";
25 26
26import { addGraphqlIntegrationOptimistic } from "./atoms";27import { addGraphqlIntegrationOptimistic } from "./atoms";
27import { GraphqlSourceFields } from "./GraphqlSourceFields";28import { GraphqlSourceFields } from "./GraphqlSourceFields";
28import { graphqlTemplatesFromPlacements } from "./auth-method-config";29import { graphqlTemplatesFromPlacements } from "./auth-method-config";
30import { GRAPHQL_APIKEY_TEMPLATE } from "./defaults";
31import type { AuthTemplate } from "../sdk/types";
29 32
30const ErrorMessage = Schema.Struct({ message: Schema.String });33// v2 GraphQL add flow: register the integration with its declared auth-method
31const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);34// LIST (the shared `AuthMethodListEditor` — GraphQL stays header/query apiKey;
32 35// OAuth is hidden), then route to the integration's detail hub. Connection
33const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>36// creation is no longer part of the add flow — accounts are added from the hub
34 Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {37// (P6: add without auth, connect later).
35 onNone: () => fallback,
36 onSome: ({ message }) => message,
37 });
38 38
39const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean =>39// GraphQL has no add-time detection, so the list starts empty (module constant
40 Option.match(Exit.findErrorOption(exit), {40// — a fresh [] every render would re-seed the list each render).
41 onNone: () => false,41const NO_SEEDS: readonly AuthMethodSeed[] = [];
42 onSome: Predicate.isTagged("IntegrationAlreadyExistsError"),
43 });
44
45const 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).
54 42
55export default function AddGraphqlSource(props: {43export default function AddGraphqlSource(props: {
56 onComplete: (slug?: string) => void;44 onComplete: (slug?: string) => void;
@@ -61,7 +49,7 @@ export default function AddGraphqlSource(props: { · 4 unmodified lines
61 const identity = useIntegrationIdentity({49 const identity = useIntegrationIdentity({
62 fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "",50 fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "",
63 });51 });
64 const [authValue, setAuthValue] = useState<AuthTemplateEditorValue>({ kind: "none" });52 const authMethodList = useAuthMethodList(NO_SEEDS);
65 const [adding, setAdding] = useState(false);53 const [adding, setAdding] = useState(false);
66 const [addError, setAddError] = useState<string | null>(null);54 const [addError, setAddError] = useState<string | null>(null);
67 55
@@ -69,10 +57,33 @@ export default function AddGraphqlSource(props: { · 1 unmodified line
69 mode: "promiseExit",57 mode: "promiseExit",
70 });58 });
71 59
72 // An apiKey method needs at least one named placement; `none` is always valid.60 // The templates to register: each apikey row emits one template per named
73 const apiKeyComplete =61 // placement (GraphQL templates carry one header/query slot each). The first
74 authValue.kind !== "apikey" ||62 // keeps the primary `apikey` slug (matching the prior single-method flow);
75 authValue.placements.some((placement) => placement.name.trim().length > 0);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 );
76 87
77 const resolvedSlug = useMemo(88 const resolvedSlug = useMemo(
78 () =>89 () =>
@@ -85,13 +96,7 @@ export default function AddGraphqlSource(props: { · 6 unmodified lines
85 // Pre-empt the API's `IntegrationAlreadyExistsError`: adding an integration96 // Pre-empt the API's `IntegrationAlreadyExistsError`: adding an integration
86 // whose slug already exists clobbers the existing one's connections/policies,97 // whose slug already exists clobbers the existing one's connections/policies,
87 // so the API blocks it. Surface that here from the tenant-scoped catalog list.98 // so the API blocks it. Surface that here from the tenant-scoped catalog list.
88 const integrationsResult = useAtomValue(integrationsOptimisticAtom);99 const slugAlreadyExists = useSlugAlreadyExists(resolvedSlug);
89 const slugAlreadyExists = useMemo(
90 () =>
91 AsyncResult.isSuccess(integrationsResult) &&
92 integrationsResult.value.some((integration) => String(integration.slug) === resolvedSlug),
93 [integrationsResult, resolvedSlug],
94 );
95 100
96 const canAdd = endpoint.trim().length > 0 && apiKeyComplete && !adding && !slugAlreadyExists;101 const canAdd = endpoint.trim().length > 0 && apiKeyComplete && !adding && !slugAlreadyExists;
97 102
@@ -108,24 +113,19 @@ export default function AddGraphqlSource(props: { · 10 unmodified lines
108 setAddError(null);113 setAddError(null);
109 const { trimmedEndpoint, slug, displayName } = sourceIdentity();114 const { trimmedEndpoint, slug, displayName } = sourceIdentity();
110 115
111 const authenticationTemplate =
112 authValue.kind === "apikey" ? graphqlTemplatesFromPlacements(authValue.placements) : [];
113
114 const integrationExit = await doAddIntegration({116 const integrationExit = await doAddIntegration({
115 payload: {117 payload: {
116 endpoint: trimmedEndpoint,118 endpoint: trimmedEndpoint,
117 slug,119 slug,
118 name: displayName,120 name: displayName,
119 ...(authenticationTemplate.length > 0 ? { authenticationTemplate } : {}),121 ...(authenticationTemplate.length > 0
122 ? { authenticationTemplate: [...authenticationTemplate] }
123 : {}),
120 },124 },
121 reactivityKeys: integrationWriteKeys,125 reactivityKeys: integrationWriteKeys,
122 });126 });
123 if (Exit.isFailure(integrationExit)) {127 if (Exit.isFailure(integrationExit)) {
124 setAddError(128 setAddError(addIntegrationErrorMessage(integrationExit, slug, "Failed to add source"));
125 isIntegrationAlreadyExistsExit(integrationExit)
126 ? integrationExistsMessage(slug)
127 : errorMessageFromExit(integrationExit, "Failed to add source"),
128 );
129 setAdding(false);129 setAdding(false);
130 return;130 return;
131 }131 }
@@ -140,36 +140,16 @@ export default function AddGraphqlSource(props: { · 8 unmodified lines
140 140
141 <GraphqlSourceFields endpoint={endpoint} onEndpointChange={setEndpoint} identity={identity} />141 <GraphqlSourceFields endpoint={endpoint} onEndpointChange={setEndpoint} identity={identity} />
142 142
143 <section className="space-y-2.5">143 <AuthMethodListEditor
144 <FieldLabel>How does this API authenticate?</FieldLabel>144 list={authMethodList}
145 <AuthTemplateEditor145 allowedKinds={["none", "apikey"]}
146 value={authValue}146 emptyHint="No authentication declared. Add a method, or add the source without auth and connect an account from the integration page later."
147 onChange={setAuthValue}147 footerHint="Every method here is registered with the source. Connect an account from the integration page after adding."
148 allowedKinds={["none", "apikey"]}148 />
149 />149
150 </section>150 {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSlug} />}
151 151
152 {slugAlreadyExists && !adding && (152 {addError && <FormErrorAlert message={addError} />}
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 )}
173 153
174 <FloatActions>154 <FloatActions>
175 <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}>155 <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
7 7
8import { AccountsSection } from "@executor-js/react/components/accounts-section";8import { AccountsSection } from "@executor-js/react/components/accounts-section";
9import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";9import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
10import type { CreateCustomMethod } from "@executor-js/react/components/add-custom-method-modal";
11import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";10import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
11import {
12 useCustomMethodActions,
13 type AuthMethodsCodec,
14 type ConfigureAuthMethods,
15} from "@executor-js/react/lib/custom-auth-methods";
12 16
13import { graphqlConfigAtom, graphqlConfigure } from "./atoms";17import { graphqlConfigAtom, graphqlConfigure } from "./atoms";
14import { authMethodsFromConfig, graphqlTemplatesFromPlacements } from "./auth-method-config";18import { authMethodsFromConfig, graphqlTemplatesFromPlacements } from "./auth-method-config";
@@ -47,45 +51,41 @@ export default function GraphqlAccountsPanel(props: { · 32 unmodified lines
47 [existingTemplate],51 [existingTemplate],
48 );52 );
49 53
50 // Add a custom apiKey method: build graphql `apiKey` templates from the54 // Custom-method create/remove: the shared skeleton (merge-append → diff out
51 // generic placements (slug omitted → backend backfills `custom_<id>`),55 // the created method; filter → replace) parameterized by the GraphQL codec.
52 // merge-append (the configure endpoint merges) and persist. Returns the56 // Stays plugin-side only where it touches the graphql `AuthTemplate` types.
53 // created `AuthMethod` so Add-account can select it immediately.57 const configure = useCallback<ConfigureAuthMethods<AuthTemplate>>(
54 const createCustomMethod = useCallback<CreateCustomMethod>(58 async (input) => {
55 async (input: { readonly label: string; readonly placements: readonly Placement[] }) => {
56 const templates = graphqlTemplatesFromPlacements(input.placements, "");
57 if (templates.length === 0) return null;
58 const exit = await doConfigure({59 const exit = await doConfigure({
59 params: { slug: String(slug) },60 params: { slug: String(slug) },
60 payload: { authenticationTemplate: templates },61 payload: {
62 authenticationTemplate: input.authenticationTemplate,
63 ...(input.mode ? { mode: input.mode } : {}),
64 },
61 reactivityKeys: integrationWriteKeys,65 reactivityKeys: integrationWriteKeys,
62 });66 });
63 if (Exit.isFailure(exit)) return null;67 return Exit.map(exit, (result) => result.authenticationTemplate);
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;
69 },68 },
70 [doConfigure, existingTemplate, slug],69 [doConfigure, slug],
71 );70 );
72 71
73 const removeCustomMethod = useCallback(72 const codec = useMemo<AuthMethodsCodec<AuthTemplate>>(
74 async (method: AuthMethod): Promise<boolean> => {73 () => ({
75 if (method.source !== "custom") return false;74 toAuthMethods: authMethodsFromConfig,
76 const next = existingTemplate.filter(75 // Slug blank → backend backfills `custom_<id>` per emitted template.
77 (template: AuthTemplate) => template.slug !== String(method.template),76 templatesFromPlacements: (placements: readonly Placement[]) =>
78 );77 graphqlTemplatesFromPlacements(placements, ""),
79 const exit = await doConfigure({78 slugOf: (template: AuthTemplate) => template.slug,
80 params: { slug: String(slug) },79 }),
81 payload: { authenticationTemplate: next, mode: "replace" },80 [],
82 reactivityKeys: integrationWriteKeys,
83 });
84 return Exit.isSuccess(exit);
85 },
86 [doConfigure, existingTemplate, slug],
87 );81 );
88 82
83 const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({
84 existing: existingTemplate,
85 codec,
86 configure,
87 });
88
89 return (89 return (
90 <div className="mx-auto max-w-3xl space-y-8 px-6 py-8">90 <div className="mx-auto max-w-3xl space-y-8 px-6 py-8">
91 <AccountsSection91 <AccountsSection
packages/plugins/graphql/src/sdk/plugin.ts+14−47
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9 IntegrationAlreadyExistsError,9 IntegrationAlreadyExistsError,
10 IntegrationDetectionResult,10 IntegrationDetectionResult,
11 IntegrationSlug,11 IntegrationSlug,
12 mergeAuthTemplates,
12 ToolName,13 ToolName,
13 ToolResult,14 ToolResult,
14 type AuthMethodDescriptor,15 type AuthMethodDescriptor,
@@ -387,14 +388,17 @@ const toStoredOperations = ( · 372 unmodified lines
387const introspectHeadersForConnection = (388const introspectHeadersForConnection = (
388 config: GraphqlIntegrationConfig,389 config: GraphqlIntegrationConfig,
389 credentialValue: string | null,390 credentialValue: string | null,
391 templateSlug: AuthTemplateSlug | null,
390): RenderedAuth => {392): RenderedAuth => {
391 const headers: Record<string, string> = { ...(config.headers ?? {}) };393 const headers: Record<string, string> = { ...(config.headers ?? {}) };
392 const queryParams: Record<string, string> = { ...(config.queryParams ?? {}) };394 const queryParams: Record<string, string> = { ...(config.queryParams ?? {}) };
393 if (credentialValue !== null) {395 if (credentialValue !== null) {
394 // A connection references exactly one template; when several are declared we396 // Render the exact template the connection references; with no slug
395 // can't know which without the slug (not carried into `resolveTools`), so we397 // (connection row not yet persisted) fall back to the first declared.
396 // apply the first. Most integrations declare a single auth method.398 const template =
397 const template = config.authenticationTemplate[0];399 (templateSlug !== null
400 ? config.authenticationTemplate.find((t: AuthTemplate) => t.slug === String(templateSlug))
401 : undefined) ?? config.authenticationTemplate[0];
398 if (template) {402 if (template) {
399 const rendered = renderAuthTemplate(template, credentialValue);403 const rendered = renderAuthTemplate(template, credentialValue);
400 Object.assign(headers, rendered.headers);404 Object.assign(headers, rendered.headers);
@@ -410,12 +414,13 @@ const introspectHeadersForConnection = ( · 9 unmodified lines
410const introspectForConnection = (414const introspectForConnection = (
411 config: GraphqlIntegrationConfig,415 config: GraphqlIntegrationConfig,
412 credentialValue: string | null,416 credentialValue: string | null,
417 templateSlug: AuthTemplateSlug | null,
413 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,418 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
414): Effect.Effect<IntrospectionResult, GraphqlIntrospectionError> => {419): Effect.Effect<IntrospectionResult, GraphqlIntrospectionError> => {
415 if (config.introspectionJson) {420 if (config.introspectionJson) {
416 return parseIntrospectionJson(config.introspectionJson);421 return parseIntrospectionJson(config.introspectionJson);
417 }422 }
418 const auth = introspectHeadersForConnection(config, credentialValue);423 const auth = introspectHeadersForConnection(config, credentialValue, templateSlug);
419 return introspect(424 return introspect(
420 config.endpoint,425 config.endpoint,
421 Object.keys(auth.headers).length > 0 ? auth.headers : undefined,426 Object.keys(auth.headers).length > 0 ? auth.headers : undefined,
@@ -532,44 +537,6 @@ export const describeGraphqlIntegrationDisplay = ( · 110 unmodified lines
532 return { url: config?.endpoint };537 return { url: config?.endpoint };
533};538};
534 539
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
543const shortId = (): string => Math.random().toString(36).slice(2, 8);
544
545const freshCustomSlug = (taken: ReadonlySet<string>): string => {
546 let candidate = `custom_${shortId()}`;
547 while (taken.has(candidate)) candidate = `custom_${shortId()}`;
548 return candidate;
549};
550
551const 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
573// ---------------------------------------------------------------------------540// ---------------------------------------------------------------------------
574// Plugin extension541// Plugin extension
575// ---------------------------------------------------------------------------542// ---------------------------------------------------------------------------
@@ -735,10 +702,7 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 159 unmodified lines
735 const merged =702 const merged =
736 input.mode === "replace"703 input.mode === "replace"
737 ? input.authenticationTemplate704 ? input.authenticationTemplate
738 : mergeGraphqlAuthTemplate(705 : mergeAuthTemplates(current.authenticationTemplate, input.authenticationTemplate);
739 current.authenticationTemplate,
740 input.authenticationTemplate,
741 );
742 706
743 const next = GraphqlIntegrationConfig.make({707 const next = GraphqlIntegrationConfig.make({
744 ...current,708 ...current,
@@ -884,9 +848,11 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 139 unmodified lines
884 // -----------------------------------------------------------------------848 // -----------------------------------------------------------------------
885 resolveTools: ({849 resolveTools: ({
886 config,850 config,
851 template,
887 getValue,852 getValue,
888 }: {853 }: {
889 readonly config: IntegrationConfig;854 readonly config: IntegrationConfig;
855 readonly template: AuthTemplateSlug | null;
890 readonly getValue: () => Effect.Effect<string | null, unknown>;856 readonly getValue: () => Effect.Effect<string | null, unknown>;
891 }) =>857 }) =>
892 Effect.gen(function* () {858 Effect.gen(function* () {
@@ -902,6 +868,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 9 unmodified lines
902 const introspection = yield* introspectForConnection(868 const introspection = yield* introspectForConnection(
903 graphqlConfig,869 graphqlConfig,
904 credentialValue,870 credentialValue,
871 template,
905 options?.httpClientLayer ?? httpClientLayerFallback,872 options?.httpClientLayer ?? httpClientLayerFallback,
906 ).pipe(Effect.option);873 ).pipe(Effect.option);
907 if (Option.isNone(introspection)) return { tools: [] };874 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
107 integration: IntegrationSlug.make("example"),107 integration: IntegrationSlug.make("example"),
108 name: ConnectionName.make("default"),108 name: ConnectionName.make("default"),
109 },109 },
110 template: null,
110 getValue: () => Effect.succeed(null),111 getValue: () => Effect.succeed(null),
111 };112 };
112 const result = yield* plugin.resolveTools!(input);113 const result = yield* plugin.resolveTools!(input);
packages/plugins/mcp/src/api/group.ts+30−1
@@ -7,7 +7,12 @@ import { · 6 unmodified lines
7} from "@executor-js/sdk/shared";7} from "@executor-js/sdk/shared";
8 8
9import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors";9import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors";
10import { McpAuthTemplate, McpIntegrationConfig } from "../sdk/types";10import {
11 McpAuthMethod,
12 McpAuthMethodInput,
13 McpAuthTemplate,
14 McpIntegrationConfig,
15} from "../sdk/types";
11 16
12// ---------------------------------------------------------------------------17// ---------------------------------------------------------------------------
13// Params18// Params
@@ -31,6 +36,10 @@ const AddRemoteServerPayload = Schema.Struct({ · 17 unmodified lines
31 slug: Schema.optional(Schema.String),36 slug: Schema.optional(Schema.String),
32 queryParams: Schema.optional(StringMap),37 queryParams: Schema.optional(StringMap),
33 headers: Schema.optional(StringMap),38 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. */
34 auth: Schema.optional(McpAuthTemplate),43 auth: Schema.optional(McpAuthTemplate),
35});44});
36 45
@@ -83,6 +92,18 @@ const ConfigureServerResponse = Schema.Struct({ · 46 unmodified lines
83 config: McpIntegrationConfig,92 config: McpIntegrationConfig,
84});93});
85 94
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.
98const ConfigureAuthPayload = Schema.Struct({
99 authenticationTemplate: Schema.Array(McpAuthMethodInput),
100 mode: Schema.optional(Schema.Literals(["merge", "replace"])),
101});
102
103const ConfigureAuthResponse = Schema.Struct({
104 authenticationTemplate: Schema.Array(McpAuthMethod),
105});
106
86const GetServerResponse = Schema.NullOr(107const GetServerResponse = Schema.NullOr(
87 Schema.Struct({108 Schema.Struct({
88 slug: IntegrationSlug,109 slug: IntegrationSlug,
@@ -143,4 +164,12 @@ export const McpGroup = HttpApiGroup.make("mcp") · 54 unmodified lines
143 success: ConfigureServerResponse,164 success: ConfigureServerResponse,
144 error: [InternalError, McpConnectionError, McpToolDiscoveryError],165 error: [InternalError, McpConnectionError, McpToolDiscoveryError],
145 }),166 }),
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 }),
146 );175 );
packages/plugins/mcp/src/api/handlers.test.ts+1−0
@@ -28,6 +28,7 @@ const failingExtension: McpPluginExtension = { · 27 unmodified lines
28 removeServer: () => unused,28 removeServer: () => unused,
29 getServer: () => Effect.succeed(null),29 getServer: () => Effect.succeed(null),
30 configureServer: () => unused,30 configureServer: () => unused,
31 configureAuth: () => unused,
31};32};
32 33
33const Api = addGroup(McpGroup);34const Api = addGroup(McpGroup);
packages/plugins/mcp/src/api/handlers.ts+16−0
@@ -58,6 +58,9 @@ const toServerInput = ( · 57 unmodified lines
58 queryParams?: Record<string, string>;58 queryParams?: Record<string, string>;
59 headers?: Record<string, string>;59 headers?: Record<string, string>;
60 slug?: string;60 slug?: string;
61 authenticationTemplate?: McpServerInput extends { authenticationTemplate?: infer T }
62 ? T
63 : never;
61 auth?: McpServerInput extends { auth?: infer A } ? A : never;64 auth?: McpServerInput extends { auth?: infer A } ? A : never;
62 };65 };
63 66
@@ -69,6 +72,7 @@ const toServerInput = ( · 5 unmodified lines
69 queryParams: p.queryParams,72 queryParams: p.queryParams,
70 headers: p.headers,73 headers: p.headers,
71 slug: p.slug,74 slug: p.slug,
75 authenticationTemplate: p.authenticationTemplate,
72 auth: p.auth,76 auth: p.auth,
73 };77 };
74};78};
@@ -133,5 +137,17 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 58 unmodified lines
133 return { config: payload.config };137 return { config: payload.config };
134 }),138 }),
135 ),139 ),
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 ),
136 ),152 ),
137);153);
packages/plugins/mcp/src/react/AddMcpSource.tsx+80−132
@@ -1,26 +1,20 @@
1import { useReducer, useCallback, useEffect, useMemo, useRef, useState } from "react";1import { useReducer, useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { useAtomSet, useAtomValue } from "@effect/atom-react";2import { useAtomSet } from "@effect/atom-react";
3import { Link } from "@tanstack/react-router";
4import * as Exit from "effect/Exit";3import * as Exit from "effect/Exit";
5import * as Match from "effect/Match";4import * as Match from "effect/Match";
6import * as Option from "effect/Option";
7import * as Predicate from "effect/Predicate";
8import * as Schema from "effect/Schema";
9import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
10 5
11import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms";
12import { Button } from "@executor-js/react/components/button";6import { Button } from "@executor-js/react/components/button";
13import {7import {
14 AuthTemplateEditor,8 AuthMethodListEditor,
15 type AuthTemplateEditorKind,9 useAuthMethodList,
16 type AuthTemplateEditorValue,10 type AuthMethodRow,
17} from "@executor-js/react/components/auth-template-editor";11 type AuthMethodSeed,
12} from "@executor-js/react/components/auth-method-list-editor";
18import {13import {
19 CardStack,14 CardStack,
20 CardStackContent,15 CardStackContent,
21 CardStackEntryField,16 CardStackEntryField,
22} from "@executor-js/react/components/card-stack";17} from "@executor-js/react/components/card-stack";
23import { FieldLabel } from "@executor-js/react/components/field";
24import { FloatActions } from "@executor-js/react/components/float-actions";18import { FloatActions } from "@executor-js/react/components/float-actions";
25import { Input } from "@executor-js/react/components/input";19import { Input } from "@executor-js/react/components/input";
26import { Spinner } from "@executor-js/react/components/spinner";20import { Spinner } from "@executor-js/react/components/spinner";
@@ -31,20 +25,29 @@ import { · 4 unmodified lines
31 IntegrationIdentityFields,25 IntegrationIdentityFields,
32 useIntegrationIdentity,26 useIntegrationIdentity,
33} from "@executor-js/react/plugins/integration-identity";27} from "@executor-js/react/plugins/integration-identity";
28import {
29 addIntegrationErrorMessage,
30 errorMessageFromExit,
31 FormErrorAlert,
32 SlugCollisionAlert,
33 useSlugAlreadyExists,
34} from "@executor-js/react/lib/integration-add";
34 35
35import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";36import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
37import type { McpAuthMethodInput } from "../sdk/types";
36import { probeMcpEndpoint, addMcpServer } from "./atoms";38import { probeMcpEndpoint, addMcpServer } from "./atoms";
37import { McpRemoteSourceFields } from "./McpRemoteSourceFields";39import { McpRemoteSourceFields } from "./McpRemoteSourceFields";
38import { mcpAuthTemplateFromEditorValue } from "./auth-method-config";40import { mcpAuthMethodInputFromEditorValue } from "./auth-method-config";
39import { mcpPresets, type McpPreset } from "../sdk/presets";41import { mcpPresets, type McpPreset } from "../sdk/presets";
40 42
41// Post-redesign the remote add flow only REGISTERS the auth template through the43// The remote add flow REGISTERS the server's declared auth methods through the
42// shared `AuthTemplateEditor` — accounts (the API key value / OAuth sign-in) are44// shared `AuthMethodListEditor` — accounts (the API key value / OAuth sign-in)
43// added later from the integration's detail hub (P6: add without auth, connect45// are added later from the integration's detail hub (P6: add without auth,
44// later). An OAuth-only server (DCR-capable) is constrained to the OAuth tab.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).
45 50
46const ErrorMessage = Schema.Struct({ message: Schema.String });
47const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
48const STDIO_ENV_ESCAPE_REPLACEMENTS: Readonly<Record<string, string>> = {51const STDIO_ENV_ESCAPE_REPLACEMENTS: Readonly<Record<string, string>> = {
49 "\\": "\\",52 "\\": "\\",
50 n: "\n",53 n: "\n",
@@ -53,21 +56,6 @@ const STDIO_ENV_ESCAPE_REPLACEMENTS: Readonly<Record<string, string>> = { · 2 unmodified lines
53 '"': '"',56 '"': '"',
54};57};
55 58
56const 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
62const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean =>
63 Option.match(Exit.findErrorOption(exit), {
64 onNone: () => false,
65 onSome: Predicate.isTagged("IntegrationAlreadyExistsError"),
66 });
67
68const integrationExistsMessage = (slug: string): string =>
69 `An integration named "${slug}" already exists. To add more authentication, update your existing integration.`;
70
71// ---------------------------------------------------------------------------59// ---------------------------------------------------------------------------
72// Preset lookup60// Preset lookup
73// ---------------------------------------------------------------------------61// ---------------------------------------------------------------------------
@@ -212,18 +200,35 @@ export default function AddMcpSource(props: { · 138 unmodified lines
212 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });200 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });
213 const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });201 const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });
214 202
215 const [authValue, setAuthValue] = useState<AuthTemplateEditorValue>({ kind: "none" });
216
217 const probe = "probe" in state ? state.probe : null;203 const probe = "probe" in state ? state.probe : null;
218 204
219 // OAuth-only servers (DCR-capable) constrain the editor to the OAuth tab; all205 // The probe seeds the method list: detected OAuth → an OAuth row; a 401
220 // other servers offer none / API key / OAuth (matching the prior tab set).206 // without OAuth metadata → a bearer-header row; an open server → a no-auth
221 const allowedAuthKinds: readonly AuthTemplateEditorKind[] =207 // row. The user can edit any row or add alternate methods alongside.
222 probe?.requiresOAuth && probe.supportsDynamicRegistration208 const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(() => {
223 ? ["oauth"]209 if (!probe) return [];
224 : probe?.requiresAuthentication210 if (probe.requiresOAuth) {
225 ? ["apikey", "oauth"]211 return [
226 : ["none", "apikey", "oauth"];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);
227 232
228 const remoteIdentity = useIntegrationIdentity({233 const remoteIdentity = useIntegrationIdentity({
229 fallbackName:234 fallbackName:
@@ -237,18 +242,10 @@ export default function AddMcpSource(props: { · 7 unmodified lines
237 // so the API blocks it. Surface that here from the tenant-scoped catalog list.242 // so the API blocks it. Surface that here from the tenant-scoped catalog list.
238 // A blank derived namespace lets the server assign the slug, so only flag a243 // A blank derived namespace lets the server assign the slug, so only flag a
239 // collision when the user-derived slug is non-empty.244 // 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 );
248 const remoteSlug = slugifyNamespace(remoteIdentity.namespace);245 const remoteSlug = slugifyNamespace(remoteIdentity.namespace);
249 const stdioSlug = slugifyNamespace(stdioIdentity.namespace);246 const stdioSlug = slugifyNamespace(stdioIdentity.namespace);
250 const remoteSlugExists = remoteSlug.length > 0 && existingSlugs.includes(remoteSlug);247 const remoteSlugExists = useSlugAlreadyExists(remoteSlug);
251 const stdioSlugExists = stdioSlug.length > 0 && existingSlugs.includes(stdioSlug);248 const stdioSlugExists = useSlugAlreadyExists(stdioSlug);
252 249
253 const canAdd = Boolean(probe) && !isAdding && !remoteSlugExists;250 const canAdd = Boolean(probe) && !isAdding && !remoteSlugExists;
254 // Probe failures are shown inline on the URL field; other failures251 // Probe failures are shown inline on the URL field; other failures
@@ -270,16 +267,6 @@ export default function AddMcpSource(props: { · 15 unmodified lines
270 });267 });
271 return;268 return;
272 }269 }
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 );
283 dispatch({ type: "probe-ok", probe: exit.value });270 dispatch({ type: "probe-ok", probe: exit.value });
284 }, [state.url, doProbe]);271 }, [state.url, doProbe]);
285 272
@@ -301,15 +288,10 @@ export default function AddMcpSource(props: { · 15 unmodified lines
301 return () => clearTimeout(handle);288 return () => clearTimeout(handle);
302 }, [transport, state.step, state.url]);289 }, [transport, state.step, state.url]);
303 290
304 // Register the integration with the chosen auth template, returning the291 // Register the integration with the declared auth methods, returning the
305 // assigned slug (or null on failure — an error is dispatched in that case).292 // assigned slug (or null on failure — an error is dispatched in that case).
306 const registerIntegration = useCallback(293 const registerIntegration = useCallback(
307 async (294 async (authenticationTemplate: readonly McpAuthMethodInput[]): Promise<string | null> => {
308 auth:
309 | { kind: "none" }
310 | { kind: "header"; headerName: string; prefix?: string }
311 | { kind: "oauth2" },
312 ): Promise<string | null> => {
313 const displayName = remoteIdentity.name.trim() || probe?.serverName || probe?.name || "MCP";295 const displayName = remoteIdentity.name.trim() || probe?.serverName || probe?.name || "MCP";
314 const slug = slugifyNamespace(remoteIdentity.namespace) || undefined;296 const slug = slugifyNamespace(remoteIdentity.namespace) || undefined;
315 const exit = await doAddServer({297 const exit = await doAddServer({
@@ -318,16 +300,14 @@ export default function AddMcpSource(props: { · 2 unmodified lines
318 name: displayName,300 name: displayName,
319 endpoint: state.url.trim(),301 endpoint: state.url.trim(),
320 ...(slug ? { slug } : {}),302 ...(slug ? { slug } : {}),
321 auth,303 authenticationTemplate,
322 },304 },
323 reactivityKeys: integrationWriteKeys,305 reactivityKeys: integrationWriteKeys,
324 });306 });
325 if (Exit.isFailure(exit)) {307 if (Exit.isFailure(exit)) {
326 dispatch({308 dispatch({
327 type: "add-fail",309 type: "add-fail",
328 error: isIntegrationAlreadyExistsExit(exit)310 error: addIntegrationErrorMessage(exit, slug ?? displayName, "Failed to add server"),
329 ? integrationExistsMessage(slug ?? displayName)
330 : errorMessageFromExit(exit, "Failed to add server"),
331 });311 });
332 return null;312 return null;
333 }313 }
@@ -339,10 +319,17 @@ export default function AddMcpSource(props: { · 5 unmodified lines
339 const handleAddRemote = useCallback(async () => {319 const handleAddRemote = useCallback(async () => {
340 if (!probe) return;320 if (!probe) return;
341 dispatch({ type: "add-start" });321 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 );
343 if (slug === null) return;330 if (slug === null) return;
344 props.onComplete(slug);331 props.onComplete(slug);
345 }, [probe, authValue, registerIntegration, props]);332 }, [probe, authMethodList.rows, registerIntegration, props]);
346 333
347 // ---- Stdio actions ----334 // ---- Stdio actions ----
348 335
@@ -406,11 +393,7 @@ export default function AddMcpSource(props: { · 57 unmodified lines
406 reactivityKeys: integrationWriteKeys,393 reactivityKeys: integrationWriteKeys,
407 });394 });
408 if (Exit.isFailure(exit)) {395 if (Exit.isFailure(exit)) {
409 setStdioError(396 setStdioError(addIntegrationErrorMessage(exit, slug ?? displayName, "Failed to add server"));
410 isIntegrationAlreadyExistsExit(exit)
411 ? integrationExistsMessage(slug ?? displayName)
412 : errorMessageFromExit(exit, "Failed to add server"),
413 );
414 setStdioAdding(false);397 setStdioAdding(false);
415 return;398 return;
416 }399 }
@@ -470,27 +453,24 @@ export default function AddMcpSource(props: { · 53 unmodified lines
470 onRetry={handleProbe}453 onRetry={handleProbe}
471 />454 />
472 455
473 {/* Authentication — declares the auth template to register through the456 {/* Authentication — declares the auth methods to register through the
474 shared editor. The credential itself (API key value / OAuth sign-in)457 shared list editor. The credentials themselves (API key value /
475 is added from the integration's detail hub after adding. */}458 OAuth sign-in) are added from the integration's detail hub after
459 adding. */}
476 {probe && (460 {probe && (
477 <section className="space-y-2.5">461 <AuthMethodListEditor
478 <FieldLabel>How does this server authenticate?</FieldLabel>462 list={authMethodList}
479 <AuthTemplateEditor463 title="How does this server authenticate?"
480 value={authValue}464 oauthMetadata="discovered"
481 onChange={setAuthValue}465 emptyHint="No methods declared. Add a method, or add the server without auth and connect from the integration page later."
482 allowedKinds={allowedAuthKinds}466 footerHint="Every method here is registered with the server. Connect an account from the integration page after adding."
483 oauthMetadata="discovered"467 />
484 />
485 </section>
486 )}468 )}
487 469
488 {/* Error (add server). Probe errors show inline on the field. */}470 {/* Error (add server). Probe errors show inline on the field. */}
489 {otherError && (471 {otherError && (
490 <div className="space-y-2">472 <div className="space-y-2">
491 <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">473 <FormErrorAlert message={otherError} />
492 <p className="text-[12px] text-destructive">{otherError}</p>
493 </div>
494 <Button474 <Button
495 type="button"475 type="button"
496 variant="outline"476 variant="outline"
@@ -503,21 +483,7 @@ export default function AddMcpSource(props: { · 6 unmodified lines
503 </div>483 </div>
504 )}484 )}
505 485
506 {remoteSlugExists && !isAdding && (486 {remoteSlugExists && !isAdding && <SlugCollisionAlert slug={remoteSlug} />}
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 )}
521 487
522 <FloatActions>488 <FloatActions>
523 <Button489 <Button
@@ -589,27 +555,9 @@ export default function AddMcpSource(props: { · 65 unmodified lines
589 <IntegrationIdentityFields identity={stdioIdentity} namePlaceholder="My MCP Server" />555 <IntegrationIdentityFields identity={stdioIdentity} namePlaceholder="My MCP Server" />
590 556
591 {/* Stdio error */}557 {/* Stdio error */}
592 {stdioError && (558 {stdioError && <FormErrorAlert message={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 )}
597 559
598 {stdioSlugExists && !stdioAdding && (560 {stdioSlugExists && !stdioAdding && <SlugCollisionAlert slug={stdioSlug} />}
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 )}
613 561
614 <FloatActions>562 <FloatActions>
615 <Button563 <Button
packages/plugins/mcp/src/react/EditMcpSource.tsx+88−234
@@ -1,24 +1,17 @@
1import { useCallback, useEffect, useMemo, useState } from "react";1import { useCallback, useMemo, useState } from "react";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";2import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import * as Exit from "effect/Exit";4import * as Exit from "effect/Exit";
5 5
6import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared";6import { IntegrationSlug } from "@executor-js/sdk/shared";
7import { createConnection } from "@executor-js/react/api/atoms";7import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
8import { connectionWriteKeys, integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
9import { connectionIdentifier } from "@executor-js/react/lib/connection-name";
10import {8import {
11 AuthTemplateEditor,9 AuthMethodListEditor,
12 type AuthTemplateEditorValue,10 useAuthMethodList,
13} from "@executor-js/react/components/auth-template-editor";11 type AuthMethodRow,
14import {12 type AuthMethodSeed,
15 CredentialControlField,13} from "@executor-js/react/components/auth-method-list-editor";
16 ConnectionOwnerUsageRow,
17 useConnectionOwner,
18} from "@executor-js/react/plugins/connection-owner";
19import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in";
20import { Button } from "@executor-js/react/components/button";14import { Button } from "@executor-js/react/components/button";
21import { AddAccountModal } from "@executor-js/react/components/add-account-modal";
22import {15import {
23 CardStack,16 CardStack,
24 CardStackContent,17 CardStackContent,
@@ -27,16 +20,15 @@ import { · 2 unmodified lines
27 CardStackEntryDescription,20 CardStackEntryDescription,
28 CardStackEntryTitle,21 CardStackEntryTitle,
29} from "@executor-js/react/components/card-stack";22} from "@executor-js/react/components/card-stack";
30import { Input } from "@executor-js/react/components/input";
31import { Badge } from "@executor-js/react/components/badge";23import { Badge } from "@executor-js/react/components/badge";
32import type { AuthMethod } from "@executor-js/react/lib/auth-placements";24import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
33
34import { configureMcpServer, mcpServerAtom } from "./atoms";
35import type { McpAuthTemplate, McpIntegrationConfig } from "../sdk/types";
36import { mcpAuthTemplateFromEditorValue } from "./auth-method-config";
37 25
38const HEADER_TEMPLATE = AuthTemplateSlug.make("header");26import { configureMcpAuth, mcpServerAtom } from "./atoms";
39const OAUTH_TEMPLATE = AuthTemplateSlug.make("oauth2");27import type { McpAuthMethod, McpAuthMethodInput, McpIntegrationConfig } from "../sdk/types";
28import {
29 editorValueFromMcpAuthMethod,
30 mcpAuthMethodInputFromEditorValue,
31} from "./auth-method-config";
40 32
41type McpServer = {33type McpServer = {
42 readonly slug: IntegrationSlug;34 readonly slug: IntegrationSlug;
@@ -47,34 +39,19 @@ type McpServer = { · 4 unmodified lines
47 readonly config: McpIntegrationConfig;39 readonly config: McpIntegrationConfig;
48};40};
49 41
50const authTemplateToEditorValue = (auth: McpRemoteConfig["auth"]): AuthTemplateEditorValue => {42type McpRemoteConfig = Extract<McpIntegrationConfig, { transport: "remote" }>;
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};
65 43
66const authTemplatesEqual = (left: McpAuthTemplate, right: McpAuthTemplate): boolean => {44const methodSeedLabel = (method: McpAuthMethod): string => {
67 if (left.kind !== right.kind) return false;45 if (method.kind === "oauth2") return "OAuth";
68 if (left.kind === "header" && right.kind === "header") {46 if (method.kind === "header") return `API key (${method.headerName})`;
69 return left.headerName === right.headerName && (left.prefix ?? "") === (right.prefix ?? "");47 return "No authentication";
70 }
71 return true;
72};48};
73 49
74// ---------------------------------------------------------------------------50// ---------------------------------------------------------------------------
75// Remote edit — v2: the integration's endpoint + auth template are part of its51// Remote edit — v2: the integration's endpoint is part of its identity
76// identity (opaque-to-core config). The editable surface is the connection: an52// (opaque-to-core config); the editable surface is the declared auth-method
77// API-key header value, or an OAuth sign-in, both owner-scoped.53// LIST, through the same shared editor as the add flow. Accounts (credentials)
54// are managed from the integration page's accounts hub.
78// ---------------------------------------------------------------------------55// ---------------------------------------------------------------------------
79 56
80function RemoteEdit(props: {57function RemoteEdit(props: {
@@ -82,115 +59,81 @@ function RemoteEdit(props: { · 1 unmodified line
82 onSave: () => void;59 onSave: () => void;
83}) {60}) {
84 const { server } = props;61 const { server } = props;
85 const auth = server.config.auth;62 const doConfigureAuth = useAtomSet(configureMcpAuth, { mode: "promiseExit" });
86 const { connectionOwner, setConnectionOwner, connectionOwnerOptions } = useConnectionOwner();
87 const doCreate = useAtomSet(createConnection, { mode: "promiseExit" });
88 const doConfigure = useAtomSet(configureMcpServer, { mode: "promiseExit" });
89 63
90 const [authValue, setAuthValue] = useState<AuthTemplateEditorValue>(() =>64 const seeds = useMemo<readonly AuthMethodSeed[]>(
91 authTemplateToEditorValue(auth),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],
92 );74 );
93 const [apiKey, setApiKey] = useState("");75 const list = useAuthMethodList(seeds);
76
94 const [saving, setSaving] = useState(false);77 const [saving, setSaving] = useState(false);
95 const [authSaving, setAuthSaving] = useState(false);
96 const [error, setError] = useState<string | null>(null);78 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);
102 79
103 useEffect(() => {80 // The edited methods, slugs preserved for seeded rows so existing
104 setAuthValue(authTemplateToEditorValue(auth));81 // connections (bound by template slug) stay attached. New rows omit the
105 }, [auth]);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 );
106 91
107 const handleSaveAuth = useCallback(async () => {92 const methodsChanged = useMemo(() => {
108 setAuthSaving(true);93 const stored = server.config.authenticationTemplate;
109 setError(null);94 if (editedMethods.length !== stored.length) return true;
110 const nextConfig: McpRemoteConfig = {95 return editedMethods.some((method: McpAuthMethodInput, index: number) => {
111 ...server.config,96 const current = stored[index];
112 auth: mcpAuthTemplateFromEditorValue(authValue),97 if (!current) return true;
113 };98 if ((method.slug ?? "") !== current.slug) return true;
114 const exit = await doConfigure({99 if (method.kind !== current.kind) return true;
115 params: { slug: server.slug },100 if (method.kind === "header" && current.kind === "header") {
116 payload: { config: nextConfig },101 return (
117 reactivityKeys: integrationWriteKeys,102 method.headerName !== current.headerName ||
103 (method.prefix ?? "") !== (current.prefix ?? "")
104 );
105 }
106 return false;
118 });107 });
119 if (Exit.isFailure(exit)) {108 }, [editedMethods, server.config.authenticationTemplate]);
120 setError("Failed to update authentication method");
121 setAuthSaving(false);
122 return;
123 }
124 setAuthSaving(false);
125 }, [authValue, doConfigure, server.config, server.slug]);
126 109
127 const handleSaveKey = useCallback(async () => {110 const handleSave = useCallback(async () => {
128 if (apiKey.trim() === "") return;
129 setSaving(true);111 setSaving(true);
130 setError(null);112 setError(null);
131 const exit = await doCreate({113 const exit = await doConfigureAuth({
114 params: { slug: server.slug },
132 payload: {115 payload: {
133 owner: connectionOwner,116 authenticationTemplate:
134 name: connectionIdentifier(`${server.slug} key`),117 editedMethods.length > 0 ? editedMethods : [{ kind: "none" as const }],
135 integration: server.slug,118 mode: "replace",
136 template: HEADER_TEMPLATE,
137 identityLabel: server.description || String(server.slug),
138 value: apiKey.trim(),
139 },119 },
140 reactivityKeys: connectionWriteKeys,120 reactivityKeys: integrationWriteKeys,
141 });121 });
142 if (Exit.isFailure(exit)) {122 if (Exit.isFailure(exit)) {
143 setError("Failed to save credential");123 setError("Failed to update authentication methods");
144 setSaving(false);124 setSaving(false);
145 return;125 return;
146 }126 }
147 setSaving(false);127 setSaving(false);
148 setConnected(true);128 }, [doConfigureAuth, editedMethods, server.slug]);
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 );
186 129
187 return (130 return (
188 <div className="space-y-6">131 <div className="space-y-6">
189 <div>132 <div>
190 <h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1>133 <h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1>
191 <p className="mt-1 text-sm text-muted-foreground">134 <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 —135 Manage how this MCP server authenticates. The endpoint is part of the server's identity —
193 remove and re-add to change it.136 remove and re-add to change it. Accounts are added from the integration page.
194 </p>137 </p>
195 </div>138 </div>
196 139
@@ -210,110 +153,23 @@ function RemoteEdit(props: { · 13 unmodified lines
210 </CardStackContent>153 </CardStackContent>
211 </CardStack>154 </CardStack>
212 155
213 <div className="space-y-3 rounded-lg border border-border bg-card p-4">156 <AuthMethodListEditor
214 <div>157 list={list}
215 <p className="text-sm font-medium text-card-foreground">Authentication method</p>158 title="How does this server authenticate?"
216 <p className="mt-0.5 text-xs text-muted-foreground">159 oauthMetadata="discovered"
217 Choose how a connection credential is sent to this MCP server.160 emptyHint="No methods declared. Add one, or save to mark this server as open (no authentication)."
218 </p>161 footerHint="Connections pick one of these methods. Removing a method detaches connections created against it."
219 </div>162 />
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>
239 163
240 {authChanged ? (164 {methodsChanged ? (
241 <div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">165 <div className="flex justify-end">
242 Save the authentication method before adding a connection.166 <Button type="button" size="sm" onClick={() => void handleSave()} disabled={saving}>
167 {saving ? "Saving…" : "Save authentication methods"}
168 </Button>
243 </div>169 </div>
244 ) : null}170 ) : null}
245 171
246 {!authChanged && auth.kind === "header" && (172 {error && <FormErrorAlert message={error} />}
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 )}
317 173
318 <div className="flex items-center justify-end border-t border-border pt-4">174 <div className="flex items-center justify-end border-t border-border pt-4">
319 <Button onClick={props.onSave}>Done</Button>175 <Button onClick={props.onSave}>Done</Button>
@@ -322,8 +178,6 @@ function RemoteEdit(props: { · 2 unmodified lines
322 );178 );
323}179}
324 180
325type McpRemoteConfig = Extract<McpIntegrationConfig, { transport: "remote" }>;
326
327// ---------------------------------------------------------------------------181// ---------------------------------------------------------------------------
328// Stdio read-only view182// Stdio read-only view
329// ---------------------------------------------------------------------------183// ---------------------------------------------------------------------------
packages/plugins/mcp/src/react/McpAccountsPanel.tsx+59−101
@@ -3,72 +3,33 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; · 2 unmodified lines
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import * as Exit from "effect/Exit";4import * as Exit from "effect/Exit";
5 5
6import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared";6import { IntegrationSlug } from "@executor-js/sdk/shared";
7import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";7import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";
8import { AccountsSection } from "@executor-js/react/components/accounts-section";8import { AccountsSection } from "@executor-js/react/components/accounts-section";
9import type { CreateCustomMethod } from "@executor-js/react/components/add-custom-method-modal";
10import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";9import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
10import {
11 useCustomMethodActions,
12 type AuthMethodsCodec,
13 type ConfigureAuthMethods,
14} from "@executor-js/react/lib/custom-auth-methods";
11import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";15import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
12 16
13import { configureMcpServer, mcpServerAtom } from "./atoms";17import { configureMcpAuth, mcpServerAtom } from "./atoms";
14import type { McpIntegrationConfig } from "../sdk/types";18import { authMethodsFromConfig, mcpAuthMethodInputsFromPlacements } from "./auth-method-config";
19import type { McpAuthMethod } from "../sdk/types";
15 20
16const NONE_TEMPLATE = AuthTemplateSlug.make("none");21// ---------------------------------------------------------------------------
17const HEADER_TEMPLATE = AuthTemplateSlug.make("header");22// MCP Accounts hub — fills the generic detail page's `accounts` slot.
18const OAUTH_TEMPLATE = AuthTemplateSlug.make("oauth2");23//
19 24// Reads the integration's declared `authenticationTemplate` (via `getServer`),
20const NO_AUTH_METHOD: AuthMethod = {25// converts it to generic `AuthMethod[]`, and composes the generic
21 id: "none",26// `AccountsSection` — whose Add-account offers those methods plus a "+ Custom
22 label: "No authentication",27// method" row. The custom-method create/remove is the shared skeleton
23 kind: "none",28// (`useCustomMethodActions`) parameterized by the MCP codec and the
24 source: "spec",29// merge-append `configureAuth` endpoint, so adding an API key method never
25 template: NONE_TEMPLATE,30// displaces a declared OAuth method. Stdio servers have no remote credential
26 placements: [],31// to configure — no methods, no custom-method affordance.
27};32// ---------------------------------------------------------------------------
28
29const 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
60const 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};
72 33
73export default function McpAccountsPanel(props: {34export default function McpAccountsPanel(props: {
74 readonly sourceId: string;35 readonly sourceId: string;
@@ -78,61 +39,58 @@ export default function McpAccountsPanel(props: { · 3 unmodified lines
78 const { sourceId, integrationName, accountHandoff } = props;39 const { sourceId, integrationName, accountHandoff } = props;
79 const slug = IntegrationSlug.make(sourceId);40 const slug = IntegrationSlug.make(sourceId);
80 const serverResult = useAtomValue(mcpServerAtom(slug));41 const serverResult = useAtomValue(mcpServerAtom(slug));
81 const doConfigure = useAtomSet(configureMcpServer, { mode: "promiseExit" });42 const doConfigureAuth = useAtomSet(configureMcpAuth, { mode: "promiseExit" });
82 43
83 const server = AsyncResult.isSuccess(serverResult) ? serverResult.value : null;44 const server = AsyncResult.isSuccess(serverResult) ? serverResult.value : null;
84 const config = server?.config ?? null;45 const config = server?.config ?? null;
85 const method = config ? authMethodFromConfig(config) : null;46 const remote = config !== null && config.transport === "remote" ? config : null;
86 const methods = method ? [method] : [];
87 47
88 const createCustomMethod = useCallback<CreateCustomMethod>(48 const existingTemplate = useMemo<readonly McpAuthMethod[]>(
89 async (input: { readonly label: string; readonly placements: readonly Placement[] }) => {49 () => remote?.authenticationTemplate ?? [],
90 if (config === null || config.transport !== "remote") return null;50 [remote],
91 const header = headerAuthFromPlacements(input.placements);51 );
92 if (header === null) return null;52
93 const nextConfig: McpIntegrationConfig = {53 const methods = useMemo<readonly AuthMethod[]>(
94 ...config,54 () => (remote ? authMethodsFromConfig(existingTemplate, remote.endpoint) : []),
95 auth: { kind: "header", ...header },55 [existingTemplate, remote],
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],
106 );56 );
107 57
108 const removeCustomMethod = useCallback(58 const configure = useCallback<ConfigureAuthMethods<McpAuthMethod>>(
109 async (methodToRemove: AuthMethod): Promise<boolean> => {59 async (input) => {
110 if (60 const exit = await doConfigureAuth({
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({
122 params: { slug },61 params: { slug },
123 payload: { config: nextConfig },62 payload: {
63 authenticationTemplate: input.authenticationTemplate,
64 ...(input.mode ? { mode: input.mode } : {}),
65 },
124 reactivityKeys: integrationWriteKeys,66 reactivityKeys: integrationWriteKeys,
125 });67 });
126 return Exit.isSuccess(exit);68 return Exit.map(exit, (result) => result.authenticationTemplate);
127 },69 },
128 [config, doConfigure, slug],70 [doConfigureAuth, slug],
129 );71 );
130 72
131 const canConfigureAuth = useMemo(73 const codec = useMemo<AuthMethodsCodec<McpAuthMethod>>(
132 () => config !== null && config.transport === "remote",74 () => ({
133 [config],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],
134 );84 );
135 85
86 const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({
87 existing: existingTemplate,
88 codec,
89 configure,
90 });
91
92 const canConfigureAuth = remote !== null;
93
136 return (94 return (
137 <div className="mx-auto max-w-3xl space-y-8 px-6 py-8">95 <div className="mx-auto max-w-3xl space-y-8 px-6 py-8">
138 <AccountsSection96 <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
14import type { AuthMethod } from "@executor-js/react/lib/auth-placements";14import type { AuthMethod } from "@executor-js/react/lib/auth-placements";
15 15
16import { mcpServerAtom } from "./atoms";16import { mcpServerAtom } from "./atoms";
17 17import type { McpAuthMethod } from "../sdk/types";
18const OAUTH_TEMPLATE = AuthTemplateSlug.make("oauth2");
19 18
20// ---------------------------------------------------------------------------19// ---------------------------------------------------------------------------
21// McpSignInButton — top-bar action on the integration detail page (v2).20// McpSignInButton — top-bar action on the integration detail page (v2).
22//21//
23// Reads the integration's auth template; for an `oauth2` server it runs the22// Reads the integration's declared auth methods; when one is `oauth2` it runs
24// OAuth flow to mint a connection. "Connected" is derived from whether ANY23// the OAuth flow to mint a connection through that method. "Connected" is
25// owner already has a connection for this integration (the global owner toggle24// derived from whether ANY owner already has a connection for this integration
26// is retired, so the check merges both owners). The NEW connection's owner is a25// (the global owner toggle is retired, so the check merges both owners). The
27// real create-target — chosen EXPLICITLY via the `owner` prop (default Workspace26// NEW connection's owner is a real create-target — chosen EXPLICITLY via the
28// `org` on an org-scoped host, Local `org` on a non-org host like local),27// `owner` prop (default Workspace `org` on an org-scoped host, Local `org` on
29// never read from an ambient owner.28// a non-org host like local), never read from an ambient owner.
30// ---------------------------------------------------------------------------29// ---------------------------------------------------------------------------
31 30
32export default function McpSignInButton(props: { sourceId: string; owner?: Owner }) {31export default function McpSignInButton(props: { sourceId: string; owner?: Owner }) {
@@ -38,7 +37,9 @@ export default function McpSignInButton(props: { sourceId: string; owner?: Owner · 5 unmodified lines
38 37
39 const server = AsyncResult.isSuccess(serverResult) ? serverResult.value : null;38 const server = AsyncResult.isSuccess(serverResult) ? serverResult.value : null;
40 const remote = server !== null && server.config.transport === "remote" ? server.config : null;39 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;
42 const connections: readonly Connection[] = AsyncResult.isSuccess(connectionsResult)43 const connections: readonly Connection[] = AsyncResult.isSuccess(connectionsResult)
43 ? connectionsResult.value44 ? connectionsResult.value
44 : [];45 : [];
@@ -48,35 +49,35 @@ export default function McpSignInButton(props: { sourceId: string; owner?: Owner · 3 unmodified lines
48 49
49 const methods = useMemo<readonly AuthMethod[]>(50 const methods = useMemo<readonly AuthMethod[]>(
50 () =>51 () =>
51 remote === null52 remote === null || oauthMethod === null
52 ? []53 ? []
53 : [54 : [
54 {55 {
55 id: "oauth2",56 id: oauthMethod.slug,
56 label: "OAuth",57 label: "OAuth",
57 kind: "oauth",58 kind: "oauth",
58 source: "spec",59 source: "spec",
59 template: OAUTH_TEMPLATE,60 template: AuthTemplateSlug.make(oauthMethod.slug),
60 placements: [],61 placements: [],
61 oauth: { discoveryUrl: remote.endpoint, supportsDynamicRegistration: true },62 oauth: { discoveryUrl: remote.endpoint, supportsDynamicRegistration: true },
62 },63 },
63 ],64 ],
64 [remote],65 [remote, oauthMethod],
65 );66 );
66 const initialState = useMemo(67 const initialState = useMemo(
67 () =>68 () =>
68 modalOpen && server69 modalOpen && server && oauthMethod
69 ? {70 ? {
70 key: `${String(slug)}:${targetOwner}:oauth`,71 key: `${String(slug)}:${targetOwner}:oauth`,
71 owner: targetOwner,72 owner: targetOwner,
72 template: String(OAUTH_TEMPLATE),73 template: oauthMethod.slug,
73 label: `${server.description || String(slug)} OAuth`,74 label: `${server.description || String(slug)} OAuth`,
74 }75 }
75 : null,76 : null,
76 [modalOpen, server, slug, targetOwner],77 [modalOpen, oauthMethod, server, slug, targetOwner],
77 );78 );
78 79
79 if (!isOAuth) return null;80 if (oauthMethod === null) return null;
80 81
81 return (82 return (
82 <>83 <>
packages/plugins/mcp/src/react/atoms.ts+2−0
@@ -26,3 +26,5 @@ export const probeMcpEndpoint = McpClient.mutation("mcp", "probeEndpoint"); · 25 unmodified lines
26export const addMcpServer = McpClient.mutation("mcp", "addServer");26export const addMcpServer = McpClient.mutation("mcp", "addServer");
27export const removeMcpServer = McpClient.mutation("mcp", "removeServer");27export const removeMcpServer = McpClient.mutation("mcp", "removeServer");
28export const configureMcpServer = McpClient.mutation("mcp", "configureServer");28export const configureMcpServer = McpClient.mutation("mcp", "configureServer");
29// Merge-append auth methods onto an integration's `authenticationTemplate`.
30export const configureMcpAuth = McpClient.mutation("mcp", "configureAuth");
packages/plugins/mcp/src/react/auth-method-config.test.ts+83−9
@@ -1,11 +1,16 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";2import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
3 3
4import { mcpAuthTemplateFromEditorValue } from "./auth-method-config";4import {
5 authMethodsFromConfig,
6 editorValueFromMcpAuthMethod,
7 mcpAuthMethodInputFromEditorValue,
8 mcpAuthMethodInputsFromPlacements,
9} from "./auth-method-config";
5 10
6describe("mcpAuthTemplateFromEditorValue", () => {11describe("mcpAuthMethodInputFromEditorValue", () => {
7 it("maps 'none' → { kind: 'none' }", () => {12 it("maps 'none' → { kind: 'none' }", () => {
8 expect(mcpAuthTemplateFromEditorValue({ kind: "none" })).toEqual({ kind: "none" });13 expect(mcpAuthMethodInputFromEditorValue({ kind: "none" })).toEqual({ kind: "none" });
9 });14 });
10 15
11 it("maps 'oauth' → { kind: 'oauth2' } (endpoints are resolved at connect time)", () => {16 it("maps 'oauth' → { kind: 'oauth2' } (endpoints are resolved at connect time)", () => {
@@ -15,15 +20,15 @@ describe("mcpAuthTemplateFromEditorValue", () => { · 3 unmodified lines
15 tokenUrl: "https://a.example.com/token",20 tokenUrl: "https://a.example.com/token",
16 scopes: ["mcp.read"],21 scopes: ["mcp.read"],
17 };22 };
18 expect(mcpAuthTemplateFromEditorValue(value)).toEqual({ kind: "oauth2" });23 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ kind: "oauth2" });
19 });24 });
20 25
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)", () => {
22 const value: AuthTemplateEditorValue = {27 const value: AuthTemplateEditorValue = {
23 kind: "apikey",28 kind: "apikey",
24 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],29 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
25 };30 };
26 expect(mcpAuthTemplateFromEditorValue(value)).toEqual({31 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({
27 kind: "header",32 kind: "header",
28 headerName: "Authorization",33 headerName: "Authorization",
29 prefix: "Bearer ",34 prefix: "Bearer ",
@@ -35,7 +40,7 @@ describe("mcpAuthTemplateFromEditorValue", () => { · 5 unmodified lines
35 kind: "apikey",40 kind: "apikey",
36 placements: [{ carrier: "header", name: "X-Token", prefix: "" }],41 placements: [{ carrier: "header", name: "X-Token", prefix: "" }],
37 };42 };
38 expect(mcpAuthTemplateFromEditorValue(value)).toEqual({43 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({
39 kind: "header",44 kind: "header",
40 headerName: "X-Token",45 headerName: "X-Token",
41 });46 });
@@ -46,7 +51,7 @@ describe("mcpAuthTemplateFromEditorValue", () => { · 4 unmodified lines
46 kind: "apikey",51 kind: "apikey",
47 placements: [{ carrier: "query", name: "api_key", prefix: "" }],52 placements: [{ carrier: "query", name: "api_key", prefix: "" }],
48 };53 };
49 expect(mcpAuthTemplateFromEditorValue(value)).toEqual({ kind: "none" });54 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ kind: "none" });
50 });55 });
51 56
52 it("uses the first NAMED header placement (skips unnamed)", () => {57 it("uses the first NAMED header placement (skips unnamed)", () => {
@@ -57,9 +62,78 @@ describe("mcpAuthTemplateFromEditorValue", () => { · 4 unmodified lines
57 { carrier: "header", name: "X-Token", prefix: "" },62 { carrier: "header", name: "X-Token", prefix: "" },
58 ],63 ],
59 };64 };
60 expect(mcpAuthTemplateFromEditorValue(value)).toEqual({65 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({
61 kind: "header",66 kind: "header",
62 headerName: "X-Token",67 headerName: "X-Token",
63 });68 });
64 });69 });
65});70});
71
72describe("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
97describe("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
124describe("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 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// MCP ↔ generic auth-template converter.2// MCP ↔ generic auth-template converter.
3//3//
4// The shared add-time `AuthTemplateEditor` speaks the plugin-agnostic4// The shared add-time `AuthTemplateEditor` / `AuthMethodListEditor` speak the
5// `AuthTemplateEditorValue`. MCP's stored auth is a single `McpAuthTemplate`5// plugin-agnostic `AuthTemplateEditorValue`; the hub speaks `AuthMethod`. MCP
6// (`none` / `header` / `oauth2`) — one method, not an array. This converts the6// stores a slugged `authenticationTemplate` array (`none` / `header` /
7// editor value to that shape and lives with the MCP plugin because it touches7// `oauth2`, one method per entry — a header method carries a single header,
8// the transport-specific `McpAuthTemplate` type.8// not an array). These converters live with the MCP plugin because they touch
9// the transport-specific `McpAuthMethod` types.
9// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
10 11
12import { AuthTemplateSlug } from "@executor-js/sdk/shared";
11import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";13import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
14import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
12 15
13import type { McpAuthTemplate } from "../sdk/types";16import type { McpAuthMethod, McpAuthMethodInput } from "../sdk/types";
14 17
15/** Convert a generic editor value into MCP's single `McpAuthTemplate`. An18/** Convert a generic editor value into one MCP auth-method input (no slug —
16 * apiKey method maps to a `header` template using its FIRST header placement19 * the backend assigns kind-based slugs). An apiKey method maps to a `header`
17 * (MCP carries a single header, not an array); a header placement's prefix is20 * method using its FIRST named header placement, preserving the prefix; an
18 * preserved. OAuth maps to `oauth2`; `none` to `none`. An apiKey value with no21 * apiKey value with no usable header placement falls back to `none`. */
19 * usable header placement falls back to `none`. */22export function mcpAuthMethodInputFromEditorValue(
20export function mcpAuthTemplateFromEditorValue(value: AuthTemplateEditorValue): McpAuthTemplate {23 value: AuthTemplateEditorValue,
24): McpAuthMethodInput {
21 if (value.kind === "oauth") return { kind: "oauth2" };25 if (value.kind === "oauth") return { kind: "oauth2" };
22 if (value.kind === "apikey") {26 if (value.kind === "apikey") {
23 const header = value.placements.find(27 const header = value.placements.find(
@@ -32,3 +36,80 @@ export function mcpAuthTemplateFromEditorValue(value: AuthTemplateEditorValue): · 8 unmodified lines
32 }36 }
33 return { kind: "none" };37 return { kind: "none" };
34}38}
39
40/** Convert one stored MCP method into the generic editor value. */
41export 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`. */
58export 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. */
101export 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
33 33
34export type RemoteConnectorInput = Omit<34export type RemoteConnectorInput = Omit<
35 McpRemoteIntegrationConfig,35 McpRemoteIntegrationConfig,
36 "auth" | "remoteTransport" | "headers" | "queryParams"36 "authenticationTemplate" | "remoteTransport" | "headers" | "queryParams"
37> & {37> & {
38 readonly remoteTransport?: McpRemoteIntegrationConfig["remoteTransport"];38 readonly remoteTransport?: McpRemoteIntegrationConfig["remoteTransport"];
39 readonly headers?: Record<string, string>;39 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
5 5
6// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
7// `describeMcpAuthMethods` projects the stored MCP config into the catalog's7// `describeMcpAuthMethods` projects the stored MCP config into the catalog's
8// plugin-agnostic `AuthMethodDescriptor[]`. It is pure/sync and must tolerate a8// plugin-agnostic `AuthMethodDescriptor[]`, one per declared method. It is
9// malformed or foreign config blob by returning `[]`.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.
10// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
11 13
12const recordWith = (config: IntegrationConfig): IntegrationRecord => ({14const recordWith = (config: IntegrationConfig): IntegrationRecord => ({
@@ -20,12 +22,12 @@ const recordWith = (config: IntegrationConfig): IntegrationRecord => ({ · 7 unmodified lines
20});22});
21 23
22describe("describeMcpAuthMethods", () => {24describe("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", () => {
24 const methods = describeMcpAuthMethods(26 const methods = describeMcpAuthMethods(
25 recordWith({27 recordWith({
26 transport: "remote",28 transport: "remote",
27 endpoint: "https://x.example/oauth/mcp",29 endpoint: "https://x.example/oauth/mcp",
28 auth: { kind: "oauth2" },30 authenticationTemplate: [{ slug: "oauth2", kind: "oauth2" }],
29 }),31 }),
30 );32 );
31 33
@@ -43,19 +45,21 @@ describe("describeMcpAuthMethods", () => { · 11 unmodified lines
43 ]);45 ]);
44 });46 });
45 47
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", () => {
47 const methods = describeMcpAuthMethods(49 const methods = describeMcpAuthMethods(
48 recordWith({50 recordWith({
49 transport: "remote",51 transport: "remote",
50 endpoint: "https://x.example/mcp",52 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 ],
52 }),56 }),
53 );57 );
54 58
55 expect(methods).toEqual([59 expect(methods).toEqual([
56 {60 {
57 id: "header",61 id: "header",
58 label: "API key (header)",62 label: "API key (X-Api-Key)",
59 kind: "apikey",63 kind: "apikey",
60 template: "header",64 template: "header",
61 placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }],65 placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }],
@@ -63,12 +67,30 @@ describe("describeMcpAuthMethods", () => { · 1 unmodified line
63 ]);67 ]);
64 });68 });
65 69
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
66 it("defaults the header prefix to an empty string when unset", () => {88 it("defaults the header prefix to an empty string when unset", () => {
67 const methods = describeMcpAuthMethods(89 const methods = describeMcpAuthMethods(
68 recordWith({90 recordWith({
69 transport: "remote",91 transport: "remote",
70 endpoint: "https://x.example/mcp",92 endpoint: "https://x.example/mcp",
71 auth: { kind: "header", headerName: "Authorization" },93 authenticationTemplate: [{ slug: "header", kind: "header", headerName: "Authorization" }],
72 }),94 }),
73 );95 );
74 96
@@ -77,12 +99,12 @@ describe("describeMcpAuthMethods", () => { · 2 unmodified lines
77 ]);99 ]);
78 });100 });
79 101
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", () => {
81 const methods = describeMcpAuthMethods(103 const methods = describeMcpAuthMethods(
82 recordWith({104 recordWith({
83 transport: "remote",105 transport: "remote",
84 endpoint: "https://x.example/mcp",106 endpoint: "https://x.example/mcp",
85 auth: { kind: "none" },107 authenticationTemplate: [{ slug: "none", kind: "none" }],
86 }),108 }),
87 );109 );
88 expect(methods).toEqual([110 expect(methods).toEqual([
@@ -95,6 +117,28 @@ describe("describeMcpAuthMethods", () => { · 6 unmodified lines
95 ]);117 ]);
96 });118 });
97 119
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
98 it("treats legacy remote configs with no auth field as open/no-auth", () => {142 it("treats legacy remote configs with no auth field as open/no-auth", () => {
99 const methods = describeMcpAuthMethods(143 const methods = describeMcpAuthMethods(
100 recordWith({144 recordWith({
@@ -131,7 +175,7 @@ describe("describeMcpAuthMethods", () => { · 30 unmodified lines
131 recordWith({175 recordWith({
132 transport: "remote",176 transport: "remote",
133 endpoint: "https://mcp.posthog.com/mcp",177 endpoint: "https://mcp.posthog.com/mcp",
134 auth: { kind: "none" },178 authenticationTemplate: [{ slug: "none", kind: "none" }],
135 }),179 }),
136 ),180 ),
137 ).toEqual({ url: "https://mcp.posthog.com/mcp" });181 ).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
301 ),301 ),
302 );302 );
303 303
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
304 // When discovery fails (auth, network, etc.) the connection still lands with353 // When discovery fails (auth, network, etc.) the connection still lands with
305 // an empty tool set so the user can retry via `connections.refresh` once they354 // an empty tool set so the user can retry via `connections.refresh` once they
306 // fix the underlying problem.355 // fix the underlying problem.
packages/plugins/mcp/src/sdk/plugin.ts+129−44
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
10 definePlugin,10 definePlugin,
11 IntegrationAlreadyExistsError,11 IntegrationAlreadyExistsError,
12 IntegrationSlug,12 IntegrationSlug,
13 mergeAuthTemplates,
13 OAuthClientSlug,14 OAuthClientSlug,
14 tool,15 tool,
15 ToolResult,16 ToolResult,
@@ -35,11 +36,16 @@ import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest"; · 19 unmodified lines
35import { mcpPresets } from "./presets";36import { mcpPresets } from "./presets";
36import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape";37import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape";
37import {38import {
39 McpAuthMethodInput,
38 McpAuthTemplate,40 McpAuthTemplate,
39 McpRemoteTransport,41 McpRemoteTransport,
42 type McpAuthMethod,
40 type McpToolAnnotations,43 type McpToolAnnotations,
44 mcpAuthMethodFromLegacy,
45 normalizeMcpAuthMethods,
41 parseMcpIntegrationConfig,46 parseMcpIntegrationConfig,
42 type McpIntegrationConfig as McpIntegrationConfigType,47 type McpIntegrationConfig as McpIntegrationConfigType,
48 type McpRemoteIntegrationConfig,
43 type McpStdioIntegrationConfig,49 type McpStdioIntegrationConfig,
44} from "./types";50} from "./types";
45 51
@@ -75,7 +81,13 @@ const legacyMcpClientMatches = ( · 29 unmodified lines
75 config: McpIntegrationConfigType | null,81 config: McpIntegrationConfigType | null,
76): boolean => {82): boolean => {
77 if (!candidates.has(String(client.slug))) return false;83 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 }
79 return client.grant === "authorization_code" && (client.resource ?? null) === config.endpoint;91 return client.grant === "authorization_code" && (client.resource ?? null) === config.endpoint;
80};92};
81 93
@@ -129,7 +141,10 @@ const McpRemoteServerInputSchema = Schema.Struct({ · 47 unmodified lines
129 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),141 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
130 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),142 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
131 slug: Schema.optional(Schema.String),143 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. */
133 auth: Schema.optional(McpAuthTemplate),148 auth: Schema.optional(McpAuthTemplate),
134});149});
135 150
@@ -152,6 +167,15 @@ const McpAddServerOutputSchema = Schema.Struct({ · 16 unmodified lines
152 slug: Schema.String,167 slug: Schema.String,
153});168});
154 169
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. */
173export const McpConfigureAuthInputSchema = Schema.Struct({
174 authenticationTemplate: Schema.Array(McpAuthMethodInput),
175 mode: Schema.optional(Schema.Literals(["merge", "replace"])),
176});
177export type McpConfigureAuthInput = typeof McpConfigureAuthInputSchema.Type;
178
155const McpProbeEndpointInputSchema = Schema.Struct({179const McpProbeEndpointInputSchema = Schema.Struct({
156 endpoint: Schema.String,180 endpoint: Schema.String,
157 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),181 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
@@ -240,7 +264,9 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 82 unmodified lines
240 remoteTransport: input.remoteTransport ?? "auto",264 remoteTransport: input.remoteTransport ?? "auto",
241 queryParams: input.queryParams,265 queryParams: input.queryParams,
242 headers: input.headers,266 headers: input.headers,
243 auth: input.auth ?? { kind: "none" },267 authenticationTemplate: input.authenticationTemplate
268 ? normalizeMcpAuthMethods(input.authenticationTemplate)
269 : [mcpAuthMethodFromLegacy(input.auth ?? { kind: "none" })],
244 };270 };
245};271};
246 272
@@ -380,12 +406,31 @@ const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({ · 133 unmodified lines
380 406
381// ---------------------------------------------------------------------------407// ---------------------------------------------------------------------------
382// Connector input — render the integration config + the connection's resolved408// 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`.
384// ---------------------------------------------------------------------------411// ---------------------------------------------------------------------------
385 412
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. */
418const 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
386const buildConnectorInput = (430const buildConnectorInput = (
387 config: McpIntegrationConfigType,431 config: McpIntegrationConfigType,
388 value: string | null,432 value: string | null,
433 templateSlug: string | null,
389 allowStdio: boolean,434 allowStdio: boolean,
390): Effect.Effect<ConnectorInput, McpConnectionError> => {435): Effect.Effect<ConnectorInput, McpConnectionError> => {
391 if (config.transport === "stdio") {436 if (config.transport === "stdio") {
@@ -410,10 +455,10 @@ const buildConnectorInput = ( · 18 unmodified lines
410 const headers: Record<string, string> = { ...(config.headers ?? {}) };455 const headers: Record<string, string> = { ...(config.headers ?? {}) };
411 let authProvider: OAuthClientProvider | undefined;456 let authProvider: OAuthClientProvider | undefined;
412 457
413 const auth = config.auth;458 const auth = selectAuthMethod(config, templateSlug);
414 if (auth.kind === "header" && value !== null) {459 if (auth?.kind === "header" && value !== null) {
415 headers[auth.headerName] = auth.prefix ? `${auth.prefix}${value}` : value;460 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) {
417 authProvider = makeOAuthProvider(value);462 authProvider = makeOAuthProvider(value);
418 }463 }
419 464
@@ -432,13 +477,14 @@ const buildConnectorInput = ( · 12 unmodified lines
432 477
433// ---------------------------------------------------------------------------478// ---------------------------------------------------------------------------
434// Declared auth methods — project the stored MCP config into the catalog's479// Declared auth methods — project the stored MCP config into the catalog's
435// plugin-agnostic `AuthMethodDescriptor[]`. Pure and tolerant of a malformed or480// plugin-agnostic `AuthMethodDescriptor[]`, one per declared method. Pure and
436// foreign config blob (returns `[]`). Exported for tests.481// tolerant of a malformed or foreign config blob (returns `[]`). Exported for
482// tests.
437//483//
438// open (`none`) → one none method carrying no credential inputs484// none → a no-auth method carrying no credential inputs
439// stdio → [] (no remote connection to configure)485// stdio → [] (no remote connection to configure)
440// header → one apikey method carrying the header placement486// header → an apikey method carrying the header placement
441// oauth2 → one oauth method carrying the MCP endpoint to probe487// oauth2 → an oauth method carrying the MCP endpoint to probe
442// (`discoveryUrl`); endpoints are discovered live at488// (`discoveryUrl`); endpoints are discovered live at
443// connect time, so they are NOT pre-resolved here. We489// connect time, so they are NOT pre-resolved here. We
444// mark `supportsDynamicRegistration: true` because MCP490// mark `supportsDynamicRegistration: true` because MCP
@@ -452,40 +498,32 @@ export const describeMcpAuthMethods = ( · 7 unmodified lines
452 const config = parseMcpIntegrationConfig(record.config);498 const config = parseMcpIntegrationConfig(record.config);
453 if (!config || config.transport === "stdio") return [];499 if (!config || config.transport === "stdio") return [];
454 500
455 const auth = config.auth;501 return config.authenticationTemplate.map((method: McpAuthMethod): AuthMethodDescriptor => {
456 if (auth.kind === "none") {502 if (method.kind === "header") {
457 return [503 return {
458 {504 id: method.slug,
459 id: "none",505 label: `API key (${method.headerName})`,
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)",
471 kind: "apikey",506 kind: "apikey",
472 template: "header",507 template: method.slug,
473 placements: [{ carrier: "header", name: auth.headerName, prefix: auth.prefix ?? "" }],508 placements: [{ carrier: "header", name: method.headerName, prefix: method.prefix ?? "" }],
474 },509 };
475 ];510 }
476 }511 if (method.kind === "oauth2") {
477 if (auth.kind === "oauth2") {512 return {
478 return [513 id: method.slug,
479 {
480 id: "oauth2",
481 label: "OAuth",514 label: "OAuth",
482 kind: "oauth",515 kind: "oauth",
483 template: "oauth2",516 template: method.slug,
484 oauth: { discoveryUrl: config.endpoint, supportsDynamicRegistration: true },517 oauth: { discoveryUrl: config.endpoint, supportsDynamicRegistration: true },
485 },518 };
486 ];519 }
487 }520 return {
488 return [];521 id: method.slug,
522 label: "No authentication",
523 kind: "none",
524 template: method.slug,
525 };
526 });
489};527};
490 528
491export const describeMcpIntegrationDisplay = (529export const describeMcpIntegrationDisplay = (
@@ -777,12 +815,49 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 285 unmodified lines
777 }),815 }),
778 );816 );
779 817
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
780 return {854 return {
781 probeEndpoint,855 probeEndpoint,
782 addServer,856 addServer,
783 removeServer,857 removeServer,
784 getServer,858 getServer,
785 configureServer,859 configureServer,
860 configureAuth,
786 };861 };
787 },862 },
788 863
@@ -794,14 +869,19 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 5 unmodified lines
794 // Discovery failures (auth not ready, server down) yield an empty tool set869 // Discovery failures (auth not ready, server down) yield an empty tool set
795 // rather than failing — the connection still lands and can be refreshed.870 // rather than failing — the connection still lands and can be refreshed.
796 // -----------------------------------------------------------------------871 // -----------------------------------------------------------------------
797 resolveTools: ({ config, connection, getValue }) =>872 resolveTools: ({ config, connection, template, getValue }) =>
798 Effect.gen(function* () {873 Effect.gen(function* () {
799 const parsed = parseMcpIntegrationConfig(config);874 const parsed = parseMcpIntegrationConfig(config);
800 if (!parsed) return { tools: [] as readonly ToolDef[] };875 if (!parsed) return { tools: [] as readonly ToolDef[] };
801 876
802 const value = yield* getValue().pipe(Effect.orElseSucceed(() => null));877 const value = yield* getValue().pipe(Effect.orElseSucceed(() => null));
803 878
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(
805 Effect.map((ci) => createMcpConnector(ci)),885 Effect.map((ci) => createMcpConnector(ci)),
806 Effect.result,886 Effect.result,
807 );887 );
@@ -848,6 +928,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 40 unmodified lines
848 const connector: McpConnector = yield* buildConnectorInput(928 const connector: McpConnector = yield* buildConnectorInput(
849 parsed,929 parsed,
850 credential.value,930 credential.value,
931 String(credential.template),
851 allowStdio,932 allowStdio,
852 ).pipe(Effect.map((ci) => createMcpConnector(ci)));933 ).pipe(Effect.map((ci) => createMcpConnector(ci)));
853 934
@@ -1078,4 +1159,8 @@ export interface McpPluginExtension { · 224 unmodified lines
1078 slug: string,1159 slug: string,
1079 config: McpIntegrationConfigType,1160 config: McpIntegrationConfigType,
1080 ) => Effect.Effect<void, McpExtensionFailure>;1161 ) => Effect.Effect<void, McpExtensionFailure>;
1162 readonly configureAuth: (
1163 slug: string,
1164 input: McpConfigureAuthInput,
1165 ) => Effect.Effect<readonly McpAuthMethod[], McpExtensionFailure>;
1081}1166}
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
25export type McpTransport = typeof McpTransport.Type;25export type McpTransport = typeof McpTransport.Type;
26 26
27// ---------------------------------------------------------------------------27// ---------------------------------------------------------------------------
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.
29//32//
30// none — no credential (open server)33// none — no credential (open server)
31// header — render the value into a request header (e.g. `Authorization:34// header — render the value into a request header (e.g. `Authorization:
@@ -34,6 +37,22 @@ export type McpTransport = typeof McpTransport.Type; · 2 unmodified lines
34// via the MCP SDK's OAuthClientProvider37// via the MCP SDK's OAuthClientProvider
35// ---------------------------------------------------------------------------38// ---------------------------------------------------------------------------
36 39
40export 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]);
50export 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. */
37export const McpAuthTemplate = Schema.Union([56export const McpAuthTemplate = Schema.Union([
38 Schema.Struct({ kind: Schema.Literal("none") }),57 Schema.Struct({ kind: Schema.Literal("none") }),
39 Schema.Struct({58 Schema.Struct({
@@ -45,6 +64,40 @@ export const McpAuthTemplate = Schema.Union([ · 5 unmodified lines
45]);64]);
46export type McpAuthTemplate = typeof McpAuthTemplate.Type;65export type McpAuthTemplate = typeof McpAuthTemplate.Type;
47 66
67export 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. */
74export 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]);
84export 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. */
88export 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
48// ---------------------------------------------------------------------------101// ---------------------------------------------------------------------------
49// Integration config — the opaque blob stored on the integration row. A102// Integration config — the opaque blob stored on the integration row. A
50// discriminated union on transport.103// discriminated union on transport.
@@ -65,8 +118,9 @@ export const McpRemoteIntegrationConfig = Schema.Struct({ · 14 unmodified lines
65 queryParams: Schema.optional(StringMap),118 queryParams: Schema.optional(StringMap),
66 /** Static headers sent on every request (non-credential) */119 /** Static headers sent on every request (non-credential) */
67 headers: Schema.optional(StringMap),120 headers: Schema.optional(StringMap),
68 /** Auth template — how the connection's value is rendered onto requests */121 /** Declared auth methods — how a connection's value is rendered onto
69 auth: McpAuthTemplate,122 * requests. A connection's `template` picks one by slug. */
123 authenticationTemplate: Schema.Array(McpAuthMethod),
70});124});
71export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type;125export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type;
72 126
@@ -90,18 +144,33 @@ export const McpIntegrationConfig = Schema.Union([ · 17 unmodified lines
90export type McpIntegrationConfig = typeof McpIntegrationConfig.Type;144export type McpIntegrationConfig = typeof McpIntegrationConfig.Type;
91 145
92const decodeIntegrationConfig = Schema.decodeUnknownOption(McpIntegrationConfig);146const decodeIntegrationConfig = Schema.decodeUnknownOption(McpIntegrationConfig);
147const decodeLegacyAuth = Schema.decodeUnknownOption(McpAuthTemplate);
93 148
94/** Parse an opaque integration `config` blob into a typed MCP config, or null149/** 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", … }]`). */
96export const parseMcpIntegrationConfig = (config: unknown): McpIntegrationConfig | null => {154export const parseMcpIntegrationConfig = (config: unknown): McpIntegrationConfig | null => {
97 if (155 if (
98 typeof config === "object" &&156 typeof config === "object" &&
99 config !== null &&157 config !== null &&
100 "transport" in config &&158 "transport" in config &&
101 config.transport === "remote" &&159 config.transport === "remote" &&
102 !("auth" in config)160 !("authenticationTemplate" in config)
103 ) {161 ) {
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 );
105 }174 }
106 return Option.getOrNull(decodeIntegrationConfig(config));175 return Option.getOrNull(decodeIntegrationConfig(config));
107};176};
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+42−167
@@ -1,19 +1,14 @@
1import { useCallback, useEffect, useMemo, useRef, useState } from "react";1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { useAtomSet, useAtomValue } from "@effect/atom-react";2import { useAtomSet } from "@effect/atom-react";
3import { Link } from "@tanstack/react-router";
4import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";4import * as Exit from "effect/Exit";
6import * as Option from "effect/Option";5import * as Option from "effect/Option";
7import * as Predicate from "effect/Predicate";
8import * as Schema from "effect/Schema";
9import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
10 6
11import {7import {
12 AuthTemplateSlug,8 AuthTemplateSlug,
13 IntegrationSlug,9 IntegrationSlug,
14 type OAuthAuthentication,10 type OAuthAuthentication,
15} from "@executor-js/sdk/shared";11} from "@executor-js/sdk/shared";
16import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms";
17import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";12import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
18import {13import {
19 slugifyNamespace,14 slugifyNamespace,
@@ -21,15 +16,23 @@ import { · 1 unmodified line
21} from "@executor-js/react/plugins/integration-identity";16} from "@executor-js/react/plugins/integration-identity";
22import { Button } from "@executor-js/react/components/button";17import { Button } from "@executor-js/react/components/button";
23import {18import {
24 AuthTemplateEditor,19 AuthMethodListEditor,
25 type AuthTemplateEditorValue,20 useAuthMethodList,
26} from "@executor-js/react/components/auth-template-editor";21 type AuthMethodRow,
22 type AuthMethodSeed,
23} from "@executor-js/react/components/auth-method-list-editor";
27import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack";24import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack";
28import { FieldLabel } from "@executor-js/react/components/field";25import { FieldLabel } from "@executor-js/react/components/field";
29import { FloatActions } from "@executor-js/react/components/float-actions";26import { FloatActions } from "@executor-js/react/components/float-actions";
30import { Textarea } from "@executor-js/react/components/textarea";27import { Textarea } from "@executor-js/react/components/textarea";
31import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";28import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
32import { PlusIcon, XIcon } from "lucide-react";29import {
30 addIntegrationErrorMessage,
31 errorMessageFromExit,
32 FormErrorAlert,
33 SlugCollisionAlert,
34 useSlugAlreadyExists,
35} from "@executor-js/react/lib/integration-add";
33 36
34import { authenticationFromEditorValue, editorValueFromAuthentication } from "./auth-method-config";37import { authenticationFromEditorValue, editorValueFromAuthentication } from "./auth-method-config";
35import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";38import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";
@@ -72,24 +75,6 @@ const googleBundleUrls = ( · 36 unmodified lines
72 return [...new Set([...fromPresets, ...customUrls])];75 return [...new Set([...fromPresets, ...customUrls])];
73};76};
74 77
75const ErrorMessage = Schema.Struct({ message: Schema.String });
76const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
77
78const 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
84const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean =>
85 Option.match(Exit.findErrorOption(exit), {
86 onNone: () => false,
87 onSome: Predicate.isTagged("IntegrationAlreadyExistsError"),
88 });
89
90const integrationExistsMessage = (slug: string): string =>
91 `An integration named "${slug}" already exists. To add more authentication, update your existing integration.`;
92
93// ---------------------------------------------------------------------------78// ---------------------------------------------------------------------------
94// OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve79// OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve
95// them against the chosen base URL so the stored auth template is absolute.80// 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
377 [preview, resolvedBaseUrl],362 [preview, resolvedBaseUrl],
378 );363 );
379 364
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
388 // Editable auth methods, seeded from the spec-detected templates. The add flow365 // Editable auth methods, seeded from the spec-detected templates. The add flow
389 // registers EVERY method (P6) — so this is a LIST, preserving multi-method366 // 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 the367 // specs (e.g. apiKey + OAuth). Each seed carries the detected template's
391 // detected template's original `seedSlug`, so an unedited detected method368 // original slug, so an unedited detected method submits with its EXACT
392 // submits with its EXACT original slug (preserving behavior); added methods369 // original slug (preserving behavior); added methods (no seed) get a
393 // (no seed) get a deterministic fresh slug. The user can edit, add, or remove370 // deterministic fresh slug. Re-seeded whenever a fresh detection arrives
394 // a method before submitting; on submit the list converts back to371 // (keyed on the detected templates, stable per analysis + base URL).
395 // `Authentication[]`. Re-seeded whenever a fresh detection arrives (keyed on372 const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(() => {
396 // the detected templates, which are stable per analysis + base URL).373 const labels = [
397 type AuthMethodRow = {374 ...(preview?.headerPresets ?? []).map((preset) => preset.label),
398 readonly value: AuthTemplateEditorValue;375 ...(preview?.oauth2Presets ?? []).map((preset) => preset.label),
399 readonly seedSlug?: string;376 ];
400 };377 return authenticationTemplate.map(
401 const [authMethods, setAuthMethods] = useState<readonly AuthMethodRow[]>([]);378 (template: Authentication, index: number): AuthMethodSeed => ({
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) => ({
408 value: editorValueFromAuthentication(template),379 value: editorValueFromAuthentication(template),
409 seedSlug: String(template.slug),380 slug: String(template.slug),
410 })),381 ...(labels[index] !== undefined ? { label: labels[index] } : {}),
382 }),
411 );383 );
412 }, [authenticationTemplate]);384 }, [preview, authenticationTemplate]);
413 385 const authMethodList = useAuthMethodList(authMethodSeeds);
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 }, []);
437 386
438 // The methods to register, mapped back to stored `Authentication[]`. Drops387 // The methods to register, mapped back to stored `Authentication[]`. Drops
439 // `none` rows (nothing to register). An unedited detected method keeps its388 // `none` rows (nothing to register). An unedited detected method keeps its
440 // original `seedSlug`; an added method gets a deterministic fresh slug.389 // original `seedSlug`; an added method gets a deterministic fresh slug.
441 const editedAuthenticationTemplate: readonly Authentication[] = useMemo(() => {390 const editedAuthenticationTemplate: readonly Authentication[] = useMemo(() => {
442 const templates: Authentication[] = [];391 const templates: Authentication[] = [];
443 authMethods.forEach((row: AuthMethodRow, index: number) => {392 authMethodList.rows.forEach((row: AuthMethodRow, index: number) => {
444 const slug =393 const slug =
445 row.seedSlug ?? (row.value.kind === "oauth" ? `oauth-${index}` : `apikey-${index}`);394 row.seedSlug ?? (row.value.kind === "oauth" ? `oauth-${index}` : `apikey-${index}`);
446 const template = authenticationFromEditorValue(row.value, slug);395 const template = authenticationFromEditorValue(row.value, slug);
447 if (template !== null) templates.push(template);396 if (template !== null) templates.push(template);
448 });397 });
449 return templates;398 return templates;
450 }, [authMethods]);399 }, [authMethodList.rows]);
451 400
452 // Pre-empt the API's `IntegrationAlreadyExistsError`: adding an integration401 // Pre-empt the API's `IntegrationAlreadyExistsError`: adding an integration
453 // whose slug already exists clobbers the existing one's connections/policies,402 // whose slug already exists clobbers the existing one's connections/policies,
454 // so the API blocks it. Surface that here from the tenant-scoped catalog list.403 // so the API blocks it. Surface that here from the tenant-scoped catalog list.
455 const integrationsResult = useAtomValue(integrationsOptimisticAtom);404 const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId);
456 const slugAlreadyExists = useMemo(
457 () =>
458 AsyncResult.isSuccess(integrationsResult) &&
459 integrationsResult.value.some((integration) => integration.slug === resolvedSourceId),
460 [integrationsResult, resolvedSourceId],
461 );
462 405
463 // The bundle path is ready once at least one Google API is selected (no406 // The bundle path is ready once at least one Google API is selected (no
464 // network preview gates it); the single/custom-spec path still requires a407 // network preview gates it); the single/custom-spec path still requires a
@@ -521,11 +464,7 @@ export default function AddOpenApiSource(props: { · 56 unmodified lines
521 reactivityKeys: integrationWriteKeys,464 reactivityKeys: integrationWriteKeys,
522 });465 });
523 if (Exit.isFailure(exit)) {466 if (Exit.isFailure(exit)) {
524 setAddError(467 setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add integration"));
525 isIntegrationAlreadyExistsExit(exit)
526 ? integrationExistsMessage(resolvedSourceId)
527 : errorMessageFromExit(exit, "Failed to add integration"),
528 );
529 return null;468 return null;
530 }469 }
531 return exit.value.slug;470 return exit.value.slug;
@@ -644,85 +583,21 @@ export default function AddOpenApiSource(props: { · 112 unmodified lines
644 />583 />
645 ) : null}584 ) : null}
646 585
647 {analyzeError && (586 {analyzeError && <FormErrorAlert message={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 )}
652 587
653 {preview && !isGoogleBundlePreset && (588 {preview && !isGoogleBundlePreset && (
654 <section className="space-y-3">589 <AuthMethodListEditor
655 <div className="flex items-center justify-between gap-3">590 list={authMethodList}
656 <FieldLabel>How does this API authenticate?</FieldLabel>591 emptyHint="No authentication detected. Add a method, or add the integration without auth and connect an account from the integration page later."
657 <Button type="button" variant="outline" size="sm" onClick={addAuthMethod}>592 footerHint="Every method here is registered with the integration. Connect an account from the integration page after adding."
658 <PlusIcon />593 />
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>
703 )}594 )}
704 595
705 {hasPreviewOrBundle && slugAlreadyExists && !adding && (596 {hasPreviewOrBundle && slugAlreadyExists && !adding && (
706 <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">597 <SlugCollisionAlert slug={resolvedSourceId} />
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>
719 )}598 )}
720 599
721 {addError && (600 {addError && <FormErrorAlert message={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 )}
726 601
727 <FloatActions>602 <FloatActions>
728 <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}>603 <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
10import { AccountsSection } from "@executor-js/react/components/accounts-section";10import { AccountsSection } from "@executor-js/react/components/accounts-section";
11import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";11import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";
12import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";12import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
13import type { CreateCustomMethod } from "@executor-js/react/components/add-custom-method-modal";
14import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";13import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
14import {
15 useCustomMethodActions,
16 type AuthMethodsCodec,
17 type ConfigureAuthMethods,
18} from "@executor-js/react/lib/custom-auth-methods";
15 19
16import { openApiConfigAtom, openapiConfigure } from "./atoms";20import { openApiConfigAtom, openapiConfigure } from "./atoms";
17import { authMethodsFromConfig, templateFromPlacements } from "./auth-method-config";21import { authMethodsFromConfig, templateFromPlacements } from "./auth-method-config";
@@ -70,44 +74,42 @@ export default function OpenApiAccountsPanel(props: { · 52 unmodified lines
70 return declared.length > 0 ? declared : [NO_AUTH_METHOD];74 return declared.length > 0 ? declared : [NO_AUTH_METHOD];
71 }, [existingTemplate]);75 }, [existingTemplate]);
72 76
73 // Add a custom apiKey method: build an `APIKeyAuthentication` from the generic77 // Custom-method create/remove: the shared skeleton (merge-append → diff out
74 // placements (slug omitted → backend backfills `custom_<id>`), merge-append it78 // the created method; filter → replace) parameterized by the OpenAPI codec.
75 // onto the existing template, and persist. Returns the created `AuthMethod`79 // Stays plugin-side only where it touches the OpenAPI `Authentication` types.
76 // (derived from the same template) so Add-account can select it immediately.80 const configure = useCallback<ConfigureAuthMethods<Authentication>>(
77 const createCustomMethod = useCallback<CreateCustomMethod>(81 async (input) => {
78 async (input: { readonly label: string; readonly placements: readonly Placement[] }) => {
79 const method = templateFromPlacements(input.placements);
80 const exit = await doConfigure({82 const exit = await doConfigure({
81 params: { slug },83 params: { slug },
82 payload: { authenticationTemplate: [...existingTemplate, method] },84 payload: {
85 authenticationTemplate: input.authenticationTemplate,
86 ...(input.mode ? { mode: input.mode } : {}),
87 },
83 reactivityKeys: integrationWriteKeys,88 reactivityKeys: integrationWriteKeys,
84 });89 });
85 if (Exit.isFailure(exit)) return null;90 return Exit.map(exit, (result) => result.authenticationTemplate as readonly Authentication[]);
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;
91 },91 },
92 [doConfigure, slug, existingTemplate],92 [doConfigure, slug],
93 );93 );
94 94
95 const removeCustomMethod = useCallback(95 const codec = useMemo<AuthMethodsCodec<Authentication>>(
96 async (method: AuthMethod): Promise<boolean> => {96 () => ({
97 if (method.source !== "custom") return false;97 toAuthMethods: authMethodsFromConfig,
98 const next = existingTemplate.filter(98 // Slug omitted → backend backfills `custom_<id>`.
99 (template: Authentication) => String(template.slug) !== String(method.template),99 templatesFromPlacements: (placements: readonly Placement[]) => [
100 );100 templateFromPlacements(placements),
101 const exit = await doConfigure({101 ],
102 params: { slug },102 slugOf: (template: Authentication) => String(template.slug),
103 payload: { authenticationTemplate: next, mode: "replace" },103 }),
104 reactivityKeys: integrationWriteKeys,104 [],
105 });
106 return Exit.isSuccess(exit);
107 },
108 [doConfigure, existingTemplate, slug],
109 );105 );
110 106
107 const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({
108 existing: existingTemplate,
109 codec,
110 configure,
111 });
112
111 // For a bundled `google` integration, surface a caution when any selected API113 // For a bundled `google` integration, surface a caution when any selected API
112 // needs a privileged or unsupported OAuth consent the user should know about114 // needs a privileged or unsupported OAuth consent the user should know about
113 // BEFORE connecting an account. Derived from the stored Discovery URLs.115 // 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
3import { HttpClient } from "effect/unstable/http";3import { HttpClient } from "effect/unstable/http";
4 4
5import {5import {
6 AuthTemplateSlug,
7 IntegrationAlreadyExistsError,6 IntegrationAlreadyExistsError,
8 IntegrationDetectionResult,7 IntegrationDetectionResult,
9 IntegrationSlug,8 IntegrationSlug,
@@ -11,6 +10,7 @@ import { · 1 unmodified line
11 ToolResult,10 ToolResult,
12 authToolFailure,11 authToolFailure,
13 definePlugin,12 definePlugin,
13 mergeAuthTemplates,
14 tool,14 tool,
15 type AuthMethodDescriptor,15 type AuthMethodDescriptor,
16 type AuthPlacementDescriptor,16 type AuthPlacementDescriptor,
@@ -521,53 +521,6 @@ const specInputToSourceUrl = (spec: OpenApiSpecInput): string | undefined => · 504 unmodified lines
521const specInputToGoogleBundle = (spec: OpenApiSpecInput): readonly string[] | undefined =>521const specInputToGoogleBundle = (spec: OpenApiSpecInput): readonly string[] | undefined =>
522 spec.kind === "googleDiscoveryBundle" ? spec.urls : undefined;522 spec.kind === "googleDiscoveryBundle" ? spec.urls : undefined;
523 523
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
532const shortId = (): string => Math.random().toString(36).slice(2, 8);
533
534/** Generate a `custom_<id>` slug not present in `taken`. */
535const 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. */
545const 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
571// ---------------------------------------------------------------------------524// ---------------------------------------------------------------------------
572// Declared auth methods — project the stored `authenticationTemplate` into the525// Declared auth methods — project the stored `authenticationTemplate` into the
573// catalog's plugin-agnostic `AuthMethodDescriptor[]`. This mirrors the client's526// catalog's plugin-agnostic `AuthMethodDescriptor[]`. This mirrors the client's
@@ -941,7 +894,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 367 unmodified lines
941 const merged =894 const merged =
942 input.mode === "replace"895 input.mode === "replace"
943 ? input.authenticationTemplate896 ? input.authenticationTemplate
944 : mergeAuthenticationTemplate(897 : mergeAuthTemplates(
945 current.authenticationTemplate ?? [],898 current.authenticationTemplate ?? [],
946 input.authenticationTemplate,899 input.authenticationTemplate,
947 );900 );
packages/react/package.json+1−0
@@ -12,6 +12,7 @@ · 11 unmodified lines
12 "./components/*": "./src/components/*.tsx",12 "./components/*": "./src/components/*.tsx",
13 "./hooks/*": "./src/hooks/*.ts",13 "./hooks/*": "./src/hooks/*.ts",
14 "./lib/auth-placements": "./src/lib/auth-placements.tsx",14 "./lib/auth-placements": "./src/lib/auth-placements.tsx",
15 "./lib/integration-add": "./src/lib/integration-add.tsx",
15 "./lib/*": "./src/lib/*.ts",16 "./lib/*": "./src/lib/*.ts",
16 "./globals.css": "./src/styles/globals.css"17 "./globals.css": "./src/styles/globals.css"
17 },18 },
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
14import { useCallback, useEffect, useRef, useState } from "react";
15import { PlusIcon, XIcon } from "lucide-react";
16
17import { Button } from "./button";
18import { FieldLabel } from "./field";
19import {
20 AuthTemplateEditor,
21 emptyApiKeyValue,
22 type AuthTemplateEditorKind,
23 type AuthTemplateEditorPreset,
24 type AuthTemplateEditorValue,
25} from "./auth-template-editor";
26
27export 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
36export interface AuthMethodRow {
37 readonly value: AuthTemplateEditorValue;
38 readonly seedSlug?: string;
39 readonly seedLabel?: string;
40}
41
42export 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. */
52export 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
88export 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
101export 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
13import { useCallback } from "react";
14import * as Exit from "effect/Exit";
15
16import type { AuthMethod, Placement } from "./auth-placements";
17import 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`). */
21export 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`). */
34export type ConfigureAuthMethods<T> = (input: {
35 readonly authenticationTemplate: readonly T[];
36 readonly mode?: "merge" | "replace";
37}) => Promise<Exit.Exit<readonly T[], unknown>>;
38
39export interface CustomMethodActions {
40 readonly createCustomMethod: CreateCustomMethod;
41 readonly removeCustomMethod: (method: AuthMethod) => Promise<boolean>;
42}
43
44export 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
8import { useMemo } from "react";
9import { useAtomValue } from "@effect/atom-react";
10import { Link } from "@tanstack/react-router";
11import * as Exit from "effect/Exit";
12import * as Option from "effect/Option";
13import * as Predicate from "effect/Predicate";
14import * as Schema from "effect/Schema";
15import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
16
17import { integrationsOptimisticAtom } from "../api/atoms";
18
19const ErrorMessage = Schema.Struct({ message: Schema.String });
20const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
21
22/** The failed Exit's `message`, or `fallback` when the error carries none. */
23export 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
29export const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean =>
30 Option.match(Exit.findErrorOption(exit), {
31 onNone: () => false,
32 onSome: Predicate.isTagged("IntegrationAlreadyExistsError"),
33 });
34
35export 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`). */
41export 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. */
53export 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. */
65export 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`). */
75export 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
3Black-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,
6so that's the natural shape.
7
8## Write a test
9
10```js
11import { mcpTarget, mcpTest, cookieConsentStrategy } from "./src/testkit.mjs";
12
13// the per-host seam: which MCP server + how to consent (headless, CI-safe)
14const selfhost = mcpTarget({
15 name: "selfhost",
16 server: "http://localhost:5173/mcp",
17 consent: cookieConsentStrategy({ appBaseUrl: "http://localhost:5173", email, password }),
18});
19
20mcpTest("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
30node run.mjs # runs tests, writes runs/*.run.json, prints chat
31node src/render.mjs runs/X.run.json # render a recording in the terminal
32node src/render.mjs runs/X.run.json --html out.html # watchable HTML
33node 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 /
39error) + `asserts` + `ok`/`error`/`durationMs`. The HTML renders it as a chat: task
40bubble → reasoning → collapsible tool cards (args + result) → inline asserts. Failures
41record 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
6import { chromium } from "playwright";
7import { execSync } from "node:child_process";
8import { createRuntime, cookieConsentStrategy } from "../vendor/mcporter/dist/index.js";
9import { renderPlayer } from "./src/render.mjs";
10import { mkdtempSync, writeFileSync, readdirSync, renameSync, rmSync, mkdirSync } from "node:fs";
11import { 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)
14rmSync(join(homedir(), ".mcporter"), { recursive: true, force: true });
15
16const W = 1000, H = 720, APP = process.env.MCP_APP ?? "http://localhost:5173";
17const EMAIL = process.env.MCP_EMAIL ?? "admin@demo.test", PW = process.env.MCP_PASSWORD ?? "demo-password-12345";
18const TMP = "/tmp/rec-flow"; rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
19const 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") }); };
20const recCtx = (b, dir) => b.newContext({ viewport: { width: W, height: H }, colorScheme: "dark", recordVideo: { dir, size: { width: W, height: H } } });
21const 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
56for (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`);
57writeFileSync(`${TMP}/list.txt`, ["1","2","3"].map(n=>`file '${TMP}/${n}.mp4'`).join("\n"));
58execSync(`ffmpeg -y -f concat -safe 0 -i ${TMP}/list.txt -c copy ${import.meta.dirname}/runs/oauth-flow.mp4 2>/dev/null`);
59console.log("✓ runs/oauth-flow.mp4");
testkit/run.mjs+8−0
@@ -0,0 +1,8 @@
1import { runTests } from "./src/testkit.mjs";
2import { renderTerminal } from "./src/render.mjs";
3import { readFileSync } from "node:fs";
4await import("./tests/execute.test.mjs");
5await import("./tests/x-oauth-basic.test.mjs");
6const { results } = await runTests();
7console.log("\n──────── recordings ────────");
8for (const r of results) console.log("\n" + renderTerminal(JSON.parse(readFileSync(r.file, "utf8"))));
testkit/serve.mjs+21−0
@@ -0,0 +1,21 @@
1import http from "node:http"; import fs from "node:fs"; import path from "node:path";
2import { fileURLToPath } from "node:url";
3const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "runs");
4const TYPES = { ".html": "text/html", ".json": "application/json", ".mp4": "video/mp4", ".png": "image/png" };
5http.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 }.
3export 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}
45let this_video;
testkit/src/driver.mjs+87−0
@@ -0,0 +1,87 @@
1import { mkdtempSync, writeFileSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4import { createRuntime } from "../../vendor/mcporter/dist/index.js";
5
6class AssertionError extends Error {}
7const 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.
14export 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>
20const RUN = __RUN__;
21const SPEED = 1;
22const esc = s => String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
23const chat = document.getElementById('chat');
24const 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; };
25const sleep = ms => new Promise(r=>setTimeout(r, ms/SPEED));
26async 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.
2export 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 @@
1import { readFileSync, writeFileSync } from "node:fs";
2
3const C = { dim: "\x1b[2m", grn: "\x1b[32m", red: "\x1b[31m", cyn: "\x1b[36m", yel: "\x1b[33m", b: "\x1b[1m", r: "\x1b[0m" };
4const icon = { user: "🧑", assistant: "🤖", tool: "🔧", assert: " ", error: "💥" };
5
6export 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
20export 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
47if (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
55import { dirname, join as _join } from "node:path";
56import { fileURLToPath } from "node:url";
57export 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 @@
1import { mkdirSync, writeFileSync } from "node:fs";
2import { join, dirname } from "node:path";
3import { fileURLToPath } from "node:url";
4import { Recorder } from "./recorder.mjs";
5import { McpDriver } from "./driver.mjs";
6export { cookieConsentStrategy } from "../../vendor/mcporter/dist/index.js";
7
8const RUNS = join(dirname(fileURLToPath(import.meta.url)), "..", "runs");
9
10// A target = the per-host seam: which MCP server + how to consent (+ optional boot).
11export function mcpTarget(cfg) {
12 return { name: cfg.name ?? "target", server: cfg.server, consent: cfg.consent, boot: cfg.boot };
13}
14
15const tests = [];
16export function mcpTest(name, target, fn) { tests.push({ name, target, fn }); }
17
18const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
19
20export 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 @@
1import { mcpTarget, mcpTest, cookieConsentStrategy } from "../src/testkit.mjs";
2
3// the per-host seam: self-host dev + headless OAuth (no browser, runs in CI)
4export 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
14mcpTest("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
22mcpTest("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 @@
1import { mcpTest } from "../src/testkit.mjs";
2import { selfhost } from "./execute.test.mjs";
3
4const uniqueId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
5
6mcpTest(
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: `
20const slug = ${JSON.stringify(slug)};
21const baseUrl = ${JSON.stringify(baseUrl)};
22const authorizationUrl = baseUrl + "/2/oauth2/authorize";
23const tokenUrl = baseUrl + "/2/oauth2/token";
24
25const 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
38const 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
48const 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
57return 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);