* 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 | 109 | # Throwaway UX prototype (not part of the app) |
| 110 | 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 | 321 | "version": "0.0.0", |
| 322 | 322 | "dependencies": { |
| 323 | 323 | "@executor-js/api": "workspace:*", |
| 324 | + "@executor-js/plugin-graphql": "workspace:*", | |
| 325 | + "@executor-js/plugin-mcp": "workspace:*", | |
| 324 | 326 | "@executor-js/plugin-openapi": "workspace:*", |
| 325 | 327 | "@executor-js/sdk": "workspace:*", |
| 326 | 328 | "@kitlangton/terminal-control": "^0.3.0", |
| @@ -13,6 +13,8 @@ · 12 unmodified lines | ||
| 13 | 13 | }, |
| 14 | 14 | "dependencies": { |
| 15 | 15 | "@executor-js/api": "workspace:*", |
| 16 | + "@executor-js/plugin-graphql": "workspace:*", | |
| 17 | + "@executor-js/plugin-mcp": "workspace:*", | |
| 16 | 18 | "@executor-js/plugin-openapi": "workspace:*", |
| 17 | 19 | "@executor-js/sdk": "workspace:*", |
| 18 | 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 | 24 | cwd: proc.cwd, |
| 25 | 25 | env: { ...process.env, ...proc.env }, |
| 26 | 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 | 32 | child.on("exit", (code) => { |
| 29 | 33 | if (code !== 0 && code !== null && !tearingDown) { |
| @@ -32,12 +36,38 @@ export const bootProcesses = ( · 2 unmodified lines | ||
| 32 | 36 | }); |
| 33 | 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 | 56 | return { |
| 36 | 57 | teardown: async () => { |
| 37 | 58 | tearingDown = true; |
| 38 | − for (const child of children) child.kill("SIGTERM"); | |
| 39 | − await new Promise((resolve) => setTimeout(resolve, 500)); | |
| 40 | − for (const child of children) if (child.exitCode === null) child.kill("SIGKILL"); | |
| 59 | + const allExited = Promise.all(children.map(exited)); | |
| 60 | + for (const child of children) signalTree(child, "SIGTERM"); | |
| 61 | + // Wait for a REAL exit (not a fixed sleep) — a lingering server would | |
| 62 | + // answer the next invocation's waitForHttp as a half-dead zombie. | |
| 63 | + const graceful = await Promise.race([ | |
| 64 | + allExited.then(() => true), | |
| 65 | + new Promise<false>((resolve) => setTimeout(() => resolve(false), 5_000)), | |
| 66 | + ]); | |
| 67 | + if (!graceful) { | |
| 68 | + for (const child of children) signalTree(child, "SIGKILL"); | |
| 69 | + await Promise.race([allExited, new Promise((resolve) => setTimeout(resolve, 2_000))]); | |
| 70 | + } | |
| 41 | 71 | }, |
| 42 | 72 | }; |
| 43 | 73 | }; |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | // fully-stubbed instance (multi-user WorkOS stub, free-plan Autumn, no |
| 4 | 4 | // network). Set E2E_CLOUD_URL to attach to an already-running instance |
| 5 | 5 | // instead (e.g. while iterating on a scenario). |
| 6 | +import { rmSync } from "node:fs"; | |
| 6 | 7 | import { resolve } from "node:path"; |
| 7 | 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 | 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 | 28 | const env = { |
| 21 | 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 | 34 | // Stub creds — never contacted; the stub layers replace the clients. |
| 23 | 35 | WORKOS_API_KEY: "sk_e2e_stub", |
| 24 | 36 | WORKOS_CLIENT_ID: "client_e2e_stub", |
| @@ -33,7 +45,7 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 8 unmodified lines | ||
| 33 | 45 | MCP_RESOURCE_ORIGIN: CLOUD_BASE_URL, |
| 34 | 46 | // Throwaway PGlite on its own port + dir so it never fights `bun dev`. |
| 35 | 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 | 51 | const procs = bootProcesses( |
| @@ -34,6 +34,10 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 33 unmodified lines | ||
| 34 | 34 | EXECUTOR_BOOTSTRAP_ADMIN_EMAIL: SELFHOST_ADMIN.email, |
| 35 | 35 | EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD: SELFHOST_ADMIN.password, |
| 36 | 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 | 1808 | integration: rowToIntegration(integrationRow), |
| 1809 | 1809 | config: decodeJsonColumn(integrationRow.config), |
| 1810 | 1810 | connection: ref, |
| 1811 | + template: existingRow ? AuthTemplateSlug.make(existingRow.template) : null, | |
| 1811 | 1812 | getValue: () => resolveConnectionValueByRef(ref), |
| 1812 | 1813 | }) |
| 1813 | 1814 | .pipe( |
| @@ -79,6 +79,7 @@ export type { · 78 unmodified lines | ||
| 79 | 79 | IntegrationDisplayDescriptor, |
| 80 | 80 | RegisterIntegrationInput, |
| 81 | 81 | } from "./integration"; |
| 82 | +export { freshCustomAuthSlug, mergeAuthTemplates } from "./integration"; | |
| 82 | 83 | export type { |
| 83 | 84 | Connection, |
| 84 | 85 | ConnectionRef, |
| @@ -92,6 +92,47 @@ export interface Integration { · 91 unmodified lines | ||
| 92 | 92 | * render auth / produce tools. Core treats it as an opaque JSON blob. */ |
| 93 | 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 | 136 | /** What a plugin's extension method passes to `ctx.core.integrations.register`. |
| 96 | 137 | * The v2 analog of v1's `SourceInput`, minus the per-source tool list (tools are |
| 97 | 138 | * produced per-connection now). */ |
| @@ -194,6 +194,10 @@ export interface ResolveToolsInput { · 193 unmodified lines | ||
| 194 | 194 | readonly config: IntegrationConfig; |
| 195 | 195 | /** The connection whose tools are being resolved. */ |
| 196 | 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 | 201 | /** Lazily resolve the connection's credential value via its provider — only |
| 198 | 202 | * the kinds that actually call out (mcp) pay for it. */ |
| 199 | 203 | readonly getValue: () => Effect.Effect<string | null, StorageFailure>; |
| @@ -1,13 +1,7 @@ | ||
| 1 | 1 | import { useCallback, useMemo, useState } from "react"; |
| 2 | −import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 3 | −import { Link } from "@tanstack/react-router"; | |
| 2 | +import { useAtomSet } from "@effect/atom-react"; | |
| 4 | 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 | 5 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 12 | 6 | import { |
| 13 | 7 | integrationDisplayNameFromUrl, |
| @@ -16,41 +10,35 @@ import { · 2 unmodified lines | ||
| 16 | 10 | } from "@executor-js/react/plugins/integration-identity"; |
| 17 | 11 | import { Button } from "@executor-js/react/components/button"; |
| 18 | 12 | import { |
| 19 | − AuthTemplateEditor, | |
| 20 | − type AuthTemplateEditorValue, | |
| 21 | −} from "@executor-js/react/components/auth-template-editor"; | |
| 22 | −import { FieldLabel } from "@executor-js/react/components/field"; | |
| 13 | + AuthMethodListEditor, | |
| 14 | + useAuthMethodList, | |
| 15 | + type AuthMethodRow, | |
| 16 | + type AuthMethodSeed, | |
| 17 | +} from "@executor-js/react/components/auth-method-list-editor"; | |
| 23 | 18 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 24 | 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 | 27 | import { addGraphqlIntegrationOptimistic } from "./atoms"; |
| 27 | 28 | import { GraphqlSourceFields } from "./GraphqlSourceFields"; |
| 28 | 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 }); | |
| 31 | −const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | |
| 32 | − | |
| 33 | −const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => | |
| 34 | − Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { | |
| 35 | − onNone: () => fallback, | |
| 36 | − onSome: ({ message }) => message, | |
| 37 | − }); | |
| 33 | +// v2 GraphQL add flow: register the integration with its declared auth-method | |
| 34 | +// LIST (the shared `AuthMethodListEditor` — GraphQL stays header/query apiKey; | |
| 35 | +// OAuth is hidden), then route to the integration's detail hub. Connection | |
| 36 | +// creation is no longer part of the add flow — accounts are added from the hub | |
| 37 | +// (P6: add without auth, connect later). | |
| 38 | 38 | |
| 39 | −const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean => | |
| 40 | − Option.match(Exit.findErrorOption(exit), { | |
| 41 | − onNone: () => false, | |
| 42 | − onSome: Predicate.isTagged("IntegrationAlreadyExistsError"), | |
| 43 | − }); | |
| 44 | − | |
| 45 | −const integrationExistsMessage = (slug: string): string => | |
| 46 | − `An integration named "${slug}" already exists. To add more authentication, update your existing integration.`; | |
| 47 | − | |
| 48 | −// v2 GraphQL add flow (post-redesign): register the integration (introspects | |
| 49 | −// the endpoint and declares an apiKey auth template when the user configures a | |
| 50 | −// header), then route to the integration's detail hub. Connection creation is | |
| 51 | −// no longer part of the add flow — accounts are added from the hub (P6: add | |
| 52 | −// without auth, connect later). Auth is declared through the shared | |
| 53 | −// `AuthTemplateEditor` (GraphQL stays header/query apiKey — OAuth is hidden). | |
| 39 | +// GraphQL has no add-time detection, so the list starts empty (module constant | |
| 40 | +// — a fresh [] every render would re-seed the list each render). | |
| 41 | +const NO_SEEDS: readonly AuthMethodSeed[] = []; | |
| 54 | 42 | |
| 55 | 43 | export default function AddGraphqlSource(props: { |
| 56 | 44 | onComplete: (slug?: string) => void; |
| @@ -61,7 +49,7 @@ export default function AddGraphqlSource(props: { · 4 unmodified lines | ||
| 61 | 49 | const identity = useIntegrationIdentity({ |
| 62 | 50 | fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "", |
| 63 | 51 | }); |
| 64 | − const [authValue, setAuthValue] = useState<AuthTemplateEditorValue>({ kind: "none" }); | |
| 52 | + const authMethodList = useAuthMethodList(NO_SEEDS); | |
| 65 | 53 | const [adding, setAdding] = useState(false); |
| 66 | 54 | const [addError, setAddError] = useState<string | null>(null); |
| 67 | 55 | |
| @@ -69,10 +57,33 @@ export default function AddGraphqlSource(props: { · 1 unmodified line | ||
| 69 | 57 | mode: "promiseExit", |
| 70 | 58 | }); |
| 71 | 59 | |
| 72 | − // An apiKey method needs at least one named placement; `none` is always valid. | |
| 73 | − const apiKeyComplete = | |
| 74 | − authValue.kind !== "apikey" || | |
| 75 | − authValue.placements.some((placement) => placement.name.trim().length > 0); | |
| 60 | + // The templates to register: each apikey row emits one template per named | |
| 61 | + // placement (GraphQL templates carry one header/query slot each). The first | |
| 62 | + // keeps the primary `apikey` slug (matching the prior single-method flow); | |
| 63 | + // the rest get deterministic `apikey-<n>` slugs. `none` rows register | |
| 64 | + // nothing. | |
| 65 | + const authenticationTemplate = useMemo<readonly AuthTemplate[]>(() => { | |
| 66 | + const templates: AuthTemplate[] = []; | |
| 67 | + for (const row of authMethodList.rows) { | |
| 68 | + if (row.value.kind !== "apikey") continue; | |
| 69 | + for (const template of graphqlTemplatesFromPlacements(row.value.placements, "")) { | |
| 70 | + const index = templates.length; | |
| 71 | + templates.push({ | |
| 72 | + ...template, | |
| 73 | + slug: index === 0 ? GRAPHQL_APIKEY_TEMPLATE : `apikey-${index + 1}`, | |
| 74 | + }); | |
| 75 | + } | |
| 76 | + } | |
| 77 | + return templates; | |
| 78 | + }, [authMethodList.rows]); | |
| 79 | + | |
| 80 | + // Every apikey row needs at least one named placement; `none` rows are | |
| 81 | + // always valid. | |
| 82 | + const apiKeyComplete = authMethodList.rows.every( | |
| 83 | + (row: AuthMethodRow) => | |
| 84 | + row.value.kind !== "apikey" || | |
| 85 | + row.value.placements.some((placement) => placement.name.trim().length > 0), | |
| 86 | + ); | |
| 76 | 87 | |
| 77 | 88 | const resolvedSlug = useMemo( |
| 78 | 89 | () => |
| @@ -85,13 +96,7 @@ export default function AddGraphqlSource(props: { · 6 unmodified lines | ||
| 85 | 96 | // Pre-empt the API's `IntegrationAlreadyExistsError`: adding an integration |
| 86 | 97 | // whose slug already exists clobbers the existing one's connections/policies, |
| 87 | 98 | // so the API blocks it. Surface that here from the tenant-scoped catalog list. |
| 88 | − const integrationsResult = useAtomValue(integrationsOptimisticAtom); | |
| 89 | − const slugAlreadyExists = useMemo( | |
| 90 | − () => | |
| 91 | − AsyncResult.isSuccess(integrationsResult) && | |
| 92 | − integrationsResult.value.some((integration) => String(integration.slug) === resolvedSlug), | |
| 93 | − [integrationsResult, resolvedSlug], | |
| 94 | − ); | |
| 99 | + const slugAlreadyExists = useSlugAlreadyExists(resolvedSlug); | |
| 95 | 100 | |
| 96 | 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 | 113 | setAddError(null); |
| 109 | 114 | const { trimmedEndpoint, slug, displayName } = sourceIdentity(); |
| 110 | 115 | |
| 111 | − const authenticationTemplate = | |
| 112 | − authValue.kind === "apikey" ? graphqlTemplatesFromPlacements(authValue.placements) : []; | |
| 113 | − | |
| 114 | 116 | const integrationExit = await doAddIntegration({ |
| 115 | 117 | payload: { |
| 116 | 118 | endpoint: trimmedEndpoint, |
| 117 | 119 | slug, |
| 118 | 120 | name: displayName, |
| 119 | − ...(authenticationTemplate.length > 0 ? { authenticationTemplate } : {}), | |
| 121 | + ...(authenticationTemplate.length > 0 | |
| 122 | + ? { authenticationTemplate: [...authenticationTemplate] } | |
| 123 | + : {}), | |
| 120 | 124 | }, |
| 121 | 125 | reactivityKeys: integrationWriteKeys, |
| 122 | 126 | }); |
| 123 | 127 | if (Exit.isFailure(integrationExit)) { |
| 124 | − setAddError( | |
| 125 | − isIntegrationAlreadyExistsExit(integrationExit) | |
| 126 | − ? integrationExistsMessage(slug) | |
| 127 | − : errorMessageFromExit(integrationExit, "Failed to add source"), | |
| 128 | − ); | |
| 128 | + setAddError(addIntegrationErrorMessage(integrationExit, slug, "Failed to add source")); | |
| 129 | 129 | setAdding(false); |
| 130 | 130 | return; |
| 131 | 131 | } |
| @@ -140,36 +140,16 @@ export default function AddGraphqlSource(props: { · 8 unmodified lines | ||
| 140 | 140 | |
| 141 | 141 | <GraphqlSourceFields endpoint={endpoint} onEndpointChange={setEndpoint} identity={identity} /> |
| 142 | 142 | |
| 143 | − <section className="space-y-2.5"> | |
| 144 | − <FieldLabel>How does this API authenticate?</FieldLabel> | |
| 145 | − <AuthTemplateEditor | |
| 146 | − value={authValue} | |
| 147 | − onChange={setAuthValue} | |
| 148 | − allowedKinds={["none", "apikey"]} | |
| 149 | − /> | |
| 150 | − </section> | |
| 151 | − | |
| 152 | − {slugAlreadyExists && !adding && ( | |
| 153 | − <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | |
| 154 | − <p className="text-[12px] text-destructive"> | |
| 155 | − An integration named "{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 | − )} | |
| 143 | + <AuthMethodListEditor | |
| 144 | + list={authMethodList} | |
| 145 | + allowedKinds={["none", "apikey"]} | |
| 146 | + emptyHint="No authentication declared. Add a method, or add the source without auth and connect an account from the integration page later." | |
| 147 | + footerHint="Every method here is registered with the source. Connect an account from the integration page after adding." | |
| 148 | + /> | |
| 149 | + | |
| 150 | + {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSlug} />} | |
| 151 | + | |
| 152 | + {addError && <FormErrorAlert message={addError} />} | |
| 173 | 153 | |
| 174 | 154 | <FloatActions> |
| 175 | 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 | 8 | import { AccountsSection } from "@executor-js/react/components/accounts-section"; |
| 9 | 9 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 10 | −import type { CreateCustomMethod } from "@executor-js/react/components/add-custom-method-modal"; | |
| 11 | 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 | 17 | import { graphqlConfigAtom, graphqlConfigure } from "./atoms"; |
| 14 | 18 | import { authMethodsFromConfig, graphqlTemplatesFromPlacements } from "./auth-method-config"; |
| @@ -47,45 +51,41 @@ export default function GraphqlAccountsPanel(props: { · 32 unmodified lines | ||
| 47 | 51 | [existingTemplate], |
| 48 | 52 | ); |
| 49 | 53 | |
| 50 | − // Add a custom apiKey method: build graphql `apiKey` templates from the | |
| 51 | − // generic placements (slug omitted → backend backfills `custom_<id>`), | |
| 52 | − // merge-append (the configure endpoint merges) and persist. Returns the | |
| 53 | − // created `AuthMethod` so Add-account can select it immediately. | |
| 54 | − const createCustomMethod = useCallback<CreateCustomMethod>( | |
| 55 | − async (input: { readonly label: string; readonly placements: readonly Placement[] }) => { | |
| 56 | − const templates = graphqlTemplatesFromPlacements(input.placements, ""); | |
| 57 | − if (templates.length === 0) return null; | |
| 54 | + // Custom-method create/remove: the shared skeleton (merge-append → diff out | |
| 55 | + // the created method; filter → replace) parameterized by the GraphQL codec. | |
| 56 | + // Stays plugin-side only where it touches the graphql `AuthTemplate` types. | |
| 57 | + const configure = useCallback<ConfigureAuthMethods<AuthTemplate>>( | |
| 58 | + async (input) => { | |
| 58 | 59 | const exit = await doConfigure({ |
| 59 | 60 | params: { slug: String(slug) }, |
| 60 | − payload: { authenticationTemplate: templates }, | |
| 61 | + payload: { | |
| 62 | + authenticationTemplate: input.authenticationTemplate, | |
| 63 | + ...(input.mode ? { mode: input.mode } : {}), | |
| 64 | + }, | |
| 61 | 65 | reactivityKeys: integrationWriteKeys, |
| 62 | 66 | }); |
| 63 | − if (Exit.isFailure(exit)) return null; | |
| 64 | − const before = new Set(existingTemplate.map((template: AuthTemplate) => template.slug)); | |
| 65 | − const created = authMethodsFromConfig(exit.value.authenticationTemplate).find( | |
| 66 | − (candidate: AuthMethod) => !before.has(String(candidate.template)), | |
| 67 | − ); | |
| 68 | − return created ?? null; | |
| 67 | + return Exit.map(exit, (result) => result.authenticationTemplate); | |
| 69 | 68 | }, |
| 70 | − [doConfigure, existingTemplate, slug], | |
| 69 | + [doConfigure, slug], | |
| 71 | 70 | ); |
| 72 | 71 | |
| 73 | − const removeCustomMethod = useCallback( | |
| 74 | − async (method: AuthMethod): Promise<boolean> => { | |
| 75 | − if (method.source !== "custom") return false; | |
| 76 | − const next = existingTemplate.filter( | |
| 77 | − (template: AuthTemplate) => template.slug !== String(method.template), | |
| 78 | − ); | |
| 79 | − const exit = await doConfigure({ | |
| 80 | − params: { slug: String(slug) }, | |
| 81 | − payload: { authenticationTemplate: next, mode: "replace" }, | |
| 82 | − reactivityKeys: integrationWriteKeys, | |
| 83 | − }); | |
| 84 | − return Exit.isSuccess(exit); | |
| 85 | − }, | |
| 86 | − [doConfigure, existingTemplate, slug], | |
| 72 | + const codec = useMemo<AuthMethodsCodec<AuthTemplate>>( | |
| 73 | + () => ({ | |
| 74 | + toAuthMethods: authMethodsFromConfig, | |
| 75 | + // Slug blank → backend backfills `custom_<id>` per emitted template. | |
| 76 | + templatesFromPlacements: (placements: readonly Placement[]) => | |
| 77 | + graphqlTemplatesFromPlacements(placements, ""), | |
| 78 | + slugOf: (template: AuthTemplate) => template.slug, | |
| 79 | + }), | |
| 80 | + [], | |
| 87 | 81 | ); |
| 88 | 82 | |
| 83 | + const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({ | |
| 84 | + existing: existingTemplate, | |
| 85 | + codec, | |
| 86 | + configure, | |
| 87 | + }); | |
| 88 | + | |
| 89 | 89 | return ( |
| 90 | 90 | <div className="mx-auto max-w-3xl space-y-8 px-6 py-8"> |
| 91 | 91 | <AccountsSection |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | IntegrationAlreadyExistsError, |
| 10 | 10 | IntegrationDetectionResult, |
| 11 | 11 | IntegrationSlug, |
| 12 | + mergeAuthTemplates, | |
| 12 | 13 | ToolName, |
| 13 | 14 | ToolResult, |
| 14 | 15 | type AuthMethodDescriptor, |
| @@ -387,14 +388,17 @@ const toStoredOperations = ( · 372 unmodified lines | ||
| 387 | 388 | const introspectHeadersForConnection = ( |
| 388 | 389 | config: GraphqlIntegrationConfig, |
| 389 | 390 | credentialValue: string | null, |
| 391 | + templateSlug: AuthTemplateSlug | null, | |
| 390 | 392 | ): RenderedAuth => { |
| 391 | 393 | const headers: Record<string, string> = { ...(config.headers ?? {}) }; |
| 392 | 394 | const queryParams: Record<string, string> = { ...(config.queryParams ?? {}) }; |
| 393 | 395 | if (credentialValue !== null) { |
| 394 | − // A connection references exactly one template; when several are declared we | |
| 395 | − // can't know which without the slug (not carried into `resolveTools`), so we | |
| 396 | − // apply the first. Most integrations declare a single auth method. | |
| 397 | − const template = config.authenticationTemplate[0]; | |
| 396 | + // Render the exact template the connection references; with no slug | |
| 397 | + // (connection row not yet persisted) fall back to the first declared. | |
| 398 | + const template = | |
| 399 | + (templateSlug !== null | |
| 400 | + ? config.authenticationTemplate.find((t: AuthTemplate) => t.slug === String(templateSlug)) | |
| 401 | + : undefined) ?? config.authenticationTemplate[0]; | |
| 398 | 402 | if (template) { |
| 399 | 403 | const rendered = renderAuthTemplate(template, credentialValue); |
| 400 | 404 | Object.assign(headers, rendered.headers); |
| @@ -410,12 +414,13 @@ const introspectHeadersForConnection = ( · 9 unmodified lines | ||
| 410 | 414 | const introspectForConnection = ( |
| 411 | 415 | config: GraphqlIntegrationConfig, |
| 412 | 416 | credentialValue: string | null, |
| 417 | + templateSlug: AuthTemplateSlug | null, | |
| 413 | 418 | httpClientLayer: Layer.Layer<HttpClient.HttpClient>, |
| 414 | 419 | ): Effect.Effect<IntrospectionResult, GraphqlIntrospectionError> => { |
| 415 | 420 | if (config.introspectionJson) { |
| 416 | 421 | return parseIntrospectionJson(config.introspectionJson); |
| 417 | 422 | } |
| 418 | − const auth = introspectHeadersForConnection(config, credentialValue); | |
| 423 | + const auth = introspectHeadersForConnection(config, credentialValue, templateSlug); | |
| 419 | 424 | return introspect( |
| 420 | 425 | config.endpoint, |
| 421 | 426 | Object.keys(auth.headers).length > 0 ? auth.headers : undefined, |
| @@ -532,44 +537,6 @@ export const describeGraphqlIntegrationDisplay = ( · 110 unmodified lines | ||
| 532 | 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 | 541 | // Plugin extension |
| 575 | 542 | // --------------------------------------------------------------------------- |
| @@ -735,10 +702,7 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 159 unmodified lines | ||
| 735 | 702 | const merged = |
| 736 | 703 | input.mode === "replace" |
| 737 | 704 | ? input.authenticationTemplate |
| 738 | − : mergeGraphqlAuthTemplate( | |
| 739 | − current.authenticationTemplate, | |
| 740 | − input.authenticationTemplate, | |
| 741 | − ); | |
| 705 | + : mergeAuthTemplates(current.authenticationTemplate, input.authenticationTemplate); | |
| 742 | 706 | |
| 743 | 707 | const next = GraphqlIntegrationConfig.make({ |
| 744 | 708 | ...current, |
| @@ -884,9 +848,11 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 139 unmodified lines | ||
| 884 | 848 | // ----------------------------------------------------------------------- |
| 885 | 849 | resolveTools: ({ |
| 886 | 850 | config, |
| 851 | + template, | |
| 887 | 852 | getValue, |
| 888 | 853 | }: { |
| 889 | 854 | readonly config: IntegrationConfig; |
| 855 | + readonly template: AuthTemplateSlug | null; | |
| 890 | 856 | readonly getValue: () => Effect.Effect<string | null, unknown>; |
| 891 | 857 | }) => |
| 892 | 858 | Effect.gen(function* () { |
| @@ -902,6 +868,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 9 unmodified lines | ||
| 902 | 868 | const introspection = yield* introspectForConnection( |
| 903 | 869 | graphqlConfig, |
| 904 | 870 | credentialValue, |
| 871 | + template, | |
| 905 | 872 | options?.httpClientLayer ?? httpClientLayerFallback, |
| 906 | 873 | ).pipe(Effect.option); |
| 907 | 874 | if (Option.isNone(introspection)) return { tools: [] }; |
| @@ -107,6 +107,7 @@ describe("httpSourcePlugin.resolveTools", () => { · 106 unmodified lines | ||
| 107 | 107 | integration: IntegrationSlug.make("example"), |
| 108 | 108 | name: ConnectionName.make("default"), |
| 109 | 109 | }, |
| 110 | + template: null, | |
| 110 | 111 | getValue: () => Effect.succeed(null), |
| 111 | 112 | }; |
| 112 | 113 | const result = yield* plugin.resolveTools!(input); |
| @@ -7,7 +7,12 @@ import { · 6 unmodified lines | ||
| 7 | 7 | } from "@executor-js/sdk/shared"; |
| 8 | 8 | |
| 9 | 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 | 18 | // Params |
| @@ -31,6 +36,10 @@ const AddRemoteServerPayload = Schema.Struct({ · 17 unmodified lines | ||
| 31 | 36 | slug: Schema.optional(Schema.String), |
| 32 | 37 | queryParams: Schema.optional(StringMap), |
| 33 | 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 | 43 | auth: Schema.optional(McpAuthTemplate), |
| 35 | 44 | }); |
| 36 | 45 | |
| @@ -83,6 +92,18 @@ const ConfigureServerResponse = Schema.Struct({ · 46 unmodified lines | ||
| 83 | 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 | 107 | const GetServerResponse = Schema.NullOr( |
| 87 | 108 | Schema.Struct({ |
| 88 | 109 | slug: IntegrationSlug, |
| @@ -143,4 +164,12 @@ export const McpGroup = HttpApiGroup.make("mcp") · 54 unmodified lines | ||
| 143 | 164 | success: ConfigureServerResponse, |
| 144 | 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 | 28 | removeServer: () => unused, |
| 29 | 29 | getServer: () => Effect.succeed(null), |
| 30 | 30 | configureServer: () => unused, |
| 31 | + configureAuth: () => unused, | |
| 31 | 32 | }; |
| 32 | 33 | |
| 33 | 34 | const Api = addGroup(McpGroup); |
| @@ -58,6 +58,9 @@ const toServerInput = ( · 57 unmodified lines | ||
| 58 | 58 | queryParams?: Record<string, string>; |
| 59 | 59 | headers?: Record<string, string>; |
| 60 | 60 | slug?: string; |
| 61 | + authenticationTemplate?: McpServerInput extends { authenticationTemplate?: infer T } | |
| 62 | + ? T | |
| 63 | + : never; | |
| 61 | 64 | auth?: McpServerInput extends { auth?: infer A } ? A : never; |
| 62 | 65 | }; |
| 63 | 66 | |
| @@ -69,6 +72,7 @@ const toServerInput = ( · 5 unmodified lines | ||
| 69 | 72 | queryParams: p.queryParams, |
| 70 | 73 | headers: p.headers, |
| 71 | 74 | slug: p.slug, |
| 75 | + authenticationTemplate: p.authenticationTemplate, | |
| 72 | 76 | auth: p.auth, |
| 73 | 77 | }; |
| 74 | 78 | }; |
| @@ -133,5 +137,17 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 58 unmodified lines | ||
| 133 | 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 | 1 | import { useReducer, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | −import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 3 | −import { Link } from "@tanstack/react-router"; | |
| 2 | +import { useAtomSet } from "@effect/atom-react"; | |
| 4 | 3 | import * as Exit from "effect/Exit"; |
| 5 | 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 | 6 | import { Button } from "@executor-js/react/components/button"; |
| 13 | 7 | import { |
| 14 | − AuthTemplateEditor, | |
| 15 | − type AuthTemplateEditorKind, | |
| 16 | − type AuthTemplateEditorValue, | |
| 17 | −} from "@executor-js/react/components/auth-template-editor"; | |
| 8 | + AuthMethodListEditor, | |
| 9 | + useAuthMethodList, | |
| 10 | + type AuthMethodRow, | |
| 11 | + type AuthMethodSeed, | |
| 12 | +} from "@executor-js/react/components/auth-method-list-editor"; | |
| 18 | 13 | import { |
| 19 | 14 | CardStack, |
| 20 | 15 | CardStackContent, |
| 21 | 16 | CardStackEntryField, |
| 22 | 17 | } from "@executor-js/react/components/card-stack"; |
| 23 | −import { FieldLabel } from "@executor-js/react/components/field"; | |
| 24 | 18 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 25 | 19 | import { Input } from "@executor-js/react/components/input"; |
| 26 | 20 | import { Spinner } from "@executor-js/react/components/spinner"; |
| @@ -31,20 +25,29 @@ import { · 4 unmodified lines | ||
| 31 | 25 | IntegrationIdentityFields, |
| 32 | 26 | useIntegrationIdentity, |
| 33 | 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 | 36 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 37 | +import type { McpAuthMethodInput } from "../sdk/types"; | |
| 36 | 38 | import { probeMcpEndpoint, addMcpServer } from "./atoms"; |
| 37 | 39 | import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; |
| 38 | −import { mcpAuthTemplateFromEditorValue } from "./auth-method-config"; | |
| 40 | +import { mcpAuthMethodInputFromEditorValue } from "./auth-method-config"; | |
| 39 | 41 | import { mcpPresets, type McpPreset } from "../sdk/presets"; |
| 40 | 42 | |
| 41 | −// Post-redesign the remote add flow only REGISTERS the auth template through the | |
| 42 | −// shared `AuthTemplateEditor` — accounts (the API key value / OAuth sign-in) are | |
| 43 | −// added later from the integration's detail hub (P6: add without auth, connect | |
| 44 | −// later). An OAuth-only server (DCR-capable) is constrained to the OAuth tab. | |
| 43 | +// The remote add flow REGISTERS the server's declared auth methods through the | |
| 44 | +// shared `AuthMethodListEditor` — accounts (the API key value / OAuth sign-in) | |
| 45 | +// are added later from the integration's detail hub (P6: add without auth, | |
| 46 | +// connect later). The probe SEEDS the list (detected OAuth → an OAuth row; a | |
| 47 | +// 401 without OAuth → a bearer-header row; open server → a no-auth row) and | |
| 48 | +// the user can add alternate methods (e.g. an API key alongside OAuth, or a | |
| 49 | +// declared method on a server that advertises none). | |
| 45 | 50 | |
| 46 | −const ErrorMessage = Schema.Struct({ message: Schema.String }); | |
| 47 | −const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | |
| 48 | 51 | const STDIO_ENV_ESCAPE_REPLACEMENTS: Readonly<Record<string, string>> = { |
| 49 | 52 | "\\": "\\", |
| 50 | 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 | 60 | // Preset lookup |
| 73 | 61 | // --------------------------------------------------------------------------- |
| @@ -212,18 +200,35 @@ export default function AddMcpSource(props: { · 138 unmodified lines | ||
| 212 | 200 | const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" }); |
| 213 | 201 | const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" }); |
| 214 | 202 | |
| 215 | − const [authValue, setAuthValue] = useState<AuthTemplateEditorValue>({ kind: "none" }); | |
| 216 | − | |
| 217 | 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 | |
| 220 | − // other servers offer none / API key / OAuth (matching the prior tab set). | |
| 221 | − const allowedAuthKinds: readonly AuthTemplateEditorKind[] = | |
| 222 | − probe?.requiresOAuth && probe.supportsDynamicRegistration | |
| 223 | − ? ["oauth"] | |
| 224 | − : probe?.requiresAuthentication | |
| 225 | − ? ["apikey", "oauth"] | |
| 226 | − : ["none", "apikey", "oauth"]; | |
| 205 | + // The probe seeds the method list: detected OAuth → an OAuth row; a 401 | |
| 206 | + // without OAuth metadata → a bearer-header row; an open server → a no-auth | |
| 207 | + // row. The user can edit any row or add alternate methods alongside. | |
| 208 | + const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(() => { | |
| 209 | + if (!probe) return []; | |
| 210 | + if (probe.requiresOAuth) { | |
| 211 | + return [ | |
| 212 | + { | |
| 213 | + value: { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }, | |
| 214 | + label: "Detected", | |
| 215 | + }, | |
| 216 | + ]; | |
| 217 | + } | |
| 218 | + if (probe.requiresAuthentication) { | |
| 219 | + return [ | |
| 220 | + { | |
| 221 | + value: { | |
| 222 | + kind: "apikey", | |
| 223 | + placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | |
| 224 | + }, | |
| 225 | + label: "Detected", | |
| 226 | + }, | |
| 227 | + ]; | |
| 228 | + } | |
| 229 | + return [{ value: { kind: "none" }, label: "Detected" }]; | |
| 230 | + }, [probe]); | |
| 231 | + const authMethodList = useAuthMethodList(authMethodSeeds); | |
| 227 | 232 | |
| 228 | 233 | const remoteIdentity = useIntegrationIdentity({ |
| 229 | 234 | fallbackName: |
| @@ -237,18 +242,10 @@ export default function AddMcpSource(props: { · 7 unmodified lines | ||
| 237 | 242 | // so the API blocks it. Surface that here from the tenant-scoped catalog list. |
| 238 | 243 | // A blank derived namespace lets the server assign the slug, so only flag a |
| 239 | 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 | 245 | const remoteSlug = slugifyNamespace(remoteIdentity.namespace); |
| 249 | 246 | const stdioSlug = slugifyNamespace(stdioIdentity.namespace); |
| 250 | − const remoteSlugExists = remoteSlug.length > 0 && existingSlugs.includes(remoteSlug); | |
| 251 | − const stdioSlugExists = stdioSlug.length > 0 && existingSlugs.includes(stdioSlug); | |
| 247 | + const remoteSlugExists = useSlugAlreadyExists(remoteSlug); | |
| 248 | + const stdioSlugExists = useSlugAlreadyExists(stdioSlug); | |
| 252 | 249 | |
| 253 | 250 | const canAdd = Boolean(probe) && !isAdding && !remoteSlugExists; |
| 254 | 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 | 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 | 270 | dispatch({ type: "probe-ok", probe: exit.value }); |
| 284 | 271 | }, [state.url, doProbe]); |
| 285 | 272 | |
| @@ -301,15 +288,10 @@ export default function AddMcpSource(props: { · 15 unmodified lines | ||
| 301 | 288 | return () => clearTimeout(handle); |
| 302 | 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 | 292 | // assigned slug (or null on failure — an error is dispatched in that case). |
| 306 | 293 | const registerIntegration = useCallback( |
| 307 | − async ( | |
| 308 | − auth: | |
| 309 | − | { kind: "none" } | |
| 310 | − | { kind: "header"; headerName: string; prefix?: string } | |
| 311 | − | { kind: "oauth2" }, | |
| 312 | − ): Promise<string | null> => { | |
| 294 | + async (authenticationTemplate: readonly McpAuthMethodInput[]): Promise<string | null> => { | |
| 313 | 295 | const displayName = remoteIdentity.name.trim() || probe?.serverName || probe?.name || "MCP"; |
| 314 | 296 | const slug = slugifyNamespace(remoteIdentity.namespace) || undefined; |
| 315 | 297 | const exit = await doAddServer({ |
| @@ -318,16 +300,14 @@ export default function AddMcpSource(props: { · 2 unmodified lines | ||
| 318 | 300 | name: displayName, |
| 319 | 301 | endpoint: state.url.trim(), |
| 320 | 302 | ...(slug ? { slug } : {}), |
| 321 | − auth, | |
| 303 | + authenticationTemplate, | |
| 322 | 304 | }, |
| 323 | 305 | reactivityKeys: integrationWriteKeys, |
| 324 | 306 | }); |
| 325 | 307 | if (Exit.isFailure(exit)) { |
| 326 | 308 | dispatch({ |
| 327 | 309 | type: "add-fail", |
| 328 | − error: isIntegrationAlreadyExistsExit(exit) | |
| 329 | − ? integrationExistsMessage(slug ?? displayName) | |
| 330 | − : errorMessageFromExit(exit, "Failed to add server"), | |
| 310 | + error: addIntegrationErrorMessage(exit, slug ?? displayName, "Failed to add server"), | |
| 331 | 311 | }); |
| 332 | 312 | return null; |
| 333 | 313 | } |
| @@ -339,10 +319,17 @@ export default function AddMcpSource(props: { · 5 unmodified lines | ||
| 339 | 319 | const handleAddRemote = useCallback(async () => { |
| 340 | 320 | if (!probe) return; |
| 341 | 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 | 330 | if (slug === null) return; |
| 344 | 331 | props.onComplete(slug); |
| 345 | − }, [probe, authValue, registerIntegration, props]); | |
| 332 | + }, [probe, authMethodList.rows, registerIntegration, props]); | |
| 346 | 333 | |
| 347 | 334 | // ---- Stdio actions ---- |
| 348 | 335 | |
| @@ -406,11 +393,7 @@ export default function AddMcpSource(props: { · 57 unmodified lines | ||
| 406 | 393 | reactivityKeys: integrationWriteKeys, |
| 407 | 394 | }); |
| 408 | 395 | if (Exit.isFailure(exit)) { |
| 409 | − setStdioError( | |
| 410 | − isIntegrationAlreadyExistsExit(exit) | |
| 411 | − ? integrationExistsMessage(slug ?? displayName) | |
| 412 | − : errorMessageFromExit(exit, "Failed to add server"), | |
| 413 | − ); | |
| 396 | + setStdioError(addIntegrationErrorMessage(exit, slug ?? displayName, "Failed to add server")); | |
| 414 | 397 | setStdioAdding(false); |
| 415 | 398 | return; |
| 416 | 399 | } |
| @@ -470,27 +453,24 @@ export default function AddMcpSource(props: { · 53 unmodified lines | ||
| 470 | 453 | onRetry={handleProbe} |
| 471 | 454 | /> |
| 472 | 455 | |
| 473 | − {/* Authentication — declares the auth template to register through the | |
| 474 | − shared editor. The credential itself (API key value / OAuth sign-in) | |
| 475 | − is added from the integration's detail hub after adding. */} | |
| 456 | + {/* Authentication — declares the auth methods to register through the | |
| 457 | + shared list editor. The credentials themselves (API key value / | |
| 458 | + OAuth sign-in) are added from the integration's detail hub after | |
| 459 | + adding. */} | |
| 476 | 460 | {probe && ( |
| 477 | − <section className="space-y-2.5"> | |
| 478 | − <FieldLabel>How does this server authenticate?</FieldLabel> | |
| 479 | − <AuthTemplateEditor | |
| 480 | − value={authValue} | |
| 481 | − onChange={setAuthValue} | |
| 482 | − allowedKinds={allowedAuthKinds} | |
| 483 | − oauthMetadata="discovered" | |
| 484 | − /> | |
| 485 | − </section> | |
| 461 | + <AuthMethodListEditor | |
| 462 | + list={authMethodList} | |
| 463 | + title="How does this server authenticate?" | |
| 464 | + oauthMetadata="discovered" | |
| 465 | + emptyHint="No methods declared. Add a method, or add the server without auth and connect from the integration page later." | |
| 466 | + footerHint="Every method here is registered with the server. Connect an account from the integration page after adding." | |
| 467 | + /> | |
| 486 | 468 | )} |
| 487 | 469 | |
| 488 | 470 | {/* Error (add server). Probe errors show inline on the field. */} |
| 489 | 471 | {otherError && ( |
| 490 | 472 | <div className="space-y-2"> |
| 491 | − <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | |
| 492 | − <p className="text-[12px] text-destructive">{otherError}</p> | |
| 493 | − </div> | |
| 473 | + <FormErrorAlert message={otherError} /> | |
| 494 | 474 | <Button |
| 495 | 475 | type="button" |
| 496 | 476 | variant="outline" |
| @@ -503,21 +483,7 @@ export default function AddMcpSource(props: { · 6 unmodified lines | ||
| 503 | 483 | </div> |
| 504 | 484 | )} |
| 505 | 485 | |
| 506 | − {remoteSlugExists && !isAdding && ( | |
| 507 | − <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | |
| 508 | − <p className="text-[12px] text-destructive"> | |
| 509 | − An integration named "{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 | − )} | |
| 486 | + {remoteSlugExists && !isAdding && <SlugCollisionAlert slug={remoteSlug} />} | |
| 521 | 487 | |
| 522 | 488 | <FloatActions> |
| 523 | 489 | <Button |
| @@ -589,27 +555,9 @@ export default function AddMcpSource(props: { · 65 unmodified lines | ||
| 589 | 555 | <IntegrationIdentityFields identity={stdioIdentity} namePlaceholder="My MCP Server" /> |
| 590 | 556 | |
| 591 | 557 | {/* Stdio error */} |
| 592 | − {stdioError && ( | |
| 593 | − <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | |
| 594 | − <p className="text-[12px] text-destructive">{stdioError}</p> | |
| 595 | − </div> | |
| 596 | − )} | |
| 558 | + {stdioError && <FormErrorAlert message={stdioError} />} | |
| 597 | 559 | |
| 598 | − {stdioSlugExists && !stdioAdding && ( | |
| 599 | − <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | |
| 600 | − <p className="text-[12px] text-destructive"> | |
| 601 | − An integration named "{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 | − )} | |
| 560 | + {stdioSlugExists && !stdioAdding && <SlugCollisionAlert slug={stdioSlug} />} | |
| 613 | 561 | |
| 614 | 562 | <FloatActions> |
| 615 | 563 | <Button |
| @@ -1,24 +1,17 @@ | ||
| 1 | −import { useCallback, useEffect, useMemo, useState } from "react"; | |
| 1 | +import { useCallback, useMemo, useState } from "react"; | |
| 2 | 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 3 | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | 4 | import * as Exit from "effect/Exit"; |
| 5 | 5 | |
| 6 | −import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 7 | −import { createConnection } from "@executor-js/react/api/atoms"; | |
| 8 | −import { connectionWriteKeys, integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | |
| 9 | −import { connectionIdentifier } from "@executor-js/react/lib/connection-name"; | |
| 6 | +import { IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 7 | +import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | |
| 10 | 8 | import { |
| 11 | − AuthTemplateEditor, | |
| 12 | − type AuthTemplateEditorValue, | |
| 13 | −} from "@executor-js/react/components/auth-template-editor"; | |
| 14 | −import { | |
| 15 | − CredentialControlField, | |
| 16 | − ConnectionOwnerUsageRow, | |
| 17 | − useConnectionOwner, | |
| 18 | −} from "@executor-js/react/plugins/connection-owner"; | |
| 19 | −import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in"; | |
| 9 | + AuthMethodListEditor, | |
| 10 | + useAuthMethodList, | |
| 11 | + type AuthMethodRow, | |
| 12 | + type AuthMethodSeed, | |
| 13 | +} from "@executor-js/react/components/auth-method-list-editor"; | |
| 20 | 14 | import { Button } from "@executor-js/react/components/button"; |
| 21 | −import { AddAccountModal } from "@executor-js/react/components/add-account-modal"; | |
| 22 | 15 | import { |
| 23 | 16 | CardStack, |
| 24 | 17 | CardStackContent, |
| @@ -27,16 +20,15 @@ import { · 2 unmodified lines | ||
| 27 | 20 | CardStackEntryDescription, |
| 28 | 21 | CardStackEntryTitle, |
| 29 | 22 | } from "@executor-js/react/components/card-stack"; |
| 30 | −import { Input } from "@executor-js/react/components/input"; | |
| 31 | 23 | import { Badge } from "@executor-js/react/components/badge"; |
| 32 | −import type { AuthMethod } from "@executor-js/react/lib/auth-placements"; | |
| 33 | − | |
| 34 | −import { configureMcpServer, mcpServerAtom } from "./atoms"; | |
| 35 | −import type { McpAuthTemplate, McpIntegrationConfig } from "../sdk/types"; | |
| 36 | −import { mcpAuthTemplateFromEditorValue } from "./auth-method-config"; | |
| 24 | +import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; | |
| 37 | 25 | |
| 38 | −const HEADER_TEMPLATE = AuthTemplateSlug.make("header"); | |
| 39 | −const OAUTH_TEMPLATE = AuthTemplateSlug.make("oauth2"); | |
| 26 | +import { configureMcpAuth, mcpServerAtom } from "./atoms"; | |
| 27 | +import type { McpAuthMethod, McpAuthMethodInput, McpIntegrationConfig } from "../sdk/types"; | |
| 28 | +import { | |
| 29 | + editorValueFromMcpAuthMethod, | |
| 30 | + mcpAuthMethodInputFromEditorValue, | |
| 31 | +} from "./auth-method-config"; | |
| 40 | 32 | |
| 41 | 33 | type McpServer = { |
| 42 | 34 | readonly slug: IntegrationSlug; |
| @@ -47,34 +39,19 @@ type McpServer = { · 4 unmodified lines | ||
| 47 | 39 | readonly config: McpIntegrationConfig; |
| 48 | 40 | }; |
| 49 | 41 | |
| 50 | −const authTemplateToEditorValue = (auth: McpRemoteConfig["auth"]): AuthTemplateEditorValue => { | |
| 51 | − if (auth.kind === "none") return { kind: "none" }; | |
| 52 | − if (auth.kind === "oauth2") | |
| 53 | − return { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }; | |
| 54 | − return { | |
| 55 | − kind: "apikey", | |
| 56 | − placements: [ | |
| 57 | − { | |
| 58 | − carrier: "header", | |
| 59 | − name: auth.headerName, | |
| 60 | − prefix: auth.prefix ?? "", | |
| 61 | − }, | |
| 62 | − ], | |
| 63 | − }; | |
| 64 | −}; | |
| 42 | +type McpRemoteConfig = Extract<McpIntegrationConfig, { transport: "remote" }>; | |
| 65 | 43 | |
| 66 | −const authTemplatesEqual = (left: McpAuthTemplate, right: McpAuthTemplate): boolean => { | |
| 67 | − if (left.kind !== right.kind) return false; | |
| 68 | − if (left.kind === "header" && right.kind === "header") { | |
| 69 | − return left.headerName === right.headerName && (left.prefix ?? "") === (right.prefix ?? ""); | |
| 70 | − } | |
| 71 | − return true; | |
| 44 | +const methodSeedLabel = (method: McpAuthMethod): string => { | |
| 45 | + if (method.kind === "oauth2") return "OAuth"; | |
| 46 | + if (method.kind === "header") return `API key (${method.headerName})`; | |
| 47 | + return "No authentication"; | |
| 72 | 48 | }; |
| 73 | 49 | |
| 74 | 50 | // --------------------------------------------------------------------------- |
| 75 | −// Remote edit — v2: the integration's endpoint + auth template are part of its | |
| 76 | −// identity (opaque-to-core config). The editable surface is the connection: an | |
| 77 | −// API-key header value, or an OAuth sign-in, both owner-scoped. | |
| 51 | +// Remote edit — v2: the integration's endpoint is part of its identity | |
| 52 | +// (opaque-to-core config); the editable surface is the declared auth-method | |
| 53 | +// LIST, through the same shared editor as the add flow. Accounts (credentials) | |
| 54 | +// are managed from the integration page's accounts hub. | |
| 78 | 55 | // --------------------------------------------------------------------------- |
| 79 | 56 | |
| 80 | 57 | function RemoteEdit(props: { |
| @@ -82,115 +59,81 @@ function RemoteEdit(props: { · 1 unmodified line | ||
| 82 | 59 | onSave: () => void; |
| 83 | 60 | }) { |
| 84 | 61 | const { server } = props; |
| 85 | − const auth = server.config.auth; | |
| 86 | − const { connectionOwner, setConnectionOwner, connectionOwnerOptions } = useConnectionOwner(); | |
| 87 | − const doCreate = useAtomSet(createConnection, { mode: "promiseExit" }); | |
| 88 | − const doConfigure = useAtomSet(configureMcpServer, { mode: "promiseExit" }); | |
| 62 | + const doConfigureAuth = useAtomSet(configureMcpAuth, { mode: "promiseExit" }); | |
| 89 | 63 | |
| 90 | − const [authValue, setAuthValue] = useState<AuthTemplateEditorValue>(() => | |
| 91 | − authTemplateToEditorValue(auth), | |
| 64 | + const seeds = useMemo<readonly AuthMethodSeed[]>( | |
| 65 | + () => | |
| 66 | + server.config.authenticationTemplate.map( | |
| 67 | + (method: McpAuthMethod): AuthMethodSeed => ({ | |
| 68 | + value: editorValueFromMcpAuthMethod(method), | |
| 69 | + slug: method.slug, | |
| 70 | + label: methodSeedLabel(method), | |
| 71 | + }), | |
| 72 | + ), | |
| 73 | + [server.config.authenticationTemplate], | |
| 92 | 74 | ); |
| 93 | − const [apiKey, setApiKey] = useState(""); | |
| 75 | + const list = useAuthMethodList(seeds); | |
| 76 | + | |
| 94 | 77 | const [saving, setSaving] = useState(false); |
| 95 | − const [authSaving, setAuthSaving] = useState(false); | |
| 96 | 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(() => { | |
| 104 | − setAuthValue(authTemplateToEditorValue(auth)); | |
| 105 | − }, [auth]); | |
| 80 | + // The edited methods, slugs preserved for seeded rows so existing | |
| 81 | + // connections (bound by template slug) stay attached. New rows omit the | |
| 82 | + // slug — the backend assigns kind-based ones. | |
| 83 | + const editedMethods = useMemo<readonly McpAuthMethodInput[]>( | |
| 84 | + () => | |
| 85 | + list.rows.map((row: AuthMethodRow): McpAuthMethodInput => { | |
| 86 | + const input = mcpAuthMethodInputFromEditorValue(row.value); | |
| 87 | + return row.seedSlug !== undefined ? { ...input, slug: row.seedSlug } : input; | |
| 88 | + }), | |
| 89 | + [list.rows], | |
| 90 | + ); | |
| 106 | 91 | |
| 107 | − const handleSaveAuth = useCallback(async () => { | |
| 108 | − setAuthSaving(true); | |
| 109 | − setError(null); | |
| 110 | − const nextConfig: McpRemoteConfig = { | |
| 111 | − ...server.config, | |
| 112 | − auth: mcpAuthTemplateFromEditorValue(authValue), | |
| 113 | − }; | |
| 114 | − const exit = await doConfigure({ | |
| 115 | − params: { slug: server.slug }, | |
| 116 | − payload: { config: nextConfig }, | |
| 117 | − reactivityKeys: integrationWriteKeys, | |
| 92 | + const methodsChanged = useMemo(() => { | |
| 93 | + const stored = server.config.authenticationTemplate; | |
| 94 | + if (editedMethods.length !== stored.length) return true; | |
| 95 | + return editedMethods.some((method: McpAuthMethodInput, index: number) => { | |
| 96 | + const current = stored[index]; | |
| 97 | + if (!current) return true; | |
| 98 | + if ((method.slug ?? "") !== current.slug) return true; | |
| 99 | + if (method.kind !== current.kind) return true; | |
| 100 | + if (method.kind === "header" && current.kind === "header") { | |
| 101 | + return ( | |
| 102 | + method.headerName !== current.headerName || | |
| 103 | + (method.prefix ?? "") !== (current.prefix ?? "") | |
| 104 | + ); | |
| 105 | + } | |
| 106 | + return false; | |
| 118 | 107 | }); |
| 119 | − if (Exit.isFailure(exit)) { | |
| 120 | − setError("Failed to update authentication method"); | |
| 121 | − setAuthSaving(false); | |
| 122 | − return; | |
| 123 | − } | |
| 124 | − setAuthSaving(false); | |
| 125 | − }, [authValue, doConfigure, server.config, server.slug]); | |
| 108 | + }, [editedMethods, server.config.authenticationTemplate]); | |
| 126 | 109 | |
| 127 | − const handleSaveKey = useCallback(async () => { | |
| 128 | − if (apiKey.trim() === "") return; | |
| 110 | + const handleSave = useCallback(async () => { | |
| 129 | 111 | setSaving(true); |
| 130 | 112 | setError(null); |
| 131 | − const exit = await doCreate({ | |
| 113 | + const exit = await doConfigureAuth({ | |
| 114 | + params: { slug: server.slug }, | |
| 132 | 115 | payload: { |
| 133 | − owner: connectionOwner, | |
| 134 | − name: connectionIdentifier(`${server.slug} key`), | |
| 135 | − integration: server.slug, | |
| 136 | − template: HEADER_TEMPLATE, | |
| 137 | − identityLabel: server.description || String(server.slug), | |
| 138 | − value: apiKey.trim(), | |
| 116 | + authenticationTemplate: | |
| 117 | + editedMethods.length > 0 ? editedMethods : [{ kind: "none" as const }], | |
| 118 | + mode: "replace", | |
| 139 | 119 | }, |
| 140 | − reactivityKeys: connectionWriteKeys, | |
| 120 | + reactivityKeys: integrationWriteKeys, | |
| 141 | 121 | }); |
| 142 | 122 | if (Exit.isFailure(exit)) { |
| 143 | − setError("Failed to save credential"); | |
| 123 | + setError("Failed to update authentication methods"); | |
| 144 | 124 | setSaving(false); |
| 145 | 125 | return; |
| 146 | 126 | } |
| 147 | 127 | setSaving(false); |
| 148 | − setConnected(true); | |
| 149 | − props.onSave(); | |
| 150 | − }, [apiKey, connectionOwner, doCreate, server, props]); | |
| 151 | − | |
| 152 | − const handleOAuth = useCallback(() => { | |
| 153 | − setError(null); | |
| 154 | − setOauthModalOpen(true); | |
| 155 | − }, []); | |
| 156 | − | |
| 157 | − const oauthMethods = useMemo<readonly AuthMethod[]>( | |
| 158 | − () => | |
| 159 | − auth.kind === "oauth2" | |
| 160 | − ? [ | |
| 161 | − { | |
| 162 | − id: "oauth2", | |
| 163 | − label: "OAuth", | |
| 164 | − kind: "oauth", | |
| 165 | − source: "spec", | |
| 166 | − template: OAUTH_TEMPLATE, | |
| 167 | − placements: [], | |
| 168 | − oauth: { discoveryUrl: server.config.endpoint, supportsDynamicRegistration: true }, | |
| 169 | − }, | |
| 170 | − ] | |
| 171 | − : [], | |
| 172 | − [auth.kind, server.config.endpoint], | |
| 173 | − ); | |
| 174 | − const oauthInitialState = useMemo( | |
| 175 | − () => | |
| 176 | − oauthModalOpen && auth.kind === "oauth2" | |
| 177 | − ? { | |
| 178 | − key: `${String(server.slug)}:${connectionOwner}:oauth`, | |
| 179 | − owner: connectionOwner, | |
| 180 | − template: String(OAUTH_TEMPLATE), | |
| 181 | − label: `${server.description || String(server.slug)} OAuth`, | |
| 182 | − } | |
| 183 | − : null, | |
| 184 | − [auth.kind, connectionOwner, oauthModalOpen, server], | |
| 185 | − ); | |
| 128 | + }, [doConfigureAuth, editedMethods, server.slug]); | |
| 186 | 129 | |
| 187 | 130 | return ( |
| 188 | 131 | <div className="space-y-6"> |
| 189 | 132 | <div> |
| 190 | 133 | <h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1> |
| 191 | 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 — | |
| 193 | − remove and re-add to change it. | |
| 135 | + Manage how this MCP server authenticates. The endpoint is part of the server's identity — | |
| 136 | + remove and re-add to change it. Accounts are added from the integration page. | |
| 194 | 137 | </p> |
| 195 | 138 | </div> |
| 196 | 139 | |
| @@ -210,110 +153,23 @@ function RemoteEdit(props: { · 13 unmodified lines | ||
| 210 | 153 | </CardStackContent> |
| 211 | 154 | </CardStack> |
| 212 | 155 | |
| 213 | − <div className="space-y-3 rounded-lg border border-border bg-card p-4"> | |
| 214 | − <div> | |
| 215 | − <p className="text-sm font-medium text-card-foreground">Authentication method</p> | |
| 216 | − <p className="mt-0.5 text-xs text-muted-foreground"> | |
| 217 | − Choose how a connection credential is sent to this MCP server. | |
| 218 | − </p> | |
| 219 | − </div> | |
| 220 | − <AuthTemplateEditor | |
| 221 | − value={authValue} | |
| 222 | − onChange={setAuthValue} | |
| 223 | − allowedKinds={["none", "apikey", "oauth"]} | |
| 224 | − oauthMetadata="discovered" | |
| 225 | − /> | |
| 226 | − {authChanged ? ( | |
| 227 | − <div className="flex justify-end"> | |
| 228 | − <Button | |
| 229 | − type="button" | |
| 230 | − size="sm" | |
| 231 | − onClick={() => void handleSaveAuth()} | |
| 232 | − disabled={authSaving} | |
| 233 | − > | |
| 234 | − {authSaving ? "Saving…" : "Save authentication method"} | |
| 235 | − </Button> | |
| 236 | − </div> | |
| 237 | − ) : null} | |
| 238 | − </div> | |
| 156 | + <AuthMethodListEditor | |
| 157 | + list={list} | |
| 158 | + title="How does this server authenticate?" | |
| 159 | + oauthMetadata="discovered" | |
| 160 | + emptyHint="No methods declared. Add one, or save to mark this server as open (no authentication)." | |
| 161 | + footerHint="Connections pick one of these methods. Removing a method detaches connections created against it." | |
| 162 | + /> | |
| 239 | 163 | |
| 240 | − {authChanged ? ( | |
| 241 | − <div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground"> | |
| 242 | − Save the authentication method before adding a connection. | |
| 164 | + {methodsChanged ? ( | |
| 165 | + <div className="flex justify-end"> | |
| 166 | + <Button type="button" size="sm" onClick={() => void handleSave()} disabled={saving}> | |
| 167 | + {saving ? "Saving…" : "Save authentication methods"} | |
| 168 | + </Button> | |
| 243 | 169 | </div> |
| 244 | 170 | ) : null} |
| 245 | 171 | |
| 246 | − {!authChanged && auth.kind === "header" && ( | |
| 247 | − <ConnectionOwnerUsageRow | |
| 248 | − value={connectionOwner} | |
| 249 | − options={connectionOwnerOptions} | |
| 250 | − onChange={setConnectionOwner} | |
| 251 | − label="Connection saved to" | |
| 252 | − help="Choose who can use this credential." | |
| 253 | − > | |
| 254 | − <CredentialControlField label={`${auth.headerName} value`} help="Saved as a connection."> | |
| 255 | − <Input | |
| 256 | − type="password" | |
| 257 | − value={apiKey} | |
| 258 | − onChange={(e) => setApiKey((e.target as HTMLInputElement).value)} | |
| 259 | − placeholder="sk-…" | |
| 260 | − className="font-mono text-sm" | |
| 261 | − autoComplete="new-password" | |
| 262 | − /> | |
| 263 | − <div className="mt-2 flex justify-end"> | |
| 264 | − <Button | |
| 265 | − type="button" | |
| 266 | − size="sm" | |
| 267 | − onClick={() => void handleSaveKey()} | |
| 268 | − disabled={apiKey.trim() === "" || saving} | |
| 269 | − > | |
| 270 | − {saving ? "Saving…" : connected ? "Saved" : "Save connection"} | |
| 271 | − </Button> | |
| 272 | − </div> | |
| 273 | − </CredentialControlField> | |
| 274 | − </ConnectionOwnerUsageRow> | |
| 275 | − )} | |
| 276 | − | |
| 277 | − {!authChanged && auth.kind === "oauth2" && ( | |
| 278 | − <ConnectionOwnerUsageRow | |
| 279 | − value={connectionOwner} | |
| 280 | − options={connectionOwnerOptions} | |
| 281 | − onChange={setConnectionOwner} | |
| 282 | − label="Connection saved to" | |
| 283 | − help="Choose who can use the OAuth connection." | |
| 284 | − > | |
| 285 | − <CredentialControlField label="Connect via OAuth" help="Start the provider OAuth flow."> | |
| 286 | − <OAuthSignInButton | |
| 287 | − busy={false} | |
| 288 | − error={error} | |
| 289 | − isConnected={connected} | |
| 290 | − onSignIn={handleOAuth} | |
| 291 | − signingInLabel="Signing in…" | |
| 292 | − reconnectingLabel="Reconnecting…" | |
| 293 | − /> | |
| 294 | − <AddAccountModal | |
| 295 | − integration={server.slug} | |
| 296 | − integrationName={server.description || String(server.slug)} | |
| 297 | − methods={oauthMethods} | |
| 298 | − open={oauthModalOpen} | |
| 299 | − onOpenChange={setOauthModalOpen} | |
| 300 | − initialState={oauthInitialState} | |
| 301 | − /> | |
| 302 | − </CredentialControlField> | |
| 303 | − </ConnectionOwnerUsageRow> | |
| 304 | − )} | |
| 305 | − | |
| 306 | − {!authChanged && auth.kind === "none" && ( | |
| 307 | − <div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground"> | |
| 308 | − This server does not require a credential. | |
| 309 | − </div> | |
| 310 | − )} | |
| 311 | − | |
| 312 | − {error && ( | |
| 313 | − <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | |
| 314 | − <p className="text-sm text-destructive">{error}</p> | |
| 315 | − </div> | |
| 316 | − )} | |
| 172 | + {error && <FormErrorAlert message={error} />} | |
| 317 | 173 | |
| 318 | 174 | <div className="flex items-center justify-end border-t border-border pt-4"> |
| 319 | 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 | 182 | // Stdio read-only view |
| 329 | 183 | // --------------------------------------------------------------------------- |
| @@ -3,72 +3,33 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; · 2 unmodified lines | ||
| 3 | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | 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 | 7 | import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; |
| 8 | 8 | import { AccountsSection } from "@executor-js/react/components/accounts-section"; |
| 9 | −import type { CreateCustomMethod } from "@executor-js/react/components/add-custom-method-modal"; | |
| 10 | 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 | 15 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 12 | 16 | |
| 13 | −import { configureMcpServer, mcpServerAtom } from "./atoms"; | |
| 14 | −import type { McpIntegrationConfig } from "../sdk/types"; | |
| 17 | +import { configureMcpAuth, mcpServerAtom } from "./atoms"; | |
| 18 | +import { authMethodsFromConfig, mcpAuthMethodInputsFromPlacements } from "./auth-method-config"; | |
| 19 | +import type { McpAuthMethod } from "../sdk/types"; | |
| 15 | 20 | |
| 16 | −const NONE_TEMPLATE = AuthTemplateSlug.make("none"); | |
| 17 | −const HEADER_TEMPLATE = AuthTemplateSlug.make("header"); | |
| 18 | −const OAUTH_TEMPLATE = AuthTemplateSlug.make("oauth2"); | |
| 19 | − | |
| 20 | −const NO_AUTH_METHOD: AuthMethod = { | |
| 21 | − id: "none", | |
| 22 | − label: "No authentication", | |
| 23 | − kind: "none", | |
| 24 | − source: "spec", | |
| 25 | − template: NONE_TEMPLATE, | |
| 26 | − placements: [], | |
| 27 | −}; | |
| 28 | − | |
| 29 | −const authMethodFromConfig = (config: McpIntegrationConfig): AuthMethod | null => { | |
| 30 | − if (config.transport === "stdio") return null; | |
| 31 | − const auth = config.auth; | |
| 32 | − if (auth.kind === "none") return NO_AUTH_METHOD; | |
| 33 | − if (auth.kind === "oauth2") { | |
| 34 | − return { | |
| 35 | − id: "oauth2", | |
| 36 | − label: "OAuth2", | |
| 37 | − kind: "oauth", | |
| 38 | − source: "spec", | |
| 39 | − template: OAUTH_TEMPLATE, | |
| 40 | − placements: [], | |
| 41 | − oauth: { discoveryUrl: config.endpoint, supportsDynamicRegistration: true }, | |
| 42 | − }; | |
| 43 | − } | |
| 44 | − return { | |
| 45 | − id: "header", | |
| 46 | − label: `API key (${auth.headerName})`, | |
| 47 | − kind: "apikey", | |
| 48 | − source: "custom", | |
| 49 | − template: HEADER_TEMPLATE, | |
| 50 | − placements: [ | |
| 51 | − { | |
| 52 | − carrier: "header", | |
| 53 | − name: auth.headerName, | |
| 54 | − prefix: auth.prefix ?? "", | |
| 55 | − }, | |
| 56 | − ], | |
| 57 | − }; | |
| 58 | −}; | |
| 59 | − | |
| 60 | −const headerAuthFromPlacements = ( | |
| 61 | − placements: readonly Placement[], | |
| 62 | −): { readonly headerName: string; readonly prefix?: string } | null => { | |
| 63 | − const header = placements.find( | |
| 64 | − (placement: Placement) => placement.carrier === "header" && placement.name.trim().length > 0, | |
| 65 | − ); | |
| 66 | − if (!header) return null; | |
| 67 | − return { | |
| 68 | − headerName: header.name.trim(), | |
| 69 | − ...(header.prefix ? { prefix: header.prefix } : {}), | |
| 70 | − }; | |
| 71 | −}; | |
| 21 | +// --------------------------------------------------------------------------- | |
| 22 | +// MCP Accounts hub — fills the generic detail page's `accounts` slot. | |
| 23 | +// | |
| 24 | +// Reads the integration's declared `authenticationTemplate` (via `getServer`), | |
| 25 | +// converts it to generic `AuthMethod[]`, and composes the generic | |
| 26 | +// `AccountsSection` — whose Add-account offers those methods plus a "+ Custom | |
| 27 | +// method" row. The custom-method create/remove is the shared skeleton | |
| 28 | +// (`useCustomMethodActions`) parameterized by the MCP codec and the | |
| 29 | +// merge-append `configureAuth` endpoint, so adding an API key method never | |
| 30 | +// displaces a declared OAuth method. Stdio servers have no remote credential | |
| 31 | +// to configure — no methods, no custom-method affordance. | |
| 32 | +// --------------------------------------------------------------------------- | |
| 72 | 33 | |
| 73 | 34 | export default function McpAccountsPanel(props: { |
| 74 | 35 | readonly sourceId: string; |
| @@ -78,61 +39,58 @@ export default function McpAccountsPanel(props: { · 3 unmodified lines | ||
| 78 | 39 | const { sourceId, integrationName, accountHandoff } = props; |
| 79 | 40 | const slug = IntegrationSlug.make(sourceId); |
| 80 | 41 | const serverResult = useAtomValue(mcpServerAtom(slug)); |
| 81 | − const doConfigure = useAtomSet(configureMcpServer, { mode: "promiseExit" }); | |
| 42 | + const doConfigureAuth = useAtomSet(configureMcpAuth, { mode: "promiseExit" }); | |
| 82 | 43 | |
| 83 | 44 | const server = AsyncResult.isSuccess(serverResult) ? serverResult.value : null; |
| 84 | 45 | const config = server?.config ?? null; |
| 85 | − const method = config ? authMethodFromConfig(config) : null; | |
| 86 | − const methods = method ? [method] : []; | |
| 46 | + const remote = config !== null && config.transport === "remote" ? config : null; | |
| 87 | 47 | |
| 88 | − const createCustomMethod = useCallback<CreateCustomMethod>( | |
| 89 | − async (input: { readonly label: string; readonly placements: readonly Placement[] }) => { | |
| 90 | − if (config === null || config.transport !== "remote") return null; | |
| 91 | − const header = headerAuthFromPlacements(input.placements); | |
| 92 | − if (header === null) return null; | |
| 93 | − const nextConfig: McpIntegrationConfig = { | |
| 94 | − ...config, | |
| 95 | − auth: { kind: "header", ...header }, | |
| 96 | − }; | |
| 97 | − const exit = await doConfigure({ | |
| 98 | − params: { slug }, | |
| 99 | − payload: { config: nextConfig }, | |
| 100 | − reactivityKeys: integrationWriteKeys, | |
| 101 | − }); | |
| 102 | − if (Exit.isFailure(exit)) return null; | |
| 103 | − return authMethodFromConfig(exit.value.config); | |
| 104 | − }, | |
| 105 | − [config, doConfigure, slug], | |
| 48 | + const existingTemplate = useMemo<readonly McpAuthMethod[]>( | |
| 49 | + () => remote?.authenticationTemplate ?? [], | |
| 50 | + [remote], | |
| 51 | + ); | |
| 52 | + | |
| 53 | + const methods = useMemo<readonly AuthMethod[]>( | |
| 54 | + () => (remote ? authMethodsFromConfig(existingTemplate, remote.endpoint) : []), | |
| 55 | + [existingTemplate, remote], | |
| 106 | 56 | ); |
| 107 | 57 | |
| 108 | − const removeCustomMethod = useCallback( | |
| 109 | − async (methodToRemove: AuthMethod): Promise<boolean> => { | |
| 110 | − if ( | |
| 111 | − config === null || | |
| 112 | − config.transport !== "remote" || | |
| 113 | − methodToRemove.template !== HEADER_TEMPLATE | |
| 114 | − ) { | |
| 115 | − return false; | |
| 116 | − } | |
| 117 | − const nextConfig: McpIntegrationConfig = { | |
| 118 | − ...config, | |
| 119 | − auth: { kind: "none" }, | |
| 120 | − }; | |
| 121 | − const exit = await doConfigure({ | |
| 58 | + const configure = useCallback<ConfigureAuthMethods<McpAuthMethod>>( | |
| 59 | + async (input) => { | |
| 60 | + const exit = await doConfigureAuth({ | |
| 122 | 61 | params: { slug }, |
| 123 | − payload: { config: nextConfig }, | |
| 62 | + payload: { | |
| 63 | + authenticationTemplate: input.authenticationTemplate, | |
| 64 | + ...(input.mode ? { mode: input.mode } : {}), | |
| 65 | + }, | |
| 124 | 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( | |
| 132 | − () => config !== null && config.transport === "remote", | |
| 133 | − [config], | |
| 73 | + const codec = useMemo<AuthMethodsCodec<McpAuthMethod>>( | |
| 74 | + () => ({ | |
| 75 | + toAuthMethods: (templates: readonly McpAuthMethod[]) => | |
| 76 | + authMethodsFromConfig(templates, remote?.endpoint ?? ""), | |
| 77 | + // MCP custom methods are header credentials; the inputs omit slugs and | |
| 78 | + // the backend merge backfills `custom_<id>`. | |
| 79 | + templatesFromPlacements: (placements: readonly Placement[]) => | |
| 80 | + mcpAuthMethodInputsFromPlacements(placements) as readonly McpAuthMethod[], | |
| 81 | + slugOf: (template: McpAuthMethod) => template.slug, | |
| 82 | + }), | |
| 83 | + [remote?.endpoint], | |
| 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 | 94 | return ( |
| 137 | 95 | <div className="mx-auto max-w-3xl space-y-8 px-6 py-8"> |
| 138 | 96 | <AccountsSection |
| @@ -14,19 +14,18 @@ import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in"; · 13 unmodified lines | ||
| 14 | 14 | import type { AuthMethod } from "@executor-js/react/lib/auth-placements"; |
| 15 | 15 | |
| 16 | 16 | import { mcpServerAtom } from "./atoms"; |
| 17 | − | |
| 18 | −const OAUTH_TEMPLATE = AuthTemplateSlug.make("oauth2"); | |
| 17 | +import type { McpAuthMethod } from "../sdk/types"; | |
| 19 | 18 | |
| 20 | 19 | // --------------------------------------------------------------------------- |
| 21 | 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 | |
| 24 | −// OAuth flow to mint a connection. "Connected" is derived from whether ANY | |
| 25 | −// owner already has a connection for this integration (the global owner toggle | |
| 26 | −// is retired, so the check merges both owners). The NEW connection's owner is a | |
| 27 | −// real create-target — chosen EXPLICITLY via the `owner` prop (default Workspace | |
| 28 | −// `org` on an org-scoped host, Local `org` on a non-org host like local), | |
| 29 | −// never read from an ambient owner. | |
| 22 | +// Reads the integration's declared auth methods; when one is `oauth2` it runs | |
| 23 | +// the OAuth flow to mint a connection through that method. "Connected" is | |
| 24 | +// derived from whether ANY owner already has a connection for this integration | |
| 25 | +// (the global owner toggle is retired, so the check merges both owners). The | |
| 26 | +// NEW connection's owner is a real create-target — chosen EXPLICITLY via the | |
| 27 | +// `owner` prop (default Workspace `org` on an org-scoped host, Local `org` on | |
| 28 | +// a non-org host like local), never read from an ambient owner. | |
| 30 | 29 | // --------------------------------------------------------------------------- |
| 31 | 30 | |
| 32 | 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 | 38 | const server = AsyncResult.isSuccess(serverResult) ? serverResult.value : null; |
| 40 | 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 | 43 | const connections: readonly Connection[] = AsyncResult.isSuccess(connectionsResult) |
| 43 | 44 | ? connectionsResult.value |
| 44 | 45 | : []; |
| @@ -48,35 +49,35 @@ export default function McpSignInButton(props: { sourceId: string; owner?: Owner · 3 unmodified lines | ||
| 48 | 49 | |
| 49 | 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 | 57 | label: "OAuth", |
| 57 | 58 | kind: "oauth", |
| 58 | 59 | source: "spec", |
| 59 | − template: OAUTH_TEMPLATE, | |
| 60 | + template: AuthTemplateSlug.make(oauthMethod.slug), | |
| 60 | 61 | placements: [], |
| 61 | 62 | oauth: { discoveryUrl: remote.endpoint, supportsDynamicRegistration: true }, |
| 62 | 63 | }, |
| 63 | 64 | ], |
| 64 | − [remote], | |
| 65 | + [remote, oauthMethod], | |
| 65 | 66 | ); |
| 66 | 67 | const initialState = useMemo( |
| 67 | 68 | () => |
| 68 | − modalOpen && server | |
| 69 | + modalOpen && server && oauthMethod | |
| 69 | 70 | ? { |
| 70 | 71 | key: `${String(slug)}:${targetOwner}:oauth`, |
| 71 | 72 | owner: targetOwner, |
| 72 | − template: String(OAUTH_TEMPLATE), | |
| 73 | + template: oauthMethod.slug, | |
| 73 | 74 | label: `${server.description || String(slug)} OAuth`, |
| 74 | 75 | } |
| 75 | 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 | 82 | return ( |
| 82 | 83 | <> |
| @@ -26,3 +26,5 @@ export const probeMcpEndpoint = McpClient.mutation("mcp", "probeEndpoint"); · 25 unmodified lines | ||
| 26 | 26 | export const addMcpServer = McpClient.mutation("mcp", "addServer"); |
| 27 | 27 | export const removeMcpServer = McpClient.mutation("mcp", "removeServer"); |
| 28 | 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 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 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 | 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 | 16 | it("maps 'oauth' → { kind: 'oauth2' } (endpoints are resolved at connect time)", () => { |
| @@ -15,15 +20,15 @@ describe("mcpAuthTemplateFromEditorValue", () => { · 3 unmodified lines | ||
| 15 | 20 | tokenUrl: "https://a.example.com/token", |
| 16 | 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 | 27 | const value: AuthTemplateEditorValue = { |
| 23 | 28 | kind: "apikey", |
| 24 | 29 | placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], |
| 25 | 30 | }; |
| 26 | − expect(mcpAuthTemplateFromEditorValue(value)).toEqual({ | |
| 31 | + expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ | |
| 27 | 32 | kind: "header", |
| 28 | 33 | headerName: "Authorization", |
| 29 | 34 | prefix: "Bearer ", |
| @@ -35,7 +40,7 @@ describe("mcpAuthTemplateFromEditorValue", () => { · 5 unmodified lines | ||
| 35 | 40 | kind: "apikey", |
| 36 | 41 | placements: [{ carrier: "header", name: "X-Token", prefix: "" }], |
| 37 | 42 | }; |
| 38 | − expect(mcpAuthTemplateFromEditorValue(value)).toEqual({ | |
| 43 | + expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ | |
| 39 | 44 | kind: "header", |
| 40 | 45 | headerName: "X-Token", |
| 41 | 46 | }); |
| @@ -46,7 +51,7 @@ describe("mcpAuthTemplateFromEditorValue", () => { · 4 unmodified lines | ||
| 46 | 51 | kind: "apikey", |
| 47 | 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 | 57 | it("uses the first NAMED header placement (skips unnamed)", () => { |
| @@ -57,9 +62,78 @@ describe("mcpAuthTemplateFromEditorValue", () => { · 4 unmodified lines | ||
| 57 | 62 | { carrier: "header", name: "X-Token", prefix: "" }, |
| 58 | 63 | ], |
| 59 | 64 | }; |
| 60 | − expect(mcpAuthTemplateFromEditorValue(value)).toEqual({ | |
| 65 | + expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ | |
| 61 | 66 | kind: "header", |
| 62 | 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 | 2 | // MCP ↔ generic auth-template converter. |
| 3 | 3 | // |
| 4 | −// The shared add-time `AuthTemplateEditor` speaks the plugin-agnostic | |
| 5 | −// `AuthTemplateEditorValue`. MCP's stored auth is a single `McpAuthTemplate` | |
| 6 | −// (`none` / `header` / `oauth2`) — one method, not an array. This converts the | |
| 7 | −// editor value to that shape and lives with the MCP plugin because it touches | |
| 8 | −// the transport-specific `McpAuthTemplate` type. | |
| 4 | +// The shared add-time `AuthTemplateEditor` / `AuthMethodListEditor` speak the | |
| 5 | +// plugin-agnostic `AuthTemplateEditorValue`; the hub speaks `AuthMethod`. MCP | |
| 6 | +// stores a slugged `authenticationTemplate` array (`none` / `header` / | |
| 7 | +// `oauth2`, one method per entry — a header method carries a single header, | |
| 8 | +// not an array). These converters live with the MCP plugin because they touch | |
| 9 | +// the transport-specific `McpAuthMethod` types. | |
| 9 | 10 | // --------------------------------------------------------------------------- |
| 10 | 11 | |
| 12 | +import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | |
| 11 | 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 | |
| 16 | − * apiKey method maps to a `header` template using its FIRST header placement | |
| 17 | − * (MCP carries a single header, not an array); a header placement's prefix is | |
| 18 | − * preserved. OAuth maps to `oauth2`; `none` to `none`. An apiKey value with no | |
| 19 | − * usable header placement falls back to `none`. */ | |
| 20 | −export function mcpAuthTemplateFromEditorValue(value: AuthTemplateEditorValue): McpAuthTemplate { | |
| 18 | +/** Convert a generic editor value into one MCP auth-method input (no slug — | |
| 19 | + * the backend assigns kind-based slugs). An apiKey method maps to a `header` | |
| 20 | + * method using its FIRST named header placement, preserving the prefix; an | |
| 21 | + * apiKey value with no usable header placement falls back to `none`. */ | |
| 22 | +export function mcpAuthMethodInputFromEditorValue( | |
| 23 | + value: AuthTemplateEditorValue, | |
| 24 | +): McpAuthMethodInput { | |
| 21 | 25 | if (value.kind === "oauth") return { kind: "oauth2" }; |
| 22 | 26 | if (value.kind === "apikey") { |
| 23 | 27 | const header = value.placements.find( |
| @@ -32,3 +36,80 @@ export function mcpAuthTemplateFromEditorValue(value: AuthTemplateEditorValue): · 8 unmodified lines | ||
| 32 | 36 | } |
| 33 | 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 | 34 | export type RemoteConnectorInput = Omit< |
| 35 | 35 | McpRemoteIntegrationConfig, |
| 36 | − "auth" | "remoteTransport" | "headers" | "queryParams" | |
| 36 | + "authenticationTemplate" | "remoteTransport" | "headers" | "queryParams" | |
| 37 | 37 | > & { |
| 38 | 38 | readonly remoteTransport?: McpRemoteIntegrationConfig["remoteTransport"]; |
| 39 | 39 | readonly headers?: Record<string, string>; |
| @@ -5,8 +5,10 @@ import { describeMcpAuthMethods, describeMcpIntegrationDisplay } from "./plugin" · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | // --------------------------------------------------------------------------- |
| 7 | 7 | // `describeMcpAuthMethods` projects the stored MCP config into the catalog's |
| 8 | −// plugin-agnostic `AuthMethodDescriptor[]`. It is pure/sync and must tolerate a | |
| 9 | −// malformed or foreign config blob by returning `[]`. | |
| 8 | +// plugin-agnostic `AuthMethodDescriptor[]`, one per declared method. It is | |
| 9 | +// pure/sync and must tolerate a malformed or foreign config blob by returning | |
| 10 | +// `[]`. Legacy configs (singular `auth`, or no auth field at all) are lifted | |
| 11 | +// into a one-element `authenticationTemplate` whose slug is the method kind. | |
| 10 | 12 | // --------------------------------------------------------------------------- |
| 11 | 13 | |
| 12 | 14 | const recordWith = (config: IntegrationConfig): IntegrationRecord => ({ |
| @@ -20,12 +22,12 @@ const recordWith = (config: IntegrationConfig): IntegrationRecord => ({ · 7 unmodified lines | ||
| 20 | 22 | }); |
| 21 | 23 | |
| 22 | 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 | 26 | const methods = describeMcpAuthMethods( |
| 25 | 27 | recordWith({ |
| 26 | 28 | transport: "remote", |
| 27 | 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 | 49 | const methods = describeMcpAuthMethods( |
| 48 | 50 | recordWith({ |
| 49 | 51 | transport: "remote", |
| 50 | 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 | 59 | expect(methods).toEqual([ |
| 56 | 60 | { |
| 57 | 61 | id: "header", |
| 58 | − label: "API key (header)", | |
| 62 | + label: "API key (X-Api-Key)", | |
| 59 | 63 | kind: "apikey", |
| 60 | 64 | template: "header", |
| 61 | 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 | 88 | it("defaults the header prefix to an empty string when unset", () => { |
| 67 | 89 | const methods = describeMcpAuthMethods( |
| 68 | 90 | recordWith({ |
| 69 | 91 | transport: "remote", |
| 70 | 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 | 103 | const methods = describeMcpAuthMethods( |
| 82 | 104 | recordWith({ |
| 83 | 105 | transport: "remote", |
| 84 | 106 | endpoint: "https://x.example/mcp", |
| 85 | − auth: { kind: "none" }, | |
| 107 | + authenticationTemplate: [{ slug: "none", kind: "none" }], | |
| 86 | 108 | }), |
| 87 | 109 | ); |
| 88 | 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 | 142 | it("treats legacy remote configs with no auth field as open/no-auth", () => { |
| 99 | 143 | const methods = describeMcpAuthMethods( |
| 100 | 144 | recordWith({ |
| @@ -131,7 +175,7 @@ describe("describeMcpAuthMethods", () => { · 30 unmodified lines | ||
| 131 | 175 | recordWith({ |
| 132 | 176 | transport: "remote", |
| 133 | 177 | endpoint: "https://mcp.posthog.com/mcp", |
| 134 | − auth: { kind: "none" }, | |
| 178 | + authenticationTemplate: [{ slug: "none", kind: "none" }], | |
| 135 | 179 | }), |
| 136 | 180 | ), |
| 137 | 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 | 353 | // When discovery fails (auth, network, etc.) the connection still lands with |
| 305 | 354 | // an empty tool set so the user can retry via `connections.refresh` once they |
| 306 | 355 | // fix the underlying problem. |
| @@ -10,6 +10,7 @@ import { · 9 unmodified lines | ||
| 10 | 10 | definePlugin, |
| 11 | 11 | IntegrationAlreadyExistsError, |
| 12 | 12 | IntegrationSlug, |
| 13 | + mergeAuthTemplates, | |
| 13 | 14 | OAuthClientSlug, |
| 14 | 15 | tool, |
| 15 | 16 | ToolResult, |
| @@ -35,11 +36,16 @@ import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest"; · 19 unmodified lines | ||
| 35 | 36 | import { mcpPresets } from "./presets"; |
| 36 | 37 | import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; |
| 37 | 38 | import { |
| 39 | + McpAuthMethodInput, | |
| 38 | 40 | McpAuthTemplate, |
| 39 | 41 | McpRemoteTransport, |
| 42 | + type McpAuthMethod, | |
| 40 | 43 | type McpToolAnnotations, |
| 44 | + mcpAuthMethodFromLegacy, | |
| 45 | + normalizeMcpAuthMethods, | |
| 41 | 46 | parseMcpIntegrationConfig, |
| 42 | 47 | type McpIntegrationConfig as McpIntegrationConfigType, |
| 48 | + type McpRemoteIntegrationConfig, | |
| 43 | 49 | type McpStdioIntegrationConfig, |
| 44 | 50 | } from "./types"; |
| 45 | 51 | |
| @@ -75,7 +81,13 @@ const legacyMcpClientMatches = ( · 29 unmodified lines | ||
| 75 | 81 | config: McpIntegrationConfigType | null, |
| 76 | 82 | ): boolean => { |
| 77 | 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 | 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 | 141 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 130 | 142 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 131 | 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 | 148 | auth: Schema.optional(McpAuthTemplate), |
| 134 | 149 | }); |
| 135 | 150 | |
| @@ -152,6 +167,15 @@ const McpAddServerOutputSchema = Schema.Struct({ · 16 unmodified lines | ||
| 152 | 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 | 179 | const McpProbeEndpointInputSchema = Schema.Struct({ |
| 156 | 180 | endpoint: Schema.String, |
| 157 | 181 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| @@ -240,7 +264,9 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 82 unmodified lines | ||
| 240 | 264 | remoteTransport: input.remoteTransport ?? "auto", |
| 241 | 265 | queryParams: input.queryParams, |
| 242 | 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 | 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 | 430 | const buildConnectorInput = ( |
| 387 | 431 | config: McpIntegrationConfigType, |
| 388 | 432 | value: string | null, |
| 433 | + templateSlug: string | null, | |
| 389 | 434 | allowStdio: boolean, |
| 390 | 435 | ): Effect.Effect<ConnectorInput, McpConnectionError> => { |
| 391 | 436 | if (config.transport === "stdio") { |
| @@ -410,10 +455,10 @@ const buildConnectorInput = ( · 18 unmodified lines | ||
| 410 | 455 | const headers: Record<string, string> = { ...(config.headers ?? {}) }; |
| 411 | 456 | let authProvider: OAuthClientProvider | undefined; |
| 412 | 457 | |
| 413 | − const auth = config.auth; | |
| 414 | − if (auth.kind === "header" && value !== null) { | |
| 458 | + const auth = selectAuthMethod(config, templateSlug); | |
| 459 | + if (auth?.kind === "header" && value !== null) { | |
| 415 | 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 | 462 | authProvider = makeOAuthProvider(value); |
| 418 | 463 | } |
| 419 | 464 | |
| @@ -432,13 +477,14 @@ const buildConnectorInput = ( · 12 unmodified lines | ||
| 432 | 477 | |
| 433 | 478 | // --------------------------------------------------------------------------- |
| 434 | 479 | // Declared auth methods — project the stored MCP config into the catalog's |
| 435 | −// plugin-agnostic `AuthMethodDescriptor[]`. Pure and tolerant of a malformed or | |
| 436 | −// foreign config blob (returns `[]`). Exported for tests. | |
| 480 | +// plugin-agnostic `AuthMethodDescriptor[]`, one per declared method. Pure and | |
| 481 | +// tolerant of a malformed or foreign config blob (returns `[]`). Exported for | |
| 482 | +// tests. | |
| 437 | 483 | // |
| 438 | −// open (`none`) → one none method carrying no credential inputs | |
| 484 | +// none → a no-auth method carrying no credential inputs | |
| 439 | 485 | // stdio → [] (no remote connection to configure) |
| 440 | −// header → one apikey method carrying the header placement | |
| 441 | −// oauth2 → one oauth method carrying the MCP endpoint to probe | |
| 486 | +// header → an apikey method carrying the header placement | |
| 487 | +// oauth2 → an oauth method carrying the MCP endpoint to probe | |
| 442 | 488 | // (`discoveryUrl`); endpoints are discovered live at |
| 443 | 489 | // connect time, so they are NOT pre-resolved here. We |
| 444 | 490 | // mark `supportsDynamicRegistration: true` because MCP |
| @@ -452,40 +498,32 @@ export const describeMcpAuthMethods = ( · 7 unmodified lines | ||
| 452 | 498 | const config = parseMcpIntegrationConfig(record.config); |
| 453 | 499 | if (!config || config.transport === "stdio") return []; |
| 454 | 500 | |
| 455 | − const auth = config.auth; | |
| 456 | − if (auth.kind === "none") { | |
| 457 | − return [ | |
| 458 | − { | |
| 459 | − id: "none", | |
| 460 | − label: "No authentication", | |
| 461 | − kind: "none", | |
| 462 | − template: "none", | |
| 463 | − }, | |
| 464 | − ]; | |
| 465 | − } | |
| 466 | − if (auth.kind === "header") { | |
| 467 | − return [ | |
| 468 | − { | |
| 469 | − id: "header", | |
| 470 | − label: "API key (header)", | |
| 501 | + return config.authenticationTemplate.map((method: McpAuthMethod): AuthMethodDescriptor => { | |
| 502 | + if (method.kind === "header") { | |
| 503 | + return { | |
| 504 | + id: method.slug, | |
| 505 | + label: `API key (${method.headerName})`, | |
| 471 | 506 | kind: "apikey", |
| 472 | − template: "header", | |
| 473 | − placements: [{ carrier: "header", name: auth.headerName, prefix: auth.prefix ?? "" }], | |
| 474 | − }, | |
| 475 | − ]; | |
| 476 | − } | |
| 477 | − if (auth.kind === "oauth2") { | |
| 478 | − return [ | |
| 479 | − { | |
| 480 | − id: "oauth2", | |
| 507 | + template: method.slug, | |
| 508 | + placements: [{ carrier: "header", name: method.headerName, prefix: method.prefix ?? "" }], | |
| 509 | + }; | |
| 510 | + } | |
| 511 | + if (method.kind === "oauth2") { | |
| 512 | + return { | |
| 513 | + id: method.slug, | |
| 481 | 514 | label: "OAuth", |
| 482 | 515 | kind: "oauth", |
| 483 | − template: "oauth2", | |
| 516 | + template: method.slug, | |
| 484 | 517 | oauth: { discoveryUrl: config.endpoint, supportsDynamicRegistration: true }, |
| 485 | − }, | |
| 486 | − ]; | |
| 487 | − } | |
| 488 | − return []; | |
| 518 | + }; | |
| 519 | + } | |
| 520 | + return { | |
| 521 | + id: method.slug, | |
| 522 | + label: "No authentication", | |
| 523 | + kind: "none", | |
| 524 | + template: method.slug, | |
| 525 | + }; | |
| 526 | + }); | |
| 489 | 527 | }; |
| 490 | 528 | |
| 491 | 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 | 854 | return { |
| 781 | 855 | probeEndpoint, |
| 782 | 856 | addServer, |
| 783 | 857 | removeServer, |
| 784 | 858 | getServer, |
| 785 | 859 | configureServer, |
| 860 | + configureAuth, | |
| 786 | 861 | }; |
| 787 | 862 | }, |
| 788 | 863 | |
| @@ -794,14 +869,19 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 5 unmodified lines | ||
| 794 | 869 | // Discovery failures (auth not ready, server down) yield an empty tool set |
| 795 | 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 | 873 | Effect.gen(function* () { |
| 799 | 874 | const parsed = parseMcpIntegrationConfig(config); |
| 800 | 875 | if (!parsed) return { tools: [] as readonly ToolDef[] }; |
| 801 | 876 | |
| 802 | 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 | 885 | Effect.map((ci) => createMcpConnector(ci)), |
| 806 | 886 | Effect.result, |
| 807 | 887 | ); |
| @@ -848,6 +928,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 40 unmodified lines | ||
| 848 | 928 | const connector: McpConnector = yield* buildConnectorInput( |
| 849 | 929 | parsed, |
| 850 | 930 | credential.value, |
| 931 | + String(credential.template), | |
| 851 | 932 | allowStdio, |
| 852 | 933 | ).pipe(Effect.map((ci) => createMcpConnector(ci))); |
| 853 | 934 | |
| @@ -1078,4 +1159,8 @@ export interface McpPluginExtension { · 224 unmodified lines | ||
| 1078 | 1159 | slug: string, |
| 1079 | 1160 | config: McpIntegrationConfigType, |
| 1080 | 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 | 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 | 33 | // none — no credential (open server) |
| 31 | 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 | 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 | 56 | export const McpAuthTemplate = Schema.Union([ |
| 38 | 57 | Schema.Struct({ kind: Schema.Literal("none") }), |
| 39 | 58 | Schema.Struct({ |
| @@ -45,6 +64,40 @@ export const McpAuthTemplate = Schema.Union([ · 5 unmodified lines | ||
| 45 | 64 | ]); |
| 46 | 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 | 102 | // Integration config — the opaque blob stored on the integration row. A |
| 50 | 103 | // discriminated union on transport. |
| @@ -65,8 +118,9 @@ export const McpRemoteIntegrationConfig = Schema.Struct({ · 14 unmodified lines | ||
| 65 | 118 | queryParams: Schema.optional(StringMap), |
| 66 | 119 | /** Static headers sent on every request (non-credential) */ |
| 67 | 120 | headers: Schema.optional(StringMap), |
| 68 | − /** Auth template — how the connection's value is rendered onto requests */ | |
| 69 | − auth: McpAuthTemplate, | |
| 121 | + /** Declared auth methods — how a connection's value is rendered onto | |
| 122 | + * requests. A connection's `template` picks one by slug. */ | |
| 123 | + authenticationTemplate: Schema.Array(McpAuthMethod), | |
| 70 | 124 | }); |
| 71 | 125 | export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type; |
| 72 | 126 | |
| @@ -90,18 +144,33 @@ export const McpIntegrationConfig = Schema.Union([ · 17 unmodified lines | ||
| 90 | 144 | export type McpIntegrationConfig = typeof McpIntegrationConfig.Type; |
| 91 | 145 | |
| 92 | 146 | const decodeIntegrationConfig = Schema.decodeUnknownOption(McpIntegrationConfig); |
| 147 | +const decodeLegacyAuth = Schema.decodeUnknownOption(McpAuthTemplate); | |
| 93 | 148 | |
| 94 | 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 | 154 | export const parseMcpIntegrationConfig = (config: unknown): McpIntegrationConfig | null => { |
| 97 | 155 | if ( |
| 98 | 156 | typeof config === "object" && |
| 99 | 157 | config !== null && |
| 100 | 158 | "transport" in config && |
| 101 | 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 | 175 | return Option.getOrNull(decodeIntegrationConfig(config)); |
| 107 | 176 | }; |
| @@ -1,19 +1,14 @@ | ||
| 1 | 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | −import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 3 | −import { Link } from "@tanstack/react-router"; | |
| 2 | +import { useAtomSet } from "@effect/atom-react"; | |
| 4 | 3 | import * as Effect from "effect/Effect"; |
| 5 | 4 | import * as Exit from "effect/Exit"; |
| 6 | 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 | 7 | import { |
| 12 | 8 | AuthTemplateSlug, |
| 13 | 9 | IntegrationSlug, |
| 14 | 10 | type OAuthAuthentication, |
| 15 | 11 | } from "@executor-js/sdk/shared"; |
| 16 | −import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms"; | |
| 17 | 12 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 18 | 13 | import { |
| 19 | 14 | slugifyNamespace, |
| @@ -21,15 +16,23 @@ import { · 1 unmodified line | ||
| 21 | 16 | } from "@executor-js/react/plugins/integration-identity"; |
| 22 | 17 | import { Button } from "@executor-js/react/components/button"; |
| 23 | 18 | import { |
| 24 | − AuthTemplateEditor, | |
| 25 | − type AuthTemplateEditorValue, | |
| 26 | −} from "@executor-js/react/components/auth-template-editor"; | |
| 19 | + AuthMethodListEditor, | |
| 20 | + useAuthMethodList, | |
| 21 | + type AuthMethodRow, | |
| 22 | + type AuthMethodSeed, | |
| 23 | +} from "@executor-js/react/components/auth-method-list-editor"; | |
| 27 | 24 | import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack"; |
| 28 | 25 | import { FieldLabel } from "@executor-js/react/components/field"; |
| 29 | 26 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 30 | 27 | import { Textarea } from "@executor-js/react/components/textarea"; |
| 31 | 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 | 37 | import { authenticationFromEditorValue, editorValueFromAuthentication } from "./auth-method-config"; |
| 35 | 38 | import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; |
| @@ -72,24 +75,6 @@ const googleBundleUrls = ( · 36 unmodified lines | ||
| 72 | 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 | 79 | // OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve |
| 95 | 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 | 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 | 365 | // Editable auth methods, seeded from the spec-detected templates. The add flow |
| 389 | 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 | |
| 391 | − // detected template's original `seedSlug`, so an unedited detected method | |
| 392 | − // submits with its EXACT original slug (preserving behavior); added methods | |
| 393 | − // (no seed) get a deterministic fresh slug. The user can edit, add, or remove | |
| 394 | − // a method before submitting; on submit the list converts back to | |
| 395 | − // `Authentication[]`. Re-seeded whenever a fresh detection arrives (keyed on | |
| 396 | − // the detected templates, which are stable per analysis + base URL). | |
| 397 | − type AuthMethodRow = { | |
| 398 | − readonly value: AuthTemplateEditorValue; | |
| 399 | − readonly seedSlug?: string; | |
| 400 | − }; | |
| 401 | − const [authMethods, setAuthMethods] = useState<readonly AuthMethodRow[]>([]); | |
| 402 | − const seededFromRef = useRef<readonly Authentication[] | null>(null); | |
| 403 | − useEffect(() => { | |
| 404 | − if (seededFromRef.current === authenticationTemplate) return; | |
| 405 | − seededFromRef.current = authenticationTemplate; | |
| 406 | − setAuthMethods( | |
| 407 | − authenticationTemplate.map((template: Authentication) => ({ | |
| 367 | + // specs (e.g. apiKey + OAuth). Each seed carries the detected template's | |
| 368 | + // original slug, so an unedited detected method submits with its EXACT | |
| 369 | + // original slug (preserving behavior); added methods (no seed) get a | |
| 370 | + // deterministic fresh slug. Re-seeded whenever a fresh detection arrives | |
| 371 | + // (keyed on the detected templates, stable per analysis + base URL). | |
| 372 | + const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(() => { | |
| 373 | + const labels = [ | |
| 374 | + ...(preview?.headerPresets ?? []).map((preset) => preset.label), | |
| 375 | + ...(preview?.oauth2Presets ?? []).map((preset) => preset.label), | |
| 376 | + ]; | |
| 377 | + return authenticationTemplate.map( | |
| 378 | + (template: Authentication, index: number): AuthMethodSeed => ({ | |
| 408 | 379 | value: editorValueFromAuthentication(template), |
| 409 | − seedSlug: String(template.slug), | |
| 410 | − })), | |
| 380 | + slug: String(template.slug), | |
| 381 | + ...(labels[index] !== undefined ? { label: labels[index] } : {}), | |
| 382 | + }), | |
| 411 | 383 | ); |
| 412 | − }, [authenticationTemplate]); | |
| 413 | − | |
| 414 | − const setAuthMethodAt = useCallback((index: number, next: AuthTemplateEditorValue) => { | |
| 415 | − setAuthMethods((current: readonly AuthMethodRow[]) => | |
| 416 | − current.map((row: AuthMethodRow, i: number) => (i === index ? { ...row, value: next } : row)), | |
| 417 | − ); | |
| 418 | − }, []); | |
| 419 | − | |
| 420 | − const removeAuthMethodAt = useCallback((index: number) => { | |
| 421 | − setAuthMethods((current: readonly AuthMethodRow[]) => | |
| 422 | − current.filter((_row: AuthMethodRow, i: number) => i !== index), | |
| 423 | − ); | |
| 424 | − }, []); | |
| 425 | − | |
| 426 | − const addAuthMethod = useCallback(() => { | |
| 427 | − setAuthMethods((current: readonly AuthMethodRow[]) => [ | |
| 428 | − ...current, | |
| 429 | − { | |
| 430 | − value: { | |
| 431 | − kind: "apikey", | |
| 432 | − placements: [{ carrier: "header", name: "Authorization", prefix: "" }], | |
| 433 | − }, | |
| 434 | − }, | |
| 435 | − ]); | |
| 436 | − }, []); | |
| 384 | + }, [preview, authenticationTemplate]); | |
| 385 | + const authMethodList = useAuthMethodList(authMethodSeeds); | |
| 437 | 386 | |
| 438 | 387 | // The methods to register, mapped back to stored `Authentication[]`. Drops |
| 439 | 388 | // `none` rows (nothing to register). An unedited detected method keeps its |
| 440 | 389 | // original `seedSlug`; an added method gets a deterministic fresh slug. |
| 441 | 390 | const editedAuthenticationTemplate: readonly Authentication[] = useMemo(() => { |
| 442 | 391 | const templates: Authentication[] = []; |
| 443 | − authMethods.forEach((row: AuthMethodRow, index: number) => { | |
| 392 | + authMethodList.rows.forEach((row: AuthMethodRow, index: number) => { | |
| 444 | 393 | const slug = |
| 445 | 394 | row.seedSlug ?? (row.value.kind === "oauth" ? `oauth-${index}` : `apikey-${index}`); |
| 446 | 395 | const template = authenticationFromEditorValue(row.value, slug); |
| 447 | 396 | if (template !== null) templates.push(template); |
| 448 | 397 | }); |
| 449 | 398 | return templates; |
| 450 | − }, [authMethods]); | |
| 399 | + }, [authMethodList.rows]); | |
| 451 | 400 | |
| 452 | 401 | // Pre-empt the API's `IntegrationAlreadyExistsError`: adding an integration |
| 453 | 402 | // whose slug already exists clobbers the existing one's connections/policies, |
| 454 | 403 | // so the API blocks it. Surface that here from the tenant-scoped catalog list. |
| 455 | − const integrationsResult = useAtomValue(integrationsOptimisticAtom); | |
| 456 | − const slugAlreadyExists = useMemo( | |
| 457 | − () => | |
| 458 | − AsyncResult.isSuccess(integrationsResult) && | |
| 459 | − integrationsResult.value.some((integration) => integration.slug === resolvedSourceId), | |
| 460 | − [integrationsResult, resolvedSourceId], | |
| 461 | − ); | |
| 404 | + const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId); | |
| 462 | 405 | |
| 463 | 406 | // The bundle path is ready once at least one Google API is selected (no |
| 464 | 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 | 464 | reactivityKeys: integrationWriteKeys, |
| 522 | 465 | }); |
| 523 | 466 | if (Exit.isFailure(exit)) { |
| 524 | − setAddError( | |
| 525 | − isIntegrationAlreadyExistsExit(exit) | |
| 526 | − ? integrationExistsMessage(resolvedSourceId) | |
| 527 | − : errorMessageFromExit(exit, "Failed to add integration"), | |
| 528 | − ); | |
| 467 | + setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add integration")); | |
| 529 | 468 | return null; |
| 530 | 469 | } |
| 531 | 470 | return exit.value.slug; |
| @@ -644,85 +583,21 @@ export default function AddOpenApiSource(props: { · 112 unmodified lines | ||
| 644 | 583 | /> |
| 645 | 584 | ) : null} |
| 646 | 585 | |
| 647 | − {analyzeError && ( | |
| 648 | − <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | |
| 649 | − <p className="text-[12px] text-destructive">{analyzeError}</p> | |
| 650 | − </div> | |
| 651 | − )} | |
| 586 | + {analyzeError && <FormErrorAlert message={analyzeError} />} | |
| 652 | 587 | |
| 653 | 588 | {preview && !isGoogleBundlePreset && ( |
| 654 | − <section className="space-y-3"> | |
| 655 | − <div className="flex items-center justify-between gap-3"> | |
| 656 | − <FieldLabel>How does this API authenticate?</FieldLabel> | |
| 657 | − <Button type="button" variant="outline" size="sm" onClick={addAuthMethod}> | |
| 658 | − <PlusIcon /> | |
| 659 | − Add method | |
| 660 | − </Button> | |
| 661 | − </div> | |
| 662 | − {authMethods.length === 0 ? ( | |
| 663 | − <p className="text-[11px] text-muted-foreground"> | |
| 664 | − No authentication detected. Add a method, or add the integration without auth and | |
| 665 | − connect an account from the integration page later. | |
| 666 | − </p> | |
| 667 | − ) : ( | |
| 668 | − <div className="flex flex-col gap-3"> | |
| 669 | − {authMethods.map((row: AuthMethodRow, index: number) => ( | |
| 670 | − <div | |
| 671 | − key={index} | |
| 672 | − className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3" | |
| 673 | − > | |
| 674 | − <div className="flex items-center justify-between"> | |
| 675 | − <span className="text-xs font-medium text-muted-foreground"> | |
| 676 | − Method {index + 1} | |
| 677 | − {detectedMethodLabels[index] ? ` · ${detectedMethodLabels[index]}` : ""} | |
| 678 | − </span> | |
| 679 | − <Button | |
| 680 | − type="button" | |
| 681 | − variant="ghost" | |
| 682 | − size="icon-sm" | |
| 683 | − aria-label="Remove method" | |
| 684 | − className="text-muted-foreground hover:text-foreground" | |
| 685 | − onClick={() => removeAuthMethodAt(index)} | |
| 686 | − > | |
| 687 | − <XIcon /> | |
| 688 | − </Button> | |
| 689 | − </div> | |
| 690 | − <AuthTemplateEditor | |
| 691 | − value={row.value} | |
| 692 | − onChange={(next: AuthTemplateEditorValue) => setAuthMethodAt(index, next)} | |
| 693 | − /> | |
| 694 | − </div> | |
| 695 | − ))} | |
| 696 | − </div> | |
| 697 | − )} | |
| 698 | − <p className="text-[11px] text-muted-foreground"> | |
| 699 | − Every method here is registered with the integration. Connect an account from the | |
| 700 | − integration page after adding. | |
| 701 | − </p> | |
| 702 | − </section> | |
| 589 | + <AuthMethodListEditor | |
| 590 | + list={authMethodList} | |
| 591 | + emptyHint="No authentication detected. Add a method, or add the integration without auth and connect an account from the integration page later." | |
| 592 | + footerHint="Every method here is registered with the integration. Connect an account from the integration page after adding." | |
| 593 | + /> | |
| 703 | 594 | )} |
| 704 | 595 | |
| 705 | 596 | {hasPreviewOrBundle && slugAlreadyExists && !adding && ( |
| 706 | − <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | |
| 707 | − <p className="text-[12px] text-destructive"> | |
| 708 | − An integration named "{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> | |
| 597 | + <SlugCollisionAlert slug={resolvedSourceId} /> | |
| 719 | 598 | )} |
| 720 | 599 | |
| 721 | − {addError && ( | |
| 722 | − <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | |
| 723 | − <p className="text-[12px] text-destructive">{addError}</p> | |
| 724 | − </div> | |
| 725 | − )} | |
| 600 | + {addError && <FormErrorAlert message={addError} />} | |
| 726 | 601 | |
| 727 | 602 | <FloatActions> |
| 728 | 603 | <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}> |
| @@ -10,8 +10,12 @@ import { TriangleAlert } from "lucide-react"; · 9 unmodified lines | ||
| 10 | 10 | import { AccountsSection } from "@executor-js/react/components/accounts-section"; |
| 11 | 11 | import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert"; |
| 12 | 12 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 13 | −import type { CreateCustomMethod } from "@executor-js/react/components/add-custom-method-modal"; | |
| 14 | 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 | 20 | import { openApiConfigAtom, openapiConfigure } from "./atoms"; |
| 17 | 21 | import { authMethodsFromConfig, templateFromPlacements } from "./auth-method-config"; |
| @@ -70,44 +74,42 @@ export default function OpenApiAccountsPanel(props: { · 52 unmodified lines | ||
| 70 | 74 | return declared.length > 0 ? declared : [NO_AUTH_METHOD]; |
| 71 | 75 | }, [existingTemplate]); |
| 72 | 76 | |
| 73 | − // Add a custom apiKey method: build an `APIKeyAuthentication` from the generic | |
| 74 | − // placements (slug omitted → backend backfills `custom_<id>`), merge-append it | |
| 75 | − // onto the existing template, and persist. Returns the created `AuthMethod` | |
| 76 | − // (derived from the same template) so Add-account can select it immediately. | |
| 77 | − const createCustomMethod = useCallback<CreateCustomMethod>( | |
| 78 | − async (input: { readonly label: string; readonly placements: readonly Placement[] }) => { | |
| 79 | − const method = templateFromPlacements(input.placements); | |
| 77 | + // Custom-method create/remove: the shared skeleton (merge-append → diff out | |
| 78 | + // the created method; filter → replace) parameterized by the OpenAPI codec. | |
| 79 | + // Stays plugin-side only where it touches the OpenAPI `Authentication` types. | |
| 80 | + const configure = useCallback<ConfigureAuthMethods<Authentication>>( | |
| 81 | + async (input) => { | |
| 80 | 82 | const exit = await doConfigure({ |
| 81 | 83 | params: { slug }, |
| 82 | − payload: { authenticationTemplate: [...existingTemplate, method] }, | |
| 84 | + payload: { | |
| 85 | + authenticationTemplate: input.authenticationTemplate, | |
| 86 | + ...(input.mode ? { mode: input.mode } : {}), | |
| 87 | + }, | |
| 83 | 88 | reactivityKeys: integrationWriteKeys, |
| 84 | 89 | }); |
| 85 | − if (Exit.isFailure(exit)) return null; | |
| 86 | − const before = new Set(existingTemplate.map((template) => String(template.slug))); | |
| 87 | − const created = authMethodsFromConfig( | |
| 88 | − exit.value.authenticationTemplate as readonly Authentication[], | |
| 89 | − ).find((candidate: AuthMethod) => !before.has(String(candidate.template))); | |
| 90 | − return created ?? null; | |
| 90 | + return Exit.map(exit, (result) => result.authenticationTemplate as readonly Authentication[]); | |
| 91 | 91 | }, |
| 92 | − [doConfigure, slug, existingTemplate], | |
| 92 | + [doConfigure, slug], | |
| 93 | 93 | ); |
| 94 | 94 | |
| 95 | − const removeCustomMethod = useCallback( | |
| 96 | − async (method: AuthMethod): Promise<boolean> => { | |
| 97 | − if (method.source !== "custom") return false; | |
| 98 | − const next = existingTemplate.filter( | |
| 99 | − (template: Authentication) => String(template.slug) !== String(method.template), | |
| 100 | − ); | |
| 101 | − const exit = await doConfigure({ | |
| 102 | − params: { slug }, | |
| 103 | − payload: { authenticationTemplate: next, mode: "replace" }, | |
| 104 | − reactivityKeys: integrationWriteKeys, | |
| 105 | − }); | |
| 106 | − return Exit.isSuccess(exit); | |
| 107 | − }, | |
| 108 | − [doConfigure, existingTemplate, slug], | |
| 95 | + const codec = useMemo<AuthMethodsCodec<Authentication>>( | |
| 96 | + () => ({ | |
| 97 | + toAuthMethods: authMethodsFromConfig, | |
| 98 | + // Slug omitted → backend backfills `custom_<id>`. | |
| 99 | + templatesFromPlacements: (placements: readonly Placement[]) => [ | |
| 100 | + templateFromPlacements(placements), | |
| 101 | + ], | |
| 102 | + slugOf: (template: Authentication) => String(template.slug), | |
| 103 | + }), | |
| 104 | + [], | |
| 109 | 105 | ); |
| 110 | 106 | |
| 107 | + const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({ | |
| 108 | + existing: existingTemplate, | |
| 109 | + codec, | |
| 110 | + configure, | |
| 111 | + }); | |
| 112 | + | |
| 111 | 113 | // For a bundled `google` integration, surface a caution when any selected API |
| 112 | 114 | // needs a privileged or unsupported OAuth consent the user should know about |
| 113 | 115 | // BEFORE connecting an account. Derived from the stored Discovery URLs. |
| @@ -3,7 +3,6 @@ import type { Layer } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import { HttpClient } from "effect/unstable/http"; |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | − AuthTemplateSlug, | |
| 7 | 6 | IntegrationAlreadyExistsError, |
| 8 | 7 | IntegrationDetectionResult, |
| 9 | 8 | IntegrationSlug, |
| @@ -11,6 +10,7 @@ import { · 1 unmodified line | ||
| 11 | 10 | ToolResult, |
| 12 | 11 | authToolFailure, |
| 13 | 12 | definePlugin, |
| 13 | + mergeAuthTemplates, | |
| 14 | 14 | tool, |
| 15 | 15 | type AuthMethodDescriptor, |
| 16 | 16 | type AuthPlacementDescriptor, |
| @@ -521,53 +521,6 @@ const specInputToSourceUrl = (spec: OpenApiSpecInput): string | undefined => · 504 unmodified lines | ||
| 521 | 521 | const specInputToGoogleBundle = (spec: OpenApiSpecInput): readonly string[] | undefined => |
| 522 | 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 | 525 | // Declared auth methods — project the stored `authenticationTemplate` into the |
| 573 | 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 | 894 | const merged = |
| 942 | 895 | input.mode === "replace" |
| 943 | 896 | ? input.authenticationTemplate |
| 944 | − : mergeAuthenticationTemplate( | |
| 897 | + : mergeAuthTemplates( | |
| 945 | 898 | current.authenticationTemplate ?? [], |
| 946 | 899 | input.authenticationTemplate, |
| 947 | 900 | ); |
| @@ -12,6 +12,7 @@ · 11 unmodified lines | ||
| 12 | 12 | "./components/*": "./src/components/*.tsx", |
| 13 | 13 | "./hooks/*": "./src/hooks/*.ts", |
| 14 | 14 | "./lib/auth-placements": "./src/lib/auth-placements.tsx", |
| 15 | + "./lib/integration-add": "./src/lib/integration-add.tsx", | |
| 15 | 16 | "./lib/*": "./src/lib/*.ts", |
| 16 | 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 | +); | |