diff --git a/src/lib/components/mcp/AddServerForm.svelte b/src/lib/components/mcp/AddServerForm.svelte index 96a389b5202..fddfe07213c 100644 --- a/src/lib/components/mcp/AddServerForm.svelte +++ b/src/lib/components/mcp/AddServerForm.svelte @@ -1,18 +1,31 @@ @@ -128,11 +271,64 @@ placeholder="https://example.com/mcp" class="mt-1.5 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" /> - + + {#if isCheckingOAuth} +
+ + + Checking authentication requirements... + +
+ {:else if oauthRequired} +
+
+ {#if oauthCompleted} +
+ +
+
+

Authenticated

+

+ OAuth authentication completed successfully. You can now add this server. +

+
+ {:else} +
+ +
+
+

Authentication Required

+

+ This server requires OAuth authentication to access its tools. +

+ {#if oauthError} +

+ {oauthError} +

+ {/if} + +
+ {/if} +
+
+ {/if} +
diff --git a/src/lib/components/mcp/ServerCard.svelte b/src/lib/components/mcp/ServerCard.svelte index 694cd1ee803..64c06a98912 100644 --- a/src/lib/components/mcp/ServerCard.svelte +++ b/src/lib/components/mcp/ServerCard.svelte @@ -1,6 +1,15 @@
{/if} + + {#if oauthError} +
+
+ {oauthError} +
+
+ {/if} + + + {#if tokenStatus === "expired" || tokenStatus === "missing"} +
+
+ + + {tokenStatus === "expired" ? "Authentication expired" : "Authentication required"} + + +
+
+ {:else if tokenStatus === "expiring"} +
+
+ + Token expires soon +
+
+ {/if} +
+ {/if} +
+
+ 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..ef827a013b2 --- /dev/null +++ b/src/routes/api/mcp/oauth/discover/+server.ts @@ -0,0 +1,124 @@ +import type { RequestHandler } from "./$types"; +import { isValidUrl } from "$lib/server/urlSafety"; + +interface DiscoveryRequest { + url: string; +} + +interface OAuthServerMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint?: string; + scopes_supported?: string[]; + response_types_supported?: string[]; + code_challenge_methods_supported?: string[]; + token_endpoint_auth_methods_supported?: string[]; + grant_types_supported?: string[]; +} + +interface ProtectedResourceMetadata { + resource: string; + authorization_servers?: string[]; + scopes_supported?: string[]; +} + +function parseResourceMetadataUrl(wwwAuthenticate: string): string | null { + const match = wwwAuthenticate.match(/resource_metadata="([^"]+)"/); + return match?.[1] ?? null; +} + +async function fetchJson(url: string, signal: AbortSignal): Promise { + try { + const response = await fetch(url, { + headers: { Accept: "application/json" }, + signal, + }); + if (!response.ok) return null; + return (await response.json()) as T; + } catch { + return null; + } +} + +async function fetchAuthServerMetadata( + issuerUrl: string, + signal: AbortSignal +): Promise { + const url = new URL(issuerUrl); + const wellKnownUrl = `${url.origin}/.well-known/oauth-authorization-server`; + const metadata = await fetchJson(wellKnownUrl, signal); + + if (!metadata?.authorization_endpoint || !metadata?.token_endpoint) return null; + if ( + metadata.code_challenge_methods_supported && + !metadata.code_challenge_methods_supported.includes("S256") + ) { + return null; + } + return metadata; +} + +export const POST: RequestHandler = async ({ request }) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); + + try { + const body: DiscoveryRequest = await request.json(); + const { url } = body; + + if (!url || !isValidUrl(url)) { + return new Response(JSON.stringify({ error: "Invalid URL" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + // RFC 9728: Probe the server to get resource_metadata from WWW-Authenticate + const probeResponse = await fetch(url, { + method: "GET", + signal: controller.signal, + }); + + if (probeResponse.status === 401) { + const wwwAuth = probeResponse.headers.get("www-authenticate") ?? ""; + const prmUrl = parseResourceMetadataUrl(wwwAuth); + + if (prmUrl) { + const prm = await fetchJson(prmUrl, controller.signal); + if (prm?.authorization_servers?.[0]) { + const metadata = await fetchAuthServerMetadata( + prm.authorization_servers[0], + controller.signal + ); + if (metadata) { + clearTimeout(timeoutId); + return new Response(JSON.stringify({ metadata }), { + headers: { "Content-Type": "application/json" }, + }); + } + } + } + } + + // Fallback: same-origin well-known + const metadata = await fetchAuthServerMetadata(url, controller.signal); + clearTimeout(timeoutId); + + if (metadata) { + return new Response(JSON.stringify({ metadata }), { + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ metadata: null }), { + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + clearTimeout(timeoutId); + return new Response( + JSON.stringify({ error: error instanceof Error ? error.message : "Discovery failed" }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +}; diff --git a/src/routes/conversation/[id]/+page.svelte b/src/routes/conversation/[id]/+page.svelte index 16a01a0d20c..bd694cf0e79 100644 --- a/src/routes/conversation/[id]/+page.svelte +++ b/src/routes/conversation/[id]/+page.svelte @@ -17,7 +17,7 @@ import { fetchMessageUpdates } from "$lib/utils/messageUpdates"; import type { v4 } from "uuid"; import { useSettingsStore } from "$lib/stores/settings.js"; - import { enabledServers } from "$lib/stores/mcpServers"; + import { getServersWithAuth } from "$lib/stores/mcpServers"; import { browser } from "$app/environment"; import { addBackgroundGeneration, @@ -212,6 +212,7 @@ const messageUpdatesAbortController = new AbortController(); + const mcpServers = getServersWithAuth(); const messageUpdatesIterator = await fetchMessageUpdates( page.params.id, { @@ -220,8 +221,8 @@ messageId, isRetry, files: isRetry ? userMessage?.files : base64Files, - selectedMcpServerNames: $enabledServers.map((s) => s.name), - selectedMcpServers: $enabledServers.map((s) => ({ + selectedMcpServerNames: mcpServers.map((s) => s.name), + selectedMcpServers: mcpServers.map((s) => ({ name: s.name, url: s.url, headers: s.headers,