Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 200 additions & 4 deletions src/lib/components/mcp/AddServerForm.svelte
Original file line number Diff line number Diff line change
@@ -1,18 +1,31 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import type { KeyValuePair } from "$lib/types/Tool";
import type { OAuthServerMetadata } from "$lib/types/McpOAuth";
import {
validateMcpServerUrl,
validateHeader,
isSensitiveHeader,
} from "$lib/utils/mcpValidation";
import { checkOAuthRequired, startOAuthFlow } from "$lib/services/mcpOAuthService";
import { reloadFromStorage } from "$lib/stores/mcpOAuthTokens";
import IconEye from "~icons/carbon/view";
import IconEyeOff from "~icons/carbon/view-off";
import IconTrash from "~icons/carbon/trash-can";
import IconAdd from "~icons/carbon/add";
import IconWarning from "~icons/carbon/warning";
import IconLocked from "~icons/carbon/locked";
import IconCheckmark from "~icons/carbon/checkmark-filled";
import IconPending from "~icons/carbon/pending-filled";

interface Props {
onsubmit: (server: { name: string; url: string; headers?: KeyValuePair[] }) => void;
onsubmit: (server: {
id?: string;
name: string;
url: string;
headers?: KeyValuePair[];
oauthEnabled?: boolean;
}) => void;
oncancel: () => void;
initialName?: string;
initialUrl?: string;
Expand All @@ -35,6 +48,129 @@
let showHeaderValues = $state<Record<number, boolean>>({});
let error = $state<string | null>(null);

let isCheckingOAuth = $state(false);
let oauthRequired = $state(false);
let oauthMetadata = $state<OAuthServerMetadata | null>(null);
let oauthCompleted = $state(false);
let oauthError = $state<string | null>(null);
let oauthCheckTimeout: ReturnType<typeof setTimeout> | undefined;
let pendingServerId = $state<string | null>(null);
let oauthPopup = $state<Window | null>(null);
let popupPollTimer: ReturnType<typeof setInterval> | undefined;

$effect(() => {
if (url) {
oauthRequired = false;
oauthMetadata = null;
oauthCompleted = false;
oauthError = null;

clearTimeout(oauthCheckTimeout);
oauthCheckTimeout = setTimeout(() => {
checkServerOAuth();
}, 500);
}
});

async function checkServerOAuth() {
if (!url.trim()) return;

const validUrl = validateMcpServerUrl(url);
if (!validUrl) return;

isCheckingOAuth = true;
oauthError = null;

try {
const result = await checkOAuthRequired(validUrl);
oauthRequired = result.required;
oauthMetadata = result.metadata ?? null;

if (result.error) {
oauthError = result.error;
}
} catch (err) {
console.error("OAuth check failed:", err);
oauthError = err instanceof Error ? err.message : "Failed to check authentication";
} finally {
isCheckingOAuth = false;
}
}

async function handleStartOAuth() {
if (!oauthMetadata) return;

pendingServerId = `pending-${crypto.randomUUID()}`;
oauthError = null;

if (popupPollTimer) {
clearInterval(popupPollTimer);
popupPollTimer = undefined;
}

try {
oauthPopup = await startOAuthFlow(
pendingServerId,
url,
name || "MCP Server",
oauthMetadata,
window.location.href
);

if (oauthPopup) {
popupPollTimer = setInterval(() => {
if (oauthPopup?.closed) {
clearInterval(popupPollTimer);
popupPollTimer = undefined;
oauthPopup = null;

if (!oauthCompleted) {
oauthError = "Authentication window was closed";
}
}
}, 500);
}
} catch (err) {
oauthError = err instanceof Error ? err.message : "Failed to start authentication";
pendingServerId = null;
oauthPopup = null;
}
}

function handleOAuthMessage(event: MessageEvent) {
if (event.origin !== window.location.origin) return;

if (event.data?.type === "mcp-oauth-complete") {
if (event.data.serverId !== pendingServerId) return;

if (popupPollTimer) {
clearInterval(popupPollTimer);
popupPollTimer = undefined;
}
oauthPopup = null;

if (event.data.success) {
oauthCompleted = true;
oauthError = null;
reloadFromStorage();
} else {
oauthError = event.data.error || "Authentication failed";
}
}
}

onMount(() => {
window.addEventListener("message", handleOAuthMessage);
});

onDestroy(() => {
window.removeEventListener("message", handleOAuthMessage);
clearTimeout(oauthCheckTimeout);
if (popupPollTimer) {
clearInterval(popupPollTimer);
}
});

function addHeader() {
headers = [...headers, { key: "", value: "" }];
}
Expand Down Expand Up @@ -68,6 +204,11 @@
return false;
}

if (oauthRequired && !oauthCompleted) {
error = "Please complete OAuth authentication first";
return false;
}

// Validate headers
for (let i = 0; i < headers.length; i++) {
const header = headers[i];
Expand All @@ -91,9 +232,11 @@
const filteredHeaders = headers.filter((h) => h.key.trim() && h.value.trim());

onsubmit({
id: pendingServerId ?? undefined,
name: name.trim(),
url: url.trim(),
headers: filteredHeaders.length > 0 ? filteredHeaders : undefined,
oauthEnabled: oauthRequired && oauthCompleted,
});
}
</script>
Expand Down Expand Up @@ -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"
/>
<!-- <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Only HTTPS is supported (e.g., https://localhost:5101).
</p> -->
</div>

<!-- OAuth Status -->
{#if isCheckingOAuth}
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-800">
<IconPending class="size-4 animate-spin text-gray-500 dark:text-gray-400" />
<span class="text-sm text-gray-600 dark:text-gray-400">
Checking authentication requirements...
</span>
</div>
{:else if oauthRequired}
<div
class="rounded-lg border p-4 {oauthCompleted
? 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-900/20'
: 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-900/20'}"
>
<div class="flex items-start gap-3">
{#if oauthCompleted}
<div
class="flex size-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-800/50"
>
<IconCheckmark class="size-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p class="font-medium text-green-800 dark:text-green-200">Authenticated</p>
<p class="mt-0.5 text-sm text-green-700 dark:text-green-300">
OAuth authentication completed successfully. You can now add this server.
</p>
</div>
{:else}
<div
class="flex size-8 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-800/50"
>
<IconLocked class="size-5 text-blue-600 dark:text-blue-400" />
</div>
<div class="flex-1">
<p class="font-medium text-blue-800 dark:text-blue-200">Authentication Required</p>
<p class="mt-0.5 text-sm text-blue-700 dark:text-blue-300">
This server requires OAuth authentication to access its tools.
</p>
{#if oauthError}
<p class="mt-2 text-sm text-red-600 dark:text-red-400">
{oauthError}
</p>
{/if}
<button
type="button"
onclick={handleStartOAuth}
class="mt-3 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
>
Sign in with OAuth
</button>
</div>
{/if}
</div>
</div>
{/if}

<!-- HTTP Headers -->
<details class="rounded-lg border border-gray-200 dark:border-gray-700">
<summary class="cursor-pointer px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300">
Expand Down
Loading
Loading