mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Compare commits
45 Commits
v0.9.14
...
copilot/cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd8f58bca9 | ||
|
|
fd3972bda2 | ||
|
|
68c454b4b6 | ||
|
|
9bcc96f207 | ||
|
|
259241f295 | ||
|
|
c88429934c | ||
|
|
6de3221974 | ||
|
|
ac0b60ed4b | ||
|
|
a57218d076 | ||
|
|
8c985b7de1 | ||
|
|
01bb011736 | ||
|
|
449e6ea4fd | ||
|
|
1869f283ba | ||
|
|
07adeab036 | ||
|
|
5d7d8fdd1a | ||
|
|
fb847797c0 | ||
|
|
8df2b4704a | ||
|
|
602b5cb80e | ||
|
|
e63f045819 | ||
|
|
a4e4791b60 | ||
|
|
01370ea959 | ||
|
|
f5d66c1bb7 | ||
|
|
9e59dd9fb0 | ||
|
|
250487f042 | ||
|
|
da91708420 | ||
|
|
576bba1f9e | ||
|
|
f4b83929a6 | ||
|
|
3825f389cd | ||
|
|
44e0309fd4 | ||
|
|
7e570a900a | ||
|
|
6268a02c0e | ||
|
|
695d663939 | ||
|
|
d595e5d874 | ||
|
|
ff797b4ab9 | ||
|
|
9105507722 | ||
|
|
f79028ed64 | ||
|
|
5ca5e2ad47 | ||
|
|
2f7726b008 | ||
|
|
26b26a5fb1 | ||
|
|
7dbd6c386e | ||
|
|
c1fee91142 | ||
|
|
1130f6833e | ||
|
|
c3f1de8f5b | ||
|
|
86367a4875 | ||
|
|
bd4c546bba |
34
.github/copilot-instructions.md
vendored
34
.github/copilot-instructions.md
vendored
@@ -13,6 +13,7 @@ MCPHub is a TypeScript/Node.js MCP (Model Context Protocol) server management hu
|
||||
- **MCP Integration**: Connects multiple MCP servers (`src/services/mcpService.ts`)
|
||||
- **Authentication**: JWT-based with bcrypt password hashing
|
||||
- **Configuration**: JSON-based MCP server definitions (`mcp_settings.json`)
|
||||
- **Documentation**: API docs and usage instructions(`docs/`)
|
||||
|
||||
## Working Effectively
|
||||
|
||||
@@ -30,7 +31,7 @@ cp .env.example .env
|
||||
|
||||
# Build and test to verify setup
|
||||
pnpm lint # ~3 seconds - NEVER CANCEL
|
||||
pnpm backend:build # ~5 seconds - NEVER CANCEL
|
||||
pnpm backend:build # ~5 seconds - NEVER CANCEL
|
||||
pnpm test:ci # ~16 seconds - NEVER CANCEL. Set timeout to 60+ seconds
|
||||
pnpm frontend:build # ~5 seconds - NEVER CANCEL
|
||||
pnpm build # ~10 seconds total - NEVER CANCEL. Set timeout to 60+ seconds
|
||||
@@ -48,7 +49,7 @@ pnpm dev # Backend on :3001, Frontend on :5173
|
||||
# Terminal 1: Backend only
|
||||
pnpm backend:dev # Runs on port 3000 (or PORT env var)
|
||||
|
||||
# Terminal 2: Frontend only
|
||||
# Terminal 2: Frontend only
|
||||
pnpm frontend:dev # Runs on port 5173, proxies API to backend
|
||||
```
|
||||
|
||||
@@ -62,7 +63,7 @@ pnpm build # NEVER CANCEL - Set timeout to 60+ seconds
|
||||
|
||||
# Individual builds
|
||||
pnpm backend:build # TypeScript compilation - ~5 seconds
|
||||
pnpm frontend:build # Vite build - ~5 seconds
|
||||
pnpm frontend:build # Vite build - ~5 seconds
|
||||
|
||||
# Start production server
|
||||
pnpm start # Requires dist/ and frontend/dist/ to exist
|
||||
@@ -91,6 +92,7 @@ pnpm format # Prettier formatting - ~3 seconds
|
||||
**ALWAYS perform these validation steps after making changes:**
|
||||
|
||||
### 1. Basic Application Functionality
|
||||
|
||||
```bash
|
||||
# Start the application
|
||||
pnpm dev
|
||||
@@ -105,6 +107,7 @@ curl -I http://localhost:3000/
|
||||
```
|
||||
|
||||
### 2. MCP Server Integration Test
|
||||
|
||||
```bash
|
||||
# Check MCP servers are loading (look for log messages)
|
||||
# Expected log output should include:
|
||||
@@ -114,6 +117,7 @@ curl -I http://localhost:3000/
|
||||
```
|
||||
|
||||
### 3. Build Verification
|
||||
|
||||
```bash
|
||||
# Verify production build works
|
||||
pnpm build
|
||||
@@ -126,6 +130,7 @@ node scripts/verify-dist.js
|
||||
## Project Structure and Key Files
|
||||
|
||||
### Critical Backend Files
|
||||
|
||||
- `src/index.ts` - Application entry point
|
||||
- `src/server.ts` - Express server setup and middleware
|
||||
- `src/services/mcpService.ts` - **Core MCP server management logic**
|
||||
@@ -136,11 +141,14 @@ node scripts/verify-dist.js
|
||||
- `src/types/index.ts` - TypeScript type definitions
|
||||
|
||||
### Critical Frontend Files
|
||||
|
||||
- `frontend/src/` - React application source
|
||||
- `frontend/src/pages/` - Page components (development entry point)
|
||||
- `frontend/src/components/` - Reusable UI components
|
||||
- `frontend/src/utils/fetchInterceptor.js` - Backend API interaction
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- `mcp_settings.json` - **MCP server definitions and user accounts**
|
||||
- `package.json` - Dependencies and scripts
|
||||
- `tsconfig.json` - TypeScript configuration
|
||||
@@ -148,6 +156,7 @@ node scripts/verify-dist.js
|
||||
- `.eslintrc.json` - Linting rules
|
||||
|
||||
### Docker and Deployment
|
||||
|
||||
- `Dockerfile` - Multi-stage build with Python base + Node.js
|
||||
- `entrypoint.sh` - Docker startup script
|
||||
- `bin/cli.js` - NPM package CLI entry point
|
||||
@@ -155,12 +164,14 @@ node scripts/verify-dist.js
|
||||
## Development Process and Conventions
|
||||
|
||||
### Code Style Requirements
|
||||
|
||||
- **ESM modules**: Always use `.js` extensions in imports, not `.ts`
|
||||
- **English only**: All code comments must be written in English
|
||||
- **TypeScript strict**: Follow strict type checking rules
|
||||
- **Import style**: `import { something } from './file.js'` (note .js extension)
|
||||
|
||||
### Key Configuration Notes
|
||||
|
||||
- **MCP servers**: Defined in `mcp_settings.json` with command/args
|
||||
- **Endpoints**: `/mcp/{group|server}` and `/mcp/$smart` for routing
|
||||
- **i18n**: Frontend uses react-i18next with files in `locales/` folder
|
||||
@@ -168,6 +179,7 @@ node scripts/verify-dist.js
|
||||
- **Default credentials**: admin/admin123 (configured in mcp_settings.json)
|
||||
|
||||
### Development Entry Points
|
||||
|
||||
- **Add MCP server**: Modify `mcp_settings.json` and restart
|
||||
- **New API endpoint**: Add route in `src/routes/`, controller in `src/controllers/`
|
||||
- **Frontend feature**: Start from `frontend/src/pages/` or `frontend/src/components/`
|
||||
@@ -176,29 +188,38 @@ node scripts/verify-dist.js
|
||||
### Common Development Tasks
|
||||
|
||||
#### Adding a new MCP server:
|
||||
|
||||
1. Add server definition to `mcp_settings.json`
|
||||
2. Restart backend to load new server
|
||||
3. Check logs for successful connection
|
||||
4. Test via dashboard or API endpoints
|
||||
|
||||
#### API development:
|
||||
|
||||
1. Define route in `src/routes/`
|
||||
2. Implement controller in `src/controllers/`
|
||||
3. Add types in `src/types/index.ts` if needed
|
||||
4. Write tests in `tests/controllers/`
|
||||
|
||||
#### Frontend development:
|
||||
|
||||
1. Create/modify components in `frontend/src/components/`
|
||||
2. Add pages in `frontend/src/pages/`
|
||||
3. Update routing if needed
|
||||
4. Test in development mode with `pnpm frontend:dev`
|
||||
|
||||
#### Documentation:
|
||||
|
||||
1. Update or add docs in `docs/` folder
|
||||
2. Ensure README.md reflects any major changes
|
||||
|
||||
## Validation and CI Requirements
|
||||
|
||||
### Before Committing - ALWAYS Run:
|
||||
|
||||
```bash
|
||||
pnpm lint # Must pass - ~3 seconds
|
||||
pnpm backend:build # Must compile - ~5 seconds
|
||||
pnpm backend:build # Must compile - ~5 seconds
|
||||
pnpm test:ci # All tests must pass - ~16 seconds
|
||||
pnpm build # Full build must work - ~10 seconds
|
||||
```
|
||||
@@ -206,6 +227,7 @@ pnpm build # Full build must work - ~10 seconds
|
||||
**CRITICAL**: CI will fail if any of these commands fail. Fix issues locally first.
|
||||
|
||||
### CI Pipeline (.github/workflows/ci.yml)
|
||||
|
||||
- Runs on Node.js 20.x
|
||||
- Tests: linting, type checking, unit tests with coverage
|
||||
- **NEVER CANCEL**: CI builds may take 2-3 minutes total
|
||||
@@ -213,22 +235,26 @@ pnpm build # Full build must work - ~10 seconds
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
- **"uvx command not found"**: Some MCP servers require `uvx` (Python package manager) - this is expected in development
|
||||
- **Port already in use**: Change PORT environment variable or kill existing processes
|
||||
- **Frontend not loading**: Ensure frontend was built with `pnpm frontend:build`
|
||||
- **MCP server connection failed**: Check server command/args in `mcp_settings.json`
|
||||
|
||||
### Build Failures
|
||||
|
||||
- **TypeScript errors**: Run `pnpm backend:build` to see compilation errors
|
||||
- **Test failures**: Run `pnpm test:verbose` for detailed test output
|
||||
- **Lint errors**: Run `pnpm lint` and fix reported issues
|
||||
|
||||
### Development Issues
|
||||
|
||||
- **Backend not starting**: Check for port conflicts, verify `mcp_settings.json` syntax
|
||||
- **Frontend proxy errors**: Ensure backend is running before starting frontend
|
||||
- **Hot reload not working**: Restart development server
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- **Install time**: pnpm install takes ~30 seconds
|
||||
- **Build time**: Full build takes ~10 seconds
|
||||
- **Test time**: Complete test suite takes ~16 seconds
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
package-lock.json
|
||||
|
||||
# production
|
||||
dist
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"semi": false,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
|
||||
30
AGENTS.md
Normal file
30
AGENTS.md
Normal 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.
|
||||
105
README.md
105
README.md
@@ -19,6 +19,10 @@ 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
|
||||
- **NEW**: Act as OAuth 2.0 authorization server for external clients (ChatGPT Web, custom apps)
|
||||
- **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 +61,81 @@ 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.
|
||||
|
||||
#### OAuth Authorization Server (NEW)
|
||||
|
||||
MCPHub can now act as an OAuth 2.0 authorization server, allowing external applications to securely access your MCP servers using standard OAuth flows. This is particularly useful for integrating with ChatGPT Web and other services that require OAuth authentication.
|
||||
|
||||
**Enable OAuth Server:**
|
||||
|
||||
```json
|
||||
{
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"accessTokenLifetime": 3600,
|
||||
"refreshTokenLifetime": 1209600,
|
||||
"allowedScopes": ["read", "write"]
|
||||
}
|
||||
},
|
||||
"oauthClients": [
|
||||
{
|
||||
"clientId": "your-client-id",
|
||||
"name": "ChatGPT Web",
|
||||
"redirectUris": ["https://chatgpt.com/oauth/callback"],
|
||||
"grants": ["authorization_code", "refresh_token"],
|
||||
"scopes": ["read", "write"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- Standard OAuth 2.0 authorization code flow
|
||||
- PKCE support for enhanced security
|
||||
- Token refresh capabilities
|
||||
- Compatible with ChatGPT Web and other OAuth clients
|
||||
|
||||
For detailed setup instructions, see the [OAuth Server Documentation](docs/oauth-server.md).
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
**Recommended**: Mount your custom config:
|
||||
@@ -106,7 +185,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 +198,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 +210,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)**:
|
||||
|
||||

|
||||
@@ -164,7 +265,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:
|
||||
|
||||
65
README.zh.md
65
README.zh.md
@@ -57,6 +57,45 @@ MCPHub 通过将多个 MCP(Model Context Protocol)服务器组织为灵活
|
||||
}
|
||||
```
|
||||
|
||||
#### OAuth 配置(可选)
|
||||
|
||||
MCPHub 支持通过 OAuth 2.0 访问上游 MCP 服务器。完整说明请参阅[《OAuth 功能指南》](docs/zh/features/oauth.mdx)。实际使用中通常会遇到两类配置:
|
||||
|
||||
- **支持动态注册的服务器**(如 Vercel、Linear):会公开全部元数据,MCPHub 可自动注册并完成授权,仅需声明服务器地址。
|
||||
- **需要手动配置客户端的服务器**(如 GitHub Copilot):需要在提供商后台创建 OAuth 应用,并将获得的 Client ID/Secret 写入 MCPHub。
|
||||
|
||||
动态注册示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vercel": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.vercel.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
手动注册示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"type": "sse",
|
||||
"url": "https://api.githubcopilot.com/mcp/",
|
||||
"oauth": {
|
||||
"clientId": "${GITHUB_OAUTH_APP_ID}",
|
||||
"clientSecret": "${GITHUB_OAUTH_APP_SECRET}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
对于需要手动注册的提供商,请先在上游控制台创建 OAuth 应用,将回调地址设置为 `http://localhost:3000/oauth/callback`(或你的部署域名),然后在控制台或配置文件中填写凭据。
|
||||
|
||||
### Docker 部署
|
||||
|
||||
**推荐**:挂载自定义配置:
|
||||
@@ -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 端点(推荐)**:
|
||||

|
||||
要针对特定服务器分组进行访问,请使用基于分组的 HTTP 端点:
|
||||
@@ -164,7 +225,11 @@ http://localhost:3000/sse
|
||||
要启用智能路由,请使用:
|
||||
|
||||
```
|
||||
# 在所有服务器中搜索
|
||||
http://localhost:3000/sse/$smart
|
||||
|
||||
# 在特定分组中搜索
|
||||
http://localhost:3000/sse/$smart/{group}
|
||||
```
|
||||
|
||||
要针对特定服务器分组进行访问,请使用基于分组的 SSE 端点:
|
||||
|
||||
244
SECURITY_SUMMARY.md
Normal file
244
SECURITY_SUMMARY.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Security Summary - MCPHub Security Fixes
|
||||
|
||||
## Recent Security Fixes
|
||||
|
||||
### Authentication Bypass Vulnerability (FIXED - 2025-11-23)
|
||||
|
||||
✅ **CRITICAL FIX APPLIED**: Authentication bypass vulnerability in MCP transport endpoints
|
||||
|
||||
**Vulnerability Details:**
|
||||
- **Severity**: Critical (CVSS 9.8 - Unauthenticated Remote Access)
|
||||
- **Affected Versions**: All versions prior to this fix
|
||||
- **CVE**: Pending assignment
|
||||
- **Discovery**: Security researcher report
|
||||
- **Status**: ✅ FIXED
|
||||
|
||||
**Issue:**
|
||||
The MCP transport endpoints (`/:user/mcp/:group` and `/:user/sse/:group`) accepted requests without verifying credentials. An attacker could impersonate any user by simply placing their username in the URL path, bypassing all authentication and accessing privileged MCP operations.
|
||||
|
||||
**Root Cause:**
|
||||
- `validateBearerAuth()` in `sseService.ts` was using `loadSettings()` which filters settings based on user context
|
||||
- `DataServicex.filterSettings()` replaces `systemConfig` with user-specific config for non-admin users
|
||||
- This caused the global `enableBearerAuth` configuration to be unavailable during validation
|
||||
- Result: Bearer authentication was never enforced, even when explicitly enabled in configuration
|
||||
|
||||
**Impact:**
|
||||
An unauthenticated attacker could:
|
||||
- Impersonate any user account
|
||||
- Access private MCP server groups
|
||||
- Execute privileged MCP tool operations
|
||||
- Exfiltrate secrets or data from configured MCP servers (Slack bots, kubectl, databases, etc.)
|
||||
|
||||
**Fix Applied:**
|
||||
- Changed `validateBearerAuth()` to use `loadOriginalSettings()` instead of `loadSettings()`
|
||||
- This ensures bearer auth validation always has access to the actual global systemConfig
|
||||
- Updated all test mocks to properly test authentication
|
||||
|
||||
**Verification:**
|
||||
- ✅ 16 new security tests added to prevent regression
|
||||
- ✅ All 204 tests passing
|
||||
- ✅ Unauthenticated requests now return 401 Unauthorized
|
||||
- ✅ Bearer auth properly enforced when enabled
|
||||
- ✅ Proper WWW-Authenticate headers returned
|
||||
|
||||
**Remediation:**
|
||||
- Update to the latest version immediately
|
||||
- Review access logs for suspicious activity
|
||||
- Ensure `enableBearerAuth: true` is set in production
|
||||
- Use a strong `bearerAuthKey` value
|
||||
|
||||
---
|
||||
|
||||
# Security Summary - OAuth Authorization Server Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document summarizes the security analysis and measures taken for the OAuth 2.0 authorization server implementation in MCPHub.
|
||||
|
||||
## Vulnerability Scan Results
|
||||
|
||||
### Dependency Vulnerabilities
|
||||
|
||||
✅ **PASSED**: No vulnerabilities found in dependencies
|
||||
- `@node-oauth/oauth2-server@5.2.1` - Clean scan, no known vulnerabilities
|
||||
- All other dependencies scanned and verified secure
|
||||
|
||||
### Code Security Analysis (CodeQL)
|
||||
|
||||
⚠️ **ADVISORY**: 12 alerts found regarding missing rate limiting on authentication endpoints
|
||||
|
||||
**Details:**
|
||||
- **Issue**: Authorization routes do not have rate limiting middleware
|
||||
- **Impact**: Potential brute force attacks on authentication endpoints
|
||||
- **Severity**: Medium
|
||||
- **Status**: Documented, not critical
|
||||
|
||||
**Affected Endpoints:**
|
||||
- `/oauth/authorize` (GET/POST)
|
||||
- `/oauth/token` (POST)
|
||||
- `/api/oauth/clients/*` (various methods)
|
||||
|
||||
**Mitigation:**
|
||||
1. All endpoints require proper authentication
|
||||
2. Authorization codes expire after 5 minutes by default
|
||||
3. Access tokens expire after 1 hour by default
|
||||
4. Failed authentication attempts are logged
|
||||
5. Documentation includes rate limiting recommendations for production
|
||||
|
||||
**Recommended Actions for Production:**
|
||||
- Implement `express-rate-limit` middleware on OAuth endpoints
|
||||
- Consider using reverse proxy rate limiting (nginx, Cloudflare)
|
||||
- Monitor for suspicious authentication patterns
|
||||
- Set up alerting for repeated failed attempts
|
||||
|
||||
## Security Features Implemented
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
✅ **OAuth 2.0 Compliance**: Fully compliant with RFC 6749
|
||||
✅ **PKCE Support**: RFC 7636 implementation for public clients
|
||||
✅ **Token-based Authentication**: Access tokens and refresh tokens
|
||||
✅ **JWT Integration**: Backward compatible with existing JWT auth
|
||||
✅ **User Permissions**: Proper admin status lookup for OAuth users
|
||||
|
||||
### Input Validation
|
||||
|
||||
✅ **Query Parameter Validation**: All OAuth parameters validated with regex patterns
|
||||
✅ **Client ID Validation**: Alphanumeric with hyphens/underscores only
|
||||
✅ **Redirect URI Validation**: Strict matching against registered URIs
|
||||
✅ **Scope Validation**: Only allowed scopes can be requested
|
||||
✅ **State Parameter**: CSRF protection via state validation
|
||||
|
||||
### Output Security
|
||||
|
||||
✅ **XSS Protection**: All user input HTML-escaped in authorization page
|
||||
✅ **HTML Escaping**: Custom escapeHtml function for template rendering
|
||||
✅ **Safe Token Handling**: Tokens never exposed in URLs or logs
|
||||
|
||||
### Token Security
|
||||
|
||||
✅ **Secure Token Generation**: Cryptographically random tokens (32 bytes)
|
||||
✅ **Token Expiration**: Configurable lifetimes for all token types
|
||||
✅ **Token Revocation**: Support for revoking access and refresh tokens
|
||||
✅ **Automatic Cleanup**: Expired tokens automatically removed from memory
|
||||
|
||||
### Transport Security
|
||||
|
||||
✅ **HTTPS Ready**: Designed for HTTPS in production
|
||||
✅ **No Tokens in URL**: Access tokens never passed in query parameters
|
||||
✅ **Secure Headers**: Proper Content-Type and security headers
|
||||
|
||||
### Client Security
|
||||
|
||||
✅ **Client Secret Support**: Optional for confidential clients
|
||||
✅ **Public Client Support**: PKCE for clients without secrets
|
||||
✅ **Redirect URI Whitelist**: Strict validation of redirect destinations
|
||||
✅ **Client Registration**: Secure client management API
|
||||
|
||||
### Code Quality
|
||||
|
||||
✅ **TypeScript Strict Mode**: Full type safety
|
||||
✅ **ESLint Clean**: No linting errors
|
||||
✅ **Test Coverage**: 180 tests passing, including 11 OAuth-specific tests
|
||||
✅ **Async Safety**: Proper async/await usage throughout
|
||||
✅ **Resource Cleanup**: Graceful shutdown support with interval cleanup
|
||||
|
||||
## Security Best Practices Followed
|
||||
|
||||
1. **Defense in Depth**: Multiple layers of security (auth, validation, escaping)
|
||||
2. **Principle of Least Privilege**: Scopes limit what clients can access
|
||||
3. **Fail Securely**: Invalid requests rejected with appropriate errors
|
||||
4. **Security by Default**: Secure settings out of the box
|
||||
5. **Standard Compliance**: Following OAuth 2.0 and PKCE RFCs
|
||||
6. **Code Reviews**: All changes reviewed for security implications
|
||||
7. **Documentation**: Comprehensive security guidance provided
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### In-Memory Token Storage
|
||||
|
||||
**Issue**: Tokens stored in memory, not persisted to database
|
||||
**Impact**: Tokens lost on server restart
|
||||
**Mitigation**: Refresh tokens allow users to re-authenticate
|
||||
**Future**: Consider database storage for production deployments
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
**Issue**: No built-in rate limiting on OAuth endpoints
|
||||
**Impact**: Potential brute force attacks
|
||||
**Mitigation**:
|
||||
- Short-lived authorization codes (5 min default)
|
||||
- Authentication required for authorization endpoint
|
||||
- Documented recommendations for production
|
||||
**Future**: Consider adding rate limiting middleware
|
||||
|
||||
### Token Introspection
|
||||
|
||||
**Issue**: No token introspection endpoint (RFC 7662)
|
||||
**Impact**: Limited third-party token validation
|
||||
**Mitigation**: Clients can use userinfo endpoint
|
||||
**Future**: Consider implementing RFC 7662 if needed
|
||||
|
||||
## Production Deployment Recommendations
|
||||
|
||||
### Critical
|
||||
|
||||
1. ✅ Use HTTPS in production (SSL/TLS certificates)
|
||||
2. ✅ Change default admin password immediately
|
||||
3. ✅ Use strong client secrets for confidential clients
|
||||
4. ⚠️ Implement rate limiting (express-rate-limit or reverse proxy)
|
||||
5. ✅ Enable proper logging and monitoring
|
||||
|
||||
### Recommended
|
||||
|
||||
6. Consider using a database for token storage
|
||||
7. Set up automated security scanning in CI/CD
|
||||
8. Use a reverse proxy (nginx) with security headers
|
||||
9. Implement IP whitelisting for admin endpoints
|
||||
10. Regular security audits and dependency updates
|
||||
|
||||
### Optional
|
||||
|
||||
11. Implement token introspection endpoint
|
||||
12. Add support for JWT-based access tokens
|
||||
13. Integrate with external OAuth providers
|
||||
14. Implement advanced scope management
|
||||
15. Add OAuth client approval workflow
|
||||
|
||||
## Compliance & Standards
|
||||
|
||||
✅ **OAuth 2.0 (RFC 6749)**: Full authorization code grant implementation
|
||||
✅ **PKCE (RFC 7636)**: Code challenge and verifier support
|
||||
✅ **OAuth Server Metadata (RFC 8414)**: Discovery endpoint available
|
||||
✅ **OpenID Connect Compatible**: Basic userinfo endpoint
|
||||
|
||||
## Vulnerability Disclosure
|
||||
|
||||
If you discover a security vulnerability in MCPHub's OAuth implementation, please:
|
||||
|
||||
1. **Do Not** create a public GitHub issue
|
||||
2. Email the maintainers privately
|
||||
3. Provide detailed reproduction steps
|
||||
4. Allow time for a fix before public disclosure
|
||||
|
||||
## Security Update Policy
|
||||
|
||||
- **Critical vulnerabilities**: Patched within 24-48 hours
|
||||
- **High severity**: Patched within 1 week
|
||||
- **Medium severity**: Patched in next minor release
|
||||
- **Low severity**: Patched in next patch release
|
||||
|
||||
## Conclusion
|
||||
|
||||
The OAuth 2.0 authorization server implementation in MCPHub follows security best practices and is production-ready with the noted limitations. The main advisory regarding rate limiting should be addressed in production deployments through application-level or reverse proxy rate limiting.
|
||||
|
||||
**Overall Security Assessment**: ✅ **SECURE** with production hardening recommendations
|
||||
|
||||
**Last Updated**: 2025-11-23
|
||||
**Next Review**: Recommended quarterly or after major changes
|
||||
|
||||
## Recent Security Audit Results
|
||||
|
||||
- ✅ **Authentication Bypass**: FIXED (2025-11-23)
|
||||
- ✅ **OAuth 2.0 Implementation**: Secure with noted limitations
|
||||
- ⚠️ **Rate Limiting**: Recommendation for production deployment
|
||||
@@ -60,6 +60,32 @@ Generates and returns the complete OpenAPI 3.0.3 specification for all connected
|
||||
Comma-separated list of server names to include
|
||||
</ParamField>
|
||||
|
||||
### Group/Server-Specific OpenAPI Specification
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/:name/openapi.json
|
||||
curl "http://localhost:3000/api/mygroup/openapi.json"
|
||||
```
|
||||
|
||||
```bash With Parameters
|
||||
curl "http://localhost:3000/api/myserver/openapi.json?title=My Server API&version=1.0.0"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Generates and returns the OpenAPI 3.0.3 specification for a specific group or server. If a group with the given name exists, it returns the specification for all servers in that group. Otherwise, it treats the name as a server name and returns the specification for that server only.
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
<ParamField path="name" type="string" required>
|
||||
Group ID/name or server name
|
||||
</ParamField>
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
Same as the main OpenAPI specification endpoint (title, description, version, serverUrl, includeDisabled).
|
||||
|
||||
### Available Servers
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
142
docs/api-reference/prompts.mdx
Normal file
142
docs/api-reference/prompts.mdx
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: "Prompts"
|
||||
description: "Manage and execute MCP prompts."
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'mintlify';
|
||||
|
||||
<Card
|
||||
title="POST /api/mcp/:serverName/prompts/:promptName"
|
||||
href="#get-a-prompt"
|
||||
>
|
||||
Execute a prompt on an MCP server.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="POST /api/servers/:serverName/prompts/:promptName/toggle"
|
||||
href="#toggle-a-prompt"
|
||||
>
|
||||
Enable or disable a prompt.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="PUT /api/servers/:serverName/prompts/:promptName/description"
|
||||
href="#update-prompt-description"
|
||||
>
|
||||
Update the description of a prompt.
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### Get a Prompt
|
||||
|
||||
Execute a prompt on an MCP server and get the result.
|
||||
|
||||
- **Endpoint**: `/api/mcp/:serverName/prompts/:promptName`
|
||||
- **Method**: `POST`
|
||||
- **Authentication**: Required
|
||||
- **Parameters**:
|
||||
- `:serverName` (string, required): The name of the MCP server.
|
||||
- `:promptName` (string, required): The name of the prompt.
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"arguments": {
|
||||
"arg1": "value1",
|
||||
"arg2": "value2"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `arguments` (object, optional): Arguments to pass to the prompt.
|
||||
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Prompt content"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/mcp/myserver/prompts/code-review" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{
|
||||
"arguments": {
|
||||
"language": "typescript",
|
||||
"code": "const x = 1;"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Toggle a Prompt
|
||||
|
||||
Enable or disable a specific prompt on a server.
|
||||
|
||||
- **Endpoint**: `/api/servers/:serverName/prompts/:promptName/toggle`
|
||||
- **Method**: `POST`
|
||||
- **Authentication**: Required
|
||||
- **Parameters**:
|
||||
- `:serverName` (string, required): The name of the server.
|
||||
- `:promptName` (string, required): The name of the prompt.
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
- `enabled` (boolean, required): `true` to enable the prompt, `false` to disable it.
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/servers/myserver/prompts/code-review/toggle" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{"enabled": false}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Prompt Description
|
||||
|
||||
Update the description of a specific prompt.
|
||||
|
||||
- **Endpoint**: `/api/servers/:serverName/prompts/:promptName/description`
|
||||
- **Method**: `PUT`
|
||||
- **Authentication**: Required
|
||||
- **Parameters**:
|
||||
- `:serverName` (string, required): The name of the server.
|
||||
- `:promptName` (string, required): The name of the prompt.
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"description": "New prompt description"
|
||||
}
|
||||
```
|
||||
- `description` (string, required): The new description for the prompt.
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl -X PUT "http://localhost:3000/api/servers/myserver/prompts/code-review/description" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{"description": "Review code for best practices and potential issues"}'
|
||||
```
|
||||
|
||||
**Note**: Prompts are templates that can be used to generate standardized requests to MCP servers. They are defined by the MCP server and can have arguments that are filled in when the prompt is executed.
|
||||
@@ -54,6 +54,20 @@ import { Card, Cards } from 'mintlify';
|
||||
Update the description of a tool.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="PUT /api/system-config"
|
||||
href="#update-system-config"
|
||||
>
|
||||
Update system configuration settings.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /api/settings"
|
||||
href="#get-settings"
|
||||
>
|
||||
Get all server settings and configurations.
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### Get All Servers
|
||||
@@ -207,3 +221,45 @@ Updates the description of a specific tool.
|
||||
}
|
||||
```
|
||||
- `description` (string, required): The new description for the tool.
|
||||
|
||||
---
|
||||
|
||||
### Update System Config
|
||||
|
||||
Updates the system-wide configuration settings.
|
||||
|
||||
- **Endpoint**: `/api/system-config`
|
||||
- **Method**: `PUT`
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"openaiApiKey": "sk-...",
|
||||
"openaiBaseUrl": "https://api.openai.com/v1",
|
||||
"modelName": "gpt-4",
|
||||
"temperature": 0.7,
|
||||
"maxTokens": 2048
|
||||
}
|
||||
```
|
||||
- All fields are optional. Only provided fields will be updated.
|
||||
|
||||
---
|
||||
|
||||
### Get Settings
|
||||
|
||||
Retrieves all server settings and configurations.
|
||||
|
||||
- **Endpoint**: `/api/settings`
|
||||
- **Method**: `GET`
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"servers": [...],
|
||||
"groups": [...],
|
||||
"systemConfig": {...}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: For detailed prompt management, see the [Prompts API](/api-reference/prompts) documentation.
|
||||
|
||||
113
docs/api-reference/system.mdx
Normal file
113
docs/api-reference/system.mdx
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
title: "System"
|
||||
description: "System and utility endpoints."
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'mintlify';
|
||||
|
||||
<Card
|
||||
title="GET /health"
|
||||
href="#health-check"
|
||||
>
|
||||
Check the health status of the MCPHub server.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /oauth/callback"
|
||||
href="#oauth-callback"
|
||||
>
|
||||
OAuth callback endpoint for authentication flows.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="POST /api/dxt/upload"
|
||||
href="#upload-dxt-file"
|
||||
>
|
||||
Upload a DXT configuration file.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /api/mcp-settings/export"
|
||||
href="#export-mcp-settings"
|
||||
>
|
||||
Export MCP settings as JSON.
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### Health Check
|
||||
|
||||
Check the health status of the MCPHub server.
|
||||
|
||||
- **Endpoint**: `/health`
|
||||
- **Method**: `GET`
|
||||
- **Authentication**: Not required
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2024-11-12T01:30:00.000Z",
|
||||
"uptime": 12345
|
||||
}
|
||||
```
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3000/health"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### OAuth Callback
|
||||
|
||||
OAuth callback endpoint for handling OAuth authentication flows. This endpoint is automatically called by OAuth providers after user authorization.
|
||||
|
||||
- **Endpoint**: `/oauth/callback`
|
||||
- **Method**: `GET`
|
||||
- **Authentication**: Not required (public callback URL)
|
||||
- **Query Parameters**: Varies by OAuth provider (typically includes `code`, `state`, etc.)
|
||||
|
||||
**Note**: This endpoint is used internally by MCPHub's OAuth integration and should not be called directly by clients.
|
||||
|
||||
---
|
||||
|
||||
### Upload DXT File
|
||||
|
||||
Upload a DXT (Desktop Extension) configuration file to import server configurations.
|
||||
|
||||
- **Endpoint**: `/api/dxt/upload`
|
||||
- **Method**: `POST`
|
||||
- **Authentication**: Required
|
||||
- **Content-Type**: `multipart/form-data`
|
||||
- **Body**:
|
||||
- `file` (file, required): The DXT configuration file to upload.
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/dxt/upload" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-F "file=@config.dxt"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Export MCP Settings
|
||||
|
||||
Export the current MCP settings configuration as a JSON file.
|
||||
|
||||
- **Endpoint**: `/api/mcp-settings/export`
|
||||
- **Method**: `GET`
|
||||
- **Authentication**: Required
|
||||
- **Response**: Returns the `mcp_settings.json` configuration file.
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3000/api/mcp-settings/export" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-o mcp_settings.json
|
||||
```
|
||||
|
||||
**Note**: This endpoint allows you to download a backup of your MCP settings, which can be used to restore or migrate your configuration.
|
||||
86
docs/api-reference/tools.mdx
Normal file
86
docs/api-reference/tools.mdx
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: "Tools"
|
||||
description: "Execute MCP tools programmatically."
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'mintlify';
|
||||
|
||||
<Card
|
||||
title="POST /api/tools/call/:server"
|
||||
href="#call-a-tool"
|
||||
>
|
||||
Call a specific tool on an MCP server.
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### Call a Tool
|
||||
|
||||
Execute a specific tool on an MCP server with given arguments.
|
||||
|
||||
- **Endpoint**: `/api/tools/call/:server`
|
||||
- **Method**: `POST`
|
||||
- **Parameters**:
|
||||
- `:server` (string, required): The name of the MCP server.
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"toolName": "tool-name",
|
||||
"arguments": {
|
||||
"param1": "value1",
|
||||
"param2": "value2"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `toolName` (string, required): The name of the tool to execute.
|
||||
- `arguments` (object, optional): The arguments to pass to the tool. Defaults to an empty object.
|
||||
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Tool execution result"
|
||||
}
|
||||
],
|
||||
"toolName": "tool-name",
|
||||
"arguments": {
|
||||
"param1": "value1",
|
||||
"param2": "value2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/tools/call/amap" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{
|
||||
"toolName": "amap-maps_weather",
|
||||
"arguments": {
|
||||
"city": "Beijing"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- The tool arguments are automatically converted to the proper types based on the tool's input schema.
|
||||
- Use the `x-session-id` header to maintain session state across multiple tool calls if needed.
|
||||
- This endpoint requires authentication.
|
||||
|
||||
---
|
||||
|
||||
### Alternative: OpenAPI Tool Execution
|
||||
|
||||
For OpenAPI-compatible tool execution without authentication, see the [OpenAPI Integration](/api-reference/openapi#tool-execution) documentation. The OpenAPI endpoints provide:
|
||||
|
||||
- **GET** `/api/tools/:serverName/:toolName` - For simple tools with query parameters
|
||||
- **POST** `/api/tools/:serverName/:toolName` - For complex tools with JSON body
|
||||
|
||||
These endpoints are designed for integration with OpenWebUI and other OpenAPI-compatible systems.
|
||||
195
docs/api-reference/users.mdx
Normal file
195
docs/api-reference/users.mdx
Normal file
@@ -0,0 +1,195 @@
|
||||
---
|
||||
title: "Users"
|
||||
description: "Manage users in MCPHub."
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'mintlify';
|
||||
|
||||
<Card
|
||||
title="GET /api/users"
|
||||
href="#get-all-users"
|
||||
>
|
||||
Get a list of all users.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /api/users/:username"
|
||||
href="#get-a-user"
|
||||
>
|
||||
Get details of a specific user.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="POST /api/users"
|
||||
href="#create-a-user"
|
||||
>
|
||||
Create a new user.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="PUT /api/users/:username"
|
||||
href="#update-a-user"
|
||||
>
|
||||
Update an existing user.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="DELETE /api/users/:username"
|
||||
href="#delete-a-user"
|
||||
>
|
||||
Delete a user.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /api/users-stats"
|
||||
href="#get-user-statistics"
|
||||
>
|
||||
Get statistics about users and their server access.
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### Get All Users
|
||||
|
||||
Retrieves a list of all users in the system.
|
||||
|
||||
- **Endpoint**: `/api/users`
|
||||
- **Method**: `GET`
|
||||
- **Authentication**: Required (Admin only)
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"username": "admin",
|
||||
"role": "admin",
|
||||
"servers": ["server1", "server2"],
|
||||
"groups": ["group1"]
|
||||
},
|
||||
{
|
||||
"username": "user1",
|
||||
"role": "user",
|
||||
"servers": ["server1"],
|
||||
"groups": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get a User
|
||||
|
||||
Retrieves details of a specific user.
|
||||
|
||||
- **Endpoint**: `/api/users/:username`
|
||||
- **Method**: `GET`
|
||||
- **Authentication**: Required (Admin only)
|
||||
- **Parameters**:
|
||||
- `:username` (string, required): The username of the user.
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"username": "user1",
|
||||
"role": "user",
|
||||
"servers": ["server1", "server2"],
|
||||
"groups": ["group1"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create a User
|
||||
|
||||
Creates a new user in the system.
|
||||
|
||||
- **Endpoint**: `/api/users`
|
||||
- **Method**: `POST`
|
||||
- **Authentication**: Required (Admin only)
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"username": "newuser",
|
||||
"password": "securepassword",
|
||||
"role": "user",
|
||||
"servers": ["server1"],
|
||||
"groups": ["group1"]
|
||||
}
|
||||
```
|
||||
- `username` (string, required): The username for the new user.
|
||||
- `password` (string, required): The password for the new user. Must be at least 6 characters.
|
||||
- `role` (string, optional): The role of the user. Either `"admin"` or `"user"`. Defaults to `"user"`.
|
||||
- `servers` (array of strings, optional): List of server names the user has access to.
|
||||
- `groups` (array of strings, optional): List of group IDs the user belongs to.
|
||||
|
||||
---
|
||||
|
||||
### Update a User
|
||||
|
||||
Updates an existing user's information.
|
||||
|
||||
- **Endpoint**: `/api/users/:username`
|
||||
- **Method**: `PUT`
|
||||
- **Authentication**: Required (Admin only)
|
||||
- **Parameters**:
|
||||
- `:username` (string, required): The username of the user to update.
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"password": "newpassword",
|
||||
"role": "admin",
|
||||
"servers": ["server1", "server2", "server3"],
|
||||
"groups": ["group1", "group2"]
|
||||
}
|
||||
```
|
||||
- `password` (string, optional): New password for the user.
|
||||
- `role` (string, optional): New role for the user.
|
||||
- `servers` (array of strings, optional): Updated list of accessible servers.
|
||||
- `groups` (array of strings, optional): Updated list of groups.
|
||||
|
||||
---
|
||||
|
||||
### Delete a User
|
||||
|
||||
Removes a user from the system.
|
||||
|
||||
- **Endpoint**: `/api/users/:username`
|
||||
- **Method**: `DELETE`
|
||||
- **Authentication**: Required (Admin only)
|
||||
- **Parameters**:
|
||||
- `:username` (string, required): The username of the user to delete.
|
||||
|
||||
---
|
||||
|
||||
### Get User Statistics
|
||||
|
||||
Retrieves statistics about users and their access to servers and groups.
|
||||
|
||||
- **Endpoint**: `/api/users-stats`
|
||||
- **Method**: `GET`
|
||||
- **Authentication**: Required (Admin only)
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"totalUsers": 5,
|
||||
"adminUsers": 1,
|
||||
"regularUsers": 4,
|
||||
"usersPerServer": {
|
||||
"server1": 3,
|
||||
"server2": 2
|
||||
},
|
||||
"usersPerGroup": {
|
||||
"group1": 2,
|
||||
"group2": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: All user management endpoints require admin authentication.
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -94,9 +96,13 @@
|
||||
"pages": [
|
||||
"api-reference/servers",
|
||||
"api-reference/groups",
|
||||
"api-reference/users",
|
||||
"api-reference/tools",
|
||||
"api-reference/prompts",
|
||||
"api-reference/auth",
|
||||
"api-reference/logs",
|
||||
"api-reference/config"
|
||||
"api-reference/config",
|
||||
"api-reference/system"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -124,9 +130,13 @@
|
||||
"pages": [
|
||||
"zh/api-reference/servers",
|
||||
"zh/api-reference/groups",
|
||||
"zh/api-reference/users",
|
||||
"zh/api-reference/tools",
|
||||
"zh/api-reference/prompts",
|
||||
"zh/api-reference/auth",
|
||||
"zh/api-reference/logs",
|
||||
"zh/api-reference/config"
|
||||
"zh/api-reference/config",
|
||||
"zh/api-reference/system"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -159,4 +169,4 @@
|
||||
"discord": "https://discord.gg/qMKNsn5Q"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
267
docs/environment-variables.md
Normal file
267
docs/environment-variables.md
Normal 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
141
docs/features/oauth.mdx
Normal 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. GitHub’s MCP endpoint (`https://api.githubcopilot.com/mcp/`) is one example. To connect:
|
||||
|
||||
1. Create an OAuth App in the provider’s console (for GitHub, go to **Settings → Developer settings → OAuth Apps**).
|
||||
2. Set the callback/redirect URL to `http://localhost:3000/oauth/callback` (or your deployed dashboard domain).
|
||||
3. Copy the issued client ID and client secret.
|
||||
4. Supply the credentials through the MCPHub dashboard or by editing `mcp_settings.json` as shown below.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"type": "sse",
|
||||
"url": "https://api.githubcopilot.com/mcp/",
|
||||
"oauth": {
|
||||
"clientId": "${GITHUB_OAUTH_APP_ID}",
|
||||
"clientSecret": "${GITHUB_OAUTH_APP_SECRET}",
|
||||
"scopes": ["replace-with-provider-scope"],
|
||||
"resource": "https://api.githubcopilot.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- MCPHub skips dynamic registration and uses the credentials you provide to complete the OAuth exchange.
|
||||
- Update the dashboard or configuration file whenever you rotate secrets.
|
||||
- Replace `scopes` with the exact scope strings required by the provider.
|
||||
|
||||
## Configuration Options
|
||||
You can rely on auto-detection for most servers or declare OAuth settings explicitly in `mcp_settings.json`. Only populate the fields you need.
|
||||
|
||||
### Basic Auto Detection (Minimal Config)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"secured-sse": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"oauth": {
|
||||
"scopes": ["mcp.tools", "mcp.prompts"],
|
||||
"resource": "https://mcp.example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- MCPHub will discover the authorization server from challenge headers and walk the user through authorization automatically.
|
||||
- Tokens (including refresh tokens) are stored on disk and reused on restart.
|
||||
|
||||
### Static Client Credentials (Bring Your Own Client)
|
||||
```json
|
||||
{
|
||||
"oauth": {
|
||||
"clientId": "mcphub-client",
|
||||
"clientSecret": "replace-me-if-required",
|
||||
"authorizationEndpoint": "https://auth.example.com/oauth/authorize",
|
||||
"tokenEndpoint": "https://auth.example.com/oauth/token",
|
||||
"redirectUri": "http://localhost:3000/oauth/callback"
|
||||
}
|
||||
}
|
||||
```
|
||||
- Use this when the authorization server requires manual client provisioning.
|
||||
- `redirectUri` defaults to `http://localhost:3000/oauth/callback`; override it when running behind a custom domain.
|
||||
|
||||
### Dynamic Client Registration (RFC 7591)
|
||||
```json
|
||||
{
|
||||
"oauth": {
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"issuer": "https://auth.example.com",
|
||||
"metadata": {
|
||||
"client_name": "MCPHub",
|
||||
"redirect_uris": [
|
||||
"http://localhost:3000/oauth/callback",
|
||||
"https://mcphub.example.com/oauth/callback"
|
||||
],
|
||||
"scope": "mcp.tools mcp.prompts",
|
||||
"grant_types": ["authorization_code", "refresh_token"]
|
||||
},
|
||||
"initialAccessToken": "optional-token-if-required"
|
||||
},
|
||||
"scopes": ["mcp.tools", "mcp.prompts"],
|
||||
"resource": "https://mcp.example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
- MCPHub discovers endpoints via `issuer`, registers itself, and persists the issued `client_id`/`client_secret`.
|
||||
- Provide `initialAccessToken` only when the registration endpoint is protected.
|
||||
|
||||
## Authorization Flow
|
||||
1. **Initialization** – On startup MCPHub processes every server entry, discovers metadata, and registers the client if `dynamicRegistration.enabled` is true.
|
||||
2. **User Authorization** – Initiating a connection launches the system browser to the server’s authorize page with PKCE parameters.
|
||||
3. **Callback Handling** – The built-in route (`/oauth/callback`) verifies the `state`, completes the token exchange, and saves the tokens via the MCP SDK.
|
||||
4. **Token Lifecycle** – Access and refresh tokens are cached in memory, refreshed automatically, and written back to `mcp_settings.json`.
|
||||
|
||||
## Tips & Troubleshooting
|
||||
- Confirm that the redirect URI used during authorization exactly matches one of the `redirect_uris` registered with the authorization server.
|
||||
- When running behind HTTPS, expose the callback URL publicly or configure a reverse proxy at `/oauth/callback`.
|
||||
- If discovery fails, supply `authorizationEndpoint` and `tokenEndpoint` explicitly to bypass metadata lookup.
|
||||
- Remove stale tokens from `mcp_settings.json` if an authorization server revokes access—MCPHub will prompt for a fresh login on the next request.
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
169
docs/oauth-dynamic-registration-implementation.md
Normal file
169
docs/oauth-dynamic-registration-implementation.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# OAuth 动态客户端注册实现总结
|
||||
|
||||
## 概述
|
||||
|
||||
成功为 MCPHub 的 OAuth 2.0 授权服务器添加了 RFC 7591 标准的动态客户端注册功能。此功能允许 OAuth 客户端在运行时自动注册,无需管理员手动配置。
|
||||
|
||||
## 实现的功能
|
||||
|
||||
### 1. 核心端点
|
||||
|
||||
#### POST /oauth/register - 注册新客户端
|
||||
- 公开端点,支持动态客户端注册
|
||||
- 自动生成 client_id 和可选的 client_secret
|
||||
- 返回 registration_access_token 用于后续管理
|
||||
- 支持 PKCE 流程(token_endpoint_auth_method: "none")
|
||||
|
||||
#### GET /oauth/register/:clientId - 读取客户端配置
|
||||
- 需要 registration_access_token 认证
|
||||
- 返回完整的客户端元数据
|
||||
|
||||
#### PUT /oauth/register/:clientId - 更新客户端配置
|
||||
- 需要 registration_access_token 认证
|
||||
- 支持更新 redirect_uris、scopes、metadata 等
|
||||
|
||||
#### DELETE /oauth/register/:clientId - 删除客户端注册
|
||||
- 需要 registration_access_token 认证
|
||||
- 删除客户端并清理相关 tokens
|
||||
|
||||
### 2. 配置选项
|
||||
|
||||
在 `mcp_settings.json` 中添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"allowedGrantTypes": ["authorization_code", "refresh_token"],
|
||||
"requiresAuthentication": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 客户端元数据支持
|
||||
|
||||
实现了 RFC 7591 定义的完整客户端元数据:
|
||||
|
||||
- `application_type`: "web" 或 "native"
|
||||
- `response_types`: OAuth 响应类型数组
|
||||
- `token_endpoint_auth_method`: 认证方法
|
||||
- `contacts`: 联系邮箱数组
|
||||
- `logo_uri`: 客户端 logo URL
|
||||
- `client_uri`: 客户端主页 URL
|
||||
- `policy_uri`: 隐私政策 URL
|
||||
- `tos_uri`: 服务条款 URL
|
||||
- `jwks_uri`: JSON Web Key Set URL
|
||||
- `jwks`: 内联 JSON Web Key Set
|
||||
|
||||
### 4. 安全特性
|
||||
|
||||
- **Registration Access Token**: 每个注册的客户端获得唯一的访问令牌
|
||||
- **Token 过期**: Registration tokens 30 天后过期
|
||||
- **HTTPS 验证**: Redirect URIs 必须使用 HTTPS(localhost 除外)
|
||||
- **Scope 验证**: 只允许配置中定义的 scopes
|
||||
- **Grant Type 限制**: 只允许配置中定义的 grant types
|
||||
|
||||
## 文件变更
|
||||
|
||||
### 新增文件
|
||||
1. `src/controllers/oauthDynamicRegistrationController.ts` - 动态注册控制器
|
||||
2. `examples/oauth-dynamic-registration-config.json` - 配置示例
|
||||
|
||||
### 修改文件
|
||||
1. `src/types/index.ts` - 添加元数据字段到 IOAuthClient 和 OAuthServerConfig
|
||||
2. `src/routes/index.ts` - 注册新的动态注册端点
|
||||
3. `src/controllers/oauthServerController.ts` - 元数据端点包含 registration_endpoint
|
||||
4. `docs/oauth-server.md` - 添加完整的动态注册文档
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 注册新客户端
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/oauth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"client_name": "My Application",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "read write",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"logo_uri": "https://example.com/logo.png",
|
||||
"client_uri": "https://example.com",
|
||||
"contacts": ["admin@example.com"]
|
||||
}'
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"client_id": "a1b2c3d4e5f6g7h8",
|
||||
"client_name": "My Application",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"registration_access_token": "reg_token_xyz123",
|
||||
"registration_client_uri": "http://localhost:3000/oauth/register/a1b2c3d4e5f6g7h8",
|
||||
"client_id_issued_at": 1699200000
|
||||
}
|
||||
```
|
||||
|
||||
### 读取客户端配置
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### 更新客户端
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"client_name": "Updated Name",
|
||||
"redirect_uris": ["https://example.com/callback", "https://example.com/callback2"]
|
||||
}'
|
||||
```
|
||||
|
||||
### 删除客户端
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
## 测试结果
|
||||
|
||||
✅ 所有 180 个测试通过
|
||||
✅ TypeScript 编译成功
|
||||
✅ 代码覆盖率维持在合理水平
|
||||
✅ 与现有功能完全兼容
|
||||
|
||||
## RFC 合规性
|
||||
|
||||
完全遵循以下 RFC 标准:
|
||||
|
||||
- **RFC 7591**: OAuth 2.0 Dynamic Client Registration Protocol
|
||||
- **RFC 8414**: OAuth 2.0 Authorization Server Metadata
|
||||
- **RFC 7636**: Proof Key for Code Exchange (PKCE)
|
||||
- **RFC 9728**: OAuth 2.0 Protected Resource Metadata
|
||||
|
||||
## 下一步建议
|
||||
|
||||
1. **持久化存储**: 当前 registration tokens 存储在内存中,生产环境应使用数据库
|
||||
2. **速率限制**: 添加注册端点的速率限制以防止滥用
|
||||
3. **客户端证明**: 考虑添加软件声明(software_statement)支持
|
||||
4. **审计日志**: 记录所有注册、更新和删除操作
|
||||
5. **通知机制**: 在客户端注册时通知管理员(可选)
|
||||
|
||||
## 兼容性
|
||||
|
||||
- 与 ChatGPT Web 完全兼容
|
||||
- 支持所有标准 OAuth 2.0 客户端库
|
||||
- 向后兼容现有的手动客户端配置方式
|
||||
538
docs/oauth-server.md
Normal file
538
docs/oauth-server.md
Normal file
@@ -0,0 +1,538 @@
|
||||
# OAuth 2.0 Authorization Server
|
||||
|
||||
MCPHub can act as an OAuth 2.0 authorization server, allowing external applications like ChatGPT Web to securely authenticate and access your MCP servers.
|
||||
|
||||
## Overview
|
||||
|
||||
The OAuth 2.0 authorization server feature enables MCPHub to:
|
||||
|
||||
- Provide standard OAuth 2.0 authentication flows
|
||||
- Issue and manage access tokens for external clients
|
||||
- Support secure authorization without exposing user credentials
|
||||
- Enable integration with services that require OAuth (like ChatGPT Web)
|
||||
|
||||
## Configuration
|
||||
|
||||
### Enable OAuth Server
|
||||
|
||||
Add the following configuration to your `mcp_settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"accessTokenLifetime": 3600,
|
||||
"refreshTokenLifetime": 1209600,
|
||||
"authorizationCodeLifetime": 300,
|
||||
"requireClientSecret": false,
|
||||
"allowedScopes": ["read", "write"],
|
||||
"requireState": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `enabled` | boolean | `false` | Enable/disable OAuth authorization server |
|
||||
| `accessTokenLifetime` | number | `3600` | Access token lifetime in seconds (1 hour) |
|
||||
| `refreshTokenLifetime` | number | `1209600` | Refresh token lifetime in seconds (14 days) |
|
||||
| `authorizationCodeLifetime` | number | `300` | Authorization code lifetime in seconds (5 minutes) |
|
||||
| `requireClientSecret` | boolean | `false` | Whether client secret is required (set to false for PKCE) |
|
||||
| `allowedScopes` | string[] | `["read", "write"]` | List of allowed OAuth scopes |
|
||||
| `requireState` | boolean | `false` | When `true`, rejects authorization requests that omit the `state` parameter |
|
||||
|
||||
## OAuth Clients
|
||||
|
||||
### Creating OAuth Clients
|
||||
|
||||
#### Via API (Recommended)
|
||||
|
||||
Create an OAuth client using the API:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/oauth/clients \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN" \
|
||||
-d '{
|
||||
"name": "My Application",
|
||||
"redirectUris": ["https://example.com/callback"],
|
||||
"grants": ["authorization_code", "refresh_token"],
|
||||
"scopes": ["read", "write"],
|
||||
"requireSecret": false
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "OAuth client created successfully",
|
||||
"client": {
|
||||
"clientId": "a1b2c3d4e5f6g7h8",
|
||||
"clientSecret": null,
|
||||
"name": "My Application",
|
||||
"redirectUris": ["https://example.com/callback"],
|
||||
"grants": ["authorization_code", "refresh_token"],
|
||||
"scopes": ["read", "write"],
|
||||
"owner": "admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: If `requireSecret` is true, the `clientSecret` will be shown only once. Save it securely!
|
||||
|
||||
#### Via Configuration File
|
||||
|
||||
Alternatively, add OAuth clients directly to `mcp_settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"oauthClients": [
|
||||
{
|
||||
"clientId": "my-app-client",
|
||||
"clientSecret": "optional-secret-for-confidential-clients",
|
||||
"name": "My Application",
|
||||
"redirectUris": ["https://example.com/callback"],
|
||||
"grants": ["authorization_code", "refresh_token"],
|
||||
"scopes": ["read", "write"],
|
||||
"owner": "admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Managing OAuth Clients
|
||||
|
||||
#### List All Clients
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/oauth/clients \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
#### Get Specific Client
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/oauth/clients/CLIENT_ID \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
#### Update Client
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/api/oauth/clients/CLIENT_ID \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN" \
|
||||
-d '{
|
||||
"name": "Updated Name",
|
||||
"redirectUris": ["https://example.com/callback", "https://example.com/callback2"]
|
||||
}'
|
||||
```
|
||||
|
||||
#### Delete Client
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:3000/api/oauth/clients/CLIENT_ID \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
#### Regenerate Client Secret
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/oauth/clients/CLIENT_ID/regenerate-secret \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
## OAuth Flow
|
||||
|
||||
MCPHub supports the OAuth 2.0 Authorization Code flow with PKCE (Proof Key for Code Exchange).
|
||||
|
||||
### 1. Authorization Request
|
||||
|
||||
The client application redirects the user to the authorization endpoint:
|
||||
|
||||
```
|
||||
GET /oauth/authorize?
|
||||
client_id=CLIENT_ID&
|
||||
redirect_uri=REDIRECT_URI&
|
||||
response_type=code&
|
||||
scope=read%20write&
|
||||
state=RANDOM_STATE&
|
||||
code_challenge=CODE_CHALLENGE&
|
||||
code_challenge_method=S256
|
||||
```
|
||||
|
||||
Parameters:
|
||||
- `client_id`: OAuth client ID
|
||||
- `redirect_uri`: Redirect URI (must match registered URI)
|
||||
- `response_type`: Must be `code`
|
||||
- `scope`: Space-separated list of scopes (e.g., `read write`)
|
||||
- `state`: Random string to prevent CSRF attacks
|
||||
- `code_challenge`: PKCE code challenge (optional but recommended)
|
||||
- `code_challenge_method`: PKCE method (`S256` or `plain`)
|
||||
|
||||
### 2. User Authorization
|
||||
|
||||
The user is presented with a consent page showing:
|
||||
- Application name
|
||||
- Requested scopes
|
||||
- Approve/Deny buttons
|
||||
|
||||
If the user approves, they are redirected to the redirect URI with an authorization code:
|
||||
|
||||
```
|
||||
https://example.com/callback?code=AUTHORIZATION_CODE&state=RANDOM_STATE
|
||||
```
|
||||
|
||||
### 3. Token Exchange
|
||||
|
||||
The client exchanges the authorization code for an access token:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/oauth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=authorization_code" \
|
||||
-d "code=AUTHORIZATION_CODE" \
|
||||
-d "redirect_uri=REDIRECT_URI" \
|
||||
-d "client_id=CLIENT_ID" \
|
||||
-d "code_verifier=CODE_VERIFIER"
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"access_token": "ACCESS_TOKEN",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "REFRESH_TOKEN",
|
||||
"scope": "read write"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Using Access Token
|
||||
|
||||
Use the access token to make authenticated requests:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/servers \
|
||||
-H "Authorization: Bearer ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### 5. Refreshing Token
|
||||
|
||||
When the access token expires, use the refresh token to get a new one:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/oauth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=refresh_token" \
|
||||
-d "refresh_token=REFRESH_TOKEN" \
|
||||
-d "client_id=CLIENT_ID"
|
||||
```
|
||||
|
||||
## PKCE (Proof Key for Code Exchange)
|
||||
|
||||
PKCE is a security extension to OAuth 2.0 that prevents authorization code interception attacks. It's especially important for public clients (mobile apps, SPAs).
|
||||
|
||||
### Generating PKCE Parameters
|
||||
|
||||
1. Generate a code verifier (random string):
|
||||
```javascript
|
||||
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
||||
```
|
||||
|
||||
2. Generate code challenge from verifier:
|
||||
```javascript
|
||||
const codeChallenge = crypto
|
||||
.createHash('sha256')
|
||||
.update(codeVerifier)
|
||||
.digest('base64url');
|
||||
```
|
||||
|
||||
3. Include in authorization request:
|
||||
- `code_challenge`: The generated challenge
|
||||
- `code_challenge_method`: `S256`
|
||||
|
||||
4. Include in token request:
|
||||
- `code_verifier`: The original verifier
|
||||
|
||||
## OAuth Scopes
|
||||
|
||||
MCPHub supports the following default scopes:
|
||||
|
||||
| Scope | Description |
|
||||
|-------|-------------|
|
||||
| `read` | Read access to MCP servers and tools |
|
||||
| `write` | Execute tools and modify MCP server configurations |
|
||||
|
||||
You can customize allowed scopes in the `oauthServer.allowedScopes` configuration.
|
||||
|
||||
## Dynamic Client Registration (RFC 7591)
|
||||
|
||||
MCPHub supports RFC 7591 Dynamic Client Registration, allowing OAuth clients to register themselves programmatically without manual configuration.
|
||||
|
||||
### Enable Dynamic Registration
|
||||
|
||||
Add to your `mcp_settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"allowedGrantTypes": ["authorization_code", "refresh_token"],
|
||||
"requiresAuthentication": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Register a New Client
|
||||
|
||||
**POST /oauth/register**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/oauth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"client_name": "My Application",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "read write",
|
||||
"token_endpoint_auth_method": "none"
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"client_id": "a1b2c3d4e5f6g7h8",
|
||||
"client_name": "My Application",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "read write",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"registration_access_token": "reg_token_xyz123",
|
||||
"registration_client_uri": "http://localhost:3000/oauth/register/a1b2c3d4e5f6g7h8",
|
||||
"client_id_issued_at": 1699200000
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** Save the `registration_access_token` - it's required to read, update, or delete the client registration.
|
||||
|
||||
### Read Client Configuration
|
||||
|
||||
**GET /oauth/register/:clientId**
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### Update Client Configuration
|
||||
|
||||
**PUT /oauth/register/:clientId**
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"client_name": "Updated Application Name",
|
||||
"redirect_uris": ["https://example.com/callback", "https://example.com/callback2"]
|
||||
}'
|
||||
```
|
||||
|
||||
### Delete Client Registration
|
||||
|
||||
**DELETE /oauth/register/:clientId**
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### Optional Client Metadata
|
||||
|
||||
When registering a client, you can include additional metadata:
|
||||
|
||||
- `application_type`: `"web"` or `"native"` (default: `"web"`)
|
||||
- `contacts`: Array of email addresses
|
||||
- `logo_uri`: URL of client logo
|
||||
- `client_uri`: URL of client homepage
|
||||
- `policy_uri`: URL of privacy policy
|
||||
- `tos_uri`: URL of terms of service
|
||||
- `jwks_uri`: URL of JSON Web Key Set
|
||||
- `jwks`: Inline JSON Web Key Set
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"client_name": "My Application",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"application_type": "web",
|
||||
"contacts": ["admin@example.com"],
|
||||
"logo_uri": "https://example.com/logo.png",
|
||||
"client_uri": "https://example.com",
|
||||
"policy_uri": "https://example.com/privacy",
|
||||
"tos_uri": "https://example.com/terms"
|
||||
}
|
||||
```
|
||||
|
||||
## Server Metadata
|
||||
|
||||
MCPHub provides OAuth 2.0 Authorization Server Metadata (RFC 8414) at:
|
||||
|
||||
```
|
||||
GET /.well-known/oauth-authorization-server
|
||||
```
|
||||
|
||||
Response (with dynamic registration enabled):
|
||||
```json
|
||||
{
|
||||
"issuer": "http://localhost:3000",
|
||||
"authorization_endpoint": "http://localhost:3000/oauth/authorize",
|
||||
"token_endpoint": "http://localhost:3000/oauth/token",
|
||||
"userinfo_endpoint": "http://localhost:3000/oauth/userinfo",
|
||||
"registration_endpoint": "http://localhost:3000/oauth/register",
|
||||
"scopes_supported": ["read", "write"],
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"],
|
||||
"code_challenge_methods_supported": ["S256", "plain"]
|
||||
}
|
||||
```
|
||||
|
||||
## User Info Endpoint
|
||||
|
||||
Get authenticated user information (OpenID Connect compatible):
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/oauth/userinfo \
|
||||
-H "Authorization: Bearer ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"sub": "username",
|
||||
"username": "username"
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with ChatGPT Web
|
||||
|
||||
To integrate MCPHub with ChatGPT Web:
|
||||
|
||||
1. Enable OAuth server in MCPHub configuration
|
||||
2. Create an OAuth client with ChatGPT's redirect URI
|
||||
3. Configure ChatGPT Web MCP Connector:
|
||||
- **MCP Server URL**: `http://your-mcphub-url/mcp`
|
||||
- **Authentication**: OAuth
|
||||
- **OAuth Client ID**: Your client ID
|
||||
- **OAuth Client Secret**: Leave empty (PKCE flow)
|
||||
- **Authorization URL**: `http://your-mcphub-url/oauth/authorize`
|
||||
- **Token URL**: `http://your-mcphub-url/oauth/token`
|
||||
- **Scopes**: `read write`
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **HTTPS in Production**: Always use HTTPS in production to protect tokens in transit
|
||||
2. **Secure Client Secrets**: If using confidential clients, store client secrets securely
|
||||
3. **Token Storage**: Access tokens are stored in memory by default. For production, consider using a database
|
||||
4. **Token Rotation**: Implement token rotation by using refresh tokens
|
||||
5. **Scope Limitation**: Grant only necessary scopes to clients
|
||||
6. **Redirect URI Validation**: Always validate redirect URIs strictly
|
||||
7. **State Parameter**: Always use the state parameter to prevent CSRF attacks
|
||||
8. **PKCE**: Use PKCE for public clients (strongly recommended)
|
||||
9. **Rate Limiting**: For production deployments, implement rate limiting on OAuth endpoints to prevent brute force attacks. Consider using middleware like `express-rate-limit`
|
||||
10. **Input Validation**: All OAuth parameters are validated, but additional application-level validation may be beneficial
|
||||
11. **XSS Protection**: The authorization page escapes all user input to prevent XSS attacks
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "OAuth server not available"
|
||||
|
||||
Make sure `oauthServer.enabled` is set to `true` in your configuration and restart MCPHub.
|
||||
|
||||
### "Invalid redirect_uri"
|
||||
|
||||
Ensure the redirect URI in the authorization request exactly matches one of the registered redirect URIs for the client.
|
||||
|
||||
### "Invalid client"
|
||||
|
||||
Verify the client ID is correct and the OAuth client exists in the configuration.
|
||||
|
||||
### Token expired
|
||||
|
||||
Use the refresh token to obtain a new access token, or re-authorize the application.
|
||||
|
||||
## Example: JavaScript Client
|
||||
|
||||
```javascript
|
||||
// Generate PKCE parameters
|
||||
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
||||
const codeChallenge = crypto
|
||||
.createHash('sha256')
|
||||
.update(codeVerifier)
|
||||
.digest('base64url');
|
||||
|
||||
// Store code verifier for later use
|
||||
sessionStorage.setItem('codeVerifier', codeVerifier);
|
||||
|
||||
// Redirect to authorization endpoint
|
||||
const authUrl = new URL('http://localhost:3000/oauth/authorize');
|
||||
authUrl.searchParams.set('client_id', 'my-client-id');
|
||||
authUrl.searchParams.set('redirect_uri', 'http://localhost:8080/callback');
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('scope', 'read write');
|
||||
authUrl.searchParams.set('state', crypto.randomBytes(16).toString('hex'));
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
window.location.href = authUrl.toString();
|
||||
|
||||
// In callback handler:
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const codeVerifier = sessionStorage.getItem('codeVerifier');
|
||||
|
||||
// Exchange code for token
|
||||
const tokenResponse = await fetch('http://localhost:3000/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
redirect_uri: 'http://localhost:8080/callback',
|
||||
client_id: 'my-client-id',
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
// Store tokens securely
|
||||
localStorage.setItem('accessToken', tokens.access_token);
|
||||
localStorage.setItem('refreshToken', tokens.refresh_token);
|
||||
|
||||
// Use access token
|
||||
const response = await fetch('http://localhost:3000/api/servers', {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [OAuth 2.0 - RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749)
|
||||
- [OAuth 2.0 Authorization Server Metadata - RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)
|
||||
- [PKCE - RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)
|
||||
- [OAuth 2.0 for Browser-Based Apps](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps)
|
||||
@@ -60,6 +60,32 @@ curl "http://localhost:3000/api/openapi.json?title=我的 MCP API&version=2.0.0"
|
||||
要包含的服务器名称列表(逗号分隔)
|
||||
</ParamField>
|
||||
|
||||
### 组/服务器特定的 OpenAPI 规范
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/:name/openapi.json
|
||||
curl "http://localhost:3000/api/mygroup/openapi.json"
|
||||
```
|
||||
|
||||
```bash 带参数
|
||||
curl "http://localhost:3000/api/myserver/openapi.json?title=我的服务器 API&version=1.0.0"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
为特定组或服务器生成并返回 OpenAPI 3.0.3 规范。如果存在具有给定名称的组,则返回该组中所有服务器的规范。否则,将名称视为服务器名称并仅返回该服务器的规范。
|
||||
|
||||
**路径参数:**
|
||||
|
||||
<ParamField path="name" type="string" required>
|
||||
组 ID/名称或服务器名称
|
||||
</ParamField>
|
||||
|
||||
**查询参数:**
|
||||
|
||||
与主 OpenAPI 规范端点相同(title、description、version、serverUrl、includeDisabled)。
|
||||
|
||||
### 可用服务器
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
142
docs/zh/api-reference/prompts.mdx
Normal file
142
docs/zh/api-reference/prompts.mdx
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: "提示词"
|
||||
description: "管理和执行 MCP 提示词。"
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'mintlify';
|
||||
|
||||
<Card
|
||||
title="POST /api/mcp/:serverName/prompts/:promptName"
|
||||
href="#get-a-prompt"
|
||||
>
|
||||
在 MCP 服务器上执行提示词。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="POST /api/servers/:serverName/prompts/:promptName/toggle"
|
||||
href="#toggle-a-prompt"
|
||||
>
|
||||
启用或禁用提示词。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="PUT /api/servers/:serverName/prompts/:promptName/description"
|
||||
href="#update-prompt-description"
|
||||
>
|
||||
更新提示词的描述。
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### 获取提示词
|
||||
|
||||
在 MCP 服务器上执行提示词并获取结果。
|
||||
|
||||
- **端点**: `/api/mcp/:serverName/prompts/:promptName`
|
||||
- **方法**: `POST`
|
||||
- **身份验证**: 必需
|
||||
- **参数**:
|
||||
- `:serverName` (字符串, 必需): MCP 服务器的名称。
|
||||
- `:promptName` (字符串, 必需): 提示词的名称。
|
||||
- **请求正文**:
|
||||
```json
|
||||
{
|
||||
"arguments": {
|
||||
"arg1": "value1",
|
||||
"arg2": "value2"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `arguments` (对象, 可选): 传递给提示词的参数。
|
||||
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "提示词内容"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/mcp/myserver/prompts/code-review" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{
|
||||
"arguments": {
|
||||
"language": "typescript",
|
||||
"code": "const x = 1;"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 切换提示词
|
||||
|
||||
启用或禁用服务器上的特定提示词。
|
||||
|
||||
- **端点**: `/api/servers/:serverName/prompts/:promptName/toggle`
|
||||
- **方法**: `POST`
|
||||
- **身份验证**: 必需
|
||||
- **参数**:
|
||||
- `:serverName` (字符串, 必需): 服务器的名称。
|
||||
- `:promptName` (字符串, 必需): 提示词的名称。
|
||||
- **请求正文**:
|
||||
```json
|
||||
{
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
- `enabled` (布尔值, 必需): `true` 启用提示词, `false` 禁用提示词。
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/servers/myserver/prompts/code-review/toggle" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{"enabled": false}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 更新提示词描述
|
||||
|
||||
更新特定提示词的描述。
|
||||
|
||||
- **端点**: `/api/servers/:serverName/prompts/:promptName/description`
|
||||
- **方法**: `PUT`
|
||||
- **身份验证**: 必需
|
||||
- **参数**:
|
||||
- `:serverName` (字符串, 必需): 服务器的名称。
|
||||
- `:promptName` (字符串, 必需): 提示词的名称。
|
||||
- **请求正文**:
|
||||
```json
|
||||
{
|
||||
"description": "新的提示词描述"
|
||||
}
|
||||
```
|
||||
- `description` (字符串, 必需): 提示词的新描述。
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl -X PUT "http://localhost:3000/api/servers/myserver/prompts/code-review/description" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{"description": "审查代码的最佳实践和潜在问题"}'
|
||||
```
|
||||
|
||||
**注意**: 提示词是可用于生成标准化请求到 MCP 服务器的模板。它们由 MCP 服务器定义,并且可以具有在执行提示词时填充的参数。
|
||||
@@ -54,6 +54,20 @@ import { Card, Cards } from 'mintlify';
|
||||
更新工具的描述。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="PUT /api/system-config"
|
||||
href="#update-system-config"
|
||||
>
|
||||
更新系统配置设置。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /api/settings"
|
||||
href="#get-settings"
|
||||
>
|
||||
获取所有服务器设置和配置。
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### 获取所有服务器
|
||||
@@ -207,3 +221,45 @@ import { Card, Cards } from 'mintlify';
|
||||
}
|
||||
```
|
||||
- `description` (string, 必填): 工具的新描述。
|
||||
|
||||
---
|
||||
|
||||
### 更新系统配置
|
||||
|
||||
更新系统范围的配置设置。
|
||||
|
||||
- **端点**: `/api/system-config`
|
||||
- **方法**: `PUT`
|
||||
- **正文**:
|
||||
```json
|
||||
{
|
||||
"openaiApiKey": "sk-...",
|
||||
"openaiBaseUrl": "https://api.openai.com/v1",
|
||||
"modelName": "gpt-4",
|
||||
"temperature": 0.7,
|
||||
"maxTokens": 2048
|
||||
}
|
||||
```
|
||||
- 所有字段都是可选的。只有提供的字段会被更新。
|
||||
|
||||
---
|
||||
|
||||
### 获取设置
|
||||
|
||||
检索所有服务器设置和配置。
|
||||
|
||||
- **端点**: `/api/settings`
|
||||
- **方法**: `GET`
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"servers": [...],
|
||||
"groups": [...],
|
||||
"systemConfig": {...}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**: 有关详细的提示词管理,请参阅 [提示词 API](/zh/api-reference/prompts) 文档。
|
||||
|
||||
113
docs/zh/api-reference/system.mdx
Normal file
113
docs/zh/api-reference/system.mdx
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
title: "系统"
|
||||
description: "系统和实用程序端点。"
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'mintlify';
|
||||
|
||||
<Card
|
||||
title="GET /health"
|
||||
href="#health-check"
|
||||
>
|
||||
检查 MCPHub 服务器的健康状态。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /oauth/callback"
|
||||
href="#oauth-callback"
|
||||
>
|
||||
用于身份验证流程的 OAuth 回调端点。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="POST /api/dxt/upload"
|
||||
href="#upload-dxt-file"
|
||||
>
|
||||
上传 DXT 配置文件。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /api/mcp-settings/export"
|
||||
href="#export-mcp-settings"
|
||||
>
|
||||
将 MCP 设置导出为 JSON。
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### 健康检查
|
||||
|
||||
检查 MCPHub 服务器的健康状态。
|
||||
|
||||
- **端点**: `/health`
|
||||
- **方法**: `GET`
|
||||
- **身份验证**: 不需要
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2024-11-12T01:30:00.000Z",
|
||||
"uptime": 12345
|
||||
}
|
||||
```
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3000/health"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### OAuth 回调
|
||||
|
||||
用于处理 OAuth 身份验证流程的 OAuth 回调端点。此端点在用户授权后由 OAuth 提供商自动调用。
|
||||
|
||||
- **端点**: `/oauth/callback`
|
||||
- **方法**: `GET`
|
||||
- **身份验证**: 不需要(公共回调 URL)
|
||||
- **查询参数**: 因 OAuth 提供商而异(通常包括 `code`、`state` 等)
|
||||
|
||||
**注意**: 此端点由 MCPHub 的 OAuth 集成内部使用,客户端不应直接调用。
|
||||
|
||||
---
|
||||
|
||||
### 上传 DXT 文件
|
||||
|
||||
上传 DXT(桌面扩展)配置文件以导入服务器配置。
|
||||
|
||||
- **端点**: `/api/dxt/upload`
|
||||
- **方法**: `POST`
|
||||
- **身份验证**: 必需
|
||||
- **Content-Type**: `multipart/form-data`
|
||||
- **正文**:
|
||||
- `file` (文件, 必需): 要上传的 DXT 配置文件。
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/dxt/upload" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-F "file=@config.dxt"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 导出 MCP 设置
|
||||
|
||||
将当前 MCP 设置配置导出为 JSON 文件。
|
||||
|
||||
- **端点**: `/api/mcp-settings/export`
|
||||
- **方法**: `GET`
|
||||
- **身份验证**: 必需
|
||||
- **响应**: 返回 `mcp_settings.json` 配置文件。
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3000/api/mcp-settings/export" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-o mcp_settings.json
|
||||
```
|
||||
|
||||
**注意**: 此端点允许您下载 MCP 设置的备份,可用于恢复或迁移您的配置。
|
||||
86
docs/zh/api-reference/tools.mdx
Normal file
86
docs/zh/api-reference/tools.mdx
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: "工具"
|
||||
description: "以编程方式执行 MCP 工具。"
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'mintlify';
|
||||
|
||||
<Card
|
||||
title="POST /api/tools/call/:server"
|
||||
href="#call-a-tool"
|
||||
>
|
||||
在 MCP 服务器上调用特定工具。
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### 调用工具
|
||||
|
||||
使用给定参数在 MCP 服务器上执行特定工具。
|
||||
|
||||
- **端点**: `/api/tools/call/:server`
|
||||
- **方法**: `POST`
|
||||
- **参数**:
|
||||
- `:server` (字符串, 必需): MCP 服务器的名称。
|
||||
- **请求正文**:
|
||||
```json
|
||||
{
|
||||
"toolName": "tool-name",
|
||||
"arguments": {
|
||||
"param1": "value1",
|
||||
"param2": "value2"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `toolName` (字符串, 必需): 要执行的工具名称。
|
||||
- `arguments` (对象, 可选): 传递给工具的参数。默认为空对象。
|
||||
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "工具执行结果"
|
||||
}
|
||||
],
|
||||
"toolName": "tool-name",
|
||||
"arguments": {
|
||||
"param1": "value1",
|
||||
"param2": "value2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/tools/call/amap" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{
|
||||
"toolName": "amap-maps_weather",
|
||||
"arguments": {
|
||||
"city": "Beijing"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**注意事项:**
|
||||
- 工具参数会根据工具的输入模式自动转换为适当的类型。
|
||||
- 如果需要,可以使用 `x-session-id` 请求头在多个工具调用之间维护会话状态。
|
||||
- 此端点需要身份验证。
|
||||
|
||||
---
|
||||
|
||||
### 替代方案:OpenAPI 工具执行
|
||||
|
||||
有关无需身份验证的 OpenAPI 兼容工具执行,请参阅 [OpenAPI 集成](/api-reference/openapi#tool-execution) 文档。OpenAPI 端点提供:
|
||||
|
||||
- **GET** `/api/tools/:serverName/:toolName` - 用于带查询参数的简单工具
|
||||
- **POST** `/api/tools/:serverName/:toolName` - 用于带 JSON 正文的复杂工具
|
||||
|
||||
这些端点专为与 OpenWebUI 和其他 OpenAPI 兼容系统集成而设计。
|
||||
195
docs/zh/api-reference/users.mdx
Normal file
195
docs/zh/api-reference/users.mdx
Normal file
@@ -0,0 +1,195 @@
|
||||
---
|
||||
title: "用户"
|
||||
description: "在 MCPHub 中管理用户。"
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'mintlify';
|
||||
|
||||
<Card
|
||||
title="GET /api/users"
|
||||
href="#get-all-users"
|
||||
>
|
||||
获取所有用户的列表。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /api/users/:username"
|
||||
href="#get-a-user"
|
||||
>
|
||||
获取特定用户的详细信息。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="POST /api/users"
|
||||
href="#create-a-user"
|
||||
>
|
||||
创建新用户。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="PUT /api/users/:username"
|
||||
href="#update-a-user"
|
||||
>
|
||||
更新现有用户。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="DELETE /api/users/:username"
|
||||
href="#delete-a-user"
|
||||
>
|
||||
删除用户。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /api/users-stats"
|
||||
href="#get-user-statistics"
|
||||
>
|
||||
获取有关用户及其服务器访问权限的统计信息。
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### 获取所有用户
|
||||
|
||||
检索系统中所有用户的列表。
|
||||
|
||||
- **端点**: `/api/users`
|
||||
- **方法**: `GET`
|
||||
- **身份验证**: 必需(仅管理员)
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"username": "admin",
|
||||
"role": "admin",
|
||||
"servers": ["server1", "server2"],
|
||||
"groups": ["group1"]
|
||||
},
|
||||
{
|
||||
"username": "user1",
|
||||
"role": "user",
|
||||
"servers": ["server1"],
|
||||
"groups": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 获取用户
|
||||
|
||||
检索特定用户的详细信息。
|
||||
|
||||
- **端点**: `/api/users/:username`
|
||||
- **方法**: `GET`
|
||||
- **身份验证**: 必需(仅管理员)
|
||||
- **参数**:
|
||||
- `:username` (字符串, 必需): 用户的用户名。
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"username": "user1",
|
||||
"role": "user",
|
||||
"servers": ["server1", "server2"],
|
||||
"groups": ["group1"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 创建用户
|
||||
|
||||
在系统中创建新用户。
|
||||
|
||||
- **端点**: `/api/users`
|
||||
- **方法**: `POST`
|
||||
- **身份验证**: 必需(仅管理员)
|
||||
- **请求正文**:
|
||||
```json
|
||||
{
|
||||
"username": "newuser",
|
||||
"password": "securepassword",
|
||||
"role": "user",
|
||||
"servers": ["server1"],
|
||||
"groups": ["group1"]
|
||||
}
|
||||
```
|
||||
- `username` (字符串, 必需): 新用户的用户名。
|
||||
- `password` (字符串, 必需): 新用户的密码。至少 6 个字符。
|
||||
- `role` (字符串, 可选): 用户的角色。可以是 `"admin"` 或 `"user"`。默认为 `"user"`。
|
||||
- `servers` (字符串数组, 可选): 用户可以访问的服务器名称列表。
|
||||
- `groups` (字符串数组, 可选): 用户所属的组 ID 列表。
|
||||
|
||||
---
|
||||
|
||||
### 更新用户
|
||||
|
||||
更新现有用户的信息。
|
||||
|
||||
- **端点**: `/api/users/:username`
|
||||
- **方法**: `PUT`
|
||||
- **身份验证**: 必需(仅管理员)
|
||||
- **参数**:
|
||||
- `:username` (字符串, 必需): 要更新的用户的用户名。
|
||||
- **请求正文**:
|
||||
```json
|
||||
{
|
||||
"password": "newpassword",
|
||||
"role": "admin",
|
||||
"servers": ["server1", "server2", "server3"],
|
||||
"groups": ["group1", "group2"]
|
||||
}
|
||||
```
|
||||
- `password` (字符串, 可选): 用户的新密码。
|
||||
- `role` (字符串, 可选): 用户的新角色。
|
||||
- `servers` (字符串数组, 可选): 更新的可访问服务器列表。
|
||||
- `groups` (字符串数组, 可选): 更新的组列表。
|
||||
|
||||
---
|
||||
|
||||
### 删除用户
|
||||
|
||||
从系统中删除用户。
|
||||
|
||||
- **端点**: `/api/users/:username`
|
||||
- **方法**: `DELETE`
|
||||
- **身份验证**: 必需(仅管理员)
|
||||
- **参数**:
|
||||
- `:username` (字符串, 必需): 要删除的用户的用户名。
|
||||
|
||||
---
|
||||
|
||||
### 获取用户统计信息
|
||||
|
||||
检索有关用户及其对服务器和组的访问权限的统计信息。
|
||||
|
||||
- **端点**: `/api/users-stats`
|
||||
- **方法**: `GET`
|
||||
- **身份验证**: 必需(仅管理员)
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"totalUsers": 5,
|
||||
"adminUsers": 1,
|
||||
"regularUsers": 4,
|
||||
"usersPerServer": {
|
||||
"server1": 3,
|
||||
"server2": 2
|
||||
},
|
||||
"usersPerGroup": {
|
||||
"group1": 2,
|
||||
"group2": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**: 所有用户管理端点都需要管理员身份验证。
|
||||
@@ -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
|
||||
|
||||
@@ -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 依赖
|
||||
|
||||
@@ -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
141
docs/zh/features/oauth.mdx
Normal 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 会在下一次请求时重新触发授权。
|
||||
@@ -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">
|
||||
|
||||
80
examples/mcp_settings_with_env_vars.json
Normal file
80
examples/mcp_settings_with_env_vars.json
Normal 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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
25
examples/oauth-dynamic-registration-config.json
Normal file
25
examples/oauth-dynamic-registration-config.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"accessTokenLifetime": 3600,
|
||||
"refreshTokenLifetime": 1209600,
|
||||
"authorizationCodeLifetime": 300,
|
||||
"requireClientSecret": false,
|
||||
"allowedScopes": ["read", "write"],
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"allowedGrantTypes": ["authorization_code", "refresh_token"],
|
||||
"requiresAuthentication": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"mcpServers": {},
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "$2b$10$abcdefghijklmnopqrstuv",
|
||||
"isAdmin": true
|
||||
}
|
||||
]
|
||||
}
|
||||
76
examples/oauth-server-config.json
Normal file
76
examples/oauth-server-config.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest",
|
||||
"--headless"
|
||||
]
|
||||
},
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-server-fetch"
|
||||
]
|
||||
}
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
|
||||
"isAdmin": true
|
||||
}
|
||||
],
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"accessTokenLifetime": 3600,
|
||||
"refreshTokenLifetime": 1209600,
|
||||
"authorizationCodeLifetime": 300,
|
||||
"requireClientSecret": false,
|
||||
"allowedScopes": [
|
||||
"read",
|
||||
"write"
|
||||
]
|
||||
},
|
||||
"routing": {
|
||||
"skipAuth": false
|
||||
}
|
||||
},
|
||||
"oauthClients": [
|
||||
{
|
||||
"clientId": "chatgpt-web-client",
|
||||
"name": "ChatGPT Web Integration",
|
||||
"redirectUris": [
|
||||
"https://chatgpt.com/oauth/callback",
|
||||
"https://chat.openai.com/oauth/callback"
|
||||
],
|
||||
"grants": [
|
||||
"authorization_code",
|
||||
"refresh_token"
|
||||
],
|
||||
"scopes": [
|
||||
"read",
|
||||
"write"
|
||||
],
|
||||
"owner": "admin"
|
||||
},
|
||||
{
|
||||
"clientId": "example-public-app",
|
||||
"name": "Example Public Application",
|
||||
"redirectUris": [
|
||||
"http://localhost:8080/callback",
|
||||
"http://localhost:3001/callback"
|
||||
],
|
||||
"grants": [
|
||||
"authorization_code",
|
||||
"refresh_token"
|
||||
],
|
||||
"scopes": [
|
||||
"read",
|
||||
"write"
|
||||
],
|
||||
"owner": "admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -57,28 +57,28 @@ const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white p-8 rounded-xl shadow-2xl max-w-md w-full mx-4 border border-gray-100">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('users.addNew')}</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">{t('users.addNew')}</h2>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-3 mb-4">
|
||||
<p className="text-sm">{error}</p>
|
||||
<div className="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6 rounded-md">
|
||||
<p className="text-sm font-medium">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('users.username')} *
|
||||
{t('users.username')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -87,7 +87,7 @@ const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t('users.usernamePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
@@ -95,7 +95,7 @@ const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('users.password')} *
|
||||
{t('users.password')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -104,43 +104,68 @@ const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t('users.passwordPlaceholder')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isAdmin"
|
||||
name="isAdmin"
|
||||
checked={formData.isAdmin}
|
||||
onChange={handleInputChange}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded transition-colors duration-200"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<label htmlFor="isAdmin" className="ml-2 block text-sm text-gray-700">
|
||||
<label
|
||||
htmlFor="isAdmin"
|
||||
className="ml-3 block text-sm font-medium text-gray-700 cursor-pointer select-none"
|
||||
>
|
||||
{t('users.adminRole')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<div className="flex justify-end space-x-3 mt-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 transition-colors duration-200"
|
||||
className="px-5 py-2.5 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-all duration-200 font-medium btn-secondary shadow-sm"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 font-medium btn-primary shadow-md disabled:opacity-70 disabled:cursor-not-allowed flex items-center"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && (
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
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>
|
||||
)}
|
||||
{isSubmitting ? t('common.creating') : t('users.create')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,51 +1,52 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Server } from '@/types'
|
||||
import { apiPut } from '../utils/fetchInterceptor'
|
||||
import ServerForm from './ServerForm'
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server } from '@/types';
|
||||
import { apiPut } from '../utils/fetchInterceptor';
|
||||
import ServerForm from './ServerForm';
|
||||
|
||||
interface EditServerFormProps {
|
||||
server: Server
|
||||
onEdit: () => void
|
||||
onCancel: () => void
|
||||
server: Server;
|
||||
onEdit: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (payload: any) => {
|
||||
try {
|
||||
setError(null)
|
||||
const result = await apiPut(`/servers/${server.name}`, payload)
|
||||
setError(null);
|
||||
const encodedServerName = encodeURIComponent(server.name);
|
||||
const result = await apiPut(`/servers/${encodedServerName}`, payload);
|
||||
|
||||
if (!result.success) {
|
||||
// Use specific error message from the response if available
|
||||
if (result && result.message) {
|
||||
setError(result.message)
|
||||
setError(result.message);
|
||||
} else {
|
||||
setError(t('server.updateError', { serverName: server.name }))
|
||||
setError(t('server.updateError', { serverName: server.name }));
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
onEdit()
|
||||
onEdit();
|
||||
} catch (err) {
|
||||
console.error('Error updating server:', err)
|
||||
console.error('Error updating server:', err);
|
||||
|
||||
// Use friendly error messages based on error type
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'))
|
||||
} else if (err instanceof TypeError && (
|
||||
err.message.includes('NetworkError') ||
|
||||
err.message.includes('Failed to fetch')
|
||||
)) {
|
||||
setError(t('errors.serverConnection'))
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverUpdate', { serverName: server.name }))
|
||||
setError(t('errors.serverUpdate', { serverName: server.name }));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
@@ -57,7 +58,7 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
|
||||
formError={error}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default EditServerForm
|
||||
export default EditServerForm;
|
||||
|
||||
@@ -62,93 +62,132 @@ const EditUserForm = ({ user, onEdit, onCancel }: EditUserFormProps) => {
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white p-8 rounded-xl shadow-2xl max-w-md w-full mx-4 border border-gray-100">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||
{t('users.edit')} - {user.username}
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">
|
||||
{t('users.edit')} - <span className="text-blue-600">{user.username}</span>
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-3 mb-4">
|
||||
<p className="text-sm">{error}</p>
|
||||
<div className="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6 rounded-md">
|
||||
<p className="text-sm font-medium">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isAdmin"
|
||||
name="isAdmin"
|
||||
checked={formData.isAdmin}
|
||||
onChange={handleInputChange}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded transition-colors duration-200"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<label htmlFor="isAdmin" className="ml-2 block text-sm text-gray-700">
|
||||
<label
|
||||
htmlFor="isAdmin"
|
||||
className="ml-3 block text-sm font-medium text-gray-700 cursor-pointer select-none"
|
||||
>
|
||||
{t('users.adminRole')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('users.newPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
value={formData.newPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t('users.newPasswordPlaceholder')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isSubmitting}
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 pt-4 mt-2">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold tracking-wider mb-3">
|
||||
{t('users.changePassword')}
|
||||
</p>
|
||||
|
||||
{formData.newPassword && (
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('users.confirmPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t('users.confirmPasswordPlaceholder')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isSubmitting}
|
||||
minLength={6}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="newPassword"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
{t('users.newPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
value={formData.newPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t('users.newPasswordPlaceholder')}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
|
||||
disabled={isSubmitting}
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.newPassword && (
|
||||
<div className="animate-fadeIn">
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
{t('users.confirmPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t('users.confirmPasswordPlaceholder')}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
|
||||
disabled={isSubmitting}
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<div className="flex justify-end space-x-3 mt-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 transition-colors duration-200"
|
||||
className="px-5 py-2.5 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-all duration-200 font-medium btn-secondary shadow-sm"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 font-medium btn-primary shadow-md disabled:opacity-70 disabled:cursor-not-allowed flex items-center"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && (
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
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>
|
||||
)}
|
||||
{isSubmitting ? t('common.updating') : t('users.update')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
311
frontend/src/components/JSONImportForm.tsx
Normal file
311
frontend/src/components/JSONImportForm.tsx
Normal 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;
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
205
frontend/src/components/RegistryServerCard.tsx
Normal file
205
frontend/src/components/RegistryServerCard.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RegistryServerEntry } from '@/types';
|
||||
|
||||
interface RegistryServerCardProps {
|
||||
serverEntry: RegistryServerEntry;
|
||||
onClick: (serverEntry: RegistryServerEntry) => void;
|
||||
}
|
||||
|
||||
const RegistryServerCard: React.FC<RegistryServerCardProps> = ({ serverEntry, onClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const { server, _meta } = serverEntry;
|
||||
|
||||
const handleClick = () => {
|
||||
onClick(serverEntry);
|
||||
};
|
||||
|
||||
// Get display description
|
||||
const getDisplayDescription = () => {
|
||||
if (server.description && server.description.length <= 150) {
|
||||
return server.description;
|
||||
}
|
||||
return server.description
|
||||
? server.description.slice(0, 150) + '...'
|
||||
: t('registry.noDescription');
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
return `${year}/${month}/${day}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Get icon to display
|
||||
const getIcon = () => {
|
||||
if (server.icons && server.icons.length > 0) {
|
||||
// Prefer light theme icon
|
||||
const lightIcon = server.icons.find((icon) => !icon.theme || icon.theme === 'light');
|
||||
return lightIcon || server.icons[0];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const icon = getIcon();
|
||||
const officialMeta = _meta?.['io.modelcontextprotocol.registry/official'];
|
||||
const isLatest = officialMeta?.isLatest;
|
||||
const publishedAt = officialMeta?.publishedAt;
|
||||
const updatedAt = officialMeta?.updatedAt;
|
||||
|
||||
// Count packages and remotes
|
||||
const packageCount = server.packages?.length || 0;
|
||||
const remoteCount = server.remotes?.length || 0;
|
||||
const totalOptions = packageCount + remoteCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-lg hover:border-blue-400 hover:-translate-y-1 transition-all duration-300 cursor-pointer group relative overflow-hidden h-full flex flex-col"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Background gradient overlay on hover */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/0 to-purple-50/0 group-hover:from-blue-50/30 group-hover:to-purple-50/30 transition-all duration-300 pointer-events-none" />
|
||||
|
||||
{/* Server Header */}
|
||||
<div className="relative z-10 flex-1 flex flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
{/* Icon */}
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon.src}
|
||||
alt={server.title}
|
||||
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center text-white text-xl font-semibold flex-shrink-0">
|
||||
M
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and badges */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors duration-200 mb-1 line-clamp-2">
|
||||
{server.name}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
{isLatest && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{t('registry.latest')}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
v{server.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Name */}
|
||||
{/* <div className="mb-2">
|
||||
<p className="text-xs text-gray-500 font-mono">{server.name}</p>
|
||||
</div> */}
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-3 flex-1">
|
||||
<p className="text-gray-600 text-sm leading-relaxed line-clamp-3">
|
||||
{getDisplayDescription()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Installation Options Info */}
|
||||
{totalOptions > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center space-x-4">
|
||||
{packageCount > 0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">
|
||||
{packageCount}{' '}
|
||||
{packageCount === 1 ? t('registry.package') : t('registry.packages')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{remoteCount > 0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">
|
||||
{remoteCount} {remoteCount === 1 ? t('registry.remote') : t('registry.remotes')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer - fixed at bottom */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-100 mt-auto">
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-500">
|
||||
{(publishedAt || updatedAt) && (
|
||||
<>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>{formatDate(updatedAt || publishedAt)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-blue-600 text-sm font-medium group-hover:text-blue-700 transition-colors">
|
||||
<span>{t('registry.viewDetails')}</span>
|
||||
<svg
|
||||
className="w-4 h-4 ml-1 transform group-hover:translate-x-1 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegistryServerCard;
|
||||
698
frontend/src/components/RegistryServerDetail.tsx
Normal file
698
frontend/src/components/RegistryServerDetail.tsx
Normal file
@@ -0,0 +1,698 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RegistryServerEntry,
|
||||
RegistryPackage,
|
||||
RegistryRemote,
|
||||
RegistryServerData,
|
||||
ServerConfig,
|
||||
} from '@/types';
|
||||
import ServerForm from './ServerForm';
|
||||
|
||||
interface RegistryServerDetailProps {
|
||||
serverEntry: RegistryServerEntry;
|
||||
onBack: () => void;
|
||||
onInstall?: (server: RegistryServerData, config: ServerConfig) => void;
|
||||
installing?: boolean;
|
||||
isInstalled?: boolean;
|
||||
fetchVersions?: (serverName: string) => Promise<RegistryServerEntry[]>;
|
||||
}
|
||||
|
||||
const RegistryServerDetail: React.FC<RegistryServerDetailProps> = ({
|
||||
serverEntry,
|
||||
onBack,
|
||||
onInstall,
|
||||
installing = false,
|
||||
isInstalled = false,
|
||||
fetchVersions,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { server, _meta } = serverEntry;
|
||||
|
||||
const [_selectedVersion, _setSelectedVersion] = useState<string>(server.version);
|
||||
const [_availableVersions, setAvailableVersions] = useState<RegistryServerEntry[]>([]);
|
||||
const [_loadingVersions, setLoadingVersions] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [selectedInstallType, setSelectedInstallType] = useState<'package' | 'remote' | null>(null);
|
||||
const [selectedOption, setSelectedOption] = useState<RegistryPackage | RegistryRemote | null>(
|
||||
null,
|
||||
);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
packages: true,
|
||||
remotes: true,
|
||||
repository: true,
|
||||
});
|
||||
|
||||
const officialMeta = _meta?.['io.modelcontextprotocol.registry/official'];
|
||||
|
||||
// Load available versions
|
||||
useEffect(() => {
|
||||
const loadVersions = async () => {
|
||||
if (fetchVersions) {
|
||||
setLoadingVersions(true);
|
||||
try {
|
||||
const versions = await fetchVersions(server.name);
|
||||
setAvailableVersions(versions);
|
||||
} catch (error) {
|
||||
console.error('Failed to load versions:', error);
|
||||
} finally {
|
||||
setLoadingVersions(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadVersions();
|
||||
}, [server.name, fetchVersions]);
|
||||
|
||||
// Get icon to display
|
||||
const getIcon = () => {
|
||||
if (server.icons && server.icons.length > 0) {
|
||||
const lightIcon = server.icons.find((icon) => !icon.theme || icon.theme === 'light');
|
||||
return lightIcon || server.icons[0];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const icon = getIcon();
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle section expansion
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
|
||||
};
|
||||
|
||||
// Handle install button click
|
||||
const handleInstallClick = (
|
||||
type: 'package' | 'remote',
|
||||
option: RegistryPackage | RegistryRemote,
|
||||
) => {
|
||||
setSelectedInstallType(type);
|
||||
setSelectedOption(option);
|
||||
setInstallError(null);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// Handle modal close
|
||||
const handleModalClose = () => {
|
||||
setModalVisible(false);
|
||||
setInstallError(null);
|
||||
};
|
||||
|
||||
// Handle install submission
|
||||
const handleInstallSubmit = async (payload: any) => {
|
||||
try {
|
||||
if (!onInstall || !selectedOption || !selectedInstallType) return;
|
||||
|
||||
setInstallError(null);
|
||||
|
||||
// Extract the ServerConfig from the payload
|
||||
const config: ServerConfig = payload.config;
|
||||
|
||||
// Call onInstall with server data and config
|
||||
onInstall(server, config);
|
||||
setModalVisible(false);
|
||||
} catch (err) {
|
||||
console.error('Error installing server:', err);
|
||||
setInstallError(t('errors.serverInstall'));
|
||||
}
|
||||
};
|
||||
|
||||
// Build initial data for ServerForm
|
||||
const getInitialFormData = () => {
|
||||
if (!selectedOption || !selectedInstallType) return null;
|
||||
console.log('Building initial form data for:', selectedOption);
|
||||
|
||||
if (selectedInstallType === 'package' && 'identifier' in selectedOption) {
|
||||
const pkg = selectedOption as RegistryPackage;
|
||||
|
||||
// Build environment variables from package definition
|
||||
const env: Record<string, string> = {};
|
||||
if (pkg.environmentVariables) {
|
||||
pkg.environmentVariables.forEach((envVar) => {
|
||||
env[envVar.name] = envVar.default || '';
|
||||
});
|
||||
}
|
||||
|
||||
const command = getCommand(pkg.registryType);
|
||||
return {
|
||||
name: server.name,
|
||||
status: 'disconnected' as const,
|
||||
config: {
|
||||
type: 'stdio' as const,
|
||||
command: command,
|
||||
args: getArgs(command, pkg),
|
||||
env: Object.keys(env).length > 0 ? env : undefined,
|
||||
},
|
||||
};
|
||||
} else if (selectedInstallType === 'remote' && 'url' in selectedOption) {
|
||||
const remote = selectedOption as RegistryRemote;
|
||||
|
||||
// Build headers from remote definition
|
||||
const headers: Record<string, string> = {};
|
||||
if (remote.headers) {
|
||||
remote.headers.forEach((header) => {
|
||||
headers[header.name] = header.default || header.value || '';
|
||||
});
|
||||
}
|
||||
|
||||
// Determine transport type - default to streamable-http for remotes
|
||||
const transportType = remote.type === 'sse' ? ('sse' as const) : ('streamable-http' as const);
|
||||
|
||||
return {
|
||||
name: server.name,
|
||||
status: 'disconnected' as const,
|
||||
config: {
|
||||
type: transportType,
|
||||
url: remote.url,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Render package option
|
||||
const renderPackage = (pkg: RegistryPackage, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 rounded-lg p-4 mb-3 hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{pkg.identifier}</h4>
|
||||
{pkg.version && <p className="text-sm text-gray-500">Version: {pkg.version}</p>}
|
||||
{pkg.runtimeHint && <p className="text-sm text-gray-600 mt-1">{pkg.runtimeHint}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleInstallClick('package', pkg)}
|
||||
disabled={isInstalled || installing}
|
||||
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
isInstalled
|
||||
? 'bg-green-600 text-white cursor-default'
|
||||
: installing
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isInstalled
|
||||
? t('registry.installed')
|
||||
: installing
|
||||
? t('registry.installing')
|
||||
: t('registry.install')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Package details */}
|
||||
{pkg.registryType && (
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
<span className="font-medium">Registry:</span> {pkg.registryType}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transport type */}
|
||||
{pkg.transport && (
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
<span className="font-medium">Transport:</span> {pkg.transport.type}
|
||||
{pkg.transport.url && <span className="ml-2 text-gray-500">({pkg.transport.url})</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environment Variables */}
|
||||
{pkg.environmentVariables && pkg.environmentVariables.length > 0 && (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">
|
||||
{t('registry.environmentVariables')}:
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{pkg.environmentVariables.map((envVar, envIndex) => (
|
||||
<div key={envIndex} className="text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="font-mono text-gray-900 font-medium">{envVar.name}</span>
|
||||
{envVar.isRequired && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
{t('common.required')}
|
||||
</span>
|
||||
)}
|
||||
{envVar.isSecret && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{t('common.secret')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{envVar.description && <p className="text-gray-600 mt-1">{envVar.description}</p>}
|
||||
{envVar.default && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.default')}:</span>{' '}
|
||||
<span className="font-mono">{envVar.default}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Package Arguments */}
|
||||
{pkg.packageArguments && pkg.packageArguments.length > 0 && (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">
|
||||
{t('registry.packageArguments')}:
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{pkg.packageArguments.map((arg, argIndex) => (
|
||||
<div key={argIndex} className="text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="font-mono text-gray-900 font-medium">{arg.name}</span>
|
||||
{arg.isRequired && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
{t('common.required')}
|
||||
</span>
|
||||
)}
|
||||
{arg.isSecret && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{t('common.secret')}
|
||||
</span>
|
||||
)}
|
||||
{arg.isRepeated && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{t('common.repeated')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{arg.description && <p className="text-gray-600 mt-1">{arg.description}</p>}
|
||||
{arg.type && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.type')}:</span>{' '}
|
||||
<span className="font-mono">{arg.type}</span>
|
||||
</p>
|
||||
)}
|
||||
{arg.default && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.default')}:</span>{' '}
|
||||
<span className="font-mono">{arg.default}</span>
|
||||
</p>
|
||||
)}
|
||||
{arg.value && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.value')}:</span>{' '}
|
||||
<span className="font-mono">{arg.value}</span>
|
||||
</p>
|
||||
)}
|
||||
{arg.valueHint && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.valueHint')}:</span>{' '}
|
||||
<span className="font-mono">{arg.valueHint}</span>
|
||||
</p>
|
||||
)}
|
||||
{arg.choices && arg.choices.length > 0 && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.choices')}:</span>{' '}
|
||||
<span className="font-mono">{arg.choices.join(', ')}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render remote option
|
||||
const renderRemote = (remote: RegistryRemote, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 rounded-lg p-4 mb-3 hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{remote.type}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1 break-all">{remote.url}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleInstallClick('remote', remote)}
|
||||
disabled={isInstalled || installing}
|
||||
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
isInstalled
|
||||
? 'bg-green-600 text-white cursor-default'
|
||||
: installing
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isInstalled
|
||||
? t('registry.installed')
|
||||
: installing
|
||||
? t('registry.installing')
|
||||
: t('registry.install')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
{remote.headers && remote.headers.length > 0 && (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">{t('registry.headers')}:</h5>
|
||||
<div className="space-y-2">
|
||||
{remote.headers.map((header, headerIndex) => (
|
||||
<div key={headerIndex} className="text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="font-mono text-gray-900 font-medium">{header.name}</span>
|
||||
{header.isRequired && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
{t('common.required')}
|
||||
</span>
|
||||
)}
|
||||
{header.isSecret && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{t('common.secret')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{header.description && <p className="text-gray-600 mt-1">{header.description}</p>}
|
||||
{header.value && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.value')}:</span>{' '}
|
||||
<span className="font-mono">{header.value}</span>
|
||||
</p>
|
||||
)}
|
||||
{header.default && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.default')}:</span>{' '}
|
||||
<span className="font-mono">{header.default}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 mb-4 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{t('registry.backToList')}
|
||||
</button>
|
||||
|
||||
<div className="flex items-start space-x-4">
|
||||
{/* Icon */}
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon.src}
|
||||
alt={server.title}
|
||||
className="w-20 h-20 rounded-lg object-cover flex-shrink-0"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center text-white text-3xl font-semibold flex-shrink-0">
|
||||
M
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and metadata */}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{server.name}</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{officialMeta?.isLatest && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
||||
{t('registry.latest')}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
|
||||
v{server.version}
|
||||
</span>
|
||||
{officialMeta?.status && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800">
|
||||
{officialMeta.status}
|
||||
</span>
|
||||
)}
|
||||
{/* Dates */}
|
||||
<span className="flex flex-wrap items-center gap-4 text-sm text-gray-600">
|
||||
{officialMeta?.publishedAt && (
|
||||
<div>
|
||||
<span className="font-medium">{t('registry.published')}:</span>{' '}
|
||||
{formatDate(officialMeta.publishedAt)}
|
||||
</div>
|
||||
)}
|
||||
{officialMeta?.updatedAt && (
|
||||
<div>
|
||||
<span className="font-medium">{t('registry.updated')}:</span>{' '}
|
||||
{formatDate(officialMeta.updatedAt)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-3">{t('registry.description')}</h2>
|
||||
<p className="text-gray-700 leading-relaxed whitespace-pre-wrap">{server.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
{server.websiteUrl && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-3">{t('registry.website')}</h2>
|
||||
<a
|
||||
href={server.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
{server.websiteUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{server.packages && server.packages.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => toggleSection('packages')}
|
||||
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<span>
|
||||
{t('registry.packages')} ({server.packages.length})
|
||||
</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transform transition-transform ${expandedSections.packages ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{expandedSections.packages && (
|
||||
<div className="space-y-3">{server.packages.map(renderPackage)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remotes */}
|
||||
{server.remotes && server.remotes.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => toggleSection('remotes')}
|
||||
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<span>
|
||||
{t('registry.remotes')} ({server.remotes.length})
|
||||
</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transform transition-transform ${expandedSections.remotes ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{expandedSections.remotes && (
|
||||
<div className="space-y-3">{server.remotes.map(renderRemote)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repository */}
|
||||
{server.repository && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => toggleSection('repository')}
|
||||
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<span>{t('registry.repository')}</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transform transition-transform ${expandedSections.repository ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{expandedSections.repository && (
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
{server.repository.url && (
|
||||
<div className="mb-2">
|
||||
<span className="font-medium text-gray-700">URL:</span>{' '}
|
||||
<a
|
||||
href={server.repository.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline break-all"
|
||||
>
|
||||
{server.repository.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{server.repository.source && (
|
||||
<div className="mb-2">
|
||||
<span className="font-medium text-gray-700">Source:</span>{' '}
|
||||
{server.repository.source}
|
||||
</div>
|
||||
)}
|
||||
{server.repository.subfolder && (
|
||||
<div className="mb-2">
|
||||
<span className="font-medium text-gray-700">Subfolder:</span>{' '}
|
||||
{server.repository.subfolder}
|
||||
</div>
|
||||
)}
|
||||
{server.repository.id && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">ID:</span> {server.repository.id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Install Modal */}
|
||||
{modalVisible && selectedOption && selectedInstallType && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<ServerForm
|
||||
onSubmit={handleInstallSubmit}
|
||||
onCancel={handleModalClose}
|
||||
modalTitle={t('registry.installServer', { name: server.title || server.name })}
|
||||
formError={installError}
|
||||
initialData={getInitialFormData()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegistryServerDetail;
|
||||
// Helper function to determine command based on registry type
|
||||
function getCommand(registryType: string): string {
|
||||
// Map registry types to appropriate commands
|
||||
switch (registryType.toLowerCase()) {
|
||||
case 'pypi':
|
||||
case 'python':
|
||||
return 'uvx';
|
||||
case 'npm':
|
||||
case 'node':
|
||||
return 'npx';
|
||||
case 'oci':
|
||||
case 'docker':
|
||||
return 'docker';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get appropriate args based on command type and package identifier
|
||||
function getArgs(command: string, pkg: RegistryPackage): string[] {
|
||||
const identifier = [pkg.identifier + (pkg.version ? `@${pkg.version}` : '')];
|
||||
|
||||
// Build package arguments if available
|
||||
const packageArgs: string[] = [];
|
||||
if (pkg.packageArguments && pkg.packageArguments.length > 0) {
|
||||
pkg.packageArguments.forEach((arg) => {
|
||||
// Add required arguments or arguments with default values
|
||||
if (arg.isRequired || arg.default || arg.value) {
|
||||
const argName = `--${arg.name}`;
|
||||
// Priority: value > default > placeholder
|
||||
const argValue = arg.value || arg.default || `\${${arg.name.toUpperCase()}}`;
|
||||
packageArgs.push(argName, argValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Map commands to appropriate argument patterns
|
||||
switch (command.toLowerCase()) {
|
||||
case 'uvx':
|
||||
// For Python packages: uvx package-name --arg1 value1 --arg2 value2
|
||||
return [...identifier, ...packageArgs];
|
||||
case 'npx':
|
||||
// For Node.js packages: npx package-name --arg1 value1 --arg2 value2
|
||||
return [...identifier, ...packageArgs];
|
||||
case 'docker': {
|
||||
// add envs from environment variables if available
|
||||
const envs: string[] = [];
|
||||
if (pkg.environmentVariables) {
|
||||
pkg.environmentVariables.forEach((env) => {
|
||||
envs.push('-e', `${env.name}`);
|
||||
});
|
||||
}
|
||||
// For Docker images: docker run -i package-name --arg1 value1 --arg2 value2
|
||||
return ['run', '-i', '--rm', ...envs, ...identifier, ...packageArgs];
|
||||
}
|
||||
default:
|
||||
// If no specific pattern is defined, return identifier with package args
|
||||
return [...identifier, ...packageArgs];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
78
frontend/src/components/ui/CursorPagination.tsx
Normal file
78
frontend/src/components/ui/CursorPagination.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CursorPaginationProps {
|
||||
currentPage: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
onNextPage: () => void;
|
||||
onPreviousPage: () => void;
|
||||
}
|
||||
|
||||
const CursorPagination: React.FC<CursorPaginationProps> = ({
|
||||
currentPage,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
onNextPage,
|
||||
onPreviousPage,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center space-x-2 my-6">
|
||||
{/* Previous button */}
|
||||
<button
|
||||
onClick={onPreviousPage}
|
||||
disabled={!hasPreviousPage}
|
||||
className={`px-4 py-2 rounded transition-all duration-200 ${
|
||||
hasPreviousPage
|
||||
? 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 inline-block mr-1"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
{/* Current page indicator */}
|
||||
<span className="px-4 py-2 bg-blue-500 text-white rounded btn-primary">
|
||||
Page {currentPage}
|
||||
</span>
|
||||
|
||||
{/* Next button */}
|
||||
<button
|
||||
onClick={onNextPage}
|
||||
disabled={!hasNextPage}
|
||||
className={`px-4 py-2 rounded transition-all duration-200 ${
|
||||
hasNextPage
|
||||
? 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 inline-block ml-1"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CursorPagination;
|
||||
116
frontend/src/components/ui/DefaultPasswordWarningModal.tsx
Normal file
116
frontend/src/components/ui/DefaultPasswordWarningModal.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Tool } from '@/types'
|
||||
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check } from '@/components/icons/LucideIcons'
|
||||
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check, Copy } from '@/components/icons/LucideIcons'
|
||||
import { callTool, ToolCallResult, updateToolDescription } from '@/services/toolService'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { Switch } from './ToggleGroup'
|
||||
import DynamicForm from './DynamicForm'
|
||||
import ToolResult from './ToolResult'
|
||||
@@ -26,6 +27,7 @@ function isEmptyValue(value: any): boolean {
|
||||
|
||||
const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { showToast } = useToast()
|
||||
const { nameSeparator } = useSettingsData()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showRunForm, setShowRunForm] = useState(false)
|
||||
@@ -36,6 +38,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
const descriptionInputRef = useRef<HTMLInputElement>(null)
|
||||
const descriptionTextRef = useRef<HTMLSpanElement>(null)
|
||||
const [textWidth, setTextWidth] = useState<number>(0)
|
||||
const [copiedToolName, setCopiedToolName] = useState(false)
|
||||
|
||||
// Focus the input when editing mode is activated
|
||||
useEffect(() => {
|
||||
@@ -108,6 +111,41 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyToolName = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(tool.name)
|
||||
setCopiedToolName(true)
|
||||
showToast(t('common.copySuccess'), 'success')
|
||||
setTimeout(() => setCopiedToolName(false), 2000)
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = tool.name
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
setCopiedToolName(true)
|
||||
showToast(t('common.copySuccess'), 'success')
|
||||
setTimeout(() => setCopiedToolName(false), 2000)
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed'), 'error')
|
||||
console.error('Copy to clipboard failed:', err)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('common.copyFailed'), 'error')
|
||||
console.error('Copy to clipboard failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRunTool = async (arguments_: Record<string, any>) => {
|
||||
setIsRunning(true)
|
||||
try {
|
||||
@@ -149,8 +187,19 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
<h3 className="text-lg font-medium text-gray-900 inline-flex items-center">
|
||||
{tool.name.replace(server + nameSeparator, '')}
|
||||
<button
|
||||
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
|
||||
onClick={handleCopyToolName}
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copiedToolName ? (
|
||||
<Check size={16} className="text-green-500" />
|
||||
) : (
|
||||
<Copy size={16} />
|
||||
)}
|
||||
</button>
|
||||
<span className="ml-2 text-sm font-normal text-gray-600 inline-flex items-center">
|
||||
{isEditingDescription ? (
|
||||
<>
|
||||
|
||||
@@ -4,6 +4,7 @@ export const PERMISSIONS = {
|
||||
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
|
||||
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
|
||||
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
|
||||
SETTINGS_OAUTH_SERVER: 'settings:oauth_server',
|
||||
SETTINGS_EXPORT_CONFIG: 'settings:export_config',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -63,55 +63,58 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
};
|
||||
|
||||
// Start normal polling
|
||||
const startNormalPolling = useCallback((options?: { immediate?: boolean }) => {
|
||||
const immediate = options?.immediate ?? true;
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
const startNormalPolling = useCallback(
|
||||
(options?: { immediate?: boolean }) => {
|
||||
const immediate = options?.immediate ?? true;
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
console.log('[ServerContext] Fetching servers from API...');
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
console.log('[ServerContext] Fetching servers from API...');
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
}
|
||||
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// Use friendly error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverFetch'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// Use friendly error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverFetch'));
|
||||
}
|
||||
// Execute immediately unless explicitly skipped
|
||||
if (immediate) {
|
||||
fetchServers();
|
||||
}
|
||||
};
|
||||
|
||||
// Execute immediately unless explicitly skipped
|
||||
if (immediate) {
|
||||
fetchServers();
|
||||
}
|
||||
|
||||
// Set up regular polling
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
}, [t]);
|
||||
// Set up regular polling
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Watch for authentication status changes
|
||||
useEffect(() => {
|
||||
@@ -147,7 +150,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
try {
|
||||
console.log('[ServerContext] Initial fetch - attempt', attemptsRef.current + 1);
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
@@ -245,16 +248,30 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
const refreshIfNeeded = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastFetch = now - lastFetchTimeRef.current;
|
||||
|
||||
|
||||
// Log who is calling this
|
||||
console.log('[ServerContext] refreshIfNeeded called, time since last fetch:', timeSinceLastFetch, 'ms');
|
||||
|
||||
console.log(
|
||||
'[ServerContext] refreshIfNeeded called, time since last fetch:',
|
||||
timeSinceLastFetch,
|
||||
'ms',
|
||||
);
|
||||
|
||||
// Only refresh if enough time has passed since last fetch
|
||||
if (timeSinceLastFetch >= MIN_REFRESH_INTERVAL) {
|
||||
console.log('[ServerContext] Triggering refresh (exceeded MIN_REFRESH_INTERVAL:', MIN_REFRESH_INTERVAL, 'ms)');
|
||||
console.log(
|
||||
'[ServerContext] Triggering refresh (exceeded MIN_REFRESH_INTERVAL:',
|
||||
MIN_REFRESH_INTERVAL,
|
||||
'ms)',
|
||||
);
|
||||
triggerRefresh();
|
||||
} else {
|
||||
console.log('[ServerContext] Skipping refresh (MIN_REFRESH_INTERVAL:', MIN_REFRESH_INTERVAL, 'ms, time since last:', timeSinceLastFetch, 'ms)');
|
||||
console.log(
|
||||
'[ServerContext] Skipping refresh (MIN_REFRESH_INTERVAL:',
|
||||
MIN_REFRESH_INTERVAL,
|
||||
'ms, time since last:',
|
||||
timeSinceLastFetch,
|
||||
'ms)',
|
||||
);
|
||||
}
|
||||
}, [triggerRefresh]);
|
||||
|
||||
@@ -263,74 +280,85 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
}, []);
|
||||
|
||||
const handleServerEdit = useCallback(async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
const handleServerEdit = useCallback(
|
||||
async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
}, [t]);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleServerRemove = useCallback(async (serverName: string) => {
|
||||
try {
|
||||
const result = await apiDelete(`/servers/${serverName}`);
|
||||
const handleServerRemove = useCallback(
|
||||
async (serverName: string) => {
|
||||
try {
|
||||
const encodedServerName = encodeURIComponent(serverName);
|
||||
const result = await apiDelete(`/servers/${encodedServerName}`);
|
||||
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
return false;
|
||||
}
|
||||
}, [t]);
|
||||
const handleServerToggle = useCallback(
|
||||
async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const encodedServerName = encodeURIComponent(server.name);
|
||||
const result = await apiPost(`/servers/${encodedServerName}/toggle`, { enabled });
|
||||
|
||||
const handleServerToggle = useCallback(async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
// Update the UI immediately to reflect the change
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the UI immediately to reflect the change
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
}, [t]);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const value: ServerContextType = {
|
||||
servers,
|
||||
@@ -356,4 +384,4 @@ export const useServerContext = () => {
|
||||
throw new Error('useServerContext must be used within a ServerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
283
frontend/src/hooks/useRegistryData.ts
Normal file
283
frontend/src/hooks/useRegistryData.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RegistryServerEntry,
|
||||
RegistryServersResponse,
|
||||
RegistryServerVersionResponse,
|
||||
RegistryServerVersionsResponse,
|
||||
} from '@/types';
|
||||
import { apiGet } from '../utils/fetchInterceptor';
|
||||
|
||||
export const useRegistryData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<RegistryServerEntry[]>([]);
|
||||
const [allServers, setAllServers] = useState<RegistryServerEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
// Cursor-based pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [serversPerPage, setServersPerPage] = useState(9);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
const [cursorHistory, setCursorHistory] = useState<string[]>([]);
|
||||
const [totalPages] = useState(1); // Legacy support, not used in cursor pagination
|
||||
|
||||
// Fetch registry servers with cursor-based pagination
|
||||
const fetchRegistryServers = useCallback(
|
||||
async (cursor?: string, search?: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams();
|
||||
params.append('limit', serversPerPage.toString());
|
||||
if (cursor) {
|
||||
params.append('cursor', cursor);
|
||||
}
|
||||
const queryToUse = search !== undefined ? search : searchQuery;
|
||||
if (queryToUse.trim()) {
|
||||
params.append('search', queryToUse.trim());
|
||||
}
|
||||
|
||||
const response = await apiGet(`/registry/servers?${params.toString()}`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
const data: RegistryServersResponse = response.data;
|
||||
if (data.servers && Array.isArray(data.servers)) {
|
||||
setServers(data.servers);
|
||||
// Update pagination state
|
||||
const hasMore = data.metadata.count === serversPerPage && !!data.metadata.nextCursor;
|
||||
setHasNextPage(hasMore);
|
||||
setNextCursor(data.metadata.nextCursor || null);
|
||||
|
||||
// For display purposes, keep track of all loaded servers
|
||||
if (!cursor) {
|
||||
// First page
|
||||
setAllServers(data.servers);
|
||||
} else {
|
||||
// Subsequent pages - append to all servers
|
||||
setAllServers((prev) => [...prev, ...data.servers]);
|
||||
}
|
||||
} else {
|
||||
console.error('Invalid registry servers data format:', data);
|
||||
setError(t('registry.fetchError'));
|
||||
}
|
||||
} else {
|
||||
setError(t('registry.fetchError'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching registry servers:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t, serversPerPage],
|
||||
);
|
||||
|
||||
// Navigate to next page
|
||||
const goToNextPage = useCallback(async () => {
|
||||
if (!hasNextPage || !nextCursor) return;
|
||||
|
||||
// Save current cursor to history for back navigation
|
||||
const currentCursor = cursorHistory[cursorHistory.length - 1] || '';
|
||||
setCursorHistory((prev) => [...prev, currentCursor]);
|
||||
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
await fetchRegistryServers(nextCursor, searchQuery);
|
||||
}, [hasNextPage, nextCursor, cursorHistory, searchQuery, fetchRegistryServers]);
|
||||
|
||||
// Navigate to previous page
|
||||
const goToPreviousPage = useCallback(async () => {
|
||||
if (currentPage <= 1) return;
|
||||
|
||||
// Get the previous cursor from history
|
||||
const newHistory = [...cursorHistory];
|
||||
newHistory.pop(); // Remove current position
|
||||
const previousCursor = newHistory[newHistory.length - 1];
|
||||
|
||||
setCursorHistory(newHistory);
|
||||
setCurrentPage((prev) => prev - 1);
|
||||
|
||||
// Fetch with previous cursor (undefined for first page)
|
||||
await fetchRegistryServers(previousCursor || undefined, searchQuery);
|
||||
}, [currentPage, cursorHistory, searchQuery, fetchRegistryServers]);
|
||||
|
||||
// Change page (legacy support for page number navigation)
|
||||
const changePage = useCallback(
|
||||
async (page: number) => {
|
||||
if (page === currentPage) return;
|
||||
|
||||
if (page > currentPage && hasNextPage) {
|
||||
await goToNextPage();
|
||||
} else if (page < currentPage && currentPage > 1) {
|
||||
await goToPreviousPage();
|
||||
}
|
||||
},
|
||||
[currentPage, hasNextPage, goToNextPage, goToPreviousPage],
|
||||
);
|
||||
|
||||
// Change items per page
|
||||
const changeServersPerPage = useCallback(
|
||||
async (newServersPerPage: number) => {
|
||||
setServersPerPage(newServersPerPage);
|
||||
setCurrentPage(1);
|
||||
setCursorHistory([]);
|
||||
setAllServers([]);
|
||||
await fetchRegistryServers(undefined, searchQuery);
|
||||
},
|
||||
[searchQuery, fetchRegistryServers],
|
||||
);
|
||||
|
||||
// Fetch server by name
|
||||
const fetchServerByName = useCallback(
|
||||
async (serverName: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// URL encode the server name
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const response = await apiGet(`/registry/servers/${encodedName}/versions`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
const data: RegistryServerVersionsResponse = response.data;
|
||||
if (data.servers && Array.isArray(data.servers) && data.servers.length > 0) {
|
||||
// Return the first server entry (should be the latest or specified version)
|
||||
return data.servers[0];
|
||||
} else {
|
||||
console.error('Invalid registry server data format:', data);
|
||||
setError(t('registry.serverNotFound'));
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
setError(t('registry.serverNotFound'));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching registry server ${serverName}:`, err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Fetch all versions of a server
|
||||
const fetchServerVersions = useCallback(async (serverName: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// URL encode the server name
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const response = await apiGet(`/registry/servers/${encodedName}/versions`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
const data: RegistryServerVersionsResponse = response.data;
|
||||
if (data.servers && Array.isArray(data.servers)) {
|
||||
return data.servers;
|
||||
} else {
|
||||
console.error('Invalid registry server versions data format:', data);
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching versions for server ${serverName}:`, err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch specific version of a server
|
||||
const fetchServerVersion = useCallback(async (serverName: string, version: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// URL encode the server name and version
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const encodedVersion = encodeURIComponent(version);
|
||||
const response = await apiGet(`/registry/servers/${encodedName}/versions/${encodedVersion}`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
const data: RegistryServerVersionResponse = response.data;
|
||||
if (data && data.server) {
|
||||
return data;
|
||||
} else {
|
||||
console.error('Invalid registry server version data format:', data);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching version ${version} for server ${serverName}:`, err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Search servers by query (client-side filtering on loaded data)
|
||||
const searchServers = useCallback(
|
||||
async (query: string) => {
|
||||
console.log('Searching registry servers with query:', query);
|
||||
setSearchQuery(query);
|
||||
setCurrentPage(1);
|
||||
setCursorHistory([]);
|
||||
setAllServers([]);
|
||||
|
||||
await fetchRegistryServers(undefined, query);
|
||||
},
|
||||
[fetchRegistryServers],
|
||||
);
|
||||
|
||||
// Clear search
|
||||
const clearSearch = useCallback(async () => {
|
||||
setSearchQuery('');
|
||||
setCurrentPage(1);
|
||||
setCursorHistory([]);
|
||||
setAllServers([]);
|
||||
await fetchRegistryServers(undefined, '');
|
||||
}, [fetchRegistryServers]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchRegistryServers(undefined, searchQuery);
|
||||
// Only run on mount
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
|
||||
return {
|
||||
servers,
|
||||
allServers,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
searchQuery,
|
||||
searchServers,
|
||||
clearSearch,
|
||||
fetchServerByName,
|
||||
fetchServerVersions,
|
||||
fetchServerVersion,
|
||||
// Cursor-based pagination
|
||||
currentPage,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPreviousPage: currentPage > 1,
|
||||
changePage,
|
||||
goToNextPage,
|
||||
goToPreviousPage,
|
||||
serversPerPage,
|
||||
changeServersPerPage,
|
||||
};
|
||||
};
|
||||
@@ -34,6 +34,21 @@ interface MCPRouterConfig {
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
interface OAuthServerConfig {
|
||||
enabled: boolean;
|
||||
accessTokenLifetime: number;
|
||||
refreshTokenLifetime: number;
|
||||
authorizationCodeLifetime: number;
|
||||
requireClientSecret: boolean;
|
||||
allowedScopes: string[];
|
||||
requireState: boolean;
|
||||
dynamicRegistration: {
|
||||
enabled: boolean;
|
||||
allowedGrantTypes: string[];
|
||||
requiresAuthentication: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
systemConfig?: {
|
||||
routing?: RoutingConfig;
|
||||
@@ -41,6 +56,8 @@ interface SystemSettings {
|
||||
smartRouting?: SmartRoutingConfig;
|
||||
mcpRouter?: MCPRouterConfig;
|
||||
nameSeparator?: string;
|
||||
oauthServer?: OAuthServerConfig;
|
||||
enableSessionRebuild?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,6 +65,21 @@ interface TempRoutingConfig {
|
||||
bearerAuthKey: string;
|
||||
}
|
||||
|
||||
const getDefaultOAuthServerConfig = (): OAuthServerConfig => ({
|
||||
enabled: true,
|
||||
accessTokenLifetime: 3600,
|
||||
refreshTokenLifetime: 1209600,
|
||||
authorizationCodeLifetime: 300,
|
||||
requireClientSecret: false,
|
||||
allowedScopes: ['read', 'write'],
|
||||
requireState: false,
|
||||
dynamicRegistration: {
|
||||
enabled: true,
|
||||
allowedGrantTypes: ['authorization_code', 'refresh_token'],
|
||||
requiresAuthentication: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const useSettingsData = () => {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
@@ -85,7 +117,12 @@ export const useSettingsData = () => {
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
|
||||
const [oauthServerConfig, setOAuthServerConfig] = useState<OAuthServerConfig>(
|
||||
getDefaultOAuthServerConfig(),
|
||||
);
|
||||
|
||||
const [nameSeparator, setNameSeparator] = useState<string>('-');
|
||||
const [enableSessionRebuild, setEnableSessionRebuild] = useState<boolean>(false);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -138,9 +175,50 @@ export const useSettingsData = () => {
|
||||
baseUrl: data.data.systemConfig.mcpRouter.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
}
|
||||
if (data.success) {
|
||||
if (data.data?.systemConfig?.oauthServer) {
|
||||
const oauth = data.data.systemConfig.oauthServer;
|
||||
const defaultOauthConfig = getDefaultOAuthServerConfig();
|
||||
const defaultDynamic = defaultOauthConfig.dynamicRegistration;
|
||||
const allowedScopes = Array.isArray(oauth.allowedScopes)
|
||||
? [...oauth.allowedScopes]
|
||||
: [...defaultOauthConfig.allowedScopes];
|
||||
const dynamicAllowedGrantTypes = Array.isArray(
|
||||
oauth.dynamicRegistration?.allowedGrantTypes,
|
||||
)
|
||||
? [...oauth.dynamicRegistration!.allowedGrantTypes!]
|
||||
: [...defaultDynamic.allowedGrantTypes];
|
||||
|
||||
setOAuthServerConfig({
|
||||
enabled: oauth.enabled ?? defaultOauthConfig.enabled,
|
||||
accessTokenLifetime:
|
||||
oauth.accessTokenLifetime ?? defaultOauthConfig.accessTokenLifetime,
|
||||
refreshTokenLifetime:
|
||||
oauth.refreshTokenLifetime ?? defaultOauthConfig.refreshTokenLifetime,
|
||||
authorizationCodeLifetime:
|
||||
oauth.authorizationCodeLifetime ?? defaultOauthConfig.authorizationCodeLifetime,
|
||||
requireClientSecret:
|
||||
oauth.requireClientSecret ?? defaultOauthConfig.requireClientSecret,
|
||||
requireState: oauth.requireState ?? defaultOauthConfig.requireState,
|
||||
allowedScopes,
|
||||
dynamicRegistration: {
|
||||
enabled: oauth.dynamicRegistration?.enabled ?? defaultDynamic.enabled,
|
||||
allowedGrantTypes: dynamicAllowedGrantTypes,
|
||||
requiresAuthentication:
|
||||
oauth.dynamicRegistration?.requiresAuthentication ??
|
||||
defaultDynamic.requiresAuthentication,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setOAuthServerConfig(getDefaultOAuthServerConfig());
|
||||
}
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.nameSeparator !== undefined) {
|
||||
setNameSeparator(data.data.systemConfig.nameSeparator);
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.enableSessionRebuild !== undefined) {
|
||||
setEnableSessionRebuild(data.data.systemConfig.enableSessionRebuild);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
|
||||
@@ -390,6 +468,77 @@ export const useSettingsData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Update OAuth server configuration
|
||||
const updateOAuthServerConfig = async <T extends keyof OAuthServerConfig>(
|
||||
key: T,
|
||||
value: OAuthServerConfig[T],
|
||||
) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
oauthServer: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setOAuthServerConfig((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update OAuth server config:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update OAuth server config';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update multiple OAuth server config fields
|
||||
const updateOAuthServerConfigBatch = async (updates: Partial<OAuthServerConfig>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
oauthServer: updates,
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setOAuthServerConfig((prev) => ({
|
||||
...prev,
|
||||
...updates,
|
||||
}));
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update OAuth server config:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update OAuth server config';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update name separator
|
||||
const updateNameSeparator = async (value: string) => {
|
||||
setLoading(true);
|
||||
@@ -420,6 +569,36 @@ export const useSettingsData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Update session rebuild setting
|
||||
const updateSessionRebuild = async (value: boolean) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
enableSessionRebuild: value,
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setEnableSessionRebuild(value);
|
||||
showToast(t('settings.restartRequired'), 'info');
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update session rebuild setting:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update session rebuild setting';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportMCPSettings = async (serverName?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -455,7 +634,9 @@ export const useSettingsData = () => {
|
||||
installConfig,
|
||||
smartRoutingConfig,
|
||||
mcpRouterConfig,
|
||||
oauthServerConfig,
|
||||
nameSeparator,
|
||||
enableSessionRebuild,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
@@ -468,7 +649,10 @@ export const useSettingsData = () => {
|
||||
updateRoutingConfigBatch,
|
||||
updateMCPRouterConfig,
|
||||
updateMCPRouterConfigBatch,
|
||||
updateOAuthServerConfig,
|
||||
updateOAuthServerConfigBatch,
|
||||
updateNameSeparator,
|
||||
updateSessionRebuild,
|
||||
exportMCPSettings,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { getToken } from '../services/authService';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
import LanguageSwitch from '@/components/ui/LanguageSwitch';
|
||||
import DefaultPasswordWarningModal from '@/components/ui/DefaultPasswordWarningModal';
|
||||
|
||||
const sanitizeReturnUrl = (value: string | null): string | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Support both relative paths and absolute URLs on the same origin
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
|
||||
const url = new URL(value, origin);
|
||||
if (url.origin !== origin) {
|
||||
return null;
|
||||
}
|
||||
const relativePath = `${url.pathname}${url.search}${url.hash}`;
|
||||
return relativePath || '/';
|
||||
} catch {
|
||||
if (value.startsWith('/') && !value.startsWith('//')) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -11,8 +35,48 @@ 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 location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const returnUrl = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
return sanitizeReturnUrl(params.get('returnUrl'));
|
||||
}, [location.search]);
|
||||
|
||||
const buildRedirectTarget = useCallback(() => {
|
||||
if (!returnUrl) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
// Only attach JWT when returning to the OAuth authorize endpoint
|
||||
if (!returnUrl.startsWith('/oauth/authorize')) {
|
||||
return returnUrl;
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return returnUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const origin = window.location.origin;
|
||||
const url = new URL(returnUrl, origin);
|
||||
url.searchParams.set('token', token);
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
} catch {
|
||||
const separator = returnUrl.includes('?') ? '&' : '?';
|
||||
return `${returnUrl}${separator}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
}, [returnUrl]);
|
||||
|
||||
const redirectAfterLogin = useCallback(() => {
|
||||
if (returnUrl) {
|
||||
window.location.assign(buildRedirectTarget());
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
}, [buildRedirectTarget, navigate, returnUrl]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -26,10 +90,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 {
|
||||
redirectAfterLogin();
|
||||
}
|
||||
} else {
|
||||
setError(t('auth.loginFailed'));
|
||||
}
|
||||
@@ -40,6 +109,11 @@ const LoginPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseWarning = () => {
|
||||
setShowDefaultPasswordWarning(false);
|
||||
redirectAfterLogin();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full overflow-hidden bg-gray-50 dark:bg-gray-950">
|
||||
{/* Top-right controls */}
|
||||
@@ -138,8 +212,14 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Password Warning Modal */}
|
||||
<DefaultPasswordWarningModal
|
||||
isOpen={showDefaultPasswordWarning}
|
||||
onClose={handleCloseWarning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
export default LoginPage;
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { MarketServer, CloudServer, ServerConfig } from '@/types';
|
||||
import {
|
||||
MarketServer,
|
||||
CloudServer,
|
||||
ServerConfig,
|
||||
RegistryServerEntry,
|
||||
RegistryServerData,
|
||||
} from '@/types';
|
||||
import { useMarketData } from '@/hooks/useMarketData';
|
||||
import { useCloudData } from '@/hooks/useCloudData';
|
||||
import { useRegistryData } from '@/hooks/useRegistryData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { apiPost } from '@/utils/fetchInterceptor';
|
||||
import MarketServerCard from '@/components/MarketServerCard';
|
||||
import MarketServerDetail from '@/components/MarketServerDetail';
|
||||
import CloudServerCard from '@/components/CloudServerCard';
|
||||
import CloudServerDetail from '@/components/CloudServerDetail';
|
||||
import RegistryServerCard from '@/components/RegistryServerCard';
|
||||
import RegistryServerDetail from '@/components/RegistryServerDetail';
|
||||
import MCPRouterApiKeyError from '@/components/MCPRouterApiKeyError';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import CursorPagination from '@/components/ui/CursorPagination';
|
||||
|
||||
const MarketPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -19,7 +29,7 @@ const MarketPage: React.FC = () => {
|
||||
const { serverName } = useParams<{ serverName?: string }>();
|
||||
const { showToast } = useToast();
|
||||
|
||||
// Get tab from URL search params, default to cloud market
|
||||
// Get tab from URL search params
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const currentTab = searchParams.get('tab') || 'cloud';
|
||||
|
||||
@@ -44,10 +54,10 @@ const MarketPage: React.FC = () => {
|
||||
totalPages: localTotalPages,
|
||||
changePage: changeLocalPage,
|
||||
serversPerPage: localServersPerPage,
|
||||
changeServersPerPage: changeLocalServersPerPage
|
||||
changeServersPerPage: changeLocalServersPerPage,
|
||||
} = useMarketData();
|
||||
|
||||
// Cloud market data
|
||||
// Cloud market data
|
||||
const {
|
||||
servers: cloudServers,
|
||||
allServers: allCloudServers,
|
||||
@@ -61,29 +71,67 @@ const MarketPage: React.FC = () => {
|
||||
totalPages: cloudTotalPages,
|
||||
changePage: changeCloudPage,
|
||||
serversPerPage: cloudServersPerPage,
|
||||
changeServersPerPage: changeCloudServersPerPage
|
||||
changeServersPerPage: changeCloudServersPerPage,
|
||||
} = useCloudData();
|
||||
|
||||
// Registry data
|
||||
const {
|
||||
servers: registryServers,
|
||||
allServers: allRegistryServers,
|
||||
loading: registryLoading,
|
||||
error: registryError,
|
||||
setError: setRegistryError,
|
||||
searchServers: searchRegistryServers,
|
||||
clearSearch: clearRegistrySearch,
|
||||
fetchServerByName: fetchRegistryServerByName,
|
||||
fetchServerVersions: fetchRegistryServerVersions,
|
||||
// Cursor-based pagination
|
||||
currentPage: registryCurrentPage,
|
||||
totalPages: registryTotalPages,
|
||||
hasNextPage: registryHasNextPage,
|
||||
hasPreviousPage: registryHasPreviousPage,
|
||||
changePage: changeRegistryPage,
|
||||
goToNextPage: goToRegistryNextPage,
|
||||
goToPreviousPage: goToRegistryPreviousPage,
|
||||
serversPerPage: registryServersPerPage,
|
||||
changeServersPerPage: changeRegistryServersPerPage,
|
||||
} = useRegistryData();
|
||||
|
||||
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
|
||||
const [selectedCloudServer, setSelectedCloudServer] = useState<CloudServer | null>(null);
|
||||
const [selectedRegistryServer, setSelectedRegistryServer] = useState<RegistryServerEntry | null>(
|
||||
null,
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [registrySearchQuery, setRegistrySearchQuery] = useState('');
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [installedCloudServers, setInstalledCloudServers] = useState<Set<string>>(new Set());
|
||||
const [installedRegistryServers, setInstalledRegistryServers] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load server details if a server name is in the URL
|
||||
useEffect(() => {
|
||||
const loadServerDetails = async () => {
|
||||
if (serverName) {
|
||||
// Determine if it's a cloud or local server based on the current tab
|
||||
// Determine if it's a cloud, local, or registry server based on the current tab
|
||||
if (currentTab === 'cloud') {
|
||||
// Try to find the server in cloud servers
|
||||
const server = cloudServers.find(s => s.name === serverName);
|
||||
const server = cloudServers.find((s) => s.name === serverName);
|
||||
if (server) {
|
||||
setSelectedCloudServer(server);
|
||||
} else {
|
||||
// If server not found, navigate back to market page
|
||||
navigate('/market?tab=cloud');
|
||||
}
|
||||
} else if (currentTab === 'registry') {
|
||||
console.log('Loading registry server details for:', serverName);
|
||||
// Registry market
|
||||
const serverEntry = await fetchRegistryServerByName(serverName);
|
||||
if (serverEntry) {
|
||||
setSelectedRegistryServer(serverEntry);
|
||||
} else {
|
||||
// If server not found, navigate back to market page
|
||||
navigate('/market?tab=registry');
|
||||
}
|
||||
} else {
|
||||
// Local market
|
||||
const server = await fetchLocalServerByName(serverName);
|
||||
@@ -97,14 +145,22 @@ const MarketPage: React.FC = () => {
|
||||
} else {
|
||||
setSelectedServer(null);
|
||||
setSelectedCloudServer(null);
|
||||
setSelectedRegistryServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
loadServerDetails();
|
||||
}, [serverName, currentTab, cloudServers, fetchLocalServerByName, navigate]);
|
||||
}, [
|
||||
serverName,
|
||||
currentTab,
|
||||
cloudServers,
|
||||
fetchLocalServerByName,
|
||||
fetchRegistryServerByName,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
// Tab switching handler
|
||||
const switchTab = (tab: 'local' | 'cloud') => {
|
||||
const switchTab = (tab: 'local' | 'cloud' | 'registry') => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.set('tab', tab);
|
||||
setSearchParams(newSearchParams);
|
||||
@@ -118,6 +174,8 @@ const MarketPage: React.FC = () => {
|
||||
e.preventDefault();
|
||||
if (currentTab === 'local') {
|
||||
searchLocalServers(searchQuery);
|
||||
} else if (currentTab === 'registry') {
|
||||
searchRegistryServers(registrySearchQuery);
|
||||
}
|
||||
// Cloud search is not implemented in the original cloud page
|
||||
};
|
||||
@@ -129,18 +187,35 @@ const MarketPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchQuery('');
|
||||
if (currentTab === 'local') {
|
||||
setSearchQuery('');
|
||||
filterLocalByCategory('');
|
||||
filterLocalByTag('');
|
||||
} else if (currentTab === 'registry') {
|
||||
setRegistrySearchQuery('');
|
||||
clearRegistrySearch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerClick = (server: MarketServer | CloudServer) => {
|
||||
const handleServerClick = (server: MarketServer | CloudServer | RegistryServerEntry) => {
|
||||
if (currentTab === 'cloud') {
|
||||
navigate(`/market/${server.name}?tab=cloud`);
|
||||
const cloudServer = server as CloudServer;
|
||||
navigate(`/market/${cloudServer.name}?tab=cloud`);
|
||||
} else if (currentTab === 'registry') {
|
||||
const registryServer = server as RegistryServerEntry;
|
||||
console.log('Registry server clicked:', registryServer);
|
||||
const serverName = registryServer.server?.name;
|
||||
console.log('Server name extracted:', serverName);
|
||||
if (serverName) {
|
||||
const targetUrl = `/market/${encodeURIComponent(serverName)}?tab=registry`;
|
||||
console.log('Navigating to:', targetUrl);
|
||||
navigate(targetUrl);
|
||||
} else {
|
||||
console.error('Server name is undefined in registry server:', registryServer);
|
||||
}
|
||||
} else {
|
||||
navigate(`/market/${server.name}?tab=local`);
|
||||
const marketServer = server as MarketServer;
|
||||
navigate(`/market/${marketServer.name}?tab=local`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -167,7 +242,7 @@ const MarketPage: React.FC = () => {
|
||||
|
||||
const payload = {
|
||||
name: server.name,
|
||||
config: config
|
||||
config: config,
|
||||
};
|
||||
|
||||
const result = await apiPost('/servers', payload);
|
||||
@@ -179,9 +254,8 @@ const MarketPage: React.FC = () => {
|
||||
}
|
||||
|
||||
// Update installed servers set
|
||||
setInstalledCloudServers(prev => new Set(prev).add(server.name));
|
||||
setInstalledCloudServers((prev) => new Set(prev).add(server.name));
|
||||
showToast(t('cloud.installSuccess', { name: server.title || server.name }), 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error installing cloud server:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
@@ -191,7 +265,41 @@ const MarketPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCallTool = async (serverName: string, toolName: string, args: Record<string, any>) => {
|
||||
// Handle registry server installation
|
||||
const handleRegistryInstall = async (server: RegistryServerData, config: ServerConfig) => {
|
||||
try {
|
||||
setInstalling(true);
|
||||
|
||||
const payload = {
|
||||
name: server.name,
|
||||
config: config,
|
||||
};
|
||||
|
||||
const result = await apiPost('/servers', payload);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage = result?.message || t('server.addError');
|
||||
showToast(errorMessage, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update installed servers set
|
||||
setInstalledRegistryServers((prev) => new Set(prev).add(server.name));
|
||||
showToast(t('registry.installSuccess', { name: server.title || server.name }), 'success');
|
||||
} catch (error) {
|
||||
console.error('Error installing registry server:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
showToast(t('registry.installError', { error: errorMessage }), 'error');
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCallTool = async (
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
args: Record<string, any>,
|
||||
) => {
|
||||
try {
|
||||
const result = await callServerTool(serverName, toolName, args);
|
||||
showToast(t('cloud.toolCallSuccess', { toolName }), 'success');
|
||||
@@ -208,13 +316,17 @@ const MarketPage: React.FC = () => {
|
||||
|
||||
// Helper function to check if error is MCPRouter API key not configured
|
||||
const isMCPRouterApiKeyError = (errorMessage: string) => {
|
||||
return errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
|
||||
errorMessage.toLowerCase().includes('mcprouter api key not configured');
|
||||
return (
|
||||
errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
|
||||
errorMessage.toLowerCase().includes('mcprouter api key not configured')
|
||||
);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (currentTab === 'local') {
|
||||
changeLocalPage(page);
|
||||
} else if (currentTab === 'registry') {
|
||||
changeRegistryPage(page);
|
||||
} else {
|
||||
changeCloudPage(page);
|
||||
}
|
||||
@@ -226,6 +338,8 @@ const MarketPage: React.FC = () => {
|
||||
const newValue = parseInt(e.target.value, 10);
|
||||
if (currentTab === 'local') {
|
||||
changeLocalServersPerPage(newValue);
|
||||
} else if (currentTab === 'registry') {
|
||||
changeRegistryServersPerPage(newValue);
|
||||
} else {
|
||||
changeCloudServersPerPage(newValue);
|
||||
}
|
||||
@@ -259,19 +373,50 @@ const MarketPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Render registry server detail if selected
|
||||
if (selectedRegistryServer) {
|
||||
return (
|
||||
<RegistryServerDetail
|
||||
serverEntry={selectedRegistryServer}
|
||||
onBack={handleBackToList}
|
||||
onInstall={handleRegistryInstall}
|
||||
installing={installing}
|
||||
isInstalled={installedRegistryServers.has(selectedRegistryServer.server.name)}
|
||||
fetchVersions={fetchRegistryServerVersions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Get current data based on active tab
|
||||
const isLocalTab = currentTab === 'local';
|
||||
const servers = isLocalTab ? localServers : cloudServers;
|
||||
const allServers = isLocalTab ? allLocalServers : allCloudServers;
|
||||
const isRegistryTab = currentTab === 'registry';
|
||||
const servers = isLocalTab ? localServers : isRegistryTab ? registryServers : cloudServers;
|
||||
const allServers = isLocalTab
|
||||
? allLocalServers
|
||||
: isRegistryTab
|
||||
? allRegistryServers
|
||||
: allCloudServers;
|
||||
const categories = isLocalTab ? localCategories : [];
|
||||
const loading = isLocalTab ? localLoading : cloudLoading;
|
||||
const error = isLocalTab ? localError : cloudError;
|
||||
const setError = isLocalTab ? setLocalError : setCloudError;
|
||||
const loading = isLocalTab ? localLoading : isRegistryTab ? registryLoading : cloudLoading;
|
||||
const error = isLocalTab ? localError : isRegistryTab ? registryError : cloudError;
|
||||
const setError = isLocalTab ? setLocalError : isRegistryTab ? setRegistryError : setCloudError;
|
||||
const selectedCategory = isLocalTab ? selectedLocalCategory : '';
|
||||
const selectedTag = isLocalTab ? selectedLocalTag : '';
|
||||
const currentPage = isLocalTab ? localCurrentPage : cloudCurrentPage;
|
||||
const totalPages = isLocalTab ? localTotalPages : cloudTotalPages;
|
||||
const serversPerPage = isLocalTab ? localServersPerPage : cloudServersPerPage;
|
||||
const currentPage = isLocalTab
|
||||
? localCurrentPage
|
||||
: isRegistryTab
|
||||
? registryCurrentPage
|
||||
: cloudCurrentPage;
|
||||
const totalPages = isLocalTab
|
||||
? localTotalPages
|
||||
: isRegistryTab
|
||||
? registryTotalPages
|
||||
: cloudTotalPages;
|
||||
const serversPerPage = isLocalTab
|
||||
? localServersPerPage
|
||||
: isRegistryTab
|
||||
? registryServersPerPage
|
||||
: cloudServersPerPage;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -281,13 +426,15 @@ const MarketPage: React.FC = () => {
|
||||
<nav className="-mb-px flex space-x-3">
|
||||
<button
|
||||
onClick={() => switchTab('cloud')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${!isLocalTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
|
||||
!isLocalTab && !isRegistryTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('cloud.title')}
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">(
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">
|
||||
(
|
||||
<a
|
||||
href="https://mcprouter.co"
|
||||
target="_blank"
|
||||
@@ -301,13 +448,15 @@ const MarketPage: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchTab('local')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${isLocalTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
|
||||
isLocalTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('market.title')}
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">(
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">
|
||||
(
|
||||
<a
|
||||
href="https://mcpm.sh"
|
||||
target="_blank"
|
||||
@@ -319,6 +468,28 @@ const MarketPage: React.FC = () => {
|
||||
)
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchTab('registry')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
|
||||
isRegistryTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('registry.title')}
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">
|
||||
(
|
||||
<a
|
||||
href="https://registry.modelcontextprotocol.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="external-link"
|
||||
>
|
||||
{t('registry.official')}
|
||||
</a>
|
||||
)
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,8 +506,17 @@ const MarketPage: React.FC = () => {
|
||||
onClick={() => setError(null)}
|
||||
className="text-red-700 hover:text-red-900 transition-colors duration-200"
|
||||
>
|
||||
<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 01.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 01.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>
|
||||
@@ -345,16 +525,24 @@ const MarketPage: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Search bar for local market only */}
|
||||
{isLocalTab && (
|
||||
{/* Search bar for local market and registry */}
|
||||
{(isLocalTab || isRegistryTab) && (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
|
||||
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
|
||||
<div className="flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('market.searchPlaceholder')}
|
||||
value={isRegistryTab ? registrySearchQuery : searchQuery}
|
||||
onChange={(e) => {
|
||||
if (isRegistryTab) {
|
||||
setRegistrySearchQuery(e.target.value);
|
||||
} else {
|
||||
setSearchQuery(e.target.value);
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
isRegistryTab ? t('registry.searchPlaceholder') : t('market.searchPlaceholder')
|
||||
}
|
||||
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -362,15 +550,16 @@ const MarketPage: React.FC = () => {
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{t('market.search')}
|
||||
{isRegistryTab ? t('registry.search') : t('market.search')}
|
||||
</button>
|
||||
{(searchQuery || selectedCategory || selectedTag) && (
|
||||
{((isLocalTab && (searchQuery || selectedCategory || selectedTag)) ||
|
||||
(isRegistryTab && registrySearchQuery)) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
|
||||
>
|
||||
{t('market.clearFilters')}
|
||||
{isRegistryTab ? t('registry.clearFilters') : t('market.clearFilters')}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
@@ -388,7 +577,10 @@ const MarketPage: React.FC = () => {
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
{selectedCategory && (
|
||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterLocalByCategory('')}>
|
||||
<span
|
||||
className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200"
|
||||
onClick={() => filterLocalByCategory('')}
|
||||
>
|
||||
{t('market.clearCategoryFilter')}
|
||||
</span>
|
||||
)}
|
||||
@@ -398,10 +590,11 @@ const MarketPage: React.FC = () => {
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => handleCategoryClick(category)}
|
||||
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${selectedCategory === category
|
||||
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
|
||||
}`}
|
||||
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${
|
||||
selectedCategory === category
|
||||
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
@@ -414,9 +607,25 @@ const MarketPage: React.FC = () => {
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-center py-4 loading-container">
|
||||
<svg className="animate-spin h-6 w-6 text-blue-500 mb-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
|
||||
className="animate-spin h-6 w-6 text-blue-500 mb-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>
|
||||
<p className="text-sm text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
@@ -438,61 +647,110 @@ const MarketPage: React.FC = () => {
|
||||
{loading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
|
||||
<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>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<p className="text-gray-600">{isLocalTab ? t('market.noServers') : t('cloud.noServers')}</p>
|
||||
<p className="text-gray-600">
|
||||
{isLocalTab
|
||||
? t('market.noServers')
|
||||
: isRegistryTab
|
||||
? t('registry.noServers')
|
||||
: t('cloud.noServers')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{servers.map((server, index) => (
|
||||
{servers.map((server, index) =>
|
||||
isLocalTab ? (
|
||||
<MarketServerCard
|
||||
key={index}
|
||||
server={server as MarketServer}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
) : isRegistryTab ? (
|
||||
<RegistryServerCard
|
||||
key={index}
|
||||
serverEntry={server as RegistryServerEntry}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
) : (
|
||||
<CloudServerCard
|
||||
key={index}
|
||||
server={server as CloudServer}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
{isLocalTab ? (
|
||||
t('market.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length
|
||||
})
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-[2] text-sm text-gray-500">
|
||||
{isLocalTab
|
||||
? t('market.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length,
|
||||
})
|
||||
: isRegistryTab
|
||||
? t('registry.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: (currentPage - 1) * serversPerPage + servers.length,
|
||||
total: allServers.length + (registryHasNextPage ? '+' : ''),
|
||||
})
|
||||
: t('cloud.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length,
|
||||
})}
|
||||
</div>
|
||||
<div className="flex-[4] flex justify-center">
|
||||
{isRegistryTab ? (
|
||||
<CursorPagination
|
||||
currentPage={currentPage}
|
||||
hasNextPage={registryHasNextPage}
|
||||
hasPreviousPage={registryHasPreviousPage}
|
||||
onNextPage={goToRegistryNextPage}
|
||||
onPreviousPage={goToRegistryPreviousPage}
|
||||
/>
|
||||
) : (
|
||||
t('cloud.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length
|
||||
})
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-[2] flex items-center justify-end space-x-2">
|
||||
<label htmlFor="perPage" className="text-sm text-gray-600">
|
||||
{isLocalTab ? t('market.perPage') : t('cloud.perPage')}:
|
||||
{isLocalTab
|
||||
? t('market.perPage')
|
||||
: isRegistryTab
|
||||
? t('registry.perPage')
|
||||
: t('cloud.perPage')}
|
||||
:
|
||||
</label>
|
||||
<select
|
||||
id="perPage"
|
||||
@@ -507,9 +765,6 @@ const MarketPage: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -49,6 +49,20 @@ const SettingsPage: React.FC = () => {
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
})
|
||||
|
||||
const [tempOAuthServerConfig, setTempOAuthServerConfig] = useState<{
|
||||
accessTokenLifetime: string
|
||||
refreshTokenLifetime: string
|
||||
authorizationCodeLifetime: string
|
||||
allowedScopes: string
|
||||
dynamicRegistrationAllowedGrantTypes: string
|
||||
}>({
|
||||
accessTokenLifetime: '3600',
|
||||
refreshTokenLifetime: '1209600',
|
||||
authorizationCodeLifetime: '300',
|
||||
allowedScopes: 'read, write',
|
||||
dynamicRegistrationAllowedGrantTypes: 'authorization_code, refresh_token',
|
||||
})
|
||||
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-')
|
||||
|
||||
const {
|
||||
@@ -58,7 +72,9 @@ const SettingsPage: React.FC = () => {
|
||||
installConfig: savedInstallConfig,
|
||||
smartRoutingConfig,
|
||||
mcpRouterConfig,
|
||||
oauthServerConfig,
|
||||
nameSeparator,
|
||||
enableSessionRebuild,
|
||||
loading,
|
||||
updateRoutingConfig,
|
||||
updateRoutingConfigBatch,
|
||||
@@ -66,7 +82,9 @@ const SettingsPage: React.FC = () => {
|
||||
updateSmartRoutingConfig,
|
||||
updateSmartRoutingConfigBatch,
|
||||
updateMCPRouterConfig,
|
||||
updateOAuthServerConfig,
|
||||
updateNameSeparator,
|
||||
updateSessionRebuild,
|
||||
exportMCPSettings,
|
||||
} = useSettingsData()
|
||||
|
||||
@@ -101,6 +119,33 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
}, [mcpRouterConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (oauthServerConfig) {
|
||||
setTempOAuthServerConfig({
|
||||
accessTokenLifetime:
|
||||
oauthServerConfig.accessTokenLifetime !== undefined
|
||||
? String(oauthServerConfig.accessTokenLifetime)
|
||||
: '',
|
||||
refreshTokenLifetime:
|
||||
oauthServerConfig.refreshTokenLifetime !== undefined
|
||||
? String(oauthServerConfig.refreshTokenLifetime)
|
||||
: '',
|
||||
authorizationCodeLifetime:
|
||||
oauthServerConfig.authorizationCodeLifetime !== undefined
|
||||
? String(oauthServerConfig.authorizationCodeLifetime)
|
||||
: '',
|
||||
allowedScopes:
|
||||
oauthServerConfig.allowedScopes && oauthServerConfig.allowedScopes.length > 0
|
||||
? oauthServerConfig.allowedScopes.join(', ')
|
||||
: '',
|
||||
dynamicRegistrationAllowedGrantTypes:
|
||||
oauthServerConfig.dynamicRegistration?.allowedGrantTypes?.length
|
||||
? oauthServerConfig.dynamicRegistration.allowedGrantTypes.join(', ')
|
||||
: '',
|
||||
})
|
||||
}
|
||||
}, [oauthServerConfig])
|
||||
|
||||
// Update local tempNameSeparator when nameSeparator changes
|
||||
useEffect(() => {
|
||||
setTempNameSeparator(nameSeparator)
|
||||
@@ -110,6 +155,7 @@ const SettingsPage: React.FC = () => {
|
||||
routingConfig: false,
|
||||
installConfig: false,
|
||||
smartRoutingConfig: false,
|
||||
oauthServerConfig: false,
|
||||
mcpRouterConfig: false,
|
||||
nameSeparator: false,
|
||||
password: false,
|
||||
@@ -121,6 +167,7 @@ const SettingsPage: React.FC = () => {
|
||||
| 'routingConfig'
|
||||
| 'installConfig'
|
||||
| 'smartRoutingConfig'
|
||||
| 'oauthServerConfig'
|
||||
| 'mcpRouterConfig'
|
||||
| 'nameSeparator'
|
||||
| 'password'
|
||||
@@ -222,6 +269,81 @@ const SettingsPage: React.FC = () => {
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key])
|
||||
}
|
||||
|
||||
type OAuthServerNumberField =
|
||||
| 'accessTokenLifetime'
|
||||
| 'refreshTokenLifetime'
|
||||
| 'authorizationCodeLifetime'
|
||||
|
||||
const handleOAuthServerNumberChange = (key: OAuthServerNumberField, value: string) => {
|
||||
setTempOAuthServerConfig((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleOAuthServerTextChange = (
|
||||
key: 'allowedScopes' | 'dynamicRegistrationAllowedGrantTypes',
|
||||
value: string,
|
||||
) => {
|
||||
setTempOAuthServerConfig((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const saveOAuthServerNumberConfig = async (key: OAuthServerNumberField) => {
|
||||
const rawValue = tempOAuthServerConfig[key]
|
||||
if (!rawValue || rawValue.trim() === '') {
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const parsedValue = Number(rawValue)
|
||||
if (Number.isNaN(parsedValue) || parsedValue < 0) {
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
await updateOAuthServerConfig(key, parsedValue)
|
||||
}
|
||||
|
||||
const saveOAuthServerAllowedScopes = async () => {
|
||||
const scopes = tempOAuthServerConfig.allowedScopes
|
||||
.split(',')
|
||||
.map((scope) => scope.trim())
|
||||
.filter((scope) => scope.length > 0)
|
||||
|
||||
await updateOAuthServerConfig('allowedScopes', scopes)
|
||||
}
|
||||
|
||||
const saveOAuthServerGrantTypes = async () => {
|
||||
const grantTypes = tempOAuthServerConfig.dynamicRegistrationAllowedGrantTypes
|
||||
.split(',')
|
||||
.map((grant) => grant.trim())
|
||||
.filter((grant) => grant.length > 0)
|
||||
|
||||
await updateOAuthServerConfig('dynamicRegistration', {
|
||||
...oauthServerConfig.dynamicRegistration,
|
||||
allowedGrantTypes: grantTypes,
|
||||
})
|
||||
}
|
||||
|
||||
const handleOAuthServerToggle = async (
|
||||
key: 'enabled' | 'requireClientSecret' | 'requireState',
|
||||
value: boolean,
|
||||
) => {
|
||||
await updateOAuthServerConfig(key, value)
|
||||
}
|
||||
|
||||
const handleDynamicRegistrationToggle = async (
|
||||
updates: Partial<typeof oauthServerConfig.dynamicRegistration>,
|
||||
) => {
|
||||
await updateOAuthServerConfig('dynamicRegistration', {
|
||||
...oauthServerConfig.dynamicRegistration,
|
||||
...updates,
|
||||
})
|
||||
}
|
||||
|
||||
const saveNameSeparator = async () => {
|
||||
await updateNameSeparator(tempNameSeparator)
|
||||
}
|
||||
@@ -287,7 +409,7 @@ const SettingsPage: React.FC = () => {
|
||||
try {
|
||||
const result = await exportMCPSettings()
|
||||
console.log('Fetched MCP settings:', result)
|
||||
const configJson = JSON.stringify(result, null, 2)
|
||||
const configJson = JSON.stringify(result.data, null, 2)
|
||||
setMcpSettingsJson(configJson)
|
||||
} catch (error) {
|
||||
console.error('Error fetching MCP settings:', error)
|
||||
@@ -492,6 +614,266 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
{/* OAuth Server Configuration Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_OAUTH_SERVER}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('oauthServerConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.oauthServer')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.oauthServerConfig ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.oauthServerConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableOauthServer')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableOauthServerDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={oauthServerConfig.enabled}
|
||||
onCheckedChange={(checked) => handleOAuthServerToggle('enabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.requireClientSecret')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.requireClientSecretDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading || !oauthServerConfig.enabled}
|
||||
checked={oauthServerConfig.requireClientSecret}
|
||||
onCheckedChange={(checked) =>
|
||||
handleOAuthServerToggle('requireClientSecret', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.requireState')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.requireStateDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading || !oauthServerConfig.enabled}
|
||||
checked={oauthServerConfig.requireState}
|
||||
onCheckedChange={(checked) => handleOAuthServerToggle('requireState', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.accessTokenLifetime')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.accessTokenLifetimeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
value={tempOAuthServerConfig.accessTokenLifetime}
|
||||
onChange={(e) =>
|
||||
handleOAuthServerNumberChange('accessTokenLifetime', e.target.value)
|
||||
}
|
||||
placeholder={t('settings.accessTokenLifetimePlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveOAuthServerNumberConfig('accessTokenLifetime')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.refreshTokenLifetime')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.refreshTokenLifetimeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
value={tempOAuthServerConfig.refreshTokenLifetime}
|
||||
onChange={(e) =>
|
||||
handleOAuthServerNumberChange('refreshTokenLifetime', e.target.value)
|
||||
}
|
||||
placeholder={t('settings.refreshTokenLifetimePlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveOAuthServerNumberConfig('refreshTokenLifetime')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.authorizationCodeLifetime')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.authorizationCodeLifetimeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
value={tempOAuthServerConfig.authorizationCodeLifetime}
|
||||
onChange={(e) =>
|
||||
handleOAuthServerNumberChange('authorizationCodeLifetime', e.target.value)
|
||||
}
|
||||
placeholder={t('settings.authorizationCodeLifetimePlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveOAuthServerNumberConfig('authorizationCodeLifetime')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.allowedScopes')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.allowedScopesDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempOAuthServerConfig.allowedScopes}
|
||||
onChange={(e) => handleOAuthServerTextChange('allowedScopes', e.target.value)}
|
||||
placeholder={t('settings.allowedScopesPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={saveOAuthServerAllowedScopes}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.enableDynamicRegistration')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.dynamicRegistrationDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading || !oauthServerConfig.enabled}
|
||||
checked={oauthServerConfig.dynamicRegistration.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleDynamicRegistrationToggle({ enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.dynamicRegistrationAllowedGrantTypes')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.dynamicRegistrationAllowedGrantTypesDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempOAuthServerConfig.dynamicRegistrationAllowedGrantTypes}
|
||||
onChange={(e) =>
|
||||
handleOAuthServerTextChange(
|
||||
'dynamicRegistrationAllowedGrantTypes',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder={t('settings.dynamicRegistrationAllowedGrantTypesPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={
|
||||
loading ||
|
||||
!oauthServerConfig.enabled ||
|
||||
!oauthServerConfig.dynamicRegistration.enabled
|
||||
}
|
||||
/>
|
||||
<button
|
||||
onClick={saveOAuthServerGrantTypes}
|
||||
disabled={
|
||||
loading ||
|
||||
!oauthServerConfig.enabled ||
|
||||
!oauthServerConfig.dynamicRegistration.enabled
|
||||
}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.dynamicRegistrationAuth')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.dynamicRegistrationAuthDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={
|
||||
loading ||
|
||||
!oauthServerConfig.enabled ||
|
||||
!oauthServerConfig.dynamicRegistration.enabled
|
||||
}
|
||||
checked={oauthServerConfig.dynamicRegistration.requiresAuthentication}
|
||||
onCheckedChange={(checked) =>
|
||||
handleDynamicRegistrationToggle({ requiresAuthentication: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
{/* MCPRouter Configuration Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_INSTALL_CONFIG}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card dashboard-card">
|
||||
@@ -599,6 +981,18 @@ const SettingsPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableSessionRebuild')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableSessionRebuildDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={enableSessionRebuild}
|
||||
onCheckedChange={(checked) => updateSessionRebuild(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -794,10 +1188,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>
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useUserData } from '@/hooks/useUserData';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import AddUserForm from '@/components/AddUserForm';
|
||||
import EditUserForm from '@/components/EditUserForm';
|
||||
import UserCard from '@/components/UserCard';
|
||||
import { Edit, Trash, User as UserIcon } from 'lucide-react';
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog';
|
||||
|
||||
const UsersPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -22,11 +23,12 @@ const UsersPage: React.FC = () => {
|
||||
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [userToDelete, setUserToDelete] = useState<string | null>(null);
|
||||
|
||||
// Check if current user is admin
|
||||
if (!currentUser?.isAdmin) {
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="bg-white shadow rounded-lg p-6 dashboard-card">
|
||||
<p className="text-red-600">{t('users.adminRequired')}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -41,10 +43,17 @@ const UsersPage: React.FC = () => {
|
||||
triggerRefresh(); // Refresh the users list after editing
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (username: string) => {
|
||||
const result = await deleteUser(username);
|
||||
if (!result?.success) {
|
||||
setUserError(result?.message || t('users.deleteError'));
|
||||
const handleDeleteClick = (username: string) => {
|
||||
setUserToDelete(username);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (userToDelete) {
|
||||
const result = await deleteUser(userToDelete);
|
||||
if (!result?.success) {
|
||||
setUserError(result?.message || t('users.deleteError'));
|
||||
}
|
||||
setUserToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,13 +67,13 @@ const UsersPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="container mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t('pages.users.title')}</h1>
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={handleAddUser}
|
||||
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"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center btn-primary transition-all duration-200 shadow-sm"
|
||||
>
|
||||
<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="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
@@ -75,13 +84,23 @@ const UsersPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{userError && (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
|
||||
<p>{userError}</p>
|
||||
<div className="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg shadow-sm">
|
||||
<div className="flex justify-between items-center">
|
||||
<p>{userError}</p>
|
||||
<button
|
||||
onClick={() => setUserError(null)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usersLoading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 loading-container">
|
||||
<div className="bg-white shadow rounded-lg p-6 loading-container flex justify-center items-center h-64">
|
||||
<div className="flex flex-col items-center justify-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>
|
||||
@@ -91,20 +110,93 @@ const UsersPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 empty-state">
|
||||
<p className="text-gray-600">{t('users.noUsers')}</p>
|
||||
<div className="bg-white shadow rounded-lg p-6 empty-state dashboard-card">
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="p-4 bg-gray-100 rounded-full mb-4">
|
||||
<UserIcon className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-gray-600 text-lg font-medium">{t('users.noUsers')}</p>
|
||||
<button
|
||||
onClick={handleAddUser}
|
||||
className="mt-4 text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
{t('users.addFirst')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{users.map((user) => (
|
||||
<UserCard
|
||||
key={user.username}
|
||||
user={user}
|
||||
currentUser={currentUser}
|
||||
onEdit={handleEditClick}
|
||||
onDelete={handleDeleteUser}
|
||||
/>
|
||||
))}
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden table-container dashboard-card">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('users.username')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('users.role')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('users.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.map((user) => {
|
||||
const isCurrentUser = currentUser?.username === user.username;
|
||||
return (
|
||||
<tr key={user.username} className="hover:bg-gray-50 transition-colors duration-150">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10">
|
||||
<div className="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold text-lg">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900 flex items-center">
|
||||
{user.username}
|
||||
{isCurrentUser && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs bg-blue-100 text-blue-800 rounded-full border border-blue-200">
|
||||
{t('users.currentUser')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${user.isAdmin
|
||||
? 'bg-purple-100 text-purple-800 border border-purple-200'
|
||||
: 'bg-gray-100 text-gray-800 border border-gray-200'
|
||||
}`}>
|
||||
{user.isAdmin ? t('users.admin') : t('users.user')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => handleEditClick(user)}
|
||||
className="text-blue-600 hover:text-blue-900 p-1 rounded hover:bg-blue-50 transition-colors"
|
||||
title={t('users.edit')}
|
||||
>
|
||||
<Edit size={18} />
|
||||
</button>
|
||||
{!isCurrentUser && (
|
||||
<button
|
||||
onClick={() => handleDeleteClick(user.username)}
|
||||
className="text-red-600 hover:text-red-900 p-1 rounded hover:bg-red-50 transition-colors"
|
||||
title={t('users.delete')}
|
||||
>
|
||||
<Trash size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -119,6 +211,15 @@ const UsersPage: React.FC = () => {
|
||||
onCancel={() => setEditingUser(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteDialog
|
||||
isOpen={!!userToDelete}
|
||||
onClose={() => setUserToDelete(null)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
serverName={userToDelete || ''}
|
||||
isGroup={false}
|
||||
isUser={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,4 +359,150 @@ export interface AuthResponse {
|
||||
token?: string;
|
||||
user?: IUser;
|
||||
message?: string;
|
||||
isUsingDefaultPassword?: boolean;
|
||||
}
|
||||
|
||||
// Official Registry types (from registry.modelcontextprotocol.io)
|
||||
export interface RegistryVariable {
|
||||
choices?: string[];
|
||||
default?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
isRequired?: boolean;
|
||||
isSecret?: boolean;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface RegistryVariables {
|
||||
[key: string]: RegistryVariable;
|
||||
}
|
||||
|
||||
export interface RegistryEnvironmentVariable {
|
||||
choices?: string[];
|
||||
default?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
isRequired?: boolean;
|
||||
isSecret?: boolean;
|
||||
name: string;
|
||||
value?: string;
|
||||
variables?: RegistryVariables;
|
||||
}
|
||||
|
||||
export interface RegistryPackageArgument {
|
||||
choices?: string[];
|
||||
default?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
isRepeated?: boolean;
|
||||
isRequired?: boolean;
|
||||
isSecret?: boolean;
|
||||
name: string;
|
||||
type?: string;
|
||||
value?: string;
|
||||
valueHint?: string;
|
||||
variables?: RegistryVariables;
|
||||
}
|
||||
|
||||
export interface RegistryTransportHeader {
|
||||
choices?: string[];
|
||||
default?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
isRequired?: boolean;
|
||||
isSecret?: boolean;
|
||||
name: string;
|
||||
value?: string;
|
||||
variables?: RegistryVariables;
|
||||
}
|
||||
|
||||
export interface RegistryTransport {
|
||||
headers?: RegistryTransportHeader[];
|
||||
type: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface RegistryPackage {
|
||||
environmentVariables?: RegistryEnvironmentVariable[];
|
||||
fileSha256?: string;
|
||||
identifier: string;
|
||||
packageArguments?: RegistryPackageArgument[];
|
||||
registryBaseUrl?: string;
|
||||
registryType: string;
|
||||
runtimeArguments?: RegistryPackageArgument[];
|
||||
runtimeHint?: string;
|
||||
transport?: RegistryTransport;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface RegistryRemote {
|
||||
headers?: RegistryTransportHeader[];
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RegistryRepository {
|
||||
id?: string;
|
||||
source?: string;
|
||||
subfolder?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface RegistryIcon {
|
||||
mimeType: string;
|
||||
sizes?: string[];
|
||||
src: string;
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
export interface RegistryServerData {
|
||||
$schema?: string;
|
||||
_meta?: {
|
||||
'io.modelcontextprotocol.registry/publisher-provided'?: Record<string, any>;
|
||||
};
|
||||
description: string;
|
||||
icons?: RegistryIcon[];
|
||||
name: string;
|
||||
packages?: RegistryPackage[];
|
||||
remotes?: RegistryRemote[];
|
||||
repository?: RegistryRepository;
|
||||
title: string;
|
||||
version: string;
|
||||
websiteUrl?: string;
|
||||
}
|
||||
|
||||
export interface RegistryOfficialMeta {
|
||||
isLatest?: boolean;
|
||||
publishedAt?: string;
|
||||
status?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface RegistryServerEntry {
|
||||
_meta?: {
|
||||
'io.modelcontextprotocol.registry/official'?: RegistryOfficialMeta;
|
||||
};
|
||||
server: RegistryServerData;
|
||||
}
|
||||
|
||||
export interface RegistryMetadata {
|
||||
count: number;
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface RegistryServersResponse {
|
||||
metadata: RegistryMetadata;
|
||||
servers: RegistryServerEntry[];
|
||||
}
|
||||
|
||||
export interface RegistryServerVersionsResponse {
|
||||
metadata: RegistryMetadata;
|
||||
servers: RegistryServerEntry[];
|
||||
}
|
||||
|
||||
export interface RegistryServerVersionResponse {
|
||||
_meta?: {
|
||||
'io.modelcontextprotocol.registry/official'?: RegistryOfficialMeta;
|
||||
};
|
||||
server: RegistryServerData;
|
||||
}
|
||||
|
||||
38
frontend/src/utils/passwordValidation.ts
Normal file
38
frontend/src/utils/passwordValidation.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
167
locales/en.json
167
locales/en.json
@@ -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...",
|
||||
@@ -211,7 +237,15 @@
|
||||
"dismiss": "Dismiss",
|
||||
"github": "GitHub",
|
||||
"wechat": "WeChat",
|
||||
"discord": "Discord"
|
||||
"discord": "Discord",
|
||||
"required": "Required",
|
||||
"secret": "Secret",
|
||||
"default": "Default",
|
||||
"value": "Value",
|
||||
"type": "Type",
|
||||
"repeated": "Repeated",
|
||||
"valueHint": "Value Hint",
|
||||
"choices": "Choices"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -250,7 +284,8 @@
|
||||
"appearance": "Appearance",
|
||||
"routeConfig": "Security",
|
||||
"installConfig": "Installation",
|
||||
"smartRouting": "Smart Routing"
|
||||
"smartRouting": "Smart Routing",
|
||||
"oauthServer": "OAuth Server"
|
||||
},
|
||||
"market": {
|
||||
"title": "Market Hub - Local and Cloud Markets"
|
||||
@@ -349,6 +384,16 @@
|
||||
"confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue installing server?",
|
||||
"confirmAndInstall": "Confirm and Install"
|
||||
},
|
||||
"oauthServer": {
|
||||
"authorizeTitle": "Authorize Application",
|
||||
"authorizeSubtitle": "Allow this application to access your MCPHub account.",
|
||||
"buttons": {
|
||||
"approve": "Allow access",
|
||||
"deny": "Deny",
|
||||
"approveSubtitle": "Recommended if you trust this application.",
|
||||
"denySubtitle": "You can always grant access later."
|
||||
}
|
||||
},
|
||||
"cloud": {
|
||||
"title": "Cloud Support",
|
||||
"subtitle": "Powered by MCPRouter",
|
||||
@@ -401,6 +446,41 @@
|
||||
"installSuccess": "Server {{name}} installed successfully",
|
||||
"installError": "Failed to install server: {{error}}"
|
||||
},
|
||||
"registry": {
|
||||
"title": "Registry",
|
||||
"official": "Official",
|
||||
"latest": "Latest",
|
||||
"description": "Description",
|
||||
"website": "Website",
|
||||
"repository": "Repository",
|
||||
"packages": "Packages",
|
||||
"package": "package",
|
||||
"remotes": "Remotes",
|
||||
"remote": "remote",
|
||||
"published": "Published",
|
||||
"updated": "Updated",
|
||||
"install": "Install",
|
||||
"installing": "Installing...",
|
||||
"installed": "Installed",
|
||||
"installServer": "Install {{name}}",
|
||||
"installSuccess": "Server {{name}} installed successfully",
|
||||
"installError": "Failed to install server: {{error}}",
|
||||
"noDescription": "No description available",
|
||||
"viewDetails": "View Details",
|
||||
"backToList": "Back to Registry",
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Search registry servers by name",
|
||||
"clearFilters": "Clear",
|
||||
"noServers": "No registry servers found",
|
||||
"fetchError": "Error fetching registry servers",
|
||||
"serverNotFound": "Registry server not found",
|
||||
"showing": "Showing {{from}}-{{to}} of {{total}} registry servers",
|
||||
"perPage": "Per page",
|
||||
"environmentVariables": "Environment Variables",
|
||||
"packageArguments": "Package Arguments",
|
||||
"runtimeArguments": "Runtime Arguments",
|
||||
"headers": "Headers"
|
||||
},
|
||||
"tool": {
|
||||
"run": "Run",
|
||||
"running": "Running...",
|
||||
@@ -505,6 +585,8 @@
|
||||
"systemSettings": "System Settings",
|
||||
"nameSeparatorLabel": "Name Separator",
|
||||
"nameSeparatorDescription": "Character used to separate server name and tool/prompt name (default: -)",
|
||||
"enableSessionRebuild": "Enable Server Session Rebuild",
|
||||
"enableSessionRebuildDescription": "When enabled, applies the improved server session rebuild code for better session management experience",
|
||||
"restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly.",
|
||||
"exportMcpSettings": "Export Settings",
|
||||
"mcpSettingsJson": "MCP Settings JSON",
|
||||
@@ -512,7 +594,33 @@
|
||||
"copyToClipboard": "Copy to Clipboard",
|
||||
"downloadJson": "Download JSON",
|
||||
"exportSuccess": "Settings exported successfully",
|
||||
"exportError": "Failed to fetch settings"
|
||||
"exportError": "Failed to fetch settings",
|
||||
"enableOauthServer": "Enable OAuth Server",
|
||||
"enableOauthServerDescription": "Allow MCPHub to issue OAuth tokens for external clients",
|
||||
"requireClientSecret": "Require Client Secret",
|
||||
"requireClientSecretDescription": "When enabled, confidential clients must present a client secret (disable for PKCE-only clients)",
|
||||
"requireState": "Require State Parameter",
|
||||
"requireStateDescription": "Reject authorization requests that omit the OAuth state parameter",
|
||||
"accessTokenLifetime": "Access Token Lifetime (seconds)",
|
||||
"accessTokenLifetimeDescription": "How long issued access tokens remain valid",
|
||||
"accessTokenLifetimePlaceholder": "e.g. 3600",
|
||||
"refreshTokenLifetime": "Refresh Token Lifetime (seconds)",
|
||||
"refreshTokenLifetimeDescription": "How long refresh tokens remain valid",
|
||||
"refreshTokenLifetimePlaceholder": "e.g. 1209600",
|
||||
"authorizationCodeLifetime": "Authorization Code Lifetime (seconds)",
|
||||
"authorizationCodeLifetimeDescription": "How long authorization codes remain valid before they can be exchanged",
|
||||
"authorizationCodeLifetimePlaceholder": "e.g. 300",
|
||||
"allowedScopes": "Allowed Scopes",
|
||||
"allowedScopesDescription": "Comma-separated list of scopes users can approve during authorization",
|
||||
"allowedScopesPlaceholder": "e.g. read, write",
|
||||
"enableDynamicRegistration": "Enable Dynamic Client Registration",
|
||||
"dynamicRegistrationDescription": "Allow RFC 7591 compliant clients to self-register using the public endpoint",
|
||||
"dynamicRegistrationAllowedGrantTypes": "Allowed Grant Types",
|
||||
"dynamicRegistrationAllowedGrantTypesDescription": "Comma-separated list of grants permitted for dynamically registered clients",
|
||||
"dynamicRegistrationAllowedGrantTypesPlaceholder": "e.g. authorization_code, refresh_token",
|
||||
"dynamicRegistrationAuth": "Require Authentication",
|
||||
"dynamicRegistrationAuthDescription": "Protect the registration endpoint so only authenticated requests can register clients",
|
||||
"invalidNumberInput": "Please enter a valid non-negative number"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "Upload",
|
||||
@@ -539,6 +647,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",
|
||||
@@ -550,9 +673,13 @@
|
||||
"password": "Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"changePassword": "Change Password",
|
||||
"adminRole": "Administrator",
|
||||
"admin": "Admin",
|
||||
"user": "User",
|
||||
"role": "Role",
|
||||
"actions": "Actions",
|
||||
"addFirst": "Add your first user",
|
||||
"permissions": "Permissions",
|
||||
"adminPermissions": "Full system access",
|
||||
"userPermissions": "Limited access",
|
||||
@@ -633,5 +760,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
167
locales/fr.json
167
locales/fr.json
@@ -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...",
|
||||
@@ -211,7 +237,15 @@
|
||||
"dismiss": "Rejeter",
|
||||
"github": "GitHub",
|
||||
"wechat": "WeChat",
|
||||
"discord": "Discord"
|
||||
"discord": "Discord",
|
||||
"required": "Requis",
|
||||
"secret": "Secret",
|
||||
"default": "Défaut",
|
||||
"value": "Valeur",
|
||||
"type": "Type",
|
||||
"repeated": "Répété",
|
||||
"valueHint": "Indice de valeur",
|
||||
"choices": "Choix"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
@@ -250,7 +284,8 @@
|
||||
"appearance": "Apparence",
|
||||
"routeConfig": "Sécurité",
|
||||
"installConfig": "Installation",
|
||||
"smartRouting": "Routage intelligent"
|
||||
"smartRouting": "Routage intelligent",
|
||||
"oauthServer": "Serveur OAuth"
|
||||
},
|
||||
"market": {
|
||||
"title": "Marché Hub - Marchés locaux et Cloud"
|
||||
@@ -349,6 +384,16 @@
|
||||
"confirmVariablesMessage": "Veuillez vous assurer que ces variables sont correctement définies dans votre environnement d'exécution. Continuer l'installation du serveur ?",
|
||||
"confirmAndInstall": "Confirmer et installer"
|
||||
},
|
||||
"oauthServer": {
|
||||
"authorizeTitle": "Autoriser l'application",
|
||||
"authorizeSubtitle": "Autorisez cette application à accéder à votre compte MCPHub.",
|
||||
"buttons": {
|
||||
"approve": "Autoriser l'accès",
|
||||
"deny": "Refuser",
|
||||
"approveSubtitle": "Recommandé si vous faites confiance à cette application.",
|
||||
"denySubtitle": "Vous pourrez toujours accorder l'accès plus tard."
|
||||
}
|
||||
},
|
||||
"cloud": {
|
||||
"title": "Support Cloud",
|
||||
"subtitle": "Propulsé par MCPRouter",
|
||||
@@ -401,6 +446,41 @@
|
||||
"installSuccess": "Serveur {{name}} installé avec succès",
|
||||
"installError": "Échec de l'installation du serveur : {{error}}"
|
||||
},
|
||||
"registry": {
|
||||
"title": "Registre",
|
||||
"official": "Officiel",
|
||||
"latest": "Dernière version",
|
||||
"description": "Description",
|
||||
"website": "Site web",
|
||||
"repository": "Dépôt",
|
||||
"packages": "Paquets",
|
||||
"package": "paquet",
|
||||
"remotes": "Services distants",
|
||||
"remote": "service distant",
|
||||
"published": "Publié",
|
||||
"updated": "Mis à jour",
|
||||
"install": "Installer",
|
||||
"installing": "Installation...",
|
||||
"installed": "Installé",
|
||||
"installServer": "Installer {{name}}",
|
||||
"installSuccess": "Serveur {{name}} installé avec succès",
|
||||
"installError": "Échec de l'installation du serveur : {{error}}",
|
||||
"noDescription": "Aucune description disponible",
|
||||
"viewDetails": "Voir les détails",
|
||||
"backToList": "Retour au registre",
|
||||
"search": "Rechercher",
|
||||
"searchPlaceholder": "Rechercher des serveurs par nom",
|
||||
"clearFilters": "Effacer",
|
||||
"noServers": "Aucun serveur trouvé dans le registre",
|
||||
"fetchError": "Erreur lors de la récupération des serveurs du registre",
|
||||
"serverNotFound": "Serveur du registre non trouvé",
|
||||
"showing": "Affichage de {{from}}-{{to}} sur {{total}} serveurs du registre",
|
||||
"perPage": "Par page",
|
||||
"environmentVariables": "Variables d'environnement",
|
||||
"packageArguments": "Arguments du paquet",
|
||||
"runtimeArguments": "Arguments d'exécution",
|
||||
"headers": "En-têtes"
|
||||
},
|
||||
"tool": {
|
||||
"run": "Exécuter",
|
||||
"running": "Exécution en cours...",
|
||||
@@ -505,6 +585,8 @@
|
||||
"systemSettings": "Paramètres système",
|
||||
"nameSeparatorLabel": "Séparateur de noms",
|
||||
"nameSeparatorDescription": "Caractère utilisé pour séparer le nom du serveur et le nom de l'outil/prompt (par défaut : -)",
|
||||
"enableSessionRebuild": "Activer la reconstruction de session serveur",
|
||||
"enableSessionRebuildDescription": "Lorsqu'il est activé, applique le code de reconstruction de session serveur amélioré pour une meilleure expérience de gestion de session",
|
||||
"restartRequired": "Configuration enregistrée. Il est recommandé de redémarrer l'application pour s'assurer que tous les services chargent correctement les nouveaux paramètres.",
|
||||
"exportMcpSettings": "Exporter les paramètres",
|
||||
"mcpSettingsJson": "JSON des paramètres MCP",
|
||||
@@ -512,7 +594,33 @@
|
||||
"copyToClipboard": "Copier dans le presse-papiers",
|
||||
"downloadJson": "Télécharger JSON",
|
||||
"exportSuccess": "Paramètres exportés avec succès",
|
||||
"exportError": "Échec de la récupération des paramètres"
|
||||
"exportError": "Échec de la récupération des paramètres",
|
||||
"enableOauthServer": "Activer le serveur OAuth",
|
||||
"enableOauthServerDescription": "Permet à MCPHub d'émettre des jetons OAuth pour les clients externes",
|
||||
"requireClientSecret": "Exiger un secret client",
|
||||
"requireClientSecretDescription": "Lorsque activé, les clients confidentiels doivent présenter un client secret (désactivez-le pour les clients PKCE publics)",
|
||||
"requireState": "Exiger le paramètre state",
|
||||
"requireStateDescription": "Refuser les demandes d'autorisation qui n'incluent pas le paramètre state",
|
||||
"accessTokenLifetime": "Durée de vie du jeton d'accès (secondes)",
|
||||
"accessTokenLifetimeDescription": "Durée pendant laquelle les jetons d'accès émis restent valides",
|
||||
"accessTokenLifetimePlaceholder": "ex. 3600",
|
||||
"refreshTokenLifetime": "Durée de vie du jeton d'actualisation (secondes)",
|
||||
"refreshTokenLifetimeDescription": "Durée pendant laquelle les jetons d'actualisation restent valides",
|
||||
"refreshTokenLifetimePlaceholder": "ex. 1209600",
|
||||
"authorizationCodeLifetime": "Durée de vie du code d'autorisation (secondes)",
|
||||
"authorizationCodeLifetimeDescription": "Temps pendant lequel les codes d'autorisation peuvent être échangés",
|
||||
"authorizationCodeLifetimePlaceholder": "ex. 300",
|
||||
"allowedScopes": "Scopes autorisés",
|
||||
"allowedScopesDescription": "Liste séparée par des virgules des scopes que les utilisateurs peuvent approuver",
|
||||
"allowedScopesPlaceholder": "ex. read, write",
|
||||
"enableDynamicRegistration": "Activer l'enregistrement dynamique",
|
||||
"dynamicRegistrationDescription": "Autoriser les clients conformes RFC 7591 à s'enregistrer via l'endpoint public",
|
||||
"dynamicRegistrationAllowedGrantTypes": "Types de flux autorisés",
|
||||
"dynamicRegistrationAllowedGrantTypesDescription": "Liste séparée par des virgules des types de flux disponibles pour les clients enregistrés dynamiquement",
|
||||
"dynamicRegistrationAllowedGrantTypesPlaceholder": "ex. authorization_code, refresh_token",
|
||||
"dynamicRegistrationAuth": "Exiger une authentification",
|
||||
"dynamicRegistrationAuthDescription": "Protège l'endpoint d'enregistrement afin que seules les requêtes authentifiées puissent créer des clients",
|
||||
"invalidNumberInput": "Veuillez saisir un nombre valide supérieur ou égal à zéro"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "Télécharger",
|
||||
@@ -539,6 +647,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",
|
||||
@@ -550,9 +673,13 @@
|
||||
"password": "Mot de passe",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"adminRole": "Administrateur",
|
||||
"admin": "Admin",
|
||||
"user": "Utilisateur",
|
||||
"role": "Rôle",
|
||||
"actions": "Actions",
|
||||
"addFirst": "Ajoutez votre premier utilisateur",
|
||||
"permissions": "Permissions",
|
||||
"adminPermissions": "Accès complet au système",
|
||||
"userPermissions": "Accès limité",
|
||||
@@ -633,5 +760,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
790
locales/tr.json
Normal file
790
locales/tr.json
Normal file
@@ -0,0 +1,790 @@
|
||||
{
|
||||
"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",
|
||||
"oauthServer": "OAuth Sunucusu"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"oauthServer": {
|
||||
"authorizeTitle": "Uygulamayı Yetkilendir",
|
||||
"authorizeSubtitle": "Bu uygulamanın MCPHub hesabınıza erişmesine izin verin.",
|
||||
"buttons": {
|
||||
"approve": "Erişime izin ver",
|
||||
"deny": "Reddet",
|
||||
"approveSubtitle": "Bu uygulamaya güveniyorsanız izin vermeniz önerilir.",
|
||||
"denySubtitle": "İstediğiniz zaman daha sonra erişim verebilirsiniz."
|
||||
}
|
||||
},
|
||||
"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: -)",
|
||||
"enableSessionRebuild": "Sunucu Oturum Yeniden Oluşturmayı Etkinleştir",
|
||||
"enableSessionRebuildDescription": "Etkinleştirildiğinde, daha iyi oturum yönetimi deneyimi için geliştirilmiş sunucu oturum yeniden oluşturma kodunu uygular",
|
||||
"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",
|
||||
"enableOauthServer": "OAuth Sunucusunu Etkinleştir",
|
||||
"enableOauthServerDescription": "MCPHub'ın harici istemciler için OAuth jetonları vermesine izin ver",
|
||||
"requireClientSecret": "İstemci Sırrı Zorunlu",
|
||||
"requireClientSecretDescription": "Etkin olduğunda gizli istemciler client secret sunmalıdır (yalnızca PKCE kullanan istemciler için kapatabilirsiniz)",
|
||||
"requireState": "State parametresi zorunlu",
|
||||
"requireStateDescription": "State parametresi olmayan yetkilendirme isteklerini reddeder",
|
||||
"accessTokenLifetime": "Erişim jetonu süresi (saniye)",
|
||||
"accessTokenLifetimeDescription": "Verilen erişim jetonlarının geçerli kalacağı süre",
|
||||
"accessTokenLifetimePlaceholder": "örn. 3600",
|
||||
"refreshTokenLifetime": "Yenileme jetonu süresi (saniye)",
|
||||
"refreshTokenLifetimeDescription": "Yenileme jetonlarının geçerli kalacağı süre",
|
||||
"refreshTokenLifetimePlaceholder": "örn. 1209600",
|
||||
"authorizationCodeLifetime": "Yetkilendirme kodu süresi (saniye)",
|
||||
"authorizationCodeLifetimeDescription": "Yetkilendirme kodlarının takas edilebileceği süre",
|
||||
"authorizationCodeLifetimePlaceholder": "örn. 300",
|
||||
"allowedScopes": "İzin verilen kapsamlar",
|
||||
"allowedScopesDescription": "Kullanıcıların onaylayabileceği kapsamların virgülle ayrılmış listesi",
|
||||
"allowedScopesPlaceholder": "örn. read, write",
|
||||
"enableDynamicRegistration": "Dinamik istemci kaydını etkinleştir",
|
||||
"dynamicRegistrationDescription": "RFC 7591 uyumlu istemcilerin herkese açık uç nokta üzerinden kayıt olmasına izin ver",
|
||||
"dynamicRegistrationAllowedGrantTypes": "İzin verilen grant türleri",
|
||||
"dynamicRegistrationAllowedGrantTypesDescription": "Dinamik olarak kaydedilen istemciler için kullanılabilecek grant türlerinin virgülle ayrılmış listesi",
|
||||
"dynamicRegistrationAllowedGrantTypesPlaceholder": "örn. authorization_code, refresh_token",
|
||||
"dynamicRegistrationAuth": "Kayıt için kimlik doğrulaması iste",
|
||||
"dynamicRegistrationAuthDescription": "Kayıt uç noktasını korur, yalnızca kimliği doğrulanmış istekler yeni istemci oluşturabilir",
|
||||
"invalidNumberInput": "Lütfen sıfırdan küçük olmayan geçerli bir sayı girin"
|
||||
},
|
||||
"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",
|
||||
"changePassword": "Şifre Değiştir",
|
||||
"adminRole": "Yönetici",
|
||||
"admin": "Yönetici",
|
||||
"user": "Kullanıcı",
|
||||
"role": "Rol",
|
||||
"actions": "Eylemler",
|
||||
"addFirst": "İlk kullanıcınızı ekleyin",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
167
locales/zh.json
167
locales/zh.json
@@ -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": "创建中...",
|
||||
@@ -212,7 +238,15 @@
|
||||
"dismiss": "忽略",
|
||||
"github": "GitHub",
|
||||
"wechat": "微信",
|
||||
"discord": "Discord"
|
||||
"discord": "Discord",
|
||||
"required": "必填",
|
||||
"secret": "敏感",
|
||||
"default": "默认值",
|
||||
"value": "值",
|
||||
"type": "类型",
|
||||
"repeated": "可重复",
|
||||
"valueHint": "值提示",
|
||||
"choices": "可选值"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
@@ -245,7 +279,8 @@
|
||||
"appearance": "外观",
|
||||
"routeConfig": "安全配置",
|
||||
"installConfig": "安装",
|
||||
"smartRouting": "智能路由"
|
||||
"smartRouting": "智能路由",
|
||||
"oauthServer": "OAuth 服务器"
|
||||
},
|
||||
"groups": {
|
||||
"title": "分组管理"
|
||||
@@ -350,6 +385,16 @@
|
||||
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续安装服务器?",
|
||||
"confirmAndInstall": "确认并安装"
|
||||
},
|
||||
"oauthServer": {
|
||||
"authorizeTitle": "授权应用",
|
||||
"authorizeSubtitle": "允许此应用访问您的 MCPHub 账号。",
|
||||
"buttons": {
|
||||
"approve": "允许访问",
|
||||
"deny": "拒绝",
|
||||
"approveSubtitle": "如果您信任此应用,建议选择允许。",
|
||||
"denySubtitle": "您可以在之后随时再次授权。"
|
||||
}
|
||||
},
|
||||
"cloud": {
|
||||
"title": "云端支持",
|
||||
"subtitle": "由 MCPRouter 提供支持",
|
||||
@@ -402,6 +447,41 @@
|
||||
"installSuccess": "服务器 {{name}} 安装成功",
|
||||
"installError": "安装服务器失败:{{error}}"
|
||||
},
|
||||
"registry": {
|
||||
"title": "注册中心",
|
||||
"official": "官方",
|
||||
"latest": "最新版本",
|
||||
"description": "描述",
|
||||
"website": "网站",
|
||||
"repository": "代码仓库",
|
||||
"packages": "安装包",
|
||||
"package": "安装包",
|
||||
"remotes": "远程服务",
|
||||
"remote": "远程服务",
|
||||
"published": "发布时间",
|
||||
"updated": "更新时间",
|
||||
"install": "安装",
|
||||
"installing": "安装中...",
|
||||
"installed": "已安装",
|
||||
"installServer": "安装 {{name}}",
|
||||
"installSuccess": "服务器 {{name}} 安装成功",
|
||||
"installError": "安装服务器失败:{{error}}",
|
||||
"noDescription": "无描述信息",
|
||||
"viewDetails": "查看详情",
|
||||
"backToList": "返回注册中心",
|
||||
"search": "搜索",
|
||||
"searchPlaceholder": "按名称搜索注册中心服务器",
|
||||
"clearFilters": "清除",
|
||||
"noServers": "未找到注册中心服务器",
|
||||
"fetchError": "获取注册中心服务器失败",
|
||||
"serverNotFound": "未找到注册中心服务器",
|
||||
"showing": "显示 {{from}}-{{to}}/{{total}} 个注册中心服务器",
|
||||
"perPage": "每页显示",
|
||||
"environmentVariables": "环境变量",
|
||||
"packageArguments": "安装包参数",
|
||||
"runtimeArguments": "运行时参数",
|
||||
"headers": "请求头"
|
||||
},
|
||||
"tool": {
|
||||
"run": "运行",
|
||||
"running": "运行中...",
|
||||
@@ -507,6 +587,8 @@
|
||||
"systemSettings": "系统设置",
|
||||
"nameSeparatorLabel": "名称分隔符",
|
||||
"nameSeparatorDescription": "用于分隔服务器名称和工具/提示名称(默认:-)",
|
||||
"enableSessionRebuild": "启用服务端会话重建",
|
||||
"enableSessionRebuildDescription": "开启后会应用服务端会话重建的改进代码,提供更好的会话管理体验",
|
||||
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。",
|
||||
"exportMcpSettings": "导出配置",
|
||||
"mcpSettingsJson": "MCP 配置 JSON",
|
||||
@@ -514,7 +596,33 @@
|
||||
"copyToClipboard": "复制到剪贴板",
|
||||
"downloadJson": "下载 JSON",
|
||||
"exportSuccess": "配置导出成功",
|
||||
"exportError": "获取配置失败"
|
||||
"exportError": "获取配置失败",
|
||||
"enableOauthServer": "启用 OAuth 服务器",
|
||||
"enableOauthServerDescription": "允许 MCPHub 作为 OAuth 2.0 授权服务器向外部客户端签发令牌",
|
||||
"requireClientSecret": "需要客户端密钥",
|
||||
"requireClientSecretDescription": "开启后,保密客户端必须携带 client secret(如需仅使用 PKCE 的公共客户端可关闭)",
|
||||
"requireState": "要求 state 参数",
|
||||
"requireStateDescription": "拒绝未携带 state 参数的授权请求",
|
||||
"accessTokenLifetime": "访问令牌有效期(秒)",
|
||||
"accessTokenLifetimeDescription": "控制访问令牌可使用的时长",
|
||||
"accessTokenLifetimePlaceholder": "例如:3600",
|
||||
"refreshTokenLifetime": "刷新令牌有效期(秒)",
|
||||
"refreshTokenLifetimeDescription": "控制刷新令牌的过期时间",
|
||||
"refreshTokenLifetimePlaceholder": "例如:1209600",
|
||||
"authorizationCodeLifetime": "授权码有效期(秒)",
|
||||
"authorizationCodeLifetimeDescription": "授权码在被兑换前可保持有效的时间",
|
||||
"authorizationCodeLifetimePlaceholder": "例如:300",
|
||||
"allowedScopes": "允许的作用域",
|
||||
"allowedScopesDescription": "使用逗号分隔的作用域列表,在授权时展示给用户",
|
||||
"allowedScopesPlaceholder": "例如:read, write",
|
||||
"enableDynamicRegistration": "启用动态客户端注册",
|
||||
"dynamicRegistrationDescription": "允许遵循 RFC 7591 的客户端通过公共端点自行注册",
|
||||
"dynamicRegistrationAllowedGrantTypes": "允许的授权类型",
|
||||
"dynamicRegistrationAllowedGrantTypesDescription": "使用逗号分隔动态注册客户端可以使用的授权类型",
|
||||
"dynamicRegistrationAllowedGrantTypesPlaceholder": "例如:authorization_code, refresh_token",
|
||||
"dynamicRegistrationAuth": "注册需要认证",
|
||||
"dynamicRegistrationAuthDescription": "开启后,注册端点需要认证请求才能创建客户端",
|
||||
"invalidNumberInput": "请输入合法的非负数字"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "上传",
|
||||
@@ -541,6 +649,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": "添加新用户",
|
||||
@@ -552,9 +675,13 @@
|
||||
"password": "密码",
|
||||
"newPassword": "新密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"changePassword": "修改密码",
|
||||
"adminRole": "管理员",
|
||||
"admin": "管理员",
|
||||
"user": "用户",
|
||||
"role": "角色",
|
||||
"actions": "操作",
|
||||
"addFirst": "添加第一个用户",
|
||||
"permissions": "权限",
|
||||
"adminPermissions": "完全系统访问权限",
|
||||
"userPermissions": "受限访问权限",
|
||||
@@ -635,5 +762,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": "关闭窗口"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,5 +41,27 @@
|
||||
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
|
||||
"isAdmin": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"accessTokenLifetime": 3600,
|
||||
"refreshTokenLifetime": 1209600,
|
||||
"authorizationCodeLifetime": 300,
|
||||
"requireClientSecret": false,
|
||||
"allowedScopes": [
|
||||
"read",
|
||||
"write"
|
||||
],
|
||||
"requireState": false,
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"allowedGrantTypes": [
|
||||
"authorization_code",
|
||||
"refresh_token"
|
||||
],
|
||||
"requiresAuthentication": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13310
package-lock.json
generated
13310
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -46,7 +46,8 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "^12.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.18.1",
|
||||
"@modelcontextprotocol/sdk": "^1.20.2",
|
||||
"@node-oauth/oauth2-server": "^5.2.1",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/multer": "^1.4.13",
|
||||
@@ -64,8 +65,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 +106,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",
|
||||
|
||||
890
pnpm-lock.yaml
generated
890
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ 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 { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -19,6 +20,22 @@ const defaultConfig = {
|
||||
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
const ensureOAuthServerDefaults = (settings: McpSettings): boolean => {
|
||||
if (!settings.systemConfig) {
|
||||
settings.systemConfig = {
|
||||
oauthServer: cloneDefaultOAuthServerConfig(),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.oauthServer) {
|
||||
settings.systemConfig.oauthServer = cloneDefaultOAuthServerConfig();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Settings cache
|
||||
let settingsCache: McpSettings | null = null;
|
||||
|
||||
@@ -33,24 +50,36 @@ export const loadOriginalSettings = (): McpSettings => {
|
||||
}
|
||||
|
||||
const settingsPath = getSettingsPath();
|
||||
// check if file exists
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
console.warn(`Settings file not found at ${settingsPath}, using default settings.`);
|
||||
const defaultSettings: McpSettings = { mcpServers: {}, users: [] };
|
||||
ensureOAuthServerDefaults(defaultSettings);
|
||||
// Cache default settings
|
||||
settingsCache = defaultSettings;
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read and parse settings file
|
||||
const settingsData = fs.readFileSync(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(settingsData);
|
||||
const initialized = ensureOAuthServerDefaults(settings);
|
||||
if (initialized) {
|
||||
try {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
} catch (writeError) {
|
||||
console.error('Failed to persist default OAuth server configuration:', writeError);
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
settingsCache = settings;
|
||||
|
||||
console.log(`Loaded settings from ${settingsPath}`);
|
||||
return settings;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`Failed to load settings from ${settingsPath}:`, errorMessage);
|
||||
const defaultSettings = { mcpServers: {}, users: [] };
|
||||
|
||||
// Cache default settings
|
||||
settingsCache = defaultSettings;
|
||||
|
||||
return defaultSettings;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load settings from ${settingsPath}: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,22 +125,33 @@ 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);
|
||||
} 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;
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
42
src/constants/oauthServerDefaults.ts
Normal file
42
src/constants/oauthServerDefaults.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { OAuthServerConfig } from '../types/index.js';
|
||||
|
||||
export const DEFAULT_OAUTH_SERVER_CONFIG: OAuthServerConfig = {
|
||||
enabled: true,
|
||||
accessTokenLifetime: 3600,
|
||||
refreshTokenLifetime: 1209600,
|
||||
authorizationCodeLifetime: 300,
|
||||
requireClientSecret: false,
|
||||
allowedScopes: ['read', 'write'],
|
||||
requireState: false,
|
||||
dynamicRegistration: {
|
||||
enabled: true,
|
||||
allowedGrantTypes: ['authorization_code', 'refresh_token'],
|
||||
requiresAuthentication: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const cloneDefaultOAuthServerConfig = (): OAuthServerConfig => {
|
||||
const allowedScopes = DEFAULT_OAUTH_SERVER_CONFIG.allowedScopes
|
||||
? [...DEFAULT_OAUTH_SERVER_CONFIG.allowedScopes]
|
||||
: [];
|
||||
|
||||
const baseDynamicRegistration =
|
||||
DEFAULT_OAUTH_SERVER_CONFIG.dynamicRegistration ?? {
|
||||
enabled: false,
|
||||
allowedGrantTypes: [],
|
||||
requiresAuthentication: false,
|
||||
};
|
||||
|
||||
const dynamicRegistration = {
|
||||
...baseDynamicRegistration,
|
||||
allowedGrantTypes: baseDynamicRegistration.allowedGrantTypes
|
||||
? [...baseDynamicRegistration.allowedGrantTypes]
|
||||
: [],
|
||||
};
|
||||
|
||||
return {
|
||||
...DEFAULT_OAUTH_SERVER_CONFIG,
|
||||
allowedScopes,
|
||||
dynamicRegistration,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
388
src/controllers/oauthCallbackController.ts
Normal file
388
src/controllers/oauthCallbackController.ts
Normal 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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
276
src/controllers/oauthClientController.ts
Normal file
276
src/controllers/oauthClientController.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { validationResult } from 'express-validator';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
getOAuthClients,
|
||||
findOAuthClientById,
|
||||
createOAuthClient,
|
||||
updateOAuthClient,
|
||||
deleteOAuthClient,
|
||||
} from '../models/OAuth.js';
|
||||
import { IOAuthClient } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* GET /api/oauth/clients
|
||||
* Get all OAuth clients
|
||||
*/
|
||||
export const getAllClients = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const clients = getOAuthClients();
|
||||
|
||||
// Don't expose client secrets in the list
|
||||
const sanitizedClients = clients.map((client) => ({
|
||||
clientId: client.clientId,
|
||||
name: client.name,
|
||||
redirectUris: client.redirectUris,
|
||||
grants: client.grants,
|
||||
scopes: client.scopes,
|
||||
owner: client.owner,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
clients: sanitizedClients,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get OAuth clients error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to retrieve OAuth clients',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/oauth/clients/:clientId
|
||||
* Get a specific OAuth client
|
||||
*/
|
||||
export const getClient = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const client = findOAuthClientById(clientId);
|
||||
|
||||
if (!client) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'OAuth client not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't expose client secret
|
||||
const sanitizedClient = {
|
||||
clientId: client.clientId,
|
||||
name: client.name,
|
||||
redirectUris: client.redirectUris,
|
||||
grants: client.grants,
|
||||
scopes: client.scopes,
|
||||
owner: client.owner,
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
client: sanitizedClient,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get OAuth client error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to retrieve OAuth client',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/oauth/clients
|
||||
* Create a new OAuth client
|
||||
*/
|
||||
export const createClient = (req: Request, res: Response): void => {
|
||||
try {
|
||||
// Validate request
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation failed',
|
||||
errors: errors.array(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, redirectUris, grants, scopes, requireSecret } = req.body;
|
||||
const user = (req as any).user;
|
||||
|
||||
// Generate client ID
|
||||
const clientId = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Generate client secret if required
|
||||
const clientSecret = requireSecret !== false ? crypto.randomBytes(32).toString('hex') : undefined;
|
||||
|
||||
// Create client
|
||||
const client: IOAuthClient = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
name,
|
||||
redirectUris: Array.isArray(redirectUris) ? redirectUris : [redirectUris],
|
||||
grants: grants || ['authorization_code', 'refresh_token'],
|
||||
scopes: scopes || ['read', 'write'],
|
||||
owner: user?.username || 'admin',
|
||||
};
|
||||
|
||||
const createdClient = createOAuthClient(client);
|
||||
|
||||
// Return client with secret (only shown once)
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'OAuth client created successfully',
|
||||
client: {
|
||||
clientId: createdClient.clientId,
|
||||
clientSecret: createdClient.clientSecret,
|
||||
name: createdClient.name,
|
||||
redirectUris: createdClient.redirectUris,
|
||||
grants: createdClient.grants,
|
||||
scopes: createdClient.scopes,
|
||||
owner: createdClient.owner,
|
||||
},
|
||||
warning: clientSecret
|
||||
? 'Client secret is only shown once. Please save it securely.'
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create OAuth client error:', error);
|
||||
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to create OAuth client',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /api/oauth/clients/:clientId
|
||||
* Update an OAuth client
|
||||
*/
|
||||
export const updateClient = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const { name, redirectUris, grants, scopes } = req.body;
|
||||
|
||||
const updates: Partial<IOAuthClient> = {};
|
||||
if (name) updates.name = name;
|
||||
if (redirectUris) updates.redirectUris = Array.isArray(redirectUris) ? redirectUris : [redirectUris];
|
||||
if (grants) updates.grants = grants;
|
||||
if (scopes) updates.scopes = scopes;
|
||||
|
||||
const updatedClient = updateOAuthClient(clientId, updates);
|
||||
|
||||
if (!updatedClient) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'OAuth client not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't expose client secret
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'OAuth client updated successfully',
|
||||
client: {
|
||||
clientId: updatedClient.clientId,
|
||||
name: updatedClient.name,
|
||||
redirectUris: updatedClient.redirectUris,
|
||||
grants: updatedClient.grants,
|
||||
scopes: updatedClient.scopes,
|
||||
owner: updatedClient.owner,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update OAuth client error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to update OAuth client',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/oauth/clients/:clientId
|
||||
* Delete an OAuth client
|
||||
*/
|
||||
export const deleteClient = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const deleted = deleteOAuthClient(clientId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'OAuth client not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'OAuth client deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete OAuth client error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to delete OAuth client',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/oauth/clients/:clientId/regenerate-secret
|
||||
* Regenerate client secret
|
||||
*/
|
||||
export const regenerateSecret = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const client = findOAuthClientById(clientId);
|
||||
|
||||
if (!client) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'OAuth client not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate new secret
|
||||
const newSecret = crypto.randomBytes(32).toString('hex');
|
||||
const updatedClient = updateOAuthClient(clientId, { clientSecret: newSecret });
|
||||
|
||||
if (!updatedClient) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to regenerate client secret',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Client secret regenerated successfully',
|
||||
clientSecret: newSecret,
|
||||
warning: 'Client secret is only shown once. Please save it securely.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Regenerate secret error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to regenerate client secret',
|
||||
});
|
||||
}
|
||||
};
|
||||
543
src/controllers/oauthDynamicRegistrationController.ts
Normal file
543
src/controllers/oauthDynamicRegistrationController.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
import { Request, Response } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
createOAuthClient,
|
||||
findOAuthClientById,
|
||||
updateOAuthClient,
|
||||
deleteOAuthClient,
|
||||
} from '../models/OAuth.js';
|
||||
import { IOAuthClient } from '../types/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
// Store registration access tokens (in production, use database)
|
||||
const registrationTokens = new Map<string, { clientId: string; createdAt: Date }>();
|
||||
|
||||
/**
|
||||
* Generate registration access token
|
||||
*/
|
||||
const generateRegistrationToken = (clientId: string): string => {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
registrationTokens.set(token, {
|
||||
clientId,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
return token;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify registration access token
|
||||
*/
|
||||
const verifyRegistrationToken = (token: string): string | null => {
|
||||
const data = registrationTokens.get(token);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Token expires after 30 days
|
||||
const expiresAt = new Date(data.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
if (new Date() > expiresAt) {
|
||||
registrationTokens.delete(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.clientId;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /oauth/register
|
||||
* RFC 7591 Dynamic Client Registration
|
||||
* Public endpoint for registering new OAuth clients
|
||||
*/
|
||||
export const registerClient = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
|
||||
// Check if dynamic registration is enabled
|
||||
if (!oauthConfig?.dynamicRegistration?.enabled) {
|
||||
res.status(403).json({
|
||||
error: 'invalid_request',
|
||||
error_description: 'Dynamic client registration is not enabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
const {
|
||||
redirect_uris,
|
||||
client_name,
|
||||
grant_types,
|
||||
response_types,
|
||||
scope,
|
||||
token_endpoint_auth_method,
|
||||
application_type,
|
||||
contacts,
|
||||
logo_uri,
|
||||
client_uri,
|
||||
policy_uri,
|
||||
tos_uri,
|
||||
jwks_uri,
|
||||
jwks,
|
||||
} = req.body;
|
||||
|
||||
// redirect_uris is required
|
||||
if (!redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_redirect_uri',
|
||||
error_description: 'redirect_uris is required and must be a non-empty array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate redirect URIs
|
||||
for (const uri of redirect_uris) {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
// For security, only allow https (except localhost for development)
|
||||
if (
|
||||
url.protocol !== 'https:' &&
|
||||
!url.hostname.match(/^(localhost|127\.0\.0\.1|\[::1\])$/)
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_redirect_uri',
|
||||
error_description: `Redirect URI must use HTTPS: ${uri}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_redirect_uri',
|
||||
error_description: `Invalid redirect URI: ${uri}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate client credentials
|
||||
const clientId = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Determine if client secret is needed based on token_endpoint_auth_method
|
||||
const authMethod = token_endpoint_auth_method || 'client_secret_basic';
|
||||
const needsSecret = authMethod !== 'none';
|
||||
const clientSecret = needsSecret ? crypto.randomBytes(32).toString('hex') : undefined;
|
||||
|
||||
// Default grant types
|
||||
const defaultGrantTypes = ['authorization_code', 'refresh_token'];
|
||||
const clientGrantTypes = grant_types || defaultGrantTypes;
|
||||
|
||||
// Validate grant types
|
||||
const allowedGrantTypes = oauthConfig.dynamicRegistration.allowedGrantTypes || [
|
||||
'authorization_code',
|
||||
'refresh_token',
|
||||
];
|
||||
for (const grantType of clientGrantTypes) {
|
||||
if (!allowedGrantTypes.includes(grantType)) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_client_metadata',
|
||||
error_description: `Grant type not allowed: ${grantType}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate scopes
|
||||
const requestedScopes = scope ? scope.split(' ') : ['read', 'write'];
|
||||
const allowedScopes = oauthConfig.allowedScopes || ['read', 'write'];
|
||||
for (const requestedScope of requestedScopes) {
|
||||
if (!allowedScopes.includes(requestedScope)) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_client_metadata',
|
||||
error_description: `Scope not allowed: ${requestedScope}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate registration access token
|
||||
const registrationAccessToken = generateRegistrationToken(clientId);
|
||||
const baseUrl =
|
||||
settings.systemConfig?.install?.baseUrl || `${req.protocol}://${req.get('host')}`;
|
||||
const registrationClientUri = `${baseUrl}/oauth/register/${clientId}`;
|
||||
|
||||
// Create OAuth client
|
||||
const client: IOAuthClient = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
name: client_name || 'Dynamically Registered Client',
|
||||
redirectUris: redirect_uris,
|
||||
grants: clientGrantTypes,
|
||||
scopes: requestedScopes,
|
||||
owner: 'dynamic-registration',
|
||||
// Store additional metadata
|
||||
metadata: {
|
||||
application_type: application_type || 'web',
|
||||
contacts,
|
||||
logo_uri,
|
||||
client_uri,
|
||||
policy_uri,
|
||||
tos_uri,
|
||||
jwks_uri,
|
||||
jwks,
|
||||
token_endpoint_auth_method: authMethod,
|
||||
response_types: response_types || ['code'],
|
||||
},
|
||||
};
|
||||
|
||||
const createdClient = createOAuthClient(client);
|
||||
|
||||
// Build response according to RFC 7591
|
||||
const response: any = {
|
||||
client_id: createdClient.clientId,
|
||||
client_name: createdClient.name,
|
||||
redirect_uris: createdClient.redirectUris,
|
||||
grant_types: createdClient.grants,
|
||||
response_types: client.metadata?.response_types || ['code'],
|
||||
scope: (createdClient.scopes || []).join(' '),
|
||||
token_endpoint_auth_method: authMethod,
|
||||
registration_access_token: registrationAccessToken,
|
||||
registration_client_uri: registrationClientUri,
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
// Include client secret if generated
|
||||
if (clientSecret) {
|
||||
response.client_secret = clientSecret;
|
||||
response.client_secret_expires_at = 0; // 0 means it doesn't expire
|
||||
}
|
||||
|
||||
// Include optional metadata
|
||||
if (application_type) response.application_type = application_type;
|
||||
if (contacts) response.contacts = contacts;
|
||||
if (logo_uri) response.logo_uri = logo_uri;
|
||||
if (client_uri) response.client_uri = client_uri;
|
||||
if (policy_uri) response.policy_uri = policy_uri;
|
||||
if (tos_uri) response.tos_uri = tos_uri;
|
||||
if (jwks_uri) response.jwks_uri = jwks_uri;
|
||||
if (jwks) response.jwks = jwks;
|
||||
|
||||
res.status(201).json(response);
|
||||
} catch (error) {
|
||||
console.error('Dynamic client registration error:', error);
|
||||
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_client_metadata',
|
||||
error_description: 'Client with this ID already exists',
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
error_description: 'Failed to register client',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /oauth/register/:clientId
|
||||
* RFC 7591 Client Configuration Endpoint
|
||||
* Read client configuration
|
||||
*/
|
||||
export const getClientConfiguration = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Registration access token required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const tokenClientId = verifyRegistrationToken(token);
|
||||
|
||||
if (!tokenClientId || tokenClientId !== clientId) {
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Invalid or expired registration access token',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = findOAuthClientById(clientId);
|
||||
if (!client) {
|
||||
res.status(404).json({
|
||||
error: 'invalid_client',
|
||||
error_description: 'Client not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build response
|
||||
const response: any = {
|
||||
client_id: client.clientId,
|
||||
client_name: client.name,
|
||||
redirect_uris: client.redirectUris,
|
||||
grant_types: client.grants,
|
||||
response_types: client.metadata?.response_types || ['code'],
|
||||
scope: (client.scopes || []).join(' '),
|
||||
token_endpoint_auth_method:
|
||||
client.metadata?.token_endpoint_auth_method || 'client_secret_basic',
|
||||
};
|
||||
|
||||
// Include optional metadata
|
||||
if (client.metadata) {
|
||||
if (client.metadata.application_type)
|
||||
response.application_type = client.metadata.application_type;
|
||||
if (client.metadata.contacts) response.contacts = client.metadata.contacts;
|
||||
if (client.metadata.logo_uri) response.logo_uri = client.metadata.logo_uri;
|
||||
if (client.metadata.client_uri) response.client_uri = client.metadata.client_uri;
|
||||
if (client.metadata.policy_uri) response.policy_uri = client.metadata.policy_uri;
|
||||
if (client.metadata.tos_uri) response.tos_uri = client.metadata.tos_uri;
|
||||
if (client.metadata.jwks_uri) response.jwks_uri = client.metadata.jwks_uri;
|
||||
if (client.metadata.jwks) response.jwks = client.metadata.jwks;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Get client configuration error:', error);
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
error_description: 'Failed to retrieve client configuration',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /oauth/register/:clientId
|
||||
* RFC 7591 Client Update Endpoint
|
||||
* Update client configuration
|
||||
*/
|
||||
export const updateClientConfiguration = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Registration access token required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const tokenClientId = verifyRegistrationToken(token);
|
||||
|
||||
if (!tokenClientId || tokenClientId !== clientId) {
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Invalid or expired registration access token',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = findOAuthClientById(clientId);
|
||||
if (!client) {
|
||||
res.status(404).json({
|
||||
error: 'invalid_client',
|
||||
error_description: 'Client not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
redirect_uris,
|
||||
client_name,
|
||||
grant_types,
|
||||
scope,
|
||||
contacts,
|
||||
logo_uri,
|
||||
client_uri,
|
||||
policy_uri,
|
||||
tos_uri,
|
||||
} = req.body;
|
||||
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
|
||||
// Validate redirect URIs if provided
|
||||
if (redirect_uris) {
|
||||
if (!Array.isArray(redirect_uris) || redirect_uris.length === 0) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_redirect_uri',
|
||||
error_description: 'redirect_uris must be a non-empty array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const uri of redirect_uris) {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
if (
|
||||
url.protocol !== 'https:' &&
|
||||
!url.hostname.match(/^(localhost|127\.0\.0\.1|\[::1\])$/)
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_redirect_uri',
|
||||
error_description: `Redirect URI must use HTTPS: ${uri}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_redirect_uri',
|
||||
error_description: `Invalid redirect URI: ${uri}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate grant types if provided
|
||||
if (grant_types) {
|
||||
const allowedGrantTypes = oauthConfig?.dynamicRegistration?.allowedGrantTypes || [
|
||||
'authorization_code',
|
||||
'refresh_token',
|
||||
];
|
||||
for (const grantType of grant_types) {
|
||||
if (!allowedGrantTypes.includes(grantType)) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_client_metadata',
|
||||
error_description: `Grant type not allowed: ${grantType}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate scopes if provided
|
||||
if (scope) {
|
||||
const requestedScopes = scope.split(' ');
|
||||
const allowedScopes = oauthConfig?.allowedScopes || ['read', 'write'];
|
||||
for (const requestedScope of requestedScopes) {
|
||||
if (!allowedScopes.includes(requestedScope)) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_client_metadata',
|
||||
error_description: `Scope not allowed: ${requestedScope}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build updates
|
||||
const updates: Partial<IOAuthClient> = {};
|
||||
if (client_name) updates.name = client_name;
|
||||
if (redirect_uris) updates.redirectUris = redirect_uris;
|
||||
if (grant_types) updates.grants = grant_types;
|
||||
if (scope) updates.scopes = scope.split(' ');
|
||||
|
||||
// Update metadata
|
||||
if (client.metadata || contacts || logo_uri || client_uri || policy_uri || tos_uri) {
|
||||
updates.metadata = {
|
||||
...client.metadata,
|
||||
contacts,
|
||||
logo_uri,
|
||||
client_uri,
|
||||
policy_uri,
|
||||
tos_uri,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedClient = updateOAuthClient(clientId, updates);
|
||||
|
||||
if (!updatedClient) {
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
error_description: 'Failed to update client',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build response
|
||||
const response: any = {
|
||||
client_id: updatedClient.clientId,
|
||||
client_name: updatedClient.name,
|
||||
redirect_uris: updatedClient.redirectUris,
|
||||
grant_types: updatedClient.grants,
|
||||
response_types: updatedClient.metadata?.response_types || ['code'],
|
||||
scope: (updatedClient.scopes || []).join(' '),
|
||||
token_endpoint_auth_method:
|
||||
updatedClient.metadata?.token_endpoint_auth_method || 'client_secret_basic',
|
||||
};
|
||||
|
||||
// Include optional metadata
|
||||
if (updatedClient.metadata) {
|
||||
if (updatedClient.metadata.application_type)
|
||||
response.application_type = updatedClient.metadata.application_type;
|
||||
if (updatedClient.metadata.contacts) response.contacts = updatedClient.metadata.contacts;
|
||||
if (updatedClient.metadata.logo_uri) response.logo_uri = updatedClient.metadata.logo_uri;
|
||||
if (updatedClient.metadata.client_uri)
|
||||
response.client_uri = updatedClient.metadata.client_uri;
|
||||
if (updatedClient.metadata.policy_uri)
|
||||
response.policy_uri = updatedClient.metadata.policy_uri;
|
||||
if (updatedClient.metadata.tos_uri) response.tos_uri = updatedClient.metadata.tos_uri;
|
||||
if (updatedClient.metadata.jwks_uri) response.jwks_uri = updatedClient.metadata.jwks_uri;
|
||||
if (updatedClient.metadata.jwks) response.jwks = updatedClient.metadata.jwks;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Update client configuration error:', error);
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
error_description: 'Failed to update client configuration',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /oauth/register/:clientId
|
||||
* RFC 7591 Client Delete Endpoint
|
||||
* Delete client registration
|
||||
*/
|
||||
export const deleteClientRegistration = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Registration access token required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const tokenClientId = verifyRegistrationToken(token);
|
||||
|
||||
if (!tokenClientId || tokenClientId !== clientId) {
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Invalid or expired registration access token',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = deleteOAuthClient(clientId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({
|
||||
error: 'invalid_client',
|
||||
error_description: 'Client not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up registration token
|
||||
registrationTokens.delete(token);
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Delete client registration error:', error);
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
error_description: 'Failed to delete client registration',
|
||||
});
|
||||
}
|
||||
};
|
||||
525
src/controllers/oauthServerController.ts
Normal file
525
src/controllers/oauthServerController.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
import { Request, Response } from 'express';
|
||||
import {
|
||||
getOAuthServer,
|
||||
handleTokenRequest,
|
||||
handleAuthenticateRequest,
|
||||
} from '../services/oauthServerService.js';
|
||||
import { findOAuthClientById } from '../models/OAuth.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import OAuth2Server from '@node-oauth/oauth2-server';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { JWT_SECRET } from '../config/jwt.js';
|
||||
|
||||
const { Request: OAuth2Request, Response: OAuth2Response } = OAuth2Server;
|
||||
|
||||
type AuthenticatedUser = {
|
||||
username: string;
|
||||
isAdmin?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempt to attach a user to the request based on a JWT token present in header, query, or body.
|
||||
*/
|
||||
function resolveUserFromRequest(req: Request): AuthenticatedUser | null {
|
||||
const headerToken = req.header('x-auth-token');
|
||||
const queryToken = typeof req.query.token === 'string' ? req.query.token : undefined;
|
||||
const bodyToken =
|
||||
req.body && typeof (req.body as Record<string, unknown>).token === 'string'
|
||||
? ((req.body as Record<string, string>).token as string)
|
||||
: undefined;
|
||||
const token = headerToken || queryToken || bodyToken;
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { user?: AuthenticatedUser };
|
||||
if (decoded?.user) {
|
||||
return decoded.user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Invalid JWT supplied to OAuth authorize endpoint:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to escape HTML
|
||||
*/
|
||||
function escapeHtml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to validate query parameters
|
||||
*/
|
||||
function validateQueryParam(value: any, name: string, pattern?: RegExp): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${name} must be a string`);
|
||||
}
|
||||
if (pattern && !pattern.test(value)) {
|
||||
throw new Error(`${name} has invalid format`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth authorization consent HTML page with i18n support
|
||||
* (keeps visual style consistent with OAuth callback pages)
|
||||
*/
|
||||
const generateAuthorizeHtml = (
|
||||
title: string,
|
||||
message: string,
|
||||
options: {
|
||||
clientName: string;
|
||||
scopes: { name: string; description: string }[];
|
||||
approveLabel: string;
|
||||
denyLabel: string;
|
||||
approveButtonLabel: string;
|
||||
denyButtonLabel: string;
|
||||
formFields: string;
|
||||
},
|
||||
): string => {
|
||||
const backgroundColor = '#eef5ff';
|
||||
const borderColor = '#c3d4ff';
|
||||
const titleColor = '#23408f';
|
||||
const approveColor = '#2563eb';
|
||||
const denyColor = '#ef4444';
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 640px; margin: 40px auto; padding: 24px; background: #f3f4f6; }
|
||||
.container { background-color: ${backgroundColor}; border: 1px solid ${borderColor}; padding: 24px 28px; border-radius: 12px; box-shadow: 0 10px 25px rgba(15, 23, 42, 0.12); }
|
||||
h1 { color: ${titleColor}; margin-top: 0; font-size: 22px; display: flex; align-items: center; gap: 8px; }
|
||||
h1 span.icon { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 999px; background: white; border: 1px solid ${borderColor}; font-size: 16px; }
|
||||
p.subtitle { margin-top: 8px; margin-bottom: 20px; color: #4b5563; font-size: 14px; }
|
||||
.client-box { margin: 16px 0 20px; padding: 14px 16px; background: #eef2ff; border-radius: 10px; border: 1px solid #e5e7eb; display: flex; flex-direction: column; gap: 4px; }
|
||||
.client-box-label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #6b7280; }
|
||||
.client-box-name { font-weight: 600; color: #111827; }
|
||||
.scopes { margin: 22px 0 16px; }
|
||||
.scopes-title { font-size: 13px; font-weight: 600; color: #374151; margin-bottom: 8px; }
|
||||
.scope-item { padding: 8px 0; border-bottom: 1px solid #e5e7eb; display: flex; flex-direction: column; gap: 2px; }
|
||||
.scope-item:last-child { border-bottom: none; }
|
||||
.scope-name { font-weight: 600; font-size: 13px; color: #111827; }
|
||||
.scope-description { font-size: 12px; color: #4b5563; }
|
||||
.buttons { margin-top: 26px; display: flex; gap: 12px; }
|
||||
.buttons form { flex: 1; }
|
||||
button { width: 100%; padding: 10px 14px; border-radius: 999px; cursor: pointer; font-size: 14px; font-weight: 500; border-width: 1px; border-style: solid; transition: background-color 120ms ease, box-shadow 120ms ease, transform 60ms ease; }
|
||||
button.approve { background: ${approveColor}; color: white; border-color: ${approveColor}; box-shadow: 0 4px 12px rgba(37, 99, 235, 0.35); }
|
||||
button.approve:hover { background: #1d4ed8; box-shadow: 0 6px 16px rgba(37, 99, 235, 0.45); transform: translateY(-1px); }
|
||||
button.deny { background: white; color: ${denyColor}; border-color: ${denyColor}; }
|
||||
button.deny:hover { background: #fef2f2; }
|
||||
.button-label { display: block; }
|
||||
.button-sub { display: block; font-size: 11px; opacity: 0.85; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1><span class="icon">🔐</span>${escapeHtml(title)}</h1>
|
||||
<p class="subtitle">${escapeHtml(message)}</p>
|
||||
<div class="client-box">
|
||||
<span class="client-box-label">${escapeHtml(options.clientName ? 'Application' : 'Client')}</span>
|
||||
<span class="client-box-name">${escapeHtml(options.clientName || '')}</span>
|
||||
</div>
|
||||
<div class="scopes">
|
||||
<div class="scopes-title">${escapeHtml('This application will be able to:')}</div>
|
||||
${options.scopes
|
||||
.map(
|
||||
(s) => `
|
||||
<div class="scope-item">
|
||||
<span class="scope-name">${escapeHtml(s.name)}</span>
|
||||
<span class="scope-description">${escapeHtml(s.description)}</span>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<form method="POST" action="/oauth/authorize">
|
||||
${options.formFields}
|
||||
<input type="hidden" name="allow" value="true" />
|
||||
<button type="submit" class="approve">
|
||||
<span class="button-label">${escapeHtml(options.approveLabel)}</span>
|
||||
<span class="button-sub">${escapeHtml(options.approveButtonLabel)}</span>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/oauth/authorize">
|
||||
${options.formFields}
|
||||
<input type="hidden" name="allow" value="false" />
|
||||
<button type="submit" class="deny">
|
||||
<span class="button-label">${escapeHtml(options.denyLabel)}</span>
|
||||
<span class="button-sub">${escapeHtml(options.denyButtonLabel)}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /oauth/authorize
|
||||
* Display authorization page or handle authorization
|
||||
*/
|
||||
export const getAuthorize = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const oauth = getOAuthServer();
|
||||
if (!oauth) {
|
||||
res.status(503).json({ error: 'OAuth server not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get and validate query parameters
|
||||
const client_id = validateQueryParam(req.query.client_id, 'client_id', /^[a-zA-Z0-9_-]+$/);
|
||||
const redirect_uri = validateQueryParam(req.query.redirect_uri, 'redirect_uri');
|
||||
const response_type = validateQueryParam(req.query.response_type, 'response_type', /^code$/);
|
||||
const scope = req.query.scope
|
||||
? validateQueryParam(req.query.scope, 'scope', /^[a-zA-Z0-9_ ]+$/)
|
||||
: undefined;
|
||||
const state = req.query.state
|
||||
? validateQueryParam(req.query.state, 'state', /^[a-zA-Z0-9_-]+$/)
|
||||
: undefined;
|
||||
const code_challenge = req.query.code_challenge
|
||||
? validateQueryParam(req.query.code_challenge, 'code_challenge', /^[a-zA-Z0-9_-]+$/)
|
||||
: undefined;
|
||||
const code_challenge_method = req.query.code_challenge_method
|
||||
? validateQueryParam(
|
||||
req.query.code_challenge_method,
|
||||
'code_challenge_method',
|
||||
/^(S256|plain)$/,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Validate required parameters
|
||||
if (!client_id || !redirect_uri || !response_type) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: 'invalid_request', error_description: 'Missing required parameters' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify client
|
||||
const client = findOAuthClientById(client_id as string);
|
||||
if (!client) {
|
||||
res.status(400).json({ error: 'invalid_client', error_description: 'Client not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify redirect URI
|
||||
if (!client.redirectUris.includes(redirect_uri as string)) {
|
||||
res.status(400).json({ error: 'invalid_request', error_description: 'Invalid redirect_uri' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is authenticated (including via JWT token)
|
||||
let user = (req as any).user;
|
||||
if (!user) {
|
||||
const tokenUser = resolveUserFromRequest(req);
|
||||
if (tokenUser) {
|
||||
(req as any).user = tokenUser;
|
||||
user = tokenUser;
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
// Redirect to login page with return URL
|
||||
const returnUrl = encodeURIComponent(req.originalUrl);
|
||||
res.redirect(`/login?returnUrl=${returnUrl}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestToken = typeof req.query.token === 'string' ? req.query.token : '';
|
||||
const tokenField = requestToken
|
||||
? `<input type="hidden" name="token" value="${escapeHtml(requestToken)}">`
|
||||
: '';
|
||||
|
||||
// Get translation function from request (set by i18n middleware)
|
||||
const t = (req as any).t || ((key: string) => key);
|
||||
|
||||
const scopes = (scope || 'read write')
|
||||
.split(' ')
|
||||
.filter((s) => s)
|
||||
.map((s) => ({ name: s, description: getScopeDescription(s) }));
|
||||
|
||||
const formFields = `
|
||||
<input type="hidden" name="client_id" value="${escapeHtml(client_id)}" />
|
||||
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}" />
|
||||
<input type="hidden" name="response_type" value="${escapeHtml(response_type)}" />
|
||||
<input type="hidden" name="scope" value="${escapeHtml(scope || '')}" />
|
||||
<input type="hidden" name="state" value="${escapeHtml(state || '')}" />
|
||||
${code_challenge ? `<input type="hidden" name="code_challenge" value="${escapeHtml(code_challenge)}" />` : ''}
|
||||
${code_challenge_method ? `<input type="hidden" name="code_challenge_method" value="${escapeHtml(code_challenge_method)}" />` : ''}
|
||||
${tokenField}
|
||||
`;
|
||||
|
||||
// Render authorization consent page with consistent, localized styling
|
||||
res.send(
|
||||
generateAuthorizeHtml(
|
||||
t('oauthServer.authorizeTitle') || 'Authorize Application',
|
||||
t('oauthServer.authorizeSubtitle') ||
|
||||
'Allow this application to access your MCPHub account.',
|
||||
{
|
||||
clientName: client.name,
|
||||
scopes,
|
||||
approveLabel: t('oauthServer.buttons.approve') || 'Allow access',
|
||||
denyLabel: t('oauthServer.buttons.deny') || 'Deny',
|
||||
approveButtonLabel:
|
||||
t('oauthServer.buttons.approveSubtitle') ||
|
||||
'Recommended if you trust this application.',
|
||||
denyButtonLabel:
|
||||
t('oauthServer.buttons.denySubtitle') || 'You can always grant access later.',
|
||||
formFields,
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Authorization error:', error);
|
||||
res.status(500).json({ error: 'server_error', error_description: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /oauth/authorize
|
||||
* Handle authorization decision
|
||||
*/
|
||||
export const postAuthorize = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const oauth = getOAuthServer();
|
||||
if (!oauth) {
|
||||
res.status(503).json({ error: 'OAuth server not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { allow, redirect_uri, state } = req.body;
|
||||
|
||||
// If user denied
|
||||
if (allow !== 'true') {
|
||||
const redirectUrl = new URL(redirect_uri);
|
||||
redirectUrl.searchParams.set('error', 'access_denied');
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
res.redirect(redirectUrl.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
// Get authenticated user (JWT support for browser form submissions)
|
||||
let user = (req as any).user;
|
||||
if (!user) {
|
||||
const tokenUser = resolveUserFromRequest(req);
|
||||
if (tokenUser) {
|
||||
(req as any).user = tokenUser;
|
||||
user = tokenUser;
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'unauthorized', error_description: 'User not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create OAuth request/response
|
||||
const request = new OAuth2Request(req);
|
||||
const response = new OAuth2Response(res);
|
||||
|
||||
// Authorize the request
|
||||
const code = await oauth.authorize(request, response, {
|
||||
authenticateHandler: {
|
||||
handle: async () => user,
|
||||
},
|
||||
});
|
||||
|
||||
// Build redirect URL with authorization code
|
||||
const redirectUrl = new URL(redirect_uri);
|
||||
redirectUrl.searchParams.set('code', code.authorizationCode);
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
|
||||
res.redirect(redirectUrl.toString());
|
||||
} catch (error) {
|
||||
console.error('Authorization error:', error);
|
||||
|
||||
// Handle OAuth errors
|
||||
if (error instanceof Error && 'code' in error) {
|
||||
const oauthError = error as any;
|
||||
const redirect_uri = req.body.redirect_uri;
|
||||
const state = req.body.state;
|
||||
|
||||
if (redirect_uri) {
|
||||
const redirectUrl = new URL(redirect_uri);
|
||||
redirectUrl.searchParams.set('error', oauthError.name || 'server_error');
|
||||
if (oauthError.message) {
|
||||
redirectUrl.searchParams.set('error_description', oauthError.message);
|
||||
}
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
res.redirect(redirectUrl.toString());
|
||||
} else {
|
||||
res.status(400).json({
|
||||
error: oauthError.name || 'server_error',
|
||||
error_description: oauthError.message || 'Internal server error',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
res.status(500).json({ error: 'server_error', error_description: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /oauth/token
|
||||
* Exchange authorization code for access token
|
||||
*/
|
||||
export const postToken = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const token = await handleTokenRequest(req, res);
|
||||
res.json({
|
||||
access_token: token.accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: Math.floor(((token.accessTokenExpiresAt?.getTime() || 0) - Date.now()) / 1000),
|
||||
refresh_token: token.refreshToken,
|
||||
scope: Array.isArray(token.scope) ? token.scope.join(' ') : token.scope,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Token error:', error);
|
||||
|
||||
if (error instanceof Error && 'code' in error) {
|
||||
const oauthError = error as any;
|
||||
res.status(oauthError.code || 400).json({
|
||||
error: oauthError.name || 'invalid_request',
|
||||
error_description: oauthError.message || 'Token request failed',
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
error: 'invalid_request',
|
||||
error_description: 'Token request failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /oauth/userinfo
|
||||
* Get user info from access token (OpenID Connect compatible)
|
||||
*/
|
||||
export const getUserInfo = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const token = await handleAuthenticateRequest(req, res);
|
||||
|
||||
res.json({
|
||||
sub: token.user.username,
|
||||
username: token.user.username,
|
||||
// Add more user info as needed
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('UserInfo error:', error);
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Invalid or expired access token',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /.well-known/oauth-authorization-server
|
||||
* OAuth 2.0 Authorization Server Metadata (RFC 8414)
|
||||
*/
|
||||
export const getMetadata = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
|
||||
if (!oauthConfig || !oauthConfig.enabled) {
|
||||
res.status(404).json({ error: 'OAuth server not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
settings.systemConfig?.install?.baseUrl || `${req.protocol}://${req.get('host')}`;
|
||||
const allowedScopes = oauthConfig.allowedScopes || ['read', 'write'];
|
||||
|
||||
const metadata: any = {
|
||||
issuer: baseUrl,
|
||||
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
||||
token_endpoint: `${baseUrl}/oauth/token`,
|
||||
userinfo_endpoint: `${baseUrl}/oauth/userinfo`,
|
||||
scopes_supported: allowedScopes,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
token_endpoint_auth_methods_supported:
|
||||
oauthConfig.requireClientSecret !== false
|
||||
? ['client_secret_basic', 'client_secret_post', 'none']
|
||||
: ['none'],
|
||||
code_challenge_methods_supported: ['S256', 'plain'],
|
||||
};
|
||||
|
||||
// Add dynamic registration endpoint if enabled
|
||||
if (oauthConfig.dynamicRegistration?.enabled) {
|
||||
metadata.registration_endpoint = `${baseUrl}/oauth/register`;
|
||||
}
|
||||
|
||||
res.json(metadata);
|
||||
} catch (error) {
|
||||
console.error('Metadata error:', error);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /.well-known/oauth-protected-resource
|
||||
* OAuth 2.0 Protected Resource Metadata (RFC 9728)
|
||||
* Provides information about authorization servers that protect this resource
|
||||
*/
|
||||
export const getProtectedResourceMetadata = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
|
||||
if (!oauthConfig || !oauthConfig.enabled) {
|
||||
res.status(404).json({ error: 'OAuth server not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
settings.systemConfig?.install?.baseUrl || `${req.protocol}://${req.get('host')}`;
|
||||
const allowedScopes = oauthConfig.allowedScopes || ['read', 'write'];
|
||||
|
||||
// Return protected resource metadata according to RFC 9728
|
||||
res.json({
|
||||
resource: baseUrl,
|
||||
authorization_servers: [baseUrl],
|
||||
scopes_supported: allowedScopes,
|
||||
bearer_methods_supported: ['header'],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Protected resource metadata error:', error);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to get scope description
|
||||
*/
|
||||
function getScopeDescription(scope: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
read: 'Read access to your MCP servers and tools',
|
||||
write: 'Execute tools and modify MCP server configurations',
|
||||
admin: 'Administrative access to all resources',
|
||||
};
|
||||
return descriptions[scope] || 'Access to MCPHub resources';
|
||||
}
|
||||
@@ -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);
|
||||
let toolName = decodeURIComponent(req.params.toolName);
|
||||
|
||||
// Import handleCallToolRequest function
|
||||
const { handleCallToolRequest } = await import('../services/mcpService.js');
|
||||
@@ -182,14 +115,17 @@ export const executeToolViaOpenAPI = async (req: Request, res: Response): Promis
|
||||
const tool = serverInfo.tools.find(
|
||||
(t: any) => t.name === fullToolName || t.name === toolName,
|
||||
);
|
||||
if (tool && tool.inputSchema) {
|
||||
inputSchema = tool.inputSchema as Record<string, any>;
|
||||
if (tool) {
|
||||
toolName = tool.name; // Use the matched tool's actual name (with server prefix if applicable) for the subsequent call to handleCallToolRequest.
|
||||
if (tool.inputSchema) {
|
||||
inputSchema = tool.inputSchema as Record<string, any>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
169
src/controllers/registryController.ts
Normal file
169
src/controllers/registryController.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
|
||||
const REGISTRY_BASE_URL = 'https://registry.modelcontextprotocol.io/v0.1';
|
||||
|
||||
/**
|
||||
* Get all MCP servers from the official registry
|
||||
* Proxies the request to avoid CORS issues in the frontend
|
||||
* Supports cursor-based pagination
|
||||
*/
|
||||
export const getAllRegistryServers = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { cursor, limit, search } = req.query;
|
||||
|
||||
// Build URL with query parameters
|
||||
const url = new URL(`${REGISTRY_BASE_URL}/servers`);
|
||||
if (cursor && typeof cursor === 'string') {
|
||||
url.searchParams.append('cursor', cursor);
|
||||
}
|
||||
if (limit && typeof limit === 'string') {
|
||||
const limitNum = parseInt(limit, 10);
|
||||
if (!isNaN(limitNum) && limitNum > 0) {
|
||||
url.searchParams.append('limit', limit);
|
||||
}
|
||||
}
|
||||
if (search && typeof search === 'string') {
|
||||
url.searchParams.append('search', search);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Accept: 'application/json, application/problem+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const apiResponse: ApiResponse<typeof data> = {
|
||||
success: true,
|
||||
data: data,
|
||||
};
|
||||
|
||||
res.json(apiResponse);
|
||||
} catch (error) {
|
||||
console.error('Error fetching registry servers:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to fetch registry servers';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all versions of a specific MCP server
|
||||
* Proxies the request to avoid CORS issues in the frontend
|
||||
*/
|
||||
export const getRegistryServerVersions = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName } = req.params;
|
||||
|
||||
if (!serverName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// URL encode the server name
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const response = await fetch(`${REGISTRY_BASE_URL}/servers/${encodedName}/versions`, {
|
||||
headers: {
|
||||
Accept: 'application/json, application/problem+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const apiResponse: ApiResponse<typeof data> = {
|
||||
success: true,
|
||||
data: data,
|
||||
};
|
||||
|
||||
res.json(apiResponse);
|
||||
} catch (error) {
|
||||
console.error('Error fetching registry server versions:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to fetch registry server versions';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a specific version of an MCP server
|
||||
* Proxies the request to avoid CORS issues in the frontend
|
||||
*/
|
||||
export const getRegistryServerVersion = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, version } = req.params;
|
||||
|
||||
if (!serverName || !version) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name and version are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// URL encode the server name and version
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const encodedVersion = encodeURIComponent(version);
|
||||
const response = await fetch(
|
||||
`${REGISTRY_BASE_URL}/servers/${encodedName}/versions/${encodedVersion}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json, application/problem+json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server version not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const apiResponse: ApiResponse<typeof data> = {
|
||||
success: true,
|
||||
data: data,
|
||||
};
|
||||
|
||||
res.json(apiResponse);
|
||||
} catch (error) {
|
||||
console.error('Error fetching registry server version:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to fetch registry server version';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
|
||||
import { createSafeJSON } from '../utils/serialization.js';
|
||||
import { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js';
|
||||
|
||||
export const getAllServers = async (_: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -375,7 +376,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 +440,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) {
|
||||
@@ -504,32 +509,64 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
|
||||
|
||||
export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { routing, install, smartRouting, mcpRouter, nameSeparator } = req.body;
|
||||
const { routing, install, smartRouting, mcpRouter, nameSeparator, enableSessionRebuild, oauthServer } = req.body;
|
||||
const currentUser = (req as any).user;
|
||||
|
||||
const hasRoutingUpdate =
|
||||
routing &&
|
||||
(typeof routing.enableGlobalRoute === 'boolean' ||
|
||||
typeof routing.enableGroupNameRoute === 'boolean' ||
|
||||
typeof routing.enableBearerAuth === 'boolean' ||
|
||||
typeof routing.bearerAuthKey === 'string' ||
|
||||
typeof routing.skipAuth === 'boolean');
|
||||
|
||||
const hasInstallUpdate =
|
||||
install &&
|
||||
(typeof install.pythonIndexUrl === 'string' ||
|
||||
typeof install.npmRegistry === 'string' ||
|
||||
typeof install.baseUrl === 'string');
|
||||
|
||||
const hasSmartRoutingUpdate =
|
||||
smartRouting &&
|
||||
(typeof smartRouting.enabled === 'boolean' ||
|
||||
typeof smartRouting.dbUrl === 'string' ||
|
||||
typeof smartRouting.openaiApiBaseUrl === 'string' ||
|
||||
typeof smartRouting.openaiApiKey === 'string' ||
|
||||
typeof smartRouting.openaiApiEmbeddingModel === 'string');
|
||||
|
||||
const hasMcpRouterUpdate =
|
||||
mcpRouter &&
|
||||
(typeof mcpRouter.apiKey === 'string' ||
|
||||
typeof mcpRouter.referer === 'string' ||
|
||||
typeof mcpRouter.title === 'string' ||
|
||||
typeof mcpRouter.baseUrl === 'string');
|
||||
|
||||
const hasNameSeparatorUpdate = typeof nameSeparator === 'string';
|
||||
|
||||
const hasSessionRebuildUpdate = typeof enableSessionRebuild !== 'boolean';
|
||||
|
||||
const hasOAuthServerUpdate =
|
||||
oauthServer &&
|
||||
(typeof oauthServer.enabled === 'boolean' ||
|
||||
typeof oauthServer.accessTokenLifetime === 'number' ||
|
||||
typeof oauthServer.refreshTokenLifetime === 'number' ||
|
||||
typeof oauthServer.authorizationCodeLifetime === 'number' ||
|
||||
typeof oauthServer.requireClientSecret === 'boolean' ||
|
||||
typeof oauthServer.requireState === 'boolean' ||
|
||||
Array.isArray(oauthServer.allowedScopes) ||
|
||||
(oauthServer.dynamicRegistration &&
|
||||
(typeof oauthServer.dynamicRegistration.enabled === 'boolean' ||
|
||||
typeof oauthServer.dynamicRegistration.requiresAuthentication === 'boolean' ||
|
||||
Array.isArray(oauthServer.dynamicRegistration.allowedGrantTypes))));
|
||||
|
||||
if (
|
||||
(!routing ||
|
||||
(typeof routing.enableGlobalRoute !== 'boolean' &&
|
||||
typeof routing.enableGroupNameRoute !== 'boolean' &&
|
||||
typeof routing.enableBearerAuth !== 'boolean' &&
|
||||
typeof routing.bearerAuthKey !== 'string' &&
|
||||
typeof routing.skipAuth !== 'boolean')) &&
|
||||
(!install ||
|
||||
(typeof install.pythonIndexUrl !== 'string' &&
|
||||
typeof install.npmRegistry !== 'string' &&
|
||||
typeof install.baseUrl !== 'string')) &&
|
||||
(!smartRouting ||
|
||||
(typeof smartRouting.enabled !== 'boolean' &&
|
||||
typeof smartRouting.dbUrl !== 'string' &&
|
||||
typeof smartRouting.openaiApiBaseUrl !== 'string' &&
|
||||
typeof smartRouting.openaiApiKey !== 'string' &&
|
||||
typeof smartRouting.openaiApiEmbeddingModel !== 'string')) &&
|
||||
(!mcpRouter ||
|
||||
(typeof mcpRouter.apiKey !== 'string' &&
|
||||
typeof mcpRouter.referer !== 'string' &&
|
||||
typeof mcpRouter.title !== 'string' &&
|
||||
typeof mcpRouter.baseUrl !== 'string')) &&
|
||||
(typeof nameSeparator !== 'string')
|
||||
!hasRoutingUpdate &&
|
||||
!hasInstallUpdate &&
|
||||
!hasSmartRoutingUpdate &&
|
||||
!hasMcpRouterUpdate &&
|
||||
!hasNameSeparatorUpdate &&
|
||||
!hasSessionRebuildUpdate &&
|
||||
!hasOAuthServerUpdate
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -566,6 +603,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
},
|
||||
oauthServer: cloneDefaultOAuthServerConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -606,6 +644,28 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.oauthServer) {
|
||||
settings.systemConfig.oauthServer = cloneDefaultOAuthServerConfig();
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.oauthServer.dynamicRegistration) {
|
||||
const defaultConfig = cloneDefaultOAuthServerConfig();
|
||||
const defaultDynamic = defaultConfig.dynamicRegistration ?? {
|
||||
enabled: false,
|
||||
allowedGrantTypes: [],
|
||||
requiresAuthentication: false,
|
||||
};
|
||||
settings.systemConfig.oauthServer.dynamicRegistration = {
|
||||
enabled: defaultDynamic.enabled ?? false,
|
||||
allowedGrantTypes: [
|
||||
...(Array.isArray(defaultDynamic.allowedGrantTypes)
|
||||
? defaultDynamic.allowedGrantTypes
|
||||
: []),
|
||||
],
|
||||
requiresAuthentication: defaultDynamic.requiresAuthentication ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
if (routing) {
|
||||
if (typeof routing.enableGlobalRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
||||
@@ -711,10 +771,68 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
}
|
||||
}
|
||||
|
||||
if (oauthServer) {
|
||||
const target = settings.systemConfig.oauthServer;
|
||||
if (typeof oauthServer.enabled === 'boolean') {
|
||||
target.enabled = oauthServer.enabled;
|
||||
}
|
||||
if (typeof oauthServer.accessTokenLifetime === 'number') {
|
||||
target.accessTokenLifetime = oauthServer.accessTokenLifetime;
|
||||
}
|
||||
if (typeof oauthServer.refreshTokenLifetime === 'number') {
|
||||
target.refreshTokenLifetime = oauthServer.refreshTokenLifetime;
|
||||
}
|
||||
if (typeof oauthServer.authorizationCodeLifetime === 'number') {
|
||||
target.authorizationCodeLifetime = oauthServer.authorizationCodeLifetime;
|
||||
}
|
||||
if (typeof oauthServer.requireClientSecret === 'boolean') {
|
||||
target.requireClientSecret = oauthServer.requireClientSecret;
|
||||
}
|
||||
if (typeof oauthServer.requireState === 'boolean') {
|
||||
target.requireState = oauthServer.requireState;
|
||||
}
|
||||
if (Array.isArray(oauthServer.allowedScopes)) {
|
||||
target.allowedScopes = oauthServer.allowedScopes
|
||||
.filter((scope: any): scope is string => typeof scope === 'string')
|
||||
.map((scope: string) => scope.trim())
|
||||
.filter((scope: string) => scope.length > 0);
|
||||
}
|
||||
|
||||
if (oauthServer.dynamicRegistration) {
|
||||
const dynamicTarget = target.dynamicRegistration || {
|
||||
enabled: false,
|
||||
allowedGrantTypes: ['authorization_code', 'refresh_token'],
|
||||
requiresAuthentication: false,
|
||||
};
|
||||
|
||||
if (typeof oauthServer.dynamicRegistration.enabled === 'boolean') {
|
||||
dynamicTarget.enabled = oauthServer.dynamicRegistration.enabled;
|
||||
}
|
||||
|
||||
if (Array.isArray(oauthServer.dynamicRegistration.allowedGrantTypes)) {
|
||||
dynamicTarget.allowedGrantTypes = oauthServer.dynamicRegistration.allowedGrantTypes
|
||||
.filter((grant: any): grant is string => typeof grant === 'string')
|
||||
.map((grant: string) => grant.trim())
|
||||
.filter((grant: string) => grant.length > 0);
|
||||
}
|
||||
|
||||
if (typeof oauthServer.dynamicRegistration.requiresAuthentication === 'boolean') {
|
||||
dynamicTarget.requiresAuthentication =
|
||||
oauthServer.dynamicRegistration.requiresAuthentication;
|
||||
}
|
||||
|
||||
target.dynamicRegistration = dynamicTarget;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof nameSeparator === 'string') {
|
||||
settings.systemConfig.nameSeparator = nameSeparator;
|
||||
}
|
||||
|
||||
if (typeof enableSessionRebuild === 'boolean') {
|
||||
settings.systemConfig.enableSessionRebuild = enableSessionRebuild;
|
||||
}
|
||||
|
||||
if (saveSettings(settings, currentUser)) {
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -747,7 +865,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 +929,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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -3,6 +3,8 @@ import jwt from 'jsonwebtoken';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import defaultConfig from '../config/index.js';
|
||||
import { JWT_SECRET } from '../config/jwt.js';
|
||||
import { getToken } from '../models/OAuth.js';
|
||||
import { isOAuthServerEnabled } from '../services/oauthServerService.js';
|
||||
|
||||
const validateBearerAuth = (req: Request, routingConfig: any): boolean => {
|
||||
if (!routingConfig.enableBearerAuth) {
|
||||
@@ -34,7 +36,7 @@ const checkReadonly = (req: Request): boolean => {
|
||||
};
|
||||
|
||||
// Middleware to authenticate JWT token
|
||||
export const auth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
export const auth = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
const t = (req as any).t;
|
||||
if (!checkReadonly(req)) {
|
||||
res.status(403).json({ success: false, message: t('api.errors.readonly') });
|
||||
@@ -61,6 +63,28 @@ export const auth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for OAuth access token in Authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ') && isOAuthServerEnabled()) {
|
||||
const accessToken = authHeader.substring(7);
|
||||
const oauthToken = getToken(accessToken);
|
||||
|
||||
if (oauthToken && oauthToken.accessToken === accessToken) {
|
||||
// Valid OAuth token - look up user to get admin status
|
||||
const { findUserByUsername } = await import('../models/User.js');
|
||||
const user = findUserByUsername(oauthToken.username);
|
||||
|
||||
// Set user context with proper admin status
|
||||
(req as any).user = {
|
||||
username: oauthToken.username,
|
||||
isAdmin: user?.isAdmin || false,
|
||||
};
|
||||
(req as any).oauthToken = oauthToken;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get token from header or query parameter
|
||||
const headerToken = req.header('x-auth-token');
|
||||
const queryToken = req.query.token as string;
|
||||
@@ -72,7 +96,7 @@ export const auth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify token
|
||||
// Verify JWT token
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { UserContextService } from '../services/userContextService.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
import { resolveOAuthUserFromAuthHeader } from '../utils/oauthBearer.js';
|
||||
|
||||
/**
|
||||
* User context middleware
|
||||
@@ -45,6 +46,18 @@ export const sseUserContextMiddleware = async (
|
||||
try {
|
||||
const userContextService = UserContextService.getInstance();
|
||||
const username = req.params.user;
|
||||
let cleanedUp = false;
|
||||
const cleanup = () => {
|
||||
if (cleanedUp) {
|
||||
return;
|
||||
}
|
||||
cleanedUp = true;
|
||||
userContextService.clearCurrentUser();
|
||||
};
|
||||
const attachCleanupHandlers = () => {
|
||||
res.on('finish', cleanup);
|
||||
res.on('close', cleanup);
|
||||
};
|
||||
|
||||
if (username) {
|
||||
// For user-scoped routes, set the user context
|
||||
@@ -57,22 +70,22 @@ export const sseUserContextMiddleware = async (
|
||||
};
|
||||
|
||||
userContextService.setCurrentUser(user);
|
||||
|
||||
// Clean up user context when response ends
|
||||
res.on('finish', () => {
|
||||
userContextService.clearCurrentUser();
|
||||
});
|
||||
|
||||
// Also clean up on connection close for SSE
|
||||
res.on('close', () => {
|
||||
userContextService.clearCurrentUser();
|
||||
});
|
||||
|
||||
attachCleanupHandlers();
|
||||
console.log(`User context set for SSE/MCP endpoint: ${username}`);
|
||||
} else {
|
||||
// For global routes, clear user context (admin access)
|
||||
userContextService.clearCurrentUser();
|
||||
console.log('Global SSE/MCP endpoint access - no user context');
|
||||
const rawAuthHeader = Array.isArray(req.headers.authorization)
|
||||
? req.headers.authorization[0]
|
||||
: req.headers.authorization;
|
||||
const bearerUser = resolveOAuthUserFromAuthHeader(rawAuthHeader);
|
||||
|
||||
if (bearerUser) {
|
||||
userContextService.setCurrentUser(bearerUser);
|
||||
attachCleanupHandlers();
|
||||
console.log(`OAuth user context set for SSE/MCP endpoint: ${bearerUser.username}`);
|
||||
} else {
|
||||
cleanup();
|
||||
console.log('Global SSE/MCP endpoint access - no user context');
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
347
src/models/OAuth.ts
Normal file
347
src/models/OAuth.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import crypto from 'crypto';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { IOAuthClient, IOAuthAuthorizationCode, IOAuthToken } from '../types/index.js';
|
||||
|
||||
// In-memory storage for authorization codes and tokens
|
||||
// Authorization codes are short-lived and kept in memory only.
|
||||
// Tokens are mirrored to settings (mcp_settings.json) for persistence.
|
||||
const authorizationCodes = new Map<string, IOAuthAuthorizationCode>();
|
||||
const tokens = new Map<string, IOAuthToken>();
|
||||
|
||||
// Initialize token store from settings on first import
|
||||
(() => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (Array.isArray(settings.oauthTokens)) {
|
||||
for (const stored of settings.oauthTokens) {
|
||||
const token: IOAuthToken = {
|
||||
...stored,
|
||||
accessTokenExpiresAt: new Date(stored.accessTokenExpiresAt),
|
||||
refreshTokenExpiresAt: stored.refreshTokenExpiresAt
|
||||
? new Date(stored.refreshTokenExpiresAt)
|
||||
: undefined,
|
||||
};
|
||||
tokens.set(token.accessToken, token);
|
||||
if (token.refreshToken) {
|
||||
tokens.set(token.refreshToken, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize OAuth tokens from settings:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* Get all OAuth clients from configuration
|
||||
*/
|
||||
export const getOAuthClients = (): IOAuthClient[] => {
|
||||
const settings = loadSettings();
|
||||
return settings.oauthClients || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Find OAuth client by client ID
|
||||
*/
|
||||
export const findOAuthClientById = (clientId: string): IOAuthClient | undefined => {
|
||||
const clients = getOAuthClients();
|
||||
return clients.find((c) => c.clientId === clientId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new OAuth client
|
||||
*/
|
||||
export const createOAuthClient = (client: IOAuthClient): IOAuthClient => {
|
||||
const settings = loadSettings();
|
||||
if (!settings.oauthClients) {
|
||||
settings.oauthClients = [];
|
||||
}
|
||||
|
||||
// Check if client already exists
|
||||
const existing = settings.oauthClients.find((c) => c.clientId === client.clientId);
|
||||
if (existing) {
|
||||
throw new Error(`OAuth client with ID ${client.clientId} already exists`);
|
||||
}
|
||||
|
||||
settings.oauthClients.push(client);
|
||||
saveSettings(settings);
|
||||
return client;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing OAuth client
|
||||
*/
|
||||
export const updateOAuthClient = (
|
||||
clientId: string,
|
||||
updates: Partial<IOAuthClient>,
|
||||
): IOAuthClient | null => {
|
||||
const settings = loadSettings();
|
||||
if (!settings.oauthClients) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const index = settings.oauthClients.findIndex((c) => c.clientId === clientId);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
settings.oauthClients[index] = { ...settings.oauthClients[index], ...updates };
|
||||
saveSettings(settings);
|
||||
return settings.oauthClients[index];
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an OAuth client
|
||||
*/
|
||||
export const deleteOAuthClient = (clientId: string): boolean => {
|
||||
const settings = loadSettings();
|
||||
if (!settings.oauthClients) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = settings.oauthClients.findIndex((c) => c.clientId === clientId);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
settings.oauthClients.splice(index, 1);
|
||||
saveSettings(settings);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a secure random token
|
||||
*/
|
||||
const generateToken = (length: number = 32): string => {
|
||||
return crypto.randomBytes(length).toString('hex');
|
||||
};
|
||||
|
||||
/**
|
||||
* Save authorization code
|
||||
*/
|
||||
export const saveAuthorizationCode = (
|
||||
code: Omit<IOAuthAuthorizationCode, 'code' | 'expiresAt'>,
|
||||
expiresIn: number = 300,
|
||||
): string => {
|
||||
const authCode = generateToken();
|
||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
authorizationCodes.set(authCode, {
|
||||
code: authCode,
|
||||
expiresAt,
|
||||
...code,
|
||||
});
|
||||
|
||||
return authCode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get authorization code
|
||||
*/
|
||||
export const getAuthorizationCode = (code: string): IOAuthAuthorizationCode | undefined => {
|
||||
const authCode = authorizationCodes.get(code);
|
||||
if (!authCode) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (authCode.expiresAt < new Date()) {
|
||||
authorizationCodes.delete(code);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return authCode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Revoke authorization code
|
||||
*/
|
||||
export const revokeAuthorizationCode = (code: string): void => {
|
||||
authorizationCodes.delete(code);
|
||||
};
|
||||
|
||||
/**
|
||||
* Save access token and optionally refresh token
|
||||
*/
|
||||
export const saveToken = (
|
||||
tokenData: Omit<IOAuthToken, 'accessToken' | 'accessTokenExpiresAt'>,
|
||||
accessTokenLifetime: number = 3600,
|
||||
refreshTokenLifetime?: number,
|
||||
): IOAuthToken => {
|
||||
const accessToken = generateToken();
|
||||
const accessTokenExpiresAt = new Date(Date.now() + accessTokenLifetime * 1000);
|
||||
|
||||
let refreshToken: string | undefined;
|
||||
let refreshTokenExpiresAt: Date | undefined;
|
||||
|
||||
if (refreshTokenLifetime) {
|
||||
refreshToken = generateToken();
|
||||
refreshTokenExpiresAt = new Date(Date.now() + refreshTokenLifetime * 1000);
|
||||
}
|
||||
|
||||
const token: IOAuthToken = {
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
refreshToken,
|
||||
refreshTokenExpiresAt,
|
||||
...tokenData,
|
||||
};
|
||||
|
||||
tokens.set(accessToken, token);
|
||||
if (refreshToken) {
|
||||
tokens.set(refreshToken, token);
|
||||
}
|
||||
|
||||
// Persist tokens to settings
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const existing = settings.oauthTokens || [];
|
||||
const filtered = existing.filter(
|
||||
(t) => t.accessToken !== token.accessToken && t.refreshToken !== token.refreshToken,
|
||||
);
|
||||
const updated = [
|
||||
...filtered,
|
||||
{
|
||||
...token,
|
||||
accessTokenExpiresAt: token.accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt: token.refreshTokenExpiresAt,
|
||||
},
|
||||
];
|
||||
settings.oauthTokens = updated;
|
||||
saveSettings(settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist OAuth token to settings:', error);
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get token by access token or refresh token
|
||||
*/
|
||||
export const getToken = (token: string): IOAuthToken | undefined => {
|
||||
const tokenData = tokens.get(token);
|
||||
if (!tokenData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check if access token is expired
|
||||
if (tokenData.accessToken === token && tokenData.accessTokenExpiresAt < new Date()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check if refresh token is expired
|
||||
if (
|
||||
tokenData.refreshToken === token &&
|
||||
tokenData.refreshTokenExpiresAt &&
|
||||
tokenData.refreshTokenExpiresAt < new Date()
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return tokenData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Revoke token (both access and refresh tokens)
|
||||
*/
|
||||
export const revokeToken = (token: string): void => {
|
||||
const tokenData = tokens.get(token);
|
||||
if (tokenData) {
|
||||
tokens.delete(tokenData.accessToken);
|
||||
if (tokenData.refreshToken) {
|
||||
tokens.delete(tokenData.refreshToken);
|
||||
}
|
||||
|
||||
// Also remove from persisted settings
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (Array.isArray(settings.oauthTokens)) {
|
||||
settings.oauthTokens = settings.oauthTokens.filter(
|
||||
(t) =>
|
||||
t.accessToken !== tokenData.accessToken && t.refreshToken !== tokenData.refreshToken,
|
||||
);
|
||||
saveSettings(settings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to remove OAuth token from settings:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean up expired codes and tokens (should be called periodically)
|
||||
*/
|
||||
export const cleanupExpired = (): void => {
|
||||
const now = new Date();
|
||||
|
||||
// Clean up expired authorization codes
|
||||
for (const [code, authCode] of authorizationCodes.entries()) {
|
||||
if (authCode.expiresAt < now) {
|
||||
authorizationCodes.delete(code);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up expired tokens
|
||||
const processedTokens = new Set<string>();
|
||||
for (const [_key, token] of tokens.entries()) {
|
||||
// Skip if we've already processed this token
|
||||
if (processedTokens.has(token.accessToken)) {
|
||||
continue;
|
||||
}
|
||||
processedTokens.add(token.accessToken);
|
||||
|
||||
const accessExpired = token.accessTokenExpiresAt < now;
|
||||
const refreshExpired = token.refreshTokenExpiresAt && token.refreshTokenExpiresAt < now;
|
||||
|
||||
// If both are expired, remove the token
|
||||
if (accessExpired && (!token.refreshToken || refreshExpired)) {
|
||||
tokens.delete(token.accessToken);
|
||||
if (token.refreshToken) {
|
||||
tokens.delete(token.refreshToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync persisted tokens: keep only non-expired ones
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (Array.isArray(settings.oauthTokens)) {
|
||||
const validTokens: IOAuthToken[] = [];
|
||||
for (const stored of settings.oauthTokens) {
|
||||
const accessExpiresAt = new Date(stored.accessTokenExpiresAt);
|
||||
const refreshExpiresAt = stored.refreshTokenExpiresAt
|
||||
? new Date(stored.refreshTokenExpiresAt)
|
||||
: undefined;
|
||||
const accessExpired = accessExpiresAt < now;
|
||||
const refreshExpired = refreshExpiresAt && refreshExpiresAt < now;
|
||||
|
||||
if (!accessExpired || (stored.refreshToken && !refreshExpired)) {
|
||||
validTokens.push(stored);
|
||||
}
|
||||
}
|
||||
settings.oauthTokens = validTokens;
|
||||
saveSettings(settings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup persisted OAuth tokens:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Run cleanup every 5 minutes in production
|
||||
let cleanupIntervalId: NodeJS.Timeout | null = null;
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
cleanupIntervalId = setInterval(cleanupExpired, 5 * 60 * 1000);
|
||||
// Allow the interval to not keep the process alive
|
||||
cleanupIntervalId.unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cleanup interval (for graceful shutdown)
|
||||
*/
|
||||
export const stopCleanup = (): void => {
|
||||
if (cleanupIntervalId) {
|
||||
clearInterval(cleanupIntervalId);
|
||||
cleanupIntervalId = null;
|
||||
}
|
||||
};
|
||||
@@ -56,9 +56,18 @@ import {
|
||||
getCloudServerToolsList,
|
||||
callCloudTool,
|
||||
} from '../controllers/cloudController.js';
|
||||
import {
|
||||
getAllRegistryServers,
|
||||
getRegistryServerVersions,
|
||||
getRegistryServerVersion,
|
||||
} from '../controllers/registryController.js';
|
||||
import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js';
|
||||
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
|
||||
import { getRuntimeConfig, getPublicConfig, getMcpSettingsJson } from '../controllers/configController.js';
|
||||
import {
|
||||
getRuntimeConfig,
|
||||
getPublicConfig,
|
||||
getMcpSettingsJson,
|
||||
} from '../controllers/configController.js';
|
||||
import { callTool } from '../controllers/toolController.js';
|
||||
import { getPrompt } from '../controllers/promptController.js';
|
||||
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
|
||||
@@ -70,6 +79,29 @@ import {
|
||||
executeToolViaOpenAPI,
|
||||
getGroupOpenAPISpec,
|
||||
} from '../controllers/openApiController.js';
|
||||
import { handleOAuthCallback } from '../controllers/oauthCallbackController.js';
|
||||
import {
|
||||
getAuthorize,
|
||||
postAuthorize,
|
||||
postToken,
|
||||
getUserInfo,
|
||||
getMetadata,
|
||||
getProtectedResourceMetadata,
|
||||
} from '../controllers/oauthServerController.js';
|
||||
import {
|
||||
getAllClients,
|
||||
getClient,
|
||||
createClient,
|
||||
updateClient,
|
||||
deleteClient,
|
||||
regenerateSecret,
|
||||
} from '../controllers/oauthClientController.js';
|
||||
import {
|
||||
registerClient,
|
||||
getClientConfiguration,
|
||||
updateClientConfiguration,
|
||||
deleteClientRegistration,
|
||||
} from '../controllers/oauthDynamicRegistrationController.js';
|
||||
import { auth } from '../middlewares/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -78,6 +110,23 @@ 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);
|
||||
|
||||
// OAuth Authorization Server endpoints (no auth required for OAuth flow)
|
||||
app.get('/oauth/authorize', getAuthorize);
|
||||
app.post('/oauth/authorize', express.urlencoded({ extended: true }), postAuthorize);
|
||||
app.post('/oauth/token', express.urlencoded({ extended: true }), postToken); // Public endpoint for token exchange
|
||||
app.get('/oauth/userinfo', getUserInfo); // Validates OAuth token
|
||||
app.get('/.well-known/oauth-authorization-server', getMetadata); // Public metadata endpoint
|
||||
app.get('/.well-known/oauth-protected-resource', getProtectedResourceMetadata); // Public protected resource metadata
|
||||
|
||||
// RFC 7591 Dynamic Client Registration endpoints (public for registration)
|
||||
app.post('/oauth/register', registerClient); // Register new OAuth client
|
||||
app.get('/oauth/register/:clientId', getClientConfiguration); // Read client configuration
|
||||
app.put('/oauth/register/:clientId', updateClientConfiguration); // Update client configuration
|
||||
app.delete('/oauth/register/:clientId', deleteClientRegistration); // Delete client registration
|
||||
|
||||
// API routes protected by auth middleware in middlewares/index.ts
|
||||
router.get('/servers', getAllServers);
|
||||
router.get('/settings', getAllSettings);
|
||||
@@ -115,6 +164,21 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.delete('/users/:username', deleteExistingUser);
|
||||
router.get('/users-stats', getUserStats);
|
||||
|
||||
// OAuth Client management routes (admin only)
|
||||
router.get('/oauth/clients', getAllClients);
|
||||
router.get('/oauth/clients/:clientId', getClient);
|
||||
router.post(
|
||||
'/oauth/clients',
|
||||
[
|
||||
check('name', 'Client name is required').not().isEmpty(),
|
||||
check('redirectUris', 'At least one redirect URI is required').isArray({ min: 1 }),
|
||||
],
|
||||
createClient,
|
||||
);
|
||||
router.put('/oauth/clients/:clientId', updateClient);
|
||||
router.delete('/oauth/clients/:clientId', deleteClient);
|
||||
router.post('/oauth/clients/:clientId/regenerate-secret', regenerateSecret);
|
||||
|
||||
// Tool management routes
|
||||
router.post('/tools/call/:server', callTool);
|
||||
|
||||
@@ -144,6 +208,11 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.get('/cloud/servers/:serverName/tools', getCloudServerToolsList);
|
||||
router.post('/cloud/servers/:serverName/tools/:toolName/call', callCloudTool);
|
||||
|
||||
// Registry routes (proxy to official MCP registry)
|
||||
router.get('/registry/servers', getAllRegistryServers);
|
||||
router.get('/registry/servers/:serverName/versions', getRegistryServerVersions);
|
||||
router.get('/registry/servers/:serverName/versions/:version', getRegistryServerVersion);
|
||||
|
||||
// Log routes
|
||||
router.get('/logs', getAllLogs);
|
||||
router.delete('/logs', clearLogs);
|
||||
|
||||
@@ -17,6 +17,8 @@ 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';
|
||||
import { initOAuthServer } from './services/oauthServerService.js';
|
||||
|
||||
/**
|
||||
* Get the directory of the current module
|
||||
@@ -27,7 +29,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 +60,19 @@ export class AppServer {
|
||||
// Initialize default admin user if no users exist
|
||||
await initializeDefaultUser();
|
||||
|
||||
// Initialize OAuth provider if configured (for proxying upstream MCP OAuth)
|
||||
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');
|
||||
}
|
||||
|
||||
// Initialize OAuth authorization server (for MCPHub's own OAuth)
|
||||
initOAuthServer();
|
||||
|
||||
initMiddlewares(this.app);
|
||||
initRoutes(this.app);
|
||||
console.log('Server initialized successfully');
|
||||
@@ -67,28 +82,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 +112,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,
|
||||
);
|
||||
|
||||
@@ -37,9 +37,12 @@ export class DataServicex implements DataService {
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
const result = { ...all };
|
||||
result.mcpServers = newSettings.mcpServers;
|
||||
result.users = newSettings.users;
|
||||
result.systemConfig = newSettings.systemConfig;
|
||||
result.groups = newSettings.groups;
|
||||
result.oauthClients = newSettings.oauthClients;
|
||||
result.oauthTokens = newSettings.oauthTokens;
|
||||
return result;
|
||||
} else {
|
||||
const result = JSON.parse(JSON.stringify(all));
|
||||
|
||||
593
src/services/mcpOAuthProvider.ts
Normal file
593
src/services/mcpOAuthProvider.ts
Normal 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;
|
||||
};
|
||||
@@ -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 || '',
|
||||
|
||||
584
src/services/oauthClientRegistration.ts
Normal file
584
src/services/oauthClientRegistration.ts
Normal 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;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user