Skip to content

Commit 5250f64

Browse files
Show an actionable message when MCP OAuth dynamic registration is rejected (UsefulSoftwareCo#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) --------- Co-authored-by: Jack <72348727+Jack-GitHub12@users.noreply.github.com> Co-authored-by: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
1 parent 0de0106 commit 5250f64

6 files changed

Lines changed: 195 additions & 15 deletions

File tree

packages/core/sdk/src/oauth-helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export interface OAuthEndpointUrlPolicy {
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

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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 {
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", () => {
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

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {
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 => {
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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ export interface OAuthTestServerOptions {
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 = (
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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,4 +429,47 @@ describe("runDcrConnect", () => {
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

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -514,14 +514,22 @@ type DcrOutcome =
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 =>
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(
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) {
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) {
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) {
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

0 commit comments

Comments
 (0)