Compare commits

...

30 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
017e405c41 Address code review comments: remove redundant cleanup and clarify test intent
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-11-05 11:15:19 +00:00
copilot-swe-agent[bot]
8da0323326 Add BASE_PATH configuration documentation
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-11-05 11:11:22 +00:00
copilot-swe-agent[bot]
71e217fcc2 Fix BASE_PATH configuration breaking login in development mode
- Normalize BASE_PATH by removing trailing slashes in backend config
- Update Vite dev server proxy to dynamically support BASE_PATH from env
- Add integration tests for BASE_PATH functionality
- All tests pass, build successful

Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-11-05 11:07:27 +00:00
copilot-swe-agent[bot]
fa133e21b0 Initial plan 2025-11-05 10:45:45 +00:00
samanhappy
602b5cb80e fix: update GitHub repository links to point to the new repository (#423) 2025-11-03 17:04:49 +08:00
samanhappy
e63f045819 refactor: remove outdated references to MCP protocol and cloud deployment in documentation (#422) 2025-11-03 17:02:10 +08:00
Chengwei Guo
a4e4791b60 fix the deployment on kubernetes (#417) 2025-11-03 14:16:12 +08:00
samanhappy
01370ea959 Revert "Feat: Enhance package cache for stdio servers (#400)" (#418) 2025-11-03 13:35:24 +08:00
samanhappy
f5d66c1bb7 fix versions for react and react-dom (#414) 2025-11-02 23:02:25 +08:00
dependabot[bot]
9e59dd9fb0 chore(deps-dev): bump react and @types/react (#407)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 22:48:13 +08:00
dependabot[bot]
250487f042 chore(deps-dev): bump lucide-react from 0.486.0 to 0.552.0 (#408)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 22:45:01 +08:00
dependabot[bot]
da91708420 chore(deps): bump i18next from 25.5.0 to 25.6.0 (#409)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 22:44:42 +08:00
dependabot[bot]
576bba1f9e chore(deps): bump openai from 4.104.0 to 6.7.0 (#410)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 22:44:21 +08:00
dependabot[bot]
f4b83929a6 chore(deps): bump axios from 1.12.2 to 1.13.1 (#406)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 22:43:57 +08:00
Alptekin Gülcan
3825f389cd Feat: Add Turkish localization (tr) (#411) 2025-11-02 22:43:18 +08:00
samanhappy
44e0309fd4 Feat: Enhance package cache for stdio servers (#400) 2025-10-31 21:56:43 +08:00
Copilot
7e570a900a Fix: Convert form parameters to schema-defined types before MCP tool calls (#397)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-29 18:41:23 +08:00
Copilot
6268a02c0e Fix URL routing for MCP servers with slashes in names (#396)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-29 18:37:24 +08:00
samanhappy
695d663939 Fix display for null server author (#398) 2025-10-29 14:44:09 +08:00
samanhappy
d595e5d874 Fix support for nested smart group segments in MCP routing (#394) 2025-10-28 17:51:58 +08:00
Copilot
ff797b4ab9 Add group-scoped smart routing via $smart/{group} pattern (#388)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-26 20:51:55 +08:00
samanhappy
9105507722 Refactor: Clean up code formatting and improve readability across multiple files (#387) 2025-10-26 19:27:30 +08:00
Copilot
f79028ed64 Expand environment variables throughout mcp_settings.json configuration (#384)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-26 19:25:53 +08:00
Copilot
5ca5e2ad47 Add password security: default credential warning and strength validation (#386)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-26 19:22:51 +08:00
Copilot
2f7726b008 Add JSON import for MCP servers (#385)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-10-26 19:13:06 +08:00
Copilot
26b26a5fb1 Add OAuth support for upstream MCP servers (#381)
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-10-26 16:09:34 +08:00
Copilot
7dbd6c386e Fix: Environment variable expansion in headers for HTTP-based MCP transports (#380)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-23 19:18:50 +08:00
Copilot
c1fee91142 Fix Dependabot alert #18: Remove outdated package-lock.json causing axios vulnerability false positive (#379)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-22 15:16:24 +08:00
samanhappy
1130f6833e fix: use reconnect mechanism for sse tool calling error (#378) 2025-10-22 12:05:21 +08:00
dependabot[bot]
c3f1de8f5b chore(deps-dev): bump vite from 6.3.6 to 6.4.1 (#376)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-22 12:04:16 +08:00
78 changed files with 8221 additions and 14447 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
/node_modules
/.pnp
.pnp.js
package-lock.json
# production
dist

30
AGENTS.md Normal file
View File

@@ -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.

View File

@@ -19,6 +19,8 @@ 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.
- **Environment Variable Expansion**: Use environment variables anywhere in your configuration for secure credential management. See [Environment Variables Guide](docs/environment-variables.md).
- **Docker-Ready**: Deploy instantly with our containerized setup.
## 🔧 Quick Start
@@ -57,6 +59,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:
@@ -106,7 +147,11 @@ This endpoint provides a unified streamable HTTP interface for all your MCP serv
Smart Routing is MCPHub's intelligent tool discovery system that uses vector semantic search to automatically find the most relevant tools for any given task.
```
# Search across all servers
http://localhost:3000/mcp/$smart
# Search within a specific group
http://localhost:3000/mcp/$smart/{group}
```
**How it Works:**
@@ -115,6 +160,7 @@ http://localhost:3000/mcp/$smart
2. **Semantic Search**: User queries are converted to vectors and matched against tool embeddings using cosine similarity
3. **Intelligent Filtering**: Dynamic thresholds ensure relevant results without noise
4. **Precise Execution**: Found tools can be directly executed with proper parameter validation
5. **Group Scoping**: Optionally limit searches to servers within a specific group for focused results
**Setup Requirements:**
@@ -126,6 +172,23 @@ To enable Smart Routing, you need:
- OpenAI API key (or compatible embedding service)
- Enable Smart Routing in MCPHub settings
**Group-Scoped Smart Routing**:
You can combine Smart Routing with group filtering to search only within specific server groups:
```
# Search only within production servers
http://localhost:3000/mcp/$smart/production
# Search only within development servers
http://localhost:3000/mcp/$smart/development
```
This enables:
- **Focused Discovery**: Find tools only from relevant servers
- **Environment Isolation**: Separate tool discovery by environment (dev, staging, prod)
- **Team-Based Access**: Limit tool search to team-specific server groups
**Group-Specific Endpoints (Recommended)**:
![Group Management](assets/group.png)
@@ -164,7 +227,11 @@ http://localhost:3000/sse
For smart routing, use:
```
# Search across all servers
http://localhost:3000/sse/$smart
# Search within a specific group
http://localhost:3000/sse/$smart/{group}
```
For targeted access to specific server groups, use the group-based SSE endpoint:

View File

@@ -57,6 +57,45 @@ MCPHub 通过将多个 MCPModel 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 部署
**推荐**:挂载自定义配置:
@@ -106,7 +145,11 @@ http://localhost:3000/mcp
智能路由是 MCPHub 的智能工具发现系统,使用向量语义搜索自动为任何给定任务找到最相关的工具。
```
# 在所有服务器中搜索
http://localhost:3000/mcp/$smart
# 在特定分组中搜索
http://localhost:3000/mcp/$smart/{group}
```
**工作原理:**
@@ -115,6 +158,7 @@ http://localhost:3000/mcp/$smart
2. **语义搜索**:用户查询转换为向量并使用余弦相似度与工具嵌入匹配
3. **智能筛选**:动态阈值确保相关结果且无噪声
4. **精确执行**:找到的工具可以直接执行并进行适当的参数验证
5. **分组限定**:可选择将搜索限制在特定分组的服务器内以获得更精确的结果
**设置要求:**
@@ -126,6 +170,23 @@ http://localhost:3000/mcp/$smart
- OpenAI API 密钥(或兼容的嵌入服务)
- 在 MCPHub 设置中启用智能路由
**分组限定的智能路由**
您可以将智能路由与分组筛选结合使用,仅在特定服务器分组内搜索:
```
# 仅在生产服务器中搜索
http://localhost:3000/mcp/$smart/production
# 仅在开发服务器中搜索
http://localhost:3000/mcp/$smart/development
```
这样可以实现:
- **精准发现**:仅从相关服务器查找工具
- **环境隔离**:按环境(开发、测试、生产)分离工具发现
- **基于团队的访问**:将工具搜索限制在特定团队的服务器分组
**基于分组的 HTTP 端点(推荐)**
![分组](assets/group.zh.png)
要针对特定服务器分组进行访问,请使用基于分组的 HTTP 端点:
@@ -164,7 +225,11 @@ http://localhost:3000/sse
要启用智能路由,请使用:
```
# 在所有服务器中搜索
http://localhost:3000/sse/$smart
# 在特定分组中搜索
http://localhost:3000/sse/$smart/{group}
```
要针对特定服务器分组进行访问,请使用基于分组的 SSE 端点:

View File

@@ -0,0 +1,175 @@
# BASE_PATH Configuration Guide
## Overview
MCPHub supports running under a custom base path (e.g., `/mcphub/`) for scenarios where you need to deploy the application under a subdirectory or behind a reverse proxy.
## Configuration
### Setting BASE_PATH
Add the `BASE_PATH` environment variable to your `.env` file:
```bash
PORT=3000
NODE_ENV=development
BASE_PATH=/mcphub/
```
**Note:** Trailing slashes in BASE_PATH are automatically normalized (removed). Both `/mcphub/` and `/mcphub` will work and be normalized to `/mcphub`.
### In Production (Docker)
Set the environment variable when running the container:
```bash
docker run -e BASE_PATH=/mcphub/ -p 3000:3000 mcphub
```
### Behind a Reverse Proxy (nginx)
Example nginx configuration:
```nginx
location /mcphub/ {
proxy_pass http://localhost:3000/mcphub/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
```
## How It Works
### Backend Routes
All backend routes are automatically prefixed with BASE_PATH:
- **Without BASE_PATH:**
- Config: `http://localhost:3000/config`
- Auth: `http://localhost:3000/api/auth/login`
- Health: `http://localhost:3000/health`
- **With BASE_PATH="/mcphub":**
- Config: `http://localhost:3000/mcphub/config`
- Auth: `http://localhost:3000/mcphub/api/auth/login`
- Health: `http://localhost:3000/health` (global, no prefix)
### Frontend
The frontend automatically detects the BASE_PATH at runtime by calling the `/config` endpoint. All API calls are automatically prefixed.
### Development Mode
The Vite dev server proxy is automatically configured to support BASE_PATH:
1. Set `BASE_PATH` in your `.env` file
2. Start the dev server: `pnpm dev`
3. Access the application through Vite: `http://localhost:5173`
4. All API calls are proxied correctly with the BASE_PATH prefix
## Testing
You can test the BASE_PATH configuration with curl:
```bash
# Set BASE_PATH=/mcphub/ in .env file
# Test config endpoint
curl http://localhost:3000/mcphub/config
# Test login
curl -X POST http://localhost:3000/mcphub/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
```
## Troubleshooting
### Issue: Login fails with BASE_PATH set
**Solution:** Make sure you're using version 0.10.4 or later, which includes the fix for BASE_PATH in development mode.
### Issue: 404 errors on API endpoints
**Symptoms:**
- Login returns 404
- Config endpoint returns 404
- API calls fail with 404
**Solution:**
1. Verify BASE_PATH is set correctly in `.env` file
2. Restart the backend server to pick up the new configuration
3. Check that you're accessing the correct URL with the BASE_PATH prefix
### Issue: Vite proxy not working
**Solution:**
1. Ensure you have the latest version of `frontend/vite.config.ts`
2. Restart the frontend dev server
3. Verify the BASE_PATH is being loaded from the `.env` file in the project root
## Implementation Details
### Backend (src/config/index.ts)
```typescript
const normalizeBasePath = (path: string): string => {
if (!path) return '';
return path.replace(/\/+$/, '');
};
const defaultConfig = {
basePath: normalizeBasePath(process.env.BASE_PATH || ''),
// ...
};
```
### Frontend (frontend/vite.config.ts)
```typescript
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, path.resolve(__dirname, '..'), '');
let basePath = env.BASE_PATH || '';
basePath = basePath.replace(/\/+$/, '');
const proxyConfig: Record<string, any> = {};
const pathsToProxy = ['/api', '/config', '/public-config', '/health', '/oauth'];
pathsToProxy.forEach((path) => {
const proxyPath = basePath + path;
proxyConfig[proxyPath] = {
target: 'http://localhost:3000',
changeOrigin: true,
};
});
return {
server: {
proxy: proxyConfig,
},
};
});
```
### Frontend Runtime (frontend/src/utils/runtime.ts)
The frontend loads the BASE_PATH at runtime from the `/config` endpoint:
```typescript
export const loadRuntimeConfig = async (): Promise<RuntimeConfig> => {
// Tries different possible config paths
const response = await fetch('/config');
const data = await response.json();
return data.data; // Contains basePath, version, name
};
```
## Related Files
- `src/config/index.ts` - Backend BASE_PATH normalization
- `frontend/vite.config.ts` - Vite proxy configuration
- `frontend/src/utils/runtime.ts` - Frontend runtime config loading
- `tests/integration/base-path-routes.test.ts` - Integration tests

View File

@@ -78,7 +78,7 @@ git clone https://github.com/YOUR_USERNAME/mcphub.git
cd mcphub
# 2. Add upstream remote
git remote add upstream https://github.com/mcphub/mcphub.git
git remote add upstream https://github.com/samanhappy/mcphub.git
# 3. Install dependencies
pnpm install

View File

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

View File

@@ -0,0 +1,267 @@
# Environment Variable Expansion in mcp_settings.json
## Overview
MCPHub now supports comprehensive environment variable expansion throughout the entire `mcp_settings.json` configuration file. This allows you to externalize sensitive information and configuration values, making your setup more secure and flexible.
## Supported Formats
MCPHub supports two environment variable formats:
1. **${VAR}** - Standard format (recommended)
2. **$VAR** - Unix-style format (variable name must start with an uppercase letter or underscore, followed by uppercase letters, numbers, or underscores)
## What Can Be Expanded
Environment variables can now be used in **ANY** string value throughout your configuration:
- Server URLs
- Commands and arguments
- Headers
- Environment variables passed to child processes
- OpenAPI specifications and security configurations
- OAuth credentials
- System configuration values
- Any other string fields
## Examples
### 1. SSE/HTTP Server Configuration
```json
{
"mcpServers": {
"my-api-server": {
"type": "sse",
"url": "${MCP_SERVER_URL}",
"headers": {
"Authorization": "Bearer ${API_TOKEN}",
"X-Custom-Header": "${CUSTOM_VALUE}"
}
}
}
}
```
Environment variables:
```bash
export MCP_SERVER_URL="https://api.example.com/mcp"
export API_TOKEN="secret-token-123"
export CUSTOM_VALUE="my-custom-value"
```
### 2. Stdio Server Configuration
```json
{
"mcpServers": {
"my-python-server": {
"type": "stdio",
"command": "${PYTHON_PATH}",
"args": ["-m", "${MODULE_NAME}", "--api-key", "${API_KEY}"],
"env": {
"DATABASE_URL": "${DATABASE_URL}",
"DEBUG": "${DEBUG_MODE}"
}
}
}
}
```
Environment variables:
```bash
export PYTHON_PATH="/usr/bin/python3"
export MODULE_NAME="my_mcp_server"
export API_KEY="secret-api-key"
export DATABASE_URL="postgresql://localhost/mydb"
export DEBUG_MODE="true"
```
### 3. OpenAPI Server Configuration
```json
{
"mcpServers": {
"openapi-service": {
"type": "openapi",
"openapi": {
"url": "${OPENAPI_SPEC_URL}",
"security": {
"type": "apiKey",
"apiKey": {
"name": "X-API-Key",
"in": "header",
"value": "${OPENAPI_API_KEY}"
}
}
}
}
}
}
```
Environment variables:
```bash
export OPENAPI_SPEC_URL="https://api.example.com/openapi.json"
export OPENAPI_API_KEY="your-api-key-here"
```
### 4. OAuth Configuration
```json
{
"mcpServers": {
"oauth-server": {
"type": "sse",
"url": "${OAUTH_SERVER_URL}",
"oauth": {
"clientId": "${OAUTH_CLIENT_ID}",
"clientSecret": "${OAUTH_CLIENT_SECRET}",
"accessToken": "${OAUTH_ACCESS_TOKEN}"
}
}
}
}
```
Environment variables:
```bash
export OAUTH_SERVER_URL="https://oauth.example.com/mcp"
export OAUTH_CLIENT_ID="my-client-id"
export OAUTH_CLIENT_SECRET="my-client-secret"
export OAUTH_ACCESS_TOKEN="my-access-token"
```
### 5. System Configuration
```json
{
"systemConfig": {
"install": {
"pythonIndexUrl": "${PYTHON_INDEX_URL}",
"npmRegistry": "${NPM_REGISTRY}"
},
"mcpRouter": {
"apiKey": "${MCPROUTER_API_KEY}",
"referer": "${MCPROUTER_REFERER}"
}
}
}
```
Environment variables:
```bash
export PYTHON_INDEX_URL="https://pypi.tuna.tsinghua.edu.cn/simple"
export NPM_REGISTRY="https://registry.npmmirror.com"
export MCPROUTER_API_KEY="router-api-key"
export MCPROUTER_REFERER="https://myapp.com"
```
## Complete Example
See [examples/mcp_settings_with_env_vars.json](../examples/mcp_settings_with_env_vars.json) for a comprehensive example configuration using environment variables.
## Best Practices
### Security
1. **Never commit sensitive values to version control** - Use environment variables for all secrets
2. **Use .env files for local development** - MCPHub automatically loads `.env` files
3. **Use secure secret management in production** - Consider using Docker secrets, Kubernetes secrets, or cloud provider secret managers
### Organization
1. **Group related variables** - Use prefixes for related configuration (e.g., `API_`, `DB_`, `OAUTH_`)
2. **Document required variables** - Maintain a list of required environment variables in your README
3. **Provide example .env file** - Create a `.env.example` file with placeholder values
### Example .env File
```bash
# Server Configuration
MCP_SERVER_URL=https://api.example.com/mcp
API_TOKEN=your-api-token-here
# Python Server
PYTHON_PATH=/usr/bin/python3
MODULE_NAME=my_mcp_server
# Database
DATABASE_URL=postgresql://localhost/mydb
# OpenAPI
OPENAPI_SPEC_URL=https://api.example.com/openapi.json
OPENAPI_API_KEY=your-openapi-key
# OAuth
OAUTH_CLIENT_ID=your-client-id
OAUTH_CLIENT_SECRET=your-client-secret
OAUTH_ACCESS_TOKEN=your-access-token
```
## Docker Usage
When using Docker, pass environment variables using `-e` flag or `--env-file`:
```bash
# Using individual variables
docker run -e API_TOKEN=secret -e SERVER_URL=https://api.example.com mcphub
# Using env file
docker run --env-file .env mcphub
```
Or in docker-compose.yml:
```yaml
version: '3.8'
services:
mcphub:
image: mcphub
env_file:
- .env
environment:
- MCP_SERVER_URL=${MCP_SERVER_URL}
- API_TOKEN=${API_TOKEN}
```
## Troubleshooting
### Variable Not Expanding
If a variable is not expanding:
1. Check that the variable is set: `echo $VAR_NAME`
2. Verify the variable name matches exactly (case-sensitive)
3. Ensure the variable is exported: `export VAR_NAME=value`
4. Restart MCPHub after setting environment variables
### Empty Values
If an environment variable is not set, it will be replaced with an empty string. Make sure all required variables are set before starting MCPHub.
### Nested Variables
Environment variables in nested objects and arrays are fully supported:
```json
{
"nested": {
"deep": {
"value": "${MY_VAR}"
}
},
"array": ["${VAR1}", "${VAR2}"]
}
```
## Migration from Previous Version
If you were previously using environment variables only in headers, no changes are needed. The new implementation is backward compatible and simply extends support to all configuration fields.
## Technical Details
- Environment variables are expanded once when the configuration is loaded
- Expansion is recursive and handles nested objects and arrays
- Non-string values (booleans, numbers, null) are preserved as-is
- Empty string is used when an environment variable is not set

141
docs/features/oauth.mdx Normal file
View File

@@ -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. GitHubs MCP endpoint (`https://api.githubcopilot.com/mcp/`) is one example. To connect:
1. Create an OAuth App in the providers 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 servers 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.

View File

@@ -276,17 +276,92 @@ Access Smart Routing through the special `$smart` endpoint:
<Tabs>
<Tab title="HTTP MCP">
```
# Search across all servers
http://localhost:3000/mcp/$smart
# Search within a specific group
http://localhost:3000/mcp/$smart/{group}
```
</Tab>
<Tab title="SSE (Legacy)">
```
# Search across all servers
http://localhost:3000/sse/$smart
# Search within a specific group
http://localhost:3000/sse/$smart/{group}
```
</Tab>
</Tabs>
### Group-Scoped Smart Routing
Smart Routing now supports group-scoped searches, allowing you to limit tool discovery to servers within a specific group:
<AccordionGroup>
<Accordion title="Using Group-Scoped Smart Routing">
Connect your AI client to a group-specific Smart Routing endpoint:
```
http://localhost:3000/mcp/$smart/production
```
This endpoint will only search for tools within servers that belong to the "production" group.
**Benefits:**
- **Focused Results**: Only tools from relevant servers are returned
- **Better Performance**: Reduced search space for faster queries
- **Environment Isolation**: Keep development, staging, and production tools separate
- **Access Control**: Limit tool discovery based on user permissions
</Accordion>
<Accordion title="Example: Environment-Based Groups">
Create groups for different environments:
```bash
# Development environment
http://localhost:3000/mcp/$smart/development
# Staging environment
http://localhost:3000/mcp/$smart/staging
# Production environment
http://localhost:3000/mcp/$smart/production
```
Each endpoint will only return tools from servers in that specific environment group.
</Accordion>
<Accordion title="Example: Team-Based Groups">
Organize tools by team or department:
```bash
# Backend team tools
http://localhost:3000/mcp/$smart/backend-team
# Frontend team tools
http://localhost:3000/mcp/$smart/frontend-team
# DevOps team tools
http://localhost:3000/mcp/$smart/devops-team
```
This enables teams to have focused access to their relevant toolsets.
</Accordion>
<Accordion title="How It Works">
When using `$smart/{group}`:
1. The system identifies the specified group
2. Retrieves all servers belonging to that group
3. Filters the tool search to only those servers
4. Returns results scoped to the group's servers
If the group doesn't exist or has no servers, the search will return no results.
</Accordion>
</AccordionGroup>
{/* ### Basic Usage
Connect your AI client to the Smart Routing endpoint and make natural language requests:

View File

@@ -294,22 +294,47 @@ Optional for Smart Routing:
labels:
app: mcphub
spec:
initContainers:
- name: prepare-config
image: busybox:1.28
command:
[
"sh",
"-c",
"cp /config-ro/mcp_settings.json /etc/mcphub/mcp_settings.json",
]
volumeMounts:
- name: config
mountPath: /config-ro
readOnly: true
- name: app-storage
mountPath: /etc/mcphub
containers:
- name: mcphub
image: samanhappy/mcphub:latest
ports:
- containerPort: 3000
env:
- name: PORT
value: "3000"
volumeMounts:
- name: config
mountPath: /app/mcp_settings.json
subPath: mcp_settings.json
- name: mcphub
image: samanhappy/mcphub:latest
ports:
- containerPort: 3000
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
env:
- name: PORT
value: "3000"
- name: MCPHUB_SETTING_PATH
value: /etc/mcphub/mcp_settings.json
volumeMounts:
- name: app-storage
mountPath: /etc/mcphub
volumes:
- name: config
configMap:
name: mcphub-config
- name: config
configMap:
name: mcphub-config
- name: app-storage
emptyDir: {}
```
#### 3. Service

View File

@@ -48,7 +48,7 @@ docker --version
```bash
# 克隆主仓库
git clone https://github.com/mcphub/mcphub.git
git clone https://github.com/samanhappy/mcphub.git
cd mcphub
# 或者克隆您的 fork

View File

@@ -388,7 +388,7 @@ CMD ["node", "dist/index.js"]
````md
```bash
# 克隆 MCPHub 仓库
git clone https://github.com/mcphub/mcphub.git
git clone https://github.com/samanhappy/mcphub.git
cd mcphub
# 安装依赖
@@ -413,7 +413,7 @@ npm start
```bash
# 克隆 MCPHub 仓库
git clone https://github.com/mcphub/mcphub.git
git clone https://github.com/samanhappy/mcphub.git
cd mcphub
# 安装依赖
@@ -441,7 +441,7 @@ npm start
```powershell
# Windows PowerShell 安装步骤
# 克隆仓库
git clone https://github.com/mcphub/mcphub.git
git clone https://github.com/samanhappy/mcphub.git
Set-Location mcphub
# 安装 Node.js 依赖
@@ -458,7 +458,7 @@ npm run dev
```powershell
# Windows PowerShell 安装步骤
# 克隆仓库
git clone https://github.com/mcphub/mcphub.git
git clone https://github.com/samanhappy/mcphub.git
Set-Location mcphub
# 安装 Node.js 依赖

View File

@@ -331,7 +331,7 @@ MCPHub 文档支持以下图标库的图标:
"pages": [
{
"name": "GitHub 仓库",
"url": "https://github.com/mcphub/mcphub",
"url": "https://github.com/samanhappy/mcphub",
"icon": "github"
},
{
@@ -382,7 +382,6 @@ zh/
"pages": [
"zh/concepts/introduction",
"zh/concepts/architecture",
"zh/concepts/mcp-protocol",
"zh/concepts/routing"
]
}

141
docs/zh/features/oauth.mdx Normal file
View File

@@ -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 会在下一次请求时重新触发授权。

View File

@@ -35,9 +35,6 @@ MCPHub 是一个现代化的 Model Context Protocol (MCP) 服务器管理平台
了解 MCPHub 的核心概念,为深入使用做好准备。
<CardGroup cols={2}>
<Card title="MCP 协议介绍" icon="network-wired" href="/zh/concepts/mcp-protocol">
深入了解 Model Context Protocol 的工作原理和最佳实践
</Card>
<Card title="智能路由机制" icon="route" href="/zh/features/smart-routing">
学习 MCPHub 的智能路由算法和配置策略
</Card>
@@ -57,12 +54,6 @@ MCPHub 支持多种部署方式,满足不同规模和场景的需求。
<Card title="Docker 部署" icon="docker" href="/zh/configuration/docker-setup">
使用 Docker 容器快速部署,支持单机和集群模式
</Card>
<Card title="云服务部署" icon="cloud" href="/zh/deployment/cloud">
在 AWS、GCP、Azure 等云平台上部署 MCPHub
</Card>
<Card title="Kubernetes" icon="dharmachakra" href="/zh/deployment/kubernetes">
在 Kubernetes 集群中部署高可用的 MCPHub 服务
</Card>
</CardGroup>
## API 和集成
@@ -73,9 +64,6 @@ MCPHub 提供完整的 RESTful API 和多语言 SDK方便与现有系统集
<Card title="API 参考文档" icon="code" href="/zh/api-reference/introduction">
完整的 API 接口文档,包含详细的请求示例和响应格式
</Card>
<Card title="SDK 和工具" icon="toolbox" href="/zh/sdk">
官方 SDK 和命令行工具,加速开发集成
</Card>
</CardGroup>
## 社区和支持
@@ -83,7 +71,7 @@ MCPHub 提供完整的 RESTful API 和多语言 SDK方便与现有系统集
加入 MCPHub 社区,获取帮助和分享经验。
<CardGroup cols={2}>
<Card title="GitHub 仓库" icon="github" href="https://github.com/mcphub/mcphub">
<Card title="GitHub 仓库" icon="github" href="https://github.com/samanhappy/mcphub">
查看源代码、提交问题和贡献代码
</Card>
<Card title="Discord 社区" icon="discord" href="https://discord.gg/mcphub">

View File

@@ -0,0 +1,80 @@
{
"mcpServers": {
"example-sse-server": {
"type": "sse",
"url": "${MCP_SERVER_URL}",
"headers": {
"Authorization": "Bearer ${API_TOKEN}",
"X-Custom-Header": "${CUSTOM_HEADER_VALUE}"
},
"enabled": true
},
"example-streamable-http": {
"type": "streamable-http",
"url": "https://${SERVER_HOST}/mcp",
"headers": {
"API-Key": "${API_KEY}"
}
},
"example-stdio-server": {
"type": "stdio",
"command": "${PYTHON_PATH}",
"args": [
"-m",
"${MODULE_NAME}",
"--config",
"${CONFIG_PATH}"
],
"env": {
"API_KEY": "${MY_API_KEY}",
"DEBUG": "${DEBUG_MODE}",
"DATABASE_URL": "${DATABASE_URL}"
}
},
"example-openapi-server": {
"type": "openapi",
"openapi": {
"url": "${OPENAPI_SPEC_URL}",
"security": {
"type": "apiKey",
"apiKey": {
"name": "X-API-Key",
"in": "header",
"value": "${OPENAPI_API_KEY}"
}
}
},
"headers": {
"User-Agent": "MCPHub/${VERSION}"
}
},
"example-oauth-server": {
"type": "sse",
"url": "${OAUTH_SERVER_URL}",
"oauth": {
"clientId": "${OAUTH_CLIENT_ID}",
"clientSecret": "${OAUTH_CLIENT_SECRET}",
"accessToken": "${OAUTH_ACCESS_TOKEN}",
"scopes": ["read", "write"]
}
}
},
"users": [
{
"username": "admin",
"password": "${ADMIN_PASSWORD_HASH}",
"isAdmin": true
}
],
"systemConfig": {
"install": {
"pythonIndexUrl": "${PYTHON_INDEX_URL}",
"npmRegistry": "${NPM_REGISTRY}"
},
"mcpRouter": {
"apiKey": "${MCPROUTER_API_KEY}",
"referer": "${MCPROUTER_REFERER}",
"baseUrl": "${MCPROUTER_BASE_URL}"
}
}
}

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChangePasswordCredentials } from '../types';
import { changePassword } from '../services/authService';
import { validatePasswordStrength } from '../utils/passwordValidation';
interface ChangePasswordFormProps {
onSuccess?: () => void;
@@ -18,6 +19,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [passwordErrors, setPasswordErrors] = useState<string[]>([]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
@@ -25,6 +27,12 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
setConfirmPassword(value);
} else {
setFormData(prev => ({ ...prev, [name]: value }));
// Validate password strength on change for new password
if (name === 'newPassword') {
const validation = validatePasswordStrength(value);
setPasswordErrors(validation.errors);
}
}
};
@@ -32,6 +40,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
e.preventDefault();
setError(null);
// Validate password strength
const validation = validatePasswordStrength(formData.newPassword);
if (!validation.isValid) {
setError(t('auth.passwordStrengthError'));
setPasswordErrors(validation.errors);
return;
}
// Validate passwords match
if (formData.newPassword !== confirmPassword) {
setError(t('auth.passwordsNotMatch'));
@@ -100,8 +116,24 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
value={formData.newPassword}
onChange={handleChange}
required
minLength={6}
minLength={8}
/>
{/* Password strength hints */}
{formData.newPassword && passwordErrors.length > 0 && (
<div className="mt-2 text-sm text-gray-600">
<p className="font-semibold mb-1">{t('auth.passwordStrengthHint')}</p>
<ul className="list-disc list-inside space-y-1">
{passwordErrors.map((errorKey) => (
<li key={errorKey} className="text-red-600">
{t(`auth.${errorKey}`)}
</li>
))}
</ul>
</div>
)}
{formData.newPassword && passwordErrors.length === 0 && (
<p className="mt-2 text-sm text-green-600"> {t('auth.passwordStrengthHint')}</p>
)}
</div>
<div className="mb-6">
@@ -116,7 +148,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
value={confirmPassword}
onChange={handleChange}
required
minLength={6}
minLength={8}
/>
</div>

View File

@@ -0,0 +1,311 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { apiPost } from '@/utils/fetchInterceptor';
interface JSONImportFormProps {
onSuccess: () => void;
onCancel: () => void;
}
interface McpServerConfig {
command?: string;
args?: string[];
env?: Record<string, string>;
type?: string;
url?: string;
headers?: Record<string, string>;
}
interface ImportJsonFormat {
mcpServers: Record<string, McpServerConfig>;
}
const JSONImportForm: React.FC<JSONImportFormProps> = ({ onSuccess, onCancel }) => {
const { t } = useTranslation();
const [jsonInput, setJsonInput] = useState('');
const [error, setError] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [previewServers, setPreviewServers] = useState<Array<{ name: string; config: any }> | null>(
null,
);
const examplePlaceholder = `STDIO example:
{
"mcpServers": {
"stdio-server-example": {
"command": "npx",
"args": ["-y", "mcp-server-example"]
}
}
}
SSE example:
{
"mcpServers": {
"sse-server-example": {
"type": "sse",
"url": "http://localhost:3000"
}
}
}
HTTP example:
{
"mcpServers": {
"http-server-example": {
"type": "streamable-http",
"url": "http://localhost:3001",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer your-token"
}
}
}
}`;
const parseAndValidateJson = (input: string): ImportJsonFormat | null => {
try {
const parsed = JSON.parse(input.trim());
// Validate structure
if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') {
setError(t('jsonImport.invalidFormat'));
return null;
}
return parsed as ImportJsonFormat;
} catch (e) {
setError(t('jsonImport.parseError'));
return null;
}
};
const handlePreview = () => {
setError(null);
const parsed = parseAndValidateJson(jsonInput);
if (!parsed) return;
const servers = Object.entries(parsed.mcpServers).map(([name, config]) => {
// Normalize config to MCPHub format
const normalizedConfig: any = {};
if (config.type === 'sse' || config.type === 'streamable-http') {
normalizedConfig.type = config.type;
normalizedConfig.url = config.url;
if (config.headers) {
normalizedConfig.headers = config.headers;
}
} else {
// Default to stdio
normalizedConfig.type = 'stdio';
normalizedConfig.command = config.command;
normalizedConfig.args = config.args || [];
if (config.env) {
normalizedConfig.env = config.env;
}
}
return { name, config: normalizedConfig };
});
setPreviewServers(servers);
};
const handleImport = async () => {
if (!previewServers) return;
setIsImporting(true);
setError(null);
try {
let successCount = 0;
const errors: string[] = [];
for (const server of previewServers) {
try {
const result = await apiPost('/servers', {
name: server.name,
config: server.config,
});
if (result.success) {
successCount++;
} else {
errors.push(`${server.name}: ${result.message || t('jsonImport.addFailed')}`);
}
} catch (err) {
errors.push(
`${server.name}: ${err instanceof Error ? err.message : t('jsonImport.addFailed')}`,
);
}
}
if (errors.length > 0) {
setError(
t('jsonImport.partialSuccess', { count: successCount, total: previewServers.length }) +
'\n' +
errors.join('\n'),
);
}
if (successCount > 0) {
onSuccess();
}
} catch (err) {
console.error('Import error:', err);
setError(t('jsonImport.importFailed'));
} finally {
setIsImporting(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white shadow rounded-lg p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('jsonImport.title')}</h2>
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
</button>
</div>
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
<p className="text-red-700 whitespace-pre-wrap">{error}</p>
</div>
)}
{!previewServers ? (
<div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('jsonImport.inputLabel')}
</label>
<textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
className="w-full h-96 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
placeholder={examplePlaceholder}
/>
<p className="text-xs text-gray-500 mt-2">{t('jsonImport.inputHelp')}</p>
</div>
<div className="flex justify-end space-x-4">
<button
onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handlePreview}
disabled={!jsonInput.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 btn-primary"
>
{t('jsonImport.preview')}
</button>
</div>
</div>
) : (
<div>
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900 mb-3">
{t('jsonImport.previewTitle')}
</h3>
<div className="space-y-3">
{previewServers.map((server, index) => (
<div key={index} className="bg-gray-50 p-4 rounded-lg border border-gray-200">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-gray-900">{server.name}</h4>
<div className="mt-2 space-y-1 text-sm text-gray-600">
<div>
<strong>{t('server.type')}:</strong> {server.config.type || 'stdio'}
</div>
{server.config.command && (
<div>
<strong>{t('server.command')}:</strong> {server.config.command}
</div>
)}
{server.config.args && server.config.args.length > 0 && (
<div>
<strong>{t('server.arguments')}:</strong>{' '}
{server.config.args.join(' ')}
</div>
)}
{server.config.url && (
<div>
<strong>{t('server.url')}:</strong> {server.config.url}
</div>
)}
{server.config.env && Object.keys(server.config.env).length > 0 && (
<div>
<strong>{t('server.envVars')}:</strong>{' '}
{Object.keys(server.config.env).join(', ')}
</div>
)}
{server.config.headers &&
Object.keys(server.config.headers).length > 0 && (
<div>
<strong>{t('server.headers')}:</strong>{' '}
{Object.keys(server.config.headers).join(', ')}
</div>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
<div className="flex justify-end space-x-4">
<button
onClick={() => setPreviewServers(null)}
disabled={isImporting}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
>
{t('common.back')}
</button>
<button
onClick={handleImport}
disabled={isImporting}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
>
{isImporting ? (
<>
<svg
className="animate-spin h-4 w-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{t('jsonImport.importing')}
</>
) : (
t('jsonImport.import')
)}
</button>
</div>
</div>
)}
</div>
</div>
);
};
export default JSONImportForm;

View File

@@ -19,7 +19,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
onBack,
onInstall,
installing = false,
isInstalled = false
isInstalled = false,
}) => {
const { t } = useTranslation();
const [modalVisible, setModalVisible] = useState(false);
@@ -32,21 +32,23 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
const getButtonProps = () => {
if (isInstalled) {
return {
className: "bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white",
className: 'bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white',
disabled: true,
text: t('market.installed')
text: t('market.installed'),
};
} else if (installing) {
return {
className: "bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white",
className:
'bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white',
disabled: true,
text: t('market.installing')
text: t('market.installing'),
};
} else {
return {
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary",
className:
'bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary',
disabled: false,
text: t('market.install')
text: t('market.install'),
};
}
};
@@ -133,12 +135,18 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
return (
<div className="bg-white rounded-lg shadow-md p-6">
<div className="mb-4">
<button
onClick={onBack}
className="text-gray-600 hover:text-gray-900 flex items-center"
>
<svg className="h-5 w-5 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
<button onClick={onBack} className="text-gray-600 hover:text-gray-900 flex items-center">
<svg
className="h-5 w-5 mr-1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
clipRule="evenodd"
/>
</svg>
{t('market.backToList')}
</button>
@@ -150,7 +158,8 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
{server.display_name}
<span className="text-sm font-normal text-gray-500 ml-2">({server.name})</span>
<span className="text-sm font-normal text-gray-600 ml-4">
{t('market.author')}: {server.author.name} {t('market.license')}: {server.license}
{t('market.author')}: {server.author?.name || t('market.unknown')} {' '}
{t('market.license')}: {server.license}
<a
href={server.repository.url}
target="_blank"
@@ -182,18 +191,24 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<p className="text-gray-700 mb-6">{server.description}</p>
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t('market.categories')} & {t('market.tags')}</h3>
<h3 className="text-lg font-semibold mb-3">
{t('market.categories')} & {t('market.tags')}
</h3>
<div className="flex flex-wrap gap-2">
{server.categories?.map((category, index) => (
<span key={`cat-${index}`} className="bg-gray-100 text-gray-800 px-3 py-1 rounded">
{category}
</span>
))}
{server.tags && server.tags.map((tag, index) => (
<span key={`tag-${index}`} className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm">
#{tag}
</span>
))}
{server.tags &&
server.tags.map((tag, index) => (
<span
key={`tag-${index}`}
className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm"
>
#{tag}
</span>
))}
</div>
</div>
@@ -224,9 +239,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{name}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{arg.description}
</td>
<td className="px-6 py-4 text-sm text-gray-500">{arg.description}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{arg.required ? (
<span className="text-green-600"></span>
@@ -268,7 +281,10 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
</h4>
<p className="text-gray-600 mb-2">{tool.description}</p>
<div className="mt-2">
<pre id={`schema-${index}`} className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2">
<pre
id={`schema-${index}`}
className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2"
>
{JSON.stringify(tool.inputSchema, null, 2)}
</pre>
</div>
@@ -285,9 +301,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<div key={index} className="border border-gray-200 rounded p-4">
<h4 className="font-medium mb-2">{example.title}</h4>
<p className="text-gray-600 mb-2">{example.description}</p>
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">
{example.prompt}
</pre>
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">{example.prompt}</pre>
</div>
))}
</div>
@@ -316,11 +330,11 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
status: 'disconnected',
config: preferredInstallation
? {
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {}
}
: undefined
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {},
}
: undefined,
}}
/>
</div>
@@ -332,14 +346,16 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{t('server.confirmVariables')}
</h3>
<p className="text-gray-600 mb-4">
{t('server.variablesDetected')}
</p>
<p className="text-gray-600 mb-4">{t('server.variablesDetected')}</p>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
@@ -356,14 +372,12 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
</div>
</div>
</div>
<p className="text-gray-600 text-sm mb-6">
{t('market.confirmVariablesMessage')}
</p>
<p className="text-gray-600 text-sm mb-6">{t('market.confirmVariablesMessage')}</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => {
setConfirmationVisible(false)
setPendingPayload(null)
setConfirmationVisible(false);
setPendingPayload(null);
}}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
>

View File

@@ -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<boolean>
onRefresh?: () => void
server: Server;
onRemove: (serverName: string) => void;
onEdit: (server: Server) => void;
onToggle?: (server: Server, enabled: boolean) => Promise<boolean>;
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<HTMLDivElement>(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<HTMLDivElement>(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}
</h2>
<StatusBadge status={server.status} />
<StatusBadge status={server.status} onAuthClick={handleOAuthAuthorization} />
{/* Tool count display */}
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm btn-primary">
@@ -269,8 +288,8 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
</div>
<button
onClick={(e) => {
e.stopPropagation()
setShowErrorPopover(false)
e.stopPropagation();
setShowErrorPopover(false);
}}
className="text-gray-400 hover:text-gray-600"
>
@@ -380,7 +399,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
serverName={server.name}
/>
</>
)
}
);
};
export default ServerCard
export default ServerCard;

View File

@@ -42,6 +42,20 @@ const ServerForm = ({
}));
};
const getInitialOAuthConfig = (data: Server | null): ServerFormData['oauth'] => {
const oauth = data?.config?.oauth;
return {
clientId: oauth?.clientId || '',
clientSecret: oauth?.clientSecret || '',
scopes: oauth?.scopes ? oauth.scopes.join(' ') : '',
accessToken: oauth?.accessToken || '',
refreshToken: oauth?.refreshToken || '',
authorizationEndpoint: oauth?.authorizationEndpoint || '',
tokenEndpoint: oauth?.tokenEndpoint || '',
resource: oauth?.resource || '',
};
};
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http' | 'openapi'>(
getInitialServerType(),
);
@@ -80,6 +94,7 @@ const ServerForm = ({
initialData.config.options.maxTotalTimeout) ||
undefined,
},
oauth: getInitialOAuthConfig(initialData),
// OpenAPI configuration initialization
openapi:
initialData && initialData.config && initialData.config.openapi
@@ -135,6 +150,7 @@ const ServerForm = ({
);
const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState<boolean>(false);
const [isOAuthSectionExpanded, setIsOAuthSectionExpanded] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const isEdit = !!initialData;
@@ -186,6 +202,19 @@ const ServerForm = ({
setHeaderVars(newHeaderVars);
};
const handleOAuthChange = <K extends keyof NonNullable<ServerFormData['oauth']>>(
field: K,
value: string,
) => {
setFormData((prev) => ({
...prev,
oauth: {
...(prev.oauth || {}),
[field]: value,
},
}));
};
// Handle options changes
const handleOptionsChange = (
field: 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout',
@@ -232,6 +261,42 @@ const ServerForm = ({
options.maxTotalTimeout = formData.options.maxTotalTimeout;
}
const oauthConfig = (() => {
if (!formData.oauth) return undefined;
const {
clientId,
clientSecret,
scopes,
accessToken,
refreshToken,
authorizationEndpoint,
tokenEndpoint,
resource,
} = formData.oauth;
const oauth: Record<string, unknown> = {};
if (clientId && clientId.trim()) oauth.clientId = clientId.trim();
if (clientSecret && clientSecret.trim()) oauth.clientSecret = clientSecret.trim();
if (scopes && scopes.trim()) {
const parsedScopes = scopes
.split(/[\s,]+/)
.map((scope) => scope.trim())
.filter((scope) => scope.length > 0);
if (parsedScopes.length > 0) {
oauth.scopes = parsedScopes;
}
}
if (accessToken && accessToken.trim()) oauth.accessToken = accessToken.trim();
if (refreshToken && refreshToken.trim()) oauth.refreshToken = refreshToken.trim();
if (authorizationEndpoint && authorizationEndpoint.trim()) {
oauth.authorizationEndpoint = authorizationEndpoint.trim();
}
if (tokenEndpoint && tokenEndpoint.trim()) oauth.tokenEndpoint = tokenEndpoint.trim();
if (resource && resource.trim()) oauth.resource = resource.trim();
return Object.keys(oauth).length > 0 ? oauth : undefined;
})();
const payload = {
name: formData.name,
config: {
@@ -304,6 +369,7 @@ const ServerForm = ({
? {
url: formData.url,
...(Object.keys(headers).length > 0 ? { headers } : {}),
...(oauthConfig ? { oauth: oauthConfig } : {}),
}
: {
command: formData.command,
@@ -896,6 +962,132 @@ const ServerForm = ({
</div>
))}
</div>
<div className="mb-4">
<div
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200"
onClick={() => setIsOAuthSectionExpanded(!isOAuthSectionExpanded)}
>
<label className="text-gray-700 text-sm font-bold">
{t('server.oauth.sectionTitle')}
</label>
<span className="text-gray-500 text-sm">{isOAuthSectionExpanded ? '▼' : '▶'}</span>
</div>
{isOAuthSectionExpanded && (
<div className="border border-gray-200 rounded-b p-4 bg-gray-50 border-t-0">
<p className="text-xs text-gray-500 mb-3">
{t('server.oauth.sectionDescription')}
</p>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label className="block text-xs text-gray-600 mb-1">
{t('server.oauth.clientId')}
</label>
<input
type="text"
value={formData.oauth?.clientId || ''}
onChange={(e) => handleOAuthChange('clientId', e.target.value)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="client id"
autoComplete="off"
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">
{t('server.oauth.clientSecret')}
</label>
<input
type="password"
value={formData.oauth?.clientSecret || ''}
onChange={(e) => handleOAuthChange('clientSecret', e.target.value)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="client secret"
autoComplete="off"
/>
</div>
{/*
<div>
<label className="block text-xs text-gray-600 mb-1">
{t('server.oauth.authorizationEndpoint')}
</label>
<input
type="url"
value={formData.oauth?.authorizationEndpoint || ''}
onChange={(e) => handleOAuthChange('authorizationEndpoint', e.target.value)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="https://auth.example.com/authorize"
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">
{t('server.oauth.tokenEndpoint')}
</label>
<input
type="url"
value={formData.oauth?.tokenEndpoint || ''}
onChange={(e) => handleOAuthChange('tokenEndpoint', e.target.value)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="https://auth.example.com/token"
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">
{t('server.oauth.scopes')}
</label>
<input
type="text"
value={formData.oauth?.scopes || ''}
onChange={(e) => handleOAuthChange('scopes', e.target.value)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder={t('server.oauth.scopesPlaceholder')}
autoComplete="off"
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">
{t('server.oauth.resource')}
</label>
<input
type="text"
value={formData.oauth?.resource || ''}
onChange={(e) => handleOAuthChange('resource', e.target.value)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="https://mcp.example.com/mcp"
autoComplete="off"
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">
{t('server.oauth.accessToken')}
</label>
<input
type="password"
value={formData.oauth?.accessToken || ''}
onChange={(e) => handleOAuthChange('accessToken', e.target.value)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="access-token"
autoComplete="off"
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">
{t('server.oauth.refreshToken')}
</label>
<input
type="password"
value={formData.oauth?.refreshToken || ''}
onChange={(e) => handleOAuthChange('refreshToken', e.target.value)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="refresh-token"
autoComplete="off"
/>
</div>
*/}
</div>
</div>
)}
</div>
</>
) : (
<>

View File

@@ -13,24 +13,21 @@ type BadgeProps = {
const badgeVariants = {
default: 'bg-blue-500 text-white hover:bg-blue-600',
secondary: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600',
outline: 'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
secondary:
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600',
outline:
'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
destructive: 'bg-red-500 text-white hover:bg-red-600',
};
export function Badge({
children,
variant = 'default',
className,
onClick
}: BadgeProps) {
export function Badge({ children, variant = 'default', className, onClick }: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
badgeVariants[variant],
onClick ? 'cursor-pointer' : '',
className
className,
)}
onClick={onClick}
>
@@ -40,27 +37,40 @@ export function Badge({
}
// For backward compatibility with existing code
export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
export const StatusBadge = ({
status,
onAuthClick,
}: {
status: 'connected' | 'disconnected' | 'connecting' | 'oauth_required';
onAuthClick?: (e: React.MouseEvent) => void;
}) => {
const { t } = useTranslation();
const colors = {
connecting: 'status-badge-connecting',
connected: 'status-badge-online',
disconnected: 'status-badge-offline',
oauth_required: 'status-badge-oauth-required',
};
// Map status to translation keys
const statusTranslations = {
connected: 'status.online',
disconnected: 'status.offline',
connecting: 'status.connecting'
connecting: 'status.connecting',
oauth_required: 'status.oauthRequired',
};
const isOAuthRequired = status === 'oauth_required';
return (
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${colors[status]}`}
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${colors[status]} ${isOAuthRequired && onAuthClick ? 'cursor-pointer hover:opacity-80' : ''}`}
onClick={isOAuthRequired && onAuthClick ? (e) => onAuthClick(e) : undefined}
title={isOAuthRequired ? t('status.clickToAuthorize') : undefined}
>
{isOAuthRequired && '🔐 '}
{t(statusTranslations[status] || status)}
</span>
);
};
};

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
interface DefaultPasswordWarningModalProps {
isOpen: boolean;
onClose: () => void;
}
const DefaultPasswordWarningModal: React.FC<DefaultPasswordWarningModalProps> = ({
isOpen,
onClose,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
if (!isOpen) return null;
const handleGoToSettings = () => {
onClose();
navigate('/settings');
// Auto-scroll to password section after a small delay to ensure page is loaded
setTimeout(() => {
const passwordSection = document.querySelector('[data-section="password"]');
if (passwordSection) {
passwordSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
// If the section is collapsed, expand it
const clickTarget = passwordSection.querySelector('[role="button"]');
if (clickTarget && !passwordSection.querySelector('.mt-4')) {
(clickTarget as HTMLElement).click();
}
}
}, 100);
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
return (
<div
className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4"
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full transform transition-all duration-200 ease-out"
role="dialog"
aria-modal="true"
aria-labelledby="password-warning-title"
aria-describedby="password-warning-message"
>
<div className="p-6">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<svg
className="w-6 h-6 text-yellow-600 dark:text-yellow-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<div className="flex-1">
<h3
id="password-warning-title"
className="text-lg font-medium text-gray-900 dark:text-white mb-2"
>
{t('auth.defaultPasswordWarning')}
</h3>
<p
id="password-warning-message"
className="text-gray-600 dark:text-gray-300 leading-relaxed"
>
{t('auth.defaultPasswordMessage')}
</p>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors duration-150 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleGoToSettings}
className="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-md transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500 btn-warning"
autoFocus
>
{t('auth.goToSettings')}
</button>
</div>
</div>
</div>
</div>
);
};
export default DefaultPasswordWarningModal;

View File

@@ -11,7 +11,8 @@ const LanguageSwitch: React.FC = () => {
const availableLanguages = [
{ code: 'en', label: 'English' },
{ code: 'zh', label: '中文' },
{ code: 'fr', label: 'Français' }
{ code: 'fr', label: 'Français' },
{ code: 'tr', label: 'Türkçe' }
];
// Update current language when it changes

View File

@@ -14,12 +14,12 @@ const initialState: AuthState = {
// Create auth context
const AuthContext = createContext<{
auth: AuthState;
login: (username: string, password: string) => Promise<boolean>;
login: (username: string, password: string) => Promise<{ success: boolean; isUsingDefaultPassword?: boolean }>;
register: (username: string, password: string, isAdmin?: boolean) => Promise<boolean>;
logout: () => void;
}>({
auth: initialState,
login: async () => false,
login: async () => ({ success: false }),
register: async () => false,
logout: () => { },
});
@@ -90,7 +90,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}, []);
// Login function
const login = async (username: string, password: string): Promise<boolean> => {
const login = async (username: string, password: string): Promise<{ success: boolean; isUsingDefaultPassword?: boolean }> => {
try {
const response = await authService.login({ username, password });
@@ -101,14 +101,17 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
user: response.user,
error: null,
});
return true;
return {
success: true,
isUsingDefaultPassword: response.isUsingDefaultPassword,
};
} else {
setAuth({
...initialState,
loading: false,
error: response.message || 'Authentication failed',
});
return false;
return { success: false };
}
} catch (error) {
setAuth({
@@ -116,7 +119,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
loading: false,
error: 'Authentication failed',
});
return false;
return { success: false };
}
};

View File

@@ -287,9 +287,13 @@ export const useCloudData = () => {
const callServerTool = useCallback(
async (serverName: string, toolName: string, args: Record<string, any>) => {
try {
const data = await apiPost(`/cloud/servers/${serverName}/tools/${toolName}/call`, {
arguments: args,
});
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const data = await apiPost(
`/cloud/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/call`,
{
arguments: args,
},
);
if (data && data.success) {
return data.data;

View File

@@ -6,6 +6,7 @@ import LanguageDetector from 'i18next-browser-languagedetector';
import enTranslation from '../../locales/en.json';
import zhTranslation from '../../locales/zh.json';
import frTranslation from '../../locales/fr.json';
import trTranslation from '../../locales/tr.json';
i18n
// Detect user language
@@ -24,6 +25,9 @@ i18n
fr: {
translation: frTranslation,
},
tr: {
translation: trTranslation,
},
},
fallbackLng: 'en',
debug: process.env.NODE_ENV === 'development',

View File

@@ -144,6 +144,18 @@ body {
border: 1px solid rgba(255, 193, 7, 0.3);
}
.status-badge-oauth-required {
background-color: white !important;
color: rgba(156, 39, 176, 0.9) !important;
border: 1px solid #ba68c8;
}
.dark .status-badge-oauth-required {
background-color: rgba(156, 39, 176, 0.15) !important;
color: rgba(186, 104, 200, 0.9) !important;
border: 1px solid rgba(156, 39, 176, 0.3);
}
/* Enhanced status icons for dark theme */
.dark .status-icon-blue {
background-color: rgba(59, 130, 246, 0.15) !important;

View File

@@ -12,14 +12,16 @@ const DashboardPage: React.FC = () => {
total: servers.length,
online: servers.filter((server: Server) => server.status === 'connected').length,
offline: servers.filter((server: Server) => server.status === 'disconnected').length,
connecting: servers.filter((server: Server) => server.status === 'connecting').length
connecting: servers.filter((server: Server) => server.status === 'connecting').length,
oauthRequired: servers.filter((server: Server) => server.status === 'oauth_required').length,
};
// Map status to translation keys
const statusTranslations: Record<string, string> = {
connected: 'status.online',
disconnected: 'status.offline',
connecting: 'status.connecting'
connecting: 'status.connecting',
oauth_required: 'status.oauthRequired',
};
return (
@@ -38,8 +40,17 @@ const DashboardPage: React.FC = () => {
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200"
aria-label={t('app.closeButton')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
@@ -49,9 +60,25 @@ const DashboardPage: React.FC = () => {
{isLoading && (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
<div className="flex flex-col items-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<svg
className="animate-spin h-10 w-10 text-blue-500 mb-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p className="text-gray-600">{t('app.loading')}</p>
</div>
@@ -64,12 +91,25 @@ const DashboardPage: React.FC = () => {
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-blue-100 text-blue-800 icon-container status-icon-blue">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
</svg>
</div>
<div className="ml-4">
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.totalServers')}</h2>
<h2 className="text-xl font-semibold text-gray-700">
{t('pages.dashboard.totalServers')}
</h2>
<p className="text-3xl font-bold text-gray-900">{serverStats.total}</p>
</div>
</div>
@@ -79,12 +119,25 @@ const DashboardPage: React.FC = () => {
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-green-100 text-green-800 icon-container status-icon-green">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="ml-4">
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.onlineServers')}</h2>
<h2 className="text-xl font-semibold text-gray-700">
{t('pages.dashboard.onlineServers')}
</h2>
<p className="text-3xl font-bold text-gray-900">{serverStats.online}</p>
</div>
</div>
@@ -94,12 +147,25 @@ const DashboardPage: React.FC = () => {
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-red-100 text-red-800 icon-container status-icon-red">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="ml-4">
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.offlineServers')}</h2>
<h2 className="text-xl font-semibold text-gray-700">
{t('pages.dashboard.offlineServers')}
</h2>
<p className="text-3xl font-bold text-gray-900">{serverStats.offline}</p>
</div>
</div>
@@ -109,16 +175,28 @@ const DashboardPage: React.FC = () => {
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800 icon-container status-icon-yellow">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="ml-4">
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.connectingServers')}</h2>
<h2 className="text-xl font-semibold text-gray-700">
{t('pages.dashboard.connectingServers')}
</h2>
<p className="text-3xl font-bold text-gray-900">{serverStats.connecting}</p>
</div>
</div>
</div>
</div>
)}
@@ -126,24 +204,41 @@ const DashboardPage: React.FC = () => {
{/* Recent activity list */}
{servers.length > 0 && !isLoading && (
<div className="mt-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>
<h2 className="text-xl font-semibold text-gray-900 mb-4">
{t('pages.dashboard.recentServers')}
</h2>
<div className="bg-white shadow rounded-lg overflow-hidden table-container">
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
scope="col"
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{t('server.name')}
</th>
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
scope="col"
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{t('server.status')}
</th>
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
scope="col"
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{t('server.tools')}
</th>
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
scope="col"
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{t('server.prompts')}
</th>
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
scope="col"
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{t('server.enabled')}
</th>
</tr>
@@ -155,12 +250,18 @@ const DashboardPage: React.FC = () => {
{server.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
? 'status-badge-online'
: server.status === 'disconnected'
? 'status-badge-offline'
: 'status-badge-connecting'
}`}>
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
server.status === 'connected'
? 'status-badge-online'
: server.status === 'disconnected'
? 'status-badge-offline'
: server.status === 'oauth_required'
? 'status-badge-oauth-required'
: 'status-badge-connecting'
}`}
>
{server.status === 'oauth_required' && '🔐 '}
{t(statusTranslations[server.status] || server.status)}
</span>
</td>
@@ -188,4 +289,4 @@ const DashboardPage: React.FC = () => {
);
};
export default DashboardPage;
export default DashboardPage;

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext';
import ThemeSwitch from '@/components/ui/ThemeSwitch';
import LanguageSwitch from '@/components/ui/LanguageSwitch';
import DefaultPasswordWarningModal from '@/components/ui/DefaultPasswordWarningModal';
const LoginPage: React.FC = () => {
const { t } = useTranslation();
@@ -11,6 +12,7 @@ const LoginPage: React.FC = () => {
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showDefaultPasswordWarning, setShowDefaultPasswordWarning] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
@@ -26,10 +28,15 @@ const LoginPage: React.FC = () => {
return;
}
const success = await login(username, password);
const result = await login(username, password);
if (success) {
navigate('/');
if (result.success) {
if (result.isUsingDefaultPassword) {
// Show warning modal instead of navigating immediately
setShowDefaultPasswordWarning(true);
} else {
navigate('/');
}
} else {
setError(t('auth.loginFailed'));
}
@@ -40,6 +47,11 @@ const LoginPage: React.FC = () => {
}
};
const handleCloseWarning = () => {
setShowDefaultPasswordWarning(false);
navigate('/');
};
return (
<div className="relative min-h-screen w-full overflow-hidden bg-gray-50 dark:bg-gray-950">
{/* Top-right controls */}
@@ -138,6 +150,12 @@ const LoginPage: React.FC = () => {
</div>
</div>
</div>
{/* Default Password Warning Modal */}
<DefaultPasswordWarningModal
isOpen={showDefaultPasswordWarning}
onClose={handleCloseWarning}
/>
</div>
);
};

View File

@@ -7,6 +7,7 @@ import AddServerForm from '@/components/AddServerForm';
import EditServerForm from '@/components/EditServerForm';
import { useServerData } from '@/hooks/useServerData';
import DxtUploadForm from '@/components/DxtUploadForm';
import JSONImportForm from '@/components/JSONImportForm';
const ServersPage: React.FC = () => {
const { t } = useTranslation();
@@ -25,6 +26,7 @@ const ServersPage: React.FC = () => {
const [editingServer, setEditingServer] = useState<Server | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [showDxtUpload, setShowDxtUpload] = useState(false);
const [showJsonImport, setShowJsonImport] = useState(false);
const handleEditClick = async (server: Server) => {
const fullServerData = await handleServerEdit(server);
@@ -55,6 +57,12 @@ const ServersPage: React.FC = () => {
triggerRefresh();
};
const handleJsonImportSuccess = () => {
// Close import dialog and refresh servers
setShowJsonImport(false);
triggerRefresh();
};
return (
<div>
<div className="flex justify-between items-center mb-8">
@@ -70,6 +78,15 @@ const ServersPage: React.FC = () => {
{t('nav.market')}
</button>
<AddServerForm onAdd={handleServerAdd} />
<button
onClick={() => setShowJsonImport(true)}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
{t('jsonImport.button')}
</button>
<button
onClick={() => setShowDxtUpload(true)}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
@@ -161,6 +178,13 @@ const ServersPage: React.FC = () => {
onCancel={() => setShowDxtUpload(false)}
/>
)}
{showJsonImport && (
<JSONImportForm
onSuccess={handleJsonImportSuccess}
onCancel={() => setShowJsonImport(false)}
/>
)}
</div>
);
};

View File

@@ -794,10 +794,11 @@ const SettingsPage: React.FC = () => {
</PermissionChecker>
{/* Change Password */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card" data-section="password">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('password')}
role="button"
>
<h2 className="font-semibold text-gray-800">{t('auth.changePassword')}</h2>
<span className="text-gray-500">{sectionsVisible.password ? '▼' : '►'}</span>

View File

@@ -59,8 +59,9 @@ export const getPrompt = async (
server?: string,
): Promise<GetPromptResult> => {
try {
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const response = await apiPost(
`/mcp/${server}/prompts/${encodeURIComponent(request.promptName)}`,
`/mcp/${encodeURIComponent(server || '')}/prompts/${encodeURIComponent(request.promptName)}`,
{
name: request.promptName,
arguments: request.arguments,
@@ -94,9 +95,13 @@ export const togglePrompt = async (
enabled: boolean,
): Promise<{ success: boolean; error?: string }> => {
try {
const response = await apiPost<any>(`/servers/${serverName}/prompts/${promptName}/toggle`, {
enabled,
});
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const response = await apiPost<any>(
`/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/toggle`,
{
enabled,
},
);
return {
success: response.success,
@@ -120,8 +125,9 @@ export const updatePromptDescription = async (
description: string,
): Promise<{ success: boolean; error?: string }> => {
try {
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const response = await apiPut<any>(
`/servers/${serverName}/prompts/${promptName}/description`,
`/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/description`,
{ description },
{
headers: {

View File

@@ -25,7 +25,10 @@ export const callTool = async (
): Promise<ToolCallResult> => {
try {
// Construct the URL with optional server parameter
const url = server ? `/tools/${server}/${request.toolName}` : '/tools/call';
// URL-encode server and tool names to handle slashes in names (e.g., "com.atlassian/atlassian-mcp-server")
const url = server
? `/tools/${encodeURIComponent(server)}/${encodeURIComponent(request.toolName)}`
: '/tools/call';
const response = await apiPost<any>(url, request.arguments, {
headers: {
@@ -62,8 +65,9 @@ export const toggleTool = async (
enabled: boolean,
): Promise<{ success: boolean; error?: string }> => {
try {
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const response = await apiPost<any>(
`/servers/${serverName}/tools/${toolName}/toggle`,
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/toggle`,
{ enabled },
{
headers: {
@@ -94,8 +98,9 @@ export const updateToolDescription = async (
description: string,
): Promise<{ success: boolean; error?: string }> => {
try {
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const response = await apiPut<any>(
`/servers/${serverName}/tools/${toolName}/description`,
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/description`,
{ description },
{
headers: {

View File

@@ -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;
@@ -308,6 +359,7 @@ export interface AuthResponse {
token?: string;
user?: IUser;
message?: string;
isUsingDefaultPassword?: boolean;
}
// Official Registry types (from registry.modelcontextprotocol.io)

View File

@@ -0,0 +1,38 @@
/**
* Frontend password strength validation utility
* Should match backend validation rules
*/
export interface PasswordValidationResult {
isValid: boolean;
errors: string[];
}
export const validatePasswordStrength = (password: string): PasswordValidationResult => {
const errors: string[] = [];
// Check minimum length
if (password.length < 8) {
errors.push('passwordMinLength');
}
// Check for at least one letter
if (!/[a-zA-Z]/.test(password)) {
errors.push('passwordRequireLetter');
}
// Check for at least one number
if (!/\d/.test(password)) {
errors.push('passwordRequireNumber');
}
// Check for at least one special character
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
errors.push('passwordRequireSpecial');
}
return {
isValid: errors.length === 0,
errors,
};
};

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import tailwindcss from '@tailwindcss/vite';
@@ -8,45 +8,48 @@ import { readFileSync } from 'fs';
// Get package.json version
const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8'));
// For runtime configuration, we'll always use relative paths
// BASE_PATH will be determined at runtime
const basePath = '';
// https://vitejs.dev/config/
export default defineConfig({
base: './', // Always use relative paths for runtime configuration
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
define: {
// Make package version available as global variable
// BASE_PATH will be loaded at runtime
'import.meta.env.PACKAGE_VERSION': JSON.stringify(packageJson.version),
},
build: {
sourcemap: true, // Enable source maps for production build
},
server: {
proxy: {
[`${basePath}/api`]: {
target: 'http://localhost:3000',
changeOrigin: true,
},
[`${basePath}/auth`]: {
target: 'http://localhost:3000',
changeOrigin: true,
},
[`${basePath}/config`]: {
target: 'http://localhost:3000',
changeOrigin: true,
},
[`${basePath}/public-config`]: {
target: 'http://localhost:3000',
changeOrigin: true,
export default defineConfig(({ mode }) => {
// Load env file from parent directory (project root)
const env = loadEnv(mode, path.resolve(__dirname, '..'), '');
// Get BASE_PATH from environment, default to empty string
// Normalize by removing trailing slashes to avoid double slashes
let basePath = env.BASE_PATH || '';
basePath = basePath.replace(/\/+$/, '');
// Create proxy configuration dynamically based on BASE_PATH
const proxyConfig: Record<string, any> = {};
// List of paths that need to be proxied
const pathsToProxy = ['/api', '/config', '/public-config', '/health', '/oauth'];
pathsToProxy.forEach((path) => {
const proxyPath = basePath + path;
proxyConfig[proxyPath] = {
target: 'http://localhost:3000',
changeOrigin: true,
};
});
return {
base: './', // Always use relative paths for runtime configuration
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
},
define: {
// Make package version available as global variable
// BASE_PATH will be loaded at runtime
'import.meta.env.PACKAGE_VERSION': JSON.stringify(packageJson.version),
},
build: {
sourcemap: true, // Enable source maps for production build
},
server: {
proxy: proxyConfig,
},
};
});

View File

@@ -40,7 +40,7 @@ module.exports = {
'^@/(.*)$': '<rootDir>/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,

View File

@@ -69,7 +69,16 @@
"changePasswordError": "Failed to change password",
"changePassword": "Change Password",
"passwordChanged": "Password changed successfully",
"passwordChangeError": "Failed to change password"
"passwordChangeError": "Failed to change password",
"defaultPasswordWarning": "Default Password Security Warning",
"defaultPasswordMessage": "You are using the default password (admin123), which poses a security risk. Please change your password immediately to protect your account.",
"goToSettings": "Go to Settings",
"passwordStrengthError": "Password does not meet security requirements",
"passwordMinLength": "Password must be at least 8 characters long",
"passwordRequireLetter": "Password must contain at least one letter",
"passwordRequireNumber": "Password must contain at least one number",
"passwordRequireSpecial": "Password must contain at least one special character",
"passwordStrengthHint": "Password must be at least 8 characters and contain letters, numbers, and special characters"
},
"server": {
"addServer": "Add Server",
@@ -107,7 +116,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 +173,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",
@@ -188,6 +213,7 @@
"processing": "Processing...",
"save": "Save",
"cancel": "Cancel",
"back": "Back",
"refresh": "Refresh",
"create": "Create",
"creating": "Creating...",
@@ -582,6 +608,21 @@
"serverExistsConfirm": "Server '{{serverName}}' already exists. Do you want to override it with the new version?",
"override": "Override"
},
"jsonImport": {
"button": "Import",
"title": "Import Servers from JSON",
"inputLabel": "Server Configuration JSON",
"inputHelp": "Paste your server configuration JSON. Supports STDIO, SSE, and HTTP (streamable-http) server types.",
"preview": "Preview",
"previewTitle": "Preview Servers to Import",
"import": "Import",
"importing": "Importing...",
"invalidFormat": "Invalid JSON format. The JSON must contain an 'mcpServers' object.",
"parseError": "Failed to parse JSON. Please check the format and try again.",
"addFailed": "Failed to add server",
"importFailed": "Failed to import servers",
"partialSuccess": "Imported {{count}} of {{total}} servers successfully. Some servers failed:"
},
"users": {
"add": "Add User",
"addNew": "Add New User",
@@ -676,5 +717,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"
}
}

View File

@@ -69,7 +69,16 @@
"changePasswordError": "Échec du changement de mot de passe",
"changePassword": "Changer le mot de passe",
"passwordChanged": "Mot de passe changé avec succès",
"passwordChangeError": "Échec du changement de mot de passe"
"passwordChangeError": "Échec du changement de mot de passe",
"defaultPasswordWarning": "Avertissement de sécurité du mot de passe par défaut",
"defaultPasswordMessage": "Vous utilisez le mot de passe par défaut (admin123), ce qui présente un risque de sécurité. Veuillez changer votre mot de passe immédiatement pour protéger votre compte.",
"goToSettings": "Aller aux paramètres",
"passwordStrengthError": "Le mot de passe ne répond pas aux exigences de sécurité",
"passwordMinLength": "Le mot de passe doit contenir au moins 8 caractères",
"passwordRequireLetter": "Le mot de passe doit contenir au moins une lettre",
"passwordRequireNumber": "Le mot de passe doit contenir au moins un chiffre",
"passwordRequireSpecial": "Le mot de passe doit contenir au moins un caractère spécial",
"passwordStrengthHint": "Le mot de passe doit contenir au moins 8 caractères et inclure des lettres, des chiffres et des caractères spéciaux"
},
"server": {
"addServer": "Ajouter un serveur",
@@ -107,7 +116,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 +173,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",
@@ -188,6 +213,7 @@
"processing": "En cours de traitement...",
"save": "Enregistrer",
"cancel": "Annuler",
"back": "Retour",
"refresh": "Actualiser",
"create": "Créer",
"creating": "Création en cours...",
@@ -582,6 +608,21 @@
"serverExistsConfirm": "Le serveur '{{serverName}}' existe déjà. Voulez-vous le remplacer par la nouvelle version ?",
"override": "Remplacer"
},
"jsonImport": {
"button": "Importer",
"title": "Importer des serveurs depuis JSON",
"inputLabel": "Configuration JSON du serveur",
"inputHelp": "Collez votre configuration JSON de serveur. Prend en charge les types de serveurs STDIO, SSE et HTTP (streamable-http).",
"preview": "Aperçu",
"previewTitle": "Aperçu des serveurs à importer",
"import": "Importer",
"importing": "Importation en cours...",
"invalidFormat": "Format JSON invalide. Le JSON doit contenir un objet 'mcpServers'.",
"parseError": "Échec de l'analyse du JSON. Veuillez vérifier le format et réessayer.",
"addFailed": "Échec de l'ajout du serveur",
"importFailed": "Échec de l'importation des serveurs",
"partialSuccess": "{{count}} serveur(s) sur {{total}} importé(s) avec succès. Certains serveurs ont échoué :"
},
"users": {
"add": "Ajouter un utilisateur",
"addNew": "Ajouter un nouvel utilisateur",
@@ -676,5 +717,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"
}
}

747
locales/tr.json Normal file
View File

@@ -0,0 +1,747 @@
{
"app": {
"title": "MCPHub Kontrol Paneli",
"error": "Hata",
"closeButton": "Kapat",
"noServers": "Kullanılabilir MCP sunucusu yok",
"loading": "Yükleniyor...",
"logout": ıkış Yap",
"profile": "Profil",
"changePassword": "Şifre Değiştir",
"toggleSidebar": "Kenar Çubuğunu Aç/Kapat",
"welcomeUser": "Hoş geldin, {{username}}",
"name": "MCPHub"
},
"about": {
"title": "Hakkında",
"versionInfo": "MCPHub Sürümü: {{version}}",
"newVersion": "Yeni sürüm mevcut!",
"currentVersion": "Mevcut sürüm",
"newVersionAvailable": "Yeni sürüm {{version}} mevcut",
"viewOnGitHub": "GitHub'da Görüntüle",
"checkForUpdates": "Güncellemeleri Kontrol Et",
"checking": "Güncellemeler kontrol ediliyor..."
},
"profile": {
"viewProfile": "Profili görüntüle",
"userCenter": "Kullanıcı Merkezi"
},
"sponsor": {
"label": "Sponsor",
"title": "Projeyi Destekle",
"rewardAlt": "Ödül QR Kodu",
"supportMessage": "Bana bir kahve ısmarlayarak MCPHub'ın geliştirilmesini destekleyin!",
"supportButton": "Ko-fi'de Destek Ol"
},
"wechat": {
"label": "WeChat",
"title": "WeChat ile Bağlan",
"qrCodeAlt": "WeChat QR Kodu",
"scanMessage": "WeChat'te bizimle bağlantı kurmak için bu QR kodunu tarayın"
},
"discord": {
"label": "Discord",
"title": "Discord sunucumuza katılın",
"community": "Destek, tartışmalar ve güncellemeler için büyüyen Discord topluluğumuza katılın!"
},
"theme": {
"title": "Tema",
"light": "Açık",
"dark": "Koyu",
"system": "Sistem"
},
"auth": {
"login": "Giriş Yap",
"loginTitle": "MCPHub'a Giriş Yap",
"slogan": "Birleşik MCP sunucu yönetim platformu",
"subtitle": "Model Context Protocol sunucuları için merkezi yönetim platformu. Esnek yönlendirme stratejileri ile birden fazla MCP sunucusunu organize edin, izleyin ve ölçeklendirin.",
"username": "Kullanıcı Adı",
"password": "Şifre",
"loggingIn": "Giriş yapılıyor...",
"emptyFields": "Kullanıcı adı ve şifre boş olamaz",
"loginFailed": "Giriş başarısız, lütfen kullanıcı adınızı ve şifrenizi kontrol edin",
"loginError": "Giriş sırasında bir hata oluştu",
"currentPassword": "Mevcut Şifre",
"newPassword": "Yeni Şifre",
"confirmPassword": "Şifreyi Onayla",
"passwordsNotMatch": "Yeni şifre ve onay eşleşmiyor",
"changePasswordSuccess": "Şifre başarıyla değiştirildi",
"changePasswordError": "Şifre değişikliği başarısız oldu",
"changePassword": "Şifre Değiştir",
"passwordChanged": "Şifre başarıyla değiştirildi",
"passwordChangeError": "Şifre değişikliği başarısız oldu",
"defaultPasswordWarning": "Varsayılan Şifre Güvenlik Uyarısı",
"defaultPasswordMessage": "Varsayılan şifreyi (admin123) kullanıyorsunuz, bu bir güvenlik riski oluşturur. Hesabınızı korumak için lütfen şifrenizi hemen değiştirin.",
"goToSettings": "Ayarlara Git",
"passwordStrengthError": "Şifre güvenlik gereksinimlerini karşılamıyor",
"passwordMinLength": "Şifre en az 8 karakter uzunluğunda olmalıdır",
"passwordRequireLetter": "Şifre en az bir harf içermelidir",
"passwordRequireNumber": "Şifre en az bir rakam içermelidir",
"passwordRequireSpecial": "Şifre en az bir özel karakter içermelidir",
"passwordStrengthHint": "Şifre en az 8 karakter olmalı ve harf, rakam ve özel karakter içermelidir"
},
"server": {
"addServer": "Sunucu Ekle",
"add": "Ekle",
"edit": "Düzenle",
"copy": "Kopyala",
"delete": "Sil",
"confirmDelete": "Bu sunucuyu silmek istediğinizden emin misiniz?",
"deleteWarning": "'{{name}}' sunucusunu silmek, onu ve tüm verilerini kaldıracaktır. Bu işlem geri alınamaz.",
"status": "Durum",
"tools": "Araçlar",
"prompts": "İstekler",
"name": "Sunucu Adı",
"url": "Sunucu URL'si",
"apiKey": "API Anahtarı",
"save": "Kaydet",
"cancel": "İptal",
"invalidConfig": "{{serverName}} için yapılandırma verisi bulunamadı",
"addError": "Sunucu eklenemedi",
"editError": "{{serverName}} sunucusu düzenlenemedi",
"deleteError": "{{serverName}} sunucusu silinemedi",
"updateError": "Sunucu güncellenemedi",
"editTitle": "Sunucuyu Düzenle: {{serverName}}",
"type": "Sunucu Türü",
"typeStdio": "STDIO",
"typeSse": "SSE",
"typeStreamableHttp": "Akış Yapılabilir HTTP",
"typeOpenapi": "OpenAPI",
"command": "Komut",
"arguments": "Argümanlar",
"envVars": "Ortam Değişkenleri",
"headers": "HTTP Başlıkları",
"key": "anahtar",
"value": "değer",
"enabled": "Etkin",
"enable": "Etkinleştir",
"disable": "Devre Dışı Bırak",
"requestOptions": "Bağlantı Yapılandırması",
"timeout": "İstek Zaman Aşımı",
"timeoutDescription": "MCP sunucusuna yapılan istekler için zaman aşımı (ms)",
"maxTotalTimeout": "Maksimum Toplam Zaman Aşımı",
"maxTotalTimeoutDescription": "MCP sunucusuna gönderilen istekler için maksimum toplam zaman aşımı (ms) (İlerleme bildirimleriyle kullanın)",
"resetTimeoutOnProgress": "İlerlemede Zaman Aşımını Sıfırla",
"resetTimeoutOnProgressDescription": "İlerleme bildirimlerinde zaman aşımını sıfırla",
"remove": "Kaldır",
"toggleError": "{{serverName}} sunucusu açılamadı/kapatılamadı",
"alreadyExists": "{{serverName}} sunucusu zaten mevcut",
"invalidData": "Geçersiz sunucu verisi sağlandı",
"notFound": "{{serverName}} sunucusu bulunamadı",
"namePlaceholder": "Sunucu adını girin",
"urlPlaceholder": "Sunucu URL'sini girin",
"commandPlaceholder": "Komutu girin",
"argumentsPlaceholder": "Argümanları girin",
"errorDetails": "Hata Detayları",
"viewErrorDetails": "Hata detaylarını görüntüle",
"copyConfig": "Yapılandırmayı Kopyala",
"confirmVariables": "Değişken Yapılandırmasını Onayla",
"variablesDetected": "Yapılandırmada değişkenler algılandı. Lütfen bu değişkenlerin düzgün yapılandırıldığını onaylayın:",
"detectedVariables": "Algılanan Değişkenler",
"confirmVariablesMessage": "Lütfen bu değişkenlerin çalışma ortamınızda düzgün tanımlandığından emin olun. Sunucu eklemeye devam edilsin mi?",
"confirmAndAdd": "Onayla ve Ekle",
"openapi": {
"inputMode": "Giriş Modu",
"inputModeUrl": "Şartname URL'si",
"inputModeSchema": "JSON Şeması",
"specUrl": "OpenAPI Şartname URL'si",
"schema": "OpenAPI JSON Şeması",
"schemaHelp": "Eksiksiz OpenAPI JSON şemanızı buraya yapıştırın",
"security": "Güvenlik Türü",
"securityNone": "Yok",
"securityApiKey": "API Anahtarı",
"securityHttp": "HTTP Kimlik Doğrulaması",
"securityOAuth2": "OAuth 2.0",
"securityOpenIdConnect": "OpenID Connect",
"apiKeyConfig": "API Anahtarı Yapılandırması",
"apiKeyName": "Başlık/Parametre Adı",
"apiKeyIn": "Konum",
"apiKeyValue": "API Anahtarı Değeri",
"httpAuthConfig": "HTTP Kimlik Doğrulama Yapılandırması",
"httpScheme": "Kimlik Doğrulama Şeması",
"httpCredentials": "Kimlik Bilgileri",
"httpSchemeBasic": "Basit",
"httpSchemeBearer": "Bearer",
"httpSchemeDigest": "Digest",
"oauth2Config": "OAuth 2.0 Yapılandırması",
"oauth2Token": "Erişim Anahtarı",
"openIdConnectConfig": "OpenID Connect Yapılandırması",
"openIdConnectUrl": "URL'yi Keşfet",
"openIdConnectToken": "ID Token",
"apiKeyInHeader": "Başlık",
"apiKeyInQuery": "Sorgu",
"apiKeyInCookie": "Çerez",
"passthroughHeaders": "Geçiş Başlıkları",
"passthroughHeadersHelp": "Araç çağrısı isteklerinden yukarı akış OpenAPI uç noktalarına geçirilecek başlık adlarının virgülle ayrılmış listesi (örn. Authorization, X-API-Key)"
},
"oauth": {
"sectionTitle": "OAuth Yapılandırması",
"sectionDescription": "OAuth korumalı sunucular için istemci kimlik bilgilerini yapılandırın (isteğe bağlı).",
"clientId": "İstemci ID",
"clientSecret": "İstemci Gizli Anahtarı",
"authorizationEndpoint": "Yetkilendirme Uç Noktası",
"tokenEndpoint": "Token Uç Noktası",
"scopes": "Kapsamlar",
"scopesPlaceholder": "scope1 scope2",
"resource": "Kaynak / Hedef Kitle",
"accessToken": "Erişim Tokeni",
"refreshToken": "Yenileme Tokeni"
}
},
"status": {
"online": "Çevrimiçi",
"offline": "Çevrimdışı",
"connecting": "Bağlanıyor",
"oauthRequired": "OAuth Gerekli",
"clickToAuthorize": "OAuth ile yetkilendirmek için tıklayın",
"oauthWindowOpened": "OAuth yetkilendirme penceresi açıldı. Lütfen yetkilendirmeyi tamamlayın."
},
"errors": {
"general": "Bir şeyler yanlış gitti",
"network": "Ağ bağlantı hatası. Lütfen internet bağlantınızı kontrol edin",
"serverConnection": "Sunucuya bağlanılamıyor. Lütfen sunucunun çalışıp çalışmadığını kontrol edin",
"serverAdd": "Sunucu eklenemedi. Lütfen sunucu durumunu kontrol edin",
"serverUpdate": "{{serverName}} sunucusu düzenlenemedi. Lütfen sunucu durumunu kontrol edin",
"serverFetch": "Sunucu verileri alınamadı. Lütfen daha sonra tekrar deneyin",
"initialStartup": "Sunucu başlatılıyor olabilir. İlk başlatmada bu işlem biraz zaman alabileceğinden lütfen bekleyin...",
"serverInstall": "Sunucu yüklenemedi",
"failedToFetchSettings": "Ayarlar getirilemedi",
"failedToUpdateRouteConfig": "Route yapılandırması güncellenemedi",
"failedToUpdateSmartRoutingConfig": "Akıllı yönlendirme yapılandırması güncellenemedi"
},
"common": {
"processing": "İşleniyor...",
"save": "Kaydet",
"cancel": "İptal",
"back": "Geri",
"refresh": "Yenile",
"create": "Oluştur",
"creating": "Oluşturuluyor...",
"update": "Güncelle",
"updating": "Güncelleniyor...",
"submitting": "Gönderiliyor...",
"delete": "Sil",
"remove": "Kaldır",
"copy": "Kopyala",
"copyId": "ID'yi Kopyala",
"copyUrl": "URL'yi Kopyala",
"copyJson": "JSON'u Kopyala",
"copySuccess": "Panoya kopyalandı",
"copyFailed": "Kopyalama başarısız",
"copied": "Kopyalandı",
"close": "Kapat",
"confirm": "Onayla",
"language": "Dil",
"true": "Doğru",
"false": "Yanlış",
"dismiss": "Anımsatma",
"github": "GitHub",
"wechat": "WeChat",
"discord": "Discord",
"required": "Gerekli",
"secret": "Gizli",
"default": "Varsayılan",
"value": "Değer",
"type": "Tür",
"repeated": "Tekrarlanan",
"valueHint": "Değer İpucu",
"choices": "Seçenekler"
},
"nav": {
"dashboard": "Kontrol Paneli",
"servers": "Sunucular",
"groups": "Gruplar",
"users": "Kullanıcılar",
"settings": "Ayarlar",
"changePassword": "Şifre Değiştir",
"market": "Market",
"cloud": "Bulut Market",
"logs": "Günlükler"
},
"pages": {
"dashboard": {
"title": "Kontrol Paneli",
"totalServers": "Toplam",
"onlineServers": "Çevrimiçi",
"offlineServers": "Çevrimdışı",
"connectingServers": "Bağlanıyor",
"recentServers": "Son Sunucular"
},
"servers": {
"title": "Sunucu Yönetimi"
},
"groups": {
"title": "Grup Yönetimi"
},
"users": {
"title": "Kullanıcı Yönetimi"
},
"settings": {
"title": "Ayarlar",
"language": "Dil",
"account": "Hesap Ayarları",
"password": "Şifre Değiştir",
"appearance": "Görünüm",
"routeConfig": "Güvenlik",
"installConfig": "Kurulum",
"smartRouting": "Akıllı Yönlendirme"
},
"market": {
"title": "Market Yönetimi - Yerel ve Bulut Marketler"
},
"logs": {
"title": "Sistem Günlükleri"
}
},
"logs": {
"filters": "Filtreler",
"search": "Günlüklerde ara...",
"autoScroll": "Otomatik kaydır",
"clearLogs": "Günlükleri temizle",
"loading": "Günlükler yükleniyor...",
"noLogs": "Kullanılabilir günlük yok.",
"noMatch": "Mevcut filtrelerle eşleşen günlük yok.",
"mainProcess": "Ana İşlem",
"childProcess": "Alt İşlem",
"main": "Ana",
"child": "Alt"
},
"groups": {
"add": "Ekle",
"addNew": "Yeni Grup Ekle",
"edit": "Grubu Düzenle",
"delete": "Sil",
"confirmDelete": "Bu grubu silmek istediğinizden emin misiniz?",
"deleteWarning": "'{{name}}' grubunu silmek, onu ve tüm sunucu ilişkilerini kaldıracaktır. Bu işlem geri alınamaz.",
"name": "Grup Adı",
"namePlaceholder": "Grup adını girin",
"nameRequired": "Grup adı gereklidir",
"description": "Açıklama",
"descriptionPlaceholder": "Grup açıklamasını girin (isteğe bağlı)",
"createError": "Grup oluşturulamadı",
"updateError": "Grup güncellenemedi",
"deleteError": "Grup silinemedi",
"serverAddError": "Sunucu gruba eklenemedi",
"serverRemoveError": "Sunucu gruptan kaldırılamadı",
"addServer": "Gruba Sunucu Ekle",
"selectServer": "Eklenecek bir sunucu seçin",
"servers": "Gruptaki Sunucular",
"remove": "Kaldır",
"noGroups": "Kullanılabilir grup yok. Başlamak için yeni bir grup oluşturun.",
"noServers": "Bu grupta sunucu yok.",
"noServerOptions": "Kullanılabilir sunucu yok",
"serverCount": "{{count}} Sunucu",
"toolSelection": "Araç Seçimi",
"toolsSelected": "Seçildi",
"allTools": "Tümü",
"selectedTools": "Seçili araçlar",
"selectAll": "Tümünü Seç",
"selectNone": "Hiçbirini Seçme",
"configureTools": "Araçları Yapılandır"
},
"market": {
"title": "Yerel Kurulum",
"official": "Resmi",
"by": "Geliştirici",
"unknown": "Bilinmeyen",
"tools": "araçlar",
"search": "Ara",
"searchPlaceholder": "Sunucuları isme, kategoriye veya etiketlere göre ara",
"clearFilters": "Temizle",
"clearCategoryFilter": "",
"clearTagFilter": "",
"categories": "Kategoriler",
"tags": "Etiketler",
"showTags": "Etiketleri göster",
"hideTags": "Etiketleri gizle",
"moreTags": "",
"noServers": "Aramanızla eşleşen sunucu bulunamadı",
"backToList": "Listeye dön",
"install": "Yükle",
"installing": "Yükleniyor...",
"installed": "Yüklendi",
"installServer": "Sunucu Yükle: {{name}}",
"installSuccess": "{{serverName}} sunucusu başarıyla yüklendi",
"author": "Yazar",
"license": "Lisans",
"repository": "Depo",
"examples": "Örnekler",
"arguments": "Argümanlar",
"argumentName": "Ad",
"description": "Açıklama",
"required": "Gerekli",
"example": "Örnek",
"viewSchema": "Şemayı görüntüle",
"fetchError": "Market sunucuları getirilirken hata",
"serverNotFound": "Sunucu bulunamadı",
"searchError": "Sunucular aranırken hata",
"filterError": "Sunucular kategoriye göre filtrelenirken hata",
"tagFilterError": "Sunucular etikete göre filtrelenirken hata",
"noInstallationMethod": "Bu sunucu için kullanılabilir kurulum yöntemi yok",
"showing": "{{total}} sunucudan {{from}}-{{to}} arası gösteriliyor",
"perPage": "Sayfa başına",
"confirmVariablesMessage": "Lütfen bu değişkenlerin çalışma ortamınızda düzgün tanımlandığından emin olun. Sunucu yüklemeye devam edilsin mi?",
"confirmAndInstall": "Onayla ve Yükle"
},
"cloud": {
"title": "Bulut Desteği",
"subtitle": "MCPRouter tarafından desteklenmektedir",
"by": "Geliştirici",
"server": "Sunucu",
"config": "Yapılandırma",
"created": "Oluşturuldu",
"updated": "Güncellendi",
"available": "Kullanılabilir",
"description": "Açıklama",
"details": "Detaylar",
"tools": "Araçlar",
"tool": "araç",
"toolsAvailable": "{{count}} araç mevcut",
"loadingTools": "Araçlar yükleniyor...",
"noTools": "Bu sunucu için kullanılabilir araç yok",
"noDescription": "Kullanılabilir açıklama yok",
"viewDetails": "Detayları Görüntüle",
"parameters": "Parametreler",
"result": "Sonuç",
"error": "Hata",
"callTool": "Çalıştır",
"calling": "Çalıştırılıyor...",
"toolCallSuccess": "{{toolName}} aracı başarıyla çalıştırıldı",
"toolCallError": "{{toolName}} aracı çalıştırılamadı: {{error}}",
"viewSchema": "Şemayı Görüntüle",
"backToList": "Bulut Market'e Dön",
"search": "Ara",
"searchPlaceholder": "Bulut sunucularını isme, başlığa veya geliştiriciye göre ara",
"clearFilters": "Filtreleri Temizle",
"clearCategoryFilter": "Temizle",
"clearTagFilter": "Temizle",
"categories": "Kategoriler",
"tags": "Etiketler",
"noCategories": "Kategori bulunamadı",
"noTags": "Etiket bulunamadı",
"noServers": "Bulut sunucusu bulunamadı",
"fetchError": "Bulut sunucuları getirilirken hata",
"serverNotFound": "Bulut sunucusu bulunamadı",
"searchError": "Bulut sunucuları aranırken hata",
"filterError": "Bulut sunucuları kategoriye göre filtrelenirken hata",
"tagFilterError": "Bulut sunucuları etikete göre filtrelenirken hata",
"showing": "{{total}} bulut sunucusundan {{from}}-{{to}} arası gösteriliyor",
"perPage": "Sayfa başına",
"apiKeyNotConfigured": "MCPRouter API anahtarı yapılandırılmamış",
"apiKeyNotConfiguredDescription": "Bulut sunucularını kullanmak için MCPRouter API anahtarınızı yapılandırmanız gerekir.",
"getApiKey": "API Anahtarı Al",
"configureInSettings": "Ayarlarda Yapılandır",
"installServer": "{{name}} Yükle",
"installSuccess": "{{name}} sunucusu başarıyla yüklendi",
"installError": "Sunucu yüklenemedi: {{error}}"
},
"registry": {
"title": "Kayıt",
"official": "Resmi",
"latest": "En Son",
"description": "Açıklama",
"website": "Web Sitesi",
"repository": "Depo",
"packages": "Paketler",
"package": "paket",
"remotes": "Uzak Sunucular",
"remote": "uzak sunucu",
"published": "Yayınlandı",
"updated": "Güncellendi",
"install": "Yükle",
"installing": "Yükleniyor...",
"installed": "Yüklendi",
"installServer": "{{name}} Yükle",
"installSuccess": "{{name}} sunucusu başarıyla yüklendi",
"installError": "Sunucu yüklenemedi: {{error}}",
"noDescription": "Kullanılabilir açıklama yok",
"viewDetails": "Detayları Görüntüle",
"backToList": "Kayda Dön",
"search": "Ara",
"searchPlaceholder": "Kayıt sunucularını isme göre ara",
"clearFilters": "Temizle",
"noServers": "Kayıt sunucusu bulunamadı",
"fetchError": "Kayıt sunucuları getirilirken hata",
"serverNotFound": "Kayıt sunucusu bulunamadı",
"showing": "{{total}} kayıt sunucusundan {{from}}-{{to}} arası gösteriliyor",
"perPage": "Sayfa başına",
"environmentVariables": "Ortam Değişkenleri",
"packageArguments": "Paket Argümanları",
"runtimeArguments": "Çalışma Zamanı Argümanları",
"headers": "Başlıklar"
},
"tool": {
"run": "Çalıştır",
"running": "Çalıştırılıyor...",
"runTool": "Aracı Çalıştır",
"cancel": "İptal",
"noDescription": "Kullanılabilir açıklama yok",
"inputSchema": "Giriş Şeması:",
"runToolWithName": "Aracı Çalıştır: {{name}}",
"execution": "Araç Çalıştırma",
"successful": "Başarılı",
"failed": "Başarısız",
"result": "Sonuç:",
"error": "Hata",
"errorDetails": "Hata Detayları:",
"noContent": "Araç başarıyla çalıştırıldı ancak içerik döndürmedi.",
"unknownError": "Bilinmeyen hata oluştu",
"jsonResponse": "JSON Yanıtı:",
"toolResult": "Araç sonucu",
"noParameters": "Bu araç herhangi bir parametre gerektirmez.",
"selectOption": "Bir seçenek seçin",
"enterValue": "{{type}} değeri girin",
"enabled": "Etkin",
"enableSuccess": "{{name}} aracı başarıyla etkinleştirildi",
"disableSuccess": "{{name}} aracı başarıyla devre dışı bırakıldı",
"toggleFailed": "Araç durumu değiştirilemedi",
"parameters": "Araç Parametreleri",
"formMode": "Form Modu",
"jsonMode": "JSON Modu",
"jsonConfiguration": "JSON Yapılandırması",
"invalidJsonFormat": "Geçersiz JSON formatı",
"fixJsonBeforeSwitching": "Form moduna geçmeden önce lütfen JSON formatını düzeltin",
"item": "Öğe {{index}}",
"addItem": "{{key}} öğesi ekle",
"enterKey": "{{key}} girin"
},
"prompt": {
"run": "Getir",
"running": "Getiriliyor...",
"result": "İstek Sonucu",
"error": "İstek Hatası",
"execution": "İstek Çalıştırma",
"successful": "Başarılı",
"failed": "Başarısız",
"errorDetails": "Hata Detayları:",
"noContent": "İstek başarıyla çalıştırıldı ancak içerik döndürmedi.",
"unknownError": "Bilinmeyen hata oluştu",
"jsonResponse": "JSON Yanıtı:",
"description": "Açıklama",
"messages": "Mesajlar",
"noDescription": "Kullanılabilir açıklama yok",
"runPromptWithName": "İsteği Getir: {{name}}"
},
"settings": {
"enableGlobalRoute": "Global Yönlendirmeyi Etkinleştir",
"enableGlobalRouteDescription": "Grup ID'si belirtmeden /sse uç noktasına bağlantıya izin ver",
"enableGroupNameRoute": "Grup Adı Yönlendirmeyi Etkinleştir",
"enableGroupNameRouteDescription": "Sadece grup ID'leri yerine grup adları kullanarak /sse uç noktasına bağlantıya izin ver",
"enableBearerAuth": "Bearer Kimlik Doğrulamasını Etkinleştir",
"enableBearerAuthDescription": "MCP istekleri için bearer token kimlik doğrulaması gerektir",
"bearerAuthKey": "Bearer Kimlik Doğrulama Anahtarı",
"bearerAuthKeyDescription": "Bearer token'da gerekli olacak kimlik doğrulama anahtarı",
"bearerAuthKeyPlaceholder": "Bearer kimlik doğrulama anahtarını girin",
"skipAuth": "Kimlik Doğrulamayı Atla",
"skipAuthDescription": "Arayüz ve API erişimi için giriş gereksinimini atla (Güvenlik için VARSAYILAN KAPALI)",
"pythonIndexUrl": "Python Paket Deposu URL'si",
"pythonIndexUrlDescription": "Python paket kurulumu için UV_DEFAULT_INDEX ortam değişkenini ayarla",
"pythonIndexUrlPlaceholder": "örn. https://pypi.org/simple",
"npmRegistry": "NPM Kayıt URL'si",
"npmRegistryDescription": "NPM paket kurulumu için npm_config_registry ortam değişkenini ayarla",
"npmRegistryPlaceholder": "örn. https://registry.npmjs.org/",
"baseUrl": "Temel URL",
"baseUrlDescription": "MCP istekleri için temel URL",
"baseUrlPlaceholder": "örn. http://localhost:3000",
"installConfig": "Kurulum",
"systemConfigUpdated": "Sistem yapılandırması başarıyla güncellendi",
"enableSmartRouting": "Akıllı Yönlendirmeyi Etkinleştir",
"enableSmartRoutingDescription": "Girdiye göre en uygun aracı aramak için akıllı yönlendirme özelliğini etkinleştir ($smart grup adını kullanarak)",
"dbUrl": "PostgreSQL URL'si (pgvector desteği gerektirir)",
"dbUrlPlaceholder": "örn. postgresql://kullanıcı:şifre@localhost:5432/veritabanıadı",
"openaiApiBaseUrl": "OpenAI API Temel URL'si",
"openaiApiBaseUrlPlaceholder": "https://api.openai.com/v1",
"openaiApiKey": "OpenAI API Anahtarı",
"openaiApiKeyPlaceholder": "OpenAI API anahtarını girin",
"openaiApiEmbeddingModel": "OpenAI Entegrasyon Modeli",
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
"smartRoutingConfigUpdated": "Akıllı yönlendirme yapılandırması başarıyla güncellendi",
"smartRoutingRequiredFields": "Akıllı yönlendirmeyi etkinleştirmek için Veritabanı URL'si ve OpenAI API Anahtarı gereklidir",
"smartRoutingValidationError": "Akıllı Yönlendirmeyi etkinleştirmeden önce lütfen gerekli alanları doldurun: {{fields}}",
"mcpRouterConfig": "Bulut Market",
"mcpRouterApiKey": "MCPRouter API Anahtarı",
"mcpRouterApiKeyDescription": "MCPRouter bulut market hizmetlerine erişim için API anahtarı",
"mcpRouterApiKeyPlaceholder": "MCPRouter API anahtarını girin",
"mcpRouterReferer": "Yönlendiren",
"mcpRouterRefererDescription": "MCPRouter API istekleri için Referer başlığı",
"mcpRouterRefererPlaceholder": "https://www.mcphubx.com",
"mcpRouterTitle": "Başlık",
"mcpRouterTitleDescription": "MCPRouter API istekleri için Başlık başlığı",
"mcpRouterTitlePlaceholder": "MCPHub",
"mcpRouterBaseUrl": "Temel URL",
"mcpRouterBaseUrlDescription": "MCPRouter API için temel URL",
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1",
"systemSettings": "Sistem Ayarları",
"nameSeparatorLabel": "İsim Ayırıcı",
"nameSeparatorDescription": "Sunucu adı ile araç/istek adını ayırmak için kullanılan karakter (varsayılan: -)",
"restartRequired": "Yapılandırma kaydedildi. Tüm hizmetlerin yeni ayarları doğru şekilde yüklemesini sağlamak için uygulamayı yeniden başlatmanız önerilir.",
"exportMcpSettings": "Ayarları Dışa Aktar",
"mcpSettingsJson": "MCP Ayarları JSON",
"mcpSettingsJsonDescription": "Yedekleme veya diğer araçlara taşıma için mevcut mcp_settings.json yapılandırmanızı görüntüleyin, kopyalayın veya indirin",
"copyToClipboard": "Panoya Kopyala",
"downloadJson": "JSON Olarak İndir",
"exportSuccess": "Ayarlar başarıyla dışa aktarıldı",
"exportError": "Ayarlar getirilemedi"
},
"dxt": {
"upload": "Yükle",
"uploadTitle": "DXT Uzantısı Yükle",
"dropFileHere": ".dxt dosyanızı buraya bırakın",
"orClickToSelect": "veya bilgisayarınızdan seçmek için tıklayın",
"invalidFileType": "Lütfen geçerli bir .dxt dosyası seçin",
"noFileSelected": "Lütfen yüklemek için bir .dxt dosyası seçin",
"uploading": "Yükleniyor...",
"uploadFailed": "DXT dosyası yüklenemedi",
"installServer": "DXT'den MCP Sunucusu Yükle",
"extensionInfo": "Uzantı Bilgisi",
"name": "Ad",
"version": "Sürüm",
"description": "Açıklama",
"author": "Geliştirici",
"tools": "Araçlar",
"serverName": "Sunucu Adı",
"serverNamePlaceholder": "Bu sunucu için bir ad girin",
"install": "Yükle",
"installing": "Yükleniyor...",
"installFailed": "DXT'den sunucu yüklenemedi",
"serverExistsTitle": "Sunucu Zaten Mevcut",
"serverExistsConfirm": "'{{serverName}}' sunucusu zaten mevcut. Yeni sürümle geçersiz kılmak istiyor musunuz?",
"override": "Geçersiz Kıl"
},
"jsonImport": {
"button": "İçe Aktar",
"title": "JSON'dan Sunucuları İçe Aktar",
"inputLabel": "Sunucu Yapılandırma JSON",
"inputHelp": "Sunucu yapılandırma JSON'unuzu yapıştırın. STDIO, SSE ve HTTP (streamable-http) sunucu türlerini destekler.",
"preview": "Önizle",
"previewTitle": "İçe Aktarılacak Sunucuları Önizle",
"import": "İçe Aktar",
"importing": "İçe aktarılıyor...",
"invalidFormat": "Geçersiz JSON formatı. JSON bir 'mcpServers' nesnesi içermelidir.",
"parseError": "JSON ayrıştırılamadı. Lütfen formatı kontrol edip tekrar deneyin.",
"addFailed": "Sunucu eklenemedi",
"importFailed": "Sunucular içe aktarılamadı",
"partialSuccess": "{{total}} sunucudan {{count}} tanesi başarıyla içe aktarıldı. Bazı sunucular başarısız oldu:"
},
"users": {
"add": "Kullanıcı Ekle",
"addNew": "Yeni Kullanıcı Ekle",
"edit": "Kullanıcıyı Düzenle",
"delete": "Kullanıcıyı Sil",
"create": "Kullanıcı Oluştur",
"update": "Kullanıcıyı Güncelle",
"username": "Kullanıcı Adı",
"password": "Şifre",
"newPassword": "Yeni Şifre",
"confirmPassword": "Şifreyi Onayla",
"adminRole": "Yönetici",
"admin": "Yönetici",
"user": "Kullanıcı",
"permissions": "İzinler",
"adminPermissions": "Tam sistem erişimi",
"userPermissions": "Sınırlı erişim",
"currentUser": "Siz",
"noUsers": "Kullanıcı bulunamadı",
"adminRequired": "Kullanıcıları yönetmek için yönetici erişimi gereklidir",
"usernameRequired": "Kullanıcı adı gereklidir",
"passwordRequired": "Şifre gereklidir",
"passwordTooShort": "Şifre en az 6 karakter uzunluğunda olmalıdır",
"passwordMismatch": "Şifreler eşleşmiyor",
"usernamePlaceholder": "Kullanıcı adını girin",
"passwordPlaceholder": "Şifreyi girin",
"newPasswordPlaceholder": "Mevcut şifreyi korumak için boş bırakın",
"confirmPasswordPlaceholder": "Yeni şifreyi onaylayın",
"createError": "Kullanıcı oluşturulamadı",
"updateError": "Kullanıcı güncellenemedi",
"deleteError": "Kullanıcı silinemedi",
"statsError": "Kullanıcı istatistikleri getirilemedi",
"deleteConfirmation": "'{{username}}' kullanıcısını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"confirmDelete": "Kullanıcıyı Sil",
"deleteWarning": "'{{username}}' kullanıcısını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz."
},
"api": {
"errors": {
"readonly": "Demo ortamı için salt okunur",
"invalid_credentials": "Geçersiz kullanıcı adı veya şifre",
"serverNameRequired": "Sunucu adı gereklidir",
"serverConfigRequired": "Sunucu yapılandırması gereklidir",
"serverConfigInvalid": "Sunucu yapılandırması bir URL, OpenAPI şartname URL'si veya şema, ya da argümanlı komut içermelidir",
"serverTypeInvalid": "Sunucu türü şunlardan biri olmalıdır: stdio, sse, streamable-http, openapi",
"urlRequiredForType": "{{type}} sunucu türü için URL gereklidir",
"openapiSpecRequired": "OpenAPI sunucu türü için OpenAPI şartname URL'si veya şema gereklidir",
"headersInvalidFormat": "Başlıklar bir nesne olmalıdır",
"headersNotSupportedForStdio": "Başlıklar stdio sunucu türü için desteklenmez",
"serverNotFound": "Sunucu bulunamadı",
"failedToRemoveServer": "Sunucu bulunamadı veya kaldırılamadı",
"internalServerError": "Dahili sunucu hatası",
"failedToGetServers": "Sunucu bilgileri alınamadı",
"failedToGetServerSettings": "Sunucu ayarları alınamadı",
"failedToGetServerConfig": "Sunucu yapılandırması alınamadı",
"failedToSaveSettings": "Ayarlar kaydedilemedi",
"toolNameRequired": "Sunucu adı ve araç adı gereklidir",
"descriptionMustBeString": "Açıklama bir string olmalıdır",
"groupIdRequired": "Grup ID gereklidir",
"groupNameRequired": "Grup adı gereklidir",
"groupNotFound": "Grup bulunamadı",
"groupIdAndServerNameRequired": "Grup ID ve sunucu adı gereklidir",
"groupOrServerNotFound": "Grup veya sunucu bulunamadı",
"toolsMustBeAllOrArray": "Araçlar \"all\" veya bir string dizisi olmalıdır",
"serverNameAndToolNameRequired": "Sunucu adı ve araç adı gereklidir",
"usernameRequired": "Kullanıcı adı gereklidir",
"userNotFound": "Kullanıcı bulunamadı",
"failedToGetUsers": "Kullanıcı bilgileri alınamadı",
"failedToGetUserInfo": "Kullanıcı bilgisi alınamadı",
"failedToGetUserStats": "Kullanıcı istatistikleri alınamadı",
"marketServerNameRequired": "Sunucu adı gereklidir",
"marketServerNotFound": "Market sunucusu bulunamadı",
"failedToGetMarketServers": "Market sunucuları bilgisi alınamadı",
"failedToGetMarketServer": "Market sunucusu bilgisi alınamadı",
"failedToGetMarketCategories": "Market kategorileri alınamadı",
"failedToGetMarketTags": "Market etiketleri alınamadı",
"failedToSearchMarketServers": "Market sunucuları aranamadı",
"failedToFilterMarketServers": "Market sunucuları filtrelenemedi",
"failedToProcessDxtFile": "DXT dosyası işlenemedi"
},
"success": {
"serverCreated": "Sunucu başarıyla oluşturuldu",
"serverUpdated": "Sunucu başarıyla güncellendi",
"serverRemoved": "Sunucu başarıyla kaldırıldı",
"serverToggled": "Sunucu durumu başarıyla değiştirildi",
"toolToggled": "{{name}} aracı başarıyla {{action}}",
"toolDescriptionUpdated": "{{name}} aracının açıklaması başarıyla güncellendi",
"systemConfigUpdated": "Sistem yapılandırması başarıyla güncellendi",
"groupCreated": "Grup başarıyla oluşturuldu",
"groupUpdated": "Grup başarıyla güncellendi",
"groupDeleted": "Grup başarıyla silindi",
"serverAddedToGroup": "Sunucu başarıyla gruba eklendi",
"serverRemovedFromGroup": "Sunucu başarıyla gruptan kaldırıldı",
"serverToolsUpdated": "Sunucu araçları başarıyla güncellendi"
}
},
"oauthCallback": {
"authorizationFailed": "Yetkilendirme Başarısız",
"authorizationFailedError": "Hata",
"authorizationFailedDetails": "Detaylar",
"invalidRequest": "Geçersiz İstek",
"missingStateParameter": "Gerekli OAuth durum parametresi eksik.",
"missingCodeParameter": "Gerekli yetkilendirme kodu parametresi eksik.",
"serverNotFound": "Sunucu Bulunamadı",
"serverNotFoundMessage": "Bu yetkilendirme isteğiyle ilişkili sunucu bulunamadı.",
"sessionExpiredMessage": "Yetkilendirme oturumunun süresi dolmuş olabilir. Lütfen tekrar yetkilendirmeyi deneyin.",
"authorizationSuccessful": "Yetkilendirme Başarılı",
"server": "Sunucu",
"status": "Durum",
"connected": "Bağlandı",
"successMessage": "Sunucu başarıyla yetkilendirildi ve bağlandı.",
"autoCloseMessage": "Bu pencere 3 saniye içinde otomatik olarak kapanacak...",
"closeNow": "Şimdi Kapat",
"connectionError": "Bağlantı Hatası",
"connectionErrorMessage": "Yetkilendirme başarılı oldu, ancak sunucuya bağlanılamadı.",
"reconnectMessage": "Lütfen kontrol panelinden yeniden bağlanmayı deneyin.",
"configurationError": "Yapılandırma Hatası",
"configurationErrorMessage": "Sunucu aktarımı OAuth finishAuth() desteklemiyor. Lütfen sunucunun streamable-http aktarımıyla yapılandırıldığından emin olun.",
"internalError": "İçsel Hata",
"internalErrorMessage": "OAuth geri araması işlenirken beklenmeyen bir hata oluştu.",
"closeWindow": "Pencereyi Kapat"
}
}

View File

@@ -69,7 +69,16 @@
"changePasswordError": "修改密码失败",
"changePassword": "修改密码",
"passwordChanged": "密码修改成功",
"passwordChangeError": "修改密码失败"
"passwordChangeError": "修改密码失败",
"defaultPasswordWarning": "默认密码安全警告",
"defaultPasswordMessage": "您正在使用默认密码admin123这存在安全风险。为了保护您的账户安全请立即修改密码。",
"goToSettings": "前往修改",
"passwordStrengthError": "密码不符合安全要求",
"passwordMinLength": "密码长度至少为 8 个字符",
"passwordRequireLetter": "密码必须包含至少一个字母",
"passwordRequireNumber": "密码必须包含至少一个数字",
"passwordRequireSpecial": "密码必须包含至少一个特殊字符",
"passwordStrengthHint": "密码必须至少 8 个字符,且包含字母、数字和特殊字符"
},
"server": {
"addServer": "添加服务器",
@@ -107,7 +116,7 @@
"enabled": "已启用",
"enable": "启用",
"disable": "禁用",
"requestOptions": "配置",
"requestOptions": "连接配置",
"timeout": "请求超时",
"timeoutDescription": "请求超时时间(毫秒)",
"maxTotalTimeout": "最大总超时",
@@ -164,12 +173,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": "发生错误",
@@ -189,6 +214,7 @@
"processing": "处理中...",
"save": "保存",
"cancel": "取消",
"back": "返回",
"refresh": "刷新",
"create": "创建",
"creating": "创建中...",
@@ -584,6 +610,21 @@
"serverExistsConfirm": "服务器 '{{serverName}}' 已存在。是否要用新版本覆盖它?",
"override": "覆盖"
},
"jsonImport": {
"button": "导入",
"title": "从 JSON 导入服务器",
"inputLabel": "服务器配置 JSON",
"inputHelp": "粘贴您的服务器配置 JSON。支持 STDIO、SSE 和 HTTP (streamable-http) 服务器类型。",
"preview": "预览",
"previewTitle": "预览要导入的服务器",
"import": "导入",
"importing": "导入中...",
"invalidFormat": "无效的 JSON 格式。JSON 必须包含 'mcpServers' 对象。",
"parseError": "解析 JSON 失败。请检查格式后重试。",
"addFailed": "添加服务器失败",
"importFailed": "导入服务器失败",
"partialSuccess": "成功导入 {{count}} / {{total}} 个服务器。部分服务器失败:"
},
"users": {
"add": "添加",
"addNew": "添加新用户",
@@ -678,5 +719,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": "关闭窗口"
}
}

13310
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",
@@ -64,8 +64,9 @@
"i18next-fs-backend": "^2.6.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"openai": "^4.104.0",
"openai": "^6.7.0",
"openapi-types": "^12.1.3",
"openid-client": "^6.8.1",
"pg": "^8.16.3",
"pgvector": "^0.2.1",
"postgres": "^3.4.7",
@@ -104,12 +105,12 @@
"jest": "^30.2.0",
"jest-environment-node": "^30.0.5",
"jest-mock-extended": "4.0.0",
"lucide-react": "^0.486.0",
"lucide-react": "^0.552.0",
"next": "^15.5.0",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-i18next": "^15.7.2",
"react-router-dom": "^7.8.2",
"supertest": "^7.1.4",

859
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,88 +1,97 @@
import dotenv from 'dotenv'
import fs from 'fs'
import { McpSettings, IUser } from '../types/index.js'
import { getConfigFilePath } from '../utils/path.js'
import { getPackageVersion } from '../utils/version.js'
import { getDataService } from '../services/services.js'
import { DataService } from '../services/dataService.js'
import dotenv from 'dotenv';
import fs from 'fs';
import { McpSettings, IUser } from '../types/index.js';
import { getConfigFilePath } from '../utils/path.js';
import { getPackageVersion } from '../utils/version.js';
import { getDataService } from '../services/services.js';
import { DataService } from '../services/dataService.js';
dotenv.config()
dotenv.config();
/**
* Normalize the base path by removing trailing slashes
*/
const normalizeBasePath = (path: string): string => {
if (!path) return '';
// Remove trailing slashes
return path.replace(/\/+$/, '');
};
const defaultConfig = {
port: process.env.PORT || 3000,
initTimeout: process.env.INIT_TIMEOUT || 300000,
basePath: process.env.BASE_PATH || '',
basePath: normalizeBasePath(process.env.BASE_PATH || ''),
readonly: 'true' === process.env.READONLY || false,
mcpHubName: 'mcphub',
mcpHubVersion: getPackageVersion(),
}
};
const dataService: DataService = getDataService()
const dataService: DataService = getDataService();
// Settings cache
let settingsCache: McpSettings | null = null
let settingsCache: McpSettings | null = null;
export const getSettingsPath = (): string => {
return getConfigFilePath('mcp_settings.json', 'Settings')
}
return getConfigFilePath('mcp_settings.json', 'Settings');
};
export const loadOriginalSettings = (): McpSettings => {
// If cache exists, return cached data directly
if (settingsCache) {
return settingsCache
return settingsCache;
}
const settingsPath = getSettingsPath()
const settingsPath = getSettingsPath();
// check if file exists
if (!fs.existsSync(settingsPath)) {
console.warn(`Settings file not found at ${settingsPath}, using default settings.`)
const defaultSettings = { mcpServers: {}, users: [] }
console.warn(`Settings file not found at ${settingsPath}, using default settings.`);
const defaultSettings = { mcpServers: {}, users: [] };
// Cache default settings
settingsCache = defaultSettings
return defaultSettings
settingsCache = defaultSettings;
return defaultSettings;
}
try {
// Read and parse settings file
const settingsData = fs.readFileSync(settingsPath, 'utf8')
const settings = JSON.parse(settingsData)
const settingsData = fs.readFileSync(settingsPath, 'utf8');
const settings = JSON.parse(settingsData);
// Update cache
settingsCache = settings
settingsCache = settings;
console.log(`Loaded settings from ${settingsPath}`)
return settings
console.log(`Loaded settings from ${settingsPath}`);
return settings;
} catch (error) {
throw new Error(`Failed to load settings from ${settingsPath}: ${error}`)
throw new Error(`Failed to load settings from ${settingsPath}: ${error}`);
}
}
};
export const loadSettings = (user?: IUser): McpSettings => {
return dataService.filterSettings!(loadOriginalSettings(), user)
}
return dataService.filterSettings!(loadOriginalSettings(), user);
};
export const saveSettings = (settings: McpSettings, user?: IUser): boolean => {
const settingsPath = getSettingsPath()
const settingsPath = getSettingsPath();
try {
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings, user)
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8')
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings, user);
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
// Update cache after successful save
settingsCache = mergedSettings
settingsCache = mergedSettings;
return true
return true;
} catch (error) {
console.error(`Failed to save settings to ${settingsPath}:`, error)
return false
console.error(`Failed to save settings to ${settingsPath}:`, error);
return false;
}
}
};
/**
* Clear settings cache, force next loadSettings call to re-read from file
*/
export const clearSettingsCache = (): void => {
settingsCache = null
}
settingsCache = null;
};
/**
* Get current cache status (for debugging)
@@ -90,60 +99,71 @@ export const clearSettingsCache = (): void => {
export const getSettingsCacheInfo = (): { hasCache: boolean } => {
return {
hasCache: settingsCache !== null,
}
}
};
};
export function replaceEnvVars(input: Record<string, any>): Record<string, any>
export function replaceEnvVars(input: string[] | undefined): string[]
export function replaceEnvVars(input: string): string
export function replaceEnvVars(input: Record<string, any>): Record<string, any>;
export function replaceEnvVars(input: string[] | undefined): string[];
export function replaceEnvVars(input: string): string;
export function replaceEnvVars(
input: Record<string, any> | string[] | string | undefined,
): Record<string, any> | string[] | string {
// Handle object input
// Handle object input - recursively expand all nested values
if (input && typeof input === 'object' && !Array.isArray(input)) {
const res: Record<string, string> = {}
const res: Record<string, any> = {};
for (const [key, value] of Object.entries(input)) {
if (typeof value === 'string') {
res[key] = expandEnvVars(value)
res[key] = expandEnvVars(value);
} else if (typeof value === 'object' && value !== null) {
// Recursively handle nested objects and arrays
res[key] = replaceEnvVars(value as any);
} else {
res[key] = String(value)
// Preserve non-string, non-object values (numbers, booleans, etc.)
res[key] = value;
}
}
return res
return res;
}
// Handle array input
// Handle array input - recursively expand all elements
if (Array.isArray(input)) {
return input.map((item) => expandEnvVars(item))
return input.map((item) => {
if (typeof item === 'string') {
return expandEnvVars(item);
} else if (typeof item === 'object' && item !== null) {
return replaceEnvVars(item as any);
}
return item;
});
}
// Handle string input
if (typeof input === 'string') {
return expandEnvVars(input)
return expandEnvVars(input);
}
// Handle undefined/null array input
if (input === undefined || input === null) {
return []
return [];
}
return input
return input;
}
export const expandEnvVars = (value: string): string => {
if (typeof value !== 'string') {
return String(value)
return String(value);
}
// Replace ${VAR} format
let result = value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '')
let result = value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '');
// Also replace $VAR format (common on Unix-like systems)
result = result.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, key) => process.env[key] || '')
return result
}
result = result.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, key) => process.env[key] || '');
return result;
};
export default defaultConfig
export default defaultConfig;
export function getNameSeparator(): string {
const settings = loadSettings()
return settings.systemConfig?.nameSeparator || '-'
const settings = loadSettings();
return settings.systemConfig?.nameSeparator || '-';
}

View File

@@ -10,6 +10,8 @@ import {
import { getDataService } from '../services/services.js';
import { DataService } from '../services/dataService.js';
import { JWT_SECRET } from '../config/jwt.js';
import { validatePasswordStrength, isDefaultPassword } from '../utils/passwordValidation.js';
import { getPackageVersion } from '../utils/version.js';
const dataService: DataService = getDataService();
@@ -64,6 +66,11 @@ export const login = async (req: Request, res: Response): Promise<void> => {
},
};
// Check if user is admin with default password
const version = getPackageVersion();
const isUsingDefaultPassword =
user.username === 'admin' && user.isAdmin && isDefaultPassword(password) && version !== 'dev';
jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }, (err, token) => {
if (err) throw err;
res.json({
@@ -75,6 +82,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
isAdmin: user.isAdmin,
permissions: dataService.getPermissions(user),
},
isUsingDefaultPassword,
});
});
} catch (error) {
@@ -172,6 +180,17 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
const username = (req as any).user.username;
try {
// Validate new password strength
const validationResult = validatePasswordStrength(newPassword);
if (!validationResult.isValid) {
res.status(400).json({
success: false,
message: 'Password does not meet security requirements',
errors: validationResult.errors,
});
return;
}
// Find user by username
const user = findUserByUsername(username);

View File

@@ -207,7 +207,8 @@ export const getCloudServersByTag = async (req: Request, res: Response): Promise
// Get tools for a specific cloud server
export const getCloudServerToolsList = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName } = req.params;
// Decode URL-encoded parameter to handle slashes in server name
const serverName = decodeURIComponent(req.params.serverName);
if (!serverName) {
res.status(400).json({
success: false,
@@ -236,7 +237,9 @@ export const getCloudServerToolsList = async (req: Request, res: Response): Prom
// Call a tool on a cloud server
export const callCloudTool = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName, toolName } = req.params;
// Decode URL-encoded parameters to handle slashes in server/tool names
const serverName = decodeURIComponent(req.params.serverName);
const toolName = decodeURIComponent(req.params.toolName);
const { arguments: args } = req.body;
if (!serverName) {

View File

@@ -0,0 +1,388 @@
/**
* 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 `
<!DOCTYPE html>
<html>
<head>
<title>${title}</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.container { background-color: ${backgroundColor}; border: 1px solid ${borderColor}; padding: 20px; border-radius: 8px; }
h1 { color: ${titleColor}; margin-top: 0; }
.detail { margin-top: 10px; padding: 10px; background: #f9f9f9; border-radius: 4px; ${type === 'error' ? 'font-family: monospace; font-size: 12px; white-space: pre-wrap;' : ''} }
.close-btn { margin-top: 20px; padding: 10px 20px; background: ${buttonColor}; color: white; border: none; border-radius: 4px; cursor: pointer; }
</style>
${autoClose ? '<script>setTimeout(() => { window.close(); }, 3000);</script>' : ''}
</head>
<body>
<div class="container">
<h1>${type === 'success' ? '✓ ' : ''}${title}</h1>
${details ? details.map((d) => `<div class="detail"><strong>${d.label}:</strong> ${d.value}</div>`).join('') : ''}
<p>${message}</p>
${autoClose ? '<p>This window will close automatically in 3 seconds...</p>' : ''}
<button class="close-btn" onclick="window.close()">${autoClose ? 'Close Now' : 'Close Window'}</button>
</div>
</body>
</html>
`;
};
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'),
),
);
}
};

View File

@@ -8,82 +8,13 @@ import {
import { getServerByName } from '../services/mcpService.js';
import { getGroupByIdOrName } from '../services/groupService.js';
import { getNameSeparator } from '../config/index.js';
import { convertParametersToTypes } from '../utils/parameterConversion.js';
/**
* Controller for OpenAPI generation endpoints
* Provides OpenAPI specifications for MCP tools to enable OpenWebUI integration
*/
/**
* Convert query parameters to their proper types based on the tool's input schema
*/
function convertQueryParametersToTypes(
queryParams: Record<string, any>,
inputSchema: Record<string, any>,
): Record<string, any> {
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
return queryParams;
}
const convertedParams: Record<string, any> = {};
const properties = inputSchema.properties;
for (const [key, value] of Object.entries(queryParams)) {
const propDef = properties[key];
if (!propDef || typeof propDef !== 'object') {
// No schema definition found, keep as is
convertedParams[key] = value;
continue;
}
const propType = propDef.type;
try {
switch (propType) {
case 'integer':
case 'number':
// Convert string to number
if (typeof value === 'string') {
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
convertedParams[key] = isNaN(numValue) ? value : numValue;
} else {
convertedParams[key] = value;
}
break;
case 'boolean':
// Convert string to boolean
if (typeof value === 'string') {
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
} else {
convertedParams[key] = value;
}
break;
case 'array':
// Handle array conversion if needed (e.g., comma-separated strings)
if (typeof value === 'string' && value.includes(',')) {
convertedParams[key] = value.split(',').map((item) => item.trim());
} else {
convertedParams[key] = value;
}
break;
default:
// For string and other types, keep as is
convertedParams[key] = value;
break;
}
} catch (error) {
// If conversion fails, keep the original value
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
convertedParams[key] = value;
}
}
return convertedParams;
}
/**
* Generate and return OpenAPI specification
* GET /api/openapi.json
@@ -167,7 +98,9 @@ export const getOpenAPIStats = async (req: Request, res: Response): Promise<void
*/
export const executeToolViaOpenAPI = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName, toolName } = req.params;
// Decode URL-encoded parameters to handle slashes in server/tool names
const serverName = decodeURIComponent(req.params.serverName);
const toolName = decodeURIComponent(req.params.toolName);
// Import handleCallToolRequest function
const { handleCallToolRequest } = await import('../services/mcpService.js');
@@ -189,7 +122,7 @@ export const executeToolViaOpenAPI = async (req: Request, res: Response): Promis
// Prepare arguments from query params (GET) or body (POST)
let args = req.method === 'GET' ? req.query : req.body || {};
args = convertQueryParametersToTypes(args, inputSchema);
args = convertParametersToTypes(args, inputSchema);
// Create a mock request structure that matches what handleCallToolRequest expects
const mockRequest = {

View File

@@ -7,7 +7,9 @@ import { handleGetPromptRequest } from '../services/mcpService.js';
*/
export const getPrompt = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName, promptName } = req.params;
// Decode URL-encoded parameters to handle slashes in server/prompt names
const serverName = decodeURIComponent(req.params.serverName);
const promptName = decodeURIComponent(req.params.promptName);
if (!serverName || !promptName) {
res.status(400).json({
success: false,

View File

@@ -375,7 +375,9 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
// Toggle tool status for a specific server
export const toggleTool = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName, toolName } = req.params;
// Decode URL-encoded parameters to handle slashes in server/tool names
const serverName = decodeURIComponent(req.params.serverName);
const toolName = decodeURIComponent(req.params.toolName);
const { enabled } = req.body;
if (!serverName || !toolName) {
@@ -437,7 +439,9 @@ export const toggleTool = async (req: Request, res: Response): Promise<void> =>
// Update tool description for a specific server
export const updateToolDescription = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName, toolName } = req.params;
// Decode URL-encoded parameters to handle slashes in server/tool names
const serverName = decodeURIComponent(req.params.serverName);
const toolName = decodeURIComponent(req.params.toolName);
const { description } = req.body;
if (!serverName || !toolName) {
@@ -529,7 +533,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
typeof mcpRouter.referer !== 'string' &&
typeof mcpRouter.title !== 'string' &&
typeof mcpRouter.baseUrl !== 'string')) &&
(typeof nameSeparator !== 'string')
typeof nameSeparator !== 'string'
) {
res.status(400).json({
success: false,
@@ -747,7 +751,9 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
// Toggle prompt status for a specific server
export const togglePrompt = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName, promptName } = req.params;
// Decode URL-encoded parameters to handle slashes in server/prompt names
const serverName = decodeURIComponent(req.params.serverName);
const promptName = decodeURIComponent(req.params.promptName);
const { enabled } = req.body;
if (!serverName || !promptName) {
@@ -809,7 +815,9 @@ export const togglePrompt = async (req: Request, res: Response): Promise<void> =
// Update prompt description for a specific server
export const updatePromptDescription = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName, promptName } = req.params;
// Decode URL-encoded parameters to handle slashes in server/prompt names
const serverName = decodeURIComponent(req.params.serverName);
const promptName = decodeURIComponent(req.params.promptName);
const { description } = req.body;
if (!serverName || !promptName) {

View File

@@ -1,6 +1,8 @@
import { Request, Response } from 'express';
import { ApiResponse } from '../types/index.js';
import { handleCallToolRequest } from '../services/mcpService.js';
import { handleCallToolRequest, getServerByName } from '../services/mcpService.js';
import { convertParametersToTypes } from '../utils/parameterConversion.js';
import { getNameSeparator } from '../config/index.js';
/**
* Interface for tool call request
@@ -47,13 +49,31 @@ export const callTool = async (req: Request, res: Response): Promise<void> => {
return;
}
// Get the server info to access the tool's input schema
const serverInfo = getServerByName(server);
let inputSchema: Record<string, any> = {};
if (serverInfo) {
// Find the tool in the server's tools list
const fullToolName = `${server}${getNameSeparator()}${toolName}`;
const tool = serverInfo.tools.find(
(t: any) => t.name === fullToolName || t.name === toolName,
);
if (tool && tool.inputSchema) {
inputSchema = tool.inputSchema as Record<string, any>;
}
}
// Convert parameters to proper types based on the tool's input schema
const convertedArgs = convertParametersToTypes(toolArgs, inputSchema);
// Create a mock request structure for handleCallToolRequest
const mockRequest = {
params: {
name: 'call_tool',
arguments: {
toolName,
arguments: toolArgs,
arguments: convertedArgs,
},
},
};
@@ -71,7 +91,7 @@ export const callTool = async (req: Request, res: Response): Promise<void> => {
data: {
content: result.content || [],
toolName,
arguments: toolArgs,
arguments: convertedArgs,
},
};

View File

@@ -10,6 +10,7 @@ import {
getAdminCount,
} from '../services/userService.js';
import { loadSettings } from '../config/index.js';
import { validatePasswordStrength } from '../utils/passwordValidation.js';
// Admin permission check middleware function
const requireAdmin = (req: Request, res: Response): boolean => {
@@ -100,6 +101,17 @@ export const createUser = async (req: Request, res: Response): Promise<void> =>
return;
}
// Validate password strength
const validationResult = validatePasswordStrength(password);
if (!validationResult.isValid) {
res.status(400).json({
success: false,
message: 'Password does not meet security requirements',
errors: validationResult.errors,
});
return;
}
const newUser = await createNewUser(username, password, isAdmin || false);
if (!newUser) {
res.status(400).json({
@@ -163,7 +175,19 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
const updateData: any = {};
if (isAdmin !== undefined) updateData.isAdmin = isAdmin;
if (newPassword) updateData.newPassword = newPassword;
if (newPassword) {
// Validate new password strength
const validationResult = validatePasswordStrength(newPassword);
if (!validationResult.isValid) {
res.status(400).json({
success: false,
message: 'Password does not meet security requirements',
errors: validationResult.errors,
});
return;
}
updateData.newPassword = newPassword;
}
if (Object.keys(updateData).length === 0) {
res.status(400).json({

View File

@@ -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);

View File

@@ -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
@@ -27,7 +28,7 @@ function getCurrentFileDir(): string {
if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined) {
return process.cwd();
}
try {
return getCurrentModuleDir();
} catch {
@@ -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');
@@ -67,28 +78,28 @@ export class AppServer {
console.log('MCP server initialized successfully');
// Original routes (global and group-based)
this.app.get(`${this.basePath}/sse/:group?`, sseUserContextMiddleware, (req, res) =>
this.app.get(`${this.basePath}/sse/:group(.*)?`, sseUserContextMiddleware, (req, res) =>
handleSseConnection(req, res),
);
this.app.post(`${this.basePath}/messages`, sseUserContextMiddleware, handleSseMessage);
this.app.post(
`${this.basePath}/mcp/:group?`,
`${this.basePath}/mcp/:group(.*)?`,
sseUserContextMiddleware,
handleMcpPostRequest,
);
this.app.get(
`${this.basePath}/mcp/:group?`,
`${this.basePath}/mcp/:group(.*)?`,
sseUserContextMiddleware,
handleMcpOtherRequest,
);
this.app.delete(
`${this.basePath}/mcp/:group?`,
`${this.basePath}/mcp/:group(.*)?`,
sseUserContextMiddleware,
handleMcpOtherRequest,
);
// User-scoped routes with user context middleware
this.app.get(`${this.basePath}/:user/sse/:group?`, sseUserContextMiddleware, (req, res) =>
this.app.get(`${this.basePath}/:user/sse/:group(.*)?`, sseUserContextMiddleware, (req, res) =>
handleSseConnection(req, res),
);
this.app.post(
@@ -97,17 +108,17 @@ export class AppServer {
handleSseMessage,
);
this.app.post(
`${this.basePath}/:user/mcp/:group?`,
`${this.basePath}/:user/mcp/:group(.*)?`,
sseUserContextMiddleware,
handleMcpPostRequest,
);
this.app.get(
`${this.basePath}/:user/mcp/:group?`,
`${this.basePath}/:user/mcp/:group(.*)?`,
sseUserContextMiddleware,
handleMcpOtherRequest,
);
this.app.delete(
`${this.basePath}/:user/mcp/:group?`,
`${this.basePath}/:user/mcp/:group(.*)?`,
sseUserContextMiddleware,
handleMcpOtherRequest,
);

View File

@@ -0,0 +1,593 @@
/**
* 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<string[] | undefined> {
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<string> {
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<void> {
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<void> {
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<void> {
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<NonNullable<ServerConfig['oauth']>['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<void> {
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<string> {
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<void> {
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<void> => {
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<OAuthClientProvider | undefined> => {
// 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;
};

View File

@@ -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<void> => {
// 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<any> => {
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: 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: conf.headers,
headers,
};
options.requestInit = {
headers: 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
@@ -206,6 +235,7 @@ const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
}
// Expand environment variables in command
transport = new StdioClientTransport({
cwd: os.homedir(),
command: conf.command,
@@ -243,10 +273,14 @@ const callToolWithReconnect = async (
const isHttp40xError = error?.message?.startsWith?.('Error POSTing to endpoint (HTTP 40');
// Only retry for StreamableHTTPClientTransport
const isStreamableHttp = serverInfo.transport instanceof StreamableHTTPClientTransport;
if (isHttp40xError && attempt < maxRetries && serverInfo.transport && isStreamableHttp) {
const isSSE = serverInfo.transport instanceof SSEClientTransport;
if (
attempt < maxRetries &&
serverInfo.transport &&
((isStreamableHttp && isHttp40xError) || isSSE)
) {
console.warn(
`HTTP 40x error detected for StreamableHTTP server ${serverInfo.name}, attempting reconnection (attempt ${attempt + 1}/${maxRetries + 1})`,
`${isHttp40xError ? 'HTTP 40x error' : 'error'} detected for ${isStreamableHttp ? 'StreamableHTTP' : 'SSE'} server ${serverInfo.name}, attempting reconnection (attempt ${attempt + 1}/${maxRetries + 1})`,
);
try {
@@ -265,7 +299,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(
@@ -341,230 +375,271 @@ export const initializeClientsFromSettings = async (
): Promise<ServerInfo[]> => {
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;
}
try {
for (const conf of allServers) {
const { name } = conf;
// 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;
}
// Expand environment variables in all configuration values
const expandedConf = replaceEnvVars(conf as any) as ServerConfigWithName;
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({
// Skip disabled servers
if (expandedConf.enabled === false) {
console.log(`Skipping disabled server: ${name}`);
nextServerInfos.push({
name,
owner: conf.owner,
owner: expandedConf.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: expandedConf.enabled === undefined ? true : expandedConf.enabled,
});
console.log(`Server '${name}' is already connected.`);
continue;
}
let transport;
let openApiClient;
if (expandedConf.type === 'openapi') {
// Handle OpenAPI type servers
if (!expandedConf.openapi?.url && !expandedConf.openapi?.schema) {
console.warn(
`Skipping OpenAPI server '${name}': missing OpenAPI specification URL or schema`,
);
nextServerInfos.push({
name,
owner: expandedConf.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: expandedConf.owner,
status: 'connecting',
error: null,
tools: [],
prompts: [],
createTime: Date.now(),
enabled: expandedConf.enabled === undefined ? true : expandedConf.enabled,
config: expandedConf, // Store reference to expanded config for OpenAPI passthrough headers
};
nextServerInfos.push(serverInfo);
try {
// Create OpenAPI client instance
openApiClient = new OpenAPIClient(expandedConf);
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, expandedConf);
}
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 = expandedConf.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,
owner: expandedConf.owner,
status: 'connecting',
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: expandedConf, // Store reference to expanded 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 = expandedConf.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, expandedConf);
} 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;
};
@@ -580,39 +655,48 @@ export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'tra
const filterServerInfos: ServerInfo[] = dataService.filterData
? dataService.filterData(serverInfos)
: serverInfos;
const infos = filterServerInfos.map(({ name, status, tools, prompts, createTime, error }) => {
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;
@@ -625,6 +709,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<void> => {
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<Tool[]> => {
const serverConfig = await serverDao.findById(serverName);
@@ -760,30 +889,48 @@ export const handleListToolsRequest = async (_: any, extra: any) => {
console.log(`Handling ListToolsRequest for group: ${group}`);
// Special handling for $smart group to return special tools
if (group === '$smart') {
// Support both $smart and $smart/{group} patterns
if (group === '$smart' || group?.startsWith('$smart/')) {
// Extract target group if pattern is $smart/{group}
const targetGroup = group?.startsWith('$smart/') ? group.substring(7) : undefined;
// Get info about available servers, filtered by target group if specified
let availableServers = serverInfos.filter(
(server) => server.status === 'connected' && server.enabled !== false,
);
// If a target group is specified, filter servers to only those in the group
if (targetGroup) {
const serversInGroup = getServersInGroup(targetGroup);
if (serversInGroup && serversInGroup.length > 0) {
availableServers = availableServers.filter((server) =>
serversInGroup.includes(server.name),
);
}
}
// Create simple server information with only server names
const serversList = availableServers
.map((server) => {
return `${server.name}`;
})
.join(', ');
const scopeDescription = targetGroup
? `servers in the "${targetGroup}" group`
: 'all available servers';
return {
tools: [
{
name: 'search_tools',
description: (() => {
// Get info about available servers
const availableServers = serverInfos.filter(
(server) => server.status === 'connected' && server.enabled !== false,
);
// Create simple server information with only server names
const serversList = availableServers
.map((server) => {
return `${server.name}`;
})
.join(', ');
return `STEP 1 of 2: Use this tool FIRST to discover and search for relevant tools across all available servers. This tool and call_tool work together as a two-step process: 1) search_tools to find what you need, 2) call_tool to execute it.
description: `STEP 1 of 2: Use this tool FIRST to discover and search for relevant tools across ${scopeDescription}. This tool and call_tool work together as a two-step process: 1) search_tools to find what you need, 2) call_tool to execute it.
For optimal results, use specific queries matching your exact needs. Call this tool multiple times with different queries for different parts of complex tasks. Example queries: "image generation tools", "code review tools", "data analysis", "translation capabilities", etc. Results are sorted by relevance using vector similarity.
After finding relevant tools, you MUST use the call_tool to actually execute them. The search_tools only finds tools - it doesn't execute them.
Available servers: ${serversList}`;
})(),
Available servers: ${serversList}`,
inputSchema: {
type: 'object',
properties: {
@@ -900,7 +1047,25 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
}
console.log(`Using similarity threshold: ${thresholdNum} for query: "${query}"`);
const servers = undefined; // No server filtering
// Determine server filtering based on group
const sessionId = extra.sessionId || '';
const group = getGroup(sessionId);
let servers: string[] | undefined = undefined; // No server filtering by default
// If group is in format $smart/{group}, filter servers to that group
if (group?.startsWith('$smart/')) {
const targetGroup = group.substring(7);
const serversInGroup = getServersInGroup(targetGroup);
if (serversInGroup !== undefined && serversInGroup !== null) {
servers = serversInGroup;
if (servers.length > 0) {
console.log(`Filtering search to servers in group "${targetGroup}": ${servers.join(', ')}`);
} else {
console.log(`Group "${targetGroup}" has no servers, search will return no results`);
}
}
}
const searchResults = await searchToolsByVector(query, limitNum, thresholdNum, servers);
console.log(`Search results: ${JSON.stringify(searchResults)}`);
@@ -1097,9 +1262,7 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
const separator = getNameSeparator();
const prefix = `${targetServerInfo.name}${separator}`;
toolName = toolName.startsWith(prefix)
? toolName.substring(prefix.length)
: toolName;
toolName = toolName.startsWith(prefix) ? toolName.substring(prefix.length) : toolName;
const result = await callToolWithReconnect(
targetServerInfo,
{
@@ -1233,9 +1396,7 @@ export const handleGetPromptRequest = async (request: any, extra: any) => {
// Remove server prefix from prompt name if present
const separator = getNameSeparator();
const prefix = `${server.name}${separator}`;
const cleanPromptName = name.startsWith(prefix)
? name.substring(prefix.length)
: name;
const cleanPromptName = name.startsWith(prefix) ? name.substring(prefix.length) : name;
const promptParams = {
name: cleanPromptName || '',

View File

@@ -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<string, RegisteredClientInfo>();
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<string[] | undefined> => {
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<client.Configuration> => {
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<RegisteredClientInfo> => {
// 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.ClientMetadata> = {
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<string> => {
try {
// Generate code challenge for PKCE (required by MCP spec)
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
// Build authorization parameters
const params: Record<string, string> = {
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<string, string> = {
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<string, string> = {};
// 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<string> => {
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<RegisteredClientInfo | null> => {
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;
};

View File

@@ -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<string | undefined> => {
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<string, string>,
): Promise<Record<string, string>> => {
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<void> => {
const settings = loadSettings();
console.log('Initializing OAuth clients for explicitly configured servers...');
const serverNames = Object.keys(settings.mcpServers);
const registrationPromises: Promise<void>[] = [];
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)');
}
};

View File

@@ -0,0 +1,158 @@
import { loadSettings, saveSettings } from '../config/index.js';
import { McpSettings, ServerConfig } from '../types/index.js';
type OAuthConfig = NonNullable<ServerConfig['oauth']>;
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<ServerConfigWithOAuth | undefined> => {
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<ServerConfigWithOAuth | undefined> => {
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<ServerConfigWithOAuth | undefined> => {
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<NonNullable<OAuthConfig['pendingAuthorization']>>,
): Promise<ServerConfigWithOAuth | undefined> => {
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<ServerConfigWithOAuth | undefined> => {
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;
}
}
});
};

View File

@@ -225,13 +225,22 @@ export async function generateOpenAPISpec(
// Generate paths from tools
const paths: OpenAPIV3.PathsObject = {};
const separator = getNameSeparator();
for (const { tool, serverName } of allTools) {
const operation = generateOperationFromTool(tool, serverName);
const { requestBody } = convertToolSchemaToOpenAPI(tool);
// Create path for the tool
const pathName = `/tools/${serverName}/${tool.name}`;
// Extract the tool name without server prefix
// Tool names are in format: serverName + separator + toolName
const prefix = `${serverName}${separator}`;
const toolNameOnly = tool.name.startsWith(prefix)
? tool.name.substring(prefix.length)
: tool.name;
// Create path for the tool with URL-encoded server and tool names
// This handles cases where names contain slashes (e.g., "com.atlassian/atlassian-mcp-server")
const pathName = `/tools/${encodeURIComponent(serverName)}/${encodeURIComponent(toolNameOnly)}`;
const method = requestBody ? 'post' : 'get';
if (!paths[pathName]) {

View File

@@ -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<string, string>; // 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<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // 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

View File

@@ -0,0 +1,93 @@
/**
* Utility functions for converting parameter types based on JSON schema definitions
*/
/**
* Convert parameters to their proper types based on the tool's input schema
* This ensures that form-submitted string values are converted to the correct types
* (e.g., numbers, booleans, arrays) before being passed to MCP tools.
*
* @param params - The parameters to convert (typically from form submission)
* @param inputSchema - The JSON schema definition for the tool's input
* @returns The converted parameters with proper types
*/
export function convertParametersToTypes(
params: Record<string, any>,
inputSchema: Record<string, any>,
): Record<string, any> {
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
return params;
}
const convertedParams: Record<string, any> = {};
const properties = inputSchema.properties;
for (const [key, value] of Object.entries(params)) {
const propDef = properties[key];
if (!propDef || typeof propDef !== 'object') {
// No schema definition found, keep as is
convertedParams[key] = value;
continue;
}
const propType = propDef.type;
try {
switch (propType) {
case 'integer':
case 'number':
// Convert string to number
if (typeof value === 'string') {
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
convertedParams[key] = isNaN(numValue) ? value : numValue;
} else {
convertedParams[key] = value;
}
break;
case 'boolean':
// Convert string to boolean
if (typeof value === 'string') {
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
} else {
convertedParams[key] = value;
}
break;
case 'array':
// Handle array conversion if needed (e.g., comma-separated strings)
if (typeof value === 'string' && value.includes(',')) {
convertedParams[key] = value.split(',').map((item) => item.trim());
} else {
convertedParams[key] = value;
}
break;
case 'object':
// Handle object conversion if needed
if (typeof value === 'string') {
try {
convertedParams[key] = JSON.parse(value);
} catch {
// If parsing fails, keep as is
convertedParams[key] = value;
}
} else {
convertedParams[key] = value;
}
break;
default:
// For string and other types, keep as is
convertedParams[key] = value;
break;
}
} catch (error) {
// If conversion fails, keep the original value
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
convertedParams[key] = value;
}
}
return convertedParams;
}

View File

@@ -0,0 +1,49 @@
/**
* Password strength validation utility
* Requirements:
* - At least 8 characters
* - Contains at least one letter
* - Contains at least one number
* - Contains at least one special character
*/
export interface PasswordValidationResult {
isValid: boolean;
errors: string[];
}
export const validatePasswordStrength = (password: string): PasswordValidationResult => {
const errors: string[] = [];
// Check minimum length
if (password.length < 8) {
errors.push('Password must be at least 8 characters long');
}
// Check for at least one letter
if (!/[a-zA-Z]/.test(password)) {
errors.push('Password must contain at least one letter');
}
// Check for at least one number
if (!/\d/.test(password)) {
errors.push('Password must contain at least one number');
}
// Check for at least one special character
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
errors.push('Password must contain at least one special character');
}
return {
isValid: errors.length === 0,
errors,
};
};
/**
* Check if a password is the default password (admin123)
*/
export const isDefaultPassword = (plainPassword: string): boolean => {
return plainPassword === 'admin123';
};

View File

@@ -18,18 +18,18 @@ function initializePackageRoot(): void {
if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined) {
return;
}
try {
// Try to get the current module's directory
const currentModuleDir = getCurrentModuleDir();
// This file is in src/utils/path.ts (or dist/utils/path.js when compiled)
// So package.json should be 2 levels up
const possibleRoots = [
path.resolve(currentModuleDir, '..', '..'), // dist -> package root
path.resolve(currentModuleDir, '..'), // dist/utils -> dist -> package root
];
for (const root of possibleRoots) {
const packageJsonPath = path.join(root, 'package.json');
if (fs.existsSync(packageJsonPath)) {
@@ -66,10 +66,10 @@ export const findPackageRoot = (startPath?: string): string | null => {
}
const debug = process.env.DEBUG === 'true';
// Possible locations for package.json relative to the search path
const possibleRoots: string[] = [];
if (startPath) {
// When start path is provided (from fileURLToPath(import.meta.url))
possibleRoots.push(
@@ -78,25 +78,30 @@ export const findPackageRoot = (startPath?: string): string | null => {
// When in dist/ (compiled code) - go up 1 level
path.resolve(startPath, '..'),
// Direct parent directories
path.resolve(startPath)
path.resolve(startPath),
);
}
// Try to use require.resolve to find the module location (works in CommonJS and ESM with createRequire)
try {
// In ESM, we can use import.meta.resolve, but it's async in some versions
// So we'll try to find the module by checking the node_modules structure
// Check if this file is in a node_modules installation
const currentFile = new Error().stack?.split('\n')[2]?.match(/\((.+?):\d+:\d+\)$/)?.[1];
if (currentFile) {
const nodeModulesIndex = currentFile.indexOf('node_modules');
if (nodeModulesIndex !== -1) {
// Extract the package path from node_modules
const afterNodeModules = currentFile.substring(nodeModulesIndex + 'node_modules'.length + 1);
const afterNodeModules = currentFile.substring(
nodeModulesIndex + 'node_modules'.length + 1,
);
const packageNameEnd = afterNodeModules.indexOf(path.sep);
if (packageNameEnd !== -1) {
const packagePath = currentFile.substring(0, nodeModulesIndex + 'node_modules'.length + 1 + packageNameEnd);
const packagePath = currentFile.substring(
0,
nodeModulesIndex + 'node_modules'.length + 1 + packageNameEnd,
);
possibleRoots.push(packagePath);
}
}
@@ -108,18 +113,15 @@ export const findPackageRoot = (startPath?: string): string | null => {
// Check module.filename location (works in Node.js when available)
if (typeof __filename !== 'undefined') {
const moduleDir = path.dirname(__filename);
possibleRoots.push(
path.resolve(moduleDir, '..', '..'),
path.resolve(moduleDir, '..')
);
possibleRoots.push(path.resolve(moduleDir, '..', '..'), path.resolve(moduleDir, '..'));
}
// Check common installation locations
possibleRoots.push(
// Current working directory (for development/tests)
process.cwd(),
// Parent of cwd
path.resolve(process.cwd(), '..')
path.resolve(process.cwd(), '..'),
);
if (debug) {
@@ -157,12 +159,12 @@ export const findPackageRoot = (startPath?: string): string | null => {
if (debug) {
console.warn('DEBUG: Could not find package root directory');
}
// Cache null result as well to avoid repeated searches
if (!startPath) {
cachedPackageRoot = null;
}
return null;
};

View File

@@ -11,13 +11,13 @@ export const getPackageVersion = (searchPath?: string): string => {
try {
// Use provided path or fallback to current working directory
const startPath = searchPath || process.cwd();
const packageRoot = findPackageRoot(startPath);
if (!packageRoot) {
console.warn('Could not find package root, using default version');
return 'dev';
}
const packageJsonPath = path.join(packageRoot, 'package.json');
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
const packageJson = JSON.parse(packageJsonContent);

View File

@@ -0,0 +1,343 @@
import { replaceEnvVars, expandEnvVars } from '../../src/config/index.js';
describe('Environment Variable Expansion - Comprehensive Tests', () => {
const originalEnv = process.env;
beforeEach(() => {
// Reset process.env before each test
jest.resetModules();
process.env = { ...originalEnv };
});
afterAll(() => {
// Restore original environment
process.env = originalEnv;
});
describe('expandEnvVars - String expansion', () => {
it('should expand ${VAR} format', () => {
process.env.TEST_VAR = 'test-value';
expect(expandEnvVars('${TEST_VAR}')).toBe('test-value');
});
it('should expand $VAR format', () => {
process.env.TEST_VAR = 'test-value';
expect(expandEnvVars('$TEST_VAR')).toBe('test-value');
});
it('should expand multiple variables', () => {
process.env.HOST = 'localhost';
process.env.PORT = '3000';
expect(expandEnvVars('http://${HOST}:${PORT}')).toBe('http://localhost:3000');
});
it('should return empty string for undefined variables', () => {
expect(expandEnvVars('${UNDEFINED_VAR}')).toBe('');
});
it('should handle strings without variables', () => {
expect(expandEnvVars('plain-string')).toBe('plain-string');
});
it('should handle mixed variable formats', () => {
process.env.VAR1 = 'value1';
process.env.VAR2 = 'value2';
expect(expandEnvVars('$VAR1-${VAR2}')).toBe('value1-value2');
});
});
describe('replaceEnvVars - Recursive expansion', () => {
it('should expand environment variables in nested objects', () => {
process.env.API_KEY = 'secret123';
process.env.BASE_URL = 'https://api.example.com';
const config = {
url: '${BASE_URL}/endpoint',
headers: {
'X-API-Key': '${API_KEY}',
'Content-Type': 'application/json',
},
nested: {
value: '$API_KEY',
},
};
const result = replaceEnvVars(config);
expect(result).toEqual({
url: 'https://api.example.com/endpoint',
headers: {
'X-API-Key': 'secret123',
'Content-Type': 'application/json',
},
nested: {
value: 'secret123',
},
});
});
it('should expand environment variables in arrays', () => {
process.env.ARG1 = 'value1';
process.env.ARG2 = 'value2';
const args = ['--arg1', '${ARG1}', '--arg2', '${ARG2}'];
const result = replaceEnvVars(args);
expect(result).toEqual(['--arg1', 'value1', '--arg2', 'value2']);
});
it('should expand environment variables in nested arrays', () => {
process.env.ITEM = 'test-item';
const config = {
items: ['${ITEM}', 'static-item'],
};
const result = replaceEnvVars(config);
expect(result).toEqual({
items: ['test-item', 'static-item'],
});
});
it('should preserve non-string values', () => {
const config = {
enabled: true,
timeout: 3000,
ratio: 0.5,
nullable: null,
};
const result = replaceEnvVars(config);
expect(result).toEqual({
enabled: true,
timeout: 3000,
ratio: 0.5,
nullable: null,
});
});
it('should expand deeply nested structures', () => {
process.env.DEEP_VALUE = 'deep-secret';
const config = {
level1: {
level2: {
level3: {
value: '${DEEP_VALUE}',
},
},
},
};
const result = replaceEnvVars(config);
expect(result).toEqual({
level1: {
level2: {
level3: {
value: 'deep-secret',
},
},
},
});
});
it('should expand environment variables in mixed nested structures', () => {
process.env.VAR1 = 'value1';
process.env.VAR2 = 'value2';
const config = {
array: [
{
key: '${VAR1}',
},
{
key: '${VAR2}',
},
],
};
const result = replaceEnvVars(config);
expect(result).toEqual({
array: [
{
key: 'value1',
},
{
key: 'value2',
},
],
});
});
});
describe('ServerConfig scenarios', () => {
it('should expand URL with environment variables', () => {
process.env.SERVER_HOST = 'api.example.com';
process.env.SERVER_PORT = '8080';
const config = {
type: 'sse',
url: 'https://${SERVER_HOST}:${SERVER_PORT}/mcp',
};
const result = replaceEnvVars(config);
expect(result.url).toBe('https://api.example.com:8080/mcp');
});
it('should expand command with environment variables', () => {
process.env.PYTHON_PATH = '/usr/bin/python3';
const config = {
type: 'stdio',
command: '${PYTHON_PATH}',
args: ['-m', 'my_module'],
};
const result = replaceEnvVars(config);
expect(result.command).toBe('/usr/bin/python3');
});
it('should expand OpenAPI configuration', () => {
process.env.API_BASE_URL = 'https://api.example.com';
process.env.API_KEY = 'secret-key-123';
const config = {
type: 'openapi',
openapi: {
url: '${API_BASE_URL}/openapi.json',
security: {
type: 'apiKey',
apiKey: {
name: 'X-API-Key',
in: 'header',
value: '${API_KEY}',
},
},
},
};
const result = replaceEnvVars(config);
expect(result.openapi.url).toBe('https://api.example.com/openapi.json');
expect(result.openapi.security.apiKey.value).toBe('secret-key-123');
});
it('should expand OAuth configuration', () => {
process.env.CLIENT_ID = 'my-client-id';
process.env.CLIENT_SECRET = 'my-client-secret';
process.env.ACCESS_TOKEN = 'my-access-token';
const config = {
type: 'sse',
url: 'https://mcp.example.com',
oauth: {
clientId: '${CLIENT_ID}',
clientSecret: '${CLIENT_SECRET}',
accessToken: '${ACCESS_TOKEN}',
scopes: ['read', 'write'],
},
};
const result = replaceEnvVars(config);
expect(result.oauth.clientId).toBe('my-client-id');
expect(result.oauth.clientSecret).toBe('my-client-secret');
expect(result.oauth.accessToken).toBe('my-access-token');
expect(result.oauth.scopes).toEqual(['read', 'write']);
});
it('should expand environment variables in env object', () => {
process.env.API_KEY = 'my-api-key';
process.env.DEBUG = 'true';
const config = {
type: 'stdio',
command: 'node',
args: ['server.js'],
env: {
MY_API_KEY: '${API_KEY}',
DEBUG: '${DEBUG}',
},
};
const result = replaceEnvVars(config);
expect(result.env.MY_API_KEY).toBe('my-api-key');
expect(result.env.DEBUG).toBe('true');
});
it('should handle complete server configuration', () => {
process.env.SERVER_URL = 'https://mcp.example.com';
process.env.AUTH_TOKEN = 'bearer-token-123';
process.env.TIMEOUT = '60000';
const config = {
type: 'streamable-http',
url: '${SERVER_URL}/mcp',
headers: {
Authorization: 'Bearer ${AUTH_TOKEN}',
'User-Agent': 'MCPHub/1.0',
},
options: {
timeout: 30000,
},
enabled: true,
};
const result = replaceEnvVars(config);
expect(result.url).toBe('https://mcp.example.com/mcp');
expect(result.headers.Authorization).toBe('Bearer bearer-token-123');
expect(result.headers['User-Agent']).toBe('MCPHub/1.0');
expect(result.options.timeout).toBe(30000);
expect(result.enabled).toBe(true);
});
});
describe('Edge cases', () => {
it('should handle empty string values', () => {
const config = {
value: '',
};
const result = replaceEnvVars(config);
expect(result.value).toBe('');
});
it('should handle undefined values', () => {
const result = replaceEnvVars(undefined);
expect(result).toEqual([]);
});
it('should handle null values in objects', () => {
const config = {
value: null,
};
const result = replaceEnvVars(config);
expect(result.value).toBe(null);
});
it('should not break on circular references prevention', () => {
// Note: This test ensures we don't have infinite recursion issues
// by using a deeply nested structure
process.env.DEEP = 'value';
const config = {
a: { b: { c: { d: { e: { f: { g: { h: { i: { j: '${DEEP}' } } } } } } } } },
};
const result = replaceEnvVars(config);
expect(result.a.b.c.d.e.f.g.h.i.j).toBe('value');
});
});
});

View File

@@ -1,73 +1,7 @@
// Simple unit test to validate the type conversion logic
describe('Parameter Type Conversion Logic', () => {
// Extract the conversion function for testing
function convertQueryParametersToTypes(
queryParams: Record<string, any>,
inputSchema: Record<string, any>
): Record<string, any> {
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
return queryParams;
}
const convertedParams: Record<string, any> = {};
const properties = inputSchema.properties;
for (const [key, value] of Object.entries(queryParams)) {
const propDef = properties[key];
if (!propDef || typeof propDef !== 'object') {
// No schema definition found, keep as is
convertedParams[key] = value;
continue;
}
const propType = propDef.type;
try {
switch (propType) {
case 'integer':
case 'number':
// Convert string to number
if (typeof value === 'string') {
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
convertedParams[key] = isNaN(numValue) ? value : numValue;
} else {
convertedParams[key] = value;
}
break;
case 'boolean':
// Convert string to boolean
if (typeof value === 'string') {
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
} else {
convertedParams[key] = value;
}
break;
case 'array':
// Handle array conversion if needed (e.g., comma-separated strings)
if (typeof value === 'string' && value.includes(',')) {
convertedParams[key] = value.split(',').map(item => item.trim());
} else {
convertedParams[key] = value;
}
break;
default:
// For string and other types, keep as is
convertedParams[key] = value;
break;
}
} catch (error) {
// If conversion fails, keep the original value
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
convertedParams[key] = value;
}
}
return convertedParams;
}
import { convertParametersToTypes } from '../../src/utils/parameterConversion.js';
// Integration tests for OpenAPI controller's parameter type conversion
describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
test('should convert integer parameters correctly', () => {
const queryParams = {
limit: '5',
@@ -84,7 +18,7 @@ describe('Parameter Type Conversion Logic', () => {
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
const result = convertParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
limit: 5, // Converted to integer
@@ -107,7 +41,7 @@ describe('Parameter Type Conversion Logic', () => {
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
const result = convertParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
price: 19.99,
@@ -133,7 +67,7 @@ describe('Parameter Type Conversion Logic', () => {
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
const result = convertParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
enabled: true,
@@ -157,7 +91,7 @@ describe('Parameter Type Conversion Logic', () => {
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
const result = convertParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
tags: ['tag1', 'tag2', 'tag3'],
@@ -171,7 +105,7 @@ describe('Parameter Type Conversion Logic', () => {
name: 'test'
};
const result = convertQueryParametersToTypes(queryParams, {});
const result = convertParametersToTypes(queryParams, {});
expect(result).toEqual({
limit: '5', // Should remain as string
@@ -192,7 +126,7 @@ describe('Parameter Type Conversion Logic', () => {
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
const result = convertParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
limit: 5, // Converted based on schema
@@ -214,7 +148,7 @@ describe('Parameter Type Conversion Logic', () => {
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
const result = convertParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
limit: 'not-a-number', // Should remain as string when conversion fails
@@ -299,4 +233,16 @@ describe('OpenAPI Granular Endpoints', () => {
const group = mockGetGroupByIdOrName('nonexistent');
expect(group).toBeNull();
});
test('should decode URL-encoded server and tool names with slashes', () => {
// Test that URL-encoded names with slashes are properly decoded
const encodedServerName = 'com.atlassian%2Fatlassian-mcp-server';
const encodedToolName = 'atlassianUserInfo';
const decodedServerName = decodeURIComponent(encodedServerName);
const decodedToolName = decodeURIComponent(encodedToolName);
expect(decodedServerName).toBe('com.atlassian/atlassian-mcp-server');
expect(decodedToolName).toBe('atlassianUserInfo');
});
});

View File

@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import request from 'supertest';
// Mock dependencies
jest.mock('../../src/utils/i18n.js', () => ({
__esModule: true,
initI18n: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../src/models/User.js', () => ({
__esModule: true,
initializeDefaultUser: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../src/services/oauthService.js', () => ({
__esModule: true,
initOAuthProvider: jest.fn(),
getOAuthRouter: jest.fn(() => null),
}));
jest.mock('../../src/services/mcpService.js', () => ({
__esModule: true,
initUpstreamServers: jest.fn().mockResolvedValue(undefined),
connected: jest.fn().mockReturnValue(true),
}));
jest.mock('../../src/middlewares/userContext.js', () => ({
__esModule: true,
userContextMiddleware: jest.fn((_req, _res, next) => next()),
sseUserContextMiddleware: jest.fn((_req, _res, next) => next()),
}));
describe('AppServer with BASE_PATH configuration', () => {
// Save original BASE_PATH
const originalBasePath = process.env.BASE_PATH;
beforeEach(() => {
jest.clearAllMocks();
// Clear module cache to allow fresh imports with different config
jest.resetModules();
});
afterEach(() => {
// Restore original BASE_PATH or remove it
if (originalBasePath !== undefined) {
process.env.BASE_PATH = originalBasePath;
} else {
delete process.env.BASE_PATH;
}
});
const flushPromises = async () => {
await new Promise((resolve) => setImmediate(resolve));
};
it('should serve auth routes with BASE_PATH=/mcphub/', async () => {
// Set environment variable for BASE_PATH (with trailing slash)
process.env.BASE_PATH = '/mcphub/';
// Dynamically import after setting env var
const { AppServer } = await import('../../src/server.js');
const config = await import('../../src/config/index.js');
// Verify config loaded the BASE_PATH and normalized it (removed trailing slash)
expect(config.default.basePath).toBe('/mcphub');
const appServer = new AppServer();
await appServer.initialize();
await flushPromises();
const app = appServer.getApp();
// Test that /mcphub/config endpoint exists
const configResponse = await request(app).get('/mcphub/config');
expect(configResponse.status).not.toBe(404);
// Test that /mcphub/public-config endpoint exists
const publicConfigResponse = await request(app).get('/mcphub/public-config');
expect(publicConfigResponse.status).not.toBe(404);
});
it('should serve auth routes without BASE_PATH (default)', async () => {
// Ensure BASE_PATH is not set
delete process.env.BASE_PATH;
// Dynamically import after clearing env var
jest.resetModules();
const { AppServer } = await import('../../src/server.js');
const config = await import('../../src/config/index.js');
// Verify config has empty BASE_PATH
expect(config.default.basePath).toBe('');
const appServer = new AppServer();
await appServer.initialize();
await flushPromises();
const app = appServer.getApp();
// Test that /config endpoint exists (without base path)
const configResponse = await request(app).get('/config');
expect(configResponse.status).not.toBe(404);
// Test that /public-config endpoint exists
const publicConfigResponse = await request(app).get('/public-config');
expect(publicConfigResponse.status).not.toBe(404);
});
it('should serve global endpoints without BASE_PATH prefix', async () => {
process.env.BASE_PATH = '/test-base/';
jest.resetModules();
const { AppServer } = await import('../../src/server.js');
const appServer = new AppServer();
await appServer.initialize();
await flushPromises();
const app = appServer.getApp();
// Test that /health endpoint is accessible globally (no BASE_PATH prefix)
// The /health endpoint is intentionally mounted without BASE_PATH
const healthResponse = await request(app).get('/health');
expect(healthResponse.status).not.toBe(404);
// Also verify that BASE_PATH prefixed routes exist
const configResponse = await request(app).get('/test-base/config');
expect(configResponse.status).not.toBe(404);
});
});

View File

@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import request from 'supertest';
const handleSseConnectionMock = jest.fn();
const handleSseMessageMock = jest.fn();
const handleMcpPostRequestMock = jest.fn();
const handleMcpOtherRequestMock = jest.fn();
const sseUserContextMiddlewareMock = jest.fn((_req, _res, next) => next());
jest.mock('../../src/utils/i18n.js', () => ({
__esModule: true,
initI18n: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../src/models/User.js', () => ({
__esModule: true,
initializeDefaultUser: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../src/services/oauthService.js', () => ({
__esModule: true,
initOAuthProvider: jest.fn(),
getOAuthRouter: jest.fn(() => null),
}));
jest.mock('../../src/middlewares/index.js', () => ({
__esModule: true,
initMiddlewares: jest.fn(),
}));
jest.mock('../../src/routes/index.js', () => ({
__esModule: true,
initRoutes: jest.fn(),
}));
jest.mock('../../src/services/mcpService.js', () => ({
__esModule: true,
initUpstreamServers: jest.fn().mockResolvedValue(undefined),
connected: jest.fn().mockReturnValue(true),
}));
jest.mock('../../src/services/sseService.js', () => ({
__esModule: true,
handleSseConnection: handleSseConnectionMock,
handleSseMessage: handleSseMessageMock,
handleMcpPostRequest: handleMcpPostRequestMock,
handleMcpOtherRequest: handleMcpOtherRequestMock,
}));
jest.mock('../../src/middlewares/userContext.js', () => ({
__esModule: true,
userContextMiddleware: jest.fn((_req, _res, next) => next()),
sseUserContextMiddleware: sseUserContextMiddlewareMock,
}));
import { AppServer } from '../../src/server.js';
const flushPromises = async () => {
await new Promise((resolve) => setImmediate(resolve));
};
describe('AppServer smart routing group paths', () => {
beforeEach(() => {
jest.clearAllMocks();
handleMcpPostRequestMock.mockImplementation(async (_req, res) => {
res.status(204).send();
});
sseUserContextMiddlewareMock.mockImplementation((_req, _res, next) => next());
});
const createApp = async () => {
const appServer = new AppServer();
await appServer.initialize();
await flushPromises();
return appServer.getApp();
};
it('routes global MCP requests with nested smart group segments', async () => {
const app = await createApp();
await request(app).post('/mcp/$smart/test-group').send({}).expect(204);
expect(handleMcpPostRequestMock).toHaveBeenCalledTimes(1);
const [req] = handleMcpPostRequestMock.mock.calls[0];
expect(req.params.group).toBe('$smart/test-group');
});
it('routes user-scoped MCP requests with nested smart group segments', async () => {
const app = await createApp();
await request(app).post('/alice/mcp/$smart/staging').send({}).expect(204);
expect(handleMcpPostRequestMock).toHaveBeenCalledTimes(1);
const [req] = handleMcpPostRequestMock.mock.calls[0];
expect(req.params.group).toBe('$smart/staging');
expect(req.params.user).toBe('alice');
});
});

View File

@@ -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';

View File

@@ -0,0 +1,179 @@
import { expandEnvVars, replaceEnvVars } from '../../src/config/index.js';
describe('MCP Service - Headers Environment Variable Expansion', () => {
const originalEnv = process.env;
beforeEach(() => {
// Reset process.env before each test
jest.resetModules();
process.env = { ...originalEnv };
});
afterAll(() => {
// Restore original environment
process.env = originalEnv;
});
describe('expandEnvVars', () => {
it('should expand environment variables in ${VAR} format', () => {
process.env.CONTEXT7_API_KEY = 'ctx7sk-test123';
const result = expandEnvVars('${CONTEXT7_API_KEY}');
expect(result).toBe('ctx7sk-test123');
});
it('should expand environment variables in $VAR format', () => {
process.env.TEST_VAR = 'test-value';
const result = expandEnvVars('$TEST_VAR');
expect(result).toBe('test-value');
});
it('should expand multiple environment variables', () => {
process.env.VAR1 = 'value1';
process.env.VAR2 = 'value2';
const result = expandEnvVars('${VAR1}-and-${VAR2}');
expect(result).toBe('value1-and-value2');
});
it('should return empty string for undefined variables', () => {
const result = expandEnvVars('${UNDEFINED_VAR}');
expect(result).toBe('');
});
it('should handle strings without variables', () => {
const result = expandEnvVars('plain-string');
expect(result).toBe('plain-string');
});
});
describe('replaceEnvVars - Object (Headers)', () => {
it('should expand environment variables in header values', () => {
process.env.CONTEXT7_API_KEY = 'ctx7sk-d16example123';
const headers = {
CONTEXT7_API_KEY: '${CONTEXT7_API_KEY}',
};
const result = replaceEnvVars(headers);
expect(result).toEqual({
CONTEXT7_API_KEY: 'ctx7sk-d16example123',
});
});
it('should expand multiple headers with environment variables', () => {
process.env.API_KEY = 'test-api-key';
process.env.AUTH_TOKEN = 'test-auth-token';
const headers = {
'X-API-Key': '${API_KEY}',
Authorization: 'Bearer ${AUTH_TOKEN}',
'Content-Type': 'application/json',
};
const result = replaceEnvVars(headers);
expect(result).toEqual({
'X-API-Key': 'test-api-key',
Authorization: 'Bearer test-auth-token',
'Content-Type': 'application/json',
});
});
it('should handle $VAR format in headers', () => {
process.env.MY_KEY = 'my-value';
const headers = {
'X-Custom-Header': '$MY_KEY',
};
const result = replaceEnvVars(headers);
expect(result).toEqual({
'X-Custom-Header': 'my-value',
});
});
it('should return empty string for undefined variables in headers', () => {
const headers = {
'X-Undefined': '${UNDEFINED_VAR}',
};
const result = replaceEnvVars(headers);
expect(result).toEqual({
'X-Undefined': '',
});
});
it('should handle mix of variables and static values', () => {
process.env.TOKEN = 'secret123';
const headers = {
Authorization: 'Bearer ${TOKEN}',
'Content-Type': 'application/json',
'X-Custom': 'static-value',
};
const result = replaceEnvVars(headers);
expect(result).toEqual({
Authorization: 'Bearer secret123',
'Content-Type': 'application/json',
'X-Custom': 'static-value',
});
});
it('should handle empty object', () => {
const headers = {};
const result = replaceEnvVars(headers);
expect(result).toEqual({});
});
});
describe('replaceEnvVars - Array (Args)', () => {
it('should expand environment variables in array elements', () => {
process.env.PORT = '3000';
const args = ['--port', '${PORT}'];
const result = replaceEnvVars(args);
expect(result).toEqual(['--port', '3000']);
});
it('should handle multiple variables in array', () => {
process.env.HOST = 'localhost';
process.env.PORT = '8080';
const args = ['--host', '${HOST}', '--port', '${PORT}'];
const result = replaceEnvVars(args);
expect(result).toEqual(['--host', 'localhost', '--port', '8080']);
});
});
describe('Real-world Context7 Scenario', () => {
it('should correctly expand Context7 API key from environment', () => {
// Simulate the environment variable being set in the container
process.env.CONTEXT7_API_KEY = 'ctx7sk-d16examplekey123';
// Simulate the configuration from mcp_settings.json
const serverConfig = {
type: 'streamable-http',
url: 'https://mcp.context7.com/mcp',
headers: {
CONTEXT7_API_KEY: '${CONTEXT7_API_KEY}',
},
enabled: true,
};
// Simulate what happens in createTransportFromConfig
const expandedHeaders = replaceEnvVars(serverConfig.headers);
// Verify that the environment variable was correctly expanded
expect(expandedHeaders.CONTEXT7_API_KEY).toBe('ctx7sk-d16examplekey123');
expect(expandedHeaders.CONTEXT7_API_KEY).not.toBe('${CONTEXT7_API_KEY}');
expect(expandedHeaders.CONTEXT7_API_KEY).toMatch(/^ctx7sk-/);
});
it('should handle case when environment variable is not set', () => {
// Don't set the environment variable
delete process.env.CONTEXT7_API_KEY;
const serverConfig = {
type: 'streamable-http',
url: 'https://mcp.context7.com/mcp',
headers: {
CONTEXT7_API_KEY: '${CONTEXT7_API_KEY}',
},
enabled: true,
};
const expandedHeaders = replaceEnvVars(serverConfig.headers);
// Should be empty string when env var is not set
expect(expandedHeaders.CONTEXT7_API_KEY).toBe('');
});
});
});

View File

@@ -0,0 +1,222 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
// Mock dependencies before importing mcpService
jest.mock('../../src/services/oauthService.js', () => ({
initializeAllOAuthClients: jest.fn(),
}));
jest.mock('../../src/services/oauthClientRegistration.js', () => ({
registerOAuthClient: jest.fn(),
}));
jest.mock('../../src/services/mcpOAuthProvider.js', () => ({
createOAuthProvider: jest.fn(),
}));
jest.mock('../../src/services/groupService.js', () => ({
getServersInGroup: jest.fn((groupId: string) => {
if (groupId === 'test-group') {
return ['server1', 'server2'];
}
if (groupId === 'empty-group') {
return [];
}
return undefined;
}),
getServerConfigInGroup: jest.fn(),
}));
jest.mock('../../src/services/sseService.js', () => ({
getGroup: jest.fn((sessionId: string) => {
if (sessionId === 'session-smart') return '$smart';
if (sessionId === 'session-smart-group') return '$smart/test-group';
if (sessionId === 'session-smart-empty') return '$smart/empty-group';
return '';
}),
}));
jest.mock('../../src/dao/index.js', () => ({
getServerDao: jest.fn(() => ({
findById: jest.fn(),
findAll: jest.fn(() => Promise.resolve([])),
})),
}));
jest.mock('../../src/services/services.js', () => ({
getDataService: jest.fn(() => ({
filterData: (data: any) => data,
})),
}));
jest.mock('../../src/services/vectorSearchService.js', () => ({
searchToolsByVector: jest.fn(),
saveToolsAsVectorEmbeddings: jest.fn(),
}));
jest.mock('../../src/config/index.js', () => ({
loadSettings: jest.fn(),
expandEnvVars: jest.fn((val: string) => val),
replaceEnvVars: jest.fn((val: any) => val),
getNameSeparator: jest.fn(() => '::'),
default: {
mcpHubName: 'test-hub',
mcpHubVersion: '1.0.0',
},
}));
// Import after mocks are set up
import { handleListToolsRequest, handleCallToolRequest } from '../../src/services/mcpService.js';
import { getServersInGroup } from '../../src/services/groupService.js';
import { getGroup } from '../../src/services/sseService.js';
import { searchToolsByVector } from '../../src/services/vectorSearchService.js';
describe('MCP Service - Smart Routing with Group Support', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('handleListToolsRequest', () => {
it('should return search_tools and call_tool for $smart group', async () => {
const result = await handleListToolsRequest({}, { sessionId: 'session-smart' });
expect(result.tools).toHaveLength(2);
expect(result.tools[0].name).toBe('search_tools');
expect(result.tools[1].name).toBe('call_tool');
expect(result.tools[0].description).toContain('all available servers');
});
it('should return filtered tools for $smart/{group} pattern', async () => {
const result = await handleListToolsRequest({}, { sessionId: 'session-smart-group' });
expect(getGroup).toHaveBeenCalledWith('session-smart-group');
expect(getServersInGroup).toHaveBeenCalledWith('test-group');
expect(result.tools).toHaveLength(2);
expect(result.tools[0].name).toBe('search_tools');
expect(result.tools[1].name).toBe('call_tool');
expect(result.tools[0].description).toContain('servers in the "test-group" group');
});
it('should handle $smart with empty group', async () => {
const result = await handleListToolsRequest({}, { sessionId: 'session-smart-empty' });
expect(getGroup).toHaveBeenCalledWith('session-smart-empty');
expect(getServersInGroup).toHaveBeenCalledWith('empty-group');
expect(result.tools).toHaveLength(2);
expect(result.tools[0].name).toBe('search_tools');
expect(result.tools[1].name).toBe('call_tool');
// Should still show group-scoped message even if group is empty
expect(result.tools[0].description).toContain('servers in the "empty-group" group');
});
});
describe('handleCallToolRequest - search_tools', () => {
it('should search across all servers when using $smart', async () => {
const mockSearchResults = [
{
serverName: 'server1',
toolName: 'server1::tool1',
description: 'Test tool 1',
inputSchema: {},
},
];
(searchToolsByVector as jest.Mock).mockResolvedValue(mockSearchResults);
const request = {
params: {
name: 'search_tools',
arguments: {
query: 'test query',
limit: 10,
},
},
};
await handleCallToolRequest(request, { sessionId: 'session-smart' });
expect(searchToolsByVector).toHaveBeenCalledWith(
'test query',
10,
expect.any(Number),
undefined, // No server filtering
);
});
it('should filter servers when using $smart/{group}', async () => {
const mockSearchResults = [
{
serverName: 'server1',
toolName: 'server1::tool1',
description: 'Test tool 1',
inputSchema: {},
},
];
(searchToolsByVector as jest.Mock).mockResolvedValue(mockSearchResults);
const request = {
params: {
name: 'search_tools',
arguments: {
query: 'test query',
limit: 10,
},
},
};
await handleCallToolRequest(request, { sessionId: 'session-smart-group' });
expect(getGroup).toHaveBeenCalledWith('session-smart-group');
expect(getServersInGroup).toHaveBeenCalledWith('test-group');
expect(searchToolsByVector).toHaveBeenCalledWith(
'test query',
10,
expect.any(Number),
['server1', 'server2'], // Filtered to group servers
);
});
it('should handle empty group in $smart/{group}', async () => {
const mockSearchResults: any[] = [];
(searchToolsByVector as jest.Mock).mockResolvedValue(mockSearchResults);
const request = {
params: {
name: 'search_tools',
arguments: {
query: 'test query',
limit: 10,
},
},
};
await handleCallToolRequest(request, { sessionId: 'session-smart-empty' });
expect(getGroup).toHaveBeenCalledWith('session-smart-empty');
expect(getServersInGroup).toHaveBeenCalledWith('empty-group');
// Empty group returns empty array, which should still be passed to search
expect(searchToolsByVector).toHaveBeenCalledWith(
'test query',
10,
expect.any(Number),
[], // Empty group
);
});
it('should validate query parameter', async () => {
const request = {
params: {
name: 'search_tools',
arguments: {
limit: 10,
},
},
};
const result = await handleCallToolRequest(request, { sessionId: 'session-smart' });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Query parameter is required');
});
});
});

View File

@@ -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<typeof config.loadSettings>;
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',
});
});
});
});

View File

@@ -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', () => {
@@ -51,6 +65,27 @@ describe('OpenAPI Generator Service', () => {
expect(spec).toHaveProperty('paths');
expect(typeof spec.paths).toBe('object');
});
it('should URL-encode server and tool names with slashes in paths', async () => {
const spec = await generateOpenAPISpec();
// Check if any paths contain URL-encoded values
// Paths with slashes in server/tool names should be encoded
const paths = Object.keys(spec.paths);
// If there are any servers with slashes, verify encoding
// e.g., "com.atlassian/atlassian-mcp-server" should become "com.atlassian%2Fatlassian-mcp-server"
for (const path of paths) {
// Path should not have unencoded slashes in the middle segments
// Valid format: /tools/{encoded-server}/{encoded-tool}
const pathSegments = path.split('/').filter((s) => s.length > 0);
if (pathSegments[0] === 'tools' && pathSegments.length >= 3) {
// The server name (segment 1) and tool name (segment 2+) should not create extra segments
// If properly encoded, there should be exactly 3 segments: ['tools', serverName, toolName]
expect(pathSegments.length).toBe(3);
}
}
});
});
describe('getToolStats', () => {

View File

@@ -0,0 +1,259 @@
import { convertParametersToTypes } from '../../src/utils/parameterConversion.js';
describe('Parameter Conversion Utilities', () => {
describe('convertParametersToTypes', () => {
it('should convert string to number when schema type is number', () => {
const params = { count: '42' };
const schema = {
type: 'object',
properties: {
count: { type: 'number' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.count).toBe(42);
expect(typeof result.count).toBe('number');
});
it('should convert string to integer when schema type is integer', () => {
const params = { age: '25' };
const schema = {
type: 'object',
properties: {
age: { type: 'integer' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.age).toBe(25);
expect(typeof result.age).toBe('number');
expect(Number.isInteger(result.age)).toBe(true);
});
it('should convert string to boolean when schema type is boolean', () => {
const params = { enabled: 'true', disabled: 'false', flag: '1' };
const schema = {
type: 'object',
properties: {
enabled: { type: 'boolean' },
disabled: { type: 'boolean' },
flag: { type: 'boolean' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.enabled).toBe(true);
expect(result.disabled).toBe(false);
expect(result.flag).toBe(true);
});
it('should convert comma-separated string to array when schema type is array', () => {
const params = { tags: 'one,two,three' };
const schema = {
type: 'object',
properties: {
tags: { type: 'array' },
},
};
const result = convertParametersToTypes(params, schema);
expect(Array.isArray(result.tags)).toBe(true);
expect(result.tags).toEqual(['one', 'two', 'three']);
});
it('should parse JSON string to object when schema type is object', () => {
const params = { config: '{"key": "value", "nested": {"prop": 123}}' };
const schema = {
type: 'object',
properties: {
config: { type: 'object' },
},
};
const result = convertParametersToTypes(params, schema);
expect(typeof result.config).toBe('object');
expect(result.config).toEqual({ key: 'value', nested: { prop: 123 } });
});
it('should keep values unchanged when they already have the correct type', () => {
const params = { count: 42, enabled: true, tags: ['a', 'b'] };
const schema = {
type: 'object',
properties: {
count: { type: 'number' },
enabled: { type: 'boolean' },
tags: { type: 'array' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.count).toBe(42);
expect(result.enabled).toBe(true);
expect(result.tags).toEqual(['a', 'b']);
});
it('should keep string values unchanged when schema type is string', () => {
const params = { name: 'John Doe' };
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.name).toBe('John Doe');
expect(typeof result.name).toBe('string');
});
it('should handle parameters without schema definition', () => {
const params = { unknown: 'value' };
const schema = {
type: 'object',
properties: {
known: { type: 'string' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.unknown).toBe('value');
});
it('should return original params when schema has no properties', () => {
const params = { key: 'value' };
const schema = { type: 'object' };
const result = convertParametersToTypes(params, schema);
expect(result).toEqual(params);
});
it('should return original params when schema is null or undefined', () => {
const params = { key: 'value' };
const resultNull = convertParametersToTypes(params, null as any);
const resultUndefined = convertParametersToTypes(params, undefined as any);
expect(resultNull).toEqual(params);
expect(resultUndefined).toEqual(params);
});
it('should handle invalid number conversion gracefully', () => {
const params = { count: 'not-a-number' };
const schema = {
type: 'object',
properties: {
count: { type: 'number' },
},
};
const result = convertParametersToTypes(params, schema);
// When conversion fails, it should keep original value
expect(result.count).toBe('not-a-number');
});
it('should handle invalid JSON string for object gracefully', () => {
const params = { config: '{invalid json}' };
const schema = {
type: 'object',
properties: {
config: { type: 'object' },
},
};
const result = convertParametersToTypes(params, schema);
// When JSON parsing fails, it should keep original value
expect(result.config).toBe('{invalid json}');
});
it('should handle mixed parameter types correctly', () => {
const params = {
name: 'Test',
count: '10',
price: '19.99',
enabled: 'true',
tags: 'tag1,tag2',
config: '{"nested": true}',
};
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
count: { type: 'integer' },
price: { type: 'number' },
enabled: { type: 'boolean' },
tags: { type: 'array' },
config: { type: 'object' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.name).toBe('Test');
expect(result.count).toBe(10);
expect(result.price).toBe(19.99);
expect(result.enabled).toBe(true);
expect(result.tags).toEqual(['tag1', 'tag2']);
expect(result.config).toEqual({ nested: true });
});
it('should handle empty string values', () => {
const params = { name: '', count: '', enabled: '' };
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
count: { type: 'number' },
enabled: { type: 'boolean' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.name).toBe('');
// Empty string should remain as empty string for number (NaN check keeps original)
expect(result.count).toBe('');
// Empty string converts to false for boolean
expect(result.enabled).toBe(false);
});
it('should handle array that is already an array', () => {
const params = { tags: ['existing', 'array'] };
const schema = {
type: 'object',
properties: {
tags: { type: 'array' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.tags).toEqual(['existing', 'array']);
});
it('should handle object that is already an object', () => {
const params = { config: { key: 'value' } };
const schema = {
type: 'object',
properties: {
config: { type: 'object' },
},
};
const result = convertParametersToTypes(params, schema);
expect(result.config).toEqual({ key: 'value' });
});
});
});