History

Show an actionable message when MCP OAuth dynamic registration is rejected (#1121)

* fix(oauth): actionable message when DCR rejects a non-loopback redirect URI Authorization servers that follow RFC 8252 strictly (e.g. Vercel MCP) only approve loopback redirect URIs for anonymous Dynamic Client Registration. Executor registers its browser origin, so a hosted, tailnet, or LAN origin trips invalid_redirect_uri. Map that opaque code to guidance: name the loopback-only requirement, the offending URI, and the two recovery paths. Gated on the redirect URI being non-loopback so we never tell a localhost user to use localhost. Adds an approveRedirectUri option to the OAuth test server to mimic the loopback-only allowlist. Goal: 001-fix-770-vercel-mcp-dcr-fallback (deliverable 1) * fix(react): surface the DCR rejection reason in the BYO fallback When transparent Dynamic Client Registration fails, runDcrConnect now returns the server's failure message in the fallback outcome instead of swallowing it, and the Add MCP Source / add-account modal shows that message (e.g. the loopback redirect-URI rejection) over the generic 'register an app' copy. The register dep widens to return { error } on failure; the OAuth start is skipped. Also narrows the new SDK test's error reads to the typed OAuthRegisterDynamicError and repositions the boundary lint directive. Goal: 001-fix-770-vercel-mcp-dcr-fallback (deliverable 2) ---------

Jack Lau committed 4 months ago

Commit 5250f641 parent 0de01066 files · +195 −15Browse files

Unified · Split

6 files
  1. packages/core/sdk/src/oauth-helpers.ts +1 −1
  2. packages/core/sdk/src/oauth-register-dynamic.test.ts +87 −1
  3. packages/core/sdk/src/oauth-service.ts +19 −7
  4. packages/core/sdk/src/testing/oauth-test-server.ts +15 −0
  5. packages/react/src/components/add-account-modal.test.ts +43 −0
  6. packages/react/src/components/add-account-modal.tsx +30 −6
packages/core/sdk/src/oauth-helpers.ts+1−1
@@ -61,7 +61,7 @@ export interface OAuthEndpointUrlPolicy { · 60 unmodified lines
6161 readonly allowHttp?: boolean;
6262 }
6363
64−const isLoopbackHttpUrl = (value: string): boolean => {
64+export const isLoopbackHttpUrl = (value: string): boolean => {
6565 if (!URL.canParse(value)) return false;
6666 const url = new URL(value);
6767 if (url.protocol !== "http:") return false;
packages/core/sdk/src/oauth-register-dynamic.test.ts+87−1
@@ -1,5 +1,5 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { Effect } from "effect";
2+import { Effect, Predicate } from "effect";
33
44 import {
55 AuthTemplateSlug,
@@ -8,6 +8,7 @@ import { · 2 unmodified lines
88 OAuthClientSlug,
99 ToolName,
1010 } from "./ids";
11+import { OAuthRegisterDynamicError } from "./oauth-client";
1112 import { definePlugin } from "./plugin";
1213 import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
1314 import { serveOAuthTestServer } from "./testing/oauth-test-server";
@@ -143,4 +144,89 @@ describe("oauth.registerDynamicClient", () => { · 129 unmodified lines
143144 }),
144145 ),
145146 );
147+
148+ // Regression: issue #770. Vercel (and other RFC 8252-strict servers) only
149+ // approve loopback redirect URIs for anonymous DCR, so a hosted/tailnet/LAN
150+ // origin trips `invalid_redirect_uri`. The failure must explain the loopback
151+ // requirement and name the offending URI, not dump the raw RFC code.
152+ it.effect("DCR rejection of a non-loopback redirect URI yields an actionable loopback hint", () =>
153+ Effect.scoped(
154+ Effect.gen(function* () {
155+ const server = yield* serveOAuthTestServer({
156+ scopes: ["read"],
157+ // Mirror Vercel: approve only loopback redirect URIs for anonymous DCR.
158+ approveRedirectUri: (uri) =>
159+ uri.startsWith("http://localhost") || uri.startsWith("http://127."),
160+ });
161+ const { executor } = yield* makeTestWorkspaceHarness({ plugins });
162+ yield* executor.acme.seed();
163+ const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
164+
165+ const nonLoopback = "https://app.example.com/api/oauth/callback";
166+ const error = yield* Effect.flip(
167+ executor.oauth.registerDynamicClient({
168+ owner: "org",
169+ slug: CLIENT,
170+ registrationEndpoint: probe.registrationEndpoint!,
171+ authorizationUrl: probe.authorizationUrl,
172+ tokenUrl: probe.tokenUrl,
173+ resource: probe.resource,
174+ scopes: ["read"],
175+ tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
176+ clientName: "Acme DCR",
177+ redirectUri: nonLoopback,
178+ originIntegration: INTEG,
179+ }),
180+ );
181+ // Predicate guard narrows the union so `.message` reads off a typed failure.
182+ expect(Predicate.isTagged("OAuthRegisterDynamicError")(error)).toBe(true);
183+ const registerError = error as OAuthRegisterDynamicError;
184+ const message = registerError.message;
185+ // Names the loopback-only requirement, the localhost fix, and the URI.
186+ expect(message).toContain("loopback");
187+ expect(message).toContain("http://localhost");
188+ expect(message).toContain(nonLoopback);
189+ }),
190+ ),
191+ );
192+
193+ // The loopback hint is gated on the redirect URI actually being non-loopback.
194+ // A server that rejects even a loopback URI keeps the generic message so we
195+ // never tell the user "use localhost" when they already are.
196+ it.effect(
197+ "DCR rejection of a loopback redirect URI keeps the generic message (no false hint)",
198+ () =>
199+ Effect.scoped(
200+ Effect.gen(function* () {
201+ const server = yield* serveOAuthTestServer({
202+ scopes: ["read"],
203+ approveRedirectUri: () => false, // reject every redirect URI, even loopback
204+ });
205+ const { executor } = yield* makeTestWorkspaceHarness({ plugins });
206+ yield* executor.acme.seed();
207+ const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
208+
209+ const error = yield* Effect.flip(
210+ executor.oauth.registerDynamicClient({
211+ owner: "org",
212+ slug: CLIENT,
213+ registrationEndpoint: probe.registrationEndpoint!,
214+ authorizationUrl: probe.authorizationUrl,
215+ tokenUrl: probe.tokenUrl,
216+ resource: probe.resource,
217+ scopes: ["read"],
218+ tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
219+ clientName: "Acme DCR",
220+ redirectUri: "http://127.0.0.1:5394/api/oauth/callback",
221+ originIntegration: INTEG,
222+ }),
223+ );
224+ expect(Predicate.isTagged("OAuthRegisterDynamicError")(error)).toBe(true);
225+ const registerError = error as OAuthRegisterDynamicError;
226+ const message = registerError.message;
227+ expect(message).toContain("Dynamic Client Registration failed: invalid_redirect_uri");
228+ expect(message).not.toContain("Automatic OAuth setup failed");
229+ }),
230+ ),
231+ );
146232 });
packages/core/sdk/src/oauth-service.ts+19−7
@@ -64,6 +64,7 @@ import { · 63 unmodified lines
6464 createPkceCodeVerifier,
6565 exchangeAuthorizationCode,
6666 exchangeClientCredentials,
67+ isLoopbackHttpUrl,
6768 rebindTokenEndpointHostToCallbackDomain,
6869 type OAuth2TokenResponse,
6970 type OAuthEndpointUrlPolicy,
@@ -573,13 +574,24 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 503 unmodified lines
573574 },
574575 { httpClientLayer, endpointUrlPolicy: deps.endpointUrlPolicy },
575576 ).pipe(
576− Effect.mapError(
577− (cause) =>
578− new OAuthRegisterDynamicError({
579− // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuthDiscoveryError carries a typed `message` field
580− message: `Dynamic Client Registration failed: ${cause.message}`,
581− }),
582− ),
577+ Effect.mapError((cause) => {
578+ // Some authorization servers (Vercel, and others that follow RFC 8252
579+ // strictly) reject anonymous Dynamic Client Registration unless the
580+ // redirect URI is loopback (http://localhost or http://127.0.0.1).
581+ // Executor registers its browser origin, so any hosted, tailnet, or
582+ // LAN origin trips `invalid_redirect_uri`. Turn that opaque RFC code
583+ // into guidance the user can act on instead of the raw error.
584+ // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuthDiscoveryError carries a typed `message`
585+ const rawMessage = cause.message;
586+ const message =
587+ cause.error === "invalid_redirect_uri" && !isLoopbackHttpUrl(flowRedirectUri)
588+ ? `Automatic OAuth setup failed: this server only approves loopback redirect ` +
589+ `URLs (http://localhost or http://127.0.0.1) for automatic registration, but ` +
590+ `Executor is using ${flowRedirectUri}. Register an OAuth app manually with that ` +
591+ `redirect URL approved by the server, or run Executor on http://localhost.`
592+ : `Dynamic Client Registration failed: ${rawMessage}`;
593+ return new OAuthRegisterDynamicError({ message });
594+ }),
583595 );
584596
585597 // Persist the minted client. DCR-minted public clients have no secret; we
packages/core/sdk/src/testing/oauth-test-server.ts+15−0
@@ -53,6 +53,11 @@ export interface OAuthTestServerOptions { · 52 unmodified lines
5353 readonly scopes?: readonly string[];
5454 readonly omitTokenResponseScopes?: readonly string[];
5555 readonly supportRefresh?: boolean;
56+ /** Gate Dynamic Client Registration on the requested redirect URIs. When set,
57+ * `/register` returns `400 invalid_redirect_uri` unless every requested
58+ * `redirect_uris` entry is approved. Mirrors authorization servers (e.g.
59+ * Vercel) that only accept loopback redirect URIs for anonymous DCR. */
60+ readonly approveRedirectUri?: (uri: string) => boolean;
5661 }
5762
5863 export interface OAuthTestServerShape {
@@ -503,6 +508,16 @@ export const serveOAuthTestServer = ( · 444 unmodified lines
503508 ? `secret_${randomUUID()}`
504509 : null;
505510 const redirectUris = new Set(arrayOfStrings(json.redirect_uris));
511+ if (
512+ options.approveRedirectUri &&
513+ [...redirectUris].some((uri) => !options.approveRedirectUri!(uri))
514+ ) {
515+ return oauthError(
516+ 400,
517+ "invalid_redirect_uri",
518+ "The provided redirect URIs are not approved for use by this authorization server.",
519+ );
520+ }
506521 clients.set(clientId, {
507522 clientSecret,
508523 redirectUris,
packages/react/src/components/add-account-modal.test.ts+43−0
@@ -429,4 +429,47 @@ describe("runDcrConnect", () => { · 428 unmodified lines
429429 });
430430 expect(calls).toEqual(["register"]);
431431 });
432+
433+ it("threads the register failure message into the fallback (registration-failed)", async () => {
434+ const message =
435+ "Automatic OAuth setup failed: this server only approves loopback redirect URLs " +
436+ "(http://localhost or http://127.0.0.1) for automatic registration, but Executor is " +
437+ "using https://app.example.com/api/oauth/callback. Register an OAuth app manually with " +
438+ "that redirect URL approved by the server, or run Executor on http://localhost.";
439+ let started = false;
440+ const outcome = await runDcrConnect(
441+ {
442+ probe: (): Promise<ProbeResult | null> =>
443+ Promise.resolve({
444+ authorizationUrl: "https://auth.example.com/authorize",
445+ tokenUrl: "https://auth.example.com/token",
446+ registrationEndpoint: "https://auth.example.com/register",
447+ }),
448+ register: (): Promise<{ readonly error: string }> => Promise.resolve({ error: message }),
449+ start: (): void => {
450+ started = true;
451+ },
452+ },
453+ {
454+ discoveryUrl: "https://mcp.example.com/mcp",
455+ owner: "user",
456+ integrationName: "App",
457+ existingSlugs: [],
458+ integration: TEST_INTEGRATION,
459+ },
460+ );
461+ // The redirect-URI rejection reaches the caller verbatim so the BYO fallback
462+ // can show why instead of the generic copy, and the OAuth start is skipped.
463+ expect(outcome).toEqual({
464+ kind: "fallback",
465+ reason: "registration-failed",
466+ probe: {
467+ authorizationUrl: "https://auth.example.com/authorize",
468+ tokenUrl: "https://auth.example.com/token",
469+ registrationEndpoint: "https://auth.example.com/register",
470+ },
471+ message,
472+ });
473+ expect(started).toBe(false);
474+ });
432475 });
packages/react/src/components/add-account-modal.tsx+30−6
@@ -514,14 +514,22 @@ type DcrOutcome = · 513 unmodified lines
514514 readonly kind: "fallback";
515515 readonly reason: "no-registration-endpoint" | "registration-failed";
516516 readonly probe: DcrProbeResult;
517+ /** A specific, user-facing reason to surface instead of the generic
518+ * "register an app" copy (e.g. a server that rejects the DCR redirect
519+ * URI). Absent when the fallback carries no actionable detail. */
520+ readonly message?: string;
517521 };
518522
519523 type RunDcrConnectDeps = {
520524 /** Probe the discovery URL → resolved endpoints + (maybe) a registration
521525 * endpoint. Resolves to null when the probe fails. */
522526 readonly probe: (url: string) => Promise<DcrProbeResult | null>;
523− /** Register a DCR client → the minted client slug, or null on failure. */
524− readonly register: (args: DcrRegisterArgs) => Promise<OAuthClientSlug | null>;
527+ /** Register a DCR client → the minted client slug, `{ error }` with a
528+ * user-facing message when the server rejects registration, or null on an
529+ * unexplained failure. */
530+ readonly register: (
531+ args: DcrRegisterArgs,
532+ ) => Promise<OAuthClientSlug | { readonly error: string } | null>;
525533 /** Start the OAuth flow with the minted client (popup / inline). */
526534 readonly start: (args: DcrStartArgs) => void;
527535 };
@@ -557,7 +565,9 @@ export const dcrClientNameForIntegration = (integrationName: string): string => · 29 unmodified lines
557565 *
558566 * - Probe failure → `{ kind: "fallback", reason: "probe-failed" }` (caller shows BYO).
559567 * - No registration endpoint → `{ kind: "fallback", reason: "no-registration-endpoint", probe }`.
560− * - Registration failure → `{ kind: "fallback", reason: "registration-failed", probe }`.
568+ * - Register rejected with a message → `{ kind: "fallback", reason: "registration-failed", probe, message }`
569+ * so the caller can show why (e.g. a redirect-URI rejection) over the generic copy.
570+ * - Register failed without detail (null) → `{ kind: "fallback", reason: "registration-failed", probe }`.
561571 * - Success → registers, calls `start`, returns `{ kind: "started" }`.
562572 */
563573 export async function runDcrConnect(
@@ -585,6 +595,11 @@ export async function runDcrConnect( · 21 unmodified lines
585595 originIntegration: input.integration,
586596 });
587597 if (minted === null) return { kind: "fallback", reason: "registration-failed", probe };
598+ // OAuthClientSlug is a branded string; an object return carries the failure
599+ // message (e.g. the server rejected the redirect URI) for the BYO fallback.
600+ if (typeof minted === "object") {
601+ return { kind: "fallback", reason: "registration-failed", probe, message: minted.error };
602+ }
588603 deps.start({ client: minted, owner: input.owner });
589604 return { kind: "started" };
590605 }
@@ -1179,7 +1194,9 @@ function AddAccountModalView(props: AddAccountModalProps) { · 588 unmodified lines
11791194 if (Exit.isFailure(exit)) return null;
11801195 return exit.value;
11811196 },
1182− register: async (args: DcrRegisterArgs): Promise<OAuthClientSlug | null> => {
1197+ register: async (
1198+ args: DcrRegisterArgs,
1199+ ): Promise<OAuthClientSlug | { readonly error: string } | null> => {
11831200 const exit = await doRegisterDynamic({
11841201 payload: {
11851202 owner: args.owner,
@@ -1196,7 +1213,11 @@ function AddAccountModalView(props: AddAccountModalProps) { · 10 unmodified lines
11961213 },
11971214 reactivityKeys: oauthClientWriteKeys,
11981215 });
1199− if (Exit.isFailure(exit)) return null;
1216+ if (Exit.isFailure(exit)) {
1217+ return {
1218+ error: messageFromExit(exit, "Automatic setup unavailable. Register an app instead."),
1219+ };
1220+ }
12001221 return exit.value.client;
12011222 },
12021223 start: (args: DcrStartArgs): void => {
@@ -1246,7 +1267,10 @@ function AddAccountModalView(props: AddAccountModalProps) { · 43 unmodified lines
12461267 if (outcome.kind === "fallback") {
12471268 setOAuthFallbackProbe("probe" in outcome ? outcome.probe : null);
12481269 setDcrFailed(true);
1249− toast.error("Automatic setup unavailable — register an app");
1270+ toast.error(
1271+ ("message" in outcome ? outcome.message : undefined) ??
1272+ "Automatic setup unavailable. Register an app instead.",
1273+ );
12501274 }
12511275 };
12521276