From 95de2b7a666ae784b559669edc13e919826f5e32 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Fri, 17 Apr 2026 00:40:19 +1000
Subject: [PATCH 1/5] Add OAuth 'Authorization Code + PKCE' flow to MCP UI

---
 ui/src/components/ChatView/ChatView.tsx       |  32 +-
 .../MCPConfigModal/MCPConfigModal.tsx         | 424 +++++++++-
 ui/src/components/MCPConfigModal/index.ts     |   2 +-
 ui/src/main.tsx                               |  13 +-
 ui/src/services/mcp/client.ts                 |  40 +-
 ui/src/services/mcp/index.ts                  |  16 +
 ui/src/services/mcp/oauth.ts                  | 793 ++++++++++++++++++
 ui/src/services/mcp/types.ts                  |  32 +
 ui/src/stores/mcpStore.ts                     |  31 +-
 9 files changed, 1324 insertions(+), 59 deletions(-)
 create mode 100644 ui/src/services/mcp/oauth.ts

diff --git a/ui/src/components/ChatView/ChatView.tsx b/ui/src/components/ChatView/ChatView.tsx
index e40a662e..3a81e04b 100644
--- a/ui/src/components/ChatView/ChatView.tsx
+++ b/ui/src/components/ChatView/ChatView.tsx
@@ -4,7 +4,7 @@ import { ChatHeader } from "@/components/ChatHeader/ChatHeader";
 import { ChatInput } from "@/components/ChatInput/ChatInput";
 import { ChatMessageList } from "@/components/ChatMessageList/ChatMessageList";
 import { ConversationSettingsModal } from "@/components/ConversationSettingsModal/ConversationSettingsModal";
-import { MCPConfigModal } from "@/components/MCPConfigModal";
+import { MCPConfigModal, type MCPServerPrefill } from "@/components/MCPConfigModal";
 import type { ModelInfo } from "@/components/ModelSelector/ModelSelector";
 import {
   useChatUIStore,
@@ -30,7 +30,7 @@ import {
   useTotalUsage,
   useCurrentConversationForExport,
 } from "@/stores/conversationStore";
-import { useMemo, useCallback } from "react";
+import { useMemo, useCallback, useState, useEffect } from "react";
 
 export interface ChatFile {
   id: string;
@@ -115,6 +115,25 @@ export function ChatView({
   const widescreenMode = useWidescreenMode();
   const subAgentModel = useSubAgentModel();
   const mcpConfigModalOpen = useMCPConfigModalOpen();
+  const [mcpPrefill, setMcpPrefill] = useState<MCPServerPrefill | null>(null);
+
+  // Check for ?mcp_server_url= query param to auto-open the MCP config modal
+  useEffect(() => {
+    const params = new URLSearchParams(window.location.search);
+    const serverUrl = params.get("mcp_server_url");
+    if (serverUrl) {
+      const serverName = params.get("mcp_server_name") ?? undefined;
+      setMcpPrefill({ url: serverUrl, name: serverName });
+      setMCPConfigModalOpen(true);
+      // Clean the URL to prevent re-triggering
+      const cleanUrl = new URL(window.location.href);
+      cleanUrl.searchParams.delete("mcp_server_url");
+      cleanUrl.searchParams.delete("mcp_server_name");
+      window.history.replaceState({}, "", cleanUrl.toString());
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps -- only run on mount
+  }, []);
+
   const { setSelectedInstances, updateInstance } = useConversationStore();
   const {
     settingsModalOpen,
@@ -268,7 +287,14 @@ export function ChatView({
       />
 
       {/* MCP Config Modal */}
-      <MCPConfigModal open={mcpConfigModalOpen} onClose={() => setMCPConfigModalOpen(false)} />
+      <MCPConfigModal
+        open={mcpConfigModalOpen}
+        onClose={() => {
+          setMCPConfigModalOpen(false);
+          setMcpPrefill(null);
+        }}
+        prefill={mcpPrefill}
+      />
     </div>
   );
 }
diff --git a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
index c64c8d0c..be1b5b8f 100644
--- a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
+++ b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
@@ -14,15 +14,18 @@ import { zodResolver } from "@hookform/resolvers/zod";
 import { z } from "zod";
 import {
   AlertCircle,
+  AlertTriangle,
   CheckCircle2,
   ChevronDown,
   ChevronRight,
   Eye,
   EyeOff,
+  KeyRound,
   Loader2,
   Pencil,
   Plug,
   Plus,
+  ShieldCheck,
   Trash2,
   Wifi,
   Wrench,
@@ -43,16 +46,35 @@ import {
 import { Switch } from "@/components/Switch/Switch";
 import { cn } from "@/utils/cn";
 import { useMCPStore, useMCPServers } from "@/stores/mcpStore";
-import { MCPClient, type MCPServerState, type MCPConnectionStatus } from "@/services/mcp";
+import {
+  MCPClient,
+  type MCPServerState,
+  type MCPConnectionStatus,
+  type MCPAuthType,
+  type MCPOAuthConfig,
+  startOAuthFlow,
+  getValidAccessToken,
+  hasValidTokens,
+  clearOAuthData,
+  detectServerAuth,
+} from "@/services/mcp";
 import type { MCPToolDefinition, JSONSchema } from "@/services/mcp";
 
 // =============================================================================
 // Types
 // =============================================================================
 
+/** Pre-fill data for adding a new server (e.g., from URL query params) */
+export interface MCPServerPrefill {
+  url: string;
+  name?: string;
+}
+
 export interface MCPConfigModalProps {
   open: boolean;
   onClose: () => void;
+  /** Pre-fill a new server (e.g., from ?mcp_server_url= query param) */
+  prefill?: MCPServerPrefill | null;
 }
 
 // =============================================================================
@@ -62,7 +84,10 @@ export interface MCPConfigModalProps {
 const serverFormSchema = z.object({
   name: z.string().min(1, "Name is required"),
   url: z.string().url("Must be a valid URL"),
+  authType: z.enum(["none", "bearer", "oauth"]),
   bearerToken: z.string(),
+  oauthClientId: z.string(),
+  oauthScopes: z.string(),
   headers: z.string(),
   timeout: z.number().int().min(1, "Must be at least 1 second"),
 });
@@ -213,6 +238,25 @@ function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
   const [expanded, setExpanded] = useState(false);
   const { connectServer, disconnectServer, setToolEnabled } = useMCPStore();
   const [isToggling, setIsToggling] = useState(false);
+  const [isAuthorizing, setIsAuthorizing] = useState(false);
+  const oauthAuthorized = server.authType === "oauth" && hasValidTokens(server.url);
+
+  const handleAuthorize = useCallback(async () => {
+    setIsAuthorizing(true);
+    try {
+      await startOAuthFlow(server.url, server.oauth);
+      // Tokens obtained — now connect
+      try {
+        await connectServer(server.id);
+      } catch {
+        // Connection error stored in server state
+      }
+    } catch (err) {
+      console.debug("OAuth flow failed:", err);
+    } finally {
+      setIsAuthorizing(false);
+    }
+  }, [server.url, server.oauth, server.id, connectServer]);
 
   // Unified toggle: switch ON = enable + connect, switch OFF = disconnect + disable
   const handleToggle = useCallback(async () => {
@@ -335,6 +379,33 @@ function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
             <span>{server.error}</span>
           </div>
         )}
+
+        {/* OAuth status & authorize button */}
+        {server.authType === "oauth" && (
+          <div className="mt-2 flex items-center gap-2">
+            {oauthAuthorized ? (
+              <span className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
+                <ShieldCheck className="h-3.5 w-3.5" />
+                Authorized
+              </span>
+            ) : (
+              <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                <KeyRound className="h-3.5 w-3.5" />
+                Not authorized
+              </span>
+            )}
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              onClick={handleAuthorize}
+              disabled={isAuthorizing}
+              isLoading={isAuthorizing}
+            >
+              {oauthAuthorized ? "Re-authorize" : "Authorize"}
+            </Button>
+          </div>
+        )}
       </div>
 
       {/* Tools list (expandable) */}
@@ -370,12 +441,17 @@ interface ServerFormProps {
   editingServer?: MCPServerState | null;
   onSubmit: (values: ServerFormValues) => void;
   onCancel: () => void;
+  /** Pre-fill data (e.g., from URL query params) */
+  prefill?: MCPServerPrefill | null;
 }
 
 type TestStatus = "idle" | "testing" | "success" | "error";
 
-function ServerForm({ editingServer, onSubmit, onCancel }: ServerFormProps) {
+type OAuthStatus = "idle" | "authorizing" | "authorized" | "error";
+
+function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormProps) {
   const [showToken, setShowToken] = useState(false);
+  const isNewServer = !editingServer;
 
   // Extract bearer token from existing headers, pass the rest as extra headers
   const existingHeaders = editingServer?.headers ?? {};
@@ -387,12 +463,19 @@ function ServerForm({ editingServer, onSubmit, onCancel }: ServerFormProps) {
     Object.entries(existingHeaders).filter(([k]) => k.toLowerCase() !== "authorization")
   );
 
+  // Infer initial auth type from existing config
+  const initialAuthType: MCPAuthType =
+    editingServer?.authType ?? (existingBearer ? "bearer" : "none");
+
   const form = useForm<ServerFormValues>({
     resolver: zodResolver(serverFormSchema),
     defaultValues: {
-      name: editingServer?.name ?? "",
-      url: editingServer?.url ?? "",
+      name: editingServer?.name ?? prefill?.name ?? "",
+      url: editingServer?.url ?? prefill?.url ?? "",
+      authType: initialAuthType,
       bearerToken: existingBearer,
+      oauthClientId: editingServer?.oauth?.clientId ?? "",
+      oauthScopes: editingServer?.oauth?.scopes ?? "",
       headers: Object.keys(extraHeaders).length > 0 ? JSON.stringify(extraHeaders, null, 2) : "",
       timeout: Math.round((editingServer?.timeout ?? 300000) / 1000),
     },
@@ -402,9 +485,27 @@ function ServerForm({ editingServer, onSubmit, onCancel }: ServerFormProps) {
   const [testMessage, setTestMessage] = useState<string>();
   const [testLatency, setTestLatency] = useState<number>();
 
-  // Reset test results when URL or headers change
+  // OAuth state
+  const [oauthStatus, setOauthStatus] = useState<OAuthStatus>(() =>
+    initialAuthType === "oauth" && editingServer?.url && hasValidTokens(editingServer.url)
+      ? "authorized"
+      : "idle"
+  );
+  const [oauthError, setOauthError] = useState<string>();
+
+  // Auth detection state
+  type DetectionStatus = "idle" | "detecting" | "detected";
+  const [detectionStatus, setDetectionStatus] = useState<DetectionStatus>("idle");
+  const [detectionMessage, setDetectionMessage] = useState("");
+  // Track whether user manually changed auth type (disables auto-select)
+  const [userOverrodeAuth, setUserOverrodeAuth] = useState(false);
+
+  // Watched form values
   const watchedUrl = form.watch("url");
   const watchedHeaders = form.watch("headers");
+  const watchedAuthType = form.watch("authType") as MCPAuthType;
+
+  // Reset test results when URL or headers change
   useEffect(() => {
     if (testStatus !== "idle" && testStatus !== "testing") {
       setTestStatus("idle");
@@ -414,13 +515,89 @@ function ServerForm({ editingServer, onSubmit, onCancel }: ServerFormProps) {
     // eslint-disable-next-line react-hooks/exhaustive-deps -- only reset on field changes
   }, [watchedUrl, watchedHeaders]);
 
+  // Reset OAuth status when auth type or URL changes
+  useEffect(() => {
+    if (watchedAuthType === "oauth" && watchedUrl) {
+      setOauthStatus(hasValidTokens(watchedUrl) ? "authorized" : "idle");
+      setOauthError(undefined);
+    } else {
+      setOauthStatus("idle");
+      setOauthError(undefined);
+    }
+  }, [watchedAuthType, watchedUrl]);
+
+  // Auto-detect auth requirements when URL changes (new servers only)
+  useEffect(() => {
+    if (!isNewServer || !watchedUrl || userOverrodeAuth) {
+      setDetectionStatus("idle");
+      setDetectionMessage("");
+      return;
+    }
+
+    // Validate URL before probing
+    if (!z.string().url().safeParse(watchedUrl).success) {
+      setDetectionStatus("idle");
+      setDetectionMessage("");
+      return;
+    }
+
+    setDetectionStatus("detecting");
+    setDetectionMessage("");
+
+    const timer = setTimeout(() => {
+      let cancelled = false;
+      detectServerAuth(watchedUrl).then((result) => {
+        if (cancelled) return;
+        setDetectionStatus("detected");
+        setDetectionMessage(result.message);
+        if (result.authType !== watchedAuthType) {
+          form.setValue("authType", result.authType);
+        }
+      });
+      // Store cancel function on the timer's cleanup
+      return () => {
+        cancelled = true;
+      };
+    }, 600);
+
+    return () => clearTimeout(timer);
+    // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run on URL change
+  }, [watchedUrl, isNewServer, userOverrodeAuth]);
+
+  const handleAuthorize = useCallback(async () => {
+    const valid = await form.trigger("url");
+    if (!valid) return;
+
+    const url = form.getValues("url");
+    const clientId = form.getValues("oauthClientId") || undefined;
+    const scopes = form.getValues("oauthScopes") || undefined;
+
+    setOauthStatus("authorizing");
+    setOauthError(undefined);
+
+    try {
+      await startOAuthFlow(url, { clientId, scopes });
+      setOauthStatus("authorized");
+    } catch (err) {
+      setOauthStatus("error");
+      setOauthError(err instanceof Error ? err.message : String(err));
+    }
+  }, [form]);
+
+  const handleRevoke = useCallback(() => {
+    const url = form.getValues("url");
+    if (url) clearOAuthData(url);
+    setOauthStatus("idle");
+  }, [form]);
+
   const handleTestConnection = useCallback(async () => {
     // Validate URL field first
     const valid = await form.trigger("url");
     if (!valid) return;
 
     const values = form.getValues();
-    let headers: Record<string, string> | undefined;
+
+    // Parse extra headers
     const extra: Record<string, string> = {};
     if (values.headers) {
       try {
@@ -431,18 +608,31 @@ function ServerForm({ editingServer, onSubmit, onCancel }: ServerFormProps) {
         return;
       }
     }
-    if (values.bearerToken || Object.keys(extra).length > 0) {
-      headers = { ...extra };
-      if (values.bearerToken) {
-        headers["Authorization"] = `Bearer ${values.bearerToken}`;
-      }
+
+    // Build client config based on auth type
+    const headers: Record<string, string> = { ...extra };
+    let getAccessTokenFn: (() => Promise<string | null>) | undefined;
+
+    if (values.authType === "bearer" && values.bearerToken) {
+      headers["Authorization"] = `Bearer ${values.bearerToken}`;
+    } else if (values.authType === "oauth") {
+      const oauthCfg: MCPOAuthConfig = {
+        clientId: values.oauthClientId || undefined,
+        scopes: values.oauthScopes || undefined,
+      };
+      getAccessTokenFn = () => getValidAccessToken(values.url, oauthCfg);
     }
 
     setTestStatus("testing");
     setTestMessage(undefined);
     setTestLatency(undefined);
 
-    const client = new MCPClient({ url: values.url, headers, timeout: 10000 });
+    const client = new MCPClient({
+      url: values.url,
+      headers: Object.keys(headers).length > 0 ? headers : undefined,
+      timeout: 10000,
+      getAccessToken: getAccessTokenFn,
+    });
     const start = performance.now();
 
     try {
@@ -468,8 +658,22 @@ function ServerForm({ editingServer, onSubmit, onCancel }: ServerFormProps) {
     onSubmit(values);
   });
 
+  const authTypeOptions = [
+    { value: "none" as const, label: "None" },
+    { value: "bearer" as const, label: "Bearer Token" },
+    { value: "oauth" as const, label: "OAuth (PKCE)" },
+  ];
+
   return (
     <form onSubmit={handleSubmit} className="space-y-4">
+      {/* Warning banner when pre-filled from a URL param */}
+      {prefill && (
+        <div className="flex items-start gap-2 text-xs text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-2.5 rounded-md">
+          <AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
+          <span>Server URL provided via link. Only add servers you trust.</span>
+        </div>
+      )}
+
       <FormField
         label="Server Name"
         htmlFor="server-name"
@@ -489,35 +693,141 @@ function ServerForm({ editingServer, onSubmit, onCancel }: ServerFormProps) {
         <Input id="server-url" {...form.register("url")} placeholder="https://mcp.example.com" />
       </FormField>
 
-      <FormField
-        label="Authorization"
-        htmlFor="server-bearer-token"
-        helpText="Bearer token for authenticating with the MCP server"
-        error={form.formState.errors.bearerToken?.message}
-      >
-        <div className="relative">
-          <Input
-            id="server-bearer-token"
-            type={showToken ? "text" : "password"}
-            {...form.register("bearerToken")}
-            placeholder="your-api-key"
-            className="pr-10 font-mono"
-          />
-          <button
-            type="button"
-            onClick={() => setShowToken(!showToken)}
-            className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted text-muted-foreground"
-            aria-label={showToken ? "Hide token" : "Show token"}
-          >
-            {showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
-          </button>
+      {/* Auth detection indicator */}
+      {detectionStatus === "detecting" && (
+        <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+          <Loader2 className="h-3 w-3 animate-spin" />
+          Checking authentication requirements...
+        </div>
+      )}
+      {detectionStatus === "detected" && detectionMessage && (
+        <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+          <CheckCircle2 className="h-3 w-3" />
+          {detectionMessage}
+        </div>
+      )}
+
+      {/* Auth type selector */}
+      <FormField label="Authentication" htmlFor="server-auth-type">
+        <div className="flex gap-1.5" role="radiogroup" aria-label="Authentication type">
+          {authTypeOptions.map((opt) => (
+            <button
+              key={opt.value}
+              type="button"
+              role="radio"
+              aria-checked={watchedAuthType === opt.value}
+              onClick={() => {
+                form.setValue("authType", opt.value);
+                setUserOverrodeAuth(true);
+              }}
+              className={cn(
+                "px-3 py-1.5 rounded-md text-sm border transition-colors",
+                watchedAuthType === opt.value
+                  ? "border-primary bg-primary/10 text-primary font-medium"
+                  : "border-input text-muted-foreground hover:bg-muted"
+              )}
+            >
+              {opt.label}
+            </button>
+          ))}
         </div>
       </FormField>
 
+      {/* Bearer Token fields */}
+      {watchedAuthType === "bearer" && (
+        <FormField
+          label="Bearer Token"
+          htmlFor="server-bearer-token"
+          helpText="Token for authenticating with the MCP server"
+          error={form.formState.errors.bearerToken?.message}
+        >
+          <div className="relative">
+            <Input
+              id="server-bearer-token"
+              type={showToken ? "text" : "password"}
+              {...form.register("bearerToken")}
+              placeholder="your-api-key"
+              className="pr-10 font-mono"
+            />
+            <button
+              type="button"
+              onClick={() => setShowToken(!showToken)}
+              className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted text-muted-foreground"
+              aria-label={showToken ? "Hide token" : "Show token"}
+            >
+              {showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
+            </button>
+          </div>
+        </FormField>
+      )}
+
+      {/* OAuth fields */}
+      {watchedAuthType === "oauth" && (
+        <div className="space-y-3 rounded-md border border-input p-3">
+          <FormField
+            label="Client ID"
+            htmlFor="server-oauth-client-id"
+            helpText="Leave empty to use dynamic client registration"
+          >
+            <Input
+              id="server-oauth-client-id"
+              {...form.register("oauthClientId")}
+              placeholder="Optional — for pre-registered apps"
+              className="font-mono"
+            />
+          </FormField>
+
+          <FormField
+            label="Scopes"
+            htmlFor="server-oauth-scopes"
+            helpText="Space-separated OAuth scopes (auto-detected if empty)"
+          >
+            <Input
+              id="server-oauth-scopes"
+              {...form.register("oauthScopes")}
+              placeholder="Optional — e.g. read write"
+            />
+          </FormField>
+
+          {/* OAuth status & authorize button */}
+          <div className="flex items-center gap-3 pt-1">
+            {oauthStatus === "authorized" ? (
+              <>
+                <span className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
+                  <ShieldCheck className="h-3.5 w-3.5" />
+                  Authorized
+                </span>
+                <Button type="button" variant="ghost" size="sm" onClick={handleRevoke}>
+                  Revoke
+                </Button>
+              </>
+            ) : oauthStatus === "authorizing" ? (
+              <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                Waiting for authorization...
+              </span>
+            ) : (
+              <>
+                <Button type="button" variant="outline" size="sm" onClick={handleAuthorize}>
+                  <KeyRound className="h-4 w-4 mr-1.5" />
+                  Authorize
+                </Button>
+                {oauthStatus === "error" && oauthError && (
+                  <div className="flex items-start gap-1.5 text-xs text-destructive flex-1 min-w-0">
+                    <XCircle className="h-3 w-3 shrink-0 mt-0.5" />
+                    <span className="break-words">{oauthError}</span>
+                  </div>
+                )}
+              </>
+            )}
+          </div>
+        </div>
+      )}
+
       <FormField
         label="Additional Headers (JSON)"
         htmlFor="server-headers"
-        helpText="Optional extra HTTP headers beyond authorization"
+        helpText="Optional extra HTTP headers"
         error={form.formState.errors.headers?.message}
       >
         <textarea
@@ -595,12 +905,20 @@ function ServerForm({ editingServer, onSubmit, onCancel }: ServerFormProps) {
 // Main Component
 // =============================================================================
 
-export function MCPConfigModal({ open, onClose }: MCPConfigModalProps) {
+export function MCPConfigModal({ open, onClose, prefill }: MCPConfigModalProps) {
   const servers = useMCPServers();
   const { addServer, updateServer, removeServer } = useMCPStore();
   const [showForm, setShowForm] = useState(false);
   const [editingServer, setEditingServer] = useState<MCPServerState | null>(null);
 
+  // Auto-show form when opened with a prefill
+  useEffect(() => {
+    if (open && prefill) {
+      setEditingServer(null);
+      setShowForm(true);
+    }
+  }, [open, prefill]);
+
   const handleAddClick = useCallback(() => {
     setEditingServer(null);
     setShowForm(true);
@@ -618,8 +936,7 @@ export function MCPConfigModal({ open, onClose }: MCPConfigModalProps) {
 
   const handleFormSubmit = useCallback(
     (values: ServerFormValues) => {
-      // Merge bearer token + extra headers
-      let headers: Record<string, string> | undefined;
+      // Parse extra headers
       const extra: Record<string, string> = {};
       if (values.headers) {
         try {
@@ -628,31 +945,49 @@ export function MCPConfigModal({ open, onClose }: MCPConfigModalProps) {
           // Invalid JSON - ignore extra headers
         }
       }
-      if (values.bearerToken || Object.keys(extra).length > 0) {
-        headers = { ...extra };
-        if (values.bearerToken) {
-          headers["Authorization"] = `Bearer ${values.bearerToken}`;
+
+      // Build headers — only add Authorization for bearer auth
+      let headers: Record<string, string> | undefined;
+      if (values.authType === "bearer") {
+        if (values.bearerToken || Object.keys(extra).length > 0) {
+          headers = { ...extra };
+          if (values.bearerToken) {
+            headers["Authorization"] = `Bearer ${values.bearerToken}`;
+          }
         }
+      } else if (Object.keys(extra).length > 0) {
+        headers = extra;
       }
 
+      // Build OAuth config
+      const oauth: MCPOAuthConfig | undefined =
+        values.authType === "oauth"
+          ? {
+              clientId: values.oauthClientId || undefined,
+              scopes: values.oauthScopes || undefined,
+            }
+          : undefined;
+
       const timeout = values.timeout * 1000;
 
       if (editingServer) {
-        // Update existing server
         updateServer(editingServer.id, {
           name: values.name,
           url: values.url,
+          authType: values.authType as MCPAuthType,
           headers,
           timeout,
+          oauth,
         });
       } else {
-        // Add new server
         addServer({
           name: values.name,
           url: values.url,
           enabled: true,
+          authType: values.authType as MCPAuthType,
           headers,
           timeout,
+          oauth,
         });
       }
 
@@ -690,6 +1025,7 @@ export function MCPConfigModal({ open, onClose }: MCPConfigModalProps) {
             editingServer={editingServer}
             onSubmit={handleFormSubmit}
             onCancel={handleFormCancel}
+            prefill={editingServer ? null : prefill}
           />
         ) : (
           <div className="space-y-4">
diff --git a/ui/src/components/MCPConfigModal/index.ts b/ui/src/components/MCPConfigModal/index.ts
index 2a1e005d..bb47cf58 100644
--- a/ui/src/components/MCPConfigModal/index.ts
+++ b/ui/src/components/MCPConfigModal/index.ts
@@ -1 +1 @@
-export { MCPConfigModal, type MCPConfigModalProps } from "./MCPConfigModal";
+export { MCPConfigModal, type MCPConfigModalProps, type MCPServerPrefill } from "./MCPConfigModal";
diff --git a/ui/src/main.tsx b/ui/src/main.tsx
index a4647825..1c8e3e7f 100644
--- a/ui/src/main.tsx
+++ b/ui/src/main.tsx
@@ -2,6 +2,17 @@ import { StrictMode } from "react";
 import { createRoot } from "react-dom/client";
 import "./index.css";
 import App from "./App";
+import { handleMCPOAuthCallback } from "./services/mcp/oauth";
+
+// Intercept MCP OAuth popup callbacks before rendering the full app.
+// If this page is a popup returning from an OAuth authorization server,
+// forward the code+state to the opener window and close.
+if (handleMCPOAuthCallback()) {
+  document.getElementById("root")!.textContent =
+    "Authorization complete. You may close this window.";
+} else {
+  bootstrap();
+}
 
 async function bootstrap() {
   // In WASM mode, register the service worker and wait for it to control the
@@ -23,5 +34,3 @@ async function bootstrap() {
     </StrictMode>
   );
 }
-
-bootstrap();
diff --git a/ui/src/services/mcp/client.ts b/ui/src/services/mcp/client.ts
index eb0c4bdb..06ab55c4 100644
--- a/ui/src/services/mcp/client.ts
+++ b/ui/src/services/mcp/client.ts
@@ -56,6 +56,10 @@ export interface MCPClientConfig {
   headers?: Record<string, string>;
   /** Request timeout in ms (default: 300000) */
   timeout?: number;
+  /** Async callback to get a valid access token before each request (for OAuth).
+   *  If provided and returns a string, sets Authorization: Bearer <token>.
+   *  If returns null, no Authorization header is added. */
+  getAccessToken?: () => Promise<string | null>;
 }
 
 /** Status change callback */
@@ -234,13 +238,19 @@ export class MCPClient {
     // Send DELETE to terminate session if we have a session ID
     if (this.sessionId) {
       try {
-        await fetch(this.config.url, {
-          method: "DELETE",
-          headers: {
-            "Mcp-Session-Id": this.sessionId,
-            ...this.config.headers,
-          },
-        });
+        const headers: Record<string, string> = {
+          "Mcp-Session-Id": this.sessionId,
+          ...this.config.headers,
+        };
+        if (this.config.getAccessToken) {
+          try {
+            const token = await this.config.getAccessToken();
+            if (token) headers["Authorization"] = `Bearer ${token}`;
+          } catch {
+            // Best effort during disconnect
+          }
+        }
+        await fetch(this.config.url, { method: "DELETE", headers });
       } catch {
         // Ignore errors during disconnect
       }
@@ -405,6 +415,12 @@ export class MCPClient {
         ...this.config.headers,
       };
 
+      // Inject OAuth token if provider is configured
+      if (this.config.getAccessToken) {
+        const token = await this.config.getAccessToken();
+        if (token) headers["Authorization"] = `Bearer ${token}`;
+      }
+
       // Include session ID if we have one
       if (this.sessionId) {
         headers["Mcp-Session-Id"] = this.sessionId;
@@ -540,6 +556,16 @@ export class MCPClient {
       ...this.config.headers,
     };
 
+    // Inject OAuth token if provider is configured
+    if (this.config.getAccessToken) {
+      try {
+        const token = await this.config.getAccessToken();
+        if (token) headers["Authorization"] = `Bearer ${token}`;
+      } catch {
+        // Best effort for notifications
+      }
+    }
+
     if (this.sessionId) {
       headers["Mcp-Session-Id"] = this.sessionId;
     }
diff --git a/ui/src/services/mcp/index.ts b/ui/src/services/mcp/index.ts
index 6c291882..ca299945 100644
--- a/ui/src/services/mcp/index.ts
+++ b/ui/src/services/mcp/index.ts
@@ -67,9 +67,25 @@ export {
   type MCPUIContentType,
   type MCPUIResource,
   type MCPUIToolCallResult,
+  // OAuth types
+  type MCPAuthType,
+  type OAuthTokens,
+  type MCPOAuthConfig,
   // Client-side types
   type MCPConnectionStatus,
   type MCPServerConfig,
   type MCPServerState,
   createServerState,
 } from "./types";
+
+export {
+  startOAuthFlow,
+  getValidAccessToken,
+  getStoredTokens,
+  hasValidTokens,
+  clearOAuthData,
+  isTokenExpired,
+  handleMCPOAuthCallback,
+  detectServerAuth,
+  type AuthDetectionResult,
+} from "./oauth";
diff --git a/ui/src/services/mcp/oauth.ts b/ui/src/services/mcp/oauth.ts
new file mode 100644
index 00000000..d04aadbd
--- /dev/null
+++ b/ui/src/services/mcp/oauth.ts
@@ -0,0 +1,793 @@
+/**
+ * MCP OAuth Authorization Code + PKCE Flow
+ *
+ * Implements OAuth 2.1 PKCE authorization for MCP servers per the MCP spec.
+ * Handles metadata discovery, dynamic client registration, token exchange,
+ * refresh, and popup-based authorization.
+ *
+ * @see https://modelcontextprotocol.io/specification/draft/basic/authorization
+ */
+
+import type { OAuthTokens, MCPOAuthConfig, MCPAuthType } from "./types";
+
+// =============================================================================
+// PKCE Helpers
+// =============================================================================
+
+function base64url(bytes: Uint8Array): string {
+  let binary = "";
+  for (const b of bytes) binary += String.fromCharCode(b);
+  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+function generateCodeVerifier(): string {
+  return base64url(crypto.getRandomValues(new Uint8Array(32)));
+}
+
+async function generateCodeChallenge(verifier: string): Promise<string> {
+  const encoded = new TextEncoder().encode(verifier);
+  const digest = await crypto.subtle.digest("SHA-256", encoded);
+  return base64url(new Uint8Array(digest));
+}
+
+function generateState(): string {
+  return "mcp-" + base64url(crypto.getRandomValues(new Uint8Array(16)));
+}
+
+// =============================================================================
+// Discovery Types
+// =============================================================================
+
+interface ProtectedResourceMetadata {
+  resource: string;
+  authorization_servers?: string[];
+  scopes_supported?: string[];
+}
+
+interface AuthServerMetadata {
+  issuer: string;
+  authorization_endpoint: string;
+  token_endpoint: string;
+  registration_endpoint?: string;
+  scopes_supported?: string[];
+  response_types_supported?: string[];
+  grant_types_supported?: string[];
+  code_challenge_methods_supported?: string[];
+}
+
+interface ClientRegistration {
+  client_id: string;
+  client_secret?: string;
+  client_secret_expires_at?: number;
+}
+
+// =============================================================================
+// Storage
+// =============================================================================
+
+/** Stored alongside tokens for refresh without re-discovery */
+interface StoredOAuthData {
+  tokens: OAuthTokens;
+  tokenEndpoint: string;
+  clientId: string;
+  clientSecret?: string;
+  resource: string;
+}
+
+/** Transient flow data stored while popup is open */
+interface PendingFlow {
+  serverUrl: string;
+  codeVerifier: string;
+  redirectUri: string;
+  tokenEndpoint: string;
+  clientId: string;
+  clientSecret?: string;
+  resource: string;
+}
+
+const STORAGE_PREFIX = "hadrian-mcp-oauth";
+
+function oauthDataKey(serverUrl: string): string {
+  return `${STORAGE_PREFIX}::${serverUrl}`;
+}
+
+function clientRegKey(issuer: string): string {
+  return `${STORAGE_PREFIX}-client::${issuer}`;
+}
+
+function pendingFlowKey(state: string): string {
+  return `${STORAGE_PREFIX}-pending::${state}`;
+}
+
+function getStoredOAuthData(serverUrl: string): StoredOAuthData | null {
+  try {
+    const raw = localStorage.getItem(oauthDataKey(serverUrl));
+    return raw ? JSON.parse(raw) : null;
+  } catch {
+    return null;
+  }
+}
+
+function storeOAuthData(serverUrl: string, data: StoredOAuthData): void {
+  localStorage.setItem(oauthDataKey(serverUrl), JSON.stringify(data));
+}
+
+/** Get stored tokens for a server URL */
+export function getStoredTokens(serverUrl: string): OAuthTokens | null {
+  return getStoredOAuthData(serverUrl)?.tokens ?? null;
+}
+
+/** Clear all stored OAuth data for a server */
+export function clearOAuthData(serverUrl: string): void {
+  localStorage.removeItem(oauthDataKey(serverUrl));
+}
+
+function getStoredClientReg(issuer: string): ClientRegistration | null {
+  try {
+    const raw = localStorage.getItem(clientRegKey(issuer));
+    if (!raw) return null;
+    const reg = JSON.parse(raw) as ClientRegistration;
+    // Check expiry
+    if (
+      reg.client_secret_expires_at &&
+      reg.client_secret_expires_at !== 0 &&
+      reg.client_secret_expires_at * 1000 < Date.now()
+    ) {
+      localStorage.removeItem(clientRegKey(issuer));
+      return null;
+    }
+    return reg;
+  } catch {
+    return null;
+  }
+}
+
+function storeClientReg(issuer: string, reg: ClientRegistration): void {
+  localStorage.setItem(clientRegKey(issuer), JSON.stringify(reg));
+}
+
+function storePendingFlow(state: string, flow: PendingFlow): void {
+  localStorage.setItem(pendingFlowKey(state), JSON.stringify(flow));
+}
+
+function getPendingFlow(state: string): PendingFlow | null {
+  try {
+    const raw = localStorage.getItem(pendingFlowKey(state));
+    return raw ? JSON.parse(raw) : null;
+  } catch {
+    return null;
+  }
+}
+
+function clearPendingFlow(state: string): void {
+  localStorage.removeItem(pendingFlowKey(state));
+}
+
+// =============================================================================
+// Token Helpers
+// =============================================================================
+
+/** Check if an OAuth token has expired (with 30s buffer) */
+export function isTokenExpired(tokens: OAuthTokens): boolean {
+  if (!tokens.expires_in) return false;
+  const expiresAt = tokens.obtained_at + tokens.expires_in * 1000;
+  return Date.now() > expiresAt - 30_000;
+}
+
+/** Check if a server URL has valid (non-expired) OAuth tokens */
+export function hasValidTokens(serverUrl: string): boolean {
+  const tokens = getStoredTokens(serverUrl);
+  return tokens !== null && !isTokenExpired(tokens);
+}
+
+// =============================================================================
+// Discovery
+// =============================================================================
+
+async function fetchJson<T>(url: string): Promise<T | null> {
+  try {
+    const res = await fetch(url);
+    if (!res.ok) return null;
+    return (await res.json()) as T;
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * Discover Protected Resource Metadata (RFC 9728).
+ * Tries path-aware well-known URL first, then root.
+ */
+async function discoverProtectedResourceMetadata(
+  serverUrl: string
+): Promise<ProtectedResourceMetadata | null> {
+  const url = new URL(serverUrl);
+
+  // Also try 401 response from the MCP endpoint for WWW-Authenticate header
+  let fromWwwAuth: ProtectedResourceMetadata | null = null;
+  try {
+    const res = await fetch(serverUrl, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "initialize", params: {} }),
+    });
+    if (res.status === 401) {
+      const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
+      const resourceMetaUrl = wwwAuth.match(/resource_metadata="([^"]+)"/)?.[1];
+      if (resourceMetaUrl) {
+        fromWwwAuth = await fetchJson<ProtectedResourceMetadata>(resourceMetaUrl);
+      }
+      // Also extract scope hint
+      if (!fromWwwAuth) {
+        const scopeHint = wwwAuth.match(/scope="([^"]+)"/)?.[1];
+        if (scopeHint) {
+          fromWwwAuth = {
+            resource: serverUrl,
+            scopes_supported: scopeHint.split(" "),
+          };
+        }
+      }
+    }
+  } catch {
+    // Continue to well-known discovery
+  }
+
+  if (fromWwwAuth?.authorization_servers?.length) return fromWwwAuth;
+
+  // Well-known discovery
+  const paths =
+    url.pathname !== "/" && url.pathname !== ""
+      ? [
+          `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`,
+          `${url.origin}/.well-known/oauth-protected-resource`,
+        ]
+      : [`${url.origin}/.well-known/oauth-protected-resource`];
+
+  for (const path of paths) {
+    const meta = await fetchJson<ProtectedResourceMetadata>(path);
+    if (meta?.authorization_servers?.length) return meta;
+  }
+
+  // If we got partial metadata from WWW-Authenticate (scope but no AS), still useful
+  return fromWwwAuth;
+}
+
+/**
+ * Discover Authorization Server Metadata (RFC 8414 / OIDC Discovery).
+ * Tries OAuth 2.0 well-known first, then OIDC.
+ */
+async function discoverAuthServerMetadata(issuerUrl: string): Promise<AuthServerMetadata | null> {
+  const url = new URL(issuerUrl);
+
+  const paths =
+    url.pathname !== "/" && url.pathname !== ""
+      ? [
+          `${url.origin}/.well-known/oauth-authorization-server${url.pathname}`,
+          `${url.origin}/.well-known/openid-configuration${url.pathname}`,
+          `${issuerUrl}/.well-known/openid-configuration`,
+        ]
+      : [
+          `${url.origin}/.well-known/oauth-authorization-server`,
+          `${url.origin}/.well-known/openid-configuration`,
+        ];
+
+  for (const path of paths) {
+    const meta = await fetchJson<AuthServerMetadata>(path);
+    if (meta?.authorization_endpoint && meta.token_endpoint) return meta;
+  }
+
+  return null;
+}
+
+// =============================================================================
+// Dynamic Client Registration (RFC 7591)
+// =============================================================================
+
+async function dynamicClientRegistration(
+  registrationEndpoint: string,
+  redirectUri: string
+): Promise<ClientRegistration> {
+  const res = await fetch(registrationEndpoint, {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({
+      redirect_uris: [redirectUri],
+      grant_types: ["authorization_code", "refresh_token"],
+      response_types: ["code"],
+      application_type: "web",
+      client_name: "Hadrian Gateway",
+      token_endpoint_auth_method: "none",
+    }),
+  });
+
+  if (!res.ok) {
+    const body = await res.text().catch(() => "");
+    throw new Error(`Dynamic client registration failed (${res.status}): ${body}`);
+  }
+
+  return (await res.json()) as ClientRegistration;
+}
+
+// =============================================================================
+// Token Exchange & Refresh
+// =============================================================================
+
+async function exchangeCodeForTokens(
+  tokenEndpoint: string,
+  code: string,
+  redirectUri: string,
+  clientId: string,
+  codeVerifier: string,
+  resource: string,
+  clientSecret?: string
+): Promise<OAuthTokens> {
+  const params: Record<string, string> = {
+    grant_type: "authorization_code",
+    code,
+    redirect_uri: redirectUri,
+    client_id: clientId,
+    code_verifier: codeVerifier,
+    resource,
+  };
+  if (clientSecret) params.client_secret = clientSecret;
+
+  const res = await fetch(tokenEndpoint, {
+    method: "POST",
+    headers: { "Content-Type": "application/x-www-form-urlencoded" },
+    body: new URLSearchParams(params),
+  });
+
+  if (!res.ok) {
+    const body = await res.text().catch(() => "");
+    throw new Error(`Token exchange failed (${res.status}): ${body}`);
+  }
+
+  const data = await res.json();
+  return { ...data, obtained_at: Date.now() };
+}
+
+async function refreshTokenRequest(
+  tokenEndpoint: string,
+  refreshToken: string,
+  clientId: string,
+  resource: string,
+  clientSecret?: string
+): Promise<OAuthTokens> {
+  const params: Record<string, string> = {
+    grant_type: "refresh_token",
+    refresh_token: refreshToken,
+    client_id: clientId,
+    resource,
+  };
+  if (clientSecret) params.client_secret = clientSecret;
+
+  const res = await fetch(tokenEndpoint, {
+    method: "POST",
+    headers: { "Content-Type": "application/x-www-form-urlencoded" },
+    body: new URLSearchParams(params),
+  });
+
+  if (!res.ok) {
+    const body = await res.text().catch(() => "");
+    throw new Error(`Token refresh failed (${res.status}): ${body}`);
+  }
+
+  const data = await res.json();
+  return { ...data, obtained_at: Date.now() };
+}
+
+// =============================================================================
+// OAuth Flow Orchestration
+// =============================================================================
+
+/**
+ * Start the OAuth Authorization Code + PKCE flow for an MCP server.
+ *
+ * Opens a popup for user authorization and returns the obtained tokens.
+ * The popup redirects back to the app origin, which forwards the callback
+ * to the opener via postMessage.
+ */
+export async function startOAuthFlow(
+  serverUrl: string,
+  oauthConfig?: MCPOAuthConfig
+): Promise<OAuthTokens> {
+  // 1. Discover metadata
+  const prm = await discoverProtectedResourceMetadata(serverUrl);
+  if (!prm?.authorization_servers?.length) {
+    throw new Error(
+      "Could not discover OAuth metadata. The server may not support OAuth authorization."
+    );
+  }
+
+  const resource = prm.resource || serverUrl;
+  const issuerUrl = prm.authorization_servers[0];
+
+  const asm = await discoverAuthServerMetadata(issuerUrl);
+  if (!asm) {
+    throw new Error(`Could not discover authorization server metadata at ${issuerUrl}`);
+  }
+
+  // Validate PKCE S256 support
+  if (
+    asm.code_challenge_methods_supported &&
+    !asm.code_challenge_methods_supported.includes("S256")
+  ) {
+    throw new Error("Authorization server does not support S256 PKCE code challenge method.");
+  }
+
+  // 2. Resolve client_id
+  const redirectUri = `${window.location.origin}/`;
+  let clientId = oauthConfig?.clientId;
+  let clientSecret = oauthConfig?.clientSecret;
+
+  if (!clientId) {
+    const storedReg = getStoredClientReg(asm.issuer);
+    if (storedReg) {
+      clientId = storedReg.client_id;
+      clientSecret = clientSecret ?? storedReg.client_secret;
+    }
+  }
+
+  if (!clientId && asm.registration_endpoint) {
+    const reg = await dynamicClientRegistration(asm.registration_endpoint, redirectUri);
+    storeClientReg(asm.issuer, reg);
+    clientId = reg.client_id;
+    clientSecret = clientSecret ?? reg.client_secret;
+  }
+
+  if (!clientId) {
+    throw new Error(
+      "No client ID available. The server does not support dynamic registration. " +
+        "Please provide a Client ID in the OAuth settings."
+    );
+  }
+
+  // 3. Generate PKCE + state
+  const codeVerifier = generateCodeVerifier();
+  const codeChallenge = await generateCodeChallenge(codeVerifier);
+  const state = generateState();
+
+  // 4. Store pending flow for callback handler
+  storePendingFlow(state, {
+    serverUrl,
+    codeVerifier,
+    redirectUri,
+    tokenEndpoint: asm.token_endpoint,
+    clientId,
+    clientSecret,
+    resource,
+  });
+
+  // 5. Build authorization URL
+  const authParams = new URLSearchParams({
+    response_type: "code",
+    client_id: clientId,
+    redirect_uri: redirectUri,
+    code_challenge: codeChallenge,
+    code_challenge_method: "S256",
+    state,
+    resource,
+  });
+
+  // Determine scopes
+  const scopes =
+    oauthConfig?.scopes || prm.scopes_supported?.join(" ") || asm.scopes_supported?.join(" ");
+  if (scopes) authParams.set("scope", scopes);
+
+  const authUrl = `${asm.authorization_endpoint}?${authParams}`;
+
+  // 6. Open popup and wait for callback
+  return new Promise<OAuthTokens>((resolve, reject) => {
+    let cleanedUp = false;
+
+    const cleanup = () => {
+      if (cleanedUp) return;
+      cleanedUp = true;
+      window.removeEventListener("message", onMessage);
+      clearInterval(pollTimer);
+      clearTimeout(timeoutTimer);
+    };
+
+    const onMessage = async (event: MessageEvent) => {
+      if (event.origin !== window.location.origin) return;
+      if (event.data?.type !== "mcp-oauth-callback") return;
+      if (event.data.state !== state) return; // not our flow
+
+      cleanup();
+
+      const { code, error, errorDescription } = event.data;
+      if (error) {
+        clearPendingFlow(state);
+        reject(new Error(errorDescription || error));
+        return;
+      }
+
+      try {
+        const tokens = await exchangeCodeForTokens(
+          asm.token_endpoint,
+          code,
+          redirectUri,
+          clientId!,
+          codeVerifier,
+          resource,
+          clientSecret
+        );
+        storeOAuthData(serverUrl, {
+          tokens,
+          tokenEndpoint: asm.token_endpoint,
+          clientId: clientId!,
+          clientSecret,
+          resource,
+        });
+        clearPendingFlow(state);
+        resolve(tokens);
+      } catch (err) {
+        clearPendingFlow(state);
+        reject(err);
+      }
+    };
+
+    window.addEventListener("message", onMessage);
+
+    const popup = window.open(authUrl, "mcp-oauth", "width=600,height=700,popup=yes");
+    if (!popup) {
+      cleanup();
+      clearPendingFlow(state);
+      reject(new Error("Failed to open popup. Please allow popups for this site."));
+      return;
+    }
+
+    // Detect popup closed without completing
+    const pollTimer = setInterval(() => {
+      if (popup.closed) {
+        cleanup();
+        clearPendingFlow(state);
+        reject(new Error("Authorization was cancelled"));
+      }
+    }, 500);
+
+    // Timeout after 5 minutes
+    const timeoutTimer = setTimeout(
+      () => {
+        cleanup();
+        clearPendingFlow(state);
+        try {
+          popup.close();
+        } catch {
+          // ignore
+        }
+        reject(new Error("Authorization timed out"));
+      },
+      5 * 60 * 1000
+    );
+  });
+}
+
+// =============================================================================
+// Token Access (used by MCPClient via getAccessToken callback)
+// =============================================================================
+
+/**
+ * Get a valid access token for an MCP server.
+ * Returns the current token if still valid, or refreshes if expired.
+ * Returns null if no tokens are available (caller should initiate OAuth flow).
+ */
+export async function getValidAccessToken(
+  serverUrl: string,
+  oauthConfig?: MCPOAuthConfig
+): Promise<string | null> {
+  const data = getStoredOAuthData(serverUrl);
+  if (!data) return null;
+
+  if (!isTokenExpired(data.tokens)) {
+    return data.tokens.access_token;
+  }
+
+  // Try to refresh
+  if (data.tokens.refresh_token) {
+    try {
+      const clientId = oauthConfig?.clientId || data.clientId;
+      const clientSecret = oauthConfig?.clientSecret || data.clientSecret;
+
+      const newTokens = await refreshTokenRequest(
+        data.tokenEndpoint,
+        data.tokens.refresh_token,
+        clientId,
+        data.resource,
+        clientSecret
+      );
+
+      // Preserve refresh token if the new response didn't include one (rotation)
+      if (!newTokens.refresh_token) {
+        newTokens.refresh_token = data.tokens.refresh_token;
+      }
+
+      storeOAuthData(serverUrl, { ...data, tokens: newTokens, clientId, clientSecret });
+      return newTokens.access_token;
+    } catch (err) {
+      console.debug("MCP OAuth token refresh failed:", err);
+      clearOAuthData(serverUrl);
+      return null;
+    }
+  }
+
+  // Token expired and no refresh token
+  clearOAuthData(serverUrl);
+  return null;
+}
+
+// =============================================================================
+// Popup Callback Handler
+// =============================================================================
+
+/**
+ * Handle an OAuth callback if the current page is a popup returning from authorization.
+ * Call this early in the app's bootstrap (before rendering).
+ *
+ * Returns true if a callback was handled (the app should not render).
+ */
+export function handleMCPOAuthCallback(): boolean {
+  const params = new URLSearchParams(window.location.search);
+  const state = params.get("state");
+
+  // Only handle callbacks with our "mcp-" prefixed state
+  if (!state?.startsWith("mcp-")) return false;
+
+  const code = params.get("code");
+  const error = params.get("error");
+  const errorDescription = params.get("error_description");
+
+  if (!code && !error) return false;
+
+  if (window.opener) {
+    // We're in a popup — forward to opener and close
+    try {
+      window.opener.postMessage(
+        { type: "mcp-oauth-callback", code, state, error, errorDescription },
+        window.location.origin
+      );
+    } catch {
+      // Opener may be closed or cross-origin
+    }
+    window.close();
+    return true;
+  }
+
+  // No opener (edge case: user navigated here directly or popup mechanism failed).
+  // Process the callback inline if we have the pending flow data.
+  if (code) {
+    const flow = getPendingFlow(state);
+    if (flow) {
+      // Exchange in the background and redirect to root
+      exchangeCodeForTokens(
+        flow.tokenEndpoint,
+        code,
+        flow.redirectUri,
+        flow.clientId,
+        flow.codeVerifier,
+        flow.resource,
+        flow.clientSecret
+      )
+        .then((tokens) => {
+          storeOAuthData(flow.serverUrl, {
+            tokens,
+            tokenEndpoint: flow.tokenEndpoint,
+            clientId: flow.clientId,
+            clientSecret: flow.clientSecret,
+            resource: flow.resource,
+          });
+          clearPendingFlow(state);
+        })
+        .catch((err) => {
+          console.error("MCP OAuth inline callback failed:", err);
+          clearPendingFlow(state);
+        });
+    }
+    // Clean URL params
+    const cleanUrl = new URL(window.location.href);
+    cleanUrl.search = "";
+    window.history.replaceState({}, "", cleanUrl.toString());
+  }
+
+  return false; // Let the app render normally
+}
+
+// =============================================================================
+// Auth Detection
+// =============================================================================
+
+/** Result of probing a server URL for authentication requirements */
+export interface AuthDetectionResult {
+  authType: MCPAuthType;
+  message: string;
+}
+
+/**
+ * Probe an MCP server URL to detect what authentication is required.
+ *
+ * 1. Check well-known OAuth protected-resource metadata (lightweight)
+ * 2. Send an initialize request to the MCP endpoint
+ *    - 200 → no auth needed
+ *    - 401 + WWW-Authenticate with resource_metadata → OAuth
+ *    - 401 otherwise → bearer token
+ */
+export async function detectServerAuth(serverUrl: string): Promise<AuthDetectionResult> {
+  // 1. Well-known OAuth discovery (doesn't touch the MCP endpoint)
+  try {
+    const url = new URL(serverUrl);
+    const paths =
+      url.pathname !== "/" && url.pathname !== ""
+        ? [
+            `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`,
+            `${url.origin}/.well-known/oauth-protected-resource`,
+          ]
+        : [`${url.origin}/.well-known/oauth-protected-resource`];
+
+    for (const path of paths) {
+      try {
+        const res = await fetch(path);
+        if (res.ok) {
+          const meta = (await res.json()) as ProtectedResourceMetadata;
+          if (meta?.authorization_servers?.length) {
+            return { authType: "oauth", message: "OAuth authentication detected" };
+          }
+        }
+      } catch {
+        continue;
+      }
+    }
+  } catch {
+    // Invalid URL — fall through
+  }
+
+  // 2. Probe the MCP endpoint with an initialize request
+  try {
+    const res = await fetch(serverUrl, {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+        Accept: "application/json,text/event-stream",
+      },
+      body: JSON.stringify({
+        jsonrpc: "2.0",
+        id: 0,
+        method: "initialize",
+        params: {
+          protocolVersion: "2024-11-05",
+          capabilities: {},
+          clientInfo: { name: "hadrian-detect", version: "1.0.0" },
+        },
+      }),
+    });
+
+    if (res.ok) {
+      return { authType: "none", message: "No authentication required" };
+    }
+
+    if (res.status === 401) {
+      const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
+      const resourceMetaUrl = wwwAuth.match(/resource_metadata="([^"]+)"/)?.[1];
+
+      if (resourceMetaUrl) {
+        // Verify the resource metadata actually has OAuth servers
+        try {
+          const metaRes = await fetch(resourceMetaUrl);
+          if (metaRes.ok) {
+            const meta = (await metaRes.json()) as ProtectedResourceMetadata;
+            if (meta?.authorization_servers?.length) {
+              return { authType: "oauth", message: "OAuth authentication required" };
+            }
+          }
+        } catch {
+          // Fall through to bearer
+        }
+      }
+
+      return { authType: "bearer", message: "Bearer token required" };
+    }
+  } catch {
+    // Network error — can't determine
+  }
+
+  return { authType: "none", message: "" };
+}
diff --git a/ui/src/services/mcp/types.ts b/ui/src/services/mcp/types.ts
index f4ac7561..bfd34f2d 100644
--- a/ui/src/services/mcp/types.ts
+++ b/ui/src/services/mcp/types.ts
@@ -293,6 +293,34 @@ export interface MCPUIToolCallResult extends ToolCallResult {
   uiResources?: MCPUIResource[];
 }
 
+// =============================================================================
+// OAuth Types
+// =============================================================================
+
+/** Authentication method for an MCP server */
+export type MCPAuthType = "none" | "bearer" | "oauth";
+
+/** OAuth token set returned by the authorization server */
+export interface OAuthTokens {
+  access_token: string;
+  token_type: string;
+  expires_in?: number;
+  refresh_token?: string;
+  scope?: string;
+  /** Timestamp (ms since epoch) when the token was obtained */
+  obtained_at: number;
+}
+
+/** User-provided OAuth configuration for an MCP server */
+export interface MCPOAuthConfig {
+  /** Client ID (takes priority over dynamic registration) */
+  clientId?: string;
+  /** Client secret (for confidential clients — rare in browser) */
+  clientSecret?: string;
+  /** Space-separated OAuth scopes to request */
+  scopes?: string;
+}
+
 // =============================================================================
 // Client-side Types
 // =============================================================================
@@ -314,6 +342,10 @@ export interface MCPServerConfig {
   headers?: Record<string, string>;
   /** Request timeout in ms (default: 300000) */
   timeout?: number;
+  /** Authentication type (default: "none") */
+  authType?: MCPAuthType;
+  /** OAuth configuration (when authType is "oauth") */
+  oauth?: MCPOAuthConfig;
 }
 
 /** MCP Server state (config + runtime state) */
diff --git a/ui/src/stores/mcpStore.ts b/ui/src/stores/mcpStore.ts
index 8636d189..e090af24 100644
--- a/ui/src/stores/mcpStore.ts
+++ b/ui/src/stores/mcpStore.ts
@@ -41,6 +41,7 @@ import {
   type MCPConnectionStatus,
   type MCPToolDefinition,
   createServerState,
+  getValidAccessToken,
 } from "@/services/mcp";
 
 // =============================================================================
@@ -113,6 +114,10 @@ function getClient(server: MCPServerConfig): MCPClient {
       name: server.name,
       headers: server.headers,
       timeout: server.timeout,
+      getAccessToken:
+        server.authType === "oauth"
+          ? () => getValidAccessToken(server.url, server.oauth)
+          : undefined,
     });
     globalClients.set(server.id, client);
   }
@@ -129,6 +134,10 @@ function getConversationClient(server: MCPServerConfig, conversationId: string):
       name: server.name,
       headers: server.headers,
       timeout: server.timeout,
+      getAccessToken:
+        server.authType === "oauth"
+          ? () => getValidAccessToken(server.url, server.oauth)
+          : undefined,
     });
     conversationClients.set(key, client);
   }
@@ -282,9 +291,12 @@ export const useMCPStore = create<MCPStore>()(
       },
 
       updateServer: (serverId, updates) => {
-        // If URL or headers change, we need to recreate the client
+        // If connection-relevant fields change, we need to recreate the client
         const server = get().servers.find((s) => s.id === serverId);
-        if (server && (updates.url || updates.headers || updates.timeout)) {
+        if (
+          server &&
+          (updates.url || updates.headers || updates.timeout || updates.authType || updates.oauth)
+        ) {
           removeClient(serverId);
           removeAllConversationClientsForServer(serverId);
         }
@@ -307,6 +319,19 @@ export const useMCPStore = create<MCPStore>()(
         get()._setServerStatus(serverId, "connecting");
 
         try {
+          // For OAuth servers, ensure we have valid tokens before connecting
+          if (server.authType === "oauth") {
+            const token = await getValidAccessToken(server.url, server.oauth);
+            if (!token) {
+              get()._setServerStatus(
+                serverId,
+                "error",
+                "OAuth authorization required. Click Authorize to connect."
+              );
+              return;
+            }
+          }
+
           const client = getClient(server);
 
           // Set up status listener (tracked for cleanup)
@@ -521,6 +546,8 @@ export const useMCPStore = create<MCPStore>()(
           enabled: s.enabled,
           headers: s.headers,
           timeout: s.timeout,
+          authType: s.authType,
+          oauth: s.oauth,
           // Persist tool enable/disable preferences
           toolsEnabled: s.toolsEnabled,
         })),

From 4bac0f9680590a9a087708a53227c460731e6c2a Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Fri, 17 Apr 2026 01:00:49 +1000
Subject: [PATCH 2/5] Use well-known metadata

---
 .../MCPConfigModal/MCPConfigModal.tsx         | 22 +++++++++++--------
 ui/src/services/mcp/oauth.ts                  | 15 +++++++++++--
 2 files changed, 26 insertions(+), 11 deletions(-)

diff --git a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
index be1b5b8f..c153df55 100644
--- a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
+++ b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
@@ -553,6 +553,10 @@ function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormPr
         if (result.authType !== watchedAuthType) {
           form.setValue("authType", result.authType);
         }
+        // Pre-fill server name from resource metadata if the field is still empty
+        if (result.serverName && !form.getValues("name")) {
+          form.setValue("name", result.serverName);
+        }
       });
       // Store cancel function on the timer's cleanup
       return () => {
@@ -674,15 +678,6 @@ function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormPr
         </div>
       )}
 
-      <FormField
-        label="Server Name"
-        htmlFor="server-name"
-        required
-        error={form.formState.errors.name?.message}
-      >
-        <Input id="server-name" {...form.register("name")} placeholder="My MCP Server" />
-      </FormField>
-
       <FormField
         label="Server URL"
         htmlFor="server-url"
@@ -693,6 +688,15 @@ function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormPr
         <Input id="server-url" {...form.register("url")} placeholder="https://mcp.example.com" />
       </FormField>
 
+      <FormField
+        label="Server Name"
+        htmlFor="server-name"
+        required
+        error={form.formState.errors.name?.message}
+      >
+        <Input id="server-name" {...form.register("name")} placeholder="My MCP Server" />
+      </FormField>
+
       {/* Auth detection indicator */}
       {detectionStatus === "detecting" && (
         <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
diff --git a/ui/src/services/mcp/oauth.ts b/ui/src/services/mcp/oauth.ts
index d04aadbd..5bfb7325 100644
--- a/ui/src/services/mcp/oauth.ts
+++ b/ui/src/services/mcp/oauth.ts
@@ -40,6 +40,7 @@ function generateState(): string {
 
 interface ProtectedResourceMetadata {
   resource: string;
+  resource_name?: string;
   authorization_servers?: string[];
   scopes_supported?: string[];
 }
@@ -700,6 +701,8 @@ export function handleMCPOAuthCallback(): boolean {
 export interface AuthDetectionResult {
   authType: MCPAuthType;
   message: string;
+  /** Server name discovered from resource_name in protected resource metadata */
+  serverName?: string;
 }
 
 /**
@@ -729,7 +732,11 @@ export async function detectServerAuth(serverUrl: string): Promise<AuthDetection
         if (res.ok) {
           const meta = (await res.json()) as ProtectedResourceMetadata;
           if (meta?.authorization_servers?.length) {
-            return { authType: "oauth", message: "OAuth authentication detected" };
+            return {
+              authType: "oauth",
+              message: "OAuth authentication detected",
+              serverName: meta.resource_name,
+            };
           }
         }
       } catch {
@@ -775,7 +782,11 @@ export async function detectServerAuth(serverUrl: string): Promise<AuthDetection
           if (metaRes.ok) {
             const meta = (await metaRes.json()) as ProtectedResourceMetadata;
             if (meta?.authorization_servers?.length) {
-              return { authType: "oauth", message: "OAuth authentication required" };
+              return {
+                authType: "oauth",
+                message: "OAuth authentication required",
+                serverName: meta.resource_name,
+              };
             }
           }
         } catch {

From 27d8a94e63d861c7b4eee21cab94e547e035cca9 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Fri, 17 Apr 2026 01:04:31 +1000
Subject: [PATCH 3/5] Review fixes

---
 .../MCPConfigModal/MCPConfigModal.tsx           | 11 +++++------
 ui/src/services/mcp/oauth.ts                    | 17 ++++++++++++++---
 2 files changed, 19 insertions(+), 9 deletions(-)

diff --git a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
index c153df55..d2a0f66c 100644
--- a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
+++ b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
@@ -544,8 +544,8 @@ function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormPr
     setDetectionStatus("detecting");
     setDetectionMessage("");
 
+    let cancelled = false;
     const timer = setTimeout(() => {
-      let cancelled = false;
       detectServerAuth(watchedUrl).then((result) => {
         if (cancelled) return;
         setDetectionStatus("detected");
@@ -558,13 +558,12 @@ function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormPr
           form.setValue("name", result.serverName);
         }
       });
-      // Store cancel function on the timer's cleanup
-      return () => {
-        cancelled = true;
-      };
     }, 600);
 
-    return () => clearTimeout(timer);
+    return () => {
+      clearTimeout(timer);
+      cancelled = true;
+    };
     // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run on URL change
   }, [watchedUrl, isNewServer, userOverrodeAuth]);
 
diff --git a/ui/src/services/mcp/oauth.ts b/ui/src/services/mcp/oauth.ts
index 5bfb7325..969b6d56 100644
--- a/ui/src/services/mcp/oauth.ts
+++ b/ui/src/services/mcp/oauth.ts
@@ -195,6 +195,15 @@ async function fetchJson<T>(url: string): Promise<T | null> {
   }
 }
 
+/** Validate that a URL shares the same origin as the server (SSRF protection) */
+function isSameOrigin(targetUrl: string, serverUrl: string): boolean {
+  try {
+    return new URL(targetUrl).origin === new URL(serverUrl).origin;
+  } catch {
+    return false;
+  }
+}
+
 /**
  * Discover Protected Resource Metadata (RFC 9728).
  * Tries path-aware well-known URL first, then root.
@@ -215,10 +224,12 @@ async function discoverProtectedResourceMetadata(
     if (res.status === 401) {
       const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
       const resourceMetaUrl = wwwAuth.match(/resource_metadata="([^"]+)"/)?.[1];
-      if (resourceMetaUrl) {
+      if (resourceMetaUrl && isSameOrigin(resourceMetaUrl, serverUrl)) {
         fromWwwAuth = await fetchJson<ProtectedResourceMetadata>(resourceMetaUrl);
       }
-      // Also extract scope hint
+      // Extract scope hint from WWW-Authenticate as a fallback.
+      // Note: this is the scope required for this specific request, not
+      // necessarily all scopes the resource supports.
       if (!fromWwwAuth) {
         const scopeHint = wwwAuth.match(/scope="([^"]+)"/)?.[1];
         if (scopeHint) {
@@ -775,7 +786,7 @@ export async function detectServerAuth(serverUrl: string): Promise<AuthDetection
       const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
       const resourceMetaUrl = wwwAuth.match(/resource_metadata="([^"]+)"/)?.[1];
 
-      if (resourceMetaUrl) {
+      if (resourceMetaUrl && isSameOrigin(resourceMetaUrl, serverUrl)) {
         // Verify the resource metadata actually has OAuth servers
         try {
           const metaRes = await fetch(resourceMetaUrl);

From 24cb4cafe2d9c579fd5a32a60bc37230d4d3ec01 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Fri, 17 Apr 2026 01:19:28 +1000
Subject: [PATCH 4/5] Review fixes

---
 ui/src/components/MCPConfigModal/MCPConfigModal.tsx | 10 +++++++++-
 ui/src/services/mcp/oauth.ts                        |  2 +-
 2 files changed, 10 insertions(+), 2 deletions(-)

diff --git a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
index d2a0f66c..a4b14430 100644
--- a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
+++ b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
@@ -239,10 +239,12 @@ function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
   const { connectServer, disconnectServer, setToolEnabled } = useMCPStore();
   const [isToggling, setIsToggling] = useState(false);
   const [isAuthorizing, setIsAuthorizing] = useState(false);
+  const [authError, setAuthError] = useState<string>();
   const oauthAuthorized = server.authType === "oauth" && hasValidTokens(server.url);
 
   const handleAuthorize = useCallback(async () => {
     setIsAuthorizing(true);
+    setAuthError(undefined);
     try {
       await startOAuthFlow(server.url, server.oauth);
       // Tokens obtained — now connect
@@ -252,7 +254,7 @@ function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
         // Connection error stored in server state
       }
     } catch (err) {
-      console.debug("OAuth flow failed:", err);
+      setAuthError(err instanceof Error ? err.message : String(err));
     } finally {
       setIsAuthorizing(false);
     }
@@ -404,6 +406,12 @@ function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
             >
               {oauthAuthorized ? "Re-authorize" : "Authorize"}
             </Button>
+            {authError && (
+              <div className="flex items-start gap-1.5 text-xs text-destructive mt-1.5">
+                <AlertCircle className="h-3 w-3 shrink-0 mt-0.5" />
+                <span>{authError}</span>
+              </div>
+            )}
           </div>
         )}
       </div>
diff --git a/ui/src/services/mcp/oauth.ts b/ui/src/services/mcp/oauth.ts
index 969b6d56..2f4cd9e5 100644
--- a/ui/src/services/mcp/oauth.ts
+++ b/ui/src/services/mcp/oauth.ts
@@ -170,7 +170,7 @@ function clearPendingFlow(state: string): void {
 
 /** Check if an OAuth token has expired (with 30s buffer) */
 export function isTokenExpired(tokens: OAuthTokens): boolean {
-  if (!tokens.expires_in) return false;
+  if (tokens.expires_in == null) return false;
   const expiresAt = tokens.obtained_at + tokens.expires_in * 1000;
   return Date.now() > expiresAt - 30_000;
 }

From d86ddf7e3d48488f559a943485e53fbca0a110bc Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Fri, 17 Apr 2026 10:01:26 +1000
Subject: [PATCH 5/5] More clear auth flow

---
 .../MCPConfigModal/MCPConfigModal.tsx         | 165 ++++++++++++------
 1 file changed, 108 insertions(+), 57 deletions(-)

diff --git a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
index a4b14430..2d8a4e71 100644
--- a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
+++ b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx
@@ -292,6 +292,7 @@ function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
 
   const isConnectingStatus = server.status === "connecting" || isToggling;
   const hasTools = server.tools.length > 0;
+  const needsAuthorization = server.authType === "oauth" && !oauthAuthorized;
 
   return (
     <div className="border rounded-lg overflow-hidden">
@@ -345,7 +346,7 @@ function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
           <Switch
             checked={server.enabled}
             onChange={handleToggle}
-            disabled={isConnectingStatus}
+            disabled={isConnectingStatus || needsAuthorization}
             aria-label={server.enabled ? `Disconnect ${server.name}` : `Connect ${server.name}`}
             label=""
             className="shrink-0"
@@ -384,21 +385,28 @@ function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
 
         {/* OAuth status & authorize button */}
         {server.authType === "oauth" && (
-          <div className="mt-2 flex items-center gap-2">
+          <div
+            className={cn(
+              "mt-2 flex items-center gap-2 rounded-md p-2",
+              needsAuthorization
+                ? "bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800"
+                : ""
+            )}
+          >
             {oauthAuthorized ? (
               <span className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
                 <ShieldCheck className="h-3.5 w-3.5" />
                 Authorized
               </span>
             ) : (
-              <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
-                <KeyRound className="h-3.5 w-3.5" />
-                Not authorized
+              <span className="flex items-center gap-1.5 text-xs text-amber-700 dark:text-amber-400 flex-1">
+                <KeyRound className="h-3.5 w-3.5 shrink-0" />
+                Authorization required to connect
               </span>
             )}
             <Button
               type="button"
-              variant="outline"
+              variant={needsAuthorization ? "primary" : "outline"}
               size="sm"
               onClick={handleAuthorize}
               disabled={isAuthorizing}
@@ -774,54 +782,48 @@ function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormPr
 
       {/* OAuth fields */}
       {watchedAuthType === "oauth" && (
-        <div className="space-y-3 rounded-md border border-input p-3">
-          <FormField
-            label="Client ID"
-            htmlFor="server-oauth-client-id"
-            helpText="Leave empty to use dynamic client registration"
-          >
-            <Input
-              id="server-oauth-client-id"
-              {...form.register("oauthClientId")}
-              placeholder="Optional — for pre-registered apps"
-              className="font-mono"
-            />
-          </FormField>
-
-          <FormField
-            label="Scopes"
-            htmlFor="server-oauth-scopes"
-            helpText="Space-separated OAuth scopes (auto-detected if empty)"
-          >
-            <Input
-              id="server-oauth-scopes"
-              {...form.register("oauthScopes")}
-              placeholder="Optional — e.g. read write"
-            />
-          </FormField>
-
-          {/* OAuth status & authorize button */}
-          <div className="flex items-center gap-3 pt-1">
-            {oauthStatus === "authorized" ? (
-              <>
-                <span className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
-                  <ShieldCheck className="h-3.5 w-3.5" />
-                  Authorized
-                </span>
-                <Button type="button" variant="ghost" size="sm" onClick={handleRevoke}>
-                  Revoke
-                </Button>
-              </>
-            ) : oauthStatus === "authorizing" ? (
-              <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
-                <Loader2 className="h-3.5 w-3.5 animate-spin" />
-                Waiting for authorization...
+        <div
+          className={cn(
+            "space-y-3 rounded-md p-3",
+            oauthStatus === "authorized"
+              ? "border border-input"
+              : "border border-amber-300 dark:border-amber-800 bg-amber-50/60 dark:bg-amber-950/20"
+          )}
+        >
+          {/* Prominent authorize CTA */}
+          {oauthStatus === "authorized" ? (
+            <div className="flex items-center gap-3">
+              <span className="flex items-center gap-1.5 text-sm font-medium text-green-600 dark:text-green-400">
+                <ShieldCheck className="h-4 w-4" />
+                Authorized
               </span>
-            ) : (
-              <>
-                <Button type="button" variant="outline" size="sm" onClick={handleAuthorize}>
+              <Button type="button" variant="ghost" size="sm" onClick={handleRevoke}>
+                Revoke
+              </Button>
+            </div>
+          ) : (
+            <div className="space-y-2">
+              <div className="flex items-start gap-2 text-sm text-amber-800 dark:text-amber-300">
+                <KeyRound className="h-4 w-4 shrink-0 mt-0.5" />
+                <div className="flex-1">
+                  <div className="font-medium">Authorization required</div>
+                  <div className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
+                    Click Authorize to sign in and grant access. You won&apos;t be able to test or
+                    add the server until this step is completed.
+                  </div>
+                </div>
+              </div>
+              <div className="flex items-center gap-3">
+                <Button
+                  type="button"
+                  variant="primary"
+                  size="sm"
+                  onClick={handleAuthorize}
+                  isLoading={oauthStatus === "authorizing"}
+                  disabled={oauthStatus === "authorizing"}
+                >
                   <KeyRound className="h-4 w-4 mr-1.5" />
-                  Authorize
+                  {oauthStatus === "authorizing" ? "Waiting for authorization..." : "Authorize"}
                 </Button>
                 {oauthStatus === "error" && oauthError && (
                   <div className="flex items-start gap-1.5 text-xs text-destructive flex-1 min-w-0">
@@ -829,9 +831,42 @@ function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormPr
                     <span className="break-words">{oauthError}</span>
                   </div>
                 )}
-              </>
-            )}
-          </div>
+              </div>
+            </div>
+          )}
+
+          {/* Advanced OAuth fields */}
+          <details className="pt-1">
+            <summary className="text-xs text-muted-foreground cursor-pointer select-none hover:text-foreground">
+              Advanced options
+            </summary>
+            <div className="space-y-3 mt-3">
+              <FormField
+                label="Client ID"
+                htmlFor="server-oauth-client-id"
+                helpText="Leave empty to use dynamic client registration"
+              >
+                <Input
+                  id="server-oauth-client-id"
+                  {...form.register("oauthClientId")}
+                  placeholder="Optional — for pre-registered apps"
+                  className="font-mono"
+                />
+              </FormField>
+
+              <FormField
+                label="Scopes"
+                htmlFor="server-oauth-scopes"
+                helpText="Space-separated OAuth scopes (auto-detected if empty)"
+              >
+                <Input
+                  id="server-oauth-scopes"
+                  {...form.register("oauthScopes")}
+                  placeholder="Optional — e.g. read write"
+                />
+              </FormField>
+            </div>
+          </details>
         </div>
       )}
 
@@ -890,6 +925,14 @@ function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormPr
         </div>
       )}
 
+      {/* Gating hint when OAuth is required but not authorized */}
+      {watchedAuthType === "oauth" && oauthStatus !== "authorized" && (
+        <div className="flex items-start gap-1.5 text-xs text-muted-foreground">
+          <AlertCircle className="h-3 w-3 shrink-0 mt-0.5" />
+          <span>Authorize above to enable testing and saving this server.</span>
+        </div>
+      )}
+
       <div className="flex justify-between pt-2">
         <Button type="button" variant="ghost" onClick={onCancel}>
           Cancel
@@ -900,12 +943,20 @@ function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormPr
             variant="outline"
             onClick={handleTestConnection}
             isLoading={testStatus === "testing"}
-            disabled={testStatus === "testing"}
+            disabled={
+              testStatus === "testing" ||
+              (watchedAuthType === "oauth" && oauthStatus !== "authorized")
+            }
           >
             <Wifi className="h-4 w-4 mr-1.5" />
             Test
           </Button>
-          <Button type="submit">{editingServer ? "Save" : "Add Server"}</Button>
+          <Button
+            type="submit"
+            disabled={watchedAuthType === "oauth" && oauthStatus !== "authorized"}
+          >
+            {editingServer ? "Save" : "Add Server"}
+          </Button>
         </div>
       </div>
     </form>
