mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Compare commits
5 Commits
v0.9.16
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edd1a4b063 | ||
|
|
1ff542ed45 | ||
|
|
94d5649782 | ||
|
|
f9615c8693 | ||
|
|
29c6d4bd75 |
124
.github/DOCKER_CLI_TEST.md
vendored
124
.github/DOCKER_CLI_TEST.md
vendored
@@ -1,124 +0,0 @@
|
||||
# Docker Engine Installation Test Procedure
|
||||
|
||||
This document describes how to test the Docker Engine installation feature added with the `INSTALL_EXT=true` build argument.
|
||||
|
||||
## Test 1: Build with INSTALL_EXT=false (default)
|
||||
|
||||
```bash
|
||||
# Build without extended features
|
||||
docker build -t mcphub:base .
|
||||
|
||||
# Run the container
|
||||
docker run --rm mcphub:base docker --version
|
||||
```
|
||||
|
||||
**Expected Result**: `docker: not found` error (Docker is NOT installed)
|
||||
|
||||
## Test 2: Build with INSTALL_EXT=true
|
||||
|
||||
```bash
|
||||
# Build with extended features
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# Test Docker CLI is available
|
||||
docker run --rm mcphub:extended docker --version
|
||||
```
|
||||
|
||||
**Expected Result**: Docker version output (e.g., `Docker version 27.x.x, build xxxxx`)
|
||||
|
||||
## Test 3: Docker-in-Docker with Auto-start Daemon
|
||||
|
||||
```bash
|
||||
# Build with extended features
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# Run with privileged mode (allows Docker daemon to start)
|
||||
docker run -d \
|
||||
--name mcphub-test \
|
||||
--privileged \
|
||||
-p 3000:3000 \
|
||||
mcphub:extended
|
||||
|
||||
# Wait a few seconds for daemon to start
|
||||
sleep 5
|
||||
|
||||
# Test Docker commands from inside the container
|
||||
docker exec mcphub-test docker ps
|
||||
docker exec mcphub-test docker images
|
||||
docker exec mcphub-test docker info
|
||||
|
||||
# Cleanup
|
||||
docker stop mcphub-test
|
||||
docker rm mcphub-test
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
- Docker daemon should auto-start inside the container
|
||||
- Docker commands should work without mounting the host's Docker socket
|
||||
- `docker info` should show the container's own Docker daemon
|
||||
|
||||
## Test 4: Docker-in-Docker with Host Socket (Alternative)
|
||||
|
||||
```bash
|
||||
# Build with extended features
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# Run with Docker socket mounted (uses host's daemon)
|
||||
docker run -d \
|
||||
--name mcphub-test \
|
||||
-p 3000:3000 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
mcphub:extended
|
||||
|
||||
# Test Docker commands from inside the container
|
||||
docker exec mcphub-test docker ps
|
||||
docker exec mcphub-test docker images
|
||||
|
||||
# Cleanup
|
||||
docker stop mcphub-test
|
||||
docker rm mcphub-test
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
- Docker daemon should NOT auto-start (socket already exists from host)
|
||||
- Docker commands should work and show the host's containers and images
|
||||
|
||||
## Test 5: Verify Image Size
|
||||
|
||||
```bash
|
||||
# Build both versions
|
||||
docker build -t mcphub:base .
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# Compare image sizes
|
||||
docker images mcphub:*
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
- The `extended` image should be larger than the `base` image
|
||||
- The size difference should be reasonable (Docker Engine adds ~100-150MB)
|
||||
|
||||
## Test 6: Architecture Support
|
||||
|
||||
```bash
|
||||
# On AMD64/x86_64
|
||||
docker build --build-arg INSTALL_EXT=true --platform linux/amd64 -t mcphub:extended-amd64 .
|
||||
|
||||
# On ARM64
|
||||
docker build --build-arg INSTALL_EXT=true --platform linux/arm64 -t mcphub:extended-arm64 .
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
- Both builds should succeed
|
||||
- AMD64 includes Chrome/Playwright + Docker Engine
|
||||
- ARM64 includes Docker Engine only (Chrome installation is skipped)
|
||||
|
||||
## Notes
|
||||
|
||||
- The Docker Engine installation follows the official Docker documentation
|
||||
- Includes full Docker daemon (`dockerd`), CLI (`docker`), and containerd
|
||||
- The daemon auto-starts when running in privileged mode
|
||||
- The installation uses the Debian Bookworm repository
|
||||
- All temporary files are cleaned up to minimize image size
|
||||
- The feature is opt-in via the `INSTALL_EXT` build argument
|
||||
- `iptables` is installed as it's required for Docker networking
|
||||
34
.github/copilot-instructions.md
vendored
34
.github/copilot-instructions.md
vendored
@@ -13,7 +13,6 @@ MCPHub is a TypeScript/Node.js MCP (Model Context Protocol) server management hu
|
||||
- **MCP Integration**: Connects multiple MCP servers (`src/services/mcpService.ts`)
|
||||
- **Authentication**: JWT-based with bcrypt password hashing
|
||||
- **Configuration**: JSON-based MCP server definitions (`mcp_settings.json`)
|
||||
- **Documentation**: API docs and usage instructions(`docs/`)
|
||||
|
||||
## Working Effectively
|
||||
|
||||
@@ -31,7 +30,7 @@ cp .env.example .env
|
||||
|
||||
# Build and test to verify setup
|
||||
pnpm lint # ~3 seconds - NEVER CANCEL
|
||||
pnpm backend:build # ~5 seconds - NEVER CANCEL
|
||||
pnpm backend:build # ~5 seconds - NEVER CANCEL
|
||||
pnpm test:ci # ~16 seconds - NEVER CANCEL. Set timeout to 60+ seconds
|
||||
pnpm frontend:build # ~5 seconds - NEVER CANCEL
|
||||
pnpm build # ~10 seconds total - NEVER CANCEL. Set timeout to 60+ seconds
|
||||
@@ -49,7 +48,7 @@ pnpm dev # Backend on :3001, Frontend on :5173
|
||||
# Terminal 1: Backend only
|
||||
pnpm backend:dev # Runs on port 3000 (or PORT env var)
|
||||
|
||||
# Terminal 2: Frontend only
|
||||
# Terminal 2: Frontend only
|
||||
pnpm frontend:dev # Runs on port 5173, proxies API to backend
|
||||
```
|
||||
|
||||
@@ -63,7 +62,7 @@ pnpm build # NEVER CANCEL - Set timeout to 60+ seconds
|
||||
|
||||
# Individual builds
|
||||
pnpm backend:build # TypeScript compilation - ~5 seconds
|
||||
pnpm frontend:build # Vite build - ~5 seconds
|
||||
pnpm frontend:build # Vite build - ~5 seconds
|
||||
|
||||
# Start production server
|
||||
pnpm start # Requires dist/ and frontend/dist/ to exist
|
||||
@@ -92,7 +91,6 @@ pnpm format # Prettier formatting - ~3 seconds
|
||||
**ALWAYS perform these validation steps after making changes:**
|
||||
|
||||
### 1. Basic Application Functionality
|
||||
|
||||
```bash
|
||||
# Start the application
|
||||
pnpm dev
|
||||
@@ -107,7 +105,6 @@ curl -I http://localhost:3000/
|
||||
```
|
||||
|
||||
### 2. MCP Server Integration Test
|
||||
|
||||
```bash
|
||||
# Check MCP servers are loading (look for log messages)
|
||||
# Expected log output should include:
|
||||
@@ -117,7 +114,6 @@ curl -I http://localhost:3000/
|
||||
```
|
||||
|
||||
### 3. Build Verification
|
||||
|
||||
```bash
|
||||
# Verify production build works
|
||||
pnpm build
|
||||
@@ -130,7 +126,6 @@ node scripts/verify-dist.js
|
||||
## Project Structure and Key Files
|
||||
|
||||
### Critical Backend Files
|
||||
|
||||
- `src/index.ts` - Application entry point
|
||||
- `src/server.ts` - Express server setup and middleware
|
||||
- `src/services/mcpService.ts` - **Core MCP server management logic**
|
||||
@@ -141,14 +136,11 @@ node scripts/verify-dist.js
|
||||
- `src/types/index.ts` - TypeScript type definitions
|
||||
|
||||
### Critical Frontend Files
|
||||
|
||||
- `frontend/src/` - React application source
|
||||
- `frontend/src/pages/` - Page components (development entry point)
|
||||
- `frontend/src/components/` - Reusable UI components
|
||||
- `frontend/src/utils/fetchInterceptor.js` - Backend API interaction
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- `mcp_settings.json` - **MCP server definitions and user accounts**
|
||||
- `package.json` - Dependencies and scripts
|
||||
- `tsconfig.json` - TypeScript configuration
|
||||
@@ -156,7 +148,6 @@ node scripts/verify-dist.js
|
||||
- `.eslintrc.json` - Linting rules
|
||||
|
||||
### Docker and Deployment
|
||||
|
||||
- `Dockerfile` - Multi-stage build with Python base + Node.js
|
||||
- `entrypoint.sh` - Docker startup script
|
||||
- `bin/cli.js` - NPM package CLI entry point
|
||||
@@ -164,14 +155,12 @@ node scripts/verify-dist.js
|
||||
## Development Process and Conventions
|
||||
|
||||
### Code Style Requirements
|
||||
|
||||
- **ESM modules**: Always use `.js` extensions in imports, not `.ts`
|
||||
- **English only**: All code comments must be written in English
|
||||
- **TypeScript strict**: Follow strict type checking rules
|
||||
- **Import style**: `import { something } from './file.js'` (note .js extension)
|
||||
|
||||
### Key Configuration Notes
|
||||
|
||||
- **MCP servers**: Defined in `mcp_settings.json` with command/args
|
||||
- **Endpoints**: `/mcp/{group|server}` and `/mcp/$smart` for routing
|
||||
- **i18n**: Frontend uses react-i18next with files in `locales/` folder
|
||||
@@ -179,7 +168,6 @@ node scripts/verify-dist.js
|
||||
- **Default credentials**: admin/admin123 (configured in mcp_settings.json)
|
||||
|
||||
### Development Entry Points
|
||||
|
||||
- **Add MCP server**: Modify `mcp_settings.json` and restart
|
||||
- **New API endpoint**: Add route in `src/routes/`, controller in `src/controllers/`
|
||||
- **Frontend feature**: Start from `frontend/src/pages/` or `frontend/src/components/`
|
||||
@@ -188,38 +176,29 @@ node scripts/verify-dist.js
|
||||
### Common Development Tasks
|
||||
|
||||
#### Adding a new MCP server:
|
||||
|
||||
1. Add server definition to `mcp_settings.json`
|
||||
2. Restart backend to load new server
|
||||
3. Check logs for successful connection
|
||||
4. Test via dashboard or API endpoints
|
||||
|
||||
#### API development:
|
||||
|
||||
1. Define route in `src/routes/`
|
||||
2. Implement controller in `src/controllers/`
|
||||
3. Add types in `src/types/index.ts` if needed
|
||||
4. Write tests in `tests/controllers/`
|
||||
|
||||
#### Frontend development:
|
||||
|
||||
1. Create/modify components in `frontend/src/components/`
|
||||
2. Add pages in `frontend/src/pages/`
|
||||
3. Update routing if needed
|
||||
4. Test in development mode with `pnpm frontend:dev`
|
||||
|
||||
#### Documentation:
|
||||
|
||||
1. Update or add docs in `docs/` folder
|
||||
2. Ensure README.md reflects any major changes
|
||||
|
||||
## Validation and CI Requirements
|
||||
|
||||
### Before Committing - ALWAYS Run:
|
||||
|
||||
```bash
|
||||
pnpm lint # Must pass - ~3 seconds
|
||||
pnpm backend:build # Must compile - ~5 seconds
|
||||
pnpm backend:build # Must compile - ~5 seconds
|
||||
pnpm test:ci # All tests must pass - ~16 seconds
|
||||
pnpm build # Full build must work - ~10 seconds
|
||||
```
|
||||
@@ -227,7 +206,6 @@ pnpm build # Full build must work - ~10 seconds
|
||||
**CRITICAL**: CI will fail if any of these commands fail. Fix issues locally first.
|
||||
|
||||
### CI Pipeline (.github/workflows/ci.yml)
|
||||
|
||||
- Runs on Node.js 20.x
|
||||
- Tests: linting, type checking, unit tests with coverage
|
||||
- **NEVER CANCEL**: CI builds may take 2-3 minutes total
|
||||
@@ -235,26 +213,22 @@ pnpm build # Full build must work - ~10 seconds
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
- **"uvx command not found"**: Some MCP servers require `uvx` (Python package manager) - this is expected in development
|
||||
- **Port already in use**: Change PORT environment variable or kill existing processes
|
||||
- **Frontend not loading**: Ensure frontend was built with `pnpm frontend:build`
|
||||
- **MCP server connection failed**: Check server command/args in `mcp_settings.json`
|
||||
|
||||
### Build Failures
|
||||
|
||||
- **TypeScript errors**: Run `pnpm backend:build` to see compilation errors
|
||||
- **Test failures**: Run `pnpm test:verbose` for detailed test output
|
||||
- **Lint errors**: Run `pnpm lint` and fix reported issues
|
||||
|
||||
### Development Issues
|
||||
|
||||
- **Backend not starting**: Check for port conflicts, verify `mcp_settings.json` syntax
|
||||
- **Frontend proxy errors**: Ensure backend is running before starting frontend
|
||||
- **Hot reload not working**: Restart development server
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- **Install time**: pnpm install takes ~30 seconds
|
||||
- **Build time**: Full build takes ~10 seconds
|
||||
- **Test time**: Complete test suite takes ~16 seconds
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,5 +25,4 @@ yarn-error.log*
|
||||
*.log
|
||||
coverage/
|
||||
|
||||
data/
|
||||
temp-test-config/
|
||||
data/
|
||||
@@ -4,4 +4,4 @@
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
}
|
||||
|
||||
169
BUGFIX_SUMMARY.md
Normal file
169
BUGFIX_SUMMARY.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Bug Fix: Group Creation Not Persisting in v0.9.11
|
||||
|
||||
## Issue Description
|
||||
After deploying version 0.9.11, users were unable to add groups. The group creation appeared to succeed (no errors were reported), but the groups list remained empty.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
The problem was in the `mergeSettings` implementations in both `DataServiceImpl` and `DataServicex`:
|
||||
|
||||
### Before Fix
|
||||
|
||||
**DataServiceImpl.mergeSettings:**
|
||||
```typescript
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, _user?: IUser): McpSettings {
|
||||
return newSettings; // Simply returns newSettings, discarding fields from 'all'
|
||||
}
|
||||
```
|
||||
|
||||
**DataServicex.mergeSettings (admin user):**
|
||||
```typescript
|
||||
const result = { ...all };
|
||||
result.users = newSettings.users; // Only copied users
|
||||
result.systemConfig = newSettings.systemConfig; // Only copied systemConfig
|
||||
return result;
|
||||
// Missing: groups, mcpServers, userConfigs
|
||||
```
|
||||
|
||||
### The Problem Flow
|
||||
|
||||
When a user created a group through the API:
|
||||
|
||||
1. `groupService.createGroup()` loaded settings: `loadSettings()` → returns complete settings
|
||||
2. Modified the groups array by adding new group
|
||||
3. Called `saveSettings(modifiedSettings)`
|
||||
4. `saveSettings()` called `mergeSettings(originalSettings, modifiedSettings)`
|
||||
5. **`mergeSettings()` only preserved `users` and `systemConfig`, discarding the `groups` array**
|
||||
6. The file was saved without groups
|
||||
7. Result: Groups were never persisted!
|
||||
|
||||
### Why This Happened
|
||||
|
||||
The `mergeSettings` function is designed to selectively merge changes from user operations while preserving the rest of the original settings. However, the implementations were incomplete and only handled `users` and `systemConfig`, ignoring:
|
||||
- `groups` (the bug causing this issue!)
|
||||
- `mcpServers`
|
||||
- `userConfigs` (in DataServiceImpl)
|
||||
|
||||
## Solution
|
||||
|
||||
Updated both `mergeSettings` implementations to properly preserve ALL fields:
|
||||
|
||||
### DataServiceImpl.mergeSettings (Fixed)
|
||||
```typescript
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, _user?: IUser): McpSettings {
|
||||
return {
|
||||
...all,
|
||||
...newSettings,
|
||||
// Explicitly handle each field, preserving from 'all' when not in newSettings
|
||||
users: newSettings.users !== undefined ? newSettings.users : all.users,
|
||||
mcpServers: newSettings.mcpServers !== undefined ? newSettings.mcpServers : all.mcpServers,
|
||||
groups: newSettings.groups !== undefined ? newSettings.groups : all.groups,
|
||||
systemConfig: newSettings.systemConfig !== undefined ? newSettings.systemConfig : all.systemConfig,
|
||||
userConfigs: newSettings.userConfigs !== undefined ? newSettings.userConfigs : all.userConfigs,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### DataServicex.mergeSettings (Fixed)
|
||||
```typescript
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
const result = { ...all };
|
||||
// Merge all fields, using newSettings values when present
|
||||
if (newSettings.users !== undefined) result.users = newSettings.users;
|
||||
if (newSettings.mcpServers !== undefined) result.mcpServers = newSettings.mcpServers;
|
||||
if (newSettings.groups !== undefined) result.groups = newSettings.groups; // FIXED!
|
||||
if (newSettings.systemConfig !== undefined) result.systemConfig = newSettings.systemConfig;
|
||||
if (newSettings.userConfigs !== undefined) result.userConfigs = newSettings.userConfigs;
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
## Changes Made
|
||||
|
||||
### Modified Files
|
||||
1. `src/services/dataService.ts` - Fixed mergeSettings implementation
|
||||
2. `src/services/dataServicex.ts` - Fixed mergeSettings implementation
|
||||
|
||||
### New Test Files
|
||||
1. `tests/services/groupService.test.ts` - 11 tests for group operations
|
||||
2. `tests/services/dataServiceMerge.test.ts` - 7 tests for mergeSettings behavior
|
||||
3. `tests/integration/groupPersistence.test.ts` - 5 integration tests
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Before Fix
|
||||
- 81 tests passing
|
||||
- No tests for group persistence or mergeSettings behavior
|
||||
|
||||
### After Fix
|
||||
- **104 tests passing** (23 new tests)
|
||||
- Comprehensive coverage of:
|
||||
- Group creation and persistence
|
||||
- mergeSettings behavior for both implementations
|
||||
- Integration tests verifying end-to-end group operations
|
||||
- Field preservation during merge operations
|
||||
|
||||
## Verification
|
||||
|
||||
### Automated Tests
|
||||
```bash
|
||||
pnpm test:ci
|
||||
# Result: 104 tests passed
|
||||
```
|
||||
|
||||
### Manual Testing
|
||||
Created a test script that:
|
||||
1. Creates a group
|
||||
2. Clears cache
|
||||
3. Reloads settings
|
||||
4. Verifies the group persists
|
||||
|
||||
**Result: ✅ Group persists correctly**
|
||||
|
||||
### Integration Test Output
|
||||
```
|
||||
✅ Group creation works correctly
|
||||
✅ Group persistence works correctly
|
||||
✅ All tests passed! The group creation bug has been fixed.
|
||||
```
|
||||
|
||||
## Impact Assessment
|
||||
|
||||
### Risk Level: LOW
|
||||
- Minimal code changes (only mergeSettings implementations)
|
||||
- All existing tests continue to pass
|
||||
- No breaking changes to API or behavior
|
||||
- Only fixes broken functionality
|
||||
|
||||
### Affected Components
|
||||
- ✅ Group creation
|
||||
- ✅ Group updates
|
||||
- ✅ Server additions
|
||||
- ✅ User config updates
|
||||
- ✅ System config updates
|
||||
|
||||
### No Impact On
|
||||
- MCP server operations
|
||||
- Authentication
|
||||
- API endpoints
|
||||
- Frontend components
|
||||
- Routing logic
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
This fix is backward compatible and can be deployed immediately:
|
||||
- No database migrations required
|
||||
- No configuration changes needed
|
||||
- Existing groups (if any managed to be saved) remain intact
|
||||
- Fix is transparent to users
|
||||
|
||||
## Conclusion
|
||||
|
||||
The bug has been completely fixed with minimal, surgical changes to two functions. The fix:
|
||||
- ✅ Resolves the reported issue
|
||||
- ✅ Maintains backward compatibility
|
||||
- ✅ Adds comprehensive test coverage
|
||||
- ✅ Passes all existing tests
|
||||
- ✅ Has been verified manually
|
||||
|
||||
Users can now successfully create and persist groups as expected.
|
||||
10
Dockerfile
10
Dockerfile
@@ -22,16 +22,6 @@ RUN if [ "$INSTALL_EXT" = "true" ]; then \
|
||||
else \
|
||||
echo "Skipping Chrome installation on non-amd64 architecture: $ARCH"; \
|
||||
fi; \
|
||||
# Install Docker Engine (includes CLI and daemon) \
|
||||
apt-get update && \
|
||||
apt-get install -y ca-certificates curl iptables && \
|
||||
install -m 0755 -d /etc/apt/keyrings && \
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \
|
||||
chmod a+r /etc/apt/keyrings/docker.asc && \
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null && \
|
||||
apt-get update && \
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
RUN uv tool install mcp-server-fetch
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from 'path';
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
|
||||
// Enable debug logging if needed
|
||||
@@ -89,10 +90,7 @@ checkFrontend(projectRoot);
|
||||
|
||||
// Start the server
|
||||
console.log('🚀 Starting MCPHub server...');
|
||||
const entryPath = path.join(projectRoot, 'dist', 'index.js');
|
||||
// Convert to file:// URL for cross-platform ESM compatibility (required on Windows)
|
||||
const entryUrl = pathToFileURL(entryPath).href;
|
||||
import(entryUrl).catch(err => {
|
||||
import(path.join(projectRoot, 'dist', 'index.js')).catch(err => {
|
||||
console.error('Failed to start MCPHub:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -41,50 +41,6 @@ docker run -d \
|
||||
mcphub:local
|
||||
```
|
||||
|
||||
### Building with Extended Features
|
||||
|
||||
The Docker image supports an `INSTALL_EXT` build argument to include additional tools:
|
||||
|
||||
```bash
|
||||
# Build with extended features (includes Docker Engine, Chrome/Playwright)
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# Option 1: Run with automatic Docker-in-Docker (requires privileged mode)
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
--privileged \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
|
||||
mcphub:extended
|
||||
|
||||
# Option 2: Run with Docker socket mounted (use host's Docker daemon)
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
mcphub:extended
|
||||
|
||||
# Verify Docker is available
|
||||
docker exec mcphub docker --version
|
||||
docker exec mcphub docker ps
|
||||
```
|
||||
|
||||
<Note>
|
||||
**What's included with INSTALL_EXT=true:**
|
||||
- **Docker Engine**: Full Docker daemon with CLI for container management. The daemon auto-starts when the container runs in privileged mode.
|
||||
- **Chrome/Playwright** (amd64 only): For browser automation tasks
|
||||
|
||||
The extended image is larger but provides additional capabilities for advanced use cases.
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
**Docker-in-Docker Security Considerations:**
|
||||
- **Privileged mode** (`--privileged`): Required for the Docker daemon to start inside the container. This gives the container elevated permissions on the host.
|
||||
- **Docker socket mounting** (`/var/run/docker.sock`): Gives the container access to the host's Docker daemon. Both approaches should only be used in trusted environments.
|
||||
- For production, consider using Docker socket mounting instead of privileged mode for better security.
|
||||
</Warning>
|
||||
|
||||
## Docker Compose Setup
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
@@ -41,50 +41,6 @@ docker run -d \
|
||||
mcphub:local
|
||||
```
|
||||
|
||||
### 构建扩展功能版本
|
||||
|
||||
Docker 镜像支持 `INSTALL_EXT` 构建参数以包含额外工具:
|
||||
|
||||
```bash
|
||||
# 构建扩展功能版本(包含 Docker 引擎、Chrome/Playwright)
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# 方式 1: 使用自动 Docker-in-Docker(需要特权模式)
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
--privileged \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
|
||||
mcphub:extended
|
||||
|
||||
# 方式 2: 挂载 Docker socket(使用宿主机的 Docker 守护进程)
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
mcphub:extended
|
||||
|
||||
# 验证 Docker 可用
|
||||
docker exec mcphub docker --version
|
||||
docker exec mcphub docker ps
|
||||
```
|
||||
|
||||
<Note>
|
||||
**INSTALL_EXT=true 包含的功能:**
|
||||
- **Docker 引擎**:完整的 Docker 守护进程和 CLI,用于容器管理。在特权模式下运行时,守护进程会自动启动。
|
||||
- **Chrome/Playwright**(仅 amd64):用于浏览器自动化任务
|
||||
|
||||
扩展镜像较大,但为高级用例提供了额外功能。
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
**Docker-in-Docker 安全注意事项:**
|
||||
- **特权模式**(`--privileged`):容器内启动 Docker 守护进程需要此权限。这会授予容器在宿主机上的提升权限。
|
||||
- **Docker socket 挂载**(`/var/run/docker.sock`):使容器可以访问宿主机的 Docker 守护进程。两种方式都应仅在可信环境中使用。
|
||||
- 生产环境建议使用 Docker socket 挂载而非特权模式,以提高安全性。
|
||||
</Warning>
|
||||
|
||||
## Docker Compose 设置
|
||||
|
||||
### 基本配置
|
||||
|
||||
@@ -4,7 +4,7 @@ NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
|
||||
echo "Setting npm registry to ${NPM_REGISTRY}"
|
||||
npm config set registry "$NPM_REGISTRY"
|
||||
|
||||
# Handle HTTP_PROXY and HTTPS_PROXY environment variables
|
||||
# 处理 HTTP_PROXY 和 HTTPS_PROXY 环境变量
|
||||
if [ -n "$HTTP_PROXY" ]; then
|
||||
echo "Setting HTTP proxy to ${HTTP_PROXY}"
|
||||
npm config set proxy "$HTTP_PROXY"
|
||||
@@ -19,33 +19,4 @@ fi
|
||||
|
||||
echo "Using REQUEST_TIMEOUT: $REQUEST_TIMEOUT"
|
||||
|
||||
# Auto-start Docker daemon if Docker is installed
|
||||
if command -v dockerd >/dev/null 2>&1; then
|
||||
echo "Docker daemon detected, starting dockerd..."
|
||||
|
||||
# Create docker directory if it doesn't exist
|
||||
mkdir -p /var/lib/docker
|
||||
|
||||
# Start dockerd in the background
|
||||
dockerd --host=unix:///var/run/docker.sock --storage-driver=vfs > /var/log/dockerd.log 2>&1 &
|
||||
|
||||
# Wait for Docker daemon to be ready
|
||||
echo "Waiting for Docker daemon to be ready..."
|
||||
TIMEOUT=15
|
||||
ELAPSED=0
|
||||
while ! docker info >/dev/null 2>&1; do
|
||||
if [ $ELAPSED -ge $TIMEOUT ]; then
|
||||
echo "WARNING: Docker daemon failed to start within ${TIMEOUT} seconds"
|
||||
echo "Check /var/log/dockerd.log for details"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
ELAPSED=$((ELAPSED + 1))
|
||||
done
|
||||
|
||||
if docker info >/dev/null 2>&1; then
|
||||
echo "Docker daemon started successfully"
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -1,52 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server } from '@/types';
|
||||
import { apiPut } from '../utils/fetchInterceptor';
|
||||
import ServerForm from './ServerForm';
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Server } from '@/types'
|
||||
import { apiPut } from '../utils/fetchInterceptor'
|
||||
import ServerForm from './ServerForm'
|
||||
|
||||
interface EditServerFormProps {
|
||||
server: Server;
|
||||
onEdit: () => void;
|
||||
onCancel: () => void;
|
||||
server: Server
|
||||
onEdit: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t } = useTranslation()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (payload: any) => {
|
||||
try {
|
||||
setError(null);
|
||||
const encodedServerName = encodeURIComponent(server.name);
|
||||
const result = await apiPut(`/servers/${encodedServerName}`, payload);
|
||||
setError(null)
|
||||
const result = await apiPut(`/servers/${server.name}`, payload)
|
||||
|
||||
if (!result.success) {
|
||||
// Use specific error message from the response if available
|
||||
if (result && result.message) {
|
||||
setError(result.message);
|
||||
setError(result.message)
|
||||
} else {
|
||||
setError(t('server.updateError', { serverName: server.name }));
|
||||
setError(t('server.updateError', { serverName: server.name }))
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
onEdit();
|
||||
onEdit()
|
||||
} catch (err) {
|
||||
console.error('Error updating server:', err);
|
||||
console.error('Error updating server:', err)
|
||||
|
||||
// Use friendly error messages based on error type
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
setError(t('errors.network'))
|
||||
} else if (err instanceof TypeError && (
|
||||
err.message.includes('NetworkError') ||
|
||||
err.message.includes('Failed to fetch')
|
||||
)) {
|
||||
setError(t('errors.serverConnection'))
|
||||
} else {
|
||||
setError(t('errors.serverUpdate', { serverName: server.name }));
|
||||
setError(t('errors.serverUpdate', { serverName: server.name }))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
@@ -58,7 +57,7 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
|
||||
formError={error}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default EditServerForm;
|
||||
export default EditServerForm
|
||||
@@ -1,205 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RegistryServerEntry } from '@/types';
|
||||
|
||||
interface RegistryServerCardProps {
|
||||
serverEntry: RegistryServerEntry;
|
||||
onClick: (serverEntry: RegistryServerEntry) => void;
|
||||
}
|
||||
|
||||
const RegistryServerCard: React.FC<RegistryServerCardProps> = ({ serverEntry, onClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const { server, _meta } = serverEntry;
|
||||
|
||||
const handleClick = () => {
|
||||
onClick(serverEntry);
|
||||
};
|
||||
|
||||
// Get display description
|
||||
const getDisplayDescription = () => {
|
||||
if (server.description && server.description.length <= 150) {
|
||||
return server.description;
|
||||
}
|
||||
return server.description
|
||||
? server.description.slice(0, 150) + '...'
|
||||
: t('registry.noDescription');
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
return `${year}/${month}/${day}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Get icon to display
|
||||
const getIcon = () => {
|
||||
if (server.icons && server.icons.length > 0) {
|
||||
// Prefer light theme icon
|
||||
const lightIcon = server.icons.find((icon) => !icon.theme || icon.theme === 'light');
|
||||
return lightIcon || server.icons[0];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const icon = getIcon();
|
||||
const officialMeta = _meta?.['io.modelcontextprotocol.registry/official'];
|
||||
const isLatest = officialMeta?.isLatest;
|
||||
const publishedAt = officialMeta?.publishedAt;
|
||||
const updatedAt = officialMeta?.updatedAt;
|
||||
|
||||
// Count packages and remotes
|
||||
const packageCount = server.packages?.length || 0;
|
||||
const remoteCount = server.remotes?.length || 0;
|
||||
const totalOptions = packageCount + remoteCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-lg hover:border-blue-400 hover:-translate-y-1 transition-all duration-300 cursor-pointer group relative overflow-hidden h-full flex flex-col"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Background gradient overlay on hover */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/0 to-purple-50/0 group-hover:from-blue-50/30 group-hover:to-purple-50/30 transition-all duration-300 pointer-events-none" />
|
||||
|
||||
{/* Server Header */}
|
||||
<div className="relative z-10 flex-1 flex flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
{/* Icon */}
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon.src}
|
||||
alt={server.title}
|
||||
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center text-white text-xl font-semibold flex-shrink-0">
|
||||
M
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and badges */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors duration-200 mb-1 line-clamp-2">
|
||||
{server.name}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
{isLatest && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{t('registry.latest')}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
v{server.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Name */}
|
||||
{/* <div className="mb-2">
|
||||
<p className="text-xs text-gray-500 font-mono">{server.name}</p>
|
||||
</div> */}
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-3 flex-1">
|
||||
<p className="text-gray-600 text-sm leading-relaxed line-clamp-3">
|
||||
{getDisplayDescription()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Installation Options Info */}
|
||||
{totalOptions > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center space-x-4">
|
||||
{packageCount > 0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">
|
||||
{packageCount}{' '}
|
||||
{packageCount === 1 ? t('registry.package') : t('registry.packages')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{remoteCount > 0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">
|
||||
{remoteCount} {remoteCount === 1 ? t('registry.remote') : t('registry.remotes')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer - fixed at bottom */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-100 mt-auto">
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-500">
|
||||
{(publishedAt || updatedAt) && (
|
||||
<>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>{formatDate(updatedAt || publishedAt)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-blue-600 text-sm font-medium group-hover:text-blue-700 transition-colors">
|
||||
<span>{t('registry.viewDetails')}</span>
|
||||
<svg
|
||||
className="w-4 h-4 ml-1 transform group-hover:translate-x-1 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegistryServerCard;
|
||||
@@ -1,698 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RegistryServerEntry,
|
||||
RegistryPackage,
|
||||
RegistryRemote,
|
||||
RegistryServerData,
|
||||
ServerConfig,
|
||||
} from '@/types';
|
||||
import ServerForm from './ServerForm';
|
||||
|
||||
interface RegistryServerDetailProps {
|
||||
serverEntry: RegistryServerEntry;
|
||||
onBack: () => void;
|
||||
onInstall?: (server: RegistryServerData, config: ServerConfig) => void;
|
||||
installing?: boolean;
|
||||
isInstalled?: boolean;
|
||||
fetchVersions?: (serverName: string) => Promise<RegistryServerEntry[]>;
|
||||
}
|
||||
|
||||
const RegistryServerDetail: React.FC<RegistryServerDetailProps> = ({
|
||||
serverEntry,
|
||||
onBack,
|
||||
onInstall,
|
||||
installing = false,
|
||||
isInstalled = false,
|
||||
fetchVersions,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { server, _meta } = serverEntry;
|
||||
|
||||
const [_selectedVersion, _setSelectedVersion] = useState<string>(server.version);
|
||||
const [_availableVersions, setAvailableVersions] = useState<RegistryServerEntry[]>([]);
|
||||
const [_loadingVersions, setLoadingVersions] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [selectedInstallType, setSelectedInstallType] = useState<'package' | 'remote' | null>(null);
|
||||
const [selectedOption, setSelectedOption] = useState<RegistryPackage | RegistryRemote | null>(
|
||||
null,
|
||||
);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
packages: true,
|
||||
remotes: true,
|
||||
repository: true,
|
||||
});
|
||||
|
||||
const officialMeta = _meta?.['io.modelcontextprotocol.registry/official'];
|
||||
|
||||
// Load available versions
|
||||
useEffect(() => {
|
||||
const loadVersions = async () => {
|
||||
if (fetchVersions) {
|
||||
setLoadingVersions(true);
|
||||
try {
|
||||
const versions = await fetchVersions(server.name);
|
||||
setAvailableVersions(versions);
|
||||
} catch (error) {
|
||||
console.error('Failed to load versions:', error);
|
||||
} finally {
|
||||
setLoadingVersions(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadVersions();
|
||||
}, [server.name, fetchVersions]);
|
||||
|
||||
// Get icon to display
|
||||
const getIcon = () => {
|
||||
if (server.icons && server.icons.length > 0) {
|
||||
const lightIcon = server.icons.find((icon) => !icon.theme || icon.theme === 'light');
|
||||
return lightIcon || server.icons[0];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const icon = getIcon();
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle section expansion
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
|
||||
};
|
||||
|
||||
// Handle install button click
|
||||
const handleInstallClick = (
|
||||
type: 'package' | 'remote',
|
||||
option: RegistryPackage | RegistryRemote,
|
||||
) => {
|
||||
setSelectedInstallType(type);
|
||||
setSelectedOption(option);
|
||||
setInstallError(null);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// Handle modal close
|
||||
const handleModalClose = () => {
|
||||
setModalVisible(false);
|
||||
setInstallError(null);
|
||||
};
|
||||
|
||||
// Handle install submission
|
||||
const handleInstallSubmit = async (payload: any) => {
|
||||
try {
|
||||
if (!onInstall || !selectedOption || !selectedInstallType) return;
|
||||
|
||||
setInstallError(null);
|
||||
|
||||
// Extract the ServerConfig from the payload
|
||||
const config: ServerConfig = payload.config;
|
||||
|
||||
// Call onInstall with server data and config
|
||||
onInstall(server, config);
|
||||
setModalVisible(false);
|
||||
} catch (err) {
|
||||
console.error('Error installing server:', err);
|
||||
setInstallError(t('errors.serverInstall'));
|
||||
}
|
||||
};
|
||||
|
||||
// Build initial data for ServerForm
|
||||
const getInitialFormData = () => {
|
||||
if (!selectedOption || !selectedInstallType) return null;
|
||||
console.log('Building initial form data for:', selectedOption);
|
||||
|
||||
if (selectedInstallType === 'package' && 'identifier' in selectedOption) {
|
||||
const pkg = selectedOption as RegistryPackage;
|
||||
|
||||
// Build environment variables from package definition
|
||||
const env: Record<string, string> = {};
|
||||
if (pkg.environmentVariables) {
|
||||
pkg.environmentVariables.forEach((envVar) => {
|
||||
env[envVar.name] = envVar.default || '';
|
||||
});
|
||||
}
|
||||
|
||||
const command = getCommand(pkg.registryType);
|
||||
return {
|
||||
name: server.name,
|
||||
status: 'disconnected' as const,
|
||||
config: {
|
||||
type: 'stdio' as const,
|
||||
command: command,
|
||||
args: getArgs(command, pkg),
|
||||
env: Object.keys(env).length > 0 ? env : undefined,
|
||||
},
|
||||
};
|
||||
} else if (selectedInstallType === 'remote' && 'url' in selectedOption) {
|
||||
const remote = selectedOption as RegistryRemote;
|
||||
|
||||
// Build headers from remote definition
|
||||
const headers: Record<string, string> = {};
|
||||
if (remote.headers) {
|
||||
remote.headers.forEach((header) => {
|
||||
headers[header.name] = header.default || header.value || '';
|
||||
});
|
||||
}
|
||||
|
||||
// Determine transport type - default to streamable-http for remotes
|
||||
const transportType = remote.type === 'sse' ? ('sse' as const) : ('streamable-http' as const);
|
||||
|
||||
return {
|
||||
name: server.name,
|
||||
status: 'disconnected' as const,
|
||||
config: {
|
||||
type: transportType,
|
||||
url: remote.url,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Render package option
|
||||
const renderPackage = (pkg: RegistryPackage, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 rounded-lg p-4 mb-3 hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{pkg.identifier}</h4>
|
||||
{pkg.version && <p className="text-sm text-gray-500">Version: {pkg.version}</p>}
|
||||
{pkg.runtimeHint && <p className="text-sm text-gray-600 mt-1">{pkg.runtimeHint}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleInstallClick('package', pkg)}
|
||||
disabled={isInstalled || installing}
|
||||
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
isInstalled
|
||||
? 'bg-green-600 text-white cursor-default'
|
||||
: installing
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isInstalled
|
||||
? t('registry.installed')
|
||||
: installing
|
||||
? t('registry.installing')
|
||||
: t('registry.install')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Package details */}
|
||||
{pkg.registryType && (
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
<span className="font-medium">Registry:</span> {pkg.registryType}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transport type */}
|
||||
{pkg.transport && (
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
<span className="font-medium">Transport:</span> {pkg.transport.type}
|
||||
{pkg.transport.url && <span className="ml-2 text-gray-500">({pkg.transport.url})</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environment Variables */}
|
||||
{pkg.environmentVariables && pkg.environmentVariables.length > 0 && (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">
|
||||
{t('registry.environmentVariables')}:
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{pkg.environmentVariables.map((envVar, envIndex) => (
|
||||
<div key={envIndex} className="text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="font-mono text-gray-900 font-medium">{envVar.name}</span>
|
||||
{envVar.isRequired && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
{t('common.required')}
|
||||
</span>
|
||||
)}
|
||||
{envVar.isSecret && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{t('common.secret')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{envVar.description && <p className="text-gray-600 mt-1">{envVar.description}</p>}
|
||||
{envVar.default && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.default')}:</span>{' '}
|
||||
<span className="font-mono">{envVar.default}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Package Arguments */}
|
||||
{pkg.packageArguments && pkg.packageArguments.length > 0 && (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">
|
||||
{t('registry.packageArguments')}:
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{pkg.packageArguments.map((arg, argIndex) => (
|
||||
<div key={argIndex} className="text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="font-mono text-gray-900 font-medium">{arg.name}</span>
|
||||
{arg.isRequired && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
{t('common.required')}
|
||||
</span>
|
||||
)}
|
||||
{arg.isSecret && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{t('common.secret')}
|
||||
</span>
|
||||
)}
|
||||
{arg.isRepeated && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{t('common.repeated')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{arg.description && <p className="text-gray-600 mt-1">{arg.description}</p>}
|
||||
{arg.type && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.type')}:</span>{' '}
|
||||
<span className="font-mono">{arg.type}</span>
|
||||
</p>
|
||||
)}
|
||||
{arg.default && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.default')}:</span>{' '}
|
||||
<span className="font-mono">{arg.default}</span>
|
||||
</p>
|
||||
)}
|
||||
{arg.value && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.value')}:</span>{' '}
|
||||
<span className="font-mono">{arg.value}</span>
|
||||
</p>
|
||||
)}
|
||||
{arg.valueHint && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.valueHint')}:</span>{' '}
|
||||
<span className="font-mono">{arg.valueHint}</span>
|
||||
</p>
|
||||
)}
|
||||
{arg.choices && arg.choices.length > 0 && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.choices')}:</span>{' '}
|
||||
<span className="font-mono">{arg.choices.join(', ')}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render remote option
|
||||
const renderRemote = (remote: RegistryRemote, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 rounded-lg p-4 mb-3 hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{remote.type}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1 break-all">{remote.url}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleInstallClick('remote', remote)}
|
||||
disabled={isInstalled || installing}
|
||||
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
isInstalled
|
||||
? 'bg-green-600 text-white cursor-default'
|
||||
: installing
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isInstalled
|
||||
? t('registry.installed')
|
||||
: installing
|
||||
? t('registry.installing')
|
||||
: t('registry.install')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
{remote.headers && remote.headers.length > 0 && (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">{t('registry.headers')}:</h5>
|
||||
<div className="space-y-2">
|
||||
{remote.headers.map((header, headerIndex) => (
|
||||
<div key={headerIndex} className="text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="font-mono text-gray-900 font-medium">{header.name}</span>
|
||||
{header.isRequired && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
{t('common.required')}
|
||||
</span>
|
||||
)}
|
||||
{header.isSecret && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{t('common.secret')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{header.description && <p className="text-gray-600 mt-1">{header.description}</p>}
|
||||
{header.value && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.value')}:</span>{' '}
|
||||
<span className="font-mono">{header.value}</span>
|
||||
</p>
|
||||
)}
|
||||
{header.default && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.default')}:</span>{' '}
|
||||
<span className="font-mono">{header.default}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 mb-4 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{t('registry.backToList')}
|
||||
</button>
|
||||
|
||||
<div className="flex items-start space-x-4">
|
||||
{/* Icon */}
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon.src}
|
||||
alt={server.title}
|
||||
className="w-20 h-20 rounded-lg object-cover flex-shrink-0"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center text-white text-3xl font-semibold flex-shrink-0">
|
||||
M
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and metadata */}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{server.name}</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{officialMeta?.isLatest && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
||||
{t('registry.latest')}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
|
||||
v{server.version}
|
||||
</span>
|
||||
{officialMeta?.status && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800">
|
||||
{officialMeta.status}
|
||||
</span>
|
||||
)}
|
||||
{/* Dates */}
|
||||
<span className="flex flex-wrap items-center gap-4 text-sm text-gray-600">
|
||||
{officialMeta?.publishedAt && (
|
||||
<div>
|
||||
<span className="font-medium">{t('registry.published')}:</span>{' '}
|
||||
{formatDate(officialMeta.publishedAt)}
|
||||
</div>
|
||||
)}
|
||||
{officialMeta?.updatedAt && (
|
||||
<div>
|
||||
<span className="font-medium">{t('registry.updated')}:</span>{' '}
|
||||
{formatDate(officialMeta.updatedAt)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-3">{t('registry.description')}</h2>
|
||||
<p className="text-gray-700 leading-relaxed whitespace-pre-wrap">{server.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
{server.websiteUrl && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-3">{t('registry.website')}</h2>
|
||||
<a
|
||||
href={server.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
{server.websiteUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{server.packages && server.packages.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => toggleSection('packages')}
|
||||
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<span>
|
||||
{t('registry.packages')} ({server.packages.length})
|
||||
</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transform transition-transform ${expandedSections.packages ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{expandedSections.packages && (
|
||||
<div className="space-y-3">{server.packages.map(renderPackage)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remotes */}
|
||||
{server.remotes && server.remotes.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => toggleSection('remotes')}
|
||||
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<span>
|
||||
{t('registry.remotes')} ({server.remotes.length})
|
||||
</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transform transition-transform ${expandedSections.remotes ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{expandedSections.remotes && (
|
||||
<div className="space-y-3">{server.remotes.map(renderRemote)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repository */}
|
||||
{server.repository && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => toggleSection('repository')}
|
||||
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<span>{t('registry.repository')}</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transform transition-transform ${expandedSections.repository ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{expandedSections.repository && (
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
{server.repository.url && (
|
||||
<div className="mb-2">
|
||||
<span className="font-medium text-gray-700">URL:</span>{' '}
|
||||
<a
|
||||
href={server.repository.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline break-all"
|
||||
>
|
||||
{server.repository.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{server.repository.source && (
|
||||
<div className="mb-2">
|
||||
<span className="font-medium text-gray-700">Source:</span>{' '}
|
||||
{server.repository.source}
|
||||
</div>
|
||||
)}
|
||||
{server.repository.subfolder && (
|
||||
<div className="mb-2">
|
||||
<span className="font-medium text-gray-700">Subfolder:</span>{' '}
|
||||
{server.repository.subfolder}
|
||||
</div>
|
||||
)}
|
||||
{server.repository.id && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">ID:</span> {server.repository.id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Install Modal */}
|
||||
{modalVisible && selectedOption && selectedInstallType && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<ServerForm
|
||||
onSubmit={handleInstallSubmit}
|
||||
onCancel={handleModalClose}
|
||||
modalTitle={t('registry.installServer', { name: server.title || server.name })}
|
||||
formError={installError}
|
||||
initialData={getInitialFormData()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegistryServerDetail;
|
||||
// Helper function to determine command based on registry type
|
||||
function getCommand(registryType: string): string {
|
||||
// Map registry types to appropriate commands
|
||||
switch (registryType.toLowerCase()) {
|
||||
case 'pypi':
|
||||
case 'python':
|
||||
return 'uvx';
|
||||
case 'npm':
|
||||
case 'node':
|
||||
return 'npx';
|
||||
case 'oci':
|
||||
case 'docker':
|
||||
return 'docker';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get appropriate args based on command type and package identifier
|
||||
function getArgs(command: string, pkg: RegistryPackage): string[] {
|
||||
const identifier = [pkg.identifier + (pkg.version ? `@${pkg.version}` : '')];
|
||||
|
||||
// Build package arguments if available
|
||||
const packageArgs: string[] = [];
|
||||
if (pkg.packageArguments && pkg.packageArguments.length > 0) {
|
||||
pkg.packageArguments.forEach((arg) => {
|
||||
// Add required arguments or arguments with default values
|
||||
if (arg.isRequired || arg.default || arg.value) {
|
||||
const argName = `--${arg.name}`;
|
||||
// Priority: value > default > placeholder
|
||||
const argValue = arg.value || arg.default || `\${${arg.name.toUpperCase()}}`;
|
||||
packageArgs.push(argName, argValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Map commands to appropriate argument patterns
|
||||
switch (command.toLowerCase()) {
|
||||
case 'uvx':
|
||||
// For Python packages: uvx package-name --arg1 value1 --arg2 value2
|
||||
return [...identifier, ...packageArgs];
|
||||
case 'npx':
|
||||
// For Node.js packages: npx package-name --arg1 value1 --arg2 value2
|
||||
return [...identifier, ...packageArgs];
|
||||
case 'docker': {
|
||||
// add envs from environment variables if available
|
||||
const envs: string[] = [];
|
||||
if (pkg.environmentVariables) {
|
||||
pkg.environmentVariables.forEach((env) => {
|
||||
envs.push('-e', `${env.name}`);
|
||||
});
|
||||
}
|
||||
// For Docker images: docker run -i package-name --arg1 value1 --arg2 value2
|
||||
return ['run', '-i', '--rm', ...envs, ...identifier, ...packageArgs];
|
||||
}
|
||||
default:
|
||||
// If no specific pattern is defined, return identifier with package args
|
||||
return [...identifier, ...packageArgs];
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import ToolCard from '@/components/ui/ToolCard'
|
||||
import PromptCard from '@/components/ui/PromptCard'
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
|
||||
interface ServerCardProps {
|
||||
server: Server
|
||||
@@ -40,8 +39,6 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { exportMCPSettings } = useSettingsData()
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowDeleteDialog(true)
|
||||
@@ -102,39 +99,6 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyServerConfig = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
const result = await exportMCPSettings(server.name)
|
||||
const configJson = JSON.stringify(result.data, null, 2)
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(configJson)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = configJson
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Copy to clipboard failed:', err)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error copying server configuration:', error)
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onRemove(server.name)
|
||||
setShowDeleteDialog(false)
|
||||
@@ -147,7 +111,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: toolName }),
|
||||
'success',
|
||||
'success'
|
||||
)
|
||||
// Trigger refresh to update the tool's state in the UI
|
||||
if (onRefresh) {
|
||||
@@ -169,7 +133,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: promptName }),
|
||||
'success',
|
||||
'success'
|
||||
)
|
||||
// Trigger refresh to update the prompt's state in the UI
|
||||
if (onRefresh) {
|
||||
@@ -186,33 +150,21 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${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)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<h2
|
||||
className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}
|
||||
>
|
||||
{server.name}
|
||||
</h2>
|
||||
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
|
||||
<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 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"
|
||||
/>
|
||||
<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>
|
||||
<span>
|
||||
{server.tools?.length || 0} {t('server.tools')}
|
||||
</span>
|
||||
<span>{server.tools?.length || 0} {t('server.tools')}</span>
|
||||
</div>
|
||||
|
||||
{/* Prompt count display */}
|
||||
@@ -221,9 +173,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" />
|
||||
<path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767c.28.149.599.233.938.233h2l3 3v-3h2a2 2 0 002-2V9a2 2 0 00-2-2h-1z" />
|
||||
</svg>
|
||||
<span>
|
||||
{server.prompts?.length || 0} {t('server.prompts')}
|
||||
</span>
|
||||
<span>{server.prompts?.length || 0} {t('server.prompts')}</span>
|
||||
</div>
|
||||
|
||||
{server.error && (
|
||||
@@ -246,25 +196,19 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
width: '480px',
|
||||
transform: 'translateX(50%)',
|
||||
transform: 'translateX(50%)'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center sticky top-0 bg-white py-2 px-4 border-b border-gray-200 z-20 shadow-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-sm font-medium text-red-600">
|
||||
{t('server.errorDetails')}
|
||||
</h4>
|
||||
<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 btn-secondary"
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copied ? (
|
||||
<Check size={14} className="text-green-500" />
|
||||
) : (
|
||||
<Copy size={14} />
|
||||
)}
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@@ -278,9 +222,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 pt-2">
|
||||
<pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">
|
||||
{server.error}
|
||||
</pre>
|
||||
<pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">{server.error}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -288,9 +230,6 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button onClick={handleCopyServerConfig} className={`px-3 py-1 btn-secondary`}>
|
||||
{t('server.copy')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
|
||||
@@ -300,20 +239,20 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
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 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
|
||||
}`}
|
||||
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 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
|
||||
}`}
|
||||
disabled={isToggling}
|
||||
>
|
||||
{isToggling
|
||||
? t('common.processing')
|
||||
: server.enabled !== false
|
||||
? t('server.disable')
|
||||
: t('server.enable')}
|
||||
: t('server.enable')
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@@ -332,19 +271,10 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<>
|
||||
{server.tools && (
|
||||
<div className="mt-6">
|
||||
<h6
|
||||
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}
|
||||
>
|
||||
{t('server.tools')}
|
||||
</h6>
|
||||
<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}
|
||||
onToggle={handleToolToggle}
|
||||
/>
|
||||
<ToolCard key={index} server={server.name} tool={tool} onToggle={handleToolToggle} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,18 +282,14 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
|
||||
{server.prompts && (
|
||||
<div className="mt-6">
|
||||
<h6
|
||||
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}
|
||||
>
|
||||
{t('server.prompts')}
|
||||
</h6>
|
||||
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.prompts')}</h6>
|
||||
<div className="space-y-4">
|
||||
{server.prompts.map((prompt, index) => (
|
||||
<PromptCard
|
||||
key={index}
|
||||
server={server.name}
|
||||
prompt={prompt}
|
||||
onToggle={handlePromptToggle}
|
||||
<PromptCard
|
||||
key={index}
|
||||
server={server.name}
|
||||
prompt={prompt}
|
||||
onToggle={handlePromptToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -383,4 +309,4 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
)
|
||||
}
|
||||
|
||||
export default ServerCard
|
||||
export default ServerCard
|
||||
@@ -1,23 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, EnvVar, ServerFormData } from '@/types';
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Server, EnvVar, ServerFormData } from '@/types'
|
||||
|
||||
interface ServerFormProps {
|
||||
onSubmit: (payload: any) => void;
|
||||
onCancel: () => void;
|
||||
initialData?: Server | null;
|
||||
modalTitle: string;
|
||||
formError?: string | null;
|
||||
onSubmit: (payload: any) => void
|
||||
onCancel: () => void
|
||||
initialData?: Server | null
|
||||
modalTitle: string
|
||||
formError?: string | null
|
||||
}
|
||||
|
||||
const ServerForm = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
initialData = null,
|
||||
modalTitle,
|
||||
formError = null,
|
||||
}: ServerFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formError = null }: ServerFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Determine the initial server type from the initialData
|
||||
const getInitialServerType = () => {
|
||||
@@ -32,19 +26,7 @@ const ServerForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const getInitialServerEnvVars = (data: Server | null): EnvVar[] => {
|
||||
if (!data || !data.config || !data.config.env) return [];
|
||||
|
||||
return Object.entries(data.config.env).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
description: '', // You can set a default description if needed
|
||||
}));
|
||||
};
|
||||
|
||||
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http' | 'openapi'>(
|
||||
getInitialServerType(),
|
||||
);
|
||||
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http' | 'openapi'>(getInitialServerType());
|
||||
|
||||
const [formData, setFormData] = useState<ServerFormData>({
|
||||
name: (initialData && initialData.name) || '',
|
||||
@@ -58,178 +40,149 @@ const ServerForm = ({
|
||||
: '',
|
||||
args: (initialData && initialData.config && initialData.config.args) || [],
|
||||
type: getInitialServerType(), // Initialize the type field
|
||||
env: getInitialServerEnvVars(initialData),
|
||||
env: [],
|
||||
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,
|
||||
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 || '',
|
||||
// Passthrough headers initialization
|
||||
passthroughHeaders: initialData.config.openapi.passthroughHeaders
|
||||
? initialData.config.openapi.passthroughHeaders.join(', ')
|
||||
: '',
|
||||
}
|
||||
: {
|
||||
inputMode: 'url',
|
||||
url: '',
|
||||
schema: '',
|
||||
version: '3.1.0',
|
||||
securityType: 'none',
|
||||
passthroughHeaders: '',
|
||||
},
|
||||
});
|
||||
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 || '',
|
||||
// Passthrough headers initialization
|
||||
passthroughHeaders: initialData.config.openapi.passthroughHeaders ? initialData.config.openapi.passthroughHeaders.join(', ') : '',
|
||||
} : {
|
||||
inputMode: 'url',
|
||||
url: '',
|
||||
schema: '',
|
||||
version: '3.1.0',
|
||||
securityType: 'none',
|
||||
passthroughHeaders: '',
|
||||
}
|
||||
})
|
||||
|
||||
const [envVars, setEnvVars] = useState<EnvVar[]>(
|
||||
initialData && initialData.config && initialData.config.env
|
||||
? Object.entries(initialData.config.env).map(([key, value]) => ({ key, value }))
|
||||
: [],
|
||||
);
|
||||
)
|
||||
|
||||
const [headerVars, setHeaderVars] = useState<EnvVar[]>(
|
||||
initialData && initialData.config && initialData.config.headers
|
||||
? Object.entries(initialData.config.headers).map(([key, value]) => ({ key, value }))
|
||||
: [],
|
||||
);
|
||||
)
|
||||
|
||||
const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isEdit = !!initialData;
|
||||
const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const isEdit = !!initialData
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData({ ...formData, [name]: value });
|
||||
};
|
||||
const { name, value } = e.target
|
||||
setFormData({ ...formData, [name]: value })
|
||||
}
|
||||
|
||||
// Transform space-separated arguments string into array
|
||||
const handleArgsChange = (value: string) => {
|
||||
const args = value.split(' ').filter((arg) => arg.trim() !== '');
|
||||
setFormData({ ...formData, arguments: value, args });
|
||||
};
|
||||
const args = value.split(' ').filter((arg) => arg.trim() !== '')
|
||||
setFormData({ ...formData, arguments: value, args })
|
||||
}
|
||||
|
||||
const updateServerType = (type: 'stdio' | 'sse' | 'streamable-http' | 'openapi') => {
|
||||
setServerType(type);
|
||||
setFormData((prev) => ({ ...prev, type }));
|
||||
};
|
||||
setFormData(prev => ({ ...prev, type }));
|
||||
}
|
||||
|
||||
const handleEnvVarChange = (index: number, field: 'key' | 'value', value: string) => {
|
||||
const newEnvVars = [...envVars];
|
||||
newEnvVars[index][field] = value;
|
||||
setEnvVars(newEnvVars);
|
||||
};
|
||||
const newEnvVars = [...envVars]
|
||||
newEnvVars[index][field] = value
|
||||
setEnvVars(newEnvVars)
|
||||
}
|
||||
|
||||
const addEnvVar = () => {
|
||||
setEnvVars([...envVars, { key: '', value: '' }]);
|
||||
};
|
||||
setEnvVars([...envVars, { key: '', value: '' }])
|
||||
}
|
||||
|
||||
const removeEnvVar = (index: number) => {
|
||||
const newEnvVars = [...envVars];
|
||||
newEnvVars.splice(index, 1);
|
||||
setEnvVars(newEnvVars);
|
||||
};
|
||||
const newEnvVars = [...envVars]
|
||||
newEnvVars.splice(index, 1)
|
||||
setEnvVars(newEnvVars)
|
||||
}
|
||||
|
||||
const handleHeaderVarChange = (index: number, field: 'key' | 'value', value: string) => {
|
||||
const newHeaderVars = [...headerVars];
|
||||
newHeaderVars[index][field] = value;
|
||||
setHeaderVars(newHeaderVars);
|
||||
};
|
||||
const newHeaderVars = [...headerVars]
|
||||
newHeaderVars[index][field] = value
|
||||
setHeaderVars(newHeaderVars)
|
||||
}
|
||||
|
||||
const addHeaderVar = () => {
|
||||
setHeaderVars([...headerVars, { key: '', value: '' }]);
|
||||
};
|
||||
setHeaderVars([...headerVars, { key: '', value: '' }])
|
||||
}
|
||||
|
||||
const removeHeaderVar = (index: number) => {
|
||||
const newHeaderVars = [...headerVars];
|
||||
newHeaderVars.splice(index, 1);
|
||||
setHeaderVars(newHeaderVars);
|
||||
};
|
||||
const newHeaderVars = [...headerVars]
|
||||
newHeaderVars.splice(index, 1)
|
||||
setHeaderVars(newHeaderVars)
|
||||
}
|
||||
|
||||
// Handle options changes
|
||||
const handleOptionsChange = (
|
||||
field: 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout',
|
||||
value: number | boolean | undefined,
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
const handleOptionsChange = (field: 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout', value: number | boolean | undefined) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
options: {
|
||||
...prev.options,
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
[field]: value
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// Submit handler for server configuration
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const env: Record<string, string> = {};
|
||||
const env: Record<string, string> = {}
|
||||
envVars.forEach(({ key, value }) => {
|
||||
if (key.trim()) {
|
||||
env[key.trim()] = value;
|
||||
env[key.trim()] = value
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const headers: Record<string, string> = {}
|
||||
headerVars.forEach(({ key, value }) => {
|
||||
if (key.trim()) {
|
||||
headers[key.trim()] = value;
|
||||
headers[key.trim()] = value
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// Prepare options object, only include defined values
|
||||
const options: any = {};
|
||||
const options: any = {}
|
||||
if (formData.options?.timeout && formData.options.timeout !== 60000) {
|
||||
options.timeout = formData.options.timeout;
|
||||
options.timeout = formData.options.timeout
|
||||
}
|
||||
if (formData.options?.resetTimeoutOnProgress) {
|
||||
options.resetTimeoutOnProgress = formData.options.resetTimeoutOnProgress;
|
||||
options.resetTimeoutOnProgress = formData.options.resetTimeoutOnProgress
|
||||
}
|
||||
if (formData.options?.maxTotalTimeout) {
|
||||
options.maxTotalTimeout = formData.options.maxTotalTimeout;
|
||||
options.maxTotalTimeout = formData.options.maxTotalTimeout
|
||||
}
|
||||
|
||||
const payload = {
|
||||
@@ -238,87 +191,85 @@ const ServerForm = ({
|
||||
type: serverType, // Always include the type
|
||||
...(serverType === 'openapi'
|
||||
? {
|
||||
openapi: (() => {
|
||||
const openapi: any = {
|
||||
version: formData.openapi?.version || '3.1.0',
|
||||
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 || ''
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// 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 passthrough headers if provided
|
||||
if (formData.openapi?.passthroughHeaders && formData.openapi.passthroughHeaders.trim()) {
|
||||
openapi.passthroughHeaders = formData.openapi.passthroughHeaders
|
||||
.split(',')
|
||||
.map(header => header.trim())
|
||||
.filter(header => header.length > 0);
|
||||
}
|
||||
|
||||
// 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 || '',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Add passthrough headers if provided
|
||||
if (
|
||||
formData.openapi?.passthroughHeaders &&
|
||||
formData.openapi.passthroughHeaders.trim()
|
||||
) {
|
||||
openapi.passthroughHeaders = formData.openapi.passthroughHeaders
|
||||
.split(',')
|
||||
.map((header) => header.trim())
|
||||
.filter((header) => header.length > 0);
|
||||
}
|
||||
|
||||
return openapi;
|
||||
})(),
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
}
|
||||
return openapi;
|
||||
})(),
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {})
|
||||
}
|
||||
: serverType === 'sse' || serverType === 'streamable-http'
|
||||
? {
|
||||
url: formData.url,
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
}
|
||||
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 } : {}),
|
||||
},
|
||||
};
|
||||
command: formData.command,
|
||||
args: formData.args,
|
||||
env: Object.keys(env).length > 0 ? env : undefined,
|
||||
}
|
||||
),
|
||||
...(Object.keys(options).length > 0 ? { options } : {})
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(payload);
|
||||
onSubmit(payload)
|
||||
} catch (err) {
|
||||
setError(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
setError(`Error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6 w-full max-w-xl max-h-screen overflow-y-auto">
|
||||
@@ -330,7 +281,9 @@ const ServerForm = ({
|
||||
</div>
|
||||
|
||||
{(error || formError) && (
|
||||
<div className="bg-red-50 text-red-700 p-3 rounded mb-4">{formError || error}</div>
|
||||
<div className="bg-red-50 text-red-700 p-3 rounded mb-4">
|
||||
{formError || error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -420,12 +373,10 @@ const ServerForm = ({
|
||||
name="inputMode"
|
||||
value="url"
|
||||
checked={formData.openapi?.inputMode === 'url'}
|
||||
onChange={() =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: { ...prev.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>
|
||||
@@ -437,12 +388,10 @@ const ServerForm = ({
|
||||
name="inputMode"
|
||||
value="schema"
|
||||
checked={formData.openapi?.inputMode === 'schema'}
|
||||
onChange={() =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: { ...prev.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>
|
||||
@@ -461,12 +410,10 @@ const ServerForm = ({
|
||||
name="openapi-url"
|
||||
id="openapi-url"
|
||||
value={formData.openapi?.url || ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi!, url: e.target.value },
|
||||
}))
|
||||
}
|
||||
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'}
|
||||
@@ -477,10 +424,7 @@ const ServerForm = ({
|
||||
{/* 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"
|
||||
>
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="openapi-schema">
|
||||
{t('server.openapi.schema')}
|
||||
</label>
|
||||
<textarea
|
||||
@@ -488,12 +432,10 @@ const ServerForm = ({
|
||||
id="openapi-schema"
|
||||
rows={10}
|
||||
value={formData.openapi?.schema || ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi!, schema: e.target.value },
|
||||
}))
|
||||
}
|
||||
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",
|
||||
@@ -523,16 +465,14 @@ const ServerForm = ({
|
||||
</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 || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
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>
|
||||
@@ -546,47 +486,29 @@ const ServerForm = ({
|
||||
{/* 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>
|
||||
<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>
|
||||
<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 || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
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>
|
||||
<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 || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
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">{t('server.openapi.apiKeyInHeader')}</option>
|
||||
@@ -595,22 +517,14 @@ const ServerForm = ({
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.openapi.apiKeyValue')}
|
||||
</label>
|
||||
<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 || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
@@ -622,26 +536,16 @@ const ServerForm = ({
|
||||
{/* 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>
|
||||
<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>
|
||||
<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 || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
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">{t('server.openapi.httpSchemeBasic')}</option>
|
||||
@@ -650,28 +554,16 @@ const ServerForm = ({
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.openapi.httpCredentials')}
|
||||
</label>
|
||||
<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 || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
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'
|
||||
}
|
||||
placeholder={formData.openapi?.httpScheme === 'basic' ? 'base64-encoded-credentials' : 'bearer-token'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -681,27 +573,17 @@ const ServerForm = ({
|
||||
{/* 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>
|
||||
<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>
|
||||
<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 || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
@@ -713,48 +595,30 @@ const ServerForm = ({
|
||||
{/* 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>
|
||||
<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>
|
||||
<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 || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
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>
|
||||
<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 || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
@@ -771,22 +635,14 @@ const ServerForm = ({
|
||||
<input
|
||||
type="text"
|
||||
value={formData.openapi?.passthroughHeaders || ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: {
|
||||
...prev.openapi,
|
||||
passthroughHeaders: e.target.value,
|
||||
url: prev.openapi?.url || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, passthroughHeaders: e.target.value, 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"
|
||||
placeholder="Authorization, X-API-Key, X-Custom-Header"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{t('server.openapi.passthroughHeadersHelp')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{t('server.openapi.passthroughHeadersHelp')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
@@ -845,11 +701,7 @@ const ServerForm = ({
|
||||
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 form-input"
|
||||
placeholder={
|
||||
serverType === 'streamable-http'
|
||||
? 'e.g.: http://localhost:3000/mcp'
|
||||
: 'e.g.: http://localhost:3000/sse'
|
||||
}
|
||||
placeholder={serverType === 'streamable-http' ? "e.g.: http://localhost:3000/mcp" : "e.g.: http://localhost:3000/sse"}
|
||||
required={serverType === 'sse' || serverType === 'streamable-http'}
|
||||
/>
|
||||
</div>
|
||||
@@ -985,26 +837,23 @@ const ServerForm = ({
|
||||
<label className="text-gray-700 text-sm font-bold">
|
||||
{t('server.requestOptions')}
|
||||
</label>
|
||||
<span className="text-gray-500 text-sm">{isRequestOptionsExpanded ? '▼' : '▶'}</span>
|
||||
<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"
|
||||
>
|
||||
<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)
|
||||
}
|
||||
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"
|
||||
@@ -1014,29 +863,19 @@ const ServerForm = ({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-gray-600 text-sm font-medium mb-1"
|
||||
htmlFor="maxTotalTimeout"
|
||||
>
|
||||
<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,
|
||||
)
|
||||
}
|
||||
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>
|
||||
<p className="text-xs text-gray-500 mt-1">{t('server.maxTotalTimeoutDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1045,14 +884,10 @@ const ServerForm = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.options?.resetTimeoutOnProgress || false}
|
||||
onChange={(e) =>
|
||||
handleOptionsChange('resetTimeoutOnProgress', e.target.checked)
|
||||
}
|
||||
onChange={(e) => handleOptionsChange('resetTimeoutOnProgress', e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-gray-600 text-sm">
|
||||
{t('server.resetTimeoutOnProgress')}
|
||||
</span>
|
||||
<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')}
|
||||
@@ -1080,7 +915,7 @@ const ServerForm = ({
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ServerForm;
|
||||
export default ServerForm
|
||||
@@ -1,78 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CursorPaginationProps {
|
||||
currentPage: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
onNextPage: () => void;
|
||||
onPreviousPage: () => void;
|
||||
}
|
||||
|
||||
const CursorPagination: React.FC<CursorPaginationProps> = ({
|
||||
currentPage,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
onNextPage,
|
||||
onPreviousPage,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center space-x-2 my-6">
|
||||
{/* Previous button */}
|
||||
<button
|
||||
onClick={onPreviousPage}
|
||||
disabled={!hasPreviousPage}
|
||||
className={`px-4 py-2 rounded transition-all duration-200 ${
|
||||
hasPreviousPage
|
||||
? 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 inline-block mr-1"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
{/* Current page indicator */}
|
||||
<span className="px-4 py-2 bg-blue-500 text-white rounded btn-primary">
|
||||
Page {currentPage}
|
||||
</span>
|
||||
|
||||
{/* Next button */}
|
||||
<button
|
||||
onClick={onNextPage}
|
||||
disabled={!hasNextPage}
|
||||
className={`px-4 py-2 rounded transition-all duration-200 ${
|
||||
hasNextPage
|
||||
? 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 inline-block ml-1"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CursorPagination;
|
||||
@@ -4,7 +4,6 @@ export const PERMISSIONS = {
|
||||
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
|
||||
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
|
||||
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
|
||||
SETTINGS_EXPORT_CONFIG: 'settings:export_config',
|
||||
} as const;
|
||||
|
||||
export default PERMISSIONS;
|
||||
|
||||
@@ -63,58 +63,55 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
};
|
||||
|
||||
// Start normal polling
|
||||
const startNormalPolling = useCallback(
|
||||
(options?: { immediate?: boolean }) => {
|
||||
const immediate = options?.immediate ?? true;
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
const startNormalPolling = useCallback((options?: { immediate?: boolean }) => {
|
||||
const immediate = options?.immediate ?? true;
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
console.log('[ServerContext] Fetching servers from API...');
|
||||
const data = await apiGet('/servers');
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
console.log('[ServerContext] Fetching servers from API...');
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
}
|
||||
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// Use friendly error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverFetch'));
|
||||
}
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Execute immediately unless explicitly skipped
|
||||
if (immediate) {
|
||||
fetchServers();
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// Use friendly error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverFetch'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set up regular polling
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
// Execute immediately unless explicitly skipped
|
||||
if (immediate) {
|
||||
fetchServers();
|
||||
}
|
||||
|
||||
// Set up regular polling
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
}, [t]);
|
||||
|
||||
// Watch for authentication status changes
|
||||
useEffect(() => {
|
||||
@@ -150,7 +147,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
try {
|
||||
console.log('[ServerContext] Initial fetch - attempt', attemptsRef.current + 1);
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
@@ -248,30 +245,16 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
const refreshIfNeeded = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastFetch = now - lastFetchTimeRef.current;
|
||||
|
||||
|
||||
// Log who is calling this
|
||||
console.log(
|
||||
'[ServerContext] refreshIfNeeded called, time since last fetch:',
|
||||
timeSinceLastFetch,
|
||||
'ms',
|
||||
);
|
||||
|
||||
console.log('[ServerContext] refreshIfNeeded called, time since last fetch:', timeSinceLastFetch, 'ms');
|
||||
|
||||
// Only refresh if enough time has passed since last fetch
|
||||
if (timeSinceLastFetch >= MIN_REFRESH_INTERVAL) {
|
||||
console.log(
|
||||
'[ServerContext] Triggering refresh (exceeded MIN_REFRESH_INTERVAL:',
|
||||
MIN_REFRESH_INTERVAL,
|
||||
'ms)',
|
||||
);
|
||||
console.log('[ServerContext] Triggering refresh (exceeded MIN_REFRESH_INTERVAL:', MIN_REFRESH_INTERVAL, 'ms)');
|
||||
triggerRefresh();
|
||||
} else {
|
||||
console.log(
|
||||
'[ServerContext] Skipping refresh (MIN_REFRESH_INTERVAL:',
|
||||
MIN_REFRESH_INTERVAL,
|
||||
'ms, time since last:',
|
||||
timeSinceLastFetch,
|
||||
'ms)',
|
||||
);
|
||||
console.log('[ServerContext] Skipping refresh (MIN_REFRESH_INTERVAL:', MIN_REFRESH_INTERVAL, 'ms, time since last:', timeSinceLastFetch, 'ms)');
|
||||
}
|
||||
}, [triggerRefresh]);
|
||||
|
||||
@@ -280,85 +263,74 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
}, []);
|
||||
|
||||
const handleServerEdit = useCallback(
|
||||
async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
const handleServerEdit = useCallback(async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleServerRemove = useCallback(
|
||||
async (serverName: string) => {
|
||||
try {
|
||||
const encodedServerName = encodeURIComponent(serverName);
|
||||
const result = await apiDelete(`/servers/${encodedServerName}`);
|
||||
const handleServerRemove = useCallback(async (serverName: string) => {
|
||||
try {
|
||||
const result = await apiDelete(`/servers/${serverName}`);
|
||||
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleServerToggle = useCallback(
|
||||
async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const encodedServerName = encodeURIComponent(server.name);
|
||||
const result = await apiPost(`/servers/${encodedServerName}/toggle`, { enabled });
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
return false;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
const handleServerToggle = useCallback(async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
|
||||
|
||||
// Update the UI immediately to reflect the change
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Update the UI immediately to reflect the change
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const value: ServerContextType = {
|
||||
servers,
|
||||
@@ -384,4 +356,4 @@ export const useServerContext = () => {
|
||||
throw new Error('useServerContext must be used within a ServerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
};
|
||||
@@ -1,283 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RegistryServerEntry,
|
||||
RegistryServersResponse,
|
||||
RegistryServerVersionResponse,
|
||||
RegistryServerVersionsResponse,
|
||||
} from '@/types';
|
||||
import { apiGet } from '../utils/fetchInterceptor';
|
||||
|
||||
export const useRegistryData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<RegistryServerEntry[]>([]);
|
||||
const [allServers, setAllServers] = useState<RegistryServerEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
// Cursor-based pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [serversPerPage, setServersPerPage] = useState(9);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
const [cursorHistory, setCursorHistory] = useState<string[]>([]);
|
||||
const [totalPages] = useState(1); // Legacy support, not used in cursor pagination
|
||||
|
||||
// Fetch registry servers with cursor-based pagination
|
||||
const fetchRegistryServers = useCallback(
|
||||
async (cursor?: string, search?: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams();
|
||||
params.append('limit', serversPerPage.toString());
|
||||
if (cursor) {
|
||||
params.append('cursor', cursor);
|
||||
}
|
||||
const queryToUse = search !== undefined ? search : searchQuery;
|
||||
if (queryToUse.trim()) {
|
||||
params.append('search', queryToUse.trim());
|
||||
}
|
||||
|
||||
const response = await apiGet(`/registry/servers?${params.toString()}`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
const data: RegistryServersResponse = response.data;
|
||||
if (data.servers && Array.isArray(data.servers)) {
|
||||
setServers(data.servers);
|
||||
// Update pagination state
|
||||
const hasMore = data.metadata.count === serversPerPage && !!data.metadata.nextCursor;
|
||||
setHasNextPage(hasMore);
|
||||
setNextCursor(data.metadata.nextCursor || null);
|
||||
|
||||
// For display purposes, keep track of all loaded servers
|
||||
if (!cursor) {
|
||||
// First page
|
||||
setAllServers(data.servers);
|
||||
} else {
|
||||
// Subsequent pages - append to all servers
|
||||
setAllServers((prev) => [...prev, ...data.servers]);
|
||||
}
|
||||
} else {
|
||||
console.error('Invalid registry servers data format:', data);
|
||||
setError(t('registry.fetchError'));
|
||||
}
|
||||
} else {
|
||||
setError(t('registry.fetchError'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching registry servers:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t, serversPerPage],
|
||||
);
|
||||
|
||||
// Navigate to next page
|
||||
const goToNextPage = useCallback(async () => {
|
||||
if (!hasNextPage || !nextCursor) return;
|
||||
|
||||
// Save current cursor to history for back navigation
|
||||
const currentCursor = cursorHistory[cursorHistory.length - 1] || '';
|
||||
setCursorHistory((prev) => [...prev, currentCursor]);
|
||||
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
await fetchRegistryServers(nextCursor, searchQuery);
|
||||
}, [hasNextPage, nextCursor, cursorHistory, searchQuery, fetchRegistryServers]);
|
||||
|
||||
// Navigate to previous page
|
||||
const goToPreviousPage = useCallback(async () => {
|
||||
if (currentPage <= 1) return;
|
||||
|
||||
// Get the previous cursor from history
|
||||
const newHistory = [...cursorHistory];
|
||||
newHistory.pop(); // Remove current position
|
||||
const previousCursor = newHistory[newHistory.length - 1];
|
||||
|
||||
setCursorHistory(newHistory);
|
||||
setCurrentPage((prev) => prev - 1);
|
||||
|
||||
// Fetch with previous cursor (undefined for first page)
|
||||
await fetchRegistryServers(previousCursor || undefined, searchQuery);
|
||||
}, [currentPage, cursorHistory, searchQuery, fetchRegistryServers]);
|
||||
|
||||
// Change page (legacy support for page number navigation)
|
||||
const changePage = useCallback(
|
||||
async (page: number) => {
|
||||
if (page === currentPage) return;
|
||||
|
||||
if (page > currentPage && hasNextPage) {
|
||||
await goToNextPage();
|
||||
} else if (page < currentPage && currentPage > 1) {
|
||||
await goToPreviousPage();
|
||||
}
|
||||
},
|
||||
[currentPage, hasNextPage, goToNextPage, goToPreviousPage],
|
||||
);
|
||||
|
||||
// Change items per page
|
||||
const changeServersPerPage = useCallback(
|
||||
async (newServersPerPage: number) => {
|
||||
setServersPerPage(newServersPerPage);
|
||||
setCurrentPage(1);
|
||||
setCursorHistory([]);
|
||||
setAllServers([]);
|
||||
await fetchRegistryServers(undefined, searchQuery);
|
||||
},
|
||||
[searchQuery, fetchRegistryServers],
|
||||
);
|
||||
|
||||
// Fetch server by name
|
||||
const fetchServerByName = useCallback(
|
||||
async (serverName: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// URL encode the server name
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const response = await apiGet(`/registry/servers/${encodedName}/versions`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
const data: RegistryServerVersionsResponse = response.data;
|
||||
if (data.servers && Array.isArray(data.servers) && data.servers.length > 0) {
|
||||
// Return the first server entry (should be the latest or specified version)
|
||||
return data.servers[0];
|
||||
} else {
|
||||
console.error('Invalid registry server data format:', data);
|
||||
setError(t('registry.serverNotFound'));
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
setError(t('registry.serverNotFound'));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching registry server ${serverName}:`, err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Fetch all versions of a server
|
||||
const fetchServerVersions = useCallback(async (serverName: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// URL encode the server name
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const response = await apiGet(`/registry/servers/${encodedName}/versions`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
const data: RegistryServerVersionsResponse = response.data;
|
||||
if (data.servers && Array.isArray(data.servers)) {
|
||||
return data.servers;
|
||||
} else {
|
||||
console.error('Invalid registry server versions data format:', data);
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching versions for server ${serverName}:`, err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch specific version of a server
|
||||
const fetchServerVersion = useCallback(async (serverName: string, version: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// URL encode the server name and version
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const encodedVersion = encodeURIComponent(version);
|
||||
const response = await apiGet(`/registry/servers/${encodedName}/versions/${encodedVersion}`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
const data: RegistryServerVersionResponse = response.data;
|
||||
if (data && data.server) {
|
||||
return data;
|
||||
} else {
|
||||
console.error('Invalid registry server version data format:', data);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching version ${version} for server ${serverName}:`, err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Search servers by query (client-side filtering on loaded data)
|
||||
const searchServers = useCallback(
|
||||
async (query: string) => {
|
||||
console.log('Searching registry servers with query:', query);
|
||||
setSearchQuery(query);
|
||||
setCurrentPage(1);
|
||||
setCursorHistory([]);
|
||||
setAllServers([]);
|
||||
|
||||
await fetchRegistryServers(undefined, query);
|
||||
},
|
||||
[fetchRegistryServers],
|
||||
);
|
||||
|
||||
// Clear search
|
||||
const clearSearch = useCallback(async () => {
|
||||
setSearchQuery('');
|
||||
setCurrentPage(1);
|
||||
setCursorHistory([]);
|
||||
setAllServers([]);
|
||||
await fetchRegistryServers(undefined, '');
|
||||
}, [fetchRegistryServers]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchRegistryServers(undefined, searchQuery);
|
||||
// Only run on mount
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
|
||||
return {
|
||||
servers,
|
||||
allServers,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
searchQuery,
|
||||
searchServers,
|
||||
clearSearch,
|
||||
fetchServerByName,
|
||||
fetchServerVersions,
|
||||
fetchServerVersion,
|
||||
// Cursor-based pagination
|
||||
currentPage,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPreviousPage: currentPage > 1,
|
||||
changePage,
|
||||
goToNextPage,
|
||||
goToPreviousPage,
|
||||
serversPerPage,
|
||||
changeServersPerPage,
|
||||
};
|
||||
};
|
||||
@@ -420,21 +420,6 @@ export const useSettingsData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const exportMCPSettings = async (serverName?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
return await apiGet(`/mcp-settings/export?serverName=${serverName ? serverName : ''}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to export MCP settings:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to export MCP settings';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch settings when the component mounts or refreshKey changes
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
@@ -469,6 +454,5 @@ export const useSettingsData = () => {
|
||||
updateMCPRouterConfig,
|
||||
updateMCPRouterConfigBatch,
|
||||
updateNameSeparator,
|
||||
exportMCPSettings,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
MarketServer,
|
||||
CloudServer,
|
||||
ServerConfig,
|
||||
RegistryServerEntry,
|
||||
RegistryServerData,
|
||||
} from '@/types';
|
||||
import { MarketServer, CloudServer, ServerConfig } from '@/types';
|
||||
import { useMarketData } from '@/hooks/useMarketData';
|
||||
import { useCloudData } from '@/hooks/useCloudData';
|
||||
import { useRegistryData } from '@/hooks/useRegistryData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { apiPost } from '@/utils/fetchInterceptor';
|
||||
import MarketServerCard from '@/components/MarketServerCard';
|
||||
import MarketServerDetail from '@/components/MarketServerDetail';
|
||||
import CloudServerCard from '@/components/CloudServerCard';
|
||||
import CloudServerDetail from '@/components/CloudServerDetail';
|
||||
import RegistryServerCard from '@/components/RegistryServerCard';
|
||||
import RegistryServerDetail from '@/components/RegistryServerDetail';
|
||||
import MCPRouterApiKeyError from '@/components/MCPRouterApiKeyError';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import CursorPagination from '@/components/ui/CursorPagination';
|
||||
|
||||
const MarketPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -29,7 +19,7 @@ const MarketPage: React.FC = () => {
|
||||
const { serverName } = useParams<{ serverName?: string }>();
|
||||
const { showToast } = useToast();
|
||||
|
||||
// Get tab from URL search params
|
||||
// Get tab from URL search params, default to cloud market
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const currentTab = searchParams.get('tab') || 'cloud';
|
||||
|
||||
@@ -54,10 +44,10 @@ const MarketPage: React.FC = () => {
|
||||
totalPages: localTotalPages,
|
||||
changePage: changeLocalPage,
|
||||
serversPerPage: localServersPerPage,
|
||||
changeServersPerPage: changeLocalServersPerPage,
|
||||
changeServersPerPage: changeLocalServersPerPage
|
||||
} = useMarketData();
|
||||
|
||||
// Cloud market data
|
||||
// Cloud market data
|
||||
const {
|
||||
servers: cloudServers,
|
||||
allServers: allCloudServers,
|
||||
@@ -71,67 +61,29 @@ const MarketPage: React.FC = () => {
|
||||
totalPages: cloudTotalPages,
|
||||
changePage: changeCloudPage,
|
||||
serversPerPage: cloudServersPerPage,
|
||||
changeServersPerPage: changeCloudServersPerPage,
|
||||
changeServersPerPage: changeCloudServersPerPage
|
||||
} = useCloudData();
|
||||
|
||||
// Registry data
|
||||
const {
|
||||
servers: registryServers,
|
||||
allServers: allRegistryServers,
|
||||
loading: registryLoading,
|
||||
error: registryError,
|
||||
setError: setRegistryError,
|
||||
searchServers: searchRegistryServers,
|
||||
clearSearch: clearRegistrySearch,
|
||||
fetchServerByName: fetchRegistryServerByName,
|
||||
fetchServerVersions: fetchRegistryServerVersions,
|
||||
// Cursor-based pagination
|
||||
currentPage: registryCurrentPage,
|
||||
totalPages: registryTotalPages,
|
||||
hasNextPage: registryHasNextPage,
|
||||
hasPreviousPage: registryHasPreviousPage,
|
||||
changePage: changeRegistryPage,
|
||||
goToNextPage: goToRegistryNextPage,
|
||||
goToPreviousPage: goToRegistryPreviousPage,
|
||||
serversPerPage: registryServersPerPage,
|
||||
changeServersPerPage: changeRegistryServersPerPage,
|
||||
} = useRegistryData();
|
||||
|
||||
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
|
||||
const [selectedCloudServer, setSelectedCloudServer] = useState<CloudServer | null>(null);
|
||||
const [selectedRegistryServer, setSelectedRegistryServer] = useState<RegistryServerEntry | null>(
|
||||
null,
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [registrySearchQuery, setRegistrySearchQuery] = useState('');
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [installedCloudServers, setInstalledCloudServers] = useState<Set<string>>(new Set());
|
||||
const [installedRegistryServers, setInstalledRegistryServers] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load server details if a server name is in the URL
|
||||
useEffect(() => {
|
||||
const loadServerDetails = async () => {
|
||||
if (serverName) {
|
||||
// Determine if it's a cloud, local, or registry server based on the current tab
|
||||
// Determine if it's a cloud or local server based on the current tab
|
||||
if (currentTab === 'cloud') {
|
||||
// Try to find the server in cloud servers
|
||||
const server = cloudServers.find((s) => s.name === serverName);
|
||||
const server = cloudServers.find(s => s.name === serverName);
|
||||
if (server) {
|
||||
setSelectedCloudServer(server);
|
||||
} else {
|
||||
// If server not found, navigate back to market page
|
||||
navigate('/market?tab=cloud');
|
||||
}
|
||||
} else if (currentTab === 'registry') {
|
||||
console.log('Loading registry server details for:', serverName);
|
||||
// Registry market
|
||||
const serverEntry = await fetchRegistryServerByName(serverName);
|
||||
if (serverEntry) {
|
||||
setSelectedRegistryServer(serverEntry);
|
||||
} else {
|
||||
// If server not found, navigate back to market page
|
||||
navigate('/market?tab=registry');
|
||||
}
|
||||
} else {
|
||||
// Local market
|
||||
const server = await fetchLocalServerByName(serverName);
|
||||
@@ -145,22 +97,14 @@ const MarketPage: React.FC = () => {
|
||||
} else {
|
||||
setSelectedServer(null);
|
||||
setSelectedCloudServer(null);
|
||||
setSelectedRegistryServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
loadServerDetails();
|
||||
}, [
|
||||
serverName,
|
||||
currentTab,
|
||||
cloudServers,
|
||||
fetchLocalServerByName,
|
||||
fetchRegistryServerByName,
|
||||
navigate,
|
||||
]);
|
||||
}, [serverName, currentTab, cloudServers, fetchLocalServerByName, navigate]);
|
||||
|
||||
// Tab switching handler
|
||||
const switchTab = (tab: 'local' | 'cloud' | 'registry') => {
|
||||
const switchTab = (tab: 'local' | 'cloud') => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.set('tab', tab);
|
||||
setSearchParams(newSearchParams);
|
||||
@@ -174,8 +118,6 @@ const MarketPage: React.FC = () => {
|
||||
e.preventDefault();
|
||||
if (currentTab === 'local') {
|
||||
searchLocalServers(searchQuery);
|
||||
} else if (currentTab === 'registry') {
|
||||
searchRegistryServers(registrySearchQuery);
|
||||
}
|
||||
// Cloud search is not implemented in the original cloud page
|
||||
};
|
||||
@@ -187,35 +129,18 @@ const MarketPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchQuery('');
|
||||
if (currentTab === 'local') {
|
||||
setSearchQuery('');
|
||||
filterLocalByCategory('');
|
||||
filterLocalByTag('');
|
||||
} else if (currentTab === 'registry') {
|
||||
setRegistrySearchQuery('');
|
||||
clearRegistrySearch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerClick = (server: MarketServer | CloudServer | RegistryServerEntry) => {
|
||||
const handleServerClick = (server: MarketServer | CloudServer) => {
|
||||
if (currentTab === 'cloud') {
|
||||
const cloudServer = server as CloudServer;
|
||||
navigate(`/market/${cloudServer.name}?tab=cloud`);
|
||||
} else if (currentTab === 'registry') {
|
||||
const registryServer = server as RegistryServerEntry;
|
||||
console.log('Registry server clicked:', registryServer);
|
||||
const serverName = registryServer.server?.name;
|
||||
console.log('Server name extracted:', serverName);
|
||||
if (serverName) {
|
||||
const targetUrl = `/market/${encodeURIComponent(serverName)}?tab=registry`;
|
||||
console.log('Navigating to:', targetUrl);
|
||||
navigate(targetUrl);
|
||||
} else {
|
||||
console.error('Server name is undefined in registry server:', registryServer);
|
||||
}
|
||||
navigate(`/market/${server.name}?tab=cloud`);
|
||||
} else {
|
||||
const marketServer = server as MarketServer;
|
||||
navigate(`/market/${marketServer.name}?tab=local`);
|
||||
navigate(`/market/${server.name}?tab=local`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -242,7 +167,7 @@ const MarketPage: React.FC = () => {
|
||||
|
||||
const payload = {
|
||||
name: server.name,
|
||||
config: config,
|
||||
config: config
|
||||
};
|
||||
|
||||
const result = await apiPost('/servers', payload);
|
||||
@@ -254,8 +179,9 @@ const MarketPage: React.FC = () => {
|
||||
}
|
||||
|
||||
// Update installed servers set
|
||||
setInstalledCloudServers((prev) => new Set(prev).add(server.name));
|
||||
setInstalledCloudServers(prev => new Set(prev).add(server.name));
|
||||
showToast(t('cloud.installSuccess', { name: server.title || server.name }), 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error installing cloud server:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
@@ -265,41 +191,7 @@ const MarketPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle registry server installation
|
||||
const handleRegistryInstall = async (server: RegistryServerData, config: ServerConfig) => {
|
||||
try {
|
||||
setInstalling(true);
|
||||
|
||||
const payload = {
|
||||
name: server.name,
|
||||
config: config,
|
||||
};
|
||||
|
||||
const result = await apiPost('/servers', payload);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage = result?.message || t('server.addError');
|
||||
showToast(errorMessage, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update installed servers set
|
||||
setInstalledRegistryServers((prev) => new Set(prev).add(server.name));
|
||||
showToast(t('registry.installSuccess', { name: server.title || server.name }), 'success');
|
||||
} catch (error) {
|
||||
console.error('Error installing registry server:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
showToast(t('registry.installError', { error: errorMessage }), 'error');
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCallTool = async (
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
args: Record<string, any>,
|
||||
) => {
|
||||
const handleCallTool = async (serverName: string, toolName: string, args: Record<string, any>) => {
|
||||
try {
|
||||
const result = await callServerTool(serverName, toolName, args);
|
||||
showToast(t('cloud.toolCallSuccess', { toolName }), 'success');
|
||||
@@ -316,17 +208,13 @@ const MarketPage: React.FC = () => {
|
||||
|
||||
// Helper function to check if error is MCPRouter API key not configured
|
||||
const isMCPRouterApiKeyError = (errorMessage: string) => {
|
||||
return (
|
||||
errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
|
||||
errorMessage.toLowerCase().includes('mcprouter api key not configured')
|
||||
);
|
||||
return errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
|
||||
errorMessage.toLowerCase().includes('mcprouter api key not configured');
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (currentTab === 'local') {
|
||||
changeLocalPage(page);
|
||||
} else if (currentTab === 'registry') {
|
||||
changeRegistryPage(page);
|
||||
} else {
|
||||
changeCloudPage(page);
|
||||
}
|
||||
@@ -338,8 +226,6 @@ const MarketPage: React.FC = () => {
|
||||
const newValue = parseInt(e.target.value, 10);
|
||||
if (currentTab === 'local') {
|
||||
changeLocalServersPerPage(newValue);
|
||||
} else if (currentTab === 'registry') {
|
||||
changeRegistryServersPerPage(newValue);
|
||||
} else {
|
||||
changeCloudServersPerPage(newValue);
|
||||
}
|
||||
@@ -373,50 +259,19 @@ const MarketPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Render registry server detail if selected
|
||||
if (selectedRegistryServer) {
|
||||
return (
|
||||
<RegistryServerDetail
|
||||
serverEntry={selectedRegistryServer}
|
||||
onBack={handleBackToList}
|
||||
onInstall={handleRegistryInstall}
|
||||
installing={installing}
|
||||
isInstalled={installedRegistryServers.has(selectedRegistryServer.server.name)}
|
||||
fetchVersions={fetchRegistryServerVersions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Get current data based on active tab
|
||||
const isLocalTab = currentTab === 'local';
|
||||
const isRegistryTab = currentTab === 'registry';
|
||||
const servers = isLocalTab ? localServers : isRegistryTab ? registryServers : cloudServers;
|
||||
const allServers = isLocalTab
|
||||
? allLocalServers
|
||||
: isRegistryTab
|
||||
? allRegistryServers
|
||||
: allCloudServers;
|
||||
const servers = isLocalTab ? localServers : cloudServers;
|
||||
const allServers = isLocalTab ? allLocalServers : allCloudServers;
|
||||
const categories = isLocalTab ? localCategories : [];
|
||||
const loading = isLocalTab ? localLoading : isRegistryTab ? registryLoading : cloudLoading;
|
||||
const error = isLocalTab ? localError : isRegistryTab ? registryError : cloudError;
|
||||
const setError = isLocalTab ? setLocalError : isRegistryTab ? setRegistryError : setCloudError;
|
||||
const loading = isLocalTab ? localLoading : cloudLoading;
|
||||
const error = isLocalTab ? localError : cloudError;
|
||||
const setError = isLocalTab ? setLocalError : setCloudError;
|
||||
const selectedCategory = isLocalTab ? selectedLocalCategory : '';
|
||||
const selectedTag = isLocalTab ? selectedLocalTag : '';
|
||||
const currentPage = isLocalTab
|
||||
? localCurrentPage
|
||||
: isRegistryTab
|
||||
? registryCurrentPage
|
||||
: cloudCurrentPage;
|
||||
const totalPages = isLocalTab
|
||||
? localTotalPages
|
||||
: isRegistryTab
|
||||
? registryTotalPages
|
||||
: cloudTotalPages;
|
||||
const serversPerPage = isLocalTab
|
||||
? localServersPerPage
|
||||
: isRegistryTab
|
||||
? registryServersPerPage
|
||||
: cloudServersPerPage;
|
||||
const currentPage = isLocalTab ? localCurrentPage : cloudCurrentPage;
|
||||
const totalPages = isLocalTab ? localTotalPages : cloudTotalPages;
|
||||
const serversPerPage = isLocalTab ? localServersPerPage : cloudServersPerPage;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -426,15 +281,13 @@ const MarketPage: React.FC = () => {
|
||||
<nav className="-mb-px flex space-x-3">
|
||||
<button
|
||||
onClick={() => switchTab('cloud')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
|
||||
!isLocalTab && !isRegistryTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${!isLocalTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('cloud.title')}
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">
|
||||
(
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">(
|
||||
<a
|
||||
href="https://mcprouter.co"
|
||||
target="_blank"
|
||||
@@ -448,15 +301,13 @@ const MarketPage: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchTab('local')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
|
||||
isLocalTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${isLocalTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('market.title')}
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">
|
||||
(
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">(
|
||||
<a
|
||||
href="https://mcpm.sh"
|
||||
target="_blank"
|
||||
@@ -468,28 +319,6 @@ const MarketPage: React.FC = () => {
|
||||
)
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchTab('registry')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
|
||||
isRegistryTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('registry.title')}
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">
|
||||
(
|
||||
<a
|
||||
href="https://registry.modelcontextprotocol.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="external-link"
|
||||
>
|
||||
{t('registry.official')}
|
||||
</a>
|
||||
)
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -506,17 +335,8 @@ const MarketPage: React.FC = () => {
|
||||
onClick={() => setError(null)}
|
||||
className="text-red-700 hover:text-red-900 transition-colors duration-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -525,24 +345,16 @@ const MarketPage: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Search bar for local market and registry */}
|
||||
{(isLocalTab || isRegistryTab) && (
|
||||
{/* Search bar for local market only */}
|
||||
{isLocalTab && (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
|
||||
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
|
||||
<div className="flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
value={isRegistryTab ? registrySearchQuery : searchQuery}
|
||||
onChange={(e) => {
|
||||
if (isRegistryTab) {
|
||||
setRegistrySearchQuery(e.target.value);
|
||||
} else {
|
||||
setSearchQuery(e.target.value);
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
isRegistryTab ? t('registry.searchPlaceholder') : t('market.searchPlaceholder')
|
||||
}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('market.searchPlaceholder')}
|
||||
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -550,16 +362,15 @@ const MarketPage: React.FC = () => {
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
{isRegistryTab ? t('registry.search') : t('market.search')}
|
||||
{t('market.search')}
|
||||
</button>
|
||||
{((isLocalTab && (searchQuery || selectedCategory || selectedTag)) ||
|
||||
(isRegistryTab && registrySearchQuery)) && (
|
||||
{(searchQuery || selectedCategory || selectedTag) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
|
||||
>
|
||||
{isRegistryTab ? t('registry.clearFilters') : t('market.clearFilters')}
|
||||
{t('market.clearFilters')}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
@@ -577,10 +388,7 @@ const MarketPage: React.FC = () => {
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
{selectedCategory && (
|
||||
<span
|
||||
className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200"
|
||||
onClick={() => filterLocalByCategory('')}
|
||||
>
|
||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterLocalByCategory('')}>
|
||||
{t('market.clearCategoryFilter')}
|
||||
</span>
|
||||
)}
|
||||
@@ -590,11 +398,10 @@ const MarketPage: React.FC = () => {
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => handleCategoryClick(category)}
|
||||
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${
|
||||
selectedCategory === category
|
||||
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
|
||||
}`}
|
||||
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${selectedCategory === category
|
||||
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
@@ -607,25 +414,9 @@ const MarketPage: React.FC = () => {
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-center py-4 loading-container">
|
||||
<svg
|
||||
className="animate-spin h-6 w-6 text-blue-500 mb-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-sm text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
@@ -647,110 +438,61 @@ const MarketPage: React.FC = () => {
|
||||
{loading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<svg
|
||||
className="animate-spin h-10 w-10 text-blue-500 mb-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<p className="text-gray-600">
|
||||
{isLocalTab
|
||||
? t('market.noServers')
|
||||
: isRegistryTab
|
||||
? t('registry.noServers')
|
||||
: t('cloud.noServers')}
|
||||
</p>
|
||||
<p className="text-gray-600">{isLocalTab ? t('market.noServers') : t('cloud.noServers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{servers.map((server, index) =>
|
||||
{servers.map((server, index) => (
|
||||
isLocalTab ? (
|
||||
<MarketServerCard
|
||||
key={index}
|
||||
server={server as MarketServer}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
) : isRegistryTab ? (
|
||||
<RegistryServerCard
|
||||
key={index}
|
||||
serverEntry={server as RegistryServerEntry}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
) : (
|
||||
<CloudServerCard
|
||||
key={index}
|
||||
server={server as CloudServer}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-[2] text-sm text-gray-500">
|
||||
{isLocalTab
|
||||
? t('market.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length,
|
||||
})
|
||||
: isRegistryTab
|
||||
? t('registry.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: (currentPage - 1) * serversPerPage + servers.length,
|
||||
total: allServers.length + (registryHasNextPage ? '+' : ''),
|
||||
})
|
||||
: t('cloud.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length,
|
||||
})}
|
||||
</div>
|
||||
<div className="flex-[4] flex justify-center">
|
||||
{isRegistryTab ? (
|
||||
<CursorPagination
|
||||
currentPage={currentPage}
|
||||
hasNextPage={registryHasNextPage}
|
||||
hasPreviousPage={registryHasPreviousPage}
|
||||
onNextPage={goToRegistryNextPage}
|
||||
onPreviousPage={goToRegistryPreviousPage}
|
||||
/>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
{isLocalTab ? (
|
||||
t('market.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length
|
||||
})
|
||||
) : (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
t('cloud.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-[2] flex items-center justify-end space-x-2">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<label htmlFor="perPage" className="text-sm text-gray-600">
|
||||
{isLocalTab
|
||||
? t('market.perPage')
|
||||
: isRegistryTab
|
||||
? t('registry.perPage')
|
||||
: t('cloud.perPage')}
|
||||
:
|
||||
{isLocalTab ? t('market.perPage') : t('cloud.perPage')}:
|
||||
</label>
|
||||
<select
|
||||
id="perPage"
|
||||
@@ -765,6 +507,9 @@ const MarketPage: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,55 +1,54 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm'
|
||||
import { Switch } from '@/components/ui/ToggleGroup'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { generateRandomKey } from '@/utils/key'
|
||||
import { PermissionChecker } from '@/components/PermissionChecker'
|
||||
import { PERMISSIONS } from '@/constants/permissions'
|
||||
import { Copy, Check, Download } from 'lucide-react'
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm';
|
||||
import { Switch } from '@/components/ui/ToggleGroup';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { generateRandomKey } from '@/utils/key';
|
||||
import { PermissionChecker } from '@/components/PermissionChecker';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
pythonIndexUrl: string
|
||||
npmRegistry: string
|
||||
baseUrl: string
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
|
||||
dbUrl: string
|
||||
openaiApiBaseUrl: string
|
||||
openaiApiKey: string
|
||||
openaiApiEmbeddingModel: string
|
||||
dbUrl: string;
|
||||
openaiApiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiApiEmbeddingModel: string;
|
||||
}>({
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{
|
||||
apiKey: string
|
||||
referer: string
|
||||
title: string
|
||||
baseUrl: string
|
||||
apiKey: string;
|
||||
referer: string;
|
||||
title: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
apiKey: '',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-')
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-');
|
||||
|
||||
const {
|
||||
routingConfig,
|
||||
@@ -67,15 +66,14 @@ const SettingsPage: React.FC = () => {
|
||||
updateSmartRoutingConfigBatch,
|
||||
updateMCPRouterConfig,
|
||||
updateNameSeparator,
|
||||
exportMCPSettings,
|
||||
} = useSettingsData()
|
||||
} = useSettingsData();
|
||||
|
||||
// Update local installConfig when savedInstallConfig changes
|
||||
useEffect(() => {
|
||||
if (savedInstallConfig) {
|
||||
setInstallConfig(savedInstallConfig)
|
||||
setInstallConfig(savedInstallConfig);
|
||||
}
|
||||
}, [savedInstallConfig])
|
||||
}, [savedInstallConfig]);
|
||||
|
||||
// Update local tempSmartRoutingConfig when smartRoutingConfig changes
|
||||
useEffect(() => {
|
||||
@@ -85,9 +83,9 @@ const SettingsPage: React.FC = () => {
|
||||
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
|
||||
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
|
||||
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [smartRoutingConfig])
|
||||
}, [smartRoutingConfig]);
|
||||
|
||||
// Update local tempMCPRouterConfig when mcpRouterConfig changes
|
||||
useEffect(() => {
|
||||
@@ -97,14 +95,14 @@ const SettingsPage: React.FC = () => {
|
||||
referer: mcpRouterConfig.referer || 'https://www.mcphubx.com',
|
||||
title: mcpRouterConfig.title || 'MCPHub',
|
||||
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [mcpRouterConfig])
|
||||
}, [mcpRouterConfig]);
|
||||
|
||||
// Update local tempNameSeparator when nameSeparator changes
|
||||
useEffect(() => {
|
||||
setTempNameSeparator(nameSeparator)
|
||||
}, [nameSeparator])
|
||||
setTempNameSeparator(nameSeparator);
|
||||
}, [nameSeparator]);
|
||||
|
||||
const [sectionsVisible, setSectionsVisible] = useState({
|
||||
routingConfig: false,
|
||||
@@ -112,244 +110,138 @@ const SettingsPage: React.FC = () => {
|
||||
smartRoutingConfig: false,
|
||||
mcpRouterConfig: false,
|
||||
nameSeparator: false,
|
||||
password: false,
|
||||
exportConfig: false,
|
||||
})
|
||||
password: false
|
||||
});
|
||||
|
||||
const toggleSection = (
|
||||
section:
|
||||
| 'routingConfig'
|
||||
| 'installConfig'
|
||||
| 'smartRoutingConfig'
|
||||
| 'mcpRouterConfig'
|
||||
| 'nameSeparator'
|
||||
| 'password'
|
||||
| 'exportConfig',
|
||||
) => {
|
||||
setSectionsVisible((prev) => ({
|
||||
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'mcpRouterConfig' | 'nameSeparator' | 'password') => {
|
||||
setSectionsVisible(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}))
|
||||
}
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRoutingConfigChange = async (
|
||||
key:
|
||||
| 'enableGlobalRoute'
|
||||
| 'enableGroupNameRoute'
|
||||
| 'enableBearerAuth'
|
||||
| 'bearerAuthKey'
|
||||
| 'skipAuth',
|
||||
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) {
|
||||
const newKey = generateRandomKey()
|
||||
handleBearerAuthKeyChange(newKey)
|
||||
const newKey = generateRandomKey();
|
||||
handleBearerAuthKeyChange(newKey);
|
||||
|
||||
// Update both enableBearerAuth and bearerAuthKey in a single call
|
||||
const success = await updateRoutingConfigBatch({
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: newKey,
|
||||
})
|
||||
bearerAuthKey: newKey
|
||||
});
|
||||
|
||||
if (success) {
|
||||
// Update tempRoutingConfig to reflect the saved values
|
||||
setTempRoutingConfig((prev) => ({
|
||||
setTempRoutingConfig(prev => ({
|
||||
...prev,
|
||||
bearerAuthKey: newKey,
|
||||
}))
|
||||
bearerAuthKey: newKey
|
||||
}));
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await updateRoutingConfig(key, value)
|
||||
}
|
||||
await updateRoutingConfig(key, value);
|
||||
};
|
||||
|
||||
const handleBearerAuthKeyChange = (value: string) => {
|
||||
setTempRoutingConfig((prev) => ({
|
||||
setTempRoutingConfig(prev => ({
|
||||
...prev,
|
||||
bearerAuthKey: value,
|
||||
}))
|
||||
}
|
||||
bearerAuthKey: value
|
||||
}));
|
||||
};
|
||||
|
||||
const saveBearerAuthKey = async () => {
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey)
|
||||
}
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
|
||||
};
|
||||
|
||||
const handleInstallConfigChange = (
|
||||
key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl',
|
||||
value: string,
|
||||
) => {
|
||||
const handleInstallConfigChange = (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl', value: string) => {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl') => {
|
||||
await updateInstallConfig(key, installConfig[key])
|
||||
}
|
||||
await updateInstallConfig(key, installConfig[key]);
|
||||
};
|
||||
|
||||
const handleSmartRoutingConfigChange = (
|
||||
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
|
||||
value: string,
|
||||
) => {
|
||||
const handleSmartRoutingConfigChange = (key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel', value: string) => {
|
||||
setTempSmartRoutingConfig({
|
||||
...tempSmartRoutingConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const saveSmartRoutingConfig = async (
|
||||
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
|
||||
) => {
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key])
|
||||
}
|
||||
const saveSmartRoutingConfig = async (key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel') => {
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
|
||||
};
|
||||
|
||||
const handleMCPRouterConfigChange = (
|
||||
key: 'apiKey' | 'referer' | 'title' | 'baseUrl',
|
||||
value: string,
|
||||
) => {
|
||||
const handleMCPRouterConfigChange = (key: 'apiKey' | 'referer' | 'title' | 'baseUrl', value: string) => {
|
||||
setTempMCPRouterConfig({
|
||||
...tempMCPRouterConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const saveMCPRouterConfig = async (key: 'apiKey' | 'referer' | 'title' | 'baseUrl') => {
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key])
|
||||
}
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key]);
|
||||
};
|
||||
|
||||
const saveNameSeparator = async () => {
|
||||
await updateNameSeparator(tempNameSeparator)
|
||||
}
|
||||
await updateNameSeparator(tempNameSeparator);
|
||||
};
|
||||
|
||||
const handleSmartRoutingEnabledChange = async (value: boolean) => {
|
||||
// If enabling Smart Routing, validate required fields and save any unsaved changes
|
||||
if (value) {
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl
|
||||
const currentOpenaiApiKey =
|
||||
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl;
|
||||
const currentOpenaiApiKey = tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey;
|
||||
|
||||
if (!currentDbUrl || !currentOpenaiApiKey) {
|
||||
const missingFields = []
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'))
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'))
|
||||
const missingFields = [];
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'));
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'));
|
||||
|
||||
showToast(
|
||||
t('settings.smartRoutingValidationError', {
|
||||
fields: missingFields.join(', '),
|
||||
}),
|
||||
)
|
||||
return
|
||||
showToast(t('settings.smartRoutingValidationError', {
|
||||
fields: missingFields.join(', ')
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare updates object with unsaved changes and enabled status
|
||||
const updates: any = { enabled: value }
|
||||
const updates: any = { enabled: value };
|
||||
|
||||
// Check for unsaved changes and include them in the batch update
|
||||
if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) {
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl;
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) {
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl;
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) {
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey;
|
||||
}
|
||||
if (
|
||||
tempSmartRoutingConfig.openaiApiEmbeddingModel !==
|
||||
smartRoutingConfig.openaiApiEmbeddingModel
|
||||
) {
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel
|
||||
if (tempSmartRoutingConfig.openaiApiEmbeddingModel !== smartRoutingConfig.openaiApiEmbeddingModel) {
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel;
|
||||
}
|
||||
|
||||
// Save all changes in a single batch update
|
||||
await updateSmartRoutingConfigBatch(updates)
|
||||
await updateSmartRoutingConfigBatch(updates);
|
||||
} else {
|
||||
// If disabling, just update the enabled status
|
||||
await updateSmartRoutingConfig('enabled', value)
|
||||
await updateSmartRoutingConfig('enabled', value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChangeSuccess = () => {
|
||||
setTimeout(() => {
|
||||
navigate('/')
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const [copiedConfig, setCopiedConfig] = useState(false)
|
||||
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('')
|
||||
|
||||
const fetchMcpSettings = async () => {
|
||||
try {
|
||||
const result = await exportMCPSettings()
|
||||
console.log('Fetched MCP settings:', result)
|
||||
const configJson = JSON.stringify(result.data, null, 2)
|
||||
setMcpSettingsJson(configJson)
|
||||
} catch (error) {
|
||||
console.error('Error fetching MCP settings:', error)
|
||||
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionsVisible.exportConfig && !mcpSettingsJson) {
|
||||
fetchMcpSettings()
|
||||
}
|
||||
}, [sectionsVisible.exportConfig])
|
||||
|
||||
const handleCopyConfig = async () => {
|
||||
if (!mcpSettingsJson) return
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(mcpSettingsJson)
|
||||
setCopiedConfig(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopiedConfig(false), 2000)
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = mcpSettingsJson
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
setCopiedConfig(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopiedConfig(false), 2000)
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Copy to clipboard failed:', err)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error copying configuration:', error)
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadConfig = () => {
|
||||
if (!mcpSettingsJson) return
|
||||
|
||||
const blob = new Blob([mcpSettingsJson], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'mcp_settings.json'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success')
|
||||
}
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
@@ -373,9 +265,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableSmartRoutingDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
@@ -387,8 +277,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-red-500 px-1">*</span>
|
||||
{t('settings.dbUrl')}
|
||||
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -413,8 +302,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-red-500 px-1">*</span>
|
||||
{t('settings.openaiApiKey')}
|
||||
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -444,9 +332,7 @@ const SettingsPage: React.FC = () => {
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiBaseUrl}
|
||||
onChange={(e) =>
|
||||
handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)
|
||||
}
|
||||
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 form-input"
|
||||
disabled={loading}
|
||||
@@ -463,17 +349,13 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.openaiApiEmbeddingModel')}
|
||||
</h3>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
|
||||
onChange={(e) =>
|
||||
handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)
|
||||
}
|
||||
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 form-input"
|
||||
disabled={loading}
|
||||
@@ -510,9 +392,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterApiKey')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.mcpRouterApiKeyDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.mcpRouterApiKeyDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -536,9 +416,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterBaseUrl')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.mcpRouterBaseUrlDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.mcpRouterBaseUrlDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -570,7 +448,9 @@ const SettingsPage: React.FC = () => {
|
||||
onClick={() => toggleSection('nameSeparator')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.systemSettings')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.nameSeparator ? '▼' : '►'}</span>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.nameSeparator ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.nameSeparator && (
|
||||
@@ -610,7 +490,9 @@ const SettingsPage: React.FC = () => {
|
||||
onClick={() => toggleSection('routingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.routingConfig ? '▼' : '►'}</span>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.routingConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.routingConfig && (
|
||||
@@ -623,9 +505,7 @@ const SettingsPage: React.FC = () => {
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableBearerAuth}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableBearerAuth', checked)
|
||||
}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableBearerAuth', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -658,32 +538,24 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGlobalRouteDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableGlobalRouteDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGlobalRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGlobalRoute', checked)
|
||||
}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableGlobalRoute', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGroupNameRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGroupNameRouteDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableGroupNameRouteDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGroupNameRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGroupNameRoute', checked)
|
||||
}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableGroupNameRoute', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -700,6 +572,7 @@ const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -712,7 +585,9 @@ const SettingsPage: React.FC = () => {
|
||||
onClick={() => toggleSection('installConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.installConfig ? '▼' : '►'}</span>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.installConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.installConfig && (
|
||||
@@ -800,7 +675,9 @@ const SettingsPage: React.FC = () => {
|
||||
onClick={() => toggleSection('password')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('auth.changePassword')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.password ? '▼' : '►'}</span>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.password ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.password && (
|
||||
@@ -809,61 +686,8 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
{/* Export MCP Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_EXPORT_CONFIG}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('exportConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.exportMcpSettings')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.exportConfig ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.exportConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpSettingsJson')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.mcpSettingsJsonDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleCopyConfig}
|
||||
disabled={!mcpSettingsJson}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{copiedConfig ? <Check size={16} /> : <Copy size={16} />}
|
||||
{copiedConfig ? t('common.copied') : t('settings.copyToClipboard')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownloadConfig}
|
||||
disabled={!mcpSettingsJson}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
<Download size={16} />
|
||||
{t('settings.downloadJson')}
|
||||
</button>
|
||||
</div>
|
||||
{mcpSettingsJson && (
|
||||
<div className="mt-3">
|
||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-md overflow-x-auto text-xs max-h-96">
|
||||
{mcpSettingsJson}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPage
|
||||
export default SettingsPage;
|
||||
@@ -309,148 +309,3 @@ export interface AuthResponse {
|
||||
user?: IUser;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Official Registry types (from registry.modelcontextprotocol.io)
|
||||
export interface RegistryVariable {
|
||||
choices?: string[];
|
||||
default?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
isRequired?: boolean;
|
||||
isSecret?: boolean;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface RegistryVariables {
|
||||
[key: string]: RegistryVariable;
|
||||
}
|
||||
|
||||
export interface RegistryEnvironmentVariable {
|
||||
choices?: string[];
|
||||
default?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
isRequired?: boolean;
|
||||
isSecret?: boolean;
|
||||
name: string;
|
||||
value?: string;
|
||||
variables?: RegistryVariables;
|
||||
}
|
||||
|
||||
export interface RegistryPackageArgument {
|
||||
choices?: string[];
|
||||
default?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
isRepeated?: boolean;
|
||||
isRequired?: boolean;
|
||||
isSecret?: boolean;
|
||||
name: string;
|
||||
type?: string;
|
||||
value?: string;
|
||||
valueHint?: string;
|
||||
variables?: RegistryVariables;
|
||||
}
|
||||
|
||||
export interface RegistryTransportHeader {
|
||||
choices?: string[];
|
||||
default?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
isRequired?: boolean;
|
||||
isSecret?: boolean;
|
||||
name: string;
|
||||
value?: string;
|
||||
variables?: RegistryVariables;
|
||||
}
|
||||
|
||||
export interface RegistryTransport {
|
||||
headers?: RegistryTransportHeader[];
|
||||
type: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface RegistryPackage {
|
||||
environmentVariables?: RegistryEnvironmentVariable[];
|
||||
fileSha256?: string;
|
||||
identifier: string;
|
||||
packageArguments?: RegistryPackageArgument[];
|
||||
registryBaseUrl?: string;
|
||||
registryType: string;
|
||||
runtimeArguments?: RegistryPackageArgument[];
|
||||
runtimeHint?: string;
|
||||
transport?: RegistryTransport;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface RegistryRemote {
|
||||
headers?: RegistryTransportHeader[];
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RegistryRepository {
|
||||
id?: string;
|
||||
source?: string;
|
||||
subfolder?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface RegistryIcon {
|
||||
mimeType: string;
|
||||
sizes?: string[];
|
||||
src: string;
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
export interface RegistryServerData {
|
||||
$schema?: string;
|
||||
_meta?: {
|
||||
'io.modelcontextprotocol.registry/publisher-provided'?: Record<string, any>;
|
||||
};
|
||||
description: string;
|
||||
icons?: RegistryIcon[];
|
||||
name: string;
|
||||
packages?: RegistryPackage[];
|
||||
remotes?: RegistryRemote[];
|
||||
repository?: RegistryRepository;
|
||||
title: string;
|
||||
version: string;
|
||||
websiteUrl?: string;
|
||||
}
|
||||
|
||||
export interface RegistryOfficialMeta {
|
||||
isLatest?: boolean;
|
||||
publishedAt?: string;
|
||||
status?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface RegistryServerEntry {
|
||||
_meta?: {
|
||||
'io.modelcontextprotocol.registry/official'?: RegistryOfficialMeta;
|
||||
};
|
||||
server: RegistryServerData;
|
||||
}
|
||||
|
||||
export interface RegistryMetadata {
|
||||
count: number;
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface RegistryServersResponse {
|
||||
metadata: RegistryMetadata;
|
||||
servers: RegistryServerEntry[];
|
||||
}
|
||||
|
||||
export interface RegistryServerVersionsResponse {
|
||||
metadata: RegistryMetadata;
|
||||
servers: RegistryServerEntry[];
|
||||
}
|
||||
|
||||
export interface RegistryServerVersionResponse {
|
||||
_meta?: {
|
||||
'io.modelcontextprotocol.registry/official'?: RegistryOfficialMeta;
|
||||
};
|
||||
server: RegistryServerData;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"addServer": "Add Server",
|
||||
"add": "Add",
|
||||
"edit": "Edit",
|
||||
"copy": "Copy",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Are you sure you want to delete this server?",
|
||||
"deleteWarning": "Deleting server '{{name}}' will remove it and all its data. This action cannot be undone.",
|
||||
@@ -125,7 +124,6 @@
|
||||
"argumentsPlaceholder": "Enter arguments",
|
||||
"errorDetails": "Error Details",
|
||||
"viewErrorDetails": "View error details",
|
||||
"copyConfig": "Copy Configuration",
|
||||
"confirmVariables": "Confirm Variable Configuration",
|
||||
"variablesDetected": "Variables detected in configuration. Please confirm these variables are properly configured:",
|
||||
"detectedVariables": "Detected Variables",
|
||||
@@ -202,7 +200,6 @@
|
||||
"copyJson": "Copy JSON",
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyFailed": "Copy failed",
|
||||
"copied": "Copied",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"language": "Language",
|
||||
@@ -211,15 +208,7 @@
|
||||
"dismiss": "Dismiss",
|
||||
"github": "GitHub",
|
||||
"wechat": "WeChat",
|
||||
"discord": "Discord",
|
||||
"required": "Required",
|
||||
"secret": "Secret",
|
||||
"default": "Default",
|
||||
"value": "Value",
|
||||
"type": "Type",
|
||||
"repeated": "Repeated",
|
||||
"valueHint": "Value Hint",
|
||||
"choices": "Choices"
|
||||
"discord": "Discord"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -409,41 +398,6 @@
|
||||
"installSuccess": "Server {{name}} installed successfully",
|
||||
"installError": "Failed to install server: {{error}}"
|
||||
},
|
||||
"registry": {
|
||||
"title": "Registry",
|
||||
"official": "Official",
|
||||
"latest": "Latest",
|
||||
"description": "Description",
|
||||
"website": "Website",
|
||||
"repository": "Repository",
|
||||
"packages": "Packages",
|
||||
"package": "package",
|
||||
"remotes": "Remotes",
|
||||
"remote": "remote",
|
||||
"published": "Published",
|
||||
"updated": "Updated",
|
||||
"install": "Install",
|
||||
"installing": "Installing...",
|
||||
"installed": "Installed",
|
||||
"installServer": "Install {{name}}",
|
||||
"installSuccess": "Server {{name}} installed successfully",
|
||||
"installError": "Failed to install server: {{error}}",
|
||||
"noDescription": "No description available",
|
||||
"viewDetails": "View Details",
|
||||
"backToList": "Back to Registry",
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Search registry servers by name",
|
||||
"clearFilters": "Clear",
|
||||
"noServers": "No registry servers found",
|
||||
"fetchError": "Error fetching registry servers",
|
||||
"serverNotFound": "Registry server not found",
|
||||
"showing": "Showing {{from}}-{{to}} of {{total}} registry servers",
|
||||
"perPage": "Per page",
|
||||
"environmentVariables": "Environment Variables",
|
||||
"packageArguments": "Package Arguments",
|
||||
"runtimeArguments": "Runtime Arguments",
|
||||
"headers": "Headers"
|
||||
},
|
||||
"tool": {
|
||||
"run": "Run",
|
||||
"running": "Running...",
|
||||
@@ -548,14 +502,7 @@
|
||||
"systemSettings": "System Settings",
|
||||
"nameSeparatorLabel": "Name Separator",
|
||||
"nameSeparatorDescription": "Character used to separate server name and tool/prompt name (default: -)",
|
||||
"restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly.",
|
||||
"exportMcpSettings": "Export Settings",
|
||||
"mcpSettingsJson": "MCP Settings JSON",
|
||||
"mcpSettingsJsonDescription": "View, copy, or download your current mcp_settings.json configuration for backup or migration to other tools",
|
||||
"copyToClipboard": "Copy to Clipboard",
|
||||
"downloadJson": "Download JSON",
|
||||
"exportSuccess": "Settings exported successfully",
|
||||
"exportError": "Failed to fetch settings"
|
||||
"restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly."
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "Upload",
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"addServer": "Ajouter un serveur",
|
||||
"add": "Ajouter",
|
||||
"edit": "Modifier",
|
||||
"copy": "Copier",
|
||||
"delete": "Supprimer",
|
||||
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce serveur ?",
|
||||
"deleteWarning": "La suppression du serveur '{{name}}' le supprimera ainsi que toutes ses données. Cette action est irréversible.",
|
||||
@@ -125,7 +124,6 @@
|
||||
"argumentsPlaceholder": "Entrez les arguments",
|
||||
"errorDetails": "Détails de l'erreur",
|
||||
"viewErrorDetails": "Voir les détails de l'erreur",
|
||||
"copyConfig": "Copier la configuration",
|
||||
"confirmVariables": "Confirmer la configuration des variables",
|
||||
"variablesDetected": "Variables détectées dans la configuration. Veuillez confirmer que ces variables sont correctement configurées :",
|
||||
"detectedVariables": "Variables détectées",
|
||||
@@ -202,7 +200,6 @@
|
||||
"copyJson": "Copier le JSON",
|
||||
"copySuccess": "Copié dans le presse-papiers",
|
||||
"copyFailed": "Échec de la copie",
|
||||
"copied": "Copié",
|
||||
"close": "Fermer",
|
||||
"confirm": "Confirmer",
|
||||
"language": "Langue",
|
||||
@@ -211,15 +208,7 @@
|
||||
"dismiss": "Rejeter",
|
||||
"github": "GitHub",
|
||||
"wechat": "WeChat",
|
||||
"discord": "Discord",
|
||||
"required": "Requis",
|
||||
"secret": "Secret",
|
||||
"default": "Défaut",
|
||||
"value": "Valeur",
|
||||
"type": "Type",
|
||||
"repeated": "Répété",
|
||||
"valueHint": "Indice de valeur",
|
||||
"choices": "Choix"
|
||||
"discord": "Discord"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
@@ -409,41 +398,6 @@
|
||||
"installSuccess": "Serveur {{name}} installé avec succès",
|
||||
"installError": "Échec de l'installation du serveur : {{error}}"
|
||||
},
|
||||
"registry": {
|
||||
"title": "Registre",
|
||||
"official": "Officiel",
|
||||
"latest": "Dernière version",
|
||||
"description": "Description",
|
||||
"website": "Site web",
|
||||
"repository": "Dépôt",
|
||||
"packages": "Paquets",
|
||||
"package": "paquet",
|
||||
"remotes": "Services distants",
|
||||
"remote": "service distant",
|
||||
"published": "Publié",
|
||||
"updated": "Mis à jour",
|
||||
"install": "Installer",
|
||||
"installing": "Installation...",
|
||||
"installed": "Installé",
|
||||
"installServer": "Installer {{name}}",
|
||||
"installSuccess": "Serveur {{name}} installé avec succès",
|
||||
"installError": "Échec de l'installation du serveur : {{error}}",
|
||||
"noDescription": "Aucune description disponible",
|
||||
"viewDetails": "Voir les détails",
|
||||
"backToList": "Retour au registre",
|
||||
"search": "Rechercher",
|
||||
"searchPlaceholder": "Rechercher des serveurs par nom",
|
||||
"clearFilters": "Effacer",
|
||||
"noServers": "Aucun serveur trouvé dans le registre",
|
||||
"fetchError": "Erreur lors de la récupération des serveurs du registre",
|
||||
"serverNotFound": "Serveur du registre non trouvé",
|
||||
"showing": "Affichage de {{from}}-{{to}} sur {{total}} serveurs du registre",
|
||||
"perPage": "Par page",
|
||||
"environmentVariables": "Variables d'environnement",
|
||||
"packageArguments": "Arguments du paquet",
|
||||
"runtimeArguments": "Arguments d'exécution",
|
||||
"headers": "En-têtes"
|
||||
},
|
||||
"tool": {
|
||||
"run": "Exécuter",
|
||||
"running": "Exécution en cours...",
|
||||
@@ -548,14 +502,7 @@
|
||||
"systemSettings": "Paramètres système",
|
||||
"nameSeparatorLabel": "Séparateur de noms",
|
||||
"nameSeparatorDescription": "Caractère utilisé pour séparer le nom du serveur et le nom de l'outil/prompt (par défaut : -)",
|
||||
"restartRequired": "Configuration enregistrée. Il est recommandé de redémarrer l'application pour s'assurer que tous les services chargent correctement les nouveaux paramètres.",
|
||||
"exportMcpSettings": "Exporter les paramètres",
|
||||
"mcpSettingsJson": "JSON des paramètres MCP",
|
||||
"mcpSettingsJsonDescription": "Afficher, copier ou télécharger votre configuration mcp_settings.json actuelle pour la sauvegarde ou la migration vers d'autres outils",
|
||||
"copyToClipboard": "Copier dans le presse-papiers",
|
||||
"downloadJson": "Télécharger JSON",
|
||||
"exportSuccess": "Paramètres exportés avec succès",
|
||||
"exportError": "Échec de la récupération des paramètres"
|
||||
"restartRequired": "Configuration enregistrée. Il est recommandé de redémarrer l'application pour s'assurer que tous les services chargent correctement les nouveaux paramètres."
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "Télécharger",
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"addServer": "添加服务器",
|
||||
"add": "添加",
|
||||
"edit": "编辑",
|
||||
"copy": "复制",
|
||||
"delete": "删除",
|
||||
"confirmDelete": "您确定要删除此服务器吗?",
|
||||
"deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。",
|
||||
@@ -125,7 +124,6 @@
|
||||
"argumentsPlaceholder": "请输入参数",
|
||||
"errorDetails": "错误详情",
|
||||
"viewErrorDetails": "查看错误详情",
|
||||
"copyConfig": "复制配置",
|
||||
"confirmVariables": "确认变量配置",
|
||||
"variablesDetected": "检测到配置中包含变量,请确认这些变量是否已正确配置:",
|
||||
"detectedVariables": "检测到的变量",
|
||||
@@ -203,7 +201,6 @@
|
||||
"copyJson": "复制JSON",
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyFailed": "复制失败",
|
||||
"copied": "已复制",
|
||||
"close": "关闭",
|
||||
"confirm": "确认",
|
||||
"language": "语言",
|
||||
@@ -212,15 +209,7 @@
|
||||
"dismiss": "忽略",
|
||||
"github": "GitHub",
|
||||
"wechat": "微信",
|
||||
"discord": "Discord",
|
||||
"required": "必填",
|
||||
"secret": "敏感",
|
||||
"default": "默认值",
|
||||
"value": "值",
|
||||
"type": "类型",
|
||||
"repeated": "可重复",
|
||||
"valueHint": "值提示",
|
||||
"choices": "可选值"
|
||||
"discord": "Discord"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
@@ -410,41 +399,6 @@
|
||||
"installSuccess": "服务器 {{name}} 安装成功",
|
||||
"installError": "安装服务器失败:{{error}}"
|
||||
},
|
||||
"registry": {
|
||||
"title": "注册中心",
|
||||
"official": "官方",
|
||||
"latest": "最新版本",
|
||||
"description": "描述",
|
||||
"website": "网站",
|
||||
"repository": "代码仓库",
|
||||
"packages": "安装包",
|
||||
"package": "安装包",
|
||||
"remotes": "远程服务",
|
||||
"remote": "远程服务",
|
||||
"published": "发布时间",
|
||||
"updated": "更新时间",
|
||||
"install": "安装",
|
||||
"installing": "安装中...",
|
||||
"installed": "已安装",
|
||||
"installServer": "安装 {{name}}",
|
||||
"installSuccess": "服务器 {{name}} 安装成功",
|
||||
"installError": "安装服务器失败:{{error}}",
|
||||
"noDescription": "无描述信息",
|
||||
"viewDetails": "查看详情",
|
||||
"backToList": "返回注册中心",
|
||||
"search": "搜索",
|
||||
"searchPlaceholder": "按名称搜索注册中心服务器",
|
||||
"clearFilters": "清除",
|
||||
"noServers": "未找到注册中心服务器",
|
||||
"fetchError": "获取注册中心服务器失败",
|
||||
"serverNotFound": "未找到注册中心服务器",
|
||||
"showing": "显示 {{from}}-{{to}}/{{total}} 个注册中心服务器",
|
||||
"perPage": "每页显示",
|
||||
"environmentVariables": "环境变量",
|
||||
"packageArguments": "安装包参数",
|
||||
"runtimeArguments": "运行时参数",
|
||||
"headers": "请求头"
|
||||
},
|
||||
"tool": {
|
||||
"run": "运行",
|
||||
"running": "运行中...",
|
||||
@@ -550,14 +504,7 @@
|
||||
"systemSettings": "系统设置",
|
||||
"nameSeparatorLabel": "名称分隔符",
|
||||
"nameSeparatorDescription": "用于分隔服务器名称和工具/提示名称(默认:-)",
|
||||
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。",
|
||||
"exportMcpSettings": "导出配置",
|
||||
"mcpSettingsJson": "MCP 配置 JSON",
|
||||
"mcpSettingsJsonDescription": "查看、复制或下载当前的 mcp_settings.json 配置,可用于备份或迁移到其他工具",
|
||||
"copyToClipboard": "复制到剪贴板",
|
||||
"downloadJson": "下载 JSON",
|
||||
"exportSuccess": "配置导出成功",
|
||||
"exportError": "获取配置失败"
|
||||
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "上传",
|
||||
|
||||
@@ -42,4 +42,4 @@
|
||||
"isAdmin": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
"dotenv-expand": "^12.0.2",
|
||||
"express": "^4.21.2",
|
||||
"express-validator": "^7.2.1",
|
||||
"i18next": "^25.5.0",
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
@@ -100,6 +99,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^9.2.0",
|
||||
"eslint": "^8.57.1",
|
||||
"i18next": "^25.5.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-node": "^30.0.5",
|
||||
|
||||
481
pnpm-lock.yaml
generated
481
pnpm-lock.yaml
generated
@@ -57,9 +57,6 @@ importers:
|
||||
express-validator:
|
||||
specifier: ^7.2.1
|
||||
version: 7.2.1
|
||||
i18next:
|
||||
specifier: ^25.5.0
|
||||
version: 25.5.0(typescript@5.9.2)
|
||||
i18next-fs-backend:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
@@ -117,7 +114,7 @@ importers:
|
||||
version: 4.1.14
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.12
|
||||
version: 4.1.12(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5))
|
||||
version: 4.1.12(vite@6.3.6(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5))
|
||||
'@types/bcryptjs':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
@@ -156,7 +153,7 @@ importers:
|
||||
version: 6.21.0(eslint@8.57.1)(typescript@5.9.2)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5))
|
||||
version: 4.7.0(vite@6.3.6(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5))
|
||||
autoprefixer:
|
||||
specifier: ^10.4.21
|
||||
version: 10.4.21(postcss@8.5.6)
|
||||
@@ -172,6 +169,9 @@ importers:
|
||||
eslint:
|
||||
specifier: ^8.57.1
|
||||
version: 8.57.1
|
||||
i18next:
|
||||
specifier: ^25.5.0
|
||||
version: 25.5.0(typescript@5.9.2)
|
||||
i18next-browser-languagedetector:
|
||||
specifier: ^8.2.0
|
||||
version: 8.2.0
|
||||
@@ -234,7 +234,7 @@ importers:
|
||||
version: 5.9.2
|
||||
vite:
|
||||
specifier: ^6.3.5
|
||||
version: 6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)
|
||||
version: 6.3.6(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
@@ -480,312 +480,156 @@ packages:
|
||||
'@emnapi/wasi-threads@1.1.0':
|
||||
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.11':
|
||||
resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.9':
|
||||
resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.25.11':
|
||||
resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.25.9':
|
||||
resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.25.11':
|
||||
resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.25.9':
|
||||
resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.25.11':
|
||||
resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.25.9':
|
||||
resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.11':
|
||||
resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.9':
|
||||
resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.25.11':
|
||||
resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.25.9':
|
||||
resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.25.11':
|
||||
resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.25.9':
|
||||
resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.25.11':
|
||||
resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.25.9':
|
||||
resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.11':
|
||||
resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.9':
|
||||
resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.11':
|
||||
resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.9':
|
||||
resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.11':
|
||||
resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.9':
|
||||
resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.25.11':
|
||||
resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.25.9':
|
||||
resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.25.11':
|
||||
resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.25.9':
|
||||
resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.11':
|
||||
resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.9':
|
||||
resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.11':
|
||||
resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.9':
|
||||
resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.25.11':
|
||||
resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/sunos-x64@0.25.9':
|
||||
resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.25.11':
|
||||
resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.25.9':
|
||||
resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.25.11':
|
||||
resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.25.9':
|
||||
resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1313,113 +1157,108 @@ packages:
|
||||
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.52.5':
|
||||
resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==}
|
||||
'@rollup/rollup-android-arm-eabi@4.50.1':
|
||||
resolution: {integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.52.5':
|
||||
resolution: {integrity: sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==}
|
||||
'@rollup/rollup-android-arm64@4.50.1':
|
||||
resolution: {integrity: sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.52.5':
|
||||
resolution: {integrity: sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==}
|
||||
'@rollup/rollup-darwin-arm64@4.50.1':
|
||||
resolution: {integrity: sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.52.5':
|
||||
resolution: {integrity: sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==}
|
||||
'@rollup/rollup-darwin-x64@4.50.1':
|
||||
resolution: {integrity: sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.52.5':
|
||||
resolution: {integrity: sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==}
|
||||
'@rollup/rollup-freebsd-arm64@4.50.1':
|
||||
resolution: {integrity: sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.52.5':
|
||||
resolution: {integrity: sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==}
|
||||
'@rollup/rollup-freebsd-x64@4.50.1':
|
||||
resolution: {integrity: sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.52.5':
|
||||
resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==}
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.50.1':
|
||||
resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.52.5':
|
||||
resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==}
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.50.1':
|
||||
resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.52.5':
|
||||
resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==}
|
||||
'@rollup/rollup-linux-arm64-gnu@4.50.1':
|
||||
resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.52.5':
|
||||
resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==}
|
||||
'@rollup/rollup-linux-arm64-musl@4.50.1':
|
||||
resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.52.5':
|
||||
resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==}
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.50.1':
|
||||
resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.52.5':
|
||||
resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==}
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.50.1':
|
||||
resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.52.5':
|
||||
resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==}
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.50.1':
|
||||
resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.52.5':
|
||||
resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==}
|
||||
'@rollup/rollup-linux-riscv64-musl@4.50.1':
|
||||
resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.52.5':
|
||||
resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==}
|
||||
'@rollup/rollup-linux-s390x-gnu@4.50.1':
|
||||
resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.52.5':
|
||||
resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==}
|
||||
'@rollup/rollup-linux-x64-gnu@4.50.1':
|
||||
resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.52.5':
|
||||
resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==}
|
||||
'@rollup/rollup-linux-x64-musl@4.50.1':
|
||||
resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.52.5':
|
||||
resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==}
|
||||
'@rollup/rollup-openharmony-arm64@4.50.1':
|
||||
resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.52.5':
|
||||
resolution: {integrity: sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==}
|
||||
'@rollup/rollup-win32-arm64-msvc@4.50.1':
|
||||
resolution: {integrity: sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.52.5':
|
||||
resolution: {integrity: sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==}
|
||||
'@rollup/rollup-win32-ia32-msvc@4.50.1':
|
||||
resolution: {integrity: sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.52.5':
|
||||
resolution: {integrity: sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.52.5':
|
||||
resolution: {integrity: sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==}
|
||||
'@rollup/rollup-win32-x64-msvc@4.50.1':
|
||||
resolution: {integrity: sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -2602,11 +2441,6 @@ packages:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esbuild@0.25.11:
|
||||
resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
esbuild@0.25.9:
|
||||
resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4056,8 +3890,8 @@ packages:
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
hasBin: true
|
||||
|
||||
rollup@4.52.5:
|
||||
resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==}
|
||||
rollup@4.50.1:
|
||||
resolution: {integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -4591,8 +4425,8 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
vite@6.4.1:
|
||||
resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
|
||||
vite@6.3.6:
|
||||
resolution: {integrity: sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -5029,159 +4863,81 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
@@ -5763,70 +5519,67 @@ snapshots:
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.52.5':
|
||||
'@rollup/rollup-android-arm-eabi@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.52.5':
|
||||
'@rollup/rollup-android-arm64@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.52.5':
|
||||
'@rollup/rollup-darwin-arm64@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.52.5':
|
||||
'@rollup/rollup-darwin-x64@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.52.5':
|
||||
'@rollup/rollup-freebsd-arm64@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.52.5':
|
||||
'@rollup/rollup-freebsd-x64@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.52.5':
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.52.5':
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.52.5':
|
||||
'@rollup/rollup-linux-arm64-gnu@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.52.5':
|
||||
'@rollup/rollup-linux-arm64-musl@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.52.5':
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.52.5':
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.52.5':
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.52.5':
|
||||
'@rollup/rollup-linux-riscv64-musl@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.52.5':
|
||||
'@rollup/rollup-linux-s390x-gnu@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.52.5':
|
||||
'@rollup/rollup-linux-x64-gnu@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.52.5':
|
||||
'@rollup/rollup-linux-x64-musl@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.52.5':
|
||||
'@rollup/rollup-openharmony-arm64@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.52.5':
|
||||
'@rollup/rollup-win32-arm64-msvc@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.52.5':
|
||||
'@rollup/rollup-win32-ia32-msvc@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.52.5':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.52.5':
|
||||
'@rollup/rollup-win32-x64-msvc@4.50.1':
|
||||
optional: true
|
||||
|
||||
'@shadcn/ui@0.0.4':
|
||||
@@ -6055,12 +5808,12 @@ snapshots:
|
||||
postcss: 8.5.6
|
||||
tailwindcss: 4.1.14
|
||||
|
||||
'@tailwindcss/vite@4.1.12(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5))':
|
||||
'@tailwindcss/vite@4.1.12(vite@6.3.6(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.1.12
|
||||
'@tailwindcss/oxide': 4.1.12
|
||||
tailwindcss: 4.1.12
|
||||
vite: 6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)
|
||||
vite: 6.3.6(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)
|
||||
|
||||
'@tsconfig/node10@1.0.11': {}
|
||||
|
||||
@@ -6417,7 +6170,7 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||
optional: true
|
||||
|
||||
'@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5))':
|
||||
'@vitejs/plugin-react@4.7.0(vite@6.3.6(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.3
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.3)
|
||||
@@ -6425,7 +6178,7 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.27
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.17.0
|
||||
vite: 6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)
|
||||
vite: 6.3.6(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6974,35 +6727,6 @@ snapshots:
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
|
||||
esbuild@0.25.11:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.11
|
||||
'@esbuild/android-arm': 0.25.11
|
||||
'@esbuild/android-arm64': 0.25.11
|
||||
'@esbuild/android-x64': 0.25.11
|
||||
'@esbuild/darwin-arm64': 0.25.11
|
||||
'@esbuild/darwin-x64': 0.25.11
|
||||
'@esbuild/freebsd-arm64': 0.25.11
|
||||
'@esbuild/freebsd-x64': 0.25.11
|
||||
'@esbuild/linux-arm': 0.25.11
|
||||
'@esbuild/linux-arm64': 0.25.11
|
||||
'@esbuild/linux-ia32': 0.25.11
|
||||
'@esbuild/linux-loong64': 0.25.11
|
||||
'@esbuild/linux-mips64el': 0.25.11
|
||||
'@esbuild/linux-ppc64': 0.25.11
|
||||
'@esbuild/linux-riscv64': 0.25.11
|
||||
'@esbuild/linux-s390x': 0.25.11
|
||||
'@esbuild/linux-x64': 0.25.11
|
||||
'@esbuild/netbsd-arm64': 0.25.11
|
||||
'@esbuild/netbsd-x64': 0.25.11
|
||||
'@esbuild/openbsd-arm64': 0.25.11
|
||||
'@esbuild/openbsd-x64': 0.25.11
|
||||
'@esbuild/openharmony-arm64': 0.25.11
|
||||
'@esbuild/sunos-x64': 0.25.11
|
||||
'@esbuild/win32-arm64': 0.25.11
|
||||
'@esbuild/win32-ia32': 0.25.11
|
||||
'@esbuild/win32-x64': 0.25.11
|
||||
|
||||
esbuild@0.25.9:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.9
|
||||
@@ -8655,32 +8379,31 @@ snapshots:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
|
||||
rollup@4.52.5:
|
||||
rollup@4.50.1:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.52.5
|
||||
'@rollup/rollup-android-arm64': 4.52.5
|
||||
'@rollup/rollup-darwin-arm64': 4.52.5
|
||||
'@rollup/rollup-darwin-x64': 4.52.5
|
||||
'@rollup/rollup-freebsd-arm64': 4.52.5
|
||||
'@rollup/rollup-freebsd-x64': 4.52.5
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.52.5
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.52.5
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.52.5
|
||||
'@rollup/rollup-linux-arm64-musl': 4.52.5
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.52.5
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.52.5
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.52.5
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.52.5
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.52.5
|
||||
'@rollup/rollup-linux-x64-gnu': 4.52.5
|
||||
'@rollup/rollup-linux-x64-musl': 4.52.5
|
||||
'@rollup/rollup-openharmony-arm64': 4.52.5
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.52.5
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.52.5
|
||||
'@rollup/rollup-win32-x64-gnu': 4.52.5
|
||||
'@rollup/rollup-win32-x64-msvc': 4.52.5
|
||||
'@rollup/rollup-android-arm-eabi': 4.50.1
|
||||
'@rollup/rollup-android-arm64': 4.50.1
|
||||
'@rollup/rollup-darwin-arm64': 4.50.1
|
||||
'@rollup/rollup-darwin-x64': 4.50.1
|
||||
'@rollup/rollup-freebsd-arm64': 4.50.1
|
||||
'@rollup/rollup-freebsd-x64': 4.50.1
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.50.1
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.50.1
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.50.1
|
||||
'@rollup/rollup-linux-arm64-musl': 4.50.1
|
||||
'@rollup/rollup-linux-loongarch64-gnu': 4.50.1
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.50.1
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.50.1
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.50.1
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.50.1
|
||||
'@rollup/rollup-linux-x64-gnu': 4.50.1
|
||||
'@rollup/rollup-linux-x64-musl': 4.50.1
|
||||
'@rollup/rollup-openharmony-arm64': 4.50.1
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.50.1
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.50.1
|
||||
'@rollup/rollup-win32-x64-msvc': 4.50.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
router@2.2.0:
|
||||
@@ -9244,13 +8967,13 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite@6.4.1(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5):
|
||||
vite@6.3.6(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.5):
|
||||
dependencies:
|
||||
esbuild: 0.25.11
|
||||
esbuild: 0.25.9
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.6
|
||||
rollup: 4.52.5
|
||||
rollup: 4.50.1
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 24.6.2
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import dotenv from 'dotenv'
|
||||
import fs from 'fs'
|
||||
import { McpSettings, IUser } from '../types/index.js'
|
||||
import { getConfigFilePath } from '../utils/path.js'
|
||||
import { getPackageVersion } from '../utils/version.js'
|
||||
import { getDataService } from '../services/services.js'
|
||||
import { DataService } from '../services/dataService.js'
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import { McpSettings, IUser } from '../types/index.js';
|
||||
import { getConfigFilePath } from '../utils/path.js';
|
||||
import { getPackageVersion } from '../utils/version.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
|
||||
dotenv.config()
|
||||
dotenv.config();
|
||||
|
||||
const defaultConfig = {
|
||||
port: process.env.PORT || 3000,
|
||||
@@ -15,74 +15,70 @@ const defaultConfig = {
|
||||
readonly: 'true' === process.env.READONLY || false,
|
||||
mcpHubName: 'mcphub',
|
||||
mcpHubVersion: getPackageVersion(),
|
||||
}
|
||||
};
|
||||
|
||||
const dataService: DataService = getDataService()
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
// Settings cache
|
||||
let settingsCache: McpSettings | null = null
|
||||
let settingsCache: McpSettings | null = null;
|
||||
|
||||
export const getSettingsPath = (): string => {
|
||||
return getConfigFilePath('mcp_settings.json', 'Settings')
|
||||
}
|
||||
return getConfigFilePath('mcp_settings.json', 'Settings');
|
||||
};
|
||||
|
||||
export const loadOriginalSettings = (): McpSettings => {
|
||||
// If cache exists, return cached data directly
|
||||
if (settingsCache) {
|
||||
return settingsCache
|
||||
}
|
||||
|
||||
const settingsPath = getSettingsPath()
|
||||
// check if file exists
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
console.warn(`Settings file not found at ${settingsPath}, using default settings.`)
|
||||
const defaultSettings = { mcpServers: {}, users: [] }
|
||||
// Cache default settings
|
||||
settingsCache = defaultSettings
|
||||
return defaultSettings
|
||||
return settingsCache;
|
||||
}
|
||||
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
// Read and parse settings file
|
||||
const settingsData = fs.readFileSync(settingsPath, 'utf8')
|
||||
const settings = JSON.parse(settingsData)
|
||||
const settingsData = fs.readFileSync(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(settingsData);
|
||||
|
||||
// Update cache
|
||||
settingsCache = settings
|
||||
settingsCache = settings;
|
||||
|
||||
console.log(`Loaded settings from ${settingsPath}`)
|
||||
return settings
|
||||
console.log(`Loaded settings from ${settingsPath}`);
|
||||
return settings;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load settings from ${settingsPath}: ${error}`)
|
||||
console.error(`Failed to load settings from ${settingsPath}:`, error);
|
||||
const defaultSettings = { mcpServers: {}, users: [] };
|
||||
|
||||
// Cache default settings
|
||||
settingsCache = defaultSettings;
|
||||
|
||||
return defaultSettings;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const loadSettings = (user?: IUser): McpSettings => {
|
||||
return dataService.filterSettings!(loadOriginalSettings(), user)
|
||||
}
|
||||
return dataService.filterSettings!(loadOriginalSettings(), user);
|
||||
};
|
||||
|
||||
export const saveSettings = (settings: McpSettings, user?: IUser): boolean => {
|
||||
const settingsPath = getSettingsPath()
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings, user)
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8')
|
||||
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings, user);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
|
||||
|
||||
// Update cache after successful save
|
||||
settingsCache = mergedSettings
|
||||
settingsCache = mergedSettings;
|
||||
|
||||
return true
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to save settings to ${settingsPath}:`, error)
|
||||
return false
|
||||
console.error(`Failed to save settings to ${settingsPath}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear settings cache, force next loadSettings call to re-read from file
|
||||
*/
|
||||
export const clearSettingsCache = (): void => {
|
||||
settingsCache = null
|
||||
}
|
||||
settingsCache = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current cache status (for debugging)
|
||||
@@ -90,60 +86,60 @@ export const clearSettingsCache = (): void => {
|
||||
export const getSettingsCacheInfo = (): { hasCache: boolean } => {
|
||||
return {
|
||||
hasCache: settingsCache !== null,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function replaceEnvVars(input: Record<string, any>): Record<string, any>
|
||||
export function replaceEnvVars(input: string[] | undefined): string[]
|
||||
export function replaceEnvVars(input: string): string
|
||||
export function replaceEnvVars(input: Record<string, any>): Record<string, any>;
|
||||
export function replaceEnvVars(input: string[] | undefined): string[];
|
||||
export function replaceEnvVars(input: string): string;
|
||||
export function replaceEnvVars(
|
||||
input: Record<string, any> | string[] | string | undefined,
|
||||
): Record<string, any> | string[] | string {
|
||||
// Handle object input
|
||||
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
||||
const res: Record<string, string> = {}
|
||||
const res: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (typeof value === 'string') {
|
||||
res[key] = expandEnvVars(value)
|
||||
res[key] = expandEnvVars(value);
|
||||
} else {
|
||||
res[key] = String(value)
|
||||
res[key] = String(value);
|
||||
}
|
||||
}
|
||||
return res
|
||||
return res;
|
||||
}
|
||||
|
||||
// Handle array input
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => expandEnvVars(item))
|
||||
return input.map((item) => expandEnvVars(item));
|
||||
}
|
||||
|
||||
// Handle string input
|
||||
if (typeof input === 'string') {
|
||||
return expandEnvVars(input)
|
||||
return expandEnvVars(input);
|
||||
}
|
||||
|
||||
// Handle undefined/null array input
|
||||
if (input === undefined || input === null) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
return input
|
||||
return input;
|
||||
}
|
||||
|
||||
export const expandEnvVars = (value: string): string => {
|
||||
if (typeof value !== 'string') {
|
||||
return String(value)
|
||||
return String(value);
|
||||
}
|
||||
// Replace ${VAR} format
|
||||
let result = value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '')
|
||||
let result = value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '');
|
||||
// Also replace $VAR format (common on Unix-like systems)
|
||||
result = result.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, key) => process.env[key] || '')
|
||||
return result
|
||||
}
|
||||
result = result.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, key) => process.env[key] || '');
|
||||
return result;
|
||||
};
|
||||
|
||||
export default defaultConfig
|
||||
export default defaultConfig;
|
||||
|
||||
export function getNameSeparator(): string {
|
||||
const settings = loadSettings()
|
||||
return settings.systemConfig?.nameSeparator || '-'
|
||||
const settings = loadSettings();
|
||||
return settings.systemConfig?.nameSeparator || '-';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import config from '../config/index.js';
|
||||
import { loadSettings, loadOriginalSettings } from '../config/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
@@ -72,46 +72,3 @@ export const getPublicConfig = (req: Request, res: Response): void => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get MCP settings in JSON format for export/copy
|
||||
* Supports both full settings and individual server configuration
|
||||
*/
|
||||
export const getMcpSettingsJson = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { serverName } = req.query;
|
||||
const settings = loadOriginalSettings();
|
||||
if (serverName && typeof serverName === 'string') {
|
||||
// Return individual server configuration
|
||||
const serverConfig = settings.mcpServers[serverName];
|
||||
if (!serverConfig) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: `Server '${serverName}' not found`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
mcpServers: {
|
||||
[serverName]: serverConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Return full settings
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting MCP settings JSON:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get MCP settings',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
|
||||
const REGISTRY_BASE_URL = 'https://registry.modelcontextprotocol.io/v0.1';
|
||||
|
||||
/**
|
||||
* Get all MCP servers from the official registry
|
||||
* Proxies the request to avoid CORS issues in the frontend
|
||||
* Supports cursor-based pagination
|
||||
*/
|
||||
export const getAllRegistryServers = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { cursor, limit, search } = req.query;
|
||||
|
||||
// Build URL with query parameters
|
||||
const url = new URL(`${REGISTRY_BASE_URL}/servers`);
|
||||
if (cursor && typeof cursor === 'string') {
|
||||
url.searchParams.append('cursor', cursor);
|
||||
}
|
||||
if (limit && typeof limit === 'string') {
|
||||
const limitNum = parseInt(limit, 10);
|
||||
if (!isNaN(limitNum) && limitNum > 0) {
|
||||
url.searchParams.append('limit', limit);
|
||||
}
|
||||
}
|
||||
if (search && typeof search === 'string') {
|
||||
url.searchParams.append('search', search);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Accept: 'application/json, application/problem+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const apiResponse: ApiResponse<typeof data> = {
|
||||
success: true,
|
||||
data: data,
|
||||
};
|
||||
|
||||
res.json(apiResponse);
|
||||
} catch (error) {
|
||||
console.error('Error fetching registry servers:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to fetch registry servers';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all versions of a specific MCP server
|
||||
* Proxies the request to avoid CORS issues in the frontend
|
||||
*/
|
||||
export const getRegistryServerVersions = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName } = req.params;
|
||||
|
||||
if (!serverName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// URL encode the server name
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const response = await fetch(`${REGISTRY_BASE_URL}/servers/${encodedName}/versions`, {
|
||||
headers: {
|
||||
Accept: 'application/json, application/problem+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const apiResponse: ApiResponse<typeof data> = {
|
||||
success: true,
|
||||
data: data,
|
||||
};
|
||||
|
||||
res.json(apiResponse);
|
||||
} catch (error) {
|
||||
console.error('Error fetching registry server versions:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to fetch registry server versions';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a specific version of an MCP server
|
||||
* Proxies the request to avoid CORS issues in the frontend
|
||||
*/
|
||||
export const getRegistryServerVersion = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, version } = req.params;
|
||||
|
||||
if (!serverName || !version) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name and version are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// URL encode the server name and version
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const encodedVersion = encodeURIComponent(version);
|
||||
const response = await fetch(
|
||||
`${REGISTRY_BASE_URL}/servers/${encodedName}/versions/${encodedVersion}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json, application/problem+json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server version not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const apiResponse: ApiResponse<typeof data> = {
|
||||
success: true,
|
||||
data: data,
|
||||
};
|
||||
|
||||
res.json(apiResponse);
|
||||
} catch (error) {
|
||||
console.error('Error fetching registry server version:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to fetch registry server version';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -56,18 +56,9 @@ import {
|
||||
getCloudServerToolsList,
|
||||
callCloudTool,
|
||||
} from '../controllers/cloudController.js';
|
||||
import {
|
||||
getAllRegistryServers,
|
||||
getRegistryServerVersions,
|
||||
getRegistryServerVersion,
|
||||
} from '../controllers/registryController.js';
|
||||
import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js';
|
||||
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
|
||||
import {
|
||||
getRuntimeConfig,
|
||||
getPublicConfig,
|
||||
getMcpSettingsJson,
|
||||
} from '../controllers/configController.js';
|
||||
import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js';
|
||||
import { callTool } from '../controllers/toolController.js';
|
||||
import { getPrompt } from '../controllers/promptController.js';
|
||||
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
|
||||
@@ -153,19 +144,11 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.get('/cloud/servers/:serverName/tools', getCloudServerToolsList);
|
||||
router.post('/cloud/servers/:serverName/tools/:toolName/call', callCloudTool);
|
||||
|
||||
// Registry routes (proxy to official MCP registry)
|
||||
router.get('/registry/servers', getAllRegistryServers);
|
||||
router.get('/registry/servers/:serverName/versions', getRegistryServerVersions);
|
||||
router.get('/registry/servers/:serverName/versions/:version', getRegistryServerVersion);
|
||||
|
||||
// Log routes
|
||||
router.get('/logs', getAllLogs);
|
||||
router.delete('/logs', clearLogs);
|
||||
router.get('/logs/stream', streamLogs);
|
||||
|
||||
// MCP settings export route
|
||||
router.get('/mcp-settings/export', getMcpSettingsJson);
|
||||
|
||||
// Auth routes - move to router instead of app directly
|
||||
router.post(
|
||||
'/auth/login',
|
||||
|
||||
@@ -15,26 +15,9 @@ import {
|
||||
} from './services/sseService.js';
|
||||
import { initializeDefaultUser } from './models/User.js';
|
||||
import { sseUserContextMiddleware } from './middlewares/userContext.js';
|
||||
import { findPackageRoot } from './utils/path.js';
|
||||
import { getCurrentModuleDir } from './utils/moduleDir.js';
|
||||
|
||||
/**
|
||||
* Get the directory of the current module
|
||||
* This is wrapped in a function to allow easy mocking in test environments
|
||||
*/
|
||||
function getCurrentFileDir(): string {
|
||||
// In test environments, use process.cwd() to avoid import.meta issues
|
||||
if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined) {
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
try {
|
||||
return getCurrentModuleDir();
|
||||
} catch {
|
||||
// Fallback for environments where import.meta might not be available
|
||||
return process.cwd();
|
||||
}
|
||||
}
|
||||
// Get the current working directory (will be project root in most cases)
|
||||
const currentFileDir = process.cwd() + '/src';
|
||||
|
||||
export class AppServer {
|
||||
private app: express.Application;
|
||||
@@ -184,11 +167,10 @@ export class AppServer {
|
||||
private findFrontendDistPath(): string | null {
|
||||
// Debug flag for detailed logging
|
||||
const debug = process.env.DEBUG === 'true';
|
||||
const currentDir = getCurrentFileDir();
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Current directory:', process.cwd());
|
||||
console.log('DEBUG: Script directory:', currentDir);
|
||||
console.log('DEBUG: Script directory:', currentFileDir);
|
||||
}
|
||||
|
||||
// First, find the package root directory
|
||||
@@ -223,9 +205,51 @@ export class AppServer {
|
||||
|
||||
// Helper method to find the package root (where package.json is located)
|
||||
private findPackageRoot(): string | null {
|
||||
// Use the shared utility function which properly handles ESM module paths
|
||||
const currentDir = getCurrentFileDir();
|
||||
return findPackageRoot(currentDir);
|
||||
const debug = process.env.DEBUG === 'true';
|
||||
|
||||
// Possible locations for package.json
|
||||
const possibleRoots = [
|
||||
// Standard npm package location
|
||||
path.resolve(currentFileDir, '..', '..'),
|
||||
// Current working directory
|
||||
process.cwd(),
|
||||
// When running from dist directory
|
||||
path.resolve(currentFileDir, '..'),
|
||||
// When installed via npx
|
||||
path.resolve(currentFileDir, '..', '..', '..'),
|
||||
];
|
||||
|
||||
// Special handling for npx
|
||||
if (process.argv[1] && process.argv[1].includes('_npx')) {
|
||||
const npxDir = path.dirname(process.argv[1]);
|
||||
possibleRoots.unshift(path.resolve(npxDir, '..'));
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Checking for package.json in:', possibleRoots);
|
||||
}
|
||||
|
||||
for (const root of possibleRoots) {
|
||||
const packageJsonPath = path.join(root, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') {
|
||||
if (debug) {
|
||||
console.log(`DEBUG: Found package.json at ${packageJsonPath}`);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
} catch (e) {
|
||||
if (debug) {
|
||||
console.error(`DEBUG: Failed to parse package.json at ${packageJsonPath}:`, e);
|
||||
}
|
||||
// Continue to the next potential root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,19 @@ export class DataServiceImpl implements DataService {
|
||||
}
|
||||
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, _user?: IUser): McpSettings {
|
||||
return newSettings;
|
||||
// Merge all fields from newSettings into all, preserving fields not present in newSettings
|
||||
return {
|
||||
...all,
|
||||
...newSettings,
|
||||
// Ensure arrays and objects are properly handled
|
||||
users: newSettings.users !== undefined ? newSettings.users : all.users,
|
||||
mcpServers: newSettings.mcpServers !== undefined ? newSettings.mcpServers : all.mcpServers,
|
||||
groups: newSettings.groups !== undefined ? newSettings.groups : all.groups,
|
||||
systemConfig:
|
||||
newSettings.systemConfig !== undefined ? newSettings.systemConfig : all.systemConfig,
|
||||
userConfigs:
|
||||
newSettings.userConfigs !== undefined ? newSettings.userConfigs : all.userConfigs,
|
||||
};
|
||||
}
|
||||
|
||||
getPermissions(_user: IUser): string[] {
|
||||
|
||||
@@ -36,12 +36,17 @@ export class DataServicex implements DataService {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
// Admin users can modify all settings
|
||||
const result = { ...all };
|
||||
result.users = newSettings.users;
|
||||
result.systemConfig = newSettings.systemConfig;
|
||||
result.groups = newSettings.groups;
|
||||
// Merge all fields, using newSettings values when present
|
||||
if (newSettings.users !== undefined) result.users = newSettings.users;
|
||||
if (newSettings.mcpServers !== undefined) result.mcpServers = newSettings.mcpServers;
|
||||
if (newSettings.groups !== undefined) result.groups = newSettings.groups;
|
||||
if (newSettings.systemConfig !== undefined) result.systemConfig = newSettings.systemConfig;
|
||||
if (newSettings.userConfigs !== undefined) result.userConfigs = newSettings.userConfigs;
|
||||
return result;
|
||||
} else {
|
||||
// Non-admin users can only modify their own userConfig
|
||||
const result = JSON.parse(JSON.stringify(all));
|
||||
if (!result.userConfigs) {
|
||||
result.userConfigs = {};
|
||||
|
||||
@@ -14,11 +14,6 @@ export const getMarketServers = (): Record<string, MarketServer> => {
|
||||
const data = fs.readFileSync(serversJsonPath, 'utf8');
|
||||
const serversObj = JSON.parse(data) as Record<string, MarketServer>;
|
||||
|
||||
// use key as name field
|
||||
Object.entries(serversObj).forEach(([key, server]) => {
|
||||
server.name = key;
|
||||
});
|
||||
|
||||
const sortedEntries = Object.entries(serversObj).sort(([, serverA], [, serverB]) => {
|
||||
if (serverA.is_official && !serverB.is_official) return -1;
|
||||
if (!serverA.is_official && serverB.is_official) return 1;
|
||||
|
||||
@@ -243,14 +243,10 @@ const callToolWithReconnect = async (
|
||||
const isHttp40xError = error?.message?.startsWith?.('Error POSTing to endpoint (HTTP 40');
|
||||
// Only retry for StreamableHTTPClientTransport
|
||||
const isStreamableHttp = serverInfo.transport instanceof StreamableHTTPClientTransport;
|
||||
const isSSE = serverInfo.transport instanceof SSEClientTransport;
|
||||
if (
|
||||
attempt < maxRetries &&
|
||||
serverInfo.transport &&
|
||||
((isStreamableHttp && isHttp40xError) || isSSE)
|
||||
) {
|
||||
|
||||
if (isHttp40xError && attempt < maxRetries && serverInfo.transport && isStreamableHttp) {
|
||||
console.warn(
|
||||
`${isHttp40xError ? 'HTTP 40x error' : 'error'} detected for ${isStreamableHttp ? 'StreamableHTTP' : 'SSE'} server ${serverInfo.name}, attempting reconnection (attempt ${attempt + 1}/${maxRetries + 1})`,
|
||||
`HTTP 40x error detected for StreamableHTTP server ${serverInfo.name}, attempting reconnection (attempt ${attempt + 1}/${maxRetries + 1})`,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -1101,7 +1097,9 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
|
||||
const separator = getNameSeparator();
|
||||
const prefix = `${targetServerInfo.name}${separator}`;
|
||||
toolName = toolName.startsWith(prefix) ? toolName.substring(prefix.length) : toolName;
|
||||
toolName = toolName.startsWith(prefix)
|
||||
? toolName.substring(prefix.length)
|
||||
: toolName;
|
||||
const result = await callToolWithReconnect(
|
||||
targetServerInfo,
|
||||
{
|
||||
@@ -1235,7 +1233,9 @@ export const handleGetPromptRequest = async (request: any, extra: any) => {
|
||||
// Remove server prefix from prompt name if present
|
||||
const separator = getNameSeparator();
|
||||
const prefix = `${server.name}${separator}`;
|
||||
const cleanPromptName = name.startsWith(prefix) ? name.substring(prefix.length) : name;
|
||||
const cleanPromptName = name.startsWith(prefix)
|
||||
? name.substring(prefix.length)
|
||||
: name;
|
||||
|
||||
const promptParams = {
|
||||
name: cleanPromptName || '',
|
||||
|
||||
@@ -43,6 +43,7 @@ export function registerService<T>(key: string, entry: Service<T>) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Service registered: ${key} with entry:`, entry);
|
||||
registry.set(key, entry);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Get the directory of the current module
|
||||
* This is in a separate file to allow mocking in test environments
|
||||
*/
|
||||
export function getCurrentModuleDir(): string {
|
||||
const currentModuleFile = fileURLToPath(import.meta.url);
|
||||
return path.dirname(currentModuleFile);
|
||||
}
|
||||
@@ -1,178 +1,10 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { dirname } from 'path';
|
||||
import { getCurrentModuleDir } from './moduleDir.js';
|
||||
|
||||
// Project root directory - use process.cwd() as a simpler alternative
|
||||
const rootDir = process.cwd();
|
||||
|
||||
// Cache the package root for performance
|
||||
let cachedPackageRoot: string | null | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Initialize package root by trying to find it using the module directory
|
||||
* This should be called when the module is first loaded
|
||||
*/
|
||||
function initializePackageRoot(): void {
|
||||
// Skip initialization in test environments
|
||||
if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get the current module's directory
|
||||
const currentModuleDir = getCurrentModuleDir();
|
||||
|
||||
// This file is in src/utils/path.ts (or dist/utils/path.js when compiled)
|
||||
// So package.json should be 2 levels up
|
||||
const possibleRoots = [
|
||||
path.resolve(currentModuleDir, '..', '..'), // dist -> package root
|
||||
path.resolve(currentModuleDir, '..'), // dist/utils -> dist -> package root
|
||||
];
|
||||
|
||||
for (const root of possibleRoots) {
|
||||
const packageJsonPath = path.join(root, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') {
|
||||
cachedPackageRoot = root;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Continue checking
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If initialization fails, cachedPackageRoot remains undefined
|
||||
// and findPackageRoot will search normally
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on module load (unless in test environment)
|
||||
initializePackageRoot();
|
||||
|
||||
/**
|
||||
* Find the package root directory (where package.json is located)
|
||||
* This works correctly when the package is installed globally or locally
|
||||
* @param startPath Starting path to search from (defaults to checking module paths)
|
||||
* @returns The package root directory path, or null if not found
|
||||
*/
|
||||
export const findPackageRoot = (startPath?: string): string | null => {
|
||||
// Return cached value if available and no specific start path is requested
|
||||
if (cachedPackageRoot !== undefined && !startPath) {
|
||||
return cachedPackageRoot;
|
||||
}
|
||||
|
||||
const debug = process.env.DEBUG === 'true';
|
||||
|
||||
// Possible locations for package.json relative to the search path
|
||||
const possibleRoots: string[] = [];
|
||||
|
||||
if (startPath) {
|
||||
// When start path is provided (from fileURLToPath(import.meta.url))
|
||||
possibleRoots.push(
|
||||
// When in dist/utils (compiled code) - go up 2 levels
|
||||
path.resolve(startPath, '..', '..'),
|
||||
// When in dist/ (compiled code) - go up 1 level
|
||||
path.resolve(startPath, '..'),
|
||||
// Direct parent directories
|
||||
path.resolve(startPath)
|
||||
);
|
||||
}
|
||||
|
||||
// Try to use require.resolve to find the module location (works in CommonJS and ESM with createRequire)
|
||||
try {
|
||||
// In ESM, we can use import.meta.resolve, but it's async in some versions
|
||||
// So we'll try to find the module by checking the node_modules structure
|
||||
|
||||
// Check if this file is in a node_modules installation
|
||||
const currentFile = new Error().stack?.split('\n')[2]?.match(/\((.+?):\d+:\d+\)$/)?.[1];
|
||||
if (currentFile) {
|
||||
const nodeModulesIndex = currentFile.indexOf('node_modules');
|
||||
if (nodeModulesIndex !== -1) {
|
||||
// Extract the package path from node_modules
|
||||
const afterNodeModules = currentFile.substring(nodeModulesIndex + 'node_modules'.length + 1);
|
||||
const packageNameEnd = afterNodeModules.indexOf(path.sep);
|
||||
if (packageNameEnd !== -1) {
|
||||
const packagePath = currentFile.substring(0, nodeModulesIndex + 'node_modules'.length + 1 + packageNameEnd);
|
||||
possibleRoots.push(packagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
// Check module.filename location (works in Node.js when available)
|
||||
if (typeof __filename !== 'undefined') {
|
||||
const moduleDir = path.dirname(__filename);
|
||||
possibleRoots.push(
|
||||
path.resolve(moduleDir, '..', '..'),
|
||||
path.resolve(moduleDir, '..')
|
||||
);
|
||||
}
|
||||
|
||||
// Check common installation locations
|
||||
possibleRoots.push(
|
||||
// Current working directory (for development/tests)
|
||||
process.cwd(),
|
||||
// Parent of cwd
|
||||
path.resolve(process.cwd(), '..')
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Searching for package.json from:', startPath || 'multiple locations');
|
||||
console.log('DEBUG: Checking paths:', possibleRoots);
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
const uniqueRoots = [...new Set(possibleRoots)];
|
||||
|
||||
for (const root of uniqueRoots) {
|
||||
const packageJsonPath = path.join(root, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') {
|
||||
if (debug) {
|
||||
console.log(`DEBUG: Found package.json at ${packageJsonPath}`);
|
||||
}
|
||||
// Cache the result if no specific start path was requested
|
||||
if (!startPath) {
|
||||
cachedPackageRoot = root;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to the next potential root
|
||||
if (debug) {
|
||||
console.error(`DEBUG: Failed to parse package.json at ${packageJsonPath}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.warn('DEBUG: Could not find package root directory');
|
||||
}
|
||||
|
||||
// Cache null result as well to avoid repeated searches
|
||||
if (!startPath) {
|
||||
cachedPackageRoot = null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function getParentPath(p: string, filename: string): string {
|
||||
if (p.endsWith(filename)) {
|
||||
p = p.slice(0, -filename.length);
|
||||
}
|
||||
return path.resolve(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the path to a configuration file by checking multiple potential locations.
|
||||
* @param filename The name of the file to locate (e.g., 'servers.json', 'mcp_settings.json')
|
||||
@@ -180,27 +12,9 @@ function getParentPath(p: string, filename: string): string {
|
||||
* @returns The path to the file
|
||||
*/
|
||||
export const getConfigFilePath = (filename: string, description = 'Configuration'): string => {
|
||||
if (filename === 'mcp_settings.json') {
|
||||
const envPath = process.env.MCPHUB_SETTING_PATH;
|
||||
if (envPath) {
|
||||
// Ensure directory exists
|
||||
const dir = getParentPath(envPath, filename);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
console.log(`Created directory for settings at ${dir}`);
|
||||
}
|
||||
|
||||
// if full path, return as is
|
||||
if (envPath?.endsWith(filename)) {
|
||||
return envPath;
|
||||
}
|
||||
|
||||
// if directory, return path under that directory
|
||||
return path.resolve(envPath, filename);
|
||||
}
|
||||
}
|
||||
|
||||
const envPath = process.env.MCPHUB_SETTING_PATH;
|
||||
const potentialPaths = [
|
||||
...(envPath ? [envPath] : []),
|
||||
// Prioritize process.cwd() as the first location to check
|
||||
path.resolve(process.cwd(), filename),
|
||||
// Use path relative to the root directory
|
||||
@@ -209,28 +23,12 @@ export const getConfigFilePath = (filename: string, description = 'Configuration
|
||||
path.join(dirname(rootDir), filename),
|
||||
];
|
||||
|
||||
// Also check in the installed package root directory
|
||||
const packageRoot = findPackageRoot();
|
||||
if (packageRoot) {
|
||||
potentialPaths.push(path.join(packageRoot, filename));
|
||||
}
|
||||
|
||||
for (const filePath of potentialPaths) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
// If all paths do not exist, check if we have a fallback in the package root
|
||||
// If the file exists in the package root, use it as the default
|
||||
if (packageRoot) {
|
||||
const packageConfigPath = path.join(packageRoot, filename);
|
||||
if (fs.existsSync(packageConfigPath)) {
|
||||
console.log(`Using ${description} from package: ${packageConfigPath}`);
|
||||
return packageConfigPath;
|
||||
}
|
||||
}
|
||||
|
||||
// If all paths do not exist, use default path
|
||||
// Using the default path is acceptable because it ensures the application can proceed
|
||||
// even if the configuration file is missing. This fallback is particularly useful in
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { findPackageRoot } from './path.js';
|
||||
|
||||
/**
|
||||
* Gets the package version from package.json
|
||||
* @param searchPath Optional path to start searching from (defaults to cwd)
|
||||
* @returns The version string from package.json, or 'dev' if not found
|
||||
*/
|
||||
export const getPackageVersion = (searchPath?: string): string => {
|
||||
export const getPackageVersion = (): string => {
|
||||
try {
|
||||
// Use provided path or fallback to current working directory
|
||||
const startPath = searchPath || process.cwd();
|
||||
|
||||
const packageRoot = findPackageRoot(startPath);
|
||||
if (!packageRoot) {
|
||||
console.warn('Could not find package root, using default version');
|
||||
return 'dev';
|
||||
}
|
||||
|
||||
const packageJsonPath = path.join(packageRoot, 'package.json');
|
||||
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
|
||||
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
|
||||
const packageJson = JSON.parse(packageJsonContent);
|
||||
return packageJson.version || 'dev';
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { getMcpSettingsJson } from '../../src/controllers/configController.js'
|
||||
import * as config from '../../src/config/index.js'
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
// Mock the config module
|
||||
jest.mock('../../src/config/index.js')
|
||||
|
||||
describe('ConfigController - getMcpSettingsJson', () => {
|
||||
let mockRequest: Partial<Request>
|
||||
let mockResponse: Partial<Response>
|
||||
let mockJson: jest.Mock
|
||||
let mockStatus: jest.Mock
|
||||
|
||||
beforeEach(() => {
|
||||
mockJson = jest.fn()
|
||||
mockStatus = jest.fn().mockReturnThis()
|
||||
mockRequest = {
|
||||
query: {},
|
||||
}
|
||||
mockResponse = {
|
||||
json: mockJson,
|
||||
status: mockStatus,
|
||||
}
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Full Settings Export', () => {
|
||||
it('should handle settings without users array', () => {
|
||||
const mockSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
|
||||
|
||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
||||
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
data: {
|
||||
mcpServers: mockSettings.mcpServers,
|
||||
users: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Individual Server Export', () => {
|
||||
it('should return individual server configuration when serverName is specified', () => {
|
||||
const mockSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
env: {
|
||||
TEST_VAR: 'test-value',
|
||||
},
|
||||
},
|
||||
'another-server': {
|
||||
command: 'another',
|
||||
args: ['--another'],
|
||||
},
|
||||
},
|
||||
users: [
|
||||
{
|
||||
username: 'admin',
|
||||
password: '$2b$10$hashedpassword',
|
||||
isAdmin: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
mockRequest.query = { serverName: 'test-server' }
|
||||
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
|
||||
|
||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
||||
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
data: {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
env: {
|
||||
TEST_VAR: 'test-value',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 404 when server does not exist', () => {
|
||||
const mockSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockRequest.query = { serverName: 'non-existent-server' }
|
||||
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
|
||||
|
||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
||||
|
||||
expect(mockStatus).toHaveBeenCalledWith(404)
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
message: "Server 'non-existent-server' not found",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle errors gracefully and return 500', () => {
|
||||
const errorMessage = 'Failed to load settings'
|
||||
;(config.loadOriginalSettings as jest.Mock).mockImplementation(() => {
|
||||
throw new Error(errorMessage)
|
||||
})
|
||||
|
||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
||||
|
||||
expect(mockStatus).toHaveBeenCalledWith(500)
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
message: 'Failed to get MCP settings',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
182
tests/integration/groupPersistence.test.ts
Normal file
182
tests/integration/groupPersistence.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Integration test for group persistence
|
||||
* This test verifies that groups can be created and persisted through the full stack
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getAllGroups, createGroup, deleteGroup } from '../../src/services/groupService.js';
|
||||
import * as config from '../../src/config/index.js';
|
||||
|
||||
describe('Group Persistence Integration Tests', () => {
|
||||
const testSettingsPath = path.join(__dirname, '..', 'fixtures', 'test_mcp_settings.json');
|
||||
let originalGetConfigFilePath: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Mock getConfigFilePath to use our test settings file
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pathModule = require('../../src/utils/path.js');
|
||||
originalGetConfigFilePath = pathModule.getConfigFilePath;
|
||||
pathModule.getConfigFilePath = (filename: string) => {
|
||||
if (filename === 'mcp_settings.json') {
|
||||
return testSettingsPath;
|
||||
}
|
||||
return originalGetConfigFilePath(filename);
|
||||
};
|
||||
|
||||
// Create test settings file
|
||||
const testSettings = {
|
||||
mcpServers: {
|
||||
'test-server-1': {
|
||||
command: 'echo',
|
||||
args: ['test1'],
|
||||
},
|
||||
'test-server-2': {
|
||||
command: 'echo',
|
||||
args: ['test2'],
|
||||
},
|
||||
},
|
||||
groups: [],
|
||||
users: [{ username: 'admin', password: 'hash', isAdmin: true }],
|
||||
};
|
||||
|
||||
// Ensure fixtures directory exists
|
||||
const fixturesDir = path.dirname(testSettingsPath);
|
||||
if (!fs.existsSync(fixturesDir)) {
|
||||
fs.mkdirSync(fixturesDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(testSettingsPath, JSON.stringify(testSettings, null, 2));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore original function
|
||||
if (originalGetConfigFilePath) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pathModule = require('../../src/utils/path.js');
|
||||
pathModule.getConfigFilePath = originalGetConfigFilePath;
|
||||
}
|
||||
|
||||
// Clean up test file
|
||||
if (fs.existsSync(testSettingsPath)) {
|
||||
fs.unlinkSync(testSettingsPath);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear the settings cache before each test
|
||||
config.clearSettingsCache();
|
||||
|
||||
// Reset test settings file to clean state
|
||||
const testSettings = {
|
||||
mcpServers: {
|
||||
'test-server-1': {
|
||||
command: 'echo',
|
||||
args: ['test1'],
|
||||
},
|
||||
'test-server-2': {
|
||||
command: 'echo',
|
||||
args: ['test2'],
|
||||
},
|
||||
},
|
||||
groups: [],
|
||||
users: [{ username: 'admin', password: 'hash', isAdmin: true }],
|
||||
};
|
||||
|
||||
fs.writeFileSync(testSettingsPath, JSON.stringify(testSettings, null, 2));
|
||||
});
|
||||
|
||||
it('should persist a newly created group to file', () => {
|
||||
// Create a group
|
||||
const groupName = 'integration-test-group';
|
||||
const description = 'Test group for integration testing';
|
||||
const servers = ['test-server-1'];
|
||||
|
||||
const newGroup = createGroup(groupName, description, servers);
|
||||
|
||||
expect(newGroup).not.toBeNull();
|
||||
expect(newGroup?.name).toBe(groupName);
|
||||
|
||||
// Clear cache and reload settings from file
|
||||
config.clearSettingsCache();
|
||||
|
||||
// Verify group was persisted to file
|
||||
const savedSettings = JSON.parse(fs.readFileSync(testSettingsPath, 'utf8'));
|
||||
expect(savedSettings.groups).toHaveLength(1);
|
||||
expect(savedSettings.groups[0].name).toBe(groupName);
|
||||
expect(savedSettings.groups[0].description).toBe(description);
|
||||
expect(savedSettings.groups[0].servers).toHaveLength(1);
|
||||
expect(savedSettings.groups[0].servers[0]).toEqual({ name: 'test-server-1', tools: 'all' });
|
||||
});
|
||||
|
||||
it('should persist multiple groups sequentially', () => {
|
||||
// Create first group
|
||||
const group1 = createGroup('group-1', 'First group', ['test-server-1']);
|
||||
expect(group1).not.toBeNull();
|
||||
|
||||
// Clear cache
|
||||
config.clearSettingsCache();
|
||||
|
||||
// Create second group
|
||||
const group2 = createGroup('group-2', 'Second group', ['test-server-2']);
|
||||
expect(group2).not.toBeNull();
|
||||
|
||||
// Clear cache and verify both groups are persisted
|
||||
config.clearSettingsCache();
|
||||
const savedSettings = JSON.parse(fs.readFileSync(testSettingsPath, 'utf8'));
|
||||
expect(savedSettings.groups).toHaveLength(2);
|
||||
expect(savedSettings.groups[0].name).toBe('group-1');
|
||||
expect(savedSettings.groups[1].name).toBe('group-2');
|
||||
});
|
||||
|
||||
it('should preserve mcpServers when creating groups', () => {
|
||||
// Get initial mcpServers
|
||||
const initialSettings = JSON.parse(fs.readFileSync(testSettingsPath, 'utf8'));
|
||||
const initialServers = initialSettings.mcpServers;
|
||||
|
||||
// Create a group
|
||||
const newGroup = createGroup('test-group', 'Test', ['test-server-1']);
|
||||
expect(newGroup).not.toBeNull();
|
||||
|
||||
// Verify mcpServers are preserved
|
||||
config.clearSettingsCache();
|
||||
const savedSettings = JSON.parse(fs.readFileSync(testSettingsPath, 'utf8'));
|
||||
expect(savedSettings.mcpServers).toEqual(initialServers);
|
||||
expect(savedSettings.groups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should allow deleting a persisted group', () => {
|
||||
// Create a group
|
||||
const newGroup = createGroup('temp-group', 'Temporary', ['test-server-1']);
|
||||
expect(newGroup).not.toBeNull();
|
||||
|
||||
const groupId = newGroup!.id;
|
||||
|
||||
// Verify it's saved
|
||||
config.clearSettingsCache();
|
||||
let savedSettings = JSON.parse(fs.readFileSync(testSettingsPath, 'utf8'));
|
||||
expect(savedSettings.groups).toHaveLength(1);
|
||||
|
||||
// Delete the group
|
||||
const deleted = deleteGroup(groupId);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
// Verify it's deleted from file
|
||||
config.clearSettingsCache();
|
||||
savedSettings = JSON.parse(fs.readFileSync(testSettingsPath, 'utf8'));
|
||||
expect(savedSettings.groups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle empty groups array correctly', () => {
|
||||
// Get all groups when none exist
|
||||
const groups = getAllGroups();
|
||||
expect(groups).toEqual([]);
|
||||
|
||||
// Create a group
|
||||
createGroup('first-group', 'First', ['test-server-1']);
|
||||
|
||||
// Clear cache and get groups again
|
||||
config.clearSettingsCache();
|
||||
const groupsAfterCreate = getAllGroups();
|
||||
expect(groupsAfterCreate).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
225
tests/services/dataServiceMerge.test.ts
Normal file
225
tests/services/dataServiceMerge.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { DataServiceImpl } from '../../src/services/dataService.js';
|
||||
import { DataServicex } from '../../src/services/dataServicex.js';
|
||||
import { McpSettings, IUser } from '../../src/types/index.js';
|
||||
|
||||
describe('DataService mergeSettings', () => {
|
||||
describe('DataServiceImpl', () => {
|
||||
let service: DataServiceImpl;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new DataServiceImpl();
|
||||
});
|
||||
|
||||
it('should merge all fields from newSettings into existing settings', () => {
|
||||
const all: McpSettings = {
|
||||
users: [
|
||||
{ username: 'admin', password: 'hash1', isAdmin: true },
|
||||
{ username: 'user1', password: 'hash2', isAdmin: false },
|
||||
],
|
||||
mcpServers: {
|
||||
'server1': { command: 'cmd1', args: [] },
|
||||
'server2': { command: 'cmd2', args: [] },
|
||||
},
|
||||
groups: [
|
||||
{ id: '1', name: 'group1', servers: [], owner: 'admin' },
|
||||
],
|
||||
systemConfig: {
|
||||
routing: { enableGlobalRoute: true, enableGroupNameRoute: true },
|
||||
},
|
||||
userConfigs: {
|
||||
user1: { routing: { enableGlobalRoute: false, enableGroupNameRoute: false } },
|
||||
},
|
||||
};
|
||||
|
||||
const newSettings: McpSettings = {
|
||||
mcpServers: {},
|
||||
groups: [
|
||||
{ id: '1', name: 'group1', servers: [], owner: 'admin' },
|
||||
{ id: '2', name: 'group2', servers: [], owner: 'admin' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.mergeSettings(all, newSettings);
|
||||
|
||||
// New groups should be present
|
||||
expect(result.groups).toHaveLength(2);
|
||||
expect(result.groups).toEqual(newSettings.groups);
|
||||
|
||||
// Other fields from 'all' should be preserved when not in newSettings
|
||||
expect(result.users).toEqual(all.users);
|
||||
expect(result.systemConfig).toEqual(all.systemConfig);
|
||||
expect(result.userConfigs).toEqual(all.userConfigs);
|
||||
});
|
||||
|
||||
it('should preserve fields not present in newSettings', () => {
|
||||
const all: McpSettings = {
|
||||
users: [{ username: 'admin', password: 'hash', isAdmin: true }],
|
||||
mcpServers: {
|
||||
'server1': { command: 'cmd1', args: [] },
|
||||
},
|
||||
groups: [{ id: '1', name: 'group1', servers: [], owner: 'admin' }],
|
||||
systemConfig: { routing: { enableGlobalRoute: true, enableGroupNameRoute: true } },
|
||||
};
|
||||
|
||||
const newSettings: McpSettings = {
|
||||
mcpServers: {},
|
||||
groups: [
|
||||
{ id: '1', name: 'group1', servers: [], owner: 'admin' },
|
||||
{ id: '2', name: 'group2', servers: [], owner: 'admin' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.mergeSettings(all, newSettings);
|
||||
|
||||
// Groups from newSettings should be present
|
||||
expect(result.groups).toEqual(newSettings.groups);
|
||||
|
||||
// Other fields should be preserved from 'all'
|
||||
expect(result.users).toEqual(all.users);
|
||||
expect(result.systemConfig).toEqual(all.systemConfig);
|
||||
});
|
||||
|
||||
it('should handle undefined fields in newSettings', () => {
|
||||
const all: McpSettings = {
|
||||
users: [{ username: 'admin', password: 'hash', isAdmin: true }],
|
||||
mcpServers: { 'server1': { command: 'cmd1', args: [] } },
|
||||
groups: [{ id: '1', name: 'group1', servers: [], owner: 'admin' }],
|
||||
};
|
||||
|
||||
const newSettings: McpSettings = {
|
||||
mcpServers: {},
|
||||
// groups is undefined
|
||||
};
|
||||
|
||||
const result = service.mergeSettings(all, newSettings);
|
||||
|
||||
// Groups from 'all' should be preserved since newSettings.groups is undefined
|
||||
expect(result.groups).toEqual(all.groups);
|
||||
expect(result.users).toEqual(all.users);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataServicex', () => {
|
||||
let service: DataServicex;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new DataServicex();
|
||||
});
|
||||
|
||||
it('should merge all fields for admin users', () => {
|
||||
const adminUser: IUser = { username: 'admin', password: 'hash', isAdmin: true };
|
||||
|
||||
const all: McpSettings = {
|
||||
users: [adminUser],
|
||||
mcpServers: {
|
||||
'server1': { command: 'cmd1', args: [] },
|
||||
},
|
||||
groups: [{ id: '1', name: 'group1', servers: [], owner: 'admin' }],
|
||||
systemConfig: { routing: { enableGlobalRoute: true, enableGroupNameRoute: true } },
|
||||
};
|
||||
|
||||
const newSettings: McpSettings = {
|
||||
mcpServers: {},
|
||||
groups: [
|
||||
{ id: '1', name: 'group1', servers: [], owner: 'admin' },
|
||||
{ id: '2', name: 'group2', servers: [], owner: 'admin' },
|
||||
],
|
||||
systemConfig: { routing: { enableGlobalRoute: false, enableGroupNameRoute: false } },
|
||||
};
|
||||
|
||||
const result = service.mergeSettings(all, newSettings, adminUser);
|
||||
|
||||
// All fields from newSettings should be merged
|
||||
expect(result.groups).toEqual(newSettings.groups);
|
||||
expect(result.systemConfig).toEqual(newSettings.systemConfig);
|
||||
|
||||
// Users should be preserved from 'all' since not in newSettings
|
||||
expect(result.users).toEqual(all.users);
|
||||
});
|
||||
|
||||
it('should preserve groups for admin users when adding new groups', () => {
|
||||
const adminUser: IUser = { username: 'admin', password: 'hash', isAdmin: true };
|
||||
|
||||
const all: McpSettings = {
|
||||
users: [adminUser],
|
||||
mcpServers: { 'server1': { command: 'cmd1', args: [] } },
|
||||
groups: [{ id: '1', name: 'group1', servers: [], owner: 'admin' }],
|
||||
};
|
||||
|
||||
const newSettings: McpSettings = {
|
||||
mcpServers: {},
|
||||
groups: [
|
||||
{ id: '1', name: 'group1', servers: [], owner: 'admin' },
|
||||
{ id: '2', name: 'group2', servers: [], owner: 'admin' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.mergeSettings(all, newSettings, adminUser);
|
||||
|
||||
// New groups should be present
|
||||
expect(result.groups).toHaveLength(2);
|
||||
expect(result.groups).toEqual(newSettings.groups);
|
||||
});
|
||||
|
||||
it('should handle non-admin users correctly', () => {
|
||||
const regularUser: IUser = { username: 'user1', password: 'hash', isAdmin: false };
|
||||
|
||||
const all: McpSettings = {
|
||||
users: [regularUser],
|
||||
mcpServers: { 'server1': { command: 'cmd1', args: [] } },
|
||||
groups: [{ id: '1', name: 'group1', servers: [], owner: 'admin' }],
|
||||
userConfigs: {},
|
||||
};
|
||||
|
||||
const newSettings: McpSettings = {
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: false,
|
||||
enableGroupNameRoute: false,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = service.mergeSettings(all, newSettings, regularUser);
|
||||
|
||||
// For non-admin users, groups should not change
|
||||
expect(result.groups).toEqual(all.groups);
|
||||
|
||||
// User config should be updated
|
||||
expect(result.userConfigs).toBeDefined();
|
||||
expect(result.userConfigs?.['user1']).toBeDefined();
|
||||
expect(result.userConfigs?.['user1'].routing).toEqual(newSettings.systemConfig?.routing);
|
||||
});
|
||||
|
||||
it('should preserve all fields from original when only updating systemConfig', () => {
|
||||
const adminUser: IUser = { username: 'admin', password: 'hash', isAdmin: true };
|
||||
|
||||
const all: McpSettings = {
|
||||
users: [adminUser],
|
||||
mcpServers: { 'server1': { command: 'cmd1', args: [] } },
|
||||
groups: [{ id: '1', name: 'group1', servers: [], owner: 'admin' }],
|
||||
systemConfig: { routing: { enableGlobalRoute: true, enableGroupNameRoute: true } },
|
||||
};
|
||||
|
||||
const newSettings: McpSettings = {
|
||||
mcpServers: {},
|
||||
systemConfig: { routing: { enableGlobalRoute: false, enableGroupNameRoute: false } },
|
||||
};
|
||||
|
||||
const result = service.mergeSettings(all, newSettings, adminUser);
|
||||
|
||||
// Groups should be preserved from 'all' since not in newSettings
|
||||
expect(result.groups).toEqual(all.groups);
|
||||
// SystemConfig should be updated from newSettings
|
||||
expect(result.systemConfig).toEqual(newSettings.systemConfig);
|
||||
// Users should be preserved from 'all' since not in newSettings
|
||||
expect(result.users).toEqual(all.users);
|
||||
// mcpServers should be updated from newSettings (empty in this case)
|
||||
// This is expected behavior - when mcpServers is explicitly provided, it replaces the old value
|
||||
expect(result.mcpServers).toEqual(newSettings.mcpServers);
|
||||
});
|
||||
});
|
||||
});
|
||||
262
tests/services/groupService.test.ts
Normal file
262
tests/services/groupService.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { createGroup, getAllGroups, deleteGroup } from '../../src/services/groupService.js';
|
||||
import * as config from '../../src/config/index.js';
|
||||
import { McpSettings } from '../../src/types/index.js';
|
||||
|
||||
// Mock the config module
|
||||
jest.mock('../../src/config/index.js', () => {
|
||||
let mockSettings: McpSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: [],
|
||||
},
|
||||
},
|
||||
groups: [],
|
||||
users: [],
|
||||
};
|
||||
|
||||
return {
|
||||
loadSettings: jest.fn(() => mockSettings),
|
||||
saveSettings: jest.fn((settings: McpSettings) => {
|
||||
mockSettings = settings;
|
||||
return true;
|
||||
}),
|
||||
clearSettingsCache: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the mcpService
|
||||
jest.mock('../../src/services/mcpService.js', () => ({
|
||||
notifyToolChanged: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the dataService
|
||||
jest.mock('../../src/services/services.js', () => ({
|
||||
getDataService: jest.fn(() => ({
|
||||
filterData: (data: any[]) => data,
|
||||
filterSettings: (settings: any) => settings,
|
||||
mergeSettings: (all: any, newSettings: any) => newSettings,
|
||||
getPermissions: () => ['*'],
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Group Service', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mocks before each test
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset the mock settings to initial state
|
||||
const mockSettings: McpSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: [],
|
||||
},
|
||||
'test-server-2': {
|
||||
command: 'test2',
|
||||
args: [],
|
||||
},
|
||||
},
|
||||
groups: [],
|
||||
users: [],
|
||||
};
|
||||
|
||||
(config.loadSettings as jest.Mock).mockReturnValue(mockSettings);
|
||||
(config.saveSettings as jest.Mock).mockImplementation((settings: McpSettings) => {
|
||||
mockSettings.groups = settings.groups;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGroup', () => {
|
||||
it('should create a new group and persist it', () => {
|
||||
const groupName = 'test-group';
|
||||
const description = 'Test group description';
|
||||
const servers = ['test-server'];
|
||||
|
||||
const newGroup = createGroup(groupName, description, servers);
|
||||
|
||||
expect(newGroup).not.toBeNull();
|
||||
expect(newGroup?.name).toBe(groupName);
|
||||
expect(newGroup?.description).toBe(description);
|
||||
expect(newGroup?.servers).toHaveLength(1);
|
||||
expect(newGroup?.servers[0]).toEqual({ name: 'test-server', tools: 'all' });
|
||||
|
||||
// Verify saveSettings was called
|
||||
expect(config.saveSettings).toHaveBeenCalled();
|
||||
|
||||
// Verify the settings passed to saveSettings include the new group
|
||||
const savedSettings = (config.saveSettings as jest.Mock).mock.calls[0][0];
|
||||
expect(savedSettings.groups).toHaveLength(1);
|
||||
expect(savedSettings.groups[0].name).toBe(groupName);
|
||||
});
|
||||
|
||||
it('should create a group with multiple servers', () => {
|
||||
const groupName = 'multi-server-group';
|
||||
const servers = ['test-server', 'test-server-2'];
|
||||
|
||||
const newGroup = createGroup(groupName, undefined, servers);
|
||||
|
||||
expect(newGroup).not.toBeNull();
|
||||
expect(newGroup?.servers).toHaveLength(2);
|
||||
expect(newGroup?.servers[0]).toEqual({ name: 'test-server', tools: 'all' });
|
||||
expect(newGroup?.servers[1]).toEqual({ name: 'test-server-2', tools: 'all' });
|
||||
});
|
||||
|
||||
it('should create a group with server configuration objects', () => {
|
||||
const groupName = 'config-group';
|
||||
const servers = [
|
||||
{ name: 'test-server', tools: 'all' },
|
||||
{ name: 'test-server-2', tools: ['tool1', 'tool2'] },
|
||||
];
|
||||
|
||||
const newGroup = createGroup(groupName, undefined, servers);
|
||||
|
||||
expect(newGroup).not.toBeNull();
|
||||
expect(newGroup?.servers).toHaveLength(2);
|
||||
expect(newGroup?.servers[0]).toEqual({ name: 'test-server', tools: 'all' });
|
||||
expect(newGroup?.servers[1]).toEqual({ name: 'test-server-2', tools: ['tool1', 'tool2'] });
|
||||
});
|
||||
|
||||
it('should filter out non-existent servers', () => {
|
||||
const groupName = 'filtered-group';
|
||||
const servers = ['test-server', 'non-existent-server'];
|
||||
|
||||
const newGroup = createGroup(groupName, undefined, servers);
|
||||
|
||||
expect(newGroup).not.toBeNull();
|
||||
expect(newGroup?.servers).toHaveLength(1);
|
||||
expect(newGroup?.servers[0]).toEqual({ name: 'test-server', tools: 'all' });
|
||||
});
|
||||
|
||||
it('should not create a group with duplicate name', () => {
|
||||
const groupName = 'duplicate-group';
|
||||
|
||||
// Create first group
|
||||
const firstGroup = createGroup(groupName, 'First group');
|
||||
expect(firstGroup).not.toBeNull();
|
||||
|
||||
// Update the mock to include the first group
|
||||
const mockSettings: McpSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: [],
|
||||
},
|
||||
},
|
||||
groups: [firstGroup!],
|
||||
users: [],
|
||||
};
|
||||
(config.loadSettings as jest.Mock).mockReturnValue(mockSettings);
|
||||
|
||||
// Try to create second group with same name
|
||||
const secondGroup = createGroup(groupName, 'Second group');
|
||||
expect(secondGroup).toBeNull();
|
||||
});
|
||||
|
||||
it('should set owner to admin by default', () => {
|
||||
const groupName = 'owned-group';
|
||||
|
||||
const newGroup = createGroup(groupName);
|
||||
|
||||
expect(newGroup).not.toBeNull();
|
||||
expect(newGroup?.owner).toBe('admin');
|
||||
});
|
||||
|
||||
it('should set custom owner when provided', () => {
|
||||
const groupName = 'custom-owned-group';
|
||||
const owner = 'testuser';
|
||||
|
||||
const newGroup = createGroup(groupName, undefined, [], owner);
|
||||
|
||||
expect(newGroup).not.toBeNull();
|
||||
expect(newGroup?.owner).toBe(owner);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllGroups', () => {
|
||||
it('should return all groups', () => {
|
||||
const mockSettings: McpSettings = {
|
||||
mcpServers: {},
|
||||
groups: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'group1',
|
||||
servers: [],
|
||||
owner: 'admin',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'group2',
|
||||
servers: [],
|
||||
owner: 'admin',
|
||||
},
|
||||
],
|
||||
users: [],
|
||||
};
|
||||
(config.loadSettings as jest.Mock).mockReturnValue(mockSettings);
|
||||
|
||||
const groups = getAllGroups();
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0].name).toBe('group1');
|
||||
expect(groups[1].name).toBe('group2');
|
||||
});
|
||||
|
||||
it('should return empty array when no groups exist', () => {
|
||||
const mockSettings: McpSettings = {
|
||||
mcpServers: {},
|
||||
users: [],
|
||||
};
|
||||
(config.loadSettings as jest.Mock).mockReturnValue(mockSettings);
|
||||
|
||||
const groups = getAllGroups();
|
||||
|
||||
expect(groups).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteGroup', () => {
|
||||
it('should delete a group by id', () => {
|
||||
const mockSettings: McpSettings = {
|
||||
mcpServers: {},
|
||||
groups: [
|
||||
{
|
||||
id: 'group-to-delete',
|
||||
name: 'Delete Me',
|
||||
servers: [],
|
||||
owner: 'admin',
|
||||
},
|
||||
],
|
||||
users: [],
|
||||
};
|
||||
(config.loadSettings as jest.Mock).mockReturnValue(mockSettings);
|
||||
(config.saveSettings as jest.Mock).mockImplementation((settings: McpSettings) => {
|
||||
mockSettings.groups = settings.groups;
|
||||
return true;
|
||||
});
|
||||
|
||||
const result = deleteGroup('group-to-delete');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(config.saveSettings).toHaveBeenCalled();
|
||||
|
||||
// Verify the settings passed to saveSettings have the group removed
|
||||
const savedSettings = (config.saveSettings as jest.Mock).mock.calls[0][0];
|
||||
expect(savedSettings.groups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return false when group does not exist', () => {
|
||||
const mockSettings: McpSettings = {
|
||||
mcpServers: {},
|
||||
groups: [],
|
||||
users: [],
|
||||
};
|
||||
(config.loadSettings as jest.Mock).mockReturnValue(mockSettings);
|
||||
|
||||
const result = deleteGroup('non-existent-id');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,6 @@ Object.assign(process.env, {
|
||||
DATABASE_URL: 'sqlite::memory:',
|
||||
});
|
||||
|
||||
// Mock moduleDir to avoid import.meta parsing issues in Jest
|
||||
jest.mock('../src/utils/moduleDir.js', () => ({
|
||||
getCurrentModuleDir: jest.fn(() => process.cwd()),
|
||||
}));
|
||||
|
||||
// Global test utilities
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
// Test for CLI path handling functionality
|
||||
import path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
describe('CLI Path Handling', () => {
|
||||
describe('Cross-platform ESM URL conversion', () => {
|
||||
it('should convert Unix-style absolute path to file:// URL', () => {
|
||||
const unixPath = '/home/user/project/dist/index.js';
|
||||
const fileUrl = pathToFileURL(unixPath).href;
|
||||
|
||||
expect(fileUrl).toMatch(/^file:\/\//);
|
||||
expect(fileUrl).toContain('index.js');
|
||||
});
|
||||
|
||||
it('should handle relative paths correctly', () => {
|
||||
const relativePath = path.join(process.cwd(), 'dist', 'index.js');
|
||||
const fileUrl = pathToFileURL(relativePath).href;
|
||||
|
||||
expect(fileUrl).toMatch(/^file:\/\//);
|
||||
expect(fileUrl).toContain('dist');
|
||||
expect(fileUrl).toContain('index.js');
|
||||
});
|
||||
|
||||
it('should produce valid URL format', () => {
|
||||
const testPath = path.join(process.cwd(), 'test', 'file.js');
|
||||
const fileUrl = pathToFileURL(testPath).href;
|
||||
|
||||
// Should be a valid URL
|
||||
expect(() => new URL(fileUrl)).not.toThrow();
|
||||
|
||||
// Should start with file://
|
||||
expect(fileUrl.startsWith('file://')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle paths with spaces', () => {
|
||||
const pathWithSpaces = path.join(process.cwd(), 'my folder', 'dist', 'index.js');
|
||||
const fileUrl = pathToFileURL(pathWithSpaces).href;
|
||||
|
||||
expect(fileUrl).toMatch(/^file:\/\//);
|
||||
expect(fileUrl).toContain('index.js');
|
||||
// Spaces should be URL-encoded
|
||||
expect(fileUrl).toContain('%20');
|
||||
});
|
||||
|
||||
it('should handle paths with special characters', () => {
|
||||
const pathWithSpecialChars = path.join(process.cwd(), 'test@dir', 'file#1.js');
|
||||
const fileUrl = pathToFileURL(pathWithSpecialChars).href;
|
||||
|
||||
expect(fileUrl).toMatch(/^file:\/\//);
|
||||
// Special characters should be URL-encoded
|
||||
expect(() => new URL(fileUrl)).not.toThrow();
|
||||
});
|
||||
|
||||
// Windows-specific path handling simulation
|
||||
it('should handle Windows-style paths correctly', () => {
|
||||
// Simulate a Windows path structure
|
||||
// Note: On non-Windows systems, this creates a relative path,
|
||||
// but the test verifies the conversion mechanism works
|
||||
const mockWindowsPath = 'C:\\Users\\User\\project\\dist\\index.js';
|
||||
|
||||
// On Windows, pathToFileURL would convert C:\ to file:///C:/
|
||||
// On Unix, it treats it as a relative path, but the conversion still works
|
||||
const fileUrl = pathToFileURL(mockWindowsPath).href;
|
||||
|
||||
expect(fileUrl).toMatch(/^file:\/\//);
|
||||
expect(fileUrl).toContain('index.js');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path normalization', () => {
|
||||
it('should normalize path separators', () => {
|
||||
const mixedPath = path.join('dist', 'index.js');
|
||||
const fileUrl = pathToFileURL(path.resolve(mixedPath)).href;
|
||||
|
||||
expect(fileUrl).toMatch(/^file:\/\//);
|
||||
// All separators should be forward slashes in URL
|
||||
expect(fileUrl.split('file://')[1]).not.toContain('\\');
|
||||
});
|
||||
|
||||
it('should handle multiple consecutive slashes', () => {
|
||||
const messyPath = path.normalize('/dist//index.js');
|
||||
const fileUrl = pathToFileURL(path.resolve(messyPath)).href;
|
||||
|
||||
expect(fileUrl).toMatch(/^file:\/\//);
|
||||
expect(() => new URL(fileUrl)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path resolution for CLI use case', () => {
|
||||
it('should convert package root path to valid import URL', () => {
|
||||
const packageRoot = process.cwd();
|
||||
const entryPath = path.join(packageRoot, 'dist', 'index.js');
|
||||
const entryUrl = pathToFileURL(entryPath).href;
|
||||
|
||||
expect(entryUrl).toMatch(/^file:\/\//);
|
||||
expect(entryUrl).toContain('dist');
|
||||
expect(entryUrl).toContain('index.js');
|
||||
expect(() => new URL(entryUrl)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle nested directory structures', () => {
|
||||
const deepPath = path.join(process.cwd(), 'a', 'b', 'c', 'd', 'file.js');
|
||||
const fileUrl = pathToFileURL(deepPath).href;
|
||||
|
||||
expect(fileUrl).toMatch(/^file:\/\//);
|
||||
expect(fileUrl).toContain('file.js');
|
||||
expect(() => new URL(fileUrl)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should produce URL compatible with dynamic import()', () => {
|
||||
// This test verifies the exact pattern used in bin/cli.js
|
||||
const projectRoot = process.cwd();
|
||||
const entryPath = path.join(projectRoot, 'dist', 'index.js');
|
||||
const entryUrl = pathToFileURL(entryPath).href;
|
||||
|
||||
// The URL should be valid for import()
|
||||
expect(entryUrl).toMatch(/^file:\/\//);
|
||||
expect(typeof entryUrl).toBe('string');
|
||||
|
||||
// Verify the URL format is valid
|
||||
const urlObj = new URL(entryUrl);
|
||||
expect(urlObj.protocol).toBe('file:');
|
||||
expect(urlObj.href).toBe(entryUrl);
|
||||
|
||||
// On Windows, pathToFileURL converts 'C:\path' to 'file:///C:/path'
|
||||
// On Unix, it converts '/path' to 'file:///path'
|
||||
// Both formats are valid for dynamic import()
|
||||
expect(entryUrl).toContain('index.js');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user