Compare commits

...

118 Commits

Author SHA1 Message Date
samanhappy
1f3a6794ea feat: enhance BearerKeyDaoImpl to handle migration and caching behavior for bearer keys (#507) 2025-12-14 20:40:57 +08:00
samanhappy
c673afb97e Add HTTP/HTTPS proxy configuration and environment variable support (#506) 2025-12-14 15:44:44 +08:00
samanhappy
01855ca2ca feat: add bearer authentication key management with migration support (#503) 2025-12-13 16:46:58 +08:00
dependabot[bot]
88efad9d60 chore(deps-dev): bump next from 15.5.7 to 15.5.9 (#501)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-12 14:50:18 +08:00
samanhappy
2028233b53 Add OpenAPI support and enhance settings aggregation (#500) 2025-12-11 17:42:50 +08:00
samanhappy
1dfa0a990b Add batch server and group creation functionality (#499) 2025-12-11 14:21:58 +08:00
Alptekin Gülcan
ab7c210281 Optimizing API Operations: Simplified operationId Values and Large String Parameter Management (#488) 2025-12-07 13:11:35 +08:00
Copilot
6bd28ec89b Upgrade react and react-dom to 19.2.1 (#489)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-06 15:58:14 +08:00
Copilot
41a42f82d0 Upgrade js-yaml to 4.1.1 (#486)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-05 18:11:26 +08:00
Copilot
7aa3ff3bb1 Upgrade glob to version 10.5.0 (#485)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-05 17:52:46 +08:00
Copilot
71667dab2c Fix validator security vulnerability CVE in isLength() (#484)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-05 17:40:29 +08:00
Copilot
1921a0363b [WIP] Update auth0/node-jws to version 3.2.3 or 4.0.1 (#482)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-05 17:38:03 +08:00
Copilot
f9fe2e444b Add build-essential to Dockerfile for Python native extension compilation (#478)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-04 22:48:07 +08:00
samanhappy
8d420a927b fix: streamline tool filtering logic and add group-based filtering (#476)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-04 15:10:49 +08:00
dependabot[bot]
cb77593fd7 chore(deps-dev): bump next from 15.5.2 to 15.5.7 (#475)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-04 08:41:39 +08:00
dependabot[bot]
dbcebecf40 chore(deps): bump @modelcontextprotocol/sdk from 1.20.2 to 1.24.0 (#473)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 19:10:51 +08:00
cheestard
54e877cbd8 feat: add reload button. (#471) 2025-12-03 18:55:48 +08:00
samanhappy
61b748151f chore(deps): bump react-dom to 19.2.0 (#474) 2025-12-03 11:37:50 +08:00
dependabot[bot]
4f05815210 chore(deps-dev): bump @swc/core from 1.13.5 to 1.15.3 (#468)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 11:27:33 +08:00
dependabot[bot]
691d91f207 chore(deps-dev): bump @tailwindcss/vite from 4.1.12 to 4.1.17 (#469)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 11:27:14 +08:00
dependabot[bot]
3d58042ce5 chore(deps): bump bcryptjs from 3.0.2 to 3.0.3 (#470)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 11:26:35 +08:00
dependabot[bot]
81486b09df chore(deps): bump express from 4.21.2 to 4.22.0 (#472)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 11:26:08 +08:00
dependabot[bot]
a41707c228 chore(deps-dev): bump react and @types/react (#467)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 11:18:26 +08:00
dependabot[bot]
7391e57f35 chore(deps-dev): bump @tailwindcss/postcss from 4.1.14 to 4.1.17 (#466)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 11:18:01 +08:00
samanhappy
9d8f5ba370 Enhance MCP settings export with error handling and null value removal (#465) 2025-12-01 16:28:45 +08:00
samanhappy
764959eaca Implement OAuth client and token management with settings updates (#464) 2025-12-01 16:02:55 +08:00
samanhappy
b5dff990e5 refactor: Remove outdated OpenAPI test scripts and add comprehensive integration tests (#459) 2025-11-30 17:33:15 +08:00
samanhappy
19f11a0927 Remove outdated documentation files (#458) 2025-11-30 17:29:37 +08:00
samanhappy
884870c9de refactor: Simplify database configuration instructions and update API endpoint references (#457) 2025-11-30 17:20:01 +08:00
samanhappy
7b8d9a7e5a Refactor documentation for clarity and dual data source support (#456) 2025-11-30 16:43:50 +08:00
samanhappy
8770b9ccfe feat: Enhance Keep-Alive configuration handling (#455) 2025-11-30 09:59:48 +08:00
Copilot
063b081297 Add PostgreSQL-backed data storage support (#444)
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-11-29 17:45:25 +08:00
samanhappy
73ae33e777 refactor: Remove deprecated systemConfig section from mcp_settings.json (#452) 2025-11-27 21:34:07 +08:00
Copilot
dac0d376e8 Fix inverted validation for enableSessionRebuild system config (#451)
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-11-27 21:01:13 +08:00
samanhappy
803e35b14c refactor: Simplify DataService implementation and remove unused DataServicex (#448) 2025-11-23 22:57:44 +08:00
samanhappy
a736398cd5 feat: Update permissions and settings structure for improved configuration management (#447) 2025-11-23 20:43:24 +08:00
samanhappy
6de3221974 feat: Enhance user forms and user management UI (#437) 2025-11-23 13:50:55 +08:00
samanhappy
ac0b60ed4b feat: Implement keepalive functionality for SSE and StreamableHTTP connections (#442) 2025-11-22 12:07:21 +08:00
samanhappy
a57218d076 fix: Remove test routing and oauthClients configurations from settings (#441) 2025-11-22 11:27:00 +08:00
samanhappy
8c985b7de1 fix: Include mcpServers, oauthClients, and oauthTokens in merged settings for non-admin users (#440) 2025-11-21 17:16:48 +08:00
samanhappy
01bb011736 fix: Use base URL from settings for dynamic client registration and metadata endpoints (#438) 2025-11-21 16:20:54 +08:00
Copilot
449e6ea4fd Add OAuth 2.0 authorization server to enable ChatGPT Web integration (#413)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-11-21 13:25:02 +08:00
cheestard
1869f283ba fix: Bad Request: No valid session ID provided (#405) (#427) 2025-11-19 18:17:37 +08:00
Copilot
07adeab036 feat: Add copy button for tool names in server tool list (#435)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-11-19 11:41:11 +08:00
dependabot[bot]
5d7d8fdd1a chore(deps): bump js-yaml from 3.14.1 to 3.14.2 (#436)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-19 11:38:24 +08:00
Copilot
fb847797c0 Add missing API documentation for tool execution and management endpoints (#430)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-11-12 22:18:56 +08:00
Alptekin Gülcan
8df2b4704a Fix: Handle ToolName in CallToolRequest to Resolve Server Discovery Issues (#429)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-12 09:13:50 +08:00
samanhappy
602b5cb80e fix: update GitHub repository links to point to the new repository (#423) 2025-11-03 17:04:49 +08:00
samanhappy
e63f045819 refactor: remove outdated references to MCP protocol and cloud deployment in documentation (#422) 2025-11-03 17:02:10 +08:00
Chengwei Guo
a4e4791b60 fix the deployment on kubernetes (#417) 2025-11-03 14:16:12 +08:00
samanhappy
01370ea959 Revert "Feat: Enhance package cache for stdio servers (#400)" (#418) 2025-11-03 13:35:24 +08:00
samanhappy
f5d66c1bb7 fix versions for react and react-dom (#414) 2025-11-02 23:02:25 +08:00
dependabot[bot]
9e59dd9fb0 chore(deps-dev): bump react and @types/react (#407)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 22:48:13 +08:00
dependabot[bot]
250487f042 chore(deps-dev): bump lucide-react from 0.486.0 to 0.552.0 (#408)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 22:45:01 +08:00
dependabot[bot]
da91708420 chore(deps): bump i18next from 25.5.0 to 25.6.0 (#409)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 22:44:42 +08:00
dependabot[bot]
576bba1f9e chore(deps): bump openai from 4.104.0 to 6.7.0 (#410)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 22:44:21 +08:00
dependabot[bot]
f4b83929a6 chore(deps): bump axios from 1.12.2 to 1.13.1 (#406)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 22:43:57 +08:00
Alptekin Gülcan
3825f389cd Feat: Add Turkish localization (tr) (#411) 2025-11-02 22:43:18 +08:00
samanhappy
44e0309fd4 Feat: Enhance package cache for stdio servers (#400) 2025-10-31 21:56:43 +08:00
Copilot
7e570a900a Fix: Convert form parameters to schema-defined types before MCP tool calls (#397)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-29 18:41:23 +08:00
Copilot
6268a02c0e Fix URL routing for MCP servers with slashes in names (#396)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-29 18:37:24 +08:00
samanhappy
695d663939 Fix display for null server author (#398) 2025-10-29 14:44:09 +08:00
samanhappy
d595e5d874 Fix support for nested smart group segments in MCP routing (#394) 2025-10-28 17:51:58 +08:00
Copilot
ff797b4ab9 Add group-scoped smart routing via $smart/{group} pattern (#388)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-26 20:51:55 +08:00
samanhappy
9105507722 Refactor: Clean up code formatting and improve readability across multiple files (#387) 2025-10-26 19:27:30 +08:00
Copilot
f79028ed64 Expand environment variables throughout mcp_settings.json configuration (#384)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-26 19:25:53 +08:00
Copilot
5ca5e2ad47 Add password security: default credential warning and strength validation (#386)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-26 19:22:51 +08:00
Copilot
2f7726b008 Add JSON import for MCP servers (#385)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-10-26 19:13:06 +08:00
Copilot
26b26a5fb1 Add OAuth support for upstream MCP servers (#381)
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-10-26 16:09:34 +08:00
Copilot
7dbd6c386e Fix: Environment variable expansion in headers for HTTP-based MCP transports (#380)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-23 19:18:50 +08:00
Copilot
c1fee91142 Fix Dependabot alert #18: Remove outdated package-lock.json causing axios vulnerability false positive (#379)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-22 15:16:24 +08:00
samanhappy
1130f6833e fix: use reconnect mechanism for sse tool calling error (#378) 2025-10-22 12:05:21 +08:00
dependabot[bot]
c3f1de8f5b chore(deps-dev): bump vite from 6.3.6 to 6.4.1 (#376)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-22 12:04:16 +08:00
samanhappy
86367a4875 feat: integrate offcial mcp server registry (#374) 2025-10-19 21:15:25 +08:00
samanhappy
bd4c546bba fix settings data export & parsing error (#373) 2025-10-16 13:08:28 +08:00
Copilot
3e9e5cc3c9 feat: Auto-start Docker daemon when installed in container (#370)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-10-13 22:38:13 +08:00
samanhappy
16a92096b3 feat: Enhance package root detection and version retrieval using ESM-compatible methods (#371) 2025-10-13 22:36:29 +08:00
Copilot
4d736c543d feat: Add MCP settings export and copy functionality (#367)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-10-13 19:39:01 +08:00
samanhappy
f53c4a0e3b fix: assign server name from key in getMarketServers function (#369) 2025-10-13 18:19:21 +08:00
Copilot
d4bdb099d0 Add Docker CLI support to Docker image with INSTALL_EXT build argument (#366)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-12 16:51:02 +08:00
samanhappy
435227cbd4 fix: improve error handling and directory creation for settings path (#364) 2025-10-12 15:30:40 +08:00
Copilot
6a59becd8d Fix Windows startup error: Convert paths to file:// URLs for ESM dynamic imports (#363)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-10-12 11:31:44 +08:00
samanhappy
91698a50e3 fix: use specified environment setting path when available (#359) 2025-10-11 23:44:23 +08:00
samanhappy
a5d5045832 fix: add groups handling in mergeSettings method (#362) 2025-10-11 23:29:59 +08:00
samanhappy
198ea85225 feat: implement user management features with add, edit, and delete functionality 2025-10-02 15:11:08 +08:00
dependabot[bot]
6b39916909 chore(deps): bump typeorm from 0.3.26 to 0.3.27 (#356)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 14:47:55 +08:00
dependabot[bot]
9e8db370ff chore(deps-dev): bump @tailwindcss/postcss from 4.1.12 to 4.1.13 (#358)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 14:47:09 +08:00
dependabot[bot]
5d8bc44a73 chore(deps-dev): bump @types/node from 22.17.2 to 24.6.1 (#357)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 14:44:13 +08:00
dependabot[bot]
021901dbda chore(deps-dev): bump jest and @types/jest (#354)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 14:40:42 +08:00
WuWen
f6934a32dc feat: add configurable name separator for tools and prompts (#353) 2025-10-02 14:40:01 +08:00
samanhappy
7685b9bca8 feat: enhance visual hierarchy on LoginPage by increasing slogan size and spacing (#347) 2025-09-20 17:23:54 +08:00
samanhappy
c2dd91606f chore(deps): update @modelcontextprotocol/sdk to 1.18.1 and axios to 1.12.2 (#346) 2025-09-20 17:16:04 +08:00
samanhappy
66b6053f7f feat: add passthrough headers support for OpenAPI client and MCP protocol (#345) 2025-09-20 17:12:20 +08:00
dependabot[bot]
ba50a78879 chore(deps): bump axios from 1.11.0 to 1.12.0 (#342)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-14 16:14:13 +08:00
comeback01
a856404963 docs: add French translation for README (#339)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-09-12 17:56:00 +08:00
comeback01
9a65532a50 feat: add french localization (#337)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-09-12 14:27:05 +08:00
dependabot[bot]
c5aa97de50 chore(deps-dev): bump vite from 6.3.5 to 6.3.6 (#334)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-10 12:42:00 +08:00
dependabot[bot]
271c9fe2c3 chore(deps-dev): bump i18next from 24.2.3 to 25.4.2 (#329)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-05 08:51:54 +08:00
dependabot[bot]
d59961c4d4 chore(deps-dev): bump next from 15.5.0 to 15.5.2 (#328)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-05 08:51:23 +08:00
dependabot[bot]
d0ec80303a chore(deps-dev): bump jest-mock-extended from 4.0.0-beta1 to 4.0.0 (#330)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-04 22:49:30 +08:00
dependabot[bot]
69e92b5aa8 chore(deps): bump @apidevtools/swagger-parser from 11.0.1 to 12.0.0 (#331)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-04 22:48:29 +08:00
dependabot[bot]
5acae64b29 chore(deps-dev): bump @typescript-eslint/eslint-plugin from 6.21.0 to 7.0.0 (#327)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-04 22:47:30 +08:00
samanhappy
a5fc4a429d fix: ensure settings cache is cleared after saving and clearing cache (#326) 2025-09-01 10:15:50 +08:00
Junsen Huang
ce15330016 fix(server-context): fix server edite error (#325) 2025-08-30 17:37:30 +08:00
samanhappy
621bc36560 feat: Add OpenAPI-compatible tool execution endpoints for named APIs (#316) 2025-08-30 16:00:15 +08:00
Junsen Huang
c398223824 feat: refactor server data management to use global context (#319) 2025-08-28 22:15:16 +08:00
Copilot
5dd3e7978e Generate comprehensive GitHub Copilot instructions for MCPHub development (#314)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-08-27 21:58:06 +08:00
samanhappy
f577351f04 fix: set current working directory for StdioClientTransport to homedir (#311) 2025-08-27 19:23:00 +08:00
Copilot
62de87b1a4 Add granular OpenAPI endpoints for server-level and group-level tool access (#309)
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-08-27 17:25:32 +08:00
samanhappy
bbd6c891c9 feat(dao): Implement comprehensive DAO layer (#308)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-08-27 15:21:30 +08:00
Copilot
f9019545c3 Fix integer parameter conversion in OpenAPI endpoints (#306)
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-08-27 11:04:25 +08:00
samanhappy
d778536388 fix: update tool call API endpoint structure and enhance error handling (#300) 2025-08-26 18:49:34 +08:00
Copilot
976e90679d Add OpenAPI specification generation for OpenWebUI integration (#295)
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-08-26 14:54:19 +08:00
samanhappy
f6ee9beed3 refactor: remove MCPRouter referer and title input sections from SettingsPage (#294) 2025-08-25 15:51:02 +08:00
samanhappy
69a800fa7a fix: update MCPRouter referer URL to new domain (#293) 2025-08-25 13:25:37 +08:00
Copilot
83cbd16821 Fix Dependabot security alert #11 - resolve sha.js and brace-expansion vulnerabilities (#292)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-08-25 12:26:04 +08:00
samanhappy
9300814994 Add .git to .dockerignore to prevent Git files from being included in Docker builds (#290) 2025-08-24 15:37:38 +08:00
Rilomilo
9952927a13 remove redundant code (#288) 2025-08-24 11:40:33 +08:00
231 changed files with 34513 additions and 19618 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
.git

View File

@@ -1,2 +1,8 @@
PORT=3000
NODE_ENV=development
# Database Configuration (Optional - for database-backed configuration)
# Simply set DB_URL to enable database mode (auto-detected)
# DB_URL=postgresql://mcphub:password@localhost:5432/mcphub
# Or explicitly control with USE_DB (overrides auto-detection)
# USE_DB=true

124
.github/DOCKER_CLI_TEST.md vendored Normal file
View File

@@ -0,0 +1,124 @@
# Docker Engine Installation Test Procedure
This document describes how to test the Docker Engine installation feature added with the `INSTALL_EXT=true` build argument.
## Test 1: Build with INSTALL_EXT=false (default)
```bash
# Build without extended features
docker build -t mcphub:base .
# Run the container
docker run --rm mcphub:base docker --version
```
**Expected Result**: `docker: not found` error (Docker is NOT installed)
## Test 2: Build with INSTALL_EXT=true
```bash
# Build with extended features
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
# Test Docker CLI is available
docker run --rm mcphub:extended docker --version
```
**Expected Result**: Docker version output (e.g., `Docker version 27.x.x, build xxxxx`)
## Test 3: Docker-in-Docker with Auto-start Daemon
```bash
# Build with extended features
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
# Run with privileged mode (allows Docker daemon to start)
docker run -d \
--name mcphub-test \
--privileged \
-p 3000:3000 \
mcphub:extended
# Wait a few seconds for daemon to start
sleep 5
# Test Docker commands from inside the container
docker exec mcphub-test docker ps
docker exec mcphub-test docker images
docker exec mcphub-test docker info
# Cleanup
docker stop mcphub-test
docker rm mcphub-test
```
**Expected Result**:
- Docker daemon should auto-start inside the container
- Docker commands should work without mounting the host's Docker socket
- `docker info` should show the container's own Docker daemon
## Test 4: Docker-in-Docker with Host Socket (Alternative)
```bash
# Build with extended features
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
# Run with Docker socket mounted (uses host's daemon)
docker run -d \
--name mcphub-test \
-p 3000:3000 \
-v /var/run/docker.sock:/var/run/docker.sock \
mcphub:extended
# Test Docker commands from inside the container
docker exec mcphub-test docker ps
docker exec mcphub-test docker images
# Cleanup
docker stop mcphub-test
docker rm mcphub-test
```
**Expected Result**:
- Docker daemon should NOT auto-start (socket already exists from host)
- Docker commands should work and show the host's containers and images
## Test 5: Verify Image Size
```bash
# Build both versions
docker build -t mcphub:base .
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
# Compare image sizes
docker images mcphub:*
```
**Expected Result**:
- The `extended` image should be larger than the `base` image
- The size difference should be reasonable (Docker Engine adds ~100-150MB)
## Test 6: Architecture Support
```bash
# On AMD64/x86_64
docker build --build-arg INSTALL_EXT=true --platform linux/amd64 -t mcphub:extended-amd64 .
# On ARM64
docker build --build-arg INSTALL_EXT=true --platform linux/arm64 -t mcphub:extended-arm64 .
```
**Expected Result**:
- Both builds should succeed
- AMD64 includes Chrome/Playwright + Docker Engine
- ARM64 includes Docker Engine only (Chrome installation is skipped)
## Notes
- The Docker Engine installation follows the official Docker documentation
- Includes full Docker daemon (`dockerd`), CLI (`docker`), and containerd
- The daemon auto-starts when running in privileged mode
- The installation uses the Debian Bookworm repository
- All temporary files are cleaned up to minimize image size
- The feature is opt-in via the `INSTALL_EXT` build argument
- `iptables` is installed as it's required for Docker networking

View File

@@ -1,50 +1,272 @@
# MCPHub Coding Instructions
**ALWAYS follow these instructions first and only fallback to additional search and context gathering if the information here is incomplete or found to be in error.**
## Project Overview
MCPHub is a TypeScript/Node.js MCP server management hub that provides unified access through HTTP endpoints.
MCPHub is a TypeScript/Node.js MCP (Model Context Protocol) server management hub that provides unified access through HTTP endpoints. It serves as a centralized dashboard for managing multiple MCP servers with real-time monitoring, authentication, and flexible routing.
**Core Components:**
- **Backend**: Express.js + TypeScript + ESM (`src/server.ts`)
- **Frontend**: React/Vite + Tailwind CSS (`frontend/`)
- **MCP Integration**: Connects multiple MCP servers (`src/services/mcpService.ts`)
- **Authentication**: JWT-based with bcrypt password hashing
- **Configuration**: JSON-based MCP server definitions (`mcp_settings.json`)
- **Documentation**: API docs and usage instructions(`docs/`)
## Development Environment
## Working Effectively
### Bootstrap and Setup (CRITICAL - Follow Exact Steps)
```bash
# Install pnpm if not available
npm install -g pnpm
# Install dependencies - takes ~30 seconds
pnpm install
pnpm dev # Start both backend and frontend
pnpm backend:dev # Backend only
pnpm frontend:dev # Frontend only
# Setup environment (optional)
cp .env.example .env
# Build and test to verify setup
pnpm lint # ~3 seconds - NEVER CANCEL
pnpm backend:build # ~5 seconds - NEVER CANCEL
pnpm test:ci # ~16 seconds - NEVER CANCEL. Set timeout to 60+ seconds
pnpm frontend:build # ~5 seconds - NEVER CANCEL
pnpm build # ~10 seconds total - NEVER CANCEL. Set timeout to 60+ seconds
```
## Project Conventions
**CRITICAL TIMING**: These commands are fast but NEVER CANCEL them. Always wait for completion.
### File Structure
### Development Environment
- `src/services/` - Core business logic
- `src/controllers/` - HTTP request handlers
- `src/types/index.ts` - TypeScript type definitions
```bash
# Start both backend and frontend (recommended for most development)
pnpm dev # Backend on :3001, Frontend on :5173
# OR start separately (required on Windows, optional on Linux/macOS)
# Terminal 1: Backend only
pnpm backend:dev # Runs on port 3000 (or PORT env var)
# Terminal 2: Frontend only
pnpm frontend:dev # Runs on port 5173, proxies API to backend
```
**NEVER CANCEL**: Development servers may take 10-15 seconds to fully initialize all MCP servers.
### Build Commands (Production)
```bash
# Full production build - takes ~10 seconds total
pnpm build # NEVER CANCEL - Set timeout to 60+ seconds
# Individual builds
pnpm backend:build # TypeScript compilation - ~5 seconds
pnpm frontend:build # Vite build - ~5 seconds
# Start production server
pnpm start # Requires dist/ and frontend/dist/ to exist
```
### Testing and Validation
```bash
# Run all tests - takes ~16 seconds with 73 tests
pnpm test:ci # NEVER CANCEL - Set timeout to 60+ seconds
# Development testing
pnpm test # Interactive mode
pnpm test:watch # Watch mode for development
pnpm test:coverage # With coverage report
# Code quality
pnpm lint # ESLint - ~3 seconds
pnpm format # Prettier formatting - ~3 seconds
```
**CRITICAL**: All tests MUST pass before committing. Do not modify tests to make them pass unless specifically required for your changes.
## Manual Validation Requirements
**ALWAYS perform these validation steps after making changes:**
### 1. Basic Application Functionality
```bash
# Start the application
pnpm dev
# Verify backend responds (in another terminal)
curl http://localhost:3000/api/health
# Expected: Should return health status
# Verify frontend serves
curl -I http://localhost:3000/
# Expected: HTTP 200 OK with HTML content
```
### 2. MCP Server Integration Test
```bash
# Check MCP servers are loading (look for log messages)
# Expected log output should include:
# - "Successfully connected client for server: [name]"
# - "Successfully listed [N] tools for server: [name]"
# - Some servers may fail due to missing API keys (normal in dev)
```
### 3. Build Verification
```bash
# Verify production build works
pnpm build
node scripts/verify-dist.js
# Expected: "✅ Verification passed! Frontend and backend dist files are present."
```
**NEVER skip these validation steps**. If any fail, debug and fix before proceeding.
## Project Structure and Key Files
### Critical Backend Files
- `src/index.ts` - Application entry point
- `src/server.ts` - Express server setup and middleware
- `src/services/mcpService.ts` - **Core MCP server management logic**
- `src/config/index.ts` - Configuration management
- `src/routes/` - HTTP route definitions
- `src/controllers/` - HTTP request handlers
- `src/dao/` - Data access layer (supports JSON file & PostgreSQL)
- `src/db/` - TypeORM entities & repositories (for PostgreSQL mode)
- `src/types/index.ts` - TypeScript type definitions
### Key Notes
### DAO Layer (Dual Data Source)
- Use ESM modules: Import with `.js` extensions, not `.ts`
- Configuration file: `mcp_settings.json`
- Endpoint formats: `/mcp/{group|server}` and `/mcp/$smart`
- All code comments must be written in English
- Frontend uses i18n with resource files in `locales/` folder
- Server-side code should use appropriate abstraction layers for extensibility and replaceability
MCPHub supports **JSON file** (default) and **PostgreSQL** storage:
## Development Process
- Set `USE_DB=true` + `DB_URL=postgresql://...` to use database
- When modifying data structures, update: `src/types/`, `src/dao/`, `src/db/entities/`, `src/db/repositories/`, `src/utils/migration.ts`
- See `AGENTS.md` for detailed DAO modification checklist
- For complex features, implement step by step and wait for confirmation before proceeding to the next step
- After implementing features, no separate summary documentation is needed - update README.md and README.zh.md as appropriate
### Critical Frontend Files
- `frontend/src/` - React application source
- `frontend/src/pages/` - Page components (development entry point)
- `frontend/src/components/` - Reusable UI components
- `frontend/src/utils/fetchInterceptor.js` - Backend API interaction
### Configuration Files
- `mcp_settings.json` - **MCP server definitions and user accounts**
- `package.json` - Dependencies and scripts
- `tsconfig.json` - TypeScript configuration
- `jest.config.cjs` - Test configuration
- `.eslintrc.json` - Linting rules
### Docker and Deployment
- `Dockerfile` - Multi-stage build with Python base + Node.js
- `entrypoint.sh` - Docker startup script
- `bin/cli.js` - NPM package CLI entry point
## Development Process and Conventions
### Code Style Requirements
- **ESM modules**: Always use `.js` extensions in imports, not `.ts`
- **English only**: All code comments must be written in English
- **TypeScript strict**: Follow strict type checking rules
- **Import style**: `import { something } from './file.js'` (note .js extension)
### Key Configuration Notes
- **MCP servers**: Defined in `mcp_settings.json` with command/args
- **Endpoints**: `/mcp/{group|server}` and `/mcp/$smart` for routing
- **i18n**: Frontend uses react-i18next with files in `locales/` folder
- **Authentication**: JWT tokens with bcrypt password hashing
- **Default credentials**: admin/admin123 (configured in mcp_settings.json)
### Development Entry Points
- **MCP Servers**: Modify `src/services/mcpService.ts`
- **API Endpoints**: Add routes in `src/routes/`, controllers in `src/controllers/`
- **Frontend Features**: Start from `frontend/src/pages/`
- **Testing**: Follow existing patterns in `tests/`
- **Add MCP server**: Modify `mcp_settings.json` and restart
- **New API endpoint**: Add route in `src/routes/`, controller in `src/controllers/`
- **Frontend feature**: Start from `frontend/src/pages/` or `frontend/src/components/`
- **Add tests**: Follow patterns in `tests/` directory
### Common Development Tasks
#### Adding a new MCP server:
1. Add server definition to `mcp_settings.json`
2. Restart backend to load new server
3. Check logs for successful connection
4. Test via dashboard or API endpoints
#### API development:
1. Define route in `src/routes/`
2. Implement controller in `src/controllers/`
3. Add types in `src/types/index.ts` if needed
4. Write tests in `tests/controllers/`
#### Frontend development:
1. Create/modify components in `frontend/src/components/`
2. Add pages in `frontend/src/pages/`
3. Update routing if needed
4. Test in development mode with `pnpm frontend:dev`
#### Documentation:
1. Update or add docs in `docs/` folder
2. Ensure README.md reflects any major changes
## Validation and CI Requirements
### Before Committing - ALWAYS Run:
```bash
pnpm lint # Must pass - ~3 seconds
pnpm backend:build # Must compile - ~5 seconds
pnpm test:ci # All tests must pass - ~16 seconds
pnpm build # Full build must work - ~10 seconds
```
**CRITICAL**: CI will fail if any of these commands fail. Fix issues locally first.
### CI Pipeline (.github/workflows/ci.yml)
- Runs on Node.js 20.x
- Tests: linting, type checking, unit tests with coverage
- **NEVER CANCEL**: CI builds may take 2-3 minutes total
## Troubleshooting
### Common Issues
- **"uvx command not found"**: Some MCP servers require `uvx` (Python package manager) - this is expected in development
- **Port already in use**: Change PORT environment variable or kill existing processes
- **Frontend not loading**: Ensure frontend was built with `pnpm frontend:build`
- **MCP server connection failed**: Check server command/args in `mcp_settings.json`
### Build Failures
- **TypeScript errors**: Run `pnpm backend:build` to see compilation errors
- **Test failures**: Run `pnpm test:verbose` for detailed test output
- **Lint errors**: Run `pnpm lint` and fix reported issues
### Development Issues
- **Backend not starting**: Check for port conflicts, verify `mcp_settings.json` syntax
- **Frontend proxy errors**: Ensure backend is running before starting frontend
- **Hot reload not working**: Restart development server
## Performance Notes
- **Install time**: pnpm install takes ~30 seconds
- **Build time**: Full build takes ~10 seconds
- **Test time**: Complete test suite takes ~16 seconds
- **Startup time**: Backend initialization takes 10-15 seconds (MCP server connections)
**Remember**: NEVER CANCEL any build or test commands. Always wait for completion even if they seem slow.

4
.gitignore vendored
View File

@@ -2,6 +2,7 @@
/node_modules
/.pnp
.pnp.js
package-lock.json
# production
dist
@@ -25,4 +26,5 @@ yarn-error.log*
*.log
coverage/
data/
data/
temp-test-config/

View File

@@ -4,4 +4,4 @@
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2
}
}

78
AGENTS.md Normal file
View File

@@ -0,0 +1,78 @@
# Repository Guidelines
These notes align current contributors around the code layout, daily commands, and collaboration habits that keep `@samanhappy/mcphub` moving quickly.
## Project Structure & Module Organization
- Backend services live in `src`, grouped by responsibility (`controllers/`, `services/`, `dao/`, `routes/`, `utils/`), with `server.ts` orchestrating HTTP bootstrap.
- `frontend/src` contains the Vite + React dashboard; `frontend/public` hosts static assets and translations sit in `locales/`.
- Jest-aware test code is split between colocated specs (`src/**/*.{test,spec}.ts`) and higher-level suites in `tests/`; use `tests/utils/` helpers when exercising the CLI or SSE flows.
- Build artifacts and bundles are generated into `dist/`, `frontend/dist/`, and `coverage/`; never edit these manually.
## Build, Test, and Development Commands
- `pnpm dev` runs backend (`tsx watch src/index.ts`) and frontend (`vite`) together for local iteration.
- `pnpm backend:dev`, `pnpm frontend:dev`, and `pnpm frontend:preview` target each surface independently; prefer them when debugging one stack.
- `pnpm build` executes `pnpm backend:build` (TypeScript to `dist/`) and `pnpm frontend:build`; run before release or publishing.
- `pnpm test`, `pnpm test:watch`, and `pnpm test:coverage` drive Jest; `pnpm lint` and `pnpm format` enforce style via ESLint and Prettier.
## Coding Style & Naming Conventions
- TypeScript everywhere; default to 2-space indentation and single quotes, letting Prettier settle formatting. ESLint configuration assumes ES modules.
- Name services and data access layers with suffixes (`UserService`, `AuthDao`), React components and files in `PascalCase`, and utility modules in `camelCase`.
- Keep DTOs and shared types in `src/types` to avoid duplication; re-export through index files only when it clarifies imports.
## Testing Guidelines
- Use Jest with the `ts-jest` ESM preset; place shared setup in `tests/setup.ts` and mock helpers under `tests/utils/`.
- Mirror production directory names when adding new suites and end filenames with `.test.ts` or `.spec.ts` for automatic discovery.
- Aim to maintain or raise coverage when touching critical flows (auth, OAuth, SSE); add integration tests under `tests/integration/` when touching cross-service logic.
## Commit & Pull Request Guidelines
- Follow the existing Conventional Commit pattern (`feat:`, `fix:`, `chore:`, etc.) with imperative, present-tense summaries and optional multi-line context.
- Each PR should describe the behavior change, list testing performed, and link issues; include before/after screenshots or GIFs for frontend tweaks.
- Re-run `pnpm build` and `pnpm test` before requesting review, and ensure generated artifacts stay out of the diff.
## DAO Layer & Dual Data Source
MCPHub supports **JSON file** (default) and **PostgreSQL** storage. Set `USE_DB=true` + `DB_URL` to switch.
### Key Files
- `src/types/index.ts` - Core interfaces (`IUser`, `IGroup`, `ServerConfig`, etc.)
- `src/dao/*Dao.ts` - DAO interface + JSON implementation
- `src/dao/*DaoDbImpl.ts` - Database implementation
- `src/db/entities/*.ts` - TypeORM entities
- `src/db/repositories/*.ts` - TypeORM repository wrappers
- `src/utils/migration.ts` - JSON-to-database migration
### Modifying Data Structures (CRITICAL)
When adding/changing fields, update **ALL** these files:
| Step | File | Action |
| ---- | -------------------------- | ---------------------------- |
| 1 | `src/types/index.ts` | Add field to interface |
| 2 | `src/dao/*Dao.ts` | Update JSON impl if needed |
| 3 | `src/db/entities/*.ts` | Add TypeORM `@Column` |
| 4 | `src/dao/*DaoDbImpl.ts` | Map field in create/update |
| 5 | `src/db/repositories/*.ts` | Update if needed |
| 6 | `src/utils/migration.ts` | Include in migration |
| 7 | `mcp_settings.json` | Update example if applicable |
### Data Type Mapping
| Model | DAO | DB Entity | JSON Path |
| -------------- | ----------------- | -------------- | ------------------------ |
| `IUser` | `UserDao` | `User` | `settings.users[]` |
| `ServerConfig` | `ServerDao` | `Server` | `settings.mcpServers{}` |
| `IGroup` | `GroupDao` | `Group` | `settings.groups[]` |
| `SystemConfig` | `SystemConfigDao` | `SystemConfig` | `settings.systemConfig` |
| `UserConfig` | `UserConfigDao` | `UserConfig` | `settings.userConfigs{}` |
### Common Pitfalls
- Forgetting migration script → fields won't migrate to DB
- Optional fields need `nullable: true` in entity
- Complex objects need `simple-json` column type

View File

@@ -2,7 +2,7 @@ FROM python:3.13-slim-bookworm AS base
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN apt-get update && apt-get install -y curl gnupg git \
RUN apt-get update && apt-get install -y curl gnupg git build-essential \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
@@ -22,6 +22,16 @@ RUN if [ "$INSTALL_EXT" = "true" ]; then \
else \
echo "Skipping Chrome installation on non-amd64 architecture: $ARCH"; \
fi; \
# Install Docker Engine (includes CLI and daemon) \
apt-get update && \
apt-get install -y ca-certificates curl iptables && \
install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \
chmod a+r /etc/apt/keyrings/docker.asc && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null && \
apt-get update && \
apt-get install -y docker-ce docker-ce-cli containerd.io && \
apt-get clean && rm -rf /var/lib/apt/lists/*; \
fi
RUN uv tool install mcp-server-fetch

119
README.fr.md Normal file
View File

@@ -0,0 +1,119 @@
# MCPHub : Le Hub Unifié pour les Serveurs MCP
[English](README.md) | Français | [中文版](README.zh.md)
MCPHub facilite la gestion et la mise à l'échelle de plusieurs serveurs MCP (Model Context Protocol) en les organisant en points de terminaison HTTP streamables (SSE) flexibles, prenant en charge l'accès à tous les serveurs, à des serveurs individuels ou à des groupes de serveurs logiques.
![Aperçu du tableau de bord](assets/dashboard.zh.png)
## 🌐 Démo en direct et Documentation
- **Documentation** : [docs.mcphubx.com](https://docs.mcphubx.com/)
- **Environnement de démo** : [demo.mcphubx.com](https://demo.mcphubx.com/)
## 🚀 Fonctionnalités
- **Gestion centralisée** - Surveillez et contrôlez tous les serveurs MCP depuis un tableau de bord unifié
- **Routage flexible** - Accédez à tous les serveurs, groupes spécifiques ou serveurs individuels via HTTP/SSE
- **Routage intelligent** - Découverte d'outils propulsée par IA utilisant la recherche sémantique vectorielle ([En savoir plus](https://docs.mcphubx.com/features/smart-routing))
- **Configuration à chaud** - Ajoutez, supprimez ou mettez à jour les serveurs sans temps d'arrêt
- **Support OAuth 2.0** - Modes client et serveur pour une authentification sécurisée ([En savoir plus](https://docs.mcphubx.com/features/oauth))
- **Mode Base de données** - Stockez la configuration dans PostgreSQL pour les environnements de production ([En savoir plus](https://docs.mcphubx.com/configuration/database-configuration))
- **Prêt pour Docker** - Déployez instantanément avec la configuration conteneurisée
## 🔧 Démarrage rapide
### Configuration
Créez un fichier `mcp_settings.json` :
```json
{
"mcpServers": {
"time": {
"command": "npx",
"args": ["-y", "time-mcp"]
},
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
}
}
}
```
📖 Consultez le [Guide de configuration](https://docs.mcphubx.com/configuration/mcp-settings) pour les options complètes incluant OAuth, les variables d'environnement, et plus.
### Déploiement avec Docker
```bash
# Exécutez avec une configuration personnalisée (recommandé)
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
# Ou exécutez avec les paramètres par défaut
docker run -p 3000:3000 samanhappy/mcphub
```
### Accéder au tableau de bord
Ouvrez `http://localhost:3000` et connectez-vous avec les identifiants par défaut : `admin` / `admin123`
### Connecter les clients IA
Connectez les clients IA (Claude Desktop, Cursor, etc.) via :
```
http://localhost:3000/mcp # Tous les serveurs
http://localhost:3000/mcp/{group} # Groupe spécifique
http://localhost:3000/mcp/{server} # Serveur spécifique
http://localhost:3000/mcp/$smart # Routage intelligent
```
📖 Consultez la [Référence API](https://docs.mcphubx.com/api-reference) pour la documentation détaillée des points de terminaison.
## 📚 Documentation
| Sujet | Description |
| ------------------------------------------------------------------------------------- | ------------------------------------------- |
| [Démarrage rapide](https://docs.mcphubx.com/quickstart) | Commencez en 5 minutes |
| [Configuration](https://docs.mcphubx.com/configuration/mcp-settings) | Options de configuration du serveur MCP |
| [Mode Base de données](https://docs.mcphubx.com/configuration/database-configuration) | Configuration PostgreSQL pour la production |
| [OAuth](https://docs.mcphubx.com/features/oauth) | Configuration client et serveur OAuth 2.0 |
| [Routage intelligent](https://docs.mcphubx.com/features/smart-routing) | Découverte d'outils propulsée par IA |
| [Configuration Docker](https://docs.mcphubx.com/configuration/docker-setup) | Guide de déploiement Docker |
## 🧑‍💻 Développement local
```bash
git clone https://github.com/samanhappy/mcphub.git
cd mcphub
pnpm install
pnpm dev
```
> Pour les utilisateurs Windows, démarrez le backend et le frontend séparément : `pnpm backend:dev`, `pnpm frontend:dev`
📖 Consultez le [Guide de développement](https://docs.mcphubx.com/development) pour les instructions de configuration détaillées.
## 🔍 Stack technique
- **Backend** : Node.js, Express, TypeScript
- **Frontend** : React, Vite, Tailwind CSS
- **Authentification** : JWT & bcrypt
- **Protocole** : Model Context Protocol SDK
## 👥 Contribuer
Les contributions sont les bienvenues ! Rejoignez notre [communauté Discord](https://discord.gg/qMKNsn5Q) pour des discussions et du support.
## ❤️ Sponsor
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/samanhappy)
## 🌟 Historique des étoiles
[![Historique des étoiles](https://api.star-history.com/svg?repos=samanhappy/mcphub&type=Date)](https://www.star-history.com/#samanhappy/mcphub&Date)
## 📄 Licence
Sous licence [Apache 2.0 License](LICENSE).

188
README.md
View File

@@ -1,6 +1,6 @@
# MCPHub: The Unified Hub for Model Context Protocol (MCP) Servers
English | [中文版](README.zh.md)
English | [Français](README.fr.md) | [中文版](README.zh.md)
MCPHub makes it easy to manage and scale multiple MCP (Model Context Protocol) servers by organizing them into flexible Streamable HTTP (SSE) endpoints—supporting access to all servers, individual servers, or logical server groups.
@@ -13,171 +13,74 @@ MCPHub makes it easy to manage and scale multiple MCP (Model Context Protocol) s
## 🚀 Features
- **Broadened MCP Server Support**: Seamlessly integrate any MCP server with minimal configuration.
- **Centralized Dashboard**: Monitor real-time status and performance metrics from one sleek web UI.
- **Flexible Protocol Handling**: Full compatibility with both stdio and SSE MCP protocols.
- **Hot-Swappable Configuration**: Add, remove, or update MCP servers on the fly — no downtime required.
- **Group-Based Access Control**: Organize servers into customizable groups for streamlined permissions management.
- **Secure Authentication**: Built-in user management with role-based access powered by JWT and bcrypt.
- **Docker-Ready**: Deploy instantly with our containerized setup.
- **Centralized Management** - Monitor and control all MCP servers from a unified dashboard
- **Flexible Routing** - Access all servers, specific groups, or individual servers via HTTP/SSE
- **Smart Routing** - AI-powered tool discovery using vector semantic search ([Learn more](https://docs.mcphubx.com/features/smart-routing))
- **Hot-Swappable Config** - Add, remove, or update servers without downtime
- **OAuth 2.0 Support** - Both client and server modes for secure authentication ([Learn more](https://docs.mcphubx.com/features/oauth))
- **Database Mode** - Store configuration in PostgreSQL for production environments ([Learn more](https://docs.mcphubx.com/configuration/database-configuration))
- **Docker-Ready** - Deploy instantly with containerized setup
## 🔧 Quick Start
### Configuration
Create a `mcp_settings.json` file to customize your server settings:
Create a `mcp_settings.json` file:
```json
{
"mcpServers": {
"amap": {
"time": {
"command": "npx",
"args": ["-y", "@amap/amap-maps-mcp-server"],
"env": {
"AMAP_MAPS_API_KEY": "your-api-key"
}
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless"]
"args": ["-y", "time-mcp"]
},
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
},
"slack": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-slack"],
"env": {
"SLACK_BOT_TOKEN": "your-bot-token",
"SLACK_TEAM_ID": "your-team-id"
}
}
}
}
```
📖 See [Configuration Guide](https://docs.mcphubx.com/configuration/mcp-settings) for full options including OAuth, environment variables, and more.
### Docker Deployment
**Recommended**: Mount your custom config:
```bash
# Run with custom config (recommended)
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
```
or run with default settings:
```bash
# Or run with default settings
docker run -p 3000:3000 samanhappy/mcphub
```
### Access the Dashboard
### Access Dashboard
Open `http://localhost:3000` and log in with your credentials.
Open `http://localhost:3000` and log in with default credentials: `admin` / `admin123`
> **Note**: Default credentials are `admin` / `admin123`.
### Connect AI Clients
**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:
Connect AI clients (Claude Desktop, Cursor, etc.) via:
```
http://localhost:3000/mcp
http://localhost:3000/mcp # All servers
http://localhost:3000/mcp/{group} # Specific group
http://localhost:3000/mcp/{server} # Specific server
http://localhost:3000/mcp/$smart # Smart routing
```
This endpoint provides a unified streamable HTTP interface for all your MCP servers. It allows you to:
📖 See [API Reference](https://docs.mcphubx.com/api-reference) for detailed endpoint documentation.
- 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
## 📚 Documentation
**Smart Routing (Experimental)**:
Smart Routing is MCPHub's intelligent tool discovery system that uses vector semantic search to automatically find the most relevant tools for any given task.
```
http://localhost:3000/mcp/$smart
```
**How it Works:**
1. **Tool Indexing**: All MCP tools are automatically converted to vector embeddings and stored in PostgreSQL with pgvector
2. **Semantic Search**: User queries are converted to vectors and matched against tool embeddings using cosine similarity
3. **Intelligent Filtering**: Dynamic thresholds ensure relevant results without noise
4. **Precise Execution**: Found tools can be directly executed with proper parameter validation
**Setup Requirements:**
![Smart Routing](assets/smart-routing.png)
To enable Smart Routing, you need:
- PostgreSQL with pgvector extension
- OpenAI API key (or compatible embedding service)
- Enable Smart Routing in MCPHub settings
**Group-Specific Endpoints (Recommended)**:
![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.
### SSE Endpoint (Deprecated in Future)
Connect AI clients (e.g., Claude Desktop, Cursor, DeepChat, etc.) via:
```
http://localhost:3000/sse
```
For smart routing, use:
```
http://localhost:3000/sse/$smart
```
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}
```
| Topic | Description |
| ------------------------------------------------------------------------------ | --------------------------------- |
| [Quick Start](https://docs.mcphubx.com/quickstart) | Get started in 5 minutes |
| [Configuration](https://docs.mcphubx.com/configuration/mcp-settings) | MCP server configuration options |
| [Database Mode](https://docs.mcphubx.com/configuration/database-configuration) | PostgreSQL setup for production |
| [OAuth](https://docs.mcphubx.com/features/oauth) | OAuth 2.0 client and server setup |
| [Smart Routing](https://docs.mcphubx.com/features/smart-routing) | AI-powered tool discovery |
| [Docker Setup](https://docs.mcphubx.com/configuration/docker-setup) | Docker deployment guide |
## 🧑‍💻 Local Development
@@ -188,19 +91,9 @@ pnpm install
pnpm dev
```
This starts both frontend and backend in development mode with hot-reloading.
> For Windows users, start backend and frontend separately: `pnpm backend:dev`, `pnpm frontend:dev`
> 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
```
📖 See [Development Guide](https://docs.mcphubx.com/development) for detailed setup instructions.
## 🔍 Tech Stack
@@ -211,19 +104,10 @@ proxy_buffering off
## 👥 Contributing
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.
Contributions welcome! See 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)
## 🌟 Star History

View File

@@ -1,6 +1,6 @@
# MCPHub一站式 MCP 服务器聚合平台
[English Version](README.md) | 中文版
[English](README.md) | [Français](README.fr.md) | 中文版
MCPHub 通过将多个 MCPModel Context Protocol服务器组织为灵活的流式 HTTPSSE端点简化了管理与扩展工作。系统支持按需访问全部服务器、单个服务器或按场景分组的服务器集合。
@@ -13,171 +13,74 @@ MCPHub 通过将多个 MCPModel Context Protocol服务器组织为灵活
## 🚀 功能亮点
- **广泛的 MCP 服务器支持**:无缝集成任何 MCP 服务器,配置简单。
- **集中式管理控制台**:在一个简洁的 Web UI 中实时监控所有服务器的状态和性能指标。
- **灵活的协议兼容**:完全支持 stdio 和 SSE 两种 MCP 协议。
- **热插拔配置**:在运行时动态添加、移除或更新服务器配置,无需停机。
- **基于分组的访问控制**:自定义分组并管理服务器访问权限。
- **安全认证机制**:内置用户管理,基于 JWT 和 bcrypt实现角色权限控制。
- **Docker 就绪**:提供容器化镜像,快速部署。
- **集中式管理** - 在统一控制台中监控和管理所有 MCP 服务器
- **灵活路由** - 通过 HTTP/SSE 访问所有服务器、特定分组或单个服务器
- **智能路由** - 基于向量语义搜索的 AI 工具发现 ([了解更多](https://docs.mcphubx.com/zh/features/smart-routing))
- **热插拔配置** - 无需停机即可添加、移除或更新服务器
- **OAuth 2.0 支持** - 客户端和服务端模式,实现安全认证 ([了解更多](https://docs.mcphubx.com/zh/features/oauth))
- **数据库模式** - 将配置存储在 PostgreSQL 中,适用于生产环境 ([了解更多](https://docs.mcphubx.com/zh/configuration/database-configuration))
- **Docker 就绪** - 容器化部署,开箱即用
## 🔧 快速开始
### 配置
通过创建 `mcp_settings.json` 自定义服务器设置
创建 `mcp_settings.json` 文件
```json
{
"mcpServers": {
"amap": {
"time": {
"command": "npx",
"args": ["-y", "@amap/amap-maps-mcp-server"],
"env": {
"AMAP_MAPS_API_KEY": "your-api-key"
}
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless"]
"args": ["-y", "time-mcp"]
},
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
},
"slack": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-slack"],
"env": {
"SLACK_BOT_TOKEN": "your-bot-token",
"SLACK_TEAM_ID": "your-team-id"
}
}
}
}
```
📖 查看[配置指南](https://docs.mcphubx.com/zh/configuration/mcp-settings)了解完整选项,包括 OAuth、环境变量等。
### Docker 部署
**推荐**:挂载自定义配置:
```bash
# 挂载自定义配置运行(推荐)
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
```
或使用默认配置运行
```bash
# 或使用默认配置运行
docker run -p 3000:3000 samanhappy/mcphub
```
### 访问控制台
打开 `http://localhost:3000`,使用您的账号登录
打开 `http://localhost:3000`,使用默认账号登录`admin` / `admin123`
> **提示**:默认用户名/密码为 `admin` / `admin123`。
### 连接 AI 客户端
**控制台功能**
- 实时监控所有 MCP 服务器状态
- 启用/禁用或重新配置服务器
- 分组管理,组织服务器访问
- 用户管理,设定权限
### 支持流式的 HTTP 端点
> 截至目前,各家 AI 客户端对流式的 HTTP 端点支持不一,如果遇到问题,可以使用 SSE 端点或者等待更新。
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、DeepChat 等):
通过以下地址连接 AI 客户端Claude Desktop、Cursor 等)
```
http://localhost:3000/mcp
http://localhost:3000/mcp # 所有服务器
http://localhost:3000/mcp/{group} # 特定分组
http://localhost:3000/mcp/{server} # 特定服务器
http://localhost:3000/mcp/$smart # 智能路由
```
这个端点为所有 MCP 服务器提供统一的流式 HTTP 接口。它允许您:
📖 查看 [API 参考](https://docs.mcphubx.com/zh/api-reference)了解详细的端点文档。
- 向任何配置的 MCP 服务器发送请求
- 实时接收响应
- 轻松与各种 AI 客户端和工具集成
- 对所有服务器使用相同的端点,简化集成过程
## 📚 文档
**智能路由(实验性功能)**
智能路由是 MCPHub 的智能工具发现系统,使用向量语义搜索自动为任何给定任务找到最相关的工具。
```
http://localhost:3000/mcp/$smart
```
**工作原理:**
1. **工具索引**:所有 MCP 工具自动转换为向量嵌入并存储在 PostgreSQL 与 pgvector 中
2. **语义搜索**:用户查询转换为向量并使用余弦相似度与工具嵌入匹配
3. **智能筛选**:动态阈值确保相关结果且无噪声
4. **精确执行**:找到的工具可以直接执行并进行适当的参数验证
**设置要求:**
![智能路由](assets/smart-routing.zh.png)
为了启用智能路由,您需要:
- 支持 pgvector 扩展的 PostgreSQL
- OpenAI API 密钥(或兼容的嵌入服务)
- 在 MCPHub 设置中启用智能路由
**基于分组的 HTTP 端点(推荐)**
![分组](assets/group.zh.png)
要针对特定服务器分组进行访问,请使用基于分组的 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 等):
```
http://localhost:3000/sse
```
要启用智能路由,请使用:
```
http://localhost:3000/sse/$smart
```
要针对特定服务器分组进行访问,请使用基于分组的 SSE 端点:
```
http://localhost:3000/sse/{group}
```
要针对特定服务器进行访问,请使用以下格式:
```
http://localhost:3000/sse/{server}
```
| 主题 | 描述 |
| ------------------------------------------------------------------------------ | ---------------------------- |
| [快速开始](https://docs.mcphubx.com/zh/quickstart) | 5 分钟快速上手 |
| [配置指南](https://docs.mcphubx.com/zh/configuration/mcp-settings) | MCP 服务器配置选项 |
| [数据库模式](https://docs.mcphubx.com/zh/configuration/database-configuration) | PostgreSQL 生产环境配置 |
| [OAuth](https://docs.mcphubx.com/zh/features/oauth) | OAuth 2.0 客户端和服务端配置 |
| [智能路由](https://docs.mcphubx.com/zh/features/smart-routing) | AI 驱动的工具发现 |
| [Docker 部署](https://docs.mcphubx.com/zh/configuration/docker-setup) | Docker 部署指南 |
## 🧑‍💻 本地开发
@@ -188,19 +91,9 @@ pnpm install
pnpm dev
```
此命令将在开发模式下启动前后端,并启用热重载。
> Windows 用户需分别启动后端和前端:`pnpm backend:dev``pnpm frontend:dev`
> 针对 Windows 用户,可能需要分别启动后端服务器和前端:`pnpm backend:dev``pnpm frontend:dev`
## 🛠️ 常见问题
### 使用 nginx 反向代理
如果您在使用 nginx 反向代理 MCPHub请确保在 nginx 配置中添加以下内容:
```nginx
proxy_buffering off
```
📖 查看[开发指南](https://docs.mcphubx.com/zh/development)了解详细设置说明
## 🔍 技术栈
@@ -211,13 +104,6 @@ proxy_buffering off
## 👥 贡献指南
期待您的贡献!
- 新功能与优化
- 文档完善
- Bug 报告与修复
- 翻译与建议
欢迎加入企微交流共建群,由于群人数限制,有兴趣的同学可以扫码添加管理员为好友后拉入群聊。
<img src="assets/wexin.png" width="350">
@@ -228,7 +114,7 @@ proxy_buffering off
## 致谢
感谢以下人员的赞赏:小白、琛。你们的支持是我继续前进的动力!
感谢以下朋友的赞赏:小白、唐秀川、琛、孔、黄祥取、兰军飞、无名之辈、Kyle以及其他匿名支持者。
## 🌟 Star 历史趋势

View File

@@ -1,8 +1,7 @@
#!/usr/bin/env node
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import { fileURLToPath, pathToFileURL } from 'url';
import fs from 'fs';
// Enable debug logging if needed
@@ -90,7 +89,10 @@ checkFrontend(projectRoot);
// Start the server
console.log('🚀 Starting MCPHub server...');
import(path.join(projectRoot, 'dist', 'index.js')).catch(err => {
const entryPath = path.join(projectRoot, 'dist', 'index.js');
// Convert to file:// URL for cross-platform ESM compatibility (required on Windows)
const entryUrl = pathToFileURL(entryPath).href;
import(entryUrl).catch(err => {
console.error('Failed to start MCPHub:', err);
process.exit(1);
});

60
docker-compose.db.yml Normal file
View File

@@ -0,0 +1,60 @@
version: "3.8"
services:
# PostgreSQL database for MCPHub configuration
postgres:
image: postgres:16-alpine
container_name: mcphub-postgres
environment:
POSTGRES_DB: mcphub
POSTGRES_USER: mcphub
POSTGRES_PASSWORD: ${DB_PASSWORD:-mcphub_password}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "${DB_PORT:-5432}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mcphub"]
interval: 10s
timeout: 5s
retries: 5
networks:
- mcphub-network
# MCPHub application
mcphub:
image: samanhappy/mcphub:latest
container_name: mcphub
environment:
# Database connection (setting DB_URL automatically enables database mode)
DB_URL: "postgresql://mcphub:${DB_PASSWORD:-mcphub_password}@postgres:5432/mcphub"
# Optional: Explicitly control database mode (overrides auto-detection)
# USE_DB: "true"
# Application settings
PORT: 3000
NODE_ENV: production
# Optional: Custom npm registry
# NPM_REGISTRY: https://registry.npmjs.org/
# Optional: Proxy settings
# HTTP_PROXY: http://proxy:8080
# HTTPS_PROXY: http://proxy:8080
ports:
- "${MCPHUB_PORT:-3000}:3000"
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
networks:
- mcphub-network
volumes:
pgdata:
driver: local
networks:
mcphub-network:
driver: bridge

View File

@@ -0,0 +1,276 @@
---
title: "OpenAPI Integration"
description: "Generate OpenAPI specifications from MCP tools for seamless integration with OpenWebUI and other systems"
---
# OpenAPI Generation for OpenWebUI Integration
MCPHub now supports generating OpenAPI 3.0.3 specifications from MCP tools, enabling seamless integration with OpenWebUI and other OpenAPI-compatible systems without requiring MCPO as an intermediary proxy.
## Features
- ✅ **Automatic OpenAPI Generation**: Converts MCP tools to OpenAPI 3.0.3 specification
- ✅ **OpenWebUI Compatible**: Direct integration without MCPO proxy
- ✅ **Real-time Tool Discovery**: Dynamically includes tools from connected MCP servers
- ✅ **Dual Parameter Support**: Supports both GET (query params) and POST (JSON body) for tool execution
- ✅ **No Authentication Required**: OpenAPI endpoints are public for easy integration
- ✅ **Comprehensive Metadata**: Full OpenAPI specification with proper schemas and documentation
## API Endpoints
### OpenAPI Specification
<CodeGroup>
```bash GET /api/openapi.json
curl "http://localhost:3000/api/openapi.json"
```
```bash With Parameters
curl "http://localhost:3000/api/openapi.json?title=My MCP API&version=2.0.0"
```
</CodeGroup>
Generates and returns the complete OpenAPI 3.0.3 specification for all connected MCP tools.
**Query Parameters:**
<ParamField query="title" type="string" optional>
Custom API title
</ParamField>
<ParamField query="description" type="string" optional>
Custom API description
</ParamField>
<ParamField query="version" type="string" optional>
Custom API version
</ParamField>
<ParamField query="serverUrl" type="string" optional>
Custom server URL
</ParamField>
<ParamField query="includeDisabled" type="boolean" optional default="false">
Include disabled tools
</ParamField>
<ParamField query="servers" type="string" optional>
Comma-separated list of server names to include
</ParamField>
### Group/Server-Specific OpenAPI Specification
<CodeGroup>
```bash GET /api/:name/openapi.json
curl "http://localhost:3000/api/mygroup/openapi.json"
```
```bash With Parameters
curl "http://localhost:3000/api/myserver/openapi.json?title=My Server API&version=1.0.0"
```
</CodeGroup>
Generates and returns the OpenAPI 3.0.3 specification for a specific group or server. If a group with the given name exists, it returns the specification for all servers in that group. Otherwise, it treats the name as a server name and returns the specification for that server only.
**Path Parameters:**
<ParamField path="name" type="string" required>
Group ID/name or server name
</ParamField>
**Query Parameters:**
Same as the main OpenAPI specification endpoint (title, description, version, serverUrl, includeDisabled).
### Available Servers
<CodeGroup>
```bash GET /api/openapi/servers
curl "http://localhost:3000/api/openapi/servers"
```
</CodeGroup>
Returns a list of connected MCP server names.
<ResponseExample>
```json Example Response
{
"success": true,
"data": ["amap", "playwright", "slack"]
}
```
</ResponseExample>
### Tool Statistics
<CodeGroup>
```bash GET /api/openapi/stats
curl "http://localhost:3000/api/openapi/stats"
```
</CodeGroup>
Returns statistics about available tools and servers.
<ResponseExample>
```json Example Response
{
"success": true,
"data": {
"totalServers": 3,
"totalTools": 41,
"serverBreakdown": [
{"name": "amap", "toolCount": 12, "status": "connected"},
{"name": "playwright", "toolCount": 21, "status": "connected"},
{"name": "slack", "toolCount": 8, "status": "connected"}
]
}
}
```
</ResponseExample>
### Tool Execution
<CodeGroup>
```bash GET /api/tools/{serverName}/{toolName}
curl "http://localhost:3000/api/tools/amap/amap-maps_weather?city=Beijing"
```
```bash POST /api/tools/{serverName}/{toolName}
curl -X POST "http://localhost:3000/api/tools/playwright/playwright-browser_navigate" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com"}'
```
</CodeGroup>
Execute MCP tools via OpenAPI-compatible endpoints.
**Path Parameters:**
<ParamField path="serverName" type="string" required>
The name of the MCP server
</ParamField>
<ParamField path="toolName" type="string" required>
The name of the tool to execute
</ParamField>
## OpenWebUI Integration
To integrate MCPHub with OpenWebUI:
<Steps>
<Step title="Start MCPHub">
Ensure MCPHub is running with your MCP servers configured
</Step>
<Step title="Get OpenAPI Specification">
```bash
curl http://localhost:3000/api/openapi.json > mcphub-api.json
```
</Step>
<Step title="Add to OpenWebUI">
Import the OpenAPI specification file or point to the URL directly in OpenWebUI
</Step>
</Steps>
### Configuration Example
In OpenWebUI, you can add MCPHub as an OpenAPI tool by using:
<CardGroup cols={2}>
<Card title="OpenAPI URL" icon="link">
`http://localhost:3000/api/openapi.json`
</Card>
<Card title="Base URL" icon="server">
`http://localhost:3000/api`
</Card>
</CardGroup>
## Generated OpenAPI Structure
The generated OpenAPI specification includes:
### Tool Conversion Logic
- **Simple tools** (≤10 primitive parameters) → GET endpoints with query parameters
- **Complex tools** (objects, arrays, or >10 parameters) → POST endpoints with JSON request body
- **All tools** include comprehensive response schemas and error handling
### Example Generated Operation
```yaml
/tools/amap/amap-maps_weather:
get:
summary: "根据城市名称或者标准adcode查询指定城市的天气"
operationId: "amap_amap-maps_weather"
tags: ["amap"]
parameters:
- name: city
in: query
required: true
description: "城市名称或者adcode"
schema:
type: string
responses:
'200':
description: "Successful tool execution"
content:
application/json:
schema:
$ref: '#/components/schemas/ToolResponse'
```
### Security
- Bearer authentication is defined but not enforced for tool execution endpoints
- Enables flexible integration with various OpenAPI-compatible systems
## Benefits over MCPO
<CardGroup cols={2}>
<Card title="Direct Integration" icon="plug">
No need for intermediate proxy
</Card>
<Card title="Real-time Updates" icon="refresh">
OpenAPI spec updates automatically as MCP servers connect/disconnect
</Card>
<Card title="Better Performance" icon="bolt">
Direct tool execution without proxy overhead
</Card>
<Card title="Simplified Architecture" icon="layer-group">
One less component to manage
</Card>
</CardGroup>
## Troubleshooting
<AccordionGroup>
<Accordion title="OpenAPI spec shows no tools">
Ensure MCP servers are connected. Check `/api/openapi/stats` for server status.
</Accordion>
<Accordion title="Tool execution fails">
Verify the tool name and parameters match the OpenAPI specification. Check server logs for details.
</Accordion>
<Accordion title="OpenWebUI can't connect">
Ensure MCPHub is accessible from OpenWebUI and the OpenAPI URL is correct.
</Accordion>
<Accordion title="Missing tools in specification">
Check if tools are enabled in your MCP server configuration. Use `includeDisabled=true` to see all tools.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,142 @@
---
title: "Prompts"
description: "Manage and execute MCP prompts."
---
import { Card, Cards } from 'mintlify';
<Card
title="POST /api/mcp/:serverName/prompts/:promptName"
href="#get-a-prompt"
>
Execute a prompt on an MCP server.
</Card>
<Card
title="POST /api/servers/:serverName/prompts/:promptName/toggle"
href="#toggle-a-prompt"
>
Enable or disable a prompt.
</Card>
<Card
title="PUT /api/servers/:serverName/prompts/:promptName/description"
href="#update-prompt-description"
>
Update the description of a prompt.
</Card>
---
### Get a Prompt
Execute a prompt on an MCP server and get the result.
- **Endpoint**: `/api/mcp/:serverName/prompts/:promptName`
- **Method**: `POST`
- **Authentication**: Required
- **Parameters**:
- `:serverName` (string, required): The name of the MCP server.
- `:promptName` (string, required): The name of the prompt.
- **Body**:
```json
{
"arguments": {
"arg1": "value1",
"arg2": "value2"
}
}
```
- `arguments` (object, optional): Arguments to pass to the prompt.
- **Response**:
```json
{
"success": true,
"data": {
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "Prompt content"
}
}
]
}
}
```
**Example Request:**
```bash
curl -X POST "http://localhost:3000/api/mcp/myserver/prompts/code-review" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"arguments": {
"language": "typescript",
"code": "const x = 1;"
}
}'
```
---
### Toggle a Prompt
Enable or disable a specific prompt on a server.
- **Endpoint**: `/api/servers/:serverName/prompts/:promptName/toggle`
- **Method**: `POST`
- **Authentication**: Required
- **Parameters**:
- `:serverName` (string, required): The name of the server.
- `:promptName` (string, required): The name of the prompt.
- **Body**:
```json
{
"enabled": true
}
```
- `enabled` (boolean, required): `true` to enable the prompt, `false` to disable it.
**Example Request:**
```bash
curl -X POST "http://localhost:3000/api/servers/myserver/prompts/code-review/toggle" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"enabled": false}'
```
---
### Update Prompt Description
Update the description of a specific prompt.
- **Endpoint**: `/api/servers/:serverName/prompts/:promptName/description`
- **Method**: `PUT`
- **Authentication**: Required
- **Parameters**:
- `:serverName` (string, required): The name of the server.
- `:promptName` (string, required): The name of the prompt.
- **Body**:
```json
{
"description": "New prompt description"
}
```
- `description` (string, required): The new description for the prompt.
**Example Request:**
```bash
curl -X PUT "http://localhost:3000/api/servers/myserver/prompts/code-review/description" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"description": "Review code for best practices and potential issues"}'
```
**Note**: Prompts are templates that can be used to generate standardized requests to MCP servers. They are defined by the MCP server and can have arguments that are filled in when the prompt is executed.

View File

@@ -54,6 +54,20 @@ import { Card, Cards } from 'mintlify';
Update the description of a tool.
</Card>
<Card
title="PUT /api/system-config"
href="#update-system-config"
>
Update system configuration settings.
</Card>
<Card
title="GET /api/settings"
href="#get-settings"
>
Get all server settings and configurations.
</Card>
---
### Get All Servers
@@ -207,3 +221,45 @@ Updates the description of a specific tool.
}
```
- `description` (string, required): The new description for the tool.
---
### Update System Config
Updates the system-wide configuration settings.
- **Endpoint**: `/api/system-config`
- **Method**: `PUT`
- **Body**:
```json
{
"openaiApiKey": "sk-...",
"openaiBaseUrl": "https://api.openai.com/v1",
"modelName": "gpt-4",
"temperature": 0.7,
"maxTokens": 2048
}
```
- All fields are optional. Only provided fields will be updated.
---
### Get Settings
Retrieves all server settings and configurations.
- **Endpoint**: `/api/settings`
- **Method**: `GET`
- **Response**:
```json
{
"success": true,
"data": {
"servers": [...],
"groups": [...],
"systemConfig": {...}
}
}
```
**Note**: For detailed prompt management, see the [Prompts API](/api-reference/prompts) documentation.

View File

@@ -0,0 +1,113 @@
---
title: "System"
description: "System and utility endpoints."
---
import { Card, Cards } from 'mintlify';
<Card
title="GET /health"
href="#health-check"
>
Check the health status of the MCPHub server.
</Card>
<Card
title="GET /oauth/callback"
href="#oauth-callback"
>
OAuth callback endpoint for authentication flows.
</Card>
<Card
title="POST /api/dxt/upload"
href="#upload-dxt-file"
>
Upload a DXT configuration file.
</Card>
<Card
title="GET /api/mcp-settings/export"
href="#export-mcp-settings"
>
Export MCP settings as JSON.
</Card>
---
### Health Check
Check the health status of the MCPHub server.
- **Endpoint**: `/health`
- **Method**: `GET`
- **Authentication**: Not required
- **Response**:
```json
{
"status": "ok",
"timestamp": "2024-11-12T01:30:00.000Z",
"uptime": 12345
}
```
**Example Request:**
```bash
curl "http://localhost:3000/health"
```
---
### OAuth Callback
OAuth callback endpoint for handling OAuth authentication flows. This endpoint is automatically called by OAuth providers after user authorization.
- **Endpoint**: `/oauth/callback`
- **Method**: `GET`
- **Authentication**: Not required (public callback URL)
- **Query Parameters**: Varies by OAuth provider (typically includes `code`, `state`, etc.)
**Note**: This endpoint is used internally by MCPHub's OAuth integration and should not be called directly by clients.
---
### Upload DXT File
Upload a DXT (Desktop Extension) configuration file to import server configurations.
- **Endpoint**: `/api/dxt/upload`
- **Method**: `POST`
- **Authentication**: Required
- **Content-Type**: `multipart/form-data`
- **Body**:
- `file` (file, required): The DXT configuration file to upload.
**Example Request:**
```bash
curl -X POST "http://localhost:3000/api/dxt/upload" \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@config.dxt"
```
---
### Export MCP Settings
Export the current MCP settings configuration as a JSON file.
- **Endpoint**: `/api/mcp-settings/export`
- **Method**: `GET`
- **Authentication**: Required
- **Response**: Returns the `mcp_settings.json` configuration file.
**Example Request:**
```bash
curl "http://localhost:3000/api/mcp-settings/export" \
-H "Authorization: Bearer YOUR_TOKEN" \
-o mcp_settings.json
```
**Note**: This endpoint allows you to download a backup of your MCP settings, which can be used to restore or migrate your configuration.

View File

@@ -0,0 +1,86 @@
---
title: "Tools"
description: "Execute MCP tools programmatically."
---
import { Card, Cards } from 'mintlify';
<Card
title="POST /api/tools/call/:server"
href="#call-a-tool"
>
Call a specific tool on an MCP server.
</Card>
---
### Call a Tool
Execute a specific tool on an MCP server with given arguments.
- **Endpoint**: `/api/tools/call/:server`
- **Method**: `POST`
- **Parameters**:
- `:server` (string, required): The name of the MCP server.
- **Body**:
```json
{
"toolName": "tool-name",
"arguments": {
"param1": "value1",
"param2": "value2"
}
}
```
- `toolName` (string, required): The name of the tool to execute.
- `arguments` (object, optional): The arguments to pass to the tool. Defaults to an empty object.
- **Response**:
```json
{
"success": true,
"data": {
"content": [
{
"type": "text",
"text": "Tool execution result"
}
],
"toolName": "tool-name",
"arguments": {
"param1": "value1",
"param2": "value2"
}
}
}
```
**Example Request:**
```bash
curl -X POST "http://localhost:3000/api/tools/call/amap" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"toolName": "amap-maps_weather",
"arguments": {
"city": "Beijing"
}
}'
```
**Notes:**
- The tool arguments are automatically converted to the proper types based on the tool's input schema.
- Use the `x-session-id` header to maintain session state across multiple tool calls if needed.
- This endpoint requires authentication.
---
### Alternative: OpenAPI Tool Execution
For OpenAPI-compatible tool execution without authentication, see the [OpenAPI Integration](/api-reference/openapi#tool-execution) documentation. The OpenAPI endpoints provide:
- **GET** `/api/tools/:serverName/:toolName` - For simple tools with query parameters
- **POST** `/api/tools/:serverName/:toolName` - For complex tools with JSON body
These endpoints are designed for integration with OpenWebUI and other OpenAPI-compatible systems.

View File

@@ -0,0 +1,195 @@
---
title: "Users"
description: "Manage users in MCPHub."
---
import { Card, Cards } from 'mintlify';
<Card
title="GET /api/users"
href="#get-all-users"
>
Get a list of all users.
</Card>
<Card
title="GET /api/users/:username"
href="#get-a-user"
>
Get details of a specific user.
</Card>
<Card
title="POST /api/users"
href="#create-a-user"
>
Create a new user.
</Card>
<Card
title="PUT /api/users/:username"
href="#update-a-user"
>
Update an existing user.
</Card>
<Card
title="DELETE /api/users/:username"
href="#delete-a-user"
>
Delete a user.
</Card>
<Card
title="GET /api/users-stats"
href="#get-user-statistics"
>
Get statistics about users and their server access.
</Card>
---
### Get All Users
Retrieves a list of all users in the system.
- **Endpoint**: `/api/users`
- **Method**: `GET`
- **Authentication**: Required (Admin only)
- **Response**:
```json
{
"success": true,
"data": [
{
"username": "admin",
"role": "admin",
"servers": ["server1", "server2"],
"groups": ["group1"]
},
{
"username": "user1",
"role": "user",
"servers": ["server1"],
"groups": []
}
]
}
```
---
### Get a User
Retrieves details of a specific user.
- **Endpoint**: `/api/users/:username`
- **Method**: `GET`
- **Authentication**: Required (Admin only)
- **Parameters**:
- `:username` (string, required): The username of the user.
- **Response**:
```json
{
"success": true,
"data": {
"username": "user1",
"role": "user",
"servers": ["server1", "server2"],
"groups": ["group1"]
}
}
```
---
### Create a User
Creates a new user in the system.
- **Endpoint**: `/api/users`
- **Method**: `POST`
- **Authentication**: Required (Admin only)
- **Body**:
```json
{
"username": "newuser",
"password": "securepassword",
"role": "user",
"servers": ["server1"],
"groups": ["group1"]
}
```
- `username` (string, required): The username for the new user.
- `password` (string, required): The password for the new user. Must be at least 6 characters.
- `role` (string, optional): The role of the user. Either `"admin"` or `"user"`. Defaults to `"user"`.
- `servers` (array of strings, optional): List of server names the user has access to.
- `groups` (array of strings, optional): List of group IDs the user belongs to.
---
### Update a User
Updates an existing user's information.
- **Endpoint**: `/api/users/:username`
- **Method**: `PUT`
- **Authentication**: Required (Admin only)
- **Parameters**:
- `:username` (string, required): The username of the user to update.
- **Body**:
```json
{
"password": "newpassword",
"role": "admin",
"servers": ["server1", "server2", "server3"],
"groups": ["group1", "group2"]
}
```
- `password` (string, optional): New password for the user.
- `role` (string, optional): New role for the user.
- `servers` (array of strings, optional): Updated list of accessible servers.
- `groups` (array of strings, optional): Updated list of groups.
---
### Delete a User
Removes a user from the system.
- **Endpoint**: `/api/users/:username`
- **Method**: `DELETE`
- **Authentication**: Required (Admin only)
- **Parameters**:
- `:username` (string, required): The username of the user to delete.
---
### Get User Statistics
Retrieves statistics about users and their access to servers and groups.
- **Endpoint**: `/api/users-stats`
- **Method**: `GET`
- **Authentication**: Required (Admin only)
- **Response**:
```json
{
"success": true,
"data": {
"totalUsers": 5,
"adminUsers": 1,
"regularUsers": 4,
"usersPerServer": {
"server1": 3,
"server2": 2
},
"usersPerGroup": {
"group1": 2,
"group2": 1
}
}
}
```
**Note**: All user management endpoints require admin authentication.

View File

@@ -0,0 +1,304 @@
---
title: 'Database Configuration'
description: 'Configuring MCPHub to use a PostgreSQL database as an alternative to the mcp_settings.json file.'
---
# Database Configuration for MCPHub
## Overview
MCPHub supports storing configuration data in a PostgreSQL database as an alternative to the `mcp_settings.json` file. Database mode provides enhanced persistence and scalability for production environments and enterprise deployments.
## Why Use Database Configuration?
**Core Benefits:**
- ✅ **Better Persistence** - Configuration stored in a professional database with transaction support and data integrity
- ✅ **High Availability** - Leverage database replication and failover capabilities
- ✅ **Enterprise Ready** - Meets enterprise data management and compliance requirements
- ✅ **Backup & Recovery** - Use mature database backup tools and strategies
## Environment Variables
### Required for Database Mode
```bash
# Database connection URL (PostgreSQL)
# Simply setting DB_URL will automatically enable database mode
DB_URL=postgresql://user:password@localhost:5432/mcphub
# Or explicitly control with USE_DB (overrides auto-detection)
# USE_DB=true
```
<Note>
**Simplified Configuration**: You only need to set `DB_URL` to enable database mode. MCPHub will automatically detect and enable database mode when `DB_URL` is present. Use `USE_DB=false` to explicitly disable database mode even when `DB_URL` is set.
</Note>
## Setup Instructions
### 1. Using Docker
#### Option A: Using External Database
If you already have a PostgreSQL database:
```bash
docker run -d \
-p 3000:3000 \
-v ./mcp_settings.json:/app/mcp_settings.json \
-e DB_URL="postgresql://user:password@your-db-host:5432/mcphub" \
samanhappy/mcphub
```
#### Option B: Using PostgreSQL as a separate service
Create a `docker-compose.yml`:
```yaml
version: '3.8'
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: mcphub
POSTGRES_USER: mcphub
POSTGRES_PASSWORD: your_secure_password
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
mcphub:
image: samanhappy/mcphub:latest
environment:
USE_DB: "true"
DB_URL: "postgresql://mcphub:your_secure_password@postgres:5432/mcphub"
PORT: 3000
ports:
- "3000:3000"
depends_on:
- postgres
volumes:
pgdata:
```
Run with:
```bash
docker-compose up -d
```
### 2. Manual Setup
#### Step 1: Setup PostgreSQL Database
```bash
# Install PostgreSQL (if not already installed)
sudo apt-get install postgresql postgresql-contrib
# Create database and user
sudo -u postgres psql <<EOF
CREATE DATABASE mcphub;
CREATE USER mcphub WITH ENCRYPTED PASSWORD 'your_password';
GRANT ALL PRIVILEGES ON DATABASE mcphub TO mcphub;
EOF
```
#### Step 2: Install MCPHub
```bash
npm install -g @samanhappy/mcphub
```
#### Step 3: Set Environment Variables
Create a `.env` file:
```bash
# Simply set DB_URL to enable database mode (USE_DB is auto-detected)
DB_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
PORT=3000
```
#### Step 4: Run Migration (Optional)
If you have an existing `mcp_settings.json` file, migrate it:
```bash
# Run migration script
npx tsx src/scripts/migrate-to-database.ts
```
Or let MCPHub auto-migrate on first startup.
#### Step 5: Start MCPHub
```bash
mcphub
```
## Migration from File-Based to Database
MCPHub provides automatic migration on first startup when database mode is enabled. However, you can also run the migration manually.
### Automatic Migration
When you start MCPHub with `USE_DB=true` for the first time:
1. MCPHub connects to the database
2. Checks if any users exist in the database
3. If no users found, automatically migrates from `mcp_settings.json`
4. Creates all tables and imports all data
### Manual Migration
Run the migration script:
```bash
# Using npx
npx tsx src/scripts/migrate-to-database.ts
# Or using Node
node dist/scripts/migrate-to-database.js
```
The migration will:
- ✅ Create database tables if they don't exist
- ✅ Import all users with hashed passwords
- ✅ Import all MCP server configurations
- ✅ Import all groups
- ✅ Import system configuration
- ✅ Import user-specific configurations
- ✅ Skip existing records (safe to run multiple times)
## Configuration After Migration
Once running in database mode, all configuration changes are stored in the database:
- User management via `/api/users`
- Server management via `/api/servers`
- Group management via `/api/groups`
- System settings via `/api/system/config`
The web dashboard works exactly the same way, but now stores changes in the database instead of the file.
## Database Schema
MCPHub creates the following tables:
- **users** - User accounts and authentication
- **servers** - MCP server configurations
- **groups** - Server groups
- **system_config** - System-wide settings
- **user_configs** - User-specific settings
- **vector_embeddings** - Vector search data (for smart routing)
## Backup and Restore
### Backup
```bash
# PostgreSQL backup
pg_dump -U mcphub mcphub > mcphub_backup.sql
# Or using Docker
docker exec postgres pg_dump -U mcphub mcphub > mcphub_backup.sql
```
### Restore
```bash
# PostgreSQL restore
psql -U mcphub mcphub < mcphub_backup.sql
# Or using Docker
docker exec -i postgres psql -U mcphub mcphub < mcphub_backup.sql
```
## Switching Back to File-Based Config
If you need to switch back to file-based configuration:
1. Set `USE_DB=false` or remove `DB_URL` and `USE_DB` environment variables
2. Restart MCPHub
3. MCPHub will use `mcp_settings.json` again
Note: Changes made in database mode won't be reflected in the file unless you manually export them.
## Troubleshooting
### Connection Refused
```
Error: connect ECONNREFUSED 127.0.0.1:5432
```
**Solution:** Check that PostgreSQL is running and accessible:
```bash
# Check PostgreSQL status
sudo systemctl status postgresql
# Or for Docker
docker ps | grep postgres
```
### Authentication Failed
```
Error: password authentication failed for user "mcphub"
```
**Solution:** Verify database credentials in `DB_URL` environment variable.
### Migration Failed
```
❌ Migration failed: ...
```
**Solution:**
1. Check that `mcp_settings.json` exists and is valid JSON
2. Verify database connection
3. Check logs for specific error messages
4. Ensure database user has CREATE TABLE permissions
### Tables Already Exist
Database tables are automatically created if they don't exist. If you get errors about existing tables, check:
1. Whether a previous migration partially completed
2. Manual table creation conflicts
3. Run with `synchronize: false` in database config if needed
## Environment Variables Reference
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `DB_URL` | Yes* | - | Full PostgreSQL connection URL. Setting this automatically enables database mode |
| `USE_DB` | No | auto | Explicitly enable/disable database mode. If not set, auto-detected based on `DB_URL` presence |
| `MCPHUB_SETTING_PATH` | No | - | Path to mcp_settings.json (for migration) |
*Required for database mode. Simply setting `DB_URL` enables database mode automatically
## Security Considerations
1. **Database Credentials:** Store database credentials securely, use environment variables or secrets management
2. **Network Access:** Restrict database access to MCPHub instances only
3. **Encryption:** Use SSL/TLS for database connections in production:
```bash
DB_URL=postgresql://user:password@host:5432/mcphub?sslmode=require
```
4. **Backup:** Regularly backup your database
5. **Access Control:** Use strong database passwords and limit user permissions
## Performance
Database mode offers better performance for:
- Multiple concurrent users
- Frequent configuration changes
- Large number of servers/groups
File mode may be faster for:
- Single user setups
- Read-heavy workloads with infrequent changes
- Development/testing environments

View File

@@ -41,6 +41,50 @@ docker run -d \
mcphub:local
```
### Building with Extended Features
The Docker image supports an `INSTALL_EXT` build argument to include additional tools:
```bash
# Build with extended features (includes Docker Engine, Chrome/Playwright)
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
# Option 1: Run with automatic Docker-in-Docker (requires privileged mode)
docker run -d \
--name mcphub \
--privileged \
-p 3000:3000 \
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
mcphub:extended
# Option 2: Run with Docker socket mounted (use host's Docker daemon)
docker run -d \
--name mcphub \
-p 3000:3000 \
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
-v /var/run/docker.sock:/var/run/docker.sock \
mcphub:extended
# Verify Docker is available
docker exec mcphub docker --version
docker exec mcphub docker ps
```
<Note>
**What's included with INSTALL_EXT=true:**
- **Docker Engine**: Full Docker daemon with CLI for container management. The daemon auto-starts when the container runs in privileged mode.
- **Chrome/Playwright** (amd64 only): For browser automation tasks
The extended image is larger but provides additional capabilities for advanced use cases.
</Note>
<Warning>
**Docker-in-Docker Security Considerations:**
- **Privileged mode** (`--privileged`): Required for the Docker daemon to start inside the container. This gives the container elevated permissions on the host.
- **Docker socket mounting** (`/var/run/docker.sock`): Gives the container access to the host's Docker daemon. Both approaches should only be used in trusted environments.
- For production, consider using Docker socket mounting instead of privileged mode for better security.
</Warning>
## Docker Compose Setup
### Basic Configuration

View File

@@ -78,7 +78,7 @@ git clone https://github.com/YOUR_USERNAME/mcphub.git
cd mcphub
# 2. Add upstream remote
git remote add upstream https://github.com/mcphub/mcphub.git
git remote add upstream https://github.com/samanhappy/mcphub.git
# 3. Install dependencies
pnpm install

View File

@@ -27,7 +27,8 @@
"pages": [
"features/server-management",
"features/group-management",
"features/smart-routing"
"features/smart-routing",
"features/oauth"
]
},
{
@@ -36,7 +37,8 @@
"configuration/mcp-settings",
"configuration/environment-variables",
"configuration/docker-setup",
"configuration/nginx"
"configuration/nginx",
"configuration/database-configuration"
]
}
]
@@ -57,7 +59,8 @@
"pages": [
"zh/features/server-management",
"zh/features/group-management",
"zh/features/smart-routing"
"zh/features/smart-routing",
"zh/features/oauth"
]
},
{
@@ -66,7 +69,8 @@
"zh/configuration/mcp-settings",
"zh/configuration/environment-variables",
"zh/configuration/docker-setup",
"zh/configuration/nginx"
"zh/configuration/nginx",
"zh/configuration/database-configuration"
]
}
]
@@ -83,14 +87,24 @@
"api-reference/smart-routing"
]
},
{
"group": "OpenAPI Endpoints",
"pages": [
"api-reference/openapi"
]
},
{
"group": "Management Endpoints",
"pages": [
"api-reference/servers",
"api-reference/groups",
"api-reference/users",
"api-reference/tools",
"api-reference/prompts",
"api-reference/auth",
"api-reference/logs",
"api-reference/config"
"api-reference/config",
"api-reference/system"
]
}
]
@@ -107,14 +121,24 @@
"zh/api-reference/smart-routing"
]
},
{
"group": "OpenAPI 端点",
"pages": [
"zh/api-reference/openapi"
]
},
{
"group": "管理端点",
"pages": [
"zh/api-reference/servers",
"zh/api-reference/groups",
"zh/api-reference/users",
"zh/api-reference/tools",
"zh/api-reference/prompts",
"zh/api-reference/auth",
"zh/api-reference/logs",
"zh/api-reference/config"
"zh/api-reference/config",
"zh/api-reference/system"
]
}
]

141
docs/features/oauth.mdx Normal file
View File

@@ -0,0 +1,141 @@
# OAuth Support
## At a Glance
- Covers end-to-end OAuth 2.0 Authorization Code with PKCE for upstream MCP servers.
- Supports automatic discovery from `WWW-Authenticate` responses and RFC 8414 metadata.
- Implements dynamic client registration (RFC 7591) and resource indicators (RFC 8707).
- Persists client credentials and tokens to `mcp_settings.json` for reconnects.
## When MCPHub Switches to OAuth
1. MCPHub calls an MCP server that requires authorization and receives `401 Unauthorized`.
2. The response exposes a `WWW-Authenticate` header pointing to protected resource metadata (`authorization_server` or `as_uri`).
3. MCPHub discovers the authorization server metadata, registers (if needed), and opens the browser so the user can authorize once.
4. After the callback is handled, MCPHub reconnects with fresh tokens and resumes requests transparently.
> MCPHub logs each stage (discovery, registration, authorization URL, token exchange) in the server detail view and the backend logs.
## Quick Start by Server Type
### Servers with Dynamic Registration Support
Some servers expose complete OAuth metadata and allow dynamic client registration. For example, Vercel and Linear MCP servers only need their SSE endpoint configured:
```json
{
"mcpServers": {
"vercel": {
"type": "sse",
"url": "https://mcp.vercel.com"
},
"linear": {
"type": "sse",
"url": "https://mcp.linear.app/mcp"
}
}
}
```
- MCPHub discovers the authorization server, registers the client, and handles PKCE automatically.
- Tokens are stored in `mcp_settings.json`; no additional dashboard configuration is needed.
### Servers Requiring Manual Client Provisioning
Other providers do not support dynamic registration. GitHubs MCP endpoint (`https://api.githubcopilot.com/mcp/`) is one example. To connect:
1. Create an OAuth App in the providers console (for GitHub, go to **Settings → Developer settings → OAuth Apps**).
2. Set the callback/redirect URL to `http://localhost:3000/oauth/callback` (or your deployed dashboard domain).
3. Copy the issued client ID and client secret.
4. Supply the credentials through the MCPHub dashboard or by editing `mcp_settings.json` as shown below.
```json
{
"mcpServers": {
"github": {
"type": "sse",
"url": "https://api.githubcopilot.com/mcp/",
"oauth": {
"clientId": "${GITHUB_OAUTH_APP_ID}",
"clientSecret": "${GITHUB_OAUTH_APP_SECRET}",
"scopes": ["replace-with-provider-scope"],
"resource": "https://api.githubcopilot.com"
}
}
}
}
```
- MCPHub skips dynamic registration and uses the credentials you provide to complete the OAuth exchange.
- Update the dashboard or configuration file whenever you rotate secrets.
- Replace `scopes` with the exact scope strings required by the provider.
## Configuration Options
You can rely on auto-detection for most servers or declare OAuth settings explicitly in `mcp_settings.json`. Only populate the fields you need.
### Basic Auto Detection (Minimal Config)
```json
{
"mcpServers": {
"secured-sse": {
"type": "sse",
"url": "https://mcp.example.com/sse",
"oauth": {
"scopes": ["mcp.tools", "mcp.prompts"],
"resource": "https://mcp.example.com"
}
}
}
}
```
- MCPHub will discover the authorization server from challenge headers and walk the user through authorization automatically.
- Tokens (including refresh tokens) are stored on disk and reused on restart.
### Static Client Credentials (Bring Your Own Client)
```json
{
"oauth": {
"clientId": "mcphub-client",
"clientSecret": "replace-me-if-required",
"authorizationEndpoint": "https://auth.example.com/oauth/authorize",
"tokenEndpoint": "https://auth.example.com/oauth/token",
"redirectUri": "http://localhost:3000/oauth/callback"
}
}
```
- Use this when the authorization server requires manual client provisioning.
- `redirectUri` defaults to `http://localhost:3000/oauth/callback`; override it when running behind a custom domain.
### Dynamic Client Registration (RFC 7591)
```json
{
"oauth": {
"dynamicRegistration": {
"enabled": true,
"issuer": "https://auth.example.com",
"metadata": {
"client_name": "MCPHub",
"redirect_uris": [
"http://localhost:3000/oauth/callback",
"https://mcphub.example.com/oauth/callback"
],
"scope": "mcp.tools mcp.prompts",
"grant_types": ["authorization_code", "refresh_token"]
},
"initialAccessToken": "optional-token-if-required"
},
"scopes": ["mcp.tools", "mcp.prompts"],
"resource": "https://mcp.example.com"
}
}
```
- MCPHub discovers endpoints via `issuer`, registers itself, and persists the issued `client_id`/`client_secret`.
- Provide `initialAccessToken` only when the registration endpoint is protected.
## Authorization Flow
1. **Initialization** On startup MCPHub processes every server entry, discovers metadata, and registers the client if `dynamicRegistration.enabled` is true.
2. **User Authorization** Initiating a connection launches the system browser to the servers authorize page with PKCE parameters.
3. **Callback Handling** The built-in route (`/oauth/callback`) verifies the `state`, completes the token exchange, and saves the tokens via the MCP SDK.
4. **Token Lifecycle** Access and refresh tokens are cached in memory, refreshed automatically, and written back to `mcp_settings.json`.
## Tips & Troubleshooting
- Confirm that the redirect URI used during authorization exactly matches one of the `redirect_uris` registered with the authorization server.
- When running behind HTTPS, expose the callback URL publicly or configure a reverse proxy at `/oauth/callback`.
- If discovery fails, supply `authorizationEndpoint` and `tokenEndpoint` explicitly to bypass metadata lookup.
- Remove stale tokens from `mcp_settings.json` if an authorization server revokes access—MCPHub will prompt for a fresh login on the next request.

View File

@@ -276,17 +276,92 @@ Access Smart Routing through the special `$smart` endpoint:
<Tabs>
<Tab title="HTTP MCP">
```
# Search across all servers
http://localhost:3000/mcp/$smart
# Search within a specific group
http://localhost:3000/mcp/$smart/{group}
```
</Tab>
<Tab title="SSE (Legacy)">
```
# Search across all servers
http://localhost:3000/sse/$smart
# Search within a specific group
http://localhost:3000/sse/$smart/{group}
```
</Tab>
</Tabs>
### Group-Scoped Smart Routing
Smart Routing now supports group-scoped searches, allowing you to limit tool discovery to servers within a specific group:
<AccordionGroup>
<Accordion title="Using Group-Scoped Smart Routing">
Connect your AI client to a group-specific Smart Routing endpoint:
```
http://localhost:3000/mcp/$smart/production
```
This endpoint will only search for tools within servers that belong to the "production" group.
**Benefits:**
- **Focused Results**: Only tools from relevant servers are returned
- **Better Performance**: Reduced search space for faster queries
- **Environment Isolation**: Keep development, staging, and production tools separate
- **Access Control**: Limit tool discovery based on user permissions
</Accordion>
<Accordion title="Example: Environment-Based Groups">
Create groups for different environments:
```bash
# Development environment
http://localhost:3000/mcp/$smart/development
# Staging environment
http://localhost:3000/mcp/$smart/staging
# Production environment
http://localhost:3000/mcp/$smart/production
```
Each endpoint will only return tools from servers in that specific environment group.
</Accordion>
<Accordion title="Example: Team-Based Groups">
Organize tools by team or department:
```bash
# Backend team tools
http://localhost:3000/mcp/$smart/backend-team
# Frontend team tools
http://localhost:3000/mcp/$smart/frontend-team
# DevOps team tools
http://localhost:3000/mcp/$smart/devops-team
```
This enables teams to have focused access to their relevant toolsets.
</Accordion>
<Accordion title="How It Works">
When using `$smart/{group}`:
1. The system identifies the specified group
2. Retrieves all servers belonging to that group
3. Filters the tool search to only those servers
4. Returns results scoped to the group's servers
If the group doesn't exist or has no servers, the search will return no results.
</Accordion>
</AccordionGroup>
{/* ### Basic Usage
Connect your AI client to the Smart Routing endpoint and make natural language requests:

View File

@@ -294,22 +294,47 @@ Optional for Smart Routing:
labels:
app: mcphub
spec:
initContainers:
- name: prepare-config
image: busybox:1.28
command:
[
"sh",
"-c",
"cp /config-ro/mcp_settings.json /etc/mcphub/mcp_settings.json",
]
volumeMounts:
- name: config
mountPath: /config-ro
readOnly: true
- name: app-storage
mountPath: /etc/mcphub
containers:
- name: mcphub
image: samanhappy/mcphub:latest
ports:
- containerPort: 3000
env:
- name: PORT
value: "3000"
volumeMounts:
- name: config
mountPath: /app/mcp_settings.json
subPath: mcp_settings.json
- name: mcphub
image: samanhappy/mcphub:latest
ports:
- containerPort: 3000
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
env:
- name: PORT
value: "3000"
- name: MCPHUB_SETTING_PATH
value: /etc/mcphub/mcp_settings.json
volumeMounts:
- name: app-storage
mountPath: /etc/mcphub
volumes:
- name: config
configMap:
name: mcphub-config
- name: config
configMap:
name: mcphub-config
- name: app-storage
emptyDir: {}
```
#### 3. Service

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,276 @@
---
title: "OpenAPI 集成"
description: "从 MCP 工具生成 OpenAPI 规范,与 OpenWebUI 和其他系统无缝集成"
---
# OpenWebUI 集成的 OpenAPI 生成
MCPHub 现在支持从 MCP 工具生成 OpenAPI 3.0.3 规范,实现与 OpenWebUI 和其他 OpenAPI 兼容系统的无缝集成,无需 MCPO 作为中间代理。
## 功能特性
- ✅ **自动 OpenAPI 生成**:将 MCP 工具转换为 OpenAPI 3.0.3 规范
- ✅ **OpenWebUI 兼容**:无需 MCPO 代理的直接集成
- ✅ **实时工具发现**:动态包含已连接 MCP 服务器的工具
- ✅ **双参数支持**:支持 GET查询参数和 POSTJSON 正文)进行工具执行
- ✅ **无需身份验证**OpenAPI 端点公开,便于集成
- ✅ **完整元数据**:具有适当模式和文档的完整 OpenAPI 规范
## API 端点
### OpenAPI 规范
<CodeGroup>
```bash GET /api/openapi.json
curl "http://localhost:3000/api/openapi.json"
```
```bash 带参数
curl "http://localhost:3000/api/openapi.json?title=我的 MCP API&version=2.0.0"
```
</CodeGroup>
生成并返回所有已连接 MCP 工具的完整 OpenAPI 3.0.3 规范。
**查询参数:**
<ParamField query="title" type="string" optional>
自定义 API 标题
</ParamField>
<ParamField query="description" type="string" optional>
自定义 API 描述
</ParamField>
<ParamField query="version" type="string" optional>
自定义 API 版本
</ParamField>
<ParamField query="serverUrl" type="string" optional>
自定义服务器 URL
</ParamField>
<ParamField query="includeDisabled" type="boolean" optional default="false">
包含禁用的工具
</ParamField>
<ParamField query="servers" type="string" optional>
要包含的服务器名称列表(逗号分隔)
</ParamField>
### 组/服务器特定的 OpenAPI 规范
<CodeGroup>
```bash GET /api/:name/openapi.json
curl "http://localhost:3000/api/mygroup/openapi.json"
```
```bash 带参数
curl "http://localhost:3000/api/myserver/openapi.json?title=我的服务器 API&version=1.0.0"
```
</CodeGroup>
为特定组或服务器生成并返回 OpenAPI 3.0.3 规范。如果存在具有给定名称的组,则返回该组中所有服务器的规范。否则,将名称视为服务器名称并仅返回该服务器的规范。
**路径参数:**
<ParamField path="name" type="string" required>
组 ID/名称或服务器名称
</ParamField>
**查询参数:**
与主 OpenAPI 规范端点相同title、description、version、serverUrl、includeDisabled
### 可用服务器
<CodeGroup>
```bash GET /api/openapi/servers
curl "http://localhost:3000/api/openapi/servers"
```
</CodeGroup>
返回已连接的 MCP 服务器名称列表。
<ResponseExample>
```json 示例响应
{
"success": true,
"data": ["amap", "playwright", "slack"]
}
```
</ResponseExample>
### 工具统计
<CodeGroup>
```bash GET /api/openapi/stats
curl "http://localhost:3000/api/openapi/stats"
```
</CodeGroup>
返回有关可用工具和服务器的统计信息。
<ResponseExample>
```json 示例响应
{
"success": true,
"data": {
"totalServers": 3,
"totalTools": 41,
"serverBreakdown": [
{"name": "amap", "toolCount": 12, "status": "connected"},
{"name": "playwright", "toolCount": 21, "status": "connected"},
{"name": "slack", "toolCount": 8, "status": "connected"}
]
}
}
```
</ResponseExample>
### 工具执行
<CodeGroup>
```bash GET /api/tools/{serverName}/{toolName}
curl "http://localhost:3000/api/tools/amap/amap-maps_weather?city=Beijing"
```
```bash POST /api/tools/{serverName}/{toolName}
curl -X POST "http://localhost:3000/api/tools/playwright/playwright-browser_navigate" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com"}'
```
</CodeGroup>
通过 OpenAPI 兼容端点执行 MCP 工具。
**路径参数:**
<ParamField path="serverName" type="string" required>
MCP 服务器的名称
</ParamField>
<ParamField path="toolName" type="string" required>
要执行的工具名称
</ParamField>
## OpenWebUI 集成
要将 MCPHub 与 OpenWebUI 集成:
<Steps>
<Step title="启动 MCPHub">
确保 MCPHub 正在运行,并且已配置 MCP 服务器
</Step>
<Step title="获取 OpenAPI 规范">
```bash
curl http://localhost:3000/api/openapi.json > mcphub-api.json
```
</Step>
<Step title="添加到 OpenWebUI">
在 OpenWebUI 中导入 OpenAPI 规范文件或直接指向 URL
</Step>
</Steps>
### 配置示例
在 OpenWebUI 中,您可以通过以下方式将 MCPHub 添加为 OpenAPI 工具:
<CardGroup cols={2}>
<Card title="OpenAPI URL" icon="link">
`http://localhost:3000/api/openapi.json`
</Card>
<Card title="基础 URL" icon="server">
`http://localhost:3000/api`
</Card>
</CardGroup>
## 生成的 OpenAPI 结构
生成的 OpenAPI 规范包括:
### 工具转换逻辑
- **简单工具**≤10 个原始参数)→ 带查询参数的 GET 端点
- **复杂工具**(对象、数组或 >10 个参数)→ 带 JSON 请求正文的 POST 端点
- **所有工具**都包含完整的响应模式和错误处理
### 生成操作示例
```yaml
/tools/amap/amap-maps_weather:
get:
summary: "根据城市名称或者标准adcode查询指定城市的天气"
operationId: "amap_amap-maps_weather"
tags: ["amap"]
parameters:
- name: city
in: query
required: true
description: "城市名称或者adcode"
schema:
type: string
responses:
'200':
description: "Successful tool execution"
content:
application/json:
schema:
$ref: '#/components/schemas/ToolResponse'
```
### 安全性
- 定义了 Bearer 身份验证但不对工具执行端点强制执行
- 支持与各种 OpenAPI 兼容系统的灵活集成
## 相比 MCPO 的优势
<CardGroup cols={2}>
<Card title="直接集成" icon="plug">
无需中间代理
</Card>
<Card title="实时更新" icon="refresh">
OpenAPI 规范随着 MCP 服务器连接/断开自动更新
</Card>
<Card title="更好的性能" icon="bolt">
直接工具执行,无代理开销
</Card>
<Card title="简化架构" icon="layer-group">
减少一个需要管理的组件
</Card>
</CardGroup>
## 故障排除
<AccordionGroup>
<Accordion title="OpenAPI 规范显示没有工具">
确保 MCP 服务器已连接。检查 `/api/openapi/stats` 查看服务器状态。
</Accordion>
<Accordion title="工具执行失败">
验证工具名称和参数是否与 OpenAPI 规范匹配。检查服务器日志以获取详细信息。
</Accordion>
<Accordion title="OpenWebUI 无法连接">
确保 MCPHub 可从 OpenWebUI 访问,并且 OpenAPI URL 正确。
</Accordion>
<Accordion title="规范中缺少工具">
检查您的 MCP 服务器配置中是否启用了工具。使用 `includeDisabled=true` 查看所有工具。
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,142 @@
---
title: "提示词"
description: "管理和执行 MCP 提示词。"
---
import { Card, Cards } from 'mintlify';
<Card
title="POST /api/mcp/:serverName/prompts/:promptName"
href="#get-a-prompt"
>
在 MCP 服务器上执行提示词。
</Card>
<Card
title="POST /api/servers/:serverName/prompts/:promptName/toggle"
href="#toggle-a-prompt"
>
启用或禁用提示词。
</Card>
<Card
title="PUT /api/servers/:serverName/prompts/:promptName/description"
href="#update-prompt-description"
>
更新提示词的描述。
</Card>
---
### 获取提示词
在 MCP 服务器上执行提示词并获取结果。
- **端点**: `/api/mcp/:serverName/prompts/:promptName`
- **方法**: `POST`
- **身份验证**: 必需
- **参数**:
- `:serverName` (字符串, 必需): MCP 服务器的名称。
- `:promptName` (字符串, 必需): 提示词的名称。
- **请求正文**:
```json
{
"arguments": {
"arg1": "value1",
"arg2": "value2"
}
}
```
- `arguments` (对象, 可选): 传递给提示词的参数。
- **响应**:
```json
{
"success": true,
"data": {
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "提示词内容"
}
}
]
}
}
```
**请求示例:**
```bash
curl -X POST "http://localhost:3000/api/mcp/myserver/prompts/code-review" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"arguments": {
"language": "typescript",
"code": "const x = 1;"
}
}'
```
---
### 切换提示词
启用或禁用服务器上的特定提示词。
- **端点**: `/api/servers/:serverName/prompts/:promptName/toggle`
- **方法**: `POST`
- **身份验证**: 必需
- **参数**:
- `:serverName` (字符串, 必需): 服务器的名称。
- `:promptName` (字符串, 必需): 提示词的名称。
- **请求正文**:
```json
{
"enabled": true
}
```
- `enabled` (布尔值, 必需): `true` 启用提示词, `false` 禁用提示词。
**请求示例:**
```bash
curl -X POST "http://localhost:3000/api/servers/myserver/prompts/code-review/toggle" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"enabled": false}'
```
---
### 更新提示词描述
更新特定提示词的描述。
- **端点**: `/api/servers/:serverName/prompts/:promptName/description`
- **方法**: `PUT`
- **身份验证**: 必需
- **参数**:
- `:serverName` (字符串, 必需): 服务器的名称。
- `:promptName` (字符串, 必需): 提示词的名称。
- **请求正文**:
```json
{
"description": "新的提示词描述"
}
```
- `description` (字符串, 必需): 提示词的新描述。
**请求示例:**
```bash
curl -X PUT "http://localhost:3000/api/servers/myserver/prompts/code-review/description" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"description": "审查代码的最佳实践和潜在问题"}'
```
**注意**: 提示词是可用于生成标准化请求到 MCP 服务器的模板。它们由 MCP 服务器定义,并且可以具有在执行提示词时填充的参数。

View File

@@ -54,6 +54,20 @@ import { Card, Cards } from 'mintlify';
更新工具的描述。
</Card>
<Card
title="PUT /api/system-config"
href="#update-system-config"
>
更新系统配置设置。
</Card>
<Card
title="GET /api/settings"
href="#get-settings"
>
获取所有服务器设置和配置。
</Card>
---
### 获取所有服务器
@@ -207,3 +221,45 @@ import { Card, Cards } from 'mintlify';
}
```
- `description` (string, 必填): 工具的新描述。
---
### 更新系统配置
更新系统范围的配置设置。
- **端点**: `/api/system-config`
- **方法**: `PUT`
- **正文**:
```json
{
"openaiApiKey": "sk-...",
"openaiBaseUrl": "https://api.openai.com/v1",
"modelName": "gpt-4",
"temperature": 0.7,
"maxTokens": 2048
}
```
- 所有字段都是可选的。只有提供的字段会被更新。
---
### 获取设置
检索所有服务器设置和配置。
- **端点**: `/api/settings`
- **方法**: `GET`
- **响应**:
```json
{
"success": true,
"data": {
"servers": [...],
"groups": [...],
"systemConfig": {...}
}
}
```
**注意**: 有关详细的提示词管理,请参阅 [提示词 API](/zh/api-reference/prompts) 文档。

View File

@@ -0,0 +1,113 @@
---
title: "系统"
description: "系统和实用程序端点。"
---
import { Card, Cards } from 'mintlify';
<Card
title="GET /health"
href="#health-check"
>
检查 MCPHub 服务器的健康状态。
</Card>
<Card
title="GET /oauth/callback"
href="#oauth-callback"
>
用于身份验证流程的 OAuth 回调端点。
</Card>
<Card
title="POST /api/dxt/upload"
href="#upload-dxt-file"
>
上传 DXT 配置文件。
</Card>
<Card
title="GET /api/mcp-settings/export"
href="#export-mcp-settings"
>
将 MCP 设置导出为 JSON。
</Card>
---
### 健康检查
检查 MCPHub 服务器的健康状态。
- **端点**: `/health`
- **方法**: `GET`
- **身份验证**: 不需要
- **响应**:
```json
{
"status": "ok",
"timestamp": "2024-11-12T01:30:00.000Z",
"uptime": 12345
}
```
**请求示例:**
```bash
curl "http://localhost:3000/health"
```
---
### OAuth 回调
用于处理 OAuth 身份验证流程的 OAuth 回调端点。此端点在用户授权后由 OAuth 提供商自动调用。
- **端点**: `/oauth/callback`
- **方法**: `GET`
- **身份验证**: 不需要(公共回调 URL
- **查询参数**: 因 OAuth 提供商而异(通常包括 `code`、`state` 等)
**注意**: 此端点由 MCPHub 的 OAuth 集成内部使用,客户端不应直接调用。
---
### 上传 DXT 文件
上传 DXT桌面扩展配置文件以导入服务器配置。
- **端点**: `/api/dxt/upload`
- **方法**: `POST`
- **身份验证**: 必需
- **Content-Type**: `multipart/form-data`
- **正文**:
- `file` (文件, 必需): 要上传的 DXT 配置文件。
**请求示例:**
```bash
curl -X POST "http://localhost:3000/api/dxt/upload" \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@config.dxt"
```
---
### 导出 MCP 设置
将当前 MCP 设置配置导出为 JSON 文件。
- **端点**: `/api/mcp-settings/export`
- **方法**: `GET`
- **身份验证**: 必需
- **响应**: 返回 `mcp_settings.json` 配置文件。
**请求示例:**
```bash
curl "http://localhost:3000/api/mcp-settings/export" \
-H "Authorization: Bearer YOUR_TOKEN" \
-o mcp_settings.json
```
**注意**: 此端点允许您下载 MCP 设置的备份,可用于恢复或迁移您的配置。

View File

@@ -0,0 +1,86 @@
---
title: "工具"
description: "以编程方式执行 MCP 工具。"
---
import { Card, Cards } from 'mintlify';
<Card
title="POST /api/tools/call/:server"
href="#call-a-tool"
>
在 MCP 服务器上调用特定工具。
</Card>
---
### 调用工具
使用给定参数在 MCP 服务器上执行特定工具。
- **端点**: `/api/tools/call/:server`
- **方法**: `POST`
- **参数**:
- `:server` (字符串, 必需): MCP 服务器的名称。
- **请求正文**:
```json
{
"toolName": "tool-name",
"arguments": {
"param1": "value1",
"param2": "value2"
}
}
```
- `toolName` (字符串, 必需): 要执行的工具名称。
- `arguments` (对象, 可选): 传递给工具的参数。默认为空对象。
- **响应**:
```json
{
"success": true,
"data": {
"content": [
{
"type": "text",
"text": "工具执行结果"
}
],
"toolName": "tool-name",
"arguments": {
"param1": "value1",
"param2": "value2"
}
}
}
```
**请求示例:**
```bash
curl -X POST "http://localhost:3000/api/tools/call/amap" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"toolName": "amap-maps_weather",
"arguments": {
"city": "Beijing"
}
}'
```
**注意事项:**
- 工具参数会根据工具的输入模式自动转换为适当的类型。
- 如果需要,可以使用 `x-session-id` 请求头在多个工具调用之间维护会话状态。
- 此端点需要身份验证。
---
### 替代方案OpenAPI 工具执行
有关无需身份验证的 OpenAPI 兼容工具执行,请参阅 [OpenAPI 集成](/api-reference/openapi#tool-execution) 文档。OpenAPI 端点提供:
- **GET** `/api/tools/:serverName/:toolName` - 用于带查询参数的简单工具
- **POST** `/api/tools/:serverName/:toolName` - 用于带 JSON 正文的复杂工具
这些端点专为与 OpenWebUI 和其他 OpenAPI 兼容系统集成而设计。

View File

@@ -0,0 +1,195 @@
---
title: "用户"
description: "在 MCPHub 中管理用户。"
---
import { Card, Cards } from 'mintlify';
<Card
title="GET /api/users"
href="#get-all-users"
>
获取所有用户的列表。
</Card>
<Card
title="GET /api/users/:username"
href="#get-a-user"
>
获取特定用户的详细信息。
</Card>
<Card
title="POST /api/users"
href="#create-a-user"
>
创建新用户。
</Card>
<Card
title="PUT /api/users/:username"
href="#update-a-user"
>
更新现有用户。
</Card>
<Card
title="DELETE /api/users/:username"
href="#delete-a-user"
>
删除用户。
</Card>
<Card
title="GET /api/users-stats"
href="#get-user-statistics"
>
获取有关用户及其服务器访问权限的统计信息。
</Card>
---
### 获取所有用户
检索系统中所有用户的列表。
- **端点**: `/api/users`
- **方法**: `GET`
- **身份验证**: 必需(仅管理员)
- **响应**:
```json
{
"success": true,
"data": [
{
"username": "admin",
"role": "admin",
"servers": ["server1", "server2"],
"groups": ["group1"]
},
{
"username": "user1",
"role": "user",
"servers": ["server1"],
"groups": []
}
]
}
```
---
### 获取用户
检索特定用户的详细信息。
- **端点**: `/api/users/:username`
- **方法**: `GET`
- **身份验证**: 必需(仅管理员)
- **参数**:
- `:username` (字符串, 必需): 用户的用户名。
- **响应**:
```json
{
"success": true,
"data": {
"username": "user1",
"role": "user",
"servers": ["server1", "server2"],
"groups": ["group1"]
}
}
```
---
### 创建用户
在系统中创建新用户。
- **端点**: `/api/users`
- **方法**: `POST`
- **身份验证**: 必需(仅管理员)
- **请求正文**:
```json
{
"username": "newuser",
"password": "securepassword",
"role": "user",
"servers": ["server1"],
"groups": ["group1"]
}
```
- `username` (字符串, 必需): 新用户的用户名。
- `password` (字符串, 必需): 新用户的密码。至少 6 个字符。
- `role` (字符串, 可选): 用户的角色。可以是 `"admin"` 或 `"user"`。默认为 `"user"`。
- `servers` (字符串数组, 可选): 用户可以访问的服务器名称列表。
- `groups` (字符串数组, 可选): 用户所属的组 ID 列表。
---
### 更新用户
更新现有用户的信息。
- **端点**: `/api/users/:username`
- **方法**: `PUT`
- **身份验证**: 必需(仅管理员)
- **参数**:
- `:username` (字符串, 必需): 要更新的用户的用户名。
- **请求正文**:
```json
{
"password": "newpassword",
"role": "admin",
"servers": ["server1", "server2", "server3"],
"groups": ["group1", "group2"]
}
```
- `password` (字符串, 可选): 用户的新密码。
- `role` (字符串, 可选): 用户的新角色。
- `servers` (字符串数组, 可选): 更新的可访问服务器列表。
- `groups` (字符串数组, 可选): 更新的组列表。
---
### 删除用户
从系统中删除用户。
- **端点**: `/api/users/:username`
- **方法**: `DELETE`
- **身份验证**: 必需(仅管理员)
- **参数**:
- `:username` (字符串, 必需): 要删除的用户的用户名。
---
### 获取用户统计信息
检索有关用户及其对服务器和组的访问权限的统计信息。
- **端点**: `/api/users-stats`
- **方法**: `GET`
- **身份验证**: 必需(仅管理员)
- **响应**:
```json
{
"success": true,
"data": {
"totalUsers": 5,
"adminUsers": 1,
"regularUsers": 4,
"usersPerServer": {
"server1": 3,
"server2": 2
},
"usersPerGroup": {
"group1": 2,
"group2": 1
}
}
}
```
**注意**: 所有用户管理端点都需要管理员身份验证。

View File

@@ -0,0 +1,304 @@
---
title: '数据库配置'
description: '使用 PostgreSQL 数据库配置 MCPHub 作为 mcp_settings.json 文件的替代方案。'
---
# MCPHub 数据库配置
## 概述
MCPHub 支持将配置数据存储在 PostgreSQL 数据库中,作为 `mcp_settings.json` 文件配置的替代方案。数据库模式为生产环境和企业级部署提供了更强大的持久化和扩展能力。
## 为什么使用数据库配置?
**核心优势:**
- ✅ **更好的持久化** - 配置数据存储在专业数据库中,支持事务和数据完整性
- ✅ **高可用性** - 利用数据库复制和故障转移能力
- ✅ **企业级支持** - 符合企业数据管理和合规要求
- ✅ **备份恢复** - 使用成熟的数据库备份工具和策略
## 环境变量
### 数据库模式必需变量
```bash
# 数据库连接 URLPostgreSQL
# 只需设置 DB_URL 即可自动启用数据库模式
DB_URL=postgresql://user:password@localhost:5432/mcphub
# 或显式控制 USE_DB覆盖自动检测
# USE_DB=true
```
<Note>
**简化配置**:您只需设置 `DB_URL` 即可启用数据库模式。MCPHub 会自动检测 `DB_URL` 是否存在并启用数据库模式。如果需要在设置了 `DB_URL` 的情况下禁用数据库模式,可以显式设置 `USE_DB=false`。
</Note>
## 设置说明
### 1. 使用 Docker
#### 方案 A使用外部数据库
如果您已有 PostgreSQL 数据库:
```bash
docker run -d \
-p 3000:3000 \
-v ./mcp_settings.json:/app/mcp_settings.json \
-e DB_URL="postgresql://user:password@your-db-host:5432/mcphub" \
samanhappy/mcphub
```
#### 方案 B将 PostgreSQL 作为独立服务
创建 `docker-compose.yml` 文件:
```yaml
version: '3.8'
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: mcphub
POSTGRES_USER: mcphub
POSTGRES_PASSWORD: your_secure_password
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
mcphub:
image: samanhappy/mcphub:latest
environment:
USE_DB: "true"
DB_URL: "postgresql://mcphub:your_secure_password@postgres:5432/mcphub"
PORT: 3000
ports:
- "3000:3000"
depends_on:
- postgres
volumes:
pgdata:
```
运行:
```bash
docker-compose up -d
```
### 2. 手动设置
#### 步骤 1设置 PostgreSQL 数据库
```bash
# 安装 PostgreSQL如果尚未安装
sudo apt-get install postgresql postgresql-contrib
# 创建数据库和用户
sudo -u postgres psql <<EOF
CREATE DATABASE mcphub;
CREATE USER mcphub WITH ENCRYPTED PASSWORD 'your_password';
GRANT ALL PRIVILEGES ON DATABASE mcphub TO mcphub;
EOF
```
#### 步骤 2安装 MCPHub
```bash
npm install -g @samanhappy/mcphub
```
#### 步骤 3设置环境变量
创建 `.env` 文件:
```bash
# 只需设置 DB_URL 即可启用数据库模式USE_DB 会自动检测)
DB_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
PORT=3000
```
#### 步骤 4运行迁移可选
如果您有现有的 `mcp_settings.json` 文件,可以进行迁移:
```bash
# 运行迁移脚本
npx tsx src/scripts/migrate-to-database.ts
```
或者让 MCPHub 在首次启动时自动迁移。
#### 步骤 5启动 MCPHub
```bash
mcphub
```
## 从基于文件迁移到数据库
MCPHub 在启用数据库模式首次启动时提供自动迁移功能。您也可以手动运行迁移。
### 自动迁移
当您首次使用 `USE_DB=true` 启动 MCPHub 时:
1. MCPHub 连接到数据库
2. 检查数据库中是否存在任何用户
3. 如果未找到用户,自动从 `mcp_settings.json` 迁移
4. 创建所有表并导入所有数据
### 手动迁移
运行迁移脚本:
```bash
# 使用 npx
npx tsx src/scripts/migrate-to-database.ts
# 或使用 Node
node dist/scripts/migrate-to-database.js
```
迁移将:
- ✅ 如果不存在则创建数据库表
- ✅ 导入所有用户(包含哈希密码)
- ✅ 导入所有 MCP 服务器配置
- ✅ 导入所有分组
- ✅ 导入系统配置
- ✅ 导入用户特定配置
- ✅ 跳过已存在的记录(可安全多次运行)
## 迁移后的配置
在数据库模式下运行时,所有配置更改都存储在数据库中:
- 通过 `/api/users` 进行用户管理
- 通过 `/api/servers` 进行服务器管理
- 通过 `/api/groups` 进行分组管理
- 通过 `/api/system/config` 进行系统设置
Web 仪表板的工作方式完全相同,但现在将更改存储在数据库中而不是文件中。
## 数据库架构
MCPHub 创建以下表:
- **users** - 用户账户和认证
- **servers** - MCP 服务器配置
- **groups** - 服务器分组
- **system_config** - 系统级设置
- **user_configs** - 用户特定设置
- **vector_embeddings** - 向量搜索数据(用于智能路由)
## 备份和恢复
### 备份
```bash
# PostgreSQL 备份
pg_dump -U mcphub mcphub > mcphub_backup.sql
# 或使用 Docker
docker exec postgres pg_dump -U mcphub mcphub > mcphub_backup.sql
```
### 恢复
```bash
# PostgreSQL 恢复
psql -U mcphub mcphub < mcphub_backup.sql
# 或使用 Docker
docker exec -i postgres psql -U mcphub mcphub < mcphub_backup.sql
```
## 切换回基于文件的配置
如果您需要切换回基于文件的配置:
1. 设置 `USE_DB=false` 或删除 `DB_URL` 和 `USE_DB` 环境变量
2. 重启 MCPHub
3. MCPHub 将再次使用 `mcp_settings.json`
注意:在数据库模式下所做的更改不会反映到文件中,除非您手动导出。
## 故障排除
### 连接被拒绝
```
Error: connect ECONNREFUSED 127.0.0.1:5432
```
**解决方案:** 检查 PostgreSQL 是否正在运行并可访问:
```bash
# 检查 PostgreSQL 状态
sudo systemctl status postgresql
# 或对于 Docker
docker ps | grep postgres
```
### 认证失败
```
Error: password authentication failed for user "mcphub"
```
**解决方案:** 验证 `DB_URL` 环境变量中的数据库凭据。
### 迁移失败
```
❌ Migration failed: ...
```
**解决方案:**
1. 检查 `mcp_settings.json` 是否存在且为有效的 JSON
2. 验证数据库连接
3. 检查日志获取具体错误信息
4. 确保数据库用户具有 CREATE TABLE 权限
### 表已存在
如果数据库表不存在,会自动创建。如果遇到关于已存在表的错误,请检查:
1. 之前的迁移是否部分完成
2. 手动创建表的冲突
3. 如果需要,在数据库配置中使用 `synchronize: false` 运行
## 环境变量参考
| 变量 | 必需 | 默认值 | 描述 |
|------|------|--------|------|
| `DB_URL` | 是* | - | 完整的 PostgreSQL 连接 URL。设置此变量会自动启用数据库模式 |
| `USE_DB` | 否 | 自动 | 显式启用/禁用数据库模式。如果未设置,根据 `DB_URL` 是否存在自动检测 |
| `MCPHUB_SETTING_PATH` | 否 | - | mcp_settings.json 的路径(用于迁移) |
*数据库模式必需。只需设置 `DB_URL` 即可自动启用数据库模式
## 安全注意事项
1. **数据库凭据:** 安全存储数据库凭据,使用环境变量或密钥管理
2. **网络访问:** 仅限 MCPHub 实例访问数据库
3. **加密:** 在生产环境中使用 SSL/TLS 进行数据库连接:
```bash
DB_URL=postgresql://user:password@host:5432/mcphub?sslmode=require
```
4. **备份:** 定期备份您的数据库
5. **访问控制:** 使用强密码并限制用户权限
## 性能
数据库模式在以下场景提供更好的性能:
- 多个并发用户
- 频繁的配置更改
- 大量服务器/分组
文件模式可能更快的场景:
- 单用户设置
- 读取密集型工作负载且更改不频繁
- 开发/测试环境

View File

@@ -41,6 +41,50 @@ docker run -d \
mcphub:local
```
### 构建扩展功能版本
Docker 镜像支持 `INSTALL_EXT` 构建参数以包含额外工具:
```bash
# 构建扩展功能版本(包含 Docker 引擎、Chrome/Playwright
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
# 方式 1: 使用自动 Docker-in-Docker需要特权模式
docker run -d \
--name mcphub \
--privileged \
-p 3000:3000 \
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
mcphub:extended
# 方式 2: 挂载 Docker socket使用宿主机的 Docker 守护进程)
docker run -d \
--name mcphub \
-p 3000:3000 \
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
-v /var/run/docker.sock:/var/run/docker.sock \
mcphub:extended
# 验证 Docker 可用
docker exec mcphub docker --version
docker exec mcphub docker ps
```
<Note>
**INSTALL_EXT=true 包含的功能:**
- **Docker 引擎**:完整的 Docker 守护进程和 CLI用于容器管理。在特权模式下运行时守护进程会自动启动。
- **Chrome/Playwright**(仅 amd64用于浏览器自动化任务
扩展镜像较大,但为高级用例提供了额外功能。
</Note>
<Warning>
**Docker-in-Docker 安全注意事项:**
- **特权模式**`--privileged`):容器内启动 Docker 守护进程需要此权限。这会授予容器在宿主机上的提升权限。
- **Docker socket 挂载**`/var/run/docker.sock`):使容器可以访问宿主机的 Docker 守护进程。两种方式都应仅在可信环境中使用。
- 生产环境建议使用 Docker socket 挂载而非特权模式,以提高安全性。
</Warning>
## Docker Compose 设置
### 基本配置

View File

@@ -48,7 +48,7 @@ docker --version
```bash
# 克隆主仓库
git clone https://github.com/mcphub/mcphub.git
git clone https://github.com/samanhappy/mcphub.git
cd mcphub
# 或者克隆您的 fork
@@ -123,42 +123,6 @@ nano .env
NODE_ENV=development
PORT=3000
HOST=localhost
# 数据库配置
DATABASE_URL=sqlite:./data/dev.db
# JWT 配置
JWT_SECRET=dev-jwt-secret-key
JWT_EXPIRES_IN=7d
# 日志配置
LOG_LEVEL=debug
LOG_FORMAT=dev
# CORS 配置
CORS_ORIGIN=http://localhost:3000,http://localhost:3001
# 管理员账户
ADMIN_EMAIL=dev@mcphub.io
ADMIN_PASSWORD=dev123
# 开发功能开关
ENABLE_DEBUG_ROUTES=true
ENABLE_SWAGGER=true
ENABLE_HOT_RELOAD=true
```
### 数据库初始化
```bash
# 生成 Prisma 客户端
npx prisma generate
# 运行数据库迁移
npx prisma migrate dev --name init
# 填充测试数据
npm run db:seed
```
## 启动开发服务器

View File

@@ -388,7 +388,7 @@ CMD ["node", "dist/index.js"]
````md
```bash
# 克隆 MCPHub 仓库
git clone https://github.com/mcphub/mcphub.git
git clone https://github.com/samanhappy/mcphub.git
cd mcphub
# 安装依赖
@@ -413,7 +413,7 @@ npm start
```bash
# 克隆 MCPHub 仓库
git clone https://github.com/mcphub/mcphub.git
git clone https://github.com/samanhappy/mcphub.git
cd mcphub
# 安装依赖
@@ -441,7 +441,7 @@ npm start
```powershell
# Windows PowerShell 安装步骤
# 克隆仓库
git clone https://github.com/mcphub/mcphub.git
git clone https://github.com/samanhappy/mcphub.git
Set-Location mcphub
# 安装 Node.js 依赖
@@ -458,7 +458,7 @@ npm run dev
```powershell
# Windows PowerShell 安装步骤
# 克隆仓库
git clone https://github.com/mcphub/mcphub.git
git clone https://github.com/samanhappy/mcphub.git
Set-Location mcphub
# 安装 Node.js 依赖
@@ -528,7 +528,7 @@ docker-compose up -d
````md
```bash
# 创建新的 MCP 服务器
curl -X POST https://api.mcphub.io/api/servers \
curl -X POST http://localhost:3000/api/servers \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
@@ -539,11 +539,11 @@ curl -X POST https://api.mcphub.io/api/servers \
}'
# 获取服务器列表
curl -X GET "https://api.mcphub.io/api/servers?limit=10&active=true" \
curl -X GET "http://localhost:3000/api/servers?limit=10&active=true" \
-H "Authorization: Bearer YOUR_API_TOKEN"
# 更新服务器配置
curl -X PUT https://api.mcphub.io/api/servers/server-123 \
curl -X PUT http://localhost:3000/api/servers/server-123 \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
@@ -552,14 +552,14 @@ curl -X PUT https://api.mcphub.io/api/servers/server-123 \
}'
# 删除服务器
curl -X DELETE https://api.mcphub.io/api/servers/server-123 \
curl -X DELETE http://localhost:3000/api/servers/server-123 \
-H "Authorization: Bearer YOUR_API_TOKEN"
```
````
```bash
# 创建新的 MCP 服务器
curl -X POST https://api.mcphub.io/api/servers \
curl -X POST http://localhost:3000/api/servers \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
@@ -570,11 +570,11 @@ curl -X POST https://api.mcphub.io/api/servers \
}'
# 获取服务器列表
curl -X GET "https://api.mcphub.io/api/servers?limit=10&active=true" \
curl -X GET "http://localhost:3000/api/servers?limit=10&active=true" \
-H "Authorization: Bearer YOUR_API_TOKEN"
# 更新服务器配置
curl -X PUT https://api.mcphub.io/api/servers/server-123 \
curl -X PUT http://localhost:3000/api/servers/server-123 \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
@@ -583,7 +583,7 @@ curl -X PUT https://api.mcphub.io/api/servers/server-123 \
}'
# 删除服务器
curl -X DELETE https://api.mcphub.io/api/servers/server-123 \
curl -X DELETE http://localhost:3000/api/servers/server-123 \
-H "Authorization: Bearer YOUR_API_TOKEN"
```
@@ -592,7 +592,7 @@ curl -X DELETE https://api.mcphub.io/api/servers/server-123 \
````md
```http
POST /api/servers HTTP/1.1
Host: api.mcphub.io
Host: localhost:3000
Authorization: Bearer YOUR_API_TOKEN
Content-Type: application/json
@@ -607,7 +607,7 @@ Content-Type: application/json
```http
POST /api/servers HTTP/1.1
Host: api.mcphub.io
Host: localhost:3000
Authorization: Bearer YOUR_API_TOKEN
Content-Type: application/json
@@ -746,7 +746,7 @@ app.listen(port, () => {
```javascript
// 初始化 MCPHub 客户端
const client = new MCPHubClient({
endpoint: 'https://api.mcphub.io',
endpoint: 'http://localhost:3000',
apiKey: process.env.API_KEY,
timeout: 30000, // 30 秒超时
retries: 3, // 重试 3 次

View File

@@ -133,7 +133,7 @@ MCPHub 主要功能:
```javascript
// MCPHub 客户端初始化
const mcpClient = new MCPClient({
endpoint: 'https://api.mcphub.io',
endpoint: 'http://localhost:3000',
apiKey: process.env.MCPHUB_API_KEY,
});
```
@@ -142,7 +142,7 @@ const mcpClient = new MCPClient({
```javascript
// MCPHub 客户端初始化
const mcpClient = new MCPClient({
endpoint: 'https://api.mcphub.io',
endpoint: 'http://localhost:3000',
apiKey: process.env.MCPHUB_API_KEY,
});
```

View File

@@ -241,20 +241,6 @@ MCPHub 文档支持多级分层导航:
```json title="docs.json"
{
"tabs": [
{
"name": "文档",
"url": "https://docs.mcphub.io"
},
{
"name": "API",
"url": "https://api.mcphub.io"
},
{
"name": "SDK",
"url": "https://sdk.mcphub.io"
}
],
"navigation": {
"文档": [
{
@@ -331,18 +317,13 @@ MCPHub 文档支持以下图标库的图标:
"pages": [
{
"name": "GitHub 仓库",
"url": "https://github.com/mcphub/mcphub",
"url": "https://github.com/samanhappy/mcphub",
"icon": "github"
},
{
"name": "Discord 社区",
"url": "https://discord.gg/mcphub",
"url": "https://discord.gg/qMKNsn5Q",
"icon": "discord"
},
{
"name": "状态页面",
"url": "https://status.mcphub.io",
"icon": "status"
}
]
}
@@ -382,7 +363,6 @@ zh/
"pages": [
"zh/concepts/introduction",
"zh/concepts/architecture",
"zh/concepts/mcp-protocol",
"zh/concepts/routing"
]
}

141
docs/zh/features/oauth.mdx Normal file
View File

@@ -0,0 +1,141 @@
# OAuth 支持
## 核心亮点
- 覆盖上游 MCP 服务器的 OAuth 2.0 授权码PKCE全流程。
- 支持从 `WWW-Authenticate` 响应和 RFC 8414 元数据自动发现。
- 实现动态客户端注册RFC 7591以及资源指示RFC 8707
- 会将客户端凭据与令牌持久化到 `mcp_settings.json`,重启后直接复用。
## MCPHub 何时启用 OAuth
1. MCPHub 调用需要授权的 MCP 服务器并收到 `401 Unauthorized`。
2. 响应通过 `WWW-Authenticate` header 暴露受保护资源的元数据(`authorization_server` 或 `as_uri`)。
3. MCPHub 自动发现授权服务器、按需注册客户端,并引导用户完成一次授权。
4. 回调处理完成后MCPHub 使用新令牌重新连接并继续请求。
> MCPHub 会在服务器详情视图和后端日志中记录发现、注册、授权链接、换取令牌等关键步骤。
## 按服务器类型快速上手
### 支持动态注册的服务器
部分服务器会公开完整的 OAuth 元数据,并允许客户端动态注册。例如 Vercel 与 Linear 的 MCP 服务器只需配置 SSE 地址即可:
```json
{
"mcpServers": {
"vercel": {
"type": "sse",
"url": "https://mcp.vercel.com"
},
"linear": {
"type": "sse",
"url": "https://mcp.linear.app/mcp"
}
}
}
```
- MCPHub 会自动发现授权服务器、完成注册,并处理整个 PKCE 流程。
- 所有凭据与令牌会写入 `mcp_settings.json`,无须在控制台额外配置。
### 需要手动配置客户端的服务器
另有一些服务端并不支持动态注册。GitHub 的 MCP 端点(`https://api.githubcopilot.com/mcp/`)就是典型例子,接入步骤如下:
1. 在服务提供商控制台创建 OAuth 应用GitHub 路径为 **Settings → Developer settings → OAuth Apps**)。
2. 将回调/重定向地址设置为 `http://localhost:3000/oauth/callback`(或你的部署域名)。
3. 复制生成的 Client ID 与 Client Secret。
4. 通过 MCPHub 控制台或直接编辑 `mcp_settings.json` 写入如下配置。
```json
{
"mcpServers": {
"github": {
"type": "sse",
"url": "https://api.githubcopilot.com/mcp/",
"oauth": {
"clientId": "${GITHUB_OAUTH_APP_ID}",
"clientSecret": "${GITHUB_OAUTH_APP_SECRET}",
"scopes": ["replace-with-provider-scope"],
"resource": "https://api.githubcopilot.com"
}
}
}
}
```
- MCPHub 会跳过动态注册,直接使用你提供的凭据完成授权流程。
- 凭据轮换时需要同步更新控制台或配置文件。
- 将 `scopes` 替换为服务端要求的具体 Scope。
## 配置方式
大多数场景可依赖自动检测,也可以在 `mcp_settings.json` 中显式声明 OAuth 配置。只填写确实需要的字段。
### 自动检测(最小配置)
```json
{
"mcpServers": {
"secured-sse": {
"type": "sse",
"url": "https://mcp.example.com/sse",
"oauth": {
"scopes": ["mcp.tools", "mcp.prompts"],
"resource": "https://mcp.example.com"
}
}
}
}
```
- MCPHub 会根据挑战头自动发现授权服务器,并引导用户完成授权。
- 令牌(包含刷新令牌)会写入磁盘,重启后自动复用。
### 静态客户端凭据(自带 Client
```json
{
"oauth": {
"clientId": "mcphub-client",
"clientSecret": "replace-me-if-required",
"authorizationEndpoint": "https://auth.example.com/oauth/authorize",
"tokenEndpoint": "https://auth.example.com/oauth/token",
"redirectUri": "http://localhost:3000/oauth/callback"
}
}
```
- 适用于需要手动注册客户端的授权服务器。
- `redirectUri` 默认是 `http://localhost:3000/oauth/callback`,如在自定义域部署请同步更新。
### 动态客户端注册RFC 7591
```json
{
"oauth": {
"dynamicRegistration": {
"enabled": true,
"issuer": "https://auth.example.com",
"metadata": {
"client_name": "MCPHub",
"redirect_uris": [
"http://localhost:3000/oauth/callback",
"https://mcphub.example.com/oauth/callback"
],
"scope": "mcp.tools mcp.prompts",
"grant_types": ["authorization_code", "refresh_token"]
},
"initialAccessToken": "optional-token-if-required"
},
"scopes": ["mcp.tools", "mcp.prompts"],
"resource": "https://mcp.example.com"
}
}
```
- MCPHub 会通过 `issuer` 发现端点、完成注册,并持久化下发的 `client_id`/`client_secret`。
- 只有当注册端点受保护时才需要提供 `initialAccessToken`。
## 授权流程
1. **初始化**:启动时遍历服务器配置,发现元数据并在启用 `dynamicRegistration` 时注册客户端。
2. **用户授权**:建立连接时自动打开系统浏览器,携带 PKCE 参数访问授权页。
3. **回调处理**:内置路径 `/oauth/callback` 校验 `state`、完成换取令牌,并通过 MCP SDK 保存结果。
4. **令牌生命周期**:访问令牌与刷新令牌会缓存于内存,自动刷新,并写回 `mcp_settings.json`。
## 提示与排障
- 确保授权过程中使用的回调地址与已注册的 `redirect_uris` 完全一致。
- 若部署在 HTTPS 域名下,请对外暴露 `/oauth/callback` 或通过反向代理转发。
- 如无法完成自动发现,可显式提供 `authorizationEndpoint` 与 `tokenEndpoint`。
- 授权服务器吊销令牌后,可手动清理 `mcp_settings.json` 中的旧令牌MCPHub 会在下一次请求时重新触发授权。

View File

@@ -35,9 +35,6 @@ MCPHub 是一个现代化的 Model Context Protocol (MCP) 服务器管理平台
了解 MCPHub 的核心概念,为深入使用做好准备。
<CardGroup cols={2}>
<Card title="MCP 协议介绍" icon="network-wired" href="/zh/concepts/mcp-protocol">
深入了解 Model Context Protocol 的工作原理和最佳实践
</Card>
<Card title="智能路由机制" icon="route" href="/zh/features/smart-routing">
学习 MCPHub 的智能路由算法和配置策略
</Card>
@@ -57,12 +54,6 @@ MCPHub 支持多种部署方式,满足不同规模和场景的需求。
<Card title="Docker 部署" icon="docker" href="/zh/configuration/docker-setup">
使用 Docker 容器快速部署,支持单机和集群模式
</Card>
<Card title="云服务部署" icon="cloud" href="/zh/deployment/cloud">
在 AWS、GCP、Azure 等云平台上部署 MCPHub
</Card>
<Card title="Kubernetes" icon="dharmachakra" href="/zh/deployment/kubernetes">
在 Kubernetes 集群中部署高可用的 MCPHub 服务
</Card>
</CardGroup>
## API 和集成
@@ -73,9 +64,6 @@ MCPHub 提供完整的 RESTful API 和多语言 SDK方便与现有系统集
<Card title="API 参考文档" icon="code" href="/zh/api-reference/introduction">
完整的 API 接口文档,包含详细的请求示例和响应格式
</Card>
<Card title="SDK 和工具" icon="toolbox" href="/zh/sdk">
官方 SDK 和命令行工具,加速开发集成
</Card>
</CardGroup>
## 社区和支持
@@ -83,7 +71,7 @@ MCPHub 提供完整的 RESTful API 和多语言 SDK方便与现有系统集
加入 MCPHub 社区,获取帮助和分享经验。
<CardGroup cols={2}>
<Card title="GitHub 仓库" icon="github" href="https://github.com/mcphub/mcphub">
<Card title="GitHub 仓库" icon="github" href="https://github.com/samanhappy/mcphub">
查看源代码、提交问题和贡献代码
</Card>
<Card title="Discord 社区" icon="discord" href="https://discord.gg/mcphub">

View File

@@ -4,7 +4,7 @@ NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
echo "Setting npm registry to ${NPM_REGISTRY}"
npm config set registry "$NPM_REGISTRY"
# 处理 HTTP_PROXY HTTPS_PROXY 环境变量
# Handle HTTP_PROXY and HTTPS_PROXY environment variables
if [ -n "$HTTP_PROXY" ]; then
echo "Setting HTTP proxy to ${HTTP_PROXY}"
npm config set proxy "$HTTP_PROXY"
@@ -19,4 +19,33 @@ fi
echo "Using REQUEST_TIMEOUT: $REQUEST_TIMEOUT"
# Auto-start Docker daemon if Docker is installed
if command -v dockerd >/dev/null 2>&1; then
echo "Docker daemon detected, starting dockerd..."
# Create docker directory if it doesn't exist
mkdir -p /var/lib/docker
# Start dockerd in the background
dockerd --host=unix:///var/run/docker.sock --storage-driver=vfs > /var/log/dockerd.log 2>&1 &
# Wait for Docker daemon to be ready
echo "Waiting for Docker daemon to be ready..."
TIMEOUT=15
ELAPSED=0
while ! docker info >/dev/null 2>&1; do
if [ $ELAPSED -ge $TIMEOUT ]; then
echo "WARNING: Docker daemon failed to start within ${TIMEOUT} seconds"
echo "Check /var/log/dockerd.log for details"
break
fi
sleep 1
ELAPSED=$((ELAPSED + 1))
done
if docker info >/dev/null 2>&1; then
echo "Docker daemon started successfully"
fi
fi
exec "$@"

View File

@@ -0,0 +1,80 @@
{
"mcpServers": {
"example-sse-server": {
"type": "sse",
"url": "${MCP_SERVER_URL}",
"headers": {
"Authorization": "Bearer ${API_TOKEN}",
"X-Custom-Header": "${CUSTOM_HEADER_VALUE}"
},
"enabled": true
},
"example-streamable-http": {
"type": "streamable-http",
"url": "https://${SERVER_HOST}/mcp",
"headers": {
"API-Key": "${API_KEY}"
}
},
"example-stdio-server": {
"type": "stdio",
"command": "${PYTHON_PATH}",
"args": [
"-m",
"${MODULE_NAME}",
"--config",
"${CONFIG_PATH}"
],
"env": {
"API_KEY": "${MY_API_KEY}",
"DEBUG": "${DEBUG_MODE}",
"DATABASE_URL": "${DATABASE_URL}"
}
},
"example-openapi-server": {
"type": "openapi",
"openapi": {
"url": "${OPENAPI_SPEC_URL}",
"security": {
"type": "apiKey",
"apiKey": {
"name": "X-API-Key",
"in": "header",
"value": "${OPENAPI_API_KEY}"
}
}
},
"headers": {
"User-Agent": "MCPHub/${VERSION}"
}
},
"example-oauth-server": {
"type": "sse",
"url": "${OAUTH_SERVER_URL}",
"oauth": {
"clientId": "${OAUTH_CLIENT_ID}",
"clientSecret": "${OAUTH_CLIENT_SECRET}",
"accessToken": "${OAUTH_ACCESS_TOKEN}",
"scopes": ["read", "write"]
}
}
},
"users": [
{
"username": "admin",
"password": "${ADMIN_PASSWORD_HASH}",
"isAdmin": true
}
],
"systemConfig": {
"install": {
"pythonIndexUrl": "${PYTHON_INDEX_URL}",
"npmRegistry": "${NPM_REGISTRY}"
},
"mcpRouter": {
"apiKey": "${MCPROUTER_API_KEY}",
"referer": "${MCPROUTER_REFERER}",
"baseUrl": "${MCPROUTER_BASE_URL}"
}
}
}

View File

@@ -0,0 +1,25 @@
{
"systemConfig": {
"oauthServer": {
"enabled": true,
"accessTokenLifetime": 3600,
"refreshTokenLifetime": 1209600,
"authorizationCodeLifetime": 300,
"requireClientSecret": false,
"allowedScopes": ["read", "write"],
"dynamicRegistration": {
"enabled": true,
"allowedGrantTypes": ["authorization_code", "refresh_token"],
"requiresAuthentication": false
}
}
},
"mcpServers": {},
"users": [
{
"username": "admin",
"password": "$2b$10$abcdefghijklmnopqrstuv",
"isAdmin": true
}
]
}

View File

@@ -0,0 +1,76 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--headless"
]
},
"fetch": {
"command": "uvx",
"args": [
"mcp-server-fetch"
]
}
},
"users": [
{
"username": "admin",
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
"isAdmin": true
}
],
"systemConfig": {
"oauthServer": {
"enabled": true,
"accessTokenLifetime": 3600,
"refreshTokenLifetime": 1209600,
"authorizationCodeLifetime": 300,
"requireClientSecret": false,
"allowedScopes": [
"read",
"write"
]
},
"routing": {
"skipAuth": false
}
},
"oauthClients": [
{
"clientId": "chatgpt-web-client",
"name": "ChatGPT Web Integration",
"redirectUris": [
"https://chatgpt.com/oauth/callback",
"https://chat.openai.com/oauth/callback"
],
"grants": [
"authorization_code",
"refresh_token"
],
"scopes": [
"read",
"write"
],
"owner": "admin"
},
{
"clientId": "example-public-app",
"name": "Example Public Application",
"redirectUris": [
"http://localhost:8080/callback",
"http://localhost:3001/callback"
],
"grants": [
"authorization_code",
"refresh_token"
],
"scopes": [
"read",
"write"
],
"owner": "admin"
}
]
}

View File

@@ -3,6 +3,8 @@ import { BrowserRouter as Router, Route, Routes, Navigate, useParams } from 'rea
import { AuthProvider } from './contexts/AuthContext';
import { ToastProvider } from './contexts/ToastContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { ServerProvider } from './contexts/ServerContext';
import { SettingsProvider } from './contexts/SettingsContext';
import MainLayout from './layouts/MainLayout';
import ProtectedRoute from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage';
@@ -26,40 +28,41 @@ function App() {
return (
<ThemeProvider>
<AuthProvider>
<ToastProvider>
<Router basename={basename}>
<Routes>
{/* 公共路由 */}
<Route path="/login" element={<LoginPage />} />
<ServerProvider>
<ToastProvider>
<SettingsProvider>
<Router basename={basename}>
<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="/users" element={<UsersPage />} />
<Route path="/market" element={<MarketPage />} />
<Route path="/market/:serverName" element={<MarketPage />} />
{/* Legacy cloud routes redirect to market with cloud tab */}
<Route path="/cloud" element={<Navigate to="/market?tab=cloud" replace />} />
<Route
path="/cloud/:serverName"
element={<CloudRedirect />}
/>
<Route path="/logs" element={<LogsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route>
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
<Route element={<ProtectedRoute />}>
<Route element={<MainLayout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/servers" element={<ServersPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/market" element={<MarketPage />} />
<Route path="/market/:serverName" element={<MarketPage />} />
{/* Legacy cloud routes redirect to market with cloud tab */}
<Route path="/cloud" element={<Navigate to="/market?tab=cloud" replace />} />
<Route path="/cloud/:serverName" element={<CloudRedirect />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route>
{/* 未匹配的路由重定向到首页 */}
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Router>
</ToastProvider>
{/* 未匹配的路由重定向到首页 */}
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Router>
</SettingsProvider>
</ToastProvider>
</ServerProvider>
</AuthProvider>
</ThemeProvider>
);
}
export default App;
export default App;

View File

@@ -0,0 +1,178 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserData } from '@/hooks/useUserData';
import { UserFormData } from '@/types';
interface AddUserFormProps {
onAdd: () => void;
onCancel: () => void;
}
const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
const { t } = useTranslation();
const { createUser } = useUserData();
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<UserFormData>({
username: '',
password: '',
isAdmin: false,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!formData.username.trim()) {
setError(t('users.usernameRequired'));
return;
}
if (!formData.password.trim()) {
setError(t('users.passwordRequired'));
return;
}
if (formData.password.length < 6) {
setError(t('users.passwordTooShort'));
return;
}
setIsSubmitting(true);
try {
const result = await createUser(formData);
if (result?.success) {
onAdd();
} else {
setError(result?.message || t('users.createError'));
}
} catch (err) {
setError(err instanceof Error ? err.message : t('users.createError'));
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-xl shadow-2xl max-w-md w-full mx-4 border border-gray-100">
<form onSubmit={handleSubmit}>
<h2 className="text-xl font-bold text-gray-900 mb-6">{t('users.addNew')}</h2>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6 rounded-md">
<p className="text-sm font-medium">{error}</p>
</div>
)}
<div className="space-y-5">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
{t('users.username')} <span className="text-red-500">*</span>
</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleInputChange}
placeholder={t('users.usernamePlaceholder')}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
required
disabled={isSubmitting}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
{t('users.password')} <span className="text-red-500">*</span>
</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
placeholder={t('users.passwordPlaceholder')}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
required
disabled={isSubmitting}
minLength={6}
/>
</div>
<div className="flex items-center pt-2">
<input
type="checkbox"
id="isAdmin"
name="isAdmin"
checked={formData.isAdmin}
onChange={handleInputChange}
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded transition-colors duration-200"
disabled={isSubmitting}
/>
<label
htmlFor="isAdmin"
className="ml-3 block text-sm font-medium text-gray-700 cursor-pointer select-none"
>
{t('users.adminRole')}
</label>
</div>
</div>
<div className="flex justify-end space-x-3 mt-8">
<button
type="button"
onClick={onCancel}
className="px-5 py-2.5 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-all duration-200 font-medium btn-secondary shadow-sm"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 font-medium btn-primary shadow-md disabled:opacity-70 disabled:cursor-not-allowed flex items-center"
disabled={isSubmitting}
>
{isSubmitting && (
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)}
{isSubmitting ? t('common.creating') : t('users.create')}
</button>
</div>
</form>
</div>
</div>
);
};
export default AddUserForm;

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChangePasswordCredentials } from '../types';
import { changePassword } from '../services/authService';
import { validatePasswordStrength } from '../utils/passwordValidation';
interface ChangePasswordFormProps {
onSuccess?: () => void;
@@ -18,6 +19,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [passwordErrors, setPasswordErrors] = useState<string[]>([]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
@@ -25,6 +27,12 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
setConfirmPassword(value);
} else {
setFormData(prev => ({ ...prev, [name]: value }));
// Validate password strength on change for new password
if (name === 'newPassword') {
const validation = validatePasswordStrength(value);
setPasswordErrors(validation.errors);
}
}
};
@@ -32,6 +40,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
e.preventDefault();
setError(null);
// Validate password strength
const validation = validatePasswordStrength(formData.newPassword);
if (!validation.isValid) {
setError(t('auth.passwordStrengthError'));
setPasswordErrors(validation.errors);
return;
}
// Validate passwords match
if (formData.newPassword !== confirmPassword) {
setError(t('auth.passwordsNotMatch'));
@@ -100,8 +116,24 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
value={formData.newPassword}
onChange={handleChange}
required
minLength={6}
minLength={8}
/>
{/* Password strength hints */}
{formData.newPassword && passwordErrors.length > 0 && (
<div className="mt-2 text-sm text-gray-600">
<p className="font-semibold mb-1">{t('auth.passwordStrengthHint')}</p>
<ul className="list-disc list-inside space-y-1">
{passwordErrors.map((errorKey) => (
<li key={errorKey} className="text-red-600">
{t(`auth.${errorKey}`)}
</li>
))}
</ul>
</div>
)}
{formData.newPassword && passwordErrors.length === 0 && (
<p className="mt-2 text-sm text-green-600"> {t('auth.passwordStrengthHint')}</p>
)}
</div>
<div className="mb-6">
@@ -116,7 +148,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
value={confirmPassword}
onChange={handleChange}
required
minLength={6}
minLength={8}
/>
</div>

View File

@@ -231,8 +231,8 @@ const CloudServerDetail: React.FC<CloudServerDetailProps> = ({
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
>
<option value=""></option>
<option value="true">True</option>
<option value="false">False</option>
<option value="true">{t('common.true')}</option>
<option value="false">{t('common.false')}</option>
</select>
);
} else if (propSchema.type === 'number' || propSchema.type === 'integer') {

View File

@@ -1,51 +1,52 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Server } from '@/types'
import { apiPut } from '../utils/fetchInterceptor'
import ServerForm from './ServerForm'
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Server } from '@/types';
import { apiPut } from '../utils/fetchInterceptor';
import ServerForm from './ServerForm';
interface EditServerFormProps {
server: Server
onEdit: () => void
onCancel: () => void
server: Server;
onEdit: () => void;
onCancel: () => void;
}
const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
const { t } = useTranslation()
const [error, setError] = useState<string | null>(null)
const { t } = useTranslation();
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (payload: any) => {
try {
setError(null)
const result = await apiPut(`/servers/${server.name}`, payload)
setError(null);
const encodedServerName = encodeURIComponent(server.name);
const result = await apiPut(`/servers/${encodedServerName}`, payload);
if (!result.success) {
// Use specific error message from the response if available
if (result && result.message) {
setError(result.message)
setError(result.message);
} else {
setError(t('server.updateError', { serverName: server.name }))
setError(t('server.updateError', { serverName: server.name }));
}
return
return;
}
onEdit()
onEdit();
} catch (err) {
console.error('Error updating server:', err)
console.error('Error updating server:', err);
// Use friendly error messages based on error type
if (!navigator.onLine) {
setError(t('errors.network'))
} else if (err instanceof TypeError && (
err.message.includes('NetworkError') ||
err.message.includes('Failed to fetch')
)) {
setError(t('errors.serverConnection'))
setError(t('errors.network'));
} else if (
err instanceof TypeError &&
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
) {
setError(t('errors.serverConnection'));
} else {
setError(t('errors.serverUpdate', { serverName: server.name }))
setError(t('errors.serverUpdate', { serverName: server.name }));
}
}
}
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
@@ -57,7 +58,7 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
formError={error}
/>
</div>
)
}
);
};
export default EditServerForm
export default EditServerForm;

View File

@@ -0,0 +1,200 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserData } from '@/hooks/useUserData';
import { User, UserUpdateData } from '@/types';
interface EditUserFormProps {
user: User;
onEdit: () => void;
onCancel: () => void;
}
const EditUserForm = ({ user, onEdit, onCancel }: EditUserFormProps) => {
const { t } = useTranslation();
const { updateUser } = useUserData();
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
isAdmin: user.isAdmin,
newPassword: '',
confirmPassword: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validate passwords match if changing password
if (formData.newPassword && formData.newPassword !== formData.confirmPassword) {
setError(t('users.passwordMismatch'));
return;
}
if (formData.newPassword && formData.newPassword.length < 6) {
setError(t('users.passwordTooShort'));
return;
}
setIsSubmitting(true);
try {
const updateData: UserUpdateData = {
isAdmin: formData.isAdmin,
};
if (formData.newPassword) {
updateData.newPassword = formData.newPassword;
}
const result = await updateUser(user.username, updateData);
if (result?.success) {
onEdit();
} else {
setError(result?.message || t('users.updateError'));
}
} catch (err) {
setError(err instanceof Error ? err.message : t('users.updateError'));
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-xl shadow-2xl max-w-md w-full mx-4 border border-gray-100">
<form onSubmit={handleSubmit}>
<h2 className="text-xl font-bold text-gray-900 mb-6">
{t('users.edit')} - <span className="text-blue-600">{user.username}</span>
</h2>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6 rounded-md">
<p className="text-sm font-medium">{error}</p>
</div>
)}
<div className="space-y-5">
<div className="flex items-center pt-2">
<input
type="checkbox"
id="isAdmin"
name="isAdmin"
checked={formData.isAdmin}
onChange={handleInputChange}
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded transition-colors duration-200"
disabled={isSubmitting}
/>
<label
htmlFor="isAdmin"
className="ml-3 block text-sm font-medium text-gray-700 cursor-pointer select-none"
>
{t('users.adminRole')}
</label>
</div>
<div className="border-t border-gray-100 pt-4 mt-2">
<p className="text-xs text-gray-500 uppercase font-semibold tracking-wider mb-3">
{t('users.changePassword')}
</p>
<div className="space-y-4">
<div>
<label
htmlFor="newPassword"
className="block text-sm font-medium text-gray-700 mb-1"
>
{t('users.newPassword')}
</label>
<input
type="password"
id="newPassword"
name="newPassword"
value={formData.newPassword}
onChange={handleInputChange}
placeholder={t('users.newPasswordPlaceholder')}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
disabled={isSubmitting}
minLength={6}
/>
</div>
{formData.newPassword && (
<div className="animate-fadeIn">
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-1"
>
{t('users.confirmPassword')}
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder={t('users.confirmPasswordPlaceholder')}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
disabled={isSubmitting}
minLength={6}
/>
</div>
)}
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-8">
<button
type="button"
onClick={onCancel}
className="px-5 py-2.5 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-all duration-200 font-medium btn-secondary shadow-sm"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 font-medium btn-primary shadow-md disabled:opacity-70 disabled:cursor-not-allowed flex items-center"
disabled={isSubmitting}
>
{isSubmitting && (
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)}
{isSubmitting ? t('common.updating') : t('users.update')}
</button>
</div>
</form>
</div>
</div>
);
};
export default EditUserForm;

View File

@@ -0,0 +1,284 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { apiPost } from '@/utils/fetchInterceptor';
interface GroupImportFormProps {
onSuccess: () => void;
onCancel: () => void;
}
interface ImportGroupConfig {
name: string;
description?: string;
servers?: string[] | Array<{ name: string; tools?: string[] | 'all' }>;
}
interface ImportJsonFormat {
groups: ImportGroupConfig[];
}
const GroupImportForm: React.FC<GroupImportFormProps> = ({ onSuccess, onCancel }) => {
const { t } = useTranslation();
const [jsonInput, setJsonInput] = useState('');
const [error, setError] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [previewGroups, setPreviewGroups] = useState<ImportGroupConfig[] | null>(null);
const examplePlaceholder = `{
"groups": [
{
"name": "AI Assistants",
"servers": ["openai-server", "anthropic-server"]
},
{
"name": "Development Tools",
"servers": [
{
"name": "github-server",
"tools": ["create_issue", "list_repos"]
},
{
"name": "gitlab-server",
"tools": "all"
}
]
}
]
}
Supports:
- Simple server list: ["server1", "server2"]
- Advanced server config: [{"name": "server1", "tools": ["tool1", "tool2"]}]
- All groups will be imported in a single efficient batch operation.`;
const parseAndValidateJson = (input: string): ImportJsonFormat | null => {
try {
const parsed = JSON.parse(input.trim());
// Validate structure
if (!parsed.groups || !Array.isArray(parsed.groups)) {
setError(t('groupImport.invalidFormat'));
return null;
}
// Validate each group
for (const group of parsed.groups) {
if (!group.name || typeof group.name !== 'string') {
setError(t('groupImport.missingName'));
return null;
}
}
return parsed as ImportJsonFormat;
} catch (e) {
setError(t('groupImport.parseError'));
return null;
}
};
const handlePreview = () => {
setError(null);
const parsed = parseAndValidateJson(jsonInput);
if (!parsed) return;
setPreviewGroups(parsed.groups);
};
const handleImport = async () => {
if (!previewGroups) return;
setIsImporting(true);
setError(null);
try {
// Use batch import API for better performance
const result = await apiPost('/groups/batch', {
groups: previewGroups,
});
if (result.success) {
const { successCount, failureCount, results } = result;
if (failureCount > 0) {
const errors = results
.filter((r: any) => !r.success)
.map((r: any) => `${r.name}: ${r.message || t('groupImport.addFailed')}`);
setError(
t('groupImport.partialSuccess', { count: successCount, total: previewGroups.length }) +
'\n' +
errors.join('\n'),
);
}
if (successCount > 0) {
onSuccess();
}
} else {
setError(result.message || t('groupImport.importFailed'));
}
} catch (err) {
console.error('Import error:', err);
setError(t('groupImport.importFailed'));
} finally {
setIsImporting(false);
}
};
const renderServerList = (
servers?: string[] | Array<{ name: string; tools?: string[] | 'all' }>,
) => {
if (!servers || servers.length === 0) {
return <span className="text-gray-500">{t('groups.noServers')}</span>;
}
return (
<div className="space-y-1">
{servers.map((server, idx) => {
if (typeof server === 'string') {
return (
<div key={idx} className="text-sm">
{server}
</div>
);
} else {
return (
<div key={idx} className="text-sm">
{server.name}
{server.tools && server.tools !== 'all' && (
<span className="text-gray-500 ml-2">
({Array.isArray(server.tools) ? server.tools.join(', ') : server.tools})
</span>
)}
{server.tools === 'all' && <span className="text-gray-500 ml-2">(all tools)</span>}
</div>
);
}
})}
</div>
);
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white shadow rounded-lg p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('groupImport.title')}</h2>
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
</button>
</div>
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
<p className="text-red-700 whitespace-pre-wrap">{error}</p>
</div>
)}
{!previewGroups ? (
<div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('groupImport.inputLabel')}
</label>
<textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
className="w-full h-96 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
placeholder={examplePlaceholder}
/>
<p className="text-xs text-gray-500 mt-2">{t('groupImport.inputHelp')}</p>
</div>
<div className="flex justify-end space-x-4">
<button
onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handlePreview}
disabled={!jsonInput.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 btn-primary"
>
{t('groupImport.preview')}
</button>
</div>
</div>
) : (
<div>
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900 mb-3">
{t('groupImport.previewTitle')}
</h3>
<div className="space-y-3">
{previewGroups.map((group, index) => (
<div key={index} className="bg-gray-50 p-4 rounded-lg border border-gray-200">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-gray-900">{group.name}</h4>
{group.description && (
<p className="text-sm text-gray-600 mt-1">{group.description}</p>
)}
<div className="mt-2 text-sm text-gray-600">
<strong>{t('groups.servers')}:</strong>
<div className="mt-1">{renderServerList(group.servers)}</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
<div className="flex justify-end space-x-4">
<button
onClick={() => setPreviewGroups(null)}
disabled={isImporting}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
>
{t('common.back')}
</button>
<button
onClick={handleImport}
disabled={isImporting}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
>
{isImporting ? (
<>
<svg
className="animate-spin h-4 w-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{t('groupImport.importing')}
</>
) : (
t('groupImport.import')
)}
</button>
</div>
</div>
)}
</div>
</div>
);
};
export default GroupImportForm;

View File

@@ -0,0 +1,307 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { apiPost } from '@/utils/fetchInterceptor';
interface JSONImportFormProps {
onSuccess: () => void;
onCancel: () => void;
}
interface McpServerConfig {
command?: string;
args?: string[];
env?: Record<string, string>;
type?: string;
url?: string;
headers?: Record<string, string>;
openapi?: {
version: string;
url: string;
};
}
interface ImportJsonFormat {
mcpServers: Record<string, McpServerConfig>;
}
const JSONImportForm: React.FC<JSONImportFormProps> = ({ onSuccess, onCancel }) => {
const { t } = useTranslation();
const [jsonInput, setJsonInput] = useState('');
const [error, setError] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [previewServers, setPreviewServers] = useState<Array<{ name: string; config: any }> | null>(
null,
);
const examplePlaceholder = `{
"mcpServers": {
"stdio-server-example": {
"command": "npx",
"args": ["-y", "mcp-server-example"]
},
"sse-server-example": {
"type": "sse",
"url": "http://localhost:3000"
},
"http-server-example": {
"type": "streamable-http",
"url": "http://localhost:3001",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer your-token"
}
},
"openapi-server-example": {
"type": "openapi",
"openapi": {
"url": "https://petstore.swagger.io/v2/swagger.json"
}
}
}
}
Supports: STDIO, SSE, HTTP (streamable-http), OpenAPI
All servers will be imported in a single efficient batch operation.`;
const parseAndValidateJson = (input: string): ImportJsonFormat | null => {
try {
const parsed = JSON.parse(input.trim());
// Validate structure
if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') {
setError(t('jsonImport.invalidFormat'));
return null;
}
return parsed as ImportJsonFormat;
} catch (e) {
setError(t('jsonImport.parseError'));
return null;
}
};
const handlePreview = () => {
setError(null);
const parsed = parseAndValidateJson(jsonInput);
if (!parsed) return;
const servers = Object.entries(parsed.mcpServers).map(([name, config]) => {
// Normalize config to MCPHub format
const normalizedConfig: any = {};
if (config.type === 'sse' || config.type === 'streamable-http') {
normalizedConfig.type = config.type;
normalizedConfig.url = config.url;
if (config.headers) {
normalizedConfig.headers = config.headers;
}
} else if (config.type === 'openapi') {
normalizedConfig.type = 'openapi';
normalizedConfig.openapi = config.openapi;
} else {
// Default to stdio
normalizedConfig.type = 'stdio';
normalizedConfig.command = config.command;
normalizedConfig.args = config.args || [];
if (config.env) {
normalizedConfig.env = config.env;
}
}
return { name, config: normalizedConfig };
});
setPreviewServers(servers);
};
const handleImport = async () => {
if (!previewServers) return;
setIsImporting(true);
setError(null);
try {
// Use batch import API for better performance
const result = await apiPost('/servers/batch', {
servers: previewServers,
});
if (result.success && result.data) {
const { successCount, failureCount, results } = result.data;
if (failureCount > 0) {
const errors = results
.filter((r: any) => !r.success)
.map((r: any) => `${r.name}: ${r.message || t('jsonImport.addFailed')}`);
setError(
t('jsonImport.partialSuccess', { count: successCount, total: previewServers.length }) +
'\n' +
errors.join('\n'),
);
}
if (successCount > 0) {
onSuccess();
}
} else {
setError(result.message || t('jsonImport.importFailed'));
}
} catch (err) {
console.error('Import error:', err);
setError(t('jsonImport.importFailed'));
} finally {
setIsImporting(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white shadow rounded-lg p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('jsonImport.title')}</h2>
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
</button>
</div>
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
<p className="text-red-700 whitespace-pre-wrap">{error}</p>
</div>
)}
{!previewServers ? (
<div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('jsonImport.inputLabel')}
</label>
<textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
className="w-full h-96 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
placeholder={examplePlaceholder}
/>
<p className="text-xs text-gray-500 mt-2">{t('jsonImport.inputHelp')}</p>
</div>
<div className="flex justify-end space-x-4">
<button
onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handlePreview}
disabled={!jsonInput.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 btn-primary"
>
{t('jsonImport.preview')}
</button>
</div>
</div>
) : (
<div>
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900 mb-3">
{t('jsonImport.previewTitle')}
</h3>
<div className="space-y-3">
{previewServers.map((server, index) => (
<div key={index} className="bg-gray-50 p-4 rounded-lg border border-gray-200">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-gray-900">{server.name}</h4>
<div className="mt-2 space-y-1 text-sm text-gray-600">
<div>
<strong>{t('server.type')}:</strong> {server.config.type || 'stdio'}
</div>
{server.config.command && (
<div>
<strong>{t('server.command')}:</strong> {server.config.command}
</div>
)}
{server.config.args && server.config.args.length > 0 && (
<div>
<strong>{t('server.arguments')}:</strong>{' '}
{server.config.args.join(' ')}
</div>
)}
{server.config.url && (
<div>
<strong>{t('server.url')}:</strong> {server.config.url}
</div>
)}
{server.config.env && Object.keys(server.config.env).length > 0 && (
<div>
<strong>{t('server.envVars')}:</strong>{' '}
{Object.keys(server.config.env).join(', ')}
</div>
)}
{server.config.headers &&
Object.keys(server.config.headers).length > 0 && (
<div>
<strong>{t('server.headers')}:</strong>{' '}
{Object.keys(server.config.headers).join(', ')}
</div>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
<div className="flex justify-end space-x-4">
<button
onClick={() => setPreviewServers(null)}
disabled={isImporting}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
>
{t('common.back')}
</button>
<button
onClick={handleImport}
disabled={isImporting}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
>
{isImporting ? (
<>
<svg
className="animate-spin h-4 w-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{t('jsonImport.importing')}
</>
) : (
t('jsonImport.import')
)}
</button>
</div>
</div>
)}
</div>
</div>
);
};
export default JSONImportForm;

View File

@@ -19,7 +19,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
onBack,
onInstall,
installing = false,
isInstalled = false
isInstalled = false,
}) => {
const { t } = useTranslation();
const [modalVisible, setModalVisible] = useState(false);
@@ -32,21 +32,23 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
const getButtonProps = () => {
if (isInstalled) {
return {
className: "bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white",
className: 'bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white',
disabled: true,
text: t('market.installed')
text: t('market.installed'),
};
} else if (installing) {
return {
className: "bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white",
className:
'bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white',
disabled: true,
text: t('market.installing')
text: t('market.installing'),
};
} else {
return {
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary",
className:
'bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary',
disabled: false,
text: t('market.install')
text: t('market.install'),
};
}
};
@@ -133,12 +135,18 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
return (
<div className="bg-white rounded-lg shadow-md p-6">
<div className="mb-4">
<button
onClick={onBack}
className="text-gray-600 hover:text-gray-900 flex items-center"
>
<svg className="h-5 w-5 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
<button onClick={onBack} className="text-gray-600 hover:text-gray-900 flex items-center">
<svg
className="h-5 w-5 mr-1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
clipRule="evenodd"
/>
</svg>
{t('market.backToList')}
</button>
@@ -150,7 +158,8 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
{server.display_name}
<span className="text-sm font-normal text-gray-500 ml-2">({server.name})</span>
<span className="text-sm font-normal text-gray-600 ml-4">
{t('market.author')}: {server.author.name} {t('market.license')}: {server.license}
{t('market.author')}: {server.author?.name || t('market.unknown')} {' '}
{t('market.license')}: {server.license}
<a
href={server.repository.url}
target="_blank"
@@ -182,18 +191,24 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<p className="text-gray-700 mb-6">{server.description}</p>
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t('market.categories')} & {t('market.tags')}</h3>
<h3 className="text-lg font-semibold mb-3">
{t('market.categories')} & {t('market.tags')}
</h3>
<div className="flex flex-wrap gap-2">
{server.categories?.map((category, index) => (
<span key={`cat-${index}`} className="bg-gray-100 text-gray-800 px-3 py-1 rounded">
{category}
</span>
))}
{server.tags && server.tags.map((tag, index) => (
<span key={`tag-${index}`} className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm">
#{tag}
</span>
))}
{server.tags &&
server.tags.map((tag, index) => (
<span
key={`tag-${index}`}
className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm"
>
#{tag}
</span>
))}
</div>
</div>
@@ -224,9 +239,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{name}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{arg.description}
</td>
<td className="px-6 py-4 text-sm text-gray-500">{arg.description}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{arg.required ? (
<span className="text-green-600"></span>
@@ -268,7 +281,10 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
</h4>
<p className="text-gray-600 mb-2">{tool.description}</p>
<div className="mt-2">
<pre id={`schema-${index}`} className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2">
<pre
id={`schema-${index}`}
className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2"
>
{JSON.stringify(tool.inputSchema, null, 2)}
</pre>
</div>
@@ -285,9 +301,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<div key={index} className="border border-gray-200 rounded p-4">
<h4 className="font-medium mb-2">{example.title}</h4>
<p className="text-gray-600 mb-2">{example.description}</p>
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">
{example.prompt}
</pre>
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">{example.prompt}</pre>
</div>
))}
</div>
@@ -316,11 +330,11 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
status: 'disconnected',
config: preferredInstallation
? {
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {}
}
: undefined
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {},
}
: undefined,
}}
/>
</div>
@@ -332,14 +346,16 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{t('server.confirmVariables')}
</h3>
<p className="text-gray-600 mb-4">
{t('server.variablesDetected')}
</p>
<p className="text-gray-600 mb-4">{t('server.variablesDetected')}</p>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
@@ -356,14 +372,12 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
</div>
</div>
</div>
<p className="text-gray-600 text-sm mb-6">
{t('market.confirmVariablesMessage')}
</p>
<p className="text-gray-600 text-sm mb-6">{t('market.confirmVariablesMessage')}</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => {
setConfirmationVisible(false)
setPendingPayload(null)
setConfirmationVisible(false);
setPendingPayload(null);
}}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
>

View File

@@ -0,0 +1,205 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { RegistryServerEntry } from '@/types';
interface RegistryServerCardProps {
serverEntry: RegistryServerEntry;
onClick: (serverEntry: RegistryServerEntry) => void;
}
const RegistryServerCard: React.FC<RegistryServerCardProps> = ({ serverEntry, onClick }) => {
const { t } = useTranslation();
const { server, _meta } = serverEntry;
const handleClick = () => {
onClick(serverEntry);
};
// Get display description
const getDisplayDescription = () => {
if (server.description && server.description.length <= 150) {
return server.description;
}
return server.description
? server.description.slice(0, 150) + '...'
: t('registry.noDescription');
};
// Format date for display
const formatDate = (dateString?: string) => {
if (!dateString) return '';
try {
const date = new Date(dateString);
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}/${month}/${day}`;
} catch {
return '';
}
};
// Get icon to display
const getIcon = () => {
if (server.icons && server.icons.length > 0) {
// Prefer light theme icon
const lightIcon = server.icons.find((icon) => !icon.theme || icon.theme === 'light');
return lightIcon || server.icons[0];
}
return null;
};
const icon = getIcon();
const officialMeta = _meta?.['io.modelcontextprotocol.registry/official'];
const isLatest = officialMeta?.isLatest;
const publishedAt = officialMeta?.publishedAt;
const updatedAt = officialMeta?.updatedAt;
// Count packages and remotes
const packageCount = server.packages?.length || 0;
const remoteCount = server.remotes?.length || 0;
const totalOptions = packageCount + remoteCount;
return (
<div
className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-lg hover:border-blue-400 hover:-translate-y-1 transition-all duration-300 cursor-pointer group relative overflow-hidden h-full flex flex-col"
onClick={handleClick}
>
{/* Background gradient overlay on hover */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/0 to-purple-50/0 group-hover:from-blue-50/30 group-hover:to-purple-50/30 transition-all duration-300 pointer-events-none" />
{/* Server Header */}
<div className="relative z-10 flex-1 flex flex-col">
<div className="flex items-start justify-between mb-3">
<div className="flex items-start space-x-3 flex-1">
{/* Icon */}
{icon ? (
<img
src={icon.src}
alt={server.title}
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
) : (
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center text-white text-xl font-semibold flex-shrink-0">
M
</div>
)}
{/* Title and badges */}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors duration-200 mb-1 line-clamp-2">
{server.name}
</h3>
<div className="flex flex-wrap gap-1 mb-1">
{isLatest && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{t('registry.latest')}
</span>
)}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
v{server.version}
</span>
</div>
</div>
</div>
</div>
{/* Server Name */}
{/* <div className="mb-2">
<p className="text-xs text-gray-500 font-mono">{server.name}</p>
</div> */}
{/* Description */}
<div className="mb-3 flex-1">
<p className="text-gray-600 text-sm leading-relaxed line-clamp-3">
{getDisplayDescription()}
</p>
</div>
{/* Installation Options Info */}
{totalOptions > 0 && (
<div className="mb-3">
<div className="flex items-center space-x-4">
{packageCount > 0 && (
<div className="flex items-center space-x-1">
<svg
className="w-4 h-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
<span className="text-sm text-gray-600">
{packageCount}{' '}
{packageCount === 1 ? t('registry.package') : t('registry.packages')}
</span>
</div>
)}
{remoteCount > 0 && (
<div className="flex items-center space-x-1">
<svg
className="w-4 h-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
<span className="text-sm text-gray-600">
{remoteCount} {remoteCount === 1 ? t('registry.remote') : t('registry.remotes')}
</span>
</div>
)}
</div>
</div>
)}
{/* Footer - fixed at bottom */}
<div className="flex items-center justify-between pt-3 border-t border-gray-100 mt-auto">
<div className="flex items-center space-x-2 text-xs text-gray-500">
{(publishedAt || updatedAt) && (
<>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
clipRule="evenodd"
/>
</svg>
<span>{formatDate(updatedAt || publishedAt)}</span>
</>
)}
</div>
<div className="flex items-center text-blue-600 text-sm font-medium group-hover:text-blue-700 transition-colors">
<span>{t('registry.viewDetails')}</span>
<svg
className="w-4 h-4 ml-1 transform group-hover:translate-x-1 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</div>
);
};
export default RegistryServerCard;

View File

@@ -0,0 +1,698 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
RegistryServerEntry,
RegistryPackage,
RegistryRemote,
RegistryServerData,
ServerConfig,
} from '@/types';
import ServerForm from './ServerForm';
interface RegistryServerDetailProps {
serverEntry: RegistryServerEntry;
onBack: () => void;
onInstall?: (server: RegistryServerData, config: ServerConfig) => void;
installing?: boolean;
isInstalled?: boolean;
fetchVersions?: (serverName: string) => Promise<RegistryServerEntry[]>;
}
const RegistryServerDetail: React.FC<RegistryServerDetailProps> = ({
serverEntry,
onBack,
onInstall,
installing = false,
isInstalled = false,
fetchVersions,
}) => {
const { t } = useTranslation();
const { server, _meta } = serverEntry;
const [_selectedVersion, _setSelectedVersion] = useState<string>(server.version);
const [_availableVersions, setAvailableVersions] = useState<RegistryServerEntry[]>([]);
const [_loadingVersions, setLoadingVersions] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [selectedInstallType, setSelectedInstallType] = useState<'package' | 'remote' | null>(null);
const [selectedOption, setSelectedOption] = useState<RegistryPackage | RegistryRemote | null>(
null,
);
const [installError, setInstallError] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
packages: true,
remotes: true,
repository: true,
});
const officialMeta = _meta?.['io.modelcontextprotocol.registry/official'];
// Load available versions
useEffect(() => {
const loadVersions = async () => {
if (fetchVersions) {
setLoadingVersions(true);
try {
const versions = await fetchVersions(server.name);
setAvailableVersions(versions);
} catch (error) {
console.error('Failed to load versions:', error);
} finally {
setLoadingVersions(false);
}
}
};
loadVersions();
}, [server.name, fetchVersions]);
// Get icon to display
const getIcon = () => {
if (server.icons && server.icons.length > 0) {
const lightIcon = server.icons.find((icon) => !icon.theme || icon.theme === 'light');
return lightIcon || server.icons[0];
}
return null;
};
const icon = getIcon();
// Format date
const formatDate = (dateString?: string) => {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toLocaleDateString();
} catch {
return '';
}
};
// Toggle section expansion
const toggleSection = (section: string) => {
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
};
// Handle install button click
const handleInstallClick = (
type: 'package' | 'remote',
option: RegistryPackage | RegistryRemote,
) => {
setSelectedInstallType(type);
setSelectedOption(option);
setInstallError(null);
setModalVisible(true);
};
// Handle modal close
const handleModalClose = () => {
setModalVisible(false);
setInstallError(null);
};
// Handle install submission
const handleInstallSubmit = async (payload: any) => {
try {
if (!onInstall || !selectedOption || !selectedInstallType) return;
setInstallError(null);
// Extract the ServerConfig from the payload
const config: ServerConfig = payload.config;
// Call onInstall with server data and config
onInstall(server, config);
setModalVisible(false);
} catch (err) {
console.error('Error installing server:', err);
setInstallError(t('errors.serverInstall'));
}
};
// Build initial data for ServerForm
const getInitialFormData = () => {
if (!selectedOption || !selectedInstallType) return null;
console.log('Building initial form data for:', selectedOption);
if (selectedInstallType === 'package' && 'identifier' in selectedOption) {
const pkg = selectedOption as RegistryPackage;
// Build environment variables from package definition
const env: Record<string, string> = {};
if (pkg.environmentVariables) {
pkg.environmentVariables.forEach((envVar) => {
env[envVar.name] = envVar.default || '';
});
}
const command = getCommand(pkg.registryType);
return {
name: server.name,
status: 'disconnected' as const,
config: {
type: 'stdio' as const,
command: command,
args: getArgs(command, pkg),
env: Object.keys(env).length > 0 ? env : undefined,
},
};
} else if (selectedInstallType === 'remote' && 'url' in selectedOption) {
const remote = selectedOption as RegistryRemote;
// Build headers from remote definition
const headers: Record<string, string> = {};
if (remote.headers) {
remote.headers.forEach((header) => {
headers[header.name] = header.default || header.value || '';
});
}
// Determine transport type - default to streamable-http for remotes
const transportType = remote.type === 'sse' ? ('sse' as const) : ('streamable-http' as const);
return {
name: server.name,
status: 'disconnected' as const,
config: {
type: transportType,
url: remote.url,
headers: Object.keys(headers).length > 0 ? headers : undefined,
},
};
}
return null;
};
// Render package option
const renderPackage = (pkg: RegistryPackage, index: number) => {
return (
<div
key={index}
className="border border-gray-200 rounded-lg p-4 mb-3 hover:border-blue-400 transition-colors"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h4 className="font-medium text-gray-900">{pkg.identifier}</h4>
{pkg.version && <p className="text-sm text-gray-500">Version: {pkg.version}</p>}
{pkg.runtimeHint && <p className="text-sm text-gray-600 mt-1">{pkg.runtimeHint}</p>}
</div>
<button
onClick={() => handleInstallClick('package', pkg)}
disabled={isInstalled || installing}
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
isInstalled
? 'bg-green-600 text-white cursor-default'
: installing
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isInstalled
? t('registry.installed')
: installing
? t('registry.installing')
: t('registry.install')}
</button>
</div>
{/* Package details */}
{pkg.registryType && (
<div className="text-sm text-gray-600 mb-2">
<span className="font-medium">Registry:</span> {pkg.registryType}
</div>
)}
{/* Transport type */}
{pkg.transport && (
<div className="text-sm text-gray-600 mb-2">
<span className="font-medium">Transport:</span> {pkg.transport.type}
{pkg.transport.url && <span className="ml-2 text-gray-500">({pkg.transport.url})</span>}
</div>
)}
{/* Environment Variables */}
{pkg.environmentVariables && pkg.environmentVariables.length > 0 && (
<div className="mt-3 border-t border-gray-200 pt-3">
<h5 className="text-sm font-medium text-gray-700 mb-2">
{t('registry.environmentVariables')}:
</h5>
<div className="space-y-2">
{pkg.environmentVariables.map((envVar, envIndex) => (
<div key={envIndex} className="text-sm">
<div className="flex items-start">
<span className="font-mono text-gray-900 font-medium">{envVar.name}</span>
{envVar.isRequired && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
{t('common.required')}
</span>
)}
{envVar.isSecret && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
{t('common.secret')}
</span>
)}
</div>
{envVar.description && <p className="text-gray-600 mt-1">{envVar.description}</p>}
{envVar.default && (
<p className="text-gray-500 mt-1">
<span className="font-medium">{t('common.default')}:</span>{' '}
<span className="font-mono">{envVar.default}</span>
</p>
)}
</div>
))}
</div>
</div>
)}
{/* Package Arguments */}
{pkg.packageArguments && pkg.packageArguments.length > 0 && (
<div className="mt-3 border-t border-gray-200 pt-3">
<h5 className="text-sm font-medium text-gray-700 mb-2">
{t('registry.packageArguments')}:
</h5>
<div className="space-y-2">
{pkg.packageArguments.map((arg, argIndex) => (
<div key={argIndex} className="text-sm">
<div className="flex items-start">
<span className="font-mono text-gray-900 font-medium">{arg.name}</span>
{arg.isRequired && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
{t('common.required')}
</span>
)}
{arg.isSecret && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
{t('common.secret')}
</span>
)}
{arg.isRepeated && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
{t('common.repeated')}
</span>
)}
</div>
{arg.description && <p className="text-gray-600 mt-1">{arg.description}</p>}
{arg.type && (
<p className="text-gray-500 mt-1">
<span className="font-medium">{t('common.type')}:</span>{' '}
<span className="font-mono">{arg.type}</span>
</p>
)}
{arg.default && (
<p className="text-gray-500 mt-1">
<span className="font-medium">{t('common.default')}:</span>{' '}
<span className="font-mono">{arg.default}</span>
</p>
)}
{arg.value && (
<p className="text-gray-500 mt-1">
<span className="font-medium">{t('common.value')}:</span>{' '}
<span className="font-mono">{arg.value}</span>
</p>
)}
{arg.valueHint && (
<p className="text-gray-500 mt-1">
<span className="font-medium">{t('common.valueHint')}:</span>{' '}
<span className="font-mono">{arg.valueHint}</span>
</p>
)}
{arg.choices && arg.choices.length > 0 && (
<p className="text-gray-500 mt-1">
<span className="font-medium">{t('common.choices')}:</span>{' '}
<span className="font-mono">{arg.choices.join(', ')}</span>
</p>
)}
</div>
))}
</div>
</div>
)}
</div>
);
};
// Render remote option
const renderRemote = (remote: RegistryRemote, index: number) => {
return (
<div
key={index}
className="border border-gray-200 rounded-lg p-4 mb-3 hover:border-blue-400 transition-colors"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h4 className="font-medium text-gray-900">{remote.type}</h4>
<p className="text-sm text-gray-600 mt-1 break-all">{remote.url}</p>
</div>
<button
onClick={() => handleInstallClick('remote', remote)}
disabled={isInstalled || installing}
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
isInstalled
? 'bg-green-600 text-white cursor-default'
: installing
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isInstalled
? t('registry.installed')
: installing
? t('registry.installing')
: t('registry.install')}
</button>
</div>
{/* Headers */}
{remote.headers && remote.headers.length > 0 && (
<div className="mt-3 border-t border-gray-200 pt-3">
<h5 className="text-sm font-medium text-gray-700 mb-2">{t('registry.headers')}:</h5>
<div className="space-y-2">
{remote.headers.map((header, headerIndex) => (
<div key={headerIndex} className="text-sm">
<div className="flex items-start">
<span className="font-mono text-gray-900 font-medium">{header.name}</span>
{header.isRequired && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
{t('common.required')}
</span>
)}
{header.isSecret && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
{t('common.secret')}
</span>
)}
</div>
{header.description && <p className="text-gray-600 mt-1">{header.description}</p>}
{header.value && (
<p className="text-gray-500 mt-1">
<span className="font-medium">{t('common.value')}:</span>{' '}
<span className="font-mono">{header.value}</span>
</p>
)}
{header.default && (
<p className="text-gray-500 mt-1">
<span className="font-medium">{t('common.default')}:</span>{' '}
<span className="font-mono">{header.default}</span>
</p>
)}
</div>
))}
</div>
</div>
)}
</div>
);
};
return (
<div className="bg-white shadow rounded-lg p-6">
{/* Header */}
<div className="mb-6">
<button
onClick={onBack}
className="flex items-center text-blue-600 hover:text-blue-800 mb-4 transition-colors"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
{t('registry.backToList')}
</button>
<div className="flex items-start space-x-4">
{/* Icon */}
{icon ? (
<img
src={icon.src}
alt={server.title}
className="w-20 h-20 rounded-lg object-cover flex-shrink-0"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
) : (
<div className="w-20 h-20 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center text-white text-3xl font-semibold flex-shrink-0">
M
</div>
)}
{/* Title and metadata */}
<div className="flex-1">
<h1 className="text-3xl font-bold text-gray-900 mb-2">{server.name}</h1>
<div className="flex flex-wrap gap-2 mb-3">
{officialMeta?.isLatest && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
{t('registry.latest')}
</span>
)}
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
v{server.version}
</span>
{officialMeta?.status && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800">
{officialMeta.status}
</span>
)}
{/* Dates */}
<span className="flex flex-wrap items-center gap-4 text-sm text-gray-600">
{officialMeta?.publishedAt && (
<div>
<span className="font-medium">{t('registry.published')}:</span>{' '}
{formatDate(officialMeta.publishedAt)}
</div>
)}
{officialMeta?.updatedAt && (
<div>
<span className="font-medium">{t('registry.updated')}:</span>{' '}
{formatDate(officialMeta.updatedAt)}
</div>
)}
</span>
</div>
</div>
</div>
</div>
{/* Description */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-3">{t('registry.description')}</h2>
<p className="text-gray-700 leading-relaxed whitespace-pre-wrap">{server.description}</p>
</div>
{/* Website */}
{server.websiteUrl && (
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-3">{t('registry.website')}</h2>
<a
href={server.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{server.websiteUrl}
</a>
</div>
)}
{/* Packages */}
{server.packages && server.packages.length > 0 && (
<div className="mb-6">
<button
onClick={() => toggleSection('packages')}
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
>
<span>
{t('registry.packages')} ({server.packages.length})
</span>
<svg
className={`w-5 h-5 transform transition-transform ${expandedSections.packages ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{expandedSections.packages && (
<div className="space-y-3">{server.packages.map(renderPackage)}</div>
)}
</div>
)}
{/* Remotes */}
{server.remotes && server.remotes.length > 0 && (
<div className="mb-6">
<button
onClick={() => toggleSection('remotes')}
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
>
<span>
{t('registry.remotes')} ({server.remotes.length})
</span>
<svg
className={`w-5 h-5 transform transition-transform ${expandedSections.remotes ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{expandedSections.remotes && (
<div className="space-y-3">{server.remotes.map(renderRemote)}</div>
)}
</div>
)}
{/* Repository */}
{server.repository && (
<div className="mb-6">
<button
onClick={() => toggleSection('repository')}
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
>
<span>{t('registry.repository')}</span>
<svg
className={`w-5 h-5 transform transition-transform ${expandedSections.repository ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{expandedSections.repository && (
<div className="border border-gray-200 rounded-lg p-4">
{server.repository.url && (
<div className="mb-2">
<span className="font-medium text-gray-700">URL:</span>{' '}
<a
href={server.repository.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline break-all"
>
{server.repository.url}
</a>
</div>
)}
{server.repository.source && (
<div className="mb-2">
<span className="font-medium text-gray-700">Source:</span>{' '}
{server.repository.source}
</div>
)}
{server.repository.subfolder && (
<div className="mb-2">
<span className="font-medium text-gray-700">Subfolder:</span>{' '}
{server.repository.subfolder}
</div>
)}
{server.repository.id && (
<div>
<span className="font-medium text-gray-700">ID:</span> {server.repository.id}
</div>
)}
</div>
)}
</div>
)}
{/* Install Modal */}
{modalVisible && selectedOption && selectedInstallType && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<ServerForm
onSubmit={handleInstallSubmit}
onCancel={handleModalClose}
modalTitle={t('registry.installServer', { name: server.title || server.name })}
formError={installError}
initialData={getInitialFormData()}
/>
</div>
)}
</div>
);
};
export default RegistryServerDetail;
// Helper function to determine command based on registry type
function getCommand(registryType: string): string {
// Map registry types to appropriate commands
switch (registryType.toLowerCase()) {
case 'pypi':
case 'python':
return 'uvx';
case 'npm':
case 'node':
return 'npx';
case 'oci':
case 'docker':
return 'docker';
default:
return '';
}
}
// Helper function to get appropriate args based on command type and package identifier
function getArgs(command: string, pkg: RegistryPackage): string[] {
const identifier = [pkg.identifier + (pkg.version ? `@${pkg.version}` : '')];
// Build package arguments if available
const packageArgs: string[] = [];
if (pkg.packageArguments && pkg.packageArguments.length > 0) {
pkg.packageArguments.forEach((arg) => {
// Add required arguments or arguments with default values
if (arg.isRequired || arg.default || arg.value) {
const argName = `--${arg.name}`;
// Priority: value > default > placeholder
const argValue = arg.value || arg.default || `\${${arg.name.toUpperCase()}}`;
packageArgs.push(argName, argValue);
}
});
}
// Map commands to appropriate argument patterns
switch (command.toLowerCase()) {
case 'uvx':
// For Python packages: uvx package-name --arg1 value1 --arg2 value2
return [...identifier, ...packageArgs];
case 'npx':
// For Node.js packages: npx package-name --arg1 value1 --arg2 value2
return [...identifier, ...packageArgs];
case 'docker': {
// add envs from environment variables if available
const envs: string[] = [];
if (pkg.environmentVariables) {
pkg.environmentVariables.forEach((env) => {
envs.push('-e', `${env.name}`);
});
}
// For Docker images: docker run -i package-name --arg1 value1 --arg2 value2
return ['run', '-i', '--rm', ...envs, ...identifier, ...packageArgs];
}
default:
// If no specific pattern is defined, return identifier with package args
return [...identifier, ...packageArgs];
}
}

View File

@@ -1,170 +1,263 @@
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Server } from '@/types'
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react'
import { StatusBadge } from '@/components/ui/Badge'
import ToolCard from '@/components/ui/ToolCard'
import PromptCard from '@/components/ui/PromptCard'
import DeleteDialog from '@/components/ui/DeleteDialog'
import { useToast } from '@/contexts/ToastContext'
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Server } from '@/types';
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react';
import { StatusBadge } from '@/components/ui/Badge';
import ToolCard from '@/components/ui/ToolCard';
import PromptCard from '@/components/ui/PromptCard';
import DeleteDialog from '@/components/ui/DeleteDialog';
import { useToast } from '@/contexts/ToastContext';
import { useSettingsData } from '@/hooks/useSettingsData';
interface ServerCardProps {
server: Server
onRemove: (serverName: string) => void
onEdit: (server: Server) => void
onToggle?: (server: Server, enabled: boolean) => Promise<boolean>
onRefresh?: () => void
server: Server;
onRemove: (serverName: string) => void;
onEdit: (server: Server) => void;
onToggle?: (server: Server, enabled: boolean) => Promise<boolean>;
onRefresh?: () => void;
onReload?: (server: Server) => Promise<boolean>;
}
const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCardProps) => {
const { t } = useTranslation()
const { showToast } = useToast()
const [isExpanded, setIsExpanded] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isToggling, setIsToggling] = useState(false)
const [showErrorPopover, setShowErrorPopover] = useState(false)
const [copied, setCopied] = useState(false)
const errorPopoverRef = useRef<HTMLDivElement>(null)
const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh, onReload }: ServerCardProps) => {
const { t } = useTranslation();
const { showToast } = useToast();
const [isExpanded, setIsExpanded] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isToggling, setIsToggling] = useState(false);
const [isReloading, setIsReloading] = useState(false);
const [showErrorPopover, setShowErrorPopover] = useState(false);
const [copied, setCopied] = useState(false);
const errorPopoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (errorPopoverRef.current && !errorPopoverRef.current.contains(event.target as Node)) {
setShowErrorPopover(false)
setShowErrorPopover(false);
}
}
};
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const { exportMCPSettings } = useSettingsData();
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation()
setShowDeleteDialog(true)
}
e.stopPropagation();
setShowDeleteDialog(true);
};
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation()
onEdit(server)
}
e.stopPropagation();
onEdit(server);
};
const handleToggle = async (e: React.MouseEvent) => {
e.stopPropagation()
if (isToggling || !onToggle) return
e.stopPropagation();
if (isToggling || !onToggle) return;
setIsToggling(true)
setIsToggling(true);
try {
await onToggle(server, !(server.enabled !== false))
await onToggle(server, !(server.enabled !== false));
} finally {
setIsToggling(false)
setIsToggling(false);
}
}
};
const handleReload = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isReloading || !onReload) return;
setIsReloading(true);
try {
const success = await onReload(server);
if (success) {
showToast(t('server.reloadSuccess') || 'Server reloaded successfully', 'success');
} else {
showToast(
t('server.reloadError', { serverName: server.name }) || 'Failed to reload server',
'error',
);
}
} finally {
setIsReloading(false);
}
};
const handleErrorIconClick = (e: React.MouseEvent) => {
e.stopPropagation()
setShowErrorPopover(!showErrorPopover)
}
e.stopPropagation();
setShowErrorPopover(!showErrorPopover);
};
const copyToClipboard = (e: React.MouseEvent) => {
e.stopPropagation()
if (!server.error) return
e.stopPropagation();
if (!server.error) return;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(server.error).then(() => {
setCopied(true)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
setTimeout(() => setCopied(false), 2000)
})
setCopied(true);
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
setTimeout(() => setCopied(false), 2000);
});
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = server.error
const textArea = document.createElement('textarea');
textArea.value = server.error;
// Avoid scrolling to bottom
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy')
setCopied(true)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
setTimeout(() => setCopied(false), 2000)
document.execCommand('copy');
setCopied(true);
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
setTimeout(() => setCopied(false), 2000);
} catch (err) {
showToast(t('common.copyFailed') || 'Copy failed', 'error')
console.error('Copy to clipboard failed:', err)
showToast(t('common.copyFailed') || 'Copy failed', 'error');
console.error('Copy to clipboard failed:', err);
}
document.body.removeChild(textArea)
document.body.removeChild(textArea);
}
}
};
const handleCopyServerConfig = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
const result = await exportMCPSettings(server.name);
if (!result || !result.success || !result.data) {
showToast(result?.message || t('common.copyFailed') || 'Copy failed', 'error');
return;
}
const configJson = JSON.stringify(result.data, null, 2);
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(configJson);
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea');
textArea.value = configJson;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
} catch (err) {
showToast(t('common.copyFailed') || 'Copy failed', 'error');
console.error('Copy to clipboard failed:', err);
}
document.body.removeChild(textArea);
}
} catch (error) {
console.error('Error copying server configuration:', error);
showToast(t('common.copyFailed') || 'Copy failed', 'error');
}
};
const handleConfirmDelete = () => {
onRemove(server.name)
setShowDeleteDialog(false)
}
onRemove(server.name);
setShowDeleteDialog(false);
};
const handleToolToggle = async (toolName: string, enabled: boolean) => {
try {
const { toggleTool } = await import('@/services/toolService')
const result = await toggleTool(server.name, toolName, enabled)
const { toggleTool } = await import('@/services/toolService');
const result = await toggleTool(server.name, toolName, enabled);
if (result.success) {
showToast(
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: toolName }),
'success'
)
'success',
);
// Trigger refresh to update the tool's state in the UI
if (onRefresh) {
onRefresh()
onRefresh();
}
} else {
showToast(result.error || t('tool.toggleFailed'), 'error')
showToast(result.error || t('tool.toggleFailed'), 'error');
}
} catch (error) {
console.error('Error toggling tool:', error)
showToast(t('tool.toggleFailed'), 'error')
console.error('Error toggling tool:', error);
showToast(t('tool.toggleFailed'), 'error');
}
}
};
const handlePromptToggle = async (promptName: string, enabled: boolean) => {
try {
const { togglePrompt } = await import('@/services/promptService')
const result = await togglePrompt(server.name, promptName, enabled)
const { togglePrompt } = await import('@/services/promptService');
const result = await togglePrompt(server.name, promptName, enabled);
if (result.success) {
showToast(
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: promptName }),
'success'
)
'success',
);
// Trigger refresh to update the prompt's state in the UI
if (onRefresh) {
onRefresh()
onRefresh();
}
} else {
showToast(result.error || t('tool.toggleFailed'), 'error')
showToast(result.error || t('tool.toggleFailed'), 'error');
}
} catch (error) {
console.error('Error toggling prompt:', error)
showToast(t('tool.toggleFailed'), 'error')
console.error('Error toggling prompt:', error);
showToast(t('tool.toggleFailed'), 'error');
}
}
};
const handleOAuthAuthorization = (e: React.MouseEvent) => {
e.stopPropagation();
// Open the OAuth authorization URL in a new window
if (server.oauth?.authorizationUrl) {
const width = 600;
const height = 700;
const left = window.screen.width / 2 - width / 2;
const top = window.screen.height / 2 - height / 2;
window.open(
server.oauth.authorizationUrl,
'OAuth Authorization',
`width=${width},height=${height},left=${left},top=${top}`,
);
showToast(t('status.oauthWindowOpened'), 'info');
}
};
return (
<>
<div className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}>
<div
className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}
>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-3">
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
<StatusBadge status={server.status} />
<h2
className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}
>
{server.name}
</h2>
<StatusBadge status={server.status} onAuthClick={handleOAuthAuthorization} />
{/* Tool count display */}
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm btn-primary">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
<path
fillRule="evenodd"
d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"
clipRule="evenodd"
/>
</svg>
<span>{server.tools?.length || 0} {t('server.tools')}</span>
<span>
{server.tools?.length || 0} {t('server.tools')}
</span>
</div>
{/* Prompt count display */}
@@ -173,7 +266,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" />
<path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767c.28.149.599.233.938.233h2l3 3v-3h2a2 2 0 002-2V9a2 2 0 00-2-2h-1z" />
</svg>
<span>{server.prompts?.length || 0} {t('server.prompts')}</span>
<span>
{server.prompts?.length || 0} {t('server.prompts')}
</span>
</div>
{server.error && (
@@ -196,25 +291,31 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
maxHeight: '300px',
overflowY: 'auto',
width: '480px',
transform: 'translateX(50%)'
transform: 'translateX(50%)',
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center sticky top-0 bg-white py-2 px-4 border-b border-gray-200 z-20 shadow-sm">
<div className="flex items-center space-x-2">
<h4 className="text-sm font-medium text-red-600">{t('server.errorDetails')}</h4>
<h4 className="text-sm font-medium text-red-600">
{t('server.errorDetails')}
</h4>
<button
onClick={copyToClipboard}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors btn-secondary"
title={t('common.copy')}
>
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
{copied ? (
<Check size={14} className="text-green-500" />
) : (
<Copy size={14} />
)}
</button>
</div>
<button
onClick={(e) => {
e.stopPropagation()
setShowErrorPopover(false)
e.stopPropagation();
setShowErrorPopover(false);
}}
className="text-gray-400 hover:text-gray-600"
>
@@ -222,7 +323,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
</button>
</div>
<div className="p-4 pt-2">
<pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">{server.error}</pre>
<pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">
{server.error}
</pre>
</div>
</div>
)}
@@ -230,6 +333,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
)}
</div>
<div className="flex space-x-2">
<button onClick={handleCopyServerConfig} className={`px-3 py-1 btn-secondary`}>
{t('server.copy')}
</button>
<button
onClick={handleEdit}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
@@ -239,22 +345,31 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<div className="flex items-center">
<button
onClick={handleToggle}
className={`px-3 py-1 text-sm rounded transition-colors ${isToggling
? 'bg-gray-200 text-gray-500'
: server.enabled !== false
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
}`}
disabled={isToggling}
className={`px-3 py-1 text-sm rounded transition-colors ${
isToggling
? 'bg-gray-200 text-gray-500'
: server.enabled !== false
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
}`}
disabled={isToggling || isReloading}
>
{isToggling
? t('common.processing')
: server.enabled !== false
? t('server.disable')
: t('server.enable')
}
: t('server.enable')}
</button>
</div>
{server.enabled !== false && onReload && (
<button
onClick={handleReload}
className="px-3 py-1 bg-purple-100 text-purple-800 rounded hover:bg-purple-200 text-sm btn-secondary disabled:opacity-70 disabled:cursor-not-allowed"
disabled={isReloading || isToggling}
>
{isReloading ? t('common.processing') : t('server.reload')}
</button>
)}
<button
onClick={handleRemove}
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm btn-danger"
@@ -271,10 +386,19 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<>
{server.tools && (
<div className="mt-6">
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h6>
<h6
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}
>
{t('server.tools')}
</h6>
<div className="space-y-4">
{server.tools.map((tool, index) => (
<ToolCard key={index} server={server.name} tool={tool} onToggle={handleToolToggle} />
<ToolCard
key={index}
server={server.name}
tool={tool}
onToggle={handleToolToggle}
/>
))}
</div>
</div>
@@ -282,14 +406,18 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
{server.prompts && (
<div className="mt-6">
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.prompts')}</h6>
<h6
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}
>
{t('server.prompts')}
</h6>
<div className="space-y-4">
{server.prompts.map((prompt, index) => (
<PromptCard
key={index}
server={server.name}
prompt={prompt}
onToggle={handlePromptToggle}
<PromptCard
key={index}
server={server.name}
prompt={prompt}
onToggle={handlePromptToggle}
/>
))}
</div>
@@ -306,7 +434,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
serverName={server.name}
/>
</>
)
}
);
};
export default ServerCard
export default ServerCard;

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IGroupServerConfig, Server, Tool } from '@/types';
import { cn } from '@/utils/cn';
import { useSettingsData } from '@/hooks/useSettingsData';
interface ServerToolConfigProps {
servers: Server[];
@@ -17,6 +18,7 @@ export const ServerToolConfig: React.FC<ServerToolConfigProps> = ({
className
}) => {
const { t } = useTranslation();
const { nameSeparator } = useSettingsData();
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
// Normalize current value to IGroupServerConfig[] format
@@ -116,7 +118,7 @@ export const ServerToolConfig: React.FC<ServerToolConfigProps> = ({
const server = availableServers.find(s => s.name === serverName);
if (!server) return;
const allToolNames = server.tools?.map(tool => tool.name.replace(`${serverName}-`, '')) || [];
const allToolNames = server.tools?.map(tool => tool.name.replace(`${serverName}${nameSeparator}`, '')) || [];
const serverConfig = normalizedValue.find(config => config.name === serverName);
if (!serverConfig) {
@@ -279,7 +281,7 @@ export const ServerToolConfig: React.FC<ServerToolConfigProps> = ({
<div className="grid grid-cols-1 gap-2 max-h-32 overflow-y-auto">
{serverTools.map(tool => {
const toolName = tool.name.replace(`${server.name}-`, '');
const toolName = tool.name.replace(`${server.name}${nameSeparator}`, '');
const isToolChecked = isToolSelected(server.name, toolName);
return (

View File

@@ -0,0 +1,96 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { User, IUser } from '@/types';
import { Edit, Trash } from '@/components/icons/LucideIcons';
import DeleteDialog from '@/components/ui/DeleteDialog';
interface UserCardProps {
user: User;
currentUser: IUser | null;
onEdit: (user: User) => void;
onDelete: (username: string) => void;
}
const UserCard: React.FC<UserCardProps> = ({ user, currentUser, onEdit, onDelete }) => {
const { t } = useTranslation();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const handleDeleteClick = () => {
setShowDeleteDialog(true);
};
const handleConfirmDelete = () => {
onDelete(user.username);
setShowDeleteDialog(false);
};
const isCurrentUser = currentUser?.username === user.username;
const canDelete = !isCurrentUser; // Can't delete own account
return (
<div className="bg-white shadow rounded-lg p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white font-medium text-sm">
{user.username.charAt(0).toUpperCase()}
</span>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">
{user.username}
{isCurrentUser && (
<span className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded">
{t('users.currentUser')}
</span>
)}
</h3>
<div className="flex items-center space-x-2">
<span
className={`px-2 py-1 text-xs font-medium rounded ${user.isAdmin
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{user.isAdmin ? t('users.admin') : t('users.user')}
</span>
</div>
</div>
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => onEdit(user)}
className="text-gray-500 hover:text-gray-700"
title={t('users.edit')}
>
<Edit size={18} />
</button>
{canDelete && (
<button
onClick={handleDeleteClick}
className="text-gray-500 hover:text-red-600"
title={t('users.delete')}
>
<Trash size={18} />
</button>
)}
</div>
</div>
<DeleteDialog
isOpen={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
onConfirm={handleConfirmDelete}
serverName={user.username}
isGroup={false}
isUser={true}
/>
</div>
);
};
export default UserCard;

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export const DiscordIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
const { t } = useTranslation();
return (
<svg
role="img"
@@ -11,7 +13,7 @@ export const DiscordIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
fill="currentColor"
{...props}
>
<title>Discord</title>
<title>{t('common.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>
);

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export const GitHubIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
const { t } = useTranslation();
return (
<svg
role="img"
@@ -11,7 +13,7 @@ export const GitHubIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
fill="currentColor"
{...props}
>
<title>GitHub</title>
<title>{t('common.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>
);

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export const SponsorIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
const { t } = useTranslation();
return (
<svg
role="img"
@@ -11,7 +13,7 @@ export const SponsorIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
fill="currentColor"
{...props}
>
<title>Sponsor</title>
<title>{t('sponsor.label')}</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>
);

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export const WeChatIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
const { t } = useTranslation();
return (
<svg
role="img"
@@ -11,7 +13,7 @@ export const WeChatIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
fill="currentColor"
{...props}
>
<title>WeChat</title>
<title>{t('common.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>
);

View File

@@ -13,24 +13,21 @@ type BadgeProps = {
const badgeVariants = {
default: 'bg-blue-500 text-white hover:bg-blue-600',
secondary: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600',
outline: 'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
secondary:
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600',
outline:
'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
destructive: 'bg-red-500 text-white hover:bg-red-600',
};
export function Badge({
children,
variant = 'default',
className,
onClick
}: BadgeProps) {
export function Badge({ children, variant = 'default', className, onClick }: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
badgeVariants[variant],
onClick ? 'cursor-pointer' : '',
className
className,
)}
onClick={onClick}
>
@@ -40,27 +37,40 @@ export function Badge({
}
// For backward compatibility with existing code
export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
export const StatusBadge = ({
status,
onAuthClick,
}: {
status: 'connected' | 'disconnected' | 'connecting' | 'oauth_required';
onAuthClick?: (e: React.MouseEvent) => void;
}) => {
const { t } = useTranslation();
const colors = {
connecting: 'status-badge-connecting',
connected: 'status-badge-online',
disconnected: 'status-badge-offline',
oauth_required: 'status-badge-oauth-required',
};
// Map status to translation keys
const statusTranslations = {
connected: 'status.online',
disconnected: 'status.offline',
connecting: 'status.connecting'
connecting: 'status.connecting',
oauth_required: 'status.oauthRequired',
};
const isOAuthRequired = status === 'oauth_required';
return (
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${colors[status]}`}
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${colors[status]} ${isOAuthRequired && onAuthClick ? 'cursor-pointer hover:opacity-80' : ''}`}
onClick={isOAuthRequired && onAuthClick ? (e) => onAuthClick(e) : undefined}
title={isOAuthRequired ? t('status.clickToAuthorize') : undefined}
>
{isOAuthRequired && '🔐 '}
{t(statusTranslations[status] || status)}
</span>
);
};
};

View File

@@ -0,0 +1,78 @@
import React from 'react';
interface CursorPaginationProps {
currentPage: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
onNextPage: () => void;
onPreviousPage: () => void;
}
const CursorPagination: React.FC<CursorPaginationProps> = ({
currentPage,
hasNextPage,
hasPreviousPage,
onNextPage,
onPreviousPage,
}) => {
return (
<div className="flex items-center justify-center space-x-2 my-6">
{/* Previous button */}
<button
onClick={onPreviousPage}
disabled={!hasPreviousPage}
className={`px-4 py-2 rounded transition-all duration-200 ${
hasPreviousPage
? 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 inline-block mr-1"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
Prev
</button>
{/* Current page indicator */}
<span className="px-4 py-2 bg-blue-500 text-white rounded btn-primary">
Page {currentPage}
</span>
{/* Next button */}
<button
onClick={onNextPage}
disabled={!hasNextPage}
className={`px-4 py-2 rounded transition-all duration-200 ${
hasNextPage
? 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
Next
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 inline-block ml-1"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
);
};
export default CursorPagination;

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
interface DefaultPasswordWarningModalProps {
isOpen: boolean;
onClose: () => void;
}
const DefaultPasswordWarningModal: React.FC<DefaultPasswordWarningModalProps> = ({
isOpen,
onClose,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
if (!isOpen) return null;
const handleGoToSettings = () => {
onClose();
navigate('/settings');
// Auto-scroll to password section after a small delay to ensure page is loaded
setTimeout(() => {
const passwordSection = document.querySelector('[data-section="password"]');
if (passwordSection) {
passwordSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
// If the section is collapsed, expand it
const clickTarget = passwordSection.querySelector('[role="button"]');
if (clickTarget && !passwordSection.querySelector('.mt-4')) {
(clickTarget as HTMLElement).click();
}
}
}, 100);
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
return (
<div
className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4"
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full transform transition-all duration-200 ease-out"
role="dialog"
aria-modal="true"
aria-labelledby="password-warning-title"
aria-describedby="password-warning-message"
>
<div className="p-6">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<svg
className="w-6 h-6 text-yellow-600 dark:text-yellow-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<div className="flex-1">
<h3
id="password-warning-title"
className="text-lg font-medium text-gray-900 dark:text-white mb-2"
>
{t('auth.defaultPasswordWarning')}
</h3>
<p
id="password-warning-message"
className="text-gray-600 dark:text-gray-300 leading-relaxed"
>
{t('auth.defaultPasswordMessage')}
</p>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors duration-150 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleGoToSettings}
className="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-md transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500 btn-warning"
autoFocus
>
{t('auth.goToSettings')}
</button>
</div>
</div>
</div>
</div>
);
};
export default DefaultPasswordWarningModal;

View File

@@ -21,7 +21,14 @@ interface DynamicFormProps {
title?: string; // Optional title to display instead of default parameters title
}
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey, title }) => {
const DynamicForm: React.FC<DynamicFormProps> = ({
schema,
onSubmit,
onCancel,
loading = false,
storageKey,
title,
}) => {
const { t } = useTranslation();
const [formValues, setFormValues] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
@@ -40,9 +47,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
description: obj.description,
enum: obj.enum,
default: obj.default,
properties: obj.properties ? Object.fromEntries(
Object.entries(obj.properties).map(([key, value]) => [key, convertProperty(value)])
) : undefined,
properties: obj.properties
? Object.fromEntries(
Object.entries(obj.properties).map(([key, value]) => [
key,
convertProperty(value),
]),
)
: undefined,
required: obj.required,
items: obj.items ? convertProperty(obj.items) : undefined,
};
@@ -52,9 +64,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
return {
type: schema.type,
properties: schema.properties ? Object.fromEntries(
Object.entries(schema.properties).map(([key, value]) => [key, convertProperty(value)])
) : undefined,
properties: schema.properties
? Object.fromEntries(
Object.entries(schema.properties).map(([key, value]) => [
key,
convertProperty(value),
]),
)
: undefined,
required: schema.required,
};
};
@@ -167,7 +184,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
};
const handleInputChange = (path: string, value: any) => {
setFormValues(prev => {
setFormValues((prev) => {
const newValues = { ...prev };
const keys = path.split('.');
let current = newValues;
@@ -195,7 +212,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
// Clear error for this field
if (errors[path]) {
setErrors(prev => {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[path];
return newErrors;
@@ -209,10 +226,16 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
if (schema.type === 'object' && schema.properties) {
Object.entries(schema.properties).forEach(([key, propSchema]) => {
const fullPath = path ? `${path}.${key}` : key;
const value = getNestedValue(values, fullPath);
const value = values?.[key];
// Check required fields
if (schema.required?.includes(key) && (value === undefined || value === null || value === '')) {
if (
schema.required?.includes(key) &&
(value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0))
) {
newErrors[fullPath] = `${key} is required`;
return;
}
@@ -223,7 +246,10 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
newErrors[fullPath] = `${key} must be a string`;
} else if (propSchema.type === 'number' && typeof value !== 'number') {
newErrors[fullPath] = `${key} must be a number`;
} else if (propSchema.type === 'integer' && (!Number.isInteger(value) || typeof value !== 'number')) {
} else if (
propSchema.type === 'integer' &&
(!Number.isInteger(value) || typeof value !== 'number')
) {
newErrors[fullPath] = `${key} must be an integer`;
} else if (propSchema.type === 'boolean' && typeof value !== 'boolean') {
newErrors[fullPath] = `${key} must be a boolean`;
@@ -260,7 +286,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
return path.split('.').reduce((current, key) => current?.[key], obj);
};
const renderObjectField = (key: string, schema: JsonSchema, currentValue: any, onChange: (value: any) => void): React.ReactNode => {
const renderObjectField = (
key: string,
schema: JsonSchema,
currentValue: any,
onChange: (value: any) => void,
): React.ReactNode => {
const value = currentValue?.[key];
if (schema.type === 'string') {
@@ -299,7 +330,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
step={schema.type === 'integer' ? '1' : 'any'}
value={value ?? ''}
onChange={(e) => {
const val = e.target.value === '' ? '' : schema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
const val =
e.target.value === ''
? ''
: schema.type === 'integer'
? parseInt(e.target.value)
: parseFloat(e.target.value);
onChange(val);
}}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
@@ -333,7 +369,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
const renderField = (key: string, propSchema: JsonSchema, path: string = ''): React.ReactNode => {
const fullPath = path ? `${path}.${key}` : key;
const value = getNestedValue(formValues, fullPath);
const error = errors[fullPath]; // Handle array type
const error = errors[fullPath]; // Handle array type
if (propSchema.type === 'array') {
const arrayValue = getNestedValue(formValues, fullPath) || [];
@@ -341,7 +377,11 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
{(path
? getNestedValue(jsonSchema, path)?.required?.includes(key)
: jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -349,9 +389,11 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div className="border border-gray-200 rounded-md p-3 bg-gray-50">
{arrayValue.map((item: any, index: number) => (
<div key={index} className="mb-3 p-3 bg-white border rounded-md">
<div key={index} className="mb-3 p-3 bg-white border border-gray-200 rounded-md">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-600">{t('tool.item', { index: index + 1 })}</span>
<span className="text-sm font-medium text-gray-600">
{t('tool.item', { index: index + 1 })}
</span>
<button
type="button"
onClick={() => {
@@ -388,7 +430,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={objKey}>
<label className="block text-xs font-medium text-gray-600 mb-1">
{objKey}
{propSchema.items?.required?.includes(objKey) && <span className="text-status-red ml-1">*</span>}
{propSchema.items?.required?.includes(objKey) && (
<span className="text-status-red ml-1">*</span>
)}
</label>
{renderObjectField(objKey, objSchema as JsonSchema, item, (newValue) => {
const newArray = [...arrayValue];
@@ -429,7 +473,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} // Handle object type
} // Handle object type
if (propSchema.type === 'object') {
if (propSchema.properties) {
// Object with defined properties - render as nested form
@@ -437,16 +481,20 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
{(path
? getNestedValue(jsonSchema, path)?.required?.includes(key)
: jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
)}
<div className="border border-gray-200 rounded-md p-4 bg-gray-50">
{Object.entries(propSchema.properties).map(([objKey, objSchema]) => (
renderField(objKey, objSchema as JsonSchema, fullPath)
))}
{Object.entries(propSchema.properties).map(([objKey, objSchema]) =>
renderField(objKey, objSchema as JsonSchema, fullPath),
)}
</div>
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
@@ -458,7 +506,11 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
{(path
? getNestedValue(jsonSchema, path)?.required?.includes(key)
: jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
<span className="text-xs text-gray-500 ml-1">(JSON object)</span>
</label>
{propSchema.description && (
@@ -483,13 +535,16 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
</div>
);
}
} if (propSchema.type === 'string') {
}
if (propSchema.type === 'string') {
if (propSchema.enum) {
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -514,7 +569,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -529,12 +586,15 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
</div>
);
}
} if (propSchema.type === 'number' || propSchema.type === 'integer') {
}
if (propSchema.type === 'number' || propSchema.type === 'integer') {
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -544,7 +604,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
step={propSchema.type === 'integer' ? '1' : 'any'}
value={value !== undefined && value !== null ? value : ''}
onChange={(e) => {
const val = e.target.value === '' ? '' : propSchema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
const val =
e.target.value === ''
? ''
: propSchema.type === 'integer'
? parseInt(e.target.value)
: parseFloat(e.target.value);
handleInputChange(fullPath, val);
}}
className={`w-full border rounded-md px-3 py-2 form-input ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
@@ -566,7 +631,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
/>
<label className="ml-2 block text-sm text-gray-700">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label>
</div>
{propSchema.description && (
@@ -575,12 +642,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} // For other types, show as text input with description
} // For other types, show as text input with description
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
<span className="text-xs text-gray-500 ml-1">({propSchema.type})</span>
</label>
{propSchema.description && (
@@ -631,20 +700,22 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<button
type="button"
onClick={switchToFormMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${!isJsonMode
? 'bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
!isJsonMode
? 'bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`}
>
{t('tool.formMode')}
</button>
<button
type="button"
onClick={switchToJsonMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${isJsonMode
? 'px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
isJsonMode
? 'px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`}
>
{t('tool.jsonMode')}
</button>
@@ -662,8 +733,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
value={jsonText}
onChange={(e) => handleJsonTextChange(e.target.value)}
placeholder={`{\n "key": "value"\n}`}
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y form-input ${jsonError ? 'border-red-500' : 'border-gray-300'
} focus:outline-none focus:ring-2 focus:ring-blue-500`}
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y form-input ${
jsonError ? 'border-red-500' : 'border-gray-300'
} focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
{jsonError && <p className="text-status-red text-xs mt-1">{jsonError}</p>}
</div>
@@ -696,7 +768,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
/* Form Mode */
<form onSubmit={handleSubmit} className="space-y-4">
{Object.entries(jsonSchema.properties || {}).map(([key, propSchema]) =>
renderField(key, propSchema)
renderField(key, propSchema),
)}
<div className="flex justify-end space-x-2 pt-4">

View File

@@ -10,7 +10,9 @@ const LanguageSwitch: React.FC = () => {
// Available languages
const availableLanguages = [
{ code: 'en', label: 'English' },
{ code: 'zh', label: '中文' }
{ code: 'zh', label: '中文' },
{ code: 'fr', label: 'Français' },
{ code: 'tr', label: 'Türkçe' }
];
// Update current language when it changes

View File

@@ -0,0 +1,161 @@
import React, { useState, useRef, useEffect } from 'react';
import { Check, ChevronDown, X } from 'lucide-react';
interface MultiSelectProps {
options: { value: string; label: string }[];
selected: string[];
onChange: (selected: string[]) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}
export const MultiSelect: React.FC<MultiSelectProps> = ({
options,
selected,
onChange,
placeholder = 'Select items...',
disabled = false,
className = '',
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
setSearchTerm('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filteredOptions = options.filter((option) =>
option.label.toLowerCase().includes(searchTerm.toLowerCase()),
);
const handleToggleOption = (value: string) => {
if (disabled) return;
const newSelected = selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value];
onChange(newSelected);
};
const handleRemoveItem = (value: string, e: React.MouseEvent) => {
e.stopPropagation();
if (disabled) return;
onChange(selected.filter((item) => item !== value));
};
const handleToggleDropdown = () => {
if (disabled) return;
setIsOpen(!isOpen);
if (!isOpen) {
setTimeout(() => inputRef.current?.focus(), 0);
}
};
const getSelectedLabels = () => {
return selected
.map((value) => options.find((opt) => opt.value === value)?.label || value)
.filter(Boolean);
};
return (
<div ref={dropdownRef} className={`relative ${className}`}>
{/* Selected items display */}
<div
onClick={handleToggleDropdown}
className={`
min-h-[38px] w-full px-3 py-1.5 border rounded-md shadow-sm
flex flex-wrap items-center gap-1.5 cursor-pointer
transition-all duration-200
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white hover:border-blue-400'}
${isOpen ? 'border-blue-500 ring-1 ring-blue-500' : 'border-gray-300'}
`}
>
{selected.length > 0 ? (
<>
{getSelectedLabels().map((label, index) => (
<span
key={selected[index]}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
>
{label}
{!disabled && (
<button
type="button"
onClick={(e) => handleRemoveItem(selected[index], e)}
className="ml-1 hover:bg-blue-200 rounded-full p-0.5 transition-colors"
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
</>
) : (
<span className="text-gray-400 text-sm">{placeholder}</span>
)}
<div className="flex-1"></div>
<ChevronDown
className={`h-4 w-4 text-gray-400 transition-transform duration-200 ${isOpen ? 'transform rotate-180' : ''}`}
/>
</div>
{/* Dropdown menu */}
{isOpen && !disabled && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-hidden">
{/* Search input */}
<div className="p-2 border-b border-gray-200">
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
onClick={(e) => e.stopPropagation()}
/>
</div>
{/* Options list */}
<div className="max-h-48 overflow-y-auto">
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => {
const isSelected = selected.includes(option.value);
return (
<div
key={option.value}
onClick={() => handleToggleOption(option.value)}
className={`
px-3 py-2 cursor-pointer flex items-center justify-between
transition-colors duration-150
${isSelected ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'}
`}
>
<span className="text-sm">{option.label}</span>
{isSelected && <Check className="h-4 w-4 text-blue-600" />}
</div>
);
})
) : (
<div className="px-3 py-2 text-sm text-gray-500 text-center">
{searchTerm ? 'No results found' : 'No options available'}
</div>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -1,150 +1,174 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Prompt } from '@/types'
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check } from '@/components/icons/LucideIcons'
import { Switch } from './ToggleGroup'
import { getPrompt, PromptCallResult } from '@/services/promptService'
import DynamicForm from './DynamicForm'
import PromptResult from './PromptResult'
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Prompt } from '@/types';
import {
ChevronDown,
ChevronRight,
Play,
Loader,
Edit,
Check,
} from '@/components/icons/LucideIcons';
import { Switch } from './ToggleGroup';
import { getPrompt, updatePromptDescription, PromptCallResult } from '@/services/promptService';
import { useSettingsData } from '@/hooks/useSettingsData';
import DynamicForm from './DynamicForm';
import PromptResult from './PromptResult';
import { useToast } from '@/contexts/ToastContext';
interface PromptCardProps {
server: string
prompt: Prompt
onToggle?: (promptName: string, enabled: boolean) => void
onDescriptionUpdate?: (promptName: string, description: string) => void
server: string;
prompt: Prompt;
onToggle?: (promptName: string, enabled: boolean) => void;
onDescriptionUpdate?: (promptName: string, description: string) => void;
}
const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCardProps) => {
const { t } = useTranslation()
const [isExpanded, setIsExpanded] = useState(false)
const [showRunForm, setShowRunForm] = useState(false)
const [isRunning, setIsRunning] = useState(false)
const [result, setResult] = useState<PromptCallResult | null>(null)
const [isEditingDescription, setIsEditingDescription] = useState(false)
const [customDescription, setCustomDescription] = useState(prompt.description || '')
const descriptionInputRef = useRef<HTMLInputElement>(null)
const descriptionTextRef = useRef<HTMLSpanElement>(null)
const [textWidth, setTextWidth] = useState<number>(0)
const { t } = useTranslation();
const { showToast } = useToast();
const { nameSeparator } = useSettingsData();
const [isExpanded, setIsExpanded] = useState(false);
const [showRunForm, setShowRunForm] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [result, setResult] = useState<PromptCallResult | null>(null);
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [customDescription, setCustomDescription] = useState(prompt.description || '');
const descriptionInputRef = useRef<HTMLInputElement>(null);
const descriptionTextRef = useRef<HTMLSpanElement>(null);
const [textWidth, setTextWidth] = useState<number>(0);
// Focus the input when editing mode is activated
useEffect(() => {
if (isEditingDescription && descriptionInputRef.current) {
descriptionInputRef.current.focus()
descriptionInputRef.current.focus();
// Set input width to match text width
if (textWidth > 0) {
descriptionInputRef.current.style.width = `${textWidth + 20}px` // Add some padding
descriptionInputRef.current.style.width = `${textWidth + 20}px`; // Add some padding
}
}
}, [isEditingDescription, textWidth])
}, [isEditingDescription, textWidth]);
// Measure text width when not editing
useEffect(() => {
if (!isEditingDescription && descriptionTextRef.current) {
setTextWidth(descriptionTextRef.current.offsetWidth)
setTextWidth(descriptionTextRef.current.offsetWidth);
}
}, [isEditingDescription, customDescription])
}, [isEditingDescription, customDescription]);
// Generate a unique key for localStorage based on prompt name and server
const getStorageKey = useCallback(() => {
return `mcphub_prompt_form_${server ? `${server}_` : ''}${prompt.name}`
}, [prompt.name, server])
return `mcphub_prompt_form_${server ? `${server}_` : ''}${prompt.name}`;
}, [prompt.name, server]);
// Clear form data from localStorage
const clearStoredFormData = useCallback(() => {
localStorage.removeItem(getStorageKey())
}, [getStorageKey])
localStorage.removeItem(getStorageKey());
}, [getStorageKey]);
const handleToggle = (enabled: boolean) => {
if (onToggle) {
onToggle(prompt.name, enabled)
onToggle(prompt.name, enabled);
}
}
};
const handleDescriptionEdit = () => {
setIsEditingDescription(true)
}
setIsEditingDescription(true);
};
const handleDescriptionSave = async () => {
// For now, we'll just update the local state
// In a real implementation, you would call an API to update the description
setIsEditingDescription(false)
if (onDescriptionUpdate) {
onDescriptionUpdate(prompt.name, customDescription)
setIsEditingDescription(false);
try {
const result = await updatePromptDescription(server, prompt.name, customDescription);
if (result.success) {
showToast(t('prompt.descriptionUpdateSuccess'), 'success');
if (onDescriptionUpdate) {
onDescriptionUpdate(prompt.name, customDescription);
}
} else {
showToast(result.error || t('prompt.descriptionUpdateFailed'), 'error');
// Revert to original description on failure
setCustomDescription(prompt.description || '');
}
} catch (error) {
console.error('Error updating prompt description:', error);
showToast(t('prompt.descriptionUpdateFailed'), 'error');
// Revert to original description on failure
setCustomDescription(prompt.description || '');
}
}
};
const handleDescriptionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCustomDescription(e.target.value)
}
setCustomDescription(e.target.value);
};
const handleDescriptionKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleDescriptionSave()
handleDescriptionSave();
} else if (e.key === 'Escape') {
setCustomDescription(prompt.description || '')
setIsEditingDescription(false)
setCustomDescription(prompt.description || '');
setIsEditingDescription(false);
}
}
};
const handleGetPrompt = async (arguments_: Record<string, any>) => {
setIsRunning(true)
setIsRunning(true);
try {
const result = await getPrompt({ promptName: prompt.name, arguments: arguments_ }, server)
console.log('GetPrompt result:', result)
const result = await getPrompt({ promptName: prompt.name, arguments: arguments_ }, server);
console.log('GetPrompt result:', result);
setResult({
success: result.success,
data: result.data,
error: result.error
})
error: result.error,
});
// Clear form data on successful submission
// clearStoredFormData()
} catch (error) {
setResult({
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
})
});
} finally {
setIsRunning(false)
setIsRunning(false);
}
}
};
const handleCancelRun = () => {
setShowRunForm(false)
setShowRunForm(false);
// Clear form data when cancelled
clearStoredFormData()
setResult(null)
}
clearStoredFormData();
setResult(null);
};
const handleCloseResult = () => {
setResult(null)
}
setResult(null);
};
// Convert prompt arguments to ToolInputSchema format for DynamicForm
const convertToSchema = () => {
if (!prompt.arguments || prompt.arguments.length === 0) {
return { type: 'object', properties: {}, required: [] }
return { type: 'object', properties: {}, required: [] };
}
const properties: Record<string, any> = {}
const required: string[] = []
const properties: Record<string, any> = {};
const required: string[] = [];
prompt.arguments.forEach(arg => {
prompt.arguments.forEach((arg) => {
properties[arg.name] = {
type: 'string', // Default to string for prompts
description: arg.description || ''
}
description: arg.description || '',
};
if (arg.required) {
required.push(arg.name)
required.push(arg.name);
}
})
});
return {
type: 'object',
properties,
required
}
}
required,
};
};
return (
<div className="bg-white border border-gray-200 shadow rounded-lg p-4 mb-4">
@@ -154,11 +178,9 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
>
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900">
{prompt.name.replace(server + '-', '')}
{prompt.name.replace(server + nameSeparator, '')}
{prompt.title && (
<span className="ml-2 text-sm font-normal text-gray-600">
{prompt.title}
</span>
<span className="ml-2 text-sm font-normal text-gray-600">{prompt.title}</span>
)}
<span className="ml-2 text-sm font-normal text-gray-500 inline-flex items-center">
{isEditingDescription ? (
@@ -173,14 +195,14 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
onClick={(e) => e.stopPropagation()}
style={{
minWidth: '100px',
width: textWidth > 0 ? `${textWidth + 20}px` : 'auto'
width: textWidth > 0 ? `${textWidth + 20}px` : 'auto',
}}
/>
<button
className="ml-2 p-1 text-green-600 hover:text-green-800 cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation()
handleDescriptionSave()
e.stopPropagation();
handleDescriptionSave();
}}
>
<Check size={16} />
@@ -188,12 +210,14 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
</>
) : (
<>
<span ref={descriptionTextRef}>{customDescription || t('tool.noDescription')}</span>
<span ref={descriptionTextRef}>
{customDescription || t('tool.noDescription')}
</span>
<button
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation()
handleDescriptionEdit()
e.stopPropagation();
handleDescriptionEdit();
}}
>
<Edit size={14} />
@@ -204,10 +228,7 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
</h3>
</div>
<div className="flex items-center space-x-2">
<div
className="flex items-center space-x-2"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center space-x-2" onClick={(e) => e.stopPropagation()}>
{prompt.enabled !== undefined && (
<Switch
checked={prompt.enabled}
@@ -218,18 +239,14 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
</div>
<button
onClick={(e) => {
e.stopPropagation()
setIsExpanded(true) // Ensure card is expanded when showing run form
setShowRunForm(true)
e.stopPropagation();
setIsExpanded(true); // Ensure card is expanded when showing run form
setShowRunForm(true);
}}
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors btn-primary"
disabled={isRunning || !prompt.enabled}
>
{isRunning ? (
<Loader size={14} className="animate-spin" />
) : (
<Play size={14} />
)}
{isRunning ? <Loader size={14} className="animate-spin" /> : <Play size={14} />}
<span>{isRunning ? t('tool.running') : t('tool.run')}</span>
</button>
<button className="text-gray-400 hover:text-gray-600">
@@ -249,7 +266,9 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
onCancel={handleCancelRun}
loading={isRunning}
storageKey={getStorageKey()}
title={t('prompt.runPromptWithName', { name: prompt.name.replace(server + '-', '') })}
title={t('prompt.runPromptWithName', {
name: prompt.name.replace(server + nameSeparator, ''),
})}
/>
{/* Prompt Result */}
{result && (
@@ -276,9 +295,7 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
<p className="text-sm text-gray-600 mt-1">{arg.description}</p>
)}
</div>
<div className="text-xs text-gray-500 ml-2">
{arg.title || ''}
</div>
<div className="text-xs text-gray-500 ml-2">{arg.title || ''}</div>
</div>
))}
</div>
@@ -294,7 +311,7 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
</div>
)}
</div>
)
}
);
};
export default PromptCard
export default PromptCard;

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Check, X } from 'lucide-react';
import { cn } from '@/utils/cn';
@@ -19,6 +20,8 @@ const Toast: React.FC<ToastProps> = ({
onClose,
visible
}) => {
const { t } = useTranslation();
useEffect(() => {
if (visible) {
const timer = setTimeout(() => {
@@ -83,7 +86,7 @@ const Toast: React.FC<ToastProps> = ({
`hover:bg-${type}-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-${type}-500`
)}
>
<span className="sr-only">Dismiss</span>
<span className="sr-only">{t('common.dismiss')}</span>
<X className="h-5 w-5" />
</button>
</div>

View File

@@ -1,8 +1,10 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Tool } from '@/types'
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check } from '@/components/icons/LucideIcons'
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check, Copy } from '@/components/icons/LucideIcons'
import { callTool, ToolCallResult, updateToolDescription } from '@/services/toolService'
import { useSettingsData } from '@/hooks/useSettingsData'
import { useToast } from '@/contexts/ToastContext'
import { Switch } from './ToggleGroup'
import DynamicForm from './DynamicForm'
import ToolResult from './ToolResult'
@@ -25,6 +27,8 @@ function isEmptyValue(value: any): boolean {
const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps) => {
const { t } = useTranslation()
const { showToast } = useToast()
const { nameSeparator } = useSettingsData()
const [isExpanded, setIsExpanded] = useState(false)
const [showRunForm, setShowRunForm] = useState(false)
const [isRunning, setIsRunning] = useState(false)
@@ -34,6 +38,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
const descriptionInputRef = useRef<HTMLInputElement>(null)
const descriptionTextRef = useRef<HTMLSpanElement>(null)
const [textWidth, setTextWidth] = useState<number>(0)
const [copiedToolName, setCopiedToolName] = useState(false)
// Focus the input when editing mode is activated
useEffect(() => {
@@ -106,6 +111,41 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
}
}
const handleCopyToolName = async (e: React.MouseEvent) => {
e.stopPropagation()
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(tool.name)
setCopiedToolName(true)
showToast(t('common.copySuccess'), 'success')
setTimeout(() => setCopiedToolName(false), 2000)
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = tool.name
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
setCopiedToolName(true)
showToast(t('common.copySuccess'), 'success')
setTimeout(() => setCopiedToolName(false), 2000)
} catch (err) {
showToast(t('common.copyFailed'), 'error')
console.error('Copy to clipboard failed:', err)
}
document.body.removeChild(textArea)
}
} catch (error) {
showToast(t('common.copyFailed'), 'error')
console.error('Copy to clipboard failed:', error)
}
}
const handleRunTool = async (arguments_: Record<string, any>) => {
setIsRunning(true)
try {
@@ -147,8 +187,19 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900">
{tool.name.replace(server + '-', '')}
<h3 className="text-lg font-medium text-gray-900 inline-flex items-center">
{tool.name.replace(server + nameSeparator, '')}
<button
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
onClick={handleCopyToolName}
title={t('common.copy')}
>
{copiedToolName ? (
<Check size={16} className="text-green-500" />
) : (
<Copy size={16} />
)}
</button>
<span className="ml-2 text-sm font-normal text-gray-600 inline-flex items-center">
{isEditingDescription ? (
<>
@@ -246,7 +297,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
onCancel={handleCancelRun}
loading={isRunning}
storageKey={getStorageKey()}
title={t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}
title={t('tool.runToolWithName', { name: tool.name.replace(server + nameSeparator, '') })}
/>
{/* Tool Result */}
{result && (

View File

@@ -2,8 +2,11 @@
export const PERMISSIONS = {
// Settings page permissions
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
SETTINGS_ROUTE_CONFIG: 'settings:route_config',
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
SETTINGS_SYSTEM_CONFIG: 'settings:system_config',
SETTINGS_OAUTH_SERVER: 'settings:oauth_server',
SETTINGS_EXPORT_CONFIG: 'settings:export_config',
} as const;
export default PERMISSIONS;

View File

@@ -14,12 +14,12 @@ const initialState: AuthState = {
// Create auth context
const AuthContext = createContext<{
auth: AuthState;
login: (username: string, password: string) => Promise<boolean>;
login: (username: string, password: string) => Promise<{ success: boolean; isUsingDefaultPassword?: boolean }>;
register: (username: string, password: string, isAdmin?: boolean) => Promise<boolean>;
logout: () => void;
}>({
auth: initialState,
login: async () => false,
login: async () => ({ success: false }),
register: async () => false,
logout: () => { },
});
@@ -90,7 +90,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}, []);
// Login function
const login = async (username: string, password: string): Promise<boolean> => {
const login = async (username: string, password: string): Promise<{ success: boolean; isUsingDefaultPassword?: boolean }> => {
try {
const response = await authService.login({ username, password });
@@ -101,14 +101,17 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
user: response.user,
error: null,
});
return true;
return {
success: true,
isUsingDefaultPassword: response.isUsingDefaultPassword,
};
} else {
setAuth({
...initialState,
loading: false,
error: response.message || 'Authentication failed',
});
return false;
return { success: false };
}
} catch (error) {
setAuth({
@@ -116,7 +119,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
loading: false,
error: 'Authentication failed',
});
return false;
return { success: false };
}
};

View File

@@ -0,0 +1,411 @@
import React, { createContext, useState, useEffect, useRef, useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { Server, ApiResponse } from '@/types';
import { apiGet, apiPost, apiDelete } from '../utils/fetchInterceptor';
import { useAuth } from './AuthContext';
// Configuration options
const CONFIG = {
// Initialization phase configuration
startup: {
maxAttempts: 60, // Maximum number of attempts during initialization
pollingInterval: 3000, // Polling interval during initialization (3 seconds)
},
// Normal operation phase configuration
normal: {
pollingInterval: 10000, // Polling interval during normal operation (10 seconds)
},
};
// Context type definition
interface ServerContextType {
servers: Server[];
error: string | null;
setError: (error: string | null) => void;
isLoading: boolean;
fetchAttempts: number;
triggerRefresh: () => void;
refreshIfNeeded: () => void; // Smart refresh with debounce
handleServerAdd: () => void;
handleServerEdit: (server: Server) => Promise<any>;
handleServerRemove: (serverName: string) => Promise<boolean>;
handleServerToggle: (server: Server, enabled: boolean) => Promise<boolean>;
handleServerReload: (server: Server) => Promise<boolean>;
}
// Create Context
const ServerContext = createContext<ServerContextType | undefined>(undefined);
// Provider component
export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { t } = useTranslation();
const { auth } = useAuth();
const [servers, setServers] = useState<Server[]>([]);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [fetchAttempts, setFetchAttempts] = useState(0);
// Timer reference for polling
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// Track current attempt count to avoid dependency cycles
const attemptsRef = useRef<number>(0);
// Track last fetch time to implement smart refresh
const lastFetchTimeRef = useRef<number>(0);
// Minimum interval between manual refreshes (5 seconds in dev, 3 seconds in prod)
const MIN_REFRESH_INTERVAL = process.env.NODE_ENV === 'development' ? 5000 : 3000;
// Clear the timer
const clearTimer = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
// Start normal polling
const startNormalPolling = useCallback(
(options?: { immediate?: boolean }) => {
const immediate = options?.immediate ?? true;
// Ensure no other timers are running
clearTimer();
const fetchServers = async () => {
try {
console.log('[ServerContext] Fetching servers from API...');
const data = await apiGet('/servers');
// Update last fetch time
lastFetchTimeRef.current = Date.now();
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
} else if (data && Array.isArray(data)) {
setServers(data);
} else {
console.error('Invalid server data format:', data);
setServers([]);
}
// Reset error state
setError(null);
} catch (err) {
console.error('Error fetching servers during normal polling:', err);
// Use friendly error message
if (!navigator.onLine) {
setError(t('errors.network'));
} else if (
err instanceof TypeError &&
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
) {
setError(t('errors.serverConnection'));
} else {
setError(t('errors.serverFetch'));
}
}
};
// Execute immediately unless explicitly skipped
if (immediate) {
fetchServers();
}
// Set up regular polling
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
},
[t],
);
// Watch for authentication status changes
useEffect(() => {
if (auth.isAuthenticated) {
console.log('[ServerContext] User authenticated, triggering refresh');
// When user logs in, trigger a refresh to load servers
setRefreshKey((prevKey) => prevKey + 1);
} else {
console.log('[ServerContext] User not authenticated, clearing data and stopping polling');
// When user logs out, clear data and stop polling
clearTimer();
setServers([]);
setIsInitialLoading(false);
setError(null);
}
}, [auth.isAuthenticated]);
useEffect(() => {
// If not authenticated, don't poll
if (!auth.isAuthenticated) {
console.log('[ServerContext] User not authenticated, skipping polling setup');
return;
}
// Reset attempt count
if (refreshKey > 0) {
attemptsRef.current = 0;
setFetchAttempts(0);
}
// Initialization phase request function
const fetchInitialData = async () => {
try {
console.log('[ServerContext] Initial fetch - attempt', attemptsRef.current + 1);
const data = await apiGet('/servers');
// Update last fetch time
lastFetchTimeRef.current = Date.now();
// Handle API response wrapper object, extract data field
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
setIsInitialLoading(false);
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
startNormalPolling({ immediate: false });
return true;
} else if (data && Array.isArray(data)) {
// Compatibility handling, if API directly returns array
setServers(data);
setIsInitialLoading(false);
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
startNormalPolling({ immediate: false });
return true;
} else {
// If data format is not as expected, set to empty array
console.error('Invalid server data format:', data);
setServers([]);
setIsInitialLoading(false);
// Initialization successful but data is empty, start normal polling (skip immediate)
startNormalPolling({ immediate: false });
return true;
}
} catch (err) {
// Increment attempt count, use ref to avoid triggering effect rerun
attemptsRef.current += 1;
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err);
// Update state for display
setFetchAttempts(attemptsRef.current);
// Set appropriate error message
if (!navigator.onLine) {
setError(t('errors.network'));
} else {
setError(t('errors.initialStartup'));
}
// If maximum attempt count is exceeded, give up initialization and switch to normal polling
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
console.log('Maximum startup attempts reached, switching to normal polling');
setIsInitialLoading(false);
// Clear initialization polling
clearTimer();
// Switch to normal polling mode
startNormalPolling();
}
return false;
}
};
// On component mount, set appropriate polling based on current state
if (isInitialLoading) {
// Ensure no other timers are running
clearTimer();
// Execute initial request immediately
fetchInitialData();
// Set polling interval for initialization phase
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval);
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`);
} else {
// Initialization completed, start normal polling
startNormalPolling();
}
// Cleanup function
return () => {
clearTimer();
};
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
// Manually trigger refresh (always refreshes)
const triggerRefresh = useCallback(() => {
// Clear current timer
clearTimer();
// If in initialization phase, reset initialization state
if (isInitialLoading) {
setIsInitialLoading(true);
attemptsRef.current = 0;
setFetchAttempts(0);
}
// Change in refreshKey will trigger useEffect to run again
setRefreshKey((prevKey) => prevKey + 1);
}, [isInitialLoading]);
// Smart refresh with debounce (only refresh if enough time has passed)
const refreshIfNeeded = useCallback(() => {
const now = Date.now();
const timeSinceLastFetch = now - lastFetchTimeRef.current;
// Log who is calling this
console.log(
'[ServerContext] refreshIfNeeded called, time since last fetch:',
timeSinceLastFetch,
'ms',
);
// Only refresh if enough time has passed since last fetch
if (timeSinceLastFetch >= MIN_REFRESH_INTERVAL) {
console.log(
'[ServerContext] Triggering refresh (exceeded MIN_REFRESH_INTERVAL:',
MIN_REFRESH_INTERVAL,
'ms)',
);
triggerRefresh();
} else {
console.log(
'[ServerContext] Skipping refresh (MIN_REFRESH_INTERVAL:',
MIN_REFRESH_INTERVAL,
'ms, time since last:',
timeSinceLastFetch,
'ms)',
);
}
}, [triggerRefresh]);
// Server related operations
const handleServerAdd = useCallback(() => {
setRefreshKey((prevKey) => prevKey + 1);
}, []);
const handleServerEdit = useCallback(
async (server: Server) => {
try {
// Fetch single server config instead of all settings
const encodedServerName = encodeURIComponent(server.name);
const serverData: ApiResponse<{
name: string;
status: string;
tools: any[];
config: Record<string, any>;
}> = await apiGet(`/servers/${encodedServerName}`);
if (serverData && serverData.success && serverData.data) {
return {
name: serverData.data.name,
status: serverData.data.status,
tools: serverData.data.tools || [],
config: serverData.data.config,
};
} else {
console.error('Failed to get server config:', serverData);
setError(t('server.invalidConfig', { serverName: server.name }));
return null;
}
} catch (err) {
console.error('Error fetching server config:', err);
setError(err instanceof Error ? err.message : String(err));
return null;
}
},
[t],
);
const handleServerRemove = useCallback(
async (serverName: string) => {
try {
const encodedServerName = encodeURIComponent(serverName);
const result = await apiDelete(`/servers/${encodedServerName}`);
if (!result || !result.success) {
setError(result?.message || t('server.deleteError', { serverName }));
return false;
}
setRefreshKey((prevKey) => prevKey + 1);
return true;
} catch (err) {
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
return false;
}
},
[t],
);
const handleServerToggle = useCallback(
async (server: Server, enabled: boolean) => {
try {
const encodedServerName = encodeURIComponent(server.name);
const result = await apiPost(`/servers/${encodedServerName}/toggle`, { enabled });
if (!result || !result.success) {
console.error('Failed to toggle server:', result);
setError(result?.message || t('server.toggleError', { serverName: server.name }));
return false;
}
// Update the UI immediately to reflect the change
setRefreshKey((prevKey) => prevKey + 1);
return true;
} catch (err) {
console.error('Error toggling server:', err);
setError(err instanceof Error ? err.message : String(err));
return false;
}
},
[t],
);
const handleServerReload = useCallback(
async (server: Server) => {
try {
const encodedServerName = encodeURIComponent(server.name);
const result = await apiPost(`/servers/${encodedServerName}/reload`, {});
if (!result || !result.success) {
console.error('Failed to reload server:', result);
setError(t('server.reloadError', { serverName: server.name }));
return false;
}
// Refresh server list after successful reload
triggerRefresh();
return true;
} catch (err) {
console.error('Error reloading server:', err);
setError(err instanceof Error ? err.message : String(err));
return false;
}
},
[t, triggerRefresh],
);
const value: ServerContextType = {
servers,
error,
setError,
isLoading: isInitialLoading,
fetchAttempts,
triggerRefresh,
refreshIfNeeded,
handleServerAdd,
handleServerEdit,
handleServerRemove,
handleServerToggle,
handleServerReload,
};
return <ServerContext.Provider value={value}>{children}</ServerContext.Provider>;
};
// Custom hook to use the Server context
export const useServerContext = () => {
const context = useContext(ServerContext);
if (context === undefined) {
throw new Error('useServerContext must be used within a ServerProvider');
}
return context;
};

View File

@@ -0,0 +1,792 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
import { ApiResponse, BearerKey } from '@/types';
import { useToast } from '@/contexts/ToastContext';
import { apiGet, apiPut, apiPost, apiDelete } from '@/utils/fetchInterceptor';
// Define types for the settings data
interface RoutingConfig {
enableGlobalRoute: boolean;
enableGroupNameRoute: boolean;
enableBearerAuth: boolean;
bearerAuthKey: string;
skipAuth: boolean;
}
interface InstallConfig {
pythonIndexUrl: string;
npmRegistry: string;
baseUrl: string;
}
interface SmartRoutingConfig {
enabled: boolean;
dbUrl: string;
openaiApiBaseUrl: string;
openaiApiKey: string;
openaiApiEmbeddingModel: string;
}
interface MCPRouterConfig {
apiKey: string;
referer: string;
title: string;
baseUrl: string;
}
interface OAuthServerConfig {
enabled: boolean;
accessTokenLifetime: number;
refreshTokenLifetime: number;
authorizationCodeLifetime: number;
requireClientSecret: boolean;
allowedScopes: string[];
requireState: boolean;
dynamicRegistration: {
enabled: boolean;
allowedGrantTypes: string[];
requiresAuthentication: boolean;
};
}
interface SystemSettings {
systemConfig?: {
routing?: RoutingConfig;
install?: InstallConfig;
smartRouting?: SmartRoutingConfig;
mcpRouter?: MCPRouterConfig;
nameSeparator?: string;
oauthServer?: OAuthServerConfig;
enableSessionRebuild?: boolean;
};
bearerKeys?: BearerKey[];
}
interface TempRoutingConfig {
bearerAuthKey: string;
}
interface SettingsContextValue {
routingConfig: RoutingConfig;
tempRoutingConfig: TempRoutingConfig;
setTempRoutingConfig: React.Dispatch<React.SetStateAction<TempRoutingConfig>>;
installConfig: InstallConfig;
smartRoutingConfig: SmartRoutingConfig;
mcpRouterConfig: MCPRouterConfig;
oauthServerConfig: OAuthServerConfig;
nameSeparator: string;
enableSessionRebuild: boolean;
bearerKeys: BearerKey[];
loading: boolean;
error: string | null;
setError: React.Dispatch<React.SetStateAction<string | null>>;
triggerRefresh: () => void;
fetchSettings: () => Promise<void>;
updateRoutingConfig: (key: keyof RoutingConfig, value: any) => Promise<boolean | undefined>;
updateInstallConfig: (key: keyof InstallConfig, value: any) => Promise<boolean | undefined>;
updateSmartRoutingConfig: (
key: keyof SmartRoutingConfig,
value: any,
) => Promise<boolean | undefined>;
updateSmartRoutingConfigBatch: (
updates: Partial<SmartRoutingConfig>,
) => Promise<boolean | undefined>;
updateRoutingConfigBatch: (updates: Partial<RoutingConfig>) => Promise<boolean | undefined>;
updateMCPRouterConfig: (key: keyof MCPRouterConfig, value: any) => Promise<boolean | undefined>;
updateMCPRouterConfigBatch: (updates: Partial<MCPRouterConfig>) => Promise<boolean | undefined>;
updateOAuthServerConfig: (
key: keyof OAuthServerConfig,
value: any,
) => Promise<boolean | undefined>;
updateOAuthServerConfigBatch: (
updates: Partial<OAuthServerConfig>,
) => Promise<boolean | undefined>;
updateNameSeparator: (value: string) => Promise<boolean | undefined>;
updateSessionRebuild: (value: boolean) => Promise<boolean | undefined>;
exportMCPSettings: (serverName?: string) => Promise<any>;
// Bearer key management
refreshBearerKeys: () => Promise<void>;
createBearerKey: (payload: Omit<BearerKey, 'id'>) => Promise<BearerKey | null>;
updateBearerKey: (
id: string,
updates: Partial<Omit<BearerKey, 'id'>>,
) => Promise<BearerKey | null>;
deleteBearerKey: (id: string) => Promise<boolean>;
}
const getDefaultOAuthServerConfig = (): OAuthServerConfig => ({
enabled: true,
accessTokenLifetime: 3600,
refreshTokenLifetime: 1209600,
authorizationCodeLifetime: 300,
requireClientSecret: false,
allowedScopes: ['read', 'write'],
requireState: false,
dynamicRegistration: {
enabled: true,
allowedGrantTypes: ['authorization_code', 'refresh_token'],
requiresAuthentication: false,
},
});
const SettingsContext = createContext<SettingsContextValue | undefined>(undefined);
export const useSettings = () => {
const context = useContext(SettingsContext);
if (!context) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
};
interface SettingsProviderProps {
children: ReactNode;
}
export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children }) => {
const { t } = useTranslation();
const { showToast } = useToast();
const [routingConfig, setRoutingConfig] = useState<RoutingConfig>({
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
skipAuth: false,
});
const [tempRoutingConfig, setTempRoutingConfig] = useState<TempRoutingConfig>({
bearerAuthKey: '',
});
const [installConfig, setInstallConfig] = useState<InstallConfig>({
pythonIndexUrl: '',
npmRegistry: '',
baseUrl: 'http://localhost:3000',
});
const [smartRoutingConfig, setSmartRoutingConfig] = useState<SmartRoutingConfig>({
enabled: false,
dbUrl: '',
openaiApiBaseUrl: '',
openaiApiKey: '',
openaiApiEmbeddingModel: '',
});
const [mcpRouterConfig, setMCPRouterConfig] = useState<MCPRouterConfig>({
apiKey: '',
referer: 'https://www.mcphubx.com',
title: 'MCPHub',
baseUrl: 'https://api.mcprouter.to/v1',
});
const [oauthServerConfig, setOAuthServerConfig] = useState<OAuthServerConfig>(
getDefaultOAuthServerConfig(),
);
const [nameSeparator, setNameSeparator] = useState<string>('-');
const [enableSessionRebuild, setEnableSessionRebuild] = useState<boolean>(false);
const [bearerKeys, setBearerKeys] = useState<BearerKey[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
// Trigger a refresh of the settings data
const triggerRefresh = useCallback(() => {
setRefreshKey((prev) => prev + 1);
}, []);
// Fetch current settings
const fetchSettings = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data: ApiResponse<SystemSettings> = await apiGet('/settings');
if (data.success && data.data?.systemConfig?.routing) {
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 || '',
skipAuth: data.data.systemConfig.routing.skipAuth ?? false,
});
}
if (data.success && data.data?.systemConfig?.install) {
setInstallConfig({
pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '',
npmRegistry: data.data.systemConfig.install.npmRegistry || '',
baseUrl: data.data.systemConfig.install.baseUrl || 'http://localhost:3000',
});
}
if (data.success && data.data?.systemConfig?.smartRouting) {
setSmartRoutingConfig({
enabled: data.data.systemConfig.smartRouting.enabled ?? false,
dbUrl: data.data.systemConfig.smartRouting.dbUrl || '',
openaiApiBaseUrl: data.data.systemConfig.smartRouting.openaiApiBaseUrl || '',
openaiApiKey: data.data.systemConfig.smartRouting.openaiApiKey || '',
openaiApiEmbeddingModel:
data.data.systemConfig.smartRouting.openaiApiEmbeddingModel || '',
});
}
if (data.success && data.data?.systemConfig?.mcpRouter) {
setMCPRouterConfig({
apiKey: data.data.systemConfig.mcpRouter.apiKey || '',
referer: data.data.systemConfig.mcpRouter.referer || 'https://www.mcphubx.com',
title: data.data.systemConfig.mcpRouter.title || 'MCPHub',
baseUrl: data.data.systemConfig.mcpRouter.baseUrl || 'https://api.mcprouter.to/v1',
});
}
if (data.success) {
if (data.data?.systemConfig?.oauthServer) {
const oauth = data.data.systemConfig.oauthServer;
const defaultOauthConfig = getDefaultOAuthServerConfig();
const defaultDynamic = defaultOauthConfig.dynamicRegistration;
const allowedScopes = Array.isArray(oauth.allowedScopes)
? [...oauth.allowedScopes]
: [...defaultOauthConfig.allowedScopes];
const dynamicAllowedGrantTypes = Array.isArray(
oauth.dynamicRegistration?.allowedGrantTypes,
)
? [...oauth.dynamicRegistration!.allowedGrantTypes!]
: [...defaultDynamic.allowedGrantTypes];
setOAuthServerConfig({
enabled: oauth.enabled ?? defaultOauthConfig.enabled,
accessTokenLifetime:
oauth.accessTokenLifetime ?? defaultOauthConfig.accessTokenLifetime,
refreshTokenLifetime:
oauth.refreshTokenLifetime ?? defaultOauthConfig.refreshTokenLifetime,
authorizationCodeLifetime:
oauth.authorizationCodeLifetime ?? defaultOauthConfig.authorizationCodeLifetime,
requireClientSecret:
oauth.requireClientSecret ?? defaultOauthConfig.requireClientSecret,
requireState: oauth.requireState ?? defaultOauthConfig.requireState,
allowedScopes,
dynamicRegistration: {
enabled: oauth.dynamicRegistration?.enabled ?? defaultDynamic.enabled,
allowedGrantTypes: dynamicAllowedGrantTypes,
requiresAuthentication:
oauth.dynamicRegistration?.requiresAuthentication ??
defaultDynamic.requiresAuthentication,
},
});
} else {
setOAuthServerConfig(getDefaultOAuthServerConfig());
}
}
if (data.success && data.data?.systemConfig?.nameSeparator !== undefined) {
setNameSeparator(data.data.systemConfig.nameSeparator);
}
if (data.success && data.data?.systemConfig?.enableSessionRebuild !== undefined) {
setEnableSessionRebuild(data.data.systemConfig.enableSessionRebuild);
}
if (data.success && Array.isArray(data.data?.bearerKeys)) {
setBearerKeys(data.data.bearerKeys);
}
} catch (error) {
console.error('Failed to fetch settings:', error);
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
showToast(t('errors.failedToFetchSettings'));
} finally {
setLoading(false);
}
}, [t, showToast]);
// Update routing configuration
const updateRoutingConfig = async (key: keyof RoutingConfig, value: any) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
routing: {
[key]: value,
},
});
if (data.success) {
setRoutingConfig({
...routingConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update routing config');
showToast(data.error || t('errors.failedToUpdateRoutingConfig'));
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.failedToUpdateRoutingConfig'));
return false;
} finally {
setLoading(false);
}
};
// Update install configuration
const updateInstallConfig = async (key: keyof InstallConfig, value: any) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
install: {
[key]: value,
},
});
if (data.success) {
setInstallConfig({
...installConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update install config');
showToast(data.error || t('errors.failedToUpdateInstallConfig'));
return false;
}
} catch (error) {
console.error('Failed to update install config:', error);
setError(error instanceof Error ? error.message : 'Failed to update install config');
showToast(t('errors.failedToUpdateInstallConfig'));
return false;
} finally {
setLoading(false);
}
};
// Update smart routing configuration
const updateSmartRoutingConfig = async (key: keyof SmartRoutingConfig, value: any) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
smartRouting: {
[key]: value,
},
});
if (data.success) {
setSmartRoutingConfig({
...smartRoutingConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update smart routing config');
showToast(data.error || t('errors.failedToUpdateSmartRoutingConfig'));
return false;
}
} catch (error) {
console.error('Failed to update smart routing config:', error);
setError(error instanceof Error ? error.message : 'Failed to update smart routing config');
showToast(t('errors.failedToUpdateSmartRoutingConfig'));
return false;
} finally {
setLoading(false);
}
};
// Batch update smart routing configuration
const updateSmartRoutingConfigBatch = async (updates: Partial<SmartRoutingConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
smartRouting: updates,
});
if (data.success) {
setSmartRoutingConfig({
...smartRoutingConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update smart routing config');
showToast(data.error || t('errors.failedToUpdateSmartRoutingConfig'));
return false;
}
} catch (error) {
console.error('Failed to update smart routing config:', error);
setError(error instanceof Error ? error.message : 'Failed to update smart routing config');
showToast(t('errors.failedToUpdateSmartRoutingConfig'));
return false;
} finally {
setLoading(false);
}
};
// Batch update routing configuration
const updateRoutingConfigBatch = async (updates: Partial<RoutingConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
routing: updates,
});
if (data.success) {
setRoutingConfig({
...routingConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update routing config');
showToast(data.error || t('errors.failedToUpdateRoutingConfig'));
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.failedToUpdateRoutingConfig'));
return false;
} finally {
setLoading(false);
}
};
// Update MCP Router configuration
const updateMCPRouterConfig = async (key: keyof MCPRouterConfig, value: any) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
mcpRouter: {
[key]: value,
},
});
if (data.success) {
setMCPRouterConfig({
...mcpRouterConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update MCP Router config');
showToast(data.error || t('errors.failedToUpdateMCPRouterConfig'));
return false;
}
} catch (error) {
console.error('Failed to update MCP Router config:', error);
setError(error instanceof Error ? error.message : 'Failed to update MCP Router config');
showToast(t('errors.failedToUpdateMCPRouterConfig'));
return false;
} finally {
setLoading(false);
}
};
// Batch update MCP Router configuration
const updateMCPRouterConfigBatch = async (updates: Partial<MCPRouterConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
mcpRouter: updates,
});
if (data.success) {
setMCPRouterConfig({
...mcpRouterConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update MCP Router config');
showToast(data.error || t('errors.failedToUpdateMCPRouterConfig'));
return false;
}
} catch (error) {
console.error('Failed to update MCP Router config:', error);
setError(error instanceof Error ? error.message : 'Failed to update MCP Router config');
showToast(t('errors.failedToUpdateMCPRouterConfig'));
return false;
} finally {
setLoading(false);
}
};
// Update OAuth server configuration
const updateOAuthServerConfig = async (key: keyof OAuthServerConfig, value: any) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
oauthServer: {
[key]: value,
},
});
if (data.success) {
setOAuthServerConfig({
...oauthServerConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update OAuth server config');
showToast(data.error || t('errors.failedToUpdateOAuthServerConfig'));
return false;
}
} catch (error) {
console.error('Failed to update OAuth server config:', error);
setError(error instanceof Error ? error.message : 'Failed to update OAuth server config');
showToast(t('errors.failedToUpdateOAuthServerConfig'));
return false;
} finally {
setLoading(false);
}
};
// Batch update OAuth server configuration
const updateOAuthServerConfigBatch = async (updates: Partial<OAuthServerConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
oauthServer: updates,
});
if (data.success) {
setOAuthServerConfig({
...oauthServerConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update OAuth server config');
showToast(data.error || t('errors.failedToUpdateOAuthServerConfig'));
return false;
}
} catch (error) {
console.error('Failed to update OAuth server config:', error);
setError(error instanceof Error ? error.message : 'Failed to update OAuth server config');
showToast(t('errors.failedToUpdateOAuthServerConfig'));
return false;
} finally {
setLoading(false);
}
};
// Update name separator
const updateNameSeparator = async (value: string) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
nameSeparator: value,
});
if (data.success) {
setNameSeparator(value);
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update name separator');
showToast(data.error || t('errors.failedToUpdateNameSeparator'));
return false;
}
} catch (error) {
console.error('Failed to update name separator:', error);
setError(error instanceof Error ? error.message : 'Failed to update name separator');
showToast(t('errors.failedToUpdateNameSeparator'));
return false;
} finally {
setLoading(false);
}
};
// Update session rebuild flag
const updateSessionRebuild = async (value: boolean) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
enableSessionRebuild: value,
});
if (data.success) {
setEnableSessionRebuild(value);
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update session rebuild setting');
showToast(data.error || t('errors.failedToUpdateSessionRebuild'));
return false;
}
} catch (error) {
console.error('Failed to update session rebuild setting:', error);
setError(error instanceof Error ? error.message : 'Failed to update session rebuild setting');
showToast(t('errors.failedToUpdateSessionRebuild'));
return false;
} finally {
setLoading(false);
}
};
const exportMCPSettings = async (serverName?: string) => {
setLoading(true);
setError(null);
try {
return await apiGet(`/mcp-settings/export?serverName=${serverName ? serverName : ''}`);
} catch (error) {
console.error('Failed to export MCP settings:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to export MCP settings';
setError(errorMessage);
showToast(errorMessage);
} finally {
setLoading(false);
}
};
// Bearer key management helpers
const refreshBearerKeys = async () => {
try {
const data: ApiResponse<BearerKey[]> = await apiGet('/auth/keys');
if (data.success && Array.isArray(data.data)) {
setBearerKeys(data.data);
}
} catch (error) {
console.error('Failed to refresh bearer keys:', error);
showToast(t('errors.failedToFetchSettings'));
}
};
const createBearerKey = async (payload: Omit<BearerKey, 'id'>): Promise<BearerKey | null> => {
try {
const data: ApiResponse<BearerKey> = await apiPost('/auth/keys', payload as any);
if (data.success && data.data) {
await refreshBearerKeys();
showToast(t('settings.systemConfigUpdated'));
return data.data;
}
showToast(data.message || t('errors.failedToUpdateRoutingConfig'));
return null;
} catch (error) {
console.error('Failed to create bearer key:', error);
showToast(t('errors.failedToUpdateRoutingConfig'));
return null;
}
};
const updateBearerKey = async (
id: string,
updates: Partial<Omit<BearerKey, 'id'>>,
): Promise<BearerKey | null> => {
try {
const data: ApiResponse<BearerKey> = await apiPut(`/auth/keys/${id}`, updates as any);
if (data.success && data.data) {
await refreshBearerKeys();
showToast(t('settings.systemConfigUpdated'));
return data.data;
}
showToast(data.message || t('errors.failedToUpdateRoutingConfig'));
return null;
} catch (error) {
console.error('Failed to update bearer key:', error);
showToast(t('errors.failedToUpdateRoutingConfig'));
return null;
}
};
const deleteBearerKey = async (id: string): Promise<boolean> => {
try {
const data: ApiResponse = await apiDelete(`/auth/keys/${id}`);
if (data.success) {
await refreshBearerKeys();
showToast(t('settings.systemConfigUpdated'));
return true;
}
showToast(data.message || t('errors.failedToUpdateRoutingConfig'));
return false;
} catch (error) {
console.error('Failed to delete bearer key:', error);
showToast(t('errors.failedToUpdateRoutingConfig'));
return false;
}
};
// Fetch settings when the component mounts or refreshKey changes
useEffect(() => {
fetchSettings();
}, [fetchSettings, refreshKey]);
useEffect(() => {
if (routingConfig) {
setTempRoutingConfig({
bearerAuthKey: routingConfig.bearerAuthKey,
});
}
}, [routingConfig]);
const value: SettingsContextValue = {
routingConfig,
tempRoutingConfig,
setTempRoutingConfig,
installConfig,
smartRoutingConfig,
mcpRouterConfig,
oauthServerConfig,
nameSeparator,
enableSessionRebuild,
bearerKeys,
loading,
error,
setError,
triggerRefresh,
fetchSettings,
updateRoutingConfig,
updateInstallConfig,
updateSmartRoutingConfig,
updateSmartRoutingConfigBatch,
updateRoutingConfigBatch,
updateMCPRouterConfig,
updateMCPRouterConfigBatch,
updateOAuthServerConfig,
updateOAuthServerConfigBatch,
updateNameSeparator,
updateSessionRebuild,
exportMCPSettings,
refreshBearerKeys,
createBearerKey,
updateBearerKey,
deleteBearerKey,
};
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
};

View File

@@ -287,9 +287,13 @@ export const useCloudData = () => {
const callServerTool = useCallback(
async (serverName: string, toolName: string, args: Record<string, any>) => {
try {
const data = await apiPost(`/cloud/servers/${serverName}/tools/${toolName}/call`, {
arguments: args,
});
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const data = await apiPost(
`/cloud/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/call`,
{
arguments: args,
},
);
if (data && data.success) {
return data.data;

View File

@@ -0,0 +1,283 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
RegistryServerEntry,
RegistryServersResponse,
RegistryServerVersionResponse,
RegistryServerVersionsResponse,
} from '@/types';
import { apiGet } from '../utils/fetchInterceptor';
export const useRegistryData = () => {
const { t } = useTranslation();
const [servers, setServers] = useState<RegistryServerEntry[]>([]);
const [allServers, setAllServers] = useState<RegistryServerEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
// Cursor-based pagination states
const [currentPage, setCurrentPage] = useState(1);
const [serversPerPage, setServersPerPage] = useState(9);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(false);
const [cursorHistory, setCursorHistory] = useState<string[]>([]);
const [totalPages] = useState(1); // Legacy support, not used in cursor pagination
// Fetch registry servers with cursor-based pagination
const fetchRegistryServers = useCallback(
async (cursor?: string, search?: string) => {
try {
setLoading(true);
setError(null);
// Build query parameters
const params = new URLSearchParams();
params.append('limit', serversPerPage.toString());
if (cursor) {
params.append('cursor', cursor);
}
const queryToUse = search !== undefined ? search : searchQuery;
if (queryToUse.trim()) {
params.append('search', queryToUse.trim());
}
const response = await apiGet(`/registry/servers?${params.toString()}`);
if (response && response.success && response.data) {
const data: RegistryServersResponse = response.data;
if (data.servers && Array.isArray(data.servers)) {
setServers(data.servers);
// Update pagination state
const hasMore = data.metadata.count === serversPerPage && !!data.metadata.nextCursor;
setHasNextPage(hasMore);
setNextCursor(data.metadata.nextCursor || null);
// For display purposes, keep track of all loaded servers
if (!cursor) {
// First page
setAllServers(data.servers);
} else {
// Subsequent pages - append to all servers
setAllServers((prev) => [...prev, ...data.servers]);
}
} else {
console.error('Invalid registry servers data format:', data);
setError(t('registry.fetchError'));
}
} else {
setError(t('registry.fetchError'));
}
} catch (err) {
console.error('Error fetching registry servers:', err);
const errorMessage = err instanceof Error ? err.message : String(err);
setError(errorMessage);
} finally {
setLoading(false);
}
},
[t, serversPerPage],
);
// Navigate to next page
const goToNextPage = useCallback(async () => {
if (!hasNextPage || !nextCursor) return;
// Save current cursor to history for back navigation
const currentCursor = cursorHistory[cursorHistory.length - 1] || '';
setCursorHistory((prev) => [...prev, currentCursor]);
setCurrentPage((prev) => prev + 1);
await fetchRegistryServers(nextCursor, searchQuery);
}, [hasNextPage, nextCursor, cursorHistory, searchQuery, fetchRegistryServers]);
// Navigate to previous page
const goToPreviousPage = useCallback(async () => {
if (currentPage <= 1) return;
// Get the previous cursor from history
const newHistory = [...cursorHistory];
newHistory.pop(); // Remove current position
const previousCursor = newHistory[newHistory.length - 1];
setCursorHistory(newHistory);
setCurrentPage((prev) => prev - 1);
// Fetch with previous cursor (undefined for first page)
await fetchRegistryServers(previousCursor || undefined, searchQuery);
}, [currentPage, cursorHistory, searchQuery, fetchRegistryServers]);
// Change page (legacy support for page number navigation)
const changePage = useCallback(
async (page: number) => {
if (page === currentPage) return;
if (page > currentPage && hasNextPage) {
await goToNextPage();
} else if (page < currentPage && currentPage > 1) {
await goToPreviousPage();
}
},
[currentPage, hasNextPage, goToNextPage, goToPreviousPage],
);
// Change items per page
const changeServersPerPage = useCallback(
async (newServersPerPage: number) => {
setServersPerPage(newServersPerPage);
setCurrentPage(1);
setCursorHistory([]);
setAllServers([]);
await fetchRegistryServers(undefined, searchQuery);
},
[searchQuery, fetchRegistryServers],
);
// Fetch server by name
const fetchServerByName = useCallback(
async (serverName: string) => {
try {
setLoading(true);
setError(null);
// URL encode the server name
const encodedName = encodeURIComponent(serverName);
const response = await apiGet(`/registry/servers/${encodedName}/versions`);
if (response && response.success && response.data) {
const data: RegistryServerVersionsResponse = response.data;
if (data.servers && Array.isArray(data.servers) && data.servers.length > 0) {
// Return the first server entry (should be the latest or specified version)
return data.servers[0];
} else {
console.error('Invalid registry server data format:', data);
setError(t('registry.serverNotFound'));
return null;
}
} else {
setError(t('registry.serverNotFound'));
return null;
}
} catch (err) {
console.error(`Error fetching registry server ${serverName}:`, err);
const errorMessage = err instanceof Error ? err.message : String(err);
setError(errorMessage);
return null;
} finally {
setLoading(false);
}
},
[t],
);
// Fetch all versions of a server
const fetchServerVersions = useCallback(async (serverName: string) => {
try {
setError(null);
// URL encode the server name
const encodedName = encodeURIComponent(serverName);
const response = await apiGet(`/registry/servers/${encodedName}/versions`);
if (response && response.success && response.data) {
const data: RegistryServerVersionsResponse = response.data;
if (data.servers && Array.isArray(data.servers)) {
return data.servers;
} else {
console.error('Invalid registry server versions data format:', data);
return [];
}
} else {
return [];
}
} catch (err) {
console.error(`Error fetching versions for server ${serverName}:`, err);
const errorMessage = err instanceof Error ? err.message : String(err);
setError(errorMessage);
return [];
}
}, []);
// Fetch specific version of a server
const fetchServerVersion = useCallback(async (serverName: string, version: string) => {
try {
setError(null);
// URL encode the server name and version
const encodedName = encodeURIComponent(serverName);
const encodedVersion = encodeURIComponent(version);
const response = await apiGet(`/registry/servers/${encodedName}/versions/${encodedVersion}`);
if (response && response.success && response.data) {
const data: RegistryServerVersionResponse = response.data;
if (data && data.server) {
return data;
} else {
console.error('Invalid registry server version data format:', data);
return null;
}
} else {
return null;
}
} catch (err) {
console.error(`Error fetching version ${version} for server ${serverName}:`, err);
const errorMessage = err instanceof Error ? err.message : String(err);
setError(errorMessage);
return null;
}
}, []);
// Search servers by query (client-side filtering on loaded data)
const searchServers = useCallback(
async (query: string) => {
console.log('Searching registry servers with query:', query);
setSearchQuery(query);
setCurrentPage(1);
setCursorHistory([]);
setAllServers([]);
await fetchRegistryServers(undefined, query);
},
[fetchRegistryServers],
);
// Clear search
const clearSearch = useCallback(async () => {
setSearchQuery('');
setCurrentPage(1);
setCursorHistory([]);
setAllServers([]);
await fetchRegistryServers(undefined, '');
}, [fetchRegistryServers]);
// Initial fetch
useEffect(() => {
fetchRegistryServers(undefined, searchQuery);
// Only run on mount
// eslint-disable-next-line
}, []);
return {
servers,
allServers,
loading,
error,
setError,
searchQuery,
searchServers,
clearSearch,
fetchServerByName,
fetchServerVersions,
fetchServerVersion,
// Cursor-based pagination
currentPage,
totalPages,
hasNextPage,
hasPreviousPage: currentPage > 1,
changePage,
goToNextPage,
goToPreviousPage,
serversPerPage,
changeServersPerPage,
};
};

View File

@@ -1,272 +1,19 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Server, ApiResponse } from '@/types';
import { apiGet, apiPost, apiDelete } from '../utils/fetchInterceptor';
// This hook now delegates to the ServerContext to avoid duplicate requests
// All components will share the same server data and polling mechanism
import { useServerContext } from '@/contexts/ServerContext';
import { useEffect } from 'react';
// Configuration options
const CONFIG = {
// Initialization phase configuration
startup: {
maxAttempts: 60, // Maximum number of attempts during initialization
pollingInterval: 3000, // Polling interval during initialization (3 seconds)
},
// Normal operation phase configuration
normal: {
pollingInterval: 10000, // Polling interval during normal operation (10 seconds)
},
};
export const useServerData = () => {
const { t } = useTranslation();
const [servers, setServers] = useState<Server[]>([]);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [fetchAttempts, setFetchAttempts] = useState(0);
// Timer reference for polling
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// Track current attempt count to avoid dependency cycles
const attemptsRef = useRef<number>(0);
// Clear the timer
const clearTimer = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
// Start normal polling
const startNormalPolling = useCallback(() => {
// Ensure no other timers are running
clearTimer();
const fetchServers = async () => {
try {
const data = await apiGet('/servers');
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
} else if (data && Array.isArray(data)) {
setServers(data);
} else {
console.error('Invalid server data format:', data);
setServers([]);
}
// Reset error state
setError(null);
} catch (err) {
console.error('Error fetching servers during normal polling:', err);
// Use friendly error message
if (!navigator.onLine) {
setError(t('errors.network'));
} else if (
err instanceof TypeError &&
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
) {
setError(t('errors.serverConnection'));
} else {
setError(t('errors.serverFetch'));
}
}
};
// Execute immediately
fetchServers();
// Set up regular polling
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
}, [t]);
export const useServerData = (options?: { refreshOnMount?: boolean }) => {
const context = useServerContext();
const { refreshIfNeeded } = context;
// Optionally refresh on mount for pages that need fresh data
useEffect(() => {
// Reset attempt count
if (refreshKey > 0) {
attemptsRef.current = 0;
setFetchAttempts(0);
if (options?.refreshOnMount) {
refreshIfNeeded();
}
}, [options?.refreshOnMount, refreshIfNeeded]);
// Initialization phase request function
const fetchInitialData = async () => {
try {
const data = await apiGet('/servers');
// Handle API response wrapper object, extract data field
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
setIsInitialLoading(false);
// Initialization successful, start normal polling
startNormalPolling();
return true;
} else if (data && Array.isArray(data)) {
// Compatibility handling, if API directly returns array
setServers(data);
setIsInitialLoading(false);
// Initialization successful, start normal polling
startNormalPolling();
return true;
} else {
// If data format is not as expected, set to empty array
console.error('Invalid server data format:', data);
setServers([]);
setIsInitialLoading(false);
// Initialization successful but data is empty, start normal polling
startNormalPolling();
return true;
}
} catch (err) {
// Increment attempt count, use ref to avoid triggering effect rerun
attemptsRef.current += 1;
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err);
// Update state for display
setFetchAttempts(attemptsRef.current);
// Set appropriate error message
if (!navigator.onLine) {
setError(t('errors.network'));
} else {
setError(t('errors.initialStartup'));
}
// If maximum attempt count is exceeded, give up initialization and switch to normal polling
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
console.log('Maximum startup attempts reached, switching to normal polling');
setIsInitialLoading(false);
// Clear initialization polling
clearTimer();
// Switch to normal polling mode
startNormalPolling();
}
return false;
}
};
// On component mount, set appropriate polling based on current state
if (isInitialLoading) {
// Ensure no other timers are running
clearTimer();
// Execute initial request immediately
fetchInitialData();
// Set polling interval for initialization phase
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval);
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`);
} else {
// Initialization completed, start normal polling
startNormalPolling();
}
// Cleanup function
return () => {
clearTimer();
};
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
// Manually trigger refresh
const triggerRefresh = () => {
// Clear current timer
clearTimer();
// If in initialization phase, reset initialization state
if (isInitialLoading) {
setIsInitialLoading(true);
attemptsRef.current = 0;
setFetchAttempts(0);
}
// Change in refreshKey will trigger useEffect to run again
setRefreshKey((prevKey) => prevKey + 1);
};
// Server related operations
const handleServerAdd = () => {
setRefreshKey((prevKey) => prevKey + 1);
};
const handleServerEdit = async (server: Server) => {
try {
// Fetch settings to get the full server config before editing
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
await apiGet('/settings');
if (
settingsData &&
settingsData.success &&
settingsData.data &&
settingsData.data.mcpServers &&
settingsData.data.mcpServers[server.name]
) {
const serverConfig = settingsData.data.mcpServers[server.name];
return {
name: server.name,
status: server.status,
tools: server.tools || [],
config: serverConfig,
};
} else {
console.error('Failed to get server config from settings:', settingsData);
setError(t('server.invalidConfig', { serverName: server.name }));
return null;
}
} catch (err) {
console.error('Error fetching server settings:', err);
setError(err instanceof Error ? err.message : String(err));
return null;
}
};
const handleServerRemove = async (serverName: string) => {
try {
const result = await apiDelete(`/servers/${serverName}`);
if (!result || !result.success) {
setError(result?.message || t('server.deleteError', { serverName }));
return false;
}
setRefreshKey((prevKey) => prevKey + 1);
return true;
} catch (err) {
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
return false;
}
};
const handleServerToggle = async (server: Server, enabled: boolean) => {
try {
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
if (!result || !result.success) {
console.error('Failed to toggle server:', result);
setError(result?.message || t('server.toggleError', { serverName: server.name }));
return false;
}
// Update the UI immediately to reflect the change
setRefreshKey((prevKey) => prevKey + 1);
return true;
} catch (err) {
console.error('Error toggling server:', err);
setError(err instanceof Error ? err.message : String(err));
return false;
}
};
return {
servers,
error,
setError,
isLoading: isInitialLoading,
fetchAttempts,
triggerRefresh,
handleServerAdd,
handleServerEdit,
handleServerRemove,
handleServerToggle,
};
return context;
};

View File

@@ -1,420 +1,10 @@
import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ApiResponse } from '@/types';
import { useToast } from '@/contexts/ToastContext';
import { apiGet, apiPut } from '../utils/fetchInterceptor';
// Define types for the settings data
interface RoutingConfig {
enableGlobalRoute: boolean;
enableGroupNameRoute: boolean;
enableBearerAuth: boolean;
bearerAuthKey: string;
skipAuth: boolean;
}
interface InstallConfig {
pythonIndexUrl: string;
npmRegistry: string;
baseUrl: string;
}
interface SmartRoutingConfig {
enabled: boolean;
dbUrl: string;
openaiApiBaseUrl: string;
openaiApiKey: string;
openaiApiEmbeddingModel: string;
}
interface MCPRouterConfig {
apiKey: string;
referer: string;
title: string;
baseUrl: string;
}
interface SystemSettings {
systemConfig?: {
routing?: RoutingConfig;
install?: InstallConfig;
smartRouting?: SmartRoutingConfig;
mcpRouter?: MCPRouterConfig;
};
}
interface TempRoutingConfig {
bearerAuthKey: string;
}
import { useSettings } from '@/contexts/SettingsContext';
/**
* Hook that provides access to settings data via SettingsContext.
* This hook is a thin wrapper around useSettings to maintain backward compatibility.
* The actual data fetching happens once in SettingsProvider, avoiding duplicate API calls.
*/
export const useSettingsData = () => {
const { t } = useTranslation();
const { showToast } = useToast();
const [routingConfig, setRoutingConfig] = useState<RoutingConfig>({
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
skipAuth: false,
});
const [tempRoutingConfig, setTempRoutingConfig] = useState<TempRoutingConfig>({
bearerAuthKey: '',
});
const [installConfig, setInstallConfig] = useState<InstallConfig>({
pythonIndexUrl: '',
npmRegistry: '',
baseUrl: 'http://localhost:3000',
});
const [smartRoutingConfig, setSmartRoutingConfig] = useState<SmartRoutingConfig>({
enabled: false,
dbUrl: '',
openaiApiBaseUrl: '',
openaiApiKey: '',
openaiApiEmbeddingModel: '',
});
const [mcpRouterConfig, setMCPRouterConfig] = useState<MCPRouterConfig>({
apiKey: '',
referer: 'https://mcphub.app',
title: 'MCPHub',
baseUrl: 'https://api.mcprouter.to/v1',
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
// Trigger a refresh of the settings data
const triggerRefresh = useCallback(() => {
setRefreshKey((prev) => prev + 1);
}, []);
// Fetch current settings
const fetchSettings = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data: ApiResponse<SystemSettings> = await apiGet('/settings');
if (data.success && data.data?.systemConfig?.routing) {
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 || '',
skipAuth: data.data.systemConfig.routing.skipAuth ?? false,
});
}
if (data.success && data.data?.systemConfig?.install) {
setInstallConfig({
pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '',
npmRegistry: data.data.systemConfig.install.npmRegistry || '',
baseUrl: data.data.systemConfig.install.baseUrl || 'http://localhost:3000',
});
}
if (data.success && data.data?.systemConfig?.smartRouting) {
setSmartRoutingConfig({
enabled: data.data.systemConfig.smartRouting.enabled ?? false,
dbUrl: data.data.systemConfig.smartRouting.dbUrl || '',
openaiApiBaseUrl: data.data.systemConfig.smartRouting.openaiApiBaseUrl || '',
openaiApiKey: data.data.systemConfig.smartRouting.openaiApiKey || '',
openaiApiEmbeddingModel:
data.data.systemConfig.smartRouting.openaiApiEmbeddingModel || '',
});
}
if (data.success && data.data?.systemConfig?.mcpRouter) {
setMCPRouterConfig({
apiKey: data.data.systemConfig.mcpRouter.apiKey || '',
referer: data.data.systemConfig.mcpRouter.referer || 'https://mcphub.app',
title: data.data.systemConfig.mcpRouter.title || 'MCPHub',
baseUrl: data.data.systemConfig.mcpRouter.baseUrl || 'https://api.mcprouter.to/v1',
});
}
} catch (error) {
console.error('Failed to fetch settings:', error);
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
// 使用一个稳定的 showToast 引用,避免将其加入依赖数组
showToast(t('errors.failedToFetchSettings'));
} finally {
setLoading(false);
}
}, [t]); // 移除 showToast 依赖
// Update routing configuration
const updateRoutingConfig = async (key: keyof RoutingConfig, value: any) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
routing: {
[key]: value,
},
});
if (data.success) {
setRoutingConfig({
...routingConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || 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 data = await apiPut('/system-config', {
install: {
[key]: value,
},
});
if (data.success) {
setInstallConfig({
...installConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
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'));
return false;
} finally {
setLoading(false);
}
};
// Update smart routing configuration
const updateSmartRoutingConfig = async <T extends keyof SmartRoutingConfig>(
key: T,
value: SmartRoutingConfig[T],
) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
smartRouting: {
[key]: value,
},
});
if (data.success) {
setSmartRoutingConfig({
...smartRoutingConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSmartRoutingConfig'));
return false;
}
} catch (error) {
console.error('Failed to update smart routing config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update smart routing config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Update multiple smart routing configuration fields at once
const updateSmartRoutingConfigBatch = async (updates: Partial<SmartRoutingConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
smartRouting: updates,
});
if (data.success) {
setSmartRoutingConfig({
...smartRoutingConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSmartRoutingConfig'));
return false;
}
} catch (error) {
console.error('Failed to update smart routing config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update smart routing config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Update multiple routing configuration fields at once
const updateRoutingConfigBatch = async (updates: Partial<RoutingConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
routing: updates,
});
if (data.success) {
setRoutingConfig({
...routingConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || 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 MCPRouter configuration
const updateMCPRouterConfig = async <T extends keyof MCPRouterConfig>(
key: T,
value: MCPRouterConfig[T],
) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
mcpRouter: {
[key]: value,
},
});
if (data.success) {
setMCPRouterConfig({
...mcpRouterConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update MCPRouter config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update MCPRouter config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Update multiple MCPRouter configuration fields at once
const updateMCPRouterConfigBatch = async (updates: Partial<MCPRouterConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
mcpRouter: updates,
});
if (data.success) {
setMCPRouterConfig({
...mcpRouterConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update MCPRouter config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update MCPRouter config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Fetch settings when the component mounts or refreshKey changes
useEffect(() => {
fetchSettings();
}, [fetchSettings, refreshKey]);
useEffect(() => {
if (routingConfig) {
setTempRoutingConfig({
bearerAuthKey: routingConfig.bearerAuthKey,
});
}
}, [routingConfig]);
return {
routingConfig,
tempRoutingConfig,
setTempRoutingConfig,
installConfig,
smartRoutingConfig,
mcpRouterConfig,
loading,
error,
setError,
triggerRefresh,
fetchSettings,
updateRoutingConfig,
updateInstallConfig,
updateSmartRoutingConfig,
updateSmartRoutingConfigBatch,
updateRoutingConfigBatch,
updateMCPRouterConfig,
updateMCPRouterConfigBatch,
};
return useSettings();
};

View File

@@ -0,0 +1,100 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { User, ApiResponse, UserFormData, UserUpdateData } from '@/types';
import { apiDelete, apiGet, apiPost, apiPut } from '../utils/fetchInterceptor';
export const useUserData = () => {
const { t } = useTranslation();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const fetchUsers = useCallback(async () => {
try {
setLoading(true);
const data: ApiResponse<User[]> = await apiGet('/users');
if (!data.success) {
setError(data.message || t('users.fetchError'));
return;
}
if (data && data.success && Array.isArray(data.data)) {
setUsers(data.data);
} else {
console.error('Invalid user data format:', data);
setUsers([]);
}
setError(null);
} catch (err) {
console.error('Error fetching users:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch users');
setUsers([]);
} finally {
setLoading(false);
}
}, []);
// Trigger a refresh of the users data
const triggerRefresh = useCallback(() => {
setRefreshKey((prev) => prev + 1);
}, []);
// Create a new user
const createUser = async (userData: UserFormData) => {
try {
const result: ApiResponse<User> = await apiPost('/users', userData);
triggerRefresh();
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create user');
return null;
}
};
// Update an existing user
const updateUser = async (username: string, data: UserUpdateData) => {
try {
const result: ApiResponse<User> = await apiPut(`/users/${username}`, data);
triggerRefresh();
return result || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update user');
return null;
}
};
// Delete a user
const deleteUser = async (username: string) => {
try {
const result = await apiDelete(`/users/${username}`);
if (!result?.success) {
setError(result?.message || t('users.deleteError'));
return result;
}
triggerRefresh();
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete user');
return false;
}
};
// Fetch users when the component mounts or refreshKey changes
useEffect(() => {
fetchUsers();
}, [fetchUsers, refreshKey]);
return {
users,
loading,
error,
setError,
triggerRefresh,
createUser,
updateUser,
deleteUser,
};
};

View File

@@ -5,6 +5,8 @@ import LanguageDetector from 'i18next-browser-languagedetector';
// Import shared translations from root locales directory
import enTranslation from '../../locales/en.json';
import zhTranslation from '../../locales/zh.json';
import frTranslation from '../../locales/fr.json';
import trTranslation from '../../locales/tr.json';
i18n
// Detect user language
@@ -20,6 +22,12 @@ i18n
zh: {
translation: zhTranslation,
},
fr: {
translation: frTranslation,
},
tr: {
translation: trTranslation,
},
},
fallbackLng: 'en',
debug: process.env.NODE_ENV === 'development',

View File

@@ -144,6 +144,18 @@ body {
border: 1px solid rgba(255, 193, 7, 0.3);
}
.status-badge-oauth-required {
background-color: white !important;
color: rgba(156, 39, 176, 0.9) !important;
border: 1px solid #ba68c8;
}
.dark .status-badge-oauth-required {
background-color: rgba(156, 39, 176, 0.15) !important;
color: rgba(186, 104, 200, 0.9) !important;
border: 1px solid rgba(156, 39, 176, 0.3);
}
/* Enhanced status icons for dark theme */
.dark .status-icon-blue {
background-color: rgba(59, 130, 246, 0.15) !important;

View File

@@ -1,25 +1,28 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useServerData } from '@/hooks/useServerData';
import { Server } from '@/types';
const DashboardPage: React.FC = () => {
const { t } = useTranslation();
const { servers, error, setError, isLoading } = useServerData();
const { servers, error, setError, isLoading } = useServerData({ refreshOnMount: true });
// Calculate server statistics
const serverStats = {
total: servers.length,
online: servers.filter(server => server.status === 'connected').length,
offline: servers.filter(server => server.status === 'disconnected').length,
connecting: servers.filter(server => server.status === 'connecting').length
online: servers.filter((server: Server) => server.status === 'connected').length,
offline: servers.filter((server: Server) => server.status === 'disconnected').length,
connecting: servers.filter((server: Server) => server.status === 'connecting').length,
oauthRequired: servers.filter((server: Server) => server.status === 'oauth_required').length,
};
// Map status to translation keys
const statusTranslations = {
const statusTranslations: Record<string, string> = {
connected: 'status.online',
disconnected: 'status.offline',
connecting: 'status.connecting'
}
connecting: 'status.connecting',
oauth_required: 'status.oauthRequired',
};
return (
<div>
@@ -37,8 +40,17 @@ const DashboardPage: React.FC = () => {
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200"
aria-label={t('app.closeButton')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
@@ -48,9 +60,25 @@ const DashboardPage: React.FC = () => {
{isLoading && (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
<div className="flex flex-col items-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<svg
className="animate-spin h-10 w-10 text-blue-500 mb-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p className="text-gray-600">{t('app.loading')}</p>
</div>
@@ -63,12 +91,25 @@ const DashboardPage: React.FC = () => {
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-blue-100 text-blue-800 icon-container status-icon-blue">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
</svg>
</div>
<div className="ml-4">
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.totalServers')}</h2>
<h2 className="text-xl font-semibold text-gray-700">
{t('pages.dashboard.totalServers')}
</h2>
<p className="text-3xl font-bold text-gray-900">{serverStats.total}</p>
</div>
</div>
@@ -78,12 +119,25 @@ const DashboardPage: React.FC = () => {
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-green-100 text-green-800 icon-container status-icon-green">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="ml-4">
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.onlineServers')}</h2>
<h2 className="text-xl font-semibold text-gray-700">
{t('pages.dashboard.onlineServers')}
</h2>
<p className="text-3xl font-bold text-gray-900">{serverStats.online}</p>
</div>
</div>
@@ -93,12 +147,25 @@ const DashboardPage: React.FC = () => {
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-red-100 text-red-800 icon-container status-icon-red">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="ml-4">
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.offlineServers')}</h2>
<h2 className="text-xl font-semibold text-gray-700">
{t('pages.dashboard.offlineServers')}
</h2>
<p className="text-3xl font-bold text-gray-900">{serverStats.offline}</p>
</div>
</div>
@@ -108,16 +175,28 @@ const DashboardPage: React.FC = () => {
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800 icon-container status-icon-yellow">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="ml-4">
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.connectingServers')}</h2>
<h2 className="text-xl font-semibold text-gray-700">
{t('pages.dashboard.connectingServers')}
</h2>
<p className="text-3xl font-bold text-gray-900">{serverStats.connecting}</p>
</div>
</div>
</div>
</div>
)}
@@ -125,24 +204,41 @@ const DashboardPage: React.FC = () => {
{/* Recent activity list */}
{servers.length > 0 && !isLoading && (
<div className="mt-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>
<h2 className="text-xl font-semibold text-gray-900 mb-4">
{t('pages.dashboard.recentServers')}
</h2>
<div className="bg-white shadow rounded-lg overflow-hidden table-container">
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
scope="col"
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{t('server.name')}
</th>
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
scope="col"
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{t('server.status')}
</th>
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
scope="col"
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{t('server.tools')}
</th>
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
scope="col"
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{t('server.prompts')}
</th>
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
scope="col"
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{t('server.enabled')}
</th>
</tr>
@@ -154,12 +250,18 @@ const DashboardPage: React.FC = () => {
{server.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
? 'status-badge-online'
: server.status === 'disconnected'
? 'status-badge-offline'
: 'status-badge-connecting'
}`}>
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
server.status === 'connected'
? 'status-badge-online'
: server.status === 'disconnected'
? 'status-badge-offline'
: server.status === 'oauth_required'
? 'status-badge-oauth-required'
: 'status-badge-connecting'
}`}
>
{server.status === 'oauth_required' && '🔐 '}
{t(statusTranslations[server.status] || server.status)}
</span>
</td>
@@ -187,4 +289,4 @@ const DashboardPage: React.FC = () => {
);
};
export default DashboardPage;
export default DashboardPage;

View File

@@ -6,6 +6,7 @@ import { useServerData } from '@/hooks/useServerData';
import AddGroupForm from '@/components/AddGroupForm';
import EditGroupForm from '@/components/EditGroupForm';
import GroupCard from '@/components/GroupCard';
import GroupImportForm from '@/components/GroupImportForm';
const GroupsPage: React.FC = () => {
const { t } = useTranslation();
@@ -15,12 +16,13 @@ const GroupsPage: React.FC = () => {
error: groupError,
setError: setGroupError,
deleteGroup,
triggerRefresh
triggerRefresh,
} = useGroupData();
const { servers } = useServerData();
const { servers } = useServerData({ refreshOnMount: true });
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [showImportForm, setShowImportForm] = useState(false);
const handleEditClick = (group: Group) => {
setEditingGroup(group);
@@ -47,6 +49,11 @@ const GroupsPage: React.FC = () => {
triggerRefresh(); // Refresh the groups list after adding
};
const handleImportSuccess = () => {
setShowImportForm(false);
triggerRefresh(); // Refresh the groups list after import
};
return (
<div>
<div className="flex justify-between items-center mb-8">
@@ -56,11 +63,38 @@ const GroupsPage: React.FC = () => {
onClick={handleAddGroup}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" />
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
{t('groups.add')}
</button>
<button
onClick={() => setShowImportForm(true)}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
{t('groupImport.button')}
</button>
</div>
</div>
@@ -73,9 +107,25 @@ const GroupsPage: React.FC = () => {
{groupsLoading ? (
<div className="bg-white shadow rounded-lg p-6 loading-container">
<div className="flex flex-col items-center justify-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<svg
className="animate-spin h-10 w-10 text-blue-500 mb-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p className="text-gray-600">{t('app.loading')}</p>
</div>
@@ -98,8 +148,13 @@ const GroupsPage: React.FC = () => {
</div>
)}
{showAddForm && (
<AddGroupForm onAdd={handleAddComplete} onCancel={handleAddComplete} />
{showAddForm && <AddGroupForm onAdd={handleAddComplete} onCancel={handleAddComplete} />}
{showImportForm && (
<GroupImportForm
onSuccess={handleImportSuccess}
onCancel={() => setShowImportForm(false)}
/>
)}
{editingGroup && (
@@ -113,4 +168,4 @@ const GroupsPage: React.FC = () => {
);
};
export default GroupsPage;
export default GroupsPage;

View File

@@ -1,9 +1,33 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import React, { useState, useMemo, useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext';
import { getToken } from '../services/authService';
import ThemeSwitch from '@/components/ui/ThemeSwitch';
import LanguageSwitch from '@/components/ui/LanguageSwitch';
import DefaultPasswordWarningModal from '@/components/ui/DefaultPasswordWarningModal';
const sanitizeReturnUrl = (value: string | null): string | null => {
if (!value) {
return null;
}
try {
// Support both relative paths and absolute URLs on the same origin
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
const url = new URL(value, origin);
if (url.origin !== origin) {
return null;
}
const relativePath = `${url.pathname}${url.search}${url.hash}`;
return relativePath || '/';
} catch {
if (value.startsWith('/') && !value.startsWith('//')) {
return value;
}
return null;
}
};
const LoginPage: React.FC = () => {
const { t } = useTranslation();
@@ -11,8 +35,48 @@ const LoginPage: React.FC = () => {
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showDefaultPasswordWarning, setShowDefaultPasswordWarning] = useState(false);
const { login } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const returnUrl = useMemo(() => {
const params = new URLSearchParams(location.search);
return sanitizeReturnUrl(params.get('returnUrl'));
}, [location.search]);
const buildRedirectTarget = useCallback(() => {
if (!returnUrl) {
return '/';
}
// Only attach JWT when returning to the OAuth authorize endpoint
if (!returnUrl.startsWith('/oauth/authorize')) {
return returnUrl;
}
const token = getToken();
if (!token) {
return returnUrl;
}
try {
const origin = window.location.origin;
const url = new URL(returnUrl, origin);
url.searchParams.set('token', token);
return `${url.pathname}${url.search}${url.hash}`;
} catch {
const separator = returnUrl.includes('?') ? '&' : '?';
return `${returnUrl}${separator}token=${encodeURIComponent(token)}`;
}
}, [returnUrl]);
const redirectAfterLogin = useCallback(() => {
if (returnUrl) {
window.location.assign(buildRedirectTarget());
} else {
navigate('/');
}
}, [buildRedirectTarget, navigate, returnUrl]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -26,10 +90,15 @@ const LoginPage: React.FC = () => {
return;
}
const success = await login(username, password);
const result = await login(username, password);
if (success) {
navigate('/');
if (result.success) {
if (result.isUsingDefaultPassword) {
// Show warning modal instead of navigating immediately
setShowDefaultPasswordWarning(true);
} else {
redirectAfterLogin();
}
} else {
setError(t('auth.loginFailed'));
}
@@ -40,6 +109,11 @@ const LoginPage: React.FC = () => {
}
};
const handleCloseWarning = () => {
setShowDefaultPasswordWarning(false);
redirectAfterLogin();
};
return (
<div className="relative min-h-screen w-full overflow-hidden bg-gray-50 dark:bg-gray-950">
{/* Top-right controls */}
@@ -69,10 +143,10 @@ const LoginPage: React.FC = () => {
{/* Main content */}
<div className="relative mx-auto flex min-h-screen w-full max-w-md items-center justify-center px-6 py-16">
<div className="w-full space-y-6">
<div className="w-full space-y-16">
{/* Centered slogan */}
<div className="flex justify-center w-full">
<h1 className="text-3xl font-extrabold leading-tight tracking-tight text-gray-900 dark:text-white sm:text-4xl whitespace-nowrap">
<h1 className="text-5xl sm:text-5xl font-extrabold leading-tight tracking-tight text-gray-900 dark:text-white whitespace-nowrap">
<span className="bg-gradient-to-r from-indigo-400 via-cyan-400 to-emerald-400 bg-clip-text text-transparent">
{t('auth.slogan')}
</span>
@@ -138,8 +212,14 @@ const LoginPage: React.FC = () => {
</div>
</div>
</div>
{/* Default Password Warning Modal */}
<DefaultPasswordWarningModal
isOpen={showDefaultPasswordWarning}
onClose={handleCloseWarning}
/>
</div>
);
};
export default LoginPage;
export default LoginPage;

View File

@@ -1,17 +1,27 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { MarketServer, CloudServer, ServerConfig } from '@/types';
import {
MarketServer,
CloudServer,
ServerConfig,
RegistryServerEntry,
RegistryServerData,
} from '@/types';
import { useMarketData } from '@/hooks/useMarketData';
import { useCloudData } from '@/hooks/useCloudData';
import { useRegistryData } from '@/hooks/useRegistryData';
import { useToast } from '@/contexts/ToastContext';
import { apiPost } from '@/utils/fetchInterceptor';
import MarketServerCard from '@/components/MarketServerCard';
import MarketServerDetail from '@/components/MarketServerDetail';
import CloudServerCard from '@/components/CloudServerCard';
import CloudServerDetail from '@/components/CloudServerDetail';
import RegistryServerCard from '@/components/RegistryServerCard';
import RegistryServerDetail from '@/components/RegistryServerDetail';
import MCPRouterApiKeyError from '@/components/MCPRouterApiKeyError';
import Pagination from '@/components/ui/Pagination';
import CursorPagination from '@/components/ui/CursorPagination';
const MarketPage: React.FC = () => {
const { t } = useTranslation();
@@ -19,7 +29,7 @@ const MarketPage: React.FC = () => {
const { serverName } = useParams<{ serverName?: string }>();
const { showToast } = useToast();
// Get tab from URL search params, default to cloud market
// Get tab from URL search params
const [searchParams, setSearchParams] = useSearchParams();
const currentTab = searchParams.get('tab') || 'cloud';
@@ -44,10 +54,10 @@ const MarketPage: React.FC = () => {
totalPages: localTotalPages,
changePage: changeLocalPage,
serversPerPage: localServersPerPage,
changeServersPerPage: changeLocalServersPerPage
changeServersPerPage: changeLocalServersPerPage,
} = useMarketData();
// Cloud market data
// Cloud market data
const {
servers: cloudServers,
allServers: allCloudServers,
@@ -61,29 +71,67 @@ const MarketPage: React.FC = () => {
totalPages: cloudTotalPages,
changePage: changeCloudPage,
serversPerPage: cloudServersPerPage,
changeServersPerPage: changeCloudServersPerPage
changeServersPerPage: changeCloudServersPerPage,
} = useCloudData();
// Registry data
const {
servers: registryServers,
allServers: allRegistryServers,
loading: registryLoading,
error: registryError,
setError: setRegistryError,
searchServers: searchRegistryServers,
clearSearch: clearRegistrySearch,
fetchServerByName: fetchRegistryServerByName,
fetchServerVersions: fetchRegistryServerVersions,
// Cursor-based pagination
currentPage: registryCurrentPage,
totalPages: registryTotalPages,
hasNextPage: registryHasNextPage,
hasPreviousPage: registryHasPreviousPage,
changePage: changeRegistryPage,
goToNextPage: goToRegistryNextPage,
goToPreviousPage: goToRegistryPreviousPage,
serversPerPage: registryServersPerPage,
changeServersPerPage: changeRegistryServersPerPage,
} = useRegistryData();
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
const [selectedCloudServer, setSelectedCloudServer] = useState<CloudServer | null>(null);
const [selectedRegistryServer, setSelectedRegistryServer] = useState<RegistryServerEntry | null>(
null,
);
const [searchQuery, setSearchQuery] = useState('');
const [registrySearchQuery, setRegistrySearchQuery] = useState('');
const [installing, setInstalling] = useState(false);
const [installedCloudServers, setInstalledCloudServers] = useState<Set<string>>(new Set());
const [installedRegistryServers, setInstalledRegistryServers] = useState<Set<string>>(new Set());
// Load server details if a server name is in the URL
useEffect(() => {
const loadServerDetails = async () => {
if (serverName) {
// Determine if it's a cloud or local server based on the current tab
// Determine if it's a cloud, local, or registry server based on the current tab
if (currentTab === 'cloud') {
// Try to find the server in cloud servers
const server = cloudServers.find(s => s.name === serverName);
const server = cloudServers.find((s) => s.name === serverName);
if (server) {
setSelectedCloudServer(server);
} else {
// If server not found, navigate back to market page
navigate('/market?tab=cloud');
}
} else if (currentTab === 'registry') {
console.log('Loading registry server details for:', serverName);
// Registry market
const serverEntry = await fetchRegistryServerByName(serverName);
if (serverEntry) {
setSelectedRegistryServer(serverEntry);
} else {
// If server not found, navigate back to market page
navigate('/market?tab=registry');
}
} else {
// Local market
const server = await fetchLocalServerByName(serverName);
@@ -97,14 +145,22 @@ const MarketPage: React.FC = () => {
} else {
setSelectedServer(null);
setSelectedCloudServer(null);
setSelectedRegistryServer(null);
}
};
loadServerDetails();
}, [serverName, currentTab, cloudServers, fetchLocalServerByName, navigate]);
}, [
serverName,
currentTab,
cloudServers,
fetchLocalServerByName,
fetchRegistryServerByName,
navigate,
]);
// Tab switching handler
const switchTab = (tab: 'local' | 'cloud') => {
const switchTab = (tab: 'local' | 'cloud' | 'registry') => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set('tab', tab);
setSearchParams(newSearchParams);
@@ -118,6 +174,8 @@ const MarketPage: React.FC = () => {
e.preventDefault();
if (currentTab === 'local') {
searchLocalServers(searchQuery);
} else if (currentTab === 'registry') {
searchRegistryServers(registrySearchQuery);
}
// Cloud search is not implemented in the original cloud page
};
@@ -129,18 +187,35 @@ const MarketPage: React.FC = () => {
};
const handleClearFilters = () => {
setSearchQuery('');
if (currentTab === 'local') {
setSearchQuery('');
filterLocalByCategory('');
filterLocalByTag('');
} else if (currentTab === 'registry') {
setRegistrySearchQuery('');
clearRegistrySearch();
}
};
const handleServerClick = (server: MarketServer | CloudServer) => {
const handleServerClick = (server: MarketServer | CloudServer | RegistryServerEntry) => {
if (currentTab === 'cloud') {
navigate(`/market/${server.name}?tab=cloud`);
const cloudServer = server as CloudServer;
navigate(`/market/${cloudServer.name}?tab=cloud`);
} else if (currentTab === 'registry') {
const registryServer = server as RegistryServerEntry;
console.log('Registry server clicked:', registryServer);
const serverName = registryServer.server?.name;
console.log('Server name extracted:', serverName);
if (serverName) {
const targetUrl = `/market/${encodeURIComponent(serverName)}?tab=registry`;
console.log('Navigating to:', targetUrl);
navigate(targetUrl);
} else {
console.error('Server name is undefined in registry server:', registryServer);
}
} else {
navigate(`/market/${server.name}?tab=local`);
const marketServer = server as MarketServer;
navigate(`/market/${marketServer.name}?tab=local`);
}
};
@@ -167,7 +242,7 @@ const MarketPage: React.FC = () => {
const payload = {
name: server.name,
config: config
config: config,
};
const result = await apiPost('/servers', payload);
@@ -179,9 +254,8 @@ const MarketPage: React.FC = () => {
}
// Update installed servers set
setInstalledCloudServers(prev => new Set(prev).add(server.name));
setInstalledCloudServers((prev) => new Set(prev).add(server.name));
showToast(t('cloud.installSuccess', { name: server.title || server.name }), 'success');
} catch (error) {
console.error('Error installing cloud server:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -191,7 +265,41 @@ const MarketPage: React.FC = () => {
}
};
const handleCallTool = async (serverName: string, toolName: string, args: Record<string, any>) => {
// Handle registry server installation
const handleRegistryInstall = async (server: RegistryServerData, config: ServerConfig) => {
try {
setInstalling(true);
const payload = {
name: server.name,
config: config,
};
const result = await apiPost('/servers', payload);
if (!result.success) {
const errorMessage = result?.message || t('server.addError');
showToast(errorMessage, 'error');
return;
}
// Update installed servers set
setInstalledRegistryServers((prev) => new Set(prev).add(server.name));
showToast(t('registry.installSuccess', { name: server.title || server.name }), 'success');
} catch (error) {
console.error('Error installing registry server:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
showToast(t('registry.installError', { error: errorMessage }), 'error');
} finally {
setInstalling(false);
}
};
const handleCallTool = async (
serverName: string,
toolName: string,
args: Record<string, any>,
) => {
try {
const result = await callServerTool(serverName, toolName, args);
showToast(t('cloud.toolCallSuccess', { toolName }), 'success');
@@ -208,13 +316,17 @@ const MarketPage: React.FC = () => {
// Helper function to check if error is MCPRouter API key not configured
const isMCPRouterApiKeyError = (errorMessage: string) => {
return errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
errorMessage.toLowerCase().includes('mcprouter api key not configured');
return (
errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
errorMessage.toLowerCase().includes('mcprouter api key not configured')
);
};
const handlePageChange = (page: number) => {
if (currentTab === 'local') {
changeLocalPage(page);
} else if (currentTab === 'registry') {
changeRegistryPage(page);
} else {
changeCloudPage(page);
}
@@ -226,6 +338,8 @@ const MarketPage: React.FC = () => {
const newValue = parseInt(e.target.value, 10);
if (currentTab === 'local') {
changeLocalServersPerPage(newValue);
} else if (currentTab === 'registry') {
changeRegistryServersPerPage(newValue);
} else {
changeCloudServersPerPage(newValue);
}
@@ -259,19 +373,50 @@ const MarketPage: React.FC = () => {
);
}
// Render registry server detail if selected
if (selectedRegistryServer) {
return (
<RegistryServerDetail
serverEntry={selectedRegistryServer}
onBack={handleBackToList}
onInstall={handleRegistryInstall}
installing={installing}
isInstalled={installedRegistryServers.has(selectedRegistryServer.server.name)}
fetchVersions={fetchRegistryServerVersions}
/>
);
}
// Get current data based on active tab
const isLocalTab = currentTab === 'local';
const servers = isLocalTab ? localServers : cloudServers;
const allServers = isLocalTab ? allLocalServers : allCloudServers;
const isRegistryTab = currentTab === 'registry';
const servers = isLocalTab ? localServers : isRegistryTab ? registryServers : cloudServers;
const allServers = isLocalTab
? allLocalServers
: isRegistryTab
? allRegistryServers
: allCloudServers;
const categories = isLocalTab ? localCategories : [];
const loading = isLocalTab ? localLoading : cloudLoading;
const error = isLocalTab ? localError : cloudError;
const setError = isLocalTab ? setLocalError : setCloudError;
const loading = isLocalTab ? localLoading : isRegistryTab ? registryLoading : cloudLoading;
const error = isLocalTab ? localError : isRegistryTab ? registryError : cloudError;
const setError = isLocalTab ? setLocalError : isRegistryTab ? setRegistryError : setCloudError;
const selectedCategory = isLocalTab ? selectedLocalCategory : '';
const selectedTag = isLocalTab ? selectedLocalTag : '';
const currentPage = isLocalTab ? localCurrentPage : cloudCurrentPage;
const totalPages = isLocalTab ? localTotalPages : cloudTotalPages;
const serversPerPage = isLocalTab ? localServersPerPage : cloudServersPerPage;
const currentPage = isLocalTab
? localCurrentPage
: isRegistryTab
? registryCurrentPage
: cloudCurrentPage;
const totalPages = isLocalTab
? localTotalPages
: isRegistryTab
? registryTotalPages
: cloudTotalPages;
const serversPerPage = isLocalTab
? localServersPerPage
: isRegistryTab
? registryServersPerPage
: cloudServersPerPage;
return (
<div>
@@ -281,13 +426,15 @@ const MarketPage: React.FC = () => {
<nav className="-mb-px flex space-x-3">
<button
onClick={() => switchTab('cloud')}
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${!isLocalTab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
!isLocalTab && !isRegistryTab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{t('cloud.title')}
<span className="text-xs text-gray-400 font-normal ml-1">(
<span className="text-xs text-gray-400 font-normal ml-1">
(
<a
href="https://mcprouter.co"
target="_blank"
@@ -301,13 +448,15 @@ const MarketPage: React.FC = () => {
</button>
<button
onClick={() => switchTab('local')}
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${isLocalTab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
isLocalTab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{t('market.title')}
<span className="text-xs text-gray-400 font-normal ml-1">(
<span className="text-xs text-gray-400 font-normal ml-1">
(
<a
href="https://mcpm.sh"
target="_blank"
@@ -319,6 +468,28 @@ const MarketPage: React.FC = () => {
)
</span>
</button>
<button
onClick={() => switchTab('registry')}
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
isRegistryTab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{t('registry.title')}
<span className="text-xs text-gray-400 font-normal ml-1">
(
<a
href="https://registry.modelcontextprotocol.io"
target="_blank"
rel="noopener noreferrer"
className="external-link"
>
{t('registry.official')}
</a>
)
</span>
</button>
</nav>
</div>
</div>
@@ -335,8 +506,17 @@ const MarketPage: React.FC = () => {
onClick={() => setError(null)}
className="text-red-700 hover:text-red-900 transition-colors duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
@@ -345,16 +525,24 @@ const MarketPage: React.FC = () => {
</>
)}
{/* Search bar for local market only */}
{isLocalTab && (
{/* Search bar for local market and registry */}
{(isLocalTab || isRegistryTab) && (
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
<div className="flex-grow">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('market.searchPlaceholder')}
value={isRegistryTab ? registrySearchQuery : searchQuery}
onChange={(e) => {
if (isRegistryTab) {
setRegistrySearchQuery(e.target.value);
} else {
setSearchQuery(e.target.value);
}
}}
placeholder={
isRegistryTab ? t('registry.searchPlaceholder') : t('market.searchPlaceholder')
}
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
/>
</div>
@@ -362,15 +550,16 @@ const MarketPage: React.FC = () => {
type="submit"
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
{t('market.search')}
{isRegistryTab ? t('registry.search') : t('market.search')}
</button>
{(searchQuery || selectedCategory || selectedTag) && (
{((isLocalTab && (searchQuery || selectedCategory || selectedTag)) ||
(isRegistryTab && registrySearchQuery)) && (
<button
type="button"
onClick={handleClearFilters}
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
>
{t('market.clearFilters')}
{isRegistryTab ? t('registry.clearFilters') : t('market.clearFilters')}
</button>
)}
</form>
@@ -388,7 +577,10 @@ const MarketPage: React.FC = () => {
<div className="flex justify-between items-center mb-3">
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
{selectedCategory && (
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterLocalByCategory('')}>
<span
className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200"
onClick={() => filterLocalByCategory('')}
>
{t('market.clearCategoryFilter')}
</span>
)}
@@ -398,10 +590,11 @@ const MarketPage: React.FC = () => {
<button
key={category}
onClick={() => handleCategoryClick(category)}
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${selectedCategory === category
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
}`}
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${
selectedCategory === category
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
}`}
>
{category}
</button>
@@ -414,9 +607,25 @@ const MarketPage: React.FC = () => {
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
</div>
<div className="flex flex-col gap-2 items-center py-4 loading-container">
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<svg
className="animate-spin h-6 w-6 text-blue-500 mb-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p className="text-sm text-gray-600">{t('app.loading')}</p>
</div>
@@ -438,61 +647,110 @@ const MarketPage: React.FC = () => {
{loading ? (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
<div className="flex flex-col items-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<svg
className="animate-spin h-10 w-10 text-blue-500 mb-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p className="text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : servers.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<p className="text-gray-600">{isLocalTab ? t('market.noServers') : t('cloud.noServers')}</p>
<p className="text-gray-600">
{isLocalTab
? t('market.noServers')
: isRegistryTab
? t('registry.noServers')
: t('cloud.noServers')}
</p>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{servers.map((server, index) => (
{servers.map((server, index) =>
isLocalTab ? (
<MarketServerCard
key={index}
server={server as MarketServer}
onClick={handleServerClick}
/>
) : isRegistryTab ? (
<RegistryServerCard
key={index}
serverEntry={server as RegistryServerEntry}
onClick={handleServerClick}
/>
) : (
<CloudServerCard
key={index}
server={server as CloudServer}
onClick={handleServerClick}
/>
)
))}
),
)}
</div>
<div className="flex justify-between items-center mb-4">
<div className="text-sm text-gray-500">
{isLocalTab ? (
t('market.showing', {
from: (currentPage - 1) * serversPerPage + 1,
to: Math.min(currentPage * serversPerPage, allServers.length),
total: allServers.length
})
<div className="flex items-center mb-4">
<div className="flex-[2] text-sm text-gray-500">
{isLocalTab
? t('market.showing', {
from: (currentPage - 1) * serversPerPage + 1,
to: Math.min(currentPage * serversPerPage, allServers.length),
total: allServers.length,
})
: isRegistryTab
? t('registry.showing', {
from: (currentPage - 1) * serversPerPage + 1,
to: (currentPage - 1) * serversPerPage + servers.length,
total: allServers.length + (registryHasNextPage ? '+' : ''),
})
: t('cloud.showing', {
from: (currentPage - 1) * serversPerPage + 1,
to: Math.min(currentPage * serversPerPage, allServers.length),
total: allServers.length,
})}
</div>
<div className="flex-[4] flex justify-center">
{isRegistryTab ? (
<CursorPagination
currentPage={currentPage}
hasNextPage={registryHasNextPage}
hasPreviousPage={registryHasPreviousPage}
onNextPage={goToRegistryNextPage}
onPreviousPage={goToRegistryPreviousPage}
/>
) : (
t('cloud.showing', {
from: (currentPage - 1) * serversPerPage + 1,
to: Math.min(currentPage * serversPerPage, allServers.length),
total: allServers.length
})
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
)}
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
<div className="flex items-center space-x-2">
<div className="flex-[2] flex items-center justify-end space-x-2">
<label htmlFor="perPage" className="text-sm text-gray-600">
{isLocalTab ? t('market.perPage') : t('cloud.perPage')}:
{isLocalTab
? t('market.perPage')
: isRegistryTab
? t('registry.perPage')
: t('cloud.perPage')}
:
</label>
<select
id="perPage"
@@ -507,9 +765,6 @@ const MarketPage: React.FC = () => {
</select>
</div>
</div>
<div className="mt-6">
</div>
</>
)}
</div>

View File

@@ -7,6 +7,7 @@ import AddServerForm from '@/components/AddServerForm';
import EditServerForm from '@/components/EditServerForm';
import { useServerData } from '@/hooks/useServerData';
import DxtUploadForm from '@/components/DxtUploadForm';
import JSONImportForm from '@/components/JSONImportForm';
const ServersPage: React.FC = () => {
const { t } = useTranslation();
@@ -20,11 +21,13 @@ const ServersPage: React.FC = () => {
handleServerEdit,
handleServerRemove,
handleServerToggle,
handleServerReload,
triggerRefresh
} = useServerData();
} = useServerData({ refreshOnMount: true });
const [editingServer, setEditingServer] = useState<Server | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [showDxtUpload, setShowDxtUpload] = useState(false);
const [showJsonImport, setShowJsonImport] = useState(false);
const handleEditClick = async (server: Server) => {
const fullServerData = await handleServerEdit(server);
@@ -55,6 +58,12 @@ const ServersPage: React.FC = () => {
triggerRefresh();
};
const handleJsonImportSuccess = () => {
// Close import dialog and refresh servers
setShowJsonImport(false);
triggerRefresh();
};
return (
<div>
<div className="flex justify-between items-center mb-8">
@@ -70,6 +79,15 @@ const ServersPage: React.FC = () => {
{t('nav.market')}
</button>
<AddServerForm onAdd={handleServerAdd} />
<button
onClick={() => setShowJsonImport(true)}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
{t('jsonImport.button')}
</button>
<button
onClick={() => setShowDxtUpload(true)}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
@@ -142,6 +160,7 @@ const ServersPage: React.FC = () => {
onEdit={handleEditClick}
onToggle={handleServerToggle}
onRefresh={triggerRefresh}
onReload={handleServerReload}
/>
))}
</div>
@@ -161,8 +180,15 @@ const ServersPage: React.FC = () => {
onCancel={() => setShowDxtUpload(false)}
/>
)}
{showJsonImport && (
<JSONImportForm
onSuccess={handleJsonImportSuccess}
onCancel={() => setShowJsonImport(false)}
/>
)}
</div>
);
};
export default ServersPage;
export default ServersPage;

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,226 @@
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { User } from '@/types';
import { useUserData } from '@/hooks/useUserData';
import { useAuth } from '@/contexts/AuthContext';
import AddUserForm from '@/components/AddUserForm';
import EditUserForm from '@/components/EditUserForm';
import { Edit, Trash, User as UserIcon } from 'lucide-react';
import DeleteDialog from '@/components/ui/DeleteDialog';
const UsersPage: React.FC = () => {
const { t } = useTranslation();
const { auth } = useAuth();
const currentUser = auth.user;
const {
users,
loading: usersLoading,
error: userError,
setError: setUserError,
deleteUser,
triggerRefresh
} = useUserData();
const [editingUser, setEditingUser] = useState<User | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [userToDelete, setUserToDelete] = useState<string | null>(null);
// Check if current user is admin
if (!currentUser?.isAdmin) {
return (
<div className="bg-white shadow rounded-lg p-6 dashboard-card">
<p className="text-red-600">{t('users.adminRequired')}</p>
</div>
);
}
const handleEditClick = (user: User) => {
setEditingUser(user);
};
const handleEditComplete = () => {
setEditingUser(null);
triggerRefresh(); // Refresh the users list after editing
};
const handleDeleteClick = (username: string) => {
setUserToDelete(username);
};
const handleConfirmDelete = async () => {
if (userToDelete) {
const result = await deleteUser(userToDelete);
if (!result?.success) {
setUserError(result?.message || t('users.deleteError'));
}
setUserToDelete(null);
}
};
const handleAddUser = () => {
setShowAddForm(true);
};
const handleAddComplete = () => {
setShowAddForm(false);
triggerRefresh(); // Refresh the users list after adding
};
return (
<div></div>
<div className="container mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">{t('pages.users.title')}</h1>
<div className="flex space-x-4">
<button
onClick={handleAddUser}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center btn-primary transition-all duration-200 shadow-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{t('users.add')}
</button>
</div>
</div>
{userError && (
<div className="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg shadow-sm">
<div className="flex justify-between items-center">
<p>{userError}</p>
<button
onClick={() => setUserError(null)}
className="text-red-500 hover:text-red-700"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
)}
{usersLoading ? (
<div className="bg-white shadow rounded-lg p-6 loading-container flex justify-center items-center h-64">
<div className="flex flex-col items-center justify-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : users.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6 empty-state dashboard-card">
<div className="flex flex-col items-center justify-center py-12">
<div className="p-4 bg-gray-100 rounded-full mb-4">
<UserIcon className="h-8 w-8 text-gray-400" />
</div>
<p className="text-gray-600 text-lg font-medium">{t('users.noUsers')}</p>
<button
onClick={handleAddUser}
className="mt-4 text-blue-600 hover:text-blue-800 font-medium"
>
{t('users.addFirst')}
</button>
</div>
</div>
) : (
<div className="bg-white shadow rounded-lg overflow-hidden table-container dashboard-card">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.username')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.role')}
</th>
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => {
const isCurrentUser = currentUser?.username === user.username;
return (
<tr key={user.username} className="hover:bg-gray-50 transition-colors duration-150">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold text-lg">
{user.username.charAt(0).toUpperCase()}
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900 flex items-center">
{user.username}
{isCurrentUser && (
<span className="ml-2 px-2 py-0.5 text-xs bg-blue-100 text-blue-800 rounded-full border border-blue-200">
{t('users.currentUser')}
</span>
)}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${user.isAdmin
? 'bg-purple-100 text-purple-800 border border-purple-200'
: 'bg-gray-100 text-gray-800 border border-gray-200'
}`}>
{user.isAdmin ? t('users.admin') : t('users.user')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-3">
<button
onClick={() => handleEditClick(user)}
className="text-blue-600 hover:text-blue-900 p-1 rounded hover:bg-blue-50 transition-colors"
title={t('users.edit')}
>
<Edit size={18} />
</button>
{!isCurrentUser && (
<button
onClick={() => handleDeleteClick(user.username)}
className="text-red-600 hover:text-red-900 p-1 rounded hover:bg-red-50 transition-colors"
title={t('users.delete')}
>
<Trash size={18} />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{showAddForm && (
<AddUserForm onAdd={handleAddComplete} onCancel={handleAddComplete} />
)}
{editingUser && (
<EditUserForm
user={editingUser}
onEdit={handleEditComplete}
onCancel={() => setEditingUser(null)}
/>
)}
<DeleteDialog
isOpen={!!userToDelete}
onClose={() => setUserToDelete(null)}
onConfirm={handleConfirmDelete}
serverName={userToDelete || ''}
isGroup={false}
isUser={true}
/>
</div>
);
};

View File

@@ -20,6 +20,7 @@ export interface SystemConfig {
openaiApiKey?: string;
openaiApiEmbeddingModel?: string;
};
nameSeparator?: string;
}
export interface PublicConfigResponse {
@@ -96,3 +97,5 @@ export const shouldSkipAuth = async (): Promise<boolean> => {
return false;
}
};

View File

@@ -59,8 +59,9 @@ export const getPrompt = async (
server?: string,
): Promise<GetPromptResult> => {
try {
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const response = await apiPost(
`/mcp/${server}/prompts/${encodeURIComponent(request.promptName)}`,
`/mcp/${encodeURIComponent(server || '')}/prompts/${encodeURIComponent(request.promptName)}`,
{
name: request.promptName,
arguments: request.arguments,
@@ -94,9 +95,13 @@ export const togglePrompt = async (
enabled: boolean,
): Promise<{ success: boolean; error?: string }> => {
try {
const response = await apiPost<any>(`/servers/${serverName}/prompts/${promptName}/toggle`, {
enabled,
});
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const response = await apiPost<any>(
`/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/toggle`,
{
enabled,
},
);
return {
success: response.success,
@@ -120,8 +125,9 @@ export const updatePromptDescription = async (
description: string,
): Promise<{ success: boolean; error?: string }> => {
try {
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const response = await apiPut<any>(
`/servers/${serverName}/prompts/${promptName}/description`,
`/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/description`,
{ description },
{
headers: {

View File

@@ -25,22 +25,18 @@ export const callTool = async (
): Promise<ToolCallResult> => {
try {
// Construct the URL with optional server parameter
const url = server ? `/tools/call/${server}` : '/tools/call';
// URL-encode server and tool names to handle slashes in names (e.g., "com.atlassian/atlassian-mcp-server")
const url = server
? `/tools/${encodeURIComponent(server)}/${encodeURIComponent(request.toolName)}`
: '/tools/call';
const response = await apiPost<any>(
url,
{
toolName: request.toolName,
arguments: request.arguments,
const response = await apiPost<any>(url, request.arguments, {
headers: {
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`, // Add bearer auth for MCP routing
},
{
headers: {
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`, // Add bearer auth for MCP routing
},
},
);
});
if (!response.success) {
if (response.success === false) {
return {
success: false,
error: response.message || 'Tool call failed',
@@ -49,7 +45,7 @@ export const callTool = async (
return {
success: true,
content: response.data?.content || [],
content: response?.content || [],
};
} catch (error) {
console.error('Error calling tool:', error);
@@ -69,8 +65,9 @@ export const toggleTool = async (
enabled: boolean,
): Promise<{ success: boolean; error?: string }> => {
try {
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const response = await apiPost<any>(
`/servers/${serverName}/tools/${toolName}/toggle`,
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/toggle`,
{ enabled },
{
headers: {
@@ -101,8 +98,9 @@ export const updateToolDescription = async (
description: string,
): Promise<{ success: boolean; error?: string }> => {
try {
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
const response = await apiPut<any>(
`/servers/${serverName}/tools/${toolName}/description`,
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/description`,
{ description },
{
headers: {

View File

@@ -1,5 +1,5 @@
// Server status types
export type ServerStatus = 'connecting' | 'connected' | 'disconnected';
export type ServerStatus = 'connecting' | 'connected' | 'disconnected' | 'oauth_required';
// Market server types
export interface MarketServerRepository {
@@ -114,6 +114,8 @@ export interface ServerConfig {
env?: Record<string, string>;
headers?: Record<string, string>;
enabled?: boolean;
enableKeepAlive?: boolean; // Enable keep-alive for this server (requires global enable as well)
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms)
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
options?: {
@@ -121,12 +123,50 @@ export interface ServerConfig {
resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications
maxTotalTimeout?: number; // Maximum total timeout in milliseconds
}; // MCP request options configuration
// OAuth authentication for upstream MCP servers
oauth?: {
clientId?: string; // OAuth client ID
clientSecret?: string; // OAuth client secret
scopes?: string[]; // Required OAuth scopes
accessToken?: string; // Pre-obtained access token (if available)
refreshToken?: string; // Refresh token for renewing access
dynamicRegistration?: {
enabled?: boolean; // Enable/disable dynamic registration
issuer?: string; // OAuth issuer URL for discovery
registrationEndpoint?: string; // Direct registration endpoint URL
metadata?: {
client_name?: string;
client_uri?: string;
logo_uri?: string;
scope?: string;
redirect_uris?: string[];
grant_types?: string[];
response_types?: string[];
token_endpoint_auth_method?: string;
contacts?: string[];
software_id?: string;
software_version?: string;
[key: string]: any;
};
initialAccessToken?: string;
};
resource?: string; // OAuth resource parameter (RFC8707)
authorizationEndpoint?: string; // Authorization endpoint (authorization code flow)
tokenEndpoint?: string; // Token endpoint for exchanging authorization codes for tokens
pendingAuthorization?: {
authorizationUrl?: string;
state?: string;
codeVerifier?: string;
createdAt?: number;
};
};
// OpenAPI specific configuration
openapi?: {
url?: string; // OpenAPI specification URL
schema?: Record<string, any>; // Complete OpenAPI JSON schema
version?: string; // OpenAPI version (default: '3.1.0')
security?: OpenAPISecurityConfig; // Security configuration for API calls
passthroughHeaders?: string[]; // Header names to pass through from tool call requests to upstream OpenAPI endpoints
};
}
@@ -171,6 +211,10 @@ export interface Server {
prompts?: Prompt[];
config?: ServerConfig;
enabled?: boolean;
oauth?: {
authorizationUrl?: string;
state?: string;
};
}
// Group types
@@ -208,6 +252,20 @@ export interface ServerFormData {
resetTimeoutOnProgress?: boolean;
maxTotalTimeout?: number;
};
keepAlive?: {
enabled?: boolean;
interval?: number;
};
oauth?: {
clientId?: string;
clientSecret?: string;
scopes?: string;
accessToken?: string;
refreshToken?: string;
authorizationEndpoint?: string;
tokenEndpoint?: string;
resource?: string;
};
// OpenAPI specific fields
openapi?: {
url?: string;
@@ -232,6 +290,8 @@ export interface ServerFormData {
openIdConnectClientId?: string;
openIdConnectClientSecret?: string;
openIdConnectToken?: string;
// Passthrough headers
passthroughHeaders?: string; // Comma-separated list of header names
};
}
@@ -249,6 +309,19 @@ export interface ApiResponse<T = any> {
data?: T;
}
// Bearer authentication key configuration (frontend view model)
export type BearerKeyAccessType = 'all' | 'groups' | 'servers';
export interface BearerKey {
id: string;
name: string;
token: string;
enabled: boolean;
accessType: BearerKeyAccessType;
allowedGroups?: string[];
allowedServers?: string[];
}
// Auth types
export interface IUser {
username: string;
@@ -305,4 +378,150 @@ export interface AuthResponse {
token?: string;
user?: IUser;
message?: string;
isUsingDefaultPassword?: boolean;
}
// Official Registry types (from registry.modelcontextprotocol.io)
export interface RegistryVariable {
choices?: string[];
default?: string;
description?: string;
format?: string;
isRequired?: boolean;
isSecret?: boolean;
value?: string;
}
export interface RegistryVariables {
[key: string]: RegistryVariable;
}
export interface RegistryEnvironmentVariable {
choices?: string[];
default?: string;
description?: string;
format?: string;
isRequired?: boolean;
isSecret?: boolean;
name: string;
value?: string;
variables?: RegistryVariables;
}
export interface RegistryPackageArgument {
choices?: string[];
default?: string;
description?: string;
format?: string;
isRepeated?: boolean;
isRequired?: boolean;
isSecret?: boolean;
name: string;
type?: string;
value?: string;
valueHint?: string;
variables?: RegistryVariables;
}
export interface RegistryTransportHeader {
choices?: string[];
default?: string;
description?: string;
format?: string;
isRequired?: boolean;
isSecret?: boolean;
name: string;
value?: string;
variables?: RegistryVariables;
}
export interface RegistryTransport {
headers?: RegistryTransportHeader[];
type: string;
url?: string;
}
export interface RegistryPackage {
environmentVariables?: RegistryEnvironmentVariable[];
fileSha256?: string;
identifier: string;
packageArguments?: RegistryPackageArgument[];
registryBaseUrl?: string;
registryType: string;
runtimeArguments?: RegistryPackageArgument[];
runtimeHint?: string;
transport?: RegistryTransport;
version?: string;
}
export interface RegistryRemote {
headers?: RegistryTransportHeader[];
type: string;
url: string;
}
export interface RegistryRepository {
id?: string;
source?: string;
subfolder?: string;
url?: string;
}
export interface RegistryIcon {
mimeType: string;
sizes?: string[];
src: string;
theme?: string;
}
export interface RegistryServerData {
$schema?: string;
_meta?: {
'io.modelcontextprotocol.registry/publisher-provided'?: Record<string, any>;
};
description: string;
icons?: RegistryIcon[];
name: string;
packages?: RegistryPackage[];
remotes?: RegistryRemote[];
repository?: RegistryRepository;
title: string;
version: string;
websiteUrl?: string;
}
export interface RegistryOfficialMeta {
isLatest?: boolean;
publishedAt?: string;
status?: string;
updatedAt?: string;
}
export interface RegistryServerEntry {
_meta?: {
'io.modelcontextprotocol.registry/official'?: RegistryOfficialMeta;
};
server: RegistryServerData;
}
export interface RegistryMetadata {
count: number;
nextCursor?: string;
}
export interface RegistryServersResponse {
metadata: RegistryMetadata;
servers: RegistryServerEntry[];
}
export interface RegistryServerVersionsResponse {
metadata: RegistryMetadata;
servers: RegistryServerEntry[];
}
export interface RegistryServerVersionResponse {
_meta?: {
'io.modelcontextprotocol.registry/official'?: RegistryOfficialMeta;
};
server: RegistryServerData;
}

View File

@@ -0,0 +1,38 @@
/**
* Frontend password strength validation utility
* Should match backend validation rules
*/
export interface PasswordValidationResult {
isValid: boolean;
errors: string[];
}
export const validatePasswordStrength = (password: string): PasswordValidationResult => {
const errors: string[] = [];
// Check minimum length
if (password.length < 8) {
errors.push('passwordMinLength');
}
// Check for at least one letter
if (!/[a-zA-Z]/.test(password)) {
errors.push('passwordRequireLetter');
}
// Check for at least one number
if (!/\d/.test(password)) {
errors.push('passwordRequireNumber');
}
// Check for at least one special character
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
errors.push('passwordRequireSpecial');
}
return {
isValid: errors.length === 0,
errors,
};
};

View File

@@ -1 +0,0 @@
google-site-verification: googled76ca578b6543fbc.html

View File

@@ -40,7 +40,7 @@ module.exports = {
'^@/(.*)$': '<rootDir>/src/$1',
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|other-esm-packages)/)'],
transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|openid-client|oauth4webapi)/)'],
extensionsToTreatAsEsm: ['.ts'],
testTimeout: 30000,
verbose: true,

Some files were not shown because too many files have changed in this diff Show More