设置中心
diff --git a/packages/desktop/src/renderer/components/VoiceConfigPanel.tsx b/packages/desktop/src/renderer/components/VoiceConfigPanel.tsx
index dc99665..a747660 100644
--- a/packages/desktop/src/renderer/components/VoiceConfigPanel.tsx
+++ b/packages/desktop/src/renderer/components/VoiceConfigPanel.tsx
@@ -21,13 +21,8 @@ interface Feedback {
* 保存前会先调用 TTS 接口验证 Key 的有效性,验证通过才保存
*/
export const VoiceConfigPanel: React.FC = () => {
- const {
- voiceApiKey,
- setVoiceApiKey,
- loadVoiceApiKey,
- summaryThreshold,
- setSummaryThreshold,
- } = useVoiceStore();
+ const { voiceApiKey, setVoiceApiKey, summaryThreshold, setSummaryThreshold } =
+ useVoiceStore();
const [inputKey, setInputKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [verifying, setVerifying] = useState(false);
@@ -36,10 +31,6 @@ export const VoiceConfigPanel: React.FC = () => {
String(summaryThreshold)
);
- useEffect(() => {
- loadVoiceApiKey();
- }, [loadVoiceApiKey]);
-
useEffect(() => {
if (voiceApiKey) {
setInputKey(voiceApiKey);
diff --git a/packages/desktop/src/renderer/lib/api.ts b/packages/desktop/src/renderer/lib/api.ts
index b905a25..5d8c08a 100644
--- a/packages/desktop/src/renderer/lib/api.ts
+++ b/packages/desktop/src/renderer/lib/api.ts
@@ -47,11 +47,17 @@ export async function getBaseUrl(): Promise
{
export interface McpServer {
name: string;
- status: 'connected' | 'disconnected' | 'error';
+ status: 'connected' | 'disconnected' | 'connecting' | 'needs_auth';
toolCount?: number;
error?: string;
}
+export interface McpOAuthStatus {
+ status: McpServer['status'];
+ oauthUrl?: string;
+ error?: string;
+}
+
export interface ListMcpsResponse {
servers: McpServer[];
}
@@ -627,6 +633,50 @@ export async function listMcpServers(): Promise {
return data.servers;
}
+/**
+ * 启动指定 MCP 服务器的 OAuth 授权流程。
+ * 返回授权 URL,前端应使用 shell.openExternal 打开浏览器。
+ * @param name - MCP 服务器名称
+ * @returns 包含授权 URL 的对象
+ */
+export async function startMcpOAuth(
+ name: string
+): Promise<{ authorizationUrl: string }> {
+ const baseUrl = await getBaseUrl();
+ const response = await fetch(
+ `${baseUrl}/api/mcp/${encodeURIComponent(name)}/oauth/authorize`,
+ { method: 'POST', headers: { 'Content-Type': 'application/json' } }
+ );
+
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}));
+ throw new Error(
+ (data as { error?: string }).error ||
+ `Failed to start OAuth: ${response.status}`
+ );
+ }
+
+ return response.json();
+}
+
+/**
+ * 查询指定 MCP 服务器的 OAuth 授权状态,用于前端轮询。
+ * @param name - MCP 服务器名称
+ */
+export async function getMcpOAuthStatus(name: string): Promise {
+ const baseUrl = await getBaseUrl();
+ const response = await fetch(
+ `${baseUrl}/api/mcp/${encodeURIComponent(name)}/oauth/status`,
+ { method: 'GET', headers: { Accept: 'application/json' } }
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to get OAuth status: ${response.status}`);
+ }
+
+ return response.json();
+}
+
/**
* 获取可用的技能列表。
* @returns 技能数组
diff --git a/packages/desktop/src/renderer/stores/configStore.ts b/packages/desktop/src/renderer/stores/configStore.ts
index 12db21b..20aabf5 100644
--- a/packages/desktop/src/renderer/stores/configStore.ts
+++ b/packages/desktop/src/renderer/stores/configStore.ts
@@ -14,7 +14,6 @@ interface RetryConfig {
interface MCPConfig {
connectTimeout: number;
executeTimeout: number;
- sseReadTimeout: number;
}
interface ToolsConfig {
diff --git a/packages/desktop/src/renderer/stores/viewStore.ts b/packages/desktop/src/renderer/stores/viewStore.ts
new file mode 100644
index 0000000..03f5ca1
--- /dev/null
+++ b/packages/desktop/src/renderer/stores/viewStore.ts
@@ -0,0 +1,17 @@
+/**
+ * @file src/renderer/stores/viewStore.ts
+ * @description 主窗口视图状态管理,控制当前显示聊天界面还是设置页面
+ */
+import { create } from 'zustand';
+
+type ViewType = 'chat' | 'settings';
+
+interface ViewStore {
+ currentView: ViewType;
+ setView: (view: ViewType) => void;
+}
+
+export const useViewStore = create()((set) => ({
+ currentView: 'chat',
+ setView: (view) => set({ currentView: view }),
+}));
diff --git a/packages/desktop/src/renderer/stores/voiceStore.ts b/packages/desktop/src/renderer/stores/voiceStore.ts
index 8af4ebd..646426b 100644
--- a/packages/desktop/src/renderer/stores/voiceStore.ts
+++ b/packages/desktop/src/renderer/stores/voiceStore.ts
@@ -1,7 +1,7 @@
/**
* @file stores/voiceStore.ts
* @description 语音状态管理,负责 API Key 持久化、语音开关、TTS 合成和摘要
- * 语音开关状态通过 zustand persist 中间件持久化到 localStorage,重启后自动恢复
+ * 所有语音相关状态(包括 API Key、开关、阈值)统一通过 zustand persist 中间件持久化到 localStorage,重启后自动恢复
*/
import { create } from 'zustand';
@@ -13,13 +13,11 @@ import { audioPlayer } from '../lib/audio-player';
import { toast } from './toastStore';
import { logger } from '../lib/logger';
-const VOICE_API_KEY_STORAGE_KEY = 'minimax-voice-api-key';
const DEFAULT_SUMMARY_THRESHOLD = 200;
interface VoiceStore {
voiceApiKey: string | null;
setVoiceApiKey: (key: string) => void;
- loadVoiceApiKey: () => void;
isSpeaking: boolean;
voiceEnabled: boolean;
@@ -47,19 +45,10 @@ export const useVoiceStore = create()(
summaryThreshold: DEFAULT_SUMMARY_THRESHOLD,
/**
- * 从 localStorage 加载 MiniMax API Key 到内存
- */
- loadVoiceApiKey: () => {
- const key = localStorage.getItem(VOICE_API_KEY_STORAGE_KEY);
- set({ voiceApiKey: key });
- },
-
- /**
- * 保存 API Key 到内存和 localStorage
+ * 保存 API Key 到内存(zustand persist 中间件会自动持久化到 localStorage)
* @param key - MiniMax API Key
*/
setVoiceApiKey: (key: string) => {
- localStorage.setItem(VOICE_API_KEY_STORAGE_KEY, key);
set({ voiceApiKey: key });
},
@@ -141,6 +130,7 @@ export const useVoiceStore = create()(
{
name: 'voice-store',
partialize: (state) => ({
+ voiceApiKey: state.voiceApiKey,
voiceEnabled: state.voiceEnabled,
summaryThreshold: state.summaryThreshold,
}),
diff --git a/packages/desktop/src/renderer/types/window.d.ts b/packages/desktop/src/renderer/types/window.d.ts
index e69ab26..1088484 100644
--- a/packages/desktop/src/renderer/types/window.d.ts
+++ b/packages/desktop/src/renderer/types/window.d.ts
@@ -9,13 +9,13 @@ declare global {
};
};
api?: {
- openSettingsWindow: () => Promise;
selectFolder: (options?: {
title?: string;
defaultPath?: string;
}) => Promise;
getServerUrl: () => Promise;
log: (level: string, ...args: unknown[]) => Promise;
+ openExternal: (url: string) => Promise;
proxyFetch: (
url: string,
options: {
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index d45525f..2394445 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -7,18 +7,12 @@ import { initAllDirsAndFiles, Logger } from './util/index.js';
import { getLogsDir, getConfigPath } from './util/paths.js';
import { loadConfig } from './config/index.js';
import { startServer, httpServer } from './server/index.js';
-
-declare global {
- // 正式编译时通过 Bun --define 注入,开发模式回退为 "dev"
- var SERVER_VERSION: string | undefined;
-}
-
-const version = globalThis.SERVER_VERSION ?? 'dev';
+import { APP_NAME, APP_VERSION } from './util/app.js';
const program = new Command();
program
- .name('persona-agent-server')
- .version(version)
+ .name(APP_NAME)
+ .version(APP_VERSION)
.argument('', 'Port to listen on')
.action(async (portStr: string) => {
const port = parseInt(portStr, 10);
diff --git a/packages/server/src/mcp/config.ts b/packages/server/src/mcp/config.ts
index e2ad64d..7274c46 100644
--- a/packages/server/src/mcp/config.ts
+++ b/packages/server/src/mcp/config.ts
@@ -15,7 +15,7 @@ function isRecord(value: unknown): value is Record {
}
/**
- * Load and parse the MCP configuration file.
+ * Load and parse the MCP config file.
*
* @param configPath - Optional custom path to mcp.json (default: ~/.local/share/persona-agent/mcp/mcp.json)
* @returns A map of server name -> server config, or an empty map if file doesn't exist
diff --git a/packages/server/src/mcp/connection.ts b/packages/server/src/mcp/connection.ts
index f43dee1..737c3d1 100644
--- a/packages/server/src/mcp/connection.ts
+++ b/packages/server/src/mcp/connection.ts
@@ -1,29 +1,28 @@
/**
* @fileoverview MCP server connection and tool wrapper.
*
- * MCPServerConnection manages a single MCP server connection (stdio/sse/streamable_http).
- * MCPTool wraps a remote MCP tool into the local Tool interface.
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
-import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
+import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
import { Logger } from '../util/logger.js';
+import { APP_NAME, APP_VERSION } from '../util/app.js';
import type { Tool, ToolInput, ToolResult, JsonSchema } from '../tools/base.js';
-import type {
- Closable,
- ConnectionType,
- McpClient,
- McpServerConfig,
-} from './types.js';
+import type { ConnectionType, McpClient, McpServerConfig } from './types.js';
+import type { McpOAuthProvider } from './oauth/provider.js';
+//TODO: 这个是写死的配置, 以后要考虑放到前端的服务器设置里面给用户自行配置
const DEFAULT_TIMEOUTS = {
connectTimeout: 60,
executeTimeout: 120,
- sseReadTimeout: 120,
};
+/**
+ * Adapts a remote MCP tool into the local {@link Tool} interface.
+ * Handles execution timeout and error/result normalization.
+ */
export class MCPTool implements Tool {
public name: string;
public description: string;
@@ -46,6 +45,12 @@ export class MCPTool implements Tool {
this.executeTimeoutMs = options.executeTimeoutSec * 1000;
}
+ /**
+ * Execute the tool with the given parameters.
+ *
+ * @param params - Tool input arguments
+ * @returns Normalized result with success/error status
+ */
async execute(params: ToolInput): Promise {
try {
const result = await withTimeout(
@@ -62,8 +67,8 @@ export class MCPTool implements Tool {
return {
success: !isError,
- content,
- error: isError ? 'Tool returned error' : null,
+ content: isError ? '' : content,
+ error: isError ? content || 'Tool returned error' : null,
};
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
@@ -76,6 +81,10 @@ export class MCPTool implements Tool {
}
}
+/**
+ * Manages a single MCP server connection
+ * Handles connection lifecycle, tool discovery, and OAuth authentication for remote servers.
+ */
export class MCPServerConnection {
public name: string;
public connectionType: ConnectionType;
@@ -87,12 +96,15 @@ export class MCPServerConnection {
public headers: Record;
public connectTimeoutSec?: number;
public executeTimeoutSec?: number;
- public sseReadTimeoutSec?: number;
public tools: MCPTool[] = [];
private session: McpClient | null = null;
- private transport: Closable | null = null;
+ private transport:
+ | StdioClientTransport
+ | StreamableHTTPClientTransport
+ | null = null;
+ private authProvider?: McpOAuthProvider;
constructor(options: {
name: string;
@@ -105,7 +117,7 @@ export class MCPServerConnection {
headers?: Record;
connectTimeoutSec?: number;
executeTimeoutSec?: number;
- sseReadTimeoutSec?: number;
+ authProvider?: McpOAuthProvider;
}) {
this.name = options.name;
this.connectionType = options.connectionType;
@@ -117,7 +129,7 @@ export class MCPServerConnection {
this.headers = options.headers ?? {};
this.connectTimeoutSec = options.connectTimeoutSec;
this.executeTimeoutSec = options.executeTimeoutSec;
- this.sseReadTimeoutSec = options.sseReadTimeoutSec;
+ this.authProvider = options.authProvider;
}
private getConnectTimeoutSec(): number {
@@ -130,9 +142,12 @@ export class MCPServerConnection {
/**
* Create the appropriate transport based on connection type.
- * Supports stdio, sse, and streamable_http transports.
+ *
+ * @throws {Error} If required config (command for stdio, url for remote) is missing
*/
- private createTransport(): Closable {
+ private createTransport():
+ | StdioClientTransport
+ | StreamableHTTPClientTransport {
if (this.connectionType === 'stdio') {
if (!this.command) {
throw new Error('Missing command for stdio transport');
@@ -150,17 +165,8 @@ export class MCPServerConnection {
throw new Error('Missing url for remote transport');
}
- if (this.connectionType === 'sse') {
- return new SSEClientTransport(new URL(this.url), {
- requestInit: {
- headers:
- Object.keys(this.headers).length > 0 ? this.headers : undefined,
- },
- });
- }
-
- // streamable_http (default for URL-based connections)
return new StreamableHTTPClientTransport(new URL(this.url), {
+ authProvider: this.authProvider,
requestInit: {
headers:
Object.keys(this.headers).length > 0 ? this.headers : undefined,
@@ -169,18 +175,21 @@ export class MCPServerConnection {
}
/**
- * Connect to the MCP server, discover available tools, and populate this.tools.
- * Returns true on success, false on failure. On failure, resources are cleaned up.
+ * Connect to the MCP server and discover available tools.
+ *
+ * For remote servers requiring OAuth, returns `{ needsAuth: true }`. Read {@link authorizationUrl} to get the URL, then complete the OAuth flow before calling {@link connect} again.
+ *
+ * @returns Connection result with success status and optional auth requirement
*/
- async connect(): Promise {
+ async connect(): Promise<{ success: boolean; needsAuth?: boolean }> {
const connectTimeoutMs = this.getConnectTimeoutSec() * 1000;
- try {
- const transport = this.createTransport();
- const client = new Client({
- name: 'persona-agent',
- version: '1.0.0',
- }) as unknown as McpClient;
+ const transport = this.createTransport();
+ const client = new Client({
+ name: APP_NAME,
+ version: APP_VERSION,
+ }) as unknown as McpClient;
+ try {
const toolsList = await withTimeout(
(async () => {
await client.connect(transport);
@@ -216,18 +225,31 @@ export class MCPServerConnection {
'MCP',
`Connected to '${this.name}' (${this.connectionType}) - loaded ${this.tools.length} tools`
);
- return true;
+ return { success: true };
} catch (error: unknown) {
+ if (error instanceof UnauthorizedError) {
+ this.transport = transport;
+ Logger.log(
+ 'MCP',
+ `Server '${this.name}' requires OAuth authentication`
+ );
+ if (client?.close) {
+ await client.close().catch(() => {});
+ }
+ return { success: false, needsAuth: true };
+ }
+
const message = error instanceof Error ? error.message : String(error);
Logger.log(
'ERROR',
`Failed to connect MCP server '${this.name}': ${message}`
);
await this.disconnect();
- return false;
+ return { success: false };
}
}
+ /** Close the session and transport, releasing all resources. */
async disconnect(): Promise {
const session = this.session;
const transport = this.transport;
@@ -241,12 +263,40 @@ export class MCPServerConnection {
await transport.close();
}
}
+
+ /**
+ * Exchange an OAuth authorization code for access tokens.
+ *
+ * @param code - Authorization code from the OAuth callback
+ * @throws {Error} If no active transport or transport doesn't support OAuth
+ */
+ async finishAuth(code: string): Promise {
+ if (!this.transport) {
+ throw new Error('No active transport to finish auth');
+ }
+
+ if (this.transport instanceof StreamableHTTPClientTransport) {
+ Logger.log(
+ 'MCP-OAuth',
+ `Finishing OAuth for '${this.name}' with authorization code`
+ );
+ await this.transport.finishAuth(code);
+ } else {
+ throw new Error('Transport does not support OAuth finishAuth');
+ }
+ }
+
+ /** Authorization URL for the current OAuth flow. Available after {@link connect} returns `{ needsAuth: true }`. */
+ get authorizationUrl(): string | undefined {
+ return this.authProvider?.getAuthorizationUrl();
+ }
}
/**
* Determine the connection type from server config.
- * Normalizes 'http' to 'streamable_http' since both use the same transport.
- * Defaults to 'streamable_http' for URL-based configs, 'stdio' otherwise.
+ *
+ * @param config - MCP server configuration
+ * @returns 'stdio' for command-based configs, 'streamable_http' for URL-based
*/
export function determineConnectionType(
config: McpServerConfig
@@ -256,8 +306,6 @@ export function determineConnectionType(
switch (explicitType) {
case 'stdio':
return 'stdio';
- case 'sse':
- return 'sse';
case 'http':
case 'streamable_http':
return 'streamable_http';
diff --git a/packages/server/src/mcp/index.ts b/packages/server/src/mcp/index.ts
index d5a37bb..622e79a 100644
--- a/packages/server/src/mcp/index.ts
+++ b/packages/server/src/mcp/index.ts
@@ -8,5 +8,7 @@ export {
getMcpServer,
getMcpToolsForServers,
getMcpPromptInfo,
+ startOAuthFlow,
+ getOAuthStatus,
} from './pool.js';
export type { McpServerEntry } from './types.js';
diff --git a/packages/server/src/mcp/loader.ts b/packages/server/src/mcp/loader.ts
index b3885a4..94a8f32 100644
--- a/packages/server/src/mcp/loader.ts
+++ b/packages/server/src/mcp/loader.ts
@@ -3,26 +3,42 @@
*
* Connects to all configured MCP servers concurrently at startup.
* Each connection is independent - a single failure does not affect others.
+ * For remote servers with URLs, creates OAuth providers for authentication support.
*/
import { Logger } from '../util/logger.js';
import { MCPServerConnection, determineConnectionType } from './connection.js';
+import { McpOAuthProvider } from './oauth/provider.js';
+import { getOAuthTokensPath } from '../util/paths.js';
import type { McpServerConfig } from './types.js';
import type { McpConnection, McpToolMeta } from './types.js';
+export interface ConnectResult {
+ name: string;
+ connection?: McpConnection;
+ tools: McpToolMeta[];
+ error?: string;
+ needsAuth?: boolean;
+ oauthUrl?: string;
+ serverConn?: MCPServerConnection;
+}
+
/**
* Connect to a single MCP server and return its connection result.
+ * For remote servers (URL-based), creates an OAuth provider for authentication.
*/
async function connectOne(
name: string,
config: McpServerConfig
-): Promise<{
- name: string;
- connection?: McpConnection;
- tools: McpToolMeta[];
- error?: string;
-}> {
+): Promise {
const connectionType = determineConnectionType(config);
+
+ Logger.log('MCP', `Connecting to '${name}' (${connectionType})...`);
+
+ const authProvider = config.url
+ ? new McpOAuthProvider(name, getOAuthTokensPath())
+ : undefined;
+
const serverConn = new MCPServerConnection({
name,
connectionType,
@@ -34,12 +50,23 @@ async function connectOne(
headers: config.headers,
connectTimeoutSec: config.connect_timeout,
executeTimeoutSec: config.execute_timeout,
- sseReadTimeoutSec: config.sse_read_timeout,
+ authProvider,
});
try {
- const success = await serverConn.connect();
- if (!success) {
+ const result = await serverConn.connect();
+
+ if (result.needsAuth) {
+ return {
+ name,
+ tools: [],
+ needsAuth: true,
+ oauthUrl: serverConn.authorizationUrl,
+ serverConn,
+ };
+ }
+
+ if (!result.success) {
return { name, tools: [], error: 'Connection failed' };
}
@@ -55,7 +82,7 @@ async function connectOne(
disconnect: () => serverConn.disconnect(),
};
- return { name, connection, tools };
+ return { name, connection, tools, serverConn };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
Logger.log('ERROR', `Failed to connect MCP server '${name}': ${message}`);
@@ -67,22 +94,11 @@ async function connectOne(
* Connect to all configured MCP servers in parallel.
*
* @param serverConfigs - Map of server name -> config
- * @returns Array of connection results (one per server)
+ * @returns Array of connection results
*/
export async function connectAllServers(
serverConfigs: Map
-): Promise<
- Array<{
- name: string;
- connection?: McpConnection;
- tools: McpToolMeta[];
- error?: string;
- }>
-> {
- if (serverConfigs.size === 0) {
- return [];
- }
-
+): Promise {
Logger.log('MCP', `Connecting to ${serverConfigs.size} MCP servers...`);
const entries = Array.from(serverConfigs.entries());
@@ -91,11 +107,12 @@ export async function connectAllServers(
);
const connectedCount = results.filter((r) => r.connection).length;
- const failedCount = results.length - connectedCount;
+ const needsAuthCount = results.filter((r) => r.needsAuth).length;
+ const failedCount = results.length - connectedCount - needsAuthCount;
Logger.log(
'MCP',
- `MCP connection complete: ${connectedCount} connected, ${failedCount} failed`
+ `MCP connection complete: ${connectedCount} connected, ${needsAuthCount} needs auth, ${failedCount} failed`
);
return results;
diff --git a/packages/server/src/mcp/oauth/callback.ts b/packages/server/src/mcp/oauth/callback.ts
new file mode 100644
index 0000000..472d8c4
--- /dev/null
+++ b/packages/server/src/mcp/oauth/callback.ts
@@ -0,0 +1,79 @@
+/**
+ * @fileoverview Temporary local HTTP server for receiving OAuth callbacks.
+ *
+ * Listens on a random port, waits for the browser to redirect back with
+ * an authorization code, then shuts down.
+ */
+
+import * as http from 'node:http';
+import { Logger } from '../../util/logger.js';
+
+const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;
+
+const SUCCESS_HTML = `
+
+Authorization successful
You can close this tab and return to the app.
+`;
+
+export interface CallbackResult {
+ port: number;
+ waitForCode: () => Promise;
+ close: () => void;
+}
+
+/**
+ * Start a temporary HTTP server on a random port to receive the OAuth callback.
+ *
+ * The caller should:
+ * 1. Read `result.port` to get the actual port number
+ * 2. Configure the provider's redirect URL as `http://localhost:{port}/callback`
+ * 3. Call `result.waitForCode()` to block until the code arrives (or timeout)
+ * 4. Call `result.close()` when done
+ */
+export function startCallbackServer(): Promise {
+ let resolveCode: ((code: string) => void) | undefined;
+ const codePromise = new Promise((resolve) => {
+ resolveCode = resolve;
+ });
+
+ const server = http.createServer((req, res) => {
+ if (!req.url?.startsWith('/callback')) {
+ res.writeHead(404).end();
+ return;
+ }
+
+ const url = new URL(req.url, `http://localhost`);
+ const code = url.searchParams.get('code');
+
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(SUCCESS_HTML);
+
+ if (code && resolveCode) {
+ Logger.log('MCP-OAuth', 'OAuth callback received authorization code');
+ resolveCode(code);
+ resolveCode = undefined;
+ }
+ });
+
+ return new Promise((resolve) => {
+ server.listen(0, () => {
+ const addr = server.address();
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
+
+ Logger.log('MCP-OAuth', `OAuth callback server started on port ${port}`);
+
+ const timeoutId = setTimeout(() => {
+ server.close();
+ }, CALLBACK_TIMEOUT_MS);
+
+ resolve({
+ port,
+ waitForCode: () => codePromise,
+ close: () => {
+ clearTimeout(timeoutId);
+ server.close();
+ },
+ });
+ });
+ });
+}
diff --git a/packages/server/src/mcp/oauth/provider.ts b/packages/server/src/mcp/oauth/provider.ts
new file mode 100644
index 0000000..4f1e89c
--- /dev/null
+++ b/packages/server/src/mcp/oauth/provider.ts
@@ -0,0 +1,110 @@
+/**
+ * @fileoverview OAuthClientProvider implementation for MCP remote servers.
+ *
+ * Each remote MCP server gets its own provider instance bound to persistent storage.
+ * When SDK requires a browser redirect, the authorization URL is saved to a field
+ * instead of opening the browser directly — the pool layer handles browser launching.
+ */
+
+import type {
+ OAuthClientInformationMixed,
+ OAuthClientMetadata,
+ OAuthTokens,
+} from '@modelcontextprotocol/sdk/shared/auth.js';
+import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
+import * as storage from './storage.js';
+import { Logger } from '../../util/logger.js';
+import { APP_NAME } from '../../util/app.js';
+
+export class McpOAuthProvider implements OAuthClientProvider {
+ private serverName: string;
+ private filePath: string;
+
+ /**
+ * Set by setRedirectUrl() after the callback server starts.
+ * The port is unknown until the server binds to a random port.
+ */
+ private _redirectUrl: string | undefined;
+
+ /**
+ * Saved by redirectToAuthorization().
+ * The pool layer reads this via getAuthorizationUrl() to open the browser.
+ */
+ private _authorizationUrl: string | undefined;
+
+ constructor(serverName: string, filePath: string) {
+ this.serverName = serverName;
+ this.filePath = filePath;
+ this._redirectUrl = 'http://localhost:0/callback';
+ }
+
+ get redirectUrl(): string | undefined {
+ return this._redirectUrl;
+ }
+
+ get clientMetadata(): OAuthClientMetadata {
+ return {
+ redirect_uris: this._redirectUrl ? [this._redirectUrl] : [],
+ token_endpoint_auth_method: 'none',
+ grant_types: ['authorization_code', 'refresh_token'],
+ client_name: `${APP_NAME}-mcp-${this.serverName}`,
+ };
+ }
+
+ clientInformation(): OAuthClientInformationMixed | undefined {
+ return storage.loadClientInfo(this.filePath, this.serverName);
+ }
+
+ saveClientInformation(clientInformation: OAuthClientInformationMixed): void {
+ storage.saveClientInfo(this.filePath, this.serverName, clientInformation);
+ }
+
+ tokens(): OAuthTokens | undefined {
+ return storage.loadTokens(this.filePath, this.serverName);
+ }
+
+ saveTokens(tokens: OAuthTokens): void {
+ storage.saveTokens(this.filePath, this.serverName, tokens);
+ }
+
+ codeVerifier(): string {
+ return storage.loadCodeVerifier(this.filePath, this.serverName) ?? '';
+ }
+
+ saveCodeVerifier(codeVerifier: string): void {
+ storage.saveCodeVerifier(this.filePath, this.serverName, codeVerifier);
+ }
+
+ /**
+ * Called by SDK when user needs to visit the authorization page.
+ * Instead of opening the browser here, we save the URL for the pool
+ * layer to pick up and handle browser launching.
+ */
+ redirectToAuthorization(authorizationUrl: URL): void {
+ this._authorizationUrl = authorizationUrl.toString();
+ Logger.log(
+ 'MCP-OAuth',
+ `Redirecting to authorization: ${this._authorizationUrl}`
+ );
+ }
+
+ invalidateCredentials(
+ scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'
+ ): void {
+ if (scope === 'all') {
+ storage.clearOAuthData(this.filePath, this.serverName);
+ return;
+ }
+ // Partial scope clearing will be implemented in Stage 6.
+ // For now, fall through to full clear.
+ storage.clearOAuthData(this.filePath, this.serverName);
+ }
+
+ getAuthorizationUrl(): string | undefined {
+ return this._authorizationUrl;
+ }
+
+ setRedirectUrl(url: string): void {
+ this._redirectUrl = url;
+ }
+}
diff --git a/packages/server/src/mcp/oauth/storage.ts b/packages/server/src/mcp/oauth/storage.ts
new file mode 100644
index 0000000..ef35a51
--- /dev/null
+++ b/packages/server/src/mcp/oauth/storage.ts
@@ -0,0 +1,126 @@
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { Logger } from '../../util/logger.js';
+import type {
+ OAuthClientInformationMixed,
+ OAuthTokens,
+} from '@modelcontextprotocol/sdk/shared/auth.js';
+import type { OAuthStorageEntry } from './types.js';
+
+/**
+ * In-memory cache keyed by server name.
+ * Entries are loaded from file on first access and kept in sync on writes.
+ * A `null` value indicates the entry has been deleted.
+ */
+const cache = new Map();
+
+/** Read the entire OAuth tokens file from disk. Returns empty object if missing. */
+function loadAll(filePath: string): Record {
+ if (!fs.existsSync(filePath)) {
+ return {};
+ }
+ const content = fs.readFileSync(filePath, 'utf8');
+ if (!content.trim()) {
+ return {};
+ }
+ return JSON.parse(content) as Record;
+}
+
+/** Write the entire data map to disk, creating parent directories if needed. */
+function saveAll(
+ filePath: string,
+ data: Record
+): void {
+ const dir = path.dirname(filePath);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
+}
+
+/**
+ * Get a single server's OAuth entry.
+ * Returns from cache if available, otherwise reads from file.
+ * Returns an empty object (all fields undefined) for non-existent entries.
+ */
+function getEntry(filePath: string, name: string): OAuthStorageEntry {
+ const cached = cache.get(name);
+ if (cached !== undefined) {
+ return cached ?? {};
+ }
+ const all = loadAll(filePath);
+ const entry = all[name] ?? {};
+ cache.set(name, Object.keys(entry).length > 0 ? entry : null);
+ return entry;
+}
+
+/** Update a server's entry in both cache and file. */
+function setEntry(
+ filePath: string,
+ name: string,
+ entry: OAuthStorageEntry
+): void {
+ cache.set(name, entry);
+ const all = loadAll(filePath);
+ all[name] = entry;
+ saveAll(filePath, all);
+}
+
+export function loadTokens(
+ filePath: string,
+ name: string
+): OAuthTokens | undefined {
+ return getEntry(filePath, name).tokens;
+}
+
+export function saveTokens(
+ filePath: string,
+ name: string,
+ tokens: OAuthTokens
+): void {
+ const entry = getEntry(filePath, name);
+ setEntry(filePath, name, { ...entry, tokens });
+ Logger.log('MCP-OAuth', `Saved OAuth tokens for '${name}'`);
+}
+
+export function loadClientInfo(
+ filePath: string,
+ name: string
+): OAuthClientInformationMixed | undefined {
+ return getEntry(filePath, name).clientInfo;
+}
+
+export function saveClientInfo(
+ filePath: string,
+ name: string,
+ clientInfo: OAuthClientInformationMixed
+): void {
+ const entry = getEntry(filePath, name);
+ setEntry(filePath, name, { ...entry, clientInfo });
+ Logger.log('MCP-OAuth', `Saved OAuth client info for '${name}'`);
+}
+
+export function loadCodeVerifier(
+ filePath: string,
+ name: string
+): string | undefined {
+ return getEntry(filePath, name).codeVerifier;
+}
+
+export function saveCodeVerifier(
+ filePath: string,
+ name: string,
+ codeVerifier: string
+): void {
+ const entry = getEntry(filePath, name);
+ setEntry(filePath, name, { ...entry, codeVerifier });
+}
+
+/** Remove all OAuth data for a server from both cache and file. */
+export function clearOAuthData(filePath: string, name: string): void {
+ cache.set(name, null);
+ const all = loadAll(filePath);
+ delete all[name];
+ saveAll(filePath, all);
+ Logger.log('MCP-OAuth', `Cleared OAuth data for '${name}'`);
+}
diff --git a/packages/server/src/mcp/oauth/types.ts b/packages/server/src/mcp/oauth/types.ts
new file mode 100644
index 0000000..94b8ece
--- /dev/null
+++ b/packages/server/src/mcp/oauth/types.ts
@@ -0,0 +1,10 @@
+import type {
+ OAuthClientInformationMixed,
+ OAuthTokens,
+} from '@modelcontextprotocol/sdk/shared/auth.js';
+
+export interface OAuthStorageEntry {
+ tokens?: OAuthTokens;
+ clientInfo?: OAuthClientInformationMixed;
+ codeVerifier?: string;
+}
diff --git a/packages/server/src/mcp/pool.ts b/packages/server/src/mcp/pool.ts
index d021fe1..8f20bba 100644
--- a/packages/server/src/mcp/pool.ts
+++ b/packages/server/src/mcp/pool.ts
@@ -1,20 +1,32 @@
/**
* @fileoverview MCP Connection Pool - global singleton for managing MCP server connections.
*
-
+ * Manages server lifecycle including OAuth authentication for remote servers.
+ * When a remote MCP server requires OAuth, the pool coordinates the full flow:
+ * callback server → browser redirect → token exchange → reconnection.
*/
import { Logger } from '../util/logger.js';
import { loadMcpConfig } from './config.js';
import { connectAllServers } from './loader.js';
-import type { McpServerEntry } from './types.js';
+import { MCPServerConnection } from './connection.js';
+import { McpOAuthProvider } from './oauth/provider.js';
+import { startCallbackServer } from './oauth/callback.js';
+import { getOAuthTokensPath } from '../util/paths.js';
+import type { McpServerEntry, McpToolMeta, McpConnection } from './types.js';
import type { Tool } from '../tools/base.js';
+const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
+
const serverEntries: Map = new Map();
const connections: Map<
string,
{ name: string; tools: Tool[]; disconnect: () => Promise }
> = new Map();
+const serverConnections: Map = new Map();
+
+const pendingOAuth: Map = new Map();
+
let initialized = false;
/**
@@ -23,14 +35,11 @@ let initialized = false;
* Safe to call multiple times - subsequent calls are no-ops.
*/
export async function initMcpPool(): Promise {
- if (initialized) {
- return;
- }
+ if (initialized) return;
const serverConfigs = loadMcpConfig();
if (serverConfigs.size === 0) {
Logger.log('MCP', 'No MCP servers to connect');
- initialized = true;
return;
}
@@ -43,24 +52,36 @@ export async function initMcpPool(): Promise {
});
}
- initialized = true;
-
const results = await connectAllServers(serverConfigs);
for (const result of results) {
const entry = serverEntries.get(result.name);
if (!entry) continue;
+ if (result.serverConn) {
+ serverConnections.set(result.name, result.serverConn);
+ }
+
if (result.connection) {
connections.set(result.name, result.connection);
entry.status = 'connected';
entry.tools = result.tools;
entry.error = undefined;
+ } else if (result.needsAuth) {
+ entry.status = 'needs_auth';
+ entry.oauthUrl = result.oauthUrl;
+ entry.error = undefined;
+ Logger.log(
+ 'MCP',
+ `Server '${result.name}' requires OAuth authentication`
+ );
} else {
entry.status = 'disconnected';
entry.error = result.error ?? 'Unknown error';
}
}
+
+ initialized = true;
}
/**
@@ -129,3 +150,197 @@ export function getMcpPromptInfo(
};
});
}
+
+/**
+ * Start the OAuth flow for a server that requires authentication.
+ *
+ * This method:
+ * 1. Starts a local callback server on a random port
+ * 2. Creates a new connection with an OAuth provider
+ * 3. Triggers the SDK's built-in OAuth discovery + PKCE flow
+ * 4. Returns the authorization URL for the frontend to open in a browser
+ * 5. In the background: waits for callback → finishAuth → reconnect → update status
+ *
+ * The frontend should:
+ * - Call shell.openExternal(authorizationUrl) to open the browser
+ * - Poll getOAuthStatus() until status becomes 'connected'
+ *
+ * @param name - MCP server name
+ * @returns The authorization URL to open in the browser
+ */
+export async function startOAuthFlow(name: string): Promise<{
+ authorizationUrl: string;
+}> {
+ const entry = serverEntries.get(name);
+ if (!entry) {
+ throw new Error(`MCP server '${name}' not found`);
+ }
+ if (entry.status !== 'needs_auth' && entry.status !== 'disconnected') {
+ throw new Error(
+ `Server '${name}' is '${entry.status}', cannot start OAuth`
+ );
+ }
+ if (pendingOAuth.has(name)) {
+ throw new Error(`OAuth flow already in progress for '${name}'`);
+ }
+
+ pendingOAuth.set(name, { status: 'starting' });
+ entry.status = 'connecting';
+ entry.error = undefined;
+
+ Logger.log('MCP-OAuth', `Starting OAuth flow for '${name}'`);
+
+ const callback = await startCallbackServer();
+
+ Logger.log('MCP-OAuth', `Callback server listening on port ${callback.port}`);
+
+ const provider = new McpOAuthProvider(name, getOAuthTokensPath());
+ provider.setRedirectUrl(`http://localhost:${callback.port}/callback`);
+
+ const serverConn = new MCPServerConnection({
+ name,
+ connectionType: 'streamable_http',
+ url: entry.config.url,
+ headers: entry.config.headers,
+ connectTimeoutSec: entry.config.connect_timeout,
+ executeTimeoutSec: entry.config.execute_timeout,
+ authProvider: provider,
+ });
+ serverConnections.set(name, serverConn);
+
+ try {
+ const result = await serverConn.connect();
+
+ if (!result.needsAuth) {
+ callback.close();
+ pendingOAuth.delete(name);
+
+ const tools = buildToolMetaList(name, serverConn);
+ const connection: McpConnection = {
+ name,
+ tools: serverConn.tools,
+ disconnect: () => serverConn.disconnect(),
+ };
+ connections.set(name, connection);
+ entry.status = 'connected';
+ entry.tools = tools;
+ return { authorizationUrl: '' };
+ }
+
+ const authorizationUrl = serverConn.authorizationUrl;
+ if (!authorizationUrl) {
+ throw new Error('OAuth flow started but no authorization URL was saved');
+ }
+
+ pendingOAuth.set(name, { status: 'authorizing' });
+ entry.oauthUrl = authorizationUrl;
+
+ handleOAuthCallback(name, serverConn, callback, entry);
+
+ return { authorizationUrl };
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : String(error);
+ callback.close();
+ pendingOAuth.delete(name);
+ entry.status = 'disconnected';
+ entry.error = message;
+ throw error;
+ }
+}
+
+/**
+ * Background handler: wait for browser callback → finishAuth → reconnect.
+ * All errors update the entry status instead of throwing.
+ */
+async function handleOAuthCallback(
+ name: string,
+ serverConn: MCPServerConnection,
+ callback: { waitForCode: () => Promise; close: () => void },
+ entry: McpServerEntry
+): Promise {
+ try {
+ const code = await Promise.race([
+ callback.waitForCode(),
+ new Promise((_, reject) =>
+ setTimeout(
+ () => reject(new Error('OAuth callback timed out')),
+ OAUTH_TIMEOUT_MS
+ )
+ ),
+ ]);
+
+ pendingOAuth.set(name, { status: 'exchanging' });
+ await serverConn.finishAuth(code);
+ Logger.log('MCP', `OAuth token exchange completed for '${name}'`);
+
+ pendingOAuth.set(name, { status: 'connecting' });
+ serverConn.tools = [];
+ const connectResult = await serverConn.connect();
+
+ if (!connectResult.success) {
+ throw new Error('Reconnection failed after OAuth');
+ }
+
+ const tools = buildToolMetaList(name, serverConn);
+ const connection: McpConnection = {
+ name,
+ tools: serverConn.tools,
+ disconnect: () => serverConn.disconnect(),
+ };
+
+ connections.set(name, connection);
+ entry.status = 'connected';
+ entry.tools = tools;
+ entry.error = undefined;
+ entry.oauthUrl = undefined;
+
+ pendingOAuth.set(name, { status: 'done' });
+ Logger.log(
+ 'MCP',
+ `OAuth flow completed for '${name}' - ${tools.length} tools loaded`
+ );
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : String(error);
+ Logger.log('ERROR', `OAuth flow failed for '${name}': ${message}`);
+ entry.status = 'needs_auth';
+ entry.error = `OAuth failed: ${message}`;
+ pendingOAuth.set(name, { status: 'failed', error: message });
+ } finally {
+ callback.close();
+ setTimeout(() => pendingOAuth.delete(name), 5000);
+ }
+}
+
+function buildToolMetaList(
+ name: string,
+ serverConn: MCPServerConnection
+): McpToolMeta[] {
+ return serverConn.tools.map((tool) => ({
+ id: `mcp:${name}:${tool.name}`,
+ name: tool.name,
+ description: tool.description,
+ }));
+}
+
+/**
+ * Get the current OAuth status for a server.
+ * Used by the frontend to poll during the authorization flow.
+ *
+ * @returns status and optional oauthUrl / error
+ */
+export function getOAuthStatus(name: string): {
+ status: McpServerEntry['status'];
+ oauthUrl?: string;
+ error?: string;
+} {
+ const entry = serverEntries.get(name);
+ if (!entry) {
+ throw new Error(`MCP server '${name}' not found`);
+ }
+
+ return {
+ status: entry.status,
+ oauthUrl: entry.oauthUrl,
+ error: entry.error,
+ };
+}
diff --git a/packages/server/src/mcp/types.ts b/packages/server/src/mcp/types.ts
index cd40f84..51de33c 100644
--- a/packages/server/src/mcp/types.ts
+++ b/packages/server/src/mcp/types.ts
@@ -5,9 +5,13 @@
import type { JsonSchema } from '../tools/base.js';
import type { Tool } from '../tools/base.js';
-export type ConnectionType = 'stdio' | 'sse' | 'streamable_http';
+export type ConnectionType = 'stdio' | 'streamable_http';
-export type McpServerStatus = 'disconnected' | 'connecting' | 'connected';
+export type McpServerStatus =
+ | 'disconnected'
+ | 'connecting'
+ | 'connected'
+ | 'needs_auth';
export interface McpCallToolResult {
content?: unknown;
@@ -61,7 +65,6 @@ export interface McpServerConfig {
disabled?: boolean;
connect_timeout?: number;
execute_timeout?: number;
- sse_read_timeout?: number;
}
export interface McpConfigFile {
@@ -80,6 +83,7 @@ export interface McpServerEntry {
status: McpServerStatus;
tools: McpToolMeta[];
error?: string;
+ oauthUrl?: string;
}
export interface McpConnection {
diff --git a/packages/server/src/server/routers/mcp.ts b/packages/server/src/server/routers/mcp.ts
index 2299f33..4f821cd 100644
--- a/packages/server/src/server/routers/mcp.ts
+++ b/packages/server/src/server/routers/mcp.ts
@@ -2,13 +2,20 @@
* @fileoverview HTTP routes for MCP management.
*
* Routes:
- * - GET /api/mcp - List all MCP servers with status and tools
- * - GET /api/mcp/:name - Get a single MCP server's status and tools
+ * - GET /api/mcp - List all MCP servers with status and tools
+ * - GET /api/mcp/:name - Get a single MCP server's status and tools
+ * - POST /api/mcp/:name/oauth/authorize - Start OAuth flow, returns authorization URL
+ * - GET /api/mcp/:name/oauth/status - Poll OAuth flow status
*/
import { Router } from 'express';
import type { Request, Response } from 'express';
-import { listMcpServers, getMcpServer } from '../../mcp/index.js';
+import {
+ listMcpServers,
+ getMcpServer,
+ startOAuthFlow,
+ getOAuthStatus,
+} from '../../mcp/index.js';
import { Logger } from '../../util/logger.js';
import { getParam } from './utils.js';
@@ -50,5 +57,60 @@ export function createMcpRouter(): Router {
}
});
+ router.post('/:name/oauth/authorize', async (req: Request, res: Response) => {
+ try {
+ const name = getParam(req.params['name']);
+ if (!name) {
+ res.status(400).json({ error: 'Server name is required' });
+ return;
+ }
+
+ const result = await startOAuthFlow(name);
+ res.json(result);
+ } catch (error) {
+ Logger.log('MCP', 'Error starting OAuth flow', error);
+
+ const message = error instanceof Error ? error.message : 'Unknown error';
+
+ if (message.includes('not found')) {
+ res.status(404).json({ error: message });
+ return;
+ }
+ if (
+ message.includes('cannot start OAuth') ||
+ message.includes('already in progress')
+ ) {
+ res.status(400).json({ error: message });
+ return;
+ }
+
+ res.status(500).json({ error: message });
+ }
+ });
+
+ router.get('/:name/oauth/status', (req: Request, res: Response) => {
+ try {
+ const name = getParam(req.params['name']);
+ if (!name) {
+ res.status(400).json({ error: 'Server name is required' });
+ return;
+ }
+
+ const status = getOAuthStatus(name);
+ res.json(status);
+ } catch (error) {
+ Logger.log('MCP', 'Error getting OAuth status', error);
+
+ const message = error instanceof Error ? error.message : 'Unknown error';
+
+ if (message.includes('not found')) {
+ res.status(404).json({ error: message });
+ return;
+ }
+
+ res.status(500).json({ error: message });
+ }
+ });
+
return router;
}
diff --git a/packages/server/src/util/app.ts b/packages/server/src/util/app.ts
new file mode 100644
index 0000000..cedea0c
--- /dev/null
+++ b/packages/server/src/util/app.ts
@@ -0,0 +1,9 @@
+import pkg from '../../package.json' with { type: 'json' };
+
+declare global {
+ var SERVER_VERSION: string | undefined;
+}
+
+export const APP_NAME: string = pkg.name;
+
+export const APP_VERSION: string = globalThis.SERVER_VERSION ?? pkg.version;
diff --git a/packages/server/src/util/paths.ts b/packages/server/src/util/paths.ts
index 1d84054..ac2f394 100644
--- a/packages/server/src/util/paths.ts
+++ b/packages/server/src/util/paths.ts
@@ -51,6 +51,8 @@ export const getLogsDir = () => path.join(APP_DIR, 'logs');
export const getConfigPath = () => path.join(getConfigDir(), 'config.yaml');
export const getAuthPath = () => path.join(getConfigDir(), 'auth.json');
export const getMcpConfigPath = () => path.join(getMcpDir(), 'mcp.json');
+export const getOAuthTokensPath = () =>
+ path.join(getMcpDir(), 'oauth-tokens.json');
/**
* Returns the path to the cloudflared binary.
diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json
index fdf421f..3dbf3b9 100644
--- a/packages/server/tsconfig.json
+++ b/packages/server/tsconfig.json
@@ -29,6 +29,6 @@
"jsx": "react-jsx",
"types": ["bun-types"]
},
- "include": ["src/**/*"],
+ "include": ["src/**/*", "package.json"],
"exclude": ["node_modules", "dist", "tests"]
}