* 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) ---------
Unified · Split
| @@ -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) |
| 110 | ux-demo/ | 110 | ux-demo/ |
| 111 | 111 | ||
| 112 | # testkit run outputs (superseded by e2e/runs, also generated) | ||
| 113 | testkit/runs/ | ||
| @@ -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", |
| @@ -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", |
| @@ -0,0 +1,77 @@ | |||
| 1 | // Cross-target (browser): the multi-method auth add flow, end to end through | ||
| 2 | // the real web UI against a live loopback MCP test server (the target's dev | ||
| 3 | // server probes it). A no-auth server gets an API key declared at add time — | ||
| 4 | // the case where the server advertises nothing but the user knows better. | ||
| 5 | // The session video + per-step screenshots are the artifact. | ||
| 6 | // | ||
| 7 | // The OAuth-detected and connect-modal variants are selfhost-only (see | ||
| 8 | // selfhost/auth-methods-ui.test.ts): the cloud dev harness (workerd) cannot | ||
| 9 | // shape-probe loopback servers and its vault stub takes no pasted credentials. | ||
| 10 | import { randomBytes } from "node:crypto"; | ||
| 11 | |||
| 12 | import { expect } from "@effect/vitest"; | ||
| 13 | import { Effect } from "effect"; | ||
| 14 | import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | ||
| 15 | |||
| 16 | import { scenario } from "../src/scenario"; | ||
| 17 | |||
| 18 | scenario( | ||
| 19 | "Auth methods · the add flow declares an API key alongside the detected method", | ||
| 20 | { needs: ["browser"] }, | ||
| 21 | (ctx) => | ||
| 22 | Effect.scoped( | ||
| 23 | Effect.gen(function* () { | ||
| 24 | // An OPEN server: the probe connects without auth, so the method list | ||
| 25 | // seeds with the detected "no authentication" row. The server name is | ||
| 26 | // unique per run — the derived integration namespace must not collide | ||
| 27 | // on targets whose identities share one tenant (selfhost admin). | ||
| 28 | const server = yield* serveMcpServer(() => | ||
| 29 | makeGreetingMcpServer({ name: `open-mcp-${randomBytes(3).toString("hex")}` }), | ||
| 30 | ); | ||
| 31 | const identity = yield* ctx.target.newIdentity(); | ||
| 32 | |||
| 33 | yield* ctx.browser.session(identity, async ({ page, step }) => { | ||
| 34 | await step("Open the add-MCP flow pointed at the server", async () => { | ||
| 35 | await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, { | ||
| 36 | waitUntil: "networkidle", | ||
| 37 | }); | ||
| 38 | // The URL auto-probes (debounced); the method list appears once | ||
| 39 | // the probe lands. | ||
| 40 | await page.getByText("How does this server authenticate?").waitFor(); | ||
| 41 | }); | ||
| 42 | |||
| 43 | await step("The probe seeded the detected method", async () => { | ||
| 44 | await page.getByText("Method 1 · Detected").waitFor(); | ||
| 45 | }); | ||
| 46 | |||
| 47 | await step("Declare an API key method alongside it", async () => { | ||
| 48 | await page.getByRole("button", { name: "Add method" }).click(); | ||
| 49 | await page.getByText("Method 2").waitFor(); | ||
| 50 | // The new row opens on the API key editor with the standard | ||
| 51 | // Authorization-header placement prefilled. | ||
| 52 | const headerName = page.getByPlaceholder("Authorization").last(); | ||
| 53 | await headerName.waitFor(); | ||
| 54 | }); | ||
| 55 | |||
| 56 | await step("Add the source with both methods", async () => { | ||
| 57 | await page.getByRole("button", { name: "Add source" }).click(); | ||
| 58 | // onComplete routes to the new integration's detail hub. | ||
| 59 | await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 }); | ||
| 60 | await page.getByText("Connections").first().waitFor(); | ||
| 61 | }); | ||
| 62 | |||
| 63 | await step("The connect modal offers both methods", async () => { | ||
| 64 | await page.getByRole("button", { name: "Add connection" }).first().click(); | ||
| 65 | await page.getByRole("tab", { name: "No authentication" }).waitFor(); | ||
| 66 | await page.getByRole("tab", { name: "API key (Authorization)" }).waitFor(); | ||
| 67 | }); | ||
| 68 | |||
| 69 | const tabs = await page.getByRole("tab").allInnerTexts(); | ||
| 70 | expect(tabs.join(", "), "both declared methods are selectable").toContain( | ||
| 71 | "No authentication", | ||
| 72 | ); | ||
| 73 | expect(tabs.join(", ")).toContain("API key (Authorization)"); | ||
| 74 | }); | ||
| 75 | }), | ||
| 76 | ), | ||
| 77 | ); | ||
| @@ -0,0 +1,224 @@ | |||
| 1 | // Cross-target: multi-method authentication — an integration declares SEVERAL | ||
| 2 | // auth methods and a connection picks one by template slug. Covers the model | ||
| 3 | // rework: MCP's slugged `authenticationTemplate` array, the merge-append | ||
| 4 | // configureAuth flow (a custom API key must never displace a detected OAuth | ||
| 5 | // method), declaring a method on a server that advertises none, and GraphQL's | ||
| 6 | // multi-method add. Entirely through the typed clients — no MCP server is | ||
| 7 | // dialed (registration and method configuration are catalog statements). | ||
| 8 | import { randomBytes } from "node:crypto"; | ||
| 9 | |||
| 10 | import { expect } from "@effect/vitest"; | ||
| 11 | import { Effect } from "effect"; | ||
| 12 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 13 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | ||
| 14 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | ||
| 15 | import { IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 16 | |||
| 17 | import { scenario } from "../src/scenario"; | ||
| 18 | |||
| 19 | const api = composePluginApi([mcpHttpPlugin(), graphqlHttpPlugin()] as const); | ||
| 20 | |||
| 21 | const freshSlug = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`; | ||
| 22 | |||
| 23 | // Registration never dials the endpoint, so a closed local port is fine. | ||
| 24 | const MCP_ENDPOINT = "http://127.0.0.1:59998/mcp"; | ||
| 25 | |||
| 26 | scenario( | ||
| 27 | "Auth methods · an MCP server can declare OAuth and an API key side by side", | ||
| 28 | { needs: ["api"] }, | ||
| 29 | (ctx) => | ||
| 30 | Effect.gen(function* () { | ||
| 31 | const identity = yield* ctx.target.newIdentity(); | ||
| 32 | const client = yield* ctx.api.client(api, identity); | ||
| 33 | const slug = freshSlug("mcp-multiauth"); | ||
| 34 | |||
| 35 | yield* client.mcp.addServer({ | ||
| 36 | payload: { | ||
| 37 | transport: "remote", | ||
| 38 | name: "Multi-auth MCP", | ||
| 39 | endpoint: MCP_ENDPOINT, | ||
| 40 | slug, | ||
| 41 | authenticationTemplate: [ | ||
| 42 | { kind: "oauth2" }, | ||
| 43 | { kind: "header", headerName: "X-Api-Key", prefix: "Bearer " }, | ||
| 44 | ], | ||
| 45 | }, | ||
| 46 | }); | ||
| 47 | |||
| 48 | yield* Effect.gen(function* () { | ||
| 49 | const integration = yield* client.integrations.get({ | ||
| 50 | params: { slug: IntegrationSlug.make(slug) }, | ||
| 51 | }); | ||
| 52 | |||
| 53 | // Both methods project into the catalog, slugged by kind, so the | ||
| 54 | // connect flow can offer either and a connection binds one by slug. | ||
| 55 | expect( | ||
| 56 | integration.authMethods.map((m) => ({ kind: m.kind, template: m.template })), | ||
| 57 | "the catalog lists both declared methods", | ||
| 58 | ).toEqual([ | ||
| 59 | { kind: "oauth", template: "oauth2" }, | ||
| 60 | { kind: "apikey", template: "header" }, | ||
| 61 | ]); | ||
| 62 | |||
| 63 | const apiKey = integration.authMethods.find((m) => m.kind === "apikey"); | ||
| 64 | expect(apiKey?.placements, "the API key method carries its header placement").toEqual([ | ||
| 65 | { carrier: "header", name: "X-Api-Key", prefix: "Bearer " }, | ||
| 66 | ]); | ||
| 67 | }).pipe( | ||
| 68 | Effect.ensuring( | ||
| 69 | client.mcp | ||
| 70 | .removeServer({ params: { slug: IntegrationSlug.make(slug) } }) | ||
| 71 | .pipe(Effect.ignore), | ||
| 72 | ), | ||
| 73 | ); | ||
| 74 | }), | ||
| 75 | ); | ||
| 76 | |||
| 77 | scenario( | ||
| 78 | "Auth methods · adding an API key method keeps a detected OAuth method", | ||
| 79 | { needs: ["api"] }, | ||
| 80 | (ctx) => | ||
| 81 | Effect.gen(function* () { | ||
| 82 | const identity = yield* ctx.target.newIdentity(); | ||
| 83 | const client = yield* ctx.api.client(api, identity); | ||
| 84 | const slug = freshSlug("mcp-oauth-plus-key"); | ||
| 85 | |||
| 86 | // The add flow registered what the probe detected: OAuth only. | ||
| 87 | yield* client.mcp.addServer({ | ||
| 88 | payload: { | ||
| 89 | transport: "remote", | ||
| 90 | name: "OAuth MCP", | ||
| 91 | endpoint: MCP_ENDPOINT, | ||
| 92 | slug, | ||
| 93 | authenticationTemplate: [{ kind: "oauth2" }], | ||
| 94 | }, | ||
| 95 | }); | ||
| 96 | |||
| 97 | yield* Effect.gen(function* () { | ||
| 98 | // "+ Custom method" merge-appends — it must not displace OAuth. | ||
| 99 | const configured = yield* client.mcp.configureAuth({ | ||
| 100 | params: { slug: IntegrationSlug.make(slug) }, | ||
| 101 | payload: { | ||
| 102 | authenticationTemplate: [{ kind: "header", headerName: "X-Api-Key" }], | ||
| 103 | }, | ||
| 104 | }); | ||
| 105 | expect( | ||
| 106 | configured.authenticationTemplate.map((m) => m.kind), | ||
| 107 | "the declared set now holds both methods", | ||
| 108 | ).toEqual(["oauth2", "header"]); | ||
| 109 | expect( | ||
| 110 | configured.authenticationTemplate[1]?.slug, | ||
| 111 | "the custom method gets its own custom_ slug", | ||
| 112 | ).toMatch(/^custom_/); | ||
| 113 | |||
| 114 | const integration = yield* client.integrations.get({ | ||
| 115 | params: { slug: IntegrationSlug.make(slug) }, | ||
| 116 | }); | ||
| 117 | expect( | ||
| 118 | integration.authMethods.map((m) => m.kind), | ||
| 119 | "the catalog offers OAuth and the API key", | ||
| 120 | ).toEqual(["oauth", "apikey"]); | ||
| 121 | }).pipe( | ||
| 122 | Effect.ensuring( | ||
| 123 | client.mcp | ||
| 124 | .removeServer({ params: { slug: IntegrationSlug.make(slug) } }) | ||
| 125 | .pipe(Effect.ignore), | ||
| 126 | ), | ||
| 127 | ); | ||
| 128 | }), | ||
| 129 | ); | ||
| 130 | |||
| 131 | scenario( | ||
| 132 | "Auth methods · a no-auth MCP server can declare an API key method later", | ||
| 133 | { needs: ["api"] }, | ||
| 134 | (ctx) => | ||
| 135 | Effect.gen(function* () { | ||
| 136 | const identity = yield* ctx.target.newIdentity(); | ||
| 137 | const client = yield* ctx.api.client(api, identity); | ||
| 138 | const slug = freshSlug("mcp-open-plus-key"); | ||
| 139 | |||
| 140 | // No declared auth — the server advertises nothing. | ||
| 141 | yield* client.mcp.addServer({ | ||
| 142 | payload: { transport: "remote", name: "Open MCP", endpoint: MCP_ENDPOINT, slug }, | ||
| 143 | }); | ||
| 144 | |||
| 145 | yield* Effect.gen(function* () { | ||
| 146 | const before = yield* client.integrations.get({ | ||
| 147 | params: { slug: IntegrationSlug.make(slug) }, | ||
| 148 | }); | ||
| 149 | expect( | ||
| 150 | before.authMethods.map((m) => m.kind), | ||
| 151 | "an open server starts with the no-auth method", | ||
| 152 | ).toEqual(["none"]); | ||
| 153 | |||
| 154 | yield* client.mcp.configureAuth({ | ||
| 155 | params: { slug: IntegrationSlug.make(slug) }, | ||
| 156 | payload: { | ||
| 157 | authenticationTemplate: [ | ||
| 158 | { kind: "header", headerName: "Authorization", prefix: "Bearer " }, | ||
| 159 | ], | ||
| 160 | }, | ||
| 161 | }); | ||
| 162 | |||
| 163 | const after = yield* client.integrations.get({ | ||
| 164 | params: { slug: IntegrationSlug.make(slug) }, | ||
| 165 | }); | ||
| 166 | expect( | ||
| 167 | after.authMethods.map((m) => m.kind), | ||
| 168 | "no-auth and the declared API key coexist", | ||
| 169 | ).toEqual(["none", "apikey"]); | ||
| 170 | }).pipe( | ||
| 171 | Effect.ensuring( | ||
| 172 | client.mcp | ||
| 173 | .removeServer({ params: { slug: IntegrationSlug.make(slug) } }) | ||
| 174 | .pipe(Effect.ignore), | ||
| 175 | ), | ||
| 176 | ); | ||
| 177 | }), | ||
| 178 | ); | ||
| 179 | |||
| 180 | scenario( | ||
| 181 | "Auth methods · a GraphQL source registers multiple auth methods at add time", | ||
| 182 | { needs: ["api"] }, | ||
| 183 | (ctx) => | ||
| 184 | Effect.gen(function* () { | ||
| 185 | const identity = yield* ctx.target.newIdentity(); | ||
| 186 | const client = yield* ctx.api.client(api, identity); | ||
| 187 | const slug = freshSlug("graphql-multiauth"); | ||
| 188 | |||
| 189 | yield* client.graphql.addIntegration({ | ||
| 190 | payload: { | ||
| 191 | endpoint: "http://127.0.0.1:59998/graphql", | ||
| 192 | slug, | ||
| 193 | name: "Multi-auth GraphQL", | ||
| 194 | authenticationTemplate: [ | ||
| 195 | { kind: "apiKey", slug: "apiKey", in: "header", name: "X-Api-Key" }, | ||
| 196 | { kind: "apiKey", slug: "apikey-2", in: "query", name: "api_key" }, | ||
| 197 | ], | ||
| 198 | }, | ||
| 199 | }); | ||
| 200 | |||
| 201 | yield* Effect.gen(function* () { | ||
| 202 | const integration = yield* client.integrations.get({ | ||
| 203 | params: { slug: IntegrationSlug.make(slug) }, | ||
| 204 | }); | ||
| 205 | expect( | ||
| 206 | integration.authMethods.map((m) => ({ template: m.template, kind: m.kind })), | ||
| 207 | "both declared methods are in the catalog", | ||
| 208 | ).toEqual([ | ||
| 209 | { template: "apiKey", kind: "apikey" }, | ||
| 210 | { template: "apikey-2", kind: "apikey" }, | ||
| 211 | ]); | ||
| 212 | expect( | ||
| 213 | integration.authMethods[1]?.placements, | ||
| 214 | "the second method carries its query placement", | ||
| 215 | ).toEqual([{ carrier: "query", name: "api_key", prefix: "" }]); | ||
| 216 | }).pipe( | ||
| 217 | Effect.ensuring( | ||
| 218 | client.integrations | ||
| 219 | .remove({ params: { slug: IntegrationSlug.make(slug) } }) | ||
| 220 | .pipe(Effect.ignore), | ||
| 221 | ), | ||
| 222 | ); | ||
| 223 | }), | ||
| 224 | ); | ||
| @@ -0,0 +1,156 @@ | |||
| 1 | // Selfhost-only (browser): the multi-method auth UX beyond the no-auth case — | ||
| 2 | // an OAuth-DETECTED server gets an API key declared alongside at add time, and | ||
| 3 | // the connect modal's "+ method" adds a custom API key to an OAuth integration | ||
| 4 | // without displacing it. Selfhost-only because cloud has no browser identity | ||
| 5 | // yet and these paste a credential into the default store; the cross-target | ||
| 6 | // no-auth add-flow variant lives in scenarios/auth-methods-ui.test.ts. The | ||
| 7 | // selfhost instance runs with EXECUTOR_ALLOW_LOCAL_NETWORK so its outbound | ||
| 8 | // probe/dial can reach the loopback test servers. Video is the artifact. | ||
| 9 | import { randomBytes } from "node:crypto"; | ||
| 10 | |||
| 11 | import { expect } from "@effect/vitest"; | ||
| 12 | import { Effect } from "effect"; | ||
| 13 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 14 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | ||
| 15 | import { | ||
| 16 | makeGreetingMcpServer, | ||
| 17 | serveMcpServer, | ||
| 18 | serveMcpServerWithOAuth, | ||
| 19 | } from "@executor-js/plugin-mcp/testing"; | ||
| 20 | import { OAuthTestServer } from "@executor-js/sdk/testing"; | ||
| 21 | import { IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 22 | |||
| 23 | import { scenario } from "../src/scenario"; | ||
| 24 | |||
| 25 | const api = composePluginApi([mcpHttpPlugin()] as const); | ||
| 26 | |||
| 27 | scenario( | ||
| 28 | "Auth methods · a detected-OAuth server gets an API key declared alongside", | ||
| 29 | { needs: ["browser"] }, | ||
| 30 | (ctx) => | ||
| 31 | Effect.scoped( | ||
| 32 | Effect.gen(function* () { | ||
| 33 | // An OAuth-PROTECTED server: the probe gets a 401 with protected- | ||
| 34 | // resource metadata pointing at the test OAuth issuer, so the method | ||
| 35 | // list seeds with the detected OAuth row. | ||
| 36 | const server = yield* serveMcpServerWithOAuth( | ||
| 37 | () => makeGreetingMcpServer({ name: `oauth-mcp-${randomBytes(3).toString("hex")}` }), | ||
| 38 | { path: "/mcp" }, | ||
| 39 | ); | ||
| 40 | const identity = yield* ctx.target.newIdentity(); | ||
| 41 | |||
| 42 | yield* ctx.browser.session(identity, async ({ page, step }) => { | ||
| 43 | await step("Open the add-MCP flow pointed at the server", async () => { | ||
| 44 | await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, { | ||
| 45 | waitUntil: "networkidle", | ||
| 46 | }); | ||
| 47 | await page.getByText("How does this server authenticate?").waitFor(); | ||
| 48 | }); | ||
| 49 | |||
| 50 | await step("The probe detected OAuth", async () => { | ||
| 51 | await page.getByText("Method 1 · Detected").waitFor(); | ||
| 52 | // The OAuth editor declares discovery-at-connect, not pasted URLs. | ||
| 53 | await page.getByText("OAuth metadata is discovered from this server").waitFor(); | ||
| 54 | }); | ||
| 55 | |||
| 56 | await step("Declare an API key method alongside OAuth", async () => { | ||
| 57 | await page.getByRole("button", { name: "Add method" }).click(); | ||
| 58 | await page.getByText("Method 2").waitFor(); | ||
| 59 | await page.getByPlaceholder("Authorization").last().waitFor(); | ||
| 60 | }); | ||
| 61 | |||
| 62 | await step("Add the source with both methods", async () => { | ||
| 63 | await page.getByRole("button", { name: "Add source" }).click(); | ||
| 64 | await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 }); | ||
| 65 | await page.getByText("Connections").first().waitFor(); | ||
| 66 | }); | ||
| 67 | |||
| 68 | await step("The connect modal offers OAuth and the API key", async () => { | ||
| 69 | await page.getByRole("button", { name: "Add connection" }).first().click(); | ||
| 70 | await page.getByRole("tab", { name: "OAuth" }).waitFor(); | ||
| 71 | await page.getByRole("tab", { name: "API key (Authorization)" }).waitFor(); | ||
| 72 | }); | ||
| 73 | }); | ||
| 74 | }), | ||
| 75 | ).pipe(Effect.provide(OAuthTestServer.layer())), | ||
| 76 | ); | ||
| 77 | |||
| 78 | scenario( | ||
| 79 | "Auth methods · a custom method added in the connect modal keeps OAuth", | ||
| 80 | { needs: ["browser", "api"] }, | ||
| 81 | (ctx) => | ||
| 82 | Effect.scoped( | ||
| 83 | Effect.gen(function* () { | ||
| 84 | // A server that only accepts the bearer key — the connection created | ||
| 85 | // through the custom method must render it on the wire. | ||
| 86 | const token = `e2e-modal-key-${randomBytes(6).toString("hex")}`; | ||
| 87 | const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { | ||
| 88 | auth: { | ||
| 89 | validateAuthorization: (authorization) => | ||
| 90 | Effect.succeed(authorization === `Bearer ${token}`), | ||
| 91 | }, | ||
| 92 | }); | ||
| 93 | |||
| 94 | const identity = yield* ctx.target.newIdentity(); | ||
| 95 | const client = yield* ctx.api.client(api, identity); | ||
| 96 | const slug = `mcp-modal-key-${randomBytes(3).toString("hex")}`; | ||
| 97 | |||
| 98 | // The integration as the add flow would have left it: OAuth only. | ||
| 99 | yield* client.mcp.addServer({ | ||
| 100 | payload: { | ||
| 101 | transport: "remote", | ||
| 102 | name: "OAuth-only MCP", | ||
| 103 | endpoint: server.endpoint, | ||
| 104 | slug, | ||
| 105 | authenticationTemplate: [{ kind: "oauth2" }], | ||
| 106 | }, | ||
| 107 | }); | ||
| 108 | |||
| 109 | // Remove the integration (and the connection it creates) afterward — | ||
| 110 | // selfhost identities share one tenant, so a leaked connection would | ||
| 111 | // break the "fresh identity has zero connections" scenario. | ||
| 112 | yield* Effect.gen(function* () { | ||
| 113 | yield* ctx.browser.session(identity, async ({ page, step }) => { | ||
| 114 | await step("Open the integration's connect modal", async () => { | ||
| 115 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | ||
| 116 | await page.getByRole("button", { name: "Add connection" }).first().click(); | ||
| 117 | await page.getByRole("tab", { name: "OAuth" }).waitFor(); | ||
| 118 | }); | ||
| 119 | |||
| 120 | await step("Add a custom API key method from the modal", async () => { | ||
| 121 | await page.getByRole("button", { name: "Add authentication method" }).click(); | ||
| 122 | await page.getByLabel("Method name").fill("Team API key"); | ||
| 123 | await page.getByPlaceholder("Authorization").fill("Authorization"); | ||
| 124 | await page.getByPlaceholder("Bearer ").fill("Bearer "); | ||
| 125 | await page.getByRole("button", { name: "Add method" }).click(); | ||
| 126 | }); | ||
| 127 | |||
| 128 | await step("OAuth survives next to the new method", async () => { | ||
| 129 | await page.getByRole("tab", { name: "API key (Authorization)" }).waitFor(); | ||
| 130 | await page.getByRole("tab", { name: "OAuth" }).waitFor(); | ||
| 131 | }); | ||
| 132 | |||
| 133 | await step("Connect through the new method", async () => { | ||
| 134 | await page.getByPlaceholder("paste the value / token").fill(token); | ||
| 135 | await page.getByRole("button", { name: "Add connection" }).click(); | ||
| 136 | await page.getByText("Connection added").waitFor(); | ||
| 137 | }); | ||
| 138 | }); | ||
| 139 | |||
| 140 | // Wire proof: discovery for the new connection hit the server with | ||
| 141 | // the credential rendered through the custom method. | ||
| 142 | const requests = yield* server.requests; | ||
| 143 | expect( | ||
| 144 | requests.some((request) => request.authorization === `Bearer ${token}`), | ||
| 145 | "the server saw the bearer rendered through the custom method", | ||
| 146 | ).toBe(true); | ||
| 147 | }).pipe( | ||
| 148 | Effect.ensuring( | ||
| 149 | client.mcp | ||
| 150 | .removeServer({ params: { slug: IntegrationSlug.make(slug) } }) | ||
| 151 | .pipe(Effect.ignore), | ||
| 152 | ), | ||
| 153 | ); | ||
| 154 | }), | ||
| 155 | ), | ||
| 156 | ); | ||
| @@ -0,0 +1,91 @@ | |||
| 1 | // Selfhost-only: on a multi-method MCP integration, the connection's CHOSEN | ||
| 2 | // method is what renders the credential on the wire. A real MCP test server | ||
| 3 | // (in this test process — the selfhost dev server dials it over loopback) | ||
| 4 | // only accepts `Authorization: Bearer <token>`. The integration declares BOTH | ||
| 5 | // OAuth and a bearer-header method; a connection created through the header | ||
| 6 | // method must discover the server's tools, and the recorded requests must | ||
| 7 | // carry the rendered header — proving template selection by slug, not by | ||
| 8 | // "whatever the config says". | ||
| 9 | import { randomBytes } from "node:crypto"; | ||
| 10 | |||
| 11 | import { expect } from "@effect/vitest"; | ||
| 12 | import { Effect } from "effect"; | ||
| 13 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 14 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | ||
| 15 | import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | ||
| 16 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 17 | |||
| 18 | import { scenario } from "../src/scenario"; | ||
| 19 | |||
| 20 | const api = composePluginApi([mcpHttpPlugin()] as const); | ||
| 21 | |||
| 22 | scenario( | ||
| 23 | "Auth methods · the connection's chosen method renders the credential on the wire", | ||
| 24 | { needs: ["api"] }, | ||
| 25 | (ctx) => | ||
| 26 | Effect.scoped( | ||
| 27 | Effect.gen(function* () { | ||
| 28 | const token = `e2e-key-${randomBytes(6).toString("hex")}`; | ||
| 29 | const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { | ||
| 30 | auth: { | ||
| 31 | validateAuthorization: (authorization) => | ||
| 32 | Effect.succeed(authorization === `Bearer ${token}`), | ||
| 33 | }, | ||
| 34 | }); | ||
| 35 | |||
| 36 | const identity = yield* ctx.target.newIdentity(); | ||
| 37 | const client = yield* ctx.api.client(api, identity); | ||
| 38 | const slug = freshSlug("mcp-wire-auth"); | ||
| 39 | |||
| 40 | // Two declared methods — the connection must pick the right one. | ||
| 41 | yield* client.mcp.addServer({ | ||
| 42 | payload: { | ||
| 43 | transport: "remote", | ||
| 44 | name: "Wire-auth MCP", | ||
| 45 | endpoint: server.endpoint, | ||
| 46 | slug, | ||
| 47 | authenticationTemplate: [ | ||
| 48 | { kind: "oauth2" }, | ||
| 49 | { kind: "header", headerName: "Authorization", prefix: "Bearer " }, | ||
| 50 | ], | ||
| 51 | }, | ||
| 52 | }); | ||
| 53 | |||
| 54 | yield* Effect.gen(function* () { | ||
| 55 | // Create the connection through the HEADER method (template slug | ||
| 56 | // "header") with a pasted key. Tool discovery dials the server with | ||
| 57 | // the rendered credential at create time. | ||
| 58 | yield* client.connections.create({ | ||
| 59 | payload: { | ||
| 60 | owner: "org", | ||
| 61 | name: ConnectionName.make("wire-auth-key"), | ||
| 62 | integration: IntegrationSlug.make(slug), | ||
| 63 | template: AuthTemplateSlug.make("header"), | ||
| 64 | value: token, | ||
| 65 | }, | ||
| 66 | }); | ||
| 67 | |||
| 68 | const tools = yield* client.tools.list(); | ||
| 69 | const mine = tools.filter((tool) => String(tool.integration) === slug); | ||
| 70 | expect( | ||
| 71 | mine.map((tool) => String(tool.name)).join(", "), | ||
| 72 | "discovery through the header method found the server's tool", | ||
| 73 | ).toContain("simple_echo"); | ||
| 74 | |||
| 75 | const requests = yield* server.requests; | ||
| 76 | expect( | ||
| 77 | requests.some((request) => request.authorization === `Bearer ${token}`), | ||
| 78 | "the server saw the credential rendered through the chosen method", | ||
| 79 | ).toBe(true); | ||
| 80 | }).pipe( | ||
| 81 | Effect.ensuring( | ||
| 82 | client.mcp | ||
| 83 | .removeServer({ params: { slug: IntegrationSlug.make(slug) } }) | ||
| 84 | .pipe(Effect.ignore), | ||
| 85 | ), | ||
| 86 | ); | ||
| 87 | }), | ||
| 88 | ), | ||
| 89 | ); | ||
| 90 | |||
| 91 | const freshSlug = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`; | ||
| @@ -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 | }; |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | |||
| 3 | // fully-stubbed instance (multi-user WorkOS stub, free-plan Autumn, no | 3 | // fully-stubbed instance (multi-user WorkOS stub, free-plan Autumn, no |
| 4 | // network). Set E2E_CLOUD_URL to attach to an already-running instance | 4 | // 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). |
| 6 | import { rmSync } from "node:fs"; | ||
| 6 | import { resolve } from "node:path"; | 7 | import { resolve } from "node:path"; |
| 7 | import { fileURLToPath } from "node:url"; | 8 | import { 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( |
| @@ -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 | ], |
| @@ -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( |
| @@ -79,6 +79,7 @@ export type { · 78 unmodified lines | |||
| 79 | IntegrationDisplayDescriptor, | 79 | IntegrationDisplayDescriptor, |
| 80 | RegisterIntegrationInput, | 80 | RegisterIntegrationInput, |
| 81 | } from "./integration"; | 81 | } from "./integration"; |
| 82 | export { freshCustomAuthSlug, mergeAuthTemplates } from "./integration"; | ||
| 82 | export type { | 83 | export type { |
| 83 | Connection, | 84 | Connection, |
| 84 | ConnectionRef, | 85 | ConnectionRef, |
| @@ -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. */ |
| 93 | export type IntegrationConfig = unknown; | 93 | export 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 | |||
| 103 | const shortId = (): string => Math.random().toString(36).slice(2, 8); | ||
| 104 | |||
| 105 | export const freshCustomAuthSlug = (taken: ReadonlySet<string>): string => { | ||
| 106 | let candidate = `custom_${shortId()}`; | ||
| 107 | while (taken.has(candidate)) candidate = `custom_${shortId()}`; | ||
| 108 | return candidate; | ||
| 109 | }; | ||
| 110 | |||
| 111 | export const mergeAuthTemplates = <T extends { readonly slug: string }>( | ||
| 112 | existing: readonly T[], | ||
| 113 | incoming: readonly T[], | ||
| 114 | ): readonly T[] => { | ||
| 115 | const result: T[] = existing.map((entry: T) => entry); | ||
| 116 | const taken = new Set<string>(result.map((entry: T) => String(entry.slug))); | ||
| 117 | for (const entry of incoming) { | ||
| 118 | // `slug` may be branded-required in the plugin's schema, but JSON callers | ||
| 119 | // can submit it empty/blank — read defensively and backfill so every | ||
| 120 | // stored template has a stable slug. | ||
| 121 | const rawSlug = (entry as { readonly slug?: unknown }).slug; | ||
| 122 | const requested = typeof rawSlug === "string" ? rawSlug.trim() : ""; | ||
| 123 | const existingIndex = result.findIndex((current: T) => String(current.slug) === requested); | ||
| 124 | if (requested.length > 0 && existingIndex >= 0) { | ||
| 125 | result[existingIndex] = entry; | ||
| 126 | continue; | ||
| 127 | } | ||
| 128 | const slug = | ||
| 129 | requested.length > 0 && !taken.has(requested) ? requested : freshCustomAuthSlug(taken); | ||
| 130 | taken.add(slug); | ||
| 131 | result.push({ ...entry, slug } as T); | ||
| 132 | } | ||
| 133 | return result; | ||
| 134 | }; | ||
| 135 | |||
| 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 are | 137 | * 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). */ |
| @@ -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 — only | 201 | /** 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>; |
| @@ -1,13 +1,7 @@ | |||
| 1 | import { useCallback, useMemo, useState } from "react"; | 1 | import { useCallback, useMemo, useState } from "react"; |
| 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import { Link } from "@tanstack/react-router"; | ||
| 4 | import * as Exit from "effect/Exit"; | 3 | import * as Exit from "effect/Exit"; |
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Predicate from "effect/Predicate"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 9 | 4 | ||
| 10 | import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms"; | ||
| 11 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 5 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 12 | import { | 6 | import { |
| 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"; |
| 17 | import { Button } from "@executor-js/react/components/button"; | 11 | import { Button } from "@executor-js/react/components/button"; |
| 18 | import { | 12 | import { |
| 19 | AuthTemplateEditor, | 13 | AuthMethodListEditor, |
| 20 | type AuthTemplateEditorValue, | 14 | useAuthMethodList, |
| 21 | } from "@executor-js/react/components/auth-template-editor"; | 15 | type AuthMethodRow, |
| 22 | import { FieldLabel } from "@executor-js/react/components/field"; | 16 | type AuthMethodSeed, |
| 17 | } from "@executor-js/react/components/auth-method-list-editor"; | ||
| 23 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 18 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 24 | import { Spinner } from "@executor-js/react/components/spinner"; | 19 | import { Spinner } from "@executor-js/react/components/spinner"; |
| 20 | import { | ||
| 21 | addIntegrationErrorMessage, | ||
| 22 | FormErrorAlert, | ||
| 23 | SlugCollisionAlert, | ||
| 24 | useSlugAlreadyExists, | ||
| 25 | } from "@executor-js/react/lib/integration-add"; | ||
| 25 | 26 | ||
| 26 | import { addGraphqlIntegrationOptimistic } from "./atoms"; | 27 | import { addGraphqlIntegrationOptimistic } from "./atoms"; |
| 27 | import { GraphqlSourceFields } from "./GraphqlSourceFields"; | 28 | import { GraphqlSourceFields } from "./GraphqlSourceFields"; |
| 28 | import { graphqlTemplatesFromPlacements } from "./auth-method-config"; | 29 | import { graphqlTemplatesFromPlacements } from "./auth-method-config"; |
| 30 | import { GRAPHQL_APIKEY_TEMPLATE } from "./defaults"; | ||
| 31 | import type { AuthTemplate } from "../sdk/types"; | ||
| 29 | 32 | ||
| 30 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | 33 | // v2 GraphQL add flow: register the integration with its declared auth-method |
| 31 | const 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 | |
| 33 | const 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 | ||
| 39 | const 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, | 41 | const NO_SEEDS: readonly AuthMethodSeed[] = []; |
| 42 | onSome: Predicate.isTagged("IntegrationAlreadyExistsError"), | ||
| 43 | }); | ||
| 44 | |||
| 45 | const integrationExistsMessage = (slug: string): string => | ||
| 46 | `An integration named "${slug}" already exists. To add more authentication, update your existing integration.`; | ||
| 47 | |||
| 48 | // v2 GraphQL add flow (post-redesign): register the integration (introspects | ||
| 49 | // the endpoint and declares an apiKey auth template when the user configures a | ||
| 50 | // header), then route to the integration's detail hub. Connection creation is | ||
| 51 | // no longer part of the add flow — accounts are added from the hub (P6: add | ||
| 52 | // without auth, connect later). Auth is declared through the shared | ||
| 53 | // `AuthTemplateEditor` (GraphQL stays header/query apiKey — OAuth is hidden). | ||
| 54 | 42 | ||
| 55 | export default function AddGraphqlSource(props: { | 43 | export 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 integration | 96 | // 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 | <AuthTemplateEditor | 145 | 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 "{resolvedSlug}" 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}> |
| @@ -7,8 +7,12 @@ import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; · 6 unmodified lines | |||
| 7 | 7 | ||
| 8 | import { AccountsSection } from "@executor-js/react/components/accounts-section"; | 8 | import { AccountsSection } from "@executor-js/react/components/accounts-section"; |
| 9 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 9 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 10 | import type { CreateCustomMethod } from "@executor-js/react/components/add-custom-method-modal"; | ||
| 11 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; | 10 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; |
| 11 | import { | ||
| 12 | useCustomMethodActions, | ||
| 13 | type AuthMethodsCodec, | ||
| 14 | type ConfigureAuthMethods, | ||
| 15 | } from "@executor-js/react/lib/custom-auth-methods"; | ||
| 12 | 16 | ||
| 13 | import { graphqlConfigAtom, graphqlConfigure } from "./atoms"; | 17 | import { graphqlConfigAtom, graphqlConfigure } from "./atoms"; |
| 14 | import { authMethodsFromConfig, graphqlTemplatesFromPlacements } from "./auth-method-config"; | 18 | import { 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 the | 54 | // 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 the | 56 | // 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 | <AccountsSection | 91 | <AccountsSection |
| @@ -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 | |||
| 387 | const introspectHeadersForConnection = ( | 388 | const 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 we | 396 | // Render the exact template the connection references; with no slug |
| 395 | // can't know which without the slug (not carried into `resolveTools`), so we | 397 | // (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 | |||
| 410 | const introspectForConnection = ( | 414 | const 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 | |||
| 543 | const shortId = (): string => Math.random().toString(36).slice(2, 8); | ||
| 544 | |||
| 545 | const freshCustomSlug = (taken: ReadonlySet<string>): string => { | ||
| 546 | let candidate = `custom_${shortId()}`; | ||
| 547 | while (taken.has(candidate)) candidate = `custom_${shortId()}`; | ||
| 548 | return candidate; | ||
| 549 | }; | ||
| 550 | |||
| 551 | const mergeGraphqlAuthTemplate = ( | ||
| 552 | existing: readonly AuthTemplate[], | ||
| 553 | incoming: readonly AuthTemplate[], | ||
| 554 | ): readonly AuthTemplate[] => { | ||
| 555 | const result: AuthTemplate[] = existing.map((entry: AuthTemplate) => entry); | ||
| 556 | const taken = new Set<string>(result.map((entry: AuthTemplate) => entry.slug)); | ||
| 557 | for (const entry of incoming) { | ||
| 558 | // `slug` is a plain string; a JSON caller may submit it empty/blank — read | ||
| 559 | // defensively and backfill so every stored template has a stable slug. | ||
| 560 | const requested = entry.slug.trim(); | ||
| 561 | const existingIndex = result.findIndex((current: AuthTemplate) => current.slug === requested); | ||
| 562 | if (requested.length > 0 && existingIndex >= 0) { | ||
| 563 | result[existingIndex] = entry; | ||
| 564 | continue; | ||
| 565 | } | ||
| 566 | const slug = requested.length > 0 && !taken.has(requested) ? requested : freshCustomSlug(taken); | ||
| 567 | taken.add(slug); | ||
| 568 | result.push({ ...entry, slug } as AuthTemplate); | ||
| 569 | } | ||
| 570 | return result; | ||
| 571 | }; | ||
| 572 | |||
| 573 | // --------------------------------------------------------------------------- | 540 | // --------------------------------------------------------------------------- |
| 574 | // Plugin extension | 541 | // 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.authenticationTemplate | 704 | ? 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: [] }; |
| @@ -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); |
| @@ -7,7 +7,12 @@ import { · 6 unmodified lines | |||
| 7 | } from "@executor-js/sdk/shared"; | 7 | } from "@executor-js/sdk/shared"; |
| 8 | 8 | ||
| 9 | import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; | 9 | import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; |
| 10 | import { McpAuthTemplate, McpIntegrationConfig } from "../sdk/types"; | 10 | import { |
| 11 | McpAuthMethod, | ||
| 12 | McpAuthMethodInput, | ||
| 13 | McpAuthTemplate, | ||
| 14 | McpIntegrationConfig, | ||
| 15 | } from "../sdk/types"; | ||
| 11 | 16 | ||
| 12 | // --------------------------------------------------------------------------- | 17 | // --------------------------------------------------------------------------- |
| 13 | // Params | 18 | // 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. | ||
| 98 | const ConfigureAuthPayload = Schema.Struct({ | ||
| 99 | authenticationTemplate: Schema.Array(McpAuthMethodInput), | ||
| 100 | mode: Schema.optional(Schema.Literals(["merge", "replace"])), | ||
| 101 | }); | ||
| 102 | |||
| 103 | const ConfigureAuthResponse = Schema.Struct({ | ||
| 104 | authenticationTemplate: Schema.Array(McpAuthMethod), | ||
| 105 | }); | ||
| 106 | |||
| 86 | const GetServerResponse = Schema.NullOr( | 107 | const 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 | ); |
| @@ -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 | ||
| 33 | const Api = addGroup(McpGroup); | 34 | const Api = addGroup(McpGroup); |
| @@ -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 | ); |
| @@ -1,26 +1,20 @@ | |||
| 1 | import { useReducer, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 1 | import { useReducer, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import { Link } from "@tanstack/react-router"; | ||
| 4 | import * as Exit from "effect/Exit"; | 3 | import * as Exit from "effect/Exit"; |
| 5 | import * as Match from "effect/Match"; | 4 | import * as Match from "effect/Match"; |
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Predicate from "effect/Predicate"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 10 | 5 | ||
| 11 | import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms"; | ||
| 12 | import { Button } from "@executor-js/react/components/button"; | 6 | import { Button } from "@executor-js/react/components/button"; |
| 13 | import { | 7 | import { |
| 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"; | ||
| 18 | import { | 13 | import { |
| 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"; |
| 23 | import { FieldLabel } from "@executor-js/react/components/field"; | ||
| 24 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 18 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 25 | import { Input } from "@executor-js/react/components/input"; | 19 | import { Input } from "@executor-js/react/components/input"; |
| 26 | import { Spinner } from "@executor-js/react/components/spinner"; | 20 | import { 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"; |
| 28 | import { | ||
| 29 | addIntegrationErrorMessage, | ||
| 30 | errorMessageFromExit, | ||
| 31 | FormErrorAlert, | ||
| 32 | SlugCollisionAlert, | ||
| 33 | useSlugAlreadyExists, | ||
| 34 | } from "@executor-js/react/lib/integration-add"; | ||
| 34 | 35 | ||
| 35 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 36 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 37 | import type { McpAuthMethodInput } from "../sdk/types"; | ||
| 36 | import { probeMcpEndpoint, addMcpServer } from "./atoms"; | 38 | import { probeMcpEndpoint, addMcpServer } from "./atoms"; |
| 37 | import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; | 39 | import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; |
| 38 | import { mcpAuthTemplateFromEditorValue } from "./auth-method-config"; | 40 | import { mcpAuthMethodInputFromEditorValue } from "./auth-method-config"; |
| 39 | import { mcpPresets, type McpPreset } from "../sdk/presets"; | 41 | import { mcpPresets, type McpPreset } from "../sdk/presets"; |
| 40 | 42 | ||
| 41 | // Post-redesign the remote add flow only REGISTERS the auth template through the | 43 | // The remote add flow REGISTERS the server's declared auth methods through the |
| 42 | // shared `AuthTemplateEditor` — accounts (the API key value / OAuth sign-in) are | 44 | // shared `AuthMethodListEditor` — accounts (the API key value / OAuth sign-in) |
| 43 | // added later from the integration's detail hub (P6: add without auth, connect | 45 | // 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 | ||
| 46 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | ||
| 47 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | ||
| 48 | const STDIO_ENV_ESCAPE_REPLACEMENTS: Readonly<Record<string, string>> = { | 51 | const 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 | ||
| 56 | const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => | ||
| 57 | Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { | ||
| 58 | onNone: () => fallback, | ||
| 59 | onSome: ({ message }) => message, | ||
| 60 | }); | ||
| 61 | |||
| 62 | const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean => | ||
| 63 | Option.match(Exit.findErrorOption(exit), { | ||
| 64 | onNone: () => false, | ||
| 65 | onSome: Predicate.isTagged("IntegrationAlreadyExistsError"), | ||
| 66 | }); | ||
| 67 | |||
| 68 | const integrationExistsMessage = (slug: string): string => | ||
| 69 | `An integration named "${slug}" already exists. To add more authentication, update your existing integration.`; | ||
| 70 | |||
| 71 | // --------------------------------------------------------------------------- | 59 | // --------------------------------------------------------------------------- |
| 72 | // Preset lookup | 60 | // 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; all | 205 | // 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.supportsDynamicRegistration | 208 | const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(() => { |
| 223 | ? ["oauth"] | 209 | if (!probe) return []; |
| 224 | : probe?.requiresAuthentication | 210 | 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 a | 243 | // 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 failures | 251 | // 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 the | 291 | // 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 the | 456 | {/* 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 | <AuthTemplateEditor | 463 | 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 | <Button | 474 | <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 "{remoteSlug}" 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 | <Button | 489 | <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 "{stdioSlug}" 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 | <Button | 563 | <Button |
| @@ -1,24 +1,17 @@ | |||
| 1 | import { useCallback, useEffect, useMemo, useState } from "react"; | 1 | import { useCallback, useMemo, useState } from "react"; |
| 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | import * as Exit from "effect/Exit"; | 4 | import * as Exit from "effect/Exit"; |
| 5 | 5 | ||
| 6 | import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared"; | 6 | import { IntegrationSlug } from "@executor-js/sdk/shared"; |
| 7 | import { createConnection } from "@executor-js/react/api/atoms"; | 7 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 8 | import { connectionWriteKeys, integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 9 | import { connectionIdentifier } from "@executor-js/react/lib/connection-name"; | ||
| 10 | import { | 8 | import { |
| 11 | AuthTemplateEditor, | 9 | AuthMethodListEditor, |
| 12 | type AuthTemplateEditorValue, | 10 | useAuthMethodList, |
| 13 | } from "@executor-js/react/components/auth-template-editor"; | 11 | type AuthMethodRow, |
| 14 | import { | 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"; | ||
| 19 | import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in"; | ||
| 20 | import { Button } from "@executor-js/react/components/button"; | 14 | import { Button } from "@executor-js/react/components/button"; |
| 21 | import { AddAccountModal } from "@executor-js/react/components/add-account-modal"; | ||
| 22 | import { | 15 | import { |
| 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"; |
| 30 | import { Input } from "@executor-js/react/components/input"; | ||
| 31 | import { Badge } from "@executor-js/react/components/badge"; | 23 | import { Badge } from "@executor-js/react/components/badge"; |
| 32 | import type { AuthMethod } from "@executor-js/react/lib/auth-placements"; | 24 | import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; |
| 33 | |||
| 34 | import { configureMcpServer, mcpServerAtom } from "./atoms"; | ||
| 35 | import type { McpAuthTemplate, McpIntegrationConfig } from "../sdk/types"; | ||
| 36 | import { mcpAuthTemplateFromEditorValue } from "./auth-method-config"; | ||
| 37 | 25 | ||
| 38 | const HEADER_TEMPLATE = AuthTemplateSlug.make("header"); | 26 | import { configureMcpAuth, mcpServerAtom } from "./atoms"; |
| 39 | const OAUTH_TEMPLATE = AuthTemplateSlug.make("oauth2"); | 27 | import type { McpAuthMethod, McpAuthMethodInput, McpIntegrationConfig } from "../sdk/types"; |
| 28 | import { | ||
| 29 | editorValueFromMcpAuthMethod, | ||
| 30 | mcpAuthMethodInputFromEditorValue, | ||
| 31 | } from "./auth-method-config"; | ||
| 40 | 32 | ||
| 41 | type McpServer = { | 33 | type 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 | ||
| 50 | const authTemplateToEditorValue = (auth: McpRemoteConfig["auth"]): AuthTemplateEditorValue => { | 42 | type 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 | ||
| 66 | const authTemplatesEqual = (left: McpAuthTemplate, right: McpAuthTemplate): boolean => { | 44 | const 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 its | 51 | // Remote edit — v2: the integration's endpoint is part of its identity |
| 76 | // identity (opaque-to-core config). The editable surface is the connection: an | 52 | // (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 | ||
| 80 | function RemoteEdit(props: { | 57 | function 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 | ||
| 325 | type McpRemoteConfig = Extract<McpIntegrationConfig, { transport: "remote" }>; | ||
| 326 | |||
| 327 | // --------------------------------------------------------------------------- | 181 | // --------------------------------------------------------------------------- |
| 328 | // Stdio read-only view | 182 | // Stdio read-only view |
| 329 | // --------------------------------------------------------------------------- | 183 | // --------------------------------------------------------------------------- |
| @@ -3,72 +3,33 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; · 2 unmodified lines | |||
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | import * as Exit from "effect/Exit"; | 4 | import * as Exit from "effect/Exit"; |
| 5 | 5 | ||
| 6 | import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared"; | 6 | import { IntegrationSlug } from "@executor-js/sdk/shared"; |
| 7 | import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; | 7 | import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; |
| 8 | import { AccountsSection } from "@executor-js/react/components/accounts-section"; | 8 | import { AccountsSection } from "@executor-js/react/components/accounts-section"; |
| 9 | import type { CreateCustomMethod } from "@executor-js/react/components/add-custom-method-modal"; | ||
| 10 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; | 9 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; |
| 10 | import { | ||
| 11 | useCustomMethodActions, | ||
| 12 | type AuthMethodsCodec, | ||
| 13 | type ConfigureAuthMethods, | ||
| 14 | } from "@executor-js/react/lib/custom-auth-methods"; | ||
| 11 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 15 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 12 | 16 | ||
| 13 | import { configureMcpServer, mcpServerAtom } from "./atoms"; | 17 | import { configureMcpAuth, mcpServerAtom } from "./atoms"; |
| 14 | import type { McpIntegrationConfig } from "../sdk/types"; | 18 | import { authMethodsFromConfig, mcpAuthMethodInputsFromPlacements } from "./auth-method-config"; |
| 19 | import type { McpAuthMethod } from "../sdk/types"; | ||
| 15 | 20 | ||
| 16 | const NONE_TEMPLATE = AuthTemplateSlug.make("none"); | 21 | // --------------------------------------------------------------------------- |
| 17 | const HEADER_TEMPLATE = AuthTemplateSlug.make("header"); | 22 | // MCP Accounts hub — fills the generic detail page's `accounts` slot. |
| 18 | const OAUTH_TEMPLATE = AuthTemplateSlug.make("oauth2"); | 23 | // |
| 19 | 24 | // Reads the integration's declared `authenticationTemplate` (via `getServer`), | |
| 20 | const 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 | |||
| 29 | const authMethodFromConfig = (config: McpIntegrationConfig): AuthMethod | null => { | ||
| 30 | if (config.transport === "stdio") return null; | ||
| 31 | const auth = config.auth; | ||
| 32 | if (auth.kind === "none") return NO_AUTH_METHOD; | ||
| 33 | if (auth.kind === "oauth2") { | ||
| 34 | return { | ||
| 35 | id: "oauth2", | ||
| 36 | label: "OAuth2", | ||
| 37 | kind: "oauth", | ||
| 38 | source: "spec", | ||
| 39 | template: OAUTH_TEMPLATE, | ||
| 40 | placements: [], | ||
| 41 | oauth: { discoveryUrl: config.endpoint, supportsDynamicRegistration: true }, | ||
| 42 | }; | ||
| 43 | } | ||
| 44 | return { | ||
| 45 | id: "header", | ||
| 46 | label: `API key (${auth.headerName})`, | ||
| 47 | kind: "apikey", | ||
| 48 | source: "custom", | ||
| 49 | template: HEADER_TEMPLATE, | ||
| 50 | placements: [ | ||
| 51 | { | ||
| 52 | carrier: "header", | ||
| 53 | name: auth.headerName, | ||
| 54 | prefix: auth.prefix ?? "", | ||
| 55 | }, | ||
| 56 | ], | ||
| 57 | }; | ||
| 58 | }; | ||
| 59 | |||
| 60 | const headerAuthFromPlacements = ( | ||
| 61 | placements: readonly Placement[], | ||
| 62 | ): { readonly headerName: string; readonly prefix?: string } | null => { | ||
| 63 | const header = placements.find( | ||
| 64 | (placement: Placement) => placement.carrier === "header" && placement.name.trim().length > 0, | ||
| 65 | ); | ||
| 66 | if (!header) return null; | ||
| 67 | return { | ||
| 68 | headerName: header.name.trim(), | ||
| 69 | ...(header.prefix ? { prefix: header.prefix } : {}), | ||
| 70 | }; | ||
| 71 | }; | ||
| 72 | 33 | ||
| 73 | export default function McpAccountsPanel(props: { | 34 | export 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 | <AccountsSection | 96 | <AccountsSection |
| @@ -14,19 +14,18 @@ import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in"; · 13 unmodified lines | |||
| 14 | import type { AuthMethod } from "@executor-js/react/lib/auth-placements"; | 14 | import type { AuthMethod } from "@executor-js/react/lib/auth-placements"; |
| 15 | 15 | ||
| 16 | import { mcpServerAtom } from "./atoms"; | 16 | import { mcpServerAtom } from "./atoms"; |
| 17 | 17 | import type { McpAuthMethod } from "../sdk/types"; | |
| 18 | const 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 the | 22 | // 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 ANY | 23 | // the OAuth flow to mint a connection through that method. "Connected" is |
| 25 | // owner already has a connection for this integration (the global owner toggle | 24 | // 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 a | 25 | // (the global owner toggle is retired, so the check merges both owners). The |
| 27 | // real create-target — chosen EXPLICITLY via the `owner` prop (default Workspace | 26 | // 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 | ||
| 32 | export default function McpSignInButton(props: { sourceId: string; owner?: Owner }) { | 31 | export default function McpSignInButton(props: { sourceId: string; owner?: Owner }) { |
| @@ -38,7 +37,9 @@ export default function McpSignInButton(props: { sourceId: string; owner?: Owner · 5 unmodified lines | |||
| 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.value | 44 | ? 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 === null | 52 | 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 && server | 69 | 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 | <> |
| @@ -26,3 +26,5 @@ export const probeMcpEndpoint = McpClient.mutation("mcp", "probeEndpoint"); · 25 unmodified lines | |||
| 26 | export const addMcpServer = McpClient.mutation("mcp", "addServer"); | 26 | export const addMcpServer = McpClient.mutation("mcp", "addServer"); |
| 27 | export const removeMcpServer = McpClient.mutation("mcp", "removeServer"); | 27 | export const removeMcpServer = McpClient.mutation("mcp", "removeServer"); |
| 28 | export const configureMcpServer = McpClient.mutation("mcp", "configureServer"); | 28 | export const configureMcpServer = McpClient.mutation("mcp", "configureServer"); |
| 29 | // Merge-append auth methods onto an integration's `authenticationTemplate`. | ||
| 30 | export const configureMcpAuth = McpClient.mutation("mcp", "configureAuth"); | ||
| @@ -1,11 +1,16 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; | 2 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; |
| 3 | 3 | ||
| 4 | import { mcpAuthTemplateFromEditorValue } from "./auth-method-config"; | 4 | import { |
| 5 | authMethodsFromConfig, | ||
| 6 | editorValueFromMcpAuthMethod, | ||
| 7 | mcpAuthMethodInputFromEditorValue, | ||
| 8 | mcpAuthMethodInputsFromPlacements, | ||
| 9 | } from "./auth-method-config"; | ||
| 5 | 10 | ||
| 6 | describe("mcpAuthTemplateFromEditorValue", () => { | 11 | describe("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 | |||
| 72 | describe("editorValueFromMcpAuthMethod", () => { | ||
| 73 | it("round-trips a header method into an apikey editor value", () => { | ||
| 74 | expect( | ||
| 75 | editorValueFromMcpAuthMethod({ | ||
| 76 | slug: "header", | ||
| 77 | kind: "header", | ||
| 78 | headerName: "X-Api-Key", | ||
| 79 | prefix: "Bearer ", | ||
| 80 | }), | ||
| 81 | ).toEqual({ | ||
| 82 | kind: "apikey", | ||
| 83 | placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }], | ||
| 84 | }); | ||
| 85 | }); | ||
| 86 | |||
| 87 | it("maps oauth2 to an oauth editor value with no endpoints (discovered at connect)", () => { | ||
| 88 | expect(editorValueFromMcpAuthMethod({ slug: "oauth2", kind: "oauth2" })).toEqual({ | ||
| 89 | kind: "oauth", | ||
| 90 | authorizationUrl: "", | ||
| 91 | tokenUrl: "", | ||
| 92 | scopes: [], | ||
| 93 | }); | ||
| 94 | }); | ||
| 95 | }); | ||
| 96 | |||
| 97 | describe("authMethodsFromConfig", () => { | ||
| 98 | it("projects every declared method and marks custom_ slugs as custom", () => { | ||
| 99 | const methods = authMethodsFromConfig( | ||
| 100 | [ | ||
| 101 | { slug: "oauth2", kind: "oauth2" }, | ||
| 102 | { slug: "custom_abc123", kind: "header", headerName: "X-Api-Key" }, | ||
| 103 | { slug: "none", kind: "none" }, | ||
| 104 | ], | ||
| 105 | "https://mcp.example.com/mcp", | ||
| 106 | ); | ||
| 107 | |||
| 108 | expect( | ||
| 109 | methods.map((method) => ({ | ||
| 110 | id: method.id, | ||
| 111 | kind: method.kind, | ||
| 112 | source: method.source, | ||
| 113 | template: String(method.template), | ||
| 114 | })), | ||
| 115 | ).toEqual([ | ||
| 116 | { id: "oauth2", kind: "oauth", source: "spec", template: "oauth2" }, | ||
| 117 | { id: "custom_abc123", kind: "apikey", source: "custom", template: "custom_abc123" }, | ||
| 118 | { id: "none", kind: "none", source: "spec", template: "none" }, | ||
| 119 | ]); | ||
| 120 | expect(methods[0]?.oauth?.discoveryUrl).toBe("https://mcp.example.com/mcp"); | ||
| 121 | }); | ||
| 122 | }); | ||
| 123 | |||
| 124 | describe("mcpAuthMethodInputsFromPlacements", () => { | ||
| 125 | it("builds one header method from the first named header placement", () => { | ||
| 126 | expect( | ||
| 127 | mcpAuthMethodInputsFromPlacements([ | ||
| 128 | { carrier: "query", name: "api_key", prefix: "" }, | ||
| 129 | { carrier: "header", name: "X-Token", prefix: "Bearer " }, | ||
| 130 | ]), | ||
| 131 | ).toEqual([{ kind: "header", headerName: "X-Token", prefix: "Bearer " }]); | ||
| 132 | }); | ||
| 133 | |||
| 134 | it("is empty when no usable header placement exists", () => { | ||
| 135 | expect( | ||
| 136 | mcpAuthMethodInputsFromPlacements([{ carrier: "query", name: "api_key", prefix: "" }]), | ||
| 137 | ).toEqual([]); | ||
| 138 | }); | ||
| 139 | }); | ||
| @@ -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-agnostic | 4 | // 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 the | 6 | // stores a slugged `authenticationTemplate` array (`none` / `header` / |
| 7 | // editor value to that shape and lives with the MCP plugin because it touches | 7 | // `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 | ||
| 12 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | ||
| 11 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; | 13 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; |
| 14 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; | ||
| 12 | 15 | ||
| 13 | import type { McpAuthTemplate } from "../sdk/types"; | 16 | import type { McpAuthMethod, McpAuthMethodInput } from "../sdk/types"; |
| 14 | 17 | ||
| 15 | /** Convert a generic editor value into MCP's single `McpAuthTemplate`. An | 18 | /** 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 placement | 19 | * 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 is | 20 | * method using its FIRST named header placement, preserving the prefix; an |
| 18 | * preserved. OAuth maps to `oauth2`; `none` to `none`. An apiKey value with no | 21 | * apiKey value with no usable header placement falls back to `none`. */ |
| 19 | * usable header placement falls back to `none`. */ | 22 | export function mcpAuthMethodInputFromEditorValue( |
| 20 | export 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. */ | ||
| 41 | export function editorValueFromMcpAuthMethod(method: McpAuthMethod): AuthTemplateEditorValue { | ||
| 42 | if (method.kind === "oauth2") { | ||
| 43 | return { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }; | ||
| 44 | } | ||
| 45 | if (method.kind === "header") { | ||
| 46 | return { | ||
| 47 | kind: "apikey", | ||
| 48 | placements: [{ carrier: "header", name: method.headerName, prefix: method.prefix ?? "" }], | ||
| 49 | }; | ||
| 50 | } | ||
| 51 | return { kind: "none" }; | ||
| 52 | } | ||
| 53 | |||
| 54 | /** Project the stored methods into the generic `AuthMethod[]` the hub renders. | ||
| 55 | * Mirrors the server's `describeMcpAuthMethods`; `custom_` slugs mark | ||
| 56 | * user-created methods (removable from the hub). `endpoint` feeds the oauth | ||
| 57 | * method's probe-at-connect `discoveryUrl`. */ | ||
| 58 | export function authMethodsFromConfig( | ||
| 59 | methods: readonly McpAuthMethod[], | ||
| 60 | endpoint: string, | ||
| 61 | ): AuthMethod[] { | ||
| 62 | return methods.map((method: McpAuthMethod): AuthMethod => { | ||
| 63 | const source: "spec" | "custom" = method.slug.startsWith("custom_") ? "custom" : "spec"; | ||
| 64 | const template = AuthTemplateSlug.make(method.slug); | ||
| 65 | if (method.kind === "oauth2") { | ||
| 66 | return { | ||
| 67 | id: method.slug, | ||
| 68 | label: "OAuth", | ||
| 69 | kind: "oauth", | ||
| 70 | source, | ||
| 71 | template, | ||
| 72 | placements: [], | ||
| 73 | oauth: { discoveryUrl: endpoint, supportsDynamicRegistration: true }, | ||
| 74 | }; | ||
| 75 | } | ||
| 76 | if (method.kind === "header") { | ||
| 77 | return { | ||
| 78 | id: method.slug, | ||
| 79 | label: `API key (${method.headerName})`, | ||
| 80 | kind: "apikey", | ||
| 81 | source, | ||
| 82 | template, | ||
| 83 | placements: [{ carrier: "header", name: method.headerName, prefix: method.prefix ?? "" }], | ||
| 84 | }; | ||
| 85 | } | ||
| 86 | return { | ||
| 87 | id: method.slug, | ||
| 88 | label: "No authentication", | ||
| 89 | kind: "none", | ||
| 90 | source, | ||
| 91 | template, | ||
| 92 | placements: [], | ||
| 93 | }; | ||
| 94 | }); | ||
| 95 | } | ||
| 96 | |||
| 97 | /** Build MCP header-method inputs for a custom method from generic placements. | ||
| 98 | * MCP carries one header per method, so only the FIRST named header placement | ||
| 99 | * is used (query placements have no MCP credential carrier). Empty when no | ||
| 100 | * usable header placement exists. */ | ||
| 101 | export function mcpAuthMethodInputsFromPlacements( | ||
| 102 | placements: readonly Placement[], | ||
| 103 | ): McpAuthMethodInput[] { | ||
| 104 | const header = placements.find( | ||
| 105 | (placement: Placement) => placement.carrier === "header" && placement.name.trim().length > 0, | ||
| 106 | ); | ||
| 107 | if (!header) return []; | ||
| 108 | return [ | ||
| 109 | { | ||
| 110 | kind: "header", | ||
| 111 | headerName: header.name.trim(), | ||
| 112 | ...(header.prefix ? { prefix: header.prefix } : {}), | ||
| 113 | }, | ||
| 114 | ]; | ||
| 115 | } | ||
| @@ -33,7 +33,7 @@ export type McpConnector = Effect.Effect<McpConnection, McpConnectionError>; · 32 unmodified lines | |||
| 33 | 33 | ||
| 34 | export type RemoteConnectorInput = Omit< | 34 | export 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>; |
| @@ -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's | 7 | // `describeMcpAuthMethods` projects the stored MCP config into the catalog's |
| 8 | // plugin-agnostic `AuthMethodDescriptor[]`. It is pure/sync and must tolerate a | 8 | // 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 | ||
| 12 | const recordWith = (config: IntegrationConfig): IntegrationRecord => ({ | 14 | const recordWith = (config: IntegrationConfig): IntegrationRecord => ({ |
| @@ -20,12 +22,12 @@ const recordWith = (config: IntegrationConfig): IntegrationRecord => ({ · 7 unmodified lines | |||
| 20 | }); | 22 | }); |
| 21 | 23 | ||
| 22 | describe("describeMcpAuthMethods", () => { | 24 | describe("describeMcpAuthMethods", () => { |
| 23 | it("projects a remote oauth2 config to one oauth method carrying the discovery URL", () => { | 25 | it("projects an oauth2 method carrying the discovery URL", () => { |
| 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" }); |
| @@ -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 with | 353 | // 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 they | 354 | // an empty tool set so the user can retry via `connections.refresh` once they |
| 306 | // fix the underlying problem. | 355 | // fix the underlying problem. |
| @@ -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 | |||
| 35 | import { mcpPresets } from "./presets"; | 36 | import { mcpPresets } from "./presets"; |
| 36 | import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; | 37 | import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; |
| 37 | import { | 38 | import { |
| 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. */ | ||
| 173 | export const McpConfigureAuthInputSchema = Schema.Struct({ | ||
| 174 | authenticationTemplate: Schema.Array(McpAuthMethodInput), | ||
| 175 | mode: Schema.optional(Schema.Literals(["merge", "replace"])), | ||
| 176 | }); | ||
| 177 | export type McpConfigureAuthInput = typeof McpConfigureAuthInputSchema.Type; | ||
| 178 | |||
| 155 | const McpProbeEndpointInputSchema = Schema.Struct({ | 179 | const 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 resolved | 408 | // 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. */ | ||
| 418 | const selectAuthMethod = ( | ||
| 419 | config: McpRemoteIntegrationConfig, | ||
| 420 | templateSlug: string | null, | ||
| 421 | ): McpAuthMethod | undefined => { | ||
| 422 | const methods = config.authenticationTemplate; | ||
| 423 | if (templateSlug !== null) { | ||
| 424 | const match = methods.find((method: McpAuthMethod) => method.slug === templateSlug); | ||
| 425 | if (match) return match; | ||
| 426 | } | ||
| 427 | return methods.length === 1 ? methods[0] : undefined; | ||
| 428 | }; | ||
| 429 | |||
| 386 | const buildConnectorInput = ( | 430 | const 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's | 479 | // Declared auth methods — project the stored MCP config into the catalog's |
| 435 | // plugin-agnostic `AuthMethodDescriptor[]`. Pure and tolerant of a malformed or | 480 | // 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 inputs | 484 | // 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 placement | 486 | // header → an apikey method carrying the header placement |
| 441 | // oauth2 → one oauth method carrying the MCP endpoint to probe | 487 | // oauth2 → an oauth method carrying the MCP endpoint to probe |
| 442 | // (`discoveryUrl`); endpoints are discovered live at | 488 | // (`discoveryUrl`); endpoints are discovered live at |
| 443 | // connect time, so they are NOT pre-resolved here. We | 489 | // connect time, so they are NOT pre-resolved here. We |
| 444 | // mark `supportsDynamicRegistration: true` because MCP | 490 | // 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 | ||
| 491 | export const describeMcpIntegrationDisplay = ( | 529 | export 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 set | 869 | // 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 | } |
| @@ -25,7 +25,10 @@ export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", · 24 unmodified lines | |||
| 25 | export type McpTransport = typeof McpTransport.Type; | 25 | export 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 OAuthClientProvider | 37 | // via the MCP SDK's OAuthClientProvider |
| 35 | // --------------------------------------------------------------------------- | 38 | // --------------------------------------------------------------------------- |
| 36 | 39 | ||
| 40 | export const McpAuthMethod = Schema.Union([ | ||
| 41 | Schema.Struct({ slug: Schema.String, kind: Schema.Literal("none") }), | ||
| 42 | Schema.Struct({ | ||
| 43 | slug: Schema.String, | ||
| 44 | kind: Schema.Literal("header"), | ||
| 45 | headerName: Schema.String, | ||
| 46 | prefix: Schema.optional(Schema.String), | ||
| 47 | }), | ||
| 48 | Schema.Struct({ slug: Schema.String, kind: Schema.Literal("oauth2") }), | ||
| 49 | ]); | ||
| 50 | export type McpAuthMethod = typeof McpAuthMethod.Type; | ||
| 51 | |||
| 52 | /** Legacy single-method template (pre-array configs and the `auth` input | ||
| 53 | * shorthand on `addServer`): the method without a slug. Lifted into a | ||
| 54 | * one-element `authenticationTemplate` whose slug is the method kind, which | ||
| 55 | * matches the template slugs existing connections were created against. */ | ||
| 37 | export const McpAuthTemplate = Schema.Union([ | 56 | export 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 | ]); |
| 46 | export type McpAuthTemplate = typeof McpAuthTemplate.Type; | 65 | export type McpAuthTemplate = typeof McpAuthTemplate.Type; |
| 47 | 66 | ||
| 67 | export const mcpAuthMethodFromLegacy = (auth: McpAuthTemplate): McpAuthMethod => ({ | ||
| 68 | ...auth, | ||
| 69 | slug: auth.kind, | ||
| 70 | }); | ||
| 71 | |||
| 72 | /** Input variant of `McpAuthMethod` — callers (UI, agents) may omit the slug; | ||
| 73 | * `normalizeMcpAuthMethods` backfills it from the kind. */ | ||
| 74 | export const McpAuthMethodInput = Schema.Union([ | ||
| 75 | Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("none") }), | ||
| 76 | Schema.Struct({ | ||
| 77 | slug: Schema.optional(Schema.String), | ||
| 78 | kind: Schema.Literal("header"), | ||
| 79 | headerName: Schema.String, | ||
| 80 | prefix: Schema.optional(Schema.String), | ||
| 81 | }), | ||
| 82 | Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("oauth2") }), | ||
| 83 | ]); | ||
| 84 | export type McpAuthMethodInput = typeof McpAuthMethodInput.Type; | ||
| 85 | |||
| 86 | /** Assign each method a stable slug: a caller-provided one wins, otherwise the | ||
| 87 | * kind (`oauth2`, `header`, `none`), suffixed `_2`, `_3`, … on collision. */ | ||
| 88 | export const normalizeMcpAuthMethods = ( | ||
| 89 | methods: readonly McpAuthMethodInput[], | ||
| 90 | ): readonly McpAuthMethod[] => { | ||
| 91 | const taken = new Set<string>(); | ||
| 92 | return methods.map((method: McpAuthMethodInput): McpAuthMethod => { | ||
| 93 | const requested = method.slug?.trim() || method.kind; | ||
| 94 | let slug = requested; | ||
| 95 | for (let n = 2; taken.has(slug); n += 1) slug = `${requested}_${n}`; | ||
| 96 | taken.add(slug); | ||
| 97 | return { ...method, slug }; | ||
| 98 | }); | ||
| 99 | }; | ||
| 100 | |||
| 48 | // --------------------------------------------------------------------------- | 101 | // --------------------------------------------------------------------------- |
| 49 | // Integration config — the opaque blob stored on the integration row. A | 102 | // 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 | }); |
| 71 | export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type; | 125 | export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type; |
| 72 | 126 | ||
| @@ -90,18 +144,33 @@ export const McpIntegrationConfig = Schema.Union([ · 17 unmodified lines | |||
| 90 | export type McpIntegrationConfig = typeof McpIntegrationConfig.Type; | 144 | export type McpIntegrationConfig = typeof McpIntegrationConfig.Type; |
| 91 | 145 | ||
| 92 | const decodeIntegrationConfig = Schema.decodeUnknownOption(McpIntegrationConfig); | 146 | const decodeIntegrationConfig = Schema.decodeUnknownOption(McpIntegrationConfig); |
| 147 | const decodeLegacyAuth = Schema.decodeUnknownOption(McpAuthTemplate); | ||
| 93 | 148 | ||
| 94 | /** Parse an opaque integration `config` blob into a typed MCP config, or null | 149 | /** 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", … }]`). */ | ||
| 96 | export const parseMcpIntegrationConfig = (config: unknown): McpIntegrationConfig | null => { | 154 | export 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 | }; |
| @@ -1,19 +1,14 @@ | |||
| 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import { Link } from "@tanstack/react-router"; | ||
| 4 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; | 4 | import * as Exit from "effect/Exit"; |
| 6 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 7 | import * as Predicate from "effect/Predicate"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 10 | 6 | ||
| 11 | import { | 7 | import { |
| 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"; |
| 16 | import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms"; | ||
| 17 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 12 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 18 | import { | 13 | import { |
| 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"; |
| 22 | import { Button } from "@executor-js/react/components/button"; | 17 | import { Button } from "@executor-js/react/components/button"; |
| 23 | import { | 18 | import { |
| 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"; | ||
| 27 | import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack"; | 24 | import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack"; |
| 28 | import { FieldLabel } from "@executor-js/react/components/field"; | 25 | import { FieldLabel } from "@executor-js/react/components/field"; |
| 29 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 26 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 30 | import { Textarea } from "@executor-js/react/components/textarea"; | 27 | import { Textarea } from "@executor-js/react/components/textarea"; |
| 31 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; | 28 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; |
| 32 | import { PlusIcon, XIcon } from "lucide-react"; | 29 | import { |
| 30 | addIntegrationErrorMessage, | ||
| 31 | errorMessageFromExit, | ||
| 32 | FormErrorAlert, | ||
| 33 | SlugCollisionAlert, | ||
| 34 | useSlugAlreadyExists, | ||
| 35 | } from "@executor-js/react/lib/integration-add"; | ||
| 33 | 36 | ||
| 34 | import { authenticationFromEditorValue, editorValueFromAuthentication } from "./auth-method-config"; | 37 | import { authenticationFromEditorValue, editorValueFromAuthentication } from "./auth-method-config"; |
| 35 | import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; | 38 | import { 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 | ||
| 75 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | ||
| 76 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | ||
| 77 | |||
| 78 | const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => | ||
| 79 | Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { | ||
| 80 | onNone: () => fallback, | ||
| 81 | onSome: ({ message }) => message, | ||
| 82 | }); | ||
| 83 | |||
| 84 | const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean => | ||
| 85 | Option.match(Exit.findErrorOption(exit), { | ||
| 86 | onNone: () => false, | ||
| 87 | onSome: Predicate.isTagged("IntegrationAlreadyExistsError"), | ||
| 88 | }); | ||
| 89 | |||
| 90 | const integrationExistsMessage = (slug: string): string => | ||
| 91 | `An integration named "${slug}" already exists. To add more authentication, update your existing integration.`; | ||
| 92 | |||
| 93 | // --------------------------------------------------------------------------- | 78 | // --------------------------------------------------------------------------- |
| 94 | // OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve | 79 | // 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 flow | 365 | // Editable auth methods, seeded from the spec-detected templates. The add flow |
| 389 | // registers EVERY method (P6) — so this is a LIST, preserving multi-method | 366 | // registers EVERY method (P6) — so this is a LIST, preserving multi-method |
| 390 | // specs (e.g. apiKey + OAuth). Each row carries its editor value plus the | 367 | // specs (e.g. apiKey + OAuth). Each seed carries the detected template's |
| 391 | // detected template's original `seedSlug`, so an unedited detected method | 368 | // original slug, so an unedited detected method submits with its EXACT |
| 392 | // submits with its EXACT original slug (preserving behavior); added methods | 369 | // original slug (preserving behavior); added methods (no seed) get a |
| 393 | // (no seed) get a deterministic fresh slug. The user can edit, add, or remove | 370 | // deterministic fresh slug. Re-seeded whenever a fresh detection arrives |
| 394 | // a method before submitting; on submit the list converts back to | 371 | // (keyed on the detected templates, stable per analysis + base URL). |
| 395 | // `Authentication[]`. Re-seeded whenever a fresh detection arrives (keyed on | 372 | 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[]`. Drops | 387 | // The methods to register, mapped back to stored `Authentication[]`. Drops |
| 439 | // `none` rows (nothing to register). An unedited detected method keeps its | 388 | // `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 integration | 401 | // 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 (no | 406 | // 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 a | 407 | // 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 "{resolvedSourceId}" 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}> |
| @@ -10,8 +10,12 @@ import { TriangleAlert } from "lucide-react"; · 9 unmodified lines | |||
| 10 | import { AccountsSection } from "@executor-js/react/components/accounts-section"; | 10 | import { AccountsSection } from "@executor-js/react/components/accounts-section"; |
| 11 | import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert"; | 11 | import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert"; |
| 12 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 12 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 13 | import type { CreateCustomMethod } from "@executor-js/react/components/add-custom-method-modal"; | ||
| 14 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; | 13 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; |
| 14 | import { | ||
| 15 | useCustomMethodActions, | ||
| 16 | type AuthMethodsCodec, | ||
| 17 | type ConfigureAuthMethods, | ||
| 18 | } from "@executor-js/react/lib/custom-auth-methods"; | ||
| 15 | 19 | ||
| 16 | import { openApiConfigAtom, openapiConfigure } from "./atoms"; | 20 | import { openApiConfigAtom, openapiConfigure } from "./atoms"; |
| 17 | import { authMethodsFromConfig, templateFromPlacements } from "./auth-method-config"; | 21 | import { 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 generic | 77 | // Custom-method create/remove: the shared skeleton (merge-append → diff out |
| 74 | // placements (slug omitted → backend backfills `custom_<id>`), merge-append it | 78 | // 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 API | 113 | // For a bundled `google` integration, surface a caution when any selected API |
| 112 | // needs a privileged or unsupported OAuth consent the user should know about | 114 | // 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. |
| @@ -3,7 +3,6 @@ import type { Layer } from "effect"; · 2 unmodified lines | |||
| 3 | import { HttpClient } from "effect/unstable/http"; | 3 | import { HttpClient } from "effect/unstable/http"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 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 | |||
| 521 | const specInputToGoogleBundle = (spec: OpenApiSpecInput): readonly string[] | undefined => | 521 | const 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 | |||
| 532 | const shortId = (): string => Math.random().toString(36).slice(2, 8); | ||
| 533 | |||
| 534 | /** Generate a `custom_<id>` slug not present in `taken`. */ | ||
| 535 | const freshCustomSlug = (taken: ReadonlySet<string>): AuthTemplateSlug => { | ||
| 536 | let candidate = `custom_${shortId()}`; | ||
| 537 | while (taken.has(candidate)) candidate = `custom_${shortId()}`; | ||
| 538 | return AuthTemplateSlug.make(candidate); | ||
| 539 | }; | ||
| 540 | |||
| 541 | /** Merge incoming auth methods into the existing template array. Entries with a | ||
| 542 | * matching slug replace the existing entry; otherwise they are appended. | ||
| 543 | * Incoming entries lacking a slug (or whose slug collides with another entry | ||
| 544 | * added in this same call) get a fresh `custom_<id>` slug. */ | ||
| 545 | const mergeAuthenticationTemplate = ( | ||
| 546 | existing: readonly Authentication[], | ||
| 547 | incoming: readonly Authentication[], | ||
| 548 | ): readonly Authentication[] => { | ||
| 549 | const result: Authentication[] = existing.map((entry) => entry); | ||
| 550 | const taken = new Set<string>(result.map((entry) => String(entry.slug))); | ||
| 551 | for (const entry of incoming) { | ||
| 552 | // `slug` is branded as required, but JSON callers may omit it; read defensively. | ||
| 553 | const rawSlug = (entry as { readonly slug?: unknown }).slug; | ||
| 554 | const requested = typeof rawSlug === "string" ? rawSlug.trim() : ""; | ||
| 555 | const existingIndex = result.findIndex((current) => String(current.slug) === requested); | ||
| 556 | if (requested.length > 0 && existingIndex >= 0) { | ||
| 557 | // Replace the matching entry in place, preserving array order. | ||
| 558 | result[existingIndex] = entry; | ||
| 559 | continue; | ||
| 560 | } | ||
| 561 | const slug = | ||
| 562 | requested.length > 0 && !taken.has(requested) | ||
| 563 | ? AuthTemplateSlug.make(requested) | ||
| 564 | : freshCustomSlug(taken); | ||
| 565 | taken.add(String(slug)); | ||
| 566 | result.push({ ...entry, slug } as Authentication); | ||
| 567 | } | ||
| 568 | return result; | ||
| 569 | }; | ||
| 570 | |||
| 571 | // --------------------------------------------------------------------------- | 524 | // --------------------------------------------------------------------------- |
| 572 | // Declared auth methods — project the stored `authenticationTemplate` into the | 525 | // Declared auth methods — project the stored `authenticationTemplate` into the |
| 573 | // catalog's plugin-agnostic `AuthMethodDescriptor[]`. This mirrors the client's | 526 | // 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.authenticationTemplate | 896 | ? input.authenticationTemplate |
| 944 | : mergeAuthenticationTemplate( | 897 | : mergeAuthTemplates( |
| 945 | current.authenticationTemplate ?? [], | 898 | current.authenticationTemplate ?? [], |
| 946 | input.authenticationTemplate, | 899 | input.authenticationTemplate, |
| 947 | ); | 900 | ); |
| @@ -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 | }, |
| @@ -0,0 +1,155 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Auth-method LIST editor — the add-flow's "How does this API authenticate?" | ||
| 3 | // section, as a list. Every plugin registers EVERY declared method (P6: add | ||
| 4 | // without auth, connect later), so the add flow edits a list of generic | ||
| 5 | // `AuthTemplateEditorValue` rows seeded from detection (spec analysis, endpoint | ||
| 6 | // probe, …) with add/remove and a per-row `AuthTemplateEditor`. | ||
| 7 | // | ||
| 8 | // Composition: `useAuthMethodList` is the headless row state (seeding, | ||
| 9 | // edit/add/remove); `AuthMethodListEditor` is the presentation. Plugins own | ||
| 10 | // only the codec at the edges — seeds in (detection → editor values) and | ||
| 11 | // submit out (editor values → wire templates). | ||
| 12 | // --------------------------------------------------------------------------- | ||
| 13 | |||
| 14 | import { useCallback, useEffect, useRef, useState } from "react"; | ||
| 15 | import { PlusIcon, XIcon } from "lucide-react"; | ||
| 16 | |||
| 17 | import { Button } from "./button"; | ||
| 18 | import { FieldLabel } from "./field"; | ||
| 19 | import { | ||
| 20 | AuthTemplateEditor, | ||
| 21 | emptyApiKeyValue, | ||
| 22 | type AuthTemplateEditorKind, | ||
| 23 | type AuthTemplateEditorPreset, | ||
| 24 | type AuthTemplateEditorValue, | ||
| 25 | } from "./auth-template-editor"; | ||
| 26 | |||
| 27 | export interface AuthMethodSeed { | ||
| 28 | readonly value: AuthTemplateEditorValue; | ||
| 29 | /** The detected method's stable slug — an unedited seeded row submits with | ||
| 30 | * its EXACT original slug (preserving connections bound against it). */ | ||
| 31 | readonly slug?: string; | ||
| 32 | /** Detection label (e.g. the spec's security-scheme name) shown on the row. */ | ||
| 33 | readonly label?: string; | ||
| 34 | } | ||
| 35 | |||
| 36 | export interface AuthMethodRow { | ||
| 37 | readonly value: AuthTemplateEditorValue; | ||
| 38 | readonly seedSlug?: string; | ||
| 39 | readonly seedLabel?: string; | ||
| 40 | } | ||
| 41 | |||
| 42 | export interface AuthMethodListState { | ||
| 43 | readonly rows: readonly AuthMethodRow[]; | ||
| 44 | readonly setRowAt: (index: number, next: AuthTemplateEditorValue) => void; | ||
| 45 | readonly removeRowAt: (index: number) => void; | ||
| 46 | readonly addRow: () => void; | ||
| 47 | } | ||
| 48 | |||
| 49 | /** Headless row state for the method list. Re-seeds whenever `seeds` changes | ||
| 50 | * identity (detection results are stable per analysis), discarding edits — | ||
| 51 | * fresh detection means a fresh starting set. */ | ||
| 52 | export function useAuthMethodList(seeds: readonly AuthMethodSeed[]): AuthMethodListState { | ||
| 53 | const [rows, setRows] = useState<readonly AuthMethodRow[]>([]); | ||
| 54 | const seededFromRef = useRef<readonly AuthMethodSeed[] | null>(null); | ||
| 55 | useEffect(() => { | ||
| 56 | if (seededFromRef.current === seeds) return; | ||
| 57 | seededFromRef.current = seeds; | ||
| 58 | setRows( | ||
| 59 | seeds.map( | ||
| 60 | (seed: AuthMethodSeed): AuthMethodRow => ({ | ||
| 61 | value: seed.value, | ||
| 62 | ...(seed.slug !== undefined ? { seedSlug: seed.slug } : {}), | ||
| 63 | ...(seed.label !== undefined ? { seedLabel: seed.label } : {}), | ||
| 64 | }), | ||
| 65 | ), | ||
| 66 | ); | ||
| 67 | }, [seeds]); | ||
| 68 | |||
| 69 | const setRowAt = useCallback((index: number, next: AuthTemplateEditorValue) => { | ||
| 70 | setRows((current: readonly AuthMethodRow[]) => | ||
| 71 | current.map((row: AuthMethodRow, i: number) => (i === index ? { ...row, value: next } : row)), | ||
| 72 | ); | ||
| 73 | }, []); | ||
| 74 | |||
| 75 | const removeRowAt = useCallback((index: number) => { | ||
| 76 | setRows((current: readonly AuthMethodRow[]) => | ||
| 77 | current.filter((_row: AuthMethodRow, i: number) => i !== index), | ||
| 78 | ); | ||
| 79 | }, []); | ||
| 80 | |||
| 81 | const addRow = useCallback(() => { | ||
| 82 | setRows((current: readonly AuthMethodRow[]) => [...current, { value: emptyApiKeyValue() }]); | ||
| 83 | }, []); | ||
| 84 | |||
| 85 | return { rows, setRowAt, removeRowAt, addRow }; | ||
| 86 | } | ||
| 87 | |||
| 88 | export interface AuthMethodListEditorProps { | ||
| 89 | readonly list: AuthMethodListState; | ||
| 90 | readonly title?: string; | ||
| 91 | /** Shown when the list is empty (e.g. "No authentication detected. …"). */ | ||
| 92 | readonly emptyHint?: string; | ||
| 93 | /** Shown under the list when at least one row exists. */ | ||
| 94 | readonly footerHint?: string; | ||
| 95 | /** Per-row editor restrictions — see `AuthTemplateEditorProps`. */ | ||
| 96 | readonly allowedKinds?: readonly AuthTemplateEditorKind[]; | ||
| 97 | readonly presets?: readonly AuthTemplateEditorPreset[]; | ||
| 98 | readonly oauthMetadata?: "editable" | "discovered"; | ||
| 99 | } | ||
| 100 | |||
| 101 | export function AuthMethodListEditor(props: AuthMethodListEditorProps) { | ||
| 102 | const { list, allowedKinds, presets, oauthMetadata } = props; | ||
| 103 | return ( | ||
| 104 | <section className="space-y-3"> | ||
| 105 | <div className="flex items-center justify-between gap-3"> | ||
| 106 | <FieldLabel>{props.title ?? "How does this API authenticate?"}</FieldLabel> | ||
| 107 | <Button type="button" variant="outline" size="sm" onClick={list.addRow}> | ||
| 108 | <PlusIcon /> | ||
| 109 | Add method | ||
| 110 | </Button> | ||
| 111 | </div> | ||
| 112 | {list.rows.length === 0 ? ( | ||
| 113 | props.emptyHint ? ( | ||
| 114 | <p className="text-[11px] text-muted-foreground">{props.emptyHint}</p> | ||
| 115 | ) : null | ||
| 116 | ) : ( | ||
| 117 | <div className="flex flex-col gap-3"> | ||
| 118 | {list.rows.map((row: AuthMethodRow, index: number) => ( | ||
| 119 | <div | ||
| 120 | key={index} | ||
| 121 | className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3" | ||
| 122 | > | ||
| 123 | <div className="flex items-center justify-between"> | ||
| 124 | <span className="text-xs font-medium text-muted-foreground"> | ||
| 125 | Method {index + 1} | ||
| 126 | {row.seedLabel ? ` · ${row.seedLabel}` : ""} | ||
| 127 | </span> | ||
| 128 | <Button | ||
| 129 | type="button" | ||
| 130 | variant="ghost" | ||
| 131 | size="icon-sm" | ||
| 132 | aria-label="Remove method" | ||
| 133 | className="text-muted-foreground hover:text-foreground" | ||
| 134 | onClick={() => list.removeRowAt(index)} | ||
| 135 | > | ||
| 136 | <XIcon /> | ||
| 137 | </Button> | ||
| 138 | </div> | ||
| 139 | <AuthTemplateEditor | ||
| 140 | value={row.value} | ||
| 141 | onChange={(next: AuthTemplateEditorValue) => list.setRowAt(index, next)} | ||
| 142 | {...(allowedKinds ? { allowedKinds } : {})} | ||
| 143 | {...(presets ? { presets } : {})} | ||
| 144 | {...(oauthMetadata ? { oauthMetadata } : {})} | ||
| 145 | /> | ||
| 146 | </div> | ||
| 147 | ))} | ||
| 148 | </div> | ||
| 149 | )} | ||
| 150 | {list.rows.length > 0 && props.footerHint ? ( | ||
| 151 | <p className="text-[11px] text-muted-foreground">{props.footerHint}</p> | ||
| 152 | ) : null} | ||
| 153 | </section> | ||
| 154 | ); | ||
| 155 | } | ||
| @@ -0,0 +1,79 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Custom auth-method actions — the shared create/remove skeleton behind every | ||
| 3 | // accounts panel's "+ Custom method" flow. The shape is identical across | ||
| 4 | // plugins (openapi, graphql, mcp): convert the generic placements into the | ||
| 5 | // plugin's wire templates, merge-append via the plugin's configureAuth | ||
| 6 | // endpoint, then diff the response against the prior slugs to find the created | ||
| 7 | // method. Remove filters the set by slug and replaces. | ||
| 8 | // | ||
| 9 | // The plugin contributes only a small CODEC (wire-template ↔ generic | ||
| 10 | // `AuthMethod`) and its configure mutation; everything else lives here. | ||
| 11 | // --------------------------------------------------------------------------- | ||
| 12 | |||
| 13 | import { useCallback } from "react"; | ||
| 14 | import * as Exit from "effect/Exit"; | ||
| 15 | |||
| 16 | import type { AuthMethod, Placement } from "./auth-placements"; | ||
| 17 | import type { CreateCustomMethod } from "../components/add-custom-method-modal"; | ||
| 18 | |||
| 19 | /** The plugin-specific edges of the custom-method flow. `T` is the plugin's | ||
| 20 | * wire auth-template type (an object carrying a `slug`). */ | ||
| 21 | export interface AuthMethodsCodec<T> { | ||
| 22 | /** Project wire templates into the generic `AuthMethod[]` the hub renders. */ | ||
| 23 | readonly toAuthMethods: (templates: readonly T[]) => readonly AuthMethod[]; | ||
| 24 | /** Build the wire templates for a custom method from its generic placements. | ||
| 25 | * Empty result ⇒ the placements declare nothing persistable. Slugs are | ||
| 26 | * omitted/blank — the backend backfills `custom_<id>`. */ | ||
| 27 | readonly templatesFromPlacements: (placements: readonly Placement[]) => readonly T[]; | ||
| 28 | readonly slugOf: (template: T) => string; | ||
| 29 | } | ||
| 30 | |||
| 31 | /** The plugin's configureAuth mutation, normalized: merge-append by default, | ||
| 32 | * `replace` swaps the whole declared set. Resolves to the resulting template | ||
| 33 | * array via an Exit (the atom mutation's `promiseExit`). */ | ||
| 34 | export type ConfigureAuthMethods<T> = (input: { | ||
| 35 | readonly authenticationTemplate: readonly T[]; | ||
| 36 | readonly mode?: "merge" | "replace"; | ||
| 37 | }) => Promise<Exit.Exit<readonly T[], unknown>>; | ||
| 38 | |||
| 39 | export interface CustomMethodActions { | ||
| 40 | readonly createCustomMethod: CreateCustomMethod; | ||
| 41 | readonly removeCustomMethod: (method: AuthMethod) => Promise<boolean>; | ||
| 42 | } | ||
| 43 | |||
| 44 | export function useCustomMethodActions<T>(options: { | ||
| 45 | readonly existing: readonly T[]; | ||
| 46 | readonly codec: AuthMethodsCodec<T>; | ||
| 47 | readonly configure: ConfigureAuthMethods<T>; | ||
| 48 | }): CustomMethodActions { | ||
| 49 | const { existing, codec, configure } = options; | ||
| 50 | |||
| 51 | const createCustomMethod = useCallback<CreateCustomMethod>( | ||
| 52 | async (input: { readonly label: string; readonly placements: readonly Placement[] }) => { | ||
| 53 | const templates = codec.templatesFromPlacements(input.placements); | ||
| 54 | if (templates.length === 0) return null; | ||
| 55 | const exit = await configure({ authenticationTemplate: templates }); | ||
| 56 | if (Exit.isFailure(exit)) return null; | ||
| 57 | const before = new Set(existing.map((template: T) => codec.slugOf(template))); | ||
| 58 | const created = codec | ||
| 59 | .toAuthMethods(exit.value) | ||
| 60 | .find((candidate: AuthMethod) => !before.has(String(candidate.template))); | ||
| 61 | return created ?? null; | ||
| 62 | }, | ||
| 63 | [codec, configure, existing], | ||
| 64 | ); | ||
| 65 | |||
| 66 | const removeCustomMethod = useCallback( | ||
| 67 | async (method: AuthMethod): Promise<boolean> => { | ||
| 68 | if (method.source !== "custom") return false; | ||
| 69 | const next = existing.filter( | ||
| 70 | (template: T) => codec.slugOf(template) !== String(method.template), | ||
| 71 | ); | ||
| 72 | const exit = await configure({ authenticationTemplate: next, mode: "replace" }); | ||
| 73 | return Exit.isSuccess(exit); | ||
| 74 | }, | ||
| 75 | [codec, configure, existing], | ||
| 76 | ); | ||
| 77 | |||
| 78 | return { createCustomMethod, removeCustomMethod }; | ||
| 79 | } | ||
| @@ -0,0 +1,91 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Integration add-flow helpers — the plugin-agnostic pieces every Add-source | ||
| 3 | // form repeats: decoding a mutation Exit into a user-facing message, the | ||
| 4 | // IntegrationAlreadyExistsError guard, the live slug-collision check against | ||
| 5 | // the catalog, and the inline error/collision alerts. | ||
| 6 | // --------------------------------------------------------------------------- | ||
| 7 | |||
| 8 | import { useMemo } from "react"; | ||
| 9 | import { useAtomValue } from "@effect/atom-react"; | ||
| 10 | import { Link } from "@tanstack/react-router"; | ||
| 11 | import * as Exit from "effect/Exit"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import * as Predicate from "effect/Predicate"; | ||
| 14 | import * as Schema from "effect/Schema"; | ||
| 15 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 16 | |||
| 17 | import { integrationsOptimisticAtom } from "../api/atoms"; | ||
| 18 | |||
| 19 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | ||
| 20 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | ||
| 21 | |||
| 22 | /** The failed Exit's `message`, or `fallback` when the error carries none. */ | ||
| 23 | export const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => | ||
| 24 | Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { | ||
| 25 | onNone: () => fallback, | ||
| 26 | onSome: ({ message }) => message, | ||
| 27 | }); | ||
| 28 | |||
| 29 | export const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean => | ||
| 30 | Option.match(Exit.findErrorOption(exit), { | ||
| 31 | onNone: () => false, | ||
| 32 | onSome: Predicate.isTagged("IntegrationAlreadyExistsError"), | ||
| 33 | }); | ||
| 34 | |||
| 35 | export const integrationExistsMessage = (slug: string): string => | ||
| 36 | `An integration named "${slug}" already exists. To add more authentication, update your existing integration.`; | ||
| 37 | |||
| 38 | /** Decode an add-integration failure into its inline message: the | ||
| 39 | * already-exists guard message for a slug collision, otherwise the error's | ||
| 40 | * own message (or `fallback`). */ | ||
| 41 | export const addIntegrationErrorMessage = ( | ||
| 42 | exit: Exit.Exit<unknown, unknown>, | ||
| 43 | slug: string, | ||
| 44 | fallback: string, | ||
| 45 | ): string => | ||
| 46 | isIntegrationAlreadyExistsExit(exit) | ||
| 47 | ? integrationExistsMessage(slug) | ||
| 48 | : errorMessageFromExit(exit, fallback); | ||
| 49 | |||
| 50 | /** Whether `slug` is already taken in the tenant-scoped catalog (pre-empting | ||
| 51 | * the API's `IntegrationAlreadyExistsError`). Blank slugs never collide — | ||
| 52 | * they let the server assign one. */ | ||
| 53 | export function useSlugAlreadyExists(slug: string): boolean { | ||
| 54 | const integrationsResult = useAtomValue(integrationsOptimisticAtom); | ||
| 55 | return useMemo( | ||
| 56 | () => | ||
| 57 | slug.length > 0 && | ||
| 58 | AsyncResult.isSuccess(integrationsResult) && | ||
| 59 | integrationsResult.value.some((integration) => String(integration.slug) === slug), | ||
| 60 | [integrationsResult, slug], | ||
| 61 | ); | ||
| 62 | } | ||
| 63 | |||
| 64 | /** Inline destructive alert for a form-level error message. */ | ||
| 65 | export function FormErrorAlert(props: { readonly message: string }) { | ||
| 66 | return ( | ||
| 67 | <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | ||
| 68 | <p className="text-[12px] text-destructive">{props.message}</p> | ||
| 69 | </div> | ||
| 70 | ); | ||
| 71 | } | ||
| 72 | |||
| 73 | /** The slug-collision alert with a link to the existing integration. Render | ||
| 74 | * gated by the caller (typically `show && !adding`). */ | ||
| 75 | export function SlugCollisionAlert(props: { readonly slug: string }) { | ||
| 76 | return ( | ||
| 77 | <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | ||
| 78 | <p className="text-[12px] text-destructive"> | ||
| 79 | An integration named "{props.slug}" 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 | } | ||
| @@ -0,0 +1,50 @@ | |||
| 1 | # testkit (MCP surface) — MVP | ||
| 2 | |||
| 3 | Black-box tests through the **MCP surface**, on the vendored mcporter fork | ||
| 4 | (`vendor/mcporter`, with its headless `cookieConsentStrategy`). Every run emits a | ||
| 5 | **chat-transcript recording** (`run.json`) — the MCP surface *is* an agent-in-a-chat, | ||
| 6 | so that's the natural shape. | ||
| 7 | |||
| 8 | ## Write a test | ||
| 9 | |||
| 10 | ```js | ||
| 11 | import { mcpTarget, mcpTest, cookieConsentStrategy } from "./src/testkit.mjs"; | ||
| 12 | |||
| 13 | // the per-host seam: which MCP server + how to consent (headless, CI-safe) | ||
| 14 | const selfhost = mcpTarget({ | ||
| 15 | name: "selfhost", | ||
| 16 | server: "http://localhost:5173/mcp", | ||
| 17 | consent: cookieConsentStrategy({ appBaseUrl: "http://localhost:5173", email, password }), | ||
| 18 | }); | ||
| 19 | |||
| 20 | mcpTest("execute runs code", selfhost, async (mcp) => { | ||
| 21 | mcp.say("Confirm the sandbox evaluates and returns a value"); // → reasoning turn | ||
| 22 | const r = await mcp.call("execute", { code: "return 6*7;" }); // → tool turn | ||
| 23 | mcp.expect(r.text).toBe("42"); // → assert turn | ||
| 24 | }); | ||
| 25 | ``` | ||
| 26 | |||
| 27 | ## Run + watch | ||
| 28 | |||
| 29 | ```bash | ||
| 30 | node run.mjs # runs tests, writes runs/*.run.json, prints chat | ||
| 31 | node src/render.mjs runs/X.run.json # render a recording in the terminal | ||
| 32 | node src/render.mjs runs/X.run.json --html out.html # watchable HTML | ||
| 33 | node serve.mjs # serve runs/ (index + per-run HTML) on :8901 | ||
| 34 | ``` | ||
| 35 | |||
| 36 | ## What a recording is | ||
| 37 | |||
| 38 | `run.json` = a chat transcript (`turns`: user / assistant·reasoning / tool / assert / | ||
| 39 | error) + `asserts` + `ok`/`error`/`durationMs`. The HTML renders it as a chat: task | ||
| 40 | bubble → reasoning → collapsible tool cards (args + result) → inline asserts. Failures | ||
| 41 | record too — `ok:false` with the failing turn in place. | ||
| 42 | |||
| 43 | ## Status / next | ||
| 44 | |||
| 45 | - **Now:** scripted brain (deterministic, no LLM). Proven against dev `/mcp`. | ||
| 46 | - **Next:** `agentTest` — LLM brain (real model drives via MCP, reasoning captured | ||
| 47 | verbatim); raw JSON-RPC `protocol[]` layer linked to tool turns; MCP-apps as `app` | ||
| 48 | turns. Productionize to TS + Effect Vitest later (see memory: effect-testing-stack-note). | ||
| 49 | - Self-contained ESM JS under `testkit/` (not in the bun workspace) — imports the fork's | ||
| 50 | built `dist`. Throwaway-grade until productionized; the proof is real. | ||
| @@ -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 | } | ||
| @@ -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 | } | ||
| @@ -0,0 +1,59 @@ | |||
| 1 | // Records the full "OAuth → MCP → call" flow as one dark-themed mp4: | ||
| 2 | // 1) client title card (mcp add + pick OAuth) | ||
| 3 | // 2) REAL browser consent UI (login → connected), forced dark via colorScheme | ||
| 4 | // 3) chat replay of the real tool call | ||
| 5 | // Run from testkit/ (playwright resolves here): node record-flow.mjs | ||
| 6 | import { chromium } from "playwright"; | ||
| 7 | import { execSync } from "node:child_process"; | ||
| 8 | import { createRuntime, cookieConsentStrategy } from "../vendor/mcporter/dist/index.js"; | ||
| 9 | import { renderPlayer } from "./src/render.mjs"; | ||
| 10 | import { mkdtempSync, writeFileSync, readdirSync, renameSync, rmSync, mkdirSync } from "node:fs"; | ||
| 11 | import { tmpdir, homedir } from "node:os"; import { join } from "node:path"; | ||
| 12 | |||
| 13 | // fresh OAuth so phase 2 records the real browser consent (not a cached token) | ||
| 14 | rmSync(join(homedir(), ".mcporter"), { recursive: true, force: true }); | ||
| 15 | |||
| 16 | const W = 1000, H = 720, APP = process.env.MCP_APP ?? "http://localhost:5173"; | ||
| 17 | const EMAIL = process.env.MCP_EMAIL ?? "admin@demo.test", PW = process.env.MCP_PASSWORD ?? "demo-password-12345"; | ||
| 18 | const TMP = "/tmp/rec-flow"; rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true }); | ||
| 19 | const newRt = () => { const d = mkdtempSync(join(tmpdir(), "rf-")); writeFileSync(join(d, "mcporter.json"), JSON.stringify({ mcpServers: { selfhost: { url: APP + "/mcp" } } })); return createRuntime({ configPath: join(d, "mcporter.json") }); }; | ||
| 20 | const recCtx = (b, dir) => b.newContext({ viewport: { width: W, height: H }, colorScheme: "dark", recordVideo: { dir, size: { width: W, height: H } } }); | ||
| 21 | const grab = (dir, name) => { const f = readdirSync(dir).find(x => x.endsWith(".webm")); renameSync(join(dir, f), `${TMP}/${name}.webm`); }; | ||
| 22 | |||
| 23 | { const html = `<!doctype html><meta charset=utf8><style>body{margin:0;background:#0b0f17;color:#d7dce5;font:18px ui-monospace,Menlo,monospace;height:100vh;display:flex;flex-direction:column;justify-content:center;padding:0 60px;box-sizing:border-box}.p{color:#7ee787}.c{color:#79c0ff}.dim{color:#6b7785}div{white-space:pre;margin:6px 0;opacity:0}.cur{background:#d7dce5;animation:b .8s steps(1) infinite}@keyframes b{50%{opacity:0}}</style><div id=l1></div><div id=l2></div><div id=l3></div><div id=l4></div><script>const s=ms=>new Promise(r=>setTimeout(r,ms));async function type(el,txt){el.style.opacity=1;for(let i=0;i<=txt.length;i++){el.innerHTML=txt.slice(0,i)+'<span class=cur> </span>';await s(38)}el.innerHTML=txt}(async()=>{await s(400);await type(l1,'<span class=p>$</span> mcp add <span class=c>'+${JSON.stringify(APP)}+'/mcp</span>');await s(500);l2.style.opacity=1;l2.innerHTML='<span class=dim>? Authentication method:</span>';await s(700);l3.style.opacity=1;l3.innerHTML=' None Bearer <span class=p>❯ OAuth</span>';await s(900);l4.style.opacity=1;l4.innerHTML='<span class=dim>→ opening browser for consent…</span>';await s(1200);window.__done=true})();</script>`; | ||
| 24 | writeFileSync(`${TMP}/title.html`, html); | ||
| 25 | const b = await chromium.launch(); const ctx = await recCtx(b, `${TMP}/v1`); const p = await ctx.newPage(); | ||
| 26 | await p.goto(`file://${TMP}/title.html`); await p.waitForFunction("window.__done===true", { timeout: 15000 }).catch(()=>{}); await p.waitForTimeout(600); | ||
| 27 | await ctx.close(); await b.close(); grab(`${TMP}/v1`, "1"); console.log("phase1 ✓"); } | ||
| 28 | |||
| 29 | { let req; const rt = await newRt(); | ||
| 30 | try { await rt.listTools("selfhost", { autoAuthorize: true, oauthSessionOptions: { consentStrategy: async r => { req = r; throw new Error("x"); } } }); } catch {} | ||
| 31 | const b = await chromium.launch(); const ctx = await recCtx(b, `${TMP}/v2`); const p = await ctx.newPage(); | ||
| 32 | await p.goto(req.authorizationUrl, { waitUntil: "domcontentloaded" }); | ||
| 33 | await p.waitForSelector("input[type=email]", { timeout: 10000 }); await p.waitForTimeout(700); | ||
| 34 | await p.type("input[type=email]", EMAIL, { delay: 45 }); await p.waitForTimeout(250); | ||
| 35 | await p.type("input[type=password]", PW, { delay: 28 }); await p.waitForTimeout(450); | ||
| 36 | await p.click("button[type=submit]"); await p.waitForTimeout(2600); | ||
| 37 | await p.setContent('<html><body style="font:600 34px system-ui;display:grid;place-items:center;height:100vh;margin:0;background:#0b0f17;color:#7ee787">🔓 Connected to MCP server</body></html>'); | ||
| 38 | await p.waitForTimeout(1700); await ctx.close(); await b.close(); grab(`${TMP}/v2`, "2"); console.log("phase2 ✓ (dark login)"); } | ||
| 39 | |||
| 40 | { const rt = await newRt(); | ||
| 41 | const opts = { autoAuthorize: true, oauthSessionOptions: { consentStrategy: cookieConsentStrategy({ appBaseUrl: APP, email: EMAIL, password: PW }) } }; | ||
| 42 | const tools = await rt.listTools("selfhost", opts); | ||
| 43 | const res = await rt.callTool("selfhost", "execute", { args: { code: "return 6 * 7;" }, ...opts }); | ||
| 44 | const text = res.content.filter(c=>c.type==="text").map(c=>c.text).join(""); | ||
| 45 | const run = { task: "Run 6 × 7 in the sandbox using the execute tool", brain: "scripted", meta: { server: APP + "/mcp" }, ok: text==="42", turns: [ | ||
| 46 | { role: "user", text: "Run 6 × 7 in the sandbox using the execute tool" }, | ||
| 47 | { role: "assistant", kind: "reasoning", text: "Connected to the MCP server. I'll call execute with a tiny program." }, | ||
| 48 | { role: "tool", call: { name: "tools/list", args: {} }, result: tools.map(t=>t.name), ok: true, text: tools.map(t=>t.name).join(", ") }, | ||
| 49 | { role: "tool", call: { name: "execute", args: { code: "return 6 * 7;" } }, result: res.content, ok: true, text }, | ||
| 50 | { role: "assert", kind: "toBe", actual: text, expected: "42", ok: text === "42" } ]}; | ||
| 51 | writeFileSync(`${TMP}/chat.html`, renderPlayer(run)); | ||
| 52 | const b = await chromium.launch(); const ctx = await recCtx(b, `${TMP}/v3`); const p = await ctx.newPage(); | ||
| 53 | await p.goto(`file://${TMP}/chat.html`); await p.waitForFunction("window.__done===true", { timeout: 30000 }).catch(()=>{}); await p.waitForTimeout(1000); | ||
| 54 | await ctx.close(); await b.close(); grab(`${TMP}/v3`, "3"); console.log("phase3 ✓ →", text); } | ||
| 55 | |||
| 56 | for (const n of ["1", "2", "3"]) execSync(`ffmpeg -y -i ${TMP}/${n}.webm -vf "scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2:color=0x0b0f17,fps=30" -pix_fmt yuv420p -c:v libx264 -preset veryfast ${TMP}/${n}.mp4 2>/dev/null`); | ||
| 57 | writeFileSync(`${TMP}/list.txt`, ["1","2","3"].map(n=>`file '${TMP}/${n}.mp4'`).join("\n")); | ||
| 58 | execSync(`ffmpeg -y -f concat -safe 0 -i ${TMP}/list.txt -c copy ${import.meta.dirname}/runs/oauth-flow.mp4 2>/dev/null`); | ||
| 59 | console.log("✓ runs/oauth-flow.mp4"); | ||
| @@ -0,0 +1,8 @@ | |||
| 1 | import { runTests } from "./src/testkit.mjs"; | ||
| 2 | import { renderTerminal } from "./src/render.mjs"; | ||
| 3 | import { readFileSync } from "node:fs"; | ||
| 4 | await import("./tests/execute.test.mjs"); | ||
| 5 | await import("./tests/x-oauth-basic.test.mjs"); | ||
| 6 | const { results } = await runTests(); | ||
| 7 | console.log("\n──────── recordings ────────"); | ||
| 8 | for (const r of results) console.log("\n" + renderTerminal(JSON.parse(readFileSync(r.file, "utf8")))); | ||
| @@ -0,0 +1,21 @@ | |||
| 1 | import http from "node:http"; import fs from "node:fs"; import path from "node:path"; | ||
| 2 | import { fileURLToPath } from "node:url"; | ||
| 3 | const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "runs"); | ||
| 4 | const TYPES = { ".html": "text/html", ".json": "application/json", ".mp4": "video/mp4", ".png": "image/png" }; | ||
| 5 | http.createServer((req, res) => { | ||
| 6 | let p = decodeURIComponent((req.url || "/").split("?")[0]); | ||
| 7 | if (p === "/" || p === "") p = "/index.html"; | ||
| 8 | const fp = path.normalize(path.join(root, p)); | ||
| 9 | if (!fp.startsWith(root)) { res.writeHead(403); return res.end(); } | ||
| 10 | let st; try { st = fs.statSync(fp); } catch { res.writeHead(404); return res.end("not found"); } | ||
| 11 | const type = TYPES[path.extname(fp)] || "application/octet-stream"; | ||
| 12 | const range = req.headers.range; | ||
| 13 | if (range) { // range support so <video> seeks/streams | ||
| 14 | const m = /bytes=(\d+)-(\d*)/.exec(range); const start = +m[1]; const end = m[2] ? +m[2] : st.size - 1; | ||
| 15 | res.writeHead(206, { "content-type": type, "accept-ranges": "bytes", "content-range": `bytes ${start}-${end}/${st.size}`, "content-length": end - start + 1 }); | ||
| 16 | fs.createReadStream(fp, { start, end }).pipe(res); | ||
| 17 | } else { | ||
| 18 | res.writeHead(200, { "content-type": type, "accept-ranges": "bytes", "content-length": st.size }); | ||
| 19 | fs.createReadStream(fp).pipe(res); | ||
| 20 | } | ||
| 21 | }).listen(8901, "127.0.0.1", () => console.log("testkit runs on :8901")); | ||
| @@ -0,0 +1,45 @@ | |||
| 1 | // Browser consent: drive the real OAuth consent in a browser and record it — | ||
| 2 | // the Playwright "real hands" part. Returns { code, videoPath }. | ||
| 3 | export function browserConsent({ appBaseUrl, email, password, videoDir, headless = true, log = () => {} }) { | ||
| 4 | return async ({ authorizationUrl, redirectUrl }) => { | ||
| 5 | const { chromium } = await import("playwright"); | ||
| 6 | const browser = await chromium.launch({ headless }); | ||
| 7 | const context = await browser.newContext({ | ||
| 8 | viewport: { width: 1100, height: 760 }, | ||
| 9 | recordVideo: videoDir ? { dir: videoDir, size: { width: 1100, height: 760 } } : undefined, | ||
| 10 | }); | ||
| 11 | const page = await context.newPage(); | ||
| 12 | let code = null; | ||
| 13 | const cbBase = redirectUrl.split("?")[0]; | ||
| 14 | await context.route(cbBase + "**", (route) => { | ||
| 15 | try { code = new URL(route.request().url()).searchParams.get("code"); } catch {} | ||
| 16 | log("callback intercepted, code?", !!code); | ||
| 17 | route.fulfill({ status: 200, contentType: "text/html", | ||
| 18 | body: "<html><body style='font:600 30px system-ui;display:grid;place-items:center;height:100vh;margin:0;color:#15803d;background:#f0fdf4'>✓ Connected to MCP</body></html>" }); | ||
| 19 | }); | ||
| 20 | try { | ||
| 21 | log("goto authorize"); | ||
| 22 | await page.goto(authorizationUrl, { waitUntil: "domcontentloaded" }); | ||
| 23 | log("wait email field"); | ||
| 24 | await page.waitForSelector("input[type=email]", { timeout: 10000 }); | ||
| 25 | await page.waitForTimeout(400); | ||
| 26 | await page.fill("input[type=email]", email); | ||
| 27 | await page.fill("input[type=password]", password); | ||
| 28 | await page.waitForTimeout(300); | ||
| 29 | log("click sign in"); | ||
| 30 | await page.click("button[type=submit]"); | ||
| 31 | log("wait for callback url"); | ||
| 32 | await page.waitForURL(cbBase + "**", { timeout: 25000 }); | ||
| 33 | log("on callback, code captured?", !!code); | ||
| 34 | await page.waitForTimeout(1400); // show the "Connected" screen in the clip | ||
| 35 | } finally { | ||
| 36 | const videoPath = videoDir ? await page.video()?.path().catch(() => null) : null; | ||
| 37 | await context.close(); | ||
| 38 | await browser.close(); | ||
| 39 | this_video = videoPath; | ||
| 40 | if (!code) throw new Error("browserConsent: no authorization code captured"); | ||
| 41 | return { code, videoPath }; | ||
| 42 | } | ||
| 43 | }; | ||
| 44 | } | ||
| 45 | let this_video; | ||
| @@ -0,0 +1,87 @@ | |||
| 1 | import { mkdtempSync, writeFileSync } from "node:fs"; | ||
| 2 | import { tmpdir } from "node:os"; | ||
| 3 | import { join } from "node:path"; | ||
| 4 | import { createRuntime } from "../../vendor/mcporter/dist/index.js"; | ||
| 5 | |||
| 6 | class AssertionError extends Error {} | ||
| 7 | const textOf = (res) => | ||
| 8 | Array.isArray(res?.content) | ||
| 9 | ? res.content.filter((p) => p.type === "text").map((p) => p.text).join("\n") | ||
| 10 | : typeof res === "string" ? res : JSON.stringify(res); | ||
| 11 | |||
| 12 | // `mcp` handle passed to a scripted test. Every call is recorded as a chat turn; | ||
| 13 | // connect + headless OAuth (the target's consentStrategy) happen lazily on first use. | ||
| 14 | export class McpDriver { | ||
| 15 | constructor(target, recorder) { | ||
| 16 | this.target = target; | ||
| 17 | this.rec = recorder; | ||
| 18 | // Wrap the target's consent so the OAuth handshake is recorded as turns. | ||
| 19 | const consent = target.consent; | ||
| 20 | this._opts = { | ||
| 21 | autoAuthorize: true, | ||
| 22 | oauthSessionOptions: { | ||
| 23 | consentStrategy: async (req) => { | ||
| 24 | this.rec.turn({ role: "auth", phase: "authorize", text: "OAuth required → client registered (DCR), authorizing", detail: { authorizationUrl: req.authorizationUrl } }); | ||
| 25 | const out = await consent(req); | ||
| 26 | this.rec.turn({ role: "auth", phase: "code", text: "Signed in & consented → authorization code received", ok: true }); | ||
| 27 | return out; | ||
| 28 | }, | ||
| 29 | }, | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | async _runtime() { | ||
| 33 | if (this._rt) return this._rt; | ||
| 34 | const dir = mkdtempSync(join(tmpdir(), "mcp-testkit-")); | ||
| 35 | const cfg = { mcpServers: { [this.target.name]: { url: this.target.server } } }; | ||
| 36 | writeFileSync(join(dir, "mcporter.json"), JSON.stringify(cfg)); | ||
| 37 | this._rt = await createRuntime({ configPath: join(dir, "mcporter.json") }); | ||
| 38 | return this._rt; | ||
| 39 | } | ||
| 40 | // Connect once, recording the OAuth lifecycle: connect → (authorize → code) → connected. | ||
| 41 | // The tools/list call triggers mcporter's auth flow; the consent wrap records the middle. | ||
| 42 | async _connect() { | ||
| 43 | if (this._connected) return; | ||
| 44 | this.rec.turn({ role: "auth", phase: "connect", text: `Connecting to ${this.target.server}` }); | ||
| 45 | this._toolDefs = await (await this._runtime()).listTools(this.target.name, this._opts); | ||
| 46 | this.rec.turn({ role: "auth", phase: "connected", text: "Connected — access token acquired & cached for reuse", ok: true }); | ||
| 47 | this._connected = true; | ||
| 48 | } | ||
| 49 | say(text) { this.rec.say(text); } | ||
| 50 | async listTools() { | ||
| 51 | await this._connect(); | ||
| 52 | const names = this._toolDefs.map((t) => t.name); | ||
| 53 | this.rec.turn({ role: "tool", call: { name: "tools/list", args: {} }, result: names, ok: true, text: names.join(", ") }); | ||
| 54 | return names; | ||
| 55 | } | ||
| 56 | async call(name, args = {}) { | ||
| 57 | await this._connect(); | ||
| 58 | const rt = await this._runtime(); | ||
| 59 | const res = await rt.callTool(this.target.name, name, { args, ...this._opts }); | ||
| 60 | const text = textOf(res); | ||
| 61 | this.rec.toolCall(name, args, res?.content ?? res, !res?.isError, text); | ||
| 62 | return { raw: res, text, ok: !res?.isError }; | ||
| 63 | } | ||
| 64 | async approvePaused(text, content = {}) { | ||
| 65 | const match = /\bexecutionId:\s*(\S+)/.exec(text); | ||
| 66 | if (!match) throw new AssertionError("approvePaused: executionId not found"); | ||
| 67 | return await this.call("resume", { | ||
| 68 | executionId: match[1], | ||
| 69 | action: "accept", | ||
| 70 | content: JSON.stringify(content), | ||
| 71 | }); | ||
| 72 | } | ||
| 73 | // assertions record a turn (pass or fail) and throw on failure to fail the test | ||
| 74 | expect(actual) { | ||
| 75 | const rec = this.rec; | ||
| 76 | const check = (kind, ok, expected) => { | ||
| 77 | rec.assert({ kind, actual, expected, ok }); | ||
| 78 | if (!ok) throw new AssertionError(`expect(${JSON.stringify(actual)}).${kind}(${JSON.stringify(expected)})`); | ||
| 79 | }; | ||
| 80 | return { | ||
| 81 | toBe: (e) => check("toBe", actual === e, e), | ||
| 82 | toContain: (e) => check("toContain", (Array.isArray(actual) ? actual.includes(e) : String(actual).includes(e)), e), | ||
| 83 | toMatch: (re) => check("toMatch", re.test(String(actual)), String(re)), | ||
| 84 | toBeGreaterThan: (e) => check("toBeGreaterThan", actual > e, e), | ||
| 85 | }; | ||
| 86 | } | ||
| 87 | } | ||
| @@ -0,0 +1,47 @@ | |||
| 1 | <!doctype html><meta charset=utf8><title>MCP run</title><style> | ||
| 2 | body{font:15px/1.55 ui-sans-serif,system-ui;max-width:760px;margin:0 auto;padding:24px;color:#d7dce5;background:#0b0f17} | ||
| 3 | #banner{font-weight:700;font-size:14px;margin-bottom:14px;color:#8b98a9} | ||
| 4 | #banner.ok{color:#7ee787}#banner.no{color:#ff7b72} | ||
| 5 | .row{opacity:0;transform:translateY(6px);transition:opacity .25s,transform .25s;margin:6px 0} | ||
| 6 | .row.in{opacity:1;transform:none} | ||
| 7 | .u{background:#1f6feb;color:#fff;padding:.55rem .8rem;border-radius:12px;font-weight:600} | ||
| 8 | .a{color:#8b98a9;padding:.3rem .8rem;border-left:2px solid #30363d} | ||
| 9 | .au{background:#1c1606;border:1px solid #5c4813;color:#e3b341;padding:.4rem .7rem;border-radius:10px;font-size:13.5px} | ||
| 10 | .t{background:#0f1620;border:1px solid #21262d;border-radius:10px;padding:.5rem .75rem} | ||
| 11 | .t.run{border-color:#1f6feb}.t .g{color:#6b7785}.t .sp{color:#58a6ff;font-style:italic} | ||
| 12 | .t pre{background:#010409;color:#c9d1d9;padding:.5rem;border-radius:6px;margin:.4rem 0 0;font-size:12.5px;overflow:auto} | ||
| 13 | .x{padding:.2rem .8rem;font-size:13.5px}.x.ok{color:#7ee787}.x.no{color:#ff7b72;font-weight:700} | ||
| 14 | .e{color:#ff7b72;font-weight:700;padding:.4rem .8rem} | ||
| 15 | .cur{display:inline-block;width:7px;height:15px;background:#8b98a9;vertical-align:-2px;animation:b .8s steps(1) infinite} | ||
| 16 | @keyframes b{50%{opacity:0}} | ||
| 17 | </style> | ||
| 18 | <div id="banner">● recording</div><div id="chat"></div> | ||
| 19 | <script> | ||
| 20 | const RUN = __RUN__; | ||
| 21 | const SPEED = 1; | ||
| 22 | const esc = s => String(s).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); | ||
| 23 | const chat = document.getElementById('chat'); | ||
| 24 | const add = html => { const d=document.createElement('div'); d.innerHTML=html.trim(); const el=d.firstElementChild; el.classList.add('row'); chat.appendChild(el); requestAnimationFrame(()=>el.classList.add('in')); el.scrollIntoView({behavior:'smooth',block:'end'}); return el; }; | ||
| 25 | const sleep = ms => new Promise(r=>setTimeout(r, ms/SPEED)); | ||
| 26 | async function type(el, text){ for(let i=0;i<=text.length;i++){ el.innerHTML=esc(text.slice(0,i))+'<span class=cur></span>'; await sleep(16); } el.innerHTML=esc(text); } | ||
| 27 | (async () => { | ||
| 28 | await sleep(300); | ||
| 29 | for (const t of RUN.turns){ | ||
| 30 | if (t.role==='auth') continue; // OAuth is its own phase in the recording | ||
| 31 | if (t.role==='user'){ add('<div class="u">'+esc(t.text)+'</div>'); await sleep(700); } | ||
| 32 | else if (t.role==='assistant'){ const el=add('<div class="a"></div>'); await type(el, t.text); await sleep(250); } | ||
| 33 | else if (t.role==='tool'){ | ||
| 34 | const el=add('<div class="t run">🔧 <b>'+esc(t.call.name)+'</b> <span class="sp">running…</span></div>'); | ||
| 35 | await sleep(750); | ||
| 36 | el.classList.remove('run'); | ||
| 37 | el.innerHTML='🔧 <b>'+esc(t.call.name)+'</b> '+(t.ok?'✓':'✗')+' <span class="g">→ '+esc(String(t.text).slice(0,90))+'</span><pre>'+esc(JSON.stringify({args:t.call.args,result:t.result},null,2))+'</pre>'; | ||
| 38 | el.scrollIntoView({behavior:'smooth',block:'end'}); await sleep(550); | ||
| 39 | } | ||
| 40 | else if (t.role==='assert'){ add('<div class="x '+(t.ok?'ok':'no')+'">'+(t.ok?'✅':'❌')+' expect('+esc(JSON.stringify(t.actual))+').'+t.kind+'('+esc(JSON.stringify(t.expected))+')</div>'); await sleep(450); } | ||
| 41 | else if (t.role==='error'){ add('<div class="e">💥 '+esc(t.text)+'</div>'); await sleep(500); } | ||
| 42 | } | ||
| 43 | await sleep(400); | ||
| 44 | const b=document.getElementById('banner'); b.textContent=(RUN.ok?'✓ PASSED':'✗ FAILED')+' · '+RUN.meta.server; b.className=RUN.ok?'ok':'no'; | ||
| 45 | window.__done=true; | ||
| 46 | })(); | ||
| 47 | </script> | ||
| @@ -0,0 +1,30 @@ | |||
| 1 | // A Run is the recording: a chat transcript (turns) + asserts + outcome. | ||
| 2 | export class Recorder { | ||
| 3 | constructor(task, meta = {}) { | ||
| 4 | this.run = { | ||
| 5 | task, brain: meta.brain ?? "scripted", meta, | ||
| 6 | startedAt: Date.now(), turns: [], asserts: [], ok: true, | ||
| 7 | }; | ||
| 8 | this.run.turns.push({ t: Date.now(), role: "user", text: task }); | ||
| 9 | } | ||
| 10 | turn(t) { this.run.turns.push({ t: Date.now(), ...t }); } | ||
| 11 | say(text) { this.turn({ role: "assistant", kind: "reasoning", text }); } | ||
| 12 | toolCall(name, args, result, ok, text) { | ||
| 13 | this.turn({ role: "tool", call: { name, args }, result, ok, text }); | ||
| 14 | } | ||
| 15 | assert(a) { | ||
| 16 | this.run.asserts.push(a); | ||
| 17 | this.turn({ role: "assert", ...a }); | ||
| 18 | if (!a.ok) this.run.ok = false; | ||
| 19 | } | ||
| 20 | finish(ok, error) { | ||
| 21 | if (!ok) this.run.ok = false; | ||
| 22 | this.run.endedAt = Date.now(); | ||
| 23 | this.run.durationMs = this.run.endedAt - this.run.startedAt; | ||
| 24 | if (error) { | ||
| 25 | this.run.error = String(error?.message ?? error); | ||
| 26 | this.turn({ role: "error", text: this.run.error }); | ||
| 27 | } | ||
| 28 | return this.run; | ||
| 29 | } | ||
| 30 | } | ||
| @@ -0,0 +1,60 @@ | |||
| 1 | import { readFileSync, writeFileSync } from "node:fs"; | ||
| 2 | |||
| 3 | const C = { dim: "\x1b[2m", grn: "\x1b[32m", red: "\x1b[31m", cyn: "\x1b[36m", yel: "\x1b[33m", b: "\x1b[1m", r: "\x1b[0m" }; | ||
| 4 | const icon = { user: "🧑", assistant: "🤖", tool: "🔧", assert: " ", error: "💥" }; | ||
| 5 | |||
| 6 | export function renderTerminal(run) { | ||
| 7 | const L = []; | ||
| 8 | L.push(`${C.b}${run.ok ? C.grn + "✓" : C.red + "✗"} ${run.task}${C.r} ${C.dim}(${run.durationMs}ms, brain=${run.brain})${C.r}`); | ||
| 9 | for (const t of run.turns) { | ||
| 10 | if (t.role === "user") L.push(`\n${icon.user} ${C.b}${t.text}${C.r}`); | ||
| 11 | else if (t.role === "assistant") L.push(`${icon.assistant} ${C.dim}${t.text}${C.r}`); | ||
| 12 | else if (t.role === "auth") L.push(`${t.phase === "connected" ? "🔓" : "🔐"} ${C.yel}${t.text}${C.r}`); | ||
| 13 | else if (t.role === "tool") L.push(`${icon.tool} ${C.cyn}${t.call.name}${C.r}(${C.dim}${JSON.stringify(t.call.args)}${C.r}) ${t.ok ? C.grn + "✓" : C.red + "✗"}${C.r} ${C.dim}→ ${String(t.text).slice(0, 80)}${C.r}`); | ||
| 14 | else if (t.role === "assert") L.push(`${t.ok ? C.grn + " ✅" : C.red + " ❌"} expect(${JSON.stringify(t.actual)}).${t.kind}(${JSON.stringify(t.expected)})${C.r}`); | ||
| 15 | else if (t.role === "error") L.push(`${icon.error} ${C.red}${t.text}${C.r}`); | ||
| 16 | } | ||
| 17 | return L.join("\n"); | ||
| 18 | } | ||
| 19 | |||
| 20 | export function renderHtml(run) { | ||
| 21 | const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); | ||
| 22 | const bubble = (t) => { | ||
| 23 | if (t.role === "user") return `<div class="u"><b>${esc(t.text)}</b></div>`; | ||
| 24 | if (t.role === "auth") return `<div class="au">${t.phase === "connected" ? "🔓" : "🔐"} ${esc(t.text)}</div>`; | ||
| 25 | if (t.role === "assistant") return `<div class="a">${esc(t.text)}</div>`; | ||
| 26 | if (t.role === "tool") return `<details class="t ${t.ok ? "ok" : "no"}"><summary>🔧 <b>${esc(t.call.name)}</b> ${t.ok ? "✓" : "✗"} <span class="g">→ ${esc(String(t.text).slice(0, 100))}</span></summary><pre>${esc(JSON.stringify({ args: t.call.args, result: t.result }, null, 2))}</pre></details>`; | ||
| 27 | if (t.role === "assert") return `<div class="x ${t.ok ? "ok" : "no"}">${t.ok ? "✅" : "❌"} expect(${esc(JSON.stringify(t.actual))}).${t.kind}(${esc(JSON.stringify(t.expected))})</div>`; | ||
| 28 | if (t.role === "error") return `<div class="e">💥 ${esc(t.text)}</div>`; | ||
| 29 | return ""; | ||
| 30 | }; | ||
| 31 | return `<!doctype html><meta charset=utf8><title>${esc(run.task)}</title><style> | ||
| 32 | body{font:14px/1.5 ui-sans-serif,system-ui;max-width:760px;margin:2rem auto;color:#111;background:#fafafa} | ||
| 33 | h1{font-size:16px}.hdr{color:${run.ok ? "#15803d" : "#b91c1c"};font-weight:700;margin-bottom:1rem} | ||
| 34 | .u{background:#111;color:#fff;padding:.5rem .75rem;border-radius:10px;margin:.5rem 0} | ||
| 35 | .a{color:#555;padding:.25rem .75rem;margin:.25rem 0;border-left:2px solid #ddd} | ||
| 36 | .au{background:#fffbeb;border:1px solid #fde68a;color:#92400e;padding:.35rem .6rem;border-radius:8px;margin:.3rem 0;font-size:13px} | ||
| 37 | .t{background:#fff;border:1px solid #e5e5e5;border-radius:8px;padding:.4rem .6rem;margin:.4rem 0} | ||
| 38 | .t.no{border-color:#fca5a5}.t summary{cursor:pointer}.t .g{color:#888;font-weight:400} | ||
| 39 | .t pre{background:#f5f5f5;padding:.5rem;border-radius:6px;overflow:auto;font-size:12px} | ||
| 40 | .x{padding:.2rem .75rem;font-size:13px}.x.ok{color:#15803d}.x.no{color:#b91c1c;font-weight:700} | ||
| 41 | .e{color:#b91c1c;font-weight:700;padding:.5rem .75rem}</style> | ||
| 42 | <h1>MCP run · <span class=hdr>${run.ok ? "PASSED" : "FAILED"}</span></h1> | ||
| 43 | ${run.turns.map(bubble).join("\n")} | ||
| 44 | <p style="color:#999;font-size:12px">brain=${run.brain} · ${run.durationMs}ms · ${esc(run.meta?.server ?? "")}</p>`; | ||
| 45 | } | ||
| 46 | |||
| 47 | if (process.argv[1]?.endsWith("render.mjs")) { | ||
| 48 | const file = process.argv[2]; | ||
| 49 | const run = JSON.parse(readFileSync(file, "utf8")); | ||
| 50 | const htmlArg = process.argv.indexOf("--html"); | ||
| 51 | if (htmlArg > -1) { const out = process.argv[htmlArg + 1]; writeFileSync(out, renderHtml(run)); console.log("wrote", out); } | ||
| 52 | else console.log(renderTerminal(run)); | ||
| 53 | } | ||
| 54 | |||
| 55 | import { dirname, join as _join } from "node:path"; | ||
| 56 | import { fileURLToPath } from "node:url"; | ||
| 57 | export function renderPlayer(run) { | ||
| 58 | const tpl = readFileSync(_join(dirname(fileURLToPath(import.meta.url)), "player.template.html"), "utf8"); | ||
| 59 | return tpl.replace("__RUN__", JSON.stringify(run)); | ||
| 60 | } | ||
| @@ -0,0 +1,40 @@ | |||
| 1 | import { mkdirSync, writeFileSync } from "node:fs"; | ||
| 2 | import { join, dirname } from "node:path"; | ||
| 3 | import { fileURLToPath } from "node:url"; | ||
| 4 | import { Recorder } from "./recorder.mjs"; | ||
| 5 | import { McpDriver } from "./driver.mjs"; | ||
| 6 | export { cookieConsentStrategy } from "../../vendor/mcporter/dist/index.js"; | ||
| 7 | |||
| 8 | const RUNS = join(dirname(fileURLToPath(import.meta.url)), "..", "runs"); | ||
| 9 | |||
| 10 | // A target = the per-host seam: which MCP server + how to consent (+ optional boot). | ||
| 11 | export function mcpTarget(cfg) { | ||
| 12 | return { name: cfg.name ?? "target", server: cfg.server, consent: cfg.consent, boot: cfg.boot }; | ||
| 13 | } | ||
| 14 | |||
| 15 | const tests = []; | ||
| 16 | export function mcpTest(name, target, fn) { tests.push({ name, target, fn }); } | ||
| 17 | |||
| 18 | const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60); | ||
| 19 | |||
| 20 | export async function runTests({ outDir = RUNS } = {}) { | ||
| 21 | mkdirSync(outDir, { recursive: true }); | ||
| 22 | const results = []; | ||
| 23 | for (const t of tests) { | ||
| 24 | // the test name IS the task / opening "user" turn — the MCP surface is a chat | ||
| 25 | const rec = new Recorder(t.name, { server: t.target.server, brain: "scripted" }); | ||
| 26 | const mcp = new McpDriver(t.target, rec); | ||
| 27 | let ok = true, error; | ||
| 28 | try { await t.fn(mcp); } | ||
| 29 | catch (e) { ok = false; error = e; } | ||
| 30 | const run = rec.finish(ok, error); | ||
| 31 | const file = join(outDir, slug(t.name) + ".run.json"); | ||
| 32 | writeFileSync(file, JSON.stringify(run, null, 2)); | ||
| 33 | results.push({ name: t.name, ok: run.ok, file }); | ||
| 34 | console.log(`${run.ok ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m"} ${t.name} → ${file.replace(process.cwd() + "/", "")}`); | ||
| 35 | if (!run.ok && run.error) console.log(` ${run.error}`); | ||
| 36 | } | ||
| 37 | const failed = results.filter((r) => !r.ok).length; | ||
| 38 | console.log(`\n${results.length - failed}/${results.length} passed`); | ||
| 39 | return { results, failed }; | ||
| 40 | } | ||
| @@ -0,0 +1,25 @@ | |||
| 1 | import { mcpTarget, mcpTest, cookieConsentStrategy } from "../src/testkit.mjs"; | ||
| 2 | |||
| 3 | // the per-host seam: self-host dev + headless OAuth (no browser, runs in CI) | ||
| 4 | export const selfhost = mcpTarget({ | ||
| 5 | name: "selfhost", | ||
| 6 | server: "http://localhost:5173/mcp", | ||
| 7 | consent: cookieConsentStrategy({ | ||
| 8 | appBaseUrl: "http://localhost:5173", | ||
| 9 | email: "admin@demo.test", | ||
| 10 | password: "demo-password-12345", | ||
| 11 | }), | ||
| 12 | }); | ||
| 13 | |||
| 14 | mcpTest("execute runs code in the sandbox and returns its value", selfhost, async (mcp) => { | ||
| 15 | mcp.say("Confirm the sandbox evaluates a program and returns the value."); | ||
| 16 | const tools = await mcp.listTools(); | ||
| 17 | mcp.expect(tools).toContain("execute"); | ||
| 18 | const r = await mcp.call("execute", { code: "return 6 * 7;" }); | ||
| 19 | mcp.expect(r.text).toBe("42"); | ||
| 20 | }); | ||
| 21 | |||
| 22 | mcpTest("execute can use string operations", selfhost, async (mcp) => { | ||
| 23 | const r = await mcp.call("execute", { code: "return 'mcp'.toUpperCase();" }); | ||
| 24 | mcp.expect(r.text).toBe("MCP"); | ||
| 25 | }); | ||
| @@ -0,0 +1,83 @@ | |||
| 1 | import { mcpTest } from "../src/testkit.mjs"; | ||
| 2 | import { selfhost } from "./execute.test.mjs"; | ||
| 3 | |||
| 4 | const uniqueId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; | ||
| 5 | |||
| 6 | mcpTest( | ||
| 7 | "x emulator OAuth client-credentials clients authenticate with HTTP Basic", | ||
| 8 | selfhost, | ||
| 9 | async (mcp) => { | ||
| 10 | const slug = uniqueId("x-oauth-basic"); | ||
| 11 | const instance = slug; | ||
| 12 | const baseUrl = `https://emulators.dev/x/${instance}`; | ||
| 13 | |||
| 14 | mcp.say( | ||
| 15 | "Register the X emulator OpenAPI integration, create a confidential OAuth client, and start a client-credentials connection through Executor itself.", | ||
| 16 | ); | ||
| 17 | |||
| 18 | const started = await mcp.call("execute", { | ||
| 19 | code: ` | ||
| 20 | const slug = ${JSON.stringify(slug)}; | ||
| 21 | const baseUrl = ${JSON.stringify(baseUrl)}; | ||
| 22 | const authorizationUrl = baseUrl + "/2/oauth2/authorize"; | ||
| 23 | const tokenUrl = baseUrl + "/2/oauth2/token"; | ||
| 24 | |||
| 25 | const added = await tools.executor.openapi.addSpec({ | ||
| 26 | spec: { kind: "url", url: "https://x.emulators.dev/2/openapi.json" }, | ||
| 27 | slug, | ||
| 28 | baseUrl, | ||
| 29 | authenticationTemplate: [{ | ||
| 30 | slug: "oauth2", | ||
| 31 | type: "oauth", | ||
| 32 | authorizationUrl, | ||
| 33 | tokenUrl, | ||
| 34 | scopes: [], | ||
| 35 | }], | ||
| 36 | }); | ||
| 37 | |||
| 38 | const client = await tools.executor.coreTools.oauth.clients.create({ | ||
| 39 | owner: "org", | ||
| 40 | slug: slug + "-client", | ||
| 41 | authorizationUrl, | ||
| 42 | tokenUrl, | ||
| 43 | grant: "client_credentials", | ||
| 44 | clientId: "x-confidential-client", | ||
| 45 | clientSecret: "x-confidential-secret", | ||
| 46 | }); | ||
| 47 | |||
| 48 | const connection = await tools.executor.coreTools.oauth.start({ | ||
| 49 | client: slug + "-client", | ||
| 50 | clientOwner: "org", | ||
| 51 | owner: "org", | ||
| 52 | name: "machine", | ||
| 53 | integration: slug, | ||
| 54 | template: "oauth2", | ||
| 55 | }); | ||
| 56 | |||
| 57 | return JSON.stringify({ added, client, connection }, null, 2); | ||
| 58 | `, | ||
| 59 | }); | ||
| 60 | |||
| 61 | mcp.expect(started.text).toContain("Execution paused: Add an OpenAPI integration"); | ||
| 62 | |||
| 63 | const resumed = await mcp.approvePaused(started.text); | ||
| 64 | const ledger = await fetch(`${baseUrl}/_emulate/ledger?limit=20`).then((r) => r.json()); | ||
| 65 | const tokenRequest = ledger.entries.find( | ||
| 66 | (entry) => | ||
| 67 | entry.path === "/2/oauth2/token" && | ||
| 68 | entry.request?.body?.grant_type === "client_credentials", | ||
| 69 | ); | ||
| 70 | |||
| 71 | if (!resumed.ok) { | ||
| 72 | mcp.expect(Boolean(tokenRequest)).toBe(true); | ||
| 73 | mcp.expect(Object.hasOwn(tokenRequest.request.body, "client_secret")).toBe(false); | ||
| 74 | } | ||
| 75 | |||
| 76 | mcp.expect(resumed.ok).toBe(true); | ||
| 77 | mcp.expect(resumed.text).toContain('"status": "connected"'); | ||
| 78 | |||
| 79 | mcp.expect(Boolean(tokenRequest)).toBe(true); | ||
| 80 | mcp.expect(tokenRequest.request.headers.authorization).toBe("[redacted]"); | ||
| 81 | mcp.expect(Object.hasOwn(tokenRequest.request.body, "client_secret")).toBe(false); | ||
| 82 | }, | ||
| 83 | ); | ||