diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8cb5efc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# Repository Guidelines + +These notes align current contributors around the code layout, daily commands, and collaboration habits that keep `@samanhappy/mcphub` moving quickly. + +## Project Structure & Module Organization +- Backend services live in `src`, grouped by responsibility (`controllers/`, `services/`, `dao/`, `routes/`, `utils/`), with `server.ts` orchestrating HTTP bootstrap. +- `frontend/src` contains the Vite + React dashboard; `frontend/public` hosts static assets and translations sit in `locales/`. +- Jest-aware test code is split between colocated specs (`src/**/*.{test,spec}.ts`) and higher-level suites in `tests/`; use `tests/utils/` helpers when exercising the CLI or SSE flows. +- Build artifacts and bundles are generated into `dist/`, `frontend/dist/`, and `coverage/`; never edit these manually. + +## Build, Test, and Development Commands +- `pnpm dev` runs backend (`tsx watch src/index.ts`) and frontend (`vite`) together for local iteration. +- `pnpm backend:dev`, `pnpm frontend:dev`, and `pnpm frontend:preview` target each surface independently; prefer them when debugging one stack. +- `pnpm build` executes `pnpm backend:build` (TypeScript to `dist/`) and `pnpm frontend:build`; run before release or publishing. +- `pnpm test`, `pnpm test:watch`, and `pnpm test:coverage` drive Jest; `pnpm lint` and `pnpm format` enforce style via ESLint and Prettier. + +## Coding Style & Naming Conventions +- TypeScript everywhere; default to 2-space indentation and single quotes, letting Prettier settle formatting. ESLint configuration assumes ES modules. +- Name services and data access layers with suffixes (`UserService`, `AuthDao`), React components and files in `PascalCase`, and utility modules in `camelCase`. +- Keep DTOs and shared types in `src/types` to avoid duplication; re-export through index files only when it clarifies imports. + +## Testing Guidelines +- Use Jest with the `ts-jest` ESM preset; place shared setup in `tests/setup.ts` and mock helpers under `tests/utils/`. +- Mirror production directory names when adding new suites and end filenames with `.test.ts` or `.spec.ts` for automatic discovery. +- Aim to maintain or raise coverage when touching critical flows (auth, OAuth, SSE); add integration tests under `tests/integration/` when touching cross-service logic. + +## Commit & Pull Request Guidelines +- Follow the existing Conventional Commit pattern (`feat:`, `fix:`, `chore:`, etc.) with imperative, present-tense summaries and optional multi-line context. +- Each PR should describe the behavior change, list testing performed, and link issues; include before/after screenshots or GIFs for frontend tweaks. +- Re-run `pnpm build` and `pnpm test` before requesting review, and ensure generated artifacts stay out of the diff. diff --git a/README.md b/README.md index 48c9891..7f7dd34 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ MCPHub makes it easy to manage and scale multiple MCP (Model Context Protocol) s - **Hot-Swappable Configuration**: Add, remove, or update MCP servers on the fly — no downtime required. - **Group-Based Access Control**: Organize servers into customizable groups for streamlined permissions management. - **Secure Authentication**: Built-in user management with role-based access powered by JWT and bcrypt. +- **OAuth 2.0 Support**: Full OAuth support for upstream MCP servers with proxy authorization capabilities. - **Docker-Ready**: Deploy instantly with our containerized setup. ## 🔧 Quick Start @@ -57,6 +58,45 @@ Create a `mcp_settings.json` file to customize your server settings: } ``` +#### OAuth Configuration (Optional) + +MCPHub supports OAuth 2.0 for authenticating with upstream MCP servers. See the [OAuth feature guide](docs/features/oauth.mdx) for a full walkthrough. In practice you will run into two configuration patterns: + +- **Dynamic registration servers** (e.g., Vercel, Linear) publish all metadata and allow MCPHub to self-register. Simply declare the server URL and MCPHub handles the rest. +- **Manually provisioned servers** (e.g., GitHub Copilot) require you to create an OAuth App and provide the issued client ID/secret to MCPHub. + +Dynamic registration example: + +```json +{ + "mcpServers": { + "vercel": { + "type": "sse", + "url": "https://mcp.vercel.com" + } + } +} +``` + +Manual registration example: + +```json +{ + "mcpServers": { + "github": { + "type": "sse", + "url": "https://api.githubcopilot.com/mcp/", + "oauth": { + "clientId": "${GITHUB_OAUTH_APP_ID}", + "clientSecret": "${GITHUB_OAUTH_APP_SECRET}" + } + } + } +} +``` + +For manual providers, create the OAuth App in the upstream console, set the redirect URI to `http://localhost:3000/oauth/callback` (or your deployed domain), and then plug the credentials into the dashboard or config file. + ### Docker Deployment **Recommended**: Mount your custom config: diff --git a/README.zh.md b/README.zh.md index cee6b52..3efe652 100644 --- a/README.zh.md +++ b/README.zh.md @@ -57,6 +57,45 @@ MCPHub 通过将多个 MCP(Model Context Protocol)服务器组织为灵活 } ``` +#### OAuth 配置(可选) + +MCPHub 支持通过 OAuth 2.0 访问上游 MCP 服务器。完整说明请参阅[《OAuth 功能指南》](docs/zh/features/oauth.mdx)。实际使用中通常会遇到两类配置: + +- **支持动态注册的服务器**(如 Vercel、Linear):会公开全部元数据,MCPHub 可自动注册并完成授权,仅需声明服务器地址。 +- **需要手动配置客户端的服务器**(如 GitHub Copilot):需要在提供商后台创建 OAuth 应用,并将获得的 Client ID/Secret 写入 MCPHub。 + +动态注册示例: + +```json +{ + "mcpServers": { + "vercel": { + "type": "sse", + "url": "https://mcp.vercel.com" + } + } +} +``` + +手动注册示例: + +```json +{ + "mcpServers": { + "github": { + "type": "sse", + "url": "https://api.githubcopilot.com/mcp/", + "oauth": { + "clientId": "${GITHUB_OAUTH_APP_ID}", + "clientSecret": "${GITHUB_OAUTH_APP_SECRET}" + } + } + } +} +``` + +对于需要手动注册的提供商,请先在上游控制台创建 OAuth 应用,将回调地址设置为 `http://localhost:3000/oauth/callback`(或你的部署域名),然后在控制台或配置文件中填写凭据。 + ### Docker 部署 **推荐**:挂载自定义配置: diff --git a/docs/docs.json b/docs/docs.json index f0a4f36..a00487a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -27,7 +27,8 @@ "pages": [ "features/server-management", "features/group-management", - "features/smart-routing" + "features/smart-routing", + "features/oauth" ] }, { @@ -57,7 +58,8 @@ "pages": [ "zh/features/server-management", "zh/features/group-management", - "zh/features/smart-routing" + "zh/features/smart-routing", + "zh/features/oauth" ] }, { @@ -159,4 +161,4 @@ "discord": "https://discord.gg/qMKNsn5Q" } } -} \ No newline at end of file +} diff --git a/docs/features/oauth.mdx b/docs/features/oauth.mdx new file mode 100644 index 0000000..7598c37 --- /dev/null +++ b/docs/features/oauth.mdx @@ -0,0 +1,141 @@ +# OAuth Support + +## At a Glance +- Covers end-to-end OAuth 2.0 Authorization Code with PKCE for upstream MCP servers. +- Supports automatic discovery from `WWW-Authenticate` responses and RFC 8414 metadata. +- Implements dynamic client registration (RFC 7591) and resource indicators (RFC 8707). +- Persists client credentials and tokens to `mcp_settings.json` for reconnects. + +## When MCPHub Switches to OAuth +1. MCPHub calls an MCP server that requires authorization and receives `401 Unauthorized`. +2. The response exposes a `WWW-Authenticate` header pointing to protected resource metadata (`authorization_server` or `as_uri`). +3. MCPHub discovers the authorization server metadata, registers (if needed), and opens the browser so the user can authorize once. +4. After the callback is handled, MCPHub reconnects with fresh tokens and resumes requests transparently. + +> MCPHub logs each stage (discovery, registration, authorization URL, token exchange) in the server detail view and the backend logs. + +## Quick Start by Server Type + +### Servers with Dynamic Registration Support +Some servers expose complete OAuth metadata and allow dynamic client registration. For example, Vercel and Linear MCP servers only need their SSE endpoint configured: + +```json +{ + "mcpServers": { + "vercel": { + "type": "sse", + "url": "https://mcp.vercel.com" + }, + "linear": { + "type": "sse", + "url": "https://mcp.linear.app/mcp" + } + } +} +``` + +- MCPHub discovers the authorization server, registers the client, and handles PKCE automatically. +- Tokens are stored in `mcp_settings.json`; no additional dashboard configuration is needed. + +### Servers Requiring Manual Client Provisioning +Other providers do not support dynamic registration. GitHub’s MCP endpoint (`https://api.githubcopilot.com/mcp/`) is one example. To connect: + +1. Create an OAuth App in the provider’s console (for GitHub, go to **Settings → Developer settings → OAuth Apps**). +2. Set the callback/redirect URL to `http://localhost:3000/oauth/callback` (or your deployed dashboard domain). +3. Copy the issued client ID and client secret. +4. Supply the credentials through the MCPHub dashboard or by editing `mcp_settings.json` as shown below. + +```json +{ + "mcpServers": { + "github": { + "type": "sse", + "url": "https://api.githubcopilot.com/mcp/", + "oauth": { + "clientId": "${GITHUB_OAUTH_APP_ID}", + "clientSecret": "${GITHUB_OAUTH_APP_SECRET}", + "scopes": ["replace-with-provider-scope"], + "resource": "https://api.githubcopilot.com" + } + } + } +} +``` + +- MCPHub skips dynamic registration and uses the credentials you provide to complete the OAuth exchange. +- Update the dashboard or configuration file whenever you rotate secrets. +- Replace `scopes` with the exact scope strings required by the provider. + +## Configuration Options +You can rely on auto-detection for most servers or declare OAuth settings explicitly in `mcp_settings.json`. Only populate the fields you need. + +### Basic Auto Detection (Minimal Config) +```json +{ + "mcpServers": { + "secured-sse": { + "type": "sse", + "url": "https://mcp.example.com/sse", + "oauth": { + "scopes": ["mcp.tools", "mcp.prompts"], + "resource": "https://mcp.example.com" + } + } + } +} +``` +- MCPHub will discover the authorization server from challenge headers and walk the user through authorization automatically. +- Tokens (including refresh tokens) are stored on disk and reused on restart. + +### Static Client Credentials (Bring Your Own Client) +```json +{ + "oauth": { + "clientId": "mcphub-client", + "clientSecret": "replace-me-if-required", + "authorizationEndpoint": "https://auth.example.com/oauth/authorize", + "tokenEndpoint": "https://auth.example.com/oauth/token", + "redirectUri": "http://localhost:3000/oauth/callback" + } +} +``` +- Use this when the authorization server requires manual client provisioning. +- `redirectUri` defaults to `http://localhost:3000/oauth/callback`; override it when running behind a custom domain. + +### Dynamic Client Registration (RFC 7591) +```json +{ + "oauth": { + "dynamicRegistration": { + "enabled": true, + "issuer": "https://auth.example.com", + "metadata": { + "client_name": "MCPHub", + "redirect_uris": [ + "http://localhost:3000/oauth/callback", + "https://mcphub.example.com/oauth/callback" + ], + "scope": "mcp.tools mcp.prompts", + "grant_types": ["authorization_code", "refresh_token"] + }, + "initialAccessToken": "optional-token-if-required" + }, + "scopes": ["mcp.tools", "mcp.prompts"], + "resource": "https://mcp.example.com" + } +} +``` +- MCPHub discovers endpoints via `issuer`, registers itself, and persists the issued `client_id`/`client_secret`. +- Provide `initialAccessToken` only when the registration endpoint is protected. + +## Authorization Flow +1. **Initialization** – On startup MCPHub processes every server entry, discovers metadata, and registers the client if `dynamicRegistration.enabled` is true. +2. **User Authorization** – Initiating a connection launches the system browser to the server’s authorize page with PKCE parameters. +3. **Callback Handling** – The built-in route (`/oauth/callback`) verifies the `state`, completes the token exchange, and saves the tokens via the MCP SDK. +4. **Token Lifecycle** – Access and refresh tokens are cached in memory, refreshed automatically, and written back to `mcp_settings.json`. + +## Tips & Troubleshooting +- Confirm that the redirect URI used during authorization exactly matches one of the `redirect_uris` registered with the authorization server. +- When running behind HTTPS, expose the callback URL publicly or configure a reverse proxy at `/oauth/callback`. +- If discovery fails, supply `authorizationEndpoint` and `tokenEndpoint` explicitly to bypass metadata lookup. +- Remove stale tokens from `mcp_settings.json` if an authorization server revokes access—MCPHub will prompt for a fresh login on the next request. diff --git a/docs/zh/features/oauth.mdx b/docs/zh/features/oauth.mdx new file mode 100644 index 0000000..5a1d1d2 --- /dev/null +++ b/docs/zh/features/oauth.mdx @@ -0,0 +1,141 @@ +# OAuth 支持 + +## 核心亮点 +- 覆盖上游 MCP 服务器的 OAuth 2.0 授权码(PKCE)全流程。 +- 支持从 `WWW-Authenticate` 响应和 RFC 8414 元数据自动发现。 +- 实现动态客户端注册(RFC 7591)以及资源指示(RFC 8707)。 +- 会将客户端凭据与令牌持久化到 `mcp_settings.json`,重启后直接复用。 + +## MCPHub 何时启用 OAuth +1. MCPHub 调用需要授权的 MCP 服务器并收到 `401 Unauthorized`。 +2. 响应通过 `WWW-Authenticate` header 暴露受保护资源的元数据(`authorization_server` 或 `as_uri`)。 +3. MCPHub 自动发现授权服务器、按需注册客户端,并引导用户完成一次授权。 +4. 回调处理完成后,MCPHub 使用新令牌重新连接并继续请求。 + +> MCPHub 会在服务器详情视图和后端日志中记录发现、注册、授权链接、换取令牌等关键步骤。 + +## 按服务器类型快速上手 + +### 支持动态注册的服务器 +部分服务器会公开完整的 OAuth 元数据,并允许客户端动态注册。例如 Vercel 与 Linear 的 MCP 服务器只需配置 SSE 地址即可: + +```json +{ + "mcpServers": { + "vercel": { + "type": "sse", + "url": "https://mcp.vercel.com" + }, + "linear": { + "type": "sse", + "url": "https://mcp.linear.app/mcp" + } + } +} +``` + +- MCPHub 会自动发现授权服务器、完成注册,并处理整个 PKCE 流程。 +- 所有凭据与令牌会写入 `mcp_settings.json`,无须在控制台额外配置。 + +### 需要手动配置客户端的服务器 +另有一些服务端并不支持动态注册。GitHub 的 MCP 端点(`https://api.githubcopilot.com/mcp/`)就是典型例子,接入步骤如下: + +1. 在服务提供商控制台创建 OAuth 应用(GitHub 路径为 **Settings → Developer settings → OAuth Apps**)。 +2. 将回调/重定向地址设置为 `http://localhost:3000/oauth/callback`(或你的部署域名)。 +3. 复制生成的 Client ID 与 Client Secret。 +4. 通过 MCPHub 控制台或直接编辑 `mcp_settings.json` 写入如下配置。 + +```json +{ + "mcpServers": { + "github": { + "type": "sse", + "url": "https://api.githubcopilot.com/mcp/", + "oauth": { + "clientId": "${GITHUB_OAUTH_APP_ID}", + "clientSecret": "${GITHUB_OAUTH_APP_SECRET}", + "scopes": ["replace-with-provider-scope"], + "resource": "https://api.githubcopilot.com" + } + } + } +} +``` + +- MCPHub 会跳过动态注册,直接使用你提供的凭据完成授权流程。 +- 凭据轮换时需要同步更新控制台或配置文件。 +- 将 `scopes` 替换为服务端要求的具体 Scope。 + +## 配置方式 +大多数场景可依赖自动检测,也可以在 `mcp_settings.json` 中显式声明 OAuth 配置。只填写确实需要的字段。 + +### 自动检测(最小配置) +```json +{ + "mcpServers": { + "secured-sse": { + "type": "sse", + "url": "https://mcp.example.com/sse", + "oauth": { + "scopes": ["mcp.tools", "mcp.prompts"], + "resource": "https://mcp.example.com" + } + } + } +} +``` +- MCPHub 会根据挑战头自动发现授权服务器,并引导用户完成授权。 +- 令牌(包含刷新令牌)会写入磁盘,重启后自动复用。 + +### 静态客户端凭据(自带 Client) +```json +{ + "oauth": { + "clientId": "mcphub-client", + "clientSecret": "replace-me-if-required", + "authorizationEndpoint": "https://auth.example.com/oauth/authorize", + "tokenEndpoint": "https://auth.example.com/oauth/token", + "redirectUri": "http://localhost:3000/oauth/callback" + } +} +``` +- 适用于需要手动注册客户端的授权服务器。 +- `redirectUri` 默认是 `http://localhost:3000/oauth/callback`,如在自定义域部署请同步更新。 + +### 动态客户端注册(RFC 7591) +```json +{ + "oauth": { + "dynamicRegistration": { + "enabled": true, + "issuer": "https://auth.example.com", + "metadata": { + "client_name": "MCPHub", + "redirect_uris": [ + "http://localhost:3000/oauth/callback", + "https://mcphub.example.com/oauth/callback" + ], + "scope": "mcp.tools mcp.prompts", + "grant_types": ["authorization_code", "refresh_token"] + }, + "initialAccessToken": "optional-token-if-required" + }, + "scopes": ["mcp.tools", "mcp.prompts"], + "resource": "https://mcp.example.com" + } +} +``` +- MCPHub 会通过 `issuer` 发现端点、完成注册,并持久化下发的 `client_id`/`client_secret`。 +- 只有当注册端点受保护时才需要提供 `initialAccessToken`。 + +## 授权流程 +1. **初始化**:启动时遍历服务器配置,发现元数据并在启用 `dynamicRegistration` 时注册客户端。 +2. **用户授权**:建立连接时自动打开系统浏览器,携带 PKCE 参数访问授权页。 +3. **回调处理**:内置路径 `/oauth/callback` 校验 `state`、完成换取令牌,并通过 MCP SDK 保存结果。 +4. **令牌生命周期**:访问令牌与刷新令牌会缓存于内存,自动刷新,并写回 `mcp_settings.json`。 + +## 提示与排障 +- 确保授权过程中使用的回调地址与已注册的 `redirect_uris` 完全一致。 +- 若部署在 HTTPS 域名下,请对外暴露 `/oauth/callback` 或通过反向代理转发。 +- 如无法完成自动发现,可显式提供 `authorizationEndpoint` 与 `tokenEndpoint`。 +- 授权服务器吊销令牌后,可手动清理 `mcp_settings.json` 中的旧令牌,MCPHub 会在下一次请求时重新触发授权。 diff --git a/frontend/src/components/ServerCard.tsx b/frontend/src/components/ServerCard.tsx index 18437b2..198839e 100644 --- a/frontend/src/components/ServerCard.tsx +++ b/frontend/src/components/ServerCard.tsx @@ -1,188 +1,207 @@ -import { useState, useRef, useEffect } from 'react' -import { useTranslation } from 'react-i18next' -import { Server } from '@/types' -import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react' -import { StatusBadge } from '@/components/ui/Badge' -import ToolCard from '@/components/ui/ToolCard' -import PromptCard from '@/components/ui/PromptCard' -import DeleteDialog from '@/components/ui/DeleteDialog' -import { useToast } from '@/contexts/ToastContext' -import { useSettingsData } from '@/hooks/useSettingsData' +import { useState, useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Server } from '@/types'; +import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react'; +import { StatusBadge } from '@/components/ui/Badge'; +import ToolCard from '@/components/ui/ToolCard'; +import PromptCard from '@/components/ui/PromptCard'; +import DeleteDialog from '@/components/ui/DeleteDialog'; +import { useToast } from '@/contexts/ToastContext'; +import { useSettingsData } from '@/hooks/useSettingsData'; interface ServerCardProps { - server: Server - onRemove: (serverName: string) => void - onEdit: (server: Server) => void - onToggle?: (server: Server, enabled: boolean) => Promise - onRefresh?: () => void + server: Server; + onRemove: (serverName: string) => void; + onEdit: (server: Server) => void; + onToggle?: (server: Server, enabled: boolean) => Promise; + onRefresh?: () => void; } const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCardProps) => { - const { t } = useTranslation() - const { showToast } = useToast() - const [isExpanded, setIsExpanded] = useState(false) - const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [isToggling, setIsToggling] = useState(false) - const [showErrorPopover, setShowErrorPopover] = useState(false) - const [copied, setCopied] = useState(false) - const errorPopoverRef = useRef(null) + const { t } = useTranslation(); + const { showToast } = useToast(); + const [isExpanded, setIsExpanded] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [isToggling, setIsToggling] = useState(false); + const [showErrorPopover, setShowErrorPopover] = useState(false); + const [copied, setCopied] = useState(false); + const errorPopoverRef = useRef(null); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (errorPopoverRef.current && !errorPopoverRef.current.contains(event.target as Node)) { - setShowErrorPopover(false) + setShowErrorPopover(false); } - } + }; - document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('mousedown', handleClickOutside); return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - }, []) + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); - const { exportMCPSettings } = useSettingsData() + const { exportMCPSettings } = useSettingsData(); const handleRemove = (e: React.MouseEvent) => { - e.stopPropagation() - setShowDeleteDialog(true) - } + e.stopPropagation(); + setShowDeleteDialog(true); + }; const handleEdit = (e: React.MouseEvent) => { - e.stopPropagation() - onEdit(server) - } + e.stopPropagation(); + onEdit(server); + }; const handleToggle = async (e: React.MouseEvent) => { - e.stopPropagation() - if (isToggling || !onToggle) return + e.stopPropagation(); + if (isToggling || !onToggle) return; - setIsToggling(true) + setIsToggling(true); try { - await onToggle(server, !(server.enabled !== false)) + await onToggle(server, !(server.enabled !== false)); } finally { - setIsToggling(false) + setIsToggling(false); } - } + }; const handleErrorIconClick = (e: React.MouseEvent) => { - e.stopPropagation() - setShowErrorPopover(!showErrorPopover) - } + e.stopPropagation(); + setShowErrorPopover(!showErrorPopover); + }; const copyToClipboard = (e: React.MouseEvent) => { - e.stopPropagation() - if (!server.error) return + e.stopPropagation(); + if (!server.error) return; if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(server.error).then(() => { - setCopied(true) - showToast(t('common.copySuccess') || 'Copied to clipboard', 'success') - setTimeout(() => setCopied(false), 2000) - }) + setCopied(true); + showToast(t('common.copySuccess') || 'Copied to clipboard', 'success'); + setTimeout(() => setCopied(false), 2000); + }); } else { // Fallback for HTTP or unsupported clipboard API - const textArea = document.createElement('textarea') - textArea.value = server.error + const textArea = document.createElement('textarea'); + textArea.value = server.error; // Avoid scrolling to bottom - textArea.style.position = 'fixed' - textArea.style.left = '-9999px' - document.body.appendChild(textArea) - textArea.focus() - textArea.select() + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); try { - document.execCommand('copy') - setCopied(true) - showToast(t('common.copySuccess') || 'Copied to clipboard', 'success') - setTimeout(() => setCopied(false), 2000) + document.execCommand('copy'); + setCopied(true); + showToast(t('common.copySuccess') || 'Copied to clipboard', 'success'); + setTimeout(() => setCopied(false), 2000); } catch (err) { - showToast(t('common.copyFailed') || 'Copy failed', 'error') - console.error('Copy to clipboard failed:', err) + showToast(t('common.copyFailed') || 'Copy failed', 'error'); + console.error('Copy to clipboard failed:', err); } - document.body.removeChild(textArea) + document.body.removeChild(textArea); } - } + }; const handleCopyServerConfig = async (e: React.MouseEvent) => { - e.stopPropagation() + e.stopPropagation(); try { - const result = await exportMCPSettings(server.name) - const configJson = JSON.stringify(result.data, null, 2) + const result = await exportMCPSettings(server.name); + const configJson = JSON.stringify(result.data, null, 2); if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(configJson) - showToast(t('common.copySuccess') || 'Copied to clipboard', 'success') + await navigator.clipboard.writeText(configJson); + showToast(t('common.copySuccess') || 'Copied to clipboard', 'success'); } else { // Fallback for HTTP or unsupported clipboard API - const textArea = document.createElement('textarea') - textArea.value = configJson - textArea.style.position = 'fixed' - textArea.style.left = '-9999px' - document.body.appendChild(textArea) - textArea.focus() - textArea.select() + const textArea = document.createElement('textarea'); + textArea.value = configJson; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); try { - document.execCommand('copy') - showToast(t('common.copySuccess') || 'Copied to clipboard', 'success') + document.execCommand('copy'); + showToast(t('common.copySuccess') || 'Copied to clipboard', 'success'); } catch (err) { - showToast(t('common.copyFailed') || 'Copy failed', 'error') - console.error('Copy to clipboard failed:', err) + showToast(t('common.copyFailed') || 'Copy failed', 'error'); + console.error('Copy to clipboard failed:', err); } - document.body.removeChild(textArea) + document.body.removeChild(textArea); } } catch (error) { - console.error('Error copying server configuration:', error) - showToast(t('common.copyFailed') || 'Copy failed', 'error') + console.error('Error copying server configuration:', error); + showToast(t('common.copyFailed') || 'Copy failed', 'error'); } - } + }; const handleConfirmDelete = () => { - onRemove(server.name) - setShowDeleteDialog(false) - } + onRemove(server.name); + setShowDeleteDialog(false); + }; const handleToolToggle = async (toolName: string, enabled: boolean) => { try { - const { toggleTool } = await import('@/services/toolService') - const result = await toggleTool(server.name, toolName, enabled) + const { toggleTool } = await import('@/services/toolService'); + const result = await toggleTool(server.name, toolName, enabled); if (result.success) { showToast( t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: toolName }), 'success', - ) + ); // Trigger refresh to update the tool's state in the UI if (onRefresh) { - onRefresh() + onRefresh(); } } else { - showToast(result.error || t('tool.toggleFailed'), 'error') + showToast(result.error || t('tool.toggleFailed'), 'error'); } } catch (error) { - console.error('Error toggling tool:', error) - showToast(t('tool.toggleFailed'), 'error') + console.error('Error toggling tool:', error); + showToast(t('tool.toggleFailed'), 'error'); } - } + }; const handlePromptToggle = async (promptName: string, enabled: boolean) => { try { - const { togglePrompt } = await import('@/services/promptService') - const result = await togglePrompt(server.name, promptName, enabled) + const { togglePrompt } = await import('@/services/promptService'); + const result = await togglePrompt(server.name, promptName, enabled); if (result.success) { showToast( t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: promptName }), 'success', - ) + ); // Trigger refresh to update the prompt's state in the UI if (onRefresh) { - onRefresh() + onRefresh(); } } else { - showToast(result.error || t('tool.toggleFailed'), 'error') + showToast(result.error || t('tool.toggleFailed'), 'error'); } } catch (error) { - console.error('Error toggling prompt:', error) - showToast(t('tool.toggleFailed'), 'error') + console.error('Error toggling prompt:', error); + showToast(t('tool.toggleFailed'), 'error'); } - } + }; + + const handleOAuthAuthorization = (e: React.MouseEvent) => { + e.stopPropagation(); + // Open the OAuth authorization URL in a new window + if (server.oauth?.authorizationUrl) { + const width = 600; + const height = 700; + const left = window.screen.width / 2 - width / 2; + const top = window.screen.height / 2 - height / 2; + + window.open( + server.oauth.authorizationUrl, + 'OAuth Authorization', + `width=${width},height=${height},left=${left},top=${top}`, + ); + + showToast(t('status.oauthWindowOpened'), 'info'); + } + }; return ( <> @@ -199,7 +218,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar > {server.name} - + {/* Tool count display */}
@@ -269,8 +288,8 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
@@ -49,9 +60,25 @@ const DashboardPage: React.FC = () => { {isLoading && (
- - - + + +

{t('app.loading')}

@@ -64,12 +91,25 @@ const DashboardPage: React.FC = () => {
- - + +
-

{t('pages.dashboard.totalServers')}

+

+ {t('pages.dashboard.totalServers')} +

{serverStats.total}

@@ -79,12 +119,25 @@ const DashboardPage: React.FC = () => {
- - + +
-

{t('pages.dashboard.onlineServers')}

+

+ {t('pages.dashboard.onlineServers')} +

{serverStats.online}

@@ -94,12 +147,25 @@ const DashboardPage: React.FC = () => {
- - + +
-

{t('pages.dashboard.offlineServers')}

+

+ {t('pages.dashboard.offlineServers')} +

{serverStats.offline}

@@ -109,16 +175,28 @@ const DashboardPage: React.FC = () => {
- - + +
-

{t('pages.dashboard.connectingServers')}

+

+ {t('pages.dashboard.connectingServers')} +

{serverStats.connecting}

-
)} @@ -126,24 +204,41 @@ const DashboardPage: React.FC = () => { {/* Recent activity list */} {servers.length > 0 && !isLoading && (
-

{t('pages.dashboard.recentServers')}

+

+ {t('pages.dashboard.recentServers')} +

- - - - - @@ -155,12 +250,18 @@ const DashboardPage: React.FC = () => { {server.name} @@ -188,4 +289,4 @@ const DashboardPage: React.FC = () => { ); }; -export default DashboardPage; \ No newline at end of file +export default DashboardPage; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0eb8ce0..db09ee6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,5 +1,5 @@ // Server status types -export type ServerStatus = 'connecting' | 'connected' | 'disconnected'; +export type ServerStatus = 'connecting' | 'connected' | 'disconnected' | 'oauth_required'; // Market server types export interface MarketServerRepository { @@ -121,6 +121,43 @@ export interface ServerConfig { resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications maxTotalTimeout?: number; // Maximum total timeout in milliseconds }; // MCP request options configuration + // OAuth authentication for upstream MCP servers + oauth?: { + clientId?: string; // OAuth client ID + clientSecret?: string; // OAuth client secret + scopes?: string[]; // Required OAuth scopes + accessToken?: string; // Pre-obtained access token (if available) + refreshToken?: string; // Refresh token for renewing access + dynamicRegistration?: { + enabled?: boolean; // Enable/disable dynamic registration + issuer?: string; // OAuth issuer URL for discovery + registrationEndpoint?: string; // Direct registration endpoint URL + metadata?: { + client_name?: string; + client_uri?: string; + logo_uri?: string; + scope?: string; + redirect_uris?: string[]; + grant_types?: string[]; + response_types?: string[]; + token_endpoint_auth_method?: string; + contacts?: string[]; + software_id?: string; + software_version?: string; + [key: string]: any; + }; + initialAccessToken?: string; + }; + resource?: string; // OAuth resource parameter (RFC8707) + authorizationEndpoint?: string; // Authorization endpoint (authorization code flow) + tokenEndpoint?: string; // Token endpoint for exchanging authorization codes for tokens + pendingAuthorization?: { + authorizationUrl?: string; + state?: string; + codeVerifier?: string; + createdAt?: number; + }; + }; // OpenAPI specific configuration openapi?: { url?: string; // OpenAPI specification URL @@ -172,6 +209,10 @@ export interface Server { prompts?: Prompt[]; config?: ServerConfig; enabled?: boolean; + oauth?: { + authorizationUrl?: string; + state?: string; + }; } // Group types @@ -209,6 +250,16 @@ export interface ServerFormData { resetTimeoutOnProgress?: boolean; maxTotalTimeout?: number; }; + oauth?: { + clientId?: string; + clientSecret?: string; + scopes?: string; + accessToken?: string; + refreshToken?: string; + authorizationEndpoint?: string; + tokenEndpoint?: string; + resource?: string; + }; // OpenAPI specific fields openapi?: { url?: string; diff --git a/jest.config.cjs b/jest.config.cjs index 301c51b..ce7c5b7 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -40,7 +40,7 @@ module.exports = { '^@/(.*)$': '/src/$1', '^(\\.{1,2}/.*)\\.js$': '$1', }, - transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|other-esm-packages)/)'], + transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|openid-client|oauth4webapi)/)'], extensionsToTreatAsEsm: ['.ts'], testTimeout: 30000, verbose: true, diff --git a/locales/en.json b/locales/en.json index 6ab8e5b..4f6a0c9 100644 --- a/locales/en.json +++ b/locales/en.json @@ -107,7 +107,7 @@ "enabled": "Enabled", "enable": "Enable", "disable": "Disable", - "requestOptions": "Configuration", + "requestOptions": "Connection Configuration", "timeout": "Request Timeout", "timeoutDescription": "Timeout for requests to the MCP server (ms)", "maxTotalTimeout": "Maximum Total Timeout", @@ -164,12 +164,28 @@ "apiKeyInCookie": "Cookie", "passthroughHeaders": "Passthrough Headers", "passthroughHeadersHelp": "Comma-separated list of header names to pass through from tool call requests to upstream OpenAPI endpoints (e.g., Authorization, X-API-Key)" + }, + "oauth": { + "sectionTitle": "OAuth Configuration", + "sectionDescription": "Configure client credentials for OAuth-protected servers (optional).", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "authorizationEndpoint": "Authorization Endpoint", + "tokenEndpoint": "Token Endpoint", + "scopes": "Scopes", + "scopesPlaceholder": "scope1 scope2", + "resource": "Resource / Audience", + "accessToken": "Access Token", + "refreshToken": "Refresh Token" } }, "status": { "online": "Online", "offline": "Offline", - "connecting": "Connecting" + "connecting": "Connecting", + "oauthRequired": "OAuth Required", + "clickToAuthorize": "Click to authorize with OAuth", + "oauthWindowOpened": "OAuth authorization window opened. Please complete the authorization." }, "errors": { "general": "Something went wrong", @@ -676,5 +692,31 @@ "serverRemovedFromGroup": "Server removed from group successfully", "serverToolsUpdated": "Server tools updated successfully" } + }, + "oauthCallback": { + "authorizationFailed": "Authorization Failed", + "authorizationFailedError": "Error", + "authorizationFailedDetails": "Details", + "invalidRequest": "Invalid Request", + "missingStateParameter": "Missing required OAuth state parameter.", + "missingCodeParameter": "Missing required authorization code parameter.", + "serverNotFound": "Server Not Found", + "serverNotFoundMessage": "Could not find server associated with this authorization request.", + "sessionExpiredMessage": "The authorization session may have expired. Please try authorizing again.", + "authorizationSuccessful": "Authorization Successful", + "server": "Server", + "status": "Status", + "connected": "Connected", + "successMessage": "The server has been successfully authorized and connected.", + "autoCloseMessage": "This window will close automatically in 3 seconds...", + "closeNow": "Close Now", + "connectionError": "Connection Error", + "connectionErrorMessage": "Authorization was successful, but failed to connect to the server.", + "reconnectMessage": "Please try reconnecting from the dashboard.", + "configurationError": "Configuration Error", + "configurationErrorMessage": "Server transport does not support OAuth finishAuth(). Please ensure the server is configured with streamable-http transport.", + "internalError": "Internal Error", + "internalErrorMessage": "An unexpected error occurred while processing the OAuth callback.", + "closeWindow": "Close Window" } } \ No newline at end of file diff --git a/locales/fr.json b/locales/fr.json index baf7c32..05e1f95 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -107,7 +107,7 @@ "enabled": "Activé", "enable": "Activer", "disable": "Désactiver", - "requestOptions": "Configuration", + "requestOptions": "Configuration de la connexion", "timeout": "Délai d'attente de la requête", "timeoutDescription": "Délai d'attente pour les requêtes vers le serveur MCP (ms)", "maxTotalTimeout": "Délai d'attente total maximum", @@ -164,12 +164,28 @@ "apiKeyInCookie": "Cookie", "passthroughHeaders": "En-têtes de transmission", "passthroughHeadersHelp": "Liste séparée par des virgules des noms d'en-têtes à transmettre des requêtes d'appel d'outils vers les points de terminaison OpenAPI en amont (par ex. : Authorization, X-API-Key)" + }, + "oauth": { + "sectionTitle": "Configuration OAuth", + "sectionDescription": "Configurez les identifiants client pour les serveurs protégés par OAuth (optionnel).", + "clientId": "Identifiant client", + "clientSecret": "Secret client", + "authorizationEndpoint": "Point de terminaison d'autorisation", + "tokenEndpoint": "Point de terminaison de jeton", + "scopes": "Scopes", + "scopesPlaceholder": "scope1 scope2", + "resource": "Ressource / Audience", + "accessToken": "Jeton d'accès", + "refreshToken": "Jeton d'actualisation" } }, "status": { "online": "En ligne", "offline": "Hors ligne", - "connecting": "Connexion en cours" + "connecting": "Connexion en cours", + "oauthRequired": "OAuth requis", + "clickToAuthorize": "Cliquez pour autoriser avec OAuth", + "oauthWindowOpened": "Fenêtre d'autorisation OAuth ouverte. Veuillez compléter l'autorisation." }, "errors": { "general": "Une erreur est survenue", @@ -676,5 +692,31 @@ "serverRemovedFromGroup": "Serveur supprimé du groupe avec succès", "serverToolsUpdated": "Outils du serveur mis à jour avec succès" } + }, + "oauthCallback": { + "authorizationFailed": "Échec de l'autorisation", + "authorizationFailedError": "Erreur", + "authorizationFailedDetails": "Détails", + "invalidRequest": "Requête invalide", + "missingStateParameter": "Paramètre d'état OAuth requis manquant.", + "missingCodeParameter": "Paramètre de code d'autorisation requis manquant.", + "serverNotFound": "Serveur introuvable", + "serverNotFoundMessage": "Impossible de trouver le serveur associé à cette demande d'autorisation.", + "sessionExpiredMessage": "La session d'autorisation a peut-être expiré. Veuillez réessayer l'autorisation.", + "authorizationSuccessful": "Autorisation réussie", + "server": "Serveur", + "status": "État", + "connected": "Connecté", + "successMessage": "Le serveur a été autorisé et connecté avec succès.", + "autoCloseMessage": "Cette fenêtre se fermera automatiquement dans 3 secondes...", + "closeNow": "Fermer maintenant", + "connectionError": "Erreur de connexion", + "connectionErrorMessage": "L'autorisation a réussi, mais la connexion au serveur a échoué.", + "reconnectMessage": "Veuillez essayer de vous reconnecter à partir du tableau de bord.", + "configurationError": "Erreur de configuration", + "configurationErrorMessage": "Le transport du serveur ne prend pas en charge OAuth finishAuth(). Veuillez vous assurer que le serveur est configuré avec le transport streamable-http.", + "internalError": "Erreur interne", + "internalErrorMessage": "Une erreur inattendue s'est produite lors du traitement du callback OAuth.", + "closeWindow": "Fermer la fenêtre" } } \ No newline at end of file diff --git a/locales/zh.json b/locales/zh.json index 45fed02..f8a8e8e 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -107,7 +107,7 @@ "enabled": "已启用", "enable": "启用", "disable": "禁用", - "requestOptions": "配置", + "requestOptions": "连接配置", "timeout": "请求超时", "timeoutDescription": "请求超时时间(毫秒)", "maxTotalTimeout": "最大总超时", @@ -164,12 +164,28 @@ "apiKeyInCookie": "Cookie", "passthroughHeaders": "透传请求头", "passthroughHeadersHelp": "要从工具调用请求透传到上游OpenAPI接口的请求头名称列表,用逗号分隔(如:Authorization, X-API-Key)" + }, + "oauth": { + "sectionTitle": "OAuth 配置", + "sectionDescription": "为需要 OAuth 的服务器配置客户端凭据(可选)。", + "clientId": "客户端 ID", + "clientSecret": "客户端密钥", + "authorizationEndpoint": "授权端点", + "tokenEndpoint": "令牌端点", + "scopes": "权限范围(Scopes)", + "scopesPlaceholder": "scope1 scope2", + "resource": "资源 / 受众", + "accessToken": "访问令牌", + "refreshToken": "刷新令牌" } }, "status": { "online": "在线", "offline": "离线", - "connecting": "连接中" + "connecting": "连接中", + "oauthRequired": "需要OAuth授权", + "clickToAuthorize": "点击进行OAuth授权", + "oauthWindowOpened": "OAuth授权窗口已打开,请完成授权。" }, "errors": { "general": "发生错误", @@ -678,5 +694,31 @@ "serverRemovedFromGroup": "服务器从分组移除成功", "serverToolsUpdated": "服务器工具更新成功" } + }, + "oauthCallback": { + "authorizationFailed": "授权失败", + "authorizationFailedError": "错误", + "authorizationFailedDetails": "详情", + "invalidRequest": "无效请求", + "missingStateParameter": "缺少必需的 OAuth 状态参数。", + "missingCodeParameter": "缺少必需的授权码参数。", + "serverNotFound": "服务器未找到", + "serverNotFoundMessage": "无法找到与此授权请求关联的服务器。", + "sessionExpiredMessage": "授权会话可能已过期。请重新进行授权。", + "authorizationSuccessful": "授权成功", + "server": "服务器", + "status": "状态", + "connected": "已连接", + "successMessage": "服务器已成功授权并连接。", + "autoCloseMessage": "此窗口将在 3 秒后自动关闭...", + "closeNow": "立即关闭", + "connectionError": "连接错误", + "connectionErrorMessage": "授权成功,但连接服务器失败。", + "reconnectMessage": "请尝试从控制面板重新连接。", + "configurationError": "配置错误", + "configurationErrorMessage": "服务器传输不支持 OAuth finishAuth()。请确保服务器配置为 streamable-http 传输。", + "internalError": "内部错误", + "internalErrorMessage": "处理 OAuth 回调时发生意外错误。", + "closeWindow": "关闭窗口" } } \ No newline at end of file diff --git a/package.json b/package.json index cfb4c4b..8a3fde1 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "license": "ISC", "dependencies": { "@apidevtools/swagger-parser": "^12.0.0", - "@modelcontextprotocol/sdk": "^1.18.1", + "@modelcontextprotocol/sdk": "^1.20.2", "@types/adm-zip": "^0.5.7", "@types/bcrypt": "^6.0.0", "@types/multer": "^1.4.13", @@ -66,6 +66,7 @@ "multer": "^2.0.2", "openai": "^4.104.0", "openapi-types": "^12.1.3", + "openid-client": "^6.8.1", "pg": "^8.16.3", "pgvector": "^0.2.1", "postgres": "^3.4.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 915d809..f3c3875 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ importers: specifier: ^12.0.0 version: 12.0.0(openapi-types@12.1.3) '@modelcontextprotocol/sdk': - specifier: ^1.18.1 - version: 1.18.1 + specifier: ^1.20.2 + version: 1.20.2 '@types/adm-zip': specifier: ^0.5.7 version: 0.5.7 @@ -75,6 +75,9 @@ importers: openapi-types: specifier: ^12.1.3 version: 12.1.3 + openid-client: + specifier: ^6.8.1 + version: 6.8.1 pg: specifier: ^8.16.3 version: 8.16.3 @@ -1085,8 +1088,8 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@modelcontextprotocol/sdk@1.18.1': - resolution: {integrity: sha512-d//GE8/Yh7aC3e7p+kZG8JqqEAwwDUmAfvH1quogtbk+ksS6E0RR6toKKESPYYZVre0meqkJb27zb+dhqE9Sgw==} + '@modelcontextprotocol/sdk@1.20.2': + resolution: {integrity: sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg==} engines: {node: '>=18'} '@napi-rs/wasm-runtime@0.2.12': @@ -3267,6 +3270,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.1.0: + resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3681,6 +3687,9 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + oauth4webapi@3.8.2: + resolution: {integrity: sha512-FzZZ+bht5X0FKe7Mwz3DAVAmlH1BV5blSak/lHMBKz0/EBMhX6B10GlQYI51+oRp8ObJaX0g6pXrAxZh5s8rjw==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -3719,6 +3728,9 @@ packages: openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + openid-client@6.8.1: + resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -5569,7 +5581,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@modelcontextprotocol/sdk@1.18.1': + '@modelcontextprotocol/sdk@1.20.2': dependencies: ajv: 6.12.6 content-type: 1.0.5 @@ -7993,6 +8005,8 @@ snapshots: jiti@2.6.1: {} + jose@6.1.0: {} + js-tokens@4.0.0: {} js-yaml@3.14.1: @@ -8325,6 +8339,8 @@ snapshots: dependencies: path-key: 4.0.0 + oauth4webapi@3.8.2: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -8361,6 +8377,11 @@ snapshots: openapi-types@12.1.3: {} + openid-client@6.8.1: + dependencies: + jose: 6.1.0 + oauth4webapi: 3.8.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 diff --git a/src/controllers/oauthCallbackController.ts b/src/controllers/oauthCallbackController.ts new file mode 100644 index 0000000..99d4df4 --- /dev/null +++ b/src/controllers/oauthCallbackController.ts @@ -0,0 +1,390 @@ +/** + * OAuth Callback Controller + * + * Handles OAuth 2.0 authorization callbacks for upstream MCP servers. + * + * This controller implements a simplified callback flow that relies on the MCP SDK + * to handle the complete OAuth token exchange: + * + * 1. Extract authorization code from callback URL + * 2. Find the corresponding server using the state parameter + * 3. Store the authorization code temporarily + * 4. Reconnect the server - SDK's auth() function will: + * - Automatically discover OAuth endpoints + * - Exchange the code for tokens using PKCE + * - Save tokens via our OAuthClientProvider.saveTokens() + */ + +import { Request, Response } from 'express'; +import { + getServerByName, + getServerByOAuthState, + createTransportFromConfig, +} from '../services/mcpService.js'; +import { getNameSeparator, loadSettings } from '../config/index.js'; +import type { ServerInfo } from '../types/index.js'; + +/** + * Generate HTML response page with i18n support + */ +const generateHtmlResponse = ( + type: 'error' | 'success', + title: string, + message: string, + details?: { label: string; value: string }[], + autoClose: boolean = false, +): string => { + const backgroundColor = type === 'error' ? '#fee' : '#efe'; + const borderColor = type === 'error' ? '#fcc' : '#cfc'; + const titleColor = type === 'error' ? '#c33' : '#3c3'; + const buttonColor = type === 'error' ? '#c33' : '#3c3'; + + return ` + + + + ${title} + + ${autoClose ? '' : ''} + + +
+

${type === 'success' ? '✓ ' : ''}${title}

+ ${details ? details.map((d) => `
${d.label}: ${d.value}
`).join('') : ''} +

${message}

+ ${autoClose ? '

This window will close automatically in 3 seconds...

' : ''} + +
+ + + `; +}; + +const normalizeQueryParam = (value: unknown): string | undefined => { + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value) && value.length > 0) { + const [first] = value; + return typeof first === 'string' ? first : undefined; + } + + return undefined; +}; + +const extractServerNameFromState = (stateValue: string): string | undefined => { + try { + const normalized = stateValue.replace(/-/g, '+').replace(/_/g, '/'); + const padding = (4 - (normalized.length % 4)) % 4; + const base64 = normalized + '='.repeat(padding); + const decoded = Buffer.from(base64, 'base64').toString('utf8'); + const payload = JSON.parse(decoded); + + if (payload && typeof payload.server === 'string') { + return payload.server; + } + } catch (error) { + // Ignore decoding errors and fall back to delimiter-based parsing + } + + const separatorIndex = stateValue.indexOf(':'); + if (separatorIndex > 0) { + return stateValue.slice(0, separatorIndex); + } + + return undefined; +}; + +/** + * Handle OAuth callback after user authorization + * + * This endpoint receives the authorization code from the OAuth provider + * and initiates the server reconnection process. + * + * Expected query parameters: + * - code: Authorization code from OAuth provider + * - state: Encoded server identifier used for OAuth session validation + * - error: Optional error code if authorization failed + * - error_description: Optional error description + */ +export const handleOAuthCallback = async (req: Request, res: Response) => { + try { + const { code, state, error, error_description } = req.query; + const codeParam = normalizeQueryParam(code); + const stateParam = normalizeQueryParam(state); + + // Get translation function from request (set by i18n middleware) + const t = (req as any).t || ((key: string) => key); + + // Check for authorization errors + if (error) { + console.error(`OAuth authorization failed: ${error} - ${error_description || ''}`); + return res + .status(400) + .send( + generateHtmlResponse('error', t('oauthCallback.authorizationFailed'), '', [ + { label: t('oauthCallback.authorizationFailedError'), value: String(error) }, + ...(error_description + ? [ + { + label: t('oauthCallback.authorizationFailedDetails'), + value: String(error_description), + }, + ] + : []), + ]), + ); + } + + // Validate required parameters + if (!stateParam) { + console.error('OAuth callback missing state parameter'); + return res + .status(400) + .send( + generateHtmlResponse( + 'error', + t('oauthCallback.invalidRequest'), + t('oauthCallback.missingStateParameter'), + ), + ); + } + + if (!codeParam) { + console.error('OAuth callback missing authorization code'); + return res + .status(400) + .send( + generateHtmlResponse( + 'error', + t('oauthCallback.invalidRequest'), + t('oauthCallback.missingCodeParameter'), + ), + ); + } + + console.log(`OAuth callback received - code: present, state: ${stateParam}`); + + // Find server by state parameter + let serverInfo: ServerInfo | undefined; + + serverInfo = getServerByOAuthState(stateParam); + + let decodedServerName: string | undefined; + if (!serverInfo) { + decodedServerName = extractServerNameFromState(stateParam); + if (decodedServerName) { + console.log(`State lookup failed; decoding server name from state: ${decodedServerName}`); + serverInfo = getServerByName(decodedServerName); + } + } + + if (!serverInfo) { + console.error( + `No server found for OAuth callback. State: ${stateParam}${ + decodedServerName ? `, decoded server: ${decodedServerName}` : '' + }`, + ); + return res + .status(400) + .send( + generateHtmlResponse( + 'error', + t('oauthCallback.serverNotFound'), + `${t('oauthCallback.serverNotFoundMessage')}\n${t('oauthCallback.sessionExpiredMessage')}`, + ), + ); + } + + // Optional: Validate state parameter for additional security + if (serverInfo.oauth?.state && serverInfo.oauth.state !== stateParam) { + console.warn( + `State mismatch for server ${serverInfo.name}. Expected: ${serverInfo.oauth.state}, Got: ${stateParam}`, + ); + // Note: We log a warning but don't fail the request since we have server name as primary identifier + } + + console.log(`Processing OAuth callback for server: ${serverInfo.name}`); + + // For StreamableHTTPClientTransport, we need to call finishAuth() on the transport + // This will exchange the authorization code for tokens automatically + if (serverInfo.transport && 'finishAuth' in serverInfo.transport) { + try { + console.log(`Calling transport.finishAuth() for server: ${serverInfo.name}`); + const currentTransport = serverInfo.transport as any; + await currentTransport.finishAuth(codeParam); + + console.log(`Successfully exchanged authorization code for tokens: ${serverInfo.name}`); + + // Refresh server configuration from disk to ensure we pick up newly saved tokens + const settings = loadSettings(); + const storedConfig = settings.mcpServers?.[serverInfo.name]; + const effectiveConfig = storedConfig || serverInfo.config; + + if (!effectiveConfig) { + throw new Error( + `Missing server configuration for ${serverInfo.name} after OAuth callback`, + ); + } + + // Keep latest configuration cached on serverInfo + serverInfo.config = effectiveConfig; + + // Ensure we have up-to-date request options for the reconnect attempt + if (!serverInfo.options) { + const requestConfig = effectiveConfig.options || {}; + serverInfo.options = { + timeout: requestConfig.timeout || 60000, + resetTimeoutOnProgress: requestConfig.resetTimeoutOnProgress || false, + maxTotalTimeout: requestConfig.maxTotalTimeout, + }; + } + + // Replace the existing transport instance to avoid reusing a closed/aborted transport + try { + if (serverInfo.transport && 'close' in serverInfo.transport) { + await (serverInfo.transport as any).close(); + } + } catch (closeError) { + console.warn(`Failed to close existing transport for ${serverInfo.name}:`, closeError); + } + + console.log( + `Rebuilding transport with refreshed credentials for server: ${serverInfo.name}`, + ); + const refreshedTransport = await createTransportFromConfig( + serverInfo.name, + effectiveConfig, + ); + serverInfo.transport = refreshedTransport; + + // Update server status to indicate OAuth is complete + serverInfo.status = 'connected'; + if (serverInfo.oauth) { + serverInfo.oauth.authorizationUrl = undefined; + serverInfo.oauth.state = undefined; + serverInfo.oauth.codeVerifier = undefined; + } + + // Check if client needs to be connected + const isClientConnected = serverInfo.client && serverInfo.client.getServerCapabilities(); + + if (!isClientConnected) { + // Client is not connected yet, connect it + if (serverInfo.client && serverInfo.transport) { + console.log(`Connecting client with refreshed transport for: ${serverInfo.name}`); + try { + await serverInfo.client.connect(serverInfo.transport, serverInfo.options); + console.log(`Client connected successfully for: ${serverInfo.name}`); + + // List tools after successful connection + const capabilities = serverInfo.client.getServerCapabilities(); + console.log( + `Server capabilities for ${serverInfo.name}:`, + JSON.stringify(capabilities), + ); + + if (capabilities?.tools) { + console.log(`Listing tools for server: ${serverInfo.name}`); + const toolsResult = await serverInfo.client.listTools({}, serverInfo.options); + const separator = getNameSeparator(); + serverInfo.tools = toolsResult.tools.map((tool) => ({ + name: `${serverInfo.name}${separator}${tool.name}`, + description: tool.description || '', + inputSchema: tool.inputSchema || {}, + })); + console.log( + `Listed ${serverInfo.tools.length} tools for server: ${serverInfo.name}`, + ); + } else { + console.log(`Server ${serverInfo.name} does not support tools capability`); + } + } catch (connectError) { + console.error(`Error connecting client for ${serverInfo.name}:`, connectError); + if (connectError instanceof Error) { + console.error( + `Connect error details for ${serverInfo.name}: ${connectError.message}`, + connectError.stack, + ); + } + // Even if connection fails, mark OAuth as complete + // The user can try reconnecting from the dashboard + } + } else { + console.log( + `Cannot connect client for ${serverInfo.name}: client or transport missing`, + ); + } + } else { + console.log(`Client already connected for server: ${serverInfo.name}`); + } + + console.log(`Successfully completed OAuth flow for server: ${serverInfo.name}`); + + // Return success page + return res.status(200).send( + generateHtmlResponse( + 'success', + t('oauthCallback.authorizationSuccessful'), + `${t('oauthCallback.successMessage')}\n${t('oauthCallback.autoCloseMessage')}`, + [ + { label: t('oauthCallback.server'), value: serverInfo.name }, + { label: t('oauthCallback.status'), value: t('oauthCallback.connected') }, + ], + true, // auto-close + ), + ); + } catch (error) { + console.error(`Failed to complete OAuth flow for server ${serverInfo.name}:`, error); + console.error(`Error type: ${typeof error}, Error name: ${error?.constructor?.name}`); + console.error(`Error message: ${error instanceof Error ? error.message : String(error)}`); + console.error(`Error stack:`, error instanceof Error ? error.stack : 'No stack trace'); + + return res + .status(500) + .send( + generateHtmlResponse( + 'error', + t('oauthCallback.connectionError'), + `${t('oauthCallback.connectionErrorMessage')}\n${t('oauthCallback.reconnectMessage')}`, + [{ label: '', value: error instanceof Error ? error.message : String(error) }], + ), + ); + } + } else { + // No transport available or transport doesn't support finishAuth + console.error(`Transport for server ${serverInfo.name} does not support finishAuth()`); + return res + .status(500) + .send( + generateHtmlResponse( + 'error', + t('oauthCallback.configurationError'), + t('oauthCallback.configurationErrorMessage'), + ), + ); + } + } catch (error) { + console.error('Unexpected error handling OAuth callback:', error); + + // Get translation function from request (set by i18n middleware) + const t = (req as any).t || ((key: string) => key); + + return res + .status(500) + .send( + generateHtmlResponse( + 'error', + t('oauthCallback.internalError'), + t('oauthCallback.internalErrorMessage'), + ), + ); + } +}; diff --git a/src/routes/index.ts b/src/routes/index.ts index 50fb2ab..2bfc3c0 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -79,6 +79,7 @@ import { executeToolViaOpenAPI, getGroupOpenAPISpec, } from '../controllers/openApiController.js'; +import { handleOAuthCallback } from '../controllers/oauthCallbackController.js'; import { auth } from '../middlewares/auth.js'; const router = express.Router(); @@ -87,6 +88,9 @@ export const initRoutes = (app: express.Application): void => { // Health check endpoint (no auth required, accessible at /health) app.get('/health', healthCheck); + // OAuth callback endpoint (no auth required, public callback URL) + app.get('/oauth/callback', handleOAuthCallback); + // API routes protected by auth middleware in middlewares/index.ts router.get('/servers', getAllServers); router.get('/settings', getAllSettings); diff --git a/src/server.ts b/src/server.ts index 2686347..2657316 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,6 +17,7 @@ import { initializeDefaultUser } from './models/User.js'; import { sseUserContextMiddleware } from './middlewares/userContext.js'; import { findPackageRoot } from './utils/path.js'; import { getCurrentModuleDir } from './utils/moduleDir.js'; +import { initOAuthProvider, getOAuthRouter } from './services/oauthService.js'; /** * Get the directory of the current module @@ -58,6 +59,16 @@ export class AppServer { // Initialize default admin user if no users exist await initializeDefaultUser(); + // Initialize OAuth provider if configured + initOAuthProvider(); + const oauthRouter = getOAuthRouter(); + if (oauthRouter) { + // Mount OAuth router at the root level (before other routes) + // This must be at root level as per MCP OAuth specification + this.app.use(oauthRouter); + console.log('OAuth router mounted successfully'); + } + initMiddlewares(this.app); initRoutes(this.app); console.log('Server initialized successfully'); diff --git a/src/services/mcpOAuthProvider.ts b/src/services/mcpOAuthProvider.ts new file mode 100644 index 0000000..37df819 --- /dev/null +++ b/src/services/mcpOAuthProvider.ts @@ -0,0 +1,588 @@ +/** + * MCP OAuth Provider Implementation + * + * Implements OAuthClientProvider interface from @modelcontextprotocol/sdk/client/auth.js + * to handle OAuth 2.0 authentication for upstream MCP servers using the SDK's built-in + * OAuth support. + * + * This provider integrates with our existing OAuth infrastructure: + * - Dynamic client registration (RFC7591) + * - Token storage and refresh + * - Authorization flow handling + */ + +import { randomBytes } from 'node:crypto'; +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import type { + OAuthClientInformation, + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import { ServerConfig } from '../types/index.js'; +import { loadSettings } from '../config/index.js'; +import { + initializeOAuthForServer, + getRegisteredClient, + removeRegisteredClient, + fetchScopesFromServer, +} from './oauthClientRegistration.js'; +import { + clearOAuthData, + loadServerConfig, + mutateOAuthSettings, + persistClientCredentials, + persistTokens, + updatePendingAuthorization, + ServerConfigWithOAuth, +} from './oauthSettingsStore.js'; + +// Import getServerByName to access ServerInfo +import { getServerByName } from './mcpService.js'; + +/** + * MCPHub OAuth Provider for server-side OAuth flows + * + * This provider handles OAuth authentication for upstream MCP servers. + * Unlike browser-based providers, this runs in a Node.js server environment, + * so the authorization flow requires external handling (e.g., via web UI). + */ +export class MCPHubOAuthProvider implements OAuthClientProvider { + private serverName: string; + private serverConfig: ServerConfig; + private _codeVerifier?: string; + private _currentState?: string; + + constructor(serverName: string, serverConfig: ServerConfig) { + this.serverName = serverName; + this.serverConfig = serverConfig; + } + + private getSystemInstallBaseUrl(): string | undefined { + const settings = loadSettings(); + return settings.systemConfig?.install?.baseUrl; + } + + private sanitizeRedirectUri(input?: string): string | null { + if (!input) { + return null; + } + + try { + const url = new URL(input); + url.searchParams.delete('server'); + const params = url.searchParams.toString(); + url.search = params ? `?${params}` : ''; + return url.toString(); + } catch { + return null; + } + } + + private buildRedirectUriFromBase(baseUrl?: string): string | null { + if (!baseUrl) { + return null; + } + + const trimmed = baseUrl.trim(); + if (!trimmed) { + return null; + } + + try { + const normalizedBase = trimmed.endsWith('/') ? trimmed : `${trimmed}/`; + const redirect = new URL('oauth/callback', normalizedBase); + return this.sanitizeRedirectUri(redirect.toString()); + } catch { + return null; + } + } + + /** + * Get redirect URL for OAuth callback + */ + get redirectUrl(): string { + const dynamicConfig = this.serverConfig.oauth?.dynamicRegistration; + const metadata = dynamicConfig?.metadata || {}; + const fallback = 'http://localhost:3000/oauth/callback'; + const systemConfigured = this.buildRedirectUriFromBase(this.getSystemInstallBaseUrl()); + const metadataConfigured = this.sanitizeRedirectUri(metadata.redirect_uris?.[0]); + + return systemConfigured ?? metadataConfigured ?? fallback; + } + + /** + * Get client metadata for dynamic registration or static configuration + */ + get clientMetadata(): OAuthClientMetadata { + const dynamicConfig = this.serverConfig.oauth?.dynamicRegistration; + const metadata = dynamicConfig?.metadata || {}; + + // Use redirectUrl getter to ensure consistent callback URL + const redirectUri = this.redirectUrl; + const systemConfigured = this.buildRedirectUriFromBase(this.getSystemInstallBaseUrl()); + const metadataRedirects = + metadata.redirect_uris && metadata.redirect_uris.length > 0 + ? metadata.redirect_uris + .map((uri) => this.sanitizeRedirectUri(uri)) + .filter((uri): uri is string => Boolean(uri)) + : []; + const redirectUris: string[] = []; + + if (systemConfigured) { + redirectUris.push(systemConfigured); + } + + for (const uri of metadataRedirects) { + if (!redirectUris.includes(uri)) { + redirectUris.push(uri); + } + } + + if (!redirectUris.includes(redirectUri)) { + redirectUris.push(redirectUri); + } + + const tokenEndpointAuthMethod = + metadata.token_endpoint_auth_method && metadata.token_endpoint_auth_method !== '' + ? metadata.token_endpoint_auth_method + : this.serverConfig.oauth?.clientSecret + ? 'client_secret_post' + : 'none'; + + return { + ...metadata, // Include any additional custom metadata + client_name: metadata.client_name || `MCPHub - ${this.serverName}`, + redirect_uris: redirectUris, + grant_types: metadata.grant_types || ['authorization_code', 'refresh_token'], + response_types: metadata.response_types || ['code'], + token_endpoint_auth_method: tokenEndpointAuthMethod, + scope: metadata.scope || this.serverConfig.oauth?.scopes?.join(' ') || 'openid', + }; + } + + private async ensureScopesFromServer(): Promise { + const serverUrl = this.serverConfig.url; + const existingScopes = this.serverConfig.oauth?.scopes; + + if (!serverUrl) { + return existingScopes; + } + + if (existingScopes && existingScopes.length > 0) { + return existingScopes; + } + + try { + const scopes = await fetchScopesFromServer(serverUrl); + if (scopes && scopes.length > 0) { + const updatedConfig = await mutateOAuthSettings(this.serverName, ({ oauth }) => { + oauth.scopes = scopes; + }); + if (updatedConfig) { + this.serverConfig = updatedConfig; + } + console.log(`Stored auto-detected scopes for ${this.serverName}: ${scopes.join(', ')}`); + return scopes; + } + } catch (error) { + console.warn( + `Failed to auto-detect scopes for ${this.serverName}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + return existingScopes; + } + + private generateState(): string { + const payload = { + server: this.serverName, + nonce: randomBytes(16).toString('hex'), + }; + const base64 = Buffer.from(JSON.stringify(payload)).toString('base64'); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + async state(): Promise { + if (!this._currentState) { + this._currentState = this.generateState(); + } + return this._currentState; + } + + /** + * Get previously registered client information + */ + clientInformation(): OAuthClientInformation | undefined { + const clientInfo = getRegisteredClient(this.serverName); + + if (!clientInfo) { + // Try to use static client configuration from cached serverConfig first + let serverConfig = this.serverConfig; + + // If cached config doesn't have clientId, reload from settings + if (!serverConfig?.oauth?.clientId) { + const storedConfig = loadServerConfig(this.serverName); + + if (storedConfig) { + this.serverConfig = storedConfig; + serverConfig = storedConfig; + } + } + + // Try to use static client configuration from serverConfig + if (serverConfig?.oauth?.clientId) { + return { + client_id: serverConfig.oauth.clientId, + client_secret: serverConfig.oauth.clientSecret, + }; + } + return undefined; + } + + return { + client_id: clientInfo.clientId, + client_secret: clientInfo.clientSecret, + }; + } + + /** + * Save registered client information + * Called by SDK after successful dynamic registration + */ + async saveClientInformation(info: OAuthClientInformationFull): Promise { + console.log(`Saving OAuth client information for server: ${this.serverName}`); + + const scopeString = info.scope?.trim(); + const scopes = + scopeString && scopeString.length > 0 + ? scopeString.split(/\s+/).filter((value) => value.length > 0) + : undefined; + + try { + const updatedConfig = await persistClientCredentials(this.serverName, { + clientId: info.client_id, + clientSecret: info.client_secret, + scopes, + }); + + if (updatedConfig) { + this.serverConfig = updatedConfig; + } + + if (!scopes || scopes.length === 0) { + await this.ensureScopesFromServer(); + } + } catch (error) { + console.error( + `Failed to persist OAuth client credentials for server ${this.serverName}:`, + error, + ); + throw error; + } + } + + /** + * Get stored OAuth tokens + */ + tokens(): OAuthTokens | undefined { + // Use cached config first, but reload if needed + let serverConfig = this.serverConfig; + + // If cached config doesn't have tokens, try reloading + if (!serverConfig?.oauth?.accessToken) { + const storedConfig = loadServerConfig(this.serverName); + if (storedConfig) { + this.serverConfig = storedConfig; + serverConfig = storedConfig; + } + } + + if (!serverConfig?.oauth?.accessToken) { + return undefined; + } + + return { + access_token: serverConfig.oauth.accessToken, + token_type: 'Bearer', + refresh_token: serverConfig.oauth.refreshToken, + // Note: expires_in is not typically stored, only the token itself + // The SDK will handle token refresh when needed + }; + } + + /** + * Save OAuth tokens + * Called by SDK after successful token exchange or refresh + */ + async saveTokens(tokens: OAuthTokens): Promise { + const currentOAuth = this.serverConfig.oauth; + const accessTokenChanged = currentOAuth?.accessToken !== tokens.access_token; + const refreshTokenProvided = tokens.refresh_token !== undefined; + const refreshTokenChanged = + refreshTokenProvided && currentOAuth?.refreshToken !== tokens.refresh_token; + const hadPending = Boolean(currentOAuth?.pendingAuthorization); + + if (!accessTokenChanged && !refreshTokenChanged && !hadPending) { + return; + } + + console.log(`Saving OAuth tokens for server: ${this.serverName}`); + + const updatedConfig = await persistTokens(this.serverName, { + accessToken: tokens.access_token, + refreshToken: refreshTokenProvided ? tokens.refresh_token ?? null : undefined, + clearPendingAuthorization: hadPending, + }); + + if (updatedConfig) { + this.serverConfig = updatedConfig; + } + + this._codeVerifier = undefined; + this._currentState = undefined; + + const serverInfo = getServerByName(this.serverName); + if (serverInfo) { + serverInfo.oauth = undefined; + } + + console.log(`Saved OAuth tokens for server: ${this.serverName}`); + } + + /** + * Redirect to authorization URL + * In a server environment, we can't directly redirect the user + * Instead, we store the URL in ServerInfo for the frontend to access + */ + async redirectToAuthorization(url: URL): Promise { + console.log('='.repeat(80)); + console.log(`OAuth Authorization Required for server: ${this.serverName}`); + console.log(`Authorization URL: ${url.toString()}`); + console.log('='.repeat(80)); + let state = url.searchParams.get('state') || undefined; + + if (!state) { + state = await this.state(); + url.searchParams.set('state', state); + } else { + this._currentState = state; + } + + const authorizationUrl = url.toString(); + + try { + const pendingUpdate: Partial['pendingAuthorization']> = { + authorizationUrl, + state, + }; + + if (this._codeVerifier) { + pendingUpdate.codeVerifier = this._codeVerifier; + } + + const updatedConfig = await updatePendingAuthorization(this.serverName, pendingUpdate); + if (updatedConfig) { + this.serverConfig = updatedConfig; + } + } catch (error) { + console.error( + `Failed to persist pending OAuth authorization state for ${this.serverName}:`, + error, + ); + } + + // Store the authorization URL in ServerInfo for the frontend to access + const serverInfo = getServerByName(this.serverName); + if (serverInfo) { + serverInfo.status = 'oauth_required'; + serverInfo.oauth = { + authorizationUrl, + state, + codeVerifier: this._codeVerifier, + }; + console.log(`Stored OAuth authorization URL in ServerInfo for server: ${this.serverName}`); + } else { + console.warn(`ServerInfo not found for ${this.serverName}, cannot store authorization URL`); + } + + // Throw error to indicate authorization is needed + // The error will be caught in the connection flow and handled appropriately + throw new Error( + `OAuth authorization required for server ${this.serverName}. Please complete OAuth flow via web UI.`, + ); + } + + /** + * Save PKCE code verifier for later use in token exchange + */ + async saveCodeVerifier(verifier: string): Promise { + this._codeVerifier = verifier; + try { + const updatedConfig = await updatePendingAuthorization(this.serverName, { codeVerifier: verifier }); + if (updatedConfig) { + this.serverConfig = updatedConfig; + } + } catch (error) { + console.error(`Failed to persist OAuth code verifier for ${this.serverName}:`, error); + } + console.log(`Saved code verifier for server: ${this.serverName}`); + } + + /** + * Retrieve PKCE code verifier for token exchange + */ + async codeVerifier(): Promise { + if (this._codeVerifier) { + return this._codeVerifier; + } + + const storedConfig = loadServerConfig(this.serverName); + const storedVerifier = storedConfig?.oauth?.pendingAuthorization?.codeVerifier; + + if (storedVerifier) { + this.serverConfig = storedConfig || this.serverConfig; + this._codeVerifier = storedVerifier; + return storedVerifier; + } + + throw new Error(`No code verifier stored for server: ${this.serverName}`); + } + + /** + * Invalidate cached OAuth credentials when the SDK detects they are no longer valid. + * This keeps stored configuration in sync and forces a fresh authorization flow. + */ + async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): Promise { + const storedConfig = loadServerConfig(this.serverName); + + if (!storedConfig?.oauth) { + if (scope === 'verifier' || scope === 'all') { + this._codeVerifier = undefined; + } + return; + } + + let currentConfig = storedConfig as ServerConfigWithOAuth; + const assignUpdatedConfig = (updated?: ServerConfigWithOAuth) => { + if (updated) { + currentConfig = updated; + this.serverConfig = updated; + } else { + this.serverConfig = currentConfig; + } + }; + + assignUpdatedConfig(currentConfig); + let changed = false; + + if (scope === 'tokens' || scope === 'all') { + if (currentConfig.oauth.accessToken || currentConfig.oauth.refreshToken) { + const updated = await clearOAuthData(this.serverName, 'tokens'); + assignUpdatedConfig(updated); + changed = true; + console.warn(`Cleared OAuth tokens for server: ${this.serverName}`); + } + } + + if (scope === 'client' || scope === 'all') { + const supportsDynamicClient = currentConfig.oauth.dynamicRegistration?.enabled === true; + + if (supportsDynamicClient && (currentConfig.oauth.clientId || currentConfig.oauth.clientSecret)) { + removeRegisteredClient(this.serverName); + const updated = await clearOAuthData(this.serverName, 'client'); + assignUpdatedConfig(updated); + changed = true; + console.warn(`Cleared OAuth client registration for server: ${this.serverName}`); + } + } + + if (scope === 'verifier' || scope === 'all') { + this._codeVerifier = undefined; + this._currentState = undefined; + if (currentConfig.oauth.pendingAuthorization) { + const updated = await clearOAuthData(this.serverName, 'verifier'); + assignUpdatedConfig(updated); + changed = true; + } + } + + if (changed) { + this._currentState = undefined; + const serverInfo = getServerByName(this.serverName); + if (serverInfo) { + serverInfo.status = 'oauth_required'; + serverInfo.oauth = undefined; + } + } + } +} + +const prepopulateScopesIfMissing = async ( + serverName: string, + serverConfig: ServerConfig, +): Promise => { + if (!serverConfig.oauth || serverConfig.oauth.scopes?.length) { + return; + } + + if (!serverConfig.url) { + return; + } + + try { + const scopes = await fetchScopesFromServer(serverConfig.url); + if (scopes && scopes.length > 0) { + const updatedConfig = await mutateOAuthSettings(serverName, ({ oauth }) => { + oauth.scopes = scopes; + }); + + if (!serverConfig.oauth) { + serverConfig.oauth = {}; + } + serverConfig.oauth.scopes = scopes; + + if (updatedConfig) { + console.log(`Stored auto-detected scopes for ${serverName}: ${scopes.join(', ')}`); + } + } + } catch (error) { + console.warn( + `Failed to auto-detect scopes for ${serverName} during provider initialization: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +}; + +/** + * Create an OAuth provider for a server if OAuth is configured + * + * @param serverName - Name of the server + * @param serverConfig - Server configuration + * @returns OAuthClientProvider instance or undefined if OAuth not configured + */ +export const createOAuthProvider = async ( + serverName: string, + serverConfig: ServerConfig, +): Promise => { + // Ensure scopes are pre-populated if dynamic registration already ran previously + await prepopulateScopesIfMissing(serverName, serverConfig); + + // Initialize OAuth for the server (performs registration if needed) + // This ensures the client is registered before the SDK tries to use it + try { + await initializeOAuthForServer(serverName, serverConfig); + } catch (error) { + console.warn(`Failed to initialize OAuth for server ${serverName}:`, error); + // Continue anyway - the SDK might be able to handle it + } + + // Create and return the provider + const provider = new MCPHubOAuthProvider(serverName, serverConfig); + + console.log(`Created OAuth provider for server: ${serverName}`); + return provider; +}; diff --git a/src/services/mcpService.ts b/src/services/mcpService.ts index a451604..8a88b22 100644 --- a/src/services/mcpService.ts +++ b/src/services/mcpService.ts @@ -10,7 +10,10 @@ import { import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + StreamableHTTPClientTransport, + StreamableHTTPClientTransportOptions, +} from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { ServerInfo, ServerConfig, Tool } from '../types/index.js'; import { loadSettings, expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js'; import config from '../config/index.js'; @@ -21,6 +24,8 @@ import { OpenAPIClient } from '../clients/openapi.js'; import { RequestContextService } from './requestContextService.js'; import { getDataService } from './services.js'; import { getServerDao, ServerConfigWithName } from '../dao/index.js'; +import { initializeAllOAuthClients } from './oauthService.js'; +import { createOAuthProvider } from './mcpOAuthProvider.js'; const servers: { [sessionId: string]: Server } = {}; @@ -59,6 +64,10 @@ const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): voi }; export const initUpstreamServers = async (): Promise => { + // Initialize OAuth clients for servers with dynamic registration + await initializeAllOAuthClients(); + + // Register all tools from upstream servers await registerAllTools(true); }; @@ -155,28 +164,48 @@ export const cleanupAllServers = (): void => { }; // Helper function to create transport based on server configuration -const createTransportFromConfig = (name: string, conf: ServerConfig): any => { +export const createTransportFromConfig = async (name: string, conf: ServerConfig): Promise => { let transport; if (conf.type === 'streamable-http') { - const options: any = {}; - if (conf.headers && Object.keys(conf.headers).length > 0) { + const options: StreamableHTTPClientTransportOptions = {}; + const headers = conf.headers ? replaceEnvVars(conf.headers) : {}; + + if (Object.keys(headers).length > 0) { options.requestInit = { - headers: replaceEnvVars(conf.headers), + headers, }; } + + // Create OAuth provider if configured - SDK will handle authentication automatically + const authProvider = await createOAuthProvider(name, conf); + if (authProvider) { + options.authProvider = authProvider; + console.log(`OAuth provider configured for server: ${name}`); + } + transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options); } else if (conf.url) { // SSE transport const options: any = {}; - if (conf.headers && Object.keys(conf.headers).length > 0) { + const headers = conf.headers ? replaceEnvVars(conf.headers) : {}; + + if (Object.keys(headers).length > 0) { options.eventSourceInit = { - headers: replaceEnvVars(conf.headers), + headers, }; options.requestInit = { - headers: replaceEnvVars(conf.headers), + headers, }; } + + // Create OAuth provider if configured - SDK will handle authentication automatically + const authProvider = await createOAuthProvider(name, conf); + if (authProvider) { + options.authProvider = authProvider; + console.log(`OAuth provider configured for server: ${name}`); + } + transport = new SSEClientTransport(new URL(conf.url), options); } else if (conf.command && conf.args) { // Stdio transport @@ -269,7 +298,7 @@ const callToolWithReconnect = async ( } // Recreate transport using helper function - const newTransport = createTransportFromConfig(serverInfo.name, server); + const newTransport = await createTransportFromConfig(serverInfo.name, server); // Create new client const client = new Client( @@ -345,59 +374,143 @@ export const initializeClientsFromSettings = async ( ): Promise => { const allServers: ServerConfigWithName[] = await serverDao.findAll(); const existingServerInfos = serverInfos; - serverInfos = []; + const nextServerInfos: ServerInfo[] = []; - for (const conf of allServers) { - const { name } = conf; - // Skip disabled servers - if (conf.enabled === false) { - console.log(`Skipping disabled server: ${name}`); - serverInfos.push({ - name, - owner: conf.owner, - status: 'disconnected', - error: null, - tools: [], - prompts: [], - createTime: Date.now(), - enabled: false, - }); - continue; - } - - // Check if server is already connected - const existingServer = existingServerInfos.find( - (s) => s.name === name && s.status === 'connected', - ); - if (existingServer && (!serverName || serverName !== name)) { - serverInfos.push({ - ...existingServer, - enabled: conf.enabled === undefined ? true : conf.enabled, - }); - console.log(`Server '${name}' is already connected.`); - continue; - } - - let transport; - let openApiClient; - if (conf.type === 'openapi') { - // Handle OpenAPI type servers - if (!conf.openapi?.url && !conf.openapi?.schema) { - console.warn( - `Skipping OpenAPI server '${name}': missing OpenAPI specification URL or schema`, - ); - serverInfos.push({ + try { + for (const conf of allServers) { + const { name } = conf; + // Skip disabled servers + if (conf.enabled === false) { + console.log(`Skipping disabled server: ${name}`); + nextServerInfos.push({ name, owner: conf.owner, status: 'disconnected', - error: 'Missing OpenAPI specification URL or schema', + error: null, tools: [], prompts: [], createTime: Date.now(), + enabled: false, }); continue; } + // Check if server is already connected + const existingServer = existingServerInfos.find( + (s) => s.name === name && s.status === 'connected', + ); + if (existingServer && (!serverName || serverName !== name)) { + nextServerInfos.push({ + ...existingServer, + enabled: conf.enabled === undefined ? true : conf.enabled, + }); + console.log(`Server '${name}' is already connected.`); + continue; + } + + let transport; + let openApiClient; + if (conf.type === 'openapi') { + // Handle OpenAPI type servers + if (!conf.openapi?.url && !conf.openapi?.schema) { + console.warn( + `Skipping OpenAPI server '${name}': missing OpenAPI specification URL or schema`, + ); + nextServerInfos.push({ + name, + owner: conf.owner, + status: 'disconnected', + error: 'Missing OpenAPI specification URL or schema', + tools: [], + prompts: [], + createTime: Date.now(), + }); + continue; + } + + // Create server info first and keep reference to it + const serverInfo: ServerInfo = { + name, + owner: conf.owner, + status: 'connecting', + error: null, + tools: [], + prompts: [], + createTime: Date.now(), + enabled: conf.enabled === undefined ? true : conf.enabled, + config: conf, // Store reference to original config for OpenAPI passthrough headers + }; + nextServerInfos.push(serverInfo); + + try { + // Create OpenAPI client instance + openApiClient = new OpenAPIClient(conf); + + console.log(`Initializing OpenAPI server: ${name}...`); + + // Perform async initialization + await openApiClient.initialize(); + + // Convert OpenAPI tools to MCP tool format + const openApiTools = openApiClient.getTools(); + const mcpTools: Tool[] = openApiTools.map((tool) => ({ + name: `${name}${getNameSeparator()}${tool.name}`, + description: tool.description, + inputSchema: cleanInputSchema(tool.inputSchema), + })); + + // Update server info with successful initialization + serverInfo.status = 'connected'; + serverInfo.tools = mcpTools; + serverInfo.openApiClient = openApiClient; + + console.log( + `Successfully initialized OpenAPI server: ${name} with ${mcpTools.length} tools`, + ); + + // Save tools as vector embeddings for search + saveToolsAsVectorEmbeddings(name, mcpTools); + continue; + } catch (error) { + console.error(`Failed to initialize OpenAPI server ${name}:`, error); + + // Update the already pushed server info with error status + serverInfo.status = 'disconnected'; + serverInfo.error = `Failed to initialize OpenAPI server: ${error}`; + continue; + } + } else { + transport = await createTransportFromConfig(name, conf); + } + + const client = new Client( + { + name: `mcp-client-${name}`, + version: '1.0.0', + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + }, + }, + ); + + const initRequestOptions = isInit + ? { + timeout: Number(config.initTimeout) || 60000, + } + : undefined; + + // Get request options from server configuration, with fallbacks + const serverRequestOptions = conf.options || {}; + const requestOptions = { + timeout: serverRequestOptions.timeout || 60000, + resetTimeoutOnProgress: serverRequestOptions.resetTimeoutOnProgress || false, + maxTotalTimeout: serverRequestOptions.maxTotalTimeout, + }; + // Create server info first and keep reference to it const serverInfo: ServerInfo = { name, @@ -406,169 +519,122 @@ export const initializeClientsFromSettings = async ( error: null, tools: [], prompts: [], + client, + transport, + options: requestOptions, createTime: Date.now(), - enabled: conf.enabled === undefined ? true : conf.enabled, - config: conf, // Store reference to original config for OpenAPI passthrough headers + config: conf, // Store reference to original config }; - serverInfos.push(serverInfo); - try { - // Create OpenAPI client instance - openApiClient = new OpenAPIClient(conf); - - console.log(`Initializing OpenAPI server: ${name}...`); - - // Perform async initialization - await openApiClient.initialize(); - - // Convert OpenAPI tools to MCP tool format - const openApiTools = openApiClient.getTools(); - const mcpTools: Tool[] = openApiTools.map((tool) => ({ - name: `${name}${getNameSeparator()}${tool.name}`, - description: tool.description, - inputSchema: cleanInputSchema(tool.inputSchema), - })); - - // Update server info with successful initialization - serverInfo.status = 'connected'; - serverInfo.tools = mcpTools; - serverInfo.openApiClient = openApiClient; - - console.log( - `Successfully initialized OpenAPI server: ${name} with ${mcpTools.length} tools`, - ); - - // Save tools as vector embeddings for search - saveToolsAsVectorEmbeddings(name, mcpTools); - continue; - } catch (error) { - console.error(`Failed to initialize OpenAPI server ${name}:`, error); - - // Update the already pushed server info with error status - serverInfo.status = 'disconnected'; - serverInfo.error = `Failed to initialize OpenAPI server: ${error}`; - continue; + const pendingAuth = conf.oauth?.pendingAuthorization; + if (pendingAuth) { + serverInfo.status = 'oauth_required'; + serverInfo.error = null; + serverInfo.oauth = { + authorizationUrl: pendingAuth.authorizationUrl, + state: pendingAuth.state, + codeVerifier: pendingAuth.codeVerifier, + }; } - } else { - transport = createTransportFromConfig(name, conf); + nextServerInfos.push(serverInfo); + + client + .connect(transport, initRequestOptions || requestOptions) + .then(() => { + console.log(`Successfully connected client for server: ${name}`); + const capabilities: ServerCapabilities | undefined = client.getServerCapabilities(); + console.log(`Server capabilities: ${JSON.stringify(capabilities)}`); + + let dataError: Error | null = null; + if (capabilities?.tools) { + client + .listTools({}, initRequestOptions || requestOptions) + .then((tools) => { + console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`); + serverInfo.tools = tools.tools.map((tool) => ({ + name: `${name}${getNameSeparator()}${tool.name}`, + description: tool.description || '', + inputSchema: cleanInputSchema(tool.inputSchema || {}), + })); + // Save tools as vector embeddings for search + saveToolsAsVectorEmbeddings(name, serverInfo.tools); + }) + .catch((error) => { + console.error( + `Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`, + ); + dataError = error; + }); + } + + if (capabilities?.prompts) { + client + .listPrompts({}, initRequestOptions || requestOptions) + .then((prompts) => { + console.log( + `Successfully listed ${prompts.prompts.length} prompts for server: ${name}`, + ); + serverInfo.prompts = prompts.prompts.map((prompt) => ({ + name: `${name}${getNameSeparator()}${prompt.name}`, + title: prompt.title, + description: prompt.description, + arguments: prompt.arguments, + })); + }) + .catch((error) => { + console.error( + `Failed to list prompts for server ${name} by error: ${error} with stack: ${error.stack}`, + ); + dataError = error; + }); + } + + if (!dataError) { + serverInfo.status = 'connected'; + serverInfo.error = null; + + // Set up keep-alive ping for SSE connections + setupKeepAlive(serverInfo, conf); + } else { + serverInfo.status = 'disconnected'; + serverInfo.error = `Failed to list data: ${dataError} `; + } + }) + .catch(async (error) => { + // Check if this is an OAuth authorization error + const isOAuthError = + error?.message?.includes('OAuth authorization required') || + error?.message?.includes('Authorization required'); + + if (isOAuthError) { + // OAuth provider should have already set the status to 'oauth_required' + // and stored the authorization URL in serverInfo.oauth + console.log( + `OAuth authorization required for server ${name}. Status should be set to 'oauth_required'.`, + ); + // Make sure status is set correctly + if (serverInfo.status !== 'oauth_required') { + serverInfo.status = 'oauth_required'; + } + serverInfo.error = null; + } else { + console.error( + `Failed to connect client for server ${name} by error: ${error} with stack: ${error.stack}`, + ); + // Other connection errors + serverInfo.status = 'disconnected'; + serverInfo.error = `Failed to connect: ${error.stack} `; + } + }); + console.log(`Initialized client for server: ${name}`); } - - const client = new Client( - { - name: `mcp-client-${name}`, - version: '1.0.0', - }, - { - capabilities: { - prompts: {}, - resources: {}, - tools: {}, - }, - }, - ); - - const initRequestOptions = isInit - ? { - timeout: Number(config.initTimeout) || 60000, - } - : undefined; - - // Get request options from server configuration, with fallbacks - const serverRequestOptions = conf.options || {}; - const requestOptions = { - timeout: serverRequestOptions.timeout || 60000, - resetTimeoutOnProgress: serverRequestOptions.resetTimeoutOnProgress || false, - maxTotalTimeout: serverRequestOptions.maxTotalTimeout, - }; - - // Create server info first and keep reference to it - const serverInfo: ServerInfo = { - name, - owner: conf.owner, - status: 'connecting', - error: null, - tools: [], - prompts: [], - client, - transport, - options: requestOptions, - createTime: Date.now(), - config: conf, // Store reference to original config - }; - serverInfos.push(serverInfo); - - client - .connect(transport, initRequestOptions || requestOptions) - .then(() => { - console.log(`Successfully connected client for server: ${name}`); - const capabilities: ServerCapabilities | undefined = client.getServerCapabilities(); - console.log(`Server capabilities: ${JSON.stringify(capabilities)}`); - - let dataError: Error | null = null; - if (capabilities?.tools) { - client - .listTools({}, initRequestOptions || requestOptions) - .then((tools) => { - console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`); - serverInfo.tools = tools.tools.map((tool) => ({ - name: `${name}${getNameSeparator()}${tool.name}`, - description: tool.description || '', - inputSchema: cleanInputSchema(tool.inputSchema || {}), - })); - // Save tools as vector embeddings for search - saveToolsAsVectorEmbeddings(name, serverInfo.tools); - }) - .catch((error) => { - console.error( - `Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`, - ); - dataError = error; - }); - } - - if (capabilities?.prompts) { - client - .listPrompts({}, initRequestOptions || requestOptions) - .then((prompts) => { - console.log( - `Successfully listed ${prompts.prompts.length} prompts for server: ${name}`, - ); - serverInfo.prompts = prompts.prompts.map((prompt) => ({ - name: `${name}${getNameSeparator()}${prompt.name}`, - title: prompt.title, - description: prompt.description, - arguments: prompt.arguments, - })); - }) - .catch((error) => { - console.error( - `Failed to list prompts for server ${name} by error: ${error} with stack: ${error.stack}`, - ); - dataError = error; - }); - } - - if (!dataError) { - serverInfo.status = 'connected'; - serverInfo.error = null; - - // Set up keep-alive ping for SSE connections - setupKeepAlive(serverInfo, conf); - } else { - serverInfo.status = 'disconnected'; - serverInfo.error = `Failed to list data: ${dataError} `; - } - }) - .catch((error) => { - console.error( - `Failed to connect client for server ${name} by error: ${error} with stack: ${error.stack}`, - ); - serverInfo.status = 'disconnected'; - serverInfo.error = `Failed to connect: ${error.stack} `; - }); - console.log(`Initialized client for server: ${name}`); + } catch (error) { + // Restore previous state if initialization fails to avoid exposing an empty server list + serverInfos = existingServerInfos; + throw error; } + serverInfos = nextServerInfos; return serverInfos; }; @@ -584,39 +650,48 @@ export const getServersInfo = async (): Promise { - const serverConfig = allServers.find((server) => server.name === name); - const enabled = serverConfig ? serverConfig.enabled !== false : true; + const infos = filterServerInfos.map( + ({ name, status, tools, prompts, createTime, error, oauth }) => { + const serverConfig = allServers.find((server) => server.name === name); + const enabled = serverConfig ? serverConfig.enabled !== false : true; + + // Add enabled status and custom description to each tool + const toolsWithEnabled = tools.map((tool) => { + const toolConfig = serverConfig?.tools?.[tool.name]; + return { + ...tool, + description: toolConfig?.description || tool.description, // Use custom description if available + enabled: toolConfig?.enabled !== false, // Default to true if not explicitly disabled + }; + }); + + const promptsWithEnabled = prompts.map((prompt) => { + const promptConfig = serverConfig?.prompts?.[prompt.name]; + return { + ...prompt, + description: promptConfig?.description || prompt.description, // Use custom description if available + enabled: promptConfig?.enabled !== false, // Default to true if not explicitly disabled + }; + }); - // Add enabled status and custom description to each tool - const toolsWithEnabled = tools.map((tool) => { - const toolConfig = serverConfig?.tools?.[tool.name]; return { - ...tool, - description: toolConfig?.description || tool.description, // Use custom description if available - enabled: toolConfig?.enabled !== false, // Default to true if not explicitly disabled + name, + status, + error, + tools: toolsWithEnabled, + prompts: promptsWithEnabled, + createTime, + enabled, + oauth: oauth + ? { + authorizationUrl: oauth.authorizationUrl, + state: oauth.state, + // Don't expose codeVerifier to frontend for security + } + : undefined, }; - }); - - const promptsWithEnabled = prompts.map((prompt) => { - const promptConfig = serverConfig?.prompts?.[prompt.name]; - return { - ...prompt, - description: promptConfig?.description || prompt.description, // Use custom description if available - enabled: promptConfig?.enabled !== false, // Default to true if not explicitly disabled - }; - }); - - return { - name, - status, - error, - tools: toolsWithEnabled, - prompts: promptsWithEnabled, - createTime, - enabled, - }; - }); + }, + ); infos.sort((a, b) => { if (a.enabled === b.enabled) return 0; return a.enabled ? -1 : 1; @@ -629,6 +704,51 @@ export const getServerByName = (name: string): ServerInfo | undefined => { return serverInfos.find((serverInfo) => serverInfo.name === name); }; +// Get server by OAuth state parameter +export const getServerByOAuthState = (state: string): ServerInfo | undefined => { + return serverInfos.find((serverInfo) => serverInfo.oauth?.state === state); +}; + +/** + * Reconnect a server after OAuth authorization or configuration change + * This will close the existing connection and reinitialize the server + */ +export const reconnectServer = async (serverName: string): Promise => { + console.log(`Reconnecting server: ${serverName}`); + + const serverInfo = getServerByName(serverName); + if (!serverInfo) { + throw new Error(`Server not found: ${serverName}`); + } + + // Close existing connection if any + if (serverInfo.client) { + try { + serverInfo.client.close(); + } catch (error) { + console.warn(`Error closing client for server ${serverName}:`, error); + } + } + + if (serverInfo.transport) { + try { + serverInfo.transport.close(); + } catch (error) { + console.warn(`Error closing transport for server ${serverName}:`, error); + } + } + + if (serverInfo.keepAliveIntervalId) { + clearInterval(serverInfo.keepAliveIntervalId); + serverInfo.keepAliveIntervalId = undefined; + } + + // Reinitialize the server + await initializeClientsFromSettings(false, serverName); + + console.log(`Successfully reconnected server: ${serverName}`); +}; + // Filter tools by server configuration const filterToolsByConfig = async (serverName: string, tools: Tool[]): Promise => { const serverConfig = await serverDao.findById(serverName); diff --git a/src/services/oauthClientRegistration.ts b/src/services/oauthClientRegistration.ts new file mode 100644 index 0000000..9e6d2df --- /dev/null +++ b/src/services/oauthClientRegistration.ts @@ -0,0 +1,584 @@ +/** + * OAuth 2.0 Dynamic Client Registration Service + * + * Implements dynamic client registration for upstream MCP servers based on: + * - RFC7591: OAuth 2.0 Dynamic Client Registration Protocol + * - RFC8414: OAuth 2.0 Authorization Server Metadata + * - MCP Authorization Specification + * + * Uses the standard openid-client library for OAuth operations. + */ + +import * as client from 'openid-client'; +import { ServerConfig } from '../types/index.js'; +import { + mutateOAuthSettings, + persistClientCredentials, + persistTokens, +} from './oauthSettingsStore.js'; + +interface RegisteredClientInfo { + config: client.Configuration; + clientId: string; + clientSecret?: string; + registrationAccessToken?: string; + registrationClientUri?: string; + expiresAt?: number; + metadata: any; +} + +// Cache for registered clients to avoid re-registering on every restart +const registeredClients = new Map(); + +export const removeRegisteredClient = (serverName: string): void => { + registeredClients.delete(serverName); +}; + +/** + * Parse WWW-Authenticate header to extract resource server metadata URL + * Following RFC9728 Protected Resource Metadata specification + * + * Example header: WWW-Authenticate: Bearer resource="https://mcp.example.com/.well-known/oauth-protected-resource" + */ +export const parseWWWAuthenticateHeader = (header: string): string | null => { + if (!header || !header.toLowerCase().startsWith('bearer ')) { + return null; + } + + // Extract resource parameter from WWW-Authenticate header + const resourceMatch = header.match(/resource="([^"]+)"/i); + if (resourceMatch && resourceMatch[1]) { + return resourceMatch[1]; + } + + return null; +}; + +/** + * Fetch protected resource metadata from MCP server + * Following RFC9728 section 3 + * + * @param resourceMetadataUrl - URL to fetch resource metadata (from WWW-Authenticate header) + * @returns Authorization server URLs and other metadata + */ +export const fetchProtectedResourceMetadata = async ( + resourceMetadataUrl: string, +): Promise<{ + authorization_servers: string[]; + resource?: string; + [key: string]: any; +}> => { + try { + console.log(`Fetching protected resource metadata from: ${resourceMetadataUrl}`); + + const response = await fetch(resourceMetadataUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch resource metadata: ${response.status} ${response.statusText}`, + ); + } + + const metadata = await response.json(); + + if (!metadata.authorization_servers || !Array.isArray(metadata.authorization_servers)) { + throw new Error('Invalid resource metadata: missing authorization_servers field'); + } + + console.log(`Found ${metadata.authorization_servers.length} authorization server(s)`); + return metadata; + } catch (error) { + console.warn(`Failed to fetch protected resource metadata:`, error); + throw error; + } +}; + +/** + * Fetch scopes from protected resource metadata by trying the well-known URL + * + * @param serverUrl - The MCP server URL + * @returns Array of supported scopes or undefined if not available + */ +export const fetchScopesFromServer = async (serverUrl: string): Promise => { + try { + // Construct the well-known protected resource metadata URL + // Format: https://example.com/.well-known/oauth-protected-resource/path/to/resource + const url = new URL(serverUrl); + const resourcePath = url.pathname + url.search; + const wellKnownUrl = `${url.origin}/.well-known/oauth-protected-resource${resourcePath}`; + + console.log(`Attempting to fetch scopes from: ${wellKnownUrl}`); + + const metadata = await fetchProtectedResourceMetadata(wellKnownUrl); + + if (metadata.scopes_supported && Array.isArray(metadata.scopes_supported)) { + console.log(`Fetched scopes from server: ${metadata.scopes_supported.join(', ')}`); + return metadata.scopes_supported as string[]; + } + + return undefined; + } catch (error) { + console.log( + `Could not fetch scopes from server (this is normal if not using OAuth discovery): ${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + } +}; + +/** + * Auto-detect OAuth configuration from 401 response + * Following MCP Authorization Specification for automatic discovery + * + * @param wwwAuthenticateHeader - The WWW-Authenticate header value from 401 response + * @param serverUrl - The MCP server URL that returned 401 + * @returns Issuer URL and resource URL for OAuth configuration + */ +export const autoDetectOAuthConfig = async ( + wwwAuthenticateHeader: string, + serverUrl: string, +): Promise<{ issuer: string; resource: string; scopes?: string[] } | null> => { + try { + // Step 1: Parse WWW-Authenticate header to get resource metadata URL + const resourceMetadataUrl = parseWWWAuthenticateHeader(wwwAuthenticateHeader); + + if (!resourceMetadataUrl) { + console.log('No resource metadata URL found in WWW-Authenticate header'); + return null; + } + + // Step 2: Fetch protected resource metadata + const resourceMetadata = await fetchProtectedResourceMetadata(resourceMetadataUrl); + + // Step 3: Select first authorization server (TODO: implement proper selection logic) + const issuer = resourceMetadata.authorization_servers[0]; + + if (!issuer) { + throw new Error('No authorization servers found in resource metadata'); + } + + // Step 4: Determine resource URL (canonical URI of MCP server) + const resource = resourceMetadata.resource || new URL(serverUrl).origin; + + // Step 5: Extract supported scopes from resource metadata + const scopes = resourceMetadata.scopes_supported as string[] | undefined; + + console.log(`Auto-detected OAuth configuration:`); + console.log(` Issuer: ${issuer}`); + console.log(` Resource: ${resource}`); + if (scopes && scopes.length > 0) { + console.log(` Scopes: ${scopes.join(', ')}`); + } + + return { issuer, resource, scopes }; + } catch (error) { + console.error('Failed to auto-detect OAuth configuration:', error); + return null; + } +}; + +/** + * Perform OAuth 2.0 issuer discovery to get authorization server metadata + */ +export const discoverIssuer = async ( + issuerUrl: string, + clientId: string = 'mcphub-temp', + clientSecret?: string, +): Promise => { + try { + console.log(`Discovering OAuth issuer: ${issuerUrl}`); + const server = new URL(issuerUrl); + + const clientAuth = clientSecret ? client.ClientSecretPost(clientSecret) : client.None(); + + const config = await client.discovery(server, clientId, undefined, clientAuth); + console.log(`Successfully discovered OAuth issuer: ${issuerUrl}`); + return config; + } catch (error) { + console.error(`Failed to discover OAuth issuer ${issuerUrl}:`, error); + throw new Error( + `OAuth issuer discovery failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +}; + +/** + * Register a new OAuth client dynamically using RFC7591 + * Can be called with auto-detected configuration from 401 response + */ +export const registerClient = async ( + serverName: string, + serverConfig: ServerConfig, + autoDetectedIssuer?: string, + autoDetectedScopes?: string[], +): Promise => { + // Check if we already have a registered client for this server + const cached = registeredClients.get(serverName); + if (cached && (!cached.expiresAt || cached.expiresAt > Date.now())) { + console.log(`Using cached OAuth client for server: ${serverName}`); + return cached; + } + + const dynamicConfig = serverConfig.oauth?.dynamicRegistration; + + try { + let serverUrl: URL; + + // Step 1: Determine the authorization server URL + // Priority: autoDetectedIssuer > configured issuer > registration endpoint + const issuerUrl = autoDetectedIssuer || dynamicConfig?.issuer; + + if (issuerUrl) { + serverUrl = new URL(issuerUrl); + } else if (dynamicConfig?.registrationEndpoint) { + // Extract server URL from registration endpoint + const regUrl = new URL(dynamicConfig.registrationEndpoint); + serverUrl = new URL(`${regUrl.protocol}//${regUrl.host}`); + } else { + throw new Error( + `Cannot register OAuth client: no issuer URL available. Either provide 'issuer' in configuration or ensure server returns proper 401 with WWW-Authenticate header.`, + ); + } + + // Step 2: Prepare client metadata for registration + const metadata = dynamicConfig?.metadata || {}; + + // Determine scopes: priority is metadata.scope > autoDetectedScopes > configured scopes > 'openid' + let scopeValue: string; + if (metadata.scope) { + scopeValue = metadata.scope; + } else if (autoDetectedScopes && autoDetectedScopes.length > 0) { + scopeValue = autoDetectedScopes.join(' '); + } else if (serverConfig.oauth?.scopes) { + scopeValue = serverConfig.oauth.scopes.join(' '); + } else { + scopeValue = 'openid'; + } + + const clientMetadata: Partial = { + client_name: metadata.client_name || `MCPHub - ${serverName}`, + redirect_uris: metadata.redirect_uris || ['http://localhost:3000/oauth/callback'], + grant_types: metadata.grant_types || ['authorization_code', 'refresh_token'], + response_types: metadata.response_types || ['code'], + token_endpoint_auth_method: metadata.token_endpoint_auth_method || 'client_secret_post', + scope: scopeValue, + ...metadata, // Include any additional custom metadata + }; + + console.log(`Registering OAuth client for server: ${serverName}`); + console.log(`Server URL: ${serverUrl}`); + console.log(`Client metadata:`, JSON.stringify(clientMetadata, null, 2)); + + // Step 3: Perform dynamic client registration + const clientAuth = dynamicConfig?.initialAccessToken + ? client.ClientSecretPost(dynamicConfig.initialAccessToken) + : client.None(); + + const config = await client.dynamicClientRegistration(serverUrl, clientMetadata, clientAuth); + + console.log(`Successfully registered OAuth client for server: ${serverName}`); + + // Extract client ID from the configuration + const clientId = (config as any).client_id || (config as any).clientId; + console.log(`Client ID: ${clientId}`); + + // Step 4: Store registered client information + const clientInfo: RegisteredClientInfo = { + config, + clientId, + clientSecret: (config as any).client_secret, // Access client secret if available + registrationAccessToken: (config as any).registrationAccessToken, + registrationClientUri: (config as any).registrationClientUri, + expiresAt: (config as any).client_secret_expires_at + ? (config as any).client_secret_expires_at * 1000 + : undefined, + metadata: config, + }; + + // Cache the registered client + registeredClients.set(serverName, clientInfo); + + // Persist the client credentials and scopes to configuration + const persistedConfig = await persistClientCredentials(serverName, { + clientId, + clientSecret: clientInfo.clientSecret, + scopes: autoDetectedScopes, + authorizationEndpoint: clientInfo.config.serverMetadata().authorization_endpoint, + tokenEndpoint: clientInfo.config.serverMetadata().token_endpoint, + }); + + if (persistedConfig) { + serverConfig.oauth = { + ...(serverConfig.oauth || {}), + ...persistedConfig.oauth, + }; + } + + return clientInfo; + } catch (error) { + console.error(`Failed to register OAuth client for server ${serverName}:`, error); + throw error; + } +}; + +/** + * Get authorization URL for user authorization (OAuth 2.0 authorization code flow) + */ +export const getAuthorizationUrl = async ( + serverName: string, + serverConfig: ServerConfig, + clientInfo: RegisteredClientInfo, + redirectUri: string, + state: string, + codeVerifier: string, +): Promise => { + try { + // Generate code challenge for PKCE (required by MCP spec) + const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); + + // Build authorization parameters + const params: Record = { + redirect_uri: redirectUri, + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + scope: serverConfig.oauth?.scopes?.join(' ') || 'openid', + }; + + // Add resource parameter for MCP (RFC8707) + if (serverConfig.oauth?.resource) { + params.resource = serverConfig.oauth.resource; + } + + const authUrl = client.buildAuthorizationUrl(clientInfo.config, params); + return authUrl.toString(); + } catch (error) { + console.error(`Failed to generate authorization URL for server ${serverName}:`, error); + throw error; + } +}; + +/** + * Exchange authorization code for access token + */ +export const exchangeCodeForToken = async ( + serverName: string, + serverConfig: ServerConfig, + clientInfo: RegisteredClientInfo, + currentUrl: string, + codeVerifier: string, +): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number }> => { + try { + console.log(`Exchanging authorization code for access token for server: ${serverName}`); + + // Prepare token endpoint parameters + const tokenParams: Record = { + code_verifier: codeVerifier, + }; + + // Add resource parameter for MCP (RFC8707) + if (serverConfig.oauth?.resource) { + tokenParams.resource = serverConfig.oauth.resource; + } + + const tokens = await client.authorizationCodeGrant( + clientInfo.config, + new URL(currentUrl), + { expectedState: undefined }, // State is already validated + tokenParams, + ); + + console.log(`Successfully obtained access token for server: ${serverName}`); + + await persistTokens(serverName, { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? undefined, + }); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + }; + } catch (error) { + console.error(`Failed to exchange code for token for server ${serverName}:`, error); + throw error; + } +}; + +/** + * Refresh access token using refresh token + */ +export const refreshAccessToken = async ( + serverName: string, + serverConfig: ServerConfig, + clientInfo: RegisteredClientInfo, + refreshToken: string, +): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number }> => { + try { + console.log(`Refreshing access token for server: ${serverName}`); + + // Prepare refresh token parameters + const params: Record = {}; + + // Add resource parameter for MCP (RFC8707) + if (serverConfig.oauth?.resource) { + params.resource = serverConfig.oauth.resource; + } + + const tokens = await client.refreshTokenGrant(clientInfo.config, refreshToken, params); + + console.log(`Successfully refreshed access token for server: ${serverName}`); + + await persistTokens(serverName, { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? undefined, + }); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + }; + } catch (error) { + console.error(`Failed to refresh access token for server ${serverName}:`, error); + throw error; + } +}; + +/** + * Generate PKCE code verifier + */ +export const generateCodeVerifier = (): string => { + return client.randomPKCECodeVerifier(); +}; + +/** + * Calculate PKCE code challenge from verifier + */ +export const calculateCodeChallenge = async (codeVerifier: string): Promise => { + return client.calculatePKCECodeChallenge(codeVerifier); +}; + +/** + * Get registered client info from cache + */ +export const getRegisteredClient = (serverName: string): RegisteredClientInfo | undefined => { + return registeredClients.get(serverName); +}; + +/** + * Initialize OAuth for a server (performs registration if needed) + * Now supports auto-detection via 401 responses with WWW-Authenticate header + * + * @param serverName - Name of the server + * @param serverConfig - Server configuration + * @param autoDetectedIssuer - Optional issuer URL from auto-detection + * @param autoDetectedScopes - Optional scopes from auto-detection + * @returns RegisteredClientInfo or null + */ +export const initializeOAuthForServer = async ( + serverName: string, + serverConfig: ServerConfig, + autoDetectedIssuer?: string, + autoDetectedScopes?: string[], +): Promise => { + if (!serverConfig.oauth) { + return null; + } + + // Check if dynamic registration should be attempted + const shouldAttemptRegistration = + autoDetectedIssuer || // Auto-detected from 401 response + serverConfig.oauth.dynamicRegistration?.enabled === true || // Explicitly enabled + (serverConfig.oauth.dynamicRegistration && !serverConfig.oauth.clientId); // Configured but no static client + + if (shouldAttemptRegistration) { + try { + // Perform dynamic client registration + const clientInfo = await registerClient( + serverName, + serverConfig, + autoDetectedIssuer, + autoDetectedScopes, + ); + return clientInfo; + } catch (error) { + console.error(`Failed to initialize OAuth for server ${serverName}:`, error); + // If auto-detection failed, don't throw - allow fallback to static config + if (!autoDetectedIssuer) { + throw error; + } + } + } + + // Static client configuration - create Configuration from static values + if (serverConfig.oauth.clientId) { + // Try to fetch and store scopes if not already configured + if (!serverConfig.oauth.scopes && serverConfig.url) { + try { + const fetchedScopes = await fetchScopesFromServer(serverConfig.url); + if (fetchedScopes && fetchedScopes.length > 0) { + await mutateOAuthSettings(serverName, ({ oauth }) => { + oauth.scopes = fetchedScopes; + }); + + if (!serverConfig.oauth) { + serverConfig.oauth = {}; + } + serverConfig.oauth.scopes = fetchedScopes; + console.log(`Stored fetched scopes for ${serverName}: ${fetchedScopes.join(', ')}`); + } + } catch (error) { + console.log(`Failed to fetch scopes for ${serverName}, will use defaults`); + } + } + + // For static config, we need the authorization server URL + let serverUrl: URL; + + if (serverConfig.oauth.authorizationEndpoint) { + const authUrl = new URL(serverConfig.oauth.authorizationEndpoint!); + serverUrl = new URL(`${authUrl.protocol}//${authUrl.host}`); + } else if (serverConfig.oauth.tokenEndpoint) { + const tokenUrl = new URL(serverConfig.oauth.tokenEndpoint!); + serverUrl = new URL(`${tokenUrl.protocol}//${tokenUrl.host}`); + } else { + console.warn(`Server ${serverName} has static OAuth config but missing endpoints`); + return null; + } + + try { + // Discover the server configuration + const clientAuth = serverConfig.oauth.clientSecret + ? client.ClientSecretPost(serverConfig.oauth.clientSecret) + : client.None(); + + const config = await client.discovery( + serverUrl, + serverConfig.oauth.clientId!, + undefined, + clientAuth, + ); + + const clientInfo: RegisteredClientInfo = { + config, + clientId: serverConfig.oauth.clientId!, + clientSecret: serverConfig.oauth.clientSecret, + metadata: {}, + }; + + registeredClients.set(serverName, clientInfo); + return clientInfo; + } catch (error) { + console.error(`Failed to discover OAuth server for ${serverName}:`, error); + return null; + } + } + + return null; +}; diff --git a/src/services/oauthService.ts b/src/services/oauthService.ts new file mode 100644 index 0000000..ed539b5 --- /dev/null +++ b/src/services/oauthService.ts @@ -0,0 +1,271 @@ +import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js'; +import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'; +import { RequestHandler } from 'express'; +import { loadSettings } from '../config/index.js'; +import { initializeOAuthForServer, refreshAccessToken } from './oauthClientRegistration.js'; + +// Re-export for external use +export { + getRegisteredClient, + getAuthorizationUrl, + exchangeCodeForToken, + generateCodeVerifier, + calculateCodeChallenge, + autoDetectOAuthConfig, + parseWWWAuthenticateHeader, + fetchProtectedResourceMetadata, +} from './oauthClientRegistration.js'; + +let oauthProvider: ProxyOAuthServerProvider | null = null; +let oauthRouter: RequestHandler | null = null; + +/** + * Initialize OAuth provider from system configuration + */ +export const initOAuthProvider = (): void => { + const settings = loadSettings(); + const oauthConfig = settings.systemConfig?.oauth; + + if (!oauthConfig || !oauthConfig.enabled) { + console.log('OAuth provider is disabled or not configured'); + return; + } + + try { + // Create proxy OAuth provider + oauthProvider = new ProxyOAuthServerProvider({ + endpoints: { + authorizationUrl: oauthConfig.endpoints.authorizationUrl, + tokenUrl: oauthConfig.endpoints.tokenUrl, + revocationUrl: oauthConfig.endpoints.revocationUrl, + }, + verifyAccessToken: async (token: string) => { + // If a verification endpoint is configured, use it + if (oauthConfig.verifyAccessToken?.endpoint) { + const response = await fetch(oauthConfig.verifyAccessToken.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...oauthConfig.verifyAccessToken.headers, + }, + body: JSON.stringify({ token }), + }); + + if (!response.ok) { + throw new Error(`Token verification failed: ${response.statusText}`); + } + + const result = await response.json(); + return { + token, + clientId: result.client_id || result.clientId || 'unknown', + scopes: result.scopes || result.scope?.split(' ') || [], + }; + } + + // Default verification - just extract basic info from token + // In production, you should decode/verify JWT or call an introspection endpoint + return { + token, + clientId: 'default', + scopes: oauthConfig.scopesSupported || [], + }; + }, + getClient: async (clientId: string) => { + // Find client in configuration + const client = oauthConfig.clients?.find((c) => c.client_id === clientId); + + if (!client) { + return undefined; + } + + return { + client_id: client.client_id, + redirect_uris: client.redirect_uris, + }; + }, + }); + + // Create OAuth router + const issuerUrl = new URL(oauthConfig.issuerUrl); + const baseUrl = oauthConfig.baseUrl ? new URL(oauthConfig.baseUrl) : issuerUrl; + + oauthRouter = mcpAuthRouter({ + provider: oauthProvider, + issuerUrl, + baseUrl, + serviceDocumentationUrl: oauthConfig.serviceDocumentationUrl + ? new URL(oauthConfig.serviceDocumentationUrl) + : undefined, + scopesSupported: oauthConfig.scopesSupported, + }); + + console.log('OAuth provider initialized successfully'); + console.log(`OAuth issuer URL: ${issuerUrl.origin}`); + // Only log endpoint URLs, not full config which might contain sensitive data + console.log( + 'OAuth endpoints configured: authorization, token' + + (oauthConfig.endpoints.revocationUrl ? ', revocation' : ''), + ); + } catch (error) { + console.error('Failed to initialize OAuth provider:', error); + oauthProvider = null; + oauthRouter = null; + } +}; + +/** + * Get the OAuth router if available + */ +export const getOAuthRouter = (): RequestHandler | null => { + return oauthRouter; +}; + +/** + * Get the OAuth provider if available + */ +export const getOAuthProvider = (): ProxyOAuthServerProvider | null => { + return oauthProvider; +}; + +/** + * Check if OAuth is enabled + */ +export const isOAuthEnabled = (): boolean => { + return oauthProvider !== null && oauthRouter !== null; +}; + +/** + * Get OAuth access token for a server if configured + * Handles both static tokens and dynamic OAuth flows with automatic token refresh + */ +export const getServerOAuthToken = async (serverName: string): Promise => { + const settings = loadSettings(); + const serverConfig = settings.mcpServers[serverName]; + + if (!serverConfig?.oauth) { + return undefined; + } + + // If a pre-configured access token exists, use it + if (serverConfig.oauth.accessToken) { + // TODO: In a production system, check if token is expired and refresh if needed + // For now, just return the configured token + return serverConfig.oauth.accessToken; + } + + // If dynamic registration is enabled, initialize OAuth and get token + if (serverConfig.oauth.dynamicRegistration?.enabled) { + try { + // Initialize OAuth for this server (registers client if needed) + const clientInfo = await initializeOAuthForServer(serverName, serverConfig); + + if (!clientInfo) { + console.warn(`Failed to initialize OAuth for server: ${serverName}`); + return undefined; + } + + // If we have a refresh token, try to get a new access token + if (serverConfig.oauth.refreshToken) { + try { + const tokens = await refreshAccessToken( + serverName, + serverConfig, + clientInfo, + serverConfig.oauth.refreshToken, + ); + return tokens.accessToken; + } catch (error) { + console.error(`Failed to refresh token for server ${serverName}:`, error); + // Token refresh failed - user needs to re-authorize + // In a production system, you would trigger a new authorization flow here + return undefined; + } + } + + // No access token and no refresh token available + // User needs to go through the authorization flow + // This would typically be triggered by an API endpoint that initiates the OAuth flow + console.log(`Server ${serverName} requires user authorization via OAuth flow`); + return undefined; + } catch (error) { + console.error(`Failed to get OAuth token for server ${serverName}:`, error); + return undefined; + } + } + + // Static client configuration - check for existing token + if (serverConfig.oauth.clientId && serverConfig.oauth.accessToken) { + return serverConfig.oauth.accessToken; + } + + return undefined; +}; + +/** + * Add OAuth authorization header to request headers if token is available + */ +export const addOAuthHeader = async ( + serverName: string, + headers: Record, +): Promise> => { + const token = await getServerOAuthToken(serverName); + + if (token) { + return { + ...headers, + Authorization: `Bearer ${token}`, + }; + } + + return headers; +}; + +/** + * Initialize OAuth for all configured servers with explicit dynamic registration enabled + * Servers without explicit configuration will be registered on-demand when receiving 401 + * Call this at application startup to pre-register known OAuth servers + */ +export const initializeAllOAuthClients = async (): Promise => { + const settings = loadSettings(); + + console.log('Initializing OAuth clients for explicitly configured servers...'); + + const serverNames = Object.keys(settings.mcpServers); + const registrationPromises: Promise[] = []; + + for (const serverName of serverNames) { + const serverConfig = settings.mcpServers[serverName]; + + // Only initialize servers with explicitly enabled dynamic registration + // Others will be auto-detected and registered on first 401 response + if (serverConfig.oauth?.dynamicRegistration?.enabled === true) { + registrationPromises.push( + initializeOAuthForServer(serverName, serverConfig) + .then((clientInfo) => { + if (clientInfo) { + console.log(`✓ OAuth client pre-registered for server: ${serverName}`); + } else { + console.warn(`✗ Failed to pre-register OAuth client for server: ${serverName}`); + } + }) + .catch((error) => { + console.error( + `✗ Error pre-registering OAuth client for server ${serverName}:`, + error.message, + ); + }), + ); + } + } + + // Wait for all registrations to complete + if (registrationPromises.length > 0) { + await Promise.all(registrationPromises); + console.log( + `OAuth client pre-registration completed for ${registrationPromises.length} server(s)`, + ); + } else { + console.log('No servers configured for pre-registration (will auto-detect on 401 responses)'); + } +}; diff --git a/src/services/oauthSettingsStore.ts b/src/services/oauthSettingsStore.ts new file mode 100644 index 0000000..cccdd40 --- /dev/null +++ b/src/services/oauthSettingsStore.ts @@ -0,0 +1,158 @@ +import { loadSettings, saveSettings } from '../config/index.js'; +import { McpSettings, ServerConfig } from '../types/index.js'; + +type OAuthConfig = NonNullable; +export type ServerConfigWithOAuth = ServerConfig & { oauth: OAuthConfig }; + +export interface OAuthSettingsContext { + settings: McpSettings; + serverConfig: ServerConfig; + oauth: OAuthConfig; +} + +/** + * Load the latest server configuration from disk. + */ +export const loadServerConfig = (serverName: string): ServerConfig | undefined => { + const settings = loadSettings(); + return settings.mcpServers?.[serverName]; +}; + +/** + * Mutate OAuth configuration for a server and persist the updated settings. + * The mutator receives the shared settings object to allow related updates when needed. + */ +export const mutateOAuthSettings = async ( + serverName: string, + mutator: (context: OAuthSettingsContext) => void, +): Promise => { + const settings = loadSettings(); + const serverConfig = settings.mcpServers?.[serverName]; + + if (!serverConfig) { + console.warn(`Server ${serverName} not found while updating OAuth settings`); + return undefined; + } + + if (!serverConfig.oauth) { + serverConfig.oauth = {}; + } + + const context: OAuthSettingsContext = { + settings, + serverConfig, + oauth: serverConfig.oauth, + }; + + mutator(context); + + const saved = saveSettings(settings); + if (!saved) { + throw new Error(`Failed to persist OAuth settings for server ${serverName}`); + } + + return context.serverConfig as ServerConfigWithOAuth; +}; + +export const persistClientCredentials = async ( + serverName: string, + credentials: { + clientId: string; + clientSecret?: string; + scopes?: string[]; + authorizationEndpoint?: string; + tokenEndpoint?: string; + }, +): Promise => { + const updated = await mutateOAuthSettings(serverName, ({ oauth }) => { + oauth.clientId = credentials.clientId; + oauth.clientSecret = credentials.clientSecret; + + if (credentials.scopes && credentials.scopes.length > 0) { + oauth.scopes = credentials.scopes; + } + if (credentials.authorizationEndpoint) { + oauth.authorizationEndpoint = credentials.authorizationEndpoint; + } + if (credentials.tokenEndpoint) { + oauth.tokenEndpoint = credentials.tokenEndpoint; + } + }); + + console.log(`Persisted OAuth client credentials for server: ${serverName}`); + if (credentials.scopes && credentials.scopes.length > 0) { + console.log(`Stored OAuth scopes for ${serverName}: ${credentials.scopes.join(', ')}`); + } + + return updated; +}; + +/** + * Persist OAuth tokens and optionally replace the stored refresh token. + */ +export const persistTokens = async ( + serverName: string, + tokens: { + accessToken: string; + refreshToken?: string | null; + clearPendingAuthorization?: boolean; + }, +): Promise => { + return mutateOAuthSettings(serverName, ({ oauth }) => { + oauth.accessToken = tokens.accessToken; + + if (tokens.refreshToken !== undefined) { + if (tokens.refreshToken) { + oauth.refreshToken = tokens.refreshToken; + } else { + delete oauth.refreshToken; + } + } + + if (tokens.clearPendingAuthorization && oauth.pendingAuthorization) { + delete oauth.pendingAuthorization; + } + }); +}; + +/** + * Update or create a pending authorization record. + */ +export const updatePendingAuthorization = async ( + serverName: string, + pending: Partial>, +): Promise => { + return mutateOAuthSettings(serverName, ({ oauth }) => { + oauth.pendingAuthorization = { + ...(oauth.pendingAuthorization || {}), + ...pending, + createdAt: pending.createdAt ?? Date.now(), + }; + }); +}; + +/** + * Clear cached OAuth data using shared helpers. + */ +export const clearOAuthData = async ( + serverName: string, + scope: 'all' | 'client' | 'tokens' | 'verifier', +): Promise => { + return mutateOAuthSettings(serverName, ({ oauth }) => { + if (scope === 'tokens' || scope === 'all') { + delete oauth.accessToken; + delete oauth.refreshToken; + } + + if (scope === 'client' || scope === 'all') { + delete oauth.clientId; + delete oauth.clientSecret; + } + + if (scope === 'verifier' || scope === 'all') { + if (oauth.pendingAuthorization) { + delete oauth.pendingAuthorization; + } + } + }); +}; diff --git a/src/types/index.ts b/src/types/index.ts index f21bbb0..44baf23 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -124,6 +124,31 @@ export interface MCPRouterCallToolResponse { isError: boolean; } +// OAuth Provider Configuration for MCP Authorization Server +export interface OAuthProviderConfig { + enabled?: boolean; // Enable/disable OAuth provider + issuerUrl: string; // Authorization server's issuer identifier (e.g., 'http://auth.external.com') + baseUrl?: string; // Base URL for the authorization server metadata endpoints (defaults to issuerUrl) + serviceDocumentationUrl?: string; // URL for human-readable OAuth documentation + scopesSupported?: string[]; // List of OAuth scopes supported + endpoints: { + authorizationUrl: string; // External OAuth authorization endpoint + tokenUrl: string; // External OAuth token endpoint + revocationUrl?: string; // External OAuth revocation endpoint (optional) + }; + // Token verification function details + verifyAccessToken?: { + endpoint?: string; // Optional: External endpoint to verify access tokens + headers?: Record; // Optional: Headers for token verification requests + }; + // Client management + clients?: Array<{ + client_id: string; // Client identifier + redirect_uris: string[]; // Allowed redirect URIs for this client + scopes?: string[]; // Scopes this client can request + }>; +} + export interface SystemConfig { routing?: { enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled @@ -145,6 +170,7 @@ export interface SystemConfig { baseUrl?: string; // Base URL for MCPRouter API (default: https://api.mcprouter.to/v1) }; nameSeparator?: string; // Separator used between server name and tool/prompt name (default: '-') + oauth?: OAuthProviderConfig; // OAuth provider configuration for upstream MCP servers } export interface UserConfig { @@ -181,6 +207,55 @@ export interface ServerConfig { tools?: Record; // Tool-specific configurations with enable/disable state and custom descriptions prompts?: Record; // Prompt-specific configurations with enable/disable state and custom descriptions options?: Partial>; // MCP request options configuration + // OAuth authentication for upstream MCP servers + oauth?: { + // Static client configuration (traditional OAuth flow) + clientId?: string; // OAuth client ID + clientSecret?: string; // OAuth client secret + scopes?: string[]; // Required OAuth scopes + accessToken?: string; // Pre-obtained access token (if available) + refreshToken?: string; // Refresh token for renewing access + + // Dynamic client registration (RFC7591) + // If not explicitly configured, will auto-detect via WWW-Authenticate header on 401 responses + dynamicRegistration?: { + enabled?: boolean; // Enable/disable dynamic registration (default: auto-detect on 401) + issuer?: string; // OAuth issuer URL for discovery (e.g., 'https://auth.example.com') + registrationEndpoint?: string; // Direct registration endpoint URL (if discovery is not used) + metadata?: { + // Client metadata for registration (RFC7591 section 2) + client_name?: string; // Human-readable client name + client_uri?: string; // URL of client's home page + logo_uri?: string; // URL of client's logo + scope?: string; // Space-separated list of scope values + redirect_uris?: string[]; // Array of redirect URIs + grant_types?: string[]; // Array of OAuth 2.0 grant types (e.g., ['authorization_code', 'refresh_token']) + response_types?: string[]; // Array of OAuth 2.0 response types (e.g., ['code']) + token_endpoint_auth_method?: string; // Token endpoint authentication method (e.g., 'client_secret_basic', 'none') + contacts?: string[]; // Array of contact email addresses + software_id?: string; // Unique identifier for the client software + software_version?: string; // Version of the client software + [key: string]: any; // Additional metadata fields + }; + // Optional: Initial access token for protected registration endpoints + initialAccessToken?: string; + }; + + // MCP resource parameter (RFC8707) - the canonical URI of the MCP server + resource?: string; // e.g., 'https://mcp.example.com/mcp' + + // Authorization endpoint for user authorization (for authorization code flow) + authorizationEndpoint?: string; + // Token endpoint for exchanging authorization codes for tokens + tokenEndpoint?: string; + // Pending OAuth session metadata for PKCE/state recovery between restarts + pendingAuthorization?: { + authorizationUrl?: string; + state?: string; + codeVerifier?: string; + createdAt?: number; + }; + }; // OpenAPI specific configuration openapi?: { url?: string; // OpenAPI specification URL @@ -227,7 +302,7 @@ export interface OpenAPISecurityConfig { export interface ServerInfo { name: string; // Unique name of the server owner?: string; // Owner of the server, defaults to 'admin' user - status: 'connected' | 'connecting' | 'disconnected'; // Current connection status + status: 'connected' | 'connecting' | 'disconnected' | 'oauth_required'; // Current connection status error: string | null; // Error message if any tools: Tool[]; // List of tools available on the server prompts: Prompt[]; // List of prompts available on the server @@ -239,6 +314,12 @@ export interface ServerInfo { enabled?: boolean; // Flag to indicate if the server is enabled keepAliveIntervalId?: NodeJS.Timeout; // Timer ID for keep-alive ping interval config?: ServerConfig; // Reference to the original server configuration for OpenAPI passthrough headers + oauth?: { + // OAuth authorization state + authorizationUrl?: string; // OAuth authorization URL for user to visit + state?: string; // OAuth state parameter for CSRF protection + codeVerifier?: string; // PKCE code verifier + }; } // Details about a tool available on the server diff --git a/tests/integration/sse-service-real-client.test.ts b/tests/integration/sse-service-real-client.test.ts index c78f0a7..87a2260 100644 --- a/tests/integration/sse-service-real-client.test.ts +++ b/tests/integration/sse-service-real-client.test.ts @@ -1,3 +1,17 @@ +// Mock openid-client before importing services +jest.mock('openid-client', () => ({ + discovery: jest.fn(), + dynamicClientRegistration: jest.fn(), + ClientSecretPost: jest.fn(() => jest.fn()), + ClientSecretBasic: jest.fn(() => jest.fn()), + None: jest.fn(() => jest.fn()), + calculatePKCECodeChallenge: jest.fn(), + randomPKCECodeVerifier: jest.fn(), + buildAuthorizationUrl: jest.fn(), + authorizationCodeGrant: jest.fn(), + refreshTokenGrant: jest.fn(), +})); + import { Server } from 'http'; import { AppServer } from '../../src/server.js'; import { TestServerHelper } from '../utils/testServerHelper.js'; diff --git a/tests/services/oauthService.test.ts b/tests/services/oauthService.test.ts new file mode 100644 index 0000000..397ad7f --- /dev/null +++ b/tests/services/oauthService.test.ts @@ -0,0 +1,207 @@ +// Mock openid-client before importing services +jest.mock('openid-client', () => ({ + discovery: jest.fn(), + dynamicClientRegistration: jest.fn(), + ClientSecretPost: jest.fn(() => jest.fn()), + ClientSecretBasic: jest.fn(() => jest.fn()), + None: jest.fn(() => jest.fn()), + calculatePKCECodeChallenge: jest.fn(), + randomPKCECodeVerifier: jest.fn(), + buildAuthorizationUrl: jest.fn(), + authorizationCodeGrant: jest.fn(), + refreshTokenGrant: jest.fn(), +})); + +import { + initOAuthProvider, + isOAuthEnabled, + getServerOAuthToken, + addOAuthHeader, +} from '../../src/services/oauthService.js'; +import * as config from '../../src/config/index.js'; + +// Mock the config module +jest.mock('../../src/config/index.js', () => ({ + loadSettings: jest.fn(), +})); + +describe('OAuth Service', () => { + const mockLoadSettings = config.loadSettings as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('initOAuthProvider', () => { + it('should not initialize OAuth when disabled', () => { + mockLoadSettings.mockReturnValue({ + mcpServers: {}, + systemConfig: { + oauth: { + enabled: false, + issuerUrl: 'http://auth.example.com', + endpoints: { + authorizationUrl: 'http://auth.example.com/authorize', + tokenUrl: 'http://auth.example.com/token', + }, + }, + }, + }); + + initOAuthProvider(); + expect(isOAuthEnabled()).toBe(false); + }); + + it('should not initialize OAuth when not configured', () => { + mockLoadSettings.mockReturnValue({ + mcpServers: {}, + systemConfig: {}, + }); + + initOAuthProvider(); + expect(isOAuthEnabled()).toBe(false); + }); + + it('should attempt to initialize OAuth when enabled and properly configured', () => { + mockLoadSettings.mockReturnValue({ + mcpServers: {}, + systemConfig: { + oauth: { + enabled: true, + issuerUrl: 'http://auth.example.com', + endpoints: { + authorizationUrl: 'http://auth.example.com/authorize', + tokenUrl: 'http://auth.example.com/token', + }, + clients: [ + { + client_id: 'test-client', + redirect_uris: ['http://localhost:3000/callback'], + }, + ], + }, + }, + }); + + // In a test environment, the ProxyOAuthServerProvider may not fully initialize + // due to missing dependencies or network issues, which is expected + initOAuthProvider(); + // We just verify that the function doesn't throw an error + expect(mockLoadSettings).toHaveBeenCalled(); + }); + }); + + describe('getServerOAuthToken', () => { + it('should return undefined when server has no OAuth config', async () => { + mockLoadSettings.mockReturnValue({ + mcpServers: { + 'test-server': { + url: 'http://example.com', + }, + }, + }); + + const token = await getServerOAuthToken('test-server'); + expect(token).toBeUndefined(); + }); + + it('should return undefined when server has no access token', async () => { + mockLoadSettings.mockReturnValue({ + mcpServers: { + 'test-server': { + url: 'http://example.com', + oauth: { + clientId: 'test-client', + }, + }, + }, + }); + + const token = await getServerOAuthToken('test-server'); + expect(token).toBeUndefined(); + }); + + it('should return access token when configured', async () => { + mockLoadSettings.mockReturnValue({ + mcpServers: { + 'test-server': { + url: 'http://example.com', + oauth: { + clientId: 'test-client', + accessToken: 'test-access-token', + }, + }, + }, + }); + + const token = await getServerOAuthToken('test-server'); + expect(token).toBe('test-access-token'); + }); + }); + + describe('addOAuthHeader', () => { + it('should not modify headers when no OAuth token is configured', async () => { + mockLoadSettings.mockReturnValue({ + mcpServers: { + 'test-server': { + url: 'http://example.com', + }, + }, + }); + + const headers = { 'Content-Type': 'application/json' }; + const result = await addOAuthHeader('test-server', headers); + + expect(result).toEqual(headers); + expect(result.Authorization).toBeUndefined(); + }); + + it('should add Authorization header when OAuth token is configured', async () => { + mockLoadSettings.mockReturnValue({ + mcpServers: { + 'test-server': { + url: 'http://example.com', + oauth: { + clientId: 'test-client', + accessToken: 'test-access-token', + }, + }, + }, + }); + + const headers = { 'Content-Type': 'application/json' }; + const result = await addOAuthHeader('test-server', headers); + + expect(result).toEqual({ + 'Content-Type': 'application/json', + Authorization: 'Bearer test-access-token', + }); + }); + + it('should preserve existing headers when adding OAuth token', async () => { + mockLoadSettings.mockReturnValue({ + mcpServers: { + 'test-server': { + url: 'http://example.com', + oauth: { + clientId: 'test-client', + accessToken: 'test-access-token', + }, + }, + }, + }); + + const headers = { + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value', + }; + const result = await addOAuthHeader('test-server', headers); + + expect(result).toEqual({ + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value', + Authorization: 'Bearer test-access-token', + }); + }); + }); +}); diff --git a/tests/services/openApiGeneratorService.test.ts b/tests/services/openApiGeneratorService.test.ts index c82d0c0..36646e5 100644 --- a/tests/services/openApiGeneratorService.test.ts +++ b/tests/services/openApiGeneratorService.test.ts @@ -1,3 +1,17 @@ +// Mock openid-client before importing services +jest.mock('openid-client', () => ({ + discovery: jest.fn(), + dynamicClientRegistration: jest.fn(), + ClientSecretPost: jest.fn(() => jest.fn()), + ClientSecretBasic: jest.fn(() => jest.fn()), + None: jest.fn(() => jest.fn()), + calculatePKCECodeChallenge: jest.fn(), + randomPKCECodeVerifier: jest.fn(), + buildAuthorizationUrl: jest.fn(), + authorizationCodeGrant: jest.fn(), + refreshTokenGrant: jest.fn(), +})); + import { generateOpenAPISpec, getToolStats } from '../../src/services/openApiGeneratorService'; describe('OpenAPI Generator Service', () => {
+ {t('server.name')} + {t('server.status')} + {t('server.tools')} + {t('server.prompts')} + {t('server.enabled')}
- + + {server.status === 'oauth_required' && '🔐 '} {t(statusTranslations[server.status] || server.status)}