Connect (or reconnect) a single server by name. * When `skipUnauthOAuth` is set, OAuth servers with no stored tokens are * marked needs-auth instead of opening a browser (used at startup). * When `authIntercept` is provided, the caller controls the OAuth UI * (surfacing the auth URL, pro
(name: string, options?: { skipUnauthOAuth?: boolean; authIntercept?: AuthIntercept; forceOAuth?: boolean })
| 80 | * config is treated as `oauth: true` (auto-detected OAuth — used when the |
| 81 | * user triggers auth after a 401). */ |
| 82 | async connectOne(name: string, options?: { skipUnauthOAuth?: boolean; authIntercept?: AuthIntercept; forceOAuth?: boolean }): Promise<void> { |
| 83 | const cfg = this.configs.get(name) |
| 84 | if (!cfg) return |
| 85 | const state = this.states.get(name)! |
| 86 | const isRemote = cfg.type === "http" || cfg.type === "sse" |
| 87 | |
| 88 | // At startup, don't open a browser for OAuth servers that haven't been |
| 89 | // authenticated yet — surface them as needs-auth so the user can trigger |
| 90 | // auth explicitly from the /mcp picker. This applies to both explicitly |
| 91 | // configured OAuth servers and any remote server (which may require OAuth |
| 92 | // we don't know about yet — we'll find out on the first connect attempt). |
| 93 | if (options?.skipUnauthOAuth && isOAuthConfig(cfg) && !hasStoredAuth(name)) { |
| 94 | state.status = "needs-auth" |
| 95 | state.detail = "not authenticated — press a to authenticate" |
| 96 | return |
| 97 | } |
| 98 | |
| 99 | state.status = "connecting" |
| 100 | state.detail = undefined |
| 101 | try { |
| 102 | const { scope: _scope, ...serverConfig } = cfg |
| 103 | // Auto-detect: if forceOAuth is set and this is a remote server without |
| 104 | // explicit oauth config, inject oauth: true so the auth transport is used. |
| 105 | if (options?.forceOAuth && isRemote && !isOAuthConfig(cfg)) { |
| 106 | ;(serverConfig as McpHttpServerConfig).oauth = true |
| 107 | } |
| 108 | const connection = await connectMcpServer(name, serverConfig, options?.authIntercept) |
| 109 | this.connections.set(name, connection) |
| 110 | state.status = "connected" |
| 111 | state.toolCount = connection.tools.length |
| 112 | state.detail = `${connection.tools.length} tool${connection.tools.length === 1 ? "" : "s"}` |
| 113 | this.pendingApproval.delete(name) |
| 114 | } catch (error) { |
| 115 | const msg = (error as Error).message |
| 116 | // Any remote server that returns 401/unauthorized/invalid_token needs |
| 117 | // auth — surface as needs-auth regardless of whether `oauth` was |
| 118 | // explicitly configured (auto-detection, like Claude Code). |
| 119 | if (isRemote && /unauthorized|auth|401|oauth|invalid_token/i.test(msg)) { |
| 120 | state.status = "needs-auth" |
| 121 | state.detail = "authentication required — press a to authenticate" |
| 122 | } else { |
| 123 | state.status = "failed" |
| 124 | state.detail = msg |
| 125 | } |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | /** Disconnect a single server (keeps config; marks it disabled). */ |
| 130 | async disconnectOne(name: string): Promise<void> { |
no test coverage detected