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
92 "@streamdown/code": "^1.0.1",92 "@streamdown/code": "^1.0.1",
93 "@tanstack/react-query": "^5.80.7",93 "@tanstack/react-query": "^5.80.7",
94 "@workos-inc/authkit-nextjs": "^2.13.0",94 "@workos-inc/authkit-nextjs": "^2.13.0",
95 "better-result": "^2.7.0",
95 "class-variance-authority": "^0.7.1",96 "class-variance-authority": "^0.7.1",
96 "clsx": "^2.1.1",97 "clsx": "^2.1.1",
97 "convex": "^1.31.7",98 "convex": "^1.31.7",
executor/apps/web/package.json+1−0
@@ -17,6 +17,7 @@ · 16 unmodified lines
17 "@streamdown/code": "^1.0.1",17 "@streamdown/code": "^1.0.1",
18 "@tanstack/react-query": "^5.80.7",18 "@tanstack/react-query": "^5.80.7",
19 "@workos-inc/authkit-nextjs": "^2.13.0",19 "@workos-inc/authkit-nextjs": "^2.13.0",
20 "better-result": "^2.7.0",
20 "class-variance-authority": "^0.7.1",21 "class-variance-authority": "^0.7.1",
21 "clsx": "^2.1.1",22 "clsx": "^2.1.1",
22 "convex": "^1.31.7",23 "convex": "^1.31.7",
executor/apps/web/src/app/mcp/oauth/callback/route.ts+21−9
@@ -1,5 +1,6 @@
1import type { NextRequest } from "next/server";1import type { NextRequest } from "next/server";
2import { NextResponse } from "next/server";2import { NextResponse } from "next/server";
3import { Result } from "better-result";
3import { auth } from "@modelcontextprotocol/sdk/client/auth.js";4import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
4import {5import {
5 buildPendingCookieName,6 buildPendingCookieName,
@@ -34,6 +35,19 @@ function popupResultRedirect( · 28 unmodified lines
34 return response;35 return response;
35}36}
36 37
38function 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
37export async function GET(request: NextRequest) {51export async function GET(request: NextRequest) {
38 const code = request.nextUrl.searchParams.get("code")?.trim() ?? "";52 const code = request.nextUrl.searchParams.get("code")?.trim() ?? "";
39 const state = request.nextUrl.searchParams.get("state")?.trim() ?? "";53 const state = request.nextUrl.searchParams.get("state")?.trim() ?? "";
@@ -65,12 +79,11 @@ export async function GET(request: NextRequest) { · 25 unmodified lines
65 });79 });
66 }80 }
67 81
68 let sourceUrl: URL;82 const sourceUrlResult = Result.try(() => new URL(pending.sourceUrl));
69 try {83 if (!sourceUrlResult.isOk()) {
70 sourceUrl = new URL(pending.sourceUrl);
71 } catch {
72 return popupResultRedirect(request, cookieName, { ok: false, error: "Invalid MCP source URL" });84 return popupResultRedirect(request, cookieName, { ok: false, error: "Invalid MCP source URL" });
73 }85 }
86 const sourceUrl = sourceUrlResult.value;
74 87
75 const provider = new McpPopupOAuthProvider({88 const provider = new McpPopupOAuthProvider({
76 redirectUrl: pending.redirectUrl,89 redirectUrl: pending.redirectUrl,
@@ -79,15 +92,14 @@ export async function GET(request: NextRequest) { · 2 unmodified lines
79 clientInformation: pending.clientInformation,92 clientInformation: pending.clientInformation,
80 });93 });
81 94
82 try {95 const authResult = await Result.tryPromise(() => auth(provider, {
83 await auth(provider, {
84 serverUrl: sourceUrl,96 serverUrl: sourceUrl,
85 authorizationCode: code,97 authorizationCode: code,
86 });98 }));
87 } catch (finishError) {99 if (!authResult.isOk()) {
88 return popupResultRedirect(request, cookieName, {100 return popupResultRedirect(request, cookieName, {
89 ok: false,101 ok: false,
90 error: finishError instanceof Error ? finishError.message : "Failed to finish OAuth",102 error: resultErrorMessage(authResult.error, "Failed to finish OAuth"),
91 });103 });
92 }104 }
93 105
executor/apps/web/src/app/mcp/oauth/detect/route.ts+32−19
@@ -1,5 +1,6 @@
1import type { NextRequest } from "next/server";1import type { NextRequest } from "next/server";
2import { NextResponse } from "next/server";2import { NextResponse } from "next/server";
3import { Result } from "better-result";
3import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/client/auth.js";4import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
4 5
5type DetectResponse = {6type DetectResponse = {
@@ -17,37 +18,49 @@ function noStoreJson(payload: DetectResponse, status = 200): NextResponse { · 11 unmodified lines
17 });18 });
18}19}
19 20
21function 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
20export async function GET(request: NextRequest) {34export async function GET(request: NextRequest) {
21 const sourceUrlRaw = request.nextUrl.searchParams.get("sourceUrl")?.trim() ?? "";35 const sourceUrlRaw = request.nextUrl.searchParams.get("sourceUrl")?.trim() ?? "";
22 if (!sourceUrlRaw) {36 if (!sourceUrlRaw) {
23 return noStoreJson({ oauth: false, authorizationServers: [], detail: "Missing sourceUrl" }, 400);37 return noStoreJson({ oauth: false, authorizationServers: [], detail: "Missing sourceUrl" }, 400);
24 }38 }
25 39
26 let sourceUrl: URL;40 const sourceUrlResult = Result.try(() => new URL(sourceUrlRaw));
27 try {41 if (!sourceUrlResult.isOk()) {
28 sourceUrl = new URL(sourceUrlRaw);
29 } catch {
30 return noStoreJson({ oauth: false, authorizationServers: [], detail: "Invalid sourceUrl" }, 400);42 return noStoreJson({ oauth: false, authorizationServers: [], detail: "Invalid sourceUrl" }, 400);
31 }43 }
44 const sourceUrl = sourceUrlResult.value;
32 45
33 try {46 const metadataResult = await Result.tryPromise(() => discoverOAuthProtectedResourceMetadata(sourceUrl));
34 const metadata = await discoverOAuthProtectedResourceMetadata(sourceUrl);47 if (!metadataResult.isOk()) {
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";
47 return noStoreJson({48 return noStoreJson({
48 oauth: false,49 oauth: false,
49 authorizationServers: [],50 authorizationServers: [],
50 detail: message,51 detail: resultErrorMessage(metadataResult.error, "OAuth metadata lookup failed"),
51 });52 });
52 }53 }
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 });
53}66}
executor/apps/web/src/app/mcp/oauth/start/route.ts+21−11
@@ -1,5 +1,6 @@
1import type { NextRequest } from "next/server";1import type { NextRequest } from "next/server";
2import { NextResponse } from "next/server";2import { NextResponse } from "next/server";
3import { Result } from "better-result";
3import { auth } from "@modelcontextprotocol/sdk/client/auth.js";4import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
4import {5import {
5 buildPendingCookieName,6 buildPendingCookieName,
@@ -39,18 +40,30 @@ function badPopupResponse(request: NextRequest, message: string): NextResponse { · 33 unmodified lines
39 return popupResultRedirect(request, { ok: false, error: message });40 return popupResultRedirect(request, { ok: false, error: message });
40}41}
41 42
43function 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
42export async function GET(request: NextRequest) {56export async function GET(request: NextRequest) {
43 const sourceUrlRaw = request.nextUrl.searchParams.get("sourceUrl")?.trim() ?? "";57 const sourceUrlRaw = request.nextUrl.searchParams.get("sourceUrl")?.trim() ?? "";
44 if (!sourceUrlRaw) {58 if (!sourceUrlRaw) {
45 return badPopupResponse(request, "Missing sourceUrl");59 return badPopupResponse(request, "Missing sourceUrl");
46 }60 }
47 61
48 let sourceUrl: URL;62 const sourceUrlResult = Result.try(() => new URL(sourceUrlRaw));
49 try {63 if (!sourceUrlResult.isOk()) {
50 sourceUrl = new URL(sourceUrlRaw);
51 } catch {
52 return badPopupResponse(request, "Invalid sourceUrl");64 return badPopupResponse(request, "Invalid sourceUrl");
53 }65 }
66 const sourceUrl = sourceUrlResult.value;
54 67
55 const state = createOAuthState();68 const state = createOAuthState();
56 const redirectUrl = `${getExternalOrigin(request)}/mcp/oauth/callback`;69 const redirectUrl = `${getExternalOrigin(request)}/mcp/oauth/callback`;
@@ -59,15 +72,12 @@ export async function GET(request: NextRequest) { · 2 unmodified lines
59 state,72 state,
60 });73 });
61 74
62 let authResult: "AUTHORIZED" | "REDIRECT";75 const authResult = await Result.tryPromise(() => auth(provider, { serverUrl: sourceUrl }));
63 try {76 if (!authResult.isOk()) {
64 authResult = await auth(provider, { serverUrl: sourceUrl });77 return badPopupResponse(request, resultErrorMessage(authResult.error, "Failed to start OAuth flow"));
65 } catch (error) {
66 const message = error instanceof Error ? error.message : "Failed to start OAuth flow";
67 return badPopupResponse(request, message);
68 }78 }
69 79
70 if (authResult === "AUTHORIZED") {80 if (authResult.value === "AUTHORIZED") {
71 const tokens = provider.getTokens();81 const tokens = provider.getTokens();
72 const accessToken = tokens?.access_token?.trim() ?? "";82 const accessToken = tokens?.access_token?.trim() ?? "";
73 if (!accessToken) {83 if (!accessToken) {
executor/apps/web/src/components/tools/add-source-dialog.tsx+55−34
@@ -3,6 +3,7 @@ · 2 unmodified lines
3import { useState, type ReactNode } from "react";3import { useState, type ReactNode } from "react";
4import { Plus } from "lucide-react";4import { Plus } from "lucide-react";
5import { useAction, useMutation, useQuery } from "convex/react";5import { useAction, useMutation, useQuery } from "convex/react";
6import { Result } from "better-result";
6import { toast } from "sonner";7import { toast } from "sonner";
7import { Button } from "@/components/ui/button";8import { Button } from "@/components/ui/button";
8import {9import {
@@ -28,6 +29,19 @@ import { · 19 unmodified lines
28} from "./use-add-source-form-state";29} from "./use-add-source-form-state";
29import { saveSourceWithCredentials } from "./add-source-submit";30import { saveSourceWithCredentials } from "./add-source-submit";
30 31
32function 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
31export function AddSourceDialog({45export function AddSourceDialog({
32 existingSourceNames,46 existingSourceNames,
33 onSourceAdded,47 onSourceAdded,
@@ -87,8 +101,8 @@ export function AddSourceDialog({ · 53 unmodified lines
87 }101 }
88 102
89 setSubmitting(true);103 setSubmitting(true);
90 try {104 const saveResult = await Result.tryPromise(() =>
91 const result = await saveSourceWithCredentials({105 saveSourceWithCredentials({
92 context,106 context,
93 sourceToEdit,107 sourceToEdit,
94 credentialsLoading,108 credentialsLoading,
@@ -108,28 +122,34 @@ export function AddSourceDialog({ · 13 unmodified lines
108 hasCredentialInput: form.hasCredentialInput,122 hasCredentialInput: form.hasCredentialInput,
109 buildSecretJson: form.buildSecretJson,123 buildSecretJson: form.buildSecretJson,
110 },124 },
111 });125 })
112 126 );
113 onSourceAdded?.(result.source, { connected: result.connected });127 setSubmitting(false);
114 toast.success(128
115 sourceToEdit129 if (!saveResult.isOk()) {
116 ? result.connected130 toast.error(resultErrorMessage(
117 ? `Source "${form.name.trim()}" updated with credentials`131 saveResult.error,
118 : `Source "${form.name.trim()}" updated`132 sourceToEdit ? "Failed to update source" : "Failed to add source",
119 : result.connected133 ));
120 ? `Source "${form.name.trim()}" added with credentials — loading tools…`134 return;
121 : `Source "${form.name.trim()}" added — loading tools…`,135 }
122 );136
123 if (!sourceToEdit) {137 const result = saveResult.value;
124 form.reserveSourceName(form.name.trim());138 onSourceAdded?.(result.source, { connected: result.connected });
125 }139 toast.success(
126 form.setView("catalog");140 sourceToEdit
127 setOpen(false);141 ? result.connected
128 } catch (err) {142 ? `Source "${form.name.trim()}" updated with credentials`
129 toast.error(err instanceof Error ? err.message : sourceToEdit ? "Failed to update source" : "Failed to add source");143 : `Source "${form.name.trim()}" updated`
130 } finally {144 : result.connected
131 setSubmitting(false);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());
132 }150 }
151 form.setView("catalog");
152 setOpen(false);
133 };153 };
134 154
135 const handleMcpOAuthConnect = async () => {155 const handleMcpOAuthConnect = async () => {
@@ -143,18 +163,19 @@ export function AddSourceDialog({ · 7 unmodified lines
143 }163 }
144 164
145 setMcpOAuthBusy(true);165 setMcpOAuthBusy(true);
146 try {166 const oauthResult = await Result.tryPromise(() => startMcpOAuthPopup(endpoint));
147 const result = await startMcpOAuthPopup(endpoint);167 setMcpOAuthBusy(false);
148 if (form.authType !== "bearer") {168
149 form.handleAuthTypeChange("bearer");169 if (!oauthResult.isOk()) {
150 }170 toast.error(resultErrorMessage(oauthResult.error, "Failed to connect OAuth"));
151 form.handleAuthFieldChange("tokenValue", result.accessToken);171 return;
152 toast.success("OAuth linked successfully.");172 }
153 } catch (error) {173
154 toast.error(error instanceof Error ? error.message : "Failed to connect OAuth");174 if (form.authType !== "bearer") {
155 } finally {175 form.handleAuthTypeChange("bearer");
156 setMcpOAuthBusy(false);
157 }176 }
177 form.handleAuthFieldChange("tokenValue", oauthResult.value.accessToken);
178 toast.success("OAuth linked successfully.");
158 };179 };
159 180
160 const dialogTitle = sourceToEdit ? "Edit Tool Source" : "Add Tool Source";181 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
53 const cleanup = () => {53 const cleanup = () => {
54 window.removeEventListener("message", onMessage);54 window.removeEventListener("message", onMessage);
55 window.clearInterval(closedPoll);55 window.clearInterval(closedPoll);
56 try {56 if (!popup.closed) {
57 popup.close();57 popup.close();
58 } catch {
59 // ignore
60 }58 }
61 };59 };
62 60
executor/apps/web/src/lib/mcp-oauth-provider.ts+45−34
@@ -1,4 +1,5 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2import { Result } from "better-result";
2import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";3import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
3import type {4import type {
4 OAuthClientInformationMixed,5 OAuthClientInformationMixed,
@@ -40,27 +41,32 @@ export function encodePendingCookieValue(pending: McpOAuthPending): string { · 35 unmodified lines
40}41}
41 42
42export function decodePendingCookieValue(raw: string): McpOAuthPending | null {43export function decodePendingCookieValue(raw: string): McpOAuthPending | null {
43 try {44 const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8"));
44 const decoded = Buffer.from(raw, "base64url").toString("utf8");45 if (!decodedResult.isOk()) {
45 const parsed = JSON.parse(decoded) as Partial<McpOAuthPending>;46 return null;
46 if (47 }
47 typeof parsed.state !== "string"
48 || typeof parsed.sourceUrl !== "string"
49 || typeof parsed.redirectUrl !== "string"
50 ) {
51 return null;
52 }
53 48
54 return {49 const parsedResult = Result.try(() => JSON.parse(decodedResult.value) as Partial<McpOAuthPending>);
55 state: parsed.state,50 if (!parsedResult.isOk()) {
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 {
62 return null;51 return null;
63 }52 }
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 };
64}70}
65 71
66export function encodePopupResultCookieValue(result: McpOAuthPopupResult): string {72export function encodePopupResultCookieValue(result: McpOAuthPopupResult): string {
@@ -68,25 +74,30 @@ export function encodePopupResultCookieValue(result: McpOAuthPopupResult): strin · 1 unmodified line
68}74}
69 75
70export function decodePopupResultCookieValue(raw: string): McpOAuthPopupResult | null {76export function decodePopupResultCookieValue(raw: string): McpOAuthPopupResult | null {
71 try {77 const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8"));
72 const decoded = Buffer.from(raw, "base64url").toString("utf8");78 if (!decodedResult.isOk()) {
73 const parsed = JSON.parse(decoded) as Partial<McpOAuthPopupResult>;79 return null;
74 if (typeof parsed.ok !== "boolean") {80 }
75 return null;
76 }
77 81
78 return {82 const parsedResult = Result.try(() => JSON.parse(decodedResult.value) as Partial<McpOAuthPopupResult>);
79 ok: parsed.ok,83 if (!parsedResult.isOk()) {
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 {
88 return null;84 return null;
89 }85 }
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 };
90}101}
91 102
92export class McpPopupOAuthProvider implements OAuthClientProvider {103export class McpPopupOAuthProvider implements OAuthClientProvider {