Add MCP OAuth source flow and authenticated runtime wiring
Unified · Split
| @@ -92,6 +92,7 @@ · 91 unmodified lines | |||
| 92 | "@streamdown/code": "^1.0.1", | 92 | "@streamdown/code": "^1.0.1", |
| 93 | "@tanstack/react-query": "^5.80.7", | 93 | "@tanstack/react-query": "^5.80.7", |
| 94 | "@workos-inc/authkit-nextjs": "^2.13.0", | 94 | "@workos-inc/authkit-nextjs": "^2.13.0", |
| 95 | "better-result": "^2.7.0", | ||
| 95 | "class-variance-authority": "^0.7.1", | 96 | "class-variance-authority": "^0.7.1", |
| 96 | "clsx": "^2.1.1", | 97 | "clsx": "^2.1.1", |
| 97 | "convex": "^1.31.7", | 98 | "convex": "^1.31.7", |
| @@ -17,6 +17,7 @@ · 16 unmodified lines | |||
| 17 | "@streamdown/code": "^1.0.1", | 17 | "@streamdown/code": "^1.0.1", |
| 18 | "@tanstack/react-query": "^5.80.7", | 18 | "@tanstack/react-query": "^5.80.7", |
| 19 | "@workos-inc/authkit-nextjs": "^2.13.0", | 19 | "@workos-inc/authkit-nextjs": "^2.13.0", |
| 20 | "better-result": "^2.7.0", | ||
| 20 | "class-variance-authority": "^0.7.1", | 21 | "class-variance-authority": "^0.7.1", |
| 21 | "clsx": "^2.1.1", | 22 | "clsx": "^2.1.1", |
| 22 | "convex": "^1.31.7", | 23 | "convex": "^1.31.7", |
| @@ -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 | import { Badge } from "@/components/ui/badge"; | 2 | import { Badge } from "@/components/ui/badge"; |
| 3 | import { Button } from "@/components/ui/button"; | ||
| 3 | import { Input } from "@/components/ui/input"; | 4 | import { Input } from "@/components/ui/input"; |
| 4 | import { Label } from "@/components/ui/label"; | 5 | import { Label } from "@/components/ui/label"; |
| 5 | import { | 6 | import { |
| @@ -25,6 +26,10 @@ export type SourceAuthPanelModel = { · 19 unmodified lines | |||
| 25 | specStatus: "idle" | "detecting" | "ready" | "error"; | 26 | specStatus: "idle" | "detecting" | "ready" | "error"; |
| 26 | inferredSpecAuth: InferredSpecAuth | null; | 27 | inferredSpecAuth: InferredSpecAuth | null; |
| 27 | specError: string; | 28 | specError: string; |
| 29 | mcpOAuthStatus: "idle" | "checking" | "oauth" | "none" | "error"; | ||
| 30 | mcpOAuthDetail: string; | ||
| 31 | mcpOAuthAuthorizationServers: string[]; | ||
| 32 | mcpOAuthConnected: boolean; | ||
| 28 | authType: Exclude<SourceAuthType, "mixed">; | 33 | authType: Exclude<SourceAuthType, "mixed">; |
| 29 | authScope: CredentialScope; | 34 | authScope: CredentialScope; |
| 30 | apiKeyHeader: string; | 35 | apiKeyHeader: string; |
| @@ -51,7 +56,7 @@ function inferredAuthBadge(inferredSpecAuth: InferredSpecAuth | null): string | · 20 unmodified lines | |||
| 51 | if (inferredSpecAuth.type === "bearer") { | 56 | if (inferredSpecAuth.type === "bearer") { |
| 52 | return "Bearer"; | 57 | return "Bearer"; |
| 53 | } | 58 | } |
| 54 | return "No auth"; | 59 | return null; |
| 55 | } | 60 | } |
| 56 | 61 | ||
| 57 | export function SourceAuthPanel({ | 62 | export function SourceAuthPanel({ |
| @@ -59,17 +64,25 @@ export function SourceAuthPanel({ · 1 unmodified line | |||
| 59 | onAuthTypeChange, | 64 | onAuthTypeChange, |
| 60 | onAuthScopeChange, | 65 | onAuthScopeChange, |
| 61 | onFieldChange, | 66 | onFieldChange, |
| 67 | onMcpOAuthConnect, | ||
| 68 | mcpOAuthBusy = false, | ||
| 62 | }: { | 69 | }: { |
| 63 | model: SourceAuthPanelModel; | 70 | model: SourceAuthPanelModel; |
| 64 | onAuthTypeChange: (value: Exclude<SourceAuthType, "mixed">) => void; | 71 | onAuthTypeChange: (value: Exclude<SourceAuthType, "mixed">) => void; |
| 65 | onAuthScopeChange: (value: CredentialScope) => void; | 72 | onAuthScopeChange: (value: CredentialScope) => void; |
| 66 | onFieldChange: (field: SourceAuthPanelEditableField, value: string) => void; | 73 | onFieldChange: (field: SourceAuthPanelEditableField, value: string) => void; |
| 74 | onMcpOAuthConnect?: () => void; | ||
| 75 | mcpOAuthBusy?: boolean; | ||
| 67 | }) { | 76 | }) { |
| 68 | const { | 77 | const { |
| 69 | sourceType, | 78 | sourceType, |
| 70 | specStatus, | 79 | specStatus, |
| 71 | inferredSpecAuth, | 80 | inferredSpecAuth, |
| 72 | specError, | 81 | specError, |
| 82 | mcpOAuthStatus, | ||
| 83 | mcpOAuthDetail, | ||
| 84 | mcpOAuthAuthorizationServers, | ||
| 85 | mcpOAuthConnected, | ||
| 73 | authType, | 86 | authType, |
| 74 | authScope, | 87 | authScope, |
| 75 | apiKeyHeader, | 88 | apiKeyHeader, |
| @@ -80,44 +93,22 @@ export function SourceAuthPanel({ · 4 unmodified lines | |||
| 80 | hasExistingCredential, | 93 | hasExistingCredential, |
| 81 | } = model; | 94 | } = model; |
| 82 | 95 | ||
| 83 | if (sourceType !== "openapi" && sourceType !== "graphql") { | 96 | if (sourceType !== "openapi" && sourceType !== "graphql" && sourceType !== "mcp") { |
| 84 | return null; | 97 | return null; |
| 85 | } | 98 | } |
| 86 | 99 | ||
| 87 | const badge = inferredAuthBadge(inferredSpecAuth); | 100 | const badge = inferredAuthBadge(inferredSpecAuth); |
| 88 | const scopeHint = authScope === "workspace" ? "Shared with workspace" : "Private to your user"; | 101 | const mcpBearerConnected = sourceType === "mcp" && authType === "bearer" && mcpOAuthConnected; |
| 89 | |||
| 90 | return ( | 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"> | 103 | <div className="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> | ||
| 118 | 104 | ||
| 119 | {sourceType === "openapi" ? ( | 105 | {sourceType === "openapi" ? ( |
| 120 | <div className="flex items-center gap-2 flex-wrap"> | 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 | {badge ? ( | 112 | {badge ? ( |
| 122 | <Badge variant="outline" className="text-[10px] uppercase tracking-wider"> | 113 | <Badge variant="outline" className="text-[10px] uppercase tracking-wider"> |
| 123 | {badge} | 114 | {badge} |
| @@ -127,6 +118,30 @@ export function SourceAuthPanel({ · 3 unmodified lines | |||
| 127 | </div> | 118 | </div> |
| 128 | ) : null} | 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 | <div className="grid grid-cols-2 gap-3"> | 145 | <div className="grid grid-cols-2 gap-3"> |
| 131 | <div className="space-y-1.5"> | 146 | <div className="space-y-1.5"> |
| 132 | <Label className="text-xs text-muted-foreground">Auth Type</Label> | 147 | <Label className="text-xs text-muted-foreground">Auth Type</Label> |
| @@ -158,10 +173,35 @@ export function SourceAuthPanel({ · 25 unmodified lines | |||
| 158 | <SelectItem value="actor" className="text-xs">Only me</SelectItem> | 173 | <SelectItem value="actor" className="text-xs">Only me</SelectItem> |
| 159 | </SelectContent> | 174 | </SelectContent> |
| 160 | </Select> | 175 | </Select> |
| 161 | <p className="text-[10px] text-muted-foreground">{scopeHint}</p> | ||
| 162 | </div> | 176 | </div> |
| 163 | </div> | 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 | {authType === "apiKey" ? ( | 205 | {authType === "apiKey" ? ( |
| 166 | <div className="space-y-1.5"> | 206 | <div className="space-y-1.5"> |
| 167 | <Label className="text-xs text-muted-foreground">API Key Header</Label> | 207 | <Label className="text-xs text-muted-foreground">API Key Header</Label> |
| @@ -174,7 +214,7 @@ export function SourceAuthPanel({ · 6 unmodified lines | |||
| 174 | </div> | 214 | </div> |
| 175 | ) : null} | 215 | ) : null} |
| 176 | 216 | ||
| 177 | {authType === "bearer" ? ( | 217 | {authType === "bearer" && !mcpBearerConnected ? ( |
| 178 | <div className="space-y-1.5"> | 218 | <div className="space-y-1.5"> |
| 179 | <Label className="text-xs text-muted-foreground flex items-center gap-1.5"> | 219 | <Label className="text-xs text-muted-foreground flex items-center gap-1.5"> |
| 180 | <LockKeyhole className="h-3 w-3" /> | 220 | <LockKeyhole className="h-3 w-3" /> |
| @@ -77,6 +77,7 @@ export function createCustomSourceConfig({ · 76 unmodified lines | |||
| 77 | if (type === "mcp") { | 77 | if (type === "mcp") { |
| 78 | return { | 78 | return { |
| 79 | url: endpoint, | 79 | url: endpoint, |
| 80 | ...(auth ? { auth } : {}), | ||
| 80 | ...(mcpTransport !== "auto" ? { transport: mcpTransport } : {}), | 81 | ...(mcpTransport !== "auto" ? { transport: mcpTransport } : {}), |
| 81 | ...(actorId | 82 | ...(actorId |
| 82 | ? { queryParams: { [DEFAULT_MCP_ACTOR_QUERY_PARAM_KEY]: actorId } } | 83 | ? { queryParams: { [DEFAULT_MCP_ACTOR_QUERY_PARAM_KEY]: actorId } } |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | |||
| 3 | import { useState, type ReactNode } from "react"; | 3 | import { useState, type ReactNode } from "react"; |
| 4 | import { Plus } from "lucide-react"; | 4 | import { Plus } from "lucide-react"; |
| 5 | import { useAction, useMutation, useQuery } from "convex/react"; | 5 | import { useAction, useMutation, useQuery } from "convex/react"; |
| 6 | import { Result } from "better-result"; | ||
| 6 | import { toast } from "sonner"; | 7 | import { toast } from "sonner"; |
| 7 | import { Button } from "@/components/ui/button"; | 8 | import { Button } from "@/components/ui/button"; |
| 8 | import { | 9 | import { |
| @@ -17,6 +18,7 @@ import { convexApi } from "@/lib/convex-api"; · 8 unmodified lines | |||
| 17 | import type { CredentialRecord, ToolSourceRecord } from "@/lib/types"; | 18 | import type { CredentialRecord, ToolSourceRecord } from "@/lib/types"; |
| 18 | import { workspaceQueryArgs } from "@/lib/workspace-query-args"; | 19 | import { workspaceQueryArgs } from "@/lib/workspace-query-args"; |
| 19 | import type { CatalogCollectionItem } from "@/lib/catalog-collections"; | 20 | import type { CatalogCollectionItem } from "@/lib/catalog-collections"; |
| 21 | import { startMcpOAuthPopup } from "@/lib/mcp-oauth-popup"; | ||
| 20 | import { | 22 | import { |
| 21 | CatalogViewSection, | 23 | CatalogViewSection, |
| 22 | CustomViewSection, | 24 | CustomViewSection, |
| @@ -27,6 +29,31 @@ import { · 4 unmodified lines | |||
| 27 | } from "./use-add-source-form-state"; | 29 | } from "./use-add-source-form-state"; |
| 28 | import { saveSourceWithCredentials } from "./add-source-submit"; | 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 | export function AddSourceDialog({ | 57 | export function AddSourceDialog({ |
| 31 | existingSourceNames, | 58 | existingSourceNames, |
| 32 | onSourceAdded, | 59 | onSourceAdded, |
| @@ -49,6 +76,7 @@ export function AddSourceDialog({ · 16 unmodified lines | |||
| 49 | const credentialsLoading = Boolean(context) && credentials === undefined; | 76 | const credentialsLoading = Boolean(context) && credentials === undefined; |
| 50 | const [open, setOpen] = useState(false); | 77 | const [open, setOpen] = useState(false); |
| 51 | const [submitting, setSubmitting] = useState(false); | 78 | const [submitting, setSubmitting] = useState(false); |
| 79 | const [mcpOAuthBusy, setMcpOAuthBusy] = useState(false); | ||
| 52 | const form = useAddSourceFormState({ | 80 | const form = useAddSourceFormState({ |
| 53 | open, | 81 | open, |
| 54 | sourceToEdit, | 82 | sourceToEdit, |
| @@ -85,8 +113,8 @@ export function AddSourceDialog({ · 30 unmodified lines | |||
| 85 | } | 113 | } |
| 86 | 114 | ||
| 87 | setSubmitting(true); | 115 | setSubmitting(true); |
| 88 | try { | 116 | const saveResult = await Result.tryPromise(() => |
| 89 | const result = await saveSourceWithCredentials({ | 117 | saveSourceWithCredentials({ |
| 90 | context, | 118 | context, |
| 91 | sourceToEdit, | 119 | sourceToEdit, |
| 92 | credentialsLoading, | 120 | credentialsLoading, |
| @@ -100,33 +128,80 @@ export function AddSourceDialog({ · 7 unmodified lines | |||
| 100 | mcpTransport: form.mcpTransport, | 128 | mcpTransport: form.mcpTransport, |
| 101 | authType: form.authType, | 129 | authType: form.authType, |
| 102 | authScope: form.authScope, | 130 | authScope: form.authScope, |
| 131 | apiKeyHeader: form.apiKeyHeader, | ||
| 103 | existingScopedCredential: form.existingScopedCredential, | 132 | existingScopedCredential: form.existingScopedCredential, |
| 104 | buildAuthConfig: form.buildAuthConfig, | 133 | buildAuthConfig: form.buildAuthConfig, |
| 105 | hasCredentialInput: form.hasCredentialInput, | 134 | hasCredentialInput: form.hasCredentialInput, |
| 106 | buildSecretJson: form.buildSecretJson, | 135 | buildSecretJson: form.buildSecretJson, |
| 107 | }, | 136 | }, |
| 108 | }); | 137 | }) |
| 109 | 138 | ); | |
| 110 | onSourceAdded?.(result.source, { connected: result.connected }); | 139 | setSubmitting(false); |
| 111 | toast.success( | 140 | |
| 112 | sourceToEdit | 141 | if (!saveResult.isOk()) { |
| 113 | ? result.connected | 142 | toast.error(resultErrorMessage( |
| 114 | ? `Source "${form.name.trim()}" updated with credentials` | 143 | saveResult.error, |
| 115 | : `Source "${form.name.trim()}" updated` | 144 | sourceToEdit ? "Failed to update source" : "Failed to add source", |
| 116 | : result.connected | 145 | )); |
| 117 | ? `Source "${form.name.trim()}" added with credentials — loading tools…` | 146 | return; |
| 118 | : `Source "${form.name.trim()}" added — loading tools…`, | 147 | } |
| 119 | ); | 148 | |
| 120 | if (!sourceToEdit) { | 149 | const result = saveResult.value; |
| 121 | form.reserveSourceName(form.name.trim()); | 150 | onSourceAdded?.(result.source, { connected: result.connected }); |
| 122 | } | 151 | toast.success( |
| 123 | form.setView("catalog"); | 152 | sourceToEdit |
| 124 | setOpen(false); | 153 | ? result.connected |
| 125 | } catch (err) { | 154 | ? `Source "${form.name.trim()}" updated with credentials` |
| 126 | toast.error(err instanceof Error ? err.message : sourceToEdit ? "Failed to update source" : "Failed to add source"); | 155 | : `Source "${form.name.trim()}" updated` |
| 127 | } finally { | 156 | : result.connected |
| 128 | setSubmitting(false); | 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 | const dialogTitle = sourceToEdit ? "Edit Tool Source" : "Add Tool Source"; | 207 | const dialogTitle = sourceToEdit ? "Edit Tool Source" : "Add Tool Source"; |
| @@ -194,6 +269,10 @@ export function AddSourceDialog({ · 61 unmodified lines | |||
| 194 | specStatus: form.specStatus, | 269 | specStatus: form.specStatus, |
| 195 | inferredSpecAuth: form.inferredSpecAuth, | 270 | inferredSpecAuth: form.inferredSpecAuth, |
| 196 | specError: form.specError, | 271 | specError: form.specError, |
| 272 | mcpOAuthStatus: form.mcpOAuthStatus, | ||
| 273 | mcpOAuthDetail: form.mcpOAuthDetail, | ||
| 274 | mcpOAuthAuthorizationServers: form.mcpOAuthAuthorizationServers, | ||
| 275 | mcpOAuthConnected: form.mcpOAuthConnected, | ||
| 197 | authType: form.authType, | 276 | authType: form.authType, |
| 198 | authScope: form.authScope, | 277 | authScope: form.authScope, |
| 199 | apiKeyHeader: form.apiKeyHeader, | 278 | apiKeyHeader: form.apiKeyHeader, |
| @@ -206,6 +285,8 @@ export function AddSourceDialog({ · 6 unmodified lines | |||
| 206 | onAuthTypeChange={form.handleAuthTypeChange} | 285 | onAuthTypeChange={form.handleAuthTypeChange} |
| 207 | onAuthScopeChange={form.handleAuthScopeChange} | 286 | onAuthScopeChange={form.handleAuthScopeChange} |
| 208 | onFieldChange={form.handleAuthFieldChange} | 287 | onFieldChange={form.handleAuthFieldChange} |
| 288 | onMcpOAuthConnect={form.type === "mcp" ? handleMcpOAuthConnect : undefined} | ||
| 289 | mcpOAuthBusy={mcpOAuthBusy} | ||
| 209 | /> | 290 | /> |
| 210 | </CustomViewSection> | 291 | </CustomViewSection> |
| 211 | )} | 292 | )} |
| @@ -45,6 +45,7 @@ export type AddSourceUiState = { · 44 unmodified lines | |||
| 45 | locallyReservedNames: string[]; | 45 | locallyReservedNames: string[]; |
| 46 | authManuallyEdited: boolean; | 46 | authManuallyEdited: boolean; |
| 47 | authRevision: number; | 47 | authRevision: number; |
| 48 | mcpOAuthLinkedEndpoint: string | null; | ||
| 48 | }; | 49 | }; |
| 49 | 50 | ||
| 50 | export function createDefaultUiState(view: SourceDialogView = "catalog"): AddSourceUiState { | 51 | export function createDefaultUiState(view: SourceDialogView = "catalog"): AddSourceUiState { |
| @@ -56,6 +57,7 @@ export function createDefaultUiState(view: SourceDialogView = "catalog"): AddSou · 5 unmodified lines | |||
| 56 | locallyReservedNames: [], | 57 | locallyReservedNames: [], |
| 57 | authManuallyEdited: false, | 58 | authManuallyEdited: false, |
| 58 | authRevision: 0, | 59 | authRevision: 0, |
| 60 | mcpOAuthLinkedEndpoint: null, | ||
| 59 | }; | 61 | }; |
| 60 | } | 62 | } |
| 61 | 63 | ||
| @@ -36,12 +36,45 @@ type SaveFormSnapshot = { · 35 unmodified lines | |||
| 36 | mcpTransport: "auto" | "streamable-http" | "sse"; | 36 | mcpTransport: "auto" | "streamable-http" | "sse"; |
| 37 | authType: Exclude<SourceAuthType, "mixed">; | 37 | authType: Exclude<SourceAuthType, "mixed">; |
| 38 | authScope: CredentialScope; | 38 | authScope: CredentialScope; |
| 39 | apiKeyHeader: string; | ||
| 39 | existingScopedCredential: CredentialRecord | null; | 40 | existingScopedCredential: CredentialRecord | null; |
| 40 | buildAuthConfig: () => Record<string, unknown> | undefined; | 41 | buildAuthConfig: () => Record<string, unknown> | undefined; |
| 41 | hasCredentialInput: () => boolean; | 42 | hasCredentialInput: () => boolean; |
| 42 | buildSecretJson: () => { value?: Record<string, unknown>; error?: string }; | 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 | export async function saveSourceWithCredentials({ | 78 | export async function saveSourceWithCredentials({ |
| 46 | context, | 79 | context, |
| 47 | sourceToEdit, | 80 | sourceToEdit, |
| @@ -57,7 +90,7 @@ export async function saveSourceWithCredentials({ · 9 unmodified lines | |||
| 57 | upsertToolSource: UpsertToolSourceFn; | 90 | upsertToolSource: UpsertToolSourceFn; |
| 58 | upsertCredential: UpsertCredentialFn; | 91 | upsertCredential: UpsertCredentialFn; |
| 59 | }): Promise<{ source: ToolSourceRecord; connected: boolean }> { | 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 | ? form.buildAuthConfig() | 94 | ? form.buildAuthConfig() |
| 62 | : undefined; | 95 | : undefined; |
| 63 | 96 | ||
| @@ -81,7 +114,7 @@ export async function saveSourceWithCredentials({ · 17 unmodified lines | |||
| 81 | 114 | ||
| 82 | let linkedCredential = false; | 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 | const sourceKey = sourceKeyForSource(created); | 118 | const sourceKey = sourceKeyForSource(created); |
| 86 | if (!sourceKey) { | 119 | if (!sourceKey) { |
| 87 | throw new Error("Failed to resolve source key for credentials"); | 120 | throw new Error("Failed to resolve source key for credentials"); |
| @@ -117,6 +150,21 @@ export async function saveSourceWithCredentials({ · 29 unmodified lines | |||
| 117 | throw new Error("Enter credentials for the selected auth type"); | 150 | throw new Error("Enter credentials for the selected auth type"); |
| 118 | } | 151 | } |
| 119 | linkedCredential = true; | 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 | } else { | 168 | } else { |
| 121 | throw new Error("Enter credentials to finish setup"); | 169 | throw new Error("Enter credentials to finish setup"); |
| 122 | } | 170 | } |
| @@ -28,6 +28,18 @@ import { · 27 unmodified lines | |||
| 28 | 28 | ||
| 29 | export { existingCredentialMatchesAuthType } from "./add-source-form-utils"; | 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 | function patchUi( | 43 | function patchUi( |
| 32 | setUi: Dispatch<SetStateAction<AddSourceUiState>>, | 44 | setUi: Dispatch<SetStateAction<AddSourceUiState>>, |
| 33 | patch: Partial<AddSourceUiState>, | 45 | patch: Partial<AddSourceUiState>, |
| @@ -91,6 +103,16 @@ export function useAddSourceFormState({ · 57 unmodified lines | |||
| 91 | .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null; | 103 | .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null; |
| 92 | }, [actorId, credentialItems, editingSourceKey, values.authScope]); | 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 | useEffect(() => { | 116 | useEffect(() => { |
| 95 | if (!open) { | 117 | if (!open) { |
| 96 | return; | 118 | return; |
| @@ -129,6 +151,7 @@ export function useAddSourceFormState({ · 32 unmodified lines | |||
| 129 | 151 | ||
| 130 | const handleEndpointChange = (endpoint: string) => { | 152 | const handleEndpointChange = (endpoint: string) => { |
| 131 | form.setValue("endpoint", endpoint, { shouldDirty: true, shouldTouch: true }); | 153 | form.setValue("endpoint", endpoint, { shouldDirty: true, shouldTouch: true }); |
| 154 | patchUi(setUi, { mcpOAuthLinkedEndpoint: null }); | ||
| 132 | 155 | ||
| 133 | if (values.type === "openapi") { | 156 | if (values.type === "openapi") { |
| 134 | const inferredBaseUrl = deriveBaseUrlFromEndpoint(endpoint); | 157 | const inferredBaseUrl = deriveBaseUrlFromEndpoint(endpoint); |
| @@ -179,6 +202,7 @@ export function useAddSourceFormState({ · 44 unmodified lines | |||
| 179 | patchUi(setUi, { | 202 | patchUi(setUi, { |
| 180 | openApiBaseUrlOptions: inferredBaseUrl ? [inferredBaseUrl] : [], | 203 | openApiBaseUrlOptions: inferredBaseUrl ? [inferredBaseUrl] : [], |
| 181 | authManuallyEdited: false, | 204 | authManuallyEdited: false, |
| 205 | mcpOAuthLinkedEndpoint: null, | ||
| 182 | }); | 206 | }); |
| 183 | if (!baseUrlManuallyEdited) { | 207 | if (!baseUrlManuallyEdited) { |
| 184 | form.setValue("baseUrl", inferredBaseUrl, { shouldDirty: false, shouldTouch: false }); | 208 | form.setValue("baseUrl", inferredBaseUrl, { shouldDirty: false, shouldTouch: false }); |
| @@ -189,6 +213,7 @@ export function useAddSourceFormState({ · 4 unmodified lines | |||
| 189 | patchUiWithAuthRevision(setUi, { | 213 | patchUiWithAuthRevision(setUi, { |
| 190 | openApiBaseUrlOptions: [], | 214 | openApiBaseUrlOptions: [], |
| 191 | authManuallyEdited: false, | 215 | authManuallyEdited: false, |
| 216 | mcpOAuthLinkedEndpoint: null, | ||
| 192 | }); | 217 | }); |
| 193 | form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false }); | 218 | form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false }); |
| 194 | form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false }); | 219 | form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false }); |
| @@ -203,12 +228,18 @@ export function useAddSourceFormState({ · 8 unmodified lines | |||
| 203 | 228 | ||
| 204 | const handleAuthTypeChange = (authType: Exclude<SourceAuthType, "mixed">) => { | 229 | const handleAuthTypeChange = (authType: Exclude<SourceAuthType, "mixed">) => { |
| 205 | form.setValue("authType", authType, { shouldDirty: true, shouldTouch: true }); | 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 | const handleAuthScopeChange = (authScope: CredentialScope) => { | 237 | const handleAuthScopeChange = (authScope: CredentialScope) => { |
| 210 | form.setValue("authScope", authScope, { shouldDirty: true, shouldTouch: true }); | 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 | const handleAuthFieldChange = (field: SourceAuthPanelEditableField, value: string) => { | 245 | const handleAuthFieldChange = (field: SourceAuthPanelEditableField, value: string) => { |
| @@ -220,7 +251,7 @@ export function useAddSourceFormState({ · 5 unmodified lines | |||
| 220 | 251 | ||
| 221 | if (field === "tokenValue") { | 252 | if (field === "tokenValue") { |
| 222 | form.setValue("tokenValue", value, { shouldDirty: true, shouldTouch: true }); | 253 | form.setValue("tokenValue", value, { shouldDirty: true, shouldTouch: true }); |
| 223 | bumpAuthRevision(); | 254 | patchUiWithAuthRevision(setUi, { mcpOAuthLinkedEndpoint: null }); |
| 224 | return; | 255 | return; |
| 225 | } | 256 | } |
| 226 | if (field === "apiKeyValue") { | 257 | if (field === "apiKeyValue") { |
| @@ -319,6 +350,59 @@ export function useAddSourceFormState({ · 92 unmodified lines | |||
| 319 | : "Failed to fetch spec" | 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 | useEffect(() => { | 406 | useEffect(() => { |
| 323 | if (!inspectionEnabled || !specInspectionQuery.data) { | 407 | if (!inspectionEnabled || !specInspectionQuery.data) { |
| 324 | return; | 408 | return; |
| @@ -377,6 +461,23 @@ export function useAddSourceFormState({ · 52 unmodified lines | |||
| 377 | } | 461 | } |
| 378 | }, [inspectionEnabled, inspectionEndpoint, specInspectionQuery.isError, ui.openApiBaseUrlOptions]); | 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 | const isNameTaken = (candidate: string) => { | 481 | const isNameTaken = (candidate: string) => { |
| 381 | const taken = [...getTakenSourceNames()].map((entry) => entry.toLowerCase()); | 482 | const taken = [...getTakenSourceNames()].map((entry) => entry.toLowerCase()); |
| 382 | return taken.includes(candidate.trim().toLowerCase()); | 483 | return taken.includes(candidate.trim().toLowerCase()); |
| @@ -407,6 +508,11 @@ export function useAddSourceFormState({ · 24 unmodified lines | |||
| 407 | visibleCatalogItems, | 508 | visibleCatalogItems, |
| 408 | specStatus, | 509 | specStatus, |
| 409 | specError, | 510 | specError, |
| 511 | mcpOAuthStatus, | ||
| 512 | mcpOAuthDetail, | ||
| 513 | mcpOAuthAuthorizationServers, | ||
| 514 | mcpOAuthConnected, | ||
| 515 | hasPersistedMcpBearerToken, | ||
| 410 | inferredSpecAuth, | 516 | inferredSpecAuth, |
| 411 | authType: values.authType, | 517 | authType: values.authType, |
| 412 | authScope: values.authScope, | 518 | authScope: values.authScope, |
| @@ -423,6 +529,10 @@ export function useAddSourceFormState({ · 10 unmodified lines | |||
| 423 | handleAuthTypeChange, | 529 | handleAuthTypeChange, |
| 424 | handleAuthScopeChange, | 530 | handleAuthScopeChange, |
| 425 | handleAuthFieldChange, | 531 | handleAuthFieldChange, |
| 532 | markMcpOAuthLinked: (endpoint: string) => | ||
| 533 | patchUi(setUi, { | ||
| 534 | mcpOAuthLinkedEndpoint: normalizeEndpointForOAuth(endpoint), | ||
| 535 | }), | ||
| 426 | setBaseUrl: (baseUrl: string) => form.setValue("baseUrl", baseUrl, { shouldDirty: true, shouldTouch: true }), | 536 | setBaseUrl: (baseUrl: string) => form.setValue("baseUrl", baseUrl, { shouldDirty: true, shouldTouch: true }), |
| 427 | setMcpTransport: (mcpTransport: "auto" | "streamable-http" | "sse") => | 537 | setMcpTransport: (mcpTransport: "auto" | "streamable-http" | "sse") => |
| 428 | form.setValue("mcpTransport", mcpTransport, { shouldDirty: true, shouldTouch: true }), | 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 | export function sourceKeyForSource(source: ToolSourceRecord): string | null { | 72 | export function sourceKeyForSource(source: ToolSourceRecord): string | null { |
| 73 | if (source.type === "openapi") return `source:${source.id}`; | 73 | if (source.type === "openapi") return `source:${source.id}`; |
| 74 | if (source.type === "graphql") return `source:${source.id}`; | 74 | if (source.type === "graphql") return `source:${source.id}`; |
| 75 | if (source.type === "mcp") return `source:${source.id}`; | ||
| 75 | return null; | 76 | return null; |
| 76 | } | 77 | } |
| 77 | 78 | ||
| @@ -146,7 +147,7 @@ export function readSourceAuth( · 68 unmodified lines | |||
| 146 | header?: string; | 147 | header?: string; |
| 147 | inferred?: boolean; | 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 | return { type: "none" }; | 151 | return { type: "none" }; |
| 151 | } | 152 | } |
| 152 | 153 | ||
| @@ -84,7 +84,9 @@ export async function ensureWorkspaceTools( · 83 unmodified lines | |||
| 84 | if (workspaceTools) { | 84 | if (workspaceTools) { |
| 85 | return workspaceTools; | 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 | return result.tools; | 90 | return result.tools; |
| 89 | } | 91 | } |
| 90 | 92 | ||
| @@ -2,20 +2,25 @@ · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import type { ActionCtx } from "../_generated/server"; | 3 | import type { ActionCtx } from "../_generated/server"; |
| 4 | import { internal } from "../_generated/api"; | 4 | import { internal } from "../_generated/api"; |
| 5 | import type { Id } from "../_generated/dataModel"; | ||
| 5 | import { | 6 | import { |
| 6 | compileExternalToolSource, | 7 | compileExternalToolSource, |
| 7 | compileOpenApiToolSourceFromPrepared, | 8 | compileOpenApiToolSourceFromPrepared, |
| 8 | prepareOpenApiSpec, | 9 | prepareOpenApiSpec, |
| 9 | type CompiledToolSourceArtifact, | 10 | type CompiledToolSourceArtifact, |
| 10 | } from "../../core/src/tool-sources"; | 11 | } from "../../core/src/tool-sources"; |
| 12 | import { resolveCredentialPayload } from "../../core/src/credential-providers"; | ||
| 13 | import { buildStaticAuthHeaders } from "../../core/src/tool-source-auth"; | ||
| 11 | import type { | 14 | import type { |
| 12 | ExternalToolSourceConfig, | 15 | ExternalToolSourceConfig, |
| 13 | GraphqlToolSourceConfig, | 16 | GraphqlToolSourceConfig, |
| 14 | McpToolSourceConfig, | 17 | McpToolSourceConfig, |
| 18 | OpenApiAuth, | ||
| 15 | OpenApiToolSourceConfig, | 19 | OpenApiToolSourceConfig, |
| 16 | PreparedOpenApiSpec, | 20 | PreparedOpenApiSpec, |
| 17 | } from "../../core/src/tool-source-types"; | 21 | } from "../../core/src/tool-source-types"; |
| 18 | import type { ToolSourceRecord } from "../../core/src/types"; | 22 | import type { ToolSourceRecord } from "../../core/src/types"; |
| 23 | import { asPayload } from "../lib/object"; | ||
| 19 | 24 | ||
| 20 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; | 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 | sourceId: raw.id, | 74 | sourceId: raw.id, |
| 70 | sourceKey: `source:${raw.id}`, | 75 | sourceKey: `source:${raw.id}`, |
| 71 | url: config.url, | 76 | url: config.url, |
| 77 | auth: config.auth as McpToolSourceConfig["auth"], | ||
| 72 | transport: config.transport as McpToolSourceConfig["transport"], | 78 | transport: config.transport as McpToolSourceConfig["transport"], |
| 73 | queryParams: config.queryParams as McpToolSourceConfig["queryParams"], | 79 | queryParams: config.queryParams as McpToolSourceConfig["queryParams"], |
| 74 | defaultApproval: config.defaultApproval as McpToolSourceConfig["defaultApproval"], | 80 | defaultApproval: config.defaultApproval as McpToolSourceConfig["defaultApproval"], |
| @@ -117,6 +123,103 @@ export function normalizeExternalToolSource(raw: { · 42 unmodified lines | |||
| 117 | return result; | 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 | async function loadCachedOpenApiSpec( | 223 | async function loadCachedOpenApiSpec( |
| 121 | ctx: ActionCtx, | 224 | ctx: ActionCtx, |
| 122 | specUrl: string, | 225 | specUrl: string, |
| @@ -175,7 +278,7 @@ async function loadCachedOpenApiSpec( · 52 unmodified lines | |||
| 175 | export async function loadSourceArtifact( | 278 | export async function loadSourceArtifact( |
| 176 | ctx: ActionCtx, | 279 | ctx: ActionCtx, |
| 177 | source: ExternalToolSourceConfig, | 280 | source: ExternalToolSourceConfig, |
| 178 | options: { includeDts?: boolean } = {}, | 281 | options: { includeDts?: boolean; workspaceId: Id<"workspaces">; actorId?: string }, |
| 179 | ): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[] }> { | 282 | ): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[] }> { |
| 180 | const includeDts = options.includeDts ?? true; | 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 | try { | 319 | try { |
| 200 | const artifact = await compileExternalToolSource(source); | 320 | const artifact = await compileExternalToolSource(sourceForCompile); |
| 201 | return { artifact, warnings: [] }; | 321 | return { artifact, warnings: preWarnings }; |
| 202 | } catch (error) { | 322 | } catch (error) { |
| 203 | const message = error instanceof Error ? error.message : String(error); | 323 | const message = error instanceof Error ? error.message : String(error); |
| 204 | return { | 324 | return { |
| 205 | artifact: undefined, | 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 | sourceTimeoutMs?: number; | 54 | sourceTimeoutMs?: number; |
| 55 | allowStaleOnMismatch?: boolean; | 55 | allowStaleOnMismatch?: boolean; |
| 56 | skipCacheRead?: boolean; | 56 | skipCacheRead?: boolean; |
| 57 | actorId?: string; | ||
| 57 | } | 58 | } |
| 58 | 59 | ||
| 59 | interface WorkspaceToolInventory { | 60 | interface WorkspaceToolInventory { |
| @@ -154,16 +155,25 @@ export async function getWorkspaceTools( · 94 unmodified lines | |||
| 154 | const includeDts = options.includeDts ?? false; | 155 | const includeDts = options.includeDts ?? false; |
| 155 | const sourceTimeoutMs = options.sourceTimeoutMs; | 156 | const sourceTimeoutMs = options.sourceTimeoutMs; |
| 156 | const allowStaleOnMismatch = options.allowStaleOnMismatch ?? false; | 157 | const allowStaleOnMismatch = options.allowStaleOnMismatch ?? false; |
| 157 | const skipCacheRead = options.skipCacheRead ?? false; | 158 | const actorId = options.actorId; |
| 158 | const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId })) | 159 | const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId })) |
| 159 | .filter((source: { enabled: boolean }) => source.enabled); | 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 | traceStep("listToolSources", listSourcesStartedAt); | 170 | traceStep("listToolSources", listSourcesStartedAt); |
| 161 | const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi"); | 171 | const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi"); |
| 162 | const signature = sourceSignature(workspaceId, sources); | 172 | const signature = sourceSignature(workspaceId, sources); |
| 163 | const debugBase: Omit<WorkspaceToolsDebug, "mode" | "normalizedSourceCount" | "cacheHit" | "cacheFresh" | "timedOutSources" | "durationMs" | "trace"> = { | 173 | const debugBase: Omit<WorkspaceToolsDebug, "mode" | "normalizedSourceCount" | "cacheHit" | "cacheFresh" | "timedOutSources" | "durationMs" | "trace"> = { |
| 164 | includeDts, | 174 | includeDts, |
| 165 | sourceTimeoutMs: sourceTimeoutMs ?? null, | 175 | sourceTimeoutMs: sourceTimeoutMs ?? null, |
| 166 | skipCacheRead, | 176 | skipCacheRead, |
| 167 | sourceCount: sources.length, | 177 | sourceCount: sources.length, |
| 168 | }; | 178 | }; |
| 169 | 179 | ||
| @@ -251,7 +261,7 @@ export async function getWorkspaceTools( · 81 unmodified lines | |||
| 251 | const loadedSources = await Promise.all(configs.map(async (config) => { | 261 | const loadedSources = await Promise.all(configs.map(async (config) => { |
| 252 | if (!sourceTimeoutMs || sourceTimeoutMs <= 0) { | 262 | if (!sourceTimeoutMs || sourceTimeoutMs <= 0) { |
| 253 | return { | 263 | return { |
| 254 | ...(await loadSourceArtifact(ctx, config, { includeDts })), | 264 | ...(await loadSourceArtifact(ctx, config, { includeDts, workspaceId, actorId })), |
| 255 | timedOut: false, | 265 | timedOut: false, |
| 256 | sourceName: config.name, | 266 | sourceName: config.name, |
| 257 | }; | 267 | }; |
| @@ -274,7 +284,7 @@ export async function getWorkspaceTools( · 16 unmodified lines | |||
| 274 | }, sourceTimeoutMs); | 284 | }, sourceTimeoutMs); |
| 275 | }); | 285 | }); |
| 276 | 286 | ||
| 277 | const loadResult = loadSourceArtifact(ctx, config, { includeDts }) | 287 | const loadResult = loadSourceArtifact(ctx, config, { includeDts, workspaceId, actorId }) |
| 278 | .then((result) => ({ ...result, timedOut: false, sourceName: config.name })); | 288 | .then((result) => ({ ...result, timedOut: false, sourceName: config.name })); |
| 279 | 289 | ||
| 280 | const result = await Promise.race([loadResult, timeoutResult]); | 290 | const result = await Promise.race([loadResult, timeoutResult]); |
| @@ -354,18 +364,22 @@ export async function getWorkspaceTools( · 73 unmodified lines | |||
| 354 | warnings, | 364 | warnings, |
| 355 | }; | 365 | }; |
| 356 | 366 | ||
| 357 | const json = JSON.stringify(snapshot); | 367 | if (!skipCacheWrite) { |
| 358 | const blob = new Blob([json], { type: "application/json" }); | 368 | const json = JSON.stringify(snapshot); |
| 359 | const storageId = await ctx.storage.store(blob); | 369 | const blob = new Blob([json], { type: "application/json" }); |
| 360 | await ctx.runMutation(internal.workspaceToolCache.putEntry, { | 370 | const storageId = await ctx.storage.store(blob); |
| 361 | workspaceId, | 371 | await ctx.runMutation(internal.workspaceToolCache.putEntry, { |
| 362 | signature, | 372 | workspaceId, |
| 363 | storageId, | 373 | signature, |
| 364 | dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId })), | 374 | storageId, |
| 365 | toolCount: allTools.length, | 375 | dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId })), |
| 366 | sizeBytes: json.length, | 376 | toolCount: allTools.length, |
| 367 | }); | 377 | sizeBytes: json.length, |
| 368 | traceStep("snapshotWrite", snapshotWriteStartedAt); | 378 | }); |
| 379 | traceStep("snapshotWrite", snapshotWriteStartedAt); | ||
| 380 | } else { | ||
| 381 | trace.push("snapshotWrite=skipped(actor-scoped-mcp)"); | ||
| 382 | } | ||
| 369 | } catch (error) { | 383 | } catch (error) { |
| 370 | const msg = error instanceof Error ? error.message : String(error); | 384 | const msg = error instanceof Error ? error.message : String(error); |
| 371 | console.warn(`[executor] workspace tool cache write failed for '${workspaceId}': ${msg}`); | 385 | console.warn(`[executor] workspace tool cache write failed for '${workspaceId}': ${msg}`); |
| @@ -413,6 +427,7 @@ async function loadWorkspaceToolInventoryForContext( · 41 unmodified lines | |||
| 413 | sourceTimeoutMs, | 427 | sourceTimeoutMs, |
| 414 | allowStaleOnMismatch, | 428 | allowStaleOnMismatch, |
| 415 | skipCacheRead, | 429 | skipCacheRead, |
| 430 | actorId: context.actorId, | ||
| 416 | }), | 431 | }), |
| 417 | ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }), | 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 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; | 2 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; |
| 3 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | 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 | export async function connectMcp( | 20 | export async function connectMcp( |
| 6 | url: string, | 21 | url: string, |
| 7 | queryParams: Record<string, string> | undefined, | 22 | queryParams: Record<string, string> | undefined, |
| 8 | preferredTransport?: "sse" | "streamable-http", | 23 | preferredTransport?: "sse" | "streamable-http", |
| 24 | headers?: Record<string, string>, | ||
| 9 | ): Promise<{ client: Client; close: () => Promise<void> }> { | 25 | ): Promise<{ client: Client; close: () => Promise<void> }> { |
| 10 | const endpoint = new URL(url); | 26 | const endpoint = new URL(url); |
| 11 | if (queryParams) { | 27 | if (queryParams) { |
| @@ -20,20 +36,38 @@ export async function connectMcp( · 8 unmodified lines | |||
| 20 | ); | 36 | ); |
| 21 | 37 | ||
| 22 | if (preferredTransport === "sse") { | 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 | return { client, close: () => client.close() }; | 47 | return { client, close: () => client.close() }; |
| 25 | } | 48 | } |
| 26 | 49 | ||
| 27 | if (preferredTransport === "streamable-http") { | 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 | return { client, close: () => client.close() }; | 54 | return { client, close: () => client.close() }; |
| 30 | } | 55 | } |
| 31 | 56 | ||
| 32 | try { | 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 | return { client, close: () => client.close() }; | 61 | return { client, close: () => client.close() }; |
| 35 | } catch { | 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 | return { client, close: () => client.close() }; | 71 | return { client, close: () => client.close() }; |
| 38 | } | 72 | } |
| 39 | } | 73 | } |
| @@ -2,6 +2,7 @@ · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { connectMcp, extractMcpResult } from "../mcp-runtime"; | 3 | import { connectMcp, extractMcpResult } from "../mcp-runtime"; |
| 4 | import { jsonSchemaTypeHintFallback } from "../openapi-schema-hints"; | 4 | import { jsonSchemaTypeHintFallback } from "../openapi-schema-hints"; |
| 5 | import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool-source-auth"; | ||
| 5 | import { callMcpToolWithReconnect } from "../tool-source-execution"; | 6 | import { callMcpToolWithReconnect } from "../tool-source-execution"; |
| 6 | import { sanitizeSegment } from "../tool-path-utils"; | 7 | import { sanitizeSegment } from "../tool-path-utils"; |
| 7 | import type { McpToolSourceConfig } from "../tool-source-types"; | 8 | import type { McpToolSourceConfig } from "../tool-source-types"; |
| @@ -15,11 +16,25 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 7 unmodified lines | |||
| 15 | ? Object.fromEntries( | 16 | ? Object.fromEntries( |
| 16 | Object.entries(config.queryParams).map(([key, value]) => [key, String(value)]), | 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 | return await callMcpToolWithReconnect( | 38 | return await callMcpToolWithReconnect( |
| 24 | () => connection.client.callTool({ name, arguments: input }), | 39 | () => connection.client.callTool({ name, arguments: input }), |
| 25 | async () => { | 40 | async () => { |
| @@ -29,7 +44,7 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 3 unmodified lines | |||
| 29 | // ignore | 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 | return await connection.client.callTool({ name, arguments: input }); | 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 | displayReturnsType: compactReturnTypeHint(returnsType), | 76 | displayReturnsType: compactReturnTypeHint(returnsType), |
| 62 | ...(argPreviewKeys.length > 0 ? { argPreviewKeys } : {}), | 77 | ...(argPreviewKeys.length > 0 ? { argPreviewKeys } : {}), |
| 63 | }, | 78 | }, |
| 79 | credential: credentialSpec, | ||
| 64 | _runSpec: { | 80 | _runSpec: { |
| 65 | kind: "mcp" as const, | 81 | kind: "mcp" as const, |
| 66 | url: config.url, | 82 | url: config.url, |
| 67 | transport: config.transport, | 83 | transport: config.transport, |
| 68 | queryParams: config.queryParams, | 84 | queryParams: config.queryParams, |
| 85 | authHeaders, | ||
| 69 | toolName, | 86 | toolName, |
| 70 | }, | 87 | }, |
| 71 | run: async (input: unknown) => { | 88 | run: async (input: unknown, context) => { |
| 72 | const payload = asRecord(input); | 89 | const payload = asRecord(input); |
| 73 | const result = await callToolWithReconnect(toolName, payload); | 90 | const result = await callToolWithReconnect(toolName, payload, context.credential?.headers); |
| 74 | return extractMcpResult(result); | 91 | return extractMcpResult(result); |
| 75 | }, | 92 | }, |
| 76 | } satisfies ToolDefinition & { _runSpec: SerializedTool["runSpec"] }; | 93 | } satisfies ToolDefinition & { _runSpec: SerializedTool["runSpec"] }; |
| @@ -28,6 +28,7 @@ export interface SerializedTool { · 27 unmodified lines | |||
| 28 | url: string; | 28 | url: string; |
| 29 | transport?: "sse" | "streamable-http"; | 29 | transport?: "sse" | "streamable-http"; |
| 30 | queryParams?: Record<string, string>; | 30 | queryParams?: Record<string, string>; |
| 31 | authHeaders: Record<string, string>; | ||
| 31 | toolName: string; | 32 | toolName: string; |
| 32 | } | 33 | } |
| 33 | | PostmanSerializedRunSpec | 34 | | PostmanSerializedRunSpec |
| @@ -57,8 +58,16 @@ function resolveSerializedRunSpec(tool: ToolDefinition): SerializedTool["runSpec · 23 unmodified lines | |||
| 57 | return runSpec ?? { kind: "builtin" }; | 58 | return runSpec ?? { kind: "builtin" }; |
| 58 | } | 59 | } |
| 59 | 60 | ||
| 60 | function buildMcpConnectionKey(url: string, transport?: "sse" | "streamable-http"): string { | 61 | function buildMcpConnectionKey( |
| 61 | return `${url}|${transport ?? ""}`; | 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 | function getOrCreateMcpConnection( | 73 | function getOrCreateMcpConnection( |
| @@ -137,14 +146,19 @@ export function rehydrateTools( · 72 unmodified lines | |||
| 137 | 146 | ||
| 138 | if (st.runSpec.kind === "mcp") { | 147 | if (st.runSpec.kind === "mcp") { |
| 139 | const { url, transport, queryParams, toolName } = st.runSpec; | 148 | const { url, transport, queryParams, toolName } = st.runSpec; |
| 149 | const authHeaders = st.runSpec.authHeaders ?? {}; | ||
| 140 | return { | 150 | return { |
| 141 | ...base, | 151 | ...base, |
| 142 | run: async (input: unknown) => { | 152 | run: async (input: unknown, context) => { |
| 143 | const connKey = buildMcpConnectionKey(url, transport); | 153 | const mergedHeaders = { |
| 154 | ...authHeaders, | ||
| 155 | ...(context.credential?.headers ?? {}), | ||
| 156 | }; | ||
| 157 | const connKey = buildMcpConnectionKey(url, transport, mergedHeaders); | ||
| 144 | let conn = await getOrCreateMcpConnection( | 158 | let conn = await getOrCreateMcpConnection( |
| 145 | mcpConnections, | 159 | mcpConnections, |
| 146 | connKey, | 160 | connKey, |
| 147 | () => connectMcp(url, queryParams, transport), | 161 | () => connectMcp(url, queryParams, transport, mergedHeaders), |
| 148 | ); | 162 | ); |
| 149 | 163 | ||
| 150 | const payload = asRecord(input); | 164 | const payload = asRecord(input); |
| @@ -156,7 +170,7 @@ export function rehydrateTools( · 5 unmodified lines | |||
| 156 | } catch { | 170 | } catch { |
| 157 | // ignore | 171 | // ignore |
| 158 | } | 172 | } |
| 159 | const newConnPromise = connectMcp(url, queryParams, transport); | 173 | const newConnPromise = connectMcp(url, queryParams, transport, mergedHeaders); |
| 160 | mcpConnections.set(connKey, { promise: newConnPromise }); | 174 | mcpConnections.set(connKey, { promise: newConnPromise }); |
| 161 | conn = await newConnPromise; | 175 | conn = await newConnPromise; |
| 162 | return await conn.client.callTool({ name: toolName, arguments: payload }); | 176 | return await conn.client.callTool({ name: toolName, arguments: payload }); |
| @@ -6,6 +6,8 @@ export interface McpToolSourceConfig { · 5 unmodified lines | |||
| 6 | sourceId?: string; | 6 | sourceId?: string; |
| 7 | sourceKey?: string; | 7 | sourceKey?: string; |
| 8 | url: string; | 8 | url: string; |
| 9 | auth?: OpenApiAuth; | ||
| 10 | discoveryHeaders?: Record<string, string>; | ||
| 9 | transport?: "sse" | "streamable-http"; | 11 | transport?: "sse" | "streamable-http"; |
| 10 | queryParams?: Record<string, string>; | 12 | queryParams?: Record<string, string>; |
| 11 | defaultApproval?: ToolApprovalMode; | 13 | defaultApproval?: ToolApprovalMode; |
| @@ -256,6 +256,7 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 255 unmodified lines | |||
| 256 | kind: "mcp" as const, | 256 | kind: "mcp" as const, |
| 257 | url: "https://mcp.example.com/sse", | 257 | url: "https://mcp.example.com/sse", |
| 258 | transport: "sse" as const, | 258 | transport: "sse" as const, |
| 259 | authHeaders: {}, | ||
| 259 | toolName: "some_tool", | 260 | toolName: "some_tool", |
| 260 | }, | 261 | }, |
| 261 | run: async () => "mock", | 262 | run: async () => "mock", |