diff --git a/src/lib/components/mcp/AddServerForm.svelte b/src/lib/components/mcp/AddServerForm.svelte index a5bc035d5bf..6f5b5a623f7 100644 --- a/src/lib/components/mcp/AddServerForm.svelte +++ b/src/lib/components/mcp/AddServerForm.svelte @@ -5,6 +5,7 @@ validateHeader, isSensitiveHeader, } from "$lib/utils/mcpValidation"; + import { discoverServer, type DiscoveryResponse } from "$lib/utils/mcpOAuth"; import IconEye from "~icons/carbon/view"; import IconEyeOff from "~icons/carbon/view-off"; import IconTrash from "~icons/carbon/trash-can"; @@ -12,7 +13,12 @@ import IconWarning from "~icons/carbon/warning"; interface Props { - onsubmit: (server: { name: string; url: string; headers?: KeyValuePair[] }) => void; + onsubmit: (server: { + name: string; + url: string; + headers?: KeyValuePair[]; + discovery?: DiscoveryResponse; + }) => void; oncancel: () => void; initialName?: string; initialUrl?: string; @@ -32,6 +38,8 @@ let name = $state(""); let url = $state(""); let headers = $state([]); + let probing = $state(false); + let discoveryFailed = $state(false); $effect.pre(() => { name = initialName; @@ -41,6 +49,16 @@ let showHeaderValues = $state>({}); let error = $state(null); + // Reset the "skip discovery" escape hatch whenever the user edits the URL. + // Otherwise a typo'd URL that failed discovery once would silently bypass + // auto-discovery on every subsequent corrected URL too, leaving real + // OAuth-protected servers added without an oauth state. + $effect(() => { + // Track url as a reactive dep + void url; + discoveryFailed = false; + }); + function addHeader() { headers = [...headers, { key: "", value: "" }]; } @@ -90,16 +108,40 @@ return true; } - function handleSubmit() { + async function handleSubmit() { if (!validate()) return; - // Filter out empty headers const filteredHeaders = headers.filter((h) => h.key.trim() && h.value.trim()); + const finalUrl = url.trim(); + + // Skip the OAuth probe entirely if the user already provided manual auth + // headers — this preserves the existing custom-server flow for API-key + // based servers without an extra round trip. + const hasManualAuth = filteredHeaders.some((h) => h.key.toLowerCase() === "authorization"); + let discovery: DiscoveryResponse | undefined; + if (!hasManualAuth && !discoveryFailed) { + probing = true; + try { + discovery = await discoverServer(finalUrl); + } catch (e) { + // Discovery failed (network, broken server, AS down). Surface the + // error inline so the user can read it; on next submit we skip + // auto-discovery and add the server as-is. + probing = false; + discoveryFailed = true; + const msg = e instanceof Error ? e.message : "Discovery failed"; + error = `Could not contact server: ${msg}. Submit again to add it without auto-discovery.`; + return; + } finally { + probing = false; + } + } onsubmit({ name: name.trim(), - url: url.trim(), + url: finalUrl, headers: filteredHeaders.length > 0 ? filteredHeaders : undefined, + discovery, }); } @@ -248,9 +290,10 @@ diff --git a/src/lib/components/mcp/AuthorizeStep.svelte b/src/lib/components/mcp/AuthorizeStep.svelte new file mode 100644 index 00000000000..295c33cf8f6 --- /dev/null +++ b/src/lib/components/mcp/AuthorizeStep.svelte @@ -0,0 +1,218 @@ + + +
+
+ +
+

This server requires authorization

+

+ It is hosted at {serverUrl} + and uses + {issuerHost} + to sign you in. You'll be redirected to grant access; tokens are stored in this browser only. +

+
+
+ + {#if phase === "manual"} +
+

+ The authorization server doesn't support automatic client registration. +
Paste a Client ID you have already registered with + {issuerHost}. +

+ + +
+ {/if} + + {#if phase === "popup"} +
+ + Waiting for you to complete authorization in the popup window… +
+ {/if} + + {#if phase === "done"} +
+ + Authorized +
+ {/if} + + {#if errorMessage} +
+ +
{errorMessage}
+
+ {/if} + + {#if popupBlocked} +
+ Popup was blocked — falling back to full-page redirect… +
+ {/if} + +
+ + +
+
diff --git a/src/lib/components/mcp/MCPServerManager.svelte b/src/lib/components/mcp/MCPServerManager.svelte index 68d7dd69fa3..cb6d9752936 100644 --- a/src/lib/components/mcp/MCPServerManager.svelte +++ b/src/lib/components/mcp/MCPServerManager.svelte @@ -3,6 +3,7 @@ import Modal from "$lib/components/Modal.svelte"; import ServerCard from "./ServerCard.svelte"; import AddServerForm from "./AddServerForm.svelte"; + import AuthorizeStep from "./AuthorizeStep.svelte"; import { allMcpServers, selectedServerIds, @@ -10,8 +11,13 @@ addCustomServer, refreshMcpServers, healthCheckServer, + setServerOAuth, + setServerTokens, + toggleServer, } from "$lib/stores/mcpServers"; - import type { KeyValuePair } from "$lib/types/Tool"; + import type { KeyValuePair, MCPClientInformation, MCPOAuthState } from "$lib/types/Tool"; + import type { DiscoveryResponse, OAuthCallbackPayload } from "$lib/utils/mcpOAuth"; + import { error } from "$lib/stores/errors"; import IconAddLarge from "~icons/carbon/add-large"; import IconRefresh from "~icons/carbon/renew"; import LucideHammer from "~icons/lucide/hammer"; @@ -25,16 +31,105 @@ let { onclose }: Props = $props(); - type View = "list" | "add"; + type View = "list" | "add" | "authorize"; let currentView = $state("list"); let isRefreshing = $state(false); + let pendingAuth = $state<{ + serverId: string; + serverUrl: string; + discovery: DiscoveryResponse; + } | null>(null); const baseServers = $derived($allMcpServers.filter((s) => s.type === "base")); const customServers = $derived($allMcpServers.filter((s) => s.type === "custom")); const enabledCount = $derived($enabledServersCount); - function handleAddServer(serverData: { name: string; url: string; headers?: KeyValuePair[] }) { - addCustomServer(serverData); + function handleAddServer(serverData: { + name: string; + url: string; + headers?: KeyValuePair[]; + discovery?: DiscoveryResponse; + }) { + // Save a partial oauth state on add (asMetadata + resource) even when DCR + // produced no clientInfo — the manual-client-id flow inside AuthorizeStep + // will fill it in via handleAuthorized below. Without this, setServerTokens + // would refuse to persist tokens later because s.oauth would be undefined. + const oauth: MCPOAuthState | undefined = + serverData.discovery?.requiresAuth && + serverData.discovery.asMetadata && + serverData.discovery.resource + ? { + resource: serverData.discovery.resource, + asMetadata: serverData.discovery.asMetadata, + resourceMetadataUrl: serverData.discovery.resourceMetadataUrl, + clientInfo: serverData.discovery.clientInfo ?? { + client_id: "", + redirect_uris: [], + }, + clientWasManuallyEntered: !serverData.discovery.supportsDcr, + } + : undefined; + const id = addCustomServer({ + name: serverData.name, + url: serverData.url, + headers: serverData.headers, + oauth, + authRequired: !!oauth, + }); + // Route to the authorize step only when we built a usable oauth state. + // If `requiresAuth` came back true but `asMetadata` / `resource` were + // missing (partial discovery), we'd otherwise route to AuthorizeStep and + // then `handleAuthorized` would silently bail — leaving the user with a + // permanently-broken half-state and no UI signal. + if (oauth && serverData.discovery) { + pendingAuth = { + serverId: id, + serverUrl: serverData.url, + discovery: serverData.discovery, + }; + currentView = "authorize"; + } else if (serverData.discovery?.requiresAuth) { + // Discovery said "needs auth" but we don't have enough metadata to + // drive the dance. Tell the user explicitly instead of silently adding + // a broken server. + currentView = "list"; + $error = `${serverData.url} requires authorization but its OAuth metadata could not be loaded. Try Health Check or contact the server admin.`; + } else { + // Non-OAuth server: just close the form. The user flips the Switch on + // the card to enable it (matches pre-PR behavior). + currentView = "list"; + } + } + + function handleAuthorized(payload: OAuthCallbackPayload, clientInfo: MCPClientInformation) { + if (!pendingAuth || !payload.ok || !payload.tokens) return; + const { asMetadata, resource, resourceMetadataUrl, supportsDcr } = pendingAuth.discovery; + if (!asMetadata || !resource) return; + const serverId = pendingAuth.serverId; + // Persist the final clientInfo (auto-registered or manually entered) so + // future refresh / revoke calls don't lose it. Then write the tokens. + setServerOAuth(serverId, { + resource, + asMetadata, + resourceMetadataUrl, + clientInfo, + clientWasManuallyEntered: !supportsDcr, + }); + setServerTokens(serverId, payload.tokens); + // Auto-enable on first authorization so the user can immediately use the + // server. If they were re-authorizing an already-enabled server, leave the + // selection state alone — toggling would turn it off. + if (!$selectedServerIds.has(serverId)) { + toggleServer(serverId); + } + const server = $allMcpServers.find((s) => s.id === serverId); + if (server) healthCheckServer({ ...server }); + pendingAuth = null; + currentView = "list"; + } + + function handleAuthorizeCancel() { + pendingAuth = null; currentView = "list"; } @@ -42,6 +137,28 @@ currentView = "list"; } + function handleReauthorizeFromCard(detail: { + serverId: string; + serverUrl: string; + oauth: MCPOAuthState; + }) { + const discovery: DiscoveryResponse = { + requiresAuth: true, + resource: detail.oauth.resource, + resourceMetadataUrl: detail.oauth.resourceMetadataUrl, + asMetadata: detail.oauth.asMetadata, + clientInfo: detail.oauth.clientInfo, + supportsDcr: !detail.oauth.clientWasManuallyEntered, + }; + setServerOAuth(detail.serverId, detail.oauth); + pendingAuth = { + serverId: detail.serverId, + serverUrl: detail.serverUrl, + discovery, + }; + currentView = "authorize"; + } + async function handleRefresh() { if (isRefreshing) return; isRefreshing = true; @@ -63,6 +180,8 @@

{#if currentView === "list"} MCP Servers + {:else if currentView === "authorize"} + Authorize MCP server {:else} Add MCP server {/if} @@ -70,6 +189,9 @@

{#if currentView === "list"} Manage MCP servers to extend {publicConfig.PUBLIC_APP_NAME} with external tools. + {:else if currentView === "authorize"} + Sign in to {pendingAuth?.serverUrl} so {publicConfig.PUBLIC_APP_NAME} can call its tools on + your behalf. {:else} Add a custom MCP server to {publicConfig.PUBLIC_APP_NAME}. {/if} @@ -161,7 +283,11 @@ {:else}

{#each customServers as server (server.id)} - + {/each}
{/if} @@ -180,6 +306,14 @@ {:else if currentView === "add"} + {:else if currentView === "authorize" && pendingAuth} + {/if} diff --git a/src/lib/components/mcp/ServerCard.svelte b/src/lib/components/mcp/ServerCard.svelte index 694cd1ee803..655623f7025 100644 --- a/src/lib/components/mcp/ServerCard.svelte +++ b/src/lib/components/mcp/ServerCard.svelte @@ -1,12 +1,20 @@ + +`; + return new Response(body, { + status: 200, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + // Hardening: tighten CSP so the inline bootstrap is the only script that runs. + "Content-Security-Policy": + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'none'; connect-src 'none'; frame-ancestors 'none';", + }, + }); +} + +function safeReturnPath(input: string | undefined): string { + if (!input || typeof input !== "string") return "/"; + if (input.startsWith("//")) return "/"; + if (!input.startsWith("/")) return "/"; + return input; +} + +function redirectResponseWithHash(redirectNext: string, message: PopupResultMessage): Response { + const safePath = safeReturnPath(redirectNext); + const handoff = Buffer.from(JSON.stringify(message), "utf8").toString("base64url"); + const fragment = `#__mcp_oauth_handoff=${handoff}`; + return new Response(null, { + status: 302, + headers: { + Location: safePath + fragment, + "Cache-Control": "no-store", + }, + }); +} + +export const GET: RequestHandler = async ({ url, cookies }) => { + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const errorParam = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + + // Find the most recent flow cookie. We do this with cookies.getAll() so we + // don't depend on a specific flowId being present in the URL — the AS + // callback only carries `code` and `state`. + const flowCookies = cookies.getAll().filter((c) => c.name.startsWith(`${FLOW_COOKIE_NAME}-`)); + let cookieFlowId: string | undefined; + let flowState = null as ReturnType; + for (const c of flowCookies) { + const candidate = verifyFlowCookie(c.value); + if (candidate && candidate.expectedState === state) { + cookieFlowId = c.name.slice(`${FLOW_COOKIE_NAME}-`.length); + flowState = candidate; + break; + } + } + // Fallback for the error path (state mismatch / no state at all): take the + // first valid cookie so we know how to render (popup vs redirect). + if (!flowState) { + for (const c of flowCookies) { + const candidate = verifyFlowCookie(c.value); + if (candidate) { + cookieFlowId = c.name.slice(`${FLOW_COOKIE_NAME}-`.length); + flowState = candidate; + break; + } + } + } + + const origin = config.PUBLIC_ORIGIN || url.origin; + const popupMode = flowState?.popupMode ?? true; + const redirectNext = flowState?.redirectNext; + + const expireFlowCookie = () => { + if (!cookieFlowId) return; + cookies.set(`${FLOW_COOKIE_NAME}-${cookieFlowId}`, "", { + path: `${base}/api/mcp/oauth`, + httpOnly: true, + sameSite, + secure, + maxAge: 0, + }); + }; + + const respond = (message: PopupResultMessage) => { + expireFlowCookie(); + if (!popupMode && redirectNext) { + return redirectResponseWithHash(redirectNext, message); + } + return popupResponse(origin, { ...message }); + }; + + if (errorParam) { + return respond({ + ok: false, + flowId: flowState?.flowId ?? "", + error: htmlEscape(`${errorParam}${errorDescription ? `: ${errorDescription}` : ""}`), + }); + } + + if (!flowState) { + return respond({ + ok: false, + flowId: "", + error: "Authorization flow expired or invalid", + }); + } + + if (!code || !state) { + return respond({ + ok: false, + flowId: flowState.flowId, + error: "Missing code/state in callback", + }); + } + + if (state !== flowState.expectedState) { + return respond({ + ok: false, + flowId: flowState.flowId, + error: "State mismatch (CSRF protection)", + }); + } + + try { + const tokens = await exchangeCodeForTokens({ + asMetadata: flowState.asMetadata as unknown as AuthorizationServerMetadata, + clientInfo: flowState.clientInfo as unknown as OAuthClientInformationFull, + redirectUri: flowState.redirectUri, + resource: flowState.resource, + code, + codeVerifier: flowState.verifier, + }); + return respond({ + ok: true, + flowId: flowState.flowId, + tokens: tokensWithExpiresAt(tokens), + resource: flowState.resource, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : "Token exchange failed"; + logger.warn({ err: msg, flowId: flowState.flowId }, "[mcp-oauth] code exchange failed"); + return respond({ ok: false, flowId: flowState.flowId, error: msg }); + } +}; + +// We don't request `response_mode=form_post` from `startAuthorization`, so +// authorization servers always return code/state via the query string on a GET. +// Intentionally not exporting POST — aliasing it to GET would silently fail to +// read code/state from the form body for any AS that decides to POST anyway. diff --git a/src/routes/api/mcp/oauth/discover/+server.ts b/src/routes/api/mcp/oauth/discover/+server.ts new file mode 100644 index 00000000000..41a85c49323 --- /dev/null +++ b/src/routes/api/mcp/oauth/discover/+server.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { error, json } from "@sveltejs/kit"; +import { base } from "$app/paths"; +import type { RequestHandler } from "./$types"; +import { config } from "$lib/server/config"; +import { logger } from "$lib/server/logger"; +import { discoverServerOAuth } from "$lib/server/mcp/oauth/discover"; + +const Body = z.object({ + url: z.string().url(), +}); + +export const POST: RequestHandler = async ({ request, url }) => { + let parsed: z.infer; + try { + parsed = Body.parse(await request.json()); + } catch (e) { + return error(400, e instanceof Error ? e.message : "Invalid request body"); + } + + const origin = config.PUBLIC_ORIGIN || url.origin; + const redirectUri = `${origin}${base}/api/mcp/oauth/callback`; + + try { + const result = await discoverServerOAuth(parsed.url, { + redirectUri, + appName: config.PUBLIC_APP_NAME || "chat-ui", + }); + + return json({ + requiresAuth: result.requiresAuth, + resource: result.resource, + resourceMetadataUrl: result.resourceMetadataUrl, + asMetadata: result.asMetadata, + clientInfo: result.clientInfo, + supportsDcr: result.supportsDcr, + probeStatus: result.probeStatus, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : "Discovery failed"; + logger.warn({ err: msg, url: parsed.url }, "[mcp-oauth] discovery failed"); + return error(502, msg); + } +}; diff --git a/src/routes/api/mcp/oauth/refresh/+server.ts b/src/routes/api/mcp/oauth/refresh/+server.ts new file mode 100644 index 00000000000..66345efa4cf --- /dev/null +++ b/src/routes/api/mcp/oauth/refresh/+server.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; +import { error, json } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { logger } from "$lib/server/logger"; +import { refreshTokens, tokensWithExpiresAt } from "$lib/server/mcp/oauth/exchange"; +import type { + AuthorizationServerMetadata, + OAuthClientInformationFull, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +const Body = z.object({ + asMetadata: z.record(z.string(), z.unknown()), + clientInfo: z.record(z.string(), z.unknown()), + resource: z.string().url(), + refresh_token: z.string().min(1), +}); + +export const POST: RequestHandler = async ({ request }) => { + let parsed: z.infer; + try { + parsed = Body.parse(await request.json()); + } catch (e) { + return error(400, e instanceof Error ? e.message : "Invalid request body"); + } + + const asMetadata = parsed.asMetadata as unknown as AuthorizationServerMetadata; + const clientInfo = parsed.clientInfo as unknown as OAuthClientInformationFull; + + if (!asMetadata.token_endpoint || !asMetadata.issuer) { + return error(400, "Invalid authorization server metadata"); + } + if (!clientInfo.client_id) { + return error(400, "Missing client_id in clientInfo"); + } + + try { + const tokens = await refreshTokens({ + asMetadata, + clientInfo, + resource: parsed.resource, + refreshToken: parsed.refresh_token, + }); + return json({ tokens: tokensWithExpiresAt(tokens) }); + } catch (e) { + const msg = e instanceof Error ? e.message : "Refresh failed"; + logger.warn({ err: msg }, "[mcp-oauth] refresh failed"); + // 401 means the AS rejected the refresh_token (invalid_grant / revoked / + // rotated). 502 covers transport-layer or AS-side transient failures + // (network down, AS 5xx, timeout) — clients should preserve tokens and + // retry next window rather than wipe credentials on a network blip. + const lower = msg.toLowerCase(); + const isInvalidGrant = + lower.includes("invalid_grant") || + lower.includes("invalid_request") || + lower.includes("unauthorized") || + lower.includes("invalid_client"); + return error(isInvalidGrant ? 401 : 502, msg); + } +}; diff --git a/src/routes/api/mcp/oauth/revoke/+server.ts b/src/routes/api/mcp/oauth/revoke/+server.ts new file mode 100644 index 00000000000..e28f3558abd --- /dev/null +++ b/src/routes/api/mcp/oauth/revoke/+server.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { error, json } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { tryRevokeToken } from "$lib/server/mcp/oauth/exchange"; +import type { + AuthorizationServerMetadata, + OAuthClientInformationFull, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +const Body = z.object({ + asMetadata: z.record(z.string(), z.unknown()), + clientInfo: z.record(z.string(), z.unknown()), + token: z.string().min(1), + token_type_hint: z.enum(["access_token", "refresh_token"]).optional(), +}); + +export const POST: RequestHandler = async ({ request }) => { + let parsed: z.infer; + try { + parsed = Body.parse(await request.json()); + } catch (e) { + return error(400, e instanceof Error ? e.message : "Invalid request body"); + } + + const asMetadata = parsed.asMetadata as unknown as AuthorizationServerMetadata; + const clientInfo = parsed.clientInfo as unknown as OAuthClientInformationFull; + + const ok = await tryRevokeToken({ + asMetadata, + clientInfo, + token: parsed.token, + tokenTypeHint: parsed.token_type_hint, + }); + return json({ revoked: ok }); +}; diff --git a/src/routes/api/mcp/oauth/start/+server.ts b/src/routes/api/mcp/oauth/start/+server.ts new file mode 100644 index 00000000000..ae97ebc6964 --- /dev/null +++ b/src/routes/api/mcp/oauth/start/+server.ts @@ -0,0 +1,94 @@ +import { z } from "zod"; +import { error, json } from "@sveltejs/kit"; +import { base } from "$app/paths"; +import { randomUUID } from "crypto"; +import type { RequestHandler } from "./$types"; +import { config } from "$lib/server/config"; +import { dev } from "$app/environment"; +import { buildAuthorizationUrl } from "$lib/server/mcp/oauth/exchange"; +import { + FLOW_COOKIE_NAME, + FLOW_TTL_MS, + newFlowId, + signFlowCookie, +} from "$lib/server/mcp/oauth/state"; +import type { + AuthorizationServerMetadata, + OAuthClientInformationFull, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +const Body = z.object({ + resource: z.string().url(), + asMetadata: z.record(z.string(), z.unknown()), + clientInfo: z.record(z.string(), z.unknown()), + popupMode: z.boolean().default(true), + redirectNext: z.string().optional(), + scope: z.string().optional(), +}); + +const sameSite = config.ALLOW_INSECURE_COOKIES === "true" || dev ? "lax" : ("none" as const); +const secure = !(dev || config.ALLOW_INSECURE_COOKIES === "true"); + +export const POST: RequestHandler = async ({ request, url, cookies }) => { + let parsed: z.infer; + try { + parsed = Body.parse(await request.json()); + } catch (e) { + return error(400, e instanceof Error ? e.message : "Invalid request body"); + } + + const asMetadata = parsed.asMetadata as unknown as AuthorizationServerMetadata; + const clientInfo = parsed.clientInfo as unknown as OAuthClientInformationFull; + + if (!asMetadata.issuer || !asMetadata.authorization_endpoint || !asMetadata.token_endpoint) { + return error(400, "Invalid authorization server metadata"); + } + if (!clientInfo.client_id) { + return error(400, "Missing client_id in clientInfo"); + } + + const origin = config.PUBLIC_ORIGIN || url.origin; + const redirectUri = `${origin}${base}/api/mcp/oauth/callback`; + const flowId = newFlowId(); + const expectedState = randomUUID(); + + let authorizationUrl: URL; + let codeVerifier: string; + try { + const built = await buildAuthorizationUrl({ + asMetadata, + clientInfo, + redirectUri, + resource: parsed.resource, + state: expectedState, + scope: parsed.scope, + }); + authorizationUrl = built.authorizationUrl; + codeVerifier = built.codeVerifier; + } catch (e) { + return error(502, e instanceof Error ? e.message : "Failed to build authorization URL"); + } + + const cookieValue = signFlowCookie({ + flowId, + verifier: codeVerifier, + expectedState, + asMetadata, + clientInfo, + resource: parsed.resource, + redirectUri, + popupMode: parsed.popupMode, + redirectNext: parsed.redirectNext, + expiresAt: Date.now() + FLOW_TTL_MS, + }); + + cookies.set(`${FLOW_COOKIE_NAME}-${flowId}`, cookieValue, { + path: `${base}/api/mcp/oauth`, + httpOnly: true, + sameSite, + secure, + maxAge: Math.floor(FLOW_TTL_MS / 1000), + }); + + return json({ authUrl: authorizationUrl.toString(), flowId }); +}; diff --git a/src/routes/conversation/[id]/+page.svelte b/src/routes/conversation/[id]/+page.svelte index 6982ef0e93f..96a1d1fdb7e 100644 --- a/src/routes/conversation/[id]/+page.svelte +++ b/src/routes/conversation/[id]/+page.svelte @@ -17,7 +17,11 @@ import { fetchMessageUpdates, resolveStreamingMode } from "$lib/utils/messageUpdates"; import type { v4 } from "uuid"; import { useSettingsStore } from "$lib/stores/settings.js"; - import { enabledServers } from "$lib/stores/mcpServers"; + import { + enabledServers, + effectiveServerHeaders, + refreshAllExpiredTokens, + } from "$lib/stores/mcpServers"; import { browser } from "$app/environment"; import { addBackgroundGeneration, @@ -220,6 +224,16 @@ messageUpdatesAbortController = new AbortController(); const streamingMode = resolveStreamingMode($settings); + // Just-in-time OAuth token refresh: if any enabled server has a token + // that's already expired or expiring within the next 5 minutes, hit our + // /api/mcp/oauth/refresh endpoint and replace the token in localStorage + // before we serialize the request. Errors are best-effort — the chat + // will still send and any genuinely-broken server will surface a clear + // reconnect path via tool-error messages. + try { + await refreshAllExpiredTokens(); + } catch {} + const messageUpdatesIterator = await fetchMessageUpdates( convId, { @@ -232,7 +246,7 @@ selectedMcpServers: $enabledServers.map((s) => ({ name: s.name, url: s.url, - headers: s.headers, + headers: effectiveServerHeaders(s), })), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, streamingMode,