Add MCP OAuth source flow and authenticated runtime wiring
Unified · Split
| @@ -92,6 +92,7 @@ · 91 unmodified lines | ||
| 92 | 92 | "@streamdown/code": "^1.0.1", |
| 93 | 93 | "@tanstack/react-query": "^5.80.7", |
| 94 | 94 | "@workos-inc/authkit-nextjs": "^2.13.0", |
| 95 | + "better-result": "^2.7.0", | |
| 95 | 96 | "class-variance-authority": "^0.7.1", |
| 96 | 97 | "clsx": "^2.1.1", |
| 97 | 98 | "convex": "^1.31.7", |
| @@ -17,6 +17,7 @@ · 16 unmodified lines | ||
| 17 | 17 | "@streamdown/code": "^1.0.1", |
| 18 | 18 | "@tanstack/react-query": "^5.80.7", |
| 19 | 19 | "@workos-inc/authkit-nextjs": "^2.13.0", |
| 20 | + "better-result": "^2.7.0", | |
| 20 | 21 | "class-variance-authority": "^0.7.1", |
| 21 | 22 | "clsx": "^2.1.1", |
| 22 | 23 | "convex": "^1.31.7", |
| @@ -0,0 +1,123 @@ | ||
| 1 | +import type { NextRequest } from "next/server"; | |
| 2 | +import { NextResponse } from "next/server"; | |
| 3 | +import { Result } from "better-result"; | |
| 4 | +import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; | |
| 5 | +import { getExternalOrigin, isExternalHttps } from "@/lib/mcp-oauth-request"; | |
| 6 | +import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url"; | |
| 7 | +import { | |
| 8 | + buildPendingCookieName, | |
| 9 | + decodePendingCookieValue, | |
| 10 | + encodePopupResultCookieValue, | |
| 11 | + MCP_OAUTH_RESULT_COOKIE, | |
| 12 | + McpPopupOAuthProvider, | |
| 13 | + type McpOAuthPopupResult, | |
| 14 | +} from "@/lib/mcp-oauth-provider"; | |
| 15 | + | |
| 16 | +function popupResultRedirect( | |
| 17 | + request: NextRequest, | |
| 18 | + pendingCookieName: string | null, | |
| 19 | + payload: McpOAuthPopupResult, | |
| 20 | +): NextResponse { | |
| 21 | + const origin = getExternalOrigin(request); | |
| 22 | + const response = NextResponse.redirect(`${origin}/mcp/oauth/complete`); | |
| 23 | + response.cookies.set({ | |
| 24 | + name: MCP_OAUTH_RESULT_COOKIE, | |
| 25 | + value: encodePopupResultCookieValue(payload), | |
| 26 | + httpOnly: true, | |
| 27 | + secure: isExternalHttps(request), | |
| 28 | + sameSite: "lax", | |
| 29 | + maxAge: 2 * 60, | |
| 30 | + path: "/", | |
| 31 | + }); | |
| 32 | + if (pendingCookieName) { | |
| 33 | + response.cookies.delete(pendingCookieName); | |
| 34 | + } | |
| 35 | + return response; | |
| 36 | +} | |
| 37 | + | |
| 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 | + | |
| 51 | +export async function GET(request: NextRequest) { | |
| 52 | + const code = request.nextUrl.searchParams.get("code")?.trim() ?? ""; | |
| 53 | + const state = request.nextUrl.searchParams.get("state")?.trim() ?? ""; | |
| 54 | + const error = request.nextUrl.searchParams.get("error")?.trim(); | |
| 55 | + | |
| 56 | + if (!state) { | |
| 57 | + return popupResultRedirect(request, null, { ok: false, error: "Missing OAuth state" }); | |
| 58 | + } | |
| 59 | + | |
| 60 | + const cookieName = buildPendingCookieName(state); | |
| 61 | + const rawPending = request.cookies.get(cookieName)?.value; | |
| 62 | + const pending = rawPending ? decodePendingCookieValue(rawPending) : null; | |
| 63 | + | |
| 64 | + if (!pending) { | |
| 65 | + return popupResultRedirect(request, cookieName, { | |
| 66 | + ok: false, | |
| 67 | + error: "OAuth session expired. Try connecting again.", | |
| 68 | + }); | |
| 69 | + } | |
| 70 | + | |
| 71 | + if (error) { | |
| 72 | + return popupResultRedirect(request, cookieName, { ok: false, error: `OAuth error: ${error}` }); | |
| 73 | + } | |
| 74 | + | |
| 75 | + if (!code) { | |
| 76 | + return popupResultRedirect(request, cookieName, { | |
| 77 | + ok: false, | |
| 78 | + error: "Missing OAuth authorization code", | |
| 79 | + }); | |
| 80 | + } | |
| 81 | + | |
| 82 | + const sourceUrlResult = parseMcpSourceUrl(pending.sourceUrl); | |
| 83 | + if (!sourceUrlResult.isOk()) { | |
| 84 | + return popupResultRedirect(request, cookieName, { ok: false, error: "Invalid MCP source URL" }); | |
| 85 | + } | |
| 86 | + const sourceUrl = sourceUrlResult.value; | |
| 87 | + | |
| 88 | + const provider = new McpPopupOAuthProvider({ | |
| 89 | + redirectUrl: pending.redirectUrl, | |
| 90 | + state: pending.state, | |
| 91 | + codeVerifier: pending.codeVerifier, | |
| 92 | + clientInformation: pending.clientInformation, | |
| 93 | + }); | |
| 94 | + | |
| 95 | + const authResult = await Result.tryPromise(() => auth(provider, { | |
| 96 | + serverUrl: sourceUrl, | |
| 97 | + authorizationCode: code, | |
| 98 | + })); | |
| 99 | + if (!authResult.isOk()) { | |
| 100 | + return popupResultRedirect(request, cookieName, { | |
| 101 | + ok: false, | |
| 102 | + error: resultErrorMessage(authResult.error, "Failed to finish OAuth"), | |
| 103 | + }); | |
| 104 | + } | |
| 105 | + | |
| 106 | + const tokens = provider.getTokens(); | |
| 107 | + const accessToken = tokens?.access_token?.trim() ?? ""; | |
| 108 | + if (!accessToken) { | |
| 109 | + return popupResultRedirect(request, cookieName, { | |
| 110 | + ok: false, | |
| 111 | + error: "OAuth completed without an access token", | |
| 112 | + }); | |
| 113 | + } | |
| 114 | + | |
| 115 | + return popupResultRedirect(request, cookieName, { | |
| 116 | + ok: true, | |
| 117 | + sourceUrl: pending.sourceUrl, | |
| 118 | + accessToken, | |
| 119 | + refreshToken: tokens?.refresh_token, | |
| 120 | + scope: tokens?.scope, | |
| 121 | + expiresIn: typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined, | |
| 122 | + }); | |
| 123 | +} | |
| @@ -0,0 +1,82 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useEffect, useRef } from "react"; | |
| 4 | +import { useQuery as useTanstackQuery } from "@tanstack/react-query"; | |
| 5 | + | |
| 6 | +type PopupMessage = | |
| 7 | + | { | |
| 8 | + type: "executor:mcp-oauth-result"; | |
| 9 | + ok: true; | |
| 10 | + sourceUrl: string; | |
| 11 | + payload: { | |
| 12 | + accessToken: string; | |
| 13 | + refreshToken?: string; | |
| 14 | + scope?: string; | |
| 15 | + expiresIn?: number; | |
| 16 | + }; | |
| 17 | + } | |
| 18 | + | { | |
| 19 | + type: "executor:mcp-oauth-result"; | |
| 20 | + ok: false; | |
| 21 | + error: string; | |
| 22 | + }; | |
| 23 | + | |
| 24 | +function fallbackErrorMessage(error: unknown): PopupMessage { | |
| 25 | + return { | |
| 26 | + type: "executor:mcp-oauth-result", | |
| 27 | + ok: false, | |
| 28 | + error: error instanceof Error ? error.message : "Failed to finalize OAuth", | |
| 29 | + }; | |
| 30 | +} | |
| 31 | + | |
| 32 | +async function fetchPopupResult(): Promise<PopupMessage> { | |
| 33 | + const response = await fetch("/mcp/oauth/result", { | |
| 34 | + method: "GET", | |
| 35 | + cache: "no-store", | |
| 36 | + credentials: "same-origin", | |
| 37 | + }); | |
| 38 | + | |
| 39 | + const data = await response.json() as PopupMessage; | |
| 40 | + if (!data || data.type !== "executor:mcp-oauth-result") { | |
| 41 | + throw new Error("Invalid OAuth completion payload"); | |
| 42 | + } | |
| 43 | + return data; | |
| 44 | +} | |
| 45 | + | |
| 46 | +export default function McpOAuthCompletePage() { | |
| 47 | + const sentRef = useRef(false); | |
| 48 | + const resultQuery = useTanstackQuery({ | |
| 49 | + queryKey: ["mcp-oauth-popup-result"], | |
| 50 | + queryFn: fetchPopupResult, | |
| 51 | + retry: false, | |
| 52 | + staleTime: 0, | |
| 53 | + }); | |
| 54 | + | |
| 55 | + useEffect(() => { | |
| 56 | + if (sentRef.current || resultQuery.isPending) { | |
| 57 | + return; | |
| 58 | + } | |
| 59 | + | |
| 60 | + sentRef.current = true; | |
| 61 | + const message = resultQuery.isSuccess | |
| 62 | + ? resultQuery.data | |
| 63 | + : fallbackErrorMessage(resultQuery.error); | |
| 64 | + | |
| 65 | + try { | |
| 66 | + if (window.opener) { | |
| 67 | + window.opener.postMessage(message, window.location.origin); | |
| 68 | + } | |
| 69 | + } finally { | |
| 70 | + window.close(); | |
| 71 | + } | |
| 72 | + }, [resultQuery.data, resultQuery.error, resultQuery.isPending, resultQuery.isSuccess]); | |
| 73 | + | |
| 74 | + return ( | |
| 75 | + <main className="min-h-screen flex items-center justify-center bg-background p-6"> | |
| 76 | + <div className="text-center space-y-2"> | |
| 77 | + <h1 className="text-base font-medium">Finishing OAuth</h1> | |
| 78 | + <p className="text-sm text-muted-foreground">You can close this window if it does not close automatically.</p> | |
| 79 | + </div> | |
| 80 | + </main> | |
| 81 | + ); | |
| 82 | +} | |
| @@ -0,0 +1,74 @@ | ||
| 1 | +import type { NextRequest } from "next/server"; | |
| 2 | +import { NextResponse } from "next/server"; | |
| 3 | +import { Result } from "better-result"; | |
| 4 | +import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; | |
| 5 | +import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url"; | |
| 6 | + | |
| 7 | +type DetectResponse = { | |
| 8 | + oauth: boolean; | |
| 9 | + authorizationServers: string[]; | |
| 10 | + detail?: string; | |
| 11 | +}; | |
| 12 | + | |
| 13 | +function noStoreJson(payload: DetectResponse, status = 200): NextResponse { | |
| 14 | + return NextResponse.json(payload, { | |
| 15 | + status, | |
| 16 | + headers: { | |
| 17 | + "cache-control": "no-store", | |
| 18 | + }, | |
| 19 | + }); | |
| 20 | +} | |
| 21 | + | |
| 22 | +function resultErrorMessage(error: unknown, fallback: string): string { | |
| 23 | + const cause = typeof error === "object" && error && "cause" in error | |
| 24 | + ? (error as { cause?: unknown }).cause | |
| 25 | + : error; | |
| 26 | + if (cause instanceof Error && cause.message.trim()) { | |
| 27 | + return cause.message; | |
| 28 | + } | |
| 29 | + if (typeof cause === "string" && cause.trim()) { | |
| 30 | + return cause; | |
| 31 | + } | |
| 32 | + return fallback; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export async function GET(request: NextRequest) { | |
| 36 | + const sourceUrlRaw = request.nextUrl.searchParams.get("sourceUrl")?.trim() ?? ""; | |
| 37 | + if (!sourceUrlRaw) { | |
| 38 | + return noStoreJson({ oauth: false, authorizationServers: [], detail: "Missing sourceUrl" }, 400); | |
| 39 | + } | |
| 40 | + | |
| 41 | + const sourceUrlResult = parseMcpSourceUrl(sourceUrlRaw); | |
| 42 | + if (!sourceUrlResult.isOk()) { | |
| 43 | + return noStoreJson( | |
| 44 | + { | |
| 45 | + oauth: false, | |
| 46 | + authorizationServers: [], | |
| 47 | + detail: resultErrorMessage(sourceUrlResult.error, "Invalid sourceUrl"), | |
| 48 | + }, | |
| 49 | + 400, | |
| 50 | + ); | |
| 51 | + } | |
| 52 | + const sourceUrl = sourceUrlResult.value; | |
| 53 | + | |
| 54 | + const metadataResult = await Result.tryPromise(() => discoverOAuthProtectedResourceMetadata(sourceUrl)); | |
| 55 | + if (!metadataResult.isOk()) { | |
| 56 | + return noStoreJson({ | |
| 57 | + oauth: false, | |
| 58 | + authorizationServers: [], | |
| 59 | + detail: resultErrorMessage(metadataResult.error, "OAuth metadata lookup failed"), | |
| 60 | + }); | |
| 61 | + } | |
| 62 | + | |
| 63 | + const metadata = metadataResult.value; | |
| 64 | + const authorizationServers = Array.isArray((metadata as { authorization_servers?: unknown }).authorization_servers) | |
| 65 | + ? (metadata as { authorization_servers: unknown[] }).authorization_servers.filter( | |
| 66 | + (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, | |
| 67 | + ) | |
| 68 | + : []; | |
| 69 | + | |
| 70 | + return noStoreJson({ | |
| 71 | + oauth: authorizationServers.length > 0, | |
| 72 | + authorizationServers, | |
| 73 | + }); | |
| 74 | +} | |
| @@ -0,0 +1,79 @@ | ||
| 1 | +import type { NextRequest } from "next/server"; | |
| 2 | +import { NextResponse } from "next/server"; | |
| 3 | +import { | |
| 4 | + decodePopupResultCookieValue, | |
| 5 | + MCP_OAUTH_RESULT_COOKIE, | |
| 6 | + type McpOAuthPopupResult, | |
| 7 | +} from "@/lib/mcp-oauth-provider"; | |
| 8 | + | |
| 9 | +type PopupMessage = | |
| 10 | + | { | |
| 11 | + type: "executor:mcp-oauth-result"; | |
| 12 | + ok: true; | |
| 13 | + sourceUrl: string; | |
| 14 | + payload: { | |
| 15 | + accessToken: string; | |
| 16 | + refreshToken?: string; | |
| 17 | + scope?: string; | |
| 18 | + expiresIn?: number; | |
| 19 | + }; | |
| 20 | + } | |
| 21 | + | { | |
| 22 | + type: "executor:mcp-oauth-result"; | |
| 23 | + ok: false; | |
| 24 | + error: string; | |
| 25 | + }; | |
| 26 | + | |
| 27 | +function toPopupMessage(result: McpOAuthPopupResult | null): PopupMessage { | |
| 28 | + if (!result) { | |
| 29 | + return { | |
| 30 | + type: "executor:mcp-oauth-result", | |
| 31 | + ok: false, | |
| 32 | + error: "OAuth result is missing or expired", | |
| 33 | + }; | |
| 34 | + } | |
| 35 | + | |
| 36 | + if (!result.ok) { | |
| 37 | + return { | |
| 38 | + type: "executor:mcp-oauth-result", | |
| 39 | + ok: false, | |
| 40 | + error: result.error ?? "OAuth failed", | |
| 41 | + }; | |
| 42 | + } | |
| 43 | + | |
| 44 | + const accessToken = result.accessToken?.trim() ?? ""; | |
| 45 | + if (!accessToken) { | |
| 46 | + return { | |
| 47 | + type: "executor:mcp-oauth-result", | |
| 48 | + ok: false, | |
| 49 | + error: "OAuth finished without an access token", | |
| 50 | + }; | |
| 51 | + } | |
| 52 | + | |
| 53 | + return { | |
| 54 | + type: "executor:mcp-oauth-result", | |
| 55 | + ok: true, | |
| 56 | + sourceUrl: result.sourceUrl ?? "", | |
| 57 | + payload: { | |
| 58 | + accessToken, | |
| 59 | + ...(result.refreshToken ? { refreshToken: result.refreshToken } : {}), | |
| 60 | + ...(result.scope ? { scope: result.scope } : {}), | |
| 61 | + ...(typeof result.expiresIn === "number" ? { expiresIn: result.expiresIn } : {}), | |
| 62 | + }, | |
| 63 | + }; | |
| 64 | +} | |
| 65 | + | |
| 66 | +export async function GET(request: NextRequest) { | |
| 67 | + const encoded = request.cookies.get(MCP_OAUTH_RESULT_COOKIE)?.value ?? ""; | |
| 68 | + const parsed = encoded ? decodePopupResultCookieValue(encoded) : null; | |
| 69 | + const message = toPopupMessage(parsed); | |
| 70 | + | |
| 71 | + const response = NextResponse.json(message, { | |
| 72 | + status: message.ok ? 200 : 400, | |
| 73 | + headers: { | |
| 74 | + "cache-control": "no-store", | |
| 75 | + }, | |
| 76 | + }); | |
| 77 | + response.cookies.delete(MCP_OAUTH_RESULT_COOKIE); | |
| 78 | + return response; | |
| 79 | +} | |
| @@ -0,0 +1,106 @@ | ||
| 1 | +import type { NextRequest } from "next/server"; | |
| 2 | +import { NextResponse } from "next/server"; | |
| 3 | +import { Result } from "better-result"; | |
| 4 | +import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; | |
| 5 | +import { getExternalOrigin, isExternalHttps } from "@/lib/mcp-oauth-request"; | |
| 6 | +import { parseMcpSourceUrl } from "@/lib/mcp-oauth-url"; | |
| 7 | +import { | |
| 8 | + buildPendingCookieName, | |
| 9 | + createOAuthState, | |
| 10 | + encodePendingCookieValue, | |
| 11 | + encodePopupResultCookieValue, | |
| 12 | + MCP_OAUTH_RESULT_COOKIE, | |
| 13 | + McpPopupOAuthProvider, | |
| 14 | + type McpOAuthPopupResult, | |
| 15 | +} from "@/lib/mcp-oauth-provider"; | |
| 16 | + | |
| 17 | +function popupResultRedirect(request: NextRequest, payload: McpOAuthPopupResult): NextResponse { | |
| 18 | + const externalOrigin = getExternalOrigin(request); | |
| 19 | + const response = NextResponse.redirect(`${externalOrigin}/mcp/oauth/complete`); | |
| 20 | + response.cookies.set({ | |
| 21 | + name: MCP_OAUTH_RESULT_COOKIE, | |
| 22 | + value: encodePopupResultCookieValue(payload), | |
| 23 | + httpOnly: true, | |
| 24 | + secure: isExternalHttps(request), | |
| 25 | + sameSite: "lax", | |
| 26 | + maxAge: 2 * 60, | |
| 27 | + path: "/", | |
| 28 | + }); | |
| 29 | + return response; | |
| 30 | +} | |
| 31 | + | |
| 32 | +function badPopupResponse(request: NextRequest, message: string): NextResponse { | |
| 33 | + return popupResultRedirect(request, { ok: false, error: message }); | |
| 34 | +} | |
| 35 | + | |
| 36 | +function resultErrorMessage(error: unknown, fallback: string): string { | |
| 37 | + const cause = typeof error === "object" && error && "cause" in error | |
| 38 | + ? (error as { cause?: unknown }).cause | |
| 39 | + : error; | |
| 40 | + if (cause instanceof Error && cause.message.trim()) { | |
| 41 | + return cause.message; | |
| 42 | + } | |
| 43 | + if (typeof cause === "string" && cause.trim()) { | |
| 44 | + return cause; | |
| 45 | + } | |
| 46 | + return fallback; | |
| 47 | +} | |
| 48 | + | |
| 49 | +export async function GET(request: NextRequest) { | |
| 50 | + const sourceUrlRaw = request.nextUrl.searchParams.get("sourceUrl")?.trim() ?? ""; | |
| 51 | + if (!sourceUrlRaw) { | |
| 52 | + return badPopupResponse(request, "Missing sourceUrl"); | |
| 53 | + } | |
| 54 | + | |
| 55 | + const sourceUrlResult = parseMcpSourceUrl(sourceUrlRaw); | |
| 56 | + if (!sourceUrlResult.isOk()) { | |
| 57 | + return badPopupResponse(request, resultErrorMessage(sourceUrlResult.error, "Invalid sourceUrl")); | |
| 58 | + } | |
| 59 | + const sourceUrl = sourceUrlResult.value; | |
| 60 | + | |
| 61 | + const state = createOAuthState(); | |
| 62 | + const redirectUrl = `${getExternalOrigin(request)}/mcp/oauth/callback`; | |
| 63 | + const provider = new McpPopupOAuthProvider({ | |
| 64 | + redirectUrl, | |
| 65 | + state, | |
| 66 | + }); | |
| 67 | + | |
| 68 | + const authResult = await Result.tryPromise(() => auth(provider, { serverUrl: sourceUrl })); | |
| 69 | + if (!authResult.isOk()) { | |
| 70 | + return badPopupResponse(request, resultErrorMessage(authResult.error, "Failed to start OAuth flow")); | |
| 71 | + } | |
| 72 | + | |
| 73 | + if (authResult.value === "AUTHORIZED") { | |
| 74 | + const tokens = provider.getTokens(); | |
| 75 | + const accessToken = tokens?.access_token?.trim() ?? ""; | |
| 76 | + if (!accessToken) { | |
| 77 | + return badPopupResponse(request, "OAuth flow completed without an access token"); | |
| 78 | + } | |
| 79 | + return popupResultRedirect(request, { | |
| 80 | + ok: true, | |
| 81 | + sourceUrl: sourceUrl.toString(), | |
| 82 | + accessToken, | |
| 83 | + refreshToken: tokens?.refresh_token, | |
| 84 | + scope: tokens?.scope, | |
| 85 | + expiresIn: typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined, | |
| 86 | + }); | |
| 87 | + } | |
| 88 | + | |
| 89 | + const authorizationUrl = provider.getAuthorizationUrl(); | |
| 90 | + if (!authorizationUrl) { | |
| 91 | + return badPopupResponse(request, "Server did not request an OAuth authorization step"); | |
| 92 | + } | |
| 93 | + | |
| 94 | + const pendingCookie = encodePendingCookieValue(provider.toPending(sourceUrl.toString())); | |
| 95 | + const response = NextResponse.redirect(authorizationUrl); | |
| 96 | + response.cookies.set({ | |
| 97 | + name: buildPendingCookieName(state), | |
| 98 | + value: pendingCookie, | |
| 99 | + httpOnly: true, | |
| 100 | + secure: isExternalHttps(request), | |
| 101 | + sameSite: "lax", | |
| 102 | + maxAge: 10 * 60, | |
| 103 | + path: "/", | |
| 104 | + }); | |
| 105 | + return response; | |
| 106 | +} | |
| @@ -1,5 +1,6 @@ | ||
| 1 | −import { KeyRound, LockKeyhole, ShieldCheck, UserRound } from "lucide-react"; | |
| 1 | +import { KeyRound, LockKeyhole, UserRound } from "lucide-react"; | |
| 2 | 2 | import { Badge } from "@/components/ui/badge"; |
| 3 | +import { Button } from "@/components/ui/button"; | |
| 3 | 4 | import { Input } from "@/components/ui/input"; |
| 4 | 5 | import { Label } from "@/components/ui/label"; |
| 5 | 6 | import { |
| @@ -25,6 +26,10 @@ export type SourceAuthPanelModel = { · 19 unmodified lines | ||
| 25 | 26 | specStatus: "idle" | "detecting" | "ready" | "error"; |
| 26 | 27 | inferredSpecAuth: InferredSpecAuth | null; |
| 27 | 28 | specError: string; |
| 29 | + mcpOAuthStatus: "idle" | "checking" | "oauth" | "none" | "error"; | |
| 30 | + mcpOAuthDetail: string; | |
| 31 | + mcpOAuthAuthorizationServers: string[]; | |
| 32 | + mcpOAuthConnected: boolean; | |
| 28 | 33 | authType: Exclude<SourceAuthType, "mixed">; |
| 29 | 34 | authScope: CredentialScope; |
| 30 | 35 | apiKeyHeader: string; |
| @@ -51,7 +56,7 @@ function inferredAuthBadge(inferredSpecAuth: InferredSpecAuth | null): string | · 20 unmodified lines | ||
| 51 | 56 | if (inferredSpecAuth.type === "bearer") { |
| 52 | 57 | return "Bearer"; |
| 53 | 58 | } |
| 54 | − return "No auth"; | |
| 59 | + return null; | |
| 55 | 60 | } |
| 56 | 61 | |
| 57 | 62 | export function SourceAuthPanel({ |
| @@ -59,17 +64,25 @@ export function SourceAuthPanel({ · 1 unmodified line | ||
| 59 | 64 | onAuthTypeChange, |
| 60 | 65 | onAuthScopeChange, |
| 61 | 66 | onFieldChange, |
| 67 | + onMcpOAuthConnect, | |
| 68 | + mcpOAuthBusy = false, | |
| 62 | 69 | }: { |
| 63 | 70 | model: SourceAuthPanelModel; |
| 64 | 71 | onAuthTypeChange: (value: Exclude<SourceAuthType, "mixed">) => void; |
| 65 | 72 | onAuthScopeChange: (value: CredentialScope) => void; |
| 66 | 73 | onFieldChange: (field: SourceAuthPanelEditableField, value: string) => void; |
| 74 | + onMcpOAuthConnect?: () => void; | |
| 75 | + mcpOAuthBusy?: boolean; | |
| 67 | 76 | }) { |
| 68 | 77 | const { |
| 69 | 78 | sourceType, |
| 70 | 79 | specStatus, |
| 71 | 80 | inferredSpecAuth, |
| 72 | 81 | specError, |
| 82 | + mcpOAuthStatus, | |
| 83 | + mcpOAuthDetail, | |
| 84 | + mcpOAuthAuthorizationServers, | |
| 85 | + mcpOAuthConnected, | |
| 73 | 86 | authType, |
| 74 | 87 | authScope, |
| 75 | 88 | apiKeyHeader, |
| @@ -80,44 +93,22 @@ export function SourceAuthPanel({ · 4 unmodified lines | ||
| 80 | 93 | hasExistingCredential, |
| 81 | 94 | } = model; |
| 82 | 95 | |
| 83 | − if (sourceType !== "openapi" && sourceType !== "graphql") { | |
| 96 | + if (sourceType !== "openapi" && sourceType !== "graphql" && sourceType !== "mcp") { | |
| 84 | 97 | return null; |
| 85 | 98 | } |
| 86 | 99 | |
| 87 | 100 | const badge = inferredAuthBadge(inferredSpecAuth); |
| 88 | − const scopeHint = authScope === "workspace" ? "Shared with workspace" : "Private to your user"; | |
| 89 | − | |
| 101 | + const mcpBearerConnected = sourceType === "mcp" && authType === "bearer" && mcpOAuthConnected; | |
| 90 | 102 | return ( |
| 91 | − <div className="rounded-xl border border-border/70 bg-gradient-to-br from-muted/60 via-muted/30 to-background p-3 space-y-3"> | |
| 92 | − <div className="flex items-start justify-between gap-2"> | |
| 93 | − <div className="space-y-1"> | |
| 94 | − <div className="flex items-center gap-1.5 text-xs font-medium"> | |
| 95 | − <ShieldCheck className="h-3.5 w-3.5 text-primary" /> | |
| 96 | − Authentication | |
| 97 | − </div> | |
| 98 | − <p className="text-[11px] text-muted-foreground"> | |
| 99 | − Configure runtime and spec access credentials together. | |
| 100 | − </p> | |
| 101 | − </div> | |
| 102 | − {sourceType === "openapi" ? ( | |
| 103 | − <Badge variant="outline" className="text-[10px] uppercase tracking-wider"> | |
| 104 | − {specStatus === "detecting" | |
| 105 | − ? "Inspecting" | |
| 106 | − : specStatus === "ready" | |
| 107 | − ? "Schema ready" | |
| 108 | − : specStatus === "error" | |
| 109 | − ? "Schema error" | |
| 110 | − : "Awaiting URL"} | |
| 111 | − </Badge> | |
| 112 | − ) : ( | |
| 113 | − <Badge variant="outline" className="text-[10px] uppercase tracking-wider"> | |
| 114 | − GraphQL | |
| 115 | − </Badge> | |
| 116 | − )} | |
| 117 | − </div> | |
| 103 | + <div className="space-y-3"> | |
| 118 | 104 | |
| 119 | 105 | {sourceType === "openapi" ? ( |
| 120 | 106 | <div className="flex items-center gap-2 flex-wrap"> |
| 107 | + {specStatus !== "ready" && specStatus !== "detecting" ? ( | |
| 108 | + <Badge variant="outline" className="text-[10px] uppercase tracking-wider"> | |
| 109 | + {specStatus === "error" ? "Schema error" : "Awaiting URL"} | |
| 110 | + </Badge> | |
| 111 | + ) : null} | |
| 121 | 112 | {badge ? ( |
| 122 | 113 | <Badge variant="outline" className="text-[10px] uppercase tracking-wider"> |
| 123 | 114 | {badge} |
| @@ -127,6 +118,30 @@ export function SourceAuthPanel({ · 3 unmodified lines | ||
| 127 | 118 | </div> |
| 128 | 119 | ) : null} |
| 129 | 120 | |
| 121 | + {sourceType === "mcp" ? ( | |
| 122 | + <div className="flex items-center gap-2 flex-wrap"> | |
| 123 | + <Badge variant="outline" className="text-[10px] uppercase tracking-wider"> | |
| 124 | + {mcpOAuthStatus === "idle" | |
| 125 | + ? "Awaiting URL" | |
| 126 | + : mcpOAuthStatus === "checking" | |
| 127 | + ? "Checking OAuth" | |
| 128 | + : mcpOAuthStatus === "oauth" | |
| 129 | + ? "OAuth detected" | |
| 130 | + : mcpOAuthStatus === "error" | |
| 131 | + ? "OAuth unknown" | |
| 132 | + : "No OAuth metadata"} | |
| 133 | + </Badge> | |
| 134 | + {mcpOAuthAuthorizationServers.length > 0 ? ( | |
| 135 | + <span className="text-[10px] text-muted-foreground"> | |
| 136 | + {mcpOAuthAuthorizationServers[0]} | |
| 137 | + </span> | |
| 138 | + ) : null} | |
| 139 | + {mcpOAuthStatus === "error" && mcpOAuthDetail ? ( | |
| 140 | + <span className="text-[10px] text-terminal-amber">{mcpOAuthDetail}</span> | |
| 141 | + ) : null} | |
| 142 | + </div> | |
| 143 | + ) : null} | |
| 144 | + | |
| 130 | 145 | <div className="grid grid-cols-2 gap-3"> |
| 131 | 146 | <div className="space-y-1.5"> |
| 132 | 147 | <Label className="text-xs text-muted-foreground">Auth Type</Label> |
| @@ -158,10 +173,35 @@ export function SourceAuthPanel({ · 25 unmodified lines | ||
| 158 | 173 | <SelectItem value="actor" className="text-xs">Only me</SelectItem> |
| 159 | 174 | </SelectContent> |
| 160 | 175 | </Select> |
| 161 | − <p className="text-[10px] text-muted-foreground">{scopeHint}</p> | |
| 162 | 176 | </div> |
| 163 | 177 | </div> |
| 164 | 178 | |
| 179 | + {sourceType === "mcp" && authType === "bearer" && onMcpOAuthConnect ? ( | |
| 180 | + <div className="space-y-1.5"> | |
| 181 | + <div className="flex items-center gap-2"> | |
| 182 | + <Label className="text-xs text-muted-foreground">OAuth</Label> | |
| 183 | + {mcpBearerConnected ? ( | |
| 184 | + <Badge variant="outline" className="text-[10px] uppercase tracking-wider text-terminal-green"> | |
| 185 | + Connected | |
| 186 | + </Badge> | |
| 187 | + ) : null} | |
| 188 | + </div> | |
| 189 | + <Button | |
| 190 | + type="button" | |
| 191 | + variant="outline" | |
| 192 | + size="sm" | |
| 193 | + className="h-8 text-xs" | |
| 194 | + disabled={mcpOAuthBusy} | |
| 195 | + onClick={onMcpOAuthConnect} | |
| 196 | + > | |
| 197 | + {mcpOAuthBusy ? "Connecting..." : mcpBearerConnected ? "Reconnect OAuth" : "Connect OAuth in popup"} | |
| 198 | + </Button> | |
| 199 | + {mcpBearerConnected ? ( | |
| 200 | + <p className="text-[11px] text-muted-foreground">OAuth linked successfully.</p> | |
| 201 | + ) : null} | |
| 202 | + </div> | |
| 203 | + ) : null} | |
| 204 | + | |
| 165 | 205 | {authType === "apiKey" ? ( |
| 166 | 206 | <div className="space-y-1.5"> |
| 167 | 207 | <Label className="text-xs text-muted-foreground">API Key Header</Label> |
| @@ -174,7 +214,7 @@ export function SourceAuthPanel({ · 6 unmodified lines | ||
| 174 | 214 | </div> |
| 175 | 215 | ) : null} |
| 176 | 216 | |
| 177 | − {authType === "bearer" ? ( | |
| 217 | + {authType === "bearer" && !mcpBearerConnected ? ( | |
| 178 | 218 | <div className="space-y-1.5"> |
| 179 | 219 | <Label className="text-xs text-muted-foreground flex items-center gap-1.5"> |
| 180 | 220 | <LockKeyhole className="h-3 w-3" /> |
| @@ -77,6 +77,7 @@ export function createCustomSourceConfig({ · 76 unmodified lines | ||
| 77 | 77 | if (type === "mcp") { |
| 78 | 78 | return { |
| 79 | 79 | url: endpoint, |
| 80 | + ...(auth ? { auth } : {}), | |
| 80 | 81 | ...(mcpTransport !== "auto" ? { transport: mcpTransport } : {}), |
| 81 | 82 | ...(actorId |
| 82 | 83 | ? { queryParams: { [DEFAULT_MCP_ACTOR_QUERY_PARAM_KEY]: actorId } } |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | import { useState, type ReactNode } from "react"; |
| 4 | 4 | import { Plus } from "lucide-react"; |
| 5 | 5 | import { useAction, useMutation, useQuery } from "convex/react"; |
| 6 | +import { Result } from "better-result"; | |
| 6 | 7 | import { toast } from "sonner"; |
| 7 | 8 | import { Button } from "@/components/ui/button"; |
| 8 | 9 | import { |
| @@ -17,6 +18,7 @@ import { convexApi } from "@/lib/convex-api"; · 8 unmodified lines | ||
| 17 | 18 | import type { CredentialRecord, ToolSourceRecord } from "@/lib/types"; |
| 18 | 19 | import { workspaceQueryArgs } from "@/lib/workspace-query-args"; |
| 19 | 20 | import type { CatalogCollectionItem } from "@/lib/catalog-collections"; |
| 21 | +import { startMcpOAuthPopup } from "@/lib/mcp-oauth-popup"; | |
| 20 | 22 | import { |
| 21 | 23 | CatalogViewSection, |
| 22 | 24 | CustomViewSection, |
| @@ -27,6 +29,31 @@ import { · 4 unmodified lines | ||
| 27 | 29 | } from "./use-add-source-form-state"; |
| 28 | 30 | import { saveSourceWithCredentials } from "./add-source-submit"; |
| 29 | 31 | |
| 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 | + | |
| 45 | +function normalizeEndpoint(value: string): string { | |
| 46 | + const trimmed = value.trim(); | |
| 47 | + if (!trimmed) { | |
| 48 | + return ""; | |
| 49 | + } | |
| 50 | + try { | |
| 51 | + return new URL(trimmed).toString(); | |
| 52 | + } catch { | |
| 53 | + return trimmed; | |
| 54 | + } | |
| 55 | +} | |
| 56 | + | |
| 30 | 57 | export function AddSourceDialog({ |
| 31 | 58 | existingSourceNames, |
| 32 | 59 | onSourceAdded, |
| @@ -49,6 +76,7 @@ export function AddSourceDialog({ · 16 unmodified lines | ||
| 49 | 76 | const credentialsLoading = Boolean(context) && credentials === undefined; |
| 50 | 77 | const [open, setOpen] = useState(false); |
| 51 | 78 | const [submitting, setSubmitting] = useState(false); |
| 79 | + const [mcpOAuthBusy, setMcpOAuthBusy] = useState(false); | |
| 52 | 80 | const form = useAddSourceFormState({ |
| 53 | 81 | open, |
| 54 | 82 | sourceToEdit, |
| @@ -85,8 +113,8 @@ export function AddSourceDialog({ · 30 unmodified lines | ||
| 85 | 113 | } |
| 86 | 114 | |
| 87 | 115 | setSubmitting(true); |
| 88 | − try { | |
| 89 | − const result = await saveSourceWithCredentials({ | |
| 116 | + const saveResult = await Result.tryPromise(() => | |
| 117 | + saveSourceWithCredentials({ | |
| 90 | 118 | context, |
| 91 | 119 | sourceToEdit, |
| 92 | 120 | credentialsLoading, |
| @@ -100,33 +128,80 @@ export function AddSourceDialog({ · 7 unmodified lines | ||
| 100 | 128 | mcpTransport: form.mcpTransport, |
| 101 | 129 | authType: form.authType, |
| 102 | 130 | authScope: form.authScope, |
| 131 | + apiKeyHeader: form.apiKeyHeader, | |
| 103 | 132 | existingScopedCredential: form.existingScopedCredential, |
| 104 | 133 | buildAuthConfig: form.buildAuthConfig, |
| 105 | 134 | hasCredentialInput: form.hasCredentialInput, |
| 106 | 135 | buildSecretJson: form.buildSecretJson, |
| 107 | 136 | }, |
| 108 | − }); | |
| 109 | − | |
| 110 | − onSourceAdded?.(result.source, { connected: result.connected }); | |
| 111 | − toast.success( | |
| 112 | − sourceToEdit | |
| 113 | − ? result.connected | |
| 114 | − ? `Source "${form.name.trim()}" updated with credentials` | |
| 115 | − : `Source "${form.name.trim()}" updated` | |
| 116 | − : result.connected | |
| 117 | − ? `Source "${form.name.trim()}" added with credentials — loading tools…` | |
| 118 | − : `Source "${form.name.trim()}" added — loading tools…`, | |
| 119 | − ); | |
| 120 | − if (!sourceToEdit) { | |
| 121 | − form.reserveSourceName(form.name.trim()); | |
| 122 | − } | |
| 123 | − form.setView("catalog"); | |
| 124 | − setOpen(false); | |
| 125 | − } catch (err) { | |
| 126 | − toast.error(err instanceof Error ? err.message : sourceToEdit ? "Failed to update source" : "Failed to add source"); | |
| 127 | − } finally { | |
| 128 | − setSubmitting(false); | |
| 137 | + }) | |
| 138 | + ); | |
| 139 | + setSubmitting(false); | |
| 140 | + | |
| 141 | + if (!saveResult.isOk()) { | |
| 142 | + toast.error(resultErrorMessage( | |
| 143 | + saveResult.error, | |
| 144 | + sourceToEdit ? "Failed to update source" : "Failed to add source", | |
| 145 | + )); | |
| 146 | + return; | |
| 147 | + } | |
| 148 | + | |
| 149 | + const result = saveResult.value; | |
| 150 | + onSourceAdded?.(result.source, { connected: result.connected }); | |
| 151 | + toast.success( | |
| 152 | + sourceToEdit | |
| 153 | + ? result.connected | |
| 154 | + ? `Source "${form.name.trim()}" updated with credentials` | |
| 155 | + : `Source "${form.name.trim()}" updated` | |
| 156 | + : result.connected | |
| 157 | + ? `Source "${form.name.trim()}" added with credentials — loading tools…` | |
| 158 | + : `Source "${form.name.trim()}" added — loading tools…`, | |
| 159 | + ); | |
| 160 | + if (!sourceToEdit) { | |
| 161 | + form.reserveSourceName(form.name.trim()); | |
| 162 | + } | |
| 163 | + form.setView("catalog"); | |
| 164 | + setOpen(false); | |
| 165 | + }; | |
| 166 | + | |
| 167 | + const handleMcpOAuthConnect = async () => { | |
| 168 | + if (form.type !== "mcp") { | |
| 169 | + return; | |
| 170 | + } | |
| 171 | + const endpoint = form.endpoint.trim(); | |
| 172 | + if (!endpoint) { | |
| 173 | + toast.error("Enter an MCP endpoint URL first"); | |
| 174 | + return; | |
| 175 | + } | |
| 176 | + const initiatedEndpoint = normalizeEndpoint(endpoint); | |
| 177 | + | |
| 178 | + setMcpOAuthBusy(true); | |
| 179 | + const oauthResult = await Result.tryPromise(() => startMcpOAuthPopup(endpoint)); | |
| 180 | + setMcpOAuthBusy(false); | |
| 181 | + | |
| 182 | + if (!oauthResult.isOk()) { | |
| 183 | + toast.error(resultErrorMessage(oauthResult.error, "Failed to connect OAuth")); | |
| 184 | + return; | |
| 185 | + } | |
| 186 | + | |
| 187 | + const returnedEndpoint = normalizeEndpoint(oauthResult.value.sourceUrl); | |
| 188 | + if (returnedEndpoint && initiatedEndpoint && returnedEndpoint !== initiatedEndpoint) { | |
| 189 | + toast.error("OAuth finished for a different endpoint. Try again."); | |
| 190 | + return; | |
| 191 | + } | |
| 192 | + | |
| 193 | + const currentEndpoint = normalizeEndpoint(form.endpoint); | |
| 194 | + if (currentEndpoint !== initiatedEndpoint) { | |
| 195 | + toast.error("Endpoint changed while OAuth was running. Please reconnect OAuth."); | |
| 196 | + return; | |
| 197 | + } | |
| 198 | + | |
| 199 | + if (form.authType !== "bearer") { | |
| 200 | + form.handleAuthTypeChange("bearer"); | |
| 129 | 201 | } |
| 202 | + form.handleAuthFieldChange("tokenValue", oauthResult.value.accessToken); | |
| 203 | + form.markMcpOAuthLinked(initiatedEndpoint); | |
| 204 | + toast.success("OAuth linked successfully."); | |
| 130 | 205 | }; |
| 131 | 206 | |
| 132 | 207 | const dialogTitle = sourceToEdit ? "Edit Tool Source" : "Add Tool Source"; |
| @@ -194,6 +269,10 @@ export function AddSourceDialog({ · 61 unmodified lines | ||
| 194 | 269 | specStatus: form.specStatus, |
| 195 | 270 | inferredSpecAuth: form.inferredSpecAuth, |
| 196 | 271 | specError: form.specError, |
| 272 | + mcpOAuthStatus: form.mcpOAuthStatus, | |
| 273 | + mcpOAuthDetail: form.mcpOAuthDetail, | |
| 274 | + mcpOAuthAuthorizationServers: form.mcpOAuthAuthorizationServers, | |
| 275 | + mcpOAuthConnected: form.mcpOAuthConnected, | |
| 197 | 276 | authType: form.authType, |
| 198 | 277 | authScope: form.authScope, |
| 199 | 278 | apiKeyHeader: form.apiKeyHeader, |
| @@ -206,6 +285,8 @@ export function AddSourceDialog({ · 6 unmodified lines | ||
| 206 | 285 | onAuthTypeChange={form.handleAuthTypeChange} |
| 207 | 286 | onAuthScopeChange={form.handleAuthScopeChange} |
| 208 | 287 | onFieldChange={form.handleAuthFieldChange} |
| 288 | + onMcpOAuthConnect={form.type === "mcp" ? handleMcpOAuthConnect : undefined} | |
| 289 | + mcpOAuthBusy={mcpOAuthBusy} | |
| 209 | 290 | /> |
| 210 | 291 | </CustomViewSection> |
| 211 | 292 | )} |
| @@ -45,6 +45,7 @@ export type AddSourceUiState = { · 44 unmodified lines | ||
| 45 | 45 | locallyReservedNames: string[]; |
| 46 | 46 | authManuallyEdited: boolean; |
| 47 | 47 | authRevision: number; |
| 48 | + mcpOAuthLinkedEndpoint: string | null; | |
| 48 | 49 | }; |
| 49 | 50 | |
| 50 | 51 | export function createDefaultUiState(view: SourceDialogView = "catalog"): AddSourceUiState { |
| @@ -56,6 +57,7 @@ export function createDefaultUiState(view: SourceDialogView = "catalog"): AddSou · 5 unmodified lines | ||
| 56 | 57 | locallyReservedNames: [], |
| 57 | 58 | authManuallyEdited: false, |
| 58 | 59 | authRevision: 0, |
| 60 | + mcpOAuthLinkedEndpoint: null, | |
| 59 | 61 | }; |
| 60 | 62 | } |
| 61 | 63 | |
| @@ -36,12 +36,45 @@ type SaveFormSnapshot = { · 35 unmodified lines | ||
| 36 | 36 | mcpTransport: "auto" | "streamable-http" | "sse"; |
| 37 | 37 | authType: Exclude<SourceAuthType, "mixed">; |
| 38 | 38 | authScope: CredentialScope; |
| 39 | + apiKeyHeader: string; | |
| 39 | 40 | existingScopedCredential: CredentialRecord | null; |
| 40 | 41 | buildAuthConfig: () => Record<string, unknown> | undefined; |
| 41 | 42 | hasCredentialInput: () => boolean; |
| 42 | 43 | buildSecretJson: () => { value?: Record<string, unknown>; error?: string }; |
| 43 | 44 | }; |
| 44 | 45 | |
| 46 | +function readLegacyMcpStaticSecret( | |
| 47 | + sourceToEdit: ToolSourceRecord | undefined, | |
| 48 | + authType: Exclude<SourceAuthType, "mixed">, | |
| 49 | +): Record<string, unknown> | null { | |
| 50 | + if (sourceToEdit?.type !== "mcp") { | |
| 51 | + return null; | |
| 52 | + } | |
| 53 | + | |
| 54 | + const auth = sourceToEdit.config.auth as Record<string, unknown> | undefined; | |
| 55 | + if (!auth || auth.mode !== "static" || auth.type !== authType) { | |
| 56 | + return null; | |
| 57 | + } | |
| 58 | + | |
| 59 | + if (authType === "bearer") { | |
| 60 | + const token = typeof auth.token === "string" ? auth.token.trim() : ""; | |
| 61 | + return token ? { token } : null; | |
| 62 | + } | |
| 63 | + | |
| 64 | + if (authType === "apiKey") { | |
| 65 | + const value = typeof auth.value === "string" ? auth.value.trim() : ""; | |
| 66 | + return value ? { value } : null; | |
| 67 | + } | |
| 68 | + | |
| 69 | + if (authType === "basic") { | |
| 70 | + const username = typeof auth.username === "string" ? auth.username.trim() : ""; | |
| 71 | + const password = typeof auth.password === "string" ? auth.password.trim() : ""; | |
| 72 | + return username && password ? { username, password } : null; | |
| 73 | + } | |
| 74 | + | |
| 75 | + return null; | |
| 76 | +} | |
| 77 | + | |
| 45 | 78 | export async function saveSourceWithCredentials({ |
| 46 | 79 | context, |
| 47 | 80 | sourceToEdit, |
| @@ -57,7 +90,7 @@ export async function saveSourceWithCredentials({ · 9 unmodified lines | ||
| 57 | 90 | upsertToolSource: UpsertToolSourceFn; |
| 58 | 91 | upsertCredential: UpsertCredentialFn; |
| 59 | 92 | }): Promise<{ source: ToolSourceRecord; connected: boolean }> { |
| 60 | − const authConfig = form.type === "openapi" || form.type === "graphql" | |
| 93 | + const authConfig = form.type === "openapi" || form.type === "graphql" || form.type === "mcp" | |
| 61 | 94 | ? form.buildAuthConfig() |
| 62 | 95 | : undefined; |
| 63 | 96 | |
| @@ -81,7 +114,7 @@ export async function saveSourceWithCredentials({ · 17 unmodified lines | ||
| 81 | 114 | |
| 82 | 115 | let linkedCredential = false; |
| 83 | 116 | |
| 84 | − if ((form.type === "openapi" || form.type === "graphql") && form.authType !== "none") { | |
| 117 | + if ((form.type === "openapi" || form.type === "graphql" || form.type === "mcp") && form.authType !== "none") { | |
| 85 | 118 | const sourceKey = sourceKeyForSource(created); |
| 86 | 119 | if (!sourceKey) { |
| 87 | 120 | throw new Error("Failed to resolve source key for credentials"); |
| @@ -117,6 +150,21 @@ export async function saveSourceWithCredentials({ · 29 unmodified lines | ||
| 117 | 150 | throw new Error("Enter credentials for the selected auth type"); |
| 118 | 151 | } |
| 119 | 152 | linkedCredential = true; |
| 153 | + } else if (form.type === "mcp") { | |
| 154 | + const legacySecret = readLegacyMcpStaticSecret(sourceToEdit, form.authType); | |
| 155 | + if (!legacySecret) { | |
| 156 | + throw new Error("Enter credentials to finish setup"); | |
| 157 | + } | |
| 158 | + | |
| 159 | + await upsertCredential({ | |
| 160 | + workspaceId: context.workspaceId, | |
| 161 | + sessionId: context.sessionId, | |
| 162 | + sourceKey, | |
| 163 | + scope: form.authScope, | |
| 164 | + ...(form.authScope === "actor" ? { actorId: context.actorId } : {}), | |
| 165 | + secretJson: legacySecret, | |
| 166 | + }); | |
| 167 | + linkedCredential = true; | |
| 120 | 168 | } else { |
| 121 | 169 | throw new Error("Enter credentials to finish setup"); |
| 122 | 170 | } |
| @@ -28,6 +28,18 @@ import { · 27 unmodified lines | ||
| 28 | 28 | |
| 29 | 29 | export { existingCredentialMatchesAuthType } from "./add-source-form-utils"; |
| 30 | 30 | |
| 31 | +function normalizeEndpointForOAuth(value: string): string { | |
| 32 | + const trimmed = value.trim(); | |
| 33 | + if (!trimmed) { | |
| 34 | + return ""; | |
| 35 | + } | |
| 36 | + try { | |
| 37 | + return new URL(trimmed).toString(); | |
| 38 | + } catch { | |
| 39 | + return trimmed; | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 31 | 43 | function patchUi( |
| 32 | 44 | setUi: Dispatch<SetStateAction<AddSourceUiState>>, |
| 33 | 45 | patch: Partial<AddSourceUiState>, |
| @@ -91,6 +103,16 @@ export function useAddSourceFormState({ · 57 unmodified lines | ||
| 91 | 103 | .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null; |
| 92 | 104 | }, [actorId, credentialItems, editingSourceKey, values.authScope]); |
| 93 | 105 | |
| 106 | + const hasPersistedMcpBearerToken = useMemo(() => { | |
| 107 | + if (values.type !== "mcp" || values.authType !== "bearer") { | |
| 108 | + return false; | |
| 109 | + } | |
| 110 | + if (!existingScopedCredential) { | |
| 111 | + return false; | |
| 112 | + } | |
| 113 | + return existingCredentialMatchesAuthType(existingScopedCredential, "bearer"); | |
| 114 | + }, [existingScopedCredential, values.authType, values.type]); | |
| 115 | + | |
| 94 | 116 | useEffect(() => { |
| 95 | 117 | if (!open) { |
| 96 | 118 | return; |
| @@ -129,6 +151,7 @@ export function useAddSourceFormState({ · 32 unmodified lines | ||
| 129 | 151 | |
| 130 | 152 | const handleEndpointChange = (endpoint: string) => { |
| 131 | 153 | form.setValue("endpoint", endpoint, { shouldDirty: true, shouldTouch: true }); |
| 154 | + patchUi(setUi, { mcpOAuthLinkedEndpoint: null }); | |
| 132 | 155 | |
| 133 | 156 | if (values.type === "openapi") { |
| 134 | 157 | const inferredBaseUrl = deriveBaseUrlFromEndpoint(endpoint); |
| @@ -179,6 +202,7 @@ export function useAddSourceFormState({ · 44 unmodified lines | ||
| 179 | 202 | patchUi(setUi, { |
| 180 | 203 | openApiBaseUrlOptions: inferredBaseUrl ? [inferredBaseUrl] : [], |
| 181 | 204 | authManuallyEdited: false, |
| 205 | + mcpOAuthLinkedEndpoint: null, | |
| 182 | 206 | }); |
| 183 | 207 | if (!baseUrlManuallyEdited) { |
| 184 | 208 | form.setValue("baseUrl", inferredBaseUrl, { shouldDirty: false, shouldTouch: false }); |
| @@ -189,6 +213,7 @@ export function useAddSourceFormState({ · 4 unmodified lines | ||
| 189 | 213 | patchUiWithAuthRevision(setUi, { |
| 190 | 214 | openApiBaseUrlOptions: [], |
| 191 | 215 | authManuallyEdited: false, |
| 216 | + mcpOAuthLinkedEndpoint: null, | |
| 192 | 217 | }); |
| 193 | 218 | form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false }); |
| 194 | 219 | form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false }); |
| @@ -203,12 +228,18 @@ export function useAddSourceFormState({ · 8 unmodified lines | ||
| 203 | 228 | |
| 204 | 229 | const handleAuthTypeChange = (authType: Exclude<SourceAuthType, "mixed">) => { |
| 205 | 230 | form.setValue("authType", authType, { shouldDirty: true, shouldTouch: true }); |
| 206 | − patchUiWithAuthRevision(setUi, { authManuallyEdited: true }); | |
| 231 | + patchUiWithAuthRevision(setUi, { | |
| 232 | + authManuallyEdited: true, | |
| 233 | + ...(authType === "bearer" ? {} : { mcpOAuthLinkedEndpoint: null }), | |
| 234 | + }); | |
| 207 | 235 | }; |
| 208 | 236 | |
| 209 | 237 | const handleAuthScopeChange = (authScope: CredentialScope) => { |
| 210 | 238 | form.setValue("authScope", authScope, { shouldDirty: true, shouldTouch: true }); |
| 211 | − patchUiWithAuthRevision(setUi, { authManuallyEdited: true }); | |
| 239 | + patchUiWithAuthRevision(setUi, { | |
| 240 | + authManuallyEdited: true, | |
| 241 | + mcpOAuthLinkedEndpoint: null, | |
| 242 | + }); | |
| 212 | 243 | }; |
| 213 | 244 | |
| 214 | 245 | const handleAuthFieldChange = (field: SourceAuthPanelEditableField, value: string) => { |
| @@ -220,7 +251,7 @@ export function useAddSourceFormState({ · 5 unmodified lines | ||
| 220 | 251 | |
| 221 | 252 | if (field === "tokenValue") { |
| 222 | 253 | form.setValue("tokenValue", value, { shouldDirty: true, shouldTouch: true }); |
| 223 | − bumpAuthRevision(); | |
| 254 | + patchUiWithAuthRevision(setUi, { mcpOAuthLinkedEndpoint: null }); | |
| 224 | 255 | return; |
| 225 | 256 | } |
| 226 | 257 | if (field === "apiKeyValue") { |
| @@ -319,6 +350,59 @@ export function useAddSourceFormState({ · 92 unmodified lines | ||
| 319 | 350 | : "Failed to fetch spec" |
| 320 | 351 | : ""; |
| 321 | 352 | |
| 353 | + const mcpDetectionEndpoint = useDeferredValue(values.endpoint.trim()); | |
| 354 | + const mcpDetectionEnabled = open | |
| 355 | + && ui.view === "custom" | |
| 356 | + && values.type === "mcp" | |
| 357 | + && mcpDetectionEndpoint.length > 0; | |
| 358 | + | |
| 359 | + const mcpOAuthQuery = useTanstackQuery({ | |
| 360 | + queryKey: ["mcp-oauth-detect", mcpDetectionEndpoint], | |
| 361 | + queryFn: async () => { | |
| 362 | + const response = await fetch(`/mcp/oauth/detect?sourceUrl=${encodeURIComponent(mcpDetectionEndpoint)}`); | |
| 363 | + const json = await response.json() as { | |
| 364 | + oauth?: boolean; | |
| 365 | + authorizationServers?: unknown[]; | |
| 366 | + detail?: string; | |
| 367 | + }; | |
| 368 | + return { | |
| 369 | + oauth: Boolean(json.oauth), | |
| 370 | + authorizationServers: Array.isArray(json.authorizationServers) | |
| 371 | + ? json.authorizationServers.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) | |
| 372 | + : [], | |
| 373 | + detail: typeof json.detail === "string" ? json.detail : "", | |
| 374 | + }; | |
| 375 | + }, | |
| 376 | + enabled: mcpDetectionEnabled, | |
| 377 | + retry: false, | |
| 378 | + staleTime: 60_000, | |
| 379 | + }); | |
| 380 | + | |
| 381 | + const mcpOAuthStatus: "idle" | "checking" | "oauth" | "none" | "error" = !mcpDetectionEnabled | |
| 382 | + ? "idle" | |
| 383 | + : mcpOAuthQuery.isFetching | |
| 384 | + ? "checking" | |
| 385 | + : mcpOAuthQuery.isError | |
| 386 | + ? "error" | |
| 387 | + : mcpOAuthQuery.data?.oauth | |
| 388 | + ? "oauth" | |
| 389 | + : "none"; | |
| 390 | + | |
| 391 | + const mcpOAuthDetail = mcpDetectionEnabled | |
| 392 | + ? mcpOAuthQuery.isError | |
| 393 | + ? mcpOAuthQuery.error instanceof Error | |
| 394 | + ? mcpOAuthQuery.error.message | |
| 395 | + : "OAuth detection failed" | |
| 396 | + : mcpOAuthQuery.data?.detail ?? "" | |
| 397 | + : ""; | |
| 398 | + | |
| 399 | + const mcpOAuthAuthorizationServers = mcpOAuthQuery.data?.authorizationServers ?? []; | |
| 400 | + const mcpOAuthConnected = values.type === "mcp" | |
| 401 | + && values.authType === "bearer" | |
| 402 | + && normalizeEndpointForOAuth(values.endpoint) !== "" | |
| 403 | + && normalizeEndpointForOAuth(values.endpoint) === ui.mcpOAuthLinkedEndpoint | |
| 404 | + && values.tokenValue.trim().length > 0; | |
| 405 | + | |
| 322 | 406 | useEffect(() => { |
| 323 | 407 | if (!inspectionEnabled || !specInspectionQuery.data) { |
| 324 | 408 | return; |
| @@ -377,6 +461,23 @@ export function useAddSourceFormState({ · 52 unmodified lines | ||
| 377 | 461 | } |
| 378 | 462 | }, [inspectionEnabled, inspectionEndpoint, specInspectionQuery.isError, ui.openApiBaseUrlOptions]); |
| 379 | 463 | |
| 464 | + useEffect(() => { | |
| 465 | + if (values.type !== "mcp") { | |
| 466 | + return; | |
| 467 | + } | |
| 468 | + if (mcpOAuthStatus !== "oauth") { | |
| 469 | + return; | |
| 470 | + } | |
| 471 | + if (ui.authManuallyEdited) { | |
| 472 | + return; | |
| 473 | + } | |
| 474 | + if (values.authType !== "none") { | |
| 475 | + return; | |
| 476 | + } | |
| 477 | + form.setValue("authType", "bearer", { shouldDirty: false, shouldTouch: false }); | |
| 478 | + form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false }); | |
| 479 | + }, [form, mcpOAuthStatus, ui.authManuallyEdited, values.authType, values.type]); | |
| 480 | + | |
| 380 | 481 | const isNameTaken = (candidate: string) => { |
| 381 | 482 | const taken = [...getTakenSourceNames()].map((entry) => entry.toLowerCase()); |
| 382 | 483 | return taken.includes(candidate.trim().toLowerCase()); |
| @@ -407,6 +508,11 @@ export function useAddSourceFormState({ · 24 unmodified lines | ||
| 407 | 508 | visibleCatalogItems, |
| 408 | 509 | specStatus, |
| 409 | 510 | specError, |
| 511 | + mcpOAuthStatus, | |
| 512 | + mcpOAuthDetail, | |
| 513 | + mcpOAuthAuthorizationServers, | |
| 514 | + mcpOAuthConnected, | |
| 515 | + hasPersistedMcpBearerToken, | |
| 410 | 516 | inferredSpecAuth, |
| 411 | 517 | authType: values.authType, |
| 412 | 518 | authScope: values.authScope, |
| @@ -423,6 +529,10 @@ export function useAddSourceFormState({ · 10 unmodified lines | ||
| 423 | 529 | handleAuthTypeChange, |
| 424 | 530 | handleAuthScopeChange, |
| 425 | 531 | handleAuthFieldChange, |
| 532 | + markMcpOAuthLinked: (endpoint: string) => | |
| 533 | + patchUi(setUi, { | |
| 534 | + mcpOAuthLinkedEndpoint: normalizeEndpointForOAuth(endpoint), | |
| 535 | + }), | |
| 426 | 536 | setBaseUrl: (baseUrl: string) => form.setValue("baseUrl", baseUrl, { shouldDirty: true, shouldTouch: true }), |
| 427 | 537 | setMcpTransport: (mcpTransport: "auto" | "streamable-http" | "sse") => |
| 428 | 538 | form.setValue("mcpTransport", mcpTransport, { shouldDirty: true, shouldTouch: true }), |
| @@ -0,0 +1,104 @@ | ||
| 1 | +export type McpOAuthPopupSuccess = { | |
| 2 | + sourceUrl: string; | |
| 3 | + accessToken: string; | |
| 4 | + refreshToken?: string; | |
| 5 | + scope?: string; | |
| 6 | + expiresIn?: number; | |
| 7 | +}; | |
| 8 | + | |
| 9 | +type McpOAuthPopupMessage = | |
| 10 | + | { | |
| 11 | + type: "executor:mcp-oauth-result"; | |
| 12 | + ok: true; | |
| 13 | + sourceUrl: string; | |
| 14 | + payload: { | |
| 15 | + accessToken: string; | |
| 16 | + refreshToken?: string; | |
| 17 | + scope?: string; | |
| 18 | + expiresIn?: number; | |
| 19 | + }; | |
| 20 | + } | |
| 21 | + | { | |
| 22 | + type: "executor:mcp-oauth-result"; | |
| 23 | + ok: false; | |
| 24 | + error: string; | |
| 25 | + }; | |
| 26 | + | |
| 27 | +export async function startMcpOAuthPopup(sourceUrl: string): Promise<McpOAuthPopupSuccess> { | |
| 28 | + if (typeof window === "undefined") { | |
| 29 | + throw new Error("OAuth popup is only available in a browser context"); | |
| 30 | + } | |
| 31 | + | |
| 32 | + const startUrl = `/mcp/oauth/start?sourceUrl=${encodeURIComponent(sourceUrl)}`; | |
| 33 | + const popup = window.open( | |
| 34 | + startUrl, | |
| 35 | + "executor-mcp-oauth", | |
| 36 | + "popup=yes,width=520,height=720", | |
| 37 | + ); | |
| 38 | + | |
| 39 | + if (!popup) { | |
| 40 | + throw new Error("Popup blocked. Allow popups and try again."); | |
| 41 | + } | |
| 42 | + | |
| 43 | + return await new Promise<McpOAuthPopupSuccess>((resolve, reject) => { | |
| 44 | + let settled = false; | |
| 45 | + | |
| 46 | + const closeAndReject = (message: string) => { | |
| 47 | + if (settled) return; | |
| 48 | + settled = true; | |
| 49 | + cleanup(); | |
| 50 | + reject(new Error(message)); | |
| 51 | + }; | |
| 52 | + | |
| 53 | + const cleanup = () => { | |
| 54 | + window.removeEventListener("message", onMessage); | |
| 55 | + window.clearInterval(closedPoll); | |
| 56 | + if (!popup.closed) { | |
| 57 | + popup.close(); | |
| 58 | + } | |
| 59 | + }; | |
| 60 | + | |
| 61 | + const onMessage = (event: MessageEvent) => { | |
| 62 | + if (event.origin !== window.location.origin) { | |
| 63 | + return; | |
| 64 | + } | |
| 65 | + | |
| 66 | + const data = event.data as McpOAuthPopupMessage | undefined; | |
| 67 | + if (!data || data.type !== "executor:mcp-oauth-result") { | |
| 68 | + return; | |
| 69 | + } | |
| 70 | + | |
| 71 | + if (!data.ok) { | |
| 72 | + closeAndReject(data.error || "OAuth failed"); | |
| 73 | + return; | |
| 74 | + } | |
| 75 | + | |
| 76 | + if (!data.payload?.accessToken) { | |
| 77 | + closeAndReject("OAuth finished without an access token"); | |
| 78 | + return; | |
| 79 | + } | |
| 80 | + | |
| 81 | + if (settled) { | |
| 82 | + return; | |
| 83 | + } | |
| 84 | + | |
| 85 | + settled = true; | |
| 86 | + cleanup(); | |
| 87 | + resolve({ | |
| 88 | + sourceUrl: data.sourceUrl, | |
| 89 | + accessToken: data.payload.accessToken, | |
| 90 | + ...(data.payload.refreshToken ? { refreshToken: data.payload.refreshToken } : {}), | |
| 91 | + ...(data.payload.scope ? { scope: data.payload.scope } : {}), | |
| 92 | + ...(typeof data.payload.expiresIn === "number" ? { expiresIn: data.payload.expiresIn } : {}), | |
| 93 | + }); | |
| 94 | + }; | |
| 95 | + | |
| 96 | + window.addEventListener("message", onMessage); | |
| 97 | + | |
| 98 | + const closedPoll = window.setInterval(() => { | |
| 99 | + if (popup.closed) { | |
| 100 | + closeAndReject("OAuth popup was closed before completion"); | |
| 101 | + } | |
| 102 | + }, 300); | |
| 103 | + }); | |
| 104 | +} | |
| @@ -0,0 +1,189 @@ | ||
| 1 | +import { randomUUID } from "node:crypto"; | |
| 2 | +import { Result } from "better-result"; | |
| 3 | +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; | |
| 4 | +import type { | |
| 5 | + OAuthClientInformationMixed, | |
| 6 | + OAuthClientMetadata, | |
| 7 | + OAuthTokens, | |
| 8 | +} from "@modelcontextprotocol/sdk/shared/auth.js"; | |
| 9 | + | |
| 10 | +export type McpOAuthPending = { | |
| 11 | + state: string; | |
| 12 | + sourceUrl: string; | |
| 13 | + redirectUrl: string; | |
| 14 | + codeVerifier?: string; | |
| 15 | + clientInformation?: OAuthClientInformationMixed; | |
| 16 | +}; | |
| 17 | + | |
| 18 | +export type McpOAuthPopupResult = { | |
| 19 | + ok: boolean; | |
| 20 | + sourceUrl?: string; | |
| 21 | + accessToken?: string; | |
| 22 | + refreshToken?: string; | |
| 23 | + scope?: string; | |
| 24 | + expiresIn?: number; | |
| 25 | + error?: string; | |
| 26 | +}; | |
| 27 | + | |
| 28 | +const COOKIE_PREFIX = "executor_mcp_oauth_"; | |
| 29 | +export const MCP_OAUTH_RESULT_COOKIE = "executor_mcp_oauth_result"; | |
| 30 | + | |
| 31 | +export function buildPendingCookieName(state: string): string { | |
| 32 | + return `${COOKIE_PREFIX}${state}`; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export function createOAuthState(): string { | |
| 36 | + return randomUUID(); | |
| 37 | +} | |
| 38 | + | |
| 39 | +export function encodePendingCookieValue(pending: McpOAuthPending): string { | |
| 40 | + return Buffer.from(JSON.stringify(pending), "utf8").toString("base64url"); | |
| 41 | +} | |
| 42 | + | |
| 43 | +export function decodePendingCookieValue(raw: string): McpOAuthPending | null { | |
| 44 | + const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8")); | |
| 45 | + if (!decodedResult.isOk()) { | |
| 46 | + return null; | |
| 47 | + } | |
| 48 | + | |
| 49 | + const parsedResult = Result.try(() => JSON.parse(decodedResult.value) as Partial<McpOAuthPending>); | |
| 50 | + if (!parsedResult.isOk()) { | |
| 51 | + return null; | |
| 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 | + }; | |
| 70 | +} | |
| 71 | + | |
| 72 | +export function encodePopupResultCookieValue(result: McpOAuthPopupResult): string { | |
| 73 | + return Buffer.from(JSON.stringify(result), "utf8").toString("base64url"); | |
| 74 | +} | |
| 75 | + | |
| 76 | +export function decodePopupResultCookieValue(raw: string): McpOAuthPopupResult | null { | |
| 77 | + const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8")); | |
| 78 | + if (!decodedResult.isOk()) { | |
| 79 | + return null; | |
| 80 | + } | |
| 81 | + | |
| 82 | + const parsedResult = Result.try(() => JSON.parse(decodedResult.value) as Partial<McpOAuthPopupResult>); | |
| 83 | + if (!parsedResult.isOk()) { | |
| 84 | + return null; | |
| 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 | + }; | |
| 101 | +} | |
| 102 | + | |
| 103 | +export class McpPopupOAuthProvider implements OAuthClientProvider { | |
| 104 | + public clientMetadata: OAuthClientMetadata; | |
| 105 | + private stateValue: string; | |
| 106 | + private redirectTarget: string; | |
| 107 | + private codeVerifierValue?: string; | |
| 108 | + private clientInfo?: OAuthClientInformationMixed; | |
| 109 | + private tokenValue?: OAuthTokens; | |
| 110 | + private authorizationUrl?: string; | |
| 111 | + | |
| 112 | + constructor(input: { | |
| 113 | + redirectUrl: string; | |
| 114 | + state: string; | |
| 115 | + codeVerifier?: string; | |
| 116 | + clientInformation?: OAuthClientInformationMixed; | |
| 117 | + tokens?: OAuthTokens; | |
| 118 | + }) { | |
| 119 | + this.redirectTarget = input.redirectUrl; | |
| 120 | + this.stateValue = input.state; | |
| 121 | + this.codeVerifierValue = input.codeVerifier; | |
| 122 | + this.clientInfo = input.clientInformation; | |
| 123 | + this.tokenValue = input.tokens; | |
| 124 | + this.clientMetadata = { | |
| 125 | + redirect_uris: [input.redirectUrl], | |
| 126 | + grant_types: ["authorization_code", "refresh_token"], | |
| 127 | + response_types: ["code"], | |
| 128 | + token_endpoint_auth_method: "none", | |
| 129 | + client_name: "Executor MCP Connector", | |
| 130 | + }; | |
| 131 | + } | |
| 132 | + | |
| 133 | + get redirectUrl(): string { | |
| 134 | + return this.redirectTarget; | |
| 135 | + } | |
| 136 | + | |
| 137 | + async state(): Promise<string> { | |
| 138 | + return this.stateValue; | |
| 139 | + } | |
| 140 | + | |
| 141 | + clientInformation(): OAuthClientInformationMixed | undefined { | |
| 142 | + return this.clientInfo; | |
| 143 | + } | |
| 144 | + | |
| 145 | + async saveClientInformation(clientInformation: OAuthClientInformationMixed): Promise<void> { | |
| 146 | + this.clientInfo = clientInformation; | |
| 147 | + } | |
| 148 | + | |
| 149 | + tokens(): OAuthTokens | undefined { | |
| 150 | + return this.tokenValue; | |
| 151 | + } | |
| 152 | + | |
| 153 | + async saveTokens(tokens: OAuthTokens): Promise<void> { | |
| 154 | + this.tokenValue = tokens; | |
| 155 | + } | |
| 156 | + | |
| 157 | + async redirectToAuthorization(authorizationUrl: URL): Promise<void> { | |
| 158 | + this.authorizationUrl = authorizationUrl.toString(); | |
| 159 | + } | |
| 160 | + | |
| 161 | + async saveCodeVerifier(codeVerifier: string): Promise<void> { | |
| 162 | + this.codeVerifierValue = codeVerifier; | |
| 163 | + } | |
| 164 | + | |
| 165 | + codeVerifier(): string { | |
| 166 | + if (!this.codeVerifierValue) { | |
| 167 | + throw new Error("Missing PKCE code verifier"); | |
| 168 | + } | |
| 169 | + return this.codeVerifierValue; | |
| 170 | + } | |
| 171 | + | |
| 172 | + getAuthorizationUrl(): string | undefined { | |
| 173 | + return this.authorizationUrl; | |
| 174 | + } | |
| 175 | + | |
| 176 | + toPending(sourceUrl: string): McpOAuthPending { | |
| 177 | + return { | |
| 178 | + state: this.stateValue, | |
| 179 | + sourceUrl, | |
| 180 | + redirectUrl: this.redirectTarget, | |
| 181 | + ...(this.codeVerifierValue ? { codeVerifier: this.codeVerifierValue } : {}), | |
| 182 | + ...(this.clientInfo ? { clientInformation: this.clientInfo } : {}), | |
| 183 | + }; | |
| 184 | + } | |
| 185 | + | |
| 186 | + getTokens(): OAuthTokens | undefined { | |
| 187 | + return this.tokenValue; | |
| 188 | + } | |
| 189 | +} | |
| @@ -0,0 +1,48 @@ | ||
| 1 | +import type { NextRequest } from "next/server"; | |
| 2 | +import { Result } from "better-result"; | |
| 3 | + | |
| 4 | +function firstHeaderValue(value: string | null): string { | |
| 5 | + if (!value) { | |
| 6 | + return ""; | |
| 7 | + } | |
| 8 | + return value.split(",")[0]?.trim() ?? ""; | |
| 9 | +} | |
| 10 | + | |
| 11 | +function configuredExternalOrigin(): string | null { | |
| 12 | + const configured = | |
| 13 | + process.env.EXECUTOR_PUBLIC_ORIGIN | |
| 14 | + ?? process.env.NEXT_PUBLIC_EXECUTOR_HTTP_URL | |
| 15 | + ?? ""; | |
| 16 | + if (!configured.trim()) { | |
| 17 | + return null; | |
| 18 | + } | |
| 19 | + | |
| 20 | + const parsed = Result.try(() => new URL(configured)); | |
| 21 | + if (!parsed.isOk()) { | |
| 22 | + return null; | |
| 23 | + } | |
| 24 | + return parsed.value.origin; | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function getExternalOrigin(request: NextRequest): string { | |
| 28 | + const configured = configuredExternalOrigin(); | |
| 29 | + if (configured) { | |
| 30 | + return configured; | |
| 31 | + } | |
| 32 | + | |
| 33 | + const host = firstHeaderValue(request.headers.get("x-forwarded-host") ?? request.headers.get("host")); | |
| 34 | + const proto = firstHeaderValue(request.headers.get("x-forwarded-proto")) | |
| 35 | + || request.nextUrl.protocol.replace(":", ""); | |
| 36 | + if (host && proto) { | |
| 37 | + const parsed = Result.try(() => new URL(`${proto}://${host}`)); | |
| 38 | + if (parsed.isOk()) { | |
| 39 | + return parsed.value.origin; | |
| 40 | + } | |
| 41 | + } | |
| 42 | + return request.nextUrl.origin; | |
| 43 | +} | |
| 44 | + | |
| 45 | +export function isExternalHttps(request: NextRequest): boolean { | |
| 46 | + const origin = getExternalOrigin(request); | |
| 47 | + return origin.startsWith("https://"); | |
| 48 | +} | |
| @@ -0,0 +1,101 @@ | ||
| 1 | +import { isIP } from "node:net"; | |
| 2 | +import { Result } from "better-result"; | |
| 3 | +import { parse as parseDomain } from "tldts"; | |
| 4 | + | |
| 5 | +function isLocalHost(hostname: string): boolean { | |
| 6 | + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; | |
| 7 | +} | |
| 8 | + | |
| 9 | +function normalizeIpHost(hostname: string): string { | |
| 10 | + if (hostname.startsWith("[") && hostname.endsWith("]")) { | |
| 11 | + return hostname.slice(1, -1); | |
| 12 | + } | |
| 13 | + return hostname; | |
| 14 | +} | |
| 15 | + | |
| 16 | +function isPrivateIpv4(hostname: string): boolean { | |
| 17 | + const parts = hostname.split(".").map((part) => Number.parseInt(part, 10)); | |
| 18 | + if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) { | |
| 19 | + return true; | |
| 20 | + } | |
| 21 | + const [a, b] = parts; | |
| 22 | + if (a === 10) return true; | |
| 23 | + if (a === 127) return true; | |
| 24 | + if (a === 169 && b === 254) return true; | |
| 25 | + if (a === 172 && b >= 16 && b <= 31) return true; | |
| 26 | + if (a === 192 && b === 168) return true; | |
| 27 | + if (a === 100 && b >= 64 && b <= 127) return true; | |
| 28 | + if (a === 0) return true; | |
| 29 | + if (a >= 224) return true; | |
| 30 | + return false; | |
| 31 | +} | |
| 32 | + | |
| 33 | +function isPrivateIpv6(hostname: string): boolean { | |
| 34 | + const value = hostname.toLowerCase(); | |
| 35 | + if (value === "::1") return true; | |
| 36 | + if (value.startsWith("fc") || value.startsWith("fd")) return true; | |
| 37 | + if (value.startsWith("fe8") || value.startsWith("fe9") || value.startsWith("fea") || value.startsWith("feb")) { | |
| 38 | + return true; | |
| 39 | + } | |
| 40 | + | |
| 41 | + if (value.startsWith("::ffff:")) { | |
| 42 | + const mapped = value.slice("::ffff:".length); | |
| 43 | + return isPrivateIpv4(mapped); | |
| 44 | + } | |
| 45 | + | |
| 46 | + return false; | |
| 47 | +} | |
| 48 | + | |
| 49 | +function isPrivateIp(hostname: string): boolean { | |
| 50 | + const normalized = normalizeIpHost(hostname); | |
| 51 | + const family = isIP(normalized); | |
| 52 | + if (family === 4) { | |
| 53 | + return isPrivateIpv4(normalized); | |
| 54 | + } | |
| 55 | + if (family === 6) { | |
| 56 | + return isPrivateIpv6(normalized); | |
| 57 | + } | |
| 58 | + return false; | |
| 59 | +} | |
| 60 | + | |
| 61 | +function isPublicDnsHostname(hostname: string): boolean { | |
| 62 | + const parsed = parseDomain(hostname); | |
| 63 | + return Boolean(parsed.domain && parsed.publicSuffix); | |
| 64 | +} | |
| 65 | + | |
| 66 | +export function parseMcpSourceUrl(raw: string): Result<URL, Error> { | |
| 67 | + return Result.try({ | |
| 68 | + try: () => { | |
| 69 | + const url = new URL(raw); | |
| 70 | + const protocol = url.protocol.toLowerCase(); | |
| 71 | + const hostname = url.hostname.toLowerCase(); | |
| 72 | + | |
| 73 | + if (url.username || url.password) { | |
| 74 | + throw new Error("Credentials in MCP source URL are not allowed"); | |
| 75 | + } | |
| 76 | + | |
| 77 | + if (protocol !== "https:" && protocol !== "http:") { | |
| 78 | + throw new Error("MCP source URL must use https:// (or http:// for localhost in dev)"); | |
| 79 | + } | |
| 80 | + | |
| 81 | + const localAllowed = process.env.NODE_ENV !== "production" || process.env.EXECUTOR_ALLOW_LOCAL_MCP_OAUTH === "1"; | |
| 82 | + if (protocol === "http:" && !(localAllowed && isLocalHost(hostname))) { | |
| 83 | + throw new Error("MCP source URL must use https://"); | |
| 84 | + } | |
| 85 | + | |
| 86 | + if (isPrivateIp(hostname) && !(localAllowed && isLocalHost(hostname))) { | |
| 87 | + throw new Error("Private or local MCP hosts are not allowed"); | |
| 88 | + } | |
| 89 | + | |
| 90 | + if (!isIP(normalizeIpHost(hostname)) && !isPublicDnsHostname(hostname)) { | |
| 91 | + throw new Error("MCP source host must be a public DNS host"); | |
| 92 | + } | |
| 93 | + | |
| 94 | + return url; | |
| 95 | + }, | |
| 96 | + catch: (error) => | |
| 97 | + error instanceof Error | |
| 98 | + ? error | |
| 99 | + : new Error(String(error)), | |
| 100 | + }); | |
| 101 | +} | |
| @@ -72,6 +72,7 @@ export function sourceEndpointLabel(source: ToolSourceRecord): string { · 71 unmodified lines | ||
| 72 | 72 | export function sourceKeyForSource(source: ToolSourceRecord): string | null { |
| 73 | 73 | if (source.type === "openapi") return `source:${source.id}`; |
| 74 | 74 | if (source.type === "graphql") return `source:${source.id}`; |
| 75 | + if (source.type === "mcp") return `source:${source.id}`; | |
| 75 | 76 | return null; |
| 76 | 77 | } |
| 77 | 78 | |
| @@ -146,7 +147,7 @@ export function readSourceAuth( · 68 unmodified lines | ||
| 146 | 147 | header?: string; |
| 147 | 148 | inferred?: boolean; |
| 148 | 149 | } { |
| 149 | − if (source.type !== "openapi" && source.type !== "graphql") { | |
| 150 | + if (source.type !== "openapi" && source.type !== "graphql" && source.type !== "mcp") { | |
| 150 | 151 | return { type: "none" }; |
| 151 | 152 | } |
| 152 | 153 | |
| @@ -84,7 +84,9 @@ export async function ensureWorkspaceTools( · 83 unmodified lines | ||
| 84 | 84 | if (workspaceTools) { |
| 85 | 85 | return workspaceTools; |
| 86 | 86 | } |
| 87 | − const result = await getWorkspaceTools(ctx, task.workspaceId); | |
| 87 | + const result = await getWorkspaceTools(ctx, task.workspaceId, { | |
| 88 | + actorId: task.actorId, | |
| 89 | + }); | |
| 88 | 90 | return result.tools; |
| 89 | 91 | } |
| 90 | 92 | |
| @@ -2,20 +2,25 @@ · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import type { ActionCtx } from "../_generated/server"; |
| 4 | 4 | import { internal } from "../_generated/api"; |
| 5 | +import type { Id } from "../_generated/dataModel"; | |
| 5 | 6 | import { |
| 6 | 7 | compileExternalToolSource, |
| 7 | 8 | compileOpenApiToolSourceFromPrepared, |
| 8 | 9 | prepareOpenApiSpec, |
| 9 | 10 | type CompiledToolSourceArtifact, |
| 10 | 11 | } from "../../core/src/tool-sources"; |
| 12 | +import { resolveCredentialPayload } from "../../core/src/credential-providers"; | |
| 13 | +import { buildStaticAuthHeaders } from "../../core/src/tool-source-auth"; | |
| 11 | 14 | import type { |
| 12 | 15 | ExternalToolSourceConfig, |
| 13 | 16 | GraphqlToolSourceConfig, |
| 14 | 17 | McpToolSourceConfig, |
| 18 | + OpenApiAuth, | |
| 15 | 19 | OpenApiToolSourceConfig, |
| 16 | 20 | PreparedOpenApiSpec, |
| 17 | 21 | } from "../../core/src/tool-source-types"; |
| 18 | 22 | import type { ToolSourceRecord } from "../../core/src/types"; |
| 23 | +import { asPayload } from "../lib/object"; | |
| 19 | 24 | |
| 20 | 25 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; |
| 21 | 26 | |
| @@ -69,6 +74,7 @@ export function normalizeExternalToolSource(raw: { · 47 unmodified lines | ||
| 69 | 74 | sourceId: raw.id, |
| 70 | 75 | sourceKey: `source:${raw.id}`, |
| 71 | 76 | url: config.url, |
| 77 | + auth: config.auth as McpToolSourceConfig["auth"], | |
| 72 | 78 | transport: config.transport as McpToolSourceConfig["transport"], |
| 73 | 79 | queryParams: config.queryParams as McpToolSourceConfig["queryParams"], |
| 74 | 80 | defaultApproval: config.defaultApproval as McpToolSourceConfig["defaultApproval"], |
| @@ -117,6 +123,103 @@ export function normalizeExternalToolSource(raw: { · 42 unmodified lines | ||
| 117 | 123 | return result; |
| 118 | 124 | } |
| 119 | 125 | |
| 126 | +function buildHeadersFromCredentialSecret( | |
| 127 | + auth: OpenApiAuth, | |
| 128 | + secret: Record<string, unknown>, | |
| 129 | +): Record<string, string> { | |
| 130 | + if (auth.type === "bearer") { | |
| 131 | + const token = String(secret.token ?? "").trim(); | |
| 132 | + return token ? { authorization: `Bearer ${token}` } : {}; | |
| 133 | + } | |
| 134 | + | |
| 135 | + if (auth.type === "apiKey") { | |
| 136 | + const value = String(secret.value ?? secret.token ?? "").trim(); | |
| 137 | + return value ? { [auth.header]: value } : {}; | |
| 138 | + } | |
| 139 | + | |
| 140 | + if (auth.type === "basic") { | |
| 141 | + const username = String(secret.username ?? ""); | |
| 142 | + const password = String(secret.password ?? ""); | |
| 143 | + if (!username && !password) { | |
| 144 | + return {}; | |
| 145 | + } | |
| 146 | + const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64"); | |
| 147 | + return { authorization: `Basic ${encoded}` }; | |
| 148 | + } | |
| 149 | + | |
| 150 | + return {}; | |
| 151 | +} | |
| 152 | + | |
| 153 | +async function resolveMcpDiscoveryHeaders( | |
| 154 | + ctx: ActionCtx, | |
| 155 | + source: McpToolSourceConfig, | |
| 156 | + workspaceId: Id<"workspaces">, | |
| 157 | + actorId?: string, | |
| 158 | +): Promise<{ headers: Record<string, string>; warnings: string[] }> { | |
| 159 | + const auth = source.auth; | |
| 160 | + if (!auth || auth.type === "none") { | |
| 161 | + return { headers: {}, warnings: [] }; | |
| 162 | + } | |
| 163 | + | |
| 164 | + const mode = auth.mode ?? "static"; | |
| 165 | + if (mode === "static") { | |
| 166 | + return { headers: buildStaticAuthHeaders(auth), warnings: [] }; | |
| 167 | + } | |
| 168 | + | |
| 169 | + if (!source.sourceKey) { | |
| 170 | + return { | |
| 171 | + headers: {}, | |
| 172 | + warnings: [`Source '${source.name}': missing source key for MCP credential discovery`], | |
| 173 | + }; | |
| 174 | + } | |
| 175 | + | |
| 176 | + const record = await ctx.runQuery(internal.database.resolveCredential, { | |
| 177 | + workspaceId, | |
| 178 | + sourceKey: source.sourceKey, | |
| 179 | + scope: mode, | |
| 180 | + actorId, | |
| 181 | + }); | |
| 182 | + | |
| 183 | + if (!record) { | |
| 184 | + return { | |
| 185 | + headers: {}, | |
| 186 | + warnings: [`Source '${source.name}': missing ${mode} credential for MCP discovery`], | |
| 187 | + }; | |
| 188 | + } | |
| 189 | + | |
| 190 | + try { | |
| 191 | + const secret = await resolveCredentialPayload(record); | |
| 192 | + if (!secret) { | |
| 193 | + return { | |
| 194 | + headers: {}, | |
| 195 | + warnings: [`Source '${source.name}': credential payload unavailable for MCP discovery`], | |
| 196 | + }; | |
| 197 | + } | |
| 198 | + | |
| 199 | + const headers = buildHeadersFromCredentialSecret(auth, secret); | |
| 200 | + const overrideHeaders = asPayload(asPayload(record.overridesJson).headers); | |
| 201 | + for (const [key, value] of Object.entries(overrideHeaders)) { | |
| 202 | + if (!key) continue; | |
| 203 | + headers[key] = String(value); | |
| 204 | + } | |
| 205 | + | |
| 206 | + if (Object.keys(headers).length === 0) { | |
| 207 | + return { | |
| 208 | + headers: {}, | |
| 209 | + warnings: [`Source '${source.name}': credential did not produce MCP auth headers for discovery`], | |
| 210 | + }; | |
| 211 | + } | |
| 212 | + | |
| 213 | + return { headers, warnings: [] }; | |
| 214 | + } catch (error) { | |
| 215 | + const message = error instanceof Error ? error.message : String(error); | |
| 216 | + return { | |
| 217 | + headers: {}, | |
| 218 | + warnings: [`Source '${source.name}': failed to resolve MCP credential for discovery: ${message}`], | |
| 219 | + }; | |
| 220 | + } | |
| 221 | +} | |
| 222 | + | |
| 120 | 223 | async function loadCachedOpenApiSpec( |
| 121 | 224 | ctx: ActionCtx, |
| 122 | 225 | specUrl: string, |
| @@ -175,7 +278,7 @@ async function loadCachedOpenApiSpec( · 52 unmodified lines | ||
| 175 | 278 | export async function loadSourceArtifact( |
| 176 | 279 | ctx: ActionCtx, |
| 177 | 280 | source: ExternalToolSourceConfig, |
| 178 | − options: { includeDts?: boolean } = {}, | |
| 281 | + options: { includeDts?: boolean; workspaceId: Id<"workspaces">; actorId?: string }, | |
| 179 | 282 | ): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[] }> { |
| 180 | 283 | const includeDts = options.includeDts ?? true; |
| 181 | 284 | |
| @@ -196,14 +299,34 @@ export async function loadSourceArtifact( · 14 unmodified lines | ||
| 196 | 299 | } |
| 197 | 300 | } |
| 198 | 301 | |
| 302 | + const preWarnings: string[] = []; | |
| 303 | + let sourceForCompile: ExternalToolSourceConfig = source; | |
| 304 | + | |
| 305 | + if (source.type === "mcp") { | |
| 306 | + const resolved = await resolveMcpDiscoveryHeaders( | |
| 307 | + ctx, | |
| 308 | + source, | |
| 309 | + options.workspaceId, | |
| 310 | + options.actorId, | |
| 311 | + ); | |
| 312 | + preWarnings.push(...resolved.warnings); | |
| 313 | + sourceForCompile = { | |
| 314 | + ...source, | |
| 315 | + discoveryHeaders: resolved.headers, | |
| 316 | + }; | |
| 317 | + } | |
| 318 | + | |
| 199 | 319 | try { |
| 200 | − const artifact = await compileExternalToolSource(source); | |
| 201 | − return { artifact, warnings: [] }; | |
| 320 | + const artifact = await compileExternalToolSource(sourceForCompile); | |
| 321 | + return { artifact, warnings: preWarnings }; | |
| 202 | 322 | } catch (error) { |
| 203 | 323 | const message = error instanceof Error ? error.message : String(error); |
| 204 | 324 | return { |
| 205 | 325 | artifact: undefined, |
| 206 | − warnings: [`Failed to load ${source.type} source '${source.name}': ${message}`], | |
| 326 | + warnings: [ | |
| 327 | + ...preWarnings, | |
| 328 | + `Failed to load ${source.type} source '${source.name}': ${message}`, | |
| 329 | + ], | |
| 207 | 330 | }; |
| 208 | 331 | } |
| 209 | 332 | } |
| @@ -54,6 +54,7 @@ interface GetWorkspaceToolsOptions { · 53 unmodified lines | ||
| 54 | 54 | sourceTimeoutMs?: number; |
| 55 | 55 | allowStaleOnMismatch?: boolean; |
| 56 | 56 | skipCacheRead?: boolean; |
| 57 | + actorId?: string; | |
| 57 | 58 | } |
| 58 | 59 | |
| 59 | 60 | interface WorkspaceToolInventory { |
| @@ -154,16 +155,25 @@ export async function getWorkspaceTools( · 94 unmodified lines | ||
| 154 | 155 | const includeDts = options.includeDts ?? false; |
| 155 | 156 | const sourceTimeoutMs = options.sourceTimeoutMs; |
| 156 | 157 | const allowStaleOnMismatch = options.allowStaleOnMismatch ?? false; |
| 157 | − const skipCacheRead = options.skipCacheRead ?? false; | |
| 158 | + const actorId = options.actorId; | |
| 158 | 159 | const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId })) |
| 159 | 160 | .filter((source: { enabled: boolean }) => source.enabled); |
| 161 | + const hasActorScopedMcpSource = sources.some((source: { type: string; config: Record<string, unknown> }) => { | |
| 162 | + if (source.type !== "mcp") { | |
| 163 | + return false; | |
| 164 | + } | |
| 165 | + const auth = source.config.auth as Record<string, unknown> | undefined; | |
| 166 | + return auth?.mode === "actor"; | |
| 167 | + }); | |
| 168 | + const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource; | |
| 169 | + const skipCacheWrite = hasActorScopedMcpSource; | |
| 160 | 170 | traceStep("listToolSources", listSourcesStartedAt); |
| 161 | 171 | const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi"); |
| 162 | 172 | const signature = sourceSignature(workspaceId, sources); |
| 163 | 173 | const debugBase: Omit<WorkspaceToolsDebug, "mode" | "normalizedSourceCount" | "cacheHit" | "cacheFresh" | "timedOutSources" | "durationMs" | "trace"> = { |
| 164 | − includeDts, | |
| 165 | − sourceTimeoutMs: sourceTimeoutMs ?? null, | |
| 166 | − skipCacheRead, | |
| 174 | + includeDts, | |
| 175 | + sourceTimeoutMs: sourceTimeoutMs ?? null, | |
| 176 | + skipCacheRead, | |
| 167 | 177 | sourceCount: sources.length, |
| 168 | 178 | }; |
| 169 | 179 | |
| @@ -251,7 +261,7 @@ export async function getWorkspaceTools( · 81 unmodified lines | ||
| 251 | 261 | const loadedSources = await Promise.all(configs.map(async (config) => { |
| 252 | 262 | if (!sourceTimeoutMs || sourceTimeoutMs <= 0) { |
| 253 | 263 | return { |
| 254 | − ...(await loadSourceArtifact(ctx, config, { includeDts })), | |
| 264 | + ...(await loadSourceArtifact(ctx, config, { includeDts, workspaceId, actorId })), | |
| 255 | 265 | timedOut: false, |
| 256 | 266 | sourceName: config.name, |
| 257 | 267 | }; |
| @@ -274,7 +284,7 @@ export async function getWorkspaceTools( · 16 unmodified lines | ||
| 274 | 284 | }, sourceTimeoutMs); |
| 275 | 285 | }); |
| 276 | 286 | |
| 277 | − const loadResult = loadSourceArtifact(ctx, config, { includeDts }) | |
| 287 | + const loadResult = loadSourceArtifact(ctx, config, { includeDts, workspaceId, actorId }) | |
| 278 | 288 | .then((result) => ({ ...result, timedOut: false, sourceName: config.name })); |
| 279 | 289 | |
| 280 | 290 | const result = await Promise.race([loadResult, timeoutResult]); |
| @@ -354,18 +364,22 @@ export async function getWorkspaceTools( · 73 unmodified lines | ||
| 354 | 364 | warnings, |
| 355 | 365 | }; |
| 356 | 366 | |
| 357 | − const json = JSON.stringify(snapshot); | |
| 358 | − const blob = new Blob([json], { type: "application/json" }); | |
| 359 | − const storageId = await ctx.storage.store(blob); | |
| 360 | − await ctx.runMutation(internal.workspaceToolCache.putEntry, { | |
| 361 | − workspaceId, | |
| 362 | − signature, | |
| 363 | − storageId, | |
| 364 | − dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId })), | |
| 365 | − toolCount: allTools.length, | |
| 366 | − sizeBytes: json.length, | |
| 367 | − }); | |
| 368 | − traceStep("snapshotWrite", snapshotWriteStartedAt); | |
| 367 | + if (!skipCacheWrite) { | |
| 368 | + const json = JSON.stringify(snapshot); | |
| 369 | + const blob = new Blob([json], { type: "application/json" }); | |
| 370 | + const storageId = await ctx.storage.store(blob); | |
| 371 | + await ctx.runMutation(internal.workspaceToolCache.putEntry, { | |
| 372 | + workspaceId, | |
| 373 | + signature, | |
| 374 | + storageId, | |
| 375 | + dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId })), | |
| 376 | + toolCount: allTools.length, | |
| 377 | + sizeBytes: json.length, | |
| 378 | + }); | |
| 379 | + traceStep("snapshotWrite", snapshotWriteStartedAt); | |
| 380 | + } else { | |
| 381 | + trace.push("snapshotWrite=skipped(actor-scoped-mcp)"); | |
| 382 | + } | |
| 369 | 383 | } catch (error) { |
| 370 | 384 | const msg = error instanceof Error ? error.message : String(error); |
| 371 | 385 | console.warn(`[executor] workspace tool cache write failed for '${workspaceId}': ${msg}`); |
| @@ -413,6 +427,7 @@ async function loadWorkspaceToolInventoryForContext( · 41 unmodified lines | ||
| 413 | 427 | sourceTimeoutMs, |
| 414 | 428 | allowStaleOnMismatch, |
| 415 | 429 | skipCacheRead, |
| 430 | + actorId: context.actorId, | |
| 416 | 431 | }), |
| 417 | 432 | ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }), |
| 418 | 433 | ]); |
| @@ -2,10 +2,26 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; · 1 unmodified line | ||
| 2 | 2 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; |
| 3 | 3 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| 4 | 4 | |
| 5 | +function withHeaders( | |
| 6 | + input: RequestInfo | URL, | |
| 7 | + init: RequestInit | undefined, | |
| 8 | + headers: Record<string, string>, | |
| 9 | +): Promise<Response> { | |
| 10 | + const mergedHeaders = new Headers(init?.headers ?? {}); | |
| 11 | + for (const [key, value] of Object.entries(headers)) { | |
| 12 | + mergedHeaders.set(key, value); | |
| 13 | + } | |
| 14 | + return fetch(input, { | |
| 15 | + ...init, | |
| 16 | + headers: mergedHeaders, | |
| 17 | + }); | |
| 18 | +} | |
| 19 | + | |
| 5 | 20 | export async function connectMcp( |
| 6 | 21 | url: string, |
| 7 | 22 | queryParams: Record<string, string> | undefined, |
| 8 | 23 | preferredTransport?: "sse" | "streamable-http", |
| 24 | + headers?: Record<string, string>, | |
| 9 | 25 | ): Promise<{ client: Client; close: () => Promise<void> }> { |
| 10 | 26 | const endpoint = new URL(url); |
| 11 | 27 | if (queryParams) { |
| @@ -20,20 +36,38 @@ export async function connectMcp( · 8 unmodified lines | ||
| 20 | 36 | ); |
| 21 | 37 | |
| 22 | 38 | if (preferredTransport === "sse") { |
| 23 | − await client.connect(new SSEClientTransport(endpoint)); | |
| 39 | + await client.connect(new SSEClientTransport(endpoint, { | |
| 40 | + requestInit: headers ? { headers } : undefined, | |
| 41 | + eventSourceInit: headers | |
| 42 | + ? { | |
| 43 | + fetch: (input, init) => withHeaders(input, init, headers), | |
| 44 | + } | |
| 45 | + : undefined, | |
| 46 | + })); | |
| 24 | 47 | return { client, close: () => client.close() }; |
| 25 | 48 | } |
| 26 | 49 | |
| 27 | 50 | if (preferredTransport === "streamable-http") { |
| 28 | − await client.connect(new StreamableHTTPClientTransport(endpoint) as Parameters<Client["connect"]>[0]); | |
| 51 | + await client.connect(new StreamableHTTPClientTransport(endpoint, { | |
| 52 | + requestInit: headers ? { headers } : undefined, | |
| 53 | + }) as Parameters<Client["connect"]>[0]); | |
| 29 | 54 | return { client, close: () => client.close() }; |
| 30 | 55 | } |
| 31 | 56 | |
| 32 | 57 | try { |
| 33 | − await client.connect(new StreamableHTTPClientTransport(endpoint) as Parameters<Client["connect"]>[0]); | |
| 58 | + await client.connect(new StreamableHTTPClientTransport(endpoint, { | |
| 59 | + requestInit: headers ? { headers } : undefined, | |
| 60 | + }) as Parameters<Client["connect"]>[0]); | |
| 34 | 61 | return { client, close: () => client.close() }; |
| 35 | 62 | } catch { |
| 36 | − await client.connect(new SSEClientTransport(endpoint)); | |
| 63 | + await client.connect(new SSEClientTransport(endpoint, { | |
| 64 | + requestInit: headers ? { headers } : undefined, | |
| 65 | + eventSourceInit: headers | |
| 66 | + ? { | |
| 67 | + fetch: (input, init) => withHeaders(input, init, headers), | |
| 68 | + } | |
| 69 | + : undefined, | |
| 70 | + })); | |
| 37 | 71 | return { client, close: () => client.close() }; |
| 38 | 72 | } |
| 39 | 73 | } |
| @@ -2,6 +2,7 @@ · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { connectMcp, extractMcpResult } from "../mcp-runtime"; |
| 4 | 4 | import { jsonSchemaTypeHintFallback } from "../openapi-schema-hints"; |
| 5 | +import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool-source-auth"; | |
| 5 | 6 | import { callMcpToolWithReconnect } from "../tool-source-execution"; |
| 6 | 7 | import { sanitizeSegment } from "../tool-path-utils"; |
| 7 | 8 | import type { McpToolSourceConfig } from "../tool-source-types"; |
| @@ -15,11 +16,25 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 7 unmodified lines | ||
| 15 | 16 | ? Object.fromEntries( |
| 16 | 17 | Object.entries(config.queryParams).map(([key, value]) => [key, String(value)]), |
| 17 | 18 | ) |
| 18 | − : undefined; | |
| 19 | + : undefined; | |
| 20 | + const authHeaders = buildStaticAuthHeaders(config.auth); | |
| 21 | + const discoveryHeaders = { | |
| 22 | + ...authHeaders, | |
| 23 | + ...(config.discoveryHeaders ?? {}), | |
| 24 | + }; | |
| 25 | + const credentialSpec = buildCredentialSpec(getCredentialSourceKey(config), config.auth); | |
| 19 | 26 | |
| 20 | − let connection = await connectMcp(config.url, queryParams, config.transport); | |
| 27 | + let connection = await connectMcp(config.url, queryParams, config.transport, discoveryHeaders); | |
| 21 | 28 | |
| 22 | − async function callToolWithReconnect(name: string, input: Record<string, unknown>): Promise<unknown> { | |
| 29 | + async function callToolWithReconnect( | |
| 30 | + name: string, | |
| 31 | + input: Record<string, unknown>, | |
| 32 | + credentialHeaders?: Record<string, string>, | |
| 33 | + ): Promise<unknown> { | |
| 34 | + const mergedHeaders = { | |
| 35 | + ...discoveryHeaders, | |
| 36 | + ...(credentialHeaders ?? {}), | |
| 37 | + }; | |
| 23 | 38 | return await callMcpToolWithReconnect( |
| 24 | 39 | () => connection.client.callTool({ name, arguments: input }), |
| 25 | 40 | async () => { |
| @@ -29,7 +44,7 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 3 unmodified lines | ||
| 29 | 44 | // ignore |
| 30 | 45 | } |
| 31 | 46 | |
| 32 | − connection = await connectMcp(config.url, queryParams, config.transport); | |
| 47 | + connection = await connectMcp(config.url, queryParams, config.transport, mergedHeaders); | |
| 33 | 48 | return await connection.client.callTool({ name, arguments: input }); |
| 34 | 49 | }, |
| 35 | 50 | ); |
| @@ -61,16 +76,18 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 25 unmodified lines | ||
| 61 | 76 | displayReturnsType: compactReturnTypeHint(returnsType), |
| 62 | 77 | ...(argPreviewKeys.length > 0 ? { argPreviewKeys } : {}), |
| 63 | 78 | }, |
| 79 | + credential: credentialSpec, | |
| 64 | 80 | _runSpec: { |
| 65 | 81 | kind: "mcp" as const, |
| 66 | 82 | url: config.url, |
| 67 | 83 | transport: config.transport, |
| 68 | 84 | queryParams: config.queryParams, |
| 85 | + authHeaders, | |
| 69 | 86 | toolName, |
| 70 | 87 | }, |
| 71 | − run: async (input: unknown) => { | |
| 88 | + run: async (input: unknown, context) => { | |
| 72 | 89 | const payload = asRecord(input); |
| 73 | − const result = await callToolWithReconnect(toolName, payload); | |
| 90 | + const result = await callToolWithReconnect(toolName, payload, context.credential?.headers); | |
| 74 | 91 | return extractMcpResult(result); |
| 75 | 92 | }, |
| 76 | 93 | } satisfies ToolDefinition & { _runSpec: SerializedTool["runSpec"] }; |
| @@ -28,6 +28,7 @@ export interface SerializedTool { · 27 unmodified lines | ||
| 28 | 28 | url: string; |
| 29 | 29 | transport?: "sse" | "streamable-http"; |
| 30 | 30 | queryParams?: Record<string, string>; |
| 31 | + authHeaders: Record<string, string>; | |
| 31 | 32 | toolName: string; |
| 32 | 33 | } |
| 33 | 34 | | PostmanSerializedRunSpec |
| @@ -57,8 +58,16 @@ function resolveSerializedRunSpec(tool: ToolDefinition): SerializedTool["runSpec · 23 unmodified lines | ||
| 57 | 58 | return runSpec ?? { kind: "builtin" }; |
| 58 | 59 | } |
| 59 | 60 | |
| 60 | −function buildMcpConnectionKey(url: string, transport?: "sse" | "streamable-http"): string { | |
| 61 | − return `${url}|${transport ?? ""}`; | |
| 61 | +function buildMcpConnectionKey( | |
| 62 | + url: string, | |
| 63 | + transport: "sse" | "streamable-http" | undefined, | |
| 64 | + headers: Record<string, string>, | |
| 65 | +): string { | |
| 66 | + const headerEntries = Object.entries(headers) | |
| 67 | + .sort(([left], [right]) => left.localeCompare(right)) | |
| 68 | + .map(([key, value]) => `${key}:${value}`) | |
| 69 | + .join("|"); | |
| 70 | + return `${url}|${transport ?? ""}|${headerEntries}`; | |
| 62 | 71 | } |
| 63 | 72 | |
| 64 | 73 | function getOrCreateMcpConnection( |
| @@ -137,14 +146,19 @@ export function rehydrateTools( · 72 unmodified lines | ||
| 137 | 146 | |
| 138 | 147 | if (st.runSpec.kind === "mcp") { |
| 139 | 148 | const { url, transport, queryParams, toolName } = st.runSpec; |
| 149 | + const authHeaders = st.runSpec.authHeaders ?? {}; | |
| 140 | 150 | return { |
| 141 | 151 | ...base, |
| 142 | − run: async (input: unknown) => { | |
| 143 | − const connKey = buildMcpConnectionKey(url, transport); | |
| 152 | + run: async (input: unknown, context) => { | |
| 153 | + const mergedHeaders = { | |
| 154 | + ...authHeaders, | |
| 155 | + ...(context.credential?.headers ?? {}), | |
| 156 | + }; | |
| 157 | + const connKey = buildMcpConnectionKey(url, transport, mergedHeaders); | |
| 144 | 158 | let conn = await getOrCreateMcpConnection( |
| 145 | 159 | mcpConnections, |
| 146 | 160 | connKey, |
| 147 | − () => connectMcp(url, queryParams, transport), | |
| 161 | + () => connectMcp(url, queryParams, transport, mergedHeaders), | |
| 148 | 162 | ); |
| 149 | 163 | |
| 150 | 164 | const payload = asRecord(input); |
| @@ -156,7 +170,7 @@ export function rehydrateTools( · 5 unmodified lines | ||
| 156 | 170 | } catch { |
| 157 | 171 | // ignore |
| 158 | 172 | } |
| 159 | − const newConnPromise = connectMcp(url, queryParams, transport); | |
| 173 | + const newConnPromise = connectMcp(url, queryParams, transport, mergedHeaders); | |
| 160 | 174 | mcpConnections.set(connKey, { promise: newConnPromise }); |
| 161 | 175 | conn = await newConnPromise; |
| 162 | 176 | return await conn.client.callTool({ name: toolName, arguments: payload }); |
| @@ -6,6 +6,8 @@ export interface McpToolSourceConfig { · 5 unmodified lines | ||
| 6 | 6 | sourceId?: string; |
| 7 | 7 | sourceKey?: string; |
| 8 | 8 | url: string; |
| 9 | + auth?: OpenApiAuth; | |
| 10 | + discoveryHeaders?: Record<string, string>; | |
| 9 | 11 | transport?: "sse" | "streamable-http"; |
| 10 | 12 | queryParams?: Record<string, string>; |
| 11 | 13 | defaultApproval?: ToolApprovalMode; |
| @@ -256,6 +256,7 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 255 unmodified lines | ||
| 256 | 256 | kind: "mcp" as const, |
| 257 | 257 | url: "https://mcp.example.com/sse", |
| 258 | 258 | transport: "sse" as const, |
| 259 | + authHeaders: {}, | |
| 259 | 260 | toolName: "some_tool", |
| 260 | 261 | }, |
| 261 | 262 | run: async () => "mock", |