Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f46e2c22fc | ||
|
|
ae3fe1c6f1 | ||
|
|
76a27a454e | ||
|
|
b15b71f407 | ||
|
|
298d96d593 | ||
|
|
d44886b81b | ||
|
|
bbe44fc540 | ||
|
|
c60b98e3d6 | ||
|
|
a447fe5b41 | ||
|
|
94d51fa03a | ||
|
|
b88a7240c6 | ||
|
|
3c875590ce | ||
|
|
26fa61fcfc | ||
|
|
d689541fc4 | ||
|
|
30895c4b9a | ||
|
|
37c3fd9e06 | ||
|
|
59454ca250 | ||
|
|
63efa0038c | ||
|
|
040782da8d | ||
|
|
f1a5f692cc | ||
|
|
5d798cfe6a | ||
|
|
0490d98c9e | ||
|
|
7af3c8a2ba | ||
|
|
7c43ca359e | ||
|
|
2bb6302cbc | ||
|
|
3a3f6c984c | ||
|
|
e8bc053788 | ||
|
|
bb674236c7 | ||
|
|
0f5dfbe419 | ||
|
|
74d1ca6a87 | ||
|
|
eab421c753 | ||
|
|
886ca44681 | ||
|
|
3d4baeef1a | ||
|
|
4379513a35 | ||
|
|
9a06bae225 | ||
|
|
e5aaae466f | ||
|
|
9b1338a356 | ||
|
|
10d4616601 | ||
|
|
9ca242a0e4 | ||
|
|
0a6259decf | ||
|
|
7887a3a5f9 | ||
|
|
7f33615161 | ||
|
|
c9ec3b77ce | ||
|
|
142c3f628a |
15
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: samanhappy
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
29
.github/ISSUE_TEMPLATE/bug-report---bug-报告.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: Bug Report / Bug 报告
|
||||
about: Create a report to help us improve / 报告问题以帮助改进
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Bug Description / 问题描述**
|
||||
What happened? / 发生了什么?
|
||||
|
||||
**Steps to Reproduce / 复现步骤**
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Expected Behavior / 预期行为**
|
||||
What should happen? / 应该发生什么?
|
||||
|
||||
**Environment / 运行环境**
|
||||
- Running on / 运行方式: [docker/npx/local / docker/npx/本地]
|
||||
- Version / 版本: [e.g. 1.0.0]
|
||||
|
||||
**Screenshots / 截图**
|
||||
If relevant, add screenshots / 如果有帮助的话,请添加截图
|
||||
|
||||
**Additional Info / 补充信息**
|
||||
Any other details? / 还有其他信息吗?
|
||||
20
.github/ISSUE_TEMPLATE/feature-request---功能请求.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request / 功能请求
|
||||
about: Suggest an idea for this project / 为项目提出新想法
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Current Problem / 当前问题**
|
||||
What problem are you trying to solve? / 您想要解决什么问题?
|
||||
|
||||
**Proposed Solution / 建议方案**
|
||||
How would you like this to work? / 您期望的解决方案是什么?
|
||||
|
||||
**Alternatives / 替代方案**
|
||||
Have you considered any alternatives? / 您是否考虑过其他解决方案?
|
||||
|
||||
**Additional Context / 补充说明**
|
||||
Any screenshots, mockups, or relevant information? / 有任何截图、设计图或相关信息吗?
|
||||
12
.github/workflows/build.yml
vendored
@@ -16,6 +16,16 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Update version from tag
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "Updating package.json version to $VERSION"
|
||||
jq ".version = \"$VERSION\"" package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
echo "Updated version in package.json:"
|
||||
grep -m 1 "version" package.json
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -36,7 +46,7 @@ jobs:
|
||||
type=raw,value=latest${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
|
||||
58
.github/workflows/npm-publish.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
name: Publish to NPM
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*.*.*']
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Update version from tag
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "Updating package.json version to $VERSION"
|
||||
jq ".version = \"$VERSION\"" package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
echo "Updated version in package.json:"
|
||||
grep -m 1 "version" package.json
|
||||
|
||||
- name: Build package
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish to NPM
|
||||
run: pnpm publish --no-git-checks --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
1
.github/workflows/release.yml
vendored
@@ -13,6 +13,7 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
|
||||
@@ -2,6 +2,12 @@ FROM python:3.13-slim-bookworm AS base
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
# 添加 HTTP_PROXY 和 HTTPS_PROXY 环境变量
|
||||
ARG HTTP_PROXY=""
|
||||
ARG HTTPS_PROXY=""
|
||||
ENV HTTP_PROXY=$HTTP_PROXY
|
||||
ENV HTTPS_PROXY=$HTTPS_PROXY
|
||||
|
||||
RUN apt-get update && apt-get install -y curl gnupg git \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
@@ -28,7 +34,6 @@ RUN if [ "$INSTALL_EXT" = "true" ]; then \
|
||||
fi
|
||||
|
||||
RUN uv tool install mcp-server-fetch
|
||||
ENV UV_PYTHON_INSTALL_MIRROR="http://mirrors.aliyun.com/pypi/simple/"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
97
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文版](README.zh.md)
|
||||
|
||||
MCPHub is a unified management platform that aggregates multiple MCP (Model Context Protocol) servers into separate SSE endpoints for different scenarios by group. It streamlines your AI tool integrations through an intuitive interface and robust protocol handling.
|
||||
MCPHub is a unified management platform that aggregates multiple MCP (Model Context Protocol) servers into separate Streamable HTTP (SSE) endpoints for different scenarios by group. It streamlines your AI tool integrations through an intuitive interface and robust protocol handling.
|
||||
|
||||

|
||||
|
||||
@@ -68,11 +68,13 @@ Create a `mcp_settings.json` file to customize your server settings:
|
||||
### Docker Deployment
|
||||
|
||||
**Recommended**: Mount your custom config:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
|
||||
```
|
||||
|
||||
or run with default settings:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 samanhappy/mcphub
|
||||
```
|
||||
@@ -80,34 +82,79 @@ docker run -p 3000:3000 samanhappy/mcphub
|
||||
### Access the Dashboard
|
||||
|
||||
Open `http://localhost:3000` and log in with your credentials.
|
||||
|
||||
> **Note**: Default credentials are `admin` / `admin123`.
|
||||
|
||||
**Dashboard Overview**:
|
||||
|
||||
- Live status of all MCP servers
|
||||
- Enable/disable or reconfigure servers
|
||||
- Group management for organizing servers
|
||||
- User administration for access control
|
||||
|
||||
### SSE Endpoint
|
||||
### Streamable HTTP Endpoint
|
||||
|
||||
> As of now, support for streaming HTTP endpoints varies across different AI clients. If you encounter issues, you can use the SSE endpoint or wait for future updates.
|
||||
|
||||
Connect AI clients (e.g., Claude Desktop, Cursor, DeepChat, etc.) via:
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp
|
||||
```
|
||||
|
||||
This endpoint provides a unified streamable HTTP interface for all your MCP servers. It allows you to:
|
||||
|
||||
- Send requests to any configured MCP server
|
||||
- Receive responses in real-time
|
||||
- Easily integrate with various AI clients and tools
|
||||
- Use the same endpoint for all servers, simplifying your integration process
|
||||
|
||||
**Group-Specific Endpoints (Recommended)**:
|
||||
|
||||

|
||||
|
||||
For targeted access to specific server groups, use the group-based HTTP endpoint:
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/{group}
|
||||
```
|
||||
|
||||
Where `{group}` is the ID or name of the group you created in the dashboard. This allows you to:
|
||||
|
||||
- Connect to a specific subset of MCP servers organized by use case
|
||||
- Isolate different AI tools to access only relevant servers
|
||||
- Implement more granular access control for different environments or teams
|
||||
|
||||
**Server-Specific Endpoints**:
|
||||
For direct access to individual servers, use the server-specific HTTP endpoint:
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/{server}
|
||||
```
|
||||
|
||||
Where `{server}` is the name of the server you want to connect to. This allows you to access a specific MCP server directly.
|
||||
|
||||
> **Note**: If the server name and group name are the same, the group name will take precedence.
|
||||
|
||||
### SSE Endpoint (Deprecated in Future)
|
||||
|
||||
Connect AI clients (e.g., Claude Desktop, Cursor, DeepChat, etc.) via:
|
||||
|
||||
Connect AI clients (e.g., Claude Desktop, Cursor, Cherry Studio) via:
|
||||
```
|
||||
http://localhost:3000/sse
|
||||
```
|
||||
|
||||
**Group-Specific Endpoints (Recommended)**:
|
||||
|
||||

|
||||
|
||||
For targeted access to specific server groups, use the group-based SSE endpoint:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{groupId}
|
||||
http://localhost:3000/sse/{group}
|
||||
```
|
||||
|
||||
Where `{groupId}` is the ID of the group you created in the dashboard. This allows you to:
|
||||
- Connect to a specific subset of MCP servers organized by use case
|
||||
- Isolate different AI tools to access only relevant servers
|
||||
- Implement more granular access control for different environments or teams
|
||||
For direct access to individual servers, use the server-specific SSE endpoint:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{server}
|
||||
```
|
||||
|
||||
## 🧑💻 Local Development
|
||||
|
||||
@@ -120,6 +167,18 @@ pnpm dev
|
||||
|
||||
This starts both frontend and backend in development mode with hot-reloading.
|
||||
|
||||
> For windows users, you may need to start the backend server and frontend separately: `pnpm backend:dev`, `pnpm frontend:dev`.
|
||||
|
||||
## 🛠️ Common Issues
|
||||
|
||||
### Using Nginx as a Reverse Proxy
|
||||
|
||||
If you are using Nginx to reverse proxy MCPHub, please make sure to add the following configuration in your Nginx setup:
|
||||
|
||||
```nginx
|
||||
proxy_buffering off
|
||||
```
|
||||
|
||||
## 🔍 Tech Stack
|
||||
|
||||
- **Backend**: Node.js, Express, TypeScript
|
||||
@@ -129,13 +188,25 @@ This starts both frontend and backend in development mode with hot-reloading.
|
||||
|
||||
## 👥 Contributing
|
||||
|
||||
Contributions are welcome!
|
||||
Contributions of any kind are welcome!
|
||||
|
||||
- New features & optimizations
|
||||
- Documentation improvements
|
||||
- Bug reports & fixes
|
||||
- Translations & suggestions
|
||||
|
||||
Welcome to join our [Discord community](https://discord.gg/qMKNsn5Q) for discussions and support.
|
||||
|
||||
## ❤️ Sponsor
|
||||
|
||||
If you like this project, maybe you can consider:
|
||||
|
||||
[](https://ko-fi.com/samanhappy)
|
||||
|
||||
## 🌟 Star History
|
||||
|
||||
[](https://www.star-history.com/#samanhappy/mcphub&Date)
|
||||
|
||||
## 📄 License
|
||||
|
||||
Licensed under the [Apache 2.0 License](LICENSE).
|
||||
|
||||
99
README.zh.md
@@ -2,7 +2,7 @@
|
||||
|
||||
[English Version](README.md) | 中文版
|
||||
|
||||
MCPHub 是一个统一的 MCP(Model Context Protocol,模型上下文协议)服务器聚合平台,可以根据场景将多个服务器聚合到不同的 SSE 端点。它通过直观的界面和强大的协议处理能力,简化了您的 AI 工具集成流程。
|
||||
MCPHub 是一个统一的 MCP(Model Context Protocol,模型上下文协议)服务器聚合平台,可以根据场景将多个服务器聚合到不同的流式 HTTP(SSE)端点。它通过直观的界面和强大的协议处理能力,简化了您的 AI 工具集成流程。
|
||||
|
||||

|
||||
|
||||
@@ -68,11 +68,13 @@ MCPHub 是一个统一的 MCP(Model Context Protocol,模型上下文协议
|
||||
### Docker 部署
|
||||
|
||||
**推荐**:挂载自定义配置:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
|
||||
```
|
||||
|
||||
或使用默认配置运行:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 samanhappy/mcphub
|
||||
```
|
||||
@@ -80,34 +82,79 @@ docker run -p 3000:3000 samanhappy/mcphub
|
||||
### 访问控制台
|
||||
|
||||
打开 `http://localhost:3000`,使用您的账号登录。
|
||||
|
||||
> **提示**:默认用户名/密码为 `admin` / `admin123`。
|
||||
|
||||
**控制台功能**:
|
||||
|
||||
- 实时监控所有 MCP 服务器状态
|
||||
- 启用/禁用或重新配置服务器
|
||||
- 分组管理,组织服务器访问
|
||||
- 用户管理,设定权限
|
||||
|
||||
### SSE 端点集成
|
||||
### 支持流式的 HTTP 端点
|
||||
|
||||
> 截至目前,各家 AI 客户端对流式的 HTTP 端点支持不一,如果遇到问题,可以使用 SSE 端点或者等待更新。
|
||||
|
||||
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、DeepChat 等):
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp
|
||||
```
|
||||
|
||||
这个端点为所有 MCP 服务器提供统一的流式 HTTP 接口。它允许您:
|
||||
|
||||
- 向任何配置的 MCP 服务器发送请求
|
||||
- 实时接收响应
|
||||
- 轻松与各种 AI 客户端和工具集成
|
||||
- 对所有服务器使用相同的端点,简化集成过程
|
||||
|
||||
**基于分组的 HTTP 端点(推荐)**:
|
||||

|
||||
要针对特定服务器分组进行访问,请使用基于分组的 HTTP 端点:
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/{group}
|
||||
```
|
||||
|
||||
其中 `{group}` 是您在控制面板中创建的分组 ID 或名称。这样做可以:
|
||||
|
||||
- 连接到按用例组织的特定 MCP 服务器子集
|
||||
- 隔离不同的 AI 工具,使其只能访问相关服务器
|
||||
- 为不同环境或团队实现更精细的访问控制
|
||||
- 通过分组名称轻松识别和管理服务器
|
||||
- 允许不同的 AI 客户端使用相同的端点,简化集成过程
|
||||
|
||||
**针对特定服务器的 HTTP 端点**:
|
||||
要针对特定服务器进行访问,请使用以下格式:
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/{server}
|
||||
```
|
||||
|
||||
其中 `{server}` 是您要连接的服务器名称。这样做可以直接访问特定的 MCP 服务器。
|
||||
|
||||
> **提示**:如果服务器名称和分组名称相同,则分组名称优先。
|
||||
|
||||
### SSE 端点集成 (未来可能废弃)
|
||||
|
||||
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、DeepChat 等):
|
||||
|
||||
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、Cherry Studio 等):
|
||||
```
|
||||
http://localhost:3000/sse
|
||||
```
|
||||
|
||||
**基于分组的 SSE 端点(推荐)**:
|
||||
|
||||

|
||||
|
||||
要针对特定服务器分组进行访问,请使用基于分组的 SSE 端点:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{groupId}
|
||||
http://localhost:3000/sse/{group}
|
||||
```
|
||||
|
||||
其中 `{groupId}` 是您在控制面板中创建的分组 ID。这样做可以:
|
||||
- 连接到按用例组织的特定 MCP 服务器子集
|
||||
- 隔离不同的 AI 工具,使其只能访问相关服务器
|
||||
- 为不同环境或团队实现更精细的访问控制
|
||||
要针对特定服务器进行访问,请使用以下格式:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{server}
|
||||
```
|
||||
|
||||
## 🧑💻 本地开发
|
||||
|
||||
@@ -120,6 +167,18 @@ pnpm dev
|
||||
|
||||
此命令将在开发模式下启动前后端,并启用热重载。
|
||||
|
||||
> 针对 Windows 用户,可能需要分别启动后端服务器和前端:`pnpm backend:dev`,`pnpm frontend:dev`。
|
||||
|
||||
## 🛠️ 常见问题
|
||||
|
||||
### 使用 nginx 反向代理
|
||||
|
||||
如果您在使用 nginx 反向代理 MCPHub,请确保在 nginx 配置中添加以下内容:
|
||||
|
||||
```nginx
|
||||
proxy_buffering off
|
||||
```
|
||||
|
||||
## 🔍 技术栈
|
||||
|
||||
- **后端**:Node.js、Express、TypeScript
|
||||
@@ -136,9 +195,21 @@ pnpm dev
|
||||
- Bug 报告与修复
|
||||
- 翻译与建议
|
||||
|
||||
欢迎加入企微交流共建群
|
||||
欢迎加入企微交流共建群,由于群人数限制,有兴趣的同学可以扫码添加管理员为好友后拉入群聊。
|
||||
|
||||
<img src="assets/wegroup.png" width="500">
|
||||
<img src="assets/wexin.png" width="350">
|
||||
|
||||
如果觉得项目有帮助,不妨请我喝杯咖啡 ☕️
|
||||
|
||||
<img src="assets/reward.png" width="350">
|
||||
|
||||
## 致谢
|
||||
|
||||
感谢以下人员的赞赏:小白、琛。你们的支持是我继续前进的动力!
|
||||
|
||||
## 🌟 Star 历史趋势
|
||||
|
||||
[](https://www.star-history.com/#samanhappy/mcphub&Date)
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
|
||||
BIN
assets/reward.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
assets/wexin.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
96
bin/cli.js
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
|
||||
// Enable debug logging if needed
|
||||
// process.env.DEBUG = 'true';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Start with more debug information
|
||||
console.log('📋 MCPHub CLI');
|
||||
console.log(`📁 CLI script location: ${__dirname}`);
|
||||
|
||||
// The npm package directory structure when installed is:
|
||||
// node_modules/@samanhappy/mcphub/
|
||||
// - dist/
|
||||
// - bin/
|
||||
// - frontend/dist/
|
||||
|
||||
// Get the package root - this is where package.json is located
|
||||
function findPackageRoot() {
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
|
||||
// Possible locations for package.json
|
||||
const possibleRoots = [
|
||||
// Standard npm package location
|
||||
path.resolve(__dirname, '..'),
|
||||
// When installed via npx
|
||||
path.resolve(__dirname, '..', '..', '..')
|
||||
];
|
||||
|
||||
// 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 (isDebug) {
|
||||
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 (isDebug) {
|
||||
console.log(`DEBUG: Found package.json at ${packageJsonPath}`);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to the next potential root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('⚠️ Could not find package.json, using default path');
|
||||
return path.resolve(__dirname, '..');
|
||||
}
|
||||
|
||||
// Locate and check the frontend distribution
|
||||
function checkFrontend(packageRoot) {
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
const frontendDistPath = path.join(packageRoot, 'frontend', 'dist');
|
||||
|
||||
if (isDebug) {
|
||||
console.log(`DEBUG: Checking frontend at: ${frontendDistPath}`);
|
||||
}
|
||||
|
||||
if (fs.existsSync(frontendDistPath) && fs.existsSync(path.join(frontendDistPath, 'index.html'))) {
|
||||
console.log('✅ Frontend distribution found');
|
||||
return true;
|
||||
} else {
|
||||
console.log('⚠️ Frontend distribution not found at', frontendDistPath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const projectRoot = findPackageRoot();
|
||||
console.log(`📦 Using package root: ${projectRoot}`);
|
||||
|
||||
// Check if frontend exists
|
||||
checkFrontend(projectRoot);
|
||||
|
||||
// Start the server
|
||||
console.log('🚀 Starting MCPHub server...');
|
||||
import(path.join(projectRoot, 'dist', 'index.js')).catch(err => {
|
||||
console.error('Failed to start MCPHub:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
32
docs/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Mintlify Starter Kit
|
||||
|
||||
Click on `Use this template` to copy the Mintlify starter kit. The starter kit contains examples including
|
||||
|
||||
- Guide pages
|
||||
- Navigation
|
||||
- Customizations
|
||||
- API Reference pages
|
||||
- Use of popular components
|
||||
|
||||
### Development
|
||||
|
||||
Install the [Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the documentation changes locally. To install, use the following command
|
||||
|
||||
```
|
||||
npm i -g mintlify
|
||||
```
|
||||
|
||||
Run the following command at the root of your documentation (where docs.json is)
|
||||
|
||||
```
|
||||
mintlify dev
|
||||
```
|
||||
|
||||
### Publishing Changes
|
||||
|
||||
Install our Github App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
|
||||
- Page loads as a 404 - Make sure you are running in a folder with `docs.json`
|
||||
4
docs/api-reference/endpoint/create.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: 'Create Plant'
|
||||
openapi: 'POST /plants'
|
||||
---
|
||||
4
docs/api-reference/endpoint/delete.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: 'Delete Plant'
|
||||
openapi: 'DELETE /plants/{id}'
|
||||
---
|
||||
4
docs/api-reference/endpoint/get.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: 'Get Plants'
|
||||
openapi: 'GET /plants'
|
||||
---
|
||||
4
docs/api-reference/endpoint/webhook.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: 'New Plant'
|
||||
openapi: 'WEBHOOK /plant/webhook'
|
||||
---
|
||||
33
docs/api-reference/introduction.mdx
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: 'Introduction'
|
||||
description: 'Example section for showcasing API endpoints'
|
||||
---
|
||||
|
||||
<Note>
|
||||
If you're not looking to build API reference documentation, you can delete
|
||||
this section by removing the api-reference folder.
|
||||
</Note>
|
||||
|
||||
## Welcome
|
||||
|
||||
There are two ways to build API documentation: [OpenAPI](https://mintlify.com/docs/api-playground/openapi/setup) and [MDX components](https://mintlify.com/docs/api-playground/mdx/configuration). For the starter kit, we are using the following OpenAPI specification.
|
||||
|
||||
<Card
|
||||
title="Plant Store Endpoints"
|
||||
icon="leaf"
|
||||
href="https://github.com/mintlify/starter/blob/main/api-reference/openapi.json"
|
||||
>
|
||||
View the OpenAPI specification file
|
||||
</Card>
|
||||
|
||||
## Authentication
|
||||
|
||||
All API endpoints are authenticated using Bearer tokens and picked up from the specification file.
|
||||
|
||||
```json
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
```
|
||||
217
docs/api-reference/openapi.json
Normal file
@@ -0,0 +1,217 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "OpenAPI Plant Store",
|
||||
"description": "A sample API that uses a plant store as an example to demonstrate features in the OpenAPI specification",
|
||||
"license": {
|
||||
"name": "MIT"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://sandbox.mintlify.com"
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/plants": {
|
||||
"get": {
|
||||
"description": "Returns all plants from the system that the user has access to",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "The maximum number of results to return",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Plant response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Plant"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Unexpected error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Creates a new plant in the store",
|
||||
"requestBody": {
|
||||
"description": "Plant to add to the store",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NewPlant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "plant response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Plant"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "unexpected error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/plants/{id}": {
|
||||
"delete": {
|
||||
"description": "Deletes a single plant based on the ID supplied",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "ID of plant to delete",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Plant deleted",
|
||||
"content": {}
|
||||
},
|
||||
"400": {
|
||||
"description": "unexpected error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"webhooks": {
|
||||
"/plant/webhook": {
|
||||
"post": {
|
||||
"description": "Information about a new plant added to the store",
|
||||
"requestBody": {
|
||||
"description": "Plant added to the store",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NewPlant"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Return a 200 status to indicate that the data was received successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Plant": {
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "The name of the plant",
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"description": "Tag to specify the type",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NewPlant": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Plant"
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Identification number of the plant",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Error": {
|
||||
"required": [
|
||||
"error",
|
||||
"message"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
107
docs/development.mdx
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: 'Development'
|
||||
description: 'Preview changes locally to update your docs'
|
||||
---
|
||||
|
||||
<Info>
|
||||
**Prerequisite**: Please install Node.js (version 19 or higher) before proceeding. <br />
|
||||
Please upgrade to ```docs.json``` before proceeding and delete the legacy ```mint.json``` file.
|
||||
</Info>
|
||||
|
||||
Follow these steps to install and run Mintlify on your operating system:
|
||||
|
||||
**Step 1**: Install Mintlify:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash npm
|
||||
npm i -g mintlify
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn global add mintlify
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
**Step 2**: Navigate to the docs directory (where the `docs.json` file is located) and execute the following command:
|
||||
|
||||
```bash
|
||||
mintlify dev
|
||||
```
|
||||
|
||||
A local preview of your documentation will be available at `http://localhost:3000`.
|
||||
|
||||
### Custom Ports
|
||||
|
||||
By default, Mintlify uses port 3000. You can customize the port Mintlify runs on by using the `--port` flag. To run Mintlify on port 3333, for instance, use this command:
|
||||
|
||||
```bash
|
||||
mintlify dev --port 3333
|
||||
```
|
||||
|
||||
If you attempt to run Mintlify on a port that's already in use, it will use the next available port:
|
||||
|
||||
```md
|
||||
Port 3000 is already in use. Trying 3001 instead.
|
||||
```
|
||||
|
||||
## Mintlify Versions
|
||||
|
||||
Please note that each CLI release is associated with a specific version of Mintlify. If your local website doesn't align with the production version, please update the CLI:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash npm
|
||||
npm i -g mintlify@latest
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn global upgrade mintlify
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Validating Links
|
||||
|
||||
The CLI can assist with validating reference links made in your documentation. To identify any broken links, use the following command:
|
||||
|
||||
```bash
|
||||
mintlify broken-links
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
<Tip>
|
||||
Unlimited editors available under the [Pro
|
||||
Plan](https://mintlify.com/pricing) and above.
|
||||
</Tip>
|
||||
|
||||
If the deployment is successful, you should see the following:
|
||||
|
||||
<Frame>
|
||||
<img src="/images/checks-passed.png" style={{ borderRadius: '0.5rem' }} />
|
||||
</Frame>
|
||||
|
||||
## Code Formatting
|
||||
|
||||
We suggest using extensions on your IDE to recognize and format MDX. If you're a VSCode user, consider the [MDX VSCode extension](https://marketplace.visualstudio.com/items?itemName=unifiedjs.vscode-mdx) for syntax highlighting, and [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) for code formatting.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title='Error: Could not load the "sharp" module using the darwin-arm64 runtime'>
|
||||
|
||||
This may be due to an outdated version of node. Try the following:
|
||||
1. Remove the currently-installed version of mintlify: `npm remove -g mintlify`
|
||||
2. Upgrade to Node v19 or higher.
|
||||
3. Reinstall mintlify: `npm install -g mintlify`
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Issue: Encountering an unknown error">
|
||||
|
||||
Solution: Go to the root of your device and delete the \~/.mintlify folder. Afterwards, run `mintlify dev` again.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
Curious about what changed in the CLI version? [Check out the CLI changelog.](https://www.npmjs.com/package/mintlify?activeTab=versions)
|
||||
102
docs/docs.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"$schema": "https://mintlify.com/docs.json",
|
||||
"theme": "mint",
|
||||
"name": "Mint Starter Kit",
|
||||
"colors": {
|
||||
"primary": "#16A34A",
|
||||
"light": "#07C983",
|
||||
"dark": "#15803D"
|
||||
},
|
||||
"favicon": "/favicon.svg",
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Guides",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Get Started",
|
||||
"pages": [
|
||||
"index",
|
||||
"quickstart",
|
||||
"development"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Essentials",
|
||||
"pages": [
|
||||
"essentials/markdown",
|
||||
"essentials/code",
|
||||
"essentials/images",
|
||||
"essentials/settings",
|
||||
"essentials/navigation",
|
||||
"essentials/reusable-snippets"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "API Reference",
|
||||
"groups": [
|
||||
{
|
||||
"group": "API Documentation",
|
||||
"pages": [
|
||||
"api-reference/introduction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Endpoint Examples",
|
||||
"pages": [
|
||||
"api-reference/endpoint/get",
|
||||
"api-reference/endpoint/create",
|
||||
"api-reference/endpoint/delete",
|
||||
"api-reference/endpoint/webhook"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"global": {
|
||||
"anchors": [
|
||||
{
|
||||
"anchor": "Documentation",
|
||||
"href": "https://mintlify.com/docs",
|
||||
"icon": "book-open-cover"
|
||||
},
|
||||
{
|
||||
"anchor": "Community",
|
||||
"href": "https://mintlify.com/community",
|
||||
"icon": "slack"
|
||||
},
|
||||
{
|
||||
"anchor": "Blog",
|
||||
"href": "https://mintlify.com/blog",
|
||||
"icon": "newspaper"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"light": "/logo/light.svg",
|
||||
"dark": "/logo/dark.svg"
|
||||
},
|
||||
"navbar": {
|
||||
"links": [
|
||||
{
|
||||
"label": "Support",
|
||||
"href": "mailto:hi@mintlify.com"
|
||||
}
|
||||
],
|
||||
"primary": {
|
||||
"type": "button",
|
||||
"label": "Dashboard",
|
||||
"href": "https://dashboard.mintlify.com"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"socials": {
|
||||
"x": "https://x.com/mintlify",
|
||||
"github": "https://github.com/mintlify",
|
||||
"linkedin": "https://linkedin.com/company/mintlify"
|
||||
}
|
||||
}
|
||||
}
|
||||
37
docs/essentials/code.mdx
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: 'Code Blocks'
|
||||
description: 'Display inline code and code blocks'
|
||||
icon: 'code'
|
||||
---
|
||||
|
||||
## Basic
|
||||
|
||||
### Inline Code
|
||||
|
||||
To denote a `word` or `phrase` as code, enclose it in backticks (`).
|
||||
|
||||
```
|
||||
To denote a `word` or `phrase` as code, enclose it in backticks (`).
|
||||
```
|
||||
|
||||
### Code Block
|
||||
|
||||
Use [fenced code blocks](https://www.markdownguide.org/extended-syntax/#fenced-code-blocks) by enclosing code in three backticks and follow the leading ticks with the programming language of your snippet to get syntax highlighting. Optionally, you can also write the name of your code after the programming language.
|
||||
|
||||
```java HelloWorld.java
|
||||
class HelloWorld {
|
||||
public static void main(String[] args) {
|
||||
System.out.println("Hello, World!");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
````md
|
||||
```java HelloWorld.java
|
||||
class HelloWorld {
|
||||
public static void main(String[] args) {
|
||||
System.out.println("Hello, World!");
|
||||
}
|
||||
}
|
||||
```
|
||||
````
|
||||
59
docs/essentials/images.mdx
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: 'Images and Embeds'
|
||||
description: 'Add image, video, and other HTML elements'
|
||||
icon: 'image'
|
||||
---
|
||||
|
||||
<img
|
||||
style={{ borderRadius: '0.5rem' }}
|
||||
src="https://mintlify-assets.b-cdn.net/bigbend.jpg"
|
||||
/>
|
||||
|
||||
## Image
|
||||
|
||||
### Using Markdown
|
||||
|
||||
The [markdown syntax](https://www.markdownguide.org/basic-syntax/#images) lets you add images using the following code
|
||||
|
||||
```md
|
||||

|
||||
```
|
||||
|
||||
Note that the image file size must be less than 5MB. Otherwise, we recommend hosting on a service like [Cloudinary](https://cloudinary.com/) or [S3](https://aws.amazon.com/s3/). You can then use that URL and embed.
|
||||
|
||||
### Using Embeds
|
||||
|
||||
To get more customizability with images, you can also use [embeds](/writing-content/embed) to add images
|
||||
|
||||
```html
|
||||
<img height="200" src="/path/image.jpg" />
|
||||
```
|
||||
|
||||
## Embeds and HTML elements
|
||||
|
||||
<iframe
|
||||
width="560"
|
||||
height="315"
|
||||
src="https://www.youtube.com/embed/4KzFe50RQkQ"
|
||||
title="YouTube video player"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
style={{ width: '100%', borderRadius: '0.5rem' }}
|
||||
></iframe>
|
||||
|
||||
<br />
|
||||
|
||||
<Tip>
|
||||
|
||||
Mintlify supports [HTML tags in Markdown](https://www.markdownguide.org/basic-syntax/#html). This is helpful if you prefer HTML tags to Markdown syntax, and lets you create documentation with infinite flexibility.
|
||||
|
||||
</Tip>
|
||||
|
||||
### iFrames
|
||||
|
||||
Loads another HTML page within the document. Most commonly used for embedding videos.
|
||||
|
||||
```html
|
||||
<iframe src="https://www.youtube.com/embed/4KzFe50RQkQ"> </iframe>
|
||||
```
|
||||
88
docs/essentials/markdown.mdx
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: 'Markdown Syntax'
|
||||
description: 'Text, title, and styling in standard markdown'
|
||||
icon: 'text-size'
|
||||
---
|
||||
|
||||
## Titles
|
||||
|
||||
Best used for section headers.
|
||||
|
||||
```md
|
||||
## Titles
|
||||
```
|
||||
|
||||
### Subtitles
|
||||
|
||||
Best use to subsection headers.
|
||||
|
||||
```md
|
||||
### Subtitles
|
||||
```
|
||||
|
||||
<Tip>
|
||||
|
||||
Each **title** and **subtitle** creates an anchor and also shows up on the table of contents on the right.
|
||||
|
||||
</Tip>
|
||||
|
||||
## Text Formatting
|
||||
|
||||
We support most markdown formatting. Simply add `**`, `_`, or `~` around text to format it.
|
||||
|
||||
| Style | How to write it | Result |
|
||||
| ------------- | ----------------- | --------------- |
|
||||
| Bold | `**bold**` | **bold** |
|
||||
| Italic | `_italic_` | _italic_ |
|
||||
| Strikethrough | `~strikethrough~` | ~strikethrough~ |
|
||||
|
||||
You can combine these. For example, write `**_bold and italic_**` to get **_bold and italic_** text.
|
||||
|
||||
You need to use HTML to write superscript and subscript text. That is, add `<sup>` or `<sub>` around your text.
|
||||
|
||||
| Text Size | How to write it | Result |
|
||||
| ----------- | ------------------------ | ---------------------- |
|
||||
| Superscript | `<sup>superscript</sup>` | <sup>superscript</sup> |
|
||||
| Subscript | `<sub>subscript</sub>` | <sub>subscript</sub> |
|
||||
|
||||
## Linking to Pages
|
||||
|
||||
You can add a link by wrapping text in `[]()`. You would write `[link to google](https://google.com)` to [link to google](https://google.com).
|
||||
|
||||
Links to pages in your docs need to be root-relative. Basically, you should include the entire folder path. For example, `[link to text](/writing-content/text)` links to the page "Text" in our components section.
|
||||
|
||||
Relative links like `[link to text](../text)` will open slower because we cannot optimize them as easily.
|
||||
|
||||
## Blockquotes
|
||||
|
||||
### Singleline
|
||||
|
||||
To create a blockquote, add a `>` in front of a paragraph.
|
||||
|
||||
> Dorothy followed her through many of the beautiful rooms in her castle.
|
||||
|
||||
```md
|
||||
> Dorothy followed her through many of the beautiful rooms in her castle.
|
||||
```
|
||||
|
||||
### Multiline
|
||||
|
||||
> Dorothy followed her through many of the beautiful rooms in her castle.
|
||||
>
|
||||
> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.
|
||||
|
||||
```md
|
||||
> Dorothy followed her through many of the beautiful rooms in her castle.
|
||||
>
|
||||
> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.
|
||||
```
|
||||
|
||||
### LaTeX
|
||||
|
||||
Mintlify supports [LaTeX](https://www.latex-project.org) through the Latex component.
|
||||
|
||||
<Latex>8 x (vk x H1 - H2) = (0,1)</Latex>
|
||||
|
||||
```md
|
||||
<Latex>8 x (vk x H1 - H2) = (0,1)</Latex>
|
||||
```
|
||||
87
docs/essentials/navigation.mdx
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: 'Navigation'
|
||||
description: 'The navigation field in docs.json defines the pages that go in the navigation menu'
|
||||
icon: 'map'
|
||||
---
|
||||
|
||||
The navigation menu is the list of links on every website.
|
||||
|
||||
You will likely update `docs.json` every time you add a new page. Pages do not show up automatically.
|
||||
|
||||
## Navigation syntax
|
||||
|
||||
Our navigation syntax is recursive which means you can make nested navigation groups. You don't need to include `.mdx` in page names.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```json Regular Navigation
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Docs",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Getting Started",
|
||||
"pages": ["quickstart"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```json Nested Navigation
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Docs",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Getting Started",
|
||||
"pages": [
|
||||
"quickstart",
|
||||
{
|
||||
"group": "Nested Reference Pages",
|
||||
"pages": ["nested-reference-page"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Folders
|
||||
|
||||
Simply put your MDX files in folders and update the paths in `docs.json`.
|
||||
|
||||
For example, to have a page at `https://yoursite.com/your-folder/your-page` you would make a folder called `your-folder` containing an MDX file called `your-page.mdx`.
|
||||
|
||||
<Warning>
|
||||
|
||||
You cannot use `api` for the name of a folder unless you nest it inside another folder. Mintlify uses Next.js which reserves the top-level `api` folder for internal server calls. A folder name such as `api-reference` would be accepted.
|
||||
|
||||
</Warning>
|
||||
|
||||
```json Navigation With Folder
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Docs",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Group Name",
|
||||
"pages": ["your-folder/your-page"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Hidden Pages
|
||||
|
||||
MDX files not included in `docs.json` will not show up in the sidebar but are accessible through the search bar and by linking directly to them.
|
||||
110
docs/essentials/reusable-snippets.mdx
Normal file
@@ -0,0 +1,110 @@
|
||||
---
|
||||
title: Reusable Snippets
|
||||
description: Reusable, custom snippets to keep content in sync
|
||||
icon: 'recycle'
|
||||
---
|
||||
|
||||
import SnippetIntro from '/snippets/snippet-intro.mdx';
|
||||
|
||||
<SnippetIntro />
|
||||
|
||||
## Creating a custom snippet
|
||||
|
||||
**Pre-condition**: You must create your snippet file in the `snippets` directory.
|
||||
|
||||
<Note>
|
||||
Any page in the `snippets` directory will be treated as a snippet and will not
|
||||
be rendered into a standalone page. If you want to create a standalone page
|
||||
from the snippet, import the snippet into another file and call it as a
|
||||
component.
|
||||
</Note>
|
||||
|
||||
### Default export
|
||||
|
||||
1. Add content to your snippet file that you want to re-use across multiple
|
||||
locations. Optionally, you can add variables that can be filled in via props
|
||||
when you import the snippet.
|
||||
|
||||
```mdx snippets/my-snippet.mdx
|
||||
Hello world! This is my content I want to reuse across pages. My keyword of the
|
||||
day is {word}.
|
||||
```
|
||||
|
||||
<Warning>
|
||||
The content that you want to reuse must be inside the `snippets` directory in
|
||||
order for the import to work.
|
||||
</Warning>
|
||||
|
||||
2. Import the snippet into your destination file.
|
||||
|
||||
```mdx destination-file.mdx
|
||||
---
|
||||
title: My title
|
||||
description: My Description
|
||||
---
|
||||
|
||||
import MySnippet from '/snippets/path/to/my-snippet.mdx';
|
||||
|
||||
## Header
|
||||
|
||||
Lorem impsum dolor sit amet.
|
||||
|
||||
<MySnippet word="bananas" />
|
||||
```
|
||||
|
||||
### Reusable variables
|
||||
|
||||
1. Export a variable from your snippet file:
|
||||
|
||||
```mdx snippets/path/to/custom-variables.mdx
|
||||
export const myName = 'my name';
|
||||
|
||||
export const myObject = { fruit: 'strawberries' };
|
||||
```
|
||||
|
||||
2. Import the snippet from your destination file and use the variable:
|
||||
|
||||
```mdx destination-file.mdx
|
||||
---
|
||||
title: My title
|
||||
description: My Description
|
||||
---
|
||||
|
||||
import { myName, myObject } from '/snippets/path/to/custom-variables.mdx';
|
||||
|
||||
Hello, my name is {myName} and I like {myObject.fruit}.
|
||||
```
|
||||
|
||||
### Reusable components
|
||||
|
||||
1. Inside your snippet file, create a component that takes in props by exporting
|
||||
your component in the form of an arrow function.
|
||||
|
||||
```mdx snippets/custom-component.mdx
|
||||
export const MyComponent = ({ title }) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
<p>... snippet content ...</p>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
<Warning>
|
||||
MDX does not compile inside the body of an arrow function. Stick to HTML
|
||||
syntax when you can or use a default export if you need to use MDX.
|
||||
</Warning>
|
||||
|
||||
2. Import the snippet into your destination file and pass in the props
|
||||
|
||||
```mdx destination-file.mdx
|
||||
---
|
||||
title: My title
|
||||
description: My Description
|
||||
---
|
||||
|
||||
import { MyComponent } from '/snippets/custom-component.mdx';
|
||||
|
||||
Lorem ipsum dolor sit amet.
|
||||
|
||||
<MyComponent title={'Custom title'} />
|
||||
```
|
||||
318
docs/essentials/settings.mdx
Normal file
@@ -0,0 +1,318 @@
|
||||
---
|
||||
title: 'Global Settings'
|
||||
description: 'Mintlify gives you complete control over the look and feel of your documentation using the docs.json file'
|
||||
icon: 'gear'
|
||||
---
|
||||
|
||||
Every Mintlify site needs a `docs.json` file with the core configuration settings. Learn more about the [properties](#properties) below.
|
||||
|
||||
## Properties
|
||||
|
||||
<ResponseField name="name" type="string" required>
|
||||
Name of your project. Used for the global title.
|
||||
|
||||
Example: `mintlify`
|
||||
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="navigation" type="Navigation[]" required>
|
||||
An array of groups with all the pages within that group
|
||||
<Expandable title="Navigation">
|
||||
<ResponseField name="group" type="string">
|
||||
The name of the group.
|
||||
|
||||
Example: `Settings`
|
||||
|
||||
</ResponseField>
|
||||
<ResponseField name="pages" type="string[]">
|
||||
The relative paths to the markdown files that will serve as pages.
|
||||
|
||||
Example: `["customization", "page"]`
|
||||
|
||||
</ResponseField>
|
||||
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="logo" type="string or object">
|
||||
Path to logo image or object with path to "light" and "dark" mode logo images
|
||||
<Expandable title="Logo">
|
||||
<ResponseField name="light" type="string">
|
||||
Path to the logo in light mode
|
||||
</ResponseField>
|
||||
<ResponseField name="dark" type="string">
|
||||
Path to the logo in dark mode
|
||||
</ResponseField>
|
||||
<ResponseField name="href" type="string" default="/">
|
||||
Where clicking on the logo links you to
|
||||
</ResponseField>
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="favicon" type="string">
|
||||
Path to the favicon image
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="colors" type="Colors">
|
||||
Hex color codes for your global theme
|
||||
<Expandable title="Colors">
|
||||
<ResponseField name="primary" type="string" required>
|
||||
The primary color. Used for most often for highlighted content, section
|
||||
headers, accents, in light mode
|
||||
</ResponseField>
|
||||
<ResponseField name="light" type="string">
|
||||
The primary color for dark mode. Used for most often for highlighted
|
||||
content, section headers, accents, in dark mode
|
||||
</ResponseField>
|
||||
<ResponseField name="dark" type="string">
|
||||
The primary color for important buttons
|
||||
</ResponseField>
|
||||
<ResponseField name="background" type="object">
|
||||
The color of the background in both light and dark mode
|
||||
<Expandable title="Object">
|
||||
<ResponseField name="light" type="string" required>
|
||||
The hex color code of the background in light mode
|
||||
</ResponseField>
|
||||
<ResponseField name="dark" type="string" required>
|
||||
The hex color code of the background in dark mode
|
||||
</ResponseField>
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="topbarLinks" type="TopbarLink[]">
|
||||
Array of `name`s and `url`s of links you want to include in the topbar
|
||||
<Expandable title="TopbarLink">
|
||||
<ResponseField name="name" type="string">
|
||||
The name of the button.
|
||||
|
||||
Example: `Contact us`
|
||||
</ResponseField>
|
||||
<ResponseField name="url" type="string">
|
||||
The url once you click on the button. Example: `https://mintlify.com/docs`
|
||||
</ResponseField>
|
||||
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="topbarCtaButton" type="Call to Action">
|
||||
<Expandable title="Topbar Call to Action">
|
||||
<ResponseField name="type" type={'"link" or "github"'} default="link">
|
||||
Link shows a button. GitHub shows the repo information at the url provided including the number of GitHub stars.
|
||||
</ResponseField>
|
||||
<ResponseField name="url" type="string">
|
||||
If `link`: What the button links to.
|
||||
|
||||
If `github`: Link to the repository to load GitHub information from.
|
||||
</ResponseField>
|
||||
<ResponseField name="name" type="string">
|
||||
Text inside the button. Only required if `type` is a `link`.
|
||||
</ResponseField>
|
||||
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="versions" type="string[]">
|
||||
Array of version names. Only use this if you want to show different versions
|
||||
of docs with a dropdown in the navigation bar.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="anchors" type="Anchor[]">
|
||||
An array of the anchors, includes the `icon`, `color`, and `url`.
|
||||
<Expandable title="Anchor">
|
||||
<ResponseField name="icon" type="string">
|
||||
The [Font Awesome](https://fontawesome.com/search?q=heart) icon used to feature the anchor.
|
||||
|
||||
Example: `comments`
|
||||
</ResponseField>
|
||||
<ResponseField name="name" type="string">
|
||||
The name of the anchor label.
|
||||
|
||||
Example: `Community`
|
||||
</ResponseField>
|
||||
<ResponseField name="url" type="string">
|
||||
The start of the URL that marks what pages go in the anchor. Generally, this is the name of the folder you put your pages in.
|
||||
</ResponseField>
|
||||
<ResponseField name="color" type="string">
|
||||
The hex color of the anchor icon background. Can also be a gradient if you pass an object with the properties `from` and `to` that are each a hex color.
|
||||
</ResponseField>
|
||||
<ResponseField name="version" type="string">
|
||||
Used if you want to hide an anchor until the correct docs version is selected.
|
||||
</ResponseField>
|
||||
<ResponseField name="isDefaultHidden" type="boolean" default="false">
|
||||
Pass `true` if you want to hide the anchor until you directly link someone to docs inside it.
|
||||
</ResponseField>
|
||||
<ResponseField name="iconType" default="duotone" type="string">
|
||||
One of: "brands", "duotone", "light", "sharp-solid", "solid", or "thin"
|
||||
</ResponseField>
|
||||
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="topAnchor" type="Object">
|
||||
Override the default configurations for the top-most anchor.
|
||||
<Expandable title="Object">
|
||||
<ResponseField name="name" default="Documentation" type="string">
|
||||
The name of the top-most anchor
|
||||
</ResponseField>
|
||||
<ResponseField name="icon" default="book-open" type="string">
|
||||
Font Awesome icon.
|
||||
</ResponseField>
|
||||
<ResponseField name="iconType" default="duotone" type="string">
|
||||
One of: "brands", "duotone", "light", "sharp-solid", "solid", or "thin"
|
||||
</ResponseField>
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="tabs" type="Tabs[]">
|
||||
An array of navigational tabs.
|
||||
<Expandable title="Tabs">
|
||||
<ResponseField name="name" type="string">
|
||||
The name of the tab label.
|
||||
</ResponseField>
|
||||
<ResponseField name="url" type="string">
|
||||
The start of the URL that marks what pages go in the tab. Generally, this
|
||||
is the name of the folder you put your pages in.
|
||||
</ResponseField>
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="api" type="API">
|
||||
Configuration for API settings. Learn more about API pages at [API Components](/api-playground/demo).
|
||||
<Expandable title="API">
|
||||
<ResponseField name="baseUrl" type="string">
|
||||
The base url for all API endpoints. If `baseUrl` is an array, it will enable for multiple base url
|
||||
options that the user can toggle.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="auth" type="Auth">
|
||||
<Expandable title="Auth">
|
||||
<ResponseField name="method" type='"bearer" | "basic" | "key"'>
|
||||
The authentication strategy used for all API endpoints.
|
||||
</ResponseField>
|
||||
<ResponseField name="name" type="string">
|
||||
The name of the authentication parameter used in the API playground.
|
||||
|
||||
If method is `basic`, the format should be `[usernameName]:[passwordName]`
|
||||
</ResponseField>
|
||||
<ResponseField name="inputPrefix" type="string">
|
||||
The default value that's designed to be a prefix for the authentication input field.
|
||||
|
||||
E.g. If an `inputPrefix` of `AuthKey` would inherit the default input result of the authentication field as `AuthKey`.
|
||||
</ResponseField>
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="playground" type="Playground">
|
||||
Configurations for the API playground
|
||||
|
||||
<Expandable title="Playground">
|
||||
<ResponseField name="mode" default="show" type='"show" | "simple" | "hide"'>
|
||||
Whether the playground is showing, hidden, or only displaying the endpoint with no added user interactivity `simple`
|
||||
|
||||
Learn more at the [playground guides](/api-playground/demo)
|
||||
</ResponseField>
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="maintainOrder" type="boolean">
|
||||
Enabling this flag ensures that key ordering in OpenAPI pages matches the key ordering defined in the OpenAPI file.
|
||||
|
||||
<Warning>This behavior will soon be enabled by default, at which point this field will be deprecated.</Warning>
|
||||
</ResponseField>
|
||||
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="openapi" type="string | string[]">
|
||||
A string or an array of strings of URL(s) or relative path(s) pointing to your
|
||||
OpenAPI file.
|
||||
|
||||
Examples:
|
||||
<CodeGroup>
|
||||
```json Absolute
|
||||
"openapi": "https://example.com/openapi.json"
|
||||
```
|
||||
```json Relative
|
||||
"openapi": "/openapi.json"
|
||||
```
|
||||
```json Multiple
|
||||
"openapi": ["https://example.com/openapi1.json", "/openapi2.json", "/openapi3.json"]
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="footerSocials" type="FooterSocials">
|
||||
An object of social media accounts where the key:property pair represents the social media platform and the account url.
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"x": "https://x.com/mintlify",
|
||||
"website": "https://mintlify.com"
|
||||
}
|
||||
```
|
||||
<Expandable title="FooterSocials">
|
||||
<ResponseField name="[key]" type="string">
|
||||
One of the following values `website`, `facebook`, `x`, `discord`, `slack`, `github`, `linkedin`, `instagram`, `hacker-news`
|
||||
|
||||
Example: `x`
|
||||
</ResponseField>
|
||||
<ResponseField name="property" type="string">
|
||||
The URL to the social platform.
|
||||
|
||||
Example: `https://x.com/mintlify`
|
||||
</ResponseField>
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="feedback" type="Feedback">
|
||||
Configurations to enable feedback buttons
|
||||
|
||||
<Expandable title="Feedback">
|
||||
<ResponseField name="suggestEdit" type="boolean" default="false">
|
||||
Enables a button to allow users to suggest edits via pull requests
|
||||
</ResponseField>
|
||||
<ResponseField name="raiseIssue" type="boolean" default="false">
|
||||
Enables a button to allow users to raise an issue about the documentation
|
||||
</ResponseField>
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="modeToggle" type="ModeToggle">
|
||||
Customize the dark mode toggle.
|
||||
<Expandable title="ModeToggle">
|
||||
<ResponseField name="default" type={'"light" or "dark"'}>
|
||||
Set if you always want to show light or dark mode for new users. When not
|
||||
set, we default to the same mode as the user's operating system.
|
||||
</ResponseField>
|
||||
<ResponseField name="isHidden" type="boolean" default="false">
|
||||
Set to true to hide the dark/light mode toggle. You can combine `isHidden` with `default` to force your docs to only use light or dark mode. For example:
|
||||
|
||||
<CodeGroup>
|
||||
```json Only Dark Mode
|
||||
"modeToggle": {
|
||||
"default": "dark",
|
||||
"isHidden": true
|
||||
}
|
||||
```
|
||||
|
||||
```json Only Light Mode
|
||||
"modeToggle": {
|
||||
"default": "light",
|
||||
"isHidden": true
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
</ResponseField>
|
||||
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="backgroundImage" type="string">
|
||||
A background image to be displayed behind every page. See example with
|
||||
[Infisical](https://infisical.com/docs) and [FRPC](https://frpc.io).
|
||||
</ResponseField>
|
||||
19
docs/favicon.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.06145 23.1079C5.26816 22.3769 -3.39077 20.6274 1.4173 5.06384C9.6344 6.09939 16.9728 14.0644 9.06145 23.1079Z" fill="url(#paint0_linear_17557_2021)"/>
|
||||
<path d="M8.91928 23.0939C5.27642 21.2223 0.78371 4.20891 17.0071 0C20.7569 7.19341 19.6212 16.5452 8.91928 23.0939Z" fill="url(#paint1_linear_17557_2021)"/>
|
||||
<path d="M8.91388 23.0788C8.73534 19.8817 10.1585 9.08525 23.5699 13.1107C23.1812 20.1229 18.984 26.4182 8.91388 23.0788Z" fill="url(#paint2_linear_17557_2021)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_17557_2021" x1="3.77557" y1="5.91571" x2="5.23185" y2="21.5589" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#18E299"/>
|
||||
<stop offset="1" stop-color="#15803D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_17557_2021" x1="12.1711" y1="-0.718425" x2="10.1897" y2="22.9832" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#16A34A"/>
|
||||
<stop offset="1" stop-color="#4ADE80"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_17557_2021" x1="23.1327" y1="15.353" x2="9.33841" y2="18.5196" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4ADE80"/>
|
||||
<stop offset="1" stop-color="#0D9373"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
docs/images/checks-passed.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
docs/images/hero-dark.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
docs/images/hero-light.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
71
docs/index.mdx
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Introduction
|
||||
description: "Welcome to the home of your new documentation"
|
||||
---
|
||||
|
||||
<img
|
||||
className="block dark:hidden"
|
||||
src="/images/hero-light.png"
|
||||
alt="Hero Light"
|
||||
/>
|
||||
<img
|
||||
className="hidden dark:block"
|
||||
src="/images/hero-dark.png"
|
||||
alt="Hero Dark"
|
||||
/>
|
||||
|
||||
## Setting up
|
||||
|
||||
The first step to world-class documentation is setting up your editing environments.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card
|
||||
title="Edit Your Docs"
|
||||
icon="pen-to-square"
|
||||
href="https://mintlify.com/docs/quickstart"
|
||||
>
|
||||
Get your docs set up locally for easy development
|
||||
</Card>
|
||||
<Card
|
||||
title="Preview Changes"
|
||||
icon="image"
|
||||
href="https://mintlify.com/docs/development"
|
||||
>
|
||||
Preview your changes before you push to make sure they're perfect
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Make it yours
|
||||
|
||||
Update your docs to your brand and add valuable content for the best user conversion.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card
|
||||
title="Customize Style"
|
||||
icon="palette"
|
||||
href="https://mintlify.com/docs/settings/global"
|
||||
>
|
||||
Customize your docs to your company's colors and brands
|
||||
</Card>
|
||||
<Card
|
||||
title="Reference APIs"
|
||||
icon="code"
|
||||
href="https://mintlify.com/docs/api-playground/openapi"
|
||||
>
|
||||
Automatically generate endpoints from an OpenAPI spec
|
||||
</Card>
|
||||
<Card
|
||||
title="Add Components"
|
||||
icon="screwdriver-wrench"
|
||||
href="https://mintlify.com/docs/content/components/accordions"
|
||||
>
|
||||
Build interactive features and designs to guide your users
|
||||
</Card>
|
||||
<Card
|
||||
title="Get Inspiration"
|
||||
icon="stars"
|
||||
href="https://mintlify.com/customers"
|
||||
>
|
||||
Check out our showcase of our favorite documentation
|
||||
</Card>
|
||||
</CardGroup>
|
||||
21
docs/logo/dark.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
21
docs/logo/light.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
97
docs/quickstart.mdx
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
title: 'Quickstart'
|
||||
description: 'Start building awesome documentation in under 5 minutes'
|
||||
---
|
||||
|
||||
## Setup your development
|
||||
|
||||
Learn how to update your docs locally and deploy them to the public.
|
||||
|
||||
### Edit and preview
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion icon="github" title="Clone your docs locally">
|
||||
During the onboarding process, we created a repository on your Github with
|
||||
your docs content. You can find this repository on our
|
||||
[dashboard](https://dashboard.mintlify.com). To clone the repository
|
||||
locally, follow these
|
||||
[instructions](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository)
|
||||
in your terminal.
|
||||
</Accordion>
|
||||
<Accordion icon="rectangle-terminal" title="Preview changes">
|
||||
Previewing helps you make sure your changes look as intended. We built a
|
||||
command line interface to render these changes locally.
|
||||
1. Install the
|
||||
[Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the
|
||||
documentation changes locally with this command: ``` npm i -g mintlify ```
|
||||
2. Run the following command at the root of your documentation (where
|
||||
`docs.json` is): ``` mintlify dev ```
|
||||
<Note>
|
||||
If you’re currently using the legacy ```mint.json``` configuration file, please update the Mintlify CLI:
|
||||
|
||||
|
||||
```npm i -g mintlify@latest```
|
||||
And run the new upgrade command in your docs repository:
|
||||
|
||||
```mintlify upgrade```
|
||||
You should now be using the new ```docs.json``` configuration file. Feel free to delete the ```mint.json``` file from your repository.
|
||||
</Note>
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### Deploy your changes
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
<Accordion icon="message-bot" title="Install our Github app">
|
||||
Our Github app automatically deploys your changes to your docs site, so you
|
||||
don't need to manage deployments yourself. You can find the link to install on
|
||||
your [dashboard](https://dashboard.mintlify.com). Once the bot has been
|
||||
successfully installed, there should be a check mark next to the commit hash
|
||||
of the repo.
|
||||
</Accordion>
|
||||
<Accordion icon="rocket" title="Push your changes">
|
||||
[Commit and push your changes to
|
||||
Git](https://docs.github.com/en/get-started/using-git/pushing-commits-to-a-remote-repository#about-git-push)
|
||||
for your changes to update in your docs site. If you push and don't see that
|
||||
the Github app successfully deployed your changes, you can also manually
|
||||
update your docs through our [dashboard](https://dashboard.mintlify.com).
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
## Update your docs
|
||||
|
||||
Add content directly in your files with MDX syntax and React components. You can use any of our components, or even build your own.
|
||||
|
||||
<CardGroup>
|
||||
|
||||
<Card title="Add Content With MDX" icon="file" href="/essentials/markdown">
|
||||
Add content to your docs with MDX syntax.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Add Code Blocks"
|
||||
icon="square-code"
|
||||
href="/essentials/code"
|
||||
>
|
||||
Add code directly to your docs with syntax highlighting.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Add Images"
|
||||
icon="image"
|
||||
href="/essentials/images"
|
||||
>
|
||||
Add images to your docs to make them more engaging.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Add Custom Components"
|
||||
icon="puzzle-piece"
|
||||
href="/essentials/reusable-snippets"
|
||||
>
|
||||
Add templates to your docs to make them more reusable.
|
||||
</Card>
|
||||
|
||||
</CardGroup>
|
||||
4
docs/snippets/snippet-intro.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
One of the core principles of software development is DRY (Don't Repeat
|
||||
Yourself). This is a principle that apply to documentation as
|
||||
well. If you find yourself repeating the same content in multiple places, you
|
||||
should consider creating a custom snippet to keep your content in sync.
|
||||
@@ -4,7 +4,19 @@ NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
|
||||
echo "Setting npm registry to ${NPM_REGISTRY}"
|
||||
npm config set registry "$NPM_REGISTRY"
|
||||
|
||||
# 处理 HTTP_PROXY 和 HTTPS_PROXY 环境变量
|
||||
if [ -n "$HTTP_PROXY" ]; then
|
||||
echo "Setting HTTP proxy to ${HTTP_PROXY}"
|
||||
npm config set proxy "$HTTP_PROXY"
|
||||
export HTTP_PROXY="$HTTP_PROXY"
|
||||
fi
|
||||
|
||||
if [ -n "$HTTPS_PROXY" ]; then
|
||||
echo "Setting HTTPS proxy to ${HTTPS_PROXY}"
|
||||
npm config set https-proxy "$HTTPS_PROXY"
|
||||
export HTTPS_PROXY="$HTTPS_PROXY"
|
||||
fi
|
||||
|
||||
echo "Using REQUEST_TIMEOUT: $REQUEST_TIMEOUT"
|
||||
echo "Using UV_PYTHON_INSTALL_MIRROR: $UV_PYTHON_INSTALL_MIRROR"
|
||||
|
||||
exec "$@"
|
||||
|
||||
BIN
frontend/public/assets/reward.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
frontend/public/assets/wexin.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
@@ -10,34 +11,38 @@ import ServersPage from './pages/ServersPage';
|
||||
import GroupsPage from './pages/GroupsPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import MarketPage from './pages/MarketPage';
|
||||
import LogsPage from './pages/LogsPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* 公共路由 */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/servers" element={<ServersPage />} />
|
||||
<Route path="/groups" element={<GroupsPage />} />
|
||||
<Route path="/market" element={<MarketPage />} />
|
||||
<Route path="/market/:serverName" element={<MarketPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* 公共路由 */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/servers" element={<ServersPage />} />
|
||||
<Route path="/groups" element={<GroupsPage />} />
|
||||
<Route path="/market" element={<MarketPage />} />
|
||||
<Route path="/market/:serverName" element={<MarketPage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* 未匹配的路由重定向到首页 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
|
||||
{/* 未匹配的路由重定向到首页 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.addNew')}</h2>
|
||||
|
||||
@@ -74,7 +74,7 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
|
||||
</button>
|
||||
|
||||
{modalVisible && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<ServerForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={toggleModal}
|
||||
|
||||
@@ -82,7 +82,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.edit')}</h2>
|
||||
|
||||
@@ -61,7 +61,7 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<ServerForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={onCancel}
|
||||
|
||||
191
frontend/src/components/LogViewer.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { LogEntry } from '../services/logService';
|
||||
import { Button } from './ui/Button';
|
||||
import { Badge } from './ui/Badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LogViewerProps {
|
||||
logs: LogEntry[];
|
||||
isLoading?: boolean;
|
||||
error?: Error | null;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error = null, onClear }) => {
|
||||
const { t } = useTranslation();
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
const [typeFilter, setTypeFilter] = useState<Array<'info' | 'error' | 'warn' | 'debug'>>(['info', 'error', 'warn', 'debug']);
|
||||
const [sourceFilter, setSourceFilter] = useState<Array<'main' | 'child'>>(['main', 'child']);
|
||||
|
||||
// Auto scroll to bottom when new logs come in if autoScroll is enabled
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
// Filter logs based on current filter settings
|
||||
const filteredLogs = logs.filter(log => {
|
||||
const matchesText = filter ? log.message.toLowerCase().includes(filter.toLowerCase()) : true;
|
||||
const matchesType = typeFilter.includes(log.type);
|
||||
const matchesSource = sourceFilter.includes(log.source as 'main' | 'child');
|
||||
return matchesText && matchesType && matchesSource;
|
||||
});
|
||||
|
||||
// Format timestamp to readable format
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
};
|
||||
|
||||
// Get badge color based on log type
|
||||
const getLogTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'error': return 'bg-red-400';
|
||||
case 'warn': return 'bg-yellow-400';
|
||||
case 'debug': return 'bg-purple-400';
|
||||
default: return 'bg-blue-400';
|
||||
}
|
||||
};
|
||||
|
||||
// Get badge color based on log source
|
||||
const getSourceColor = (source: string) => {
|
||||
switch (source) {
|
||||
case 'main': return 'bg-green-400';
|
||||
case 'child': return 'bg-orange-400';
|
||||
default: return 'bg-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="bg-card p-3 rounded-t-md border-b flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-sm">{t('logs.filters')}:</span>
|
||||
|
||||
{/* Text search filter */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('logs.search')}
|
||||
className="px-2 py-1 text-sm border rounded"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Log type filters */}
|
||||
<div className="flex gap-1 items-center">
|
||||
{(['info', 'error', 'warn', 'debug'] as const).map(type => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant={typeFilter.includes(type) ? 'default' : 'outline'}
|
||||
className={`cursor-pointer ${typeFilter.includes(type) ? getLogTypeColor(type) : ''}`}
|
||||
onClick={() => {
|
||||
if (typeFilter.includes(type)) {
|
||||
setTypeFilter(prev => prev.filter(t => t !== type));
|
||||
} else {
|
||||
setTypeFilter(prev => [...prev, type]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Log source filters */}
|
||||
<div className="flex gap-1 items-center ml-2">
|
||||
{(['main', 'child'] as const).map(source => (
|
||||
<Badge
|
||||
key={source}
|
||||
variant={sourceFilter.includes(source) ? 'default' : 'outline'}
|
||||
className={`cursor-pointer ${sourceFilter.includes(source) ? getSourceColor(source) : ''}`}
|
||||
onClick={() => {
|
||||
if (sourceFilter.includes(source)) {
|
||||
setSourceFilter(prev => prev.filter(s => s !== source));
|
||||
} else {
|
||||
setSourceFilter(prev => [...prev, source]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{source === 'main' ? t('logs.mainProcess') : t('logs.childProcess')}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScroll}
|
||||
onChange={() => setAutoScroll(!autoScroll)}
|
||||
className="form-checkbox h-4 w-4"
|
||||
/>
|
||||
{t('logs.autoScroll')}
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
disabled={isLoading || logs.length === 0}
|
||||
>
|
||||
{t('logs.clearLogs')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="flex-grow p-2 overflow-auto bg-card rounded-b-md font-mono text-sm"
|
||||
style={{ maxHeight: 'calc(100vh - 300px)' }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<span>{t('logs.loading')}</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-red-500 p-2">
|
||||
{error.message}
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground p-8">
|
||||
{filter || typeFilter.length < 4 || sourceFilter.length < 2
|
||||
? t('logs.noMatch')
|
||||
: t('logs.noLogs')}
|
||||
</div>
|
||||
) : (
|
||||
filteredLogs.map((log, index) => (
|
||||
<div
|
||||
key={`${log.timestamp}-${index}`}
|
||||
className={`py-1 border-b border-gray-100 dark:border-gray-800 ${log.type === 'error' ? 'text-red-500' :
|
||||
log.type === 'warn' ? 'text-yellow-500' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-gray-400">[{formatTimestamp(log.timestamp)}]</span>
|
||||
<Badge className={`ml-2 mr-1 ${getLogTypeColor(log.type)}`}>
|
||||
{log.type}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="default"
|
||||
className={`mr-2 ${getSourceColor(log.source)}`}
|
||||
>
|
||||
{log.source === 'main' ? t('logs.main') : t('logs.child')}
|
||||
{log.processId ? ` (${log.processId})` : ''}
|
||||
</Badge>
|
||||
<span className="whitespace-pre-wrap">{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogViewer;
|
||||
@@ -270,7 +270,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
</div>
|
||||
|
||||
{modalVisible && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<ServerForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={toggleModal}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Server } from '@/types'
|
||||
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { StatusBadge } from '@/components/ui/Badge'
|
||||
import ToolCard from '@/components/ui/ToolCard'
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
@@ -111,7 +111,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
>
|
||||
<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>
|
||||
<Badge status={server.status} />
|
||||
<StatusBadge status={server.status} />
|
||||
|
||||
{server.error && (
|
||||
<div className="relative">
|
||||
|
||||
@@ -12,9 +12,21 @@ interface ServerFormProps {
|
||||
|
||||
const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formError = null }: ServerFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [serverType, setServerType] = useState<'sse' | 'stdio'>(
|
||||
initialData && initialData.config && initialData.config.url ? 'sse' : 'stdio',
|
||||
)
|
||||
|
||||
// Determine the initial server type from the initialData
|
||||
const getInitialServerType = () => {
|
||||
if (!initialData || !initialData.config) return 'stdio';
|
||||
|
||||
if (initialData.config.type) {
|
||||
return initialData.config.type; // Use explicit type if available
|
||||
} else if (initialData.config.url) {
|
||||
return 'sse'; // Fallback to SSE if URL exists
|
||||
} else {
|
||||
return 'stdio'; // Default to stdio
|
||||
}
|
||||
};
|
||||
|
||||
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http'>(getInitialServerType());
|
||||
|
||||
const [formData, setFormData] = useState<ServerFormData>({
|
||||
name: (initialData && initialData.name) || '',
|
||||
@@ -27,6 +39,8 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
: String(initialData.config.args)
|
||||
: '',
|
||||
args: (initialData && initialData.config && initialData.config.args) || [],
|
||||
type: getInitialServerType(), // Initialize the type field
|
||||
env: []
|
||||
})
|
||||
|
||||
const [envVars, setEnvVars] = useState<EnvVar[]>(
|
||||
@@ -49,6 +63,11 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
setFormData({ ...formData, arguments: value, args })
|
||||
}
|
||||
|
||||
const updateServerType = (type: 'stdio' | 'sse' | 'streamable-http') => {
|
||||
setServerType(type);
|
||||
setFormData(prev => ({ ...prev, type }));
|
||||
}
|
||||
|
||||
const handleEnvVarChange = (index: number, field: 'key' | 'value', value: string) => {
|
||||
const newEnvVars = [...envVars]
|
||||
newEnvVars[index][field] = value
|
||||
@@ -80,14 +99,17 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
config:
|
||||
serverType === 'sse'
|
||||
config: {
|
||||
type: serverType, // Always include the type
|
||||
...(serverType === 'sse' || serverType === 'streamable-http'
|
||||
? { url: formData.url }
|
||||
: {
|
||||
command: formData.command,
|
||||
args: formData.args,
|
||||
env: Object.keys(env).length > 0 ? env : undefined,
|
||||
},
|
||||
command: formData.command,
|
||||
args: formData.args,
|
||||
env: Object.keys(env).length > 0 ? env : undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(payload)
|
||||
@@ -139,10 +161,10 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
name="serverType"
|
||||
value="command"
|
||||
checked={serverType === 'stdio'}
|
||||
onChange={() => setServerType('stdio')}
|
||||
onChange={() => updateServerType('stdio')}
|
||||
className="mr-1"
|
||||
/>
|
||||
<label htmlFor="command">stdio</label>
|
||||
<label htmlFor="command">STDIO</label>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
@@ -151,15 +173,27 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
name="serverType"
|
||||
value="url"
|
||||
checked={serverType === 'sse'}
|
||||
onChange={() => setServerType('sse')}
|
||||
onChange={() => updateServerType('sse')}
|
||||
className="mr-1"
|
||||
/>
|
||||
<label htmlFor="url">sse</label>
|
||||
<label htmlFor="url">SSE</label>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="radio"
|
||||
id="streamable-http"
|
||||
name="serverType"
|
||||
value="streamable-http"
|
||||
checked={serverType === 'streamable-http'}
|
||||
onChange={() => updateServerType('streamable-http')}
|
||||
className="mr-1"
|
||||
/>
|
||||
<label htmlFor="streamable-http">Streamable HTTP</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{serverType === 'sse' ? (
|
||||
{serverType === 'sse' || serverType === 'streamable-http' ? (
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="url">
|
||||
{t('server.url')}
|
||||
@@ -171,8 +205,8 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
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"
|
||||
placeholder="e.g.: http://localhost:3000/sse"
|
||||
required={serverType === 'sse'}
|
||||
placeholder={serverType === 'streamable-http' ? "e.g.: http://localhost:3000/mcp" : "e.g.: http://localhost:3000/sse"}
|
||||
required={serverType === 'sse' || serverType === 'streamable-http'}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
20
frontend/src/components/icons/DiscordIcon.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
export const DiscordIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>Discord</title>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscordIcon;
|
||||
21
frontend/src/components/icons/GitHubIcon.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export const GitHubIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default GitHubIcon;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChevronDown, ChevronRight, Edit, Trash, Copy, Check } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, Edit, Trash, Copy, Check, User, Settings, LogOut, Info } from 'lucide-react'
|
||||
|
||||
export { ChevronDown, ChevronRight, Edit, Trash, Copy, Check }
|
||||
export { ChevronDown, ChevronRight, Edit, Trash, Copy, Check, User, Settings, LogOut, Info }
|
||||
|
||||
const LucideIcons = {
|
||||
ChevronDown,
|
||||
@@ -8,7 +8,11 @@ const LucideIcons = {
|
||||
Edit,
|
||||
Trash,
|
||||
Copy,
|
||||
Check
|
||||
Check,
|
||||
User,
|
||||
Settings,
|
||||
LogOut,
|
||||
Info
|
||||
}
|
||||
|
||||
export default LucideIcons
|
||||
20
frontend/src/components/icons/SponsorIcon.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
export const SponsorIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>Sponsor</title>
|
||||
<path d="M17.625 1.499c-2.32 0-4.354 1.203-5.625 3.03-1.271-1.827-3.305-3.03-5.625-3.03C3.129 1.499 0 4.253 0 8.249c0 4.275 3.068 7.847 5.828 10.227a33.14 33.14 0 0 0 5.616 3.876l.028.017.008.003-.001.003c.163.085.342.126.521.125.179.001.358-.041.521-.125l-.001-.003.008-.003.028-.017a33.14 33.14 0 0 0 5.616-3.876C20.932 16.096 24 12.524 24 8.249c0-3.996-3.129-6.75-6.375-6.75zm-.919 15.275a30.766 30.766 0 0 1-4.703 3.316l-.004-.002-.004.002a30.955 30.955 0 0 1-4.703-3.316c-2.677-2.307-5.047-5.298-5.047-8.523 0-2.754 2.121-4.5 4.125-4.5 2.06 0 3.914 1.479 4.544 3.684.143.495.596.797 1.086.796.49.001.943-.302 1.085-.796.63-2.205 2.484-3.684 4.544-3.684 2.004 0 4.125 1.746 4.125 4.5 0 3.225-2.37 6.216-5.048 8.523z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default SponsorIcon;
|
||||
20
frontend/src/components/icons/WeChatIcon.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
export const WeChatIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>WeChat</title>
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeChatIcon;
|
||||
1
frontend/src/components/icons/discord.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/src/components/icons/github.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
||||
|
After Width: | Height: | Size: 822 B |
1
frontend/src/components/icons/sponsor.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub Sponsors</title><path d="M17.625 1.499c-2.32 0-4.354 1.203-5.625 3.03-1.271-1.827-3.305-3.03-5.625-3.03C3.129 1.499 0 4.253 0 8.249c0 4.275 3.068 7.847 5.828 10.227a33.14 33.14 0 0 0 5.616 3.876l.028.017.008.003-.001.003c.163.085.342.126.521.125.179.001.358-.041.521-.125l-.001-.003.008-.003.028-.017a33.14 33.14 0 0 0 5.616-3.876C20.932 16.096 24 12.524 24 8.249c0-3.996-3.129-6.75-6.375-6.75zm-.919 15.275a30.766 30.766 0 0 1-4.703 3.316l-.004-.002-.004.002a30.955 30.955 0 0 1-4.703-3.316c-2.677-2.307-5.047-5.298-5.047-8.523 0-2.754 2.121-4.5 4.125-4.5 2.06 0 3.914 1.479 4.544 3.684.143.495.596.797 1.086.796.49.001.943-.302 1.085-.796.63-2.205 2.484-3.684 4.544-3.684 2.004 0 4.125 1.746 4.125 4.5 0 3.225-2.37 6.216-5.048 8.523z"/></svg>
|
||||
|
After Width: | Height: | Size: 829 B |
1
frontend/src/components/icons/wechat.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>WeChat</title><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -6,8 +6,8 @@ interface ContentProps {
|
||||
|
||||
const Content: React.FC<ContentProps> = ({ children }) => {
|
||||
return (
|
||||
<main className="flex-1 p-6 overflow-auto">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<main className="flex-1 overflow-auto p-6 bg-gray-100 dark:bg-gray-900">
|
||||
<div className="container mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,59 +1,90 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
import GitHubIcon from '@/components/icons/GitHubIcon';
|
||||
import SponsorIcon from '@/components/icons/SponsorIcon';
|
||||
import WeChatIcon from '@/components/icons/WeChatIcon';
|
||||
import DiscordIcon from '@/components/icons/DiscordIcon';
|
||||
import SponsorDialog from '@/components/ui/SponsorDialog';
|
||||
import WeChatDialog from '@/components/ui/WeChatDialog';
|
||||
|
||||
interface HeaderProps {
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { auth, logout } = useAuth();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
const { t, i18n } = useTranslation();
|
||||
const { auth } = useAuth();
|
||||
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
|
||||
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="bg-white shadow-sm z-10">
|
||||
<header className="bg-white dark:bg-gray-800 shadow-sm z-10">
|
||||
<div className="flex justify-between items-center px-4 py-3">
|
||||
<div className="flex items-center">
|
||||
{/* 侧边栏切换按钮 */}
|
||||
<button
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="p-2 rounded-md text-gray-500 hover:text-gray-900 hover:bg-gray-100 focus:outline-none"
|
||||
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
|
||||
aria-label={t('app.toggleSidebar')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
|
||||
{/* 应用标题 */}
|
||||
<h1 className="ml-4 text-xl font-bold text-gray-900">{t('app.title')}</h1>
|
||||
<h1 className="ml-4 text-xl font-bold text-gray-900 dark:text-white">{t('app.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* 用户信息和操作 */}
|
||||
|
||||
{/* Theme Switch and Version */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{auth.user && (
|
||||
<span className="text-sm text-gray-700">
|
||||
{t('app.welcomeUser', { username: auth.user.username })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{import.meta.env.PACKAGE_VERSION === 'dev'
|
||||
? import.meta.env.PACKAGE_VERSION
|
||||
: `v${import.meta.env.PACKAGE_VERSION}`}
|
||||
</span>
|
||||
<a
|
||||
href="https://github.com/samanhappy/mcphub"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
aria-label="GitHub Repository"
|
||||
>
|
||||
<GitHubIcon className="h-5 w-5" />
|
||||
</a>
|
||||
{i18n.language === 'zh' ? (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-3 py-1.5 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
|
||||
onClick={() => setWechatDialogOpen(true)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none"
|
||||
aria-label={t('wechat.label')}
|
||||
>
|
||||
{t('app.logout')}
|
||||
<WeChatIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href="https://discord.gg/qMKNsn5Q"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
aria-label={t('discord.label')}
|
||||
>
|
||||
<DiscordIcon className="h-5 w-5" />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSponsorDialogOpen(true)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none"
|
||||
aria-label={t('sponsor.label')}
|
||||
>
|
||||
<SponsorIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
<SponsorDialog open={sponsorDialogOpen} onOpenChange={setSponsorDialogOpen} />
|
||||
<WeChatDialog open={wechatDialogOpen} onOpenChange={setWechatDialogOpen} />
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import UserProfileMenu from '@/components/ui/UserProfileMenu';
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
@@ -16,6 +17,9 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
// Application version from package.json (accessed via Vite environment variables)
|
||||
const appVersion = import.meta.env.PACKAGE_VERSION as string;
|
||||
|
||||
// Menu item configuration
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
@@ -56,11 +60,11 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
label: t('nav.settings'),
|
||||
path: '/logs',
|
||||
label: t('nav.logs'),
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
@@ -68,29 +72,37 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`bg-white shadow-sm transition-all duration-300 ease-in-out ${
|
||||
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${
|
||||
collapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
>
|
||||
<nav className="p-3 space-y-1">
|
||||
{menuItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-3 py-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`
|
||||
}
|
||||
end={item.path === '/'}
|
||||
>
|
||||
<span className="flex-shrink-0">{item.icon}</span>
|
||||
{!collapsed && <span className="ml-3">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
{/* Scrollable navigation area */}
|
||||
<div className="overflow-y-auto flex-grow">
|
||||
<nav className="p-3 space-y-1">
|
||||
{menuItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-3 py-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`
|
||||
}
|
||||
end={item.path === '/'}
|
||||
>
|
||||
<span className="flex-shrink-0">{item.icon}</span>
|
||||
{!collapsed && <span className="ml-3">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* User profile menu fixed at the bottom */}
|
||||
<div className="p-3 bg-white dark:bg-gray-800">
|
||||
<UserProfileMenu collapsed={collapsed} version={appVersion} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
112
frontend/src/components/ui/AboutDialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, RefreshCw } from 'lucide-react';
|
||||
import { checkLatestVersion, compareVersions } from '@/utils/version';
|
||||
|
||||
interface AboutDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const AboutDialog: React.FC<AboutDialogProps> = ({ isOpen, onClose, version }) => {
|
||||
const { t } = useTranslation();
|
||||
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const latest = await checkLatestVersion();
|
||||
if (latest) {
|
||||
setLatestVersion(latest);
|
||||
setHasNewVersion(compareVersions(version, latest) > 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
checkForUpdates();
|
||||
}
|
||||
}, [isOpen, version]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 bg-opacity-30 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6 relative">
|
||||
{/* Close button (X) in the top-right corner */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
{t('about.title')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{t('about.currentVersion')}:
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasNewVersion && latestVersion && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900 p-3 rounded">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-600 dark:text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3 flex-1 text-sm text-blue-700 dark:text-blue-300">
|
||||
<p>{t('about.newVersionAvailable', { version: latestVersion })}</p>
|
||||
<p className="mt-1">
|
||||
<a
|
||||
href="https://github.com/samanhappy/mcphub"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{t('about.viewOnGitHub')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={checkForUpdates}
|
||||
disabled={isChecking}
|
||||
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium
|
||||
${isChecking
|
||||
? 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800'
|
||||
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isChecking ? 'animate-spin' : ''}`} />
|
||||
{isChecking ? t('about.checking') : t('about.checkForUpdates')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutDialog;
|
||||
@@ -1,25 +1,61 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ServerStatus } from '@/types'
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ServerStatus } from '@/types';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
interface BadgeProps {
|
||||
status: ServerStatus
|
||||
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'destructive';
|
||||
|
||||
type BadgeProps = {
|
||||
children: React.ReactNode;
|
||||
variant?: BadgeVariant;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const badgeVariants = {
|
||||
default: 'bg-blue-500 text-white hover:bg-blue-600',
|
||||
secondary: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600',
|
||||
outline: 'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
destructive: 'bg-red-500 text-white hover:bg-red-600',
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
className,
|
||||
onClick
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
|
||||
badgeVariants[variant],
|
||||
onClick ? 'cursor-pointer' : '',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const Badge = ({ status }: BadgeProps) => {
|
||||
const { t } = useTranslation()
|
||||
// For backward compatibility with existing code
|
||||
export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const colors = {
|
||||
connecting: 'bg-yellow-100 text-yellow-800',
|
||||
connected: 'bg-green-100 text-green-800',
|
||||
disconnected: 'bg-red-100 text-red-800',
|
||||
}
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
const statusTranslations = {
|
||||
connected: 'status.online',
|
||||
disconnected: 'status.offline',
|
||||
connecting: 'status.connecting'
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -27,7 +63,5 @@ const Badge = ({ status }: BadgeProps) => {
|
||||
>
|
||||
{t(statusTranslations[status] || status)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default Badge
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
type ButtonVariant = 'default' | 'outline' | 'ghost' | 'link' | 'destructive';
|
||||
type ButtonSize = 'default' | 'sm' | 'lg' | 'icon';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
asChild?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
default: 'bg-blue-500 text-white hover:bg-blue-600 focus:ring-blue-500',
|
||||
outline: 'border border-gray-300 dark:border-gray-700 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300',
|
||||
ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300',
|
||||
link: 'bg-transparent underline-offset-4 hover:underline text-blue-500 hover:text-blue-600',
|
||||
destructive: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-500',
|
||||
};
|
||||
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
default: 'h-10 py-2 px-4',
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
lg: 'h-12 px-6',
|
||||
icon: 'h-10 w-10 p-0',
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
className,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-md inline-flex items-center justify-center font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none',
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
60
frontend/src/components/ui/SponsorDialog.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface SponsorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const SponsorDialog: React.FC<SponsorDialogProps> = ({ open, onOpenChange }) => {
|
||||
const { i18n, t } = useTranslation();
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6 relative">
|
||||
{/* Close button (X) in the top-right corner */}
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
{t('sponsor.title')}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
{i18n.language === 'zh' ? (
|
||||
<img
|
||||
src="/assets/reward.png"
|
||||
alt={t('sponsor.rewardAlt')}
|
||||
className="max-w-full h-auto"
|
||||
style={{ maxHeight: '400px' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<p className="mb-4 text-gray-700 dark:text-gray-300">{t('sponsor.supportMessage')}</p>
|
||||
<a
|
||||
href="https://ko-fi.com/samanhappy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center bg-[#13C3FF] text-white px-4 py-2 rounded-md hover:bg-[#00A5E5] transition-colors"
|
||||
>
|
||||
{t('sponsor.supportButton')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SponsorDialog;
|
||||
51
frontend/src/components/ui/ThemeSwitch.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||
|
||||
const ThemeSwitch: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex bg-gray-200 dark:bg-gray-700 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'light'
|
||||
? 'bg-white text-yellow-600 shadow'
|
||||
: 'text-black dark:text-gray-300 hover:text-yellow-600 dark:hover:text-yellow-500'
|
||||
}`}
|
||||
title={t('theme.light')}
|
||||
aria-label={t('theme.light')}
|
||||
>
|
||||
<Sun size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'dark'
|
||||
? 'bg-gray-800 text-blue-400 shadow'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400'
|
||||
}`}
|
||||
title={t('theme.dark')}
|
||||
aria-label={t('theme.dark')}
|
||||
>
|
||||
<Moon size={18} />
|
||||
</button>
|
||||
{/* <button
|
||||
onClick={() => setTheme('system')}
|
||||
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'system'
|
||||
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow'
|
||||
: 'text-black dark:text-gray-300 hover:text-green-600 dark:hover:text-green-400'
|
||||
}`}
|
||||
title={t('theme.system')}
|
||||
aria-label={t('theme.system')}
|
||||
>
|
||||
<Monitor size={18} />
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSwitch;
|
||||
131
frontend/src/components/ui/UserProfileMenu.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { User, Settings, LogOut, Info } from 'lucide-react';
|
||||
import AboutDialog from './AboutDialog';
|
||||
import { checkLatestVersion, compareVersions } from '@/utils/version';
|
||||
|
||||
interface UserProfileMenuProps {
|
||||
collapsed: boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { auth, logout } = useAuth();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showNewVersionInfo, setShowNewVersionInfo] = useState(false);
|
||||
const [showAboutDialog, setShowAboutDialog] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Check for new version on login and component mount
|
||||
useEffect(() => {
|
||||
const checkForNewVersion = async () => {
|
||||
try {
|
||||
const latestVersion = await checkLatestVersion();
|
||||
if (latestVersion) {
|
||||
setShowNewVersionInfo(compareVersions(version, latestVersion) > 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for new version:', error);
|
||||
}
|
||||
};
|
||||
|
||||
checkForNewVersion();
|
||||
}, [version]);
|
||||
|
||||
// Close the menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
navigate('/settings');
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleLogoutClick = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleAboutClick = () => {
|
||||
setShowAboutDialog(true);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`flex ${collapsed ? 'justify-center' : 'items-center'} w-full p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors rounded-md ${isOpen ? 'bg-gray-100 dark:bg-gray-700' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0 relative">
|
||||
<div className="w-7 h-7 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
|
||||
<User className="h-4 w-4 text-gray-700 dark:text-gray-300" />
|
||||
</div>
|
||||
{showNewVersionInfo && (
|
||||
<span className="absolute -top-1 -right-1 block w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="ml-3 flex flex-col items-start">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{auth.user?.username || t('auth.user')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-0 transform -translate-y-full left-0 w-48 bg-white dark:bg-gray-800 shadow-lg rounded-md py-1 z-50">
|
||||
<button
|
||||
onClick={handleSettingsClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
{t('nav.settings')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAboutClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 relative"
|
||||
>
|
||||
<Info className="h-4 w-4 mr-2" />
|
||||
{t('about.title')}
|
||||
{showNewVersionInfo && (
|
||||
<span className="absolute top-2 right-4 block w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogoutClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
{t('app.logout')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* About dialog */}
|
||||
<AboutDialog
|
||||
isOpen={showAboutDialog}
|
||||
onClose={() => setShowAboutDialog(false)}
|
||||
version={version}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfileMenu;
|
||||
49
frontend/src/components/ui/WeChatDialog.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface WeChatDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const WeChatDialog: React.FC<WeChatDialogProps> = ({ open, onOpenChange }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6 relative">
|
||||
{/* Close button (X) in the top-right corner */}
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
{t('wechat.title')}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<img
|
||||
src="/assets/wexin.png"
|
||||
alt={t('wechat.qrCodeAlt')}
|
||||
className="max-w-full h-auto"
|
||||
style={{ maxHeight: '400px' }}
|
||||
/>
|
||||
<p className="mt-4 text-center text-gray-700 dark:text-gray-300">
|
||||
{t('wechat.scanMessage')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeChatDialog;
|
||||
76
frontend/src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
resolvedTheme: 'light' | 'dark'; // The actual theme used after resolving system preference
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
// Get theme from localStorage or default to 'system'
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const savedTheme = localStorage.getItem('theme') as Theme;
|
||||
return savedTheme || 'system';
|
||||
});
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
|
||||
|
||||
// Function to set theme and save to localStorage
|
||||
const handleSetTheme = (newTheme: Theme) => {
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
};
|
||||
|
||||
// Effect to handle system theme changes and apply theme to document
|
||||
useEffect(() => {
|
||||
const updateTheme = () => {
|
||||
const root = window.document.documentElement;
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
|
||||
// Determine which theme to use
|
||||
const themeToApply = theme === 'system' ? systemTheme : theme;
|
||||
setResolvedTheme(themeToApply as 'light' | 'dark');
|
||||
|
||||
// Apply or remove dark class based on theme
|
||||
if (themeToApply === 'dark') {
|
||||
console.log('Applying dark mode to HTML root element'); // 添加日志
|
||||
root.classList.add('dark');
|
||||
document.body.style.backgroundColor = '#111827'; // Force a dark background to ensure visible effect
|
||||
} else {
|
||||
console.log('Removing dark mode from HTML root element'); // 添加日志
|
||||
root.classList.remove('dark');
|
||||
document.body.style.backgroundColor = ''; // Reset background color
|
||||
}
|
||||
};
|
||||
|
||||
// Set up listeners for system theme changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', updateTheme);
|
||||
|
||||
// Initial theme setup
|
||||
updateTheme();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', updateTheme);
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme: handleSetTheme, resolvedTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -52,7 +52,7 @@ export const useMarketData = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t, currentPage]);
|
||||
}, [t]);
|
||||
|
||||
// Apply pagination to data
|
||||
const applyPagination = useCallback((data: MarketServer[], page: number, itemsPerPage = serversPerPage) => {
|
||||
|
||||
@@ -7,14 +7,26 @@ import { useToast } from '@/contexts/ToastContext';
|
||||
interface RoutingConfig {
|
||||
enableGlobalRoute: boolean;
|
||||
enableGroupNameRoute: boolean;
|
||||
enableBearerAuth: boolean;
|
||||
bearerAuthKey: string;
|
||||
}
|
||||
|
||||
interface InstallConfig {
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
systemConfig?: {
|
||||
routing?: RoutingConfig;
|
||||
install?: InstallConfig;
|
||||
};
|
||||
}
|
||||
|
||||
interface TempRoutingConfig {
|
||||
bearerAuthKey: string;
|
||||
}
|
||||
|
||||
export const useSettingsData = () => {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
@@ -22,6 +34,17 @@ export const useSettingsData = () => {
|
||||
const [routingConfig, setRoutingConfig] = useState<RoutingConfig>({
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
});
|
||||
|
||||
const [tempRoutingConfig, setTempRoutingConfig] = useState<TempRoutingConfig>({
|
||||
bearerAuthKey: '',
|
||||
});
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<InstallConfig>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -56,6 +79,14 @@ export const useSettingsData = () => {
|
||||
setRoutingConfig({
|
||||
enableGlobalRoute: data.data.systemConfig.routing.enableGlobalRoute ?? true,
|
||||
enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true,
|
||||
enableBearerAuth: data.data.systemConfig.routing.enableBearerAuth ?? false,
|
||||
bearerAuthKey: data.data.systemConfig.routing.bearerAuthKey || '',
|
||||
});
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.install) {
|
||||
setInstallConfig({
|
||||
pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '',
|
||||
npmRegistry: data.data.systemConfig.install.npmRegistry || '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -68,7 +99,10 @@ export const useSettingsData = () => {
|
||||
}, [t, showToast]);
|
||||
|
||||
// Update routing configuration
|
||||
const updateRoutingConfig = async (key: keyof RoutingConfig, value: boolean) => {
|
||||
const updateRoutingConfig = async <T extends keyof RoutingConfig>(
|
||||
key: T,
|
||||
value: RoutingConfig[T],
|
||||
) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -100,6 +134,53 @@ export const useSettingsData = () => {
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(t('errors.failedToUpdateRouteConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update routing config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update routing config');
|
||||
showToast(t('errors.failedToUpdateRouteConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update install configuration
|
||||
const updateInstallConfig = async (key: keyof InstallConfig, value: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/system-config', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
install: {
|
||||
[key]: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
@@ -119,13 +200,25 @@ export const useSettingsData = () => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (routingConfig) {
|
||||
setTempRoutingConfig({
|
||||
bearerAuthKey: routingConfig.bearerAuthKey,
|
||||
});
|
||||
}
|
||||
}, [routingConfig]);
|
||||
|
||||
return {
|
||||
routingConfig,
|
||||
tempRoutingConfig,
|
||||
setTempRoutingConfig,
|
||||
installConfig,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
triggerRefresh,
|
||||
fetchSettings,
|
||||
updateRoutingConfig,
|
||||
updateInstallConfig,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Use standard Tailwind directives */
|
||||
/* Use project's custom Tailwind import */
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Add some custom styles to verify CSS is working correctly */
|
||||
@@ -11,6 +11,52 @@ body {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Dark mode override styles - these will apply when dark class is on html element */
|
||||
.dark body {
|
||||
background-color: #111827;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.dark .bg-white {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-900 {
|
||||
color: #f9fafb !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-800 {
|
||||
color: #f3f4f6 !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-700 {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-600 {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-500 {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-300 {
|
||||
border-color: #4b5563 !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-100 {
|
||||
background-color: #374151 !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-50 {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.dark .shadow {
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px 0 rgba(0, 0, 0, 0.24) !important;
|
||||
}
|
||||
|
||||
.bg-custom-blue {
|
||||
background-color: #4a90e2;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const MainLayout: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-100">
|
||||
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900">
|
||||
{/* 顶部导航 */}
|
||||
<Header onToggleSidebar={toggleSidebar} />
|
||||
|
||||
|
||||
@@ -12,6 +12,44 @@
|
||||
"welcomeUser": "Welcome, {{username}}",
|
||||
"name": "MCP Hub"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"versionInfo": "MCP Hub Version: {{version}}",
|
||||
"newVersion": "New version available!",
|
||||
"currentVersion": "Current version",
|
||||
"newVersionAvailable": "New version {{version}} is available",
|
||||
"viewOnGitHub": "View on GitHub",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"checking": "Checking for updates..."
|
||||
},
|
||||
"profile": {
|
||||
"viewProfile": "View profile",
|
||||
"userCenter": "User Center"
|
||||
},
|
||||
"sponsor": {
|
||||
"label": "Sponsor",
|
||||
"title": "Support the Project",
|
||||
"rewardAlt": "Reward QR Code",
|
||||
"supportMessage": "Support the development of MCP Hub by buying me a coffee!",
|
||||
"supportButton": "Support on Ko-fi"
|
||||
},
|
||||
"wechat": {
|
||||
"label": "WeChat",
|
||||
"title": "Connect via WeChat",
|
||||
"qrCodeAlt": "WeChat QR Code",
|
||||
"scanMessage": "Scan this QR code to connect with us on WeChat"
|
||||
},
|
||||
"discord": {
|
||||
"label": "Discord",
|
||||
"title": "Join our Discord server",
|
||||
"community": "Join our growing community on Discord for support, discussions, and updates!"
|
||||
},
|
||||
"theme": {
|
||||
"title": "Theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"loginTitle": "Login to MCP Hub",
|
||||
@@ -99,7 +137,8 @@
|
||||
"delete": "Delete",
|
||||
"copy": "Copy",
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyFailed": "Copy failed"
|
||||
"copyFailed": "Copy failed",
|
||||
"close": "Close"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -107,7 +146,8 @@
|
||||
"groups": "Groups",
|
||||
"settings": "Settings",
|
||||
"changePassword": "Change Password",
|
||||
"market": "Market"
|
||||
"market": "Market",
|
||||
"logs": "Logs"
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
@@ -130,12 +170,29 @@
|
||||
"account": "Account Settings",
|
||||
"password": "Change Password",
|
||||
"appearance": "Appearance",
|
||||
"routeConfig": "Route Configuration"
|
||||
"routeConfig": "Security Configuration",
|
||||
"installConfig": "Installation Configuration"
|
||||
},
|
||||
"market": {
|
||||
"title": "Server Market - (Data from mcpm.sh)"
|
||||
},
|
||||
"logs": {
|
||||
"title": "System Logs"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"filters": "Filters",
|
||||
"search": "Search logs...",
|
||||
"autoScroll": "Auto-scroll",
|
||||
"clearLogs": "Clear logs",
|
||||
"loading": "Loading logs...",
|
||||
"noLogs": "No logs available.",
|
||||
"noMatch": "No logs match the current filters.",
|
||||
"mainProcess": "Main Process",
|
||||
"childProcess": "Child Process",
|
||||
"main": "Main",
|
||||
"child": "Child"
|
||||
},
|
||||
"groups": {
|
||||
"add": "Add",
|
||||
"addNew": "Add New Group",
|
||||
@@ -209,6 +266,18 @@
|
||||
"enableGlobalRouteDescription": "Allow connections to /sse endpoint without specifying a group ID",
|
||||
"enableGroupNameRoute": "Enable Group Name Route",
|
||||
"enableGroupNameRouteDescription": "Allow connections to /sse endpoint using group names instead of just group IDs",
|
||||
"enableBearerAuth": "Enable Bearer Authentication",
|
||||
"enableBearerAuthDescription": "Require bearer token authentication for MCP requests",
|
||||
"bearerAuthKey": "Bearer Authentication Key",
|
||||
"bearerAuthKeyDescription": "The authentication key that will be required in the Bearer token",
|
||||
"bearerAuthKeyPlaceholder": "Enter bearer authentication key",
|
||||
"pythonIndexUrl": "Python Package Repository URL",
|
||||
"pythonIndexUrlDescription": "Set UV_DEFAULT_INDEX environment variable for Python package installation",
|
||||
"pythonIndexUrlPlaceholder": "e.g. https://pypi.org/simple",
|
||||
"npmRegistry": "NPM Registry URL",
|
||||
"npmRegistryDescription": "Set npm_config_registry environment variable for NPM package installation",
|
||||
"npmRegistryPlaceholder": "e.g. https://registry.npmjs.org/",
|
||||
"installConfig": "Installation Configuration",
|
||||
"systemConfigUpdated": "System configuration updated successfully"
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,44 @@
|
||||
"welcomeUser": "欢迎, {{username}}",
|
||||
"name": "MCP Hub"
|
||||
},
|
||||
"about": {
|
||||
"title": "关于",
|
||||
"versionInfo": "MCP Hub 版本: {{version}}",
|
||||
"newVersion": "有新版本可用!",
|
||||
"currentVersion": "当前版本",
|
||||
"newVersionAvailable": "新版本 {{version}} 已发布",
|
||||
"viewOnGitHub": "在 GitHub 上查看",
|
||||
"checkForUpdates": "检查更新",
|
||||
"checking": "检查更新中..."
|
||||
},
|
||||
"profile": {
|
||||
"viewProfile": "查看个人中心",
|
||||
"userCenter": "个人中心"
|
||||
},
|
||||
"sponsor": {
|
||||
"label": "赞助",
|
||||
"title": "支持项目",
|
||||
"rewardAlt": "赞赏码",
|
||||
"supportMessage": "通过捐赠支持 MCP Hub 的开发!",
|
||||
"supportButton": "在 Ko-fi 上支持"
|
||||
},
|
||||
"wechat": {
|
||||
"label": "微信",
|
||||
"title": "微信联系",
|
||||
"qrCodeAlt": "微信二维码",
|
||||
"scanMessage": "扫描二维码添加微信"
|
||||
},
|
||||
"discord": {
|
||||
"label": "Discord",
|
||||
"title": "加入我们的 Discord 服务器",
|
||||
"community": "加入我们不断壮大的 Discord 社区,获取支持、参与讨论并了解最新动态!"
|
||||
},
|
||||
"theme": {
|
||||
"title": "主题",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"system": "系统"
|
||||
},
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
"loginTitle": "登录 MCP Hub",
|
||||
@@ -87,7 +125,8 @@
|
||||
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...",
|
||||
"serverInstall": "安装服务器失败",
|
||||
"failedToFetchSettings": "获取设置失败",
|
||||
"failedToUpdateSystemConfig": "更新系统配置失败"
|
||||
"failedToUpdateSystemConfig": "更新系统配置失败",
|
||||
"failedToUpdateRouteConfig": "更新路由配置失败"
|
||||
},
|
||||
"common": {
|
||||
"processing": "处理中...",
|
||||
@@ -99,7 +138,8 @@
|
||||
"delete": "删除",
|
||||
"copy": "复制",
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyFailed": "复制失败"
|
||||
"copyFailed": "复制失败",
|
||||
"close": "关闭"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
@@ -107,7 +147,8 @@
|
||||
"settings": "设置",
|
||||
"changePassword": "修改密码",
|
||||
"groups": "分组",
|
||||
"market": "市场"
|
||||
"market": "市场",
|
||||
"logs": "日志"
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
@@ -127,15 +168,32 @@
|
||||
"account": "账户设置",
|
||||
"password": "修改密码",
|
||||
"appearance": "外观",
|
||||
"routeConfig": "路由配置"
|
||||
"routeConfig": "安全配置",
|
||||
"installConfig": "安装配置"
|
||||
},
|
||||
"groups": {
|
||||
"title": "分组管理"
|
||||
},
|
||||
"market": {
|
||||
"title": "服务器市场 - (数据来源于 mcpm.sh)"
|
||||
},
|
||||
"logs": {
|
||||
"title": "系统日志"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"filters": "筛选",
|
||||
"search": "搜索日志...",
|
||||
"autoScroll": "自动滚动",
|
||||
"clearLogs": "清除日志",
|
||||
"loading": "加载日志中...",
|
||||
"noLogs": "暂无日志。",
|
||||
"noMatch": "没有匹配当前筛选条件的日志。",
|
||||
"mainProcess": "主进程",
|
||||
"childProcess": "子进程",
|
||||
"main": "主",
|
||||
"child": "子"
|
||||
},
|
||||
"groups": {
|
||||
"add": "添加",
|
||||
"addNew": "添加新分组",
|
||||
@@ -206,9 +264,21 @@
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "启用全局路由",
|
||||
"enableGlobalRouteDescription": "允许不指定分组 ID 就连接到 /sse 端点",
|
||||
"enableGroupNameRoute": "启用分组名称路由",
|
||||
"enableGroupNameRouteDescription": "允许使用分组名称而非分组 ID 连接到 /sse 端点",
|
||||
"enableGlobalRouteDescription": "允许不指定组 ID 就连接到 /sse 端点",
|
||||
"enableGroupNameRoute": "启用组名路由",
|
||||
"enableGroupNameRouteDescription": "允许使用组名而不仅仅是组 ID 连接到 /sse 端点",
|
||||
"enableBearerAuth": "启用 Bearer 认证",
|
||||
"enableBearerAuthDescription": "对 MCP 请求启用 Bearer 令牌认证",
|
||||
"bearerAuthKey": "Bearer 认证密钥",
|
||||
"bearerAuthKeyDescription": "Bearer 令牌中需要携带的认证密钥",
|
||||
"bearerAuthKeyPlaceholder": "请输入 Bearer 认证密钥",
|
||||
"pythonIndexUrl": "Python 包仓库地址",
|
||||
"pythonIndexUrlDescription": "设置 UV_DEFAULT_INDEX 环境变量,用于 Python 包安装",
|
||||
"pythonIndexUrlPlaceholder": "例如: https://mirrors.aliyun.com/pypi/simple",
|
||||
"npmRegistry": "NPM 仓库地址",
|
||||
"npmRegistryDescription": "设置 npm_config_registry 环境变量,用于 NPM 包安装",
|
||||
"npmRegistryPlaceholder": "例如: https://registry.npmmirror.com/",
|
||||
"installConfig": "安装配置",
|
||||
"systemConfigUpdated": "系统配置更新成功"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -39,10 +40,13 @@ const LoginPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="absolute top-4 right-4">
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
{t('auth.loginTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -58,7 +62,7 @@ const LoginPage: React.FC = () => {
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder={t('auth.username')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -74,7 +78,7 @@ const LoginPage: React.FC = () => {
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder={t('auth.password')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -83,7 +87,7 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 text-sm text-center">{error}</div>
|
||||
<div className="text-red-500 dark:text-red-400 text-sm text-center">{error}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
|
||||
28
frontend/src/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
// filepath: /Users/sunmeng/code/github/mcphub/frontend/src/pages/LogsPage.tsx
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LogViewer from '../components/LogViewer';
|
||||
import { useLogs } from '../services/logService';
|
||||
|
||||
const LogsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { logs, loading, error, clearLogs } = useLogs();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold">{t('pages.logs.title')}</h1>
|
||||
</div>
|
||||
<div className="bg-card rounded-md shadow-sm">
|
||||
<LogViewer
|
||||
logs={logs}
|
||||
isLoading={loading}
|
||||
error={error}
|
||||
onClear={clearLogs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogsPage;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Server } from '@/types';
|
||||
import ServerCard from '@/components/ServerCard';
|
||||
import AddServerForm from '@/components/AddServerForm';
|
||||
@@ -8,6 +9,7 @@ import { useServerData } from '@/hooks/useServerData';
|
||||
|
||||
const ServersPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
servers,
|
||||
error,
|
||||
@@ -50,6 +52,15 @@ const ServersPage: React.FC = () => {
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t('pages.servers.title')}</h1>
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={() => navigate('/market')}
|
||||
className="px-4 py-2 bg-emerald-100 text-emerald-800 rounded hover:bg-emerald-200 flex items-center"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3z" />
|
||||
</svg>
|
||||
{t('nav.market')}
|
||||
</button>
|
||||
<AddServerForm onAdd={handleServerAdd} />
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -17,26 +18,77 @@ const SettingsPage: React.FC = () => {
|
||||
setCurrentLanguage(i18n.language);
|
||||
}, [i18n.language]);
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
}>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
});
|
||||
|
||||
const {
|
||||
routingConfig,
|
||||
tempRoutingConfig,
|
||||
setTempRoutingConfig,
|
||||
installConfig: savedInstallConfig,
|
||||
loading,
|
||||
updateRoutingConfig
|
||||
updateRoutingConfig,
|
||||
updateInstallConfig
|
||||
} = useSettingsData();
|
||||
|
||||
// Update local installConfig when savedInstallConfig changes
|
||||
useEffect(() => {
|
||||
if (savedInstallConfig) {
|
||||
setInstallConfig(savedInstallConfig);
|
||||
}
|
||||
}, [savedInstallConfig]);
|
||||
|
||||
const [sectionsVisible, setSectionsVisible] = useState({
|
||||
routingConfig: false,
|
||||
installConfig: false,
|
||||
password: false
|
||||
});
|
||||
|
||||
const toggleSection = (section: 'routingConfig' | 'password') => {
|
||||
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'password') => {
|
||||
setSectionsVisible(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute', value: boolean) => {
|
||||
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute' | 'enableBearerAuth' | 'bearerAuthKey', value: boolean | string) => {
|
||||
await updateRoutingConfig(key, value);
|
||||
|
||||
// If enableBearerAuth is turned on and there's no key, generate one
|
||||
if (key === 'enableBearerAuth' && value === true) {
|
||||
if (!tempRoutingConfig.bearerAuthKey) {
|
||||
const newKey = generateRandomKey();
|
||||
handleBearerAuthKeyChange(newKey);
|
||||
await updateRoutingConfig('bearerAuthKey', newKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBearerAuthKeyChange = (value: string) => {
|
||||
setTempRoutingConfig(prev => ({
|
||||
...prev,
|
||||
bearerAuthKey: value
|
||||
}));
|
||||
};
|
||||
|
||||
const saveBearerAuthKey = async () => {
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
|
||||
};
|
||||
|
||||
const handleInstallConfigChange = (key: 'pythonIndexUrl' | 'npmRegistry', value: string) => {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry') => {
|
||||
await updateInstallConfig(key, installConfig[key]);
|
||||
};
|
||||
|
||||
const handlePasswordChangeSuccess = () => {
|
||||
@@ -51,30 +103,28 @@ const SettingsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="container mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1>
|
||||
|
||||
{/* Language Settings */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-800">{t('pages.settings.language')}</h2>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.language')}</h2>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${
|
||||
currentLanguage.startsWith('en')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||
}`}
|
||||
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${currentLanguage.startsWith('en')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||
}`}
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${
|
||||
currentLanguage.startsWith('zh')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||
}`}
|
||||
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${currentLanguage.startsWith('zh')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||
}`}
|
||||
onClick={() => handleLanguageChange('zh')}
|
||||
>
|
||||
中文
|
||||
@@ -84,12 +134,12 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Route Configuration Settings */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('routingConfig')}
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.routingConfig ? '▼' : '►'}
|
||||
</span>
|
||||
@@ -97,6 +147,44 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
{sectionsVisible.routingConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableBearerAuth')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableBearerAuthDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableBearerAuth}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableBearerAuth', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{routingConfig.enableBearerAuth && (
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.bearerAuthKey')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.bearerAuthKeyDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempRoutingConfig.bearerAuthKey}
|
||||
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
|
||||
placeholder={t('settings.bearerAuthKeyPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
/>
|
||||
<button
|
||||
onClick={saveBearerAuthKey}
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
|
||||
@@ -120,17 +208,83 @@ const SettingsPage: React.FC = () => {
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableGroupNameRoute', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Installation Configuration Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('installConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.installConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.installConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.pythonIndexUrl')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.pythonIndexUrlDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={installConfig.pythonIndexUrl}
|
||||
onChange={(e) => handleInstallConfigChange('pythonIndexUrl', e.target.value)}
|
||||
placeholder={t('settings.pythonIndexUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveInstallConfig('pythonIndexUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.npmRegistry')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.npmRegistryDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={installConfig.npmRegistry}
|
||||
onChange={(e) => handleInstallConfigChange('npmRegistry', e.target.value)}
|
||||
placeholder={t('settings.npmRegistryPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveInstallConfig('npmRegistry')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Change Password */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('password')}
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-800">{t('auth.changePassword')}</h2>
|
||||
<h2 className="font-semibold text-gray-800">{t('auth.changePassword')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.password ? '▼' : '►'}
|
||||
</span>
|
||||
|
||||
152
frontend/src/services/logService.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getToken } from './authService'; // Import getToken function
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number;
|
||||
type: 'info' | 'error' | 'warn' | 'debug';
|
||||
source: string;
|
||||
message: string;
|
||||
processId?: string;
|
||||
}
|
||||
|
||||
// Fetch all logs
|
||||
export const fetchLogs = async (): Promise<LogEntry[]> => {
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/logs', {
|
||||
headers: {
|
||||
'x-auth-token': token
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to fetch logs');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all logs
|
||||
export const clearLogs = async (): Promise<void> => {
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/logs', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to clear logs');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing logs:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Hook to use logs with SSE streaming
|
||||
export const useLogs = () => {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let eventSource: EventSource | null = null;
|
||||
let isMounted = true;
|
||||
|
||||
const connectToLogStream = () => {
|
||||
try {
|
||||
// Close existing connection if any
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
// Get the authentication token
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
setError(new Error('Authentication token not found. Please log in.'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to SSE endpoint with auth token in URL
|
||||
eventSource = new EventSource(`/api/logs/stream?token=${token}`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
if (!isMounted) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'initial') {
|
||||
setLogs(data.logs);
|
||||
setLoading(false);
|
||||
} else if (data.type === 'log') {
|
||||
setLogs(prevLogs => [...prevLogs, data.log]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing SSE message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
if (!isMounted) return;
|
||||
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
// Attempt to reconnect after a delay
|
||||
setTimeout(connectToLogStream, 5000);
|
||||
}
|
||||
|
||||
setError(new Error('Connection to log stream lost, attempting to reconnect...'));
|
||||
};
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
setError(err instanceof Error ? err : new Error('Failed to connect to log stream'));
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial connection
|
||||
connectToLogStream();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearAllLogs = async () => {
|
||||
try {
|
||||
await clearLogs();
|
||||
setLogs([]);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to clear logs'));
|
||||
}
|
||||
};
|
||||
|
||||
return { logs, loading, error, clearLogs: clearAllLogs };
|
||||
};
|
||||
@@ -71,6 +71,7 @@ export interface Tool {
|
||||
|
||||
// Server config types
|
||||
export interface ServerConfig {
|
||||
type?: 'stdio' | 'sse' | 'streamable-http';
|
||||
url?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
@@ -108,6 +109,8 @@ export interface ServerFormData {
|
||||
url: string;
|
||||
command: string;
|
||||
arguments: string;
|
||||
args?: string[]; // Added explicit args field
|
||||
type?: 'stdio' | 'sse' | 'streamable-http'; // Added type field
|
||||
env: EnvVar[];
|
||||
}
|
||||
|
||||
@@ -157,4 +160,4 @@ export interface AuthResponse {
|
||||
token?: string;
|
||||
user?: IUser;
|
||||
message?: string;
|
||||
}
|
||||
}
|
||||
|
||||
8
frontend/src/utils/key.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function generateRandomKey(length: number = 32): string {
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const array = new Uint8Array(length);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array)
|
||||
.map((x) => characters.charAt(x % characters.length))
|
||||
.join('');
|
||||
}
|
||||
32
frontend/src/utils/version.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
||||
const PACKAGE_NAME = '@samanhappy/mcphub';
|
||||
|
||||
export const checkLatestVersion = async (): Promise<string | null> => {
|
||||
try {
|
||||
const response = await fetch(`${NPM_REGISTRY}/${PACKAGE_NAME}/latest`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch latest version: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.version || null;
|
||||
} catch (error) {
|
||||
console.error('Error checking for latest version:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const compareVersions = (current: string, latest: string): number => {
|
||||
if (current === 'dev') return -1;
|
||||
const currentParts = current.split('.').map(Number);
|
||||
const latestParts = latest.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const currentPart = currentParts[i] || 0;
|
||||
const latestPart = latestParts[i] || 0;
|
||||
|
||||
if (currentPart > latestPart) return -1;
|
||||
if (currentPart < latestPart) return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
9
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: {
|
||||
readonly PACKAGE_VERSION: string;
|
||||
// Add other custom env variables here if needed
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
12
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class', // Use class strategy for dark mode
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
// Import the package.json to get the version
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
// Get package.json version
|
||||
const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8'));
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -11,6 +16,13 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
// Make package version available as global variable
|
||||
'import.meta.env.PACKAGE_VERSION': JSON.stringify(packageJson.version),
|
||||
},
|
||||
build: {
|
||||
sourcemap: true, // Enable source maps for production build
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
86
package.json
@@ -1,77 +1,99 @@
|
||||
{
|
||||
"name": "mcphub",
|
||||
"version": "0.0.1",
|
||||
"name": "@samanhappy/mcphub",
|
||||
"version": "dev",
|
||||
"description": "A hub server for mcp servers",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"mcphub": "bin/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"mcp_settings.json",
|
||||
"servers.json",
|
||||
"frontend/dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "pnpm backend:build && pnpm frontend:build",
|
||||
"backend:build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"backend:dev": "tsx watch src/index.ts",
|
||||
"backend:debug": "tsx watch src/index.ts --inspect",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"test": "jest",
|
||||
"frontend:dev": "cd frontend && vite",
|
||||
"frontend:build": "cd frontend && vite build",
|
||||
"frontend:preview": "cd frontend && vite preview",
|
||||
"dev": "concurrently \"pnpm backend:dev\" \"pnpm frontend:dev\""
|
||||
"dev": "concurrently \"pnpm backend:dev\" \"pnpm frontend:dev\"",
|
||||
"debug": "concurrently \"pnpm backend:debug\" \"pnpm frontend:dev\"",
|
||||
"prepublishOnly": "npm run build && node scripts/verify-dist.js"
|
||||
},
|
||||
"keywords": [
|
||||
"typescript",
|
||||
"server"
|
||||
"server",
|
||||
"mcp",
|
||||
"model context protocol"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"@modelcontextprotocol/sdk": "^1.11.1",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-validator": "^7.2.1",
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.486.0",
|
||||
"next": "^15.2.4",
|
||||
"postcss": "^8.5.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.5.0",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"tailwindcss": "^4.0.17",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.24.2"
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@tailwindcss/postcss": "^4.1.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.5",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^20.8.2",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.50.0",
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"jest": "^29.7.0",
|
||||
"lucide-react": "^0.486.0",
|
||||
"next": "^15.2.4",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.0.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"tailwindcss": "^4.0.17",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.4.18"
|
||||
}
|
||||
"vite": "^5.4.18",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha256.fa0f513aa8191764d2b6b432420788c270f07b4f999099b65bb2010eec702a30"
|
||||
}
|
||||
363
pnpm-lock.yaml
generated
@@ -9,41 +9,11 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react@19.0.12)(react@19.1.0)
|
||||
'@shadcn/ui':
|
||||
specifier: ^0.0.4
|
||||
version: 0.0.4
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.3(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))
|
||||
'@types/react':
|
||||
specifier: ^19.0.12
|
||||
version: 19.0.12
|
||||
'@types/react-dom':
|
||||
specifier: ^19.0.4
|
||||
version: 19.0.4(@types/react@19.0.12)
|
||||
'@types/uuid':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
autoprefixer:
|
||||
specifier: ^10.4.21
|
||||
version: 10.4.21(postcss@8.5.3)
|
||||
specifier: ^1.11.1
|
||||
version: 1.11.1
|
||||
bcryptjs:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
dotenv:
|
||||
specifier: ^16.3.1
|
||||
version: 16.4.7
|
||||
@@ -53,55 +23,28 @@ importers:
|
||||
express-validator:
|
||||
specifier: ^7.2.1
|
||||
version: 7.2.1
|
||||
i18next:
|
||||
specifier: ^24.2.3
|
||||
version: 24.2.3(typescript@5.8.2)
|
||||
i18next-browser-languagedetector:
|
||||
specifier: ^8.0.4
|
||||
version: 8.0.4
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
lucide-react:
|
||||
specifier: ^0.486.0
|
||||
version: 0.486.0(react@19.1.0)
|
||||
next:
|
||||
specifier: ^15.2.4
|
||||
version: 15.2.4(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
postcss:
|
||||
specifier: ^8.5.3
|
||||
version: 8.5.3
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.0
|
||||
react-dom:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.0(react@19.1.0)
|
||||
react-i18next:
|
||||
specifier: ^15.4.1
|
||||
version: 15.4.1(i18next@24.2.3(typescript@5.8.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-router-dom:
|
||||
specifier: ^7.5.0
|
||||
version: 7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
tailwind-merge:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
tailwind-scrollbar-hide:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0(tailwindcss@4.0.17)
|
||||
tailwindcss:
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17
|
||||
uuid:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
zod:
|
||||
specifier: ^3.24.2
|
||||
version: 3.24.2
|
||||
devDependencies:
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react@19.0.12)(react@19.1.0)
|
||||
'@shadcn/ui':
|
||||
specifier: ^0.0.4
|
||||
version: 0.0.4
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.3
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.3(vite@5.4.19(@types/node@20.17.28)(lightningcss@1.29.2))
|
||||
'@types/bcryptjs':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
@@ -117,6 +60,15 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^20.8.2
|
||||
version: 20.17.28
|
||||
'@types/react':
|
||||
specifier: ^19.0.12
|
||||
version: 19.0.12
|
||||
'@types/react-dom':
|
||||
specifier: ^19.0.4
|
||||
version: 19.0.4(@types/react@19.0.12)
|
||||
'@types/uuid':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^6.7.4
|
||||
version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(typescript@5.8.2)
|
||||
@@ -125,19 +77,64 @@ importers:
|
||||
version: 6.21.0(eslint@8.57.1)(typescript@5.8.2)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.2.1
|
||||
version: 4.3.4(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))
|
||||
version: 4.3.4(vite@5.4.19(@types/node@20.17.28)(lightningcss@1.29.2))
|
||||
autoprefixer:
|
||||
specifier: ^10.4.21
|
||||
version: 10.4.21(postcss@8.5.3)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
concurrently:
|
||||
specifier: ^8.2.2
|
||||
version: 8.2.2
|
||||
eslint:
|
||||
specifier: ^8.50.0
|
||||
version: 8.57.1
|
||||
i18next:
|
||||
specifier: ^24.2.3
|
||||
version: 24.2.3(typescript@5.8.2)
|
||||
i18next-browser-languagedetector:
|
||||
specifier: ^8.0.4
|
||||
version: 8.0.4
|
||||
jest:
|
||||
specifier: ^29.7.0
|
||||
version: 29.7.0(@types/node@20.17.28)(ts-node@10.9.2(@types/node@20.17.28)(typescript@5.8.2))
|
||||
lucide-react:
|
||||
specifier: ^0.486.0
|
||||
version: 0.486.0(react@19.1.0)
|
||||
next:
|
||||
specifier: ^15.2.4
|
||||
version: 15.2.4(@babel/core@7.26.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
postcss:
|
||||
specifier: ^8.5.3
|
||||
version: 8.5.3
|
||||
prettier:
|
||||
specifier: ^3.0.3
|
||||
version: 3.5.3
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.0
|
||||
react-dom:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.0(react@19.1.0)
|
||||
react-i18next:
|
||||
specifier: ^15.4.1
|
||||
version: 15.4.1(i18next@24.2.3(typescript@5.8.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-router-dom:
|
||||
specifier: ^7.6.0
|
||||
version: 7.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
tailwind-merge:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
tailwind-scrollbar-hide:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0(tailwindcss@4.0.17)
|
||||
tailwindcss:
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17
|
||||
ts-jest:
|
||||
specifier: ^29.1.1
|
||||
version: 29.3.0(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(jest@29.7.0(@types/node@20.17.28)(ts-node@10.9.2(@types/node@20.17.28)(typescript@5.8.2)))(typescript@5.8.2)
|
||||
@@ -152,7 +149,10 @@ importers:
|
||||
version: 5.8.2
|
||||
vite:
|
||||
specifier: ^5.4.18
|
||||
version: 5.4.18(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
version: 5.4.19(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
zod:
|
||||
specifier: ^3.24.2
|
||||
version: 3.24.2
|
||||
|
||||
packages:
|
||||
|
||||
@@ -867,8 +867,8 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.9.0':
|
||||
resolution: {integrity: sha512-Jq2EUCQpe0iyO5FGpzVYDNFR6oR53AIrwph9yWl7uSc7IWUMsrmpmSaTGra5hQNunXpM+9oit85p924jWuHzUA==}
|
||||
'@modelcontextprotocol/sdk@1.11.1':
|
||||
resolution: {integrity: sha512-9LfmxKTb1v+vUS1/emSk1f5ePmTLkb9Le9AxOB5T0XM59EUumwcS45z05h7aiZx3GI0Bl7mjb3FMEglYj+acuQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@next/env@15.2.4':
|
||||
@@ -1074,103 +1074,103 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.40.0':
|
||||
resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==}
|
||||
'@rollup/rollup-android-arm-eabi@4.40.1':
|
||||
resolution: {integrity: sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.40.0':
|
||||
resolution: {integrity: sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==}
|
||||
'@rollup/rollup-android-arm64@4.40.1':
|
||||
resolution: {integrity: sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.40.0':
|
||||
resolution: {integrity: sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==}
|
||||
'@rollup/rollup-darwin-arm64@4.40.1':
|
||||
resolution: {integrity: sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.40.0':
|
||||
resolution: {integrity: sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==}
|
||||
'@rollup/rollup-darwin-x64@4.40.1':
|
||||
resolution: {integrity: sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.40.0':
|
||||
resolution: {integrity: sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==}
|
||||
'@rollup/rollup-freebsd-arm64@4.40.1':
|
||||
resolution: {integrity: sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.40.0':
|
||||
resolution: {integrity: sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==}
|
||||
'@rollup/rollup-freebsd-x64@4.40.1':
|
||||
resolution: {integrity: sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.40.0':
|
||||
resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==}
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.40.1':
|
||||
resolution: {integrity: sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
|
||||
resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==}
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.1':
|
||||
resolution: {integrity: sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==}
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.1':
|
||||
resolution: {integrity: sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==}
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.1':
|
||||
resolution: {integrity: sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==}
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.1':
|
||||
resolution: {integrity: sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==}
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.1':
|
||||
resolution: {integrity: sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==}
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.1':
|
||||
resolution: {integrity: sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==}
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.1':
|
||||
resolution: {integrity: sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==}
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.1':
|
||||
resolution: {integrity: sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==}
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.1':
|
||||
resolution: {integrity: sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==}
|
||||
'@rollup/rollup-linux-x64-musl@4.40.1':
|
||||
resolution: {integrity: sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==}
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.1':
|
||||
resolution: {integrity: sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==}
|
||||
'@rollup/rollup-win32-ia32-msvc@4.40.1':
|
||||
resolution: {integrity: sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==}
|
||||
'@rollup/rollup-win32-x64-msvc@4.40.1':
|
||||
resolution: {integrity: sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -1308,9 +1308,6 @@ packages:
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/cookie@0.6.0':
|
||||
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
|
||||
|
||||
'@types/estree@1.0.7':
|
||||
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
|
||||
|
||||
@@ -2805,6 +2802,7 @@ packages:
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
deprecated: Use your platform's native DOMException instead
|
||||
|
||||
node-fetch@3.3.2:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
@@ -3031,15 +3029,15 @@ packages:
|
||||
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
react-router-dom@7.5.0:
|
||||
resolution: {integrity: sha512-fFhGFCULy4vIseTtH5PNcY/VvDJK5gvOWcwJVHQp8JQcWVr85ENhJ3UpuF/zP1tQOIFYNRJHzXtyhU1Bdgw0RA==}
|
||||
react-router-dom@7.6.0:
|
||||
resolution: {integrity: sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
|
||||
react-router@7.5.0:
|
||||
resolution: {integrity: sha512-estOHrRlDMKdlQa6Mj32gIks4J+AxNsYoE0DbTTxiMy2mPzZuWSDU+N85/r1IlNR7kGfznF3VCUlvc5IUO+B9g==}
|
||||
react-router@7.6.0:
|
||||
resolution: {integrity: sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
@@ -3109,8 +3107,8 @@ packages:
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
hasBin: true
|
||||
|
||||
rollup@4.40.0:
|
||||
resolution: {integrity: sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==}
|
||||
rollup@4.40.1:
|
||||
resolution: {integrity: sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3419,9 +3417,6 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
turbo-stream@2.4.0:
|
||||
resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -3501,8 +3496,8 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
vite@5.4.18:
|
||||
resolution: {integrity: sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==}
|
||||
vite@5.4.19:
|
||||
resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -4268,7 +4263,7 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
'@modelcontextprotocol/sdk@1.9.0':
|
||||
'@modelcontextprotocol/sdk@1.11.1':
|
||||
dependencies:
|
||||
content-type: 1.0.5
|
||||
cors: 2.8.5
|
||||
@@ -4438,64 +4433,64 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.0.12
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.40.0':
|
||||
'@rollup/rollup-android-arm-eabi@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.40.0':
|
||||
'@rollup/rollup-android-arm64@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.40.0':
|
||||
'@rollup/rollup-darwin-arm64@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.40.0':
|
||||
'@rollup/rollup-darwin-x64@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.40.0':
|
||||
'@rollup/rollup-freebsd-arm64@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.40.0':
|
||||
'@rollup/rollup-freebsd-x64@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.40.0':
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.0':
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.0':
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.0':
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.0':
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.0':
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.40.0':
|
||||
'@rollup/rollup-linux-x64-musl@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.0':
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.40.0':
|
||||
'@rollup/rollup-win32-ia32-msvc@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.40.0':
|
||||
'@rollup/rollup-win32-x64-msvc@4.40.1':
|
||||
optional: true
|
||||
|
||||
'@shadcn/ui@0.0.4':
|
||||
@@ -4587,12 +4582,12 @@ snapshots:
|
||||
postcss: 8.5.3
|
||||
tailwindcss: 4.1.3
|
||||
|
||||
'@tailwindcss/vite@4.1.3(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))':
|
||||
'@tailwindcss/vite@4.1.3(vite@5.4.19(@types/node@20.17.28)(lightningcss@1.29.2))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.1.3
|
||||
'@tailwindcss/oxide': 4.1.3
|
||||
tailwindcss: 4.1.3
|
||||
vite: 5.4.18(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
vite: 5.4.19(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
|
||||
'@tsconfig/node10@1.0.11': {}
|
||||
|
||||
@@ -4636,8 +4631,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 20.17.28
|
||||
|
||||
'@types/cookie@0.6.0': {}
|
||||
|
||||
'@types/estree@1.0.7': {}
|
||||
|
||||
'@types/express-serve-static-core@4.19.6':
|
||||
@@ -4817,14 +4810,14 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@vitejs/plugin-react@4.3.4(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))':
|
||||
'@vitejs/plugin-react@4.3.4(vite@5.4.19(@types/node@20.17.28)(lightningcss@1.29.2))':
|
||||
dependencies:
|
||||
'@babel/core': 7.26.10
|
||||
'@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.10)
|
||||
'@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.10)
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.14.2
|
||||
vite: 5.4.18(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
vite: 5.4.19(@types/node@20.17.28)(lightningcss@1.29.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6651,19 +6644,17 @@ snapshots:
|
||||
|
||||
react-refresh@0.14.2: {}
|
||||
|
||||
react-router-dom@7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
react-router-dom@7.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
react-router: 7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-router: 7.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
|
||||
react-router@7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
react-router@7.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
'@types/cookie': 0.6.0
|
||||
cookie: 1.0.2
|
||||
react: 19.1.0
|
||||
set-cookie-parser: 2.7.1
|
||||
turbo-stream: 2.4.0
|
||||
optionalDependencies:
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
@@ -6716,30 +6707,30 @@ snapshots:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
|
||||
rollup@4.40.0:
|
||||
rollup@4.40.1:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.7
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.40.0
|
||||
'@rollup/rollup-android-arm64': 4.40.0
|
||||
'@rollup/rollup-darwin-arm64': 4.40.0
|
||||
'@rollup/rollup-darwin-x64': 4.40.0
|
||||
'@rollup/rollup-freebsd-arm64': 4.40.0
|
||||
'@rollup/rollup-freebsd-x64': 4.40.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.40.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.40.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.40.0
|
||||
'@rollup/rollup-linux-loongarch64-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-powerpc64le-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.40.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.40.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.40.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.40.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.40.0
|
||||
'@rollup/rollup-android-arm-eabi': 4.40.1
|
||||
'@rollup/rollup-android-arm64': 4.40.1
|
||||
'@rollup/rollup-darwin-arm64': 4.40.1
|
||||
'@rollup/rollup-darwin-x64': 4.40.1
|
||||
'@rollup/rollup-freebsd-arm64': 4.40.1
|
||||
'@rollup/rollup-freebsd-x64': 4.40.1
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.40.1
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.40.1
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.40.1
|
||||
'@rollup/rollup-linux-arm64-musl': 4.40.1
|
||||
'@rollup/rollup-linux-loongarch64-gnu': 4.40.1
|
||||
'@rollup/rollup-linux-powerpc64le-gnu': 4.40.1
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.40.1
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.40.1
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.40.1
|
||||
'@rollup/rollup-linux-x64-gnu': 4.40.1
|
||||
'@rollup/rollup-linux-x64-musl': 4.40.1
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.40.1
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.40.1
|
||||
'@rollup/rollup-win32-x64-msvc': 4.40.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
router@2.2.0:
|
||||
@@ -6790,7 +6781,7 @@ snapshots:
|
||||
|
||||
send@1.2.0:
|
||||
dependencies:
|
||||
debug: 4.3.6
|
||||
debug: 4.4.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
@@ -7088,8 +7079,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
turbo-stream@2.4.0: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -7149,11 +7138,11 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2):
|
||||
vite@5.4.19(@types/node@20.17.28)(lightningcss@1.29.2):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.5.3
|
||||
rollup: 4.40.0
|
||||
rollup: 4.40.1
|
||||
optionalDependencies:
|
||||
'@types/node': 20.17.28
|
||||
fsevents: 2.3.3
|
||||
|
||||
44
scripts/verify-dist.js
Executable file
@@ -0,0 +1,44 @@
|
||||
// scripts/verify-dist.js
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
|
||||
// Check if frontend dist exists
|
||||
const frontendDistPath = path.join(projectRoot, 'frontend', 'dist');
|
||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||
|
||||
if (!fs.existsSync(frontendDistPath)) {
|
||||
console.error('❌ Error: frontend/dist directory does not exist!');
|
||||
console.error('Run "npm run frontend:build" to generate the frontend dist files.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(frontendIndexPath)) {
|
||||
console.error('❌ Error: frontend/dist/index.html does not exist!');
|
||||
console.error('Frontend build may be incomplete. Run "npm run frontend:build" again.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if backend dist exists
|
||||
const backendDistPath = path.join(projectRoot, 'dist');
|
||||
const serverJsPath = path.join(backendDistPath, 'server.js');
|
||||
|
||||
if (!fs.existsSync(backendDistPath)) {
|
||||
console.error('❌ Error: dist directory does not exist!');
|
||||
console.error('Run "npm run backend:build" to generate the backend dist files.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(serverJsPath)) {
|
||||
console.error('❌ Error: dist/server.js does not exist!');
|
||||
console.error('Backend build may be incomplete. Run "npm run backend:build" again.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
console.log('✅ Verification passed! Frontend and backend dist files are present.');
|
||||
console.log('📦 Package is ready for publishing.');
|
||||
@@ -1,7 +1,8 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { McpSettings } from '../types/index.js';
|
||||
import { getConfigFilePath } from '../utils/path.js';
|
||||
import { getPackageVersion } from '../utils/version.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -10,11 +11,11 @@ const defaultConfig = {
|
||||
initTimeout: process.env.INIT_TIMEOUT || 300000,
|
||||
timeout: process.env.REQUEST_TIMEOUT || 60000,
|
||||
mcpHubName: 'mcphub',
|
||||
mcpHubVersion: '0.0.1',
|
||||
mcpHubVersion: getPackageVersion(),
|
||||
};
|
||||
|
||||
export const getSettingsPath = (): string => {
|
||||
return path.resolve(process.cwd(), 'mcp_settings.json');
|
||||
return getConfigFilePath('mcp_settings.json', 'Settings');
|
||||
};
|
||||
|
||||
export const loadSettings = (): McpSettings => {
|
||||
|
||||
55
src/controllers/logController.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// filepath: /Users/sunmeng/code/github/mcphub/src/controllers/logController.ts
|
||||
import { Request, Response } from 'express';
|
||||
import logService from '../services/logService.js';
|
||||
|
||||
// Get all logs
|
||||
export const getAllLogs = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const logs = logService.getLogs();
|
||||
res.json({ success: true, data: logs });
|
||||
} catch (error) {
|
||||
console.error('Error getting logs:', error);
|
||||
res.status(500).json({ success: false, error: 'Error getting logs' });
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all logs
|
||||
export const clearLogs = (req: Request, res: Response): void => {
|
||||
try {
|
||||
logService.clearLogs();
|
||||
res.json({ success: true, message: 'Logs cleared successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error clearing logs:', error);
|
||||
res.status(500).json({ success: false, error: 'Error clearing logs' });
|
||||
}
|
||||
};
|
||||
|
||||
// Stream logs via SSE
|
||||
export const streamLogs = (req: Request, res: Response): void => {
|
||||
try {
|
||||
// Set headers for SSE
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
});
|
||||
|
||||
// Send initial data
|
||||
const logs = logService.getLogs();
|
||||
res.write(`data: ${JSON.stringify({ type: 'initial', logs })}\n\n`);
|
||||
|
||||
// Subscribe to log events
|
||||
const unsubscribe = logService.subscribe((log) => {
|
||||
res.write(`data: ${JSON.stringify({ type: 'log', log })}\n\n`);
|
||||
});
|
||||
|
||||
// Handle client disconnect
|
||||
req.on('close', () => {
|
||||
unsubscribe();
|
||||
console.log('Client disconnected from log stream');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error streaming logs:', error);
|
||||
res.status(500).json({ success: false, error: 'Error streaming logs' });
|
||||
}
|
||||
};
|
||||
@@ -69,6 +69,24 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the server type if specified
|
||||
if (config.type && !['stdio', 'sse', 'streamable-http'].includes(config.type)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server type must be one of: stdio, sse, streamable-http',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that URL is provided for sse and streamable-http types
|
||||
if ((config.type === 'sse' || config.type === 'streamable-http') && !config.url) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `URL is required for ${config.type} server type`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await addServer(name, config);
|
||||
if (result.success) {
|
||||
notifyToolChanged();
|
||||
@@ -150,6 +168,24 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the server type if specified
|
||||
if (config.type && !['stdio', 'sse', 'streamable-http'].includes(config.type)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server type must be one of: stdio, sse, streamable-http',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that URL is provided for sse and streamable-http types
|
||||
if ((config.type === 'sse' || config.type === 'streamable-http') && !config.url) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `URL is required for ${config.type} server type`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await updateMcpServer(name, config);
|
||||
if (result.success) {
|
||||
notifyToolChanged();
|
||||
@@ -247,41 +283,83 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
|
||||
|
||||
export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { routing } = req.body;
|
||||
|
||||
if (!routing || (typeof routing.enableGlobalRoute !== 'boolean' && typeof routing.enableGroupNameRoute !== 'boolean')) {
|
||||
const { routing, install } = req.body;
|
||||
|
||||
if (
|
||||
(!routing ||
|
||||
(typeof routing.enableGlobalRoute !== 'boolean' &&
|
||||
typeof routing.enableGroupNameRoute !== 'boolean' &&
|
||||
typeof routing.enableBearerAuth !== 'boolean' &&
|
||||
typeof routing.bearerAuthKey !== 'string')) &&
|
||||
(!install ||
|
||||
(typeof install.pythonIndexUrl !== 'string' && typeof install.npmRegistry !== 'string'))
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid system configuration provided',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.systemConfig) {
|
||||
settings.systemConfig = {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true
|
||||
}
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
install: {
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (!settings.systemConfig.routing) {
|
||||
settings.systemConfig.routing = {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof routing.enableGlobalRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
||||
|
||||
if (!settings.systemConfig.install) {
|
||||
settings.systemConfig.install = {
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof routing.enableGroupNameRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
|
||||
|
||||
if (routing) {
|
||||
if (typeof routing.enableGlobalRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
||||
}
|
||||
|
||||
if (typeof routing.enableGroupNameRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
|
||||
}
|
||||
|
||||
if (typeof routing.enableBearerAuth === 'boolean') {
|
||||
settings.systemConfig.routing.enableBearerAuth = routing.enableBearerAuth;
|
||||
}
|
||||
|
||||
if (typeof routing.bearerAuthKey === 'string') {
|
||||
settings.systemConfig.routing.bearerAuthKey = routing.bearerAuthKey;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (install) {
|
||||
if (typeof install.pythonIndexUrl === 'string') {
|
||||
settings.systemConfig.install.pythonIndexUrl = install.pythonIndexUrl;
|
||||
}
|
||||
if (typeof install.npmRegistry === 'string') {
|
||||
settings.systemConfig.install.npmRegistry = install.npmRegistry;
|
||||
}
|
||||
}
|
||||
|
||||
if (saveSettings(settings)) {
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -6,8 +6,10 @@ const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
|
||||
|
||||
// Middleware to authenticate JWT token
|
||||
export const auth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
// Get token from header
|
||||
const token = req.header('x-auth-token');
|
||||
// Get token from header or query parameter
|
||||
const headerToken = req.header('x-auth-token');
|
||||
const queryToken = req.query.token as string;
|
||||
const token = headerToken || queryToken;
|
||||
|
||||
// Check if no token
|
||||
if (!token) {
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import fs from 'fs';
|
||||
import { auth } from './auth.js';
|
||||
import { initializeDefaultUser } from '../models/User.js';
|
||||
|
||||
// Create __dirname equivalent for ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Try to find the correct frontend file path
|
||||
const findFrontendPath = (): string => {
|
||||
// First try development environment path
|
||||
const devPath = path.join(dirname(__dirname), 'frontend', 'dist', 'index.html');
|
||||
if (fs.existsSync(devPath)) {
|
||||
return path.join(dirname(__dirname), 'frontend', 'dist');
|
||||
}
|
||||
|
||||
// Try npm/npx installed path (remove /dist directory)
|
||||
const npmPath = path.join(dirname(dirname(__dirname)), 'frontend', 'dist', 'index.html');
|
||||
if (fs.existsSync(npmPath)) {
|
||||
return path.join(dirname(dirname(__dirname)), 'frontend', 'dist');
|
||||
}
|
||||
|
||||
// If none of the above paths exist, return the most reasonable default path and log a warning
|
||||
console.warn('Warning: Could not locate frontend files. Using default path.');
|
||||
return path.join(dirname(__dirname), 'frontend', 'dist');
|
||||
};
|
||||
|
||||
const frontendPath = findFrontendPath();
|
||||
|
||||
export const errorHandler = (
|
||||
err: Error,
|
||||
_req: Request,
|
||||
@@ -17,7 +45,8 @@ export const errorHandler = (
|
||||
};
|
||||
|
||||
export const initMiddlewares = (app: express.Application): void => {
|
||||
app.use(express.static('frontend/dist'));
|
||||
// Serve static files from the dynamically determined frontend path
|
||||
app.use(express.static(frontendPath));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.path !== '/sse' && req.path !== '/messages') {
|
||||
@@ -36,7 +65,8 @@ export const initMiddlewares = (app: express.Application): void => {
|
||||
app.use('/api', auth);
|
||||
|
||||
app.get('/', (_req: Request, res: Response) => {
|
||||
res.sendFile(path.join(process.cwd(), 'frontend', 'dist', 'index.html'));
|
||||
// Serve the frontend application
|
||||
res.sendFile(path.join(frontendPath, 'index.html'));
|
||||
});
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
@@ -35,6 +35,11 @@ import {
|
||||
getCurrentUser,
|
||||
changePassword
|
||||
} from '../controllers/authController.js';
|
||||
import {
|
||||
getAllLogs,
|
||||
clearLogs,
|
||||
streamLogs
|
||||
} from '../controllers/logController.js';
|
||||
import { auth } from '../middlewares/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -70,6 +75,11 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.get('/market/tags', getAllMarketTags);
|
||||
router.get('/market/tags/:tag', getMarketServersByTag);
|
||||
|
||||
// Log routes
|
||||
router.get('/logs', getAllLogs);
|
||||
router.delete('/logs', clearLogs);
|
||||
router.get('/logs/stream', streamLogs);
|
||||
|
||||
// Auth routes (these will NOT be protected by auth middleware)
|
||||
app.post('/auth/login', [
|
||||
check('username', 'Username is required').not().isEmpty(),
|
||||
|
||||
150
src/server.ts
@@ -1,16 +1,27 @@
|
||||
import express from 'express';
|
||||
import config from './config/index.js';
|
||||
import path from 'path';
|
||||
import { initMcpServer } from './services/mcpService.js';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
import { initUpstreamServers } from './services/mcpService.js';
|
||||
import { initMiddlewares } from './middlewares/index.js';
|
||||
import { initRoutes } from './routes/index.js';
|
||||
import { handleSseConnection, handleSseMessage } from './services/sseService.js';
|
||||
import { migrateUserData } from './utils/migration.js';
|
||||
import {
|
||||
handleSseConnection,
|
||||
handleSseMessage,
|
||||
handleMcpPostRequest,
|
||||
handleMcpOtherRequest,
|
||||
} from './services/sseService.js';
|
||||
import { initializeDefaultUser } from './models/User.js';
|
||||
|
||||
// Get the directory name in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export class AppServer {
|
||||
private app: express.Application;
|
||||
private port: number | string;
|
||||
private frontendPath: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
@@ -19,9 +30,6 @@ export class AppServer {
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
// Migrate user data from users.json to mcp_settings.json if needed
|
||||
migrateUserData();
|
||||
|
||||
// Initialize default admin user if no users exist
|
||||
await initializeDefaultUser();
|
||||
|
||||
@@ -29,20 +37,22 @@ export class AppServer {
|
||||
initRoutes(this.app);
|
||||
console.log('Server initialized successfully');
|
||||
|
||||
initMcpServer(config.mcpHubName, config.mcpHubVersion)
|
||||
initUpstreamServers()
|
||||
.then(() => {
|
||||
console.log('MCP server initialized successfully');
|
||||
this.app.get('/sse/:group?', (req, res) => handleSseConnection(req, res));
|
||||
this.app.post('/messages', handleSseMessage);
|
||||
this.app.post('/mcp/:group?', handleMcpPostRequest);
|
||||
this.app.get('/mcp/:group?', handleMcpOtherRequest);
|
||||
this.app.delete('/mcp/:group?', handleMcpOtherRequest);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error initializing MCP server:', error);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(process.cwd(), 'frontend', 'dist', 'index.html'));
|
||||
});
|
||||
// Find and serve frontend
|
||||
this.findAndServeFrontend();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing server:', error);
|
||||
@@ -50,15 +60,135 @@ export class AppServer {
|
||||
}
|
||||
}
|
||||
|
||||
private findAndServeFrontend(): void {
|
||||
// Find frontend path
|
||||
this.frontendPath = this.findFrontendDistPath();
|
||||
|
||||
if (this.frontendPath) {
|
||||
console.log(`Serving frontend from: ${this.frontendPath}`);
|
||||
this.app.use(express.static(this.frontendPath));
|
||||
|
||||
// Add the wildcard route for SPA
|
||||
if (fs.existsSync(path.join(this.frontendPath, 'index.html'))) {
|
||||
this.app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(this.frontendPath!, 'index.html'));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn('Frontend dist directory not found. Server will run without frontend.');
|
||||
this.app.get('/', (_req, res) => {
|
||||
res
|
||||
.status(404)
|
||||
.send('Frontend not found. MCPHub API is running, but the UI is not available.');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.app.listen(this.port, () => {
|
||||
console.log(`Server is running on port ${this.port}`);
|
||||
if (this.frontendPath) {
|
||||
console.log(`Open http://localhost:${this.port} in your browser to access MCPHub UI`);
|
||||
} else {
|
||||
console.log(
|
||||
`MCPHub API is running on http://localhost:${this.port}, but the UI is not available`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getApp(): express.Application {
|
||||
return this.app;
|
||||
}
|
||||
|
||||
// Helper method to find frontend dist path in different environments
|
||||
private findFrontendDistPath(): string | null {
|
||||
// Debug flag for detailed logging
|
||||
const debug = process.env.DEBUG === 'true';
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Current directory:', process.cwd());
|
||||
console.log('DEBUG: Script directory:', __dirname);
|
||||
}
|
||||
|
||||
// First, find the package root directory
|
||||
const packageRoot = this.findPackageRoot();
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Using package root:', packageRoot);
|
||||
}
|
||||
|
||||
if (!packageRoot) {
|
||||
console.warn('Could not determine package root directory');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for frontend dist in the standard location
|
||||
const frontendDistPath = path.join(packageRoot, 'frontend', 'dist');
|
||||
|
||||
if (debug) {
|
||||
console.log(`DEBUG: Checking frontend at: ${frontendDistPath}`);
|
||||
}
|
||||
|
||||
if (
|
||||
fs.existsSync(frontendDistPath) &&
|
||||
fs.existsSync(path.join(frontendDistPath, 'index.html'))
|
||||
) {
|
||||
return frontendDistPath;
|
||||
}
|
||||
|
||||
console.warn('Frontend distribution not found at', frontendDistPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper method to find the package root (where package.json is located)
|
||||
private findPackageRoot(): string | null {
|
||||
const debug = process.env.DEBUG === 'true';
|
||||
|
||||
// Possible locations for package.json
|
||||
const possibleRoots = [
|
||||
// Standard npm package location
|
||||
path.resolve(__dirname, '..', '..'),
|
||||
// Current working directory
|
||||
process.cwd(),
|
||||
// When running from dist directory
|
||||
path.resolve(__dirname, '..'),
|
||||
// When installed via npx
|
||||
path.resolve(__dirname, '..', '..', '..'),
|
||||
];
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
export default AppServer;
|
||||
|
||||
228
src/services/logService.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
// filepath: /Users/sunmeng/code/github/mcphub/src/services/logService.ts
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as os from 'os';
|
||||
import * as process from 'process';
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: number;
|
||||
type: 'info' | 'error' | 'warn' | 'debug';
|
||||
source: string;
|
||||
message: string;
|
||||
processId?: string;
|
||||
}
|
||||
|
||||
// ANSI color codes for console output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
underscore: '\x1b[4m',
|
||||
blink: '\x1b[5m',
|
||||
reverse: '\x1b[7m',
|
||||
hidden: '\x1b[8m',
|
||||
|
||||
black: '\x1b[30m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
white: '\x1b[37m',
|
||||
|
||||
bgBlack: '\x1b[40m',
|
||||
bgRed: '\x1b[41m',
|
||||
bgGreen: '\x1b[42m',
|
||||
bgYellow: '\x1b[43m',
|
||||
bgBlue: '\x1b[44m',
|
||||
bgMagenta: '\x1b[45m',
|
||||
bgCyan: '\x1b[46m',
|
||||
bgWhite: '\x1b[47m',
|
||||
};
|
||||
|
||||
// Level colors for different log types
|
||||
const levelColors = {
|
||||
info: colors.green,
|
||||
error: colors.red,
|
||||
warn: colors.yellow,
|
||||
debug: colors.cyan,
|
||||
};
|
||||
|
||||
// Maximum number of logs to keep in memory
|
||||
const MAX_LOGS = 1000;
|
||||
|
||||
class LogService {
|
||||
private logs: LogEntry[] = [];
|
||||
private logEmitter = new EventEmitter();
|
||||
private mainProcessId: string;
|
||||
private hostname: string;
|
||||
|
||||
constructor() {
|
||||
this.mainProcessId = process.pid.toString();
|
||||
this.hostname = os.hostname();
|
||||
this.overrideConsole();
|
||||
}
|
||||
|
||||
// Format a timestamp for display
|
||||
private formatTimestamp(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
// Format a log message for console output
|
||||
private formatLogMessage(
|
||||
type: 'info' | 'error' | 'warn' | 'debug',
|
||||
source: string,
|
||||
message: string,
|
||||
processId?: string,
|
||||
): string {
|
||||
const timestamp = this.formatTimestamp(Date.now());
|
||||
const pid = processId || this.mainProcessId;
|
||||
const level = type.toUpperCase();
|
||||
const levelColor = levelColors[type];
|
||||
|
||||
return `${colors.dim}[${timestamp}]${colors.reset} ${levelColor}${colors.bright}[${level}]${colors.reset} ${colors.blue}[${pid}]${colors.reset} ${colors.magenta}[${source}]${colors.reset} ${message}`;
|
||||
}
|
||||
|
||||
// Override console methods to capture logs
|
||||
private overrideConsole() {
|
||||
const originalConsoleLog = console.log;
|
||||
const originalConsoleError = console.error;
|
||||
const originalConsoleWarn = console.warn;
|
||||
const originalConsoleDebug = console.debug;
|
||||
|
||||
// Helper method to handle common logic for all console methods
|
||||
const handleConsoleMethod = (
|
||||
type: 'info' | 'error' | 'warn' | 'debug',
|
||||
originalMethod: (...args: any[]) => void,
|
||||
...args: any[]
|
||||
) => {
|
||||
const firstArg = args.length > 0 ? this.formatArgument(args[0]) : { text: '' };
|
||||
const remainingArgs = args.slice(1).map((arg) => this.formatArgument(arg).text);
|
||||
const combinedMessage = [firstArg.text, ...remainingArgs].join(' ');
|
||||
const source = firstArg.source || 'main';
|
||||
const processId = firstArg.processId;
|
||||
this.addLog(type, source, combinedMessage, processId);
|
||||
originalMethod.apply(console, [
|
||||
this.formatLogMessage(type, source, combinedMessage, processId),
|
||||
]);
|
||||
};
|
||||
|
||||
console.log = (...args: any[]) => {
|
||||
handleConsoleMethod('info', originalConsoleLog, ...args);
|
||||
};
|
||||
|
||||
console.error = (...args: any[]) => {
|
||||
handleConsoleMethod('error', originalConsoleError, ...args);
|
||||
};
|
||||
|
||||
console.warn = (...args: any[]) => {
|
||||
handleConsoleMethod('warn', originalConsoleWarn, ...args);
|
||||
};
|
||||
|
||||
console.debug = (...args: any[]) => {
|
||||
handleConsoleMethod('debug', originalConsoleDebug, ...args);
|
||||
};
|
||||
}
|
||||
|
||||
// Format an argument for logging and extract structured information
|
||||
private formatArgument(arg: any): { text: string; source?: string; processId?: string } {
|
||||
// Handle null and undefined
|
||||
if (arg === null) return { text: 'null' };
|
||||
if (arg === undefined) return { text: 'undefined' };
|
||||
|
||||
// Handle objects
|
||||
if (typeof arg === 'object') {
|
||||
try {
|
||||
return { text: JSON.stringify(arg, null, 2) };
|
||||
} catch (e) {
|
||||
return { text: String(arg) };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle strings with potential structured information
|
||||
const argStr = String(arg);
|
||||
|
||||
// Check for patterns like [processId] [source] message or [processId] [source-processId] message
|
||||
const structuredPattern = /^\s*\[([^\]]+)\]\s*\[([^\]]+)\]\s*(.*)/;
|
||||
const match = argStr.match(structuredPattern);
|
||||
|
||||
if (match) {
|
||||
const [_, firstBracket, secondBracket, remainingText] = match;
|
||||
|
||||
// Check if the second bracket has a format like 'source-processId'
|
||||
const sourcePidPattern = /^([^-]+)-(.+)$/;
|
||||
const sourcePidMatch = secondBracket.match(sourcePidPattern);
|
||||
|
||||
if (sourcePidMatch) {
|
||||
// If we have a 'source-processId' format in the second bracket
|
||||
const [_, source, extractedProcessId] = sourcePidMatch;
|
||||
return {
|
||||
text: remainingText.trim(),
|
||||
source: source.trim(),
|
||||
processId: firstBracket.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise treat first bracket as processId and second as source
|
||||
return {
|
||||
text: remainingText.trim(),
|
||||
source: secondBracket.trim(),
|
||||
processId: firstBracket.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Return original string if no structured format is detected
|
||||
return { text: argStr };
|
||||
}
|
||||
|
||||
// Add a log entry to the logs array
|
||||
private addLog(
|
||||
type: 'info' | 'error' | 'warn' | 'debug',
|
||||
source: string,
|
||||
message: string,
|
||||
processId?: string,
|
||||
) {
|
||||
const log: LogEntry = {
|
||||
timestamp: Date.now(),
|
||||
type,
|
||||
source,
|
||||
message,
|
||||
processId: processId || this.mainProcessId,
|
||||
};
|
||||
|
||||
this.logs.push(log);
|
||||
|
||||
// Limit the number of logs kept in memory
|
||||
if (this.logs.length > MAX_LOGS) {
|
||||
this.logs.shift();
|
||||
}
|
||||
|
||||
// Emit the log event for SSE subscribers
|
||||
this.logEmitter.emit('log', log);
|
||||
}
|
||||
|
||||
// Get all logs
|
||||
public getLogs(): LogEntry[] {
|
||||
return this.logs;
|
||||
}
|
||||
|
||||
// Subscribe to log events
|
||||
public subscribe(callback: (log: LogEntry) => void): () => void {
|
||||
this.logEmitter.on('log', callback);
|
||||
return () => {
|
||||
this.logEmitter.off('log', callback);
|
||||
};
|
||||
}
|
||||
|
||||
// Clear all logs
|
||||
public clearLogs(): void {
|
||||
this.logs = [];
|
||||
this.logEmitter.emit('clear');
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
const logService = new LogService();
|
||||
export default logService;
|
||||
@@ -1,10 +1,10 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { MarketServer } from '../types/index.js';
|
||||
import { getConfigFilePath } from '../utils/path.js';
|
||||
|
||||
// Get path to the servers.json file
|
||||
export const getServersJsonPath = (): string => {
|
||||
return path.resolve(process.cwd(), 'servers.json');
|
||||
return getConfigFilePath('servers.json', 'Servers');
|
||||
};
|
||||
|
||||
// Load all market servers from servers.json
|
||||
@@ -12,7 +12,15 @@ export const getMarketServers = (): Record<string, MarketServer> => {
|
||||
try {
|
||||
const serversJsonPath = getServersJsonPath();
|
||||
const data = fs.readFileSync(serversJsonPath, 'utf8');
|
||||
return JSON.parse(data);
|
||||
const serversObj = JSON.parse(data) as Record<string, MarketServer>;
|
||||
|
||||
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;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return Object.fromEntries(sortedEntries);
|
||||
} catch (error) {
|
||||
console.error('Failed to load servers from servers.json:', error);
|
||||
return {};
|
||||
@@ -29,13 +37,13 @@ export const getMarketServerByName = (name: string): MarketServer | null => {
|
||||
export const getMarketCategories = (): string[] => {
|
||||
const servers = getMarketServers();
|
||||
const categories = new Set<string>();
|
||||
|
||||
|
||||
Object.values(servers).forEach((server) => {
|
||||
server.categories?.forEach((category) => {
|
||||
categories.add(category);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
return Array.from(categories).sort();
|
||||
};
|
||||
|
||||
@@ -43,25 +51,28 @@ export const getMarketCategories = (): string[] => {
|
||||
export const getMarketTags = (): string[] => {
|
||||
const servers = getMarketServers();
|
||||
const tags = new Set<string>();
|
||||
|
||||
|
||||
Object.values(servers).forEach((server) => {
|
||||
server.tags?.forEach((tag) => {
|
||||
tags.add(tag);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
return Array.from(tags).sort();
|
||||
};
|
||||
|
||||
// Search market servers by query
|
||||
export const searchMarketServers = (query: string): MarketServer[] => {
|
||||
const servers = getMarketServers();
|
||||
const searchTerms = query.toLowerCase().split(' ').filter(term => term.length > 0);
|
||||
|
||||
const searchTerms = query
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.filter((term) => term.length > 0);
|
||||
|
||||
if (searchTerms.length === 0) {
|
||||
return Object.values(servers);
|
||||
}
|
||||
|
||||
|
||||
return Object.values(servers).filter((server) => {
|
||||
// Search in name, display_name, description, categories, and tags
|
||||
const searchableText = [
|
||||
@@ -69,21 +80,23 @@ export const searchMarketServers = (query: string): MarketServer[] => {
|
||||
server.display_name,
|
||||
server.description,
|
||||
...(server.categories || []),
|
||||
...(server.tags || [])
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
return searchTerms.some(term => searchableText.includes(term));
|
||||
...(server.tags || []),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return searchTerms.some((term) => searchableText.includes(term));
|
||||
});
|
||||
};
|
||||
|
||||
// Filter market servers by category
|
||||
export const filterMarketServersByCategory = (category: string): MarketServer[] => {
|
||||
const servers = getMarketServers();
|
||||
|
||||
|
||||
if (!category) {
|
||||
return Object.values(servers);
|
||||
}
|
||||
|
||||
|
||||
return Object.values(servers).filter((server) => {
|
||||
return server.categories?.includes(category);
|
||||
});
|
||||
@@ -92,12 +105,12 @@ export const filterMarketServersByCategory = (category: string): MarketServer[]
|
||||
// Filter market servers by tag
|
||||
export const filterMarketServersByTag = (tag: string): MarketServer[] => {
|
||||
const servers = getMarketServers();
|
||||
|
||||
|
||||
if (!tag) {
|
||||
return Object.values(servers);
|
||||
}
|
||||
|
||||
|
||||
return Object.values(servers).filter((server) => {
|
||||
return server.tags?.includes(tag);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,37 +3,50 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { ServerInfo, ServerConfig } from '../types/index.js';
|
||||
import { loadSettings, saveSettings, expandEnvVars } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { getGroup } from './sseService.js';
|
||||
import { getServersInGroup } from './groupService.js';
|
||||
|
||||
let currentServer: Server;
|
||||
const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
export const initMcpServer = async (name: string, version: string): Promise<void> => {
|
||||
currentServer = createMcpServer(name, version);
|
||||
await registerAllTools(currentServer, true, true);
|
||||
export const initUpstreamServers = async (): Promise<void> => {
|
||||
await registerAllTools(true);
|
||||
};
|
||||
|
||||
export const setMcpServer = (server: Server): void => {
|
||||
currentServer = server;
|
||||
export const getMcpServer = (sessionId?: string, group?: string): Server => {
|
||||
if (!sessionId) {
|
||||
return createMcpServer(config.mcpHubName, config.mcpHubVersion, group);
|
||||
}
|
||||
|
||||
if (!servers[sessionId]) {
|
||||
const serverGroup = group || getGroup(sessionId);
|
||||
const server = createMcpServer(config.mcpHubName, config.mcpHubVersion, serverGroup);
|
||||
servers[sessionId] = server;
|
||||
} else {
|
||||
console.log(`MCP server already exists for sessionId: ${sessionId}`);
|
||||
}
|
||||
return servers[sessionId];
|
||||
};
|
||||
|
||||
export const getMcpServer = (): Server => {
|
||||
return currentServer;
|
||||
export const deleteMcpServer = (sessionId: string): void => {
|
||||
delete servers[sessionId];
|
||||
};
|
||||
|
||||
export const notifyToolChanged = async () => {
|
||||
await registerAllTools(currentServer, true, false);
|
||||
currentServer
|
||||
.sendToolListChanged()
|
||||
.catch((error) => {
|
||||
console.error('Failed to send tool list changed notification:', error);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Tool list changed notification sent successfully');
|
||||
});
|
||||
await registerAllTools(false);
|
||||
Object.values(servers).forEach((server) => {
|
||||
server
|
||||
.sendToolListChanged()
|
||||
.catch((error) => {
|
||||
console.warn('Failed to send tool list changed notification:', error.message);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Tool list changed notification sent successfully');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Store all server information
|
||||
@@ -74,15 +87,38 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
|
||||
}
|
||||
|
||||
let transport;
|
||||
if (conf.url) {
|
||||
if (conf.type === 'streamable-http') {
|
||||
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''));
|
||||
} else if (conf.url) {
|
||||
// Default to SSE only when 'conf.type' is not specified and 'conf.url' is available
|
||||
transport = new SSEClientTransport(new URL(conf.url));
|
||||
} else if (conf.command && conf.args) {
|
||||
// If type is stdio or if command and args are provided without type
|
||||
const env: Record<string, string> = conf.env || {};
|
||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||
|
||||
// Add UV_DEFAULT_INDEX from settings if available (for Python packages)
|
||||
const settings = loadSettings(); // Add UV_DEFAULT_INDEX from settings if available (for Python packages)
|
||||
if (settings.systemConfig?.install?.pythonIndexUrl && conf.command === 'uvx') {
|
||||
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
|
||||
}
|
||||
|
||||
// Add npm_config_registry from settings if available (for NPM packages)
|
||||
if (
|
||||
settings.systemConfig?.install?.npmRegistry &&
|
||||
(conf.command === 'npm' || conf.command === 'npx')
|
||||
) {
|
||||
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
|
||||
}
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
command: conf.command,
|
||||
args: conf.args,
|
||||
env: env,
|
||||
stderr: 'pipe',
|
||||
});
|
||||
transport.stderr?.on('data', (data) => {
|
||||
console.log(`[${name}] [child] ${data}`);
|
||||
});
|
||||
} else {
|
||||
console.warn(`Skipping server '${name}': missing required configuration`);
|
||||
@@ -170,11 +206,7 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
|
||||
};
|
||||
|
||||
// Register all MCP tools
|
||||
export const registerAllTools = async (
|
||||
server: Server,
|
||||
forceInit: boolean,
|
||||
isInit: boolean,
|
||||
): Promise<void> => {
|
||||
export const registerAllTools = async (isInit: boolean): Promise<void> => {
|
||||
initializeClientsFromSettings(isInit);
|
||||
};
|
||||
|
||||
@@ -226,7 +258,7 @@ export const addServer = async (
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
registerAllTools(currentServer, false, false);
|
||||
registerAllTools(false);
|
||||
return { success: true, message: 'Server added successfully' };
|
||||
} catch (error) {
|
||||
console.error(`Failed to add server: ${name}`, error);
|
||||
@@ -333,53 +365,78 @@ export const toggleServerStatus = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Create McpServer instance
|
||||
export const createMcpServer = (name: string, version: string): Server => {
|
||||
const server = new Server({ name, version }, { capabilities: { tools: {} } });
|
||||
server.setRequestHandler(ListToolsRequestSchema, async (_, extra) => {
|
||||
const sessionId = extra.sessionId || '';
|
||||
const group = getGroup(sessionId);
|
||||
console.log(`Handling ListToolsRequest for group: ${group}`);
|
||||
const allServerInfos = serverInfos.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
const handleListToolsRequest = async (_: any, extra: any) => {
|
||||
const sessionId = extra.sessionId || '';
|
||||
const group = getGroup(sessionId);
|
||||
console.log(`Handling ListToolsRequest for group: ${group}`);
|
||||
const allServerInfos = serverInfos.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
|
||||
const allTools = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
if (serverInfo.tools && serverInfo.tools.length > 0) {
|
||||
allTools.push(...serverInfo.tools);
|
||||
}
|
||||
const allTools = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
if (serverInfo.tools && serverInfo.tools.length > 0) {
|
||||
allTools.push(...serverInfo.tools);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tools: allTools,
|
||||
};
|
||||
};
|
||||
|
||||
const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
console.log(`Handling CallToolRequest for tool: ${request.params.name}`);
|
||||
try {
|
||||
const serverInfo = getServerByTool(request.params.name);
|
||||
if (!serverInfo) {
|
||||
throw new Error(`Server not found: ${request.params.name}`);
|
||||
}
|
||||
const client = serverInfo.client;
|
||||
if (!client) {
|
||||
throw new Error(`Client not found for server: ${request.params.name}`);
|
||||
}
|
||||
const result = await client.callTool(request.params);
|
||||
console.log(`Tool call result: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error handling CallToolRequest: ${error}`);
|
||||
return {
|
||||
tools: allTools,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error: ${error}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request, _) => {
|
||||
console.log(`Handling CallToolRequest for tool: ${request.params.name}`);
|
||||
try {
|
||||
if (!request.params.arguments) {
|
||||
throw new Error('Arguments are required');
|
||||
}
|
||||
const serverInfo = getServerByTool(request.params.name);
|
||||
if (!serverInfo) {
|
||||
throw new Error(`Server not found: ${request.params.name}`);
|
||||
}
|
||||
const client = serverInfo.client;
|
||||
if (!client) {
|
||||
throw new Error(`Client not found for server: ${request.params.name}`);
|
||||
}
|
||||
const result = await client.callTool(request.params);
|
||||
console.log(`Tool call result: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error handling CallToolRequest: ${error}`);
|
||||
return { error: `Failed to call tool: ${error}` };
|
||||
// Create McpServer instance
|
||||
export const createMcpServer = (name: string, version: string, group?: string): Server => {
|
||||
// Determine server name based on routing type
|
||||
let serverName = name;
|
||||
|
||||
if (group) {
|
||||
// Check if it's a group or a single server
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) {
|
||||
// Single server routing
|
||||
serverName = `${name}_${group}`;
|
||||
} else {
|
||||
// Group routing
|
||||
serverName = `${name}_${group}_group`;
|
||||
}
|
||||
});
|
||||
}
|
||||
// If no group, use default name (global routing)
|
||||
|
||||
const server = new Server({ name: serverName, version }, { capabilities: { tools: {} } });
|
||||
server.setRequestHandler(ListToolsRequestSchema, handleListToolsRequest);
|
||||
server.setRequestHandler(CallToolRequestSchema, handleCallToolRequest);
|
||||
return server;
|
||||
};
|
||||
|
||||
@@ -1,19 +1,54 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { getMcpServer } from './mcpService.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { deleteMcpServer, getMcpServer } from './mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
const transports: { [sessionId: string]: { transport: SSEServerTransport; group: string } } = {};
|
||||
const transports: { [sessionId: string]: { transport: Transport; group: string } } = {};
|
||||
|
||||
export const getGroup = (sessionId: string): string => {
|
||||
return transports[sessionId]?.group || '';
|
||||
};
|
||||
|
||||
export const handleSseConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
// Helper function to validate bearer auth
|
||||
const validateBearerAuth = (req: Request): boolean => {
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
};
|
||||
|
||||
if (routingConfig.enableBearerAuth) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7); // Remove "Bearer " prefix
|
||||
return token === routingConfig.bearerAuthKey;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const handleSseConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
// Check bearer auth
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
};
|
||||
const group = req.params.group;
|
||||
|
||||
@@ -28,29 +63,113 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
||||
|
||||
res.on('close', () => {
|
||||
delete transports[transport.sessionId];
|
||||
deleteMcpServer(transport.sessionId);
|
||||
console.log(`SSE connection closed: ${transport.sessionId}`);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`New SSE connection established: ${transport.sessionId} with group: ${group || 'global'}`,
|
||||
);
|
||||
await getMcpServer().connect(transport);
|
||||
await getMcpServer(transport.sessionId, group).connect(transport);
|
||||
};
|
||||
|
||||
export const handleSseMessage = async (req: Request, res: Response): Promise<void> => {
|
||||
// Check bearer auth
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const { transport, group } = transports[sessionId];
|
||||
req.params.group = group;
|
||||
req.query.group = group;
|
||||
console.log(`Received message for sessionId: ${sessionId} in group: ${group}`);
|
||||
if (transport) {
|
||||
await transport.handlePostMessage(req, res);
|
||||
await (transport as SSEServerTransport).handlePostMessage(req, res);
|
||||
} else {
|
||||
console.error(`No transport found for sessionId: ${sessionId}`);
|
||||
res.status(400).send('No transport found for sessionId');
|
||||
}
|
||||
};
|
||||
|
||||
export const handleMcpPostRequest = async (req: Request, res: Response): Promise<void> => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
const group = req.params.group;
|
||||
console.log(`Handling MCP post request for sessionId: ${sessionId} and group: ${group}`);
|
||||
// Check bearer auth
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
};
|
||||
if (!group && !routingConfig.enableGlobalRoute) {
|
||||
res.status(403).send('Global routes are disabled. Please specify a group ID.');
|
||||
return;
|
||||
}
|
||||
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
if (sessionId && transports[sessionId]) {
|
||||
console.log(`Reusing existing transport for sessionId: ${sessionId}`);
|
||||
transport = transports[sessionId].transport as StreamableHTTPServerTransport;
|
||||
} else if (!sessionId && isInitializeRequest(req.body)) {
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (sessionId) => {
|
||||
transports[sessionId] = { transport, group };
|
||||
},
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
console.log(`Transport closed: ${transport.sessionId}`);
|
||||
if (transport.sessionId) {
|
||||
delete transports[transport.sessionId];
|
||||
deleteMcpServer(transport.sessionId);
|
||||
console.log(`MCP connection closed: ${transport.sessionId}`);
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`MCP connection established: ${transport.sessionId}`);
|
||||
await getMcpServer(transport.sessionId, group).connect(transport);
|
||||
} else {
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Bad Request: No valid session ID provided',
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Handling request using transport with type ${transport.constructor.name}`);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
};
|
||||
|
||||
export const handleMcpOtherRequest = async (req: Request, res: Response) => {
|
||||
console.log('Handling MCP other request');
|
||||
// Check bearer auth
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId || !transports[sessionId]) {
|
||||
res.status(400).send('Invalid or missing session ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const { transport } = transports[sessionId];
|
||||
await (transport as StreamableHTTPServerTransport).handleRequest(req, res);
|
||||
};
|
||||
|
||||
export const getConnectionCount = (): number => {
|
||||
return Object.keys(transports).length;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
|
||||
// User interface
|
||||
export interface IUser {
|
||||
@@ -11,8 +12,8 @@ export interface IUser {
|
||||
|
||||
// Group interface for server grouping
|
||||
export interface IGroup {
|
||||
id: string; // Unique UUID for the group
|
||||
name: string; // Display name of the group
|
||||
id: string; // Unique UUID for the group
|
||||
name: string; // Display name of the group
|
||||
description?: string; // Optional description of the group
|
||||
servers: string[]; // Array of server names that belong to this group
|
||||
}
|
||||
@@ -82,6 +83,12 @@ export interface McpSettings {
|
||||
routing?: {
|
||||
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
||||
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
|
||||
bearerAuthKey?: string; // The bearer auth key to validate against
|
||||
};
|
||||
install?: {
|
||||
pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX)
|
||||
npmRegistry?: string; // NPM registry URL (npm_config_registry)
|
||||
};
|
||||
// Add other system configuration sections here in the future
|
||||
};
|
||||
@@ -89,7 +96,8 @@ export interface McpSettings {
|
||||
|
||||
// Configuration details for an individual server
|
||||
export interface ServerConfig {
|
||||
url?: string; // URL for SSE-based servers
|
||||
type?: 'stdio' | 'sse' | 'streamable-http'; // Type of server
|
||||
url?: string; // URL for SSE or streamable HTTP servers
|
||||
command?: string; // Command to execute for stdio-based servers
|
||||
args?: string[]; // Arguments for the command
|
||||
env?: Record<string, string>; // Environment variables
|
||||
@@ -103,7 +111,7 @@ export interface ServerInfo {
|
||||
error: string | null; // Error message if any
|
||||
tools: ToolInfo[]; // List of tools available on the server
|
||||
client?: Client; // Client instance for communication
|
||||
transport?: SSEClientTransport | StdioClientTransport; // Transport mechanism used
|
||||
transport?: SSEClientTransport | StdioClientTransport | StreamableHTTPClientTransport; // Transport mechanism used
|
||||
createTime: number; // Timestamp of when the server was created
|
||||
enabled?: boolean; // Flag to indicate if the server is enabled
|
||||
}
|
||||
|
||||