Compare commits

..

18 Commits

Author SHA1 Message Date
samanhappy
298d96d593 feat: enhance MCP server retrieval and logging for transport closure (#101) 2025-05-16 16:47:21 +08:00
samanhappy
d44886b81b docs: update README to enhance clarity and add new features overview (#102) 2025-05-16 13:28:46 +08:00
Lawrence Sinclair
bbe44fc540 Add MseeP.ai badge (#95) 2025-05-14 17:14:48 +08:00
samanhappy
c60b98e3d6 feat: add icons and link to Header (#94) 2025-05-14 13:54:29 +08:00
samanhappy
a447fe5b41 feat: display current version in Header and remove version display from UserProfileMenu (#93) 2025-05-14 10:19:43 +08:00
samanhappy
94d51fa03a refactor: update issue templates for consistency and clarity (#91) 2025-05-13 22:01:23 +08:00
samanhappy
b88a7240c6 Update issue templates (#90) 2025-05-13 19:57:00 +08:00
samanhappy
3c875590ce refactor: remove Node.js setup and version update steps from release workflow (#84) 2025-05-13 14:37:09 +08:00
samanhappy
26fa61fcfc feat: implement version checking and update notifications in AboutDialog and UserProfileMenu (#83) 2025-05-13 14:18:49 +08:00
samanhappy
d689541fc4 refactor: remove dependency on wait-for-npm job in build workflow (#82) 2025-05-13 13:07:21 +08:00
samanhappy
30895c4b9a refactor: remove wait-for-npm job from build workflow (#81) 2025-05-13 13:05:26 +08:00
samanhappy
37c3fd9e06 feat: add bearer authentication support for MCP requests (#79) 2025-05-13 13:02:41 +08:00
samanhappy
59454ca250 feat: add wait-for-npm job and update version from tag in build workflow (#80) 2025-05-13 13:01:54 +08:00
samanhappy
63efa0038c feat: add npmRegistry support to installation configuration (#77)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-05-13 09:21:07 +08:00
samanhappy
040782da8d feat: support streamable http upstream server (#75)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-12 22:12:48 +08:00
samanhappy
f1a5f692cc feat: create MCP server for different session (#74) 2025-05-12 15:11:37 +08:00
samanhappy
5d798cfe6a feat: enhance release workflow to automatically update package.json version (#70) 2025-05-11 14:43:48 +08:00
samanhappy
0490d98c9e feat: add sponsorship section to README.md (#72) 2025-05-11 14:41:00 +08:00
35 changed files with 940 additions and 194 deletions

View 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? / 还有其他信息吗?

View 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? / 有任何截图、设计图或相关信息吗?

View File

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

View File

@@ -13,6 +13,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Release
uses: softprops/action-gh-release@v2
with:

View File

@@ -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,22 +82,28 @@ 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
### 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
@@ -106,20 +114,24 @@ This endpoint provides a unified streamable HTTP interface for all your MCP serv
![Group Management](assets/group.png)
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.
@@ -127,16 +139,19 @@ Where `{server}` is the name of the server you want to connect to. This allows y
### SSE Endpoint (Deprecated in Future)
Connect AI clients (e.g., Claude Desktop, Cursor, DeepChat, etc.) via:
```
http://localhost:3000/sse
```
For targeted access to specific server groups, use the group-based SSE endpoint:
```
http://localhost:3000/sse/{group}
```
For direct access to individual servers, use the server-specific SSE endpoint:
```
http://localhost:3000/sse/{server}
```
@@ -157,6 +172,7 @@ This starts both frontend and backend in development mode with hot-reloading.
## 🛠️ 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
@@ -172,13 +188,21 @@ proxy_buffering off
## 👥 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:
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/samanhappy)
## 📄 License
Licensed under the [Apache 2.0 License](LICENSE).

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -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>
) : (

View 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;

View 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;

View 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;

View 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;

View 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

View 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

View 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

View 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

View File

@@ -1,15 +1,23 @@
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@/contexts/AuthContext';
import ThemeSwitch from '@/components/ui/ThemeSwitch';
import GitHubIcon from '@/components/icons/GitHubIcon';
import SponsorIcon from '@/components/icons/SponsorIcon';
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 { t, i18n } = useTranslation();
const { auth } = useAuth();
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
return (
<header className="bg-white dark:bg-gray-800 shadow-sm z-10">
@@ -30,12 +38,49 @@ const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
<h1 className="ml-4 text-xl font-bold text-gray-900 dark:text-white">{t('app.title')}</h1>
</div>
{/* Theme Switch */}
{/* Theme Switch and Version */}
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-500 dark:text-gray-400">{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={() => 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')}
>
<WeChatIcon className="h-5 w-5" />
</button>
) : (
<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>
);
};

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { X } from 'lucide-react';
import { X, RefreshCw } from 'lucide-react';
import { checkLatestVersion, compareVersions } from '@/utils/version';
interface AboutDialogProps {
isOpen: boolean;
@@ -12,30 +13,28 @@ 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);
}
};
// Check if there's a new version available
// In a real application, this would make an API call to check for updates
useEffect(() => {
if (isOpen) {
// Mock implementation - in real implementation, you would fetch from an API
const checkForUpdates = async () => {
try {
// This is a placeholder - in a real app you would call an API
// const response = await fetch('/api/check-updates');
// const data = await response.json();
// For demo purposes, we'll just pretend there's a new version
// TODO: Replace this placeholder logic with a real API call to check for updates
setHasNewVersion(false);
setLatestVersion("0.0.28"); // Just a higher version for demo
} catch (error) {
console.error('Failed to check for updates:', error);
}
};
checkForUpdates();
}
}, [isOpen]);
}, [isOpen, version]);
if (!isOpen) return null;
@@ -66,7 +65,7 @@ const AboutDialog: React.FC<AboutDialogProps> = ({ isOpen, onClose, version }) =
</span>
</div>
{hasNewVersion && (
{hasNewVersion && latestVersion && (
<div className="bg-blue-50 dark:bg-blue-900 p-3 rounded">
<div className="flex items-start">
<div className="flex-shrink-0">
@@ -74,14 +73,35 @@ const AboutDialog: React.FC<AboutDialogProps> = ({ isOpen, onClose, version }) =
<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">
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
{t('about.newVersion')} (v{latestVersion})
<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>

View 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;

View File

@@ -4,6 +4,7 @@ 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;
@@ -19,17 +20,21 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
const [showAboutDialog, setShowAboutDialog] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// Check for new version - this could be replaced with a real API call
// Check for new version on login and component mount
useEffect(() => {
// Mock implementation - in a real app, you would check against an API
const checkForNewVersion = async () => {
// For demo purposes, we're just showing the notification
// In real implementation, you would compare current version with latest version
setShowNewVersionInfo(false);
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(() => {
@@ -99,7 +104,6 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
>
<Info className="h-4 w-4 mr-2" />
{t('about.title')}
&nbsp;({version})
{showNewVersionInfo && (
<span className="absolute top-2 right-4 block w-2 h-2 bg-red-500 rounded-full"></span>
)}

View 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;

View File

@@ -7,10 +7,13 @@ import { useToast } from '@/contexts/ToastContext';
interface RoutingConfig {
enableGlobalRoute: boolean;
enableGroupNameRoute: boolean;
enableBearerAuth: boolean;
bearerAuthKey: string;
}
interface InstallConfig {
pythonIndexUrl: string;
npmRegistry: string;
}
interface SystemSettings {
@@ -20,6 +23,10 @@ interface SystemSettings {
};
}
interface TempRoutingConfig {
bearerAuthKey: string;
}
export const useSettingsData = () => {
const { t } = useTranslation();
const { showToast } = useToast();
@@ -27,10 +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);
@@ -65,12 +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) {
@@ -83,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);
@@ -116,13 +135,13 @@ export const useSettingsData = () => {
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(t('errors.failedToUpdateSystemConfig'));
showToast(t('errors.failedToUpdateRouteConfig'));
return false;
}
} catch (error) {
console.error('Failed to update system config:', error);
setError(error instanceof Error ? error.message : 'Failed to update system config');
showToast(t('errors.failedToUpdateSystemConfig'));
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);
@@ -181,8 +200,18 @@ export const useSettingsData = () => {
fetchSettings();
}, [fetchSettings, refreshKey]);
useEffect(() => {
if (routingConfig) {
setTempRoutingConfig({
bearerAuthKey: routingConfig.bearerAuthKey,
});
}
}, [routingConfig]);
return {
routingConfig,
tempRoutingConfig,
setTempRoutingConfig,
installConfig,
loading,
error,

View File

@@ -16,12 +16,34 @@
"title": "About",
"versionInfo": "MCP Hub Version: {{version}}",
"newVersion": "New version available!",
"currentVersion": "Current version"
"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",
@@ -148,7 +170,7 @@
"account": "Account Settings",
"password": "Change Password",
"appearance": "Appearance",
"routeConfig": "Route Configuration",
"routeConfig": "Security Configuration",
"installConfig": "Installation Configuration"
},
"market": {
@@ -244,9 +266,17 @@
"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"
}

View File

@@ -16,12 +16,34 @@
"title": "关于",
"versionInfo": "MCP Hub 版本: {{version}}",
"newVersion": "有新版本可用!",
"currentVersion": "当前版本"
"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": "浅色",
@@ -146,7 +168,7 @@
"account": "账户设置",
"password": "修改密码",
"appearance": "外观",
"routeConfig": "路由配置",
"routeConfig": "安全配置",
"installConfig": "安装配置"
},
"groups": {
@@ -245,9 +267,17 @@
"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": "系统配置更新成功"
}

View File

@@ -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();
@@ -19,12 +20,16 @@ const SettingsPage: React.FC = () => {
const [installConfig, setInstallConfig] = useState<{
pythonIndexUrl: string;
npmRegistry: string;
}>({
pythonIndexUrl: '',
npmRegistry: '',
});
const {
routingConfig,
tempRoutingConfig,
setTempRoutingConfig,
installConfig: savedInstallConfig,
loading,
updateRoutingConfig,
@@ -51,19 +56,39 @@ const SettingsPage: React.FC = () => {
}));
};
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 handleInstallConfigChange = (value: string) => {
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,
pythonIndexUrl: value
[key]: value
});
};
const saveInstallConfig = async () => {
await updateInstallConfig('pythonIndexUrl', installConfig.pythonIndexUrl);
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry') => {
await updateInstallConfig(key, installConfig[key]);
};
const handlePasswordChangeSuccess = () => {
@@ -87,21 +112,19 @@ const SettingsPage: React.FC = () => {
<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')}
>
@@ -124,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>
@@ -147,6 +208,7 @@ const SettingsPage: React.FC = () => {
onCheckedChange={(checked) => handleRoutingConfigChange('enableGroupNameRoute', checked)}
/>
</div>
</div>
)}
</div>
@@ -174,13 +236,37 @@ const SettingsPage: React.FC = () => {
<input
type="text"
value={installConfig.pythonIndexUrl}
onChange={(e) => handleInstallConfigChange(e.target.value)}
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}
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"
>

View File

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

View 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('');
}

View 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;
};

View File

@@ -6,9 +6,7 @@ import tailwindcss from '@tailwindcss/vite';
import { readFileSync } from 'fs';
// Get package.json version
const packageJson = JSON.parse(
readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8')
);
const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8'));
// https://vitejs.dev/config/
export default defineConfig({
@@ -22,6 +20,9 @@ export default defineConfig({
// 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': {

View File

@@ -1,6 +1,6 @@
{
"name": "@samanhappy/mcphub",
"version": "0.5.4",
"version": "dev",
"description": "A hub server for mcp servers",
"main": "dist/index.js",
"type": "module",
@@ -21,6 +21,7 @@
"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",
@@ -28,6 +29,7 @@
"frontend:build": "cd frontend && vite build",
"frontend:preview": "cd frontend && vite preview",
"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": [
@@ -39,7 +41,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.11.1",
"bcryptjs": "^3.0.2",
"dotenv": "^16.3.1",
"express": "^4.18.2",
@@ -94,4 +96,4 @@
"node": ">=16.0.0"
},
"packageManager": "pnpm@10.10.0+sha256.fa0f513aa8191764d2b6b432420788c270f07b4f999099b65bb2010eec702a30"
}
}

12
pnpm-lock.yaml generated
View File

@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@modelcontextprotocol/sdk':
specifier: ^1.10.2
version: 1.10.2
specifier: ^1.11.1
version: 1.11.1
bcryptjs:
specifier: ^3.0.2
version: 3.0.2
@@ -867,8 +867,8 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@modelcontextprotocol/sdk@1.10.2':
resolution: {integrity: sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==}
'@modelcontextprotocol/sdk@1.11.1':
resolution: {integrity: sha512-9LfmxKTb1v+vUS1/emSk1f5ePmTLkb9Le9AxOB5T0XM59EUumwcS45z05h7aiZx3GI0Bl7mjb3FMEglYj+acuQ==}
engines: {node: '>=18'}
'@next/env@15.2.4':
@@ -4263,7 +4263,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@modelcontextprotocol/sdk@1.10.2':
'@modelcontextprotocol/sdk@1.11.1':
dependencies:
content-type: 1.0.5
cors: 2.8.5
@@ -6781,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

View File

@@ -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();
@@ -248,58 +284,82 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
export const updateSystemConfig = (req: Request, res: Response): void => {
try {
const { routing, install } = req.body;
if ((!routing || (typeof routing.enableGlobalRoute !== 'boolean' && typeof routing.enableGroupNameRoute !== 'boolean'))
&& (!install || typeof install.pythonIndexUrl !== 'string')) {
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: ''
}
pythonIndexUrl: '',
npmRegistry: '',
},
};
}
if (!settings.systemConfig.routing) {
settings.systemConfig.routing = {
enableGlobalRoute: true,
enableGroupNameRoute: true
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
};
}
if (!settings.systemConfig.install) {
settings.systemConfig.install = {
pythonIndexUrl: ''
pythonIndexUrl: '',
npmRegistry: '',
};
}
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,

View File

@@ -3,37 +3,49 @@ 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);
await registerAllTools(true);
};
export const setMcpServer = (server: Server): void => {
currentServer = server;
export const getMcpServer = (sessionId?: string): Server => {
if (!sessionId) {
return createMcpServer(config.mcpHubName, config.mcpHubVersion);
}
if (!servers[sessionId]) {
const server = createMcpServer(config.mcpHubName, config.mcpHubVersion);
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.warn('Failed to send tool list changed notification:', error.message);
})
.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,18 +86,30 @@ 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();
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,
@@ -180,11 +204,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);
};
@@ -236,7 +256,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);
@@ -343,59 +363,62 @@ export const toggleServerStatus = async (
}
};
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);
}
}
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 {
content: [
{
type: 'text',
text: `Error: ${error}`,
},
],
isError: true,
};
}
};
// 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);
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);
}
}
return {
tools: allTools,
};
});
server.setRequestHandler(CallToolRequestSchema, async (request, _) => {
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 {
content: [
{
type: 'text',
text: `Error: ${error}`,
},
],
isError: true,
};
}
});
server.setRequestHandler(ListToolsRequestSchema, handleListToolsRequest);
server.setRequestHandler(CallToolRequestSchema, handleCallToolRequest);
return server;
};

View File

@@ -4,7 +4,7 @@ import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { getMcpServer } from './mcpService.js';
import { deleteMcpServer, getMcpServer } from './mcpService.js';
import { loadSettings } from '../config/index.js';
const transports: { [sessionId: string]: { transport: Transport; group: string } } = {};
@@ -13,11 +13,42 @@ 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;
@@ -32,16 +63,23 @@ 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).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;
@@ -56,9 +94,15 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
};
export const handleMcpPostRequest = async (req: Request, res: Response): Promise<void> => {
console.log('Handling MCP post request');
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,
@@ -71,6 +115,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
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({
@@ -81,12 +126,16 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
});
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}`);
}
};
await getMcpServer().connect(transport);
console.log(`MCP connection established: ${transport.sessionId}`);
await getMcpServer(transport.sessionId).connect(transport);
} else {
res.status(400).json({
jsonrpc: '2.0',
@@ -99,11 +148,18 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
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');

View File

@@ -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,9 +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
};
@@ -92,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
@@ -106,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
}