History

Fix #1120: OAuth connection setup regressions

Unpacks issue #1120 into four distinct problems and fixes each: 1. Cross-integration OAuth-app leak: the "OAuth App" picker showed apps from other integrations (Datadog clients listed under Atlassian) because a discovery-only OAuth method exposes no endpoints, hitting the "no endpoints -> show all apps" branch and never consulting origin.integration. Match apps by origin.integration first, falling back to endpoint-domain matching only when endpoints are declared. Every app (DCR and manual) now records the integration it was registered for, threaded sdk -> api -> react. 2. Duplicate DCR clients ("Datadog 2..10"): runDcrConnect minted a fresh oauth_client on every connect. Reuse an existing DCR client for the same integration + owner + discovered token host before minting. 3. `executor web --scope` silently ignored without --foreground: it opens the already-running service, which keeps its own scope. Now warns loudly and names the flag that applies it. 4. opencode HTTP MCP 401: the local /mcp is bearer-gated with no OAuth discovery, so opencode's auto-OAuth chokes on a plain 401. The --foreground ready output now prints a copy-pasteable opencode config (Authorization: Bearer + oauth: false). Tests: new e2e oauth-picker-cross-integration (selfhost) and http-mcp-bearer (local); extended cli-web-transition and add-account-modal unit guards.

Rhys Sullivan committed 4 months ago

Commit 8a75d121 parent 78aa87116 files · +559 −59Browse files

Unified · Split

16 files
  1. apps/cli/src/main.ts +32 −0
  2. e2e/local/cli-web-transition.test.ts +32 −0
  3. e2e/local/http-mcp-bearer.test.ts +75 −0
  4. e2e/selfhost/oauth-picker-cross-integration.test.ts +166 −0
  5. packages/core/api/src/handlers/oauth.ts +1 −0
  6. packages/core/api/src/oauth/api.ts +8 −1
  7. packages/core/sdk/src/core-tools.ts +4 −1
  8. packages/core/sdk/src/index.ts +2 −0
  9. packages/core/sdk/src/oauth-client.ts +16 −1
  10. packages/core/sdk/src/oauth-service.ts +6 −14
  11. packages/core/sdk/src/shared.ts +2 −0
  12. packages/react/src/api/atoms.tsx +2 −1
  13. packages/react/src/components/add-account-modal.test.ts +100 −5
  14. packages/react/src/components/add-account-modal.tsx +34 −12
  15. packages/react/src/components/oauth-client-form.tsx +13 −1
  16. packages/react/src/plugins/use-effective-oauth-client.tsx +66 −23
apps/cli/src/main.ts+32−0
@@ -981,6 +981,25 @@ const runForegroundSession = (input: { · 980 unmodified lines
981981 console.log(`Web: ${baseUrl}`);
982982 console.log(`MCP: ${baseUrl}/mcp`);
983983 console.log(`OpenAPI: ${baseUrl}/api/docs`);
984+ // The HTTP /mcp endpoint is bearer-gated, and external agents have no way
985+ // to discover the token (there is no OAuth server on the local app, so a
986+ // client that tries OAuth auto-detection just errors). Surface the exact
987+ // header — and a ready opencode block that pins `oauth: false` so it sends
988+ // the header instead of probing for an authorization server.
989+ console.log(`\nConnect an HTTP MCP client (e.g. opencode):`);
990+ console.log(` Header: Authorization: Bearer ${server.authToken}`);
991+ console.log(
992+ ` opencode.json: ${JSON.stringify({
993+ mcp: {
994+ executor: {
995+ type: "remote",
996+ url: `${baseUrl}/mcp`,
997+ headers: { Authorization: `Bearer ${server.authToken}` },
998+ oauth: false,
999+ },
1000+ },
1001+ })}`,
1002+ );
9841003 if (input.hostname !== "127.0.0.1" && input.hostname !== "localhost") {
9851004 console.log(
9861005 `\n⚠ Listening on ${input.hostname}. Executor runs arbitrary commands — only expose on trusted networks.`,
@@ -2352,6 +2371,19 @@ const webCommand = Command.make( · 1365 unmodified lines
23522371 ({ foreground, port, scope, hostname, allowedHost, authToken }) =>
23532372 Effect.gen(function* () {
23542373 if (!foreground) {
2374+ // `--scope` can ONLY be honored by a foreground server we boot here.
2375+ // Without `--foreground` we just open whatever background service is
2376+ // already running, which uses the scope IT was started with — so a
2377+ // `--scope` here would be silently ignored and the user could land on a
2378+ // different workspace ("where did my config go?"). Say so loudly and
2379+ // point at the flag that actually applies it.
2380+ if (Option.isSome(scope)) {
2381+ console.warn(
2382+ `Ignoring --scope ${scope.value}: it only applies with --foreground. ` +
2383+ `The running web app uses the scope it was started with. ` +
2384+ `Run \`executor web --foreground --scope ${scope.value}\` to serve that workspace.`,
2385+ );
2386+ }
23552387 yield* openRunningLocalWebApp();
23562388 return;
23572389 }
e2e/local/cli-web-transition.test.ts+32−0
@@ -55,3 +55,35 @@ scenario( · 54 unmodified lines
5555 }
5656 }),
5757 );
58+
59+scenario(
60+ "CLI web · --scope without --foreground warns instead of silently ignoring it",
61+ { timeout: 120_000 },
62+ Effect.promise(async () => {
63+ // Regression guard for "Had to setup my config again": `executor web --scope X`
64+ // without `--foreground` cannot honor the scope (it opens the running service,
65+ // which keeps its own scope). That used to be a SILENT no-op — the user lands
66+ // on a different workspace and thinks their config vanished. It must warn.
67+ const root = mkdtempSync(join(tmpdir(), "executor-web-scope-"));
68+ const dataDir = join(root, "data");
69+ const scopeDir = join(root, "workspace");
70+ try {
71+ const { stdout, stderr } = await execFileAsync(
72+ "bun",
73+ ["run", "dev:cli", "web", "--scope", scopeDir],
74+ { cwd: repoRoot, env: { ...process.env, EXECUTOR_DATA_DIR: dataDir } },
75+ );
76+ const output = `${stdout}\n${stderr}`;
77+
78+ expect(output, "the dropped --scope is called out, not silently ignored").toContain(
79+ "Ignoring --scope",
80+ );
81+ expect(output, "the warning names the scope it dropped").toContain(scopeDir);
82+ expect(output, "the warning points at the flag that actually applies scope").toContain(
83+ "--foreground",
84+ );
85+ } finally {
86+ rmSync(root, { recursive: true, force: true });
87+ }
88+ }),
89+);
e2e/local/http-mcp-bearer.test.ts+75−0
@@ -0,0 +1,75 @@
1+// Local-only — REPRO + guard for "I've been running executor as stdio as get
2+// errors trying http one in opencode". The local app's HTTP `/mcp` endpoint is
3+// bearer-gated (hardened so loopback is not a free pass) and serves NO OAuth
4+// discovery. An external agent like opencode, pointed at the URL, tries MCP
5+// OAuth auto-detection, gets a plain `401 Bearer realm="executor"` with no
6+// resource-metadata to discover an authorization server from, and errors out.
7+//
8+// The HTTP transport itself is fine — it works the moment the bearer is supplied
9+// (opencode's remote MCP supports `headers` + `oauth: false`). This scenario
10+// proves exactly that: tools list over HTTP WITH the bearer, and the gate 401s
11+// WITHOUT it. It also asserts the `--foreground` ready output now prints a
12+// ready-to-paste opencode config (URL + bearer header + `oauth: false`) so a
13+// user does not have to reverse-engineer the gate.
14+import { expect } from "@effect/vitest";
15+import { Effect } from "effect";
16+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
17+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
18+
19+import { scenario } from "../src/scenario";
20+import { Cli, RunDir } from "../src/services";
21+import { withLocalServer } from "./local-server";
22+
23+/** Connect an MCP client to the local `/mcp` over HTTP and list tools. Rejects
24+ * if the bearer gate (or transport) refuses the connection. */
25+const listToolsOverHttp = async (
26+ origin: string,
27+ headers?: Record<string, string>,
28+): Promise<readonly string[]> => {
29+ const client = new Client({ name: "e2e-http-mcp", version: "0.0.0" });
30+ const transport = new StreamableHTTPClientTransport(new URL(`${origin}/mcp`), {
31+ requestInit: headers ? { headers } : undefined,
32+ });
33+ await client.connect(transport);
34+ try {
35+ const { tools } = await client.listTools();
36+ return tools.map((t) => t.name);
37+ } finally {
38+ await client.close().catch(() => {});
39+ }
40+};
41+
42+scenario(
43+ "Local · HTTP MCP works with the bearer header and 401s without it",
44+ { timeout: 180_000 },
45+ Effect.gen(function* () {
46+ const cli = yield* Cli;
47+ const runDir = yield* RunDir;
48+
49+ yield* withLocalServer(cli, runDir, (server) =>
50+ Effect.gen(function* () {
51+ // WITH the bearer: the HTTP transport connects and lists tools — the very
52+ // thing that "errors in opencode" when the agent omits the token.
53+ const tools = yield* Effect.promise(() =>
54+ listToolsOverHttp(server.origin, { authorization: `Bearer ${server.token}` }),
55+ );
56+ expect(
57+ tools.length,
58+ "HTTP MCP lists tools once the bearer is supplied",
59+ ).toBeGreaterThan(0);
60+
61+ // WITHOUT the bearer: the gate rejects, which is what trips opencode's
62+ // default OAuth auto-detection (no resource-metadata to recover from).
63+ const unauthorized = yield* Effect.promise(async () => {
64+ try {
65+ await listToolsOverHttp(server.origin);
66+ return "connected";
67+ } catch {
68+ return "rejected";
69+ }
70+ });
71+ expect(unauthorized, "HTTP MCP rejects a tokenless connection").toBe("rejected");
72+ }),
73+ );
74+ }),
75+);
e2e/selfhost/oauth-picker-cross-integration.test.ts+166−0
@@ -0,0 +1,166 @@
1+// Selfhost (browser): REPRO for issue #1120 ("Can't configure oauth mcp which
2+// used to work"). The user added an Atlassian remote-MCP source and the OAuth
3+// app picker listed a pile of unrelated *Datadog* apps.
4+//
5+// Why it happens: a remote MCP OAuth method carries ONLY a `discoveryUrl`
6+// (endpoints are discovered live, see describeMcpAuthMethods in the mcp plugin),
7+// never a static token/authorization URL. When transparent DCR falls back to the
8+// bring-your-own app picker, the picker filters candidate apps by endpoint root
9+// domain (`selectClientsForEndpoints`). With no declared endpoints the filter
10+// short-circuits to "every app is usable", so EVERY registered OAuth client the
11+// owner has, including DCR-minted apps from other integrations, leaks into this
12+// integration's picker. The `origin.integration` slug each client carries is
13+// never consulted.
14+//
15+// The fix: the picker matches apps to the integration by `origin.integration`
16+// (recorded on every app, DCR or manual), falling back to endpoint-domain
17+// matching only when endpoints are declared. So an app registered FOR Atlassian
18+// shows, and a Datadog app (different integration, no shared endpoint) does not.
19+//
20+// This scenario registers two apps for the same owner — one Datadog app with NO
21+// integration association, and one app registered FOR the Atlassian MCP source —
22+// then drives that source into the BYO picker (its discovery endpoint advertises
23+// no OAuth metadata, so DCR falls back) and asserts the Atlassian app appears
24+// while the Datadog app does not.
25+import { randomBytes } from "node:crypto";
26+
27+import { expect } from "@effect/vitest";
28+import { Effect } from "effect";
29+import { composePluginApi } from "@executor-js/api/server";
30+import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
31+import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
32+import { IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared";
33+
34+import { scenario } from "../src/scenario";
35+import { Api, Browser, Target } from "../src/services";
36+
37+const api = composePluginApi([mcpHttpPlugin()] as const);
38+
39+scenario(
40+ "OAuth picker · a different integration's app does not leak into an MCP source's picker",
41+ { timeout: 180_000 },
42+ Effect.scoped(
43+ Effect.gen(function* () {
44+ const target = yield* Target;
45+ const browser = yield* Browser;
46+ const { client: makeApiClient } = yield* Api;
47+ const identity = yield* target.newIdentity();
48+ const client = yield* makeApiClient(api, identity);
49+
50+ // Selfhost shares one tenant across identities, so make every resource
51+ // unique per run — including the Datadog host, which is what the picker
52+ // row renders and what we assert against.
53+ const id = randomBytes(4).toString("hex");
54+ const datadogSlug = OAuthClientSlug.make(`datadog-${id}`);
55+ const datadogHost = `api-${id}.datadoghq.com`;
56+ const atlassianSlug = OAuthClientSlug.make(`atlassian-app-${id}`);
57+ const atlassianHost = `auth-${id}.atlassian.com`;
58+ const mcpSlug = IntegrationSlug.make(`atlassian-mcp-${id}`);
59+
60+ // An Atlassian-shaped remote MCP source: it speaks MCP but advertises NO
61+ // OAuth metadata, so the connect-time DCR probe finds no registration
62+ // endpoint and the modal falls back to the bring-your-own app picker.
63+ const server = yield* serveMcpServer(() =>
64+ makeGreetingMcpServer({ name: `atlassian-mcp-${id}` }),
65+ );
66+
67+ yield* Effect.ensuring(
68+ Effect.gen(function* () {
69+ // A Datadog OAuth app the owner registered for a DIFFERENT integration.
70+ // Endpoints are on datadoghq.com — nothing to do with Atlassian, and no
71+ // integration association — so it must never surface in this picker.
72+ yield* client.oauth.createClient({
73+ payload: {
74+ owner: "user",
75+ slug: datadogSlug,
76+ authorizationUrl: `https://${datadogHost}/oauth2/authorize`,
77+ tokenUrl: `https://${datadogHost}/oauth2/token`,
78+ grant: "authorization_code",
79+ clientId: `datadog-client-${id}`,
80+ clientSecret: "datadog-secret",
81+ },
82+ });
83+
84+ // An app the owner registered FOR the Atlassian MCP source. It carries
85+ // `origin.integration`, so the picker surfaces it even though the source
86+ // declares no static endpoints to match on.
87+ yield* client.oauth.createClient({
88+ payload: {
89+ owner: "user",
90+ slug: atlassianSlug,
91+ authorizationUrl: `https://${atlassianHost}/authorize`,
92+ tokenUrl: `https://${atlassianHost}/token`,
93+ grant: "authorization_code",
94+ clientId: `atlassian-client-${id}`,
95+ clientSecret: "atlassian-secret",
96+ originIntegration: mcpSlug,
97+ },
98+ });
99+
100+ // Persistence guard: the integration association round-trips through the
101+ // API so the picker can rely on it (isolates server-side persistence
102+ // from the browser read path).
103+ const listed = yield* client.oauth.listClients();
104+ const atlassianRow = listed.find((c) => String(c.slug) === String(atlassianSlug));
105+ expect(
106+ atlassianRow?.origin.integration && String(atlassianRow.origin.integration),
107+ "the manually-registered app records its integration association",
108+ ).toBe(String(mcpSlug));
109+
110+ // The Atlassian MCP source as the add flow would leave it: OAuth only.
111+ yield* client.mcp.addServer({
112+ payload: {
113+ transport: "remote",
114+ name: `Atlassian MCP ${id}`,
115+ endpoint: server.endpoint,
116+ slug: mcpSlug,
117+ authenticationTemplate: [{ kind: "oauth2" }],
118+ },
119+ });
120+
121+ yield* browser.session(identity, async ({ page, step }) => {
122+ await step("Open the Atlassian MCP source's connect modal", async () => {
123+ await page.goto(`/integrations/${mcpSlug}`, { waitUntil: "networkidle" });
124+ await page.getByRole("button", { name: "Add connection" }).first().click();
125+ // OAuth is the only method; transparent DCR shows the Connect CTA.
126+ await page.getByRole("button", { name: "Connect", exact: true }).waitFor();
127+ });
128+
129+ await step("DCR falls back to the bring-your-own app picker", async () => {
130+ await page.getByRole("button", { name: "Connect", exact: true }).click();
131+ // The probe finds no OAuth metadata and DCR falls back to the app
132+ // step. Either register affordance ("Register a new app" when an app
133+ // matched, "Register app" in the empty state) means we're past DCR.
134+ await page
135+ .getByRole("button", { name: /^Register (app|a new app)$/ })
136+ .first()
137+ .waitFor({ timeout: 30_000 });
138+ });
139+
140+ await step("The picker lists the Atlassian app, not the Datadog one", async () => {
141+ const dialog = page.getByRole("dialog");
142+ expect(
143+ await dialog.getByText(atlassianHost).count(),
144+ `the app registered for this integration (${atlassianHost}) is offered`,
145+ ).toBeGreaterThan(0);
146+ expect(
147+ await dialog.getByText(datadogHost).count(),
148+ `a Datadog OAuth app (${datadogHost}) must not leak into Atlassian's picker`,
149+ ).toBe(0);
150+ });
151+ });
152+ }),
153+ // Never leak the source or the apps into the shared selfhost tenant.
154+ Effect.gen(function* () {
155+ yield* client.mcp.removeServer({ params: { slug: mcpSlug } }).pipe(Effect.ignore);
156+ yield* client.oauth
157+ .removeClient({ params: { slug: datadogSlug }, payload: { owner: "user" } })
158+ .pipe(Effect.ignore);
159+ yield* client.oauth
160+ .removeClient({ params: { slug: atlassianSlug }, payload: { owner: "user" } })
161+ .pipe(Effect.ignore);
162+ }),
163+ );
164+ }),
165+ ),
166+);
packages/core/api/src/handlers/oauth.ts+1−0
@@ -103,6 +103,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 102 unmodified lines
103103 clientId: payload.clientId,
104104 clientSecret: payload.clientSecret,
105105 resource: payload.resource ?? null,
106+ origin: { kind: "manual", integration: payload.originIntegration ?? null },
106107 });
107108 return { client };
108109 }),
packages/core/api/src/oauth/api.ts+8−1
@@ -65,6 +65,10 @@ const CreateClientPayload = Schema.Struct({ · 64 unmodified lines
6565 clientId: Schema.String,
6666 clientSecret: Schema.String,
6767 resource: Schema.optional(Schema.NullOr(Schema.String)),
68+ /** Integration this app is being registered for, recorded on the client so the
69+ * connect picker can surface it for that integration (mirrors the DCR path).
70+ * A manual app can still be reused across integrations. */
71+ originIntegration: Schema.optional(Schema.NullOr(IntegrationSlug)),
6872 });
6973
7074 const CreateClientResponse = Schema.Struct({
@@ -111,7 +115,10 @@ const OAuthClientSummaryResponse = Schema.Struct({ · 40 unmodified lines
111115 resource: Schema.optional(Schema.NullOr(Schema.String)),
112116 clientId: Schema.String,
113117 origin: Schema.Union([
114− Schema.Struct({ kind: Schema.Literal("manual") }),
118+ Schema.Struct({
119+ kind: Schema.Literal("manual"),
120+ integration: Schema.optional(Schema.NullOr(IntegrationSlug)),
121+ }),
115122 Schema.Struct({
116123 kind: Schema.Literal("dynamic_client_registration"),
117124 integration: Schema.optional(Schema.NullOr(IntegrationSlug)),
packages/core/sdk/src/core-tools.ts+4−1
@@ -221,7 +221,10 @@ const OAuthClientOutput = Schema.Struct({ · 220 unmodified lines
221221 resource: Schema.optional(Schema.NullOr(Schema.String)),
222222 clientId: Schema.String,
223223 origin: Schema.Union([
224− Schema.Struct({ kind: Schema.Literal("manual") }),
224+ Schema.Struct({
225+ kind: Schema.Literal("manual"),
226+ integration: Schema.optional(Schema.NullOr(Schema.String)),
227+ }),
225228 Schema.Struct({
226229 kind: Schema.Literal("dynamic_client_registration"),
227230 integration: Schema.optional(Schema.NullOr(Schema.String)),
packages/core/sdk/src/index.ts+2−0
@@ -228,7 +228,9 @@ export { · 227 unmodified lines
228228 type OAuthGrant,
229229 type OAuthAuthentication,
230230 type OAuthClient,
231+ type OAuthClientOrigin,
231232 type OAuthClientSummary,
233+ oauthClientOriginIntegration,
232234 type CreateOAuthClientInput,
233235 type RegisterDynamicClientInput,
234236 type ConnectResult,
packages/core/sdk/src/oauth-client.ts+16−1
@@ -56,12 +56,27 @@ export interface OAuthClient { · 55 unmodified lines
5656 }
5757
5858 export type OAuthClientOrigin =
59− | { readonly kind: "manual" }
59+ | {
60+ readonly kind: "manual";
61+ /** Integration this app was registered FOR, when known. A user can reuse
62+ * one app across integrations, so this is a hint (the integration whose
63+ * connect flow created it), not an exclusive binding. Used by the connect
64+ * picker to surface this app for its integration even when the integration
65+ * declares no static endpoints to match on (e.g. an MCP source). */
66+ readonly integration?: IntegrationSlug | null;
67+ }
6068 | {
6169 readonly kind: "dynamic_client_registration";
6270 readonly integration?: IntegrationSlug | null;
6371 };
6472
73+/** The integration an OAuth app was registered for, from either origin kind
74+ * (null when unknown). Centralizes the "which integration owns this app" read so
75+ * the picker and the duplicate-DCR guard agree. */
76+export const oauthClientOriginIntegration = (
77+ origin: OAuthClientOrigin,
78+): IntegrationSlug | null => origin.integration ?? null;
79+
6580 export type CreateOAuthClientInput = OAuthClient & {
6681 readonly origin?: OAuthClientOrigin;
6782 };
packages/core/sdk/src/oauth-service.ts+6−14
@@ -229,14 +229,10 @@ const parseOAuthClientOrigin = (row: { · 228 unmodified lines
229229 readonly origin_kind?: unknown;
230230 readonly origin_integration?: unknown;
231231 }): OAuthClientOrigin => {
232+ const integration =
233+ row.origin_integration == null ? null : IntegrationSlug.make(String(row.origin_integration));
232234 if (row.origin_kind === "dynamic_client_registration") {
233− return {
234− kind: "dynamic_client_registration",
235− integration:
236− row.origin_integration == null
237− ? null
238− : IntegrationSlug.make(String(row.origin_integration)),
239− };
235+ return { kind: "dynamic_client_registration", integration };
240236 }
241237 const slug = row.slug == null ? "" : String(row.slug);
242238 const resource = row.resource == null ? "" : String(row.resource);
@@ -246,9 +242,9 @@ const parseOAuthClientOrigin = (row: { · 3 unmodified lines
246242 /(^|[-_])mcp($|[-_])/.test(slug) &&
247243 /(^|\/)mcp($|[/?#])/.test(resource)
248244 ) {
249− return { kind: "dynamic_client_registration", integration: null };
245+ return { kind: "dynamic_client_registration", integration };
250246 }
251− return { kind: "manual" };
247+ return { kind: "manual", integration };
252248 };
253249
254250 interface LoadedOAuthClient {
@@ -404,11 +400,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 149 unmodified lines
404400 resource: input.resource ?? null,
405401 origin_kind: input.origin?.kind ?? "manual",
406402 origin_integration:
407− input.origin?.kind === "dynamic_client_registration"
408− ? input.origin.integration == null
409− ? null
410− : String(input.origin.integration)
411− : null,
403+ input.origin?.integration == null ? null : String(input.origin.integration),
412404 created_at: now,
413405 }),
414406 );
packages/core/sdk/src/shared.ts+2−0
@@ -106,7 +106,9 @@ export { · 105 unmodified lines
106106 type OAuthGrant,
107107 type OAuthAuthentication,
108108 type OAuthClient,
109+ type OAuthClientOrigin,
109110 type OAuthClientSummary,
111+ oauthClientOriginIntegration,
110112 type CreateOAuthClientInput,
111113 type RegisterDynamicClientInput,
112114 type ConnectResult,
packages/react/src/api/atoms.tsx+2−1
@@ -460,6 +460,7 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( · 459 unmodified lines
460460 readonly grant: OAuthGrant;
461461 readonly clientId: string;
462462 readonly resource?: string | null;
463+ readonly originIntegration?: IntegrationSlug | null;
463464 };
464465 },
465466 ) =>
@@ -472,7 +473,7 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( · 6 unmodified lines
472473 tokenUrl: arg.payload.tokenUrl,
473474 resource: arg.payload.resource ?? null,
474475 clientId: arg.payload.clientId,
475− origin: { kind: "manual" },
476+ origin: { kind: "manual", integration: arg.payload.originIntegration ?? null },
476477 };
477478 const index = rows.findIndex(
478479 (client) => client.owner === summary.owner && client.slug === summary.slug,
packages/react/src/components/add-account-modal.test.ts+100−5
@@ -232,7 +232,7 @@ describe("runDcrConnect", () => { · 231 unmodified lines
232232 discoveryUrl: "https://mcp.example.com/mcp",
233233 owner: "user",
234234 integrationName: "Linear MCP",
235− existingSlugs: [],
235+ existingClients: [],
236236 redirectUri: "https://localhost:5394/api/oauth/callback",
237237 integration: TEST_INTEGRATION,
238238 },
@@ -259,6 +259,101 @@ describe("runDcrConnect", () => { · 20 unmodified lines
259259 expect(startArgs!.owner).toBe("user");
260260 });
261261
262+ it("reuses an existing client minted for this integration instead of minting a duplicate", async () => {
263+ // Regression guard for "Datadog, Datadog 2, … Datadog 10": a prior DCR client
264+ // for THIS integration (same owner, same token host) must be reused, so a
265+ // second connect runs only `start` — never `register`.
266+ const calls: string[] = [];
267+ let startArgs: StartArgs | null = null;
268+ const outcome = await runDcrConnect(
269+ {
270+ probe: (): Promise<ProbeResult | null> => {
271+ calls.push("probe");
272+ return Promise.resolve({
273+ authorizationUrl: "https://auth.example.com/authorize",
274+ tokenUrl: "https://auth.example.com/token",
275+ registrationEndpoint: "https://auth.example.com/register",
276+ });
277+ },
278+ register: (args: RegisterArgs): Promise<OAuthClientSlug | null> => {
279+ calls.push("register");
280+ return Promise.resolve(args.slug);
281+ },
282+ start: (args: StartArgs): void => {
283+ calls.push("start");
284+ startArgs = args;
285+ },
286+ },
287+ {
288+ discoveryUrl: "https://mcp.example.com/mcp",
289+ owner: "user",
290+ integrationName: "Linear MCP",
291+ existingClients: [
292+ {
293+ owner: "user",
294+ slug: OAuthClientSlug.make("linear-mcp"),
295+ grant: "authorization_code",
296+ authorizationUrl: "https://auth.example.com/authorize",
297+ tokenUrl: "https://auth.example.com/token",
298+ clientId: "minted-earlier",
299+ origin: { kind: "dynamic_client_registration", integration: TEST_INTEGRATION },
300+ },
301+ ],
302+ integration: TEST_INTEGRATION,
303+ },
304+ );
305+
306+ expect(outcome.kind).toBe("started");
307+ // Probed (to confirm the same server), then reused — NO second registration.
308+ expect(calls).toEqual(["probe", "start"]);
309+ expect(String(startArgs!.client)).toBe("linear-mcp");
310+ });
311+
312+ it("does not reuse a client belonging to a DIFFERENT integration", async () => {
313+ // A Datadog DCR client must never be reused for an Atlassian connect, even
314+ // though both went through DCR — provenance is keyed by integration.
315+ const calls: string[] = [];
316+ await runDcrConnect(
317+ {
318+ probe: (): Promise<ProbeResult | null> =>
319+ Promise.resolve({
320+ authorizationUrl: "https://auth.atlassian.com/authorize",
321+ tokenUrl: "https://auth.atlassian.com/token",
322+ registrationEndpoint: "https://auth.atlassian.com/register",
323+ }),
324+ register: (args: RegisterArgs): Promise<OAuthClientSlug | null> => {
325+ calls.push("register");
326+ return Promise.resolve(args.slug);
327+ },
328+ start: (): void => {
329+ calls.push("start");
330+ },
331+ },
332+ {
333+ discoveryUrl: "https://mcp.atlassian.com/mcp",
334+ owner: "user",
335+ integrationName: "Atlassian",
336+ existingClients: [
337+ {
338+ owner: "user",
339+ slug: OAuthClientSlug.make("datadog"),
340+ grant: "authorization_code",
341+ authorizationUrl: "https://app.datadoghq.com/oauth2/authorize",
342+ tokenUrl: "https://api.datadoghq.com/oauth2/token",
343+ clientId: "datadog-client",
344+ origin: {
345+ kind: "dynamic_client_registration",
346+ integration: IntegrationSlug.make("datadog"),
347+ },
348+ },
349+ ],
350+ integration: TEST_INTEGRATION,
351+ },
352+ );
353+ // No Atlassian client exists yet → it mints a fresh one, not reuse Datadog.
354+ expect(calls).toContain("register");
355+ });
356+
262357 it("prefers declared scopes over probed scopes when present", async () => {
263358 let registerArgs: RegisterArgs | null = null;
264359 const outcome = await runDcrConnect(
@@ -280,7 +375,7 @@ describe("runDcrConnect", () => { · 15 unmodified lines
280375 discoveryUrl: "https://mcp.example.com/mcp",
281376 owner: "user",
282377 integrationName: "App",
283− existingSlugs: [],
378+ existingClients: [],
284379 declaredScopes: ["declared.scope"],
285380 integration: TEST_INTEGRATION,
286381 },
@@ -313,7 +408,7 @@ describe("runDcrConnect", () => { · 26 unmodified lines
313408 discoveryUrl: "https://mcp.example.com/mcp",
314409 owner: "user",
315410 integrationName: "App",
316− existingSlugs: [],
411+ existingClients: [],
317412 integration: TEST_INTEGRATION,
318413 },
319414 );
@@ -344,7 +439,7 @@ describe("runDcrConnect", () => { · 24 unmodified lines
344439 discoveryUrl: "https://mcp.example.com/mcp",
345440 owner: "user",
346441 integrationName: "App",
347− existingSlugs: [],
442+ existingClients: [],
348443 integration: TEST_INTEGRATION,
349444 },
350445 );
@@ -374,7 +469,7 @@ describe("runDcrConnect", () => { · 23 unmodified lines
374469 discoveryUrl: "https://mcp.example.com/mcp",
375470 owner: "user",
376471 integrationName: "App",
377− existingSlugs: [],
472+ existingClients: [],
378473 integration: TEST_INTEGRATION,
379474 },
380475 );
packages/react/src/components/add-account-modal.tsx+34−12
@@ -42,6 +42,7 @@ import { oauthCallbackUrl, useOAuthPopupFlow } from "../plugins/oauth-sign-in"; · 41 unmodified lines
4242 import {
4343 clientDisplayName,
4444 clientHost,
45+ getRootDomain,
4546 uniqueClientSlug,
4647 useOAuthClientsForIntegration,
4748 type OAuthClientOption,
@@ -471,8 +472,10 @@ type RunDcrConnectInput = { · 423 unmodified lines
471472 readonly discoveryUrl: string;
472473 readonly owner: Owner;
473474 readonly integrationName: string;
474− /** The owner's existing client slugs, so the minted slug stays unique. */
475− readonly existingSlugs: readonly string[];
475+ /** The owner's existing apps (UNFILTERED). Used both to keep a minted slug
476+ * unique across all apps AND to reuse a client already minted for this
477+ * integration instead of minting a duplicate on every connect. */
478+ readonly existingClients: readonly OAuthClientOption[];
476479 /** Scopes declared by the integration's method (override the probed ones). */
477480 readonly declaredScopes?: readonly string[];
478481 /** Browser-facing callback URL registered with DCR when available. */
@@ -504,7 +507,28 @@ export async function runDcrConnect( · 25 unmodified lines
504507 const registrationEndpoint = probe.registrationEndpoint;
505508 if (!registrationEndpoint) return { kind: "fallback", reason: "no-registration-endpoint" };
506509
507− const slug = uniqueClientSlug(input.integrationName, input.existingSlugs);
510+ // Reuse a client already minted for THIS integration (same owner, same
511+ // discovered token host) instead of minting a duplicate every connect — the
512+ // cause of "Datadog", "Datadog 2", … "Datadog 10" piling up. A fresh probe of
513+ // the same server yields the same endpoints, so an existing DCR client is
514+ // still valid; we re-run only the authorize/token flow with it.
515+ const probedTokenRoot = getRootDomain(probe.tokenUrl);
516+ const reusable = input.existingClients.find(
517+ (app) =>
518+ app.owner === input.owner &&
519+ app.origin?.integration != null &&
520+ String(app.origin.integration) === String(input.integration) &&
521+ getRootDomain(app.tokenUrl) === probedTokenRoot,
522+ );
523+ if (reusable) {
524+ deps.start({ client: reusable.slug, owner: input.owner });
525+ return { kind: "started" };
526+ }
527+
528+ const slug = uniqueClientSlug(
529+ input.integrationName,
530+ input.existingClients.map((app) => String(app.slug)),
531+ );
508532 const scopes =
509533 input.declaredScopes && input.declaredScopes.length > 0
510534 ? input.declaredScopes
@@ -827,7 +851,9 @@ function AddAccountModalView(props: AddAccountModalProps) { · 316 unmodified lines
827851 loading: oauthLoading,
828852 endpointMatched: oauthEndpointMatched,
829853 displayRegisterCTA: oauthDisplayRegisterCTA,
854+ allClients: oauthAllClients,
830855 } = useOAuthClientsForIntegration({
856+ integration,
831857 tokenUrl: method?.oauth?.tokenUrl,
832858 authorizationUrl: method?.oauth?.authorizationUrl,
833859 });
@@ -1143,9 +1169,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 309 unmodified lines
11431169 discoveryUrl,
11441170 owner: dcrOwner,
11451171 integrationName,
1146− existingSlugs: [...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>
1147− String(app.slug),
1148− ),
1172+ existingClients: oauthAllClients,
11491173 declaredScopes: method.oauth?.scopes,
11501174 redirectUri: oauthCallbackUrl(),
11511175 integration,
@@ -1201,9 +1225,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 49 unmodified lines
12011225 <div className="px-5 py-5">
12021226 <OAuthClientForm
12031227 integrationName={integrationName}
1204− existingSlugs={[...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>
1205− String(app.slug),
1206− )}
1228+ originIntegration={integration}
1229+ existingSlugs={oauthAllClients.map((app: OAuthClientOption) => String(app.slug))}
12071230 fixedSlug={editingClient.slug}
12081231 fixedOwner={editingClient.owner}
12091232 prefill={{
@@ -1230,9 +1253,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 20 unmodified lines
12301253 <div className="px-5 py-5">
12311254 <OAuthClientForm
12321255 integrationName={integrationName}
1233− existingSlugs={[...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>
1234− String(app.slug),
1235− )}
1256+ originIntegration={integration}
1257+ existingSlugs={oauthAllClients.map((app: OAuthClientOption) => String(app.slug))}
12361258 prefill={{
12371259 authorizationUrl:
12381260 oauthHandoffPrefill?.authorizationUrl ?? method.oauth?.authorizationUrl,
packages/react/src/components/oauth-client-form.tsx+13−1
@@ -1,7 +1,12 @@
11 import { useMemo, useState } from "react";
22 import { useAtomSet } from "@effect/atom-react";
33 import * as Exit from "effect/Exit";
4−import { OAuthClientSlug, type OAuthGrant, type Owner } from "@executor-js/sdk/shared";
4+import {
5+ type IntegrationSlug,
6+ OAuthClientSlug,
7+ type OAuthGrant,
8+ type Owner,
9+} from "@executor-js/sdk/shared";
510 import { toast } from "sonner";
611
712 import { createOAuthClientOptimistic, probeOAuth, registerDynamicOAuthClient } from "../api/atoms";
@@ -53,6 +58,10 @@ export interface OAuthClientFormPrefill { · 45 unmodified lines
5358 export function OAuthClientForm(props: {
5459 /** Human label for the integration this app backs (used in toasts + default name). */
5560 readonly integrationName: string;
61+ /** Slug of the integration this app is registered for. Recorded on the client
62+ * (`origin.integration`) so the connect picker surfaces it for this integration
63+ * even when the integration declares no static endpoints (e.g. an MCP source). */
64+ readonly originIntegration?: IntegrationSlug;
5665 /** Existing client slugs, so the generated slug stays unique across apps. */
5766 readonly existingSlugs: readonly string[];
5867 /** Endpoints/scopes declared by the integration's OAuth method. */
@@ -71,6 +80,7 @@ export function OAuthClientForm(props: { · 12 unmodified lines
7180 }) {
7281 const {
7382 integrationName,
83+ originIntegration,
7484 existingSlugs,
7585 prefill,
7686 fixedSlug,
@@ -195,6 +205,7 @@ export function OAuthClientForm(props: { · 118 unmodified lines
195205 tokenEndpointAuthMethodsSupported: authMethods,
196206 clientName: name.trim(),
197207 redirectUri: oauthCallbackUrl(),
208+ originIntegration: originIntegration ?? null,
198209 },
199210 reactivityKeys: oauthClientWriteKeys,
200211 });
@@ -223,6 +234,7 @@ export function OAuthClientForm(props: { · 22 unmodified lines
223234 clientId: clientId.trim(),
224235 clientSecret: clientSecret.trim(),
225236 resource,
237+ originIntegration: originIntegration ?? null,
226238 },
227239 reactivityKeys: oauthClientWriteKeys,
228240 });
packages/react/src/plugins/use-effective-oauth-client.tsx+66−23
@@ -1,6 +1,11 @@
11 import { useAtomValue } from "@effect/atom-react";
22 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3−import { OAuthClientSlug, type Owner } from "@executor-js/sdk/shared";
3+import {
4+ type IntegrationSlug,
5+ OAuthClientSlug,
6+ type OAuthClientOrigin,
7+ type Owner,
8+} from "@executor-js/sdk/shared";
49 import { getDomain } from "tldts";
510
611 import { oauthClientsOptimisticAtom } from "../api/atoms";
@@ -10,11 +15,16 @@ import { oauthClientsOptimisticAtom } from "../api/atoms"; · 3 unmodified lines
1015 //
1116 // An owner can register MANY apps; each is a distinct owner-scoped `oauth_client`
1217 // row with its own slug. The connect flow lists the apps usable for an
13−// integration (owner-visible clients whose OAuth endpoints share a registrable
14−// root domain with the integration's declared endpoints when known) and lets the
15−// user pick one or register a new one. User-owned apps are listed before
16−// workspace ones. When NOTHING matches a declared endpoint, the picker shows an
17−// empty state + a "register an app" CTA rather than unrelated providers' apps.
18+// integration and lets the user pick one or register a new one. An app is usable
19+// for an integration when EITHER:
20+// - it was registered for that integration (`origin.integration`), OR
21+// - its OAuth endpoints share a registrable root domain with the integration's
22+// declared endpoints (so one app can be reused across sibling integrations).
23+// User-owned apps are listed before workspace ones. When the integration declares
24+// no endpoints AND owns no app (e.g. an MCP source whose endpoints are discovered
25+// at connect time), the picker shows an empty state + a "register an app" CTA
26+// rather than every unrelated provider's app — that broad leak was the bug behind
27+// "Datadog apps showing under Atlassian".
1828 // ---------------------------------------------------------------------------
1929
2030 export interface OAuthClientOption {
@@ -24,6 +34,10 @@ export interface OAuthClientOption { · 3 unmodified lines
2434 readonly authorizationUrl: string;
2535 readonly tokenUrl: string;
2636 readonly clientId: string;
37+ /** Provenance — `origin.integration` is the integration this app was registered
38+ * for (DCR or manual), which the picker uses to surface the app for that
39+ * integration even when no static endpoints are declared to match on. */
40+ readonly origin?: OAuthClientOrigin;
2741 }
2842
2943 const hostOf = (url: string): string | undefined => {
@@ -39,12 +53,18 @@ const hostOf = (url: string): string | undefined => { · 9 unmodified lines
3953 * `accounts.google.com` → `google.com`. Falls back to the full host for
4054 * localhost / IP literals (where `tldts.getDomain` returns null) so local-dev
4155 * MCP servers still match by exact host. Returns undefined for unparseable URLs. */
42−const getRootDomain = (url: string): string | undefined => {
56+export const getRootDomain = (url: string): string | undefined => {
4357 const root = getDomain(url);
4458 if (root) return root.toLowerCase();
4559 return hostOf(url);
4660 };
4761
62+/** The integration an app was registered for, from either origin kind (or
63+ * undefined). Used to surface an app for its integration even when no static
64+ * endpoints are declared to match on. */
65+const appIntegration = (app: OAuthClientOption): string | undefined =>
66+ app.origin?.integration == null ? undefined : String(app.origin.integration);
67+
4868 export interface UseOAuthClientsResult {
4969 /** Apps usable for this integration, user-owned first. When an endpoint was
5070 * declared but nothing matched, this is EMPTY (the unmatched apps move to
@@ -73,6 +93,10 @@ export interface UseOAuthClientsResult { · 22 unmodified lines
7393 * an endpoint was declared and nothing matched. Equals `!endpointMatched`
7494 * once loaded. */
7595 readonly displayRegisterCTA: boolean;
96+ /** Every owner-visible app, UNFILTERED. Callers that need the whole catalog
97+ * (slug-collision avoidance when minting, duplicate-DCR reuse detection) read
98+ * this rather than the integration-filtered `clients`. */
99+ readonly allClients: readonly OAuthClientOption[];
76100 }
77101
78102 /** Sort apps user-owned first (so the user's own apps surface before shared
@@ -83,44 +107,59 @@ const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClient · 4 unmodified lines
83107 );
84108
85109 /**
86− * Pure matcher (no React/atoms) — split owner-visible apps into the ones that
87− * match the integration's declared OAuth endpoints and the ones that don't.
110+ * Pure matcher (no React/atoms) — split owner-visible apps into the ones usable
111+ * for an integration and the ones that aren't.
88112 *
89− * Matching is by REGISTRABLE ROOT DOMAIN ("tld+1"). When the integration
90− * declares a token endpoint, an app must match by token endpoint root; the token
91− * endpoint is what the SDK will call during code exchange/refresh and avoids
92− * authorize-root coincidences. When only an authorization endpoint is declared,
93− * the authorize root is used as the fallback compatibility signal. Unrelated
94− * providers (different root) never match.
113+ * An app is usable when EITHER signal holds:
114+ * - INTEGRATION: it was registered for this integration (`origin.integration`
115+ * equals `integration`). This is the only signal an MCP source has, since its
116+ * OAuth endpoints are discovered at connect time and never declared.
117+ * - ENDPOINT: the integration declares an endpoint and the app's endpoints share
118+ * a REGISTRABLE ROOT DOMAIN ("tld+1"). The token endpoint is preferred (it is
119+ * what the SDK calls during code exchange/refresh, avoiding authorize-root
120+ * coincidences); the authorize root is the fallback when only it is declared.
121+ * This lets one app be reused across sibling integrations of one provider.
95122 *
96− * When the integration declares no endpoints, every app is "matched" (no filter).
123+ * When NEITHER an integration nor any endpoint is known (no context at all), the
124+ * filter cannot discriminate and every app is "matched" (legacy fall-through).
125+ * Crucially, when an integration IS known but declares no endpoints, apps are
126+ * matched by integration only — unrelated providers' apps no longer leak in.
97127 */
98128 export function selectClientsForEndpoints(
99129 all: readonly OAuthClientOption[],
100− endpoints: { readonly tokenUrl?: string; readonly authorizationUrl?: string },
130+ endpoints: {
131+ readonly integration?: string;
132+ readonly tokenUrl?: string;
133+ readonly authorizationUrl?: string;
134+ },
101135 ): {
102136 readonly matched: readonly OAuthClientOption[];
103137 readonly unmatched: readonly OAuthClientOption[];
104138 readonly endpointMatched: boolean;
105139 } {
140+ const wantedIntegration = endpoints.integration;
106141 const wantedTokenRoot = endpoints.tokenUrl ? getRootDomain(endpoints.tokenUrl) : undefined;
107142 const wantedAuthorizationRoot = endpoints.authorizationUrl
108143 ? getRootDomain(endpoints.authorizationUrl)
109144 : undefined;
110− // No declared endpoints → no filter; every app is usable.
111− if (!wantedTokenRoot && !wantedAuthorizationRoot) {
145+ // No discriminating signal at all → no filter; every app is usable.
146+ if (!wantedIntegration && !wantedTokenRoot && !wantedAuthorizationRoot) {
112147 return { matched: sortUserFirst(all), unmatched: [], endpointMatched: true };
113148 }
114149 const matched: OAuthClientOption[] = [];
115150 const unmatched: OAuthClientOption[] = [];
116151 for (const app of all) {
152+ const fitsIntegration =
153+ wantedIntegration !== undefined && appIntegration(app) === wantedIntegration;
117154 const appTokenRoot = getRootDomain(app.tokenUrl);
118155 const appAuthorizationRoot = getRootDomain(app.authorizationUrl);
119− const fits = wantedTokenRoot
156+ const fitsEndpoint = wantedTokenRoot
120157 ? appTokenRoot === wantedTokenRoot
121− : appAuthorizationRoot === wantedAuthorizationRoot ||
122− appTokenRoot === wantedAuthorizationRoot;
123− if (fits) matched.push(app);
158+ : wantedAuthorizationRoot
159+ ? appAuthorizationRoot === wantedAuthorizationRoot ||
160+ appTokenRoot === wantedAuthorizationRoot
161+ : false;
162+ if (fitsIntegration || fitsEndpoint) matched.push(app);
124163 else unmatched.push(app);
125164 }
126165 return {
@@ -131,6 +170,7 @@ export function selectClientsForEndpoints( · 4 unmodified lines
131170 }
132171
133172 export function useOAuthClientsForIntegration(opts: {
173+ readonly integration?: IntegrationSlug;
134174 readonly tokenUrl?: string;
135175 readonly authorizationUrl?: string;
136176 }): UseOAuthClientsResult {
@@ -146,11 +186,13 @@ export function useOAuthClientsForIntegration(opts: { · 9 unmodified lines
146186 loading: true,
147187 endpointMatched: true,
148188 displayRegisterCTA: false,
189+ allClients: [],
149190 };
150191 }
151192
152193 const all = clientsResult.value as readonly OAuthClientOption[];
153194 const { matched, unmatched, endpointMatched } = selectClientsForEndpoints(all, {
195+ integration: opts.integration ? String(opts.integration) : undefined,
154196 tokenUrl: opts.tokenUrl,
155197 authorizationUrl: opts.authorizationUrl,
156198 });
@@ -165,6 +207,7 @@ export function useOAuthClientsForIntegration(opts: { · 8 unmodified lines
165207 loading: false,
166208 endpointMatched,
167209 displayRegisterCTA: !endpointMatched,
210+ allClients: all,
168211 };
169212 }
170213

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t