History

Use better-result across MCP OAuth flow

Rhys Sullivan committed 8 months ago

Commit 3db0cc61 parent 12386ed8 files · +177 −110Browse files

Unified · Split

8 files
  1. bun.lock +1 −0
  2. executor/apps/web/package.json +1 −0
  3. executor/apps/web/src/app/mcp/oauth/callback/route.ts +21 −9
  4. executor/apps/web/src/app/mcp/oauth/detect/route.ts +32 −19
  5. executor/apps/web/src/app/mcp/oauth/start/route.ts +21 −11
  6. executor/apps/web/src/components/tools/add-source-dialog.tsx +55 −34
  7. executor/apps/web/src/lib/mcp-oauth-popup.ts +1 −3
  8. executor/apps/web/src/lib/mcp-oauth-provider.ts +45 −34
bun.lock+1−0
@@ -92,6 +92,7 @@ · 91 unmodified lines
9292 "@streamdown/code": "^1.0.1",
9393 "@tanstack/react-query": "^5.80.7",
9494 "@workos-inc/authkit-nextjs": "^2.13.0",
95+ "better-result": "^2.7.0",
9596 "class-variance-authority": "^0.7.1",
9697 "clsx": "^2.1.1",
9798 "convex": "^1.31.7",
executor/apps/web/package.json+1−0
@@ -17,6 +17,7 @@ · 16 unmodified lines
1717 "@streamdown/code": "^1.0.1",
1818 "@tanstack/react-query": "^5.80.7",
1919 "@workos-inc/authkit-nextjs": "^2.13.0",
20+ "better-result": "^2.7.0",
2021 "class-variance-authority": "^0.7.1",
2122 "clsx": "^2.1.1",
2223 "convex": "^1.31.7",
executor/apps/web/src/app/mcp/oauth/callback/route.ts+21−9
@@ -1,5 +1,6 @@
11 import type { NextRequest } from "next/server";
22 import { NextResponse } from "next/server";
3+import { Result } from "better-result";
34 import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
45 import {
56 buildPendingCookieName,
@@ -34,6 +35,19 @@ function popupResultRedirect( · 28 unmodified lines
3435 return response;
3536 }
3637
38+function resultErrorMessage(error: unknown, fallback: string): string {
39+ const cause = typeof error === "object" && error && "cause" in error
40+ ? (error as { cause?: unknown }).cause
41+ : error;
42+ if (cause instanceof Error && cause.message.trim()) {
43+ return cause.message;
44+ }
45+ if (typeof cause === "string" && cause.trim()) {
46+ return cause;
47+ }
48+ return fallback;
49+}
50+
3751 export async function GET(request: NextRequest) {
3852 const code = request.nextUrl.searchParams.get("code")?.trim() ?? "";
3953 const state = request.nextUrl.searchParams.get("state")?.trim() ?? "";
@@ -65,12 +79,11 @@ export async function GET(request: NextRequest) { · 25 unmodified lines
6579 });
6680 }
6781
68− let sourceUrl: URL;
69− try {
70− sourceUrl = new URL(pending.sourceUrl);
71− } catch {
82+ const sourceUrlResult = Result.try(() => new URL(pending.sourceUrl));
83+ if (!sourceUrlResult.isOk()) {
7284 return popupResultRedirect(request, cookieName, { ok: false, error: "Invalid MCP source URL" });
7385 }
86+ const sourceUrl = sourceUrlResult.value;
7487
7588 const provider = new McpPopupOAuthProvider({
7689 redirectUrl: pending.redirectUrl,
@@ -79,15 +92,14 @@ export async function GET(request: NextRequest) { · 2 unmodified lines
7992 clientInformation: pending.clientInformation,
8093 });
8194
82− try {
83− await auth(provider, {
95+ const authResult = await Result.tryPromise(() => auth(provider, {
8496 serverUrl: sourceUrl,
8597 authorizationCode: code,
86− });
87− } catch (finishError) {
98+ }));
99+ if (!authResult.isOk()) {
88100 return popupResultRedirect(request, cookieName, {
89101 ok: false,
90− error: finishError instanceof Error ? finishError.message : "Failed to finish OAuth",
102+ error: resultErrorMessage(authResult.error, "Failed to finish OAuth"),
91103 });
92104 }
93105
executor/apps/web/src/app/mcp/oauth/detect/route.ts+32−19
@@ -1,5 +1,6 @@
11 import type { NextRequest } from "next/server";
22 import { NextResponse } from "next/server";
3+import { Result } from "better-result";
34 import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
45
56 type DetectResponse = {
@@ -17,37 +18,49 @@ function noStoreJson(payload: DetectResponse, status = 200): NextResponse { · 11 unmodified lines
1718 });
1819 }
1920
21+function resultErrorMessage(error: unknown, fallback: string): string {
22+ const cause = typeof error === "object" && error && "cause" in error
23+ ? (error as { cause?: unknown }).cause
24+ : error;
25+ if (cause instanceof Error && cause.message.trim()) {
26+ return cause.message;
27+ }
28+ if (typeof cause === "string" && cause.trim()) {
29+ return cause;
30+ }
31+ return fallback;
32+}
33+
2034 export async function GET(request: NextRequest) {
2135 const sourceUrlRaw = request.nextUrl.searchParams.get("sourceUrl")?.trim() ?? "";
2236 if (!sourceUrlRaw) {
2337 return noStoreJson({ oauth: false, authorizationServers: [], detail: "Missing sourceUrl" }, 400);
2438 }
2539
26− let sourceUrl: URL;
27− try {
28− sourceUrl = new URL(sourceUrlRaw);
29− } catch {
40+ const sourceUrlResult = Result.try(() => new URL(sourceUrlRaw));
41+ if (!sourceUrlResult.isOk()) {
3042 return noStoreJson({ oauth: false, authorizationServers: [], detail: "Invalid sourceUrl" }, 400);
3143 }
44+ const sourceUrl = sourceUrlResult.value;
3245
33− try {
34− const metadata = await discoverOAuthProtectedResourceMetadata(sourceUrl);
35− const authorizationServers = Array.isArray((metadata as { authorization_servers?: unknown }).authorization_servers)
36− ? (metadata as { authorization_servers: unknown[] }).authorization_servers.filter(
37− (entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
38− )
39− : [];
40−
41− return noStoreJson({
42− oauth: authorizationServers.length > 0,
43− authorizationServers,
44− });
45− } catch (error) {
46− const message = error instanceof Error ? error.message : "OAuth metadata lookup failed";
46+ const metadataResult = await Result.tryPromise(() => discoverOAuthProtectedResourceMetadata(sourceUrl));
47+ if (!metadataResult.isOk()) {
4748 return noStoreJson({
4849 oauth: false,
4950 authorizationServers: [],
50− detail: message,
51+ detail: resultErrorMessage(metadataResult.error, "OAuth metadata lookup failed"),
5152 });
5253 }
54+
55+ const metadata = metadataResult.value;
56+ const authorizationServers = Array.isArray((metadata as { authorization_servers?: unknown }).authorization_servers)
57+ ? (metadata as { authorization_servers: unknown[] }).authorization_servers.filter(
58+ (entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
59+ )
60+ : [];
61+
62+ return noStoreJson({
63+ oauth: authorizationServers.length > 0,
64+ authorizationServers,
65+ });
5366 }
executor/apps/web/src/app/mcp/oauth/start/route.ts+21−11
@@ -1,5 +1,6 @@
11 import type { NextRequest } from "next/server";
22 import { NextResponse } from "next/server";
3+import { Result } from "better-result";
34 import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
45 import {
56 buildPendingCookieName,
@@ -39,18 +40,30 @@ function badPopupResponse(request: NextRequest, message: string): NextResponse { · 33 unmodified lines
3940 return popupResultRedirect(request, { ok: false, error: message });
4041 }
4142
43+function resultErrorMessage(error: unknown, fallback: string): string {
44+ const cause = typeof error === "object" && error && "cause" in error
45+ ? (error as { cause?: unknown }).cause
46+ : error;
47+ if (cause instanceof Error && cause.message.trim()) {
48+ return cause.message;
49+ }
50+ if (typeof cause === "string" && cause.trim()) {
51+ return cause;
52+ }
53+ return fallback;
54+}
55+
4256 export async function GET(request: NextRequest) {
4357 const sourceUrlRaw = request.nextUrl.searchParams.get("sourceUrl")?.trim() ?? "";
4458 if (!sourceUrlRaw) {
4559 return badPopupResponse(request, "Missing sourceUrl");
4660 }
4761
48− let sourceUrl: URL;
49− try {
50− sourceUrl = new URL(sourceUrlRaw);
51− } catch {
62+ const sourceUrlResult = Result.try(() => new URL(sourceUrlRaw));
63+ if (!sourceUrlResult.isOk()) {
5264 return badPopupResponse(request, "Invalid sourceUrl");
5365 }
66+ const sourceUrl = sourceUrlResult.value;
5467
5568 const state = createOAuthState();
5669 const redirectUrl = `${getExternalOrigin(request)}/mcp/oauth/callback`;
@@ -59,15 +72,12 @@ export async function GET(request: NextRequest) { · 2 unmodified lines
5972 state,
6073 });
6174
62− let authResult: "AUTHORIZED" | "REDIRECT";
63− try {
64− authResult = await auth(provider, { serverUrl: sourceUrl });
65− } catch (error) {
66− const message = error instanceof Error ? error.message : "Failed to start OAuth flow";
67− return badPopupResponse(request, message);
75+ const authResult = await Result.tryPromise(() => auth(provider, { serverUrl: sourceUrl }));
76+ if (!authResult.isOk()) {
77+ return badPopupResponse(request, resultErrorMessage(authResult.error, "Failed to start OAuth flow"));
6878 }
6979
70− if (authResult === "AUTHORIZED") {
80+ if (authResult.value === "AUTHORIZED") {
7181 const tokens = provider.getTokens();
7282 const accessToken = tokens?.access_token?.trim() ?? "";
7383 if (!accessToken) {
executor/apps/web/src/components/tools/add-source-dialog.tsx+55−34
@@ -3,6 +3,7 @@ · 2 unmodified lines
33 import { useState, type ReactNode } from "react";
44 import { Plus } from "lucide-react";
55 import { useAction, useMutation, useQuery } from "convex/react";
6+import { Result } from "better-result";
67 import { toast } from "sonner";
78 import { Button } from "@/components/ui/button";
89 import {
@@ -28,6 +29,19 @@ import { · 19 unmodified lines
2829 } from "./use-add-source-form-state";
2930 import { saveSourceWithCredentials } from "./add-source-submit";
3031
32+function resultErrorMessage(error: unknown, fallback: string): string {
33+ const cause = typeof error === "object" && error && "cause" in error
34+ ? (error as { cause?: unknown }).cause
35+ : error;
36+ if (cause instanceof Error && cause.message.trim()) {
37+ return cause.message;
38+ }
39+ if (typeof cause === "string" && cause.trim()) {
40+ return cause;
41+ }
42+ return fallback;
43+}
44+
3145 export function AddSourceDialog({
3246 existingSourceNames,
3347 onSourceAdded,
@@ -87,8 +101,8 @@ export function AddSourceDialog({ · 53 unmodified lines
87101 }
88102
89103 setSubmitting(true);
90− try {
91− const result = await saveSourceWithCredentials({
104+ const saveResult = await Result.tryPromise(() =>
105+ saveSourceWithCredentials({
92106 context,
93107 sourceToEdit,
94108 credentialsLoading,
@@ -108,28 +122,34 @@ export function AddSourceDialog({ · 13 unmodified lines
108122 hasCredentialInput: form.hasCredentialInput,
109123 buildSecretJson: form.buildSecretJson,
110124 },
111− });
112−
113− onSourceAdded?.(result.source, { connected: result.connected });
114− toast.success(
115− sourceToEdit
116− ? result.connected
117− ? `Source "${form.name.trim()}" updated with credentials`
118− : `Source "${form.name.trim()}" updated`
119− : result.connected
120− ? `Source "${form.name.trim()}" added with credentials — loading tools…`
121− : `Source "${form.name.trim()}" added — loading tools…`,
122− );
123− if (!sourceToEdit) {
124− form.reserveSourceName(form.name.trim());
125− }
126− form.setView("catalog");
127− setOpen(false);
128− } catch (err) {
129− toast.error(err instanceof Error ? err.message : sourceToEdit ? "Failed to update source" : "Failed to add source");
130− } finally {
131− setSubmitting(false);
125+ })
126+ );
127+ setSubmitting(false);
128+
129+ if (!saveResult.isOk()) {
130+ toast.error(resultErrorMessage(
131+ saveResult.error,
132+ sourceToEdit ? "Failed to update source" : "Failed to add source",
133+ ));
134+ return;
135+ }
136+
137+ const result = saveResult.value;
138+ onSourceAdded?.(result.source, { connected: result.connected });
139+ toast.success(
140+ sourceToEdit
141+ ? result.connected
142+ ? `Source "${form.name.trim()}" updated with credentials`
143+ : `Source "${form.name.trim()}" updated`
144+ : result.connected
145+ ? `Source "${form.name.trim()}" added with credentials — loading tools…`
146+ : `Source "${form.name.trim()}" added — loading tools…`,
147+ );
148+ if (!sourceToEdit) {
149+ form.reserveSourceName(form.name.trim());
132150 }
151+ form.setView("catalog");
152+ setOpen(false);
133153 };
134154
135155 const handleMcpOAuthConnect = async () => {
@@ -143,18 +163,19 @@ export function AddSourceDialog({ · 7 unmodified lines
143163 }
144164
145165 setMcpOAuthBusy(true);
146− try {
147− const result = await startMcpOAuthPopup(endpoint);
148− if (form.authType !== "bearer") {
149− form.handleAuthTypeChange("bearer");
150− }
151− form.handleAuthFieldChange("tokenValue", result.accessToken);
152− toast.success("OAuth linked successfully.");
153− } catch (error) {
154− toast.error(error instanceof Error ? error.message : "Failed to connect OAuth");
155− } finally {
156− setMcpOAuthBusy(false);
166+ const oauthResult = await Result.tryPromise(() => startMcpOAuthPopup(endpoint));
167+ setMcpOAuthBusy(false);
168+
169+ if (!oauthResult.isOk()) {
170+ toast.error(resultErrorMessage(oauthResult.error, "Failed to connect OAuth"));
171+ return;
172+ }
173+
174+ if (form.authType !== "bearer") {
175+ form.handleAuthTypeChange("bearer");
157176 }
177+ form.handleAuthFieldChange("tokenValue", oauthResult.value.accessToken);
178+ toast.success("OAuth linked successfully.");
158179 };
159180
160181 const dialogTitle = sourceToEdit ? "Edit Tool Source" : "Add Tool Source";
executor/apps/web/src/lib/mcp-oauth-popup.ts+1−3
@@ -53,10 +53,8 @@ export async function startMcpOAuthPopup(sourceUrl: string): Promise<McpOAuthPop · 52 unmodified lines
5353 const cleanup = () => {
5454 window.removeEventListener("message", onMessage);
5555 window.clearInterval(closedPoll);
56− try {
56+ if (!popup.closed) {
5757 popup.close();
58− } catch {
59− // ignore
6058 }
6159 };
6260
executor/apps/web/src/lib/mcp-oauth-provider.ts+45−34
@@ -1,4 +1,5 @@
11 import { randomUUID } from "node:crypto";
2+import { Result } from "better-result";
23 import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
34 import type {
45 OAuthClientInformationMixed,
@@ -40,27 +41,32 @@ export function encodePendingCookieValue(pending: McpOAuthPending): string { · 35 unmodified lines
4041 }
4142
4243 export function decodePendingCookieValue(raw: string): McpOAuthPending | null {
43− try {
44− const decoded = Buffer.from(raw, "base64url").toString("utf8");
45− const parsed = JSON.parse(decoded) as Partial<McpOAuthPending>;
46− if (
47− typeof parsed.state !== "string"
48− || typeof parsed.sourceUrl !== "string"
49− || typeof parsed.redirectUrl !== "string"
50− ) {
51− return null;
52− }
44+ const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8"));
45+ if (!decodedResult.isOk()) {
46+ return null;
47+ }
5348
54− return {
55− state: parsed.state,
56− sourceUrl: parsed.sourceUrl,
57− redirectUrl: parsed.redirectUrl,
58− ...(typeof parsed.codeVerifier === "string" ? { codeVerifier: parsed.codeVerifier } : {}),
59− ...(parsed.clientInformation ? { clientInformation: parsed.clientInformation } : {}),
60− };
61− } catch {
49+ const parsedResult = Result.try(() => JSON.parse(decodedResult.value) as Partial<McpOAuthPending>);
50+ if (!parsedResult.isOk()) {
6251 return null;
6352 }
53+
54+ const parsed = parsedResult.value;
55+ if (
56+ typeof parsed.state !== "string"
57+ || typeof parsed.sourceUrl !== "string"
58+ || typeof parsed.redirectUrl !== "string"
59+ ) {
60+ return null;
61+ }
62+
63+ return {
64+ state: parsed.state,
65+ sourceUrl: parsed.sourceUrl,
66+ redirectUrl: parsed.redirectUrl,
67+ ...(typeof parsed.codeVerifier === "string" ? { codeVerifier: parsed.codeVerifier } : {}),
68+ ...(parsed.clientInformation ? { clientInformation: parsed.clientInformation } : {}),
69+ };
6470 }
6571
6672 export function encodePopupResultCookieValue(result: McpOAuthPopupResult): string {
@@ -68,25 +74,30 @@ export function encodePopupResultCookieValue(result: McpOAuthPopupResult): strin · 1 unmodified line
6874 }
6975
7076 export function decodePopupResultCookieValue(raw: string): McpOAuthPopupResult | null {
71− try {
72− const decoded = Buffer.from(raw, "base64url").toString("utf8");
73− const parsed = JSON.parse(decoded) as Partial<McpOAuthPopupResult>;
74− if (typeof parsed.ok !== "boolean") {
75− return null;
76− }
77+ const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8"));
78+ if (!decodedResult.isOk()) {
79+ return null;
80+ }
7781
78− return {
79− ok: parsed.ok,
80− ...(typeof parsed.sourceUrl === "string" ? { sourceUrl: parsed.sourceUrl } : {}),
81− ...(typeof parsed.accessToken === "string" ? { accessToken: parsed.accessToken } : {}),
82− ...(typeof parsed.refreshToken === "string" ? { refreshToken: parsed.refreshToken } : {}),
83− ...(typeof parsed.scope === "string" ? { scope: parsed.scope } : {}),
84− ...(typeof parsed.expiresIn === "number" ? { expiresIn: parsed.expiresIn } : {}),
85− ...(typeof parsed.error === "string" ? { error: parsed.error } : {}),
86− };
87− } catch {
82+ const parsedResult = Result.try(() => JSON.parse(decodedResult.value) as Partial<McpOAuthPopupResult>);
83+ if (!parsedResult.isOk()) {
8884 return null;
8985 }
86+
87+ const parsed = parsedResult.value;
88+ if (typeof parsed.ok !== "boolean") {
89+ return null;
90+ }
91+
92+ return {
93+ ok: parsed.ok,
94+ ...(typeof parsed.sourceUrl === "string" ? { sourceUrl: parsed.sourceUrl } : {}),
95+ ...(typeof parsed.accessToken === "string" ? { accessToken: parsed.accessToken } : {}),
96+ ...(typeof parsed.refreshToken === "string" ? { refreshToken: parsed.refreshToken } : {}),
97+ ...(typeof parsed.scope === "string" ? { scope: parsed.scope } : {}),
98+ ...(typeof parsed.expiresIn === "number" ? { expiresIn: parsed.expiresIn } : {}),
99+ ...(typeof parsed.error === "string" ? { error: parsed.error } : {}),
100+ };
90101 }
91102
92103 export class McpPopupOAuthProvider implements OAuthClientProvider {