diff --git a/README.md b/README.md index d8175f5..dd6e3ec 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,151 @@ -# Persona +
-Persona 是一个 AI Agent 桌面应用。用户可以在本地创建和管理多个 AI Agent,通过聊天界面与 Agent 交互,Agent 能够调用工具(执行命令、读写文件)来完成任务。应用内置 Cloudflare Tunnel 支持,可以将本地 Agent 暴露到公网,方便从手机或其他设备远程访问。 +# Persona Agent -项目由两个核心部分组成:server 负责对话管理、工具执行、MCP 连接等后端逻辑;desktop 是 Electron 桌面客户端,提供图形界面。 +**你的本地 AI Agent 工作站** -## 技术栈 +创建和管理多个 AI Agent,赋予它们工具、技能和性格,让它们帮你完成任务。 -**Server**(`packages/server`):TypeScript + Bun 运行时,使用 Express 提供 HTTP/WebSocket API,支持 OpenAI 和 Anthropic 等多个 LLM 供应商。Bun 的 `--compile` 将整个 server 打包成单个二进制文件,不需要用户安装任何运行时。 +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -**Desktop**(`packages/desktop`):Electron + React 18 + TypeScript,使用 electron-vite 构建,Tailwind CSS 做样式,Zustand 管理状态。应用启动时从 App Bundle 内直接运行 server 二进制,以子进程方式管理其生命周期。 +
-## 开发环境准备 + -需要安装 [Bun](https://bun.sh/) 和 Node.js(18+)。 +## 它能做什么 -```bash -# 安装依赖 -cd packages/server && bun install -cd packages/desktop && npm install -``` +Persona Agent 让你在本地创建多个 AI Agent,每个有独立的角色设定、模型配置和会话历史。你可以用一个 Agent 做编程助手,另一个做写作顾问,互不干扰。Agent 可以调用内置工具来执行命令、读写文件,也可以通过 MCP 协议连接外部工具服务器扩展能力,还可以加载 Skill 来获得专业知识。 -## 日常开发 +对话支持流式输出和完整的 Markdown 渲染,代码块一键复制,Agent 的推理过程和工具调用细节都可以展开查看。如果你想换一种氛围,可以打开伴侣模式:全屏展示角色立绘,Agent 会根据对话自动切换表情,配合语音合成朗读回复。 -在项目根目录运行一条命令即可启动开发环境: +内置 Cloudflare Tunnel,一键把本地服务暴露到公网,从手机或其它设备远程访问你的 Agent。 -```bash -npm run dev -``` +**支持的模型供应商:** OpenAI、Anthropic、Google、DeepSeek、MiniMax、xAI、Groq、Mistral、OpenRouter、Cerebras、Fireworks 等 17+ 家。每个 Agent 独立配置默认模型,每个会话可以临时切换。 -这个命令会先编译 server 的二进制文件,然后启动 Electron 开发服务器。前端代码修改会通过热更新即时生效。如果修改了 server 的代码,需要退出后重新运行 `npm run dev`,因为 server 需要重新编译。 +**跨平台:** macOS(Apple Silicon / Intel)、Windows x64。下载安装包即可使用,无需安装任何运行时。 -也可以单独操作某个包: +## 下载安装 -```bash -npm run build:server # 只编译 server 二进制 -npm run build:desktop # 只编译前端(不启动 Electron) -``` +前往 [GitHub Releases](https://github.com/Code-MonkeyZhang/persona-agent/releases) 下载对应平台的安装包: -## 构建安装包 +| 平台 | 文件 | +|------|------| +| macOS Apple Silicon | `Persona-mac-arm64-{version}.dmg` | +| macOS Intel | `Persona-mac-x64-{version}.dmg` | +| Windows x64 | `Persona-win-x64-{version}.exe` | -```bash -npm run dist -``` +macOS 打开 DMG 拖入 Applications,Windows 运行 exe 安装即可。 + +## 快速开始 + +1. 从 [Releases](https://github.com/Code-MonkeyZhang/persona-agent/releases) 下载并安装 +2. 打开应用,进入 Settings → Model Providers,填入至少一个供应商的 API Key 并验证 +3. 点击左侧栏的「+」创建 Agent,填写名称、人设,选择模型 +4. 开始对话 -这会先编译 server 和 desktop,然后用 electron-builder 打包成 .dmg(macOS)。打包后的安装包在 `packages/desktop/dist/` 目录下。安装后的应用是一个独立可执行文件,不需要用户安装 Bun 或 Node.js。 +## 从源码构建 -## 其他命令 +需要 [Bun](https://bun.sh/) 1.0+ 和 Node.js 18+。 ```bash -npm run typecheck # 对两个包一起做类型检查 +# 安装依赖 +cd packages/server && bun install +cd packages/desktop && npm install + +# 开发模式(编译 server + 启动 Electron 开发服务器) +npm run dev + +# 构建安装包 +npm run dist # 当前平台 +npm run dist:mac # macOS +npm run dist:win # Windows ``` -Server 包还支持 lint 和格式化(需要在 `packages/server` 目录下运行): +其他命令: ```bash -bun run lint # oxlint 检查 -bun run format # Prettier 格式化 -bun run test # 运行测试 +npm run typecheck # 类型检查 +npm run lint # 代码检查 +npm run format # 格式化 +npm run check # 一键检查(lint + format + typecheck) ``` -### 集成测试配置 - -`packages/server/tests/chat.test.ts` 是端到端集成测试,会调用真实的 LLM API,需要配置环境变量: +### 测试 ```bash -cd packages/server -cp .env.test.example .env.test.local -# 编辑 .env.test.local,填入你的 API Key +cd packages/server && bun test +cd packages/desktop && npm run test ``` -必填 `TEST_LLM_API_KEY`,可选 `TEST_LLM_PROVIDER`(默认 `minimax-cn`)和 `TEST_LLM_MODEL`(默认 `MiniMax-M2.7`)。`.env.test.local` 不会被提交到版本控制。如果不配置,chat 集成测试会被自动跳过,其他测试不受影响。可用的 Provider 列表见 `.env.test.example`。 - -Desktop 包的测试: +Server 的集成测试需要配置环境变量: ```bash -cd packages/desktop && npm run test +cd packages/server +cp .env.test.example .env.test.local +# 编辑 .env.test.local,填入 API Key ``` +不配置时集成测试会自动跳过,其他测试不受影响。 + ## 项目结构 ``` persona-agent/ ├── package.json # 根编排脚本 ├── packages/ -│ ├── server/ # 后端服务(Bun 项目) -│ │ ├── src/ -│ │ │ ├── agent/ # Agent 定义、配置存储、运行逻辑 -│ │ │ ├── session/ # 会话管理、消息持久化 -│ │ │ ├── server/ # HTTP/WebSocket 服务、路由、隧道 -│ │ │ ├── tools/ # 工具实现(bash、文件读写、pose) -│ │ │ ├── mcp/ # MCP 协议连接管理 -│ │ │ ├── skill/ # Skill 加载与管理 -│ │ │ ├── auth/ # 认证 -│ │ │ ├── config/ # 配置加载 -│ │ │ ├── schema/ # 事件与数据 schema -│ │ │ ├── converters/ # LLM 响应格式转换 -│ │ │ └── util/ # 工具函数、日志、路径 -│ │ ├── bin/ # 预置的 cloudflared 二进制 -│ │ └── dist/ # 编译输出的 server 二进制 -│ └── desktop/ # 桌面客户端(Electron + npm 项目) -│ ├── src/ -│ │ ├── main/ # Electron 主进程(生命周期、窗口、IPC) -│ │ ├── preload/ # 预加载脚本 -│ │ └── renderer/ # React 前端(组件、Store、样式) -│ ├── electron-builder.yml -│ └── electron.vite.config.ts +│ ├── server/ # 后端服务(Bun) +│ │ └── src/ +│ │ ├── agent/ # Agent 运行时、配置存储 +│ │ ├── session/ # 会话管理、消息持久化 +│ │ ├── server/ # HTTP/WebSocket、路由、隧道 +│ │ ├── tools/ # 内置工具 +│ │ ├── mcp/ # MCP 协议连接 +│ │ ├── skill/ # Skill 加载 +│ │ ├── auth/ # API Key 管理 +│ │ └── converters/ # LLM 响应格式转换 +│ └── desktop/ # 桌面客户端(Electron + React) +│ └── src/ +│ ├── main/ # 主进程 +│ ├── preload/ # 预加载脚本 +│ └── renderer/ # React 前端 ``` ## 用户数据目录 -应用运行时会在系统标准数据目录下创建 `persona-agent/`,存放配置、Agent、会话和日志等数据。 - | 平台 | 路径 | |------|------| | macOS | `~/.local/share/persona-agent/` | | Windows | `%APPDATA%/persona-agent/` | -| Linux | `~/.local/share/persona-agent/` | ``` persona-agent/ -├── config/ -│ ├── config.yaml # 全局配置 -│ └── auth.json # LLM 供应商 API Key -├── agents/ -│ └── {agentId}/ -│ ├── config.json # Agent 配置 -│ ├── assets/ # Agent 资源(头像、语音、姿态、背景) -│ ├── sessions/ # 会话历史 -│ └── memory/ # Agent 记忆 -├── skills/ # 用户自定义 Skill -├── mcp/ -│ ├── mcp.json # MCP 服务器配置 -│ └── servers/ # MCP 服务器运行时数据 -├── workspace/ # 默认工作目录 -└── logs/ # 运行日志 +├── config/ # 全局配置、API Key +├── agents/{id}/ # Agent 配置、资源、会话 +├── skills/ # 自定义 Skill +├── mcp/ # MCP 服务器配置和运行时数据 +└── logs/ # 运行日志 ``` + +## 致谢 + +### 参考项目 + +- [Chatbox](https://github.com/chatboxai/chatbox) — 跨平台 AI 桌面客户端 +- [Cherry Studio](https://github.com/CherryHQ/cherry-studio) — 全功能 AI 助手,多供应商 LLM 支持 +- [Halo](https://github.com/openkursar/hello-halo) — 24/7 自主桌面 AI Agent,数字人形象系统 +- [OpenCode](https://github.com/anomalyco/opencode) — AI 编程工具,本项目架构与构建体系的重要参考 +- [ZcChat](https://github.com/Zao-chen/ZcChat) — 桌面 AI 伴侣,Galgame 风格角色立绘与语音交互 + +### 技术依赖 + +- [Bun](https://bun.sh/) — Server 运行时,单文件编译分发 +- [Electron](https://www.electronjs.org/) — 跨平台桌面应用框架 +- [React](https://react.dev/) — UI 框架 +- [pi-ai](https://github.com/mariozechner/pi-ai) — 统一多供应商 LLM 调用接口 +- [Model Context Protocol](https://modelcontextprotocol.io/) — 工具扩展协议 +- [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) — 内网穿透 +- [MiniMax](https://www.minimaxi.com/) — TTS 语音合成 + +## License + +[MIT](LICENSE) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index a9f2dc2..ac920fb 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -3,7 +3,7 @@ * @description Electron 主进程入口文件 - 负责应用程序生命周期管理、窗口创建、进程管理和 IPC 通信 */ -import { app, BrowserWindow, ipcMain, dialog } from 'electron'; +import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'; import { join } from 'path'; import { spawn } from 'child_process'; import type { ChildProcess } from 'child_process'; @@ -25,7 +25,6 @@ const isWin = process.platform === 'win32'; const BINARY_NAME = isWin ? 'persona-agent-server.exe' : 'persona-agent-server'; let serverProcess: ChildProcess | null = null; -let settingsWindow: BrowserWindow | null = null; // 日志配置:开发环境写文件,生产环境不写 if (is.dev) { @@ -166,68 +165,6 @@ function createWindow(): void { } } -/** - * 创建设置窗口 - * 用于显示应用程序设置界面 - * @returns {void} - */ -function createSettingsWindow(): void { - log.info('Creating settings window'); - - // 如果设置窗口已经存在,直接聚焦到已有窗口,避免重复创建 - if (settingsWindow) { - log.info('Settings window already exists, focusing'); - settingsWindow.focus(); - return; - } - - // 创建一个新的浏览器窗口作为设置界面 - settingsWindow = new BrowserWindow({ - width: 720, - height: 600, - minWidth: 600, - minHeight: 400, - show: false, // 先不显示,等 ready-to-show 事件再显示,避免白屏闪烁 - autoHideMenuBar: true, - title: '设置中心', - resizable: true, - maximizable: false, // 禁止最大化,设置窗口不需要那么大 - fullscreenable: false, // 禁止全屏 - webPreferences: { - preload: join(__dirname, '../preload/index.cjs'), - sandbox: false, - nodeIntegration: false, - contextIsolation: true, - }, - }); - - // 窗口内容加载完成后才显示,用户体验更好 - settingsWindow.on('ready-to-show', () => { - settingsWindow?.show(); - }); - - // 窗口关闭时清空引用,允许下次重新创建 - settingsWindow.on('closed', () => { - settingsWindow = null; - }); - - // 拦截页面内的新窗口打开请求(如 ),用系统浏览器打开 - settingsWindow.webContents.setWindowOpenHandler((details) => { - require('electron').shell.openExternal(details.url); - return { action: 'deny' }; - }); - - // 加载同一个 index.html,但 URL 末尾拼接 #settings - // 渲染进程的 App 组件会读取这个 hash 值,据此渲染 而非聊天界面 - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - settingsWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}#settings`); - } else { - settingsWindow.loadFile(join(__dirname, '../renderer/index.html'), { - hash: 'settings', - }); - } -} - /** * 应用的主要入口 * @returns {Promise} @@ -255,14 +192,6 @@ app.whenReady().then(async () => { optimizer.watchWindowShortcuts(window); }); - /** - * IPC 处理器:接受前端发来的消息 打开设置窗口 - */ - ipcMain.handle('open-settings-window', () => { - log.info('IPC: open-settings-window received'); - createSettingsWindow(); - }); - /** * IPC 处理器:获取当前服务器 URL */ @@ -361,6 +290,13 @@ app.whenReady().then(async () => { } ); + /** + * IPC 处理器:使用系统默认浏览器打开指定 URL + */ + ipcMain.handle('open-external', (_event, url: string) => { + return shell.openExternal(url); + }); + await startServer(); createWindow(); diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index c587992..5f033b8 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -3,7 +3,6 @@ * * preload 运行在有 Node.js 权限的特殊环境中, * 通过 contextBridge 将以下操作暴露到 window.api: - * - 打开设置窗口 * - 系统文件夹选择器 * - 后端服务地址查询 * - 日志代理写入 @@ -18,11 +17,6 @@ import { electronAPI } from '@electron-toolkit/preload'; * 每个方法底层通过 ipcRenderer.invoke 向主进程发送 IPC 消息 */ const api = { - /** - * 打开设置窗口,通过 IPC 通知主进程。 - */ - openSettingsWindow: () => ipcRenderer.invoke('open-settings-window'), - /** * 弹出系统原生的文件夹选择对话框 * @param options - 对话框配置,可指定标题和默认打开路径 @@ -69,6 +63,13 @@ const api = { body: ArrayBuffer; }> => ipcRenderer.invoke('proxy-fetch', url, options), + /** + * 使用系统默认浏览器打开指定 URL + * @param url - 要打开的 URL + */ + openExternal: (url: string): Promise => + ipcRenderer.invoke('open-external', url), + /** 窗口控制方法集合,每个方法通过 IPC 转发到主进程执行。 */ windowControls: { minimize: () => ipcRenderer.invoke('window:minimize'), diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx index 6b7b647..562638a 100644 --- a/packages/desktop/src/renderer/App.tsx +++ b/packages/desktop/src/renderer/App.tsx @@ -2,9 +2,9 @@ * @file App.tsx * @description Electron前端渲染进程根组件 * - * 根据 URL hash 决定渲染哪个窗口: - * - #settings → 设置窗口(SettingsWindow) - * - 其他 → 主界面(WebSocketProvider + AppContent) + * 根据 viewStore.currentView 决定渲染哪个视图: + * - 'settings' → 设置页面(SettingsPage) + * - 'chat' → 主界面(WebSocketProvider + AppContent) * * AppContent 是主界面的核心,包含: * - AgentSidebar:左侧 Agent 列表 @@ -20,7 +20,7 @@ import { InputBox } from './components/InputBox'; import { AgentSidebar } from './components/AgentSidebar'; import { SessionSidebar } from './components/SessionSidebar'; import { SessionSidebarToggle } from './components/SessionSidebarToggle'; -import { SettingsWindow } from './components/SettingsWindow'; +import { SettingsPage } from './components/SettingsPage'; import { AgentEditor } from './components/AgentEditor'; import { CompanionPanel } from './components/CompanionPanel'; import { ToastContainer } from './components/Toast'; @@ -30,7 +30,7 @@ import { useSessionStore } from './stores/sessionStore'; import { useAgentStore } from './stores/agentStore'; import { useProviderStore } from './stores/providerStore'; import { useCompanionStore } from './stores/companionStore'; -import { useVoiceStore } from './stores/voiceStore'; +import { useViewStore } from './stores/viewStore'; import { logger } from './lib/logger'; /** @@ -67,7 +67,7 @@ function AppContent() { const { loadAgents, currentAgent, deleteAgentById } = useAgentStore(); const { providers, loadProviders } = useProviderStore(); const companionVisible = useCompanionStore((s) => s.visible); - const loadVoiceApiKey = useVoiceStore((s) => s.loadVoiceApiKey); // 获取TTS API key + const currentView = useViewStore((s) => s.currentView); /* 定义Agent弹窗的操作 */ /** @@ -110,10 +110,6 @@ function AppContent() { } }, [connectionStatus, loadProviders]); - useEffect(() => { - loadVoiceApiKey(); - }, [loadVoiceApiKey]); - useEffect(() => { if (connectionStatus === 'connected' && currentAgent) { loadSessions(currentAgent.id); @@ -232,47 +228,55 @@ function AppContent() { connectionStatus={connectionStatus} onOpenAgentEditor={handleOpenAgentEditor} /> - setSidebarCollapsed(true)} - /> -
- {sidebarCollapsed && ( - setSidebarCollapsed(false)} + {currentView === 'settings' ? ( +
+ +
+ ) : ( + <> + setSidebarCollapsed(true)} /> - )} -
- - - {companionVisible && ( - - )} -
+
+ {sidebarCollapsed && ( + setSidebarCollapsed(false)} + /> + )} +
+ + + {companionVisible && ( + + )} +
+ + )} ; - } - return ( diff --git a/packages/desktop/src/renderer/components/AgentSidebar.tsx b/packages/desktop/src/renderer/components/AgentSidebar.tsx index 1b48fe8..c0a0e42 100644 --- a/packages/desktop/src/renderer/components/AgentSidebar.tsx +++ b/packages/desktop/src/renderer/components/AgentSidebar.tsx @@ -6,10 +6,10 @@ import React, { useState } from 'react'; import { Settings, Plus, Loader2, Server } from 'lucide-react'; import { cn } from '../lib/utils'; import { useAgentStore } from '../stores/agentStore'; +import { useViewStore } from '../stores/viewStore'; import { AgentAvatar } from './AgentAvatar'; import { ServerManagerModal } from './ServerManagerModal'; import { isMac } from '../lib/platform'; -import { logger } from '../lib/logger'; interface AgentSidebarProps { connectionStatus: 'connected' | 'connecting' | 'disconnected'; @@ -26,10 +26,15 @@ export const AgentSidebar: React.FC = ({ onOpenAgentEditor, }) => { const { agents, currentAgent, switchAgent } = useAgentStore(); + const currentView = useViewStore((s) => s.currentView); + const setView = useViewStore((s) => s.setView); const [serverModalOpen, setServerModalOpen] = useState(false); - /** 点击 Agent 头像切换到对应 Agent */ + /** 点击 Agent 头像切换到对应 Agent,如果在设置视图则同时切回聊天 */ const handleAgentClick = async (id: string) => { + if (currentView === 'settings') { + setView('chat'); + } await switchAgent(id); }; @@ -44,13 +49,9 @@ export const AgentSidebar: React.FC = ({ onOpenAgentEditor?.(null); }; - /** 通过 IPC 打开独立的设置窗口 */ - const handleOpenSettings = async () => { - try { - await window.api?.openSettingsWindow(); - } catch (error) { - logger.error('Failed to open settings window:', error); - } + /** 切换到设置视图 */ + const handleOpenSettings = () => { + setView('settings'); }; return ( @@ -116,7 +117,12 @@ export const AgentSidebar: React.FC = ({ /> diff --git a/packages/desktop/src/renderer/components/McpListTab.tsx b/packages/desktop/src/renderer/components/McpListTab.tsx index a53ead6..f07635c 100644 --- a/packages/desktop/src/renderer/components/McpListTab.tsx +++ b/packages/desktop/src/renderer/components/McpListTab.tsx @@ -1,75 +1,75 @@ /** * @file src/renderer/components/McpListTab.tsx - * @description MCP 服务列表标签页,展示已配置的 MCP 服务器状态和工具数量 + * @description MCP 服务列表标签页,展示已配置的 MCP 服务器状态、工具数量和 OAuth 授权 */ -import React, { useEffect, useState } from 'react'; -import { Loader2, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; -import { listMcpServers, type McpServer } from '../lib/api'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Loader2, CheckCircle, XCircle, KeyRound } from 'lucide-react'; +import { + listMcpServers, + startMcpOAuth, + getMcpOAuthStatus, + type McpServer, +} from '../lib/api'; +import { logger } from '../lib/logger'; + +const POLL_INTERVAL_MS = 2000; +const POLL_TIMEOUT_MS = 5 * 60 * 1000; -/** - * 根据服务器连接状态返回对应的图标组件 - * @param status MCP 服务器状态 - * @returns 带颜色的 Lucide 图标 - */ function getStatusIcon(status: McpServer['status']) { switch (status) { case 'connected': return ; - case 'error': - return ; + case 'needs_auth': + return ; + case 'connecting': + return ; default: - return ; + return ; } } -/** - * 根据服务器连接状态返回中文描述文本 - * @param status MCP 服务器状态 - * @returns 状态对应的中文文本 - */ function getStatusText(status: McpServer['status']) { switch (status) { case 'connected': return '已连接'; - case 'error': - return '错误'; + case 'needs_auth': + return '需要授权'; + case 'connecting': + return '连接中'; + case 'disconnected': + return '未连接'; default: return '未连接'; } } -/** - * 根据服务器连接状态返回对应的 Tailwind 背景和文字颜色类名 - * @param status MCP 服务器状态 - * @returns Tailwind 类名字符串 - */ function getStatusClass(status: McpServer['status']) { switch (status) { case 'connected': return 'bg-green-100 text-green-700'; - case 'error': - return 'bg-red-100 text-red-700'; + case 'needs_auth': + return 'bg-amber-100 text-amber-700'; + case 'connecting': + return 'bg-blue-100 text-blue-700'; default: return 'bg-gray-100 text-gray-600'; } } -/** - * MCP 服务列表标签页组件,从后端加载 MCP 服务器列表并按状态分组展示 - */ export const McpListTab: React.FC = () => { const [mcps, setMcps] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [authorizing, setAuthorizing] = useState(null); + const pollingRef = useRef | null>(null); + const pollingStartRef = useRef(0); useEffect(() => { loadMcps(); + return () => stopPolling(); }, []); - /** - * 从后端拉取 MCP 服务器列表并更新本地状态 - */ const loadMcps = async () => { setIsLoading(true); setError(null); @@ -85,6 +85,81 @@ export const McpListTab: React.FC = () => { } }; + const stopPolling = useCallback(() => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + }, []); + + /** + * 轮询 OAuth 授权状态,连接成功或失败时停止。 + * 超时 5 分钟后自动停止并提示用户。 + */ + const startPolling = useCallback( + (name: string) => { + stopPolling(); + pollingStartRef.current = Date.now(); + + pollingRef.current = setInterval(async () => { + try { + const status = await getMcpOAuthStatus(name); + + if (status.status === 'connected') { + stopPolling(); + setAuthorizing(null); + logger.info('[MCP] OAuth connected for', name); + loadMcps(); + return; + } + + if (status.status === 'needs_auth' && status.error) { + stopPolling(); + setAuthorizing(null); + logger.error('[MCP] OAuth failed for', name, status.error); + loadMcps(); + return; + } + + if (Date.now() - pollingStartRef.current > POLL_TIMEOUT_MS) { + stopPolling(); + setAuthorizing(null); + } + } catch { + stopPolling(); + setAuthorizing(null); + } + }, POLL_INTERVAL_MS); + }, + [stopPolling] + ); + + /** + * 触发 OAuth 授权流程:启动后端流程 → 打开浏览器 → 开始轮询状态 + */ + const handleAuthorize = async (name: string) => { + try { + setAuthorizing(name); + logger.info('[MCP] Starting OAuth for', name); + const result = await startMcpOAuth(name); + + if (result.authorizationUrl) { + await window.api?.openExternal(result.authorizationUrl); + logger.info('[MCP] Opened authorization URL in browser for', name); + startPolling(name); + } else { + setAuthorizing(null); + loadMcps(); + } + } catch (err) { + setAuthorizing(null); + const msg = + err instanceof Error ? err.message : 'OAuth authorization failed'; + logger.error('[MCP] OAuth failed for', name, msg); + setError(msg); + } + }; + if (isLoading) { return (
@@ -142,12 +217,31 @@ export const McpListTab: React.FC = () => { {mcp.error && (

{mcp.error}

)} - {mcp.toolCount !== undefined && mcp.toolCount > 0 && ( -

- {mcp.toolCount} 个工具可用 -

- )} + {mcp.status === 'connected' && + mcp.toolCount !== undefined && + mcp.toolCount > 0 && ( +

+ {mcp.toolCount} 个工具可用 +

+ )}
+ + {mcp.status === 'needs_auth' && ( + + )} ))} diff --git a/packages/desktop/src/renderer/components/SettingsWindow.tsx b/packages/desktop/src/renderer/components/SettingsPage.tsx similarity index 83% rename from packages/desktop/src/renderer/components/SettingsWindow.tsx rename to packages/desktop/src/renderer/components/SettingsPage.tsx index 619a1f9..c49d147 100644 --- a/packages/desktop/src/renderer/components/SettingsWindow.tsx +++ b/packages/desktop/src/renderer/components/SettingsPage.tsx @@ -1,6 +1,7 @@ /** - * @file src/renderer/components/SettingsWindow.tsx - * @description 设置中心主窗口,包含通用设置、模型供应商、MCP 服务、Skills 和语音服务五个标签页 + * @file src/renderer/components/SettingsPage.tsx + * @description 设置中心页面组件,嵌入主窗口右侧内容区域 + * 包含通用设置、模型供应商、MCP 服务、Skills 和语音服务五个标签页 */ import React, { useEffect, useState } from 'react'; @@ -9,10 +10,11 @@ import { ProviderConfigPanel } from './ProviderConfigPanel'; import { ConfigForm } from './ConfigForm'; import { McpListTab } from './McpListTab'; import { SkillListTab } from './SkillListTab'; +import { VoiceConfigPanel } from './VoiceConfigPanel'; import { useConfigStore } from '../stores/configStore'; import { useProviderStore } from '../stores/providerStore'; +import { useViewStore } from '../stores/viewStore'; import { toast } from '../stores/toastStore'; -import { VoiceConfigPanel } from './VoiceConfigPanel'; type TabKey = 'general' | 'providers' | 'mcp' | 'skills' | 'voice'; @@ -25,12 +27,14 @@ const tabs: { key: TabKey; label: string; icon: React.ReactNode }[] = [ ]; /** - * 设置中心窗口组件,提供通用设置、模型供应商、MCP 服务、Skills 和语音服务五个标签页的切换和内容展示 + * 设置中心页面组件,嵌入主窗口右侧内容区域 + * 提供通用设置、模型供应商、MCP 服务、Skills 和语音服务五个标签页的切换和内容展示 */ -export const SettingsWindow: React.FC = () => { +export const SettingsPage: React.FC = () => { const { config, loading, saving, error, loadConfig, saveConfig } = useConfigStore(); const { saveAllPending } = useProviderStore(); + const setView = useViewStore((s) => s.setView); const [activeTab, setActiveTab] = useState('general'); useEffect(() => { @@ -53,16 +57,16 @@ export const SettingsWindow: React.FC = () => { }; /** - * 保存所有待写入的 Provider 配置后关闭窗口 + * 保存所有待写入的 Provider 配置后切回聊天视图 */ const handleClose = async () => { await saveAllPending(); - window.close(); + setView('chat'); }; if (loading) { return ( -
+
加载中...
); @@ -70,14 +74,14 @@ export const SettingsWindow: React.FC = () => { if (error && !config) { return ( -
+
加载配置失败:{error}
); } return ( -
+

设置中心

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"] }