Compare commits

...

21 Commits

Author SHA1 Message Date
samanhappy
66d4142039 feat: add variable detection and confirmation dialogs in server forms (#205) 2025-06-29 22:23:42 +08:00
samanhappy
cf72295f99 Refactor UI components across multiple pages for improved styling and consistency (#204) 2025-06-29 22:01:00 +08:00
samanhappy
89f85c73ff fix: resolve race conditions in initializeClientsFromSettings (#201) 2025-06-28 22:11:14 +08:00
samanhappy
adabf1d92b feat:support DXT file server installation (#200) 2025-06-27 14:45:24 +08:00
samanhappy
c3a6dfadb4 feat: Add dynamic header input fields for server configuration in ServerForm (#193) 2025-06-20 14:52:22 +08:00
samanhappy
d119be0f82 feat: Implement bearer token validation in auth middleware (#186) 2025-06-19 12:11:35 +08:00
samanhappy
1e308ec4c5 feat: add Jest testing framework and CI/CD configuration (#187)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-06-18 14:02:52 +08:00
samanhappy
1bd4fd6d9c feat: Add OpenAPI support with comprehensive configuration options and client integration (#184)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-16 17:50:51 +08:00
Imgodmaoyouknow
4b3bb26301 fix: 修复从市场安装服务器时,选择SSE和Streamable HTTP不生效的问题 (#180) 2025-06-13 22:50:36 +08:00
samanhappy
40af398f68 chore: update @modelcontextprotocol/sdk to version 1.12.1 (#176) 2025-06-10 16:50:28 +08:00
samanhappy
4726f00a22 feat: add request options configuration to server form (#171) 2025-06-10 13:51:01 +08:00
samanhappy
77f64b7b98 feat: enhance DynamicForm to support array and object (#173)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-06-10 13:50:14 +08:00
samanhappy
d9cbc5381a feat: implement keep-alive functionality for SSE connections (#166)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-06-07 20:41:51 +08:00
samanhappy
56c6447469 feat: implement settings cache with load, save, clear, and status functions (#167) 2025-06-07 20:36:52 +08:00
samanhappy
f8149c4b0b fix: update SSE transport path to use basePath from config (#165)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-06-07 20:35:20 +08:00
purefkh
e259f30539 fix: save user config when install mcp server from market (#168) 2025-06-07 20:34:51 +08:00
samanhappy
3a421bc476 fix: update tool name formatting from '/' to '-' in ToolCard and mcpService (#164)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-05 15:34:49 +08:00
samanhappy
503b60edb7 feat: add tool management features including toggle and description updates (#163)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-06-04 16:03:45 +08:00
dependabot[bot]
4039a85ee1 chore(deps-dev): bump concurrently from 8.2.2 to 9.1.2 (#156)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-02 19:49:58 +08:00
dependabot[bot]
3a83b83a9e chore(deps-dev): bump @vitejs/plugin-react from 4.4.1 to 4.5.0 (#160)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-02 19:47:04 +08:00
samanhappy
c1621805de fix: expand environment variables in database and OpenAI configuration (#162) 2025-06-02 19:46:39 +08:00
86 changed files with 7828 additions and 1452 deletions

16
.coveragerc Normal file
View File

@@ -0,0 +1,16 @@
# Test coverage configuration
# This file tells Jest what to include/exclude from coverage reports
# Coverage patterns
- "src/**/*.{ts,tsx}"
# Exclusions
- "!src/**/*.d.ts"
- "!src/index.ts"
- "!src/**/__tests__/**"
- "!src/**/*.test.{ts,tsx}"
- "!src/**/*.spec.{ts,tsx}"
- "!**/node_modules/**"
- "!coverage/**"
- "!dist/**"
- "!build/**"

View File

@@ -20,6 +20,6 @@
}
],
"@typescript-eslint/no-explicit-any": "off",
"no-undef": "off",
"no-undef": "off"
}
}

112
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,112 @@
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run linter
run: pnpm lint
- name: Run type checking
run: pnpm backend:build
- name: Run tests
run: pnpm test:ci
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage/lcov.info
flags: unittests
name: codecov-umbrella
# build:
# runs-on: ubuntu-latest
# needs: test
# steps:
# - name: Checkout code
# uses: actions/checkout@v4
# - name: Setup Node.js
# uses: actions/setup-node@v4
# with:
# node-version: '20.x'
# - name: Enable Corepack
# run: corepack enable
# - name: Install dependencies
# run: pnpm install --frozen-lockfile
# - name: Build application
# run: pnpm build
# - name: Verify build artifacts
# run: node scripts/verify-dist.js
# integration-test:
# runs-on: ubuntu-latest
# needs: test
# services:
# postgres:
# image: postgres:15
# env:
# POSTGRES_PASSWORD: postgres
# POSTGRES_DB: mcphub_test
# options: >-
# --health-cmd pg_isready
# --health-interval 10s
# --health-timeout 5s
# --health-retries 5
# steps:
# - name: Checkout code
# uses: actions/checkout@v4
# - name: Setup Node.js
# uses: actions/setup-node@v4
# with:
# node-version: '20.x'
# - name: Enable Corepack
# run: corepack enable
# - name: Install dependencies
# run: pnpm install --frozen-lockfile
# - name: Build application
# run: pnpm build
# - name: Run integration tests
# run: |
# export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/mcphub_test"
# node test-integration.ts
# env:
# NODE_ENV: test

2
.gitignore vendored
View File

@@ -24,3 +24,5 @@ yarn-error.log*
.vscode/
*.log
coverage/
data/

View File

@@ -57,7 +57,7 @@ Create a `mcp_settings.json` file to customize your server settings:
**Recommended**: Mount your custom config:
```bash
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
```
or run with default settings:

View File

@@ -57,7 +57,7 @@ MCPHub 通过将多个 MCPModel Context Protocol服务器组织为灵活
**推荐**:挂载自定义配置:
```bash
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
```
或使用默认配置运行:

View File

@@ -0,0 +1,149 @@
# OpenAPI Schema Support in MCPHub
MCPHub now supports both OpenAPI specification URLs and complete JSON schemas for OpenAPI server configuration. This allows you to either reference an external OpenAPI specification file or embed the complete schema directly in your configuration.
## Configuration Options
### 1. Using OpenAPI Specification URL (Traditional)
```json
{
"type": "openapi",
"openapi": {
"url": "https://api.example.com/openapi.json",
"version": "3.1.0",
"security": {
"type": "apiKey",
"apiKey": {
"name": "X-API-Key",
"in": "header",
"value": "your-api-key"
}
}
}
}
```
### 2. Using Complete JSON Schema (New)
```json
{
"type": "openapi",
"openapi": {
"schema": {
"openapi": "3.1.0",
"info": {
"title": "My API",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.com"
}
],
"paths": {
"/users": {
"get": {
"operationId": "getUsers",
"summary": "Get all users",
"responses": {
"200": {
"description": "List of users"
}
}
}
}
}
},
"version": "3.1.0",
"security": {
"type": "apiKey",
"apiKey": {
"name": "X-API-Key",
"in": "header",
"value": "your-api-key"
}
}
}
}
```
## Benefits of JSON Schema Support
1. **Offline Development**: No need for external URLs during development
2. **Version Control**: Schema changes can be tracked in your configuration
3. **Security**: No external dependencies or network calls required
4. **Customization**: Full control over the API specification
5. **Testing**: Easy to create test configurations with mock schemas
## Frontend Form Support
The web interface now includes:
- **Input Mode Selection**: Choose between "Specification URL" or "JSON Schema"
- **URL Input**: Traditional URL input field for external specifications
- **Schema Editor**: Large text area with syntax highlighting for JSON schema input
- **Validation**: Client-side JSON validation before submission
- **Help Text**: Contextual help for schema format
## API Validation
The backend validates that:
- At least one of `url` or `schema` is provided for OpenAPI servers
- JSON schemas are properly formatted when provided
- Security configurations are valid for both input modes
- All required OpenAPI fields are present
## Migration Guide
### From URL to Schema
If you want to convert an existing URL-based configuration to schema-based:
1. Download your OpenAPI specification from the URL
2. Copy the JSON content
3. Update your configuration to use the `schema` field instead of `url`
4. Paste the JSON content as the value of the `schema` field
### Maintaining Both
You can include both `url` and `schema` in your configuration. The system will prioritize the `schema` field if both are present.
## Examples
See the `examples/openapi-schema-config.json` file for complete configuration examples showing both URL and schema-based configurations.
## Technical Implementation
- **Backend**: OpenAPI client supports both SwaggerParser.dereference() with URLs and direct schema objects
- **Frontend**: Dynamic form rendering based on selected input mode
- **Validation**: Enhanced validation logic in server controllers
- **Type Safety**: Updated TypeScript interfaces for both input modes
## Security Considerations
When using JSON schemas:
- Ensure schemas are properly validated before use
- Be aware that large schemas increase configuration file size
- Consider using URL-based approach for frequently changing APIs
- Store sensitive information (like API keys) in environment variables, not in schemas
## Troubleshooting
### Common Issues
1. **Invalid JSON**: Ensure your schema is valid JSON format
2. **Missing Required Fields**: OpenAPI schemas must include `openapi`, `info`, and `paths` fields
3. **Schema Size**: Very large schemas may impact performance
4. **Server Configuration**: Ensure the `servers` field in your schema points to the correct endpoints
### Validation Errors
The system provides detailed error messages for:
- Malformed JSON in schema field
- Missing required OpenAPI fields
- Invalid security configurations
- Network issues with URL-based configurations

172
docs/openapi-support.md Normal file
View File

@@ -0,0 +1,172 @@
# OpenAPI Support in MCPHub
MCPHub now supports OpenAPI 3.1.1 servers as a new server type, allowing you to integrate REST APIs directly into your MCP workflow.
## Features
-**Full OpenAPI 3.1.1 Support**: Load and parse OpenAPI specifications
-**Multiple Security Types**: None, API Key, HTTP Authentication, OAuth 2.0, OpenID Connect
-**Dynamic Tool Generation**: Automatically creates MCP tools from OpenAPI operations
-**Type Safety**: Full TypeScript support with proper type definitions
-**Frontend Integration**: Easy-to-use forms for configuring OpenAPI servers
-**Internationalization**: Support for English and Chinese languages
## Configuration
### Basic Configuration
```json
{
"type": "openapi",
"openapi": {
"url": "https://api.example.com/v1/openapi.json",
"version": "3.1.0",
"security": {
"type": "none"
}
}
}
```
### With API Key Authentication
```json
{
"type": "openapi",
"openapi": {
"url": "https://api.example.com/v1/openapi.json",
"version": "3.1.0",
"security": {
"type": "apiKey",
"apiKey": {
"name": "X-API-Key",
"in": "header",
"value": "your-api-key-here"
}
}
},
"headers": {
"Accept": "application/json"
}
}
```
### With HTTP Bearer Authentication
```json
{
"type": "openapi",
"openapi": {
"url": "https://api.example.com/v1/openapi.json",
"version": "3.1.0",
"security": {
"type": "http",
"http": {
"scheme": "bearer",
"credentials": "your-bearer-token-here"
}
}
}
}
```
## Supported Security Types
1. **None**: No authentication required
2. **API Key**: API key in header or query parameter
3. **HTTP**: Basic, Bearer, or Digest authentication
4. **OAuth 2.0**: OAuth 2.0 access tokens
5. **OpenID Connect**: OpenID Connect ID tokens
## How It Works
1. **Specification Loading**: The OpenAPI client fetches and parses the OpenAPI specification
2. **Tool Generation**: Each operation in the spec becomes an MCP tool
3. **Request Handling**: Tools handle parameter validation and API calls
4. **Response Processing**: API responses are returned as tool results
## Frontend Usage
1. Navigate to the Servers page
2. Click "Add Server"
3. Select "OpenAPI" as the server type
4. Enter the OpenAPI specification URL
5. Configure security settings if needed
6. Add any additional headers
7. Save the configuration
## Testing
You can test the OpenAPI integration using the provided test scripts:
```bash
# Test OpenAPI client directly
npx tsx test-openapi.ts
# Test full integration
npx tsx test-integration.ts
```
## Example: Swagger Petstore
The Swagger Petstore API is a perfect example for testing:
```json
{
"type": "openapi",
"openapi": {
"url": "https://petstore3.swagger.io/api/v3/openapi.json",
"version": "3.1.0",
"security": {
"type": "none"
}
}
}
```
This will create tools like:
- `addPet`: Add a new pet to the store
- `findPetsByStatus`: Find pets by status
- `getPetById`: Find pet by ID
- And many more...
## Error Handling
The OpenAPI client includes comprehensive error handling:
- Network errors are properly caught and reported
- Invalid specifications are rejected with clear error messages
- API errors include response status and body information
- Type validation ensures proper parameter handling
## Limitations
- Only supports OpenAPI 3.x specifications (3.0.0 and above)
- Complex authentication flows (like OAuth 2.0 authorization code flow) require manual token management
- Large specifications may take time to parse initially
- Some advanced OpenAPI features may not be fully supported
## Contributing
To add new features or fix bugs in the OpenAPI integration:
1. Backend types: `src/types/index.ts`
2. OpenAPI client: `src/clients/openapi.ts`
3. Service integration: `src/services/mcpService.ts`
4. Frontend forms: `frontend/src/components/ServerForm.tsx`
5. Internationalization: `frontend/src/locales/`
## Troubleshooting
**Q: My OpenAPI server won't connect**
A: Check that the specification URL is accessible and returns valid JSON/YAML
**Q: Tools aren't showing up**
A: Verify that your OpenAPI specification includes valid operations with required fields
**Q: Authentication isn't working**
A: Double-check your security configuration matches the API's requirements
**Q: Getting CORS errors**
A: The API server needs to allow CORS requests from your MCPHub domain

172
docs/testing-framework.md Normal file
View File

@@ -0,0 +1,172 @@
# 测试框架和自动化测试实现报告
## 概述
本项目已成功引入现代化的测试框架和自动化测试流程。实现了基于Jest的测试环境支持TypeScript、ES模块并包含完整的CI/CD配置。
## 已实现的功能
### 1. 测试框架配置
- **Jest配置**: 使用`jest.config.cjs`配置文件支持ES模块和TypeScript
- **覆盖率报告**: 配置了代码覆盖率收集和报告
- **测试环境**: 支持Node.js环境的单元测试和集成测试
- **模块映射**: 配置了路径别名支持
### 2. 测试工具和辅助函数
创建了完善的测试工具库 (`tests/utils/testHelpers.ts`):
- **认证工具**: JWT token生成和管理
- **HTTP测试**: Supertest集成用于API测试
- **数据生成**: 测试数据工厂函数
- **响应断言**: 自定义API响应验证器
- **环境管理**: 测试环境变量配置
### 3. 测试用例实现
已实现的测试场景:
#### 基础配置测试 (`tests/basic.test.ts`)
- Jest配置验证
- 异步操作支持测试
- 自定义匹配器验证
#### 认证逻辑测试 (`tests/auth.logic.test.ts`)
- 用户登录逻辑
- 密码验证
- JWT生成和验证
- 错误处理场景
- 用户数据验证
#### 路径工具测试 (`tests/utils/pathLogic.test.ts`)
- 配置文件路径解析
- 环境变量处理
- 文件系统操作
- 错误处理和边界条件
- 跨平台路径处理
### 4. CI/CD配置
GitHub Actions配置 (`.github/workflows/ci.yml`):
- **多Node.js版本支持**: 18.x和20.x
- **自动化测试流程**:
- 代码检查 (ESLint)
- 类型检查 (TypeScript)
- 单元测试执行
- 覆盖率报告
- **构建验证**: 应用构建和产物验证
- **集成测试**: 包含数据库环境的集成测试
### 5. 测试脚本
`package.json`中添加的测试命令:
```json
{
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:verbose": "jest --verbose",
"test:ci": "jest --ci --coverage --watchAll=false"
}
```
## 测试结果
当前测试统计:
- **测试套件**: 3个
- **测试用例**: 19个
- **通过率**: 100%
- **执行时间**: ~15秒
### 测试覆盖的功能模块
1. **认证系统**: 用户登录、JWT处理、密码验证
2. **配置管理**: 文件路径解析、环境变量处理
3. **基础设施**: Jest配置、测试工具验证
## 技术特点
### 现代化特性
- **ES模块支持**: 完全支持ES2022模块语法
- **TypeScript集成**: 类型安全的测试编写
- **异步测试**: Promise和async/await支持
- **模拟系统**: Jest mock功能的深度使用
- **参数化测试**: 数据驱动的测试用例
### 最佳实践
- **测试隔离**: 每个测试用例独立运行
- **Mock管理**: 统一的mock清理和重置
- **错误处理**: 完整的错误场景测试
- **边界测试**: 输入验证和边界条件覆盖
- **文档化**: 清晰的测试用例命名和描述
## 后续扩展计划
### 短期目标
1. **API测试**: 为REST API端点添加集成测试
2. **数据库测试**: 添加数据模型和存储层测试
3. **中间件测试**: 认证和权限中间件测试
4. **服务层测试**: 核心业务逻辑测试
### 中期目标
1. **端到端测试**: 使用Playwright或Cypress
2. **性能测试**: API响应时间和负载测试
3. **安全测试**: 输入验证和安全漏洞测试
4. **契约测试**: API契约验证
### 长期目标
1. **测试数据管理**: 测试数据库和fixture管理
2. **视觉回归测试**: UI组件的视觉测试
3. **监控集成**: 生产环境测试监控
4. **自动化测试报告**: 详细的测试报告和趋势分析
## 开发指南
### 添加新测试用例
1.`tests/`目录下创建对应的测试文件
2. 使用`testHelpers.ts`中的工具函数
3. 遵循命名约定: `*.test.ts``*.spec.ts`
4. 确保测试用例具有清晰的描述和断言
### 运行测试
```bash
# 运行所有测试
pnpm test
# 监听模式
pnpm test:watch
# 生成覆盖率报告
pnpm test:coverage
# CI模式运行
pnpm test:ci
```
### Mock最佳实践
-`beforeEach`中清理所有mock
- 使用具体的mock实现而不是空函数
- 验证mock被正确调用
- 保持mock的一致性和可维护性
## 结论
本项目已成功建立了完整的现代化测试框架,具备以下优势:
1. **高度可扩展**: 易于添加新的测试用例和测试类型
2. **开发友好**: 丰富的工具函数和清晰的结构
3. **CI/CD就绪**: 完整的自动化流水线配置
4. **质量保证**: 代码覆盖率和持续测试验证
这个测试框架为项目的持续发展和质量保证提供了坚实的基础,支持敏捷开发和持续集成的最佳实践。

View File

@@ -0,0 +1,256 @@
{
"mcpServers": {
"example-api-url": {
"type": "openapi",
"openapi": {
"url": "https://api.example.com/openapi.json",
"version": "3.1.0",
"security": {
"type": "apiKey",
"apiKey": {
"name": "X-API-Key",
"in": "header",
"value": "your-api-key-here"
}
}
},
"headers": {
"User-Agent": "MCPHub/1.0"
},
"enabled": true
},
"example-api-schema": {
"type": "openapi",
"openapi": {
"schema": {
"openapi": "3.1.0",
"info": {
"title": "Example API",
"version": "1.0.0",
"description": "A sample API for demonstration"
},
"servers": [
{
"url": "https://api.example.com",
"description": "Production server"
}
],
"paths": {
"/users": {
"get": {
"operationId": "listUsers",
"summary": "List all users",
"description": "Retrieve a list of all users in the system",
"parameters": [
{
"name": "limit",
"in": "query",
"description": "Maximum number of users to return",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 10
}
},
{
"name": "offset",
"in": "query",
"description": "Number of users to skip",
"required": false,
"schema": {
"type": "integer",
"minimum": 0,
"default": 0
}
}
],
"responses": {
"200": {
"description": "List of users",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": {
"$ref": "#/components/schemas/User"
}
},
"total": {
"type": "integer",
"description": "Total number of users"
}
}
}
}
}
}
}
},
"post": {
"operationId": "createUser",
"summary": "Create a new user",
"description": "Create a new user in the system",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateUserRequest"
}
}
}
},
"responses": {
"201": {
"description": "User created successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
}
}
}
},
"/users/{userId}": {
"get": {
"operationId": "getUserById",
"summary": "Get user by ID",
"description": "Retrieve a specific user by their ID",
"parameters": [
{
"name": "userId",
"in": "path",
"required": true,
"description": "ID of the user to retrieve",
"schema": {
"type": "integer",
"minimum": 1
}
}
],
"responses": {
"200": {
"description": "User details",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
},
"404": {
"description": "User not found"
}
}
}
}
},
"components": {
"schemas": {
"User": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "Unique identifier for the user"
},
"name": {
"type": "string",
"description": "Full name of the user"
},
"email": {
"type": "string",
"format": "email",
"description": "Email address of the user"
},
"createdAt": {
"type": "string",
"format": "date-time",
"description": "Timestamp when the user was created"
},
"status": {
"type": "string",
"enum": [
"active",
"inactive",
"suspended"
],
"description": "Current status of the user"
}
},
"required": [
"id",
"name",
"email"
]
},
"CreateUserRequest": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 100,
"description": "Full name of the user"
},
"email": {
"type": "string",
"format": "email",
"description": "Email address of the user"
},
"status": {
"type": "string",
"enum": [
"active",
"inactive"
],
"default": "active",
"description": "Initial status of the user"
}
},
"required": [
"name",
"email"
]
}
},
"securitySchemes": {
"ApiKeyAuth": {
"type": "apiKey",
"in": "header",
"name": "X-API-Key"
}
}
},
"security": [
{
"ApiKeyAuth": []
}
]
},
"version": "3.1.0",
"security": {
"type": "apiKey",
"apiKey": {
"name": "X-API-Key",
"in": "header",
"value": "your-api-key-here"
}
}
},
"headers": {
"User-Agent": "MCPHub/1.0"
},
"enabled": true
}
}
}

View File

@@ -1,13 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP Hub Dashboard</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body class="bg-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -50,7 +50,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
}
const result = await createGroup(formData.name, formData.description, formData.servers)
if (!result) {
setError(t('groups.createError'))
setIsSubmitting(false)
@@ -69,7 +69,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.addNew')}</h2>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
{error}
@@ -87,7 +87,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
name="name"
value={formData.name}
onChange={handleChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder={t('groups.namePlaceholder')}
required
/>
@@ -109,14 +109,14 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 btn-primary"
disabled={isSubmitting}
>
{isSubmitting ? t('common.submitting') : t('common.create')}

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ServerForm from './ServerForm'
import { getApiUrl } from '../utils/runtime';
import { getApiUrl } from '../utils/runtime'
import { detectVariables } from '../utils/variableDetection'
interface AddServerFormProps {
onAdd: () => void
@@ -11,13 +12,26 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
const { t } = useTranslation()
const [modalVisible, setModalVisible] = useState(false)
const [error, setError] = useState<string | null>(null)
const [confirmationVisible, setConfirmationVisible] = useState(false)
const [pendingPayload, setPendingPayload] = useState<any>(null)
const [detectedVariables, setDetectedVariables] = useState<string[]>([])
const toggleModal = () => {
setModalVisible(!modalVisible)
setError(null) // Clear any previous errors when toggling modal
setConfirmationVisible(false) // Close confirmation dialog
setPendingPayload(null) // Clear pending payload
}
const handleSubmit = async (payload: any) => {
const handleConfirmSubmit = async () => {
if (pendingPayload) {
await submitServer(pendingPayload)
setConfirmationVisible(false)
setPendingPayload(null)
}
}
const submitServer = async (payload: any) => {
try {
setError(null)
const token = localStorage.getItem('mcphub_token');
@@ -65,11 +79,31 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
}
}
const handleSubmit = async (payload: any) => {
try {
// Check for variables in the payload
const variables = detectVariables(payload)
if (variables.length > 0) {
// Show confirmation dialog
setDetectedVariables(variables)
setPendingPayload(payload)
setConfirmationVisible(true)
} else {
// Submit directly if no variables found
await submitServer(payload)
}
} catch (err) {
console.error('Error processing server submission:', err)
setError(t('errors.serverAdd'))
}
}
return (
<div>
<button
onClick={toggleModal}
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4 flex items-center justify-center"
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4 flex items-center justify-center btn-primary"
>
<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 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
@@ -87,6 +121,60 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
/>
</div>
)}
{confirmationVisible && (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<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>
<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" />
</svg>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-yellow-800">
{t('server.detectedVariables')}:
</h4>
<ul className="mt-1 text-sm text-yellow-700">
{detectedVariables.map((variable, index) => (
<li key={index} className="font-mono">
${`{${variable}}`}
</li>
))}
</ul>
</div>
</div>
</div>
<p className="text-gray-600 text-sm mb-6">
{t('server.confirmVariablesMessage')}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => {
setConfirmationVisible(false)
setPendingPayload(null)
}}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleConfirmSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 btn-primary"
>
{t('server.confirmAndAdd')}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -31,17 +31,17 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validate passwords match
if (formData.newPassword !== confirmPassword) {
setError(t('auth.passwordsNotMatch'));
return;
}
setIsLoading(true);
try {
const response = await changePassword(formData);
if (response.success) {
setSuccess(true);
if (onSuccess) {
@@ -60,7 +60,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
return (
<div className="p-6 bg-white rounded-lg shadow-md">
<h2 className="text-xl font-bold mb-4">{t('auth.changePassword')}</h2>
{success ? (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{t('auth.changePasswordSuccess')}
@@ -72,7 +72,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
{error}
</div>
)}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="currentPassword">
{t('auth.currentPassword')}
@@ -81,13 +81,13 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
type="password"
id="currentPassword"
name="currentPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
value={formData.currentPassword}
onChange={handleChange}
required
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="newPassword">
{t('auth.newPassword')}
@@ -96,14 +96,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
type="password"
id="newPassword"
name="newPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
value={formData.newPassword}
onChange={handleChange}
required
minLength={6}
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="confirmPassword">
{t('auth.confirmPassword')}
@@ -112,14 +112,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
type="password"
id="confirmPassword"
name="confirmPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
value={confirmPassword}
onChange={handleChange}
required
minLength={6}
/>
</div>
<div className="flex justify-end space-x-2">
{onCancel && (
<button
@@ -134,7 +134,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
<button
type="submit"
disabled={isLoading}
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 btn-primary"
>
{isLoading ? (
<span className="flex items-center">

View File

@@ -0,0 +1,413 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getApiUrl } from '@/utils/runtime';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
interface DxtUploadFormProps {
onSuccess: (serverConfig: any) => void;
onCancel: () => void;
}
interface DxtUploadResponse {
success: boolean;
data?: {
manifest: any;
extractDir: string;
};
message?: string;
}
const DxtUploadForm: React.FC<DxtUploadFormProps> = ({ onSuccess, onCancel }) => {
const { t } = useTranslation();
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [showServerForm, setShowServerForm] = useState(false);
const [manifestData, setManifestData] = useState<any>(null);
const [extractDir, setExtractDir] = useState<string>('');
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingServerName, setPendingServerName] = useState<string>('');
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (file.name.endsWith('.dxt')) {
setSelectedFile(file);
setError(null);
} else {
setError(t('dxt.invalidFileType'));
}
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
const file = files[0];
if (file.name.endsWith('.dxt')) {
setSelectedFile(file);
setError(null);
} else {
setError(t('dxt.invalidFileType'));
}
}
};
const handleUpload = async () => {
if (!selectedFile) {
setError(t('dxt.noFileSelected'));
return;
}
setIsUploading(true);
setError(null);
try {
const formData = new FormData();
formData.append('dxtFile', selectedFile);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/dxt/upload'), {
method: 'POST',
headers: {
'x-auth-token': token || '',
},
body: formData,
});
const result: DxtUploadResponse = await response.json();
if (!response.ok) {
throw new Error(result.message || `HTTP error! Status: ${response.status}`);
}
if (result.success && result.data) {
setManifestData(result.data.manifest);
setExtractDir(result.data.extractDir);
setShowServerForm(true);
} else {
throw new Error(result.message || t('dxt.uploadFailed'));
}
} catch (err) {
console.error('DXT upload error:', err);
setError(err instanceof Error ? err.message : t('dxt.uploadFailed'));
} finally {
setIsUploading(false);
}
};
const handleInstallServer = async (serverName: string, forceOverride: boolean = false) => {
setIsUploading(true);
setError(null);
try {
// Convert DXT manifest to MCPHub stdio server configuration
const serverConfig = convertDxtToMcpConfig(manifestData, extractDir, serverName);
const token = localStorage.getItem('mcphub_token');
// First, check if server exists
if (!forceOverride) {
const checkResponse = await fetch(getApiUrl('/servers'), {
method: 'GET',
headers: {
'x-auth-token': token || '',
},
});
if (checkResponse.ok) {
const checkResult = await checkResponse.json();
const existingServer = checkResult.data?.find((server: any) => server.name === serverName);
if (existingServer) {
// Server exists, show confirmation dialog
setPendingServerName(serverName);
setShowConfirmDialog(true);
setIsUploading(false);
return;
}
}
}
// Install or override the server
const method = forceOverride ? 'PUT' : 'POST';
const url = forceOverride ? getApiUrl(`/servers/${encodeURIComponent(serverName)}`) : getApiUrl('/servers');
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
name: serverName,
config: serverConfig,
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || `HTTP error! Status: ${response.status}`);
}
if (result.success) {
onSuccess(serverConfig);
} else {
throw new Error(result.message || t('dxt.installFailed'));
}
} catch (err) {
console.error('DXT install error:', err);
setError(err instanceof Error ? err.message : t('dxt.installFailed'));
setIsUploading(false);
}
};
const handleConfirmOverride = () => {
setShowConfirmDialog(false);
if (pendingServerName) {
handleInstallServer(pendingServerName, true);
}
};
const handleCancelOverride = () => {
setShowConfirmDialog(false);
setPendingServerName('');
setIsUploading(false);
};
const convertDxtToMcpConfig = (manifest: any, extractPath: string, _serverName: string) => {
const mcpConfig = manifest.server?.mcp_config || {};
// Convert DXT manifest to MCPHub stdio configuration
const config: any = {
type: 'stdio',
command: mcpConfig.command || 'node',
args: (mcpConfig.args || []).map((arg: string) =>
arg.replace('${__dirname}', extractPath)
),
};
// Add environment variables if they exist
if (mcpConfig.env && Object.keys(mcpConfig.env).length > 0) {
config.env = { ...mcpConfig.env };
// Replace ${__dirname} in environment variables
Object.keys(config.env).forEach(key => {
if (typeof config.env[key] === 'string') {
config.env[key] = config.env[key].replace('${__dirname}', extractPath);
}
});
}
return config;
};
if (showServerForm && manifestData) {
return (
<>
<ConfirmDialog
isOpen={showConfirmDialog}
onClose={handleCancelOverride}
onConfirm={handleConfirmOverride}
title={t('dxt.serverExistsTitle')}
message={t('dxt.serverExistsConfirm', { serverName: pendingServerName })}
confirmText={t('dxt.override')}
cancelText={t('common.cancel')}
variant="warning"
/>
<div className={`fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 ${showConfirmDialog ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="bg-white shadow rounded-lg p-6 w-full max-w-2xl max-h-screen overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('dxt.installServer')}</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">{error}</p>
</div>
)}
<div className="space-y-6">
{/* Extension Info */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-medium text-gray-900 mb-2">{t('dxt.extensionInfo')}</h3>
<div className="space-y-2 text-sm">
<div><strong>{t('dxt.name')}:</strong> {manifestData.display_name || manifestData.name}</div>
<div><strong>{t('dxt.version')}:</strong> {manifestData.version}</div>
<div><strong>{t('dxt.description')}:</strong> {manifestData.description}</div>
{manifestData.author && (
<div><strong>{t('dxt.author')}:</strong> {manifestData.author.name}</div>
)}
{manifestData.tools && manifestData.tools.length > 0 && (
<div>
<strong>{t('dxt.tools')}:</strong>
<ul className="list-disc list-inside ml-4">
{manifestData.tools.map((tool: any, index: number) => (
<li key={index}>{tool.name} - {tool.description}</li>
))}
</ul>
</div>
)}
</div>
</div>
{/* Server Configuration */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('dxt.serverName')}
</label>
<input
type="text"
id="serverName"
defaultValue={manifestData.name}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
placeholder={t('dxt.serverNamePlaceholder')}
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-4">
<button
onClick={onCancel}
disabled={isUploading}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={() => {
const nameInput = document.getElementById('serverName') as HTMLInputElement;
const serverName = nameInput?.value.trim() || manifestData.name;
handleInstallServer(serverName);
}}
disabled={isUploading}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
>
{isUploading ? (
<>
<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('dxt.installing')}
</>
) : (
t('dxt.install')
)}
</button>
</div>
</div>
</div>
</div>
</>
);
}
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-lg">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('dxt.uploadTitle')}</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">{error}</p>
</div>
)}
{/* File Drop Zone */}
<div
className={`relative border-2 border-dashed rounded-lg p-8 text-center transition-colors ${isDragging
? 'border-blue-500 bg-blue-50'
: selectedFile
? 'border-gray-500 '
: 'border-gray-300 hover:border-gray-400'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{selectedFile ? (
<div className="space-y-2">
<svg className="mx-auto h-12 w-12 text-green-200" 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>
<p className="text-sm text-gray-900 font-medium">{selectedFile.name}</p>
<p className="text-xs text-gray-500">{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
) : (
<div className="space-y-2">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
<div>
<p className="text-sm text-gray-900">{t('dxt.dropFileHere')}</p>
<p className="text-xs text-gray-500">{t('dxt.orClickToSelect')}</p>
</div>
</div>
)}
<input
type="file"
accept=".dxt"
onChange={handleFileSelect}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</div>
<div className="mt-6 flex justify-end space-x-4">
<button
onClick={onCancel}
disabled={isUploading}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleUpload}
disabled={!selectedFile || isUploading}
className="px-4 py-2 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
>
{isUploading ? (
<>
<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('dxt.uploading')}
</>
) : (
t('dxt.upload')
)}
</button>
</div>
</div>
</div>
);
};
export default DxtUploadForm;

View File

@@ -38,18 +38,6 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
}))
}
const handleServerToggle = (serverName: string) => {
setFormData(prev => {
const isSelected = prev.servers.includes(serverName)
return {
...prev,
servers: isSelected
? prev.servers.filter(name => name !== serverName)
: [...prev.servers, serverName]
}
})
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
@@ -67,7 +55,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
description: formData.description,
servers: formData.servers
})
if (!result) {
setError(t('groups.updateError'))
setIsSubmitting(false)
@@ -86,7 +74,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.edit')}</h2>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
{error}
@@ -104,7 +92,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
name="name"
value={formData.name}
onChange={handleChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder={t('groups.namePlaceholder')}
required
/>
@@ -126,14 +114,14 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 btn-primary"
disabled={isSubmitting}
>
{isSubmitting ? t('common.submitting') : t('common.save')}

View File

@@ -68,7 +68,7 @@ const GroupCard = ({
const groupServers = servers.filter(server => group.servers.includes(server.name))
return (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 ">
<div className="flex justify-between items-center mb-4">
<div>
<div className="flex items-center">
@@ -89,7 +89,7 @@ const GroupCard = ({
)}
</div>
<div className="flex items-center space-x-3">
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm">
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm btn-secondary">
{t('groups.serverCount', { count: group.servers.length })}
</div>
<button
@@ -121,7 +121,7 @@ const GroupCard = ({
>
<span className="font-medium text-gray-700 text-sm">{server.name}</span>
<span className={`ml-2 inline-block h-2 w-2 rounded-full ${server.status === 'connected' ? 'bg-green-500' :
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
}`}></span>
</div>
))}

View File

@@ -48,25 +48,26 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
// Get badge color based on log type
const getLogTypeColor = (type: string) => {
switch (type) {
case 'error': return 'bg-red-400';
case 'warn': return 'bg-yellow-400';
case 'debug': return 'bg-purple-400';
default: return 'bg-blue-400';
case 'error': return 'bg-red-400/80 text-white';
case 'warn': return 'bg-yellow-400/80 text-gray-900';
case 'debug': return 'bg-purple-400/80 text-white';
case 'info': return 'bg-blue-400/80 text-white';
default: return 'bg-blue-400/80 text-white';
}
};
// Get badge color based on log source
const getSourceColor = (source: string) => {
switch (source) {
case 'main': return 'bg-green-400';
case 'child': return 'bg-orange-400';
default: return 'bg-gray-400';
case 'main': return 'bg-green-400/80 text-white';
case 'child': return 'bg-orange-400/80 text-white';
default: return 'bg-gray-400/80 text-white';
}
};
return (
<div className="flex flex-col h-full">
<div className="bg-card p-3 rounded-t-md border-b flex flex-wrap items-center justify-between gap-2">
<div className="bg-card p-3 rounded-t-md flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-sm">{t('logs.filters')}:</span>
@@ -74,14 +75,14 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
<input
type="text"
placeholder={t('logs.search')}
className="px-2 py-1 text-sm border rounded"
className="shadow appearance-none border border-gray-200 rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{/* Log type filters */}
<div className="flex gap-1 items-center">
{(['info', 'error', 'warn', 'debug'] as const).map(type => (
{(['debug', 'info', 'error', 'warn'] as const).map(type => (
<Badge
key={type}
variant={typeFilter.includes(type) ? 'default' : 'outline'}
@@ -134,6 +135,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
variant="outline"
size="sm"
onClick={onClear}
className='btn-secondary'
disabled={isLoading || logs.length === 0}
>
{t('logs.clearLogs')}
@@ -164,7 +166,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
filteredLogs.map((log, index) => (
<div
key={`${log.timestamp}-${index}`}
className={`py-1 border-b border-gray-100 dark:border-gray-800 ${log.type === 'error' ? 'text-red-500' :
className={`py-1 ${log.type === 'error' ? 'text-red-500' :
log.type === 'warn' ? 'text-yellow-500' : ''
}`}
>

View File

@@ -15,31 +15,31 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
if (!server.tags || server.tags.length === 0) {
return { tagsToShow: [], hasMore: false, moreCount: 0 };
}
// Estimate available width in the card (in characters)
const estimatedAvailableWidth = 28; // Estimated number of characters that can fit in one line
// Calculate the character space needed for tags and plus sign (including # and spacing)
const calculateTagWidth = (tag: string) => tag.length + 3; // +3 for # and spacing
// Loop to determine the maximum number of tags that can be displayed
let totalWidth = 0;
let i = 0;
// First, sort tags by length to prioritize displaying shorter tags
const sortedTags = [...server.tags].sort((a, b) => a.length - b.length);
// Calculate how many tags can fit
for (i = 0; i < sortedTags.length; i++) {
const tagWidth = calculateTagWidth(sortedTags[i]);
// If this tag would make the total width exceed available width, stop adding
if (totalWidth + tagWidth > estimatedAvailableWidth) {
break;
}
totalWidth += tagWidth;
// If this is the last tag but there's still space, no need to show "more"
if (i === sortedTags.length - 1) {
return {
@@ -49,16 +49,16 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
};
}
}
// If there's not enough space to display any tags, show at least one
if (i === 0 && sortedTags.length > 0) {
i = 1;
}
// Calculate space needed for the "more" tag
const moreCount = sortedTags.length - i;
const moreTagWidth = 3 + String(moreCount).length + t('market.moreTags').length;
// If there's enough remaining space to display the "more" tag
if (totalWidth + moreTagWidth <= estimatedAvailableWidth || i < 1) {
return {
@@ -67,7 +67,7 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
moreCount
};
}
// If there's not enough space for even the "more" tag, reduce one tag to make room
return {
tagsToShow: sortedTags.slice(0, Math.max(1, i - 1)),
@@ -79,27 +79,27 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
const { tagsToShow, hasMore, moreCount } = getTagsToDisplay();
return (
<div
className="bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-shadow cursor-pointer flex flex-col h-full"
<div
className="bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-all duration-200 cursor-pointer flex flex-col h-full page-card"
onClick={() => onClick(server)}
>
<div className="flex justify-between items-start mb-3">
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1 mr-2">{server.display_name}</h3>
{server.is_official && (
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0">
<span className="text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0 label-primary">
{t('market.official')}
</span>
)}
</div>
<p className="text-gray-600 text-sm mb-4 line-clamp-2 min-h-[40px]">{server.description}</p>
{/* Categories */}
<div className="flex flex-wrap gap-1 mb-2 min-h-[28px]">
{server.categories?.length > 0 ? (
server.categories.map((category, index) => (
<span
<span
key={index}
className="bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded whitespace-nowrap"
className="bg-gray-100 text-gray-800 text-xs px-2 py-1.5 rounded whitespace-nowrap"
>
{category}
</span>
@@ -108,15 +108,15 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
<span className="text-xs text-gray-400 py-1">-</span>
)}
</div>
{/* Tags */}
<div className="relative mb-3 min-h-[28px] overflow-x-auto">
{server.tags?.length > 0 ? (
<div className="flex gap-1 items-center whitespace-nowrap">
{tagsToShow.map((tag, index) => (
<span
<span
key={index}
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0"
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0 label-secondary"
>
#{tag}
</span>
@@ -131,8 +131,8 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
<span className="text-xs text-gray-400 py-1">-</span>
)}
</div>
<div className="flex justify-between items-center mt-auto pt-2 text-xs text-gray-500 border-t border-gray-100">
<div className="flex justify-between items-center mt-auto pt-2 text-xs text-gray-500">
<div className="overflow-hidden">
<span className="whitespace-nowrap">{t('market.by')} </span>
<span className="font-medium whitespace-nowrap overflow-hidden text-ellipsis max-w-[120px] inline-block align-bottom">

View File

@@ -2,11 +2,14 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MarketServer, MarketServerInstallation } from '@/types';
import ServerForm from './ServerForm';
import { detectVariables } from '../utils/variableDetection';
import { ServerConfig } from '@/types';
interface MarketServerDetailProps {
server: MarketServer;
onBack: () => void;
onInstall: (server: MarketServer) => void;
onInstall: (server: MarketServer, config: ServerConfig) => void;
installing?: boolean;
isInstalled?: boolean;
}
@@ -21,6 +24,9 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
const { t } = useTranslation();
const [modalVisible, setModalVisible] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmationVisible, setConfirmationVisible] = useState(false);
const [pendingPayload, setPendingPayload] = useState<any>(null);
const [detectedVariables, setDetectedVariables] = useState<string[]>([]);
// Helper function to determine button state
const getButtonProps = () => {
@@ -38,7 +44,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
};
} else {
return {
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white",
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')
};
@@ -48,6 +54,27 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
const toggleModal = () => {
setModalVisible(!modalVisible);
setError(null); // Clear any previous errors when toggling modal
setConfirmationVisible(false);
setPendingPayload(null);
};
const handleConfirmInstall = async () => {
if (pendingPayload) {
await proceedWithInstall(pendingPayload);
setConfirmationVisible(false);
setPendingPayload(null);
}
};
const proceedWithInstall = async (payload: any) => {
try {
setError(null);
onInstall(server, payload.config);
setModalVisible(false);
} catch (err) {
console.error('Error installing server:', err);
setError(t('errors.serverInstall'));
}
};
const handleInstall = () => {
@@ -70,24 +97,32 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
} else if (server.installations.default) {
return server.installations.default;
}
// If none of the preferred types are available, get the first available installation type
const installTypes = Object.keys(server.installations);
if (installTypes.length > 0) {
return server.installations[installTypes[0]];
}
return undefined;
};
const handleSubmit = async (payload: any) => {
try {
setError(null);
// Pass the server object to the parent component for installation
onInstall(server);
setModalVisible(false);
// Check for variables in the payload
const variables = detectVariables(payload);
if (variables.length > 0) {
// Show confirmation dialog
setDetectedVariables(variables);
setPendingPayload(payload);
setConfirmationVisible(true);
} else {
// Install directly if no variables found
await proceedWithInstall(payload);
}
} catch (err) {
console.error('Error installing server:', err);
console.error('Error processing server installation:', err);
setError(t('errors.serverInstall'));
}
};
@@ -112,15 +147,15 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<div className="flex justify-between items-start mb-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center flex-wrap">
{server.display_name}
{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.license')}: {server.license}
<a
href={server.repository.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline ml-1"
className="text-blue-500 hover:underline ml-1"
>
{t('market.repository')}
</a>
@@ -130,7 +165,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<div className="flex items-center">
{server.is_official && (
<span className="bg-blue-100 text-blue-800 text-sm font-medium px-4 py-2 rounded mr-2 flex items-center">
<span className="bg-blue-100 text-blue-800 text-sm font-normal px-4 py-2 rounded mr-2 flex items-center label-primary">
{t('market.official')}
</span>
)}
@@ -167,7 +202,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<h3 className="text-lg font-semibold mb-3">{t('market.arguments')}</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
{t('market.argumentName')}
@@ -196,7 +231,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
{arg.required ? (
<span className="text-green-600"></span>
) : (
<span className="text-red-600"></span>
<span className="text-gray-600"></span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
@@ -226,7 +261,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
element.classList.toggle('hidden');
}
}}
className="text-sm text-blue-600 hover:underline focus:outline-none ml-2"
className="text-sm text-blue-500 font-normal hover:underline focus:outline-none ml-2"
>
{t('market.viewSchema')}
</button>
@@ -279,19 +314,73 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
initialData={{
name: server.name,
status: 'disconnected',
config: preferredInstallation
config: preferredInstallation
? {
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {}
}
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {}
}
: undefined
}}
/>
</div>
)}
{confirmationVisible && (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<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>
<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" />
</svg>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-yellow-800">
{t('server.detectedVariables')}:
</h4>
<ul className="mt-1 text-sm text-yellow-700">
{detectedVariables.map((variable, index) => (
<li key={index} className="font-mono">
${`{${variable}}`}
</li>
))}
</ul>
</div>
</div>
</div>
<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)
}}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleConfirmInstall}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 btn-primary"
>
{t('market.confirmAndInstall')}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default MarketServerDetail;
export default MarketServerDetail;

View File

@@ -7,20 +7,20 @@ interface ProtectedRouteProps {
redirectPath?: string;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
redirectPath = '/login'
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
redirectPath = '/login'
}) => {
const { t } = useTranslation();
const { auth } = useAuth();
if (auth.loading) {
return <div className="flex items-center justify-center h-screen">{t('app.loading')}</div>;
}
if (!auth.isAuthenticated) {
return <Navigate to={redirectPath} replace />;
}
return <Outlet />;
};

View File

@@ -11,10 +11,11 @@ interface ServerCardProps {
server: Server
onRemove: (serverName: string) => void
onEdit: (server: Server) => void
onToggle?: (server: Server, enabled: boolean) => void
onToggle?: (server: Server, enabled: boolean) => Promise<boolean>
onRefresh?: () => void
}
const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) => {
const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCardProps) => {
const { t } = useTranslation()
const { showToast } = useToast()
const [isExpanded, setIsExpanded] = useState(false)
@@ -102,9 +103,32 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
setShowDeleteDialog(false)
}
const handleToolToggle = async (toolName: string, enabled: boolean) => {
try {
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()
}
} else {
showToast(result.error || t('tool.toggleFailed'), 'error')
}
} catch (error) {
console.error('Error toggling tool:', error)
showToast(t('tool.toggleFailed'), 'error')
}
}
return (
<>
<div className={`bg-white shadow rounded-lg p-6 mb-6 ${server.enabled === false ? 'opacity-60' : ''}`}>
<div className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
@@ -114,7 +138,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
<StatusBadge status={server.status} />
{/* Tool count display */}
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm">
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm btn-primary">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
</svg>
@@ -150,7 +174,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
<h4 className="text-sm font-medium text-red-600">{t('server.errorDetails')}</h4>
<button
onClick={copyToClipboard}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
className="p-1 text-gray-400 hover:text-gray-600 transition-colors btn-secondary"
title={t('common.copy')}
>
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
@@ -177,7 +201,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
<div className="flex space-x-2">
<button
onClick={handleEdit}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm"
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
>
{t('server.edit')}
</button>
@@ -187,8 +211,8 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
className={`px-3 py-1 text-sm rounded transition-colors ${isToggling
? 'bg-gray-200 text-gray-500'
: server.enabled !== false
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
}`}
disabled={isToggling}
>
@@ -202,11 +226,11 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
</div>
<button
onClick={handleRemove}
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm btn-danger"
>
{t('server.delete')}
</button>
<button className="text-gray-400 hover:text-gray-600">
<button className="text-gray-400 hover:text-gray-600 btn-secondary">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
</div>
@@ -217,7 +241,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h6>
<div className="space-y-4">
{server.tools.map((tool, index) => (
<ToolCard key={index} server={server.name} tool={tool} />
<ToolCard key={index} server={server.name} tool={tool} onToggle={handleToolToggle} />
))}
</div>
</div>

View File

@@ -26,7 +26,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
}
};
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http'>(getInitialServerType());
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http' | 'openapi'>(getInitialServerType());
const [formData, setFormData] = useState<ServerFormData>({
name: (initialData && initialData.name) || '',
@@ -41,7 +41,38 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
args: (initialData && initialData.config && initialData.config.args) || [],
type: getInitialServerType(), // Initialize the type field
env: [],
headers: []
headers: [],
options: {
timeout: (initialData && initialData.config && initialData.config.options && initialData.config.options.timeout) || 60000,
resetTimeoutOnProgress: (initialData && initialData.config && initialData.config.options && initialData.config.options.resetTimeoutOnProgress) || false,
maxTotalTimeout: (initialData && initialData.config && initialData.config.options && initialData.config.options.maxTotalTimeout) || undefined,
},
// OpenAPI configuration initialization
openapi: initialData && initialData.config && initialData.config.openapi ? {
url: initialData.config.openapi.url || '',
schema: initialData.config.openapi.schema ? JSON.stringify(initialData.config.openapi.schema, null, 2) : '',
inputMode: initialData.config.openapi.url ? 'url' : (initialData.config.openapi.schema ? 'schema' : 'url'),
version: initialData.config.openapi.version || '3.1.0',
securityType: initialData.config.openapi.security?.type || 'none',
// API Key initialization
apiKeyName: initialData.config.openapi.security?.apiKey?.name || '',
apiKeyIn: initialData.config.openapi.security?.apiKey?.in || 'header',
apiKeyValue: initialData.config.openapi.security?.apiKey?.value || '',
// HTTP auth initialization
httpScheme: initialData.config.openapi.security?.http?.scheme || 'bearer',
httpCredentials: initialData.config.openapi.security?.http?.credentials || '',
// OAuth2 initialization
oauth2Token: initialData.config.openapi.security?.oauth2?.token || '',
// OpenID Connect initialization
openIdConnectUrl: initialData.config.openapi.security?.openIdConnect?.url || '',
openIdConnectToken: initialData.config.openapi.security?.openIdConnect?.token || ''
} : {
inputMode: 'url',
url: '',
schema: '',
version: '3.1.0',
securityType: 'none'
}
})
const [envVars, setEnvVars] = useState<EnvVar[]>(
@@ -56,6 +87,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
: [],
)
const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const isEdit = !!initialData
@@ -66,11 +98,11 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
// Transform space-separated arguments string into array
const handleArgsChange = (value: string) => {
let args = value.split(' ').filter((arg) => arg.trim() !== '')
const args = value.split(' ').filter((arg) => arg.trim() !== '')
setFormData({ ...formData, arguments: value, args })
}
const updateServerType = (type: 'stdio' | 'sse' | 'streamable-http') => {
const updateServerType = (type: 'stdio' | 'sse' | 'streamable-http' | 'openapi') => {
setServerType(type);
setFormData(prev => ({ ...prev, type }));
}
@@ -107,6 +139,17 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
setHeaderVars(newHeaderVars)
}
// Handle options changes
const handleOptionsChange = (field: 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout', value: number | boolean | undefined) => {
setFormData(prev => ({
...prev,
options: {
...prev.options,
[field]: value
}
}))
}
// Submit handler for server configuration
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -127,21 +170,87 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
}
})
// Prepare options object, only include defined values
const options: any = {}
if (formData.options?.timeout && formData.options.timeout !== 60000) {
options.timeout = formData.options.timeout
}
if (formData.options?.resetTimeoutOnProgress) {
options.resetTimeoutOnProgress = formData.options.resetTimeoutOnProgress
}
if (formData.options?.maxTotalTimeout) {
options.maxTotalTimeout = formData.options.maxTotalTimeout
}
const payload = {
name: formData.name,
config: {
type: serverType, // Always include the type
...(serverType === 'sse' || serverType === 'streamable-http'
...(serverType === 'openapi'
? {
url: formData.url,
openapi: (() => {
const openapi: any = {
version: formData.openapi?.version || '3.1.0'
};
// Add URL or schema based on input mode
if (formData.openapi?.inputMode === 'url') {
openapi.url = formData.openapi?.url || '';
} else if (formData.openapi?.inputMode === 'schema' && formData.openapi?.schema) {
try {
openapi.schema = JSON.parse(formData.openapi.schema);
} catch (e) {
throw new Error('Invalid JSON schema format');
}
}
// Add security configuration if provided
if (formData.openapi?.securityType && formData.openapi.securityType !== 'none') {
openapi.security = {
type: formData.openapi.securityType,
...(formData.openapi.securityType === 'apiKey' && {
apiKey: {
name: formData.openapi.apiKeyName || '',
in: formData.openapi.apiKeyIn || 'header',
value: formData.openapi.apiKeyValue || ''
}
}),
...(formData.openapi.securityType === 'http' && {
http: {
scheme: formData.openapi.httpScheme || 'bearer',
credentials: formData.openapi.httpCredentials || ''
}
}),
...(formData.openapi.securityType === 'oauth2' && {
oauth2: {
token: formData.openapi.oauth2Token || ''
}
}),
...(formData.openapi.securityType === 'openIdConnect' && {
openIdConnect: {
url: formData.openapi.openIdConnectUrl || '',
token: formData.openapi.openIdConnectToken || ''
}
})
};
}
return openapi;
})(),
...(Object.keys(headers).length > 0 ? { headers } : {})
}
: {
command: formData.command,
args: formData.args,
env: Object.keys(env).length > 0 ? env : undefined,
}
)
: serverType === 'sse' || serverType === 'streamable-http'
? {
url: formData.url,
...(Object.keys(headers).length > 0 ? { headers } : {})
}
: {
command: formData.command,
args: formData.args,
env: Object.keys(env).length > 0 ? env : undefined,
}
),
...(Object.keys(options).length > 0 ? { options } : {})
}
}
@@ -177,7 +286,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="name"
value={formData.name}
onChange={handleInputChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="e.g.: time-mcp"
required
disabled={isEdit}
@@ -223,10 +332,334 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
/>
<label htmlFor="streamable-http">Streamable HTTP</label>
</div>
<div>
<input
type="radio"
id="openapi"
name="serverType"
value="openapi"
checked={serverType === 'openapi'}
onChange={() => updateServerType('openapi')}
className="mr-1"
/>
<label htmlFor="openapi">OpenAPI</label>
</div>
</div>
</div>
{serverType === 'sse' || serverType === 'streamable-http' ? (
{serverType === 'openapi' ? (
<>
{/* Input Mode Selection */}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
{t('server.openapi.inputMode')}
</label>
<div className="flex space-x-4">
<div>
<input
type="radio"
id="input-mode-url"
name="inputMode"
value="url"
checked={formData.openapi?.inputMode === 'url'}
onChange={() => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi!, inputMode: 'url' }
}))}
className="mr-1"
/>
<label htmlFor="input-mode-url">{t('server.openapi.inputModeUrl')}</label>
</div>
<div>
<input
type="radio"
id="input-mode-schema"
name="inputMode"
value="schema"
checked={formData.openapi?.inputMode === 'schema'}
onChange={() => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi!, inputMode: 'schema' }
}))}
className="mr-1"
/>
<label htmlFor="input-mode-schema">{t('server.openapi.inputModeSchema')}</label>
</div>
</div>
</div>
{/* URL Input */}
{formData.openapi?.inputMode === 'url' && (
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="openapi-url">
{t('server.openapi.specUrl')}
</label>
<input
type="url"
name="openapi-url"
id="openapi-url"
value={formData.openapi?.url || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi!, url: e.target.value }
}))}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="e.g.: https://api.example.com/openapi.json"
required={serverType === 'openapi' && formData.openapi?.inputMode === 'url'}
/>
</div>
)}
{/* Schema Input */}
{formData.openapi?.inputMode === 'schema' && (
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="openapi-schema">
{t('server.openapi.schema')}
</label>
<textarea
name="openapi-schema"
id="openapi-schema"
rows={10}
value={formData.openapi?.schema || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi!, schema: e.target.value }
}))}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline font-mono text-sm"
placeholder={`{
"openapi": "3.1.0",
"info": {
"title": "API",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.com"
}
],
"paths": {
...
}
}`}
required={serverType === 'openapi' && formData.openapi?.inputMode === 'schema'}
/>
<p className="text-xs text-gray-500 mt-1">{t('server.openapi.schemaHelp')}</p>
</div>
)}
{/* Security Configuration */}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
{t('server.openapi.security')}
</label>
<select
value={formData.openapi?.securityType || 'none'}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: {
...prev.openapi,
securityType: e.target.value as any,
url: prev.openapi?.url || ''
}
}))}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
>
<option value="none">{t('server.openapi.securityNone')}</option>
<option value="apiKey">{t('server.openapi.securityApiKey')}</option>
<option value="http">{t('server.openapi.securityHttp')}</option>
<option value="oauth2">{t('server.openapi.securityOAuth2')}</option>
<option value="openIdConnect">{t('server.openapi.securityOpenIdConnect')}</option>
</select>
</div>
{/* API Key Configuration */}
{formData.openapi?.securityType === 'apiKey' && (
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.apiKeyConfig')}</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.apiKeyName')}</label>
<input
type="text"
value={formData.openapi?.apiKeyName || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, apiKeyName: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm form-input focus:outline-none"
placeholder="Authorization"
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.apiKeyIn')}</label>
<select
value={formData.openapi?.apiKeyIn || 'header'}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, apiKeyIn: e.target.value as any, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
>
<option value="header">Header</option>
<option value="query">Query</option>
<option value="cookie">Cookie</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.apiKeyValue')}</label>
<input
type="password"
value={formData.openapi?.apiKeyValue || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, apiKeyValue: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="your-api-key"
/>
</div>
</div>
</div>
)}
{/* HTTP Authentication Configuration */}
{formData.openapi?.securityType === 'http' && (
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.httpAuthConfig')}</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.httpScheme')}</label>
<select
value={formData.openapi?.httpScheme || 'bearer'}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, httpScheme: e.target.value as any, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
>
<option value="basic">Basic</option>
<option value="bearer">Bearer</option>
<option value="digest">Digest</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.httpCredentials')}</label>
<input
type="password"
value={formData.openapi?.httpCredentials || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, httpCredentials: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder={formData.openapi?.httpScheme === 'basic' ? 'base64-encoded-credentials' : 'bearer-token'}
/>
</div>
</div>
</div>
)}
{/* OAuth2 Configuration */}
{formData.openapi?.securityType === 'oauth2' && (
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.oauth2Config')}</h4>
<div className="grid grid-cols-1 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.oauth2Token')}</label>
<input
type="password"
value={formData.openapi?.oauth2Token || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, oauth2Token: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="access-token"
/>
</div>
</div>
</div>
)}
{/* OpenID Connect Configuration */}
{formData.openapi?.securityType === 'openIdConnect' && (
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.openIdConnectConfig')}</h4>
<div className="grid grid-cols-1 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.openIdConnectUrl')}</label>
<input
type="url"
value={formData.openapi?.openIdConnectUrl || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, openIdConnectUrl: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="https://example.com/.well-known/openid_configuration"
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.openIdConnectToken')}</label>
<input
type="password"
value={formData.openapi?.openIdConnectToken || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, openIdConnectToken: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="id-token"
/>
</div>
</div>
</div>
)}
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<label className="block text-gray-700 text-sm font-bold">
{t('server.headers')}
</label>
<button
type="button"
onClick={addHeaderVar}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center btn-primary"
>
+ {t('server.add')}
</button>
</div>
{headerVars.map((headerVar, index) => (
<div key={index} className="flex items-center mb-2">
<div className="flex items-center space-x-2 flex-grow">
<input
type="text"
value={headerVar.key}
onChange={(e) => handleHeaderVarChange(index, 'key', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder="Authorization"
/>
<span className="flex items-center">:</span>
<input
type="text"
value={headerVar.value}
onChange={(e) => handleHeaderVarChange(index, 'value', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder="Bearer token..."
/>
</div>
<button
type="button"
onClick={() => removeHeaderVar(index)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2 btn-danger"
>
- {t('server.remove')}
</button>
</div>
))}
</div>
</>
) : serverType === 'sse' || serverType === 'streamable-http' ? (
<>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="url">
@@ -238,7 +671,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="url"
value={formData.url}
onChange={handleInputChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder={serverType === 'streamable-http' ? "e.g.: http://localhost:3000/mcp" : "e.g.: http://localhost:3000/sse"}
required={serverType === 'sse' || serverType === 'streamable-http'}
/>
@@ -252,7 +685,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<button
type="button"
onClick={addHeaderVar}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center btn-primary"
>
+ {t('server.add')}
</button>
@@ -264,7 +697,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={headerVar.key}
onChange={(e) => handleHeaderVarChange(index, 'key', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder="Authorization"
/>
<span className="flex items-center">:</span>
@@ -272,14 +705,14 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={headerVar.value}
onChange={(e) => handleHeaderVarChange(index, 'value', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder="Bearer token..."
/>
</div>
<button
type="button"
onClick={() => removeHeaderVar(index)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2 btn-danger"
>
- {t('server.remove')}
</button>
@@ -299,7 +732,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="command"
value={formData.command}
onChange={handleInputChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="e.g.: npx"
required={serverType === 'stdio'}
/>
@@ -314,7 +747,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="arguments"
value={formData.arguments}
onChange={(e) => handleArgsChange(e.target.value)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="e.g.: -y time-mcp"
required={serverType === 'stdio'}
/>
@@ -328,7 +761,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<button
type="button"
onClick={addEnvVar}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center btn-primary"
>
+ {t('server.add')}
</button>
@@ -340,7 +773,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={envVar.key}
onChange={(e) => handleEnvVarChange(index, 'key', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder={t('server.key')}
/>
<span className="flex items-center">:</span>
@@ -348,14 +781,14 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={envVar.value}
onChange={(e) => handleEnvVarChange(index, 'value', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder={t('server.value')}
/>
</div>
<button
type="button"
onClick={() => removeEnvVar(index)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2 btn-danger"
>
- {t('server.remove')}
</button>
@@ -365,17 +798,88 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</>
)}
{/* Request Options Configuration */}
{serverType !== 'openapi' && (
<div className="mb-4">
<div
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200"
onClick={() => setIsRequestOptionsExpanded(!isRequestOptionsExpanded)}
>
<label className="text-gray-700 text-sm font-bold">
{t('server.requestOptions')}
</label>
<span className="text-gray-500 text-sm">
{isRequestOptionsExpanded ? '▼' : '▶'}
</span>
</div>
{isRequestOptionsExpanded && (
<div className="border border-gray-200 rounded-b p-4 bg-gray-50 border-t-0">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-gray-600 text-sm font-medium mb-1" htmlFor="timeout">
{t('server.timeout')}
</label>
<input
type="number"
id="timeout"
value={formData.options?.timeout || 60000}
onChange={(e) => handleOptionsChange('timeout', parseInt(e.target.value) || 60000)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="30000"
min="1000"
max="300000"
/>
<p className="text-xs text-gray-500 mt-1">{t('server.timeoutDescription')}</p>
</div>
<div>
<label className="block text-gray-600 text-sm font-medium mb-1" htmlFor="maxTotalTimeout">
{t('server.maxTotalTimeout')}
</label>
<input
type="number"
id="maxTotalTimeout"
value={formData.options?.maxTotalTimeout || ''}
onChange={(e) => handleOptionsChange('maxTotalTimeout', e.target.value ? parseInt(e.target.value) : undefined)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="Optional"
min="1000"
/>
<p className="text-xs text-gray-500 mt-1">{t('server.maxTotalTimeoutDescription')}</p>
</div>
</div>
<div className="mt-3">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.options?.resetTimeoutOnProgress || false}
onChange={(e) => handleOptionsChange('resetTimeoutOnProgress', e.target.checked)}
className="mr-2"
/>
<span className="text-gray-600 text-sm">{t('server.resetTimeoutOnProgress')}</span>
</label>
<p className="text-xs text-gray-500 mt-1 ml-6">
{t('server.resetTimeoutOnProgressDescription')}
</p>
</div>
</div>
)}
</div>
)}
<div className="flex justify-end mt-6">
<button
type="button"
onClick={onCancel}
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2"
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2 btn-secondary"
>
{t('server.cancel')}
</button>
<button
type="submit"
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded"
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded btn-primary"
>
{isEdit ? t('server.save') : t('server.add')}
</button>

View File

@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@/contexts/AuthContext';
import ThemeSwitch from '@/components/ui/ThemeSwitch';
import GitHubIcon from '@/components/icons/GitHubIcon';
import SponsorIcon from '@/components/icons/SponsorIcon';
@@ -15,13 +14,12 @@ interface HeaderProps {
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
const { t, i18n } = useTranslation();
const { auth } = useAuth();
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
return (
<header className="bg-white dark:bg-gray-800 shadow-sm z-10">
<div className="flex justify-between items-center px-4 py-3">
<div className="flex justify-between items-center px-3 py-3">
<div className="flex items-center">
{/* 侧边栏切换按钮 */}
<button

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, useLocation } from 'react-router-dom';
import { NavLink } from 'react-router-dom';
import UserProfileMenu from '@/components/ui/UserProfileMenu';
interface SidebarProps {
@@ -15,11 +15,10 @@ interface MenuItem {
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
const { t } = useTranslation();
const location = useLocation();
// Application version from package.json (accessed via Vite environment variables)
const appVersion = import.meta.env.PACKAGE_VERSION as string;
// Menu item configuration
const menuItems: MenuItem[] = [
{
@@ -71,10 +70,9 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
];
return (
<aside
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${
collapsed ? 'w-16' : 'w-64'
}`}
<aside
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${collapsed ? 'w-16' : 'w-64'
}`}
>
{/* Scrollable navigation area */}
<div className="overflow-y-auto flex-grow">
@@ -83,12 +81,11 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md transition-colors ${
isActive
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`
className={({ isActive }) =>
`flex items-center px-2.5 py-2 rounded-lg transition-colors duration-200
${isActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-100'}`
}
end={item.path === '/'}
>
@@ -98,7 +95,7 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
))}
</nav>
</div>
{/* User profile menu fixed at the bottom */}
<div className="p-3 bg-white dark:bg-gray-800">
<UserProfileMenu collapsed={collapsed} version={appVersion} />

View File

@@ -93,7 +93,7 @@ const AboutDialog: React.FC<AboutDialogProps> = ({ isOpen, onClose, version }) =
<button
onClick={checkForUpdates}
disabled={isChecking}
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium btn-secondary
${isChecking
? 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800'
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600'

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ServerStatus } from '@/types';
import { cn } from '../../utils/cn';
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'destructive';
@@ -19,11 +18,11 @@ const badgeVariants = {
destructive: 'bg-red-500 text-white hover:bg-red-600',
};
export function Badge({
children,
variant = 'default',
className,
onClick
export function Badge({
children,
variant = 'default',
className,
onClick
}: BadgeProps) {
return (
<span
@@ -43,11 +42,11 @@ export function Badge({
// For backward compatibility with existing code
export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
const { t } = useTranslation();
const colors = {
connecting: 'bg-yellow-100 text-yellow-800',
connected: 'bg-green-100 text-green-800',
disconnected: 'bg-red-100 text-red-800',
connecting: 'status-badge-connecting',
connected: 'status-badge-online',
disconnected: 'status-badge-offline',
};
// Map status to translation keys

View File

@@ -0,0 +1,142 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'info';
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText,
cancelText,
variant = 'warning'
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
const getVariantStyles = () => {
switch (variant) {
case 'danger':
return {
icon: (
<svg className="w-6 h-6 text-red-600" 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>
),
confirmClass: 'bg-red-600 hover:bg-red-700 text-white',
};
case 'warning':
return {
icon: (
<svg className="w-6 h-6 text-yellow-600" 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>
),
confirmClass: 'bg-yellow-600 hover:bg-yellow-700 text-white',
};
case 'info':
return {
icon: (
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white',
};
default:
return {
icon: null,
confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white',
};
}
};
const { icon, confirmClass } = getVariantStyles();
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'Enter') {
onConfirm();
}
};
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 rounded-lg shadow-xl max-w-md w-full transform transition-all duration-200 ease-out"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-message"
>
<div className="p-6">
<div className="flex items-start space-x-3">
{icon && (
<div className="flex-shrink-0">
{icon}
</div>
)}
<div className="flex-1">
{title && (
<h3
id="confirm-dialog-title"
className="text-lg font-medium text-gray-900 mb-2"
>
{title}
</h3>
)}
<p
id="confirm-dialog-message"
className="text-gray-600 leading-relaxed"
>
{message}
</p>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-md transition-colors duration-150 btn-secondary"
autoFocus
>
{cancelText || t('common.cancel')}
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 rounded-md transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 ${confirmClass} ${variant === 'danger' ? 'btn-danger' : variant === 'warning' ? 'btn-warning' : 'btn-primary'}`}
>
{confirmText || t('common.confirm')}
</button>
</div>
</div>
</div>
</div>
);
};
export default ConfirmDialog;

View File

@@ -28,13 +28,13 @@ const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 btn-danger"
>
{t('common.delete')}
</button>

View File

@@ -18,12 +18,16 @@ interface DynamicFormProps {
onCancel: () => void;
loading?: boolean;
storageKey?: string; // Optional key for localStorage persistence
title?: string; // Optional title to display instead of default parameters title
}
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey }) => {
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey, title }) => {
const { t } = useTranslation();
const [formValues, setFormValues] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isJsonMode, setIsJsonMode] = useState<boolean>(false);
const [jsonText, setJsonText] = useState<string>('');
const [jsonError, setJsonError] = useState<string>('');
// Convert ToolInputSchema to JsonSchema - memoized to prevent infinite re-renders
const jsonSchema = useMemo(() => {
@@ -77,7 +81,13 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
} else if (propSchema.type === 'array') {
values[key] = [];
} else if (propSchema.type === 'object') {
values[key] = initializeValues(propSchema, fullPath);
// For objects with properties, recursively initialize
if (propSchema.properties) {
values[key] = initializeValues(propSchema, fullPath);
} else {
// For objects without properties, initialize as empty object
values[key] = {};
}
}
});
}
@@ -104,6 +114,58 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
setFormValues(initialValues);
}, [jsonSchema, storageKey]);
// Sync JSON text with form values when switching modes
useEffect(() => {
if (isJsonMode && Object.keys(formValues).length > 0) {
setJsonText(JSON.stringify(formValues, null, 2));
setJsonError('');
}
}, [isJsonMode, formValues]);
const handleJsonTextChange = (text: string) => {
setJsonText(text);
setJsonError('');
try {
const parsedJson = JSON.parse(text);
setFormValues(parsedJson);
// Save to localStorage if storageKey is provided
if (storageKey) {
try {
localStorage.setItem(storageKey, JSON.stringify(parsedJson));
} catch (error) {
console.warn('Failed to save form data to localStorage:', error);
}
}
} catch (error) {
setJsonError(t('tool.invalidJsonFormat'));
}
};
const switchToJsonMode = () => {
setJsonText(JSON.stringify(formValues, null, 2));
setJsonError('');
setIsJsonMode(true);
};
const switchToFormMode = () => {
// Validate JSON before switching
if (jsonText.trim()) {
try {
const parsedJson = JSON.parse(jsonText);
setFormValues(parsedJson);
setJsonError('');
setIsJsonMode(false);
} catch (error) {
setJsonError(t('tool.fixJsonBeforeSwitching'));
return;
}
} else {
setIsJsonMode(false);
}
};
const handleInputChange = (path: string, value: any) => {
setFormValues(prev => {
const newValues = { ...prev };
@@ -140,7 +202,6 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
});
}
};
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
@@ -148,7 +209,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
if (schema.type === 'object' && schema.properties) {
Object.entries(schema.properties).forEach(([key, propSchema]) => {
const fullPath = path ? `${path}.${key}` : key;
const value = values?.[key];
const value = getNestedValue(values, fullPath);
// Check required fields
if (schema.required?.includes(key) && (value === undefined || value === null || value === '')) {
@@ -166,6 +227,15 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
newErrors[fullPath] = `${key} must be an integer`;
} else if (propSchema.type === 'boolean' && typeof value !== 'boolean') {
newErrors[fullPath] = `${key} must be a boolean`;
} else if (propSchema.type === 'array' && Array.isArray(value)) {
// Validate array items
if (propSchema.items) {
value.forEach((item: any, index: number) => {
if (propSchema.items?.type === 'object' && propSchema.items.properties) {
validateObject(propSchema.items, item, `${fullPath}.${index}`);
}
});
}
} else if (propSchema.type === 'object' && typeof value === 'object') {
validateObject(propSchema, value, fullPath);
}
@@ -186,18 +256,240 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
}
};
const getNestedValue = (obj: any, path: string): any => {
return path.split('.').reduce((current, key) => current?.[key], obj);
};
const renderObjectField = (key: string, schema: JsonSchema, currentValue: any, onChange: (value: any) => void): React.ReactNode => {
const value = currentValue?.[key];
if (schema.type === 'string') {
if (schema.enum) {
return (
<select
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">{t('tool.selectOption')}</option>
{schema.enum.map((option: any, idx: number) => (
<option key={idx} value={option}>
{option}
</option>
))}
</select>
);
} else {
return (
<input
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
placeholder={schema.description || t('tool.enterKey', { key })}
/>
);
}
}
if (schema.type === 'number' || schema.type === 'integer') {
return (
<input
type="number"
step={schema.type === 'integer' ? '1' : 'any'}
value={value || ''}
onChange={(e) => {
const val = e.target.value === '' ? '' : schema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
onChange(val);
}}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
/>
);
}
if (schema.type === 'boolean') {
return (
<input
type="checkbox"
checked={value || false}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
);
}
// Default to text input
return (
<input
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
placeholder={schema.description || t('tool.enterKey', { key })}
/>
);
};
const renderField = (key: string, propSchema: JsonSchema, path: string = ''): React.ReactNode => {
const fullPath = path ? `${path}.${key}` : key;
const value = formValues[key];
const error = errors[fullPath];
const value = getNestedValue(formValues, fullPath);
const error = errors[fullPath]; // Handle array type
if (propSchema.type === 'array') {
const arrayValue = getNestedValue(formValues, fullPath) || [];
if (propSchema.type === 'string') {
return (
<div key={fullPath} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
)}
<div className="border border-gray-200 rounded-md p-3 bg-gray-50">
{arrayValue.map((item: any, index: number) => (
<div key={index} className="mb-3 p-3 bg-white border rounded-md">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-600">{t('tool.item', { index: index + 1 })}</span>
<button
type="button"
onClick={() => {
const newArray = [...arrayValue];
newArray.splice(index, 1);
handleInputChange(fullPath, newArray);
}}
className="text-status-red hover:text-red-700 text-sm"
>
{t('common.remove')}
</button>
</div>
{propSchema.items?.type === 'string' && propSchema.items.enum ? (
<select
value={item || ''}
onChange={(e) => {
const newArray = [...arrayValue];
newArray[index] = e.target.value;
handleInputChange(fullPath, newArray);
}}
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">{t('tool.selectOption')}</option>
{propSchema.items.enum.map((option: any, idx: number) => (
<option key={idx} value={option}>
{option}
</option>
))}
</select>
) : propSchema.items?.type === 'object' && propSchema.items.properties ? (
<div className="space-y-3">
{Object.entries(propSchema.items.properties).map(([objKey, objSchema]) => (
<div key={objKey}>
<label className="block text-xs font-medium text-gray-600 mb-1">
{objKey}
{propSchema.items?.required?.includes(objKey) && <span className="text-status-red ml-1">*</span>}
</label>
{renderObjectField(objKey, objSchema as JsonSchema, item, (newValue) => {
const newArray = [...arrayValue];
newArray[index] = { ...newArray[index], [objKey]: newValue };
handleInputChange(fullPath, newArray);
})}
</div>
))}
</div>
) : (
<input
type="text"
value={item || ''}
onChange={(e) => {
const newArray = [...arrayValue];
newArray[index] = e.target.value;
handleInputChange(fullPath, newArray);
}}
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
placeholder={t('tool.enterValue', { type: propSchema.items?.type || 'value' })}
/>
)}
</div>
))}
<button
type="button"
onClick={() => {
const newItem = propSchema.items?.type === 'object' ? {} : '';
handleInputChange(fullPath, [...arrayValue, newItem]);
}}
className="w-full mt-2 px-3 py-2 text-sm text-blue-600 border border-blue-300 rounded-md hover:bg-blue-50"
>
{t('tool.addItem', { key })}
</button>
</div>
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} // Handle object type
if (propSchema.type === 'object') {
if (propSchema.properties) {
// Object with defined properties - render as nested form
return (
<div key={fullPath} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
)}
<div className="border border-gray-200 rounded-md p-4 bg-gray-50">
{Object.entries(propSchema.properties).map(([objKey, objSchema]) => (
renderField(objKey, objSchema as JsonSchema, fullPath)
))}
</div>
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} else {
// Object without defined properties - render as JSON textarea
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
<span className="text-xs text-gray-500 ml-1">(JSON object)</span>
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
)}
<textarea
value={typeof value === 'object' ? JSON.stringify(value, null, 2) : value || '{}'}
onChange={(e) => {
try {
const parsedValue = JSON.parse(e.target.value);
handleInputChange(fullPath, parsedValue);
} catch (err) {
// Keep the string value if it's not valid JSON yet
handleInputChange(fullPath, e.target.value);
}
}}
placeholder={`{\n "key": "value"\n}`}
className={`w-full border rounded-md px-3 py-2 font-mono text-sm ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
rows={4}
/>
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
}
} if (propSchema.type === 'string') {
if (propSchema.enum) {
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -214,7 +506,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
</option>
))}
</select>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} else {
@@ -222,7 +514,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -231,20 +523,18 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
type="text"
value={value || ''}
onChange={(e) => handleInputChange(fullPath, e.target.value)}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red' : 'border-gray-200'} focus:outline-none form-input`}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
}
}
if (propSchema.type === 'number' || propSchema.type === 'integer') {
} if (propSchema.type === 'number' || propSchema.type === 'integer') {
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -257,9 +547,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
const val = e.target.value === '' ? '' : propSchema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
handleInputChange(fullPath, val);
}}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
className={`w-full border rounded-md px-3 py-2 form-input ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
}
@@ -276,23 +566,21 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
/>
<label className="ml-2 block text-sm text-gray-700">
{key}
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
</div>
{propSchema.description && (
<p className="text-xs text-gray-500 mt-1">{propSchema.description}</p>
)}
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
}
// For other types, show as text input with description
} // For other types, show as text input with description
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
<span className="text-xs text-gray-500 ml-1">({propSchema.type})</span>
</label>
{propSchema.description && (
@@ -303,9 +591,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
value={value || ''}
onChange={(e) => handleInputChange(fullPath, e.target.value)}
placeholder={t('tool.enterValue', { type: propSchema.type })}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500 form-input`}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
};
@@ -335,28 +623,101 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{Object.entries(jsonSchema.properties || {}).map(([key, propSchema]) =>
renderField(key, propSchema)
)}
<div className="flex justify-end space-x-2 pt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200"
>
{t('tool.cancel')}
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? t('tool.running') : t('tool.runTool')}
</button>
<div className="space-y-4">
{/* Mode Toggle */}
<div className="flex justify-between items-center pb-3">
<h6 className="text-md font-medium text-gray-900">{title}</h6>
<div className="flex space-x-2">
<button
type="button"
onClick={switchToFormMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${!isJsonMode
? 'bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`}
>
{t('tool.formMode')}
</button>
<button
type="button"
onClick={switchToJsonMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${isJsonMode
? 'px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`}
>
{t('tool.jsonMode')}
</button>
</div>
</div>
</form>
{/* JSON Mode */}
{isJsonMode ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('tool.jsonConfiguration')}
</label>
<textarea
value={jsonText}
onChange={(e) => handleJsonTextChange(e.target.value)}
placeholder={`{\n "key": "value"\n}`}
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y form-input ${jsonError ? 'border-red-500' : 'border-gray-300'
} focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
{jsonError && <p className="text-status-red text-xs mt-1">{jsonError}</p>}
</div>
<div className="flex justify-end space-x-2 pt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-1 text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
>
{t('tool.cancel')}
</button>
<button
onClick={() => {
try {
const parsedJson = JSON.parse(jsonText);
onSubmit(parsedJson);
} catch (error) {
setJsonError(t('tool.invalidJsonFormat'));
}
}}
disabled={loading || !!jsonError}
className="px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
>
{loading ? t('tool.running') : t('tool.runTool')}
</button>
</div>
</div>
) : (
/* Form Mode */
<form onSubmit={handleSubmit} className="space-y-4">
{Object.entries(jsonSchema.properties || {}).map(([key, propSchema]) =>
renderField(key, propSchema)
)}
<div className="flex justify-end space-x-2 pt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-1 text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
>
{t('tool.cancel')}
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
>
{loading ? t('tool.running') : t('tool.runTool')}
</button>
</div>
</form>
)}
</div>
);
};

View File

@@ -6,34 +6,33 @@ interface PaginationProps {
onPageChange: (page: number) => void;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange
}) => {
// Generate page buttons
const getPageButtons = () => {
const buttons = [];
const maxDisplayedPages = 5; // Maximum number of page buttons to display
// Always display first page
buttons.push(
<button
key="first"
onClick={() => onPageChange(1)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === 1
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 mx-1 rounded ${currentPage === 1
? 'bg-blue-500 text-white btn-primary'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
1
</button>
);
// Start range
let startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
const startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
// If we're showing ellipsis after first page
if (startPage > 2) {
buttons.push(
@@ -42,24 +41,23 @@ const Pagination: React.FC<PaginationProps> = ({
</span>
);
}
// Middle pages
for (let i = startPage; i <= Math.min(totalPages - 1, startPage + maxDisplayedPages - 3); i++) {
buttons.push(
<button
key={i}
onClick={() => onPageChange(i)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === i
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 mx-1 rounded ${currentPage === i
? 'bg-blue-500 text-white btn-primary'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
{i}
</button>
);
}
// If we're showing ellipsis before last page
if (startPage + maxDisplayedPages - 3 < totalPages - 1) {
buttons.push(
@@ -68,24 +66,23 @@ const Pagination: React.FC<PaginationProps> = ({
</span>
);
}
// Always display last page if there's more than one page
if (totalPages > 1) {
buttons.push(
<button
key="last"
onClick={() => onPageChange(totalPages)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === totalPages
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 mx-1 rounded ${currentPage === totalPages
? 'bg-blue-500 text-white btn-primary'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
{totalPages}
</button>
);
}
return buttons;
};
@@ -99,25 +96,23 @@ const Pagination: React.FC<PaginationProps> = ({
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className={`px-3 py-1 rounded mr-2 ${
currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 rounded mr-2 ${currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
&laquo; Prev
</button>
<div className="flex">{getPageButtons()}</div>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className={`px-3 py-1 rounded ml-2 ${
currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 rounded ml-2 ${currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
Next &raquo;
</button>

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@/contexts/ThemeContext';
import { Sun, Moon, Monitor } from 'lucide-react';
import { Sun, Moon } from 'lucide-react';
const ThemeSwitch: React.FC = () => {
const { t } = useTranslation();
@@ -9,7 +9,7 @@ const ThemeSwitch: React.FC = () => {
return (
<div className="flex items-center space-x-2">
<div className="flex bg-gray-200 dark:bg-gray-700 rounded-lg p-1">
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<button
onClick={() => setTheme('light')}
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'light'

View File

@@ -9,7 +9,6 @@ interface ToggleGroupItemProps {
}
export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
value,
isSelected,
onClick,
children
@@ -21,8 +20,8 @@ export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
aria-checked={isSelected}
className={cn(
"flex w-full items-center justify-between p-2 rounded transition-colors cursor-pointer",
isSelected
? "bg-blue-50 text-blue-700 hover:bg-blue-100 border-l-4 border-blue-500"
isSelected
? "bg-blue-50 text-blue-700 hover:bg-blue-100 border-l-4 border-blue-500"
: "hover:bg-gray-50 text-gray-700"
)}
onClick={onClick}
@@ -72,7 +71,7 @@ export const ToggleGroup: React.FC<ToggleGroupProps> = ({
<label className="block text-gray-700 text-sm font-bold mb-2">
{label}
</label>
<div className="border rounded shadow max-h-60 overflow-y-auto">
<div className="border border-gray-200 rounded shadow max-h-60 overflow-y-auto">
{options.length === 0 ? (
<p className="text-gray-500 text-sm p-3">{noOptionsText}</p>
) : (
@@ -118,7 +117,7 @@ export const Switch: React.FC<SwitchProps> = ({
disabled={disabled}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500",
checked ? "bg-blue-600" : "bg-gray-200",
checked ? "bg-blue-200" : "bg-gray-100",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
)}
onClick={() => !disabled && onCheckedChange(!checked)}

View File

@@ -1,22 +1,48 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Tool } from '@/types'
import { ChevronDown, ChevronRight, Play, Loader } from '@/components/icons/LucideIcons'
import { callTool, ToolCallResult } from '@/services/toolService'
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check } from '@/components/icons/LucideIcons'
import { callTool, ToolCallResult, updateToolDescription } from '@/services/toolService'
import { Switch } from './ToggleGroup'
import DynamicForm from './DynamicForm'
import ToolResult from './ToolResult'
interface ToolCardProps {
server: string
tool: Tool
onToggle?: (toolName: string, enabled: boolean) => void
onDescriptionUpdate?: (toolName: string, description: string) => void
}
const ToolCard = ({ tool, server }: ToolCardProps) => {
const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps) => {
const { t } = useTranslation()
const [isExpanded, setIsExpanded] = useState(false)
const [showRunForm, setShowRunForm] = useState(false)
const [isRunning, setIsRunning] = useState(false)
const [result, setResult] = useState<ToolCallResult | null>(null)
const [isEditingDescription, setIsEditingDescription] = useState(false)
const [customDescription, setCustomDescription] = useState(tool.description || '')
const descriptionInputRef = useRef<HTMLInputElement>(null)
const descriptionTextRef = useRef<HTMLSpanElement>(null)
const [textWidth, setTextWidth] = useState<number>(0)
// Focus the input when editing mode is activated
useEffect(() => {
if (isEditingDescription && descriptionInputRef.current) {
descriptionInputRef.current.focus()
// Set input width to match text width
if (textWidth > 0) {
descriptionInputRef.current.style.width = `${textWidth + 20}px` // Add some padding
}
}
}, [isEditingDescription, textWidth])
// Measure text width when not editing
useEffect(() => {
if (!isEditingDescription && descriptionTextRef.current) {
setTextWidth(descriptionTextRef.current.offsetWidth)
}
}, [isEditingDescription, customDescription])
// Generate a unique key for localStorage based on tool name and server
const getStorageKey = useCallback(() => {
@@ -28,6 +54,49 @@ const ToolCard = ({ tool, server }: ToolCardProps) => {
localStorage.removeItem(getStorageKey())
}, [getStorageKey])
const handleToggle = (enabled: boolean) => {
if (onToggle) {
onToggle(tool.name, enabled)
}
}
const handleDescriptionEdit = () => {
setIsEditingDescription(true)
}
const handleDescriptionSave = async () => {
try {
const result = await updateToolDescription(server, tool.name, customDescription)
if (result.success) {
setIsEditingDescription(false)
if (onDescriptionUpdate) {
onDescriptionUpdate(tool.name, customDescription)
}
} else {
// Revert on error
setCustomDescription(tool.description || '')
console.error('Failed to update tool description:', result.error)
}
} catch (error) {
console.error('Error updating tool description:', error)
setCustomDescription(tool.description || '')
setIsEditingDescription(false)
}
}
const handleDescriptionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCustomDescription(e.target.value)
}
const handleDescriptionKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleDescriptionSave()
} else if (e.key === 'Escape') {
setCustomDescription(tool.description || '')
setIsEditingDescription(false)
}
}
const handleRunTool = async (arguments_: Record<string, any>) => {
setIsRunning(true)
try {
@@ -61,28 +130,76 @@ const ToolCard = ({ tool, server }: ToolCardProps) => {
}
return (
<div className="bg-white border border-gray-300 shadow rounded-lg p-4 mb-4">
<div className="bg-white border border-gray-200 shadow rounded-lg p-4 mb-4">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900">
{tool.name}
<span className="ml-2 text-sm font-normal text-gray-600">
{tool.description || t('tool.noDescription')}
{tool.name.replace(server + '-', '')}
<span className="ml-2 text-sm font-normal text-gray-600 inline-flex items-center">
{isEditingDescription ? (
<>
<input
ref={descriptionInputRef}
type="text"
className="px-2 py-1 border border-blue-300 rounded bg-white text-sm focus:outline-none form-input"
value={customDescription}
onChange={handleDescriptionChange}
onKeyDown={handleDescriptionKeyDown}
onClick={(e) => e.stopPropagation()}
style={{
minWidth: '100px',
width: textWidth > 0 ? `${textWidth + 20}px` : 'auto'
}}
/>
<button
className="ml-2 p-1 text-green-600 hover:text-green-800 cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation()
handleDescriptionSave()
}}
>
<Check size={16} />
</button>
</>
) : (
<>
<span ref={descriptionTextRef}>{customDescription || t('tool.noDescription')}</span>
<button
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation()
handleDescriptionEdit()
}}
>
<Edit size={14} />
</button>
</>
)}
</span>
</h3>
</div>
<div className="flex items-center space-x-2">
<div
className="flex items-center space-x-2"
onClick={(e) => e.stopPropagation()}
>
<Switch
checked={tool.enabled ?? true}
onCheckedChange={handleToggle}
disabled={isRunning}
/>
</div>
<button
onClick={(e) => {
e.stopPropagation()
setIsExpanded(true) // Ensure card is expanded when showing run form
setShowRunForm(true)
}}
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors"
disabled={isRunning}
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors btn-primary"
disabled={isRunning || !tool.enabled}
>
{isRunning ? (
<Loader size={14} className="animate-spin" />
@@ -111,14 +228,14 @@ const ToolCard = ({ tool, server }: ToolCardProps) => {
{/* Run Form */}
{showRunForm && (
<div className="border border-gray-300 rounded-lg p-4 bg-blue-50">
<h4 className="text-sm font-medium text-gray-900 mb-3">{t('tool.runToolWithName', { name: tool.name })}</h4>
<div className="border border-gray-300 rounded-lg p-4">
<DynamicForm
schema={tool.inputSchema || { type: 'object' }}
onSubmit={handleRunTool}
onCancel={handleCancelRun}
loading={isRunning}
storageKey={getStorageKey()}
title={t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}
/>
{/* Tool Result */}
{result && (

View File

@@ -65,7 +65,6 @@ const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
// For other structured content, try to parse as JSON
try {
const jsonString = typeof item === 'string' ? item : JSON.stringify(item, null, 2);
const parsed = typeof item === 'string' ? JSON.parse(item) : item;
return (
@@ -97,9 +96,9 @@ const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{result.success ? (
<CheckCircle size={20} className="text-green-500" />
<CheckCircle size={20} className="text-status-green" />
) : (
<XCircle size={20} className="text-red-500" />
<XCircle size={20} className="text-status-red" />
)}
<div>
<h4 className="text-sm font-medium text-gray-900">

View File

@@ -73,7 +73,7 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
}`}
>
<div className="flex-shrink-0 relative">
<div className="w-7 h-7 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
<div className="w-5 h-5 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
<User className="h-4 w-4 text-gray-700 dark:text-gray-300" />
</div>
{showNewVersionInfo && (
@@ -90,7 +90,7 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
</button>
{isOpen && (
<div className="absolute top-0 transform -translate-y-full left-0 w-48 bg-white dark:bg-gray-800 shadow-lg rounded-md py-1 z-50">
<div className="absolute top-0 transform -translate-y-full left-0 w-full min-w-max bg-white border border-gray-200 dark:bg-gray-800 py-1 z-50">
<button
onClick={handleSettingsClick}
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"

View File

@@ -1,10 +1,10 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { AuthState, IUser } from '../types';
import { AuthState } from '../types';
import * as authService from '../services/authService';
import { shouldSkipAuth } from '../services/configService';
// Initial auth state
const initialState: AuthState = {
token: null,
isAuthenticated: false,
loading: true,
user: null,
@@ -21,7 +21,7 @@ const AuthContext = createContext<{
auth: initialState,
login: async () => false,
register: async () => false,
logout: () => {},
logout: () => { },
});
// Auth provider component
@@ -31,8 +31,26 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// Load user if token exists
useEffect(() => {
const loadUser = async () => {
// First check if authentication should be skipped
const skipAuth = await shouldSkipAuth();
if (skipAuth) {
// If authentication is disabled, set user as authenticated with a dummy user
setAuth({
isAuthenticated: true,
loading: false,
user: {
username: 'guest',
isAdmin: true,
},
error: null,
});
return;
}
// Normal authentication flow
const token = authService.getToken();
if (!token) {
setAuth({
...initialState,
@@ -40,13 +58,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
});
return;
}
try {
const response = await authService.getCurrentUser();
if (response.success && response.user) {
setAuth({
token,
isAuthenticated: true,
loading: false,
user: response.user,
@@ -67,7 +84,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
});
}
};
loadUser();
}, []);
@@ -75,10 +92,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await authService.login({ username, password });
if (response.success && response.token && response.user) {
setAuth({
token: response.token,
isAuthenticated: true,
loading: false,
user: response.user,
@@ -105,16 +121,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// Register function
const register = async (
username: string,
password: string,
username: string,
password: string,
isAdmin = false
): Promise<boolean> => {
try {
const response = await authService.register({ username, password, isAdmin });
if (response.success && response.token && response.user) {
setAuth({
token: response.token,
isAuthenticated: true,
loading: false,
user: response.user,

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { MarketServer, ApiResponse } from '@/types';
import { MarketServer, ApiResponse, ServerConfig } from '@/types';
import { getApiUrl } from '../utils/runtime';
export const useMarketData = () => {
@@ -347,7 +347,7 @@ export const useMarketData = () => {
// Install server to the local environment
const installServer = useCallback(
async (server: MarketServer) => {
async (server: MarketServer, customConfig: ServerConfig) => {
try {
const installType = server.installations?.npm
? 'npm'
@@ -362,14 +362,14 @@ export const useMarketData = () => {
const installation = server.installations[installType];
// Prepare server configuration
// Prepare server configuration, merging with customConfig
const serverConfig = {
name: server.name,
config: {
command: installation.command,
args: installation.args,
env: installation.env || {},
},
config: customConfig.type === 'stdio' ? {
command: customConfig.command || installation.command || '',
args: customConfig.args || installation.args || [],
env: { ...installation.env, ...customConfig.env },
} : customConfig
};
// Call the createServer API

View File

@@ -10,6 +10,7 @@ interface RoutingConfig {
enableGroupNameRoute: boolean;
enableBearerAuth: boolean;
bearerAuthKey: string;
skipAuth: boolean;
}
interface InstallConfig {
@@ -46,6 +47,7 @@ export const useSettingsData = () => {
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
skipAuth: false,
});
const [tempRoutingConfig, setTempRoutingConfig] = useState<TempRoutingConfig>({
@@ -99,6 +101,7 @@ export const useSettingsData = () => {
enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true,
enableBearerAuth: data.data.systemConfig.routing.enableBearerAuth ?? false,
bearerAuthKey: data.data.systemConfig.routing.bearerAuthKey || '',
skipAuth: data.data.systemConfig.routing.skipAuth ?? false,
});
}
if (data.success && data.data?.systemConfig?.install) {

View File

@@ -1,11 +1,22 @@
/* Use project's custom Tailwind import */
@import "tailwindcss";
@import 'tailwindcss';
/* Add some custom styles to verify CSS is working correctly */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
font-family:
'Inter',
'PingFang SC',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
'Roboto',
'Oxygen',
'Ubuntu',
'Cantarell',
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -13,7 +24,7 @@ body {
/* Dark mode override styles - these will apply when dark class is on html element */
.dark body {
background-color: #111827;
background-color: #1f2a37;
color: #e5e7eb;
}
@@ -37,30 +48,432 @@ body {
color: #d1d5db !important;
}
.dark .text-gray-500 {
/* .dark .text-gray-500 {
color: #9ca3af !important;
}
} */
.dark .border-gray-300 {
border-color: #4b5563 !important;
border-color: #2f3b4c !important;
}
.dark .border-gray-200 {
border-color: #2f3b4c !important;
}
.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) {
border-color: #2f3b4c !important;
}
.dark .bg-gray-100 {
background-color: #374151 !important;
}
/* Specific hover effects for dark mode */
.dark .hover\:bg-gray-100:hover {
background-color: rgba(110, 127, 156, 0.15) !important;
}
.dark .hover\:text-gray-900:hover {
color: rgb(190, 188, 185) !important;
}
.dark .bg-gray-50 {
background-color: #1f2937 !important;
}
.dark .text-blue-700 {
color: white !important;
}
.dark .bg-blue-50 {
background-color: #4b5563 !important;
}
.dark .bg-blue-200 {
background-color: #576476 !important;
}
.dark .shadow {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px 0 rgba(0, 0, 0, 0.24) !important;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15),
0 2px 6px rgba(0, 0, 0, 0.1) !important;
}
.bg-custom-blue {
background-color: #4a90e2;
background-color: #4a90e2;
}
.text-custom-white {
color: #ffffff;
}
}
.status-badge-online {
background-color: white !important;
color: rgba(129, 199, 132, 0.9) !important;
border: 1px solid #a6d7b7;
}
/* Enhanced status badge styles for dark theme */
.dark .status-badge-online {
background-color: rgba(76, 175, 80, 0.15) !important;
color: rgba(129, 199, 132, 0.9) !important;
border: 1px solid rgba(76, 175, 80, 0.3);
}
.status-badge-offline {
background-color: white !important;
color: rgba(107, 114, 128, 0.9) !important;
border: 1px solid #d1d5db;
}
.dark .status-badge-offline {
background-color: rgba(107, 114, 128, 0.15) !important;
color: rgba(156, 163, 175, 0.9) !important;
border: 1px solid rgba(107, 114, 128, 0.3);
}
.status-badge-connecting {
background-color: white !important;
color: rgba(255, 213, 79, 0.9) !important;
border: 1px solid #ffd57f;
}
.dark .status-badge-connecting {
background-color: rgba(255, 193, 7, 0.15) !important;
color: rgba(255, 213, 79, 0.9) !important;
border: 1px solid rgba(255, 193, 7, 0.3);
}
/* Enhanced status icons for dark theme */
.dark .status-icon-blue {
background-color: rgba(59, 130, 246, 0.15) !important;
color: rgba(96, 165, 250, 0.9) !important;
}
.dark .status-icon-green {
background-color: rgba(76, 175, 80, 0.15) !important;
color: rgba(129, 199, 132, 0.9) !important;
}
.dark .status-icon-red {
background-color: rgba(244, 67, 54, 0.15) !important;
color: rgba(239, 154, 154, 0.9) !important;
}
.dark .status-icon-yellow {
background-color: rgba(255, 193, 7, 0.15) !important;
color: rgba(255, 213, 79, 0.9) !important;
}
/* Enhanced card hover effects */
.dashboard-card {
transition: all 0.3s ease;
border-radius: 12px;
}
.dashboard-card:hover {
transform: translateY(-2px);
box-shadow:
0 8px 25px rgba(0, 0, 0, 0.2),
0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
/* Icon container hover effects */
.icon-container {
transition: all 0.3s ease;
}
.icon-container:hover {
transform: scale(1.05);
filter: brightness(1.1);
}
/* Progress bar enhancements */
.progress-bar-online {
background: linear-gradient(90deg, rgba(76, 175, 80, 0.8), rgba(129, 199, 132, 0.6));
}
.progress-bar-offline {
background: linear-gradient(90deg, rgba(244, 67, 54, 0.8), rgba(239, 154, 154, 0.6));
}
.progress-bar-connecting {
background: linear-gradient(90deg, rgba(255, 193, 7, 0.8), rgba(255, 213, 79, 0.6));
}
/* Table enhancements for dark theme */
.dark .table-container {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.dark thead {
background-color: #252d3a !important;
}
.dark tbody tr {
border-bottom: 1px solid #2f3b4c;
}
tbody tr:hover {
background-color: var(--color-gray-100) !important;
transition: background-color 0.2s ease;
}
.dark tbody tr:hover {
background-color: rgba(55, 65, 81, 0.5) !important;
transition: background-color 0.2s ease;
}
/* Error box enhancements for dark theme */
.dark .error-box {
background-color: rgba(244, 67, 54, 0.1) !important;
border-color: rgba(244, 67, 54, 0.3) !important;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.1);
}
.dark .error-box h3 {
color: rgba(239, 154, 154, 0.9) !important;
}
.dark .error-box p {
color: #d1d5db !important;
}
/* Loading container enhancements */
.loading-container {
border-radius: 12px;
backdrop-filter: blur(10px);
}
.dark .loading-container {
background-color: rgba(31, 41, 55, 0.8) !important;
border: 1px solid #2f3b4c;
}
.label-primary {
background-color: var(--color-blue-50) !important;
color: var(--color-blue-500) !important;
}
.dark .label-primary {
background-color: rgba(59, 130, 246, 0.15) !important;
color: rgba(96, 165, 250, 0.9) !important;
}
.label-secondary {
background-color: var(--color-green-50) !important;
color: var(--color-green-500) !important;
}
.dark .label-secondary {
background-color: rgba(76, 175, 80, 0.15) !important;
color: rgba(129, 199, 132, 0.9) !important;
}
.btn-primary {
background-color: var(--color-blue-100) !important;
color: var(--color-blue-800) !important;
border: none;
border-radius: 8px;
transition: all 0.3s ease;
}
.btn-primary:hover {
background-color: var(--color-blue-200) !important;
color: var(--color-blue-800) !important;
}
/* Enhanced button styles for dark theme */
.dark .btn-primary {
background-color: rgba(59, 130, 246, 0.15) !important;
color: rgba(96, 165, 250, 0.9) !important;
border: 1px solid rgba(59, 130, 246, 0.3);
transition: all 0.3s ease;
}
.dark .btn-primary:hover {
background-color: rgba(59, 130, 246, 0.25) !important;
color: rgba(96, 165, 250, 1) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
.btn-secondary {
background-color: #f9fafb !important;
color: #374151 !important;
border: 1px solid #d1d5db !important;
border-radius: 8px;
font-size: 0.875rem;
}
.btn-secondary:hover {
background-color: #e5e7eb !important;
color: #374151 !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.dark .btn-secondary {
background-color: rgba(107, 114, 128, 0.15) !important;
color: rgba(156, 163, 175, 0.9) !important;
border: 1px solid rgba(107, 114, 128, 0.3) !important;
transition: all 0.3s ease;
}
.dark .btn-secondary:hover {
background-color: rgba(107, 114, 128, 0.25) !important;
color: rgba(156, 163, 175, 1) !important;
transform: translateY(-1px);
}
.btn-warning {
background-color: var(--color-yellow-100) !important;
color: var(--color-yellow-800) !important;
border: none;
border-radius: 8px;
transition: all 0.3s ease;
}
.btn-warning:hover {
background-color: var(--color-yellow-200) !important;
color: var(--color-yellow-800) !important;
}
.dark .btn-warning {
background-color: rgba(234, 179, 8, 0.15) !important;
color: rgba(250, 204, 21, 0.9) !important;
border: 1px solid rgba(234, 179, 8, 0.3);
transition: all 0.3s ease;
}
.dark .btn-warning:hover {
background-color: rgba(234, 179, 8, 0.25) !important;
color: rgba(250, 204, 21, 1) !important;
transform: translateY(-1px);
}
.btn-danger {
background-color: var(--color-red-100) !important;
color: var(--color-red-800) !important;
border: none;
border-radius: 8px;
transition: all 0.3s ease;
}
.btn-danger:hover {
background-color: var(--color-red-200) !important;
color: var(--color-red-800) !important;
}
.dark .btn-danger {
background-color: rgba(244, 67, 54, 0.15) !important;
color: rgba(239, 154, 154, 0.9) !important;
border: 1px solid rgba(244, 67, 54, 0.3);
transition: all 0.3s ease;
}
.dark .btn-danger:hover {
background-color: rgba(244, 67, 54, 0.25) !important;
color: rgba(239, 154, 154, 1) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.2);
}
.form-input {
background-color: #f9fafb !important;
border-color: #d1d5db !important;
color: #374151 !important;
border-radius: 8px;
transition: all 0.3s ease;
}
.form-input:focus {
border-color: rgba(184, 193, 207, 0.5);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Form input enhancements for dark theme */
.dark .form-input {
background-color: #1f2937 !important;
border-color: #2f3b4c !important;
color: #e5e7eb !important;
border-radius: 8px;
}
.dark .form-input:focus {
border-color: rgba(59, 130, 246, 0.5) !important;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
}
.dark .form-input::placeholder {
color: #9ca3af !important;
}
/* Card spacing and layout improvements */
.page-card {
border-radius: 12px;
transition: all 0.3s ease;
}
.page-card:hover {
transform: translateY(-1px);
}
.dark .page-card {
background-color: #1f2937 !important;
border: 1px solid #2f3b4c;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15),
0 2px 6px rgba(0, 0, 0, 0.1);
}
/* Custom text color to match status-icon-red */
.text-status-red {
color: #991b1b; /* Tailwind red-800 for light mode */
}
.dark .text-status-red {
color: rgba(239, 154, 154, 0.9) !important;
}
.border-red {
border-color: #937d7d; /* Tailwind red-800 for light mode */
}
.dark .border-red {
border-color: rgba(188, 161, 161, 0.9) !important;
}
.dark .text-status-green {
color: rgba(129, 199, 132, 0.9) !important;
}
/* Empty state styling */
.dark .empty-state {
background-color: #1f2937 !important;
border: 1px solid #2f3b4c;
border-radius: 12px;
text-align: center;
padding: 3rem 2rem;
}
.dark .empty-state p {
color: #9ca3af !important;
}
/* Login page enhancements for dark theme */
.dark .login-container {
background-color: #1f2a37 !important;
}
.dark .login-card {
background-color: #1f2937 !important;
border: 1px solid #2f3b4c;
box-shadow:
0 8px 25px rgba(0, 0, 0, 0.2),
0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 12px;
}

View File

@@ -99,6 +99,13 @@
"enabled": "Enabled",
"enable": "Enable",
"disable": "Disable",
"requestOptions": "Configuration",
"timeout": "Request Timeout",
"timeoutDescription": "Timeout for requests to the MCP server (ms)",
"maxTotalTimeout": "Maximum Total Timeout",
"maxTotalTimeoutDescription": "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
"resetTimeoutOnProgress": "Reset Timeout on Progress",
"resetTimeoutOnProgressDescription": "Reset timeout on progress notifications",
"remove": "Remove",
"toggleError": "Failed to toggle server {{serverName}}",
"alreadyExists": "Server {{serverName}} already exists",
@@ -109,7 +116,38 @@
"commandPlaceholder": "Enter command",
"argumentsPlaceholder": "Enter arguments",
"errorDetails": "Error Details",
"viewErrorDetails": "View error details"
"viewErrorDetails": "View error details",
"confirmVariables": "Confirm Variable Configuration",
"variablesDetected": "Variables detected in configuration. Please confirm these variables are properly configured:",
"detectedVariables": "Detected Variables",
"confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue adding server?",
"confirmAndAdd": "Confirm and Add",
"openapi": {
"inputMode": "Input Mode",
"inputModeUrl": "Specification URL",
"inputModeSchema": "JSON Schema",
"specUrl": "OpenAPI Specification URL",
"schema": "OpenAPI JSON Schema",
"schemaHelp": "Paste your complete OpenAPI JSON schema here",
"security": "Security Type",
"securityNone": "None",
"securityApiKey": "API Key",
"securityHttp": "HTTP Authentication",
"securityOAuth2": "OAuth 2.0",
"securityOpenIdConnect": "OpenID Connect",
"apiKeyConfig": "API Key Configuration",
"apiKeyName": "Header/Parameter Name",
"apiKeyIn": "Location",
"apiKeyValue": "API Key Value",
"httpAuthConfig": "HTTP Authentication Configuration",
"httpScheme": "Authentication Scheme",
"httpCredentials": "Credentials",
"oauth2Config": "OAuth 2.0 Configuration",
"oauth2Token": "Access Token",
"openIdConnectConfig": "OpenID Connect Configuration",
"openIdConnectUrl": "Discovery URL",
"openIdConnectToken": "ID Token"
}
},
"status": {
"online": "Online",
@@ -137,10 +175,12 @@
"create": "Create",
"submitting": "Submitting...",
"delete": "Delete",
"remove": "Remove",
"copy": "Copy",
"copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed",
"close": "Close"
"close": "Close",
"confirm": "Confirm"
},
"nav": {
"dashboard": "Dashboard",
@@ -262,7 +302,9 @@
"tagFilterError": "Error filtering servers by tag",
"noInstallationMethod": "No installation method available for this server",
"showing": "Showing {{from}}-{{to}} of {{total}} servers",
"perPage": "Per page"
"perPage": "Per page",
"confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue installing server?",
"confirmAndInstall": "Confirm and Install"
},
"tool": {
"run": "Run",
@@ -284,7 +326,20 @@
"toolResult": "Tool result",
"noParameters": "This tool does not require any parameters.",
"selectOption": "Select an option",
"enterValue": "Enter {{type}} value"
"enterValue": "Enter {{type}} value",
"enabled": "Enabled",
"enableSuccess": "Tool {{name}} enabled successfully",
"disableSuccess": "Tool {{name}} disabled successfully",
"toggleFailed": "Failed to toggle tool status",
"parameters": "Tool Parameters",
"formMode": "Form Mode",
"jsonMode": "JSON Mode",
"jsonConfiguration": "JSON Configuration",
"invalidJsonFormat": "Invalid JSON format",
"fixJsonBeforeSwitching": "Please fix JSON format before switching to form mode",
"item": "Item {{index}}",
"addItem": "Add {{key}} item",
"enterKey": "Enter {{key}}"
},
"settings": {
"enableGlobalRoute": "Enable Global Route",
@@ -296,6 +351,8 @@
"bearerAuthKey": "Bearer Authentication Key",
"bearerAuthKeyDescription": "The authentication key that will be required in the Bearer token",
"bearerAuthKeyPlaceholder": "Enter bearer authentication key",
"skipAuth": "Skip Authentication",
"skipAuthDescription": "Bypass login requirement for frontend and API access (DEFAULT OFF for security)",
"pythonIndexUrl": "Python Package Repository URL",
"pythonIndexUrlDescription": "Set UV_DEFAULT_INDEX environment variable for Python package installation",
"pythonIndexUrlPlaceholder": "e.g. https://pypi.org/simple",
@@ -317,5 +374,30 @@
"smartRoutingConfigUpdated": "Smart routing configuration updated successfully",
"smartRoutingRequiredFields": "Database URL and OpenAI API Key are required to enable smart routing",
"smartRoutingValidationError": "Please fill in the required fields before enabling Smart Routing: {{fields}}"
},
"dxt": {
"upload": "Upload",
"uploadTitle": "Upload DXT Extension",
"dropFileHere": "Drop your .dxt file here",
"orClickToSelect": "or click to select from your computer",
"invalidFileType": "Please select a valid .dxt file",
"noFileSelected": "Please select a .dxt file to upload",
"uploading": "Uploading...",
"uploadFailed": "Failed to upload DXT file",
"installServer": "Install MCP Server from DXT",
"extensionInfo": "Extension Information",
"name": "Name",
"version": "Version",
"description": "Description",
"author": "Author",
"tools": "Tools",
"serverName": "Server Name",
"serverNamePlaceholder": "Enter a name for this server",
"install": "Install",
"installing": "Installing...",
"installFailed": "Failed to install server from DXT",
"serverExistsTitle": "Server Already Exists",
"serverExistsConfirm": "Server '{{serverName}}' already exists. Do you want to override it with the new version?",
"override": "Override"
}
}

View File

@@ -99,6 +99,13 @@
"enabled": "已启用",
"enable": "启用",
"disable": "禁用",
"requestOptions": "配置",
"timeout": "请求超时",
"timeoutDescription": "请求超时时间(毫秒)",
"maxTotalTimeout": "最大总超时",
"maxTotalTimeoutDescription": "无论是否有进度通知的最大总超时时间(毫秒)",
"resetTimeoutOnProgress": "收到进度通知时重置超时",
"resetTimeoutOnProgressDescription": "适用于发送周期性进度更新的长时间运行操作",
"remove": "移除",
"toggleError": "切换服务器 {{serverName}} 状态失败",
"alreadyExists": "服务器 {{serverName}} 已经存在",
@@ -109,7 +116,38 @@
"commandPlaceholder": "请输入命令",
"argumentsPlaceholder": "请输入参数",
"errorDetails": "错误详情",
"viewErrorDetails": "查看错误详情"
"viewErrorDetails": "查看错误详情",
"confirmVariables": "确认变量配置",
"variablesDetected": "检测到配置中包含变量,请确认这些变量是否已正确配置:",
"detectedVariables": "检测到的变量",
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续添加服务器?",
"confirmAndAdd": "确认并添加",
"openapi": {
"inputMode": "输入模式",
"inputModeUrl": "规范 URL",
"inputModeSchema": "JSON 模式",
"specUrl": "OpenAPI 规范 URL",
"schema": "OpenAPI JSON 模式",
"schemaHelp": "请在此处粘贴完整的 OpenAPI JSON 模式",
"security": "安全类型",
"securityNone": "无",
"securityApiKey": "API 密钥",
"securityHttp": "HTTP 认证",
"securityOAuth2": "OAuth 2.0",
"securityOpenIdConnect": "OpenID Connect",
"apiKeyConfig": "API 密钥配置",
"apiKeyName": "请求头/参数名称",
"apiKeyIn": "位置",
"apiKeyValue": "API 密钥值",
"httpAuthConfig": "HTTP 认证配置",
"httpScheme": "认证方案",
"httpCredentials": "凭据",
"oauth2Config": "OAuth 2.0 配置",
"oauth2Token": "访问令牌",
"openIdConnectConfig": "OpenID Connect 配置",
"openIdConnectUrl": "发现 URL",
"openIdConnectToken": "ID 令牌"
}
},
"status": {
"online": "在线",
@@ -138,10 +176,12 @@
"create": "创建",
"submitting": "提交中...",
"delete": "删除",
"remove": "移除",
"copy": "复制",
"copySuccess": "已复制到剪贴板",
"copyFailed": "复制失败",
"close": "关闭"
"close": "关闭",
"confirm": "确认"
},
"nav": {
"dashboard": "仪表盘",
@@ -263,12 +303,14 @@
"tagFilterError": "按标签筛选服务器失败",
"noInstallationMethod": "该服务器没有可用的安装方法",
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
"perPage": "每页显示"
"perPage": "每页显示",
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续安装服务器?",
"confirmAndInstall": "确认并安装"
},
"tool": {
"run": "运行",
"running": "运行中...",
"runTool": "运行工具",
"runTool": "运行",
"cancel": "取消",
"noDescription": "无描述信息",
"inputSchema": "输入模式:",
@@ -285,7 +327,20 @@
"toolResult": "工具结果",
"noParameters": "此工具不需要任何参数。",
"selectOption": "选择一个选项",
"enterValue": "输入{{type}}值"
"enterValue": "输入{{type}}值",
"enabled": "已启用",
"enableSuccess": "工具 {{name}} 启用成功",
"disableSuccess": "工具 {{name}} 禁用成功",
"toggleFailed": "切换工具状态失败",
"parameters": "工具参数",
"formMode": "表单模式",
"jsonMode": "JSON 模式",
"jsonConfiguration": "JSON 配置",
"invalidJsonFormat": "无效的 JSON 格式",
"fixJsonBeforeSwitching": "请修复 JSON 格式后再切换到表单模式",
"item": "项目 {{index}}",
"addItem": "添加 {{key}} 项目",
"enterKey": "输入 {{key}}"
},
"settings": {
"enableGlobalRoute": "启用全局路由",
@@ -297,6 +352,8 @@
"bearerAuthKey": "Bearer 认证密钥",
"bearerAuthKeyDescription": "Bearer 令牌中需要携带的认证密钥",
"bearerAuthKeyPlaceholder": "请输入 Bearer 认证密钥",
"skipAuth": "免登录开关",
"skipAuthDescription": "跳过前端和 API 访问的登录要求(默认关闭确保安全性)",
"pythonIndexUrl": "Python 包仓库地址",
"pythonIndexUrlDescription": "设置 UV_DEFAULT_INDEX 环境变量,用于 Python 包安装",
"pythonIndexUrlPlaceholder": "例如: https://mirrors.aliyun.com/pypi/simple",
@@ -319,5 +376,30 @@
"smartRoutingConfigUpdated": "智能路由配置更新成功",
"smartRoutingRequiredFields": "启用智能路由需要填写数据库连接地址和 OpenAI API 密钥",
"smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}"
},
"dxt": {
"upload": "上传",
"uploadTitle": "上传 DXT 扩展",
"dropFileHere": "将 .dxt 文件拖拽到此处",
"orClickToSelect": "或点击从计算机选择",
"invalidFileType": "请选择有效的 .dxt 文件",
"noFileSelected": "请选择要上传的 .dxt 文件",
"uploading": "上传中...",
"uploadFailed": "上传 DXT 文件失败",
"installServer": "从 DXT 安装 MCP 服务器",
"extensionInfo": "扩展信息",
"name": "名称",
"version": "版本",
"description": "描述",
"author": "作者",
"tools": "工具",
"serverName": "服务器名称",
"serverNamePlaceholder": "为此服务器输入名称",
"install": "安装",
"installing": "安装中...",
"installFailed": "从 DXT 安装服务器失败",
"serverExistsTitle": "服务器已存在",
"serverExistsConfirm": "服务器 '{{serverName}}' 已存在。是否要用新版本覆盖它?",
"override": "覆盖"
}
}

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useServerData } from '@/hooks/useServerData';
import { ServerStatus } from '@/types';
const DashboardPage: React.FC = () => {
const { t } = useTranslation();
@@ -22,26 +21,20 @@ const DashboardPage: React.FC = () => {
connecting: 'status.connecting'
}
// Calculate percentage for each status (for dashboard display)
const getStatusPercentage = (status: ServerStatus) => {
if (servers.length === 0) return 0;
return Math.round((servers.filter(server => server.status === status).length / servers.length) * 100);
};
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.dashboard.title')}</h1>
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
<div className="flex items-center justify-between">
<div>
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
<h3 className="text-status-red text-lg font-medium">{t('app.error')}</h3>
<p className="text-gray-600 mt-1">{error}</p>
</div>
<button
onClick={() => setError(null)}
className="ml-4 text-gray-500 hover:text-gray-700"
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">
@@ -52,8 +45,8 @@ const DashboardPage: React.FC = () => {
</div>
)}
{isLoading ? (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
{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>
@@ -62,12 +55,14 @@ const DashboardPage: React.FC = () => {
<p className="text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : (
)}
{!isLoading && (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{/* Total servers */}
<div className="bg-white rounded-lg shadow p-6">
<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">
<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>
@@ -80,9 +75,9 @@ const DashboardPage: React.FC = () => {
</div>
{/* Online servers */}
<div className="bg-white rounded-lg shadow p-6">
<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">
<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>
@@ -92,18 +87,12 @@ const DashboardPage: React.FC = () => {
<p className="text-3xl font-bold text-gray-900">{serverStats.online}</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${getStatusPercentage('connected')}%` }}
></div>
</div>
</div>
{/* Offline servers */}
<div className="bg-white rounded-lg shadow p-6">
<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">
<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>
@@ -113,18 +102,12 @@ const DashboardPage: React.FC = () => {
<p className="text-3xl font-bold text-gray-900">{serverStats.offline}</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-red-500 rounded-full"
style={{ width: `${getStatusPercentage('disconnected')}%` }}
></div>
</div>
</div>
{/* Connecting servers */}
<div className="bg-white rounded-lg shadow p-6">
<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">
<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>
@@ -134,12 +117,7 @@ const DashboardPage: React.FC = () => {
<p className="text-3xl font-bold text-gray-900">{serverStats.connecting}</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-yellow-500 rounded-full"
style={{ width: `${getStatusPercentage('connecting')}%` }}
></div>
</div>
</div>
</div>
)}
@@ -148,20 +126,20 @@ const DashboardPage: React.FC = () => {
{servers.length > 0 && !isLoading && (
<div className="mt-8">
<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 className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<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-3 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-3 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-3 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-3 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>
@@ -173,11 +151,11 @@ const DashboardPage: React.FC = () => {
{server.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
? 'bg-green-100 text-green-800'
: server.status === 'disconnected'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
<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'
}`}>
{t(statusTranslations[server.status] || server.status)}
</span>
@@ -189,7 +167,7 @@ const DashboardPage: React.FC = () => {
{server.enabled !== false ? (
<span className="text-green-600"></span>
) : (
<span className="text-red-600"></span>
<span className="text-status-red"></span>
)}
</td>
</tr>

View File

@@ -9,16 +9,16 @@ import GroupCard from '@/components/GroupCard';
const GroupsPage: React.FC = () => {
const { t } = useTranslation();
const {
groups,
loading: groupsLoading,
error: groupError,
const {
groups,
loading: groupsLoading,
error: groupError,
setError: setGroupError,
deleteGroup,
triggerRefresh
} = useGroupData();
const { servers } = useServerData();
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
@@ -54,7 +54,7 @@ const GroupsPage: React.FC = () => {
<div className="flex space-x-4">
<button
onClick={handleAddGroup}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
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="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" />
@@ -65,13 +65,13 @@ const GroupsPage: React.FC = () => {
</div>
{groupError && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
<p>{groupError}</p>
</div>
)}
{groupsLoading ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 loading-container">
<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>
@@ -81,7 +81,7 @@ const GroupsPage: React.FC = () => {
</div>
</div>
) : groups.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 empty-state">
<p className="text-gray-600">{t('groups.noGroups')}</p>
</div>
) : (

View File

@@ -26,7 +26,7 @@ const LoginPage: React.FC = () => {
}
const success = await login(username, password);
if (success) {
navigate('/');
} else {
@@ -40,18 +40,18 @@ const LoginPage: React.FC = () => {
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8 login-container">
<div className="absolute top-4 right-4">
<ThemeSwitch />
</div>
<div className="max-w-md w-full space-y-8">
<div className="max-w-md w-full space-y-8 login-card p-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
{t('auth.loginTitle')}
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div className="rounded-md -space-y-px">
<div>
<label htmlFor="username" className="sr-only">
{t('auth.username')}
@@ -62,7 +62,7 @@ const LoginPage: React.FC = () => {
type="text"
autoComplete="username"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm transition-all duration-200 form-input"
placeholder={t('auth.username')}
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -78,7 +78,7 @@ const LoginPage: React.FC = () => {
type="password"
autoComplete="current-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm login-input transition-all duration-200 form-input"
placeholder={t('auth.password')}
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -87,14 +87,14 @@ const LoginPage: React.FC = () => {
</div>
{error && (
<div className="text-red-500 dark:text-red-400 text-sm text-center">{error}</div>
<div className="text-red-500 dark:text-red-400 text-sm text-center error-box p-2 rounded">{error}</div>
)}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 login-button transition-all duration-200 btn-primary"
>
{loading ? t('auth.loggingIn') : t('auth.login')}
</button>

View File

@@ -11,9 +11,9 @@ const LogsPage: React.FC = () => {
return (
<div className="container mx-auto p-4">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold">{t('pages.logs.title')}</h1>
<h1 className="text-2xl font-bold text-gray-900">{t('pages.logs.title')}</h1>
</div>
<div className="bg-card rounded-md shadow-sm">
<div className="bg-card rounded-md shadow-sm border border-gray-200 page-card">
<LogViewer
logs={logs}
isLoading={loading}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { MarketServer } from '@/types';
import { useNavigate, useParams } from 'react-router-dom';
import { MarketServer, ServerConfig } from '@/types';
import { useMarketData } from '@/hooks/useMarketData';
import { useToast } from '@/contexts/ToastContext';
import MarketServerCard from '@/components/MarketServerCard';
@@ -11,15 +11,13 @@ import Pagination from '@/components/ui/Pagination';
const MarketPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { serverName } = useParams<{ serverName?: string }>();
const { showToast } = useToast();
const {
servers,
allServers,
categories,
tags,
loading,
error,
setError,
@@ -42,7 +40,6 @@ const MarketPage: React.FC = () => {
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [installing, setInstalling] = useState(false);
const [showTags, setShowTags] = useState(false);
// Load server details if a server name is in the URL
useEffect(() => {
@@ -59,7 +56,7 @@ const MarketPage: React.FC = () => {
setSelectedServer(null);
}
};
loadServerDetails();
}, [serverName, fetchServerByName, navigate]);
@@ -72,10 +69,6 @@ const MarketPage: React.FC = () => {
filterByCategory(category);
};
const handleTagClick = (tag: string) => {
filterByTag(tag);
};
const handleClearFilters = () => {
setSearchQuery('');
filterByCategory('');
@@ -90,10 +83,11 @@ const MarketPage: React.FC = () => {
navigate('/market');
};
const handleInstall = async (server: MarketServer) => {
const handleInstall = async (server: MarketServer, config: ServerConfig) => {
try {
setInstalling(true);
const success = await installServer(server);
// Pass the server object and the config to the installServer function
const success = await installServer(server, config);
if (success) {
// Show success message using toast instead of alert
showToast(t('market.installSuccess', { serverName: server.display_name }), 'success');
@@ -114,10 +108,6 @@ const MarketPage: React.FC = () => {
changeServersPerPage(newValue);
};
const toggleTagsVisibility = () => {
setShowTags(!showTags);
};
// Render detailed view if a server is selected
if (selectedServer) {
return (
@@ -143,12 +133,12 @@ const MarketPage: React.FC = () => {
</div>
{error && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
<div className="flex items-center justify-between">
<p>{error}</p>
<button
onClick={() => setError(null)}
className="text-red-700 hover:text-red-900"
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" />
@@ -159,7 +149,7 @@ const MarketPage: React.FC = () => {
)}
{/* Search bar at the top */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<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
@@ -167,12 +157,12 @@ const MarketPage: React.FC = () => {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('market.searchPlaceholder')}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
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>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded"
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')}
</button>
@@ -180,7 +170,7 @@ const MarketPage: React.FC = () => {
<button
type="button"
onClick={handleClearFilters}
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50"
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')}
</button>
@@ -191,14 +181,14 @@ const MarketPage: React.FC = () => {
<div className="flex flex-col md:flex-row gap-6">
{/* Left sidebar for filters (without search) */}
<div className="md:w-48 flex-shrink-0">
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4">
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4 page-card">
{/* Categories */}
{categories.length > 0 ? (
<div className="mb-6">
<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" onClick={() => filterByCategory('')}>
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterByCategory('')}>
{t('market.clearCategoryFilter')}
</span>
)}
@@ -208,9 +198,9 @@ const MarketPage: React.FC = () => {
<button
key={category}
onClick={() => handleCategoryClick(category)}
className={`px-3 py-2 rounded text-sm text-left ${selectedCategory === category
? 'bg-blue-100 text-blue-800 font-medium'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
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}
@@ -223,7 +213,7 @@ const MarketPage: React.FC = () => {
<div className="mb-3">
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
</div>
<div className="flex flex-col gap-2 items-center py-4">
<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>
@@ -332,7 +322,7 @@ const MarketPage: React.FC = () => {
id="perPage"
value={serversPerPage}
onChange={handleChangeItemsPerPage}
className="border rounded p-1 text-sm"
className="border rounded p-1 text-sm btn-secondary outline-none"
>
<option value="6">6</option>
<option value="9">9</option>
@@ -353,4 +343,4 @@ const MarketPage: React.FC = () => {
);
};
export default MarketPage;
export default MarketPage;

View File

@@ -6,6 +6,7 @@ import ServerCard from '@/components/ServerCard';
import AddServerForm from '@/components/AddServerForm';
import EditServerForm from '@/components/EditServerForm';
import { useServerData } from '@/hooks/useServerData';
import DxtUploadForm from '@/components/DxtUploadForm';
const ServersPage: React.FC = () => {
const { t } = useTranslation();
@@ -23,6 +24,7 @@ const ServersPage: React.FC = () => {
} = useServerData();
const [editingServer, setEditingServer] = useState<Server | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [showDxtUpload, setShowDxtUpload] = useState(false);
const handleEditClick = async (server: Server) => {
const fullServerData = await handleServerEdit(server);
@@ -47,6 +49,12 @@ const ServersPage: React.FC = () => {
}
};
const handleDxtUploadSuccess = (_serverConfig: any) => {
// Close upload dialog and refresh servers
setShowDxtUpload(false);
triggerRefresh();
};
return (
<div>
<div className="flex justify-between items-center mb-8">
@@ -54,7 +62,7 @@ const ServersPage: React.FC = () => {
<div className="flex space-x-4">
<button
onClick={() => navigate('/market')}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
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 d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3z" />
@@ -62,10 +70,19 @@ const ServersPage: React.FC = () => {
{t('nav.market')}
</button>
<AddServerForm onAdd={handleServerAdd} />
<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"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path d="M5.5 13a3.5 3.5 0 01-.369-6.98 4 4 0 117.753-1.977A4.5 4.5 0 1113.5 13H11V9.413l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.413V13H5.5z" />
</svg>
{t('dxt.upload')}
</button>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className={`px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center ${isRefreshing ? 'opacity-70 cursor-not-allowed' : ''}`}
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 ${isRefreshing ? 'opacity-70 cursor-not-allowed' : ''}`}
>
{isRefreshing ? (
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -83,7 +100,7 @@ const ServersPage: React.FC = () => {
</div>
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
<div className="flex items-center justify-between">
<div>
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
@@ -91,7 +108,7 @@ const ServersPage: React.FC = () => {
</div>
<button
onClick={() => setError(null)}
className="ml-4 text-gray-500 hover:text-gray-700"
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200 btn-secondary"
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">
@@ -103,7 +120,7 @@ const ServersPage: React.FC = () => {
)}
{isLoading ? (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
<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>
@@ -113,7 +130,7 @@ const ServersPage: React.FC = () => {
</div>
</div>
) : servers.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 empty-state">
<p className="text-gray-600">{t('app.noServers')}</p>
</div>
) : (
@@ -125,6 +142,7 @@ const ServersPage: React.FC = () => {
onRemove={handleServerRemove}
onEdit={handleEditClick}
onToggle={handleServerToggle}
onRefresh={triggerRefresh}
/>
))}
</div>
@@ -137,6 +155,13 @@ const ServersPage: React.FC = () => {
onCancel={() => setEditingServer(null)}
/>
)}
{showDxtUpload && (
<DxtUploadForm
onSuccess={handleDxtUploadSuccess}
onCancel={() => setShowDxtUpload(false)}
/>
)}
</div>
);
};

View File

@@ -85,7 +85,7 @@ const SettingsPage: React.FC = () => {
}));
};
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute' | 'enableBearerAuth' | 'bearerAuthKey', value: boolean | string) => {
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute' | 'enableBearerAuth' | 'bearerAuthKey' | 'skipAuth', value: boolean | string) => {
// If enableBearerAuth is turned on and there's no key, generate one first
if (key === 'enableBearerAuth' && value === true) {
if (!tempRoutingConfig.bearerAuthKey && !routingConfig.bearerAuthKey) {
@@ -203,23 +203,23 @@ const SettingsPage: React.FC = () => {
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1>
{/* Language Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-800">{t('pages.settings.language')}</h2>
<div className="flex space-x-3">
<button
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${currentLanguage.startsWith('en')
? 'bg-blue-500 text-white'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
className={`px-3 py-1.5 rounded-md transition-all duration-200 text-sm ${currentLanguage.startsWith('en')
? 'bg-blue-500 text-white btn-primary'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200 btn-secondary'
}`}
onClick={() => handleLanguageChange('en')}
>
English
</button>
<button
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${currentLanguage.startsWith('zh')
? 'bg-blue-500 text-white'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
className={`px-3 py-1.5 rounded-md transition-all duration-200 text-sm ${currentLanguage.startsWith('zh')
? 'bg-blue-500 text-white btn-primary'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200 btn-secondary'
}`}
onClick={() => handleLanguageChange('zh')}
>
@@ -230,13 +230,13 @@ const SettingsPage: React.FC = () => {
</div>
{/* Smart Routing Configuration Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card">
<div
className="flex justify-between items-center cursor-pointer"
className="flex justify-between items-center cursor-pointer transition-colors duration-200 hover:text-blue-600"
onClick={() => toggleSection('smartRoutingConfig')}
>
<h2 className="font-semibold text-gray-800">{t('pages.settings.smartRouting')}</h2>
<span className="text-gray-500">
<span className="text-gray-500 transition-transform duration-200">
{sectionsVisible.smartRoutingConfig ? '▼' : '►'}
</span>
</div>
@@ -267,13 +267,13 @@ const SettingsPage: React.FC = () => {
value={tempSmartRoutingConfig.dbUrl}
onChange={(e) => handleSmartRoutingConfigChange('dbUrl', e.target.value)}
placeholder={t('settings.dbUrlPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 form-input"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('dbUrl')}
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"
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>
@@ -298,7 +298,7 @@ const SettingsPage: React.FC = () => {
<button
onClick={() => saveSmartRoutingConfig('openaiApiKey')}
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"
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>
@@ -315,13 +315,13 @@ const SettingsPage: React.FC = () => {
value={tempSmartRoutingConfig.openaiApiBaseUrl}
onChange={(e) => handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)}
placeholder={t('settings.openaiApiBaseUrlPlaceholder')}
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"
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={() => saveSmartRoutingConfig('openaiApiBaseUrl')}
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"
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>
@@ -338,13 +338,13 @@ const SettingsPage: React.FC = () => {
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
onChange={(e) => handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)}
placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')}
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"
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={() => saveSmartRoutingConfig('openaiApiEmbeddingModel')}
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"
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>
@@ -392,13 +392,13 @@ const SettingsPage: React.FC = () => {
value={tempRoutingConfig.bearerAuthKey}
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
placeholder={t('settings.bearerAuthKeyPlaceholder')}
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"
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 || !routingConfig.enableBearerAuth}
/>
<button
onClick={saveBearerAuthKey}
disabled={loading || !routingConfig.enableBearerAuth}
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"
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>
@@ -430,6 +430,18 @@ const SettingsPage: React.FC = () => {
/>
</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.skipAuth')}</h3>
<p className="text-sm text-gray-500">{t('settings.skipAuthDescription')}</p>
</div>
<Switch
disabled={loading}
checked={routingConfig.skipAuth}
onCheckedChange={(checked) => handleRoutingConfigChange('skipAuth', checked)}
/>
</div>
</div>
)}
</div>
@@ -459,13 +471,13 @@ const SettingsPage: React.FC = () => {
value={installConfig.pythonIndexUrl}
onChange={(e) => handleInstallConfigChange('pythonIndexUrl', e.target.value)}
placeholder={t('settings.pythonIndexUrlPlaceholder')}
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"
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={() => saveInstallConfig('pythonIndexUrl')}
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"
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>
@@ -483,13 +495,13 @@ const SettingsPage: React.FC = () => {
value={installConfig.npmRegistry}
onChange={(e) => handleInstallConfigChange('npmRegistry', e.target.value)}
placeholder={t('settings.npmRegistryPlaceholder')}
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"
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={() => saveInstallConfig('npmRegistry')}
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"
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>

View File

@@ -0,0 +1,102 @@
import { getApiUrl, getBasePath } from '../utils/runtime';
export interface SystemConfig {
routing?: {
enableGlobalRoute?: boolean;
enableGroupNameRoute?: boolean;
enableBearerAuth?: boolean;
bearerAuthKey?: string;
skipAuth?: boolean;
};
install?: {
pythonIndexUrl?: string;
npmRegistry?: string;
};
smartRouting?: {
enabled?: boolean;
dbUrl?: string;
openaiApiBaseUrl?: string;
openaiApiKey?: string;
openaiApiEmbeddingModel?: string;
};
}
export interface PublicConfigResponse {
success: boolean;
data?: {
skipAuth?: boolean;
};
message?: string;
}
export interface SystemConfigResponse {
success: boolean;
data?: {
systemConfig?: SystemConfig;
};
message?: string;
}
/**
* Get public configuration (skipAuth setting) without authentication
*/
export const getPublicConfig = async (): Promise<{ skipAuth: boolean }> => {
try {
const basePath = getBasePath();
const response = await fetch(`${basePath}/public-config`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
const data: PublicConfigResponse = await response.json();
return { skipAuth: data.data?.skipAuth === true };
}
return { skipAuth: false };
} catch (error) {
console.debug('Failed to get public config:', error);
return { skipAuth: false };
}
};
/**
* Get system configuration without authentication
* This function tries to get the system configuration first without auth,
* and if that fails (likely due to auth requirements), it returns null
*/
export const getSystemConfigPublic = async (): Promise<SystemConfig | null> => {
try {
const response = await fetch(getApiUrl('/settings'), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
const data: SystemConfigResponse = await response.json();
return data.data?.systemConfig || null;
}
return null;
} catch (error) {
console.debug('Failed to get system config without auth:', error);
return null;
}
};
/**
* Check if authentication should be skipped based on system configuration
*/
export const shouldSkipAuth = async (): Promise<boolean> => {
try {
const config = await getPublicConfig();
return config.skipAuth;
} catch (error) {
console.debug('Failed to check skipAuth setting:', error);
return false;
}
};

View File

@@ -15,13 +15,9 @@ export const fetchLogs = async (): Promise<LogEntry[]> => {
try {
// Get authentication token
const token = getToken();
if (!token) {
throw new Error('Authentication token not found. Please log in.');
}
const response = await fetch(getApiUrl('/logs'), {
headers: {
'x-auth-token': token,
'x-auth-token': token || '',
},
});
@@ -43,14 +39,10 @@ export const clearLogs = async (): Promise<void> => {
try {
// Get authentication token
const token = getToken();
if (!token) {
throw new Error('Authentication token not found. Please log in.');
}
const response = await fetch(getApiUrl('/logs'), {
method: 'DELETE',
headers: {
'x-auth-token': token,
'x-auth-token': token || '',
},
});
@@ -84,12 +76,6 @@ export const useLogs = () => {
// Get the authentication token
const token = getToken();
if (!token) {
setError(new Error('Authentication token not found. Please log in.'));
setLoading(false);
return;
}
// Connect to SSE endpoint with auth token in URL
eventSource = new EventSource(getApiUrl(`/logs/stream?token=${token}`));

View File

@@ -26,10 +26,6 @@ export const callTool = async (
): Promise<ToolCallResult> => {
try {
const token = getToken();
if (!token) {
throw new Error('Authentication token not found. Please log in.');
}
// Construct the URL with optional server parameter
const url = server ? `/tools/call/${server}` : '/tools/call';
@@ -37,7 +33,7 @@ export const callTool = async (
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token,
'x-auth-token': token || '', // Include token for authentication
Authorization: `Bearer ${token}`, // Add bearer auth for MCP routing
},
body: JSON.stringify({
@@ -70,3 +66,82 @@ export const callTool = async (
};
}
};
/**
* Toggle a tool's enabled state for a specific server
*/
export const toggleTool = async (
serverName: string,
toolName: string,
enabled: boolean,
): Promise<{ success: boolean; error?: string }> => {
try {
const token = getToken();
const response = await fetch(getApiUrl(`/servers/${serverName}/tools/${toolName}/toggle`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ enabled }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return {
success: data.success,
error: data.success ? undefined : data.message,
};
} catch (error) {
console.error('Error toggling tool:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
};
/**
* Update a tool's description for a specific server
*/
export const updateToolDescription = async (
serverName: string,
toolName: string,
description: string,
): Promise<{ success: boolean; error?: string }> => {
try {
const token = getToken();
const response = await fetch(
getApiUrl(`/servers/${serverName}/tools/${toolName}/description`),
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
Authorization: `Bearer ${token || ''}`,
},
body: JSON.stringify({ description }),
},
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return {
success: data.success,
error: data.success ? undefined : data.message,
};
} catch (error) {
console.error('Error updating tool description:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
};

View File

@@ -67,17 +67,63 @@ export interface Tool {
name: string;
description: string;
inputSchema: ToolInputSchema;
enabled?: boolean;
}
// Server config types
export interface ServerConfig {
type?: 'stdio' | 'sse' | 'streamable-http';
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi';
url?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
headers?: Record<string, string>;
enabled?: boolean;
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
options?: {
timeout?: number; // Request timeout in milliseconds
resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications
maxTotalTimeout?: number; // Maximum total timeout in milliseconds
}; // MCP request options configuration
// OpenAPI specific configuration
openapi?: {
url?: string; // OpenAPI specification URL
schema?: Record<string, any>; // Complete OpenAPI JSON schema
version?: string; // OpenAPI version (default: '3.1.0')
security?: OpenAPISecurityConfig; // Security configuration for API calls
};
}
// OpenAPI Security Configuration
export interface OpenAPISecurityConfig {
type: 'none' | 'apiKey' | 'http' | 'oauth2' | 'openIdConnect';
// API Key authentication
apiKey?: {
name: string; // Header/query/cookie name
in: 'header' | 'query' | 'cookie';
value: string; // The API key value
};
// HTTP authentication (Basic, Bearer, etc.)
http?: {
scheme: 'basic' | 'bearer' | 'digest'; // HTTP auth scheme
bearerFormat?: string; // Bearer token format (e.g., JWT)
credentials?: string; // Base64 encoded credentials for basic auth or bearer token
};
// OAuth2 (simplified - mainly for bearer tokens)
oauth2?: {
tokenUrl?: string; // Token endpoint for client credentials flow
clientId?: string;
clientSecret?: string;
scopes?: string[]; // Required scopes
token?: string; // Pre-obtained access token
};
// OpenID Connect
openIdConnect?: {
url: string; // OpenID Connect discovery URL
clientId?: string;
clientSecret?: string;
token?: string; // Pre-obtained ID token
};
}
// Server types
@@ -111,9 +157,39 @@ export interface ServerFormData {
command: string;
arguments: string;
args?: string[]; // Added explicit args field
type?: 'stdio' | 'sse' | 'streamable-http'; // Added type field
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi'; // Added type field with openapi support
env: EnvVar[];
headers: EnvVar[];
options?: {
timeout?: number;
resetTimeoutOnProgress?: boolean;
maxTotalTimeout?: number;
};
// OpenAPI specific fields
openapi?: {
url?: string;
schema?: string; // JSON schema as string for form input
inputMode?: 'url' | 'schema'; // Mode to determine input type
version?: string;
securityType?: 'none' | 'apiKey' | 'http' | 'oauth2' | 'openIdConnect';
// API Key fields
apiKeyName?: string;
apiKeyIn?: 'header' | 'query' | 'cookie';
apiKeyValue?: string;
// HTTP auth fields
httpScheme?: 'basic' | 'bearer' | 'digest';
httpCredentials?: string;
// OAuth2 fields
oauth2TokenUrl?: string;
oauth2ClientId?: string;
oauth2ClientSecret?: string;
oauth2Token?: string;
// OpenID Connect fields
openIdConnectUrl?: string;
openIdConnectClientId?: string;
openIdConnectClientSecret?: string;
openIdConnectToken?: string;
};
}
// Group form data types

View File

@@ -56,7 +56,7 @@ export const loadRuntimeConfig = async (): Promise<RuntimeConfig> => {
const currentPath = window.location.pathname;
const possibleConfigPaths = [
// If we're already on a subpath, try to use it
currentPath.replace(/\/[^\/]*$/, '') + '/config',
currentPath.replace(/\/[^/]*$/, '') + '/config',
// Try root config
'/config',
// Try with potential base paths

View File

@@ -0,0 +1,27 @@
// Utility function to detect ${} variables in server configurations
export const detectVariables = (payload: any): string[] => {
const variables = new Set<string>();
const variableRegex = /\$\{([^}]+)\}/g;
const checkString = (str: string) => {
let match;
while ((match = variableRegex.exec(str)) !== null) {
variables.add(match[1]);
}
};
const checkObject = (obj: any, path: string = '') => {
if (typeof obj === 'string') {
checkString(obj);
} else if (Array.isArray(obj)) {
obj.forEach((item, index) => checkObject(item, `${path}[${index}]`));
} else if (obj && typeof obj === 'object') {
Object.entries(obj).forEach(([key, value]) => {
checkObject(value, path ? `${path}.${key}` : key);
});
}
};
checkObject(payload);
return Array.from(variables);
};

View File

@@ -39,6 +39,14 @@ export default defineConfig({
target: 'http://localhost:3000',
changeOrigin: true,
},
[`${basePath}/config`]: {
target: 'http://localhost:3000',
changeOrigin: true,
},
[`${basePath}/public-config`]: {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});

44
jest.config.cjs Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/tests'],
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{ts,tsx}',
'<rootDir>/src/**/*.{test,spec}.{ts,tsx}',
'<rootDir>/tests/**/*.{test,spec}.{ts,tsx}',
],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
},
],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/index.ts',
'!src/**/__tests__/**',
'!src/**/*.test.{ts,tsx}',
'!src/**/*.spec.{ts,tsx}',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
coverageThreshold: {
global: {
branches: 0,
functions: 0,
lines: 0,
statements: 0,
},
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
extensionsToTreatAsEsm: ['.ts'],
testTimeout: 10000,
verbose: true,
};

View File

@@ -1,10 +0,0 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
};

View File

@@ -25,6 +25,10 @@
"lint": "eslint . --ext .ts",
"format": "prettier --write \"src/**/*.ts\"",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:verbose": "jest --verbose",
"test:ci": "jest --ci --coverage --watchAll=false",
"frontend:dev": "cd frontend && vite",
"frontend:build": "cd frontend && vite build",
"frontend:preview": "cd frontend && vite preview",
@@ -41,15 +45,22 @@
"author": "",
"license": "ISC",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.1",
"@apidevtools/swagger-parser": "^11.0.1",
"@modelcontextprotocol/sdk": "^1.12.1",
"@types/adm-zip": "^0.5.7",
"@types/multer": "^1.4.13",
"@types/pg": "^8.15.2",
"adm-zip": "^0.5.16",
"axios": "^1.10.0",
"bcryptjs": "^3.0.2",
"dotenv": "^16.3.1",
"dotenv-expand": "^12.0.2",
"express": "^4.21.2",
"express-validator": "^7.2.1",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.1",
"openai": "^4.103.0",
"openapi-types": "^12.1.3",
"pg": "^8.16.0",
"pgvector": "^0.2.1",
"postgres": "^3.4.7",
@@ -70,6 +81,7 @@
"@types/node": "^22.15.21",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/supertest": "^6.0.3",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
@@ -77,11 +89,13 @@
"autoprefixer": "^10.4.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"concurrently": "^8.2.2",
"concurrently": "^9.1.2",
"eslint": "^8.50.0",
"i18next": "^24.2.3",
"i18next-browser-languagedetector": "^8.0.4",
"jest": "^29.7.0",
"jest-environment-node": "^30.0.0",
"jest-mock-extended": "4.0.0-beta1",
"lucide-react": "^0.486.0",
"next": "^15.2.4",
"postcss": "^8.5.3",
@@ -90,6 +104,7 @@
"react-dom": "^19.1.0",
"react-i18next": "^15.4.1",
"react-router-dom": "^7.6.0",
"supertest": "^7.1.1",
"tailwind-merge": "^3.1.0",
"tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss": "^4.0.17",

1611
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

343
src/clients/openapi.ts Normal file
View File

@@ -0,0 +1,343 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import SwaggerParser from '@apidevtools/swagger-parser';
import { OpenAPIV3 } from 'openapi-types';
import { ServerConfig, OpenAPISecurityConfig } from '../types/index.js';
export interface OpenAPIToolInfo {
name: string;
description: string;
inputSchema: Record<string, unknown>;
operationId: string;
method: string;
path: string;
parameters?: OpenAPIV3.ParameterObject[];
requestBody?: OpenAPIV3.RequestBodyObject;
responses?: OpenAPIV3.ResponsesObject;
}
export class OpenAPIClient {
private httpClient: AxiosInstance;
private spec: OpenAPIV3.Document | null = null;
private tools: OpenAPIToolInfo[] = [];
private baseUrl: string;
private securityConfig?: OpenAPISecurityConfig;
constructor(private config: ServerConfig) {
if (!config.openapi?.url && !config.openapi?.schema) {
throw new Error('OpenAPI URL or schema is required');
}
// 初始 baseUrl将在 initialize() 中从 OpenAPI servers 字段更新
this.baseUrl = config.openapi?.url ? this.extractBaseUrl(config.openapi.url) : '';
this.securityConfig = config.openapi.security;
this.httpClient = axios.create({
baseURL: this.baseUrl,
timeout: config.options?.timeout || 30000,
headers: {
'Content-Type': 'application/json',
...config.headers,
},
});
this.setupSecurity();
}
private extractBaseUrl(specUrl: string): string {
try {
const url = new URL(specUrl);
return `${url.protocol}//${url.host}`;
} catch {
// If specUrl is a relative path, assume current host
return '';
}
}
private setupSecurity(): void {
if (!this.securityConfig || this.securityConfig.type === 'none') {
return;
}
switch (this.securityConfig.type) {
case 'apiKey':
if (this.securityConfig.apiKey) {
const { name, in: location, value } = this.securityConfig.apiKey;
if (location === 'header') {
this.httpClient.defaults.headers.common[name] = value;
} else if (location === 'query') {
this.httpClient.interceptors.request.use((config: any) => {
config.params = { ...config.params, [name]: value };
return config;
});
}
// Note: Cookie authentication would need additional setup
}
break;
case 'http':
if (this.securityConfig.http) {
const { scheme, credentials } = this.securityConfig.http;
if (scheme === 'bearer' && credentials) {
this.httpClient.defaults.headers.common['Authorization'] = `Bearer ${credentials}`;
} else if (scheme === 'basic' && credentials) {
this.httpClient.defaults.headers.common['Authorization'] = `Basic ${credentials}`;
}
}
break;
case 'oauth2':
if (this.securityConfig.oauth2?.token) {
this.httpClient.defaults.headers.common['Authorization'] =
`Bearer ${this.securityConfig.oauth2.token}`;
}
break;
case 'openIdConnect':
if (this.securityConfig.openIdConnect?.token) {
this.httpClient.defaults.headers.common['Authorization'] =
`Bearer ${this.securityConfig.openIdConnect.token}`;
}
break;
}
}
async initialize(): Promise<void> {
try {
// Parse and dereference the OpenAPI specification
if (this.config.openapi?.url) {
this.spec = (await SwaggerParser.dereference(
this.config.openapi.url,
)) as OpenAPIV3.Document;
} else if (this.config.openapi?.schema) {
// For schema object, we need to pass it as a cloned object
this.spec = (await SwaggerParser.dereference(
JSON.parse(JSON.stringify(this.config.openapi.schema)),
)) as OpenAPIV3.Document;
} else {
throw new Error('Either OpenAPI URL or schema must be provided');
}
// 从 OpenAPI servers 字段更新 baseUrl
this.updateBaseUrlFromServers();
this.extractTools();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to load OpenAPI specification: ${errorMessage}`);
}
}
private updateBaseUrlFromServers(): void {
if (!this.spec?.servers || this.spec.servers.length === 0) {
return;
}
// 获取第一个 server 的 URL
const serverUrl = this.spec.servers[0].url;
// 如果是相对路径,需要与原始 spec URL 结合
if (serverUrl.startsWith('/')) {
// 相对路径,使用原始 spec URL 的协议和主机
if (this.config.openapi?.url) {
const originalUrl = new URL(this.config.openapi.url);
this.baseUrl = `${originalUrl.protocol}//${originalUrl.host}${serverUrl}`;
}
} else if (serverUrl.startsWith('http://') || serverUrl.startsWith('https://')) {
// 绝对路径
this.baseUrl = serverUrl;
} else {
// 相对路径但不以 / 开头,可能是相对于当前路径
if (this.config.openapi?.url) {
const originalUrl = new URL(this.config.openapi.url);
this.baseUrl = `${originalUrl.protocol}//${originalUrl.host}/${serverUrl}`;
}
}
// 更新 HTTP 客户端的 baseURL
this.httpClient.defaults.baseURL = this.baseUrl;
}
private extractTools(): void {
if (!this.spec?.paths) {
return;
}
this.tools = [];
for (const [path, pathItem] of Object.entries(this.spec.paths)) {
if (!pathItem) continue;
const methods = [
'get',
'post',
'put',
'delete',
'patch',
'head',
'options',
'trace',
] as const;
for (const method of methods) {
const operation = pathItem[method] as OpenAPIV3.OperationObject | undefined;
if (!operation || !operation.operationId) continue;
const tool: OpenAPIToolInfo = {
name: operation.operationId,
description:
operation.summary || operation.description || `${method.toUpperCase()} ${path}`,
inputSchema: this.generateInputSchema(operation, path, method as string),
operationId: operation.operationId,
method: method as string,
path,
parameters: operation.parameters as OpenAPIV3.ParameterObject[],
requestBody: operation.requestBody as OpenAPIV3.RequestBodyObject,
responses: operation.responses,
};
this.tools.push(tool);
}
}
}
private generateInputSchema(
operation: OpenAPIV3.OperationObject,
_path: string,
_method: string,
): Record<string, unknown> {
const schema: Record<string, unknown> = {
type: 'object',
properties: {},
required: [],
};
const properties = schema.properties as Record<string, unknown>;
const required = schema.required as string[];
// Handle path parameters
const pathParams = operation.parameters?.filter(
(p: any) => 'in' in p && p.in === 'path',
) as OpenAPIV3.ParameterObject[];
if (pathParams?.length) {
for (const param of pathParams) {
properties[param.name] = {
type: 'string',
description: param.description || `Path parameter: ${param.name}`,
};
if (param.required) {
required.push(param.name);
}
}
}
// Handle query parameters
const queryParams = operation.parameters?.filter(
(p: any) => 'in' in p && p.in === 'query',
) as OpenAPIV3.ParameterObject[];
if (queryParams?.length) {
for (const param of queryParams) {
properties[param.name] = param.schema || {
type: 'string',
description: param.description || `Query parameter: ${param.name}`,
};
if (param.required) {
required.push(param.name);
}
}
}
// Handle request body
if (operation.requestBody && 'content' in operation.requestBody) {
const requestBody = operation.requestBody as OpenAPIV3.RequestBodyObject;
const jsonContent = requestBody.content?.['application/json'];
if (jsonContent?.schema) {
properties['body'] = jsonContent.schema;
if (requestBody.required) {
required.push('body');
}
}
}
return schema;
}
async callTool(toolName: string, args: Record<string, unknown>): Promise<unknown> {
const tool = this.tools.find((t) => t.name === toolName);
if (!tool) {
throw new Error(`Tool '${toolName}' not found`);
}
try {
// Build the request URL with path parameters
let url = tool.path;
const pathParams = tool.parameters?.filter((p) => p.in === 'path') || [];
for (const param of pathParams) {
const value = args[param.name];
if (value !== undefined) {
url = url.replace(`{${param.name}}`, String(value));
}
}
// Build query parameters
const queryParams: Record<string, unknown> = {};
const queryParamDefs = tool.parameters?.filter((p) => p.in === 'query') || [];
for (const param of queryParamDefs) {
const value = args[param.name];
if (value !== undefined) {
queryParams[param.name] = value;
}
}
// Prepare request configuration
const requestConfig: AxiosRequestConfig = {
method: tool.method as any,
url,
params: queryParams,
};
// Add request body if applicable
if (args.body && ['post', 'put', 'patch'].includes(tool.method)) {
requestConfig.data = args.body;
}
// Add headers if any header parameters are defined
const headerParams = tool.parameters?.filter((p) => p.in === 'header') || [];
if (headerParams.length > 0) {
requestConfig.headers = {};
for (const param of headerParams) {
const value = args[param.name];
if (value !== undefined) {
requestConfig.headers[param.name] = String(value);
}
}
}
const response = await this.httpClient.request(requestConfig);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(
`API call failed: ${error.response?.status} ${error.response?.statusText} - ${JSON.stringify(error.response?.data)}`,
);
}
throw error;
}
}
getTools(): OpenAPIToolInfo[] {
return this.tools;
}
getSpec(): OpenAPIV3.Document | null {
return this.spec;
}
disconnect(): void {
// No persistent connection to close for OpenAPI
}
}

View File

@@ -15,18 +15,37 @@ const defaultConfig = {
mcpHubVersion: getPackageVersion(),
};
// Settings cache
let settingsCache: McpSettings | null = null;
export const getSettingsPath = (): string => {
return getConfigFilePath('mcp_settings.json', 'Settings');
};
export const loadSettings = (): McpSettings => {
// If cache exists, return cached data directly
if (settingsCache) {
return settingsCache;
}
const settingsPath = getSettingsPath();
try {
const settingsData = fs.readFileSync(settingsPath, 'utf8');
return JSON.parse(settingsData);
const settings = JSON.parse(settingsData);
// Update cache
settingsCache = settings;
console.log(`Loaded settings from ${settingsPath}`);
return settings;
} catch (error) {
console.error(`Failed to load settings from ${settingsPath}:`, error);
return { mcpServers: {}, users: [] };
const defaultSettings = { mcpServers: {}, users: [] };
// Cache default settings
settingsCache = defaultSettings;
return defaultSettings;
}
};
@@ -34,6 +53,10 @@ export const saveSettings = (settings: McpSettings): boolean => {
const settingsPath = getSettingsPath();
try {
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
// Update cache after successful save
settingsCache = settings;
return true;
} catch (error) {
console.error(`Failed to save settings to ${settingsPath}:`, error);
@@ -41,6 +64,22 @@ export const saveSettings = (settings: McpSettings): boolean => {
}
};
/**
* Clear settings cache, force next loadSettings call to re-read from file
*/
export const clearSettingsCache = (): void => {
settingsCache = null;
};
/**
* Get current cache status (for debugging)
*/
export const getSettingsCacheInfo = (): { hasCache: boolean } => {
return {
hasCache: settingsCache !== null,
};
};
export const replaceEnvVars = (env: Record<string, any>): Record<string, any> => {
const res: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {

View File

@@ -1,5 +1,6 @@
import { Request, Response } from 'express';
import config from '../config/index.js';
import { loadSettings } from '../config/index.js';
/**
* Get runtime configuration for frontend
@@ -28,3 +29,31 @@ export const getRuntimeConfig = (req: Request, res: Response): void => {
});
}
};
/**
* Get public system configuration (only skipAuth setting)
* This endpoint doesn't require authentication to allow checking if auth should be skipped
*/
export const getPublicConfig = (req: Request, res: Response): void => {
try {
const settings = loadSettings();
const skipAuth = settings.systemConfig?.routing?.skipAuth || false;
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.json({
success: true,
data: {
skipAuth,
},
});
} catch (error) {
console.error('Error getting public config:', error);
res.status(500).json({
success: false,
message: 'Failed to get public configuration',
});
}
};

View File

@@ -0,0 +1,156 @@
import { Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import AdmZip from 'adm-zip';
import { ApiResponse } from '../types/index.js';
// Get the directory name in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(__dirname, '../../data/uploads/dxt');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const timestamp = Date.now();
const originalName = path.parse(file.originalname).name;
cb(null, `${originalName}-${timestamp}.dxt`);
},
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
if (file.originalname.endsWith('.dxt')) {
cb(null, true);
} else {
cb(new Error('Only .dxt files are allowed'));
}
},
limits: {
fileSize: 100 * 1024 * 1024, // 100MB limit
},
});
export const uploadMiddleware = upload.single('dxtFile');
// Clean up old DXT server files when installing a new version
const cleanupOldDxtServer = (serverName: string): void => {
try {
const uploadDir = path.join(__dirname, '../../data/uploads/dxt');
const serverPattern = `server-${serverName}`;
if (fs.existsSync(uploadDir)) {
const files = fs.readdirSync(uploadDir);
files.forEach((file) => {
if (file.startsWith(serverPattern)) {
const filePath = path.join(uploadDir, file);
if (fs.statSync(filePath).isDirectory()) {
fs.rmSync(filePath, { recursive: true, force: true });
console.log(`Cleaned up old DXT server directory: ${filePath}`);
}
}
});
}
} catch (error) {
console.warn('Failed to cleanup old DXT server files:', error);
// Don't fail the installation if cleanup fails
}
};
export const uploadDxtFile = async (req: Request, res: Response): Promise<void> => {
try {
if (!req.file) {
res.status(400).json({
success: false,
message: 'No DXT file uploaded',
});
return;
}
const dxtFilePath = req.file.path;
const timestamp = Date.now();
const tempExtractDir = path.join(path.dirname(dxtFilePath), `temp-extracted-${timestamp}`);
try {
// Extract the DXT file (which is a ZIP archive) to a temporary directory first
const zip = new AdmZip(dxtFilePath);
zip.extractAllTo(tempExtractDir, true);
// Read and validate the manifest.json
const manifestPath = path.join(tempExtractDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
throw new Error('manifest.json not found in DXT file');
}
const manifestContent = fs.readFileSync(manifestPath, 'utf-8');
const manifest = JSON.parse(manifestContent);
// Validate required fields in manifest
if (!manifest.dxt_version) {
throw new Error('Invalid manifest: missing dxt_version');
}
if (!manifest.name) {
throw new Error('Invalid manifest: missing name');
}
if (!manifest.version) {
throw new Error('Invalid manifest: missing version');
}
if (!manifest.server) {
throw new Error('Invalid manifest: missing server configuration');
}
// Use server name as the final extract directory for automatic version management
const finalExtractDir = path.join(path.dirname(dxtFilePath), `server-${manifest.name}`);
// Clean up any existing version of this server
cleanupOldDxtServer(manifest.name);
// Move the temporary directory to the final location
fs.renameSync(tempExtractDir, finalExtractDir);
console.log(`DXT server extracted to: ${finalExtractDir}`);
// Clean up the uploaded DXT file
fs.unlinkSync(dxtFilePath);
const response: ApiResponse = {
success: true,
data: {
manifest,
extractDir: finalExtractDir,
},
};
res.json(response);
} catch (extractError) {
// Clean up files on error
if (fs.existsSync(dxtFilePath)) {
fs.unlinkSync(dxtFilePath);
}
if (fs.existsSync(tempExtractDir)) {
fs.rmSync(tempExtractDir, { recursive: true, force: true });
}
throw extractError;
}
} catch (error) {
console.error('DXT upload error:', error);
let message = 'Failed to process DXT file';
if (error instanceof Error) {
message = error.message;
}
res.status(500).json({
success: false,
message,
});
}
};

View File

@@ -9,7 +9,6 @@ import {
deleteGroup,
addServerToGroup,
removeServerFromGroup,
getServersInGroup
} from '../services/groupService.js';
// Get all groups
@@ -154,7 +153,7 @@ export const updateGroupServersBatch = (req: Request, res: Response): void => {
try {
const { id } = req.params;
const { servers } = req.body;
if (!id) {
res.status(400).json({
success: false,
@@ -338,4 +337,4 @@ export const getGroupServers = (req: Request, res: Response): void => {
message: 'Failed to get group servers',
});
}
};
};

View File

@@ -3,9 +3,10 @@ import { ApiResponse, AddServerRequest } from '../types/index.js';
import {
getServersInfo,
addServer,
addOrUpdateServer,
removeServer,
updateMcpServer,
notifyToolChanged,
syncToolEmbedding,
toggleServerStatus,
} from '../services/mcpService.js';
import { loadSettings, saveSettings } from '../config/index.js';
@@ -62,19 +63,25 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
return;
}
if (!config.url && (!config.command || !config.args)) {
if (
!config.url &&
!config.openapi?.url &&
!config.openapi?.schema &&
(!config.command || !config.args)
) {
res.status(400).json({
success: false,
message: 'Server configuration must include either a URL or command with arguments',
message:
'Server configuration must include either a URL, OpenAPI specification URL or schema, or command with arguments',
});
return;
}
// Validate the server type if specified
if (config.type && !['stdio', 'sse', 'streamable-http'].includes(config.type)) {
if (config.type && !['stdio', 'sse', 'streamable-http', 'openapi'].includes(config.type)) {
res.status(400).json({
success: false,
message: 'Server type must be one of: stdio, sse, streamable-http',
message: 'Server type must be one of: stdio, sse, streamable-http, openapi',
});
return;
}
@@ -88,6 +95,15 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
return;
}
// Validate that OpenAPI specification URL or schema is provided for openapi type
if (config.type === 'openapi' && !config.openapi?.url && !config.openapi?.schema) {
res.status(400).json({
success: false,
message: 'OpenAPI specification URL or schema is required for openapi server type',
});
return;
}
// Validate headers if provided
if (config.headers && typeof config.headers !== 'object') {
res.status(400).json({
@@ -97,7 +113,7 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
return;
}
// Validate that headers are only used with sse and streamable-http types
// Validate that headers are only used with sse, streamable-http, and openapi types
if (config.headers && config.type === 'stdio') {
res.status(400).json({
success: false,
@@ -106,6 +122,11 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
return;
}
// Set default keep-alive interval for SSE servers if not specified
if ((config.type === 'sse' || (!config.type && config.url)) && !config.keepAliveInterval) {
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
}
const result = await addServer(name, config);
if (result.success) {
notifyToolChanged();
@@ -179,19 +200,25 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
return;
}
if (!config.url && (!config.command || !config.args)) {
if (
!config.url &&
!config.openapi?.url &&
!config.openapi?.schema &&
(!config.command || !config.args)
) {
res.status(400).json({
success: false,
message: 'Server configuration must include either a URL or command with arguments',
message:
'Server configuration must include either a URL, OpenAPI specification URL or schema, or command with arguments',
});
return;
}
// Validate the server type if specified
if (config.type && !['stdio', 'sse', 'streamable-http'].includes(config.type)) {
if (config.type && !['stdio', 'sse', 'streamable-http', 'openapi'].includes(config.type)) {
res.status(400).json({
success: false,
message: 'Server type must be one of: stdio, sse, streamable-http',
message: 'Server type must be one of: stdio, sse, streamable-http, openapi',
});
return;
}
@@ -205,6 +232,15 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
return;
}
// Validate that OpenAPI specification URL or schema is provided for openapi type
if (config.type === 'openapi' && !config.openapi?.url && !config.openapi?.schema) {
res.status(400).json({
success: false,
message: 'OpenAPI specification URL or schema is required for openapi server type',
});
return;
}
// Validate headers if provided
if (config.headers && typeof config.headers !== 'object') {
res.status(400).json({
@@ -214,7 +250,7 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
return;
}
// Validate that headers are only used with sse and streamable-http types
// Validate that headers are only used with sse, streamable-http, and openapi types
if (config.headers && config.type === 'stdio') {
res.status(400).json({
success: false,
@@ -223,7 +259,12 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
return;
}
const result = await updateMcpServer(name, config);
// Set default keep-alive interval for SSE servers if not specified
if ((config.type === 'sse' || (!config.type && config.url)) && !config.keepAliveInterval) {
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
}
const result = await addOrUpdateServer(name, config, true); // Allow override for updates
if (result.success) {
notifyToolChanged();
res.json({
@@ -318,6 +359,136 @@ 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;
const { enabled } = req.body;
if (!serverName || !toolName) {
res.status(400).json({
success: false,
message: 'Server name and tool name are required',
});
return;
}
if (typeof enabled !== 'boolean') {
res.status(400).json({
success: false,
message: 'Enabled status must be a boolean',
});
return;
}
const settings = loadSettings();
if (!settings.mcpServers[serverName]) {
res.status(404).json({
success: false,
message: 'Server not found',
});
return;
}
// Initialize tools config if it doesn't exist
if (!settings.mcpServers[serverName].tools) {
settings.mcpServers[serverName].tools = {};
}
// Set the tool's enabled state
settings.mcpServers[serverName].tools![toolName] = { enabled };
if (!saveSettings(settings)) {
res.status(500).json({
success: false,
message: 'Failed to save settings',
});
return;
}
// Notify that tools have changed
notifyToolChanged();
res.json({
success: true,
message: `Tool ${toolName} ${enabled ? 'enabled' : 'disabled'} successfully`,
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
// Update tool description for a specific server
export const updateToolDescription = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName, toolName } = req.params;
const { description } = req.body;
if (!serverName || !toolName) {
res.status(400).json({
success: false,
message: 'Server name and tool name are required',
});
return;
}
if (typeof description !== 'string') {
res.status(400).json({
success: false,
message: 'Description must be a string',
});
return;
}
const settings = loadSettings();
if (!settings.mcpServers[serverName]) {
res.status(404).json({
success: false,
message: 'Server not found',
});
return;
}
// Initialize tools config if it doesn't exist
if (!settings.mcpServers[serverName].tools) {
settings.mcpServers[serverName].tools = {};
}
// Set the tool's description
if (!settings.mcpServers[serverName].tools![toolName]) {
settings.mcpServers[serverName].tools![toolName] = { enabled: true };
}
settings.mcpServers[serverName].tools![toolName].description = description;
if (!saveSettings(settings)) {
res.status(500).json({
success: false,
message: 'Failed to save settings',
});
return;
}
// Notify that tools have changed
notifyToolChanged();
syncToolEmbedding(serverName, toolName);
res.json({
success: true,
message: `Tool ${toolName} description updated successfully`,
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
export const updateSystemConfig = (req: Request, res: Response): void => {
try {
const { routing, install, smartRouting } = req.body;
@@ -327,7 +498,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
(typeof routing.enableGlobalRoute !== 'boolean' &&
typeof routing.enableGroupNameRoute !== 'boolean' &&
typeof routing.enableBearerAuth !== 'boolean' &&
typeof routing.bearerAuthKey !== 'string')) &&
typeof routing.bearerAuthKey !== 'string' &&
typeof routing.skipAuth !== 'boolean')) &&
(!install ||
(typeof install.pythonIndexUrl !== 'string' && typeof install.npmRegistry !== 'string')) &&
(!smartRouting ||
@@ -352,6 +524,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
skipAuth: false,
},
install: {
pythonIndexUrl: '',
@@ -373,6 +546,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
skipAuth: false,
};
}
@@ -409,6 +583,10 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
if (typeof routing.bearerAuthKey === 'string') {
settings.systemConfig.routing.bearerAuthKey = routing.bearerAuthKey;
}
if (typeof routing.skipAuth === 'boolean') {
settings.systemConfig.routing.skipAuth = routing.skipAuth;
}
}
if (install) {

View File

@@ -1,13 +1,9 @@
import 'reflect-metadata'; // Ensure reflect-metadata is imported here too
import { DataSource, DataSourceOptions } from 'typeorm';
import dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
import path from 'path';
import { fileURLToPath } from 'url';
import entities from './entities/index.js';
import { registerPostgresVectorType } from './types/postgresVectorType.js';
import { VectorEmbeddingSubscriber } from './subscribers/VectorEmbeddingSubscriber.js';
import { loadSettings } from '../config/index.js';
import { getSmartRoutingConfig } from '../utils/smartRouting.js';
// Helper function to create required PostgreSQL extensions
const createRequiredExtensions = async (dataSource: DataSource): Promise<void> => {
@@ -30,23 +26,7 @@ const createRequiredExtensions = async (dataSource: DataSource): Promise<void> =
// Get database URL from smart routing config or fallback to environment variable
const getDatabaseUrl = (): string => {
try {
const settings = loadSettings();
const smartRouting = settings.systemConfig?.smartRouting;
// Use smart routing dbUrl if smart routing is enabled and dbUrl is configured
if (smartRouting?.enabled && smartRouting?.dbUrl) {
console.log('Using smart routing database URL');
return smartRouting.dbUrl;
}
} catch (error) {
console.warn(
'Failed to load settings for smart routing database URL, falling back to environment variable:',
error,
);
}
return '';
return getSmartRoutingConfig().dbUrl;
};
// Default database configuration
@@ -59,7 +39,10 @@ const defaultConfig: DataSourceOptions = {
};
// AppDataSource is the TypeORM data source
let AppDataSource = new DataSource(defaultConfig);
let appDataSource = new DataSource(defaultConfig);
// Global promise to track initialization status
let initializationPromise: Promise<DataSource> | null = null;
// Function to create a new DataSource with updated configuration
export const updateDataSourceConfig = (): DataSource => {
@@ -69,31 +52,36 @@ export const updateDataSourceConfig = (): DataSource => {
};
// If the configuration has changed, we need to create a new DataSource
const currentUrl = (AppDataSource.options as any).url;
const currentUrl = (appDataSource.options as any).url;
if (currentUrl !== newConfig.url) {
console.log('Database URL configuration changed, updating DataSource...');
AppDataSource = new DataSource(newConfig);
appDataSource = new DataSource(newConfig);
// Reset initialization promise when configuration changes
initializationPromise = null;
}
return AppDataSource;
return appDataSource;
};
// Get the current AppDataSource instance
export const getAppDataSource = (): DataSource => {
return AppDataSource;
return appDataSource;
};
// Reconnect database with updated configuration
export const reconnectDatabase = async (): Promise<DataSource> => {
try {
// Close existing connection if it exists
if (AppDataSource.isInitialized) {
if (appDataSource.isInitialized) {
console.log('Closing existing database connection...');
await AppDataSource.destroy();
await appDataSource.destroy();
}
// Reset initialization promise to allow fresh initialization
initializationPromise = null;
// Update configuration and reconnect
AppDataSource = updateDataSourceConfig();
appDataSource = updateDataSourceConfig();
return await initializeDatabase();
} catch (error) {
console.error('Error during database reconnection:', error);
@@ -101,26 +89,54 @@ export const reconnectDatabase = async (): Promise<DataSource> => {
}
};
// Initialize database connection
// Initialize database connection with concurrency control
export const initializeDatabase = async (): Promise<DataSource> => {
// If initialization is already in progress, wait for it to complete
if (initializationPromise) {
console.log('Database initialization already in progress, waiting for completion...');
return initializationPromise;
}
// If already initialized, return the existing instance
if (appDataSource.isInitialized) {
console.log('Database already initialized, returning existing instance');
return Promise.resolve(appDataSource);
}
// Create a new initialization promise
initializationPromise = performDatabaseInitialization();
try {
const result = await initializationPromise;
console.log('Database initialization completed successfully');
return result;
} catch (error) {
// Reset the promise on error so initialization can be retried
initializationPromise = null;
console.error('Database initialization failed:', error);
throw error;
}
};
// Internal function to perform the actual database initialization
const performDatabaseInitialization = async (): Promise<DataSource> => {
try {
// Update configuration before initializing
AppDataSource = updateDataSourceConfig();
appDataSource = updateDataSourceConfig();
if (!AppDataSource.isInitialized) {
if (!appDataSource.isInitialized) {
console.log('Initializing database connection...');
// Register the vector type with TypeORM
await AppDataSource.initialize();
registerPostgresVectorType(AppDataSource);
await appDataSource.initialize();
registerPostgresVectorType(appDataSource);
// Create required PostgreSQL extensions
await createRequiredExtensions(AppDataSource);
await createRequiredExtensions(appDataSource);
// Set up vector column and index with a more direct approach
try {
// Check if table exists first
const tableExists = await AppDataSource.query(`
const tableExists = await appDataSource.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
@@ -134,7 +150,7 @@ export const initializeDatabase = async (): Promise<DataSource> => {
// Step 1: Drop any existing index on the column
try {
await AppDataSource.query(`DROP INDEX IF EXISTS idx_vector_embeddings_embedding;`);
await appDataSource.query(`DROP INDEX IF EXISTS idx_vector_embeddings_embedding;`);
} catch (dropError: any) {
console.warn('Note: Could not drop existing index:', dropError.message);
}
@@ -142,14 +158,14 @@ export const initializeDatabase = async (): Promise<DataSource> => {
// Step 2: Alter column type to vector (if it's not already)
try {
// Check column type first
const columnType = await AppDataSource.query(`
const columnType = await appDataSource.query(`
SELECT data_type FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'vector_embeddings'
AND column_name = 'embedding';
`);
if (columnType.length > 0 && columnType[0].data_type !== 'vector') {
await AppDataSource.query(`
await appDataSource.query(`
ALTER TABLE vector_embeddings
ALTER COLUMN embedding TYPE vector USING embedding::vector;
`);
@@ -163,7 +179,7 @@ export const initializeDatabase = async (): Promise<DataSource> => {
// Step 3: Try to create appropriate indices
try {
// First, let's check if there are any records to determine the dimensions
const records = await AppDataSource.query(`
const records = await appDataSource.query(`
SELECT dimensions FROM vector_embeddings LIMIT 1;
`);
@@ -177,13 +193,13 @@ export const initializeDatabase = async (): Promise<DataSource> => {
// Set the vector dimensions explicitly only if table has data
if (records && records.length > 0) {
await AppDataSource.query(`
await appDataSource.query(`
ALTER TABLE vector_embeddings
ALTER COLUMN embedding TYPE vector(${dimensions});
`);
// Now try to create the index
await AppDataSource.query(`
await appDataSource.query(`
CREATE INDEX IF NOT EXISTS idx_vector_embeddings_embedding
ON vector_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
`);
@@ -199,7 +215,7 @@ export const initializeDatabase = async (): Promise<DataSource> => {
try {
// Try HNSW index instead
await AppDataSource.query(`
await appDataSource.query(`
CREATE INDEX IF NOT EXISTS idx_vector_embeddings_embedding
ON vector_embeddings USING hnsw (embedding vector_cosine_ops);
`);
@@ -210,7 +226,7 @@ export const initializeDatabase = async (): Promise<DataSource> => {
try {
// Create a basic GIN index as last resort
await AppDataSource.query(`
await appDataSource.query(`
CREATE INDEX IF NOT EXISTS idx_vector_embeddings_embedding
ON vector_embeddings USING gin (embedding);
`);
@@ -235,12 +251,11 @@ export const initializeDatabase = async (): Promise<DataSource> => {
// Run one final setup check after schema synchronization is done
if (defaultConfig.synchronize) {
setTimeout(async () => {
try {
console.log('Running final vector configuration check...');
try {
console.log('Running final vector configuration check...');
// Try setup again with the same code from above
const tableExists = await AppDataSource.query(`
// Try setup again with the same code from above
const tableExists = await appDataSource.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
@@ -248,64 +263,60 @@ export const initializeDatabase = async (): Promise<DataSource> => {
);
`);
if (tableExists[0].exists) {
console.log('Vector embeddings table found, checking configuration...');
if (tableExists[0].exists) {
console.log('Vector embeddings table found, checking configuration...');
// Get the dimension size first
try {
// Try to get dimensions from an existing record
const records = await AppDataSource.query(`
// Get the dimension size first
try {
// Try to get dimensions from an existing record
const records = await appDataSource.query(`
SELECT dimensions FROM vector_embeddings LIMIT 1;
`);
// Only proceed if we have existing data, otherwise let vector service handle it
if (records && records.length > 0 && records[0].dimensions) {
const dimensions = records[0].dimensions;
console.log(`Found vector dimension from database: ${dimensions}`);
// Only proceed if we have existing data, otherwise let vector service handle it
if (records && records.length > 0 && records[0].dimensions) {
const dimensions = records[0].dimensions;
console.log(`Found vector dimension from database: ${dimensions}`);
// Ensure column type is vector with explicit dimensions
await AppDataSource.query(`
// Ensure column type is vector with explicit dimensions
await appDataSource.query(`
ALTER TABLE vector_embeddings
ALTER COLUMN embedding TYPE vector(${dimensions});
`);
console.log('Vector embedding column type updated in final check.');
console.log('Vector embedding column type updated in final check.');
// One more attempt at creating the index with dimensions
try {
// Drop existing index if any
await AppDataSource.query(`
// One more attempt at creating the index with dimensions
try {
// Drop existing index if any
await appDataSource.query(`
DROP INDEX IF EXISTS idx_vector_embeddings_embedding;
`);
// Create new index with proper dimensions
await AppDataSource.query(`
// Create new index with proper dimensions
await appDataSource.query(`
CREATE INDEX idx_vector_embeddings_embedding
ON vector_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
`);
console.log('Created IVFFlat index in final check.');
} catch (indexError: any) {
console.warn(
'Final index creation attempt did not succeed:',
indexError.message,
);
console.warn('Using basic lookup without vector index.');
}
} else {
console.log(
'No existing vector data found, vector dimensions will be configured by vector service.',
);
console.log('Created IVFFlat index in final check.');
} catch (indexError: any) {
console.warn('Final index creation attempt did not succeed:', indexError.message);
console.warn('Using basic lookup without vector index.');
}
} catch (setupError: any) {
console.warn('Vector setup in final check failed:', setupError.message);
} else {
console.log(
'No existing vector data found, vector dimensions will be configured by vector service.',
);
}
} catch (setupError: any) {
console.warn('Vector setup in final check failed:', setupError.message);
}
} catch (error: any) {
console.warn('Post-initialization vector setup failed:', error.message);
}
}, 3000); // Give synchronize some time to complete
} catch (error: any) {
console.warn('Post-initialization vector setup failed:', error.message);
}
}
}
return AppDataSource;
return appDataSource;
} catch (error) {
console.error('Error during database initialization:', error);
throw error;
@@ -314,18 +325,18 @@ export const initializeDatabase = async (): Promise<DataSource> => {
// Get database connection status
export const isDatabaseConnected = (): boolean => {
return AppDataSource.isInitialized;
return appDataSource.isInitialized;
};
// Close database connection
export const closeDatabase = async (): Promise<void> => {
if (AppDataSource.isInitialized) {
await AppDataSource.destroy();
if (appDataSource.isInitialized) {
await appDataSource.destroy();
console.log('Database connection closed.');
}
};
// Export AppDataSource for backward compatibility
export { AppDataSource };
export const AppDataSource = appDataSource;
export default getAppDataSource;

View File

@@ -1,11 +1,45 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { loadSettings } from '../config/index.js';
// Default secret key - in production, use an environment variable
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
const validateBearerAuth = (req: Request, routingConfig: any): boolean => {
if (!routingConfig.enableBearerAuth) {
return false;
}
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return false;
}
return authHeader.substring(7) === routingConfig.bearerAuthKey;
};
// Middleware to authenticate JWT token
export const auth = (req: Request, res: Response, next: NextFunction): void => {
// Check if authentication is disabled globally
const routingConfig = loadSettings().systemConfig?.routing || {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
skipAuth: false,
};
if (routingConfig.skipAuth) {
next();
return;
}
// Check if bearer auth is enabled and validate it
if (validateBearerAuth(req, routingConfig)) {
next();
return;
}
// Get token from header or query parameter
const headerToken = req.header('x-auth-token');
const queryToken = req.query.token as string;
@@ -20,11 +54,11 @@ export const auth = (req: Request, res: Response, next: NextFunction): void => {
// Verify token
try {
const decoded = jwt.verify(token, JWT_SECRET);
// Add user from payload to request
(req as any).user = (decoded as any).user;
next();
} catch (error) {
res.status(401).json({ success: false, message: 'Token is not valid' });
}
};
};

View File

@@ -1,37 +1,8 @@
import express, { Request, Response, NextFunction } from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import fs from 'fs';
import { auth } from './auth.js';
import { initializeDefaultUser } from '../models/User.js';
import config from '../config/index.js';
// Create __dirname equivalent for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Try to find the correct frontend file path
const findFrontendPath = (): string => {
// First try development environment path
const devPath = path.join(dirname(__dirname), 'frontend', 'dist', 'index.html');
if (fs.existsSync(devPath)) {
return path.join(dirname(__dirname), 'frontend', 'dist');
}
// Try npm/npx installed path (remove /dist directory)
const npmPath = path.join(dirname(dirname(__dirname)), 'frontend', 'dist', 'index.html');
if (fs.existsSync(npmPath)) {
return path.join(dirname(dirname(__dirname)), 'frontend', 'dist');
}
// If none of the above paths exist, return the most reasonable default path and log a warning
console.warn('Warning: Could not locate frontend files. Using default path.');
return path.join(dirname(__dirname), 'frontend', 'dist');
};
const frontendPath = findFrontendPath();
export const errorHandler = (
err: Error,
_req: Request,
@@ -52,6 +23,7 @@ export const initMiddlewares = (app: express.Application): void => {
app.use((req, res, next) => {
const basePath = config.basePath;
// Only apply JSON parsing for API and auth routes, not for SSE or message endpoints
// TODO exclude sse responses by mcp endpoint
if (
req.path !== `${basePath}/sse` &&
!req.path.startsWith(`${basePath}/sse/`) &&

View File

@@ -1,7 +1,5 @@
import fs from 'fs';
import path from 'path';
import bcrypt from 'bcryptjs';
import { IUser, McpSettings } from '../types/index.js';
import { IUser } from '../types/index.js';
import { loadSettings, saveSettings } from '../config/index.js';
// Get all users
@@ -29,38 +27,38 @@ const saveUsers = (users: IUser[]): void => {
// Create a new user
export const createUser = async (userData: IUser): Promise<IUser | null> => {
const users = getUsers();
// Check if username already exists
if (users.some(user => user.username === userData.username)) {
if (users.some((user) => user.username === userData.username)) {
return null;
}
// Hash the password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(userData.password, salt);
const newUser = {
username: userData.username,
password: hashedPassword,
isAdmin: userData.isAdmin || false
isAdmin: userData.isAdmin || false,
};
users.push(newUser);
saveUsers(users);
return newUser;
};
// Find user by username
export const findUserByUsername = (username: string): IUser | undefined => {
const users = getUsers();
return users.find(user => user.username === username);
return users.find((user) => user.username === username);
};
// Verify user password
export const verifyPassword = async (
plainPassword: string,
hashedPassword: string
plainPassword: string,
hashedPassword: string,
): Promise<boolean> => {
return await bcrypt.compare(plainPassword, hashedPassword);
};
@@ -68,36 +66,36 @@ export const verifyPassword = async (
// Update user password
export const updateUserPassword = async (
username: string,
newPassword: string
newPassword: string,
): Promise<boolean> => {
const users = getUsers();
const userIndex = users.findIndex(user => user.username === username);
const userIndex = users.findIndex((user) => user.username === username);
if (userIndex === -1) {
return false;
}
// Hash the new password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(newPassword, salt);
// Update the user's password
users[userIndex].password = hashedPassword;
saveUsers(users);
return true;
};
// Initialize with default admin user if no users exist
export const initializeDefaultUser = async (): Promise<void> => {
const users = getUsers();
if (users.length === 0) {
await createUser({
username: 'admin',
password: 'admin123',
isAdmin: true
isAdmin: true,
});
console.log('Default admin user created');
}
};
};

View File

@@ -8,6 +8,8 @@ import {
updateServer,
deleteServer,
toggleServer,
toggleTool,
updateToolDescription,
updateSystemConfig,
} from '../controllers/serverController.js';
import {
@@ -32,8 +34,9 @@ import {
} from '../controllers/marketController.js';
import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js';
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
import { getRuntimeConfig } from '../controllers/configController.js';
import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js';
import { callTool } from '../controllers/toolController.js';
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
import { auth } from '../middlewares/auth.js';
const router = express.Router();
@@ -46,6 +49,8 @@ export const initRoutes = (app: express.Application): void => {
router.put('/servers/:name', updateServer);
router.delete('/servers/:name', deleteServer);
router.post('/servers/:name/toggle', toggleServer);
router.post('/servers/:serverName/tools/:toolName/toggle', toggleTool);
router.put('/servers/:serverName/tools/:toolName/description', updateToolDescription);
router.put('/system-config', updateSystemConfig);
// Group management routes
@@ -63,6 +68,9 @@ export const initRoutes = (app: express.Application): void => {
// Tool management routes
router.post('/tools/call/:server', callTool);
// DXT upload routes
router.post('/dxt/upload', uploadMiddleware, uploadDxtFile);
// Market routes
router.get('/market/servers', getAllMarketServers);
router.get('/market/servers/search', searchMarketServersByQuery);
@@ -112,6 +120,9 @@ export const initRoutes = (app: express.Application): void => {
// Runtime configuration endpoint (no auth required for frontend initialization)
app.get(`${config.basePath}/config`, getRuntimeConfig);
// Public configuration endpoint (no auth required to check skipAuth setting)
app.get(`${config.basePath}/public-config`, getPublicConfig);
app.use(`${config.basePath}/api`, router);
};

View File

@@ -1,5 +1,5 @@
import { v4 as uuidv4 } from 'uuid';
import { IGroup, McpSettings } from '../types/index.js';
import { IGroup } from '../types/index.js';
import { loadSettings, saveSettings } from '../config/index.js';
import { notifyToolChanged } from './mcpService.js';

View File

@@ -1,5 +1,4 @@
// filepath: /Users/sunmeng/code/github/mcphub/src/services/logService.ts
import { spawn, ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import * as os from 'os';
import * as process from 'process';
@@ -157,7 +156,7 @@ class LogService {
if (sourcePidMatch) {
// If we have a 'source-processId' format in the second bracket
const [_, source, extractedProcessId] = sourcePidMatch;
const [_, source, _extractedProcessId] = sourcePidMatch;
return {
text: remainingText.trim(),
source: source.trim(),

View File

@@ -4,15 +4,48 @@ 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 { ServerInfo, ServerConfig } from '../types/index.js';
import { ServerInfo, ServerConfig, ToolInfo } from '../types/index.js';
import { loadSettings, saveSettings, expandEnvVars, replaceEnvVars } from '../config/index.js';
import config from '../config/index.js';
import { getGroup } from './sseService.js';
import { getServersInGroup } from './groupService.js';
import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js';
import { OpenAPIClient } from '../clients/openapi.js';
const servers: { [sessionId: string]: Server } = {};
// Helper function to set up keep-alive ping for SSE connections
const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): void => {
// Only set up keep-alive for SSE connections
if (!(serverInfo.transport instanceof SSEClientTransport)) {
return;
}
// Clear any existing interval first
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId);
}
// Use configured interval or default to 60 seconds for SSE
const interval = serverConfig.keepAliveInterval || 60000;
serverInfo.keepAliveIntervalId = setInterval(async () => {
try {
if (serverInfo.client && serverInfo.status === 'connected') {
await serverInfo.client.ping();
console.log(`Keep-alive ping successful for server: ${serverInfo.name}`);
}
} catch (error) {
console.warn(`Keep-alive ping failed for server ${serverInfo.name}:`, error);
// TODO Consider handling reconnection logic here if needed
}
}, interval);
console.log(
`Keep-alive ping set up for server ${serverInfo.name} with interval ${interval / 1000} seconds`,
);
};
export const initUpstreamServers = async (): Promise<void> => {
await registerAllTools(true);
};
@@ -50,11 +83,207 @@ export const notifyToolChanged = async () => {
});
};
export const syncToolEmbedding = async (serverName: string, toolName: string) => {
const serverInfo = getServerByName(serverName);
if (!serverInfo) {
console.warn(`Server not found: ${serverName}`);
return;
}
const tool = serverInfo.tools.find((t) => t.name === toolName);
if (!tool) {
console.warn(`Tool not found: ${toolName} on server: ${serverName}`);
return;
}
// Save tool as vector embedding for search
saveToolsAsVectorEmbeddings(serverName, [tool]);
};
// Store all server information
let serverInfos: ServerInfo[] = [];
// Helper function to create transport based on server configuration
const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
let transport;
if (conf.type === 'streamable-http') {
const options: any = {};
if (conf.headers && Object.keys(conf.headers).length > 0) {
options.requestInit = {
headers: conf.headers,
};
}
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) {
options.eventSourceInit = {
headers: conf.headers,
};
options.requestInit = {
headers: conf.headers,
};
}
transport = new SSEClientTransport(new URL(conf.url), options);
} else if (conf.command && conf.args) {
// Stdio transport
const env: Record<string, string> = {
...(process.env as Record<string, string>),
...replaceEnvVars(conf.env || {}),
};
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
const settings = loadSettings();
// Add UV_DEFAULT_INDEX and npm_config_registry if needed
if (
settings.systemConfig?.install?.pythonIndexUrl &&
(conf.command === 'uvx' || conf.command === 'uv' || conf.command === 'python')
) {
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
}
if (
settings.systemConfig?.install?.npmRegistry &&
(conf.command === 'npm' ||
conf.command === 'npx' ||
conf.command === 'pnpm' ||
conf.command === 'yarn' ||
conf.command === 'node')
) {
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
}
transport = new StdioClientTransport({
command: conf.command,
args: conf.args,
env: env,
stderr: 'pipe',
});
transport.stderr?.on('data', (data) => {
console.log(`[${name}] [child] ${data}`);
});
} else {
throw new Error(`Unable to create transport for server: ${name}`);
}
return transport;
};
// Helper function to handle client.callTool with reconnection logic
const callToolWithReconnect = async (
serverInfo: ServerInfo,
toolParams: any,
options?: any,
maxRetries: number = 1,
): Promise<any> => {
if (!serverInfo.client) {
throw new Error(`Client not found for server: ${serverInfo.name}`);
}
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await serverInfo.client.callTool(toolParams, undefined, options || {});
return result;
} catch (error: any) {
// Check if error message starts with "Error POSTing to endpoint (HTTP 40"
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) {
console.warn(
`HTTP 40x error detected for StreamableHTTP server ${serverInfo.name}, attempting reconnection (attempt ${attempt + 1}/${maxRetries + 1})`,
);
try {
// Close existing connection
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId);
serverInfo.keepAliveIntervalId = undefined;
}
serverInfo.client.close();
serverInfo.transport.close();
// Get server configuration to recreate transport
const settings = loadSettings();
const conf = settings.mcpServers[serverInfo.name];
if (!conf) {
throw new Error(`Server configuration not found for: ${serverInfo.name}`);
}
// Recreate transport using helper function
const newTransport = createTransportFromConfig(serverInfo.name, conf);
// Create new client
const client = new Client(
{
name: `mcp-client-${serverInfo.name}`,
version: '1.0.0',
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
},
},
);
// Reconnect with new transport
await client.connect(newTransport, serverInfo.options || {});
// Update server info with new client and transport
serverInfo.client = client;
serverInfo.transport = newTransport;
serverInfo.status = 'connected';
// Reload tools list after reconnection
try {
const tools = await client.listTools({}, serverInfo.options || {});
serverInfo.tools = tools.tools.map((tool) => ({
name: `${serverInfo.name}-${tool.name}`,
description: tool.description || '',
inputSchema: tool.inputSchema || {},
}));
// Save tools as vector embeddings for search
saveToolsAsVectorEmbeddings(serverInfo.name, serverInfo.tools);
} catch (listToolsError) {
console.warn(
`Failed to reload tools after reconnection for server ${serverInfo.name}:`,
listToolsError,
);
// Continue anyway, as the connection might still work for the current tool
}
console.log(`Successfully reconnected to server: ${serverInfo.name}`);
// Continue to next attempt
continue;
} catch (reconnectError) {
console.error(`Failed to reconnect to server ${serverInfo.name}:`, reconnectError);
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to reconnect: ${reconnectError}`;
// If this was the last attempt, throw the original error
if (attempt === maxRetries) {
throw error;
}
}
} else {
// Not an HTTP 40x error or no more retries, throw the original error
throw error;
}
}
}
// This should not be reached, but just in case
throw new Error('Unexpected error in callToolWithReconnect');
};
// Initialize MCP server clients
export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] => {
export const initializeClientsFromSettings = async (isInit: boolean): Promise<ServerInfo[]> => {
const settings = loadSettings();
const existingServerInfos = serverInfos;
serverInfos = [];
@@ -88,74 +317,74 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
}
let transport;
if (conf.type === 'streamable-http') {
const options: any = {};
if (conf.headers && Object.keys(conf.headers).length > 0) {
options.requestInit = {
headers: conf.headers,
};
}
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
} else if (conf.url) {
// Default to SSE only when 'conf.type' is not specified and 'conf.url' is available
const options: any = {};
if (conf.headers && Object.keys(conf.headers).length > 0) {
options.eventSourceInit = {
headers: conf.headers,
};
options.requestInit = {
headers: conf.headers,
};
}
transport = new SSEClientTransport(new URL(conf.url), options);
} else if (conf.command && conf.args) {
// If type is stdio or if command and args are provided without type
const env: Record<string, string> = {
...(process.env as Record<string, string>), // Inherit all environment variables from parent process
...replaceEnvVars(conf.env || {}), // Override with configured env vars
};
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
let openApiClient;
// Add UV_DEFAULT_INDEX from settings if available (for Python packages)
const settings = loadSettings(); // Add UV_DEFAULT_INDEX from settings if available (for Python packages)
if (
settings.systemConfig?.install?.pythonIndexUrl &&
(conf.command === 'uvx' || conf.command === 'uv' || conf.command === 'python')
) {
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
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({
name,
status: 'disconnected',
error: 'Missing OpenAPI specification URL or schema',
tools: [],
createTime: Date.now(),
});
continue;
}
// Add npm_config_registry from settings if available (for NPM packages)
if (
settings.systemConfig?.install?.npmRegistry &&
(conf.command === 'npm' ||
conf.command === 'npx' ||
conf.command === 'pnpm' ||
conf.command === 'yarn' ||
conf.command === 'node')
) {
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
}
transport = new StdioClientTransport({
command: conf.command,
args: conf.args,
env: env,
stderr: 'pipe',
});
transport.stderr?.on('data', (data) => {
console.log(`[${name}] [child] ${data}`);
});
} else {
console.warn(`Skipping server '${name}': missing required configuration`);
serverInfos.push({
// Create server info first and keep reference to it
const serverInfo: ServerInfo = {
name,
status: 'disconnected',
error: 'Missing required configuration',
status: 'connecting',
error: null,
tools: [],
createTime: Date.now(),
});
continue;
enabled: conf.enabled === undefined ? true : conf.enabled,
};
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: ToolInfo[] = openApiTools.map((tool) => ({
name: `${name}-${tool.name}`,
description: tool.description,
inputSchema: 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 = createTransportFromConfig(name, conf);
}
const client = new Client(
@@ -171,76 +400,72 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
},
},
);
const timeout = isInit ? Number(config.initTimeout) : Number(config.timeout);
client
.connect(transport, { timeout: timeout })
.then(() => {
console.log(`Successfully connected client for server: ${name}`);
client
.listTools({}, { timeout: timeout })
.then((tools) => {
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
const serverInfo = getServerByName(name);
if (!serverInfo) {
console.warn(`Server info not found for server: ${name}`);
return;
}
serverInfo.tools = tools.tools.map((tool) => ({
name: tool.name,
description: tool.description || '',
inputSchema: tool.inputSchema || {},
}));
serverInfo.status = 'connected';
serverInfo.error = null;
// Save tools as vector embeddings for search (only when smart routing is enabled)
if (serverInfo.tools.length > 0) {
try {
const settings = loadSettings();
const smartRoutingEnabled = settings.systemConfig?.smartRouting?.enabled || false;
if (smartRoutingEnabled) {
console.log(
`Smart routing enabled - saving vector embeddings for server ${name}`,
);
saveToolsAsVectorEmbeddings(name, serverInfo.tools);
}
} catch (vectorError) {
console.warn(`Failed to save vector embeddings for server ${name}:`, vectorError);
}
}
})
.catch((error) => {
console.error(
`Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`,
);
const serverInfo = getServerByName(name);
if (serverInfo) {
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to list tools: ${error.stack} `;
}
});
})
.catch((error) => {
console.error(
`Failed to connect client for server ${name} by error: ${error} with stack: ${error.stack}`,
);
const serverInfo = getServerByName(name);
if (serverInfo) {
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to connect: ${error.stack} `;
const initRequestOptions = isInit
? {
timeout: Number(config.initTimeout) || 60000,
}
});
serverInfos.push({
: 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,
status: 'connecting',
error: null,
tools: [],
client,
transport,
options: requestOptions,
createTime: Date.now(),
});
};
serverInfos.push(serverInfo);
client
.connect(transport, initRequestOptions || requestOptions)
.then(() => {
console.log(`Successfully connected client for server: ${name}`);
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}-${tool.name}`,
description: tool.description || '',
inputSchema: tool.inputSchema || {},
}));
serverInfo.status = 'connected';
serverInfo.error = null;
// Set up keep-alive ping for SSE connections
setupKeepAlive(serverInfo, conf);
// 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}`,
);
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to list tools: ${error.stack} `;
});
})
.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}`);
}
@@ -249,7 +474,7 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
// Register all MCP tools
export const registerAllTools = async (isInit: boolean): Promise<void> => {
initializeClientsFromSettings(isInit);
await initializeClientsFromSettings(isInit);
};
// Get all server information
@@ -258,11 +483,22 @@ export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] =>
const infos = serverInfos.map(({ name, status, tools, createTime, error }) => {
const serverConfig = settings.mcpServers[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
};
});
return {
name,
status,
error,
tools,
tools: toolsWithEnabled,
createTime,
enabled,
};
@@ -279,6 +515,23 @@ const getServerByName = (name: string): ServerInfo | undefined => {
return serverInfos.find((serverInfo) => serverInfo.name === name);
};
// Filter tools by server configuration
const filterToolsByConfig = (serverName: string, tools: ToolInfo[]): ToolInfo[] => {
const settings = loadSettings();
const serverConfig = settings.mcpServers[serverName];
if (!serverConfig || !serverConfig.tools) {
// If no tool configuration exists, all tools are enabled by default
return tools;
}
return tools.filter((tool) => {
const toolConfig = serverConfig.tools?.[tool.name];
// If tool is not in config, it's enabled by default
return toolConfig?.enabled !== false;
});
};
// Get server by tool name
const getServerByTool = (toolName: string): ServerInfo | undefined => {
return serverInfos.find((serverInfo) => serverInfo.tools.some((tool) => tool.name === toolName));
@@ -355,10 +608,53 @@ export const updateMcpServer = async (
}
};
// Add or update server (supports overriding existing servers for DXT)
export const addOrUpdateServer = async (
name: string,
config: ServerConfig,
allowOverride: boolean = false,
): Promise<{ success: boolean; message?: string }> => {
try {
const settings = loadSettings();
const exists = !!settings.mcpServers[name];
if (exists && !allowOverride) {
return { success: false, message: 'Server name already exists' };
}
// If overriding and this is a DXT server (stdio type with file paths),
// we might want to clean up old files in the future
if (exists && config.type === 'stdio') {
// Close existing server connections
closeServer(name);
// Remove from server infos
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
}
settings.mcpServers[name] = config;
if (!saveSettings(settings)) {
return { success: false, message: 'Failed to save settings' };
}
const action = exists ? 'updated' : 'added';
return { success: true, message: `Server ${action} successfully` };
} catch (error) {
console.error(`Failed to add/update server: ${name}`, error);
return { success: false, message: 'Failed to add/update server' };
}
};
// Close server client and transport
function closeServer(name: string) {
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
if (serverInfo && serverInfo.client && serverInfo.transport) {
// Clear keep-alive interval if exists
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId);
serverInfo.keepAliveIntervalId = undefined;
console.log(`Cleared keep-alive interval for server: ${serverInfo.name}`);
}
serverInfo.client.close();
serverInfo.transport.close();
console.log(`Closed client and transport for server: ${serverInfo.name}`);
@@ -489,7 +785,21 @@ Available servers: ${serversList}`;
const allTools = [];
for (const serverInfo of allServerInfos) {
if (serverInfo.tools && serverInfo.tools.length > 0) {
allTools.push(...serverInfo.tools);
// Filter tools based on server configuration and apply custom descriptions
const enabledTools = filterToolsByConfig(serverInfo.name, serverInfo.tools);
// Apply custom descriptions from configuration
const settings = loadSettings();
const serverConfig = settings.mcpServers[serverInfo.name];
const toolsWithCustomDescriptions = enabledTools.map((tool) => {
const toolConfig = serverConfig?.tools?.[tool.name];
return {
...tool,
description: toolConfig?.description || tool.description, // Use custom description if available
};
});
allTools.push(...toolsWithCustomDescriptions);
}
}
@@ -530,30 +840,54 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
const searchResults = await searchToolsByVector(query, limitNum, thresholdNum, servers);
console.log(`Search results: ${JSON.stringify(searchResults)}`);
// Find actual tool information from serverInfos by serverName and toolName
const tools = searchResults.map((result) => {
// Find the server in serverInfos
const server = serverInfos.find(
(serverInfo) =>
serverInfo.name === result.serverName &&
serverInfo.status === 'connected' &&
serverInfo.enabled !== false,
);
if (server && server.tools && server.tools.length > 0) {
// Find the tool in server.tools
const actualTool = server.tools.find((tool) => tool.name === result.toolName);
if (actualTool) {
// Return the actual tool info from serverInfos
return actualTool;
}
}
const tools = searchResults
.map((result) => {
// Find the server in serverInfos
const server = serverInfos.find(
(serverInfo) =>
serverInfo.name === result.serverName &&
serverInfo.status === 'connected' &&
serverInfo.enabled !== false,
);
if (server && server.tools && server.tools.length > 0) {
// Find the tool in server.tools
const actualTool = server.tools.find((tool) => tool.name === result.toolName);
if (actualTool) {
// Check if the tool is enabled in configuration
const enabledTools = filterToolsByConfig(server.name, [actualTool]);
if (enabledTools.length > 0) {
// Apply custom description from configuration
const settings = loadSettings();
const serverConfig = settings.mcpServers[server.name];
const toolConfig = serverConfig?.tools?.[actualTool.name];
// Fallback to search result if server or tool not found
return {
name: result.toolName,
description: result.description || '',
inputSchema: result.inputSchema || {},
};
});
// Return the actual tool info from serverInfos with custom description
return {
...actualTool,
description: toolConfig?.description || actualTool.description,
};
}
}
}
// Fallback to search result if server or tool not found or disabled
return {
name: result.toolName,
description: result.description || '',
inputSchema: result.inputSchema || {},
};
})
.filter((tool) => {
// Additional filter to remove tools that are disabled
if (tool.name) {
const serverName = searchResults.find((r) => r.toolName === tool.name)?.serverName;
if (serverName) {
const enabledTools = filterToolsByConfig(serverName, [tool as ToolInfo]);
return enabledTools.length > 0;
}
}
return true; // Keep fallback results
});
// Add usage guidance to the response
const response = {
@@ -586,14 +920,12 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
// Special handling for call_tool
if (request.params.name === 'call_tool') {
const { toolName, arguments: toolArgs = {} } = request.params.arguments || {};
let { toolName } = request.params.arguments || {};
if (!toolName) {
throw new Error('toolName parameter is required');
}
// arguments parameter is now optional
const { arguments: toolArgs = {} } = request.params.arguments || {};
let targetServerInfo: ServerInfo | undefined;
if (extra && extra.server) {
targetServerInfo = getServerByName(extra.server);
@@ -617,7 +949,38 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
throw new Error(`Tool '${toolName}' not found on server '${targetServerInfo.name}'`);
}
// Call the tool on the target server
// Handle OpenAPI servers differently
if (targetServerInfo.openApiClient) {
// For OpenAPI servers, use the OpenAPI client
const openApiClient = targetServerInfo.openApiClient;
// Use toolArgs if it has properties, otherwise fallback to request.params.arguments
const finalArgs =
toolArgs && Object.keys(toolArgs).length > 0 ? toolArgs : request.params.arguments || {};
console.log(
`Invoking OpenAPI tool '${toolName}' on server '${targetServerInfo.name}' with arguments: ${JSON.stringify(finalArgs)}`,
);
// Remove server prefix from tool name if present
const cleanToolName = toolName.startsWith(`${targetServerInfo.name}-`)
? toolName.replace(`${targetServerInfo.name}-`, '')
: toolName;
const result = await openApiClient.callTool(cleanToolName, finalArgs);
console.log(`OpenAPI tool invocation result: ${JSON.stringify(result)}`);
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
};
}
// Call the tool on the target server (MCP servers)
const client = targetServerInfo.client;
if (!client) {
throw new Error(`Client not found for server: ${targetServerInfo.name}`);
@@ -631,10 +994,17 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
`Invoking tool '${toolName}' on server '${targetServerInfo.name}' with arguments: ${JSON.stringify(finalArgs)}`,
);
const result = await client.callTool({
name: toolName,
arguments: finalArgs,
});
toolName = toolName.startsWith(`${targetServerInfo.name}-`)
? toolName.replace(`${targetServerInfo.name}-`, '')
: toolName;
const result = await callToolWithReconnect(
targetServerInfo,
{
name: toolName,
arguments: finalArgs,
},
targetServerInfo.options || {},
);
console.log(`Tool invocation result: ${JSON.stringify(result)}`);
return result;
@@ -645,11 +1015,48 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
if (!serverInfo) {
throw new Error(`Server not found: ${request.params.name}`);
}
// Handle OpenAPI servers differently
if (serverInfo.openApiClient) {
// For OpenAPI servers, use the OpenAPI client
const openApiClient = serverInfo.openApiClient;
// Remove server prefix from tool name if present
const cleanToolName = request.params.name.startsWith(`${serverInfo.name}-`)
? request.params.name.replace(`${serverInfo.name}-`, '')
: request.params.name;
console.log(
`Invoking OpenAPI tool '${cleanToolName}' on server '${serverInfo.name}' with arguments: ${JSON.stringify(request.params.arguments)}`,
);
const result = await openApiClient.callTool(cleanToolName, request.params.arguments || {});
console.log(`OpenAPI tool invocation result: ${JSON.stringify(result)}`);
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
};
}
// Handle MCP servers
const client = serverInfo.client;
if (!client) {
throw new Error(`Client not found for server: ${request.params.name}`);
throw new Error(`Client not found for server: ${serverInfo.name}`);
}
const result = await client.callTool(request.params);
request.params.name = request.params.name.startsWith(`${serverInfo.name}-`)
? request.params.name.replace(`${serverInfo.name}-`, '')
: request.params.name;
const result = await callToolWithReconnect(
serverInfo,
request.params,
serverInfo.options || {},
);
console.log(`Tool call result: ${JSON.stringify(result)}`);
return result;
} catch (error) {

View File

@@ -6,6 +6,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { deleteMcpServer, getMcpServer } from './mcpService.js';
import { loadSettings } from '../config/index.js';
import config from '../config/index.js';
const transports: { [sessionId: string]: { transport: Transport; group: string } } = {};
@@ -58,7 +59,7 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
return;
}
const transport = new SSEServerTransport('/messages', res);
const transport = new SSEServerTransport(`${config.basePath}/messages`, res);
transports[transport.sessionId] = { transport, group: group };
res.on('close', () => {
@@ -108,7 +109,10 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
export const handleMcpPostRequest = async (req: Request, res: Response): Promise<void> => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
const group = req.params.group;
console.log(`Handling MCP post request for sessionId: ${sessionId} and group: ${group}`);
const body = req.body;
console.log(
`Handling MCP post request for sessionId: ${sessionId} and group: ${group} with body: ${JSON.stringify(body)}`,
);
// Check bearer auth
if (!validateBearerAuth(req)) {
res.status(401).send('Bearer authentication required or invalid token');

View File

@@ -2,45 +2,17 @@ import { getRepositoryFactory } from '../db/index.js';
import { VectorEmbeddingRepository } from '../db/repositories/index.js';
import { ToolInfo } from '../types/index.js';
import { getAppDataSource, initializeDatabase } from '../db/connection.js';
import { loadSettings } from '../config/index.js';
import { getSmartRoutingConfig } from '../utils/smartRouting.js';
import OpenAI from 'openai';
// Get OpenAI configuration from smartRouting settings or fallback to environment variables
const getOpenAIConfig = () => {
try {
const settings = loadSettings();
const smartRouting = settings.systemConfig?.smartRouting;
return {
apiKey: smartRouting?.openaiApiKey || process.env.OPENAI_API_KEY,
baseURL:
smartRouting?.openaiApiBaseUrl ||
process.env.OPENAI_API_BASE_URL ||
'https://api.openai.com/v1',
embeddingModel:
smartRouting?.openaiApiEmbeddingModel ||
process.env.OPENAI_API_EMBEDDING_MODEL ||
'text-embedding-3-small',
};
} catch (error) {
console.warn(
'Failed to load smartRouting settings, falling back to environment variables:',
error,
);
return {
apiKey: '',
baseURL: 'https://api.openai.com/v1',
embeddingModel: 'text-embedding-3-small',
};
}
};
// Environment variables for embedding configuration
const EMBEDDING_ENV = {
// The embedding model to use - default to OpenAI but allow BAAI/BGE models
MODEL: process.env.EMBEDDING_MODEL || getOpenAIConfig().embeddingModel,
// Detect if using a BGE model from the environment variable
IS_BGE_MODEL: !!(process.env.EMBEDDING_MODEL && process.env.EMBEDDING_MODEL.includes('bge')),
const smartRoutingConfig = getSmartRoutingConfig();
return {
apiKey: smartRoutingConfig.openaiApiKey,
baseURL: smartRoutingConfig.openaiApiBaseUrl,
embeddingModel: smartRoutingConfig.openaiApiEmbeddingModel,
};
};
// Constants for embedding models
@@ -221,6 +193,16 @@ export const saveToolsAsVectorEmbeddings = async (
tools: ToolInfo[],
): Promise<void> => {
try {
if (tools.length === 0) {
console.warn(`No tools to save for server: ${serverName}`);
return;
}
const smartRoutingConfig = getSmartRoutingConfig();
if (!smartRoutingConfig.enabled) {
return;
}
const config = getOpenAIConfig();
const vectorRepository = getRepositoryFactory(
'vectorEmbeddings',
@@ -494,7 +476,7 @@ export const getAllVectorizedTools = async (
*/
export const removeServerToolEmbeddings = async (serverName: string): Promise<void> => {
try {
const vectorRepository = getRepositoryFactory(
const _vectorRepository = getRepositoryFactory(
'vectorEmbeddings',
)() as VectorEmbeddingRepository;

View File

@@ -2,6 +2,8 @@ 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 { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
import { SmartRoutingConfig } from '../utils/smartRouting.js';
// User interface
export interface IUser {
@@ -85,31 +87,68 @@ export interface McpSettings {
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
bearerAuthKey?: string; // The bearer auth key to validate against
skipAuth?: boolean; // Controls whether authentication is required for frontend and API access
};
install?: {
pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX)
npmRegistry?: string; // NPM registry URL (npm_config_registry)
};
smartRouting?: {
enabled?: boolean; // Controls whether smart routing is enabled
dbUrl?: string; // Database URL for smart routing
openaiApiBaseUrl?: string; // OpenAI API base URL
openaiApiKey?: string; // OpenAI API key
openaiApiEmbeddingModel?: string; // OpenAI API embedding model
};
smartRouting?: SmartRoutingConfig;
// Add other system configuration sections here in the future
};
}
// Configuration details for an individual server
export interface ServerConfig {
type?: 'stdio' | 'sse' | 'streamable-http'; // Type of server
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi'; // Type of server
url?: string; // URL for SSE or streamable HTTP servers
command?: string; // Command to execute for stdio-based servers
args?: string[]; // Arguments for the command
env?: Record<string, string>; // Environment variables
headers?: Record<string, string>; // HTTP headers for SSE/streamable-http servers
headers?: Record<string, string>; // HTTP headers for SSE/streamable-http/openapi servers
enabled?: boolean; // Flag to enable/disable the server
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
// OpenAPI specific configuration
openapi?: {
url?: string; // OpenAPI specification URL
schema?: Record<string, any>; // Complete OpenAPI JSON schema
version?: string; // OpenAPI version (default: '3.1.0')
security?: OpenAPISecurityConfig; // Security configuration for API calls
};
}
// OpenAPI Security Configuration
export interface OpenAPISecurityConfig {
type: 'none' | 'apiKey' | 'http' | 'oauth2' | 'openIdConnect';
// API Key authentication
apiKey?: {
name: string; // Header/query/cookie name
in: 'header' | 'query' | 'cookie';
value: string; // The API key value
};
// HTTP authentication (Basic, Bearer, etc.)
http?: {
scheme: 'basic' | 'bearer' | 'digest'; // HTTP auth scheme
bearerFormat?: string; // Bearer token format (e.g., JWT)
credentials?: string; // Base64 encoded credentials for basic auth or bearer token
};
// OAuth2 (simplified - mainly for bearer tokens)
oauth2?: {
tokenUrl?: string; // Token endpoint for client credentials flow
clientId?: string;
clientSecret?: string;
scopes?: string[]; // Required scopes
token?: string; // Pre-obtained access token
};
// OpenID Connect
openIdConnect?: {
url: string; // OpenID Connect discovery URL
clientId?: string;
clientSecret?: string;
token?: string; // Pre-obtained ID token
};
}
// Information about a server's status and tools
@@ -118,10 +157,13 @@ export interface ServerInfo {
status: 'connected' | 'connecting' | 'disconnected'; // Current connection status
error: string | null; // Error message if any
tools: ToolInfo[]; // List of tools available on the server
client?: Client; // Client instance for communication
client?: Client; // Client instance for communication (MCP clients)
transport?: SSEClientTransport | StdioClientTransport | StreamableHTTPClientTransport; // Transport mechanism used
openApiClient?: any; // OpenAPI client instance for openapi type servers
options?: RequestOptions; // Options for requests
createTime: number; // Timestamp of when the server was created
enabled?: boolean; // Flag to indicate if the server is enabled
keepAliveIntervalId?: NodeJS.Timeout; // Timer ID for keep-alive ping interval
}
// Details about a tool available on the server
@@ -129,6 +171,7 @@ export interface ToolInfo {
name: string; // Name of the tool
description: string; // Brief description of the tool
inputSchema: Record<string, unknown>; // Input schema for the tool
enabled?: boolean; // Whether the tool is enabled (optional, defaults to true)
}
// Standardized API response structure

143
src/utils/smartRouting.ts Normal file
View File

@@ -0,0 +1,143 @@
import { loadSettings, expandEnvVars } from '../config/index.js';
/**
* Smart routing configuration interface
*/
export interface SmartRoutingConfig {
enabled: boolean;
dbUrl: string;
openaiApiBaseUrl: string;
openaiApiKey: string;
openaiApiEmbeddingModel: string;
}
/**
* Gets the complete smart routing configuration from environment variables and settings.
*
* Priority order for each setting:
* 1. Specific environment variables (ENABLE_SMART_ROUTING, SMART_ROUTING_ENABLED, etc.)
* 2. Generic environment variables (OPENAI_API_KEY, DATABASE_URL, etc.)
* 3. Settings configuration (systemConfig.smartRouting)
* 4. Default values
*
* @returns {SmartRoutingConfig} Complete smart routing configuration
*/
export function getSmartRoutingConfig(): SmartRoutingConfig {
const settings = loadSettings();
const smartRoutingSettings: Partial<SmartRoutingConfig> =
settings.systemConfig?.smartRouting || {};
return {
// Enabled status - check multiple environment variables
enabled: getConfigValue(
[process.env.SMART_ROUTING_ENABLED],
smartRoutingSettings.enabled,
false,
parseBooleanEnvVar,
),
// Database configuration
dbUrl: getConfigValue([process.env.DB_URL], smartRoutingSettings.dbUrl, '', expandEnvVars),
// OpenAI API configuration
openaiApiBaseUrl: getConfigValue(
[process.env.OPENAI_API_BASE_URL],
smartRoutingSettings.openaiApiBaseUrl,
'https://api.openai.com/v1',
expandEnvVars,
),
openaiApiKey: getConfigValue(
[process.env.OPENAI_API_KEY],
smartRoutingSettings.openaiApiKey,
'',
expandEnvVars,
),
openaiApiEmbeddingModel: getConfigValue(
[process.env.OPENAI_API_EMBEDDING_MODEL],
smartRoutingSettings.openaiApiEmbeddingModel,
'text-embedding-3-small',
expandEnvVars,
),
};
}
/**
* Gets a configuration value with priority order: environment variables > settings > default.
*
* @param {(string | undefined)[]} envVars - Array of environment variable names to check in order
* @param {any} settingsValue - Value from settings configuration
* @param {any} defaultValue - Default value to use if no other value is found
* @param {Function} transformer - Function to transform the final value to the correct type
* @returns {any} The configuration value with the appropriate transformation applied
*/
function getConfigValue<T>(
envVars: (string | undefined)[],
settingsValue: any,
defaultValue: T,
transformer: (value: any) => T,
): T {
// Check environment variables in order
for (const envVar of envVars) {
if (envVar !== undefined && envVar !== null && envVar !== '') {
try {
return transformer(envVar);
} catch (error) {
console.warn(`Failed to transform environment variable "${envVar}":`, error);
continue;
}
}
}
// Check settings value
if (settingsValue !== undefined && settingsValue !== null) {
try {
return transformer(settingsValue);
} catch (error) {
console.warn('Failed to transform settings value:', error);
}
}
// Return default value
return defaultValue;
}
/**
* Parses a string environment variable value to a boolean.
* Supports common boolean representations: true/false, 1/0, yes/no, on/off
*
* @param {string} value - The environment variable value to parse
* @returns {boolean} The parsed boolean value
*/
function parseBooleanEnvVar(value: string): boolean {
if (typeof value === 'boolean') {
return value;
}
if (typeof value !== 'string') {
return false;
}
const normalized = value.toLowerCase().trim();
// Handle common truthy values
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
return true;
}
// Handle common falsy values
if (
normalized === 'false' ||
normalized === '0' ||
normalized === 'no' ||
normalized === 'off' ||
normalized === ''
) {
return false;
}
// Default to false for unrecognized values
console.warn(`Unrecognized boolean value for smart routing: "${value}", defaulting to false`);
return false;
}

177
test-integration.ts Normal file
View File

@@ -0,0 +1,177 @@
// Comprehensive test for OpenAPI server support in MCPHub
// This test verifies the complete integration including types, client, and service
import { OpenAPIClient } from './src/clients/openapi.js';
import { addServer, removeServer, getServersInfo } from './src/services/mcpService.js';
import type { ServerConfig } from './src/types/index.js';
async function testOpenAPIIntegration() {
console.log('🧪 Testing OpenAPI Integration in MCPHub\n');
// Test 1: OpenAPI Type System
console.log('1⃣ Testing OpenAPI Type System...');
const openAPIConfig: ServerConfig = {
type: 'openapi',
openapi: {
url: 'https://petstore3.swagger.io/api/v3/openapi.json',
version: '3.1.0',
security: {
type: 'none',
},
},
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
};
const apiKeyConfig: ServerConfig = {
type: 'openapi',
openapi: {
url: 'https://api.example.com/v1/openapi.json',
version: '3.1.0',
security: {
type: 'apiKey',
apiKey: {
name: 'X-API-Key',
in: 'header',
value: 'test-api-key',
},
},
},
};
const httpAuthConfig: ServerConfig = {
type: 'openapi',
openapi: {
url: 'https://api.example.com/v1/openapi.json',
version: '3.1.0',
security: {
type: 'http',
http: {
scheme: 'bearer',
credentials: 'test-token',
},
},
},
};
console.log('✅ OpenAPI type definitions are working correctly');
console.log(` - Basic config: ${openAPIConfig.type}`);
console.log(` - API Key config: ${apiKeyConfig.openapi?.security?.type}`);
console.log(` - HTTP Auth config: ${httpAuthConfig.openapi?.security?.type}`);
// Test 2: OpenAPI Client Direct
console.log('\n2⃣ Testing OpenAPI Client...');
try {
const client = new OpenAPIClient(openAPIConfig);
await client.initialize();
const tools = client.getTools();
console.log(`✅ OpenAPI client loaded ${tools.length} tools`);
// Show some example tools
const sampleTools = tools.slice(0, 3);
sampleTools.forEach((tool) => {
console.log(` - ${tool.name} (${tool.method.toUpperCase()} ${tool.path})`);
});
} catch (error) {
console.error('❌ OpenAPI client test failed:', (error as Error).message);
}
// Test 3: MCP Service Integration
console.log('\n3⃣ Testing MCP Service Integration...');
try {
// Test server registration
const serverName = 'test-openapi-server';
await addServer(serverName, openAPIConfig);
console.log(`✅ Successfully registered OpenAPI server: ${serverName}`);
// Test server retrieval
const servers = getServersInfo();
const openAPIServer = servers.find((s) => s.name === serverName);
if (openAPIServer) {
console.log(`✅ Server configuration retrieved correctly`);
console.log(` - Name: ${openAPIServer.name}`);
console.log(` - Status: ${openAPIServer.status}`);
}
// Clean up
removeServer(serverName);
console.log(`✅ Server cleanup completed`);
} catch (error) {
console.error('❌ MCP Service integration test failed:', (error as Error).message);
}
// Test 4: Security Configuration Variants
console.log('\n4⃣ Testing Security Configuration Variants...');
const securityConfigs = [
{ name: 'None', config: { type: 'none' as const } },
{
name: 'API Key (Header)',
config: {
type: 'apiKey' as const,
apiKey: { name: 'X-API-Key', in: 'header' as const, value: 'test' },
},
},
{
name: 'API Key (Query)',
config: {
type: 'apiKey' as const,
apiKey: { name: 'api_key', in: 'query' as const, value: 'test' },
},
},
{
name: 'HTTP Bearer',
config: {
type: 'http' as const,
http: { scheme: 'bearer' as const, credentials: 'token' },
},
},
{
name: 'HTTP Basic',
config: {
type: 'http' as const,
http: { scheme: 'basic' as const, credentials: 'user:pass' },
},
},
];
securityConfigs.forEach(({ name, config }) => {
const _testConfig: ServerConfig = {
type: 'openapi',
openapi: {
url: 'https://api.example.com/openapi.json',
version: '3.1.0',
security: config,
},
};
console.log(`${name} security configuration is valid`);
});
console.log('\n🎉 OpenAPI Integration Test Completed!');
console.log('\n📊 Summary:');
console.log(' ✅ Type system supports all OpenAPI configuration variants');
console.log(' ✅ OpenAPI client can load and parse specifications');
console.log(' ✅ MCP service can register and manage OpenAPI servers');
console.log(' ✅ Security configurations are properly typed and validated');
console.log('\n🚀 OpenAPI support is ready for production use!');
}
// Handle uncaught errors gracefully
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection at:', promise, 'reason:', reason);
process.exit(1);
});
// Run the comprehensive test
testOpenAPIIntegration().catch(console.error);

216
test-openapi-schema.ts Normal file
View File

@@ -0,0 +1,216 @@
// Test script to verify OpenAPI schema support
// Run this in the MCPHub project directory with: tsx test-openapi-schema.ts
import { OpenAPIClient } from './src/clients/openapi.js';
import type { ServerConfig } from './src/types/index.js';
async function testOpenAPISchemaSupport() {
console.log('🧪 Testing OpenAPI Schema Support...\n');
// Test 1: Schema-based OpenAPI client
console.log('1⃣ Testing OpenAPI client with JSON schema...');
const sampleSchema = {
openapi: '3.1.0',
info: {
title: 'Test API',
version: '1.0.0',
},
servers: [
{
url: 'https://api.example.com',
},
],
paths: {
'/users': {
get: {
operationId: 'getUsers',
summary: 'Get all users',
responses: {
'200': {
description: 'List of users',
content: {
'application/json': {
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
},
},
},
},
},
},
},
},
},
'/users/{id}': {
get: {
operationId: 'getUserById',
summary: 'Get user by ID',
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'integer' },
},
],
responses: {
'200': {
description: 'User details',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
email: { type: 'string' },
},
},
},
},
},
},
},
},
},
};
try {
const schemaConfig: ServerConfig = {
type: 'openapi',
openapi: {
schema: sampleSchema,
version: '3.1.0',
security: {
type: 'apiKey',
apiKey: {
name: 'X-API-Key',
in: 'header',
value: 'test-key',
},
},
},
};
console.log(' Creating OpenAPI client with schema...');
const client = new OpenAPIClient(schemaConfig);
console.log(' Initializing client...');
await client.initialize();
console.log(' Getting available tools...');
const tools = client.getTools();
console.log(` ✅ Schema-based client initialized successfully!`);
console.log(` 📋 Found ${tools.length} tools:`);
tools.forEach((tool) => {
console.log(` - ${tool.name}: ${tool.description}`);
});
// Test 2: Compare with URL-based client (if available)
console.log('\n2⃣ Testing configuration validation...');
// Valid configurations
const validConfigs = [
{
name: 'URL-based config',
config: {
type: 'openapi' as const,
openapi: {
url: 'https://api.example.com/openapi.json',
},
},
},
{
name: 'Schema-based config',
config: {
type: 'openapi' as const,
openapi: {
schema: sampleSchema,
},
},
},
{
name: 'Both URL and schema (should prefer schema)',
config: {
type: 'openapi' as const,
openapi: {
url: 'https://api.example.com/openapi.json',
schema: sampleSchema,
},
},
},
];
validConfigs.forEach(({ name, config }) => {
try {
const _client = new OpenAPIClient(config);
console.log(`${name}: Valid configuration`);
} catch (error) {
console.log(`${name}: Invalid configuration - ${error}`);
}
});
// Invalid configurations
console.log('\n3⃣ Testing invalid configurations...');
const invalidConfigs = [
{
name: 'No URL or schema',
config: {
type: 'openapi' as const,
openapi: {
version: '3.1.0',
},
},
},
{
name: 'Empty openapi object',
config: {
type: 'openapi' as const,
openapi: {},
},
},
];
invalidConfigs.forEach(({ name, config }) => {
try {
const _client = new OpenAPIClient(config);
console.log(`${name}: Should have failed but didn't`);
} catch (error) {
console.log(`${name}: Correctly rejected - ${(error as Error).message}`);
}
});
console.log('\n🎉 All tests completed successfully!');
console.log('\n📝 Summary:');
console.log(' ✅ OpenAPI client supports JSON schema input');
console.log(' ✅ Schema parsing and tool extraction works');
console.log(' ✅ Configuration validation works correctly');
console.log(' ✅ Both URL and schema modes are supported');
} catch (error) {
console.error('❌ Test failed:', (error as Error).message);
console.error(' Stack trace:', (error as Error).stack);
}
}
// Handle uncaught errors gracefully
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection at:', promise, 'reason:', reason);
process.exit(1);
});
// Run the test
testOpenAPISchemaSupport().catch(console.error);

64
test-openapi.ts Normal file
View File

@@ -0,0 +1,64 @@
// Test script to verify OpenAPI server functionality
// Run this in the MCPHub project directory with: tsx test-openapi.ts
import { OpenAPIClient } from './src/clients/openapi.js';
import type { ServerConfig } from './src/types/index.js';
async function testOpenAPIClient() {
console.log('Testing OpenAPI client...');
// Test configuration
const testConfig: ServerConfig = {
type: 'openapi',
openapi: {
url: 'https://petstore3.swagger.io/api/v3/openapi.json', // Public Swagger Petstore API
version: '3.1.0',
security: {
type: 'none',
},
},
headers: {
'Content-Type': 'application/json',
},
};
try {
// Initialize the OpenAPI client
const client = new OpenAPIClient(testConfig);
await client.initialize();
console.log('✅ OpenAPI client initialized successfully');
// Get available tools
const tools = client.getTools();
console.log(`✅ Found ${tools.length} tools:`);
tools.slice(0, 5).forEach((tool) => {
console.log(` - ${tool.name}: ${tool.description}`);
});
// Test a simple GET operation if available
const getTool = tools.find(
(tool) => tool.method === 'get' && tool.path.includes('/pet') && !tool.path.includes('{'),
);
if (getTool) {
console.log(`\n🔧 Testing tool: ${getTool.name}`);
try {
const result = await client.callTool(getTool.name, {});
console.log('✅ Tool call successful');
console.log('Result type:', typeof result);
} catch (error) {
console.log('⚠️ Tool call failed (expected for demo API):', (error as Error).message);
}
}
console.log('\n🎉 OpenAPI integration test completed!');
} catch (error) {
console.error('❌ Test failed:', error);
process.exit(1);
}
}
// Run the test
testOpenAPIClient().catch(console.error);

154
tests/auth.logic.test.ts Normal file
View File

@@ -0,0 +1,154 @@
// Simplified test for authController functionality
// Simple mock implementations
const mockJwt = {
sign: jest.fn(),
};
const mockUser = {
findUserByUsername: jest.fn(),
verifyPassword: jest.fn(),
createUser: jest.fn(),
};
// Mock the login function logic
const loginLogic = async (username: string, password: string) => {
const user = mockUser.findUserByUsername(username);
if (!user) {
return { success: false, message: 'Invalid credentials' };
}
const isPasswordValid = await mockUser.verifyPassword(password, user.password);
if (!isPasswordValid) {
return { success: false, message: 'Invalid credentials' };
}
return new Promise((resolve, reject) => {
mockJwt.sign(
{ user: { username: user.username, isAdmin: user.isAdmin } },
'secret',
{ expiresIn: '24h' },
(err: any, token: string) => {
if (err) reject(err);
resolve({
success: true,
token,
user: {
username: user.username,
isAdmin: user.isAdmin
}
});
}
);
});
};
describe('Auth Logic Tests', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Login Logic', () => {
it('should return success for valid credentials', async () => {
const mockUserData = {
username: 'testuser',
password: 'hashedPassword',
isAdmin: false,
};
const mockToken = 'mock-jwt-token';
// Setup mocks
mockUser.findUserByUsername.mockReturnValue(mockUserData);
mockUser.verifyPassword.mockResolvedValue(true);
mockJwt.sign.mockImplementation((payload, secret, options, callback) => {
callback(null, mockToken);
});
const result = await loginLogic('testuser', 'password123');
expect(result).toEqual({
success: true,
token: mockToken,
user: {
username: 'testuser',
isAdmin: false,
},
});
expect(mockUser.findUserByUsername).toHaveBeenCalledWith('testuser');
expect(mockUser.verifyPassword).toHaveBeenCalledWith('password123', 'hashedPassword');
});
it('should return error for non-existent user', async () => {
mockUser.findUserByUsername.mockReturnValue(undefined);
const result = await loginLogic('nonexistent', 'password123');
expect(result).toEqual({
success: false,
message: 'Invalid credentials',
});
expect(mockUser.findUserByUsername).toHaveBeenCalledWith('nonexistent');
expect(mockUser.verifyPassword).not.toHaveBeenCalled();
});
it('should return error for invalid password', async () => {
const mockUserData = {
username: 'testuser',
password: 'hashedPassword',
isAdmin: false,
};
mockUser.findUserByUsername.mockReturnValue(mockUserData);
mockUser.verifyPassword.mockResolvedValue(false);
const result = await loginLogic('testuser', 'wrongpassword');
expect(result).toEqual({
success: false,
message: 'Invalid credentials',
});
expect(mockUser.verifyPassword).toHaveBeenCalledWith('wrongpassword', 'hashedPassword');
});
});
describe('Utility Functions', () => {
it('should validate user data structure', () => {
const validUser = {
username: 'testuser',
password: 'password123',
isAdmin: false,
};
expect(validUser).toHaveProperty('username');
expect(validUser).toHaveProperty('password');
expect(validUser).toHaveProperty('isAdmin');
expect(typeof validUser.username).toBe('string');
expect(typeof validUser.password).toBe('string');
expect(typeof validUser.isAdmin).toBe('boolean');
});
it('should generate proper JWT payload structure', () => {
const user = {
username: 'testuser',
isAdmin: true,
};
const payload = {
user: {
username: user.username,
isAdmin: user.isAdmin,
},
};
expect(payload).toHaveProperty('user');
expect(payload.user).toHaveProperty('username', 'testuser');
expect(payload.user).toHaveProperty('isAdmin', true);
});
});
});

17
tests/basic.test.ts Normal file
View File

@@ -0,0 +1,17 @@
// Simple test to verify Jest configuration
describe('Jest Configuration', () => {
it('should be working correctly', () => {
expect(1 + 1).toBe(2);
});
it('should support async operations', async () => {
const promise = Promise.resolve('test');
await expect(promise).resolves.toBe('test');
});
it('should have custom matchers available', () => {
const date = new Date();
// Test custom matcher - this will fail if setup is not working
expect(typeof date.getTime()).toBe('number');
expect(date.getTime()).toBeGreaterThan(0);
});
});

76
tests/setup.ts Normal file
View File

@@ -0,0 +1,76 @@
// Global test setup
import 'reflect-metadata';
// Mock environment variables for testing
Object.assign(process.env, {
NODE_ENV: 'test',
JWT_SECRET: 'test-jwt-secret-key',
DATABASE_URL: 'sqlite::memory:',
});
// Global test utilities
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Matchers<R> {
toBeValidDate(): R;
toBeValidUUID(): R;
}
}
}
// Custom matchers
expect.extend({
toBeValidDate(received: any) {
const pass = received instanceof Date && !isNaN(received.getTime());
if (pass) {
return {
message: () => `expected ${received} not to be a valid date`,
pass: true,
};
} else {
return {
message: () => `expected ${received} to be a valid date`,
pass: false,
};
}
},
toBeValidUUID(received: any) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const pass = typeof received === 'string' && uuidRegex.test(received);
if (pass) {
return {
message: () => `expected ${received} not to be a valid UUID`,
pass: true,
};
} else {
return {
message: () => `expected ${received} to be a valid UUID`,
pass: false,
};
}
},
});
// Increase timeout for async operations
jest.setTimeout(10000);
// Mock console methods to reduce noise in tests
const originalError = console.error;
const originalWarn = console.warn;
beforeAll(() => {
console.error = jest.fn();
console.warn = jest.fn();
});
afterAll(() => {
console.error = originalError;
console.warn = originalWarn;
});
// Clear all mocks before each test
beforeEach(() => {
jest.clearAllMocks();
});

View File

@@ -0,0 +1,180 @@
// Test for path utilities functionality
import fs from 'fs';
import path from 'path';
// Mock fs module
jest.mock('fs');
const mockFs = fs as jest.Mocked<typeof fs>;
describe('Path Utilities Logic', () => {
beforeEach(() => {
jest.clearAllMocks();
delete process.env.MCPHUB_SETTING_PATH;
});
// Test the core logic of path resolution
const findConfigFile = (filename: string): string => {
const envPath = process.env.MCPHUB_SETTING_PATH;
const potentialPaths = [
...(envPath ? [envPath] : []),
path.resolve(process.cwd(), filename),
path.join(process.cwd(), filename),
];
for (const filePath of potentialPaths) {
if (fs.existsSync(filePath)) {
return filePath;
}
}
return path.resolve(process.cwd(), filename);
};
describe('Configuration File Resolution', () => {
it('should find existing file in current directory', () => {
const filename = 'test-config.json';
const expectedPath = path.resolve(process.cwd(), filename);
mockFs.existsSync.mockImplementation((filePath) => {
return filePath === expectedPath;
});
const result = findConfigFile(filename);
expect(result).toBe(expectedPath);
expect(mockFs.existsSync).toHaveBeenCalled();
});
it('should prioritize environment variable path', () => {
const filename = 'test-config.json';
const envPath = '/custom/path/test-config.json';
process.env.MCPHUB_SETTING_PATH = envPath;
mockFs.existsSync.mockImplementation((filePath) => {
return filePath === envPath;
});
const result = findConfigFile(filename);
expect(result).toBe(envPath);
expect(mockFs.existsSync).toHaveBeenCalledWith(envPath);
});
it('should return default path when file does not exist', () => {
const filename = 'nonexistent-config.json';
const expectedDefaultPath = path.resolve(process.cwd(), filename);
mockFs.existsSync.mockReturnValue(false);
const result = findConfigFile(filename);
expect(result).toBe(expectedDefaultPath);
});
it('should handle different file types', () => {
const testFiles = [
'config.json',
'settings.yaml',
'data.xml',
'servers.json'
];
testFiles.forEach(filename => {
const expectedPath = path.resolve(process.cwd(), filename);
mockFs.existsSync.mockImplementation((filePath) => {
return filePath === expectedPath;
});
const result = findConfigFile(filename);
expect(result).toBe(expectedPath);
expect(path.isAbsolute(result)).toBe(true);
});
});
});
describe('Path Operations', () => {
it('should generate absolute paths', () => {
const filename = 'test.json';
mockFs.existsSync.mockReturnValue(false);
const result = findConfigFile(filename);
expect(path.isAbsolute(result)).toBe(true);
expect(result).toContain(filename);
}); it('should handle path normalization', () => {
const filename = './config/../settings.json';
mockFs.existsSync.mockReturnValue(false);
const result = findConfigFile(filename);
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});
it('should work consistently across multiple calls', () => {
const filename = 'consistent-test.json';
const expectedPath = path.resolve(process.cwd(), filename);
mockFs.existsSync.mockImplementation((filePath) => {
return filePath === expectedPath;
});
const result1 = findConfigFile(filename);
const result2 = findConfigFile(filename);
expect(result1).toBe(result2);
expect(result1).toBe(expectedPath);
});
});
describe('Environment Variable Handling', () => {
it('should handle missing environment variable gracefully', () => {
const filename = 'test.json';
delete process.env.MCPHUB_SETTING_PATH;
mockFs.existsSync.mockReturnValue(false);
const result = findConfigFile(filename);
expect(typeof result).toBe('string');
expect(result).toContain(filename);
});
it('should handle empty environment variable', () => {
const filename = 'test.json';
process.env.MCPHUB_SETTING_PATH = '';
mockFs.existsSync.mockReturnValue(false);
const result = findConfigFile(filename);
expect(typeof result).toBe('string');
expect(result).toContain(filename);
});
});
describe('Error Handling', () => {
it('should handle fs.existsSync errors gracefully', () => {
const filename = 'test.json';
mockFs.existsSync.mockImplementation(() => {
throw new Error('File system error');
});
expect(() => findConfigFile(filename)).toThrow('File system error');
});
it('should validate input parameters', () => {
const emptyFilename = '';
mockFs.existsSync.mockReturnValue(false);
const result = findConfigFile(emptyFilename);
expect(typeof result).toBe('string');
// Should still return a path, even for empty filename
});
});
});

176
tests/utils/testHelpers.ts Normal file
View File

@@ -0,0 +1,176 @@
// Test utilities and helpers
import express from 'express';
import request from 'supertest';
import jwt from 'jsonwebtoken';
export interface TestUser {
username: string;
password: string;
isAdmin?: boolean;
}
export interface AuthTokens {
accessToken: string;
refreshToken?: string;
}
/**
* Create a test Express app instance
*/
export const createTestApp = (): express.Application => {
const app = express();
app.use(express.json());
return app;
};
/**
* Generate a test JWT token
*/
export const generateTestToken = (payload: any, secret = 'test-jwt-secret-key'): string => {
return jwt.sign(payload, secret, { expiresIn: '1h' });
};
/**
* Create a test user token with default claims
*/
export const createUserToken = (username = 'testuser', isAdmin = false): string => {
const payload = {
user: {
username,
isAdmin,
},
};
return generateTestToken(payload);
};
/**
* Create an admin user token
*/
export const createAdminToken = (username = 'admin'): string => {
return createUserToken(username, true);
};
/**
* Make authenticated request helper
*/
export const makeAuthenticatedRequest = (app: express.Application, token: string) => {
return {
get: (url: string) => request(app).get(url).set('Authorization', `Bearer ${token}`),
post: (url: string) => request(app).post(url).set('Authorization', `Bearer ${token}`),
put: (url: string) => request(app).put(url).set('Authorization', `Bearer ${token}`),
delete: (url: string) => request(app).delete(url).set('Authorization', `Bearer ${token}`),
patch: (url: string) => request(app).patch(url).set('Authorization', `Bearer ${token}`),
};
};
/**
* Common test data generators
*/
export const TestData = {
user: (overrides: Partial<TestUser> = {}): TestUser => ({
username: 'testuser',
password: 'password123',
isAdmin: false,
...overrides,
}),
adminUser: (overrides: Partial<TestUser> = {}): TestUser => ({
username: 'admin',
password: 'admin123',
isAdmin: true,
...overrides,
}),
serverConfig: (overrides: any = {}) => ({
type: 'openapi',
openapi: {
url: 'https://api.example.com/openapi.json',
version: '3.1.0',
security: {
type: 'none',
},
},
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
...overrides,
}),
};
/**
* Mock response helpers
*/
export const MockResponse = {
success: (data: any = {}) => ({
success: true,
data,
}),
error: (message: string, code = 400) => ({
success: false,
message,
code,
}),
validation: (errors: any[]) => ({
success: false,
errors,
}),
};
/**
* Database test helpers
*/
export const DbHelpers = {
/**
* Clear all test data from database
*/
clearDatabase: async (): Promise<void> => {
// TODO: Implement based on your database setup
console.log('Clearing test database...');
},
/**
* Seed test data
*/
seedTestData: async (): Promise<void> => {
// TODO: Implement based on your database setup
console.log('Seeding test data...');
},
};
/**
* Wait for async operations to complete
*/
export const waitFor = (ms: number): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms));
};
/**
* Assert API response structure
*/
export const expectApiResponse = (response: any) => ({
toBeSuccess: (expectedData?: any) => {
expect(response.body).toHaveProperty('success', true);
if (expectedData) {
expect(response.body.data).toEqual(expectedData);
}
},
toBeError: (expectedMessage?: string, expectedCode?: number) => {
expect(response.body).toHaveProperty('success', false);
if (expectedMessage) {
expect(response.body.message).toContain(expectedMessage);
}
if (expectedCode) {
expect(response.status).toBe(expectedCode);
}
},
toHaveValidationErrors: () => {
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('errors');
expect(Array.isArray(response.body.errors)).toBe(true);
},
});