├── rootfs └── etc │ └── s6-overlay │ └── s6-rc.d │ ├── mcp-proxy-server │ ├── type │ ├── up │ └── run │ └── user │ └── contents.d │ └── mcp-proxy-server ├── icon.png ├── logo.png ├── public ├── logo.png ├── terminal.html ├── index.html ├── terminal.js ├── tools.js ├── script.js ├── style.css └── servers.js ├── .gitignore ├── tsconfig.json ├── config └── mcp_server.json.example ├── src ├── index.ts ├── logger.ts ├── terminal.ts ├── client.ts └── config.ts ├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ └── docker-publish.yml ├── LICENSE ├── package.json ├── nginx.conf ├── config.yaml ├── DOCS.md ├── Dockerfile └── README_ZH.md /rootfs/etc/s6-overlay/s6-rc.d/mcp-proxy-server/type: -------------------------------------------------------------------------------- 1 | longrun 2 | -------------------------------------------------------------------------------- /rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/mcp-proxy-server: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ptbsare/mcp-proxy-server/HEAD/icon.png -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ptbsare/mcp-proxy-server/HEAD/logo.png -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ptbsare/mcp-proxy-server/HEAD/public/logo.png -------------------------------------------------------------------------------- /rootfs/etc/s6-overlay/s6-rc.d/mcp-proxy-server/up: -------------------------------------------------------------------------------- 1 | /etc/s6-overlay/s6-rc.d/mcp-proxy-server/run 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | *.log 4 | .env* 5 | *.db 6 | index.md 7 | SETTINGS.md 8 | reverse-transform.js 9 | # Session secret file for Admin UI 10 | config/.session_secret 11 | browser-* 12 | chatmcp 13 | typescript-sdk 14 | inspector 15 | MCP-protocol.txt 16 | tools/** 17 | config/mcp_server.json 18 | config/tool_config.json -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /config/mcp_server.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "server1-name": { 4 | "command": "/path/to/server1/executable", 5 | "args": ["--optional-arg"], 6 | "env": { 7 | "API_KEY": "your_api_key_here" 8 | } 9 | }, 10 | "server2-stdio": { 11 | "command": "server2-command" 12 | }, 13 | "server3-sse": { 14 | "active": "false", 15 | "url": "http://localhost:8080/sse" 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 4 | import { logger } from './logger.js'; 5 | import { createServer } from "./mcp-proxy.js"; 6 | 7 | async function main() { 8 | const transport = new StdioServerTransport(); 9 | const { server, cleanup } = await createServer(); 10 | 11 | await server.connect(transport); 12 | 13 | process.on("SIGINT", async () => { 14 | await cleanup(); 15 | await server.close(); 16 | process.exit(0); 17 | }); 18 | } 19 | 20 | main().catch((error) => { 21 | logger.error("Server error:", error.message); 22 | process.exit(1); 23 | }); 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | # github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | # patreon: # Replace with a single Patreon username 5 | # open_collective: # Replace with a single Open Collective username 6 | # ko_fi: # Replace with a single Ko-fi username 7 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | # liberapay: # Replace with a single Liberapay username 10 | # issuehunt: # Replace with a single IssueHunt username 11 | # otechie: # Replace with a single Otechie username 12 | custom: ['https://ptbsare.org/about/'] # Link to your custom sponsorship page -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Adam Wattis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mcp-proxy-server", 3 | "version": "0.4.1", 4 | "author": "ptbsare", 5 | "license": "MIT", 6 | "description": "An MCP proxy server that aggregates and serves multiple MCP resource servers through a single interface with stdio/sse support", 7 | "private": true, 8 | "type": "module", 9 | "bin": { 10 | "mcp-proxy-server": "./build/index.js" 11 | }, 12 | "files": [ 13 | "build" 14 | ], 15 | "scripts": { 16 | "dev": "nodemon --watch 'src/**' --ext 'ts,json' --ignore 'src/**/*.spec.ts' --exec 'tsx src/index.ts'", 17 | "dev:sse": "nodemon --watch 'src/**' --ext 'ts,json' --ignore 'src/**/*.spec.ts' --exec 'tsx src/sse.ts'", 18 | "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", 19 | "prepare": "npm run build", 20 | "watch": "tsc --watch", 21 | "inspector": "npx @modelcontextprotocol/inspector build/index.js" 22 | }, 23 | "dependencies": { 24 | "@modelcontextprotocol/sdk": "1.12.0", 25 | "@types/cors": "^2.8.17", 26 | "@types/express-session": "^1.18.1", 27 | "cors": "^2.8.5", 28 | "eventsource": "^4.0.0", 29 | "express": "^4.21.1", 30 | "express-session": "^1.18.1", 31 | "node-pty": "^1.0.0", 32 | "zod-to-json-schema": "^3.23.5" 33 | }, 34 | "devDependencies": { 35 | "@types/express": "^4.17.0", 36 | "@types/node": "^20.11.24", 37 | "nodemon": "^3.1.9", 38 | "tsx": "^4.19.2", 39 | "typescript": "^5.3.3" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 3663 ssl http2; 3 | listen [::]:3663 ssl http2; 4 | server_name your_domain.com; # Replace with your actual domain or IP 5 | 6 | # SSL Configuration 7 | ssl_certificate /path/to/your/fullchain.pem; # Replace with your certificate path 8 | ssl_certificate_key /path/to/your/privkey.pem; # Replace with your private key path 9 | ssl_protocols TLSv1.2 TLSv1.3; 10 | ssl_ciphers HIGH:!aNULL:!MD5; 11 | ssl_prefer_server_ciphers on; 12 | ssl_session_cache shared:SSL:10m; 13 | ssl_session_timeout 10m; 14 | 15 | location / { 16 | proxy_pass http://localhost:3663; # Forward to the SSE server 17 | 18 | # Headers required for SSE 19 | proxy_set_header Host $host; 20 | proxy_set_header X-Real-IP $remote_addr; 21 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 22 | proxy_set_header X-Forwarded-Proto $scheme; 23 | 24 | # Buffering settings for SSE 25 | proxy_buffering off; 26 | proxy_cache off; 27 | proxy_read_timeout 86400s; # Keep connection open for a long time 28 | proxy_send_timeout 86400s; 29 | proxy_connect_timeout 75s; 30 | 31 | # Required for SSE event stream 32 | proxy_set_header Connection ''; 33 | proxy_http_version 1.1; 34 | chunked_transfer_encoding off; 35 | } 36 | 37 | # Optional: Add access and error logs 38 | access_log /var/log/nginx/sse_proxy_access.log; 39 | error_log /var/log/nginx/sse_proxy_error.log; 40 | } -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | name: "MCP Proxy Server" 2 | version: "0.4.1" 3 | slug: "mcp_proxy_server" 4 | description: "A central hub for Model Context Protocol (MCP) servers. Manages multiple backend MCP servers (Stdio/SSE), exposing their combined tools and resources via a unified SSE interface or as a Stdio server. Features Web UI for server/tool management, real-time installation monitoring, and optional web terminal." 5 | arch: 6 | - amd64 7 | - aarch64 8 | init: false 9 | hassio_api: true 10 | hassio_role: default 11 | homeassistant_api: false 12 | host_network: true 13 | map: 14 | - type: addon_config 15 | read_only: False 16 | path: /mcp-proxy-server/config # App config (mcp_server.json etc.) in /mcp-proxy-server/config inside container, maps to HA's config dir for this addon 17 | - type: share 18 | read_only: False # For TOOLS_FOLDER, maps to /share inside container 19 | options: 20 | port: 3663 21 | enable_admin_ui: true # Defaulting to true for Ingress 22 | admin_username: "admin" 23 | admin_password: "password" 24 | tools_folder: "/share/mcp_tools" # Default location for tools, accessible via /share in the addon 25 | mcp_proxy_sse_allowed_keys: "" 26 | schema: 27 | port: int(1024,65535) 28 | enable_admin_ui: bool 29 | admin_username: str 30 | admin_password: password 31 | tools_folder: str 32 | allowed_keys: str? 33 | allowed_tokens: str? 34 | image: "ghcr.io/ptbsare/home-assistant-addons/{arch}-addon-mcp-proxy-server" # Version will be appended by build 35 | startup: application 36 | boot: auto 37 | webui: "http://[HOST]:[PORT:3663]/admin" 38 | ingress: false 39 | panel_icon: "mdi:server-network-outline" 40 | panel_title: "MCP Proxy Server" -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | function formatTimestamp(): string { 2 | const now = new Date(); 3 | const year = now.getFullYear(); 4 | const month = (now.getMonth() + 1).toString().padStart(2, '0'); 5 | const day = now.getDate().toString().padStart(2, '0'); 6 | const hours = now.getHours().toString().padStart(2, '0'); 7 | const minutes = now.getMinutes().toString().padStart(2, '0'); 8 | const seconds = now.getSeconds().toString().padStart(2, '0'); 9 | const milliseconds = now.getMilliseconds().toString().padStart(3, '0'); 10 | return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`; 11 | } 12 | 13 | enum LogLevel { 14 | Error, 15 | Warn, 16 | Info, 17 | Debug, 18 | } 19 | 20 | function getLogLevel(envVar: string | undefined): LogLevel { 21 | switch (envVar?.toLowerCase()) { 22 | case 'debug': 23 | return LogLevel.Debug; 24 | case 'info': 25 | return LogLevel.Info; 26 | case 'warn': 27 | return LogLevel.Warn; 28 | case 'error': 29 | return LogLevel.Error; 30 | default: 31 | return LogLevel.Info; // Default to Info level 32 | } 33 | } 34 | 35 | const currentLogLevel = getLogLevel(process.env.LOGGING); 36 | 37 | function log(...args: any[]): void { 38 | if (currentLogLevel >= LogLevel.Info) { 39 | console.log(`[${formatTimestamp()}] [INFO]`, ...args); 40 | } 41 | } 42 | 43 | function warn(...args: any[]): void { 44 | if (currentLogLevel >= LogLevel.Warn) { 45 | console.warn(`[${formatTimestamp()}] [WARN]`, ...args); 46 | } 47 | } 48 | 49 | function error(...args: any[]): void { 50 | if (currentLogLevel >= LogLevel.Error) { 51 | console.error(`[${formatTimestamp()}] [ERROR]`, ...args); 52 | } 53 | } 54 | 55 | function debug(...args: any[]): void { 56 | if (currentLogLevel >= LogLevel.Debug) { 57 | console.debug(`[${formatTimestamp()}] [DEBUG]`, ...args); 58 | } 59 | } 60 | 61 | export const logger = { 62 | log, 63 | warn, 64 | error, 65 | debug, 66 | }; -------------------------------------------------------------------------------- /public/terminal.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Admin Web Terminal 7 | 8 | 9 | 10 | 55 | 56 | 57 |
58 | Web Terminal 59 | Disconnected 60 | Back to Admin 61 |
62 |
63 |
64 |
65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /rootfs/etc/s6-overlay/s6-rc.d/mcp-proxy-server/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bashio 2 | # ============================================================================== 3 | # Home Assistant Add-on: MCP Proxy Server 4 | # 5 | # This script starts the MCP Proxy Server. 6 | # ============================================================================== 7 | 8 | # --- Read configuration from options.json --- 9 | export PORT=$(bashio::config 'port') 10 | export ENABLE_ADMIN_UI=$(bashio::config 'enable_admin_ui') 11 | export ADMIN_USERNAME=$(bashio::config 'admin_username') 12 | export ADMIN_PASSWORD=$(bashio::config 'admin_password') 13 | export TOOLS_FOLDER=$(bashio::config 'tools_folder') 14 | export ALLOWED_KEYS=$(bashio::config 'allowed_keys') 15 | export ALLOWED_TOKENS=$(bashio::config 'allowed_tokens') 16 | bashio::log.info "Starting MCP Proxy Server..." 17 | bashio::log.info "Port: ${PORT}" 18 | bashio::log.info "Admin UI Enabled: ${ENABLE_ADMIN_UI}" 19 | if [[ "${ENABLE_ADMIN_UI}" == "true" ]]; then 20 | bashio::log.info "Admin Username: ${ADMIN_USERNAME}" 21 | fi 22 | bashio::log.info "Tools Folder: ${TOOLS_FOLDER}" # This is /share/mcp_tools by default 23 | if [[ -n "${ALLOWED_KEYS}" ]]; then 24 | bashio::log.info "Allowed SSE Keys are configured." 25 | else 26 | bashio::log.info "No SSE Keys configured." 27 | fi 28 | if [[ -n "${ALLOWED_TOKENS}" ]]; then 29 | bashio::log.info "Allowed SSE TOKENS are configured." 30 | else 31 | bashio::log.info "No SSE TOKENS configured." 32 | fi 33 | # --- Define application paths --- 34 | # APP_BASE_DIR is the working directory set in Dockerfile, and where app files are copied. 35 | APP_BASE_DIR="/mcp-proxy-server" 36 | # APP_CONFIG_DIR_PERSISTENT is the path INSIDE the container where the app's config 37 | # is persistently stored. This path is mapped from the host's addon config directory. 38 | APP_CONFIG_DIR_PERSISTENT="${APP_BASE_DIR}/config" # i.e., /mcp-proxy-server/config 39 | 40 | # --- Ensure application persistent config folder exists --- 41 | # This directory inside the container is mapped from the host. 42 | if [ ! -d "${APP_CONFIG_DIR_PERSISTENT}" ]; then 43 | bashio::log.info "Creating application persistent config folder at ${APP_CONFIG_DIR_PERSISTENT}..." 44 | # This directory should be created by HA supervisor based on 'map' in config.yaml 45 | # but creating it here ensures it if somehow not present. 46 | mkdir -p "${APP_CONFIG_DIR_PERSISTENT}" 47 | fi 48 | 49 | # --- Copy example config files if they don't exist in the persistent config volume --- 50 | # Example files are assumed to be part of the application build, 51 | # located at $APP_BASE_DIR/config/ (e.g., /mcp-proxy-server/config/mcp_server.json.example) 52 | # These are copied to the *persistent* config directory if not already present. 53 | EXAMPLE_CONFIG_SOURCE_DIR="${APP_BASE_DIR}/config" # Source of examples within the built app 54 | 55 | if [ -f "${EXAMPLE_CONFIG_SOURCE_DIR}/mcp_server.json.example" ] && [ ! -f "${APP_CONFIG_DIR_PERSISTENT}/mcp_server.json" ]; then 56 | bashio::log.info "Copying mcp_server.json.example to ${APP_CONFIG_DIR_PERSISTENT}/mcp_server.json..." 57 | cp "${EXAMPLE_CONFIG_SOURCE_DIR}/mcp_server.json.example" "${APP_CONFIG_DIR_PERSISTENT}/mcp_server.json" 58 | fi 59 | if [ -f "${EXAMPLE_CONFIG_SOURCE_DIR}/tool_config.json.example" ] && [ ! -f "${APP_CONFIG_DIR_PERSISTENT}/tool_config.json" ]; then 60 | bashio::log.info "Copying tool_config.json.example to ${APP_CONFIG_DIR_PERSISTENT}/tool_config.json..." 61 | cp "${EXAMPLE_CONFIG_SOURCE_DIR}/tool_config.json.example" "${APP_CONFIG_DIR_PERSISTENT}/tool_config.json" 62 | fi 63 | # Note: The application itself needs to be configured to read from APP_CONFIG_DIR_PERSISTENT. 64 | # For example, src/config.ts should use paths like /mcp-proxy-server/config/mcp_server.json 65 | 66 | # --- Ensure tools folder exists (mapped from /share by config.yaml) --- 67 | if [ ! -d "${TOOLS_FOLDER}" ]; then 68 | bashio::log.info "Creating tools folder at ${TOOLS_FOLDER}..." 69 | mkdir -p "${TOOLS_FOLDER}" 70 | fi 71 | 72 | # --- Navigate to application base directory and start the server --- 73 | cd "${APP_BASE_DIR}" || exit 1 74 | 75 | bashio::log.info "Executing Node.js application: node build/sse.js" 76 | bashio::log.info "Application should read its config from: ${APP_CONFIG_DIR_PERSISTENT}" 77 | bashio::log.info "IMPORTANT: Ensure your application (e.g., src/config.ts) uses the absolute path '${APP_CONFIG_DIR_PERSISTENT}' for its configuration files (mcp_server.json, tool_config.json)." 78 | 79 | # Environment variables PORT, ENABLE_ADMIN_UI, etc., are set. 80 | # The application (build/sse.js) must be modified to load its mcp_server.json 81 | # and tool_config.json from the absolute path APP_CONFIG_DIR_PERSISTENT. 82 | exec node build/sse.js -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branch: 6 | - main 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write # Needed for checkout and release creation 11 | 12 | jobs: 13 | check_version: 14 | name: Check Version Change 15 | runs-on: ubuntu-latest 16 | outputs: 17 | should_release: ${{ steps.check_version.outputs.should_release }} 18 | version: ${{ steps.get_package_version.outputs.version }} # Pass version to next job 19 | tag_name: v${{ steps.get_package_version.outputs.version }} # Pass tag name to next job 20 | # Only run if the commit message doesn't contain '[skip release]' 21 | if: "!contains(github.event.head_commit.message, '[skip release]')" 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v4 25 | 26 | - name: Set up Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: '20' # Match the version used in other workflows 30 | 31 | - name: Get version from package.json 32 | id: get_package_version 33 | run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT 34 | 35 | - name: Get latest release tag name 36 | id: get_latest_tag 37 | # Use gh cli to get the latest release tag. Handle errors if no releases exist. 38 | run: | 39 | latest_tag=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || echo "") 40 | echo "latest_tag=${latest_tag}" >> $GITHUB_OUTPUT 41 | env: 42 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | 44 | - name: Check if release should be created 45 | id: check_version 46 | run: | 47 | current_tag="v${{ steps.get_package_version.outputs.version }}" 48 | latest_tag="${{ steps.get_latest_tag.outputs.latest_tag }}" 49 | if [ "$current_tag" != "$latest_tag" ]; then 50 | echo "Version Changed ($latest_tag -> $current_tag). Need to Release." 51 | echo "should_release=true" >> $GITHUB_OUTPUT 52 | else 53 | echo "Version $current_tag matches latest release tag $latest_tag. No Need to Release." 54 | echo "should_release=false" >> $GITHUB_OUTPUT 55 | fi 56 | 57 | create_release: 58 | name: Create GitHub Release 59 | needs: check_version 60 | if: needs.check_version.outputs.should_release == 'true' # Only run if version changed 61 | runs-on: ubuntu-latest 62 | permissions: 63 | contents: write # Need write access to create release/tag 64 | 65 | steps: 66 | - name: Checkout repository 67 | uses: actions/checkout@v4 68 | with: 69 | fetch-depth: 0 # Needed for changelog generator 70 | 71 | - name: Generate Release note body. 72 | id: github_release 73 | uses: mikepenz/release-changelog-builder-action@v5 74 | with: 75 | mode: "HYBRID" 76 | # Explicitly define the range: from the last release tag to the current commit SHA 77 | fromTag: ${{ steps.get_latest_tag.outputs.latest_tag }} 78 | toTag: ${{ github.sha }} 79 | configurationJson: | 80 | { 81 | "categories": [ 82 | { 83 | "title": "## Feature", 84 | "labels": ["feat", "feature", "Feat", "Feature"] 85 | }, 86 | { 87 | "title": "## Fix", 88 | "labels": ["fix", "bug", "Fix", "Bug"] 89 | }, 90 | { 91 | "title": "## Performance", 92 | "labels": ["perf","Perf"] 93 | }, 94 | { 95 | "title": "## Documentation", 96 | "labels": ["docs","Docs"] 97 | }, 98 | { 99 | "title": "## Chore", 100 | "labels": ["chore","Chore"] 101 | }, 102 | { 103 | "title": "## Refactor", 104 | "labels": ["refactor","Refactor"] 105 | }, 106 | { 107 | "title": "## Revert", 108 | "labels": ["revert","Revert"] 109 | }, 110 | { 111 | "title": "## Style", 112 | "labels": ["style","Style"] 113 | }, 114 | { 115 | "title": "## Test", 116 | "labels": ["test","Test"] 117 | }, 118 | { 119 | "title": "## Other", 120 | "labels": [] 121 | } 122 | ], 123 | "label_extractor": [ 124 | { 125 | "pattern": "^(build|Build|chore|Chore|ci|Ci|docs|Docs|feat|Feat|feature|Feature|bug|Bug|fix|Fix|perf|Perf|refactor|Refactor|revert|Revert|style|Style|test|Test){1}(\\([\\w\\-\\.]+\\))?(!)?: ([\\w ])+([\\s\\S]*)", 126 | "on_property": "title", 127 | "target": "$1" 128 | } 129 | ] 130 | } 131 | env: 132 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 133 | 134 | - name: Create GitHub Release 135 | uses: softprops/action-gh-release@v2 # Using softprops action 136 | with: 137 | tag_name: ${{ needs.check_version.outputs.tag_name }} 138 | name: Release ${{ needs.check_version.outputs.tag_name }} 139 | body: ${{ steps.github_release.outputs.changelog }} # Use generated changelog from the correct step ID 140 | # Optional: Mark as pre-release if version contains '-' 141 | # prerelease: ${{ contains(needs.check_version.outputs.version, '-') }} 142 | env: 143 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | MCP Server Config Editor 7 | 8 | 9 | 10 | 11 |
12 |
13 |

MCP Proxy Admin

14 | 15 |
16 | 21 |
22 | 23 |
24 |
25 |

Login

26 |
27 |
28 | 29 | 30 |
31 |
32 | 33 | 34 |
35 | 36 |

37 |
38 |
39 | 40 | 41 | 55 | 56 | 57 | 71 | 72 | 73 | 116 | 117 |
118 | 119 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/docker-publish.yml 2 | name: Build and Publish Docker Image to GHCR 3 | 4 | on: 5 | push: 6 | branches: 7 | - main # Trigger on push to main branch 8 | workflow_dispatch: # Allows manual triggering 9 | 10 | permissions: 11 | contents: read # Needed to check out code, read package.json, and read release info 12 | packages: write # Needed to push Docker image to GHCR 13 | 14 | jobs: 15 | build-and-publish: 16 | name: Build and Publish Docker Image 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up Node.js 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: '20' # Or the Node.js version used by your project 27 | 28 | - name: Extract version from package.json 29 | id: get_version 30 | run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT 31 | 32 | - name: Compare package version with latest release tag 33 | id: compare_versions 34 | uses: actions/github-script@v7 35 | with: 36 | script: | 37 | const packageVersion = "${{ steps.get_version.outputs.version }}"; 38 | console.log(`Package version: ${packageVersion}`); 39 | 40 | if (context.eventName === 'workflow_dispatch') { 41 | console.log('Manual trigger detected. Skipping version check and forcing build.'); 42 | core.setOutput('needs_build', 'true'); 43 | } else { 44 | console.log('Push trigger detected. Comparing package version with latest release tag.'); 45 | try { 46 | const latestRelease = await github.rest.repos.getLatestRelease({ 47 | owner: context.repo.owner, 48 | repo: context.repo.repo, 49 | }); 50 | 51 | const latestTag = latestRelease.data.tag_name; 52 | console.log(`Latest release tag: ${latestTag}`); 53 | 54 | // Assuming tag is like 'v1.2.3', remove 'v' prefix 55 | const latestVersion = latestTag.startsWith('v') ? latestTag.substring(1) : latestTag; 56 | console.log(`Latest release version: ${latestVersion}`); 57 | 58 | if (packageVersion !== latestVersion) { 59 | console.log('Version mismatch. Build needed.'); 60 | core.setOutput('needs_build', 'true'); 61 | } else { 62 | console.log('Versions match. No build needed.'); 63 | core.setOutput('needs_build', 'false'); 64 | } 65 | } catch (error) { 66 | // Handle case where no releases exist yet or API error 67 | if (error.status === 404) { 68 | console.log('No releases found. Build needed.'); 69 | core.setOutput('needs_build', 'true'); 70 | } else { 71 | console.error('Error fetching latest release:', error); 72 | core.setFailed(`Error fetching latest release: ${error.message}`); 73 | core.setOutput('needs_build', 'false'); // Don't build on error 74 | } 75 | } 76 | } 77 | 78 | - name: Log in to GitHub Container Registry 79 | if: steps.compare_versions.outputs.needs_build == 'true' 80 | uses: docker/login-action@v3 81 | with: 82 | registry: ghcr.io 83 | username: ${{ github.actor }} 84 | password: ${{ secrets.GITHUB_TOKEN }} 85 | 86 | - name: Set up Docker Buildx 87 | if: steps.compare_versions.outputs.needs_build == 'true' 88 | uses: docker/setup-buildx-action@v3 89 | 90 | - name: Append ENTRYPOINT and CMD to Dockerfile for standalone build 91 | if: steps.compare_versions.outputs.needs_build == 'true' 92 | run: | 93 | echo '' >> Dockerfile # Add a newline for separation 94 | echo '# Added by GitHub Actions for standalone build' >> Dockerfile 95 | echo 'ENTRYPOINT ["tini", "--"]' >> Dockerfile 96 | echo 'CMD ["node", "build/sse.js"]' >> Dockerfile 97 | echo 'Dockerfile content after append:' 98 | cat Dockerfile 99 | 100 | - name: Build and push standard Docker image 101 | if: steps.compare_versions.outputs.needs_build == 'true' 102 | uses: docker/build-push-action@v5 103 | with: 104 | context: . 105 | platforms: linux/amd64,linux/aarch64 106 | # Dockerfile already modified by "Append ENTRYPOINT..." step for standalone 107 | push: true 108 | tags: | 109 | ghcr.io/${{ github.repository }}/mcp-proxy-server:${{ steps.get_version.outputs.version }} 110 | ghcr.io/${{ github.repository }}/mcp-proxy-server:latest 111 | # build-args will use the default empty ARGs from Dockerfile for a lean image 112 | cache-from: type=gha 113 | cache-to: type=gha,mode=max 114 | 115 | - name: Build and push bundled Docker image 116 | if: steps.compare_versions.outputs.needs_build == 'true' 117 | uses: docker/build-push-action@v5 118 | with: 119 | context: . 120 | platforms: linux/amd64,linux/aarch64 121 | # Dockerfile already modified by "Append ENTRYPOINT..." step for standalone 122 | push: true 123 | tags: | 124 | ghcr.io/${{ github.repository }}/mcp-proxy-server:${{ steps.get_version.outputs.version }}-bundled-mcpservers-playwright 125 | ghcr.io/${{ github.repository }}/mcp-proxy-server:latest-bundled-mcpservers-playwright 126 | build-args: | 127 | PRE_INSTALLED_PIP_PACKAGES_ARG=markitdown-mcp mcp-proxy 128 | PRE_INSTALLED_NPM_PACKAGES_ARG=g-search-mcp fetcher-mcp playwright time-mcp mcp-trends-hub @adenot/mcp-google-search edgeone-pages-mcp @modelcontextprotocol/server-filesystem mcp-server-weibo @variflight-ai/variflight-mcp @baidumap/mcp-server-baidu-map @modelcontextprotocol/inspector 129 | PRE_INSTALLED_INIT_COMMAND_ARG=playwright install --with-deps chromium 130 | cache-from: type=gha 131 | cache-to: type=gha,mode=max -------------------------------------------------------------------------------- /DOCS.md: -------------------------------------------------------------------------------- 1 | # MCP Proxy Server Home Assistant Add-on 2 | 3 | This add-on integrates the MCP Proxy Server into Home Assistant, allowing you to manage and proxy multiple Model Context Protocol (MCP) servers through a unified interface. 4 | 5 | ## About 6 | 7 | The MCP Proxy Server acts as a central hub for your MCP resource servers. Key features include: 8 | 9 | * **Web UI Management**: Easily manage all connected MCP servers (Stdio and SSE types) through an intuitive web interface. 10 | * **Granular Tool Control**: Enable or disable individual tools from backend servers and override their display names/descriptions. 11 | * **SSE Authentication**: Secure the proxy's SSE endpoint. 12 | * **Real-time Installation Output**: Monitor Stdio server installation progress directly in the Web UI. 13 | * **Web Terminal**: Access a command-line terminal within the Admin UI for direct server interaction (use with caution). 14 | 15 | This add-on exposes these features within your Home Assistant environment. 16 | 17 | ## Installation 18 | 19 | 1. **Add the Repository**: 20 | * Navigate to the Home Assistant Supervisor add-on store. 21 | * Click on the 3-dots menu in the top right and select "Repositories". 22 | * Add the following URL: `https://github.com/ptbsare/home-assistant-addons`. 23 | * Close the dialog. 24 | 25 | 2. **Install the Add-on**: 26 | * After adding the repository, refresh the add-on store page (you might need to wait a few moments for the new repository to be processed). 27 | * Search for "MCP Proxy Server" and click on it. 28 | * Click "INSTALL" and wait for the installation to complete. 29 | 30 | ## Configuration 31 | 32 | Once installed, you need to configure the add-on before starting it. The following options are available in the "Configuration" tab of the add-on: 33 | 34 | | Option | Type | Default Value | Description | 35 | | ------------------------------ | ------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | 36 | | `port` | integer | `3663` | The network port on which the MCP Proxy Server's SSE endpoint and Admin Web UI will be accessible. | 37 | | `enable_admin_ui` | boolean | `true` | Set to `true` to enable the Admin Web UI. This is required for Ingress access. | 38 | | `admin_username` | string | `admin` | Username for accessing the Admin Web UI. **It is strongly recommended to change this.** | 39 | | `admin_password` | password| `password` | Password for accessing the Admin Web UI. **It is strongly recommended to change this to a strong, unique password.** | 40 | | `tools_folder` | string | `/share/mcp_tools` | The base directory within Home Assistant's `/share` folder where Stdio MCP servers can be installed via the Admin UI. | 41 | | `mcp_proxy_sse_allowed_keys` | string | (empty) | Optional. A comma-separated list of API keys to secure the proxy's main `/sse` endpoint. If empty, authentication for the SSE endpoint is disabled. | 42 | 43 | **Important Configuration Notes**: 44 | 45 | * **Persistent Configuration Files**: The core configuration files for the MCP Proxy Server itself (`mcp_server.json` and `tool_config.json`) are stored within the add-on's persistent configuration directory. In Home Assistant, this is mapped from `/mcp-proxy-server/config` inside the container to a location like `/config/addons_config/mcp_proxy_server/` (or similar, depending on your HA setup) on your Home Assistant host system. 46 | * You should place your `mcp_server.json` (defining backend MCP servers) and `tool_config.json` (for tool overrides) in this mapped directory on your Home Assistant host. 47 | * Refer to the main [MCP Proxy Server README](README.md) for details on the structure of these JSON files. 48 | * If these files are not present when the add-on starts, example versions might be copied, which you can then edit. 49 | * **Tools Folder**: The `tools_folder` option defaults to `/share/mcp_tools`. This means any Stdio servers installed via the Admin UI will be placed in a subdirectory under the `/share/mcp_tools/` directory on your Home Assistant host system. Ensure this path is accessible and writable if you intend to use this feature. 50 | 51 | ## Usage 52 | 53 | 1. **Start the Add-on**: Once configured, go to the add-on page and click "START". Check the "Log" tab for any errors. 54 | 2. **Accessing the Admin UI**: 55 | * If Ingress is enabled (default), you can access the Admin UI directly from the Home Assistant sidebar by clicking on "MCP Proxy Server". 56 | * Alternatively, if `enable_admin_ui` is `true`, you can access it at `http://:`. 57 | 3. **Configuring Backend Servers**: Use the Admin UI to add and manage your backend MCP servers (both Stdio and SSE types). This involves editing the `mcp_server.json` content through the UI or directly in the file system. 58 | 4. **Managing Tools**: Use the Admin UI to enable/disable tools from connected servers or override their display names and descriptions (`tool_config.json`). 59 | 5. **Connecting Clients**: Configure your MCP clients (e.g., Claude Desktop, other compatible applications) to connect to this add-on's SSE endpoint: 60 | * **URL**: `http://:/sse` 61 | * **Authentication**: If you have set `mcp_proxy_sse_allowed_keys`, your client will need to provide one of these keys, typically via an `X-Api-Key` header or a `?key=` query parameter in the URL. 62 | 63 | ## Support and Issues 64 | 65 | For issues specifically related to this Home Assistant add-on, please open an issue on the [GitHub repository](https://github.com/ptbsare/home-assistant-addons/issues). 66 | 67 | For issues related to the MCP Proxy Server application itself, refer to its own documentation or support channels. 68 | 69 | --- 70 | 71 | *This documentation is for the MCP Proxy Server Home Assistant Add-on. For more detailed information about the MCP Proxy Server application, please see its main [README.md](README.md).* -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Default base image for standalone builds. For addons, this is overridden by build.yaml. 2 | ARG BUILD_FROM=nikolaik/python-nodejs:python3.12-nodejs23 3 | 4 | 5 | FROM $BUILD_FROM AS base 6 | ARG NODE_VERSION=22 # Default Node.js version for addon OS setup 7 | ARG BUILD_FROM # Re-declare ARG to make it available in this stage 8 | WORKDIR /mcp-proxy-server 9 | 10 | # Arguments for pre-installed packages, primarily for standalone builds. 11 | # These allow users of the standalone Docker image to inject packages at build time. 12 | ARG PRE_INSTALLED_PIP_PACKAGES_ARG="" 13 | ARG PRE_INSTALLED_NPM_PACKAGES_ARG="" 14 | ARG PRE_INSTALLED_INIT_COMMAND_ARG="" 15 | 16 | # --- OS Level Setup --- 17 | # This section handles OS package installations. 18 | # It differentiates between addon builds (Debian base) and standalone (nikolaik base). 19 | 20 | # Common packages needed by the application or build process, regardless of base. 21 | # For nikolaik base, some might be present. For HA base, many need explicit install. 22 | RUN apt-get update && apt-get install -y --no-install-recommends \ 23 | gcc \ 24 | build-essential \ 25 | python3-dev \ 26 | libffi-dev \ 27 | libssl-dev \ 28 | curl \ 29 | unzip \ 30 | ca-certificates \ 31 | bash \ 32 | ffmpeg \ 33 | git \ 34 | vim \ 35 | dnsutils \ 36 | iputils-ping \ 37 | tini \ 38 | gnupg \ 39 | golang \ 40 | && apt-get clean \ 41 | && rm -rf /var/lib/apt/lists/* 42 | 43 | # --- Addon Specific OS Setup --- 44 | # Executed only if BUILD_FROM indicates a Home Assistant base image. 45 | RUN if echo "$BUILD_FROM" | grep -q "home-assistant"; then \ 46 | echo "Addon build detected (BUILD_FROM: $BUILD_FROM). Performing addon-specific OS setup." && \ 47 | # Ensure essential build tools and Python are explicitly installed if not already on HA base 48 | # The common apt-get above might have covered some, this ensures specific versions or presence. 49 | apt-get update && \ 50 | apt-get install -y --no-install-recommends \ 51 | python3 python3-pip && \ 52 | pip3 install uv --no-cache-dir --break-system-packages && \ 53 | #mkdir -p /tmp/uv_test && uv --python 3.11 venv /tmp/uv_test && rm -rf /tmp/uv_test && \ 54 | #mkdir -p /tmp/uv_test && uv --python 3.12 venv /tmp/uv_test && rm -rf /tmp/uv_test && \ 55 | #mkdir -p /tmp/uv_test && uv --python 3.13 venv /tmp/uv_test && rm -rf /tmp/uv_test && \ 56 | # Install specific Node.js version for addon 57 | echo "Installing Node.js v${NODE_VERSION} for addon..." && \ 58 | curl -fsSL "https://deb.nodesource.com/setup_${NODE_VERSION}.x" -o nodesource_setup.sh && \ 59 | bash nodesource_setup.sh && \ 60 | apt-get update && apt-get install -y nodejs && \ 61 | # S6-Overlay is assumed to be part of the Home Assistant base image. 62 | # Cleanup for addon OS setup 63 | echo "Cleaning up apt cache for addon OS setup..." && \ 64 | apt-get clean && \ 65 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*; \ 66 | else \ 67 | echo "Standalone build detected (BUILD_FROM: $BUILD_FROM). Skipping addon-specific OS setup."; \ 68 | fi 69 | 70 | RUN npm install -g pnpm bun 71 | 72 | RUN if [ -n "$PRE_INSTALLED_PIP_PACKAGES_ARG" ]; then \ 73 | echo "Installing pre-defined PIP packages: $PRE_INSTALLED_PIP_PACKAGES_ARG" && \ 74 | pip install --break-system-packages --no-cache-dir $PRE_INSTALLED_PIP_PACKAGES_ARG; \ 75 | else \ 76 | echo "Skipping pre-defined PIP packages installation."; \ 77 | fi 78 | 79 | RUN if [ -n "$PRE_INSTALLED_NPM_PACKAGES_ARG" ]; then \ 80 | echo "Installing pre-defined NPM packages: $PRE_INSTALLED_NPM_PACKAGES_ARG" && \ 81 | npm install -g $PRE_INSTALLED_NPM_PACKAGES_ARG; \ 82 | else \ 83 | echo "Skipping pre-defined NPM packages installation."; \ 84 | fi 85 | 86 | RUN if [ -n "$PRE_INSTALLED_INIT_COMMAND_ARG" ]; then \ 87 | echo "Running pre-defined init command: $PRE_INSTALLED_INIT_COMMAND_ARG" && \ 88 | eval $PRE_INSTALLED_INIT_COMMAND_ARG; \ 89 | else \ 90 | echo "Skipping pre-defined init command."; \ 91 | fi 92 | 93 | #COPY package.json package-lock.json* ./ 94 | #COPY tsconfig.json ./ 95 | #COPY public ./public 96 | # COPY . . should come before conditional rootfs copy if rootfs might overlay app files, 97 | # or after if app files might overlay rootfs defaults. 98 | # Assuming app files are primary, then addon specifics overlay. 99 | COPY . . 100 | 101 | # --- Addon Specific: Copy rootfs for S6-Overlay and other addon specific files --- 102 | RUN if echo "$BUILD_FROM" | grep -q "home-assistant"; then \ 103 | echo "Addon build: Copying rootfs contents..." && \ 104 | # Ensure rootfs directory exists in the build context 105 | if [ -d "rootfs" ]; then \ 106 | cp -r rootfs/. / ; \ 107 | else \ 108 | echo "Warning: rootfs directory not found, skipping copy."; \ 109 | fi; \ 110 | else \ 111 | echo "Standalone build: Skipping rootfs copy."; \ 112 | fi 113 | 114 | RUN npm install 115 | RUN npm run build 116 | 117 | # --- Environment Variables --- 118 | # Port for the SSE server (and Admin UI if enabled) 119 | ENV PORT=3663 120 | 121 | # Optional: Allowed API keys for SSE endpoint (comma-separated) 122 | # ENV MCP_PROXY_SSE_ALLOWED_KEYS="" 123 | # Optional: Enable Admin Web UI (set to "true" to enable) 124 | ENV ENABLE_ADMIN_UI=false 125 | 126 | # Optional: Admin UI Credentials (required if ENABLE_ADMIN_UI=true) 127 | # It's recommended to set these via `docker run -e` instead of hardcoding here 128 | ENV ADMIN_USERNAME=admin 129 | ENV ADMIN_PASSWORD=password 130 | 131 | # Optional: Default folder for Stdio server installations via Admin UI 132 | ENV TOOLS_FOLDER=/tools 133 | 134 | # --- Volumes --- 135 | # For mcp_server.json and .session_secret 136 | VOLUME /mcp-proxy-server/config 137 | # For external tools referenced in config, and default install location if TOOLS_FOLDER is /tools 138 | VOLUME /tools 139 | 140 | # --- Expose Port --- 141 | EXPOSE 3663 142 | 143 | # --- Entrypoint & Command --- 144 | # For Home Assistant addon builds, the entrypoint is /init (from S6-Overlay in the base image). 145 | # CMD is also typically handled by S6 services defined in rootfs. 146 | # By not specifying ENTRYPOINT or CMD here, we rely on the base image's defaults when built as an addon. 147 | # For standalone builds, users will need to specify the command when running the container, 148 | # e.g., docker run tini -- node build/sse.js 149 | # Or, a multi-stage build could define a specific entrypoint/cmd for the standalone target. -------------------------------------------------------------------------------- /public/terminal.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', () => { 2 | const termContainer = document.getElementById('terminal-container'); 3 | const termElement = document.getElementById('terminal'); 4 | const statusElement = document.getElementById('terminal-status'); 5 | 6 | if (!termElement || !termContainer || !statusElement) { 7 | console.error('Terminal container, element, or status element not found!'); 8 | return; 9 | } 10 | 11 | let termId = null; 12 | let termSSE = null; 13 | let term = null; // xterm instance 14 | let fitAddon = null; // xterm fit addon 15 | let resizeTimeout = null; 16 | let lastCols = 0; 17 | let lastRows = 0; 18 | 19 | function updateStatus(message, state = 'disconnected') { 20 | statusElement.textContent = message; 21 | statusElement.className = `terminal-status ${state}`; 22 | } 23 | 24 | function fitTerminal() { 25 | if (!fitAddon || !term) return; 26 | try { 27 | fitAddon.fit(); 28 | const newCols = term.cols; 29 | const newRows = term.rows; 30 | 31 | // Send resize event to backend only if size changed and termId exists 32 | if (termId && (newCols !== lastCols || newRows !== lastRows)) { 33 | console.log(`Resizing terminal ${termId} to ${newCols}x${newRows}`); 34 | fetch(`/admin/terminal/${termId}/resize`, { 35 | method: 'POST', 36 | headers: { 'Content-Type': 'application/json' }, 37 | body: JSON.stringify({ cols: newCols, rows: newRows }) 38 | }).catch(err => console.error('Error sending resize:', err)); 39 | lastCols = newCols; 40 | lastRows = newRows; 41 | } 42 | } catch (e) { 43 | console.error("Error fitting terminal:", e); 44 | } 45 | } 46 | 47 | function connectTerminalSSE(currentTermId) { 48 | if (termSSE) { 49 | termSSE.close(); 50 | console.log(`Closed previous SSE connection for terminal ${termId}`); 51 | } 52 | if (!currentTermId) { 53 | console.error("Cannot connect SSE: termId is null"); 54 | updateStatus('Error: No Term ID', 'error'); 55 | return; 56 | } 57 | 58 | console.log(`Connecting SSE for terminal output: ${currentTermId}`); 59 | updateStatus('Connecting Output Stream...', 'disconnected'); 60 | termSSE = new EventSource(`/admin/terminal/${currentTermId}/output`); 61 | 62 | termSSE.onopen = () => { 63 | console.log(`SSE connection opened for terminal ${currentTermId}`); 64 | // Status updated by 'connected' event from server 65 | }; 66 | 67 | termSSE.onerror = (err) => { 68 | console.error(`SSE connection error for terminal ${currentTermId}:`, err); 69 | updateStatus('Output Stream Error', 'error'); 70 | if (termSSE) termSSE.close(); 71 | termSSE = null; 72 | // Maybe attempt to reconnect or notify user 73 | }; 74 | 75 | termSSE.addEventListener('connected', (event) => { 76 | try { 77 | const data = JSON.parse(event.data); 78 | console.log('SSE connected event:', data); 79 | updateStatus('Connected', 'connected'); 80 | } catch (e) { 81 | console.error('Error parsing SSE connected event:', e); 82 | updateStatus('Connected (parse error)', 'connected'); 83 | } 84 | }); 85 | 86 | termSSE.addEventListener('output', (event) => { 87 | try { 88 | const data = JSON.parse(event.data); 89 | if (term && typeof data === 'string') { 90 | term.write(data); 91 | } 92 | } catch (e) { 93 | console.error('Error parsing SSE output event:', e, event.data); 94 | } 95 | }); 96 | 97 | termSSE.addEventListener('exit', (event) => { 98 | try { 99 | const data = JSON.parse(event.data); 100 | console.log(`Terminal ${currentTermId} exited:`, data); 101 | updateStatus(`Exited (Code: ${data.exitCode}, Signal: ${data.signal})`, 'disconnected'); 102 | term?.writeln(`\r\n\r\n[Process exited with code ${data.exitCode}]`); 103 | term?.dispose(); // Dispose xterm instance 104 | term = null; 105 | if (termSSE) termSSE.close(); 106 | termSSE = null; 107 | termId = null; // Reset termId as the session is gone 108 | // Maybe disable input or show a reconnect button 109 | } catch (e) { 110 | console.error('Error parsing SSE exit event:', e, event.data); 111 | updateStatus('Exited (parse error)', 'disconnected'); 112 | } 113 | }); 114 | } 115 | 116 | async function startTerminalSession() { 117 | if (termId) { 118 | console.log("Terminal session already started:", termId); 119 | return; 120 | } 121 | updateStatus('Starting Session...', 'disconnected'); 122 | try { 123 | console.log("Requesting new terminal session..."); 124 | const response = await fetch('/admin/terminal/start', { method: 'POST' }); 125 | if (!response.ok) { 126 | const errorData = await response.json().catch(() => ({ error: 'Failed to parse error response' })); 127 | throw new Error(`Failed to start terminal: ${response.status} ${response.statusText} - ${errorData.error}`); 128 | } 129 | const data = await response.json(); 130 | if (!data.termId) { 131 | throw new Error("No termId received from server"); 132 | } 133 | termId = data.termId; 134 | console.log("Terminal session started successfully, ID:", termId); 135 | 136 | // Initialize xterm.js only after getting termId 137 | if (!term) { 138 | term = new Terminal({ 139 | cursorBlink: true, 140 | convertEol: true, // Convert \n to \r\n for PTY 141 | theme: { // Basic dark theme 142 | background: '#1e1e1e', 143 | foreground: '#cccccc', 144 | cursor: '#cccccc', 145 | selectionBackground: '#555555', 146 | } 147 | }); 148 | fitAddon = new FitAddon.FitAddon(); 149 | term.loadAddon(fitAddon); 150 | term.open(termElement); 151 | 152 | // Setup input listener 153 | term.onData(data => { 154 | if (termId && termSSE && termSSE.readyState === EventSource.OPEN) { 155 | fetch(`/admin/terminal/${termId}/input`, { 156 | method: 'POST', 157 | headers: { 'Content-Type': 'application/json' }, 158 | body: JSON.stringify({ input: data }) 159 | }).catch(err => console.error('Error sending input:', err)); 160 | } else { 161 | console.warn("Cannot send input: Terminal ID or SSE connection not available."); 162 | } 163 | }); 164 | 165 | // Initial fit and setup resize listener 166 | fitTerminal(); // Initial fit 167 | window.addEventListener('resize', () => { 168 | clearTimeout(resizeTimeout); 169 | resizeTimeout = setTimeout(fitTerminal, 250); // Debounce resize events 170 | }); 171 | 172 | // Focus the terminal 173 | term.focus(); 174 | } 175 | 176 | // Connect SSE for output 177 | connectTerminalSSE(termId); 178 | 179 | } catch (error) { 180 | console.error("Error starting terminal session:", error); 181 | updateStatus(`Error: ${error.message}`, 'error'); 182 | termId = null; // Reset termId on failure 183 | } 184 | } 185 | 186 | // Cleanup on page unload 187 | window.addEventListener('beforeunload', () => { 188 | if (termId) { 189 | // Send DELETE request - use sendBeacon if possible for reliability on unload 190 | if (navigator.sendBeacon) { 191 | const data = new Blob([JSON.stringify({})], { type: 'application/json' }); // Beacon needs data 192 | navigator.sendBeacon(`/admin/terminal/${termId}`, data); // Beacon uses POST implicitly for data 193 | console.log(`Sent beacon to kill terminal ${termId}`); 194 | } else { 195 | // Fallback for older browsers (less reliable on unload) 196 | fetch(`/admin/terminal/${termId}`, { method: 'DELETE', keepalive: true }).catch(()=>{}); 197 | console.log(`Sent DELETE request to kill terminal ${termId}`); 198 | } 199 | } 200 | if (termSSE) { 201 | termSSE.close(); 202 | } 203 | }); 204 | 205 | // --- Initial Load --- 206 | startTerminalSession(); 207 | 208 | }); -------------------------------------------------------------------------------- /src/terminal.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | // Ensure 'node-pty' is installed by running 'npm install node-pty' or 'yarn add node-pty' 3 | import pty, { IPty } from 'node-pty'; 4 | import { Request, Response, Router } from 'express'; 5 | import { ServerResponse } from 'node:http'; // For SSE Response type hint 6 | import crypto from 'crypto'; // Import crypto for UUID generation 7 | 8 | // Export interface for use in sse.ts shutdown 9 | export interface ActiveTerminal { 10 | ptyProcess: IPty; 11 | id: string; 12 | lastActivity: number; // Timestamp for potential cleanup 13 | initialOutputBuffer?: string[]; // Buffer for initial output before SSE connects 14 | } 15 | 16 | // Store active terminals, keyed by a unique ID 17 | // Export Map for use in sse.ts shutdown 18 | export const activeTerminals = new Map(); 19 | export const TERMINAL_OUTPUT_SSE_CONNECTIONS = new Map(); // Separate map for SSE connections 20 | 21 | // Determine shell based on OS 22 | const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; 23 | const PTY_PROCESS_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour inactivity timeout 24 | const MAX_BUFFER_LENGTH = 200; // Max number of lines/chunks to buffer 25 | 26 | // --- PTY Management Functions --- 27 | 28 | function startPtyProcess(): ActiveTerminal { 29 | const termId = crypto.randomUUID(); 30 | const ptyProcess = pty.spawn(shell, [], { 31 | name: 'xterm-color', 32 | cols: 80, // Default size 33 | rows: 30, 34 | cwd: process.env.HOME || process.cwd(), 35 | env: process.env as { [key: string]: string } 36 | }); 37 | 38 | const terminal: ActiveTerminal = { 39 | ptyProcess, 40 | id: termId, 41 | lastActivity: Date.now(), 42 | initialOutputBuffer: [] // Initialize buffer 43 | }; 44 | 45 | activeTerminals.set(termId, terminal); 46 | console.log(`[Terminal] PTY process created with ID: ${termId}, PID: ${ptyProcess.pid}`); 47 | 48 | ptyProcess.onData((data: string) => { 49 | terminal.lastActivity = Date.now(); 50 | const sseRes = TERMINAL_OUTPUT_SSE_CONNECTIONS.get(termId); 51 | 52 | if (sseRes && !sseRes.writableEnded) { 53 | // If SSE is connected, first flush any buffered output 54 | if (terminal.initialOutputBuffer && terminal.initialOutputBuffer.length > 0) { 55 | console.log(`[Terminal ${termId}] Flushing ${terminal.initialOutputBuffer.length} buffered items to SSE.`); 56 | terminal.initialOutputBuffer.forEach(bufferedData => { 57 | try { 58 | sseRes.write(`event: output\ndata: ${JSON.stringify(bufferedData)}\n\n`); 59 | } catch (e) { 60 | console.error(`[Terminal ${termId}] Error writing buffered data to SSE stream:`, e); 61 | } 62 | }); 63 | terminal.initialOutputBuffer = []; // Clear buffer 64 | } 65 | // Then send the current data 66 | try { 67 | sseRes.write(`event: output\ndata: ${JSON.stringify(data)}\n\n`); 68 | } catch (e) { 69 | console.error(`[Terminal ${termId}] Error writing live data to SSE stream:`, e); 70 | } 71 | } else if (terminal.initialOutputBuffer) { 72 | // SSE not yet connected or has closed, buffer the data 73 | terminal.initialOutputBuffer.push(data); 74 | if (terminal.initialOutputBuffer.length > MAX_BUFFER_LENGTH) { 75 | terminal.initialOutputBuffer.shift(); // Keep buffer from growing indefinitely 76 | } 77 | } 78 | }); 79 | 80 | ptyProcess.onExit(({ exitCode, signal }: { exitCode: number, signal?: number }) => { 81 | console.log(`[Terminal ${termId}] PTY process exited with code ${exitCode}, signal ${signal}`); 82 | const sseRes = TERMINAL_OUTPUT_SSE_CONNECTIONS.get(termId); 83 | if (sseRes && !sseRes.writableEnded) { 84 | try { 85 | sseRes.write(`event: exit\ndata: ${JSON.stringify({ exitCode, signal })}\n\n`); 86 | sseRes.end(); 87 | } catch (e) { 88 | console.error(`[Terminal ${termId}] Error writing exit event to SSE stream:`, e); 89 | } 90 | } 91 | TERMINAL_OUTPUT_SSE_CONNECTIONS.delete(termId); 92 | activeTerminals.delete(termId); 93 | console.log(`[Terminal ${termId}] Cleaned up terminal and SSE connection.`); 94 | }); 95 | 96 | return terminal; 97 | } 98 | 99 | function writeToPty(termId: string, data: string): boolean { 100 | const terminal = activeTerminals.get(termId); 101 | if (terminal) { 102 | terminal.ptyProcess.write(data); 103 | terminal.lastActivity = Date.now(); 104 | return true; 105 | } 106 | return false; 107 | } 108 | 109 | function resizePty(termId: string, cols: number, rows: number): boolean { 110 | const terminal = activeTerminals.get(termId); 111 | if (terminal) { 112 | try { 113 | const safeCols = Math.max(1, Math.floor(cols)); 114 | const safeRows = Math.max(1, Math.floor(rows)); 115 | terminal.ptyProcess.resize(safeCols, safeRows); 116 | terminal.lastActivity = Date.now(); 117 | console.log(`[Terminal ${termId}] Resized to ${safeCols}x${safeRows}`); 118 | return true; 119 | } catch (e) { 120 | console.error(`[Terminal ${termId}] Error resizing PTY:`, e); 121 | return false; 122 | } 123 | } 124 | return false; 125 | } 126 | 127 | function killPty(termId: string): boolean { 128 | const terminal = activeTerminals.get(termId); 129 | if (terminal) { 130 | console.log(`[Terminal ${termId}] Killing PTY process (PID: ${terminal.ptyProcess.pid})`); 131 | terminal.ptyProcess.kill(); 132 | return true; 133 | } 134 | return false; 135 | } 136 | 137 | setInterval(() => { 138 | const now = Date.now(); 139 | activeTerminals.forEach((terminal, termId) => { 140 | if (now - terminal.lastActivity > PTY_PROCESS_TIMEOUT_MS) { 141 | console.log(`[Terminal ${termId}] PTY process timed out due to inactivity. Killing.`); 142 | killPty(termId); 143 | } 144 | }); 145 | }, 1000 * 60 * 5); 146 | 147 | export const terminalRouter = Router(); 148 | 149 | terminalRouter.post('/start', (req, res) => { 150 | try { 151 | const terminal = startPtyProcess(); 152 | res.status(200).json({ termId: terminal.id }); 153 | } catch (e) { 154 | console.error("[Terminal] Error starting PTY process:", e); 155 | res.status(500).json({ error: 'Failed to start terminal session.' }); 156 | } 157 | }); 158 | 159 | terminalRouter.post('/:termId/input', (req, res) => { 160 | const termId = req.params.termId; 161 | const input = req.body?.input; 162 | 163 | if (typeof input !== 'string') { 164 | return res.status(400).json({ error: 'Invalid input data. Expecting { "input": "string" }.' }); 165 | } 166 | 167 | if (writeToPty(termId, input)) { 168 | res.status(200).send(); 169 | } else { 170 | res.status(404).json({ error: `Terminal session not found: ${termId}` }); 171 | } 172 | }); 173 | 174 | terminalRouter.post('/:termId/resize', (req, res) => { 175 | const termId = req.params.termId; 176 | const { cols, rows } = req.body; 177 | 178 | if (typeof cols !== 'number' || typeof rows !== 'number' || cols <= 0 || rows <= 0) { 179 | return res.status(400).json({ error: 'Invalid size data. Expecting { "cols": number, "rows": number }.' }); 180 | } 181 | 182 | if (resizePty(termId, Math.floor(cols), Math.floor(rows))) { 183 | res.status(200).send(); 184 | } else { 185 | res.status(404).json({ error: `Terminal session not found: ${termId}` }); 186 | } 187 | }); 188 | 189 | terminalRouter.delete('/:termId', (req, res) => { 190 | const termId = req.params.termId; 191 | if (killPty(termId)) { 192 | res.status(200).json({ message: `Terminal session ${termId} killed.` }); 193 | } else { 194 | res.status(404).json({ error: `Terminal session not found: ${termId}` }); 195 | } 196 | }); 197 | 198 | terminalRouter.get('/:termId/output', (req, res) => { 199 | const termId = req.params.termId; 200 | const terminal = activeTerminals.get(termId); 201 | 202 | if (!terminal) { 203 | return res.status(404).json({ error: `Terminal session not found: ${termId}` }); 204 | } 205 | 206 | if (TERMINAL_OUTPUT_SSE_CONNECTIONS.has(termId)) { 207 | console.warn(`[Terminal ${termId}] Attempted to establish duplicate SSE output stream.`); 208 | const oldRes = TERMINAL_OUTPUT_SSE_CONNECTIONS.get(termId); 209 | try { oldRes?.end(); } catch(e){} 210 | TERMINAL_OUTPUT_SSE_CONNECTIONS.delete(termId); 211 | console.log(`[Terminal ${termId}] Closed existing SSE output stream to allow new connection.`); 212 | } 213 | 214 | console.log(`[Terminal ${termId}] SSE output stream connection received.`); 215 | res.writeHead(200, { 216 | 'Content-Type': 'text/event-stream', 217 | 'Cache-Control': 'no-cache, no-transform', 218 | 'Connection': 'keep-alive', 219 | }); 220 | 221 | res.write(`event: connected\ndata: ${JSON.stringify({ message: `Connected to terminal ${termId} output` })}\n\n`); 222 | 223 | TERMINAL_OUTPUT_SSE_CONNECTIONS.set(termId, res); 224 | 225 | // Flush initial buffer if it exists and has content 226 | if (terminal.initialOutputBuffer && terminal.initialOutputBuffer.length > 0) { 227 | console.log(`[Terminal ${termId}] Flushing initial output buffer (${terminal.initialOutputBuffer.length} items) to new SSE connection.`); 228 | terminal.initialOutputBuffer.forEach(bufferedData => { 229 | try { 230 | if (!res.writableEnded) { 231 | res.write(`event: output\ndata: ${JSON.stringify(bufferedData)}\n\n`); 232 | } 233 | } catch (e) { 234 | console.error(`[Terminal ${termId}] Error writing initial buffered data to SSE stream:`, e); 235 | } 236 | }); 237 | terminal.initialOutputBuffer = []; // Clear buffer after flushing 238 | } 239 | 240 | 241 | req.on('close', () => { 242 | console.log(`[Terminal ${termId}] SSE output stream connection closed by client.`); 243 | TERMINAL_OUTPUT_SSE_CONNECTIONS.delete(termId); 244 | }); 245 | }); 246 | 247 | terminalRouter.get('/list', (req, res) => { 248 | const terms = Array.from(activeTerminals.keys()).map(id => { 249 | const term = activeTerminals.get(id); 250 | return { 251 | id, 252 | pid: term?.ptyProcess.pid, 253 | lastActivity: term?.lastActivity, 254 | bufferSize: term?.initialOutputBuffer?.length || 0 255 | }; 256 | }); 257 | res.json({ terminals: terms }); 258 | }); -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 2 | import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'; 3 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 4 | import { StreamableHTTPClientTransport, StreamableHTTPClientTransportOptions } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; 5 | import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; 6 | import { TransportConfig, isSSEConfig, isStdioConfig, isHttpConfig } from './config.js'; 7 | import { EventSource } from 'eventsource'; 8 | import { logger } from './logger.js'; // Import logger functions 9 | 10 | const sleep = (time: number) => new Promise(resolve => setTimeout(() => resolve(), time)) 11 | export interface ConnectedClient { 12 | client: Client; 13 | cleanup: () => Promise; 14 | name: string; 15 | config: TransportConfig; // Added config 16 | transportType: 'sse' | 'stdio' | 'http'; // Added transportType 17 | } 18 | 19 | const createClient = (name: string, transportConfig: TransportConfig): { client: Client | undefined, transport: Transport | undefined, transportType: 'sse' | 'stdio' | 'http' | undefined } => { 20 | 21 | let transport: Transport | null = null; 22 | let transportType: 'sse' | 'stdio' | 'http' | undefined = undefined; 23 | try { 24 | if (isSSEConfig(transportConfig)) { 25 | transportType = 'sse'; 26 | const transportOptions: SSEClientTransportOptions = {}; 27 | let customHeaders: Record | undefined; 28 | 29 | if (transportConfig.bearerToken) { 30 | customHeaders = { 'Authorization': `Bearer ${transportConfig.bearerToken}` }; 31 | logger.debug(` Using Bearer Token for SSE connection to ${name}`); // Changed to debug 32 | } else if (transportConfig.apiKey) { 33 | customHeaders = { 'X-Api-Key': transportConfig.apiKey }; 34 | logger.debug(` Using X-Api-Key for SSE connection to ${name}`); // Changed to debug 35 | } 36 | 37 | if (customHeaders) { 38 | // Apply custom headers to requestInit for POST requests 39 | transportOptions.requestInit = { 40 | headers: customHeaders, 41 | }; 42 | 43 | // Apply custom headers to eventSourceInit.fetch for GET requests 44 | const headersToAdd = customHeaders; 45 | transportOptions.eventSourceInit = { 46 | fetch(input: RequestInfo | URL, init?: RequestInit): Promise { 47 | const originalHeaders = new Headers(init?.headers || {}); 48 | for (const key in headersToAdd) { 49 | originalHeaders.set(key, headersToAdd[key]); 50 | } 51 | return fetch(input, { 52 | ...init, 53 | headers: originalHeaders, 54 | }); 55 | }, 56 | } as any; 57 | } 58 | 59 | transport = new SSEClientTransport(new URL(transportConfig.url), transportOptions); 60 | } else if (isStdioConfig(transportConfig)) { 61 | transportType = 'stdio'; 62 | const mergedEnv = { 63 | ...process.env, 64 | ...transportConfig.env 65 | }; 66 | const filteredEnv: Record = {}; 67 | for (const key in mergedEnv) { 68 | if (Object.prototype.hasOwnProperty.call(mergedEnv, key) && mergedEnv[key] !== undefined) { 69 | filteredEnv[key] = mergedEnv[key] as string; 70 | } 71 | } 72 | transport = new StdioClientTransport({ 73 | command: transportConfig.command, 74 | args: transportConfig.args, 75 | env: filteredEnv 76 | }); 77 | } else if (isHttpConfig(transportConfig)) { 78 | transportType = 'http'; 79 | const transportOptions: StreamableHTTPClientTransportOptions = {}; 80 | let customHeaders: Record | undefined; 81 | 82 | if (transportConfig.bearerToken) { 83 | customHeaders = { 'Authorization': `Bearer ${transportConfig.bearerToken}` }; 84 | logger.debug(` Using Bearer Token for StreamableHTTP connection to ${name}`); // Changed to debug 85 | } else if (transportConfig.apiKey) { 86 | customHeaders = { 'X-Api-Key': transportConfig.apiKey }; 87 | logger.debug(` Using X-Api-Key for StreamableHTTP connection to ${name}`); // Changed to debug 88 | } 89 | 90 | if (customHeaders) { 91 | transportOptions.requestInit = { headers: customHeaders }; 92 | } 93 | // Note: StreamableHTTPClientTransport handles session ID internally if configured. 94 | // We might pass transportConfig.sessionId if we want to force a specific one. 95 | transport = new StreamableHTTPClientTransport(new URL(transportConfig.url), transportOptions); 96 | } else { 97 | logger.error(`Invalid or unknown transport type in configuration for server: ${name}`); // Changed to error 98 | } 99 | } catch (error) { 100 | let transportType = 'unknown'; 101 | if (isSSEConfig(transportConfig)) transportType = 'sse'; 102 | else if (isStdioConfig(transportConfig)) transportType = 'stdio'; 103 | else if (isHttpConfig(transportConfig)) transportType = 'http'; 104 | logger.error(`Failed to create transport ${transportType} to ${name}:`, error); // Changed to error 105 | } 106 | 107 | if (!transport || !transportType) { // Also check transportType 108 | logger.warn(`Transport or transportType for ${name} not available.`); // Changed to warn 109 | return { transport: undefined, client: undefined, transportType: undefined }; 110 | } 111 | 112 | const client = new Client({ 113 | name: 'mcp-proxy-client', 114 | version: '1.0.0', 115 | }, { 116 | capabilities: { 117 | prompts: {}, 118 | resources: { subscribe: true }, 119 | tools: {} 120 | } 121 | }); 122 | 123 | return { client, transport, transportType } 124 | } 125 | 126 | export const createClients = async (mcpServers: Record): Promise => { 127 | const clients: ConnectedClient[] = []; 128 | 129 | for (const [name, transportConfig] of Object.entries(mcpServers)) { 130 | logger.log(`Connecting to server: ${name}`); // Changed to log 131 | 132 | const waitFor = 2500; 133 | const retries = 3; 134 | let count = 0 135 | let retry = true 136 | 137 | while (retry) { 138 | 139 | const { client, transport, transportType } = createClient(name, transportConfig); // Capture transportType 140 | if (!client || !transport || !transportType) { // Check transportType 141 | logger.warn(`Skipping client ${name} due to failed client/transport creation.`); // Changed to warn 142 | break; 143 | } 144 | 145 | try { 146 | await client.connect(transport); 147 | logger.log(`Connected to server: ${name}`); // Changed to log 148 | 149 | clients.push({ 150 | client, 151 | name: name, 152 | config: transportConfig, // Store config 153 | transportType: transportType, // Store transportType 154 | cleanup: async () => { 155 | await transport.close(); 156 | } 157 | }); 158 | 159 | break 160 | 161 | } catch (error: any) { 162 | logger.error(`Failed to connect to ${name}: ${error.message}`); // Log error message 163 | count++; 164 | retry = (count < retries); 165 | if (retry) { 166 | try { 167 | await client.close(); 168 | } catch { } 169 | logger.log(`Retry connection to ${name} in ${waitFor}ms (${count}/${retries})`); // Changed to log 170 | await sleep(waitFor); 171 | } 172 | } 173 | 174 | } 175 | 176 | } 177 | 178 | return clients; 179 | }; 180 | 181 | // No longer using ReconnectedClientResult, returning full ConnectedClient-like structure 182 | // but as a direct object, which refreshBackendConnection will use to create a full ConnectedClient. 183 | 184 | export async function reconnectSingleClient( 185 | name: string, 186 | transportConfig: TransportConfig, 187 | existingCleanup?: () => Promise 188 | ): Promise> { // Returns the parts needed to reconstruct a ConnectedClient 189 | logger.log(`Attempting to reconnect client: ${name}`); // Changed to log 190 | 191 | if (existingCleanup) { 192 | try { 193 | await existingCleanup(); 194 | logger.log(`Existing client ${name} cleaned up before reconnecting.`); // Changed to log 195 | } catch (e: any) { 196 | logger.warn(`Error during cleanup of existing client ${name} before reconnect: ${e.message}`); // Changed to warn 197 | } 198 | } 199 | 200 | let transport: Transport | null = null; 201 | let determinedTransportType: 'sse' | 'stdio' | 'http' | undefined = undefined; 202 | 203 | try { 204 | if (isSSEConfig(transportConfig)) { 205 | determinedTransportType = 'sse'; 206 | const transportOptions: SSEClientTransportOptions = {}; 207 | let customHeaders: Record | undefined; 208 | if (transportConfig.bearerToken) { 209 | customHeaders = { 'Authorization': `Bearer ${transportConfig.bearerToken}` }; 210 | logger.debug(` Using Bearer Token for SSE connection to ${name} (reconnect)`); // Changed to debug 211 | } else if (transportConfig.apiKey) { 212 | customHeaders = { 'X-Api-Key': transportConfig.apiKey }; 213 | logger.debug(` Using X-Api-Key for SSE connection to ${name} (reconnect)`); // Changed to debug 214 | } 215 | if (customHeaders) { 216 | transportOptions.requestInit = { headers: customHeaders }; 217 | const headersToAdd = customHeaders; // Closure for fetch 218 | transportOptions.eventSourceInit = { 219 | fetch(input: RequestInfo | URL, init?: RequestInit): Promise { 220 | const originalHeaders = new Headers(init?.headers || {}); 221 | for (const key in headersToAdd) { 222 | originalHeaders.set(key, headersToAdd[key]); 223 | } 224 | return fetch(input, { ...init, headers: originalHeaders }); 225 | }, 226 | } as any; 227 | } 228 | transport = new SSEClientTransport(new URL(transportConfig.url), transportOptions); 229 | } else if (isStdioConfig(transportConfig)) { 230 | determinedTransportType = 'stdio'; 231 | const mergedEnv = { ...process.env, ...transportConfig.env }; 232 | const filteredEnv: Record = {}; 233 | for (const key in mergedEnv) { 234 | if (Object.prototype.hasOwnProperty.call(mergedEnv, key) && mergedEnv[key] !== undefined) { 235 | filteredEnv[key] = mergedEnv[key] as string; 236 | } 237 | } 238 | transport = new StdioClientTransport({ 239 | command: transportConfig.command, 240 | args: transportConfig.args, 241 | env: filteredEnv 242 | }); 243 | logger.debug(` Configured Stdio transport for ${name} (reconnect)`); // Changed to debug 244 | } else if (isHttpConfig(transportConfig)) { 245 | determinedTransportType = 'http'; 246 | const transportOptions: StreamableHTTPClientTransportOptions = {}; 247 | let customHeaders: Record | undefined; 248 | if (transportConfig.bearerToken) { 249 | customHeaders = { 'Authorization': `Bearer ${transportConfig.bearerToken}` }; 250 | logger.debug(` Using Bearer Token for StreamableHTTP connection to ${name} (reconnect)`); // Changed to debug 251 | } else if (transportConfig.apiKey) { 252 | customHeaders = { 'X-Api-Key': transportConfig.apiKey }; 253 | logger.debug(` Using X-Api-Key for StreamableHTTP connection to ${name} (reconnect)`); // Changed to debug 254 | } 255 | if (customHeaders) { 256 | transportOptions.requestInit = { headers: customHeaders }; 257 | } 258 | transport = new StreamableHTTPClientTransport(new URL(transportConfig.url), transportOptions); 259 | } else { 260 | throw new Error(`Invalid or unknown transport type in configuration for server: ${name}`); 261 | } 262 | } catch (error: any) { 263 | logger.error(`Failed to create transport for ${name} during reconnect: ${error.message}`); // Changed to error 264 | throw error; 265 | } 266 | 267 | if (!transport || !determinedTransportType) { // Check determinedTransportType as well 268 | throw new Error(`Transport or transport type for ${name} could not be created during reconnect.`); 269 | } 270 | 271 | const newSdkClient = new Client({ 272 | name: 'mcp-proxy-client-reconnect', 273 | version: '1.0.1', 274 | }, { 275 | capabilities: { prompts: {}, resources: { subscribe: true }, tools: {} } 276 | }); 277 | 278 | try { 279 | await newSdkClient.connect(transport); 280 | logger.log(`Successfully reconnected to server: ${name}`); // Changed to log 281 | const finalTransport = transport; // Capture for closure 282 | return { 283 | client: newSdkClient, 284 | config: transportConfig, // Return config 285 | transportType: determinedTransportType, // Return transportType 286 | cleanup: async () => { 287 | if (finalTransport) { 288 | await finalTransport.close(); 289 | } 290 | } 291 | }; 292 | } catch (error: any) { 293 | logger.error(`Failed to connect to ${name} during reconnect attempt: ${error.message}`); // Changed to error 294 | try { 295 | if (transport) { 296 | await transport.close(); 297 | } 298 | } catch (closeError: any) { 299 | logger.warn(`Failed to close transport for ${name} after reconnect failure: ${closeError.message}`); // Changed to warn 300 | } 301 | throw error; 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs/promises'; 2 | import { resolve } from 'path'; 3 | import { logger } from './logger.js'; 4 | 5 | export type TransportConfigStdio = { 6 | type: 'stdio'; 7 | name?: string; 8 | command: string; 9 | args?: string[]; 10 | env?: Record; 11 | active?: boolean; 12 | installDirectory?: string; 13 | installCommands?: string[]; 14 | } 15 | 16 | export type TransportConfigSSE = { 17 | type: 'sse'; 18 | name?: string; 19 | url: string; 20 | active?: boolean; 21 | apiKey?: string; 22 | bearerToken?: string; 23 | } 24 | 25 | export type TransportConfigHTTP = { 26 | type: 'http'; 27 | name?: string; 28 | url: string; 29 | active?: boolean; 30 | apiKey?: string; // Assuming similar auth for now 31 | bearerToken?: string; // Assuming similar auth for now 32 | // Add any HTTP specific options if needed, e.g., custom headers not covered by apiKey/bearerToken 33 | // requestInit?: RequestInit; // This is a more generic way if SDK supports it directly in config 34 | } 35 | 36 | export type TransportConfig = (TransportConfigStdio | TransportConfigSSE | TransportConfigHTTP) & { name?: string, active?: boolean, type: 'stdio' | 'sse' | 'http' }; 37 | 38 | export interface ProxySettings { 39 | retrySseToolCall?: boolean; // Renamed from retrySseToolCallOnDisconnect 40 | sseToolCallMaxRetries?: number; 41 | sseToolCallRetryDelayBaseMs?: number; 42 | retryHttpToolCall?: boolean; 43 | httpToolCallMaxRetries?: number; 44 | httpToolCallRetryDelayBaseMs?: number; 45 | retryStdioToolCall?: boolean; 46 | stdioToolCallMaxRetries?: number; 47 | stdioToolCallRetryDelayBaseMs?: number; 48 | } 49 | 50 | export const DEFAULT_SERVER_TOOLNAME_SEPERATOR = '__'; // Changed default separator 51 | export const SERVER_TOOLNAME_SEPERATOR_ENV_VAR = 'SERVER_TOOLNAME_SEPERATOR'; 52 | 53 | export interface Config { 54 | mcpServers: Record; 55 | proxy?: ProxySettings; 56 | serverToolnameSeparator?: string; // Added for the separator 57 | } 58 | 59 | 60 | export interface ToolSettings { 61 | enabled: boolean; 62 | exposedName?: string; 63 | exposedDescription?: string; 64 | } 65 | 66 | export interface ToolConfig { 67 | tools: Record; 68 | } 69 | 70 | 71 | export function isSSEConfig(config: TransportConfig): config is TransportConfigSSE { 72 | return config.type === 'sse'; 73 | } 74 | 75 | export function isStdioConfig(config: TransportConfig): config is TransportConfigStdio { 76 | return config.type === 'stdio'; 77 | } 78 | 79 | export function isHttpConfig(config: TransportConfig): config is TransportConfigHTTP { 80 | return config.type === 'http'; 81 | } 82 | 83 | 84 | export const loadConfig = async (): Promise => { 85 | // Define standard defaults for specific environment-overrideable proxy settings 86 | // This is moved here to be in scope for both try and catch blocks. 87 | const defaultEnvProxySettings = { 88 | retrySseToolCall: true, // Renamed from retrySseToolCallOnDisconnect 89 | sseToolCallMaxRetries: 2, 90 | sseToolCallRetryDelayBaseMs: 300, 91 | retryHttpToolCall: true, 92 | httpToolCallMaxRetries: 2, 93 | httpToolCallRetryDelayBaseMs: 300, 94 | retryStdioToolCall: true, 95 | stdioToolCallMaxRetries: 2, 96 | stdioToolCallRetryDelayBaseMs: 300, 97 | }; 98 | 99 | let serverToolnameSeparator = DEFAULT_SERVER_TOOLNAME_SEPERATOR; 100 | const envSeparator = process.env[SERVER_TOOLNAME_SEPERATOR_ENV_VAR]; 101 | const separatorRegex = /^[a-zA-Z0-9_-]+$/; // Regex for valid characters 102 | 103 | if (envSeparator !== undefined && envSeparator.trim() !== '') { 104 | const trimmedSeparator = envSeparator.trim(); 105 | if (trimmedSeparator.length >= 2 && separatorRegex.test(trimmedSeparator)) { 106 | serverToolnameSeparator = trimmedSeparator; 107 | logger.log(`Using server toolname separator from environment variable ${SERVER_TOOLNAME_SEPERATOR_ENV_VAR}: "${serverToolnameSeparator}"`); 108 | } else { 109 | logger.warn(`Invalid value for environment variable ${SERVER_TOOLNAME_SEPERATOR_ENV_VAR}: "${envSeparator}". Separator must be at least 2 characters long and contain only letters, numbers, '-', and '_'. Using default: "${DEFAULT_SERVER_TOOLNAME_SEPERATOR}".`); 110 | serverToolnameSeparator = DEFAULT_SERVER_TOOLNAME_SEPERATOR; 111 | } 112 | } else { 113 | logger.log(`Environment variable ${SERVER_TOOLNAME_SEPERATOR_ENV_VAR} not set or empty. Using default separator: "${DEFAULT_SERVER_TOOLNAME_SEPERATOR}".`); 114 | serverToolnameSeparator = DEFAULT_SERVER_TOOLNAME_SEPERATOR; 115 | } 116 | 117 | 118 | try { 119 | const configPath = resolve(process.cwd(), 'config', 'mcp_server.json'); 120 | console.log(`Attempting to load configuration from: ${configPath}`); 121 | const fileContents = await readFile(configPath, 'utf-8'); 122 | const parsedConfig = JSON.parse(fileContents) as Config; 123 | 124 | if (typeof parsedConfig !== 'object' || parsedConfig === null || typeof parsedConfig.mcpServers !== 'object') { 125 | throw new Error('Invalid config format: mcpServers object not found.'); 126 | } 127 | 128 | // Initialize proxy object on parsedConfig if it doesn't exist 129 | parsedConfig.proxy = parsedConfig.proxy || {}; 130 | 131 | // Override with environment variables or defaults for the specific settings 132 | 133 | // SSE Retry Settings 134 | const sseRetryEnv = process.env.RETRY_SSE_TOOL_CALL; // Changed env var name 135 | if (sseRetryEnv && sseRetryEnv.trim() !== '') { 136 | parsedConfig.proxy.retrySseToolCall = sseRetryEnv.toLowerCase() === 'true'; // Changed property name 137 | } else { 138 | parsedConfig.proxy.retrySseToolCall = defaultEnvProxySettings.retrySseToolCall; // Changed property name 139 | } 140 | 141 | const sseMaxRetriesEnv = process.env.SSE_TOOL_CALL_MAX_RETRIES; 142 | if (sseMaxRetriesEnv && sseMaxRetriesEnv.trim() !== '') { 143 | const numVal = parseInt(sseMaxRetriesEnv, 10); 144 | if (!isNaN(numVal)) { 145 | parsedConfig.proxy.sseToolCallMaxRetries = numVal; 146 | } else { 147 | logger.warn(`Invalid value for SSE_TOOL_CALL_MAX_RETRIES: "${sseMaxRetriesEnv}". Using default: ${defaultEnvProxySettings.sseToolCallMaxRetries}.`); 148 | parsedConfig.proxy.sseToolCallMaxRetries = defaultEnvProxySettings.sseToolCallMaxRetries; 149 | } 150 | } else { 151 | parsedConfig.proxy.sseToolCallMaxRetries = defaultEnvProxySettings.sseToolCallMaxRetries; 152 | } 153 | 154 | const sseDelayBaseEnv = process.env.SSE_TOOL_CALL_RETRY_DELAY_BASE_MS; 155 | if (sseDelayBaseEnv && sseDelayBaseEnv.trim() !== '') { 156 | const numVal = parseInt(sseDelayBaseEnv, 10); 157 | if (!isNaN(numVal)) { 158 | parsedConfig.proxy.sseToolCallRetryDelayBaseMs = numVal; 159 | } else { 160 | logger.warn(`Invalid value for SSE_TOOL_CALL_RETRY_DELAY_BASE_MS: "${sseDelayBaseEnv}". Using default: ${defaultEnvProxySettings.sseToolCallRetryDelayBaseMs}.`); 161 | parsedConfig.proxy.sseToolCallRetryDelayBaseMs = defaultEnvProxySettings.sseToolCallRetryDelayBaseMs; 162 | } 163 | } else { 164 | parsedConfig.proxy.sseToolCallRetryDelayBaseMs = defaultEnvProxySettings.sseToolCallRetryDelayBaseMs; 165 | } 166 | 167 | 168 | // HTTP Retry Settings 169 | const httpRetryEnv = process.env.RETRY_HTTP_TOOL_CALL; 170 | if (httpRetryEnv && httpRetryEnv.trim() !== '') { 171 | parsedConfig.proxy.retryHttpToolCall = httpRetryEnv.toLowerCase() === 'true'; 172 | } else { 173 | parsedConfig.proxy.retryHttpToolCall = defaultEnvProxySettings.retryHttpToolCall; 174 | } 175 | 176 | const maxRetriesEnv = process.env.HTTP_TOOL_CALL_MAX_RETRIES; 177 | if (maxRetriesEnv && maxRetriesEnv.trim() !== '') { 178 | const numVal = parseInt(maxRetriesEnv, 10); 179 | if (!isNaN(numVal)) { 180 | parsedConfig.proxy.httpToolCallMaxRetries = numVal; 181 | } else { 182 | logger.warn(`Invalid value for HTTP_TOOL_CALL_MAX_RETRIES: "${maxRetriesEnv}". Using default: ${defaultEnvProxySettings.httpToolCallMaxRetries}.`); 183 | parsedConfig.proxy.httpToolCallMaxRetries = defaultEnvProxySettings.httpToolCallMaxRetries; 184 | } 185 | } else { 186 | parsedConfig.proxy.httpToolCallMaxRetries = defaultEnvProxySettings.httpToolCallMaxRetries; 187 | } 188 | 189 | const delayBaseEnv = process.env.HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS; 190 | if (delayBaseEnv && delayBaseEnv.trim() !== '') { 191 | const numVal = parseInt(delayBaseEnv, 10); 192 | if (!isNaN(numVal)) { 193 | parsedConfig.proxy.httpToolCallRetryDelayBaseMs = numVal; 194 | } else { 195 | logger.warn(`Invalid value for HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS: "${delayBaseEnv}". Using default: ${defaultEnvProxySettings.httpToolCallRetryDelayBaseMs}.`); 196 | parsedConfig.proxy.httpToolCallRetryDelayBaseMs = defaultEnvProxySettings.httpToolCallRetryDelayBaseMs; 197 | } 198 | } else { 199 | parsedConfig.proxy.httpToolCallRetryDelayBaseMs = defaultEnvProxySettings.httpToolCallRetryDelayBaseMs; 200 | } 201 | 202 | // STDIO Retry Settings 203 | const stdioRetryEnv = process.env.RETRY_STDIO_TOOL_CALL; 204 | if (stdioRetryEnv && stdioRetryEnv.trim() !== '') { 205 | parsedConfig.proxy.retryStdioToolCall = stdioRetryEnv.toLowerCase() === 'true'; 206 | } else { 207 | parsedConfig.proxy.retryStdioToolCall = defaultEnvProxySettings.retryStdioToolCall; 208 | } 209 | 210 | const stdioMaxRetriesEnv = process.env.STDIO_TOOL_CALL_MAX_RETRIES; 211 | if (stdioMaxRetriesEnv && stdioMaxRetriesEnv.trim() !== '') { 212 | const numVal = parseInt(stdioMaxRetriesEnv, 10); 213 | if (!isNaN(numVal)) { 214 | parsedConfig.proxy.stdioToolCallMaxRetries = numVal; 215 | } else { 216 | logger.warn(`Invalid value for STDIO_TOOL_CALL_MAX_RETRIES: "${stdioMaxRetriesEnv}". Using default: ${defaultEnvProxySettings.stdioToolCallMaxRetries}.`); 217 | parsedConfig.proxy.stdioToolCallMaxRetries = defaultEnvProxySettings.stdioToolCallMaxRetries; 218 | } 219 | } else { 220 | parsedConfig.proxy.stdioToolCallMaxRetries = defaultEnvProxySettings.stdioToolCallMaxRetries; 221 | } 222 | 223 | const stdioDelayBaseEnv = process.env.STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS; 224 | if (stdioDelayBaseEnv && stdioDelayBaseEnv.trim() !== '') { 225 | const numVal = parseInt(stdioDelayBaseEnv, 10); 226 | if (!isNaN(numVal)) { 227 | parsedConfig.proxy.stdioToolCallRetryDelayBaseMs = numVal; 228 | } else { 229 | logger.warn(`Invalid value for STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS: "${stdioDelayBaseEnv}". Using default: ${defaultEnvProxySettings.stdioToolCallRetryDelayBaseMs}.`); 230 | parsedConfig.proxy.stdioToolCallRetryDelayBaseMs = defaultEnvProxySettings.stdioToolCallRetryDelayBaseMs; 231 | } 232 | } else { 233 | parsedConfig.proxy.stdioToolCallRetryDelayBaseMs = defaultEnvProxySettings.stdioToolCallRetryDelayBaseMs; 234 | } 235 | 236 | logger.log("Loaded config with final proxy settings (after env overrides):", JSON.stringify(parsedConfig.proxy).slice(1, -1)); 237 | 238 | // Add the determined separator to the config object 239 | parsedConfig.serverToolnameSeparator = serverToolnameSeparator; 240 | 241 | return parsedConfig; 242 | 243 | } catch (error: any) { 244 | logger.error(`Error loading config/mcp_server.json: ${error.message}`); 245 | 246 | // If file loading fails, initialize with environment variables or defaults for proxy settings 247 | const proxySettingsFromEnvOrDefaults: ProxySettings = { 248 | retrySseToolCall: defaultEnvProxySettings.retrySseToolCall, 249 | sseToolCallMaxRetries: defaultEnvProxySettings.sseToolCallMaxRetries, // Default for SSE max retries 250 | sseToolCallRetryDelayBaseMs: defaultEnvProxySettings.sseToolCallRetryDelayBaseMs, // Default for SSE retry delay 251 | retryHttpToolCall: defaultEnvProxySettings.retryHttpToolCall, 252 | httpToolCallMaxRetries: defaultEnvProxySettings.httpToolCallMaxRetries, 253 | httpToolCallRetryDelayBaseMs: defaultEnvProxySettings.httpToolCallRetryDelayBaseMs, 254 | retryStdioToolCall: defaultEnvProxySettings.retryStdioToolCall, 255 | stdioToolCallMaxRetries: defaultEnvProxySettings.stdioToolCallMaxRetries, 256 | stdioToolCallRetryDelayBaseMs: defaultEnvProxySettings.stdioToolCallRetryDelayBaseMs, 257 | }; 258 | 259 | // SSE Retry Settings (during error handling) 260 | const sseRetryEnvCatch = process.env.RETRY_SSE_TOOL_CALL; // Changed env var name 261 | if (sseRetryEnvCatch && sseRetryEnvCatch.trim() !== '') { 262 | proxySettingsFromEnvOrDefaults.retrySseToolCall = sseRetryEnvCatch.toLowerCase() === 'true'; // Changed property name 263 | } 264 | 265 | const sseMaxRetriesEnvCatch = process.env.SSE_TOOL_CALL_MAX_RETRIES; 266 | if (sseMaxRetriesEnvCatch && sseMaxRetriesEnvCatch.trim() !== '') { 267 | const numVal = parseInt(sseMaxRetriesEnvCatch, 10); 268 | if (!isNaN(numVal)) { 269 | proxySettingsFromEnvOrDefaults.sseToolCallMaxRetries = numVal; 270 | } else { 271 | logger.warn(`Invalid value for SSE_TOOL_CALL_MAX_RETRIES: "${sseMaxRetriesEnvCatch}" (during error handling). Using default: ${defaultEnvProxySettings.sseToolCallMaxRetries}.`); 272 | } 273 | } 274 | 275 | const sseDelayBaseEnvCatch = process.env.SSE_TOOL_CALL_RETRY_DELAY_BASE_MS; 276 | if (sseDelayBaseEnvCatch && sseDelayBaseEnvCatch.trim() !== '') { 277 | const numVal = parseInt(sseDelayBaseEnvCatch, 10); 278 | if (!isNaN(numVal)) { 279 | proxySettingsFromEnvOrDefaults.sseToolCallRetryDelayBaseMs = numVal; 280 | } else { 281 | logger.warn(`Invalid value for SSE_TOOL_CALL_RETRY_DELAY_BASE_MS: "${sseDelayBaseEnvCatch}" (during error handling). Using default: ${defaultEnvProxySettings.sseToolCallRetryDelayBaseMs}.`); 282 | } 283 | } 284 | 285 | // HTTP Retry Settings (during error handling) 286 | const httpRetryEnvCatch = process.env.RETRY_HTTP_TOOL_CALL; 287 | if (httpRetryEnvCatch && httpRetryEnvCatch.trim() !== '') { 288 | proxySettingsFromEnvOrDefaults.retryHttpToolCall = httpRetryEnvCatch.toLowerCase() === 'true'; 289 | } 290 | 291 | const maxRetriesEnvCatch = process.env.HTTP_TOOL_CALL_MAX_RETRIES; 292 | if (maxRetriesEnvCatch && maxRetriesEnvCatch.trim() !== '') { 293 | const numVal = parseInt(maxRetriesEnvCatch, 10); 294 | if (!isNaN(numVal)) { 295 | proxySettingsFromEnvOrDefaults.httpToolCallMaxRetries = numVal; 296 | } else { 297 | logger.warn(`Invalid value for HTTP_TOOL_CALL_MAX_RETRIES: "${maxRetriesEnvCatch}" (during error handling). Using default: ${defaultEnvProxySettings.httpToolCallMaxRetries}.`); 298 | } 299 | } 300 | 301 | const delayBaseEnvCatch = process.env.HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS; 302 | if (delayBaseEnvCatch && delayBaseEnvCatch.trim() !== '') { 303 | const numVal = parseInt(delayBaseEnvCatch, 10); 304 | if (!isNaN(numVal)) { 305 | proxySettingsFromEnvOrDefaults.httpToolCallRetryDelayBaseMs = numVal; 306 | } else { 307 | logger.warn(`Invalid value for HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS: "${delayBaseEnvCatch}" (during error handling). Using default: ${defaultEnvProxySettings.httpToolCallRetryDelayBaseMs}.`); 308 | } 309 | } 310 | 311 | // STDIO Retry Settings (during error handling) 312 | const stdioRetryEnvCatch = process.env.RETRY_STDIO_TOOL_CALL; 313 | if (stdioRetryEnvCatch && stdioRetryEnvCatch.trim() !== '') { 314 | proxySettingsFromEnvOrDefaults.retryStdioToolCall = stdioRetryEnvCatch.toLowerCase() === 'true'; 315 | } 316 | 317 | const stdioMaxRetriesEnvCatch = process.env.STDIO_TOOL_CALL_MAX_RETRIES; 318 | if (stdioMaxRetriesEnvCatch && stdioMaxRetriesEnvCatch.trim() !== '') { 319 | const numVal = parseInt(stdioMaxRetriesEnvCatch, 10); 320 | if (!isNaN(numVal)) { 321 | proxySettingsFromEnvOrDefaults.stdioToolCallMaxRetries = numVal; 322 | } else { 323 | logger.warn(`Invalid value for STDIO_TOOL_CALL_MAX_RETRIES: "${stdioMaxRetriesEnvCatch}" (during error handling). Using default: ${defaultEnvProxySettings.stdioToolCallMaxRetries}.`); 324 | } 325 | } 326 | 327 | const stdioDelayBaseEnvCatch = process.env.STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS; 328 | if (stdioDelayBaseEnvCatch && stdioDelayBaseEnvCatch.trim() !== '') { 329 | const numVal = parseInt(stdioDelayBaseEnvCatch, 10); 330 | if (!isNaN(numVal)) { 331 | proxySettingsFromEnvOrDefaults.stdioToolCallRetryDelayBaseMs = numVal; 332 | } else { 333 | logger.warn(`Invalid value for STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS: "${stdioDelayBaseEnvCatch}" (during error handling). Using default: ${defaultEnvProxySettings.stdioToolCallRetryDelayBaseMs}.`); 334 | } 335 | } 336 | 337 | logger.log("Using proxy settings from environment/defaults due to mcp_server.json load error:", proxySettingsFromEnvOrDefaults); 338 | return { 339 | mcpServers: {}, 340 | proxy: proxySettingsFromEnvOrDefaults, 341 | serverToolnameSeparator: serverToolnameSeparator, // Add the determined separator here too 342 | }; 343 | } 344 | }; 345 | 346 | 347 | export const loadToolConfig = async (): Promise => { 348 | const defaultConfig: ToolConfig = { tools: {} }; 349 | try { 350 | const configPath = resolve(process.cwd(), 'config', 'tool_config.json'); 351 | logger.log(`Attempting to load tool configuration from: ${configPath}`); 352 | const fileContents = await readFile(configPath, 'utf-8'); 353 | const parsedConfig = JSON.parse(fileContents) as ToolConfig; 354 | 355 | if (typeof parsedConfig !== 'object' || parsedConfig === null || typeof parsedConfig.tools !== 'object') { 356 | logger.warn('Invalid tool_config.json format: "tools" object not found or invalid. Using default.'); 357 | return defaultConfig; 358 | } 359 | for (const toolKey in parsedConfig.tools) { 360 | if (typeof parsedConfig.tools[toolKey]?.enabled !== 'boolean') { 361 | logger.warn(`Invalid setting for tool "${toolKey}" in tool_config.json: 'enabled' is missing or not a boolean. Assuming enabled.`); 362 | } 363 | } 364 | 365 | logger.log(`Successfully loaded tool configuration for ${Object.keys(parsedConfig.tools).length} tools.`); 366 | return parsedConfig; 367 | } catch (error: any) { 368 | if (error.code === 'ENOENT') { 369 | logger.log('config/tool_config.json not found. Using default (all tools enabled).'); 370 | } else { 371 | logger.error(`Error loading config/tool_config.json: ${error.message}`); 372 | logger.warn('Using default tool configuration (all tools enabled) due to error.'); 373 | } 374 | return defaultConfig; 375 | } 376 | }; -------------------------------------------------------------------------------- /README_ZH.md: -------------------------------------------------------------------------------- 1 | # MCP 代理服务器 2 | 3 | [English](README.md) 4 | 5 | ## ✨ 主要特性亮点 6 | 7 | * **🌐 Web UI 管理:** 通过直观的网页界面轻松管理所有连接的 MCP 服务器(可选功能,需要启用)。 8 | * **🔧 精细化工具控制:** 通过 Web UI 启用或禁用由已连接 MCP 服务器提供的单个工具,并可覆盖其名称/描述。 9 | * **🛡️ 灵活的端点认证:** 使用灵活的认证选项保护您的基于 HTTP 的端点 (`/sse`, `/mcp`): (`Authorization: Bearer ` 或 `X-API-Key: `)。 10 | * **🔄 健壮的会话处理与并发支持**: 11 | * 改进的 SSE 会话处理机制,用于客户端重连(依赖服务器发送的 `endpoint` 事件),并支持并发连接。 12 | * Streamable HTTP 端点 (`/mcp`) 同样支持并发客户端交互。 13 | * **🚀 多功能 MCP 操作 (服务器与代理):** 14 | * **代理功能:** 连接并聚合多种类型的后端 MCP 服务器 (Stdio, SSE, Streamable HTTP)。 15 | * **服务器功能:** 通过自身的 Streamable HTTP (`/mcp`) 和 SSE (`/sse`) 端点暴露这些聚合后的能力。也可以作为纯 Stdio 模式运行。 16 | * **✨ 实时安装输出**: 在 Web UI 中直接监控 Stdio 服务器的安装进度(stdout/stderr)。 17 | * **✨ 网页终端**: 在 Admin UI 中访问命令行终端,用于直接与服务器环境交互(可选功能,请谨慎使用,存在安全风险)。 18 | 19 | --- 20 | 21 | 本服务器作为模型上下文协议 (MCP) 资源服务器的中心枢纽。它可以: 22 | 23 | - 连接并管理多个后端的 MCP 服务器(支持 Stdio、SSE 和 Streamable HTTP 类型)。 24 | - 通过统一的 SSE 接口、Streamable HTTP 接口暴露它们组合后的能力(工具、资源),**或者**本身作为一个基于 Stdio 的 MCP 服务器运行。 25 | - 处理将请求路由到合适的后端服务器。 26 | - 在需要时聚合来自多个来源的响应(主要作为代理)。 27 | - 支持多个并发的 SSE 客户端连接,并提供可选的 API 密钥认证。 28 | 29 | ## 功能特性 30 | 31 | ### 通过代理进行资源和工具管理 32 | - 发现并连接到 `config/mcp_server.json` 中定义的多个 MCP 资源服务器。 33 | - 聚合来自所有已连接 *活动* 服务器的工具和资源。 34 | - 将工具调用和资源访问请求路由到正确的后端服务器。 35 | - 维护一致的 URI 方案。 36 | 37 | ### ✨ 可选的 Web Admin UI (`ENABLE_ADMIN_UI=true`) 38 | 提供一个基于浏览器的界面,用于管理代理服务器配置和连接的工具。功能包括: 39 | - **服务器配置**: 查看、添加、编辑和删除服务器条目 (`mcp_server.json`)。支持 Stdio、SSE 和 HTTP 三种服务器类型,并提供相关选项(type, command, args, env, url, apiKey, bearerToken, install config)。 40 | - **工具配置**: 查看从活动后端服务器发现的所有工具。启用或禁用特定工具。为每个工具覆盖显示名称和描述 (`tool_config.json`)。 41 | - **实时重载**: 通过触发配置重载来应用服务器和工具的配置更改,无需重启整个代理服务器进程。 42 | - **Stdio 服务器安装**: 对于 Stdio 类型的服务器,您可以在配置中定义安装命令。Admin UI 允许您: 43 | - 触发这些安装命令的执行。 44 | - **实时监控安装进度**,将实时的 stdout 和 stderr 输出直接流式传输到 UI。 45 | - **网页终端**: 访问集成的基于 Web 的终端,提供对代理服务器运行环境的 shell 访问。 46 | - **安全警告**: 此功能授予显著的访问权限,应极其谨慎使用,尤其是在管理界面暴露于外部网络时。 47 | 48 | ## 配置 49 | 50 | 配置主要通过环境变量和位于 `./config` 目录中的 JSON 文件完成。 51 | 52 | ### 1. 服务器连接 (`config/mcp_server.json`) 53 | 此文件定义了代理应连接的后端 MCP 服务器。 54 | 55 | 示例 `config/mcp_server.json`: 56 | ```json 57 | { 58 | "mcpServers": { 59 | "unique-server-key1": { 60 | "type": "stdio", 61 | "name": "我的 Stdio 服务器", 62 | "active": true, 63 | "command": "/path/to/server/executable", 64 | "args": ["--port", "1234"], 65 | "env": { 66 | "API_KEY": "server_specific_key" 67 | }, 68 | "installDirectory": "/custom_install_path/unique-server-key1", 69 | "installCommands": [ 70 | "git clone https://github.com/some/repo unique-server-key1", 71 | "cd unique-server-key1 && npm install && npm run build" 72 | ] 73 | }, 74 | "another-sse-server": { 75 | "type": "sse", 76 | "name": "我的 SSE 服务器", 77 | "active": true, 78 | "url": "http://localhost:8080/sse", 79 | "apiKey": "sse_server_api_key" 80 | }, 81 | "http-mcp-server": { 82 | "type": "http", 83 | "name": "我的 Streamable HTTP 服务器", 84 | "active": true, 85 | "url": "http://localhost:8081/mcp", 86 | "bearerToken": "some_secure_token_for_http_server" 87 | }, 88 | "stdio-default-install": { 89 | "type": "stdio", 90 | "name": "使用默认安装路径的Stdio服务器", 91 | "active": true, 92 | "command": "my_other_server", 93 | "installCommands": ["echo '安装到默认位置...'"] 94 | } 95 | } 96 | } 97 | ``` 98 | 99 | **字段说明:** 100 | - `mcpServers`: (必需) 一个对象,其中每个键是后端服务器的唯一标识符。 101 | - `name`: (可选) 服务器的用户友好显示名称(在 Admin UI 中使用)。 102 | - `active`: (可选, 默认: `true`) 设置为 `false` 以阻止代理连接到此服务器。 103 | - `type`: (必需) 指定传输类型。必须是 `"stdio"`, `"sse"`, 或 `"http"` 之一。 104 | - `command`: (当 `type` 为 "stdio" 时必需) 执行服务器进程的命令。 105 | - `args`: (当 `type` 为 "stdio" 时可选) 传递给命令的字符串参数数组。 106 | - `env`: (当 `type` 为 "stdio" 时可选) 为服务器进程设置的环境变量对象 (`KEY: "value"`)。这些变量会与代理服务器的环境变量合并。 107 | - `url`: (当 `type` 为 "sse" 或 "http" 时必需) 后端服务器端点的完整 URL (例如, "sse" 类型的 SSE 端点, "http" 类型的 MCP 端点)。 108 | - `apiKey`: (当 `type` 为 "sse" 或 "http" 时可选) 当代理连接到*此特定后端*服务器时,在 `X-Api-Key` 头部中发送的 API 密钥。 109 | - `bearerToken`: (当 `type` 为 "sse" 或 "http" 时可选) 当代理连接到*此特定后端*服务器时,在 `Authorization: Bearer ` 头部中发送的令牌。(如果同时提供了 `apiKey` 和 `bearerToken`,通常 `bearerToken` 优先)。 110 | - `installDirectory`: (当 `type` 为 "stdio" 时可选) 服务器*本身*应安装到的绝对路径(例如 `/opt/my-server-files`)。由 Admin UI 的安装功能使用。 111 | - 如果在 `mcp_server.json` 中提供,则使用此确切路径。 112 | - 如果省略,则有效目录取决于 `TOOLS_FOLDER` 环境变量(参见环境变量部分)。 113 | - 如果 `TOOLS_FOLDER` 已设置且非空,服务器将安装在以服务器密钥命名的子目录中(例如 `${TOOLS_FOLDER}/`)。 114 | - 如果 `TOOLS_FOLDER` 也为空或未设置,则默认为代理服务器工作目录下的 `tools` 子目录(例如 `./tools/`)。 115 | - 请确保运行代理服务器的用户对目标安装路径的父目录(例如 `TOOLS_FOLDER` 或 `./tools`)具有写权限。 116 | - `installCommands`: (Stdio 类型可选) 一个 shell 命令数组。如果目标服务器目录(由 `installDirectory` 或默认规则派生)不存在,Admin UI 的安装功能将按顺序执行这些命令。命令在目标服务器安装目录的**父目录**中执行(例如,如果目标是 `/opt/tools/my-server`,命令将在 `/opt/tools/` 中运行)。**由于存在安全风险,请谨慎使用。** 117 | 118 | ### 2. 工具配置 (`config/tool_config.json`) 119 | 此文件允许覆盖从后端服务器发现的工具的属性。主要通过 Admin UI 进行管理,但也可以手动编辑。 120 | 121 | 示例 `config/tool_config.json`: 122 | ```json 123 | { 124 | "tools": { 125 | "unique-server-key1__tool-name-from-server": { 126 | "enabled": true, 127 | "displayName": "我的自定义工具名称", 128 | "description": "一个更友好的描述。" 129 | }, 130 | "another-sse-server__another-tool": { 131 | "enabled": false 132 | } 133 | } 134 | } 135 | ``` 136 | - 键的格式为 ``,其中 `` 是 `SERVER_TOOLNAME_SEPERATOR` 环境变量的值(默认为 `__`)。 137 | - `enabled`: (可选, 默认: `true`) 设置为 `false` 以向连接到代理的客户端隐藏此工具。 138 | - `displayName`: (可选) 在客户端 UI 中覆盖工具的名称。 139 | - `description`: (可选) 覆盖工具的描述。 140 | 141 | ### 3. 环境变量 142 | 143 | - **`PORT`**: 代理服务器的 HTTP 端点(`/sse`, `/mcp`, 以及 Admin UI,如果启用)监听的端口。默认: `3663`。**注意:** 仅在以启动 HTTP 服务器的模式运行时(例如,通过 `npm run dev:sse` 或 Docker 容器)使用。`npm run dev` 脚本以 Stdio 模式运行。 144 | ```bash 145 | export PORT=8080 146 | ``` 147 | - **`ALLOWED_KEYS`**: (可选) 用于保护代理的 HTTP 端点(`/sse`, `/mcp`)的 API 密钥列表(逗号分隔)。如果未设置 `ALLOWED_KEYS` 和 `ALLOWED_TOKENS`,则禁用这些端点的认证。客户端可以通过 `X-Api-Key` 头部或 `?key=` 查询参数提供其中一个密钥。 148 | ```bash 149 | export ALLOWED_KEYS="client_key1,client_key2" 150 | ``` 151 | - **`ALLOWED_TOKENS`**: (可选) 用于保护代理的 HTTP 端点(`/sse`, `/mcp`)的 Bearer Token 列表(逗号分隔)。如果未设置 `ALLOWED_KEYS` 和 `ALLOWED_TOKENS`,则禁用认证。客户端必须通过 `Authorization: Bearer ` 头部提供其中一个 Token。如果同时配置了 `ALLOWED_KEYS` 和 `ALLOWED_TOKENS`,Bearer Token 认证将优先。 152 | ```bash 153 | export MCP_PROXY_SSE_ALLOWED_TOKENS="your_bearer_token_1,your_bearer_token_2" 154 | ``` 155 | - **`ENABLE_ADMIN_UI`**: (可选) 设置为 `true` 以启用 Web Admin UI(仅在 SSE 模式下生效)。默认: `false`。 156 | ```bash 157 | export ENABLE_ADMIN_UI=true 158 | ``` 159 | - **`ADMIN_USERNAME`**: (启用 Admin UI 时必需) Admin UI 登录用户名。默认: `admin`。 160 | - **`ADMIN_PASSWORD`**: (启用 Admin UI 时必需) Admin UI 登录密码。默认: `password` (**请修改!**)。 161 | ```bash 162 | export ADMIN_USERNAME=myadmin 163 | export ADMIN_PASSWORD=aVerySecurePassword123! 164 | ``` 165 | - **`SESSION_SECRET`**: (可选, 启用 Admin UI 时推荐) 用于签名 session cookie 的密钥。如果未设置,将使用一个默认的、不太安全的密钥,并发出警告。如果未通过环境变量提供,服务器将在首次启用 Admin UI 运行时自动生成一个安全的密钥并保存到 `config/.session_secret`。 166 | ```bash 167 | # 推荐: 生成一个强密钥 (例如 openssl rand -hex 32) 168 | export SESSION_SECRET='your_very_strong_random_secret_here' 169 | ``` 170 | - **`TOOLS_FOLDER`**: (可选) 指定通过 Admin UI 安装 Stdio 服务器时的基础目录(当 `mcp_server.json` 中未为特定服务器明确设置 `installDirectory` 时)。 171 | - 如果设置(例如 `/custom/tools_path`),则没有特定 `installDirectory` 的服务器将安装到以服务器密钥命名的子目录中(例如 `${TOOLS_FOLDER}/`)。 172 | - 如果 `TOOLS_FOLDER` 未设置或为空,则此类安装将默认为代理服务器工作目录下的 `tools` 子目录(例如 `./tools/`)。 173 | - Dockerfile 中此变量默认为 `/tools`。 174 | ```bash 175 | export TOOLS_FOLDER=/srv/mcp_tools 176 | ``` 177 | 178 | - **`SERVER_TOOLNAME_SEPERATOR`**: (可选) 定义用于组合服务器名称和工具名称以生成工具唯一键的分隔符(例如 `server-key__tool-name`)。此键在内部和 `tool_config.json` 文件中使用。 179 | - 默认值:`__`。 180 | - 必须至少包含 2 个字符,且只能包含字母(a-z, A-Z)、数字(0-9)、连字符(`-`)和下划线(`_`)。 181 | - 如果提供的值无效,将使用默认值(`__`)并记录警告。 182 | ```bash 183 | export SERVER_TOOLNAME_SEPERATOR="___" # 示例:使用三个下划线 184 | ``` 185 | 186 | - **`LOGGING`**: (可选) 控制服务器输出的最低日志级别。 187 | - 可能的值(不区分大小写):`error`, `warn`, `info`, `debug`。 188 | - 将显示指定级别及以上的所有日志。 189 | - 默认值:`info`。 190 | ```bash 191 | export LOGGING="debug" 192 | ``` 193 | 194 | - **`RETRY_SSE_TOOL_CALL`**: (可选) 控制 SSE 工具调用失败时是否自动重连并重试。设置为 `"true"` 启用,`"false"` 禁用。默认: `true`。有关详细信息,请参阅“增强的可靠性特性”部分。 195 | ```bash 196 | export RETRY_SSE_TOOL_CALL="true" 197 | ``` 198 | - **`SSE_TOOL_CALL_MAX_RETRIES`**: (可选) SSE 工具调用最大重试次数(在初始失败后)。默认: `2`。有关详细信息,请参阅“增强的可靠性特性”部分。 199 | ```bash 200 | export SSE_TOOL_CALL_MAX_RETRIES="2" 201 | ``` 202 | - **`SSE_TOOL_CALL_RETRY_DELAY_BASE_MS`**: (可选) SSE 工具调用重试延迟基准(毫秒),用于指数退避。默认: `300`。有关详细信息,请参阅“增强的可靠性特性”部分。 203 | ```bash 204 | export SSE_TOOL_CALL_RETRY_DELAY_BASE_MS="300" 205 | ``` 206 | - **`RETRY_HTTP_TOOL_CALL`**: (可选) 控制 HTTP 工具调用连接错误时是否重试。设置为 `"true"` 启用,`"false"` 禁用。默认: `true`。有关详细信息,请参阅“增强的可靠性特性”部分。 207 | ```bash 208 | export RETRY_HTTP_TOOL_CALL="true" 209 | ``` 210 | - **`HTTP_TOOL_CALL_MAX_RETRIES`**: (可选) HTTP 工具调用最大重试次数(在初始失败后)。默认: `2`。有关详细信息,请参阅“增强的可靠性特性”部分。 211 | ```bash 212 | export HTTP_TOOL_CALL_MAX_RETRIES="3" 213 | ``` 214 | - **`HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS`**: (可选) HTTP 工具调用重试延迟基准(毫秒),用于指数退避。默认: `300`。有关详细信息,请参阅“增强的可靠性特性”部分。 215 | ```bash 216 | export HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS="500" 217 | ``` 218 | - **`RETRY_STDIO_TOOL_CALL`**: (可选) 控制 Stdio 工具调用连接错误时是否重试(尝试重启进程)。设置为 `"true"` 启用,`"false"` 禁用。默认: `true`。有关详细信息,请参阅“增强的可靠性特性”部分。 219 | ```bash 220 | export RETRY_STDIO_TOOL_CALL="true" 221 | ``` 222 | - **`STDIO_TOOL_CALL_MAX_RETRIES`**: (可选) Stdio 工具调用最大重试次数(在初始失败后)。默认: `2`。有关详细信息,请参阅“增强的可靠性特性”部分。 223 | ```bash 224 | export STDIO_TOOL_CALL_MAX_RETRIES="5" 225 | ``` 226 | - **`STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS`**: (可选) Stdio 工具调用重试延迟基准(毫秒),用于指数退避。默认: `300`。有关详细信息,请参阅“增强的可靠性特性”部分。 227 | ```bash 228 | export STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS="1000" 229 | ``` 230 | 231 | ## 增强的可靠性特性 232 | 233 | MCP 代理服务器包含多项特性,用以提升其自身弹性以及与后端 MCP 服务交互的可靠性,确保更平稳的操作和更一致的工具执行。 234 | 235 | ### 1. 错误传播 236 | 代理服务器确保从后端 MCP 服务产生的错误能够一致地传播给请求客户端。这些错误被格式化为标准的 JSON-RPC 错误响应,使客户端更容易统一处理它们。 237 | 238 | ### 2. SSE 工具调用的连接重试 239 | 当对基于 SSE 的后端服务器执行 `tools/call` 操作时,如果底层连接丢失或遇到错误(包括超时),代理服务器将实现重试机制。 240 | 241 | **重试机制:** 242 | 如果初始 SSE 工具调用因连接错误或超时而失败,代理将尝试重新建立与 SSE 后端的连接。如果重新连接成功,它将使用指数退避策略重试原始的 `tools/call` 请求,类似于 HTTP 和 Stdio 重试。这意味着每次后续重试尝试之前的延迟会指数级增加,并加入少量抖动(随机性)。 243 | 244 | **配置:** 245 | 这些设置主要通过环境变量控制。如果 `config/mcp_server.json` 中 `proxy` 对象下存在这些特定键的值,它们将被环境变量覆盖。 246 | 247 | - **`RETRY_SSE_TOOL_CALL`** (环境变量): 248 | - 设置为 `"true"` 以启用 SSE 工具调用的重试。 249 | - 设置为 `"false"` 以禁用此功能。 250 | - **默认行为:** `true` (如果环境变量未设置、为空或为无效值)。 251 | 252 | - **`SSE_TOOL_CALL_MAX_RETRIES`** (环境变量): 253 | - 指定在初次失败尝试*之后*的最大重试次数。例如,如果设置为 `"2"`,则会有一次初始尝试和最多两次重试尝试,总共最多三次尝试。 254 | - **默认行为:** `2` (如果环境变量未设置、为空或不是一个有效的整数)。 255 | 256 | - **`SSE_TOOL_CALL_RETRY_DELAY_BASE_MS`** (环境变量): 257 | - 用于指数退避计算的基准延迟(以毫秒为单位)。第 *n* 次重试(0索引)之前的延迟大约是 `SSE_TOOL_CALL_RETRY_DELAY_BASE_MS * (2^n) + 抖动`。 258 | - **默认行为:** `300` (毫秒) (如果环境变量未设置、为空或不是一个有效的整数)。 259 | 260 | **示例 (环境变量):** 261 | ```bash 262 | export RETRY_SSE_TOOL_CALL="true" 263 | export SSE_TOOL_CALL_MAX_RETRIES="3" 264 | export SSE_TOOL_CALL_RETRY_DELAY_BASE_MS="500" 265 | ``` 266 | 267 | ### 3. HTTP 工具调用的请求重试 268 | 对于定向到基于 HTTP 的后端服务器的 `tools/call` 操作,代理服务器为连接错误(例如,“failed to fetch”、网络超时)实现了一套重试机制。 269 | 270 | **重试机制:** 271 | 如果初始 HTTP 请求因连接错误而失败,代理将使用指数退避策略重试该请求。这意味着每次后续重试尝试之前的延迟会指数级增加,并加入少量抖动(随机性)以防止“惊群效应”。 272 | 273 | **配置:** 274 | 这些设置主要通过环境变量控制。 275 | 276 | - **`RETRY_HTTP_TOOL_CALL`** (环境变量): 277 | - 设置为 `"true"` 以启用 HTTP 工具调用的重试。 278 | - 设置为 `"false"` 以禁用此功能。 279 | - **默认行为:** `true` (如果环境变量未设置、为空或为无效值)。 280 | 281 | - **`HTTP_TOOL_CALL_MAX_RETRIES`** (环境变量): 282 | - 指定在初次失败尝试*之后*的最大重试次数。例如,如果设置为 `"2"`,则会有一次初始尝试和最多两次重试尝试,总共最多三次尝试。 283 | - **默认行为:** `2` (如果环境变量未设置、为空或不是一个有效的整数)。 284 | 285 | - **`HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS`** (环境变量): 286 | - 用于指数退避计算的基准延迟(以毫秒为单位)。第 *n* 次重试(0索引)之前的延迟大约是 `HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS * (2^n) + 抖动`。 287 | - **默认行为:** `300` (毫秒) (如果环境变量未设置、为空或不是一个有效的整数)。 288 | 289 | ### 4. Stdio 工具调用的连接重试 290 | 对于指向基于 Stdio 的后端服务器的 `tools/call` 操作,代理实现了针对连接错误(例如,进程崩溃或无响应)的重试机制。 291 | 292 | **重试机制:** 293 | 如果初始 Stdio 连接或工具调用失败,代理将尝试重新启动 Stdio 进程并重试请求。此机制类似于 HTTP 重试,使用指数退避策略。 294 | 295 | **配置:** 296 | 这些设置主要由环境变量控制。 297 | 298 | - **`RETRY_STDIO_TOOL_CALL`** (环境变量): 299 | - 设置为 `"true"` 以启用 Stdio 工具调用重试。 300 | - 设置为 `"false"` 以禁用此功能。 301 | - **默认行为:** `true` (如果环境变量未设置、为空或为无效值)。 302 | 303 | - **`STDIO_TOOL_CALL_MAX_RETRIES`** (环境变量): 304 | - 指定在初次失败尝试*之后*的最大重试尝试次数。例如,如果设置为 `"2"`,则将有一次初始尝试和最多两次重试尝试,总共最多三次尝试。 305 | - **默认行为:** `2` (如果环境变量未设置、为空或不是一个有效的整数)。 306 | 307 | - **`STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS`** (环境变量): 308 | - 用于指数退避计算的基准延迟(以毫秒为单位)。第 *n* 次重试(从 0 开始索引)之前的延迟大约是 `STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS * (2^n) + 抖动`。 309 | - **默认行为:** `300` (毫秒) (如果环境变量未设置、为空或不是一个有效的整数)。 310 | 311 | **环境变量解析通用说明:** 312 | - 布尔环境变量(`RETRY_SSE_TOOL_CALL`,`RETRY_HTTP_TOOL_CALL`,`RETRY_STDIO_TOOL_CALL`)如果其小写值恰好是 `"true"`,则被视为 `true`。任何其他值(包括空或未设置)将应用默认值,或者如果默认值为 `false` 则为 `false`(尽管对于这些特定变量,默认值为 `true`)。 313 | - 数字环境变量(`SSE_TOOL_CALL_MAX_RETRIES`,`SSE_TOOL_CALL_RETRY_DELAY_BASE_MS`,`HTTP_TOOL_CALL_MAX_RETRIES`,`HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS`,`STDIO_TOOL_CALL_MAX_RETRIES`,`STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS`)被解析为十进制整数。如果解析失败(例如,值不是数字,或变量为空/未设置),则使用默认值。 314 | 315 | ## 开发 316 | 317 | 安装依赖: 318 | ```bash 319 | npm install 320 | # 或 yarn install 321 | ``` 322 | 323 | 构建服务器 (将 TypeScript 编译为 JavaScript 到 `build/` 目录): 324 | ```bash 325 | npm run build 326 | ``` 327 | 328 | 在开发模式下运行 (使用 `tsx` 直接执行 TS 文件,并在文件更改时自动重启): 329 | ```bash 330 | # 以 Stdio MCP 服务器模式运行 (默认模式) 331 | npm run dev 332 | 333 | # 以 SSE MCP 服务器模式运行 (启用 SSE 端点和 Admin UI,如果配置了) 334 | # 确保按需设置环境变量 (PORT, ENABLE_ADMIN_UI 等) 335 | ENABLE_ADMIN_UI=true npm run dev:sse 336 | ``` 337 | 338 | 监视文件更改并自动重新构建 (如果不使用 `tsx`): 339 | ```bash 340 | npm run watch 341 | ``` 342 | 343 | ## 使用 Docker 运行 344 | 345 | 项目提供了 `Dockerfile`。容器默认以 **SSE 模式** 运行 (使用 `build/sse.js`) 并包含所有依赖项。`TOOLS_FOLDER` 环境变量在容器内默认为 `/tools`。 346 | 347 | **推荐:使用预构建镜像 (来自 GHCR)** 348 | 349 | 建议使用 GitHub Container Registry 上的预构建镜像以便于设置。我们提供两种类型的镜像: 350 | 351 | 1. **标准版镜像 (精简版)**: 这是默认且为大多数用户推荐的镜像。它包含了 MCP 代理服务器的核心功能。 352 | * 标签: `latest`, `` (例如, `0.1.2`) 353 | ```bash 354 | # 拉取最新的标准版镜像 355 | docker pull ghcr.io/ptbsare/mcp-proxy-server/mcp-proxy-server:latest 356 | 357 | # 或拉取特定版本 358 | # docker pull ghcr.io/ptbsare/mcp-proxy-server/mcp-proxy-server:0.1.2 359 | ``` 360 | 361 | 2. **捆绑版镜像 (功能完整版)**: 此镜像包含了一组预安装的 MCP 服务器和 Playwright 浏览器依赖。它明显更大,但提供了对常用工具的开箱即用访问。 362 | * 标签: `-bundled-mcpservers-playwright` (例如, `0.1.2-bundled-mcpservers-playwright`) 或 `latest-bundled-mcpservers-playwright` 363 | ```bash 364 | # 拉取捆绑版镜像 365 | # docker pull ghcr.io/ptbsare/mcp-proxy-server/mcp-proxy-server:latest-bundled-mcpservers-playwright 366 | ``` 367 | 368 | 捆绑版镜像通过 Docker 构建参数预装了以下组件: 369 | * **PIP 包** (`PRE_INSTALLED_PIP_PACKAGES_ARG`): 370 | * `mcp-server-time` 371 | * `markitdown-mcp` 372 | * `mcp-proxy` 373 | * **NPM 包** (`PRE_INSTALLED_NPM_PACKAGES_ARG`): 374 | * `g-search-mcp` 375 | * `fetcher-mcp` 376 | * `playwright` 377 | * `time-mcp` 378 | * `mcp-trends-hub` 379 | * `@adenot/mcp-google-search` 380 | * `edgeone-pages-mcp` 381 | * `@modelcontextprotocol/server-filesystem` 382 | * `mcp-server-weibo` 383 | * `@variflight-ai/variflight-mcp` 384 | * `@baidumap/mcp-server-baidu-map` 385 | * `@modelcontextprotocol/inspector` 386 | * **初始化命令** (`PRE_INSTALLED_INIT_COMMAND_ARG`): 387 | * `playwright install --with-deps chromium` 388 | 389 | 请根据您的需求选择合适的镜像类型。对于大多数用户,标准版镜像已足够,后端 MCP 服务器可以通过 `mcp_server.json` 进行配置。 390 | 391 | 然后,运行您选择的容器镜像: 392 | 393 | ```bash 394 | docker run -d \ 395 | -p 3663:3663 \ 396 | -e PORT=3663 \ 397 | -e ENABLE_ADMIN_UI=true \ 398 | -e ADMIN_USERNAME=myadmin \ 399 | -e ADMIN_PASSWORD=yoursupersecretpassword \ 400 | -e ALLOWED_KEYS="clientkey1" \ 401 | -e TOOLS_FOLDER=/my/custom_tools_volume `# 可选: 覆盖默认的 /tools 用于服务器安装` \ 402 | -v ./my_config:/mcp-proxy-server/config \ 403 | -v /path/on/host/to/tools:/my/custom_tools_volume `# 如果覆盖了 TOOLS_FOLDER,请挂载对应卷` \ 404 | --name mcp-proxy-server \ 405 | ghcr.io/ptbsare/mcp-proxy-server/mcp-proxy-server:latest 406 | ``` 407 | - 将 `./my_config` 替换为您宿主机上包含 `mcp_server.json` 和可选的 `tool_config.json` 的目录路径。容器期望配置文件位于 `/app/config`。 408 | - 如果您为通过 Admin UI 安装的服务器覆盖了 `TOOLS_FOLDER`,请确保挂载一个对应的卷(例如 `-v /path/on/host/for_tools:/my/custom_tools_volume`)。如果使用 Dockerfile 中默认的 `/tools` (由 `TOOLS_FOLDER` 设置),您可以挂载到 `/tools` (例如 `-v /path/on/host/to/tools_default:/tools`)。 409 | - 如果您拉取了特定版本,请调整标签 (`:latest`)。 410 | - 按需使用 `-e` 标志设置其他环境变量。 411 | 412 | **本地构建镜像 (可选):** 413 | ```bash 414 | docker build -t mcp-proxy-server . 415 | ``` 416 | *(如果您在本地构建,请在上面的 `docker run` 命令中使用 `mcp-proxy-server` 替代 `ghcr.io/...` 镜像名称)。* 417 | 418 | ## 安装与客户端使用 419 | 420 | 此代理服务器主要有两种使用方式: 421 | 422 | **1. 作为 Stdio MCP 服务器:** 423 | 配置您的 MCP 客户端(如 Claude Desktop)直接运行此代理服务器。代理将连接到其 `config/mcp_server.json` 中定义的后端服务器。 424 | 425 | Claude Desktop 示例 (`claude_desktop_config.json`): 426 | ```json 427 | { 428 | "mcpServers": { 429 | "mcp-proxy": { 430 | "name": "MCP 代理 (聚合器)", 431 | "command": "/path/to/mcp-proxy-server/build/index.js", 432 | "env": { 433 | "NODE_ENV": "production", // 可选: 为代理本身设置环境变量 434 | "TOOLS_FOLDER": "/custom/path/for/proxy/tools" // 可选: 如果代理需要安装自己的后端服务 435 | } 436 | } 437 | } 438 | } 439 | ``` 440 | - 将 `/path/to/mcp-proxy-server/build/index.js` 替换为此代理服务器项目构建后的实际入口点路径。确保 `config` 目录相对于命令运行的位置是正确的,或者在代理自己的配置中使用绝对路径。 441 | 442 | **2. 作为 SSE 或 Streamable HTTP MCP 服务器:** 443 | 以启动其 HTTP 服务器的模式运行代理服务器(例如 `npm run dev:sse` 或 Docker 容器)。然后,配置您的 MCP 客户端连接到代理的相应端点: 444 | - 对于 SSE: `http://localhost:3663/sse` 445 | - 对于 Streamable HTTP: `http://localhost:3663/mcp` 446 | 447 | 如果代理启用了认证(通过 `ALLOWED_KEYS` 或 `ALLOWED_TOKENS`),客户端需要提供相应的凭据。 448 | 449 | **认证方式 (用于 `/sse` 和 `/mcp`):** 450 | * **API 密钥:** 在客户端配置中提供密钥。对于 `/sse` 端点,支持 URL 查询参数 `?key=...`。对于 `/sse` 和 `/mcp` 两个端点,都支持 `X-Api-Key` 头部。 451 | * **Bearer Token:** 在客户端配置中设置 `Authorization: Bearer ` 头部。 452 | 453 | Claude Desktop 连接 SSE 示例 (`claude_desktop_config.json`): 454 | ```json 455 | { 456 | "mcpServers": { 457 | "my-proxy-sse": { 458 | "type": "sse", // 对于区分类型的客户端很重要 459 | "name": "MCP 代理 (SSE)", 460 | // 如果使用 API 密钥认证,请附加 ?key= 461 | "url": "http://localhost:3663/sse?key=clientkey1" 462 | // 如果使用 Bearer Token 认证,客户端配置方式可能因客户端而异。 463 | // 例如,某些客户端可能支持设置自定义头部: 464 | // "headers": { 465 | // "Authorization": "Bearer your_bearer_token_1" 466 | // } 467 | } 468 | } 469 | } 470 | ``` 471 | 472 | 通用 Streamable HTTP 客户端配置示例: 473 | ```json 474 | { 475 | "mcpServers": { 476 | "my-proxy-http": { 477 | "type": "http", // 或客户端特定的标识 478 | "name": "MCP 代理 (Streamable HTTP)", 479 | "url": "http://localhost:3663/mcp", 480 | // 认证头部将根据客户端的能力进行配置 481 | // 例如: "requestInit": { "headers": { "X-Api-Key": "clientkey1" } } 482 | } 483 | } 484 | } 485 | ``` 486 | 487 | ## 调试 488 | 489 | 使用 [MCP Inspector](https://github.com/modelcontextprotocol/inspector) 进行通信调试(主要用于 Stdio 模式): 490 | ```bash 491 | npm run inspector 492 | ``` 493 | 此脚本会使用 inspector 包装已构建的服务器 (`build/index.js`) 来执行。通过控制台中提供的 URL 访问 inspector UI。对于 SSE 模式,可以使用标准的浏览器开发者工具检查网络请求。 494 | 495 | ## 参考 496 | 497 | 本项目最初受到 [adamwattis/mcp-proxy-server](https://github.com/adamwattis/mcp-proxy-server) 的启发并基于其进行了重构。 -------------------------------------------------------------------------------- /public/tools.js: -------------------------------------------------------------------------------- 1 | // --- DOM Elements (Assumed to be globally accessible or passed) --- 2 | const toolListDiv = document.getElementById('tool-list'); 3 | const saveToolConfigButton = document.getElementById('save-tool-config-button'); 4 | // const saveToolStatus = document.getElementById('save-tool-status'); // Removed: Declared in script.js 5 | // Note: Assumes currentToolConfig and discoveredTools variables are globally accessible from script.js or passed. 6 | // Note: Assumes triggerReload function is globally accessible from script.js or passed. 7 | let serverToolnameSeparator = '__'; // Default separator 8 | 9 | // --- Tool Configuration Management --- 10 | async function loadToolData() { 11 | if (!saveToolStatus || !toolListDiv) return; // Guard 12 | saveToolStatus.textContent = 'Loading tool data...'; 13 | window.toolDataLoaded = false; // Reset flag during load attempt (use global flag) 14 | try { 15 | // Fetch discovered tools, tool config, and environment info concurrently 16 | const [toolsResponse, configResponse, envResponse] = await Promise.all([ 17 | fetch('/admin/tools/list'), 18 | fetch('/admin/tools/config'), 19 | fetch('/admin/environment') // Fetch environment info 20 | ]); 21 | 22 | if (!toolsResponse.ok) throw new Error(`Failed to fetch discovered tools: ${toolsResponse.statusText}`); 23 | if (!configResponse.ok) throw new Error(`Failed to fetch tool config: ${configResponse.statusText}`); 24 | if (!envResponse.ok) throw new Error(`Failed to fetch environment info: ${envResponse.statusText}`); // Check env response 25 | 26 | const toolsResult = await toolsResponse.json(); 27 | window.discoveredTools = toolsResult.tools || []; // Expecting { tools: [...] } (use global var) 28 | 29 | window.currentToolConfig = await configResponse.json(); // Use global var 30 | if (!window.currentToolConfig || typeof window.currentToolConfig !== 'object' || !window.currentToolConfig.tools) { 31 | console.warn("Received invalid tool configuration format, initializing empty.", window.currentToolConfig); 32 | window.currentToolConfig = { tools: {} }; // Initialize if invalid or empty 33 | } 34 | 35 | const envResult = await envResponse.json(); // Parse environment info 36 | serverToolnameSeparator = envResult.serverToolnameSeparator || '__'; // Update separator 37 | console.log(`Using server toolname separator from backend: "${serverToolnameSeparator}"`); 38 | 39 | renderTools(); // Render using both discovered and configured data 40 | window.toolDataLoaded = true; // Set global flag only after successful load and render 41 | saveToolStatus.textContent = 'Tool data loaded.'; 42 | setTimeout(() => saveToolStatus.textContent = '', 3000); 43 | 44 | } catch (error) { 45 | console.error("Error loading tool data:", error); 46 | saveToolStatus.textContent = `Error loading tool data: ${error.message}`; 47 | toolListDiv.innerHTML = '

Could not load tool data.

'; 48 | } 49 | } 50 | 51 | function renderTools() { 52 | if (!toolListDiv) return; // Guard 53 | toolListDiv.innerHTML = ''; // Clear previous list 54 | 55 | // Use global variables 56 | const discoveredTools = window.discoveredTools || []; 57 | const currentToolConfig = window.currentToolConfig || { tools: {} }; 58 | 59 | 60 | if (!Array.isArray(discoveredTools)) { 61 | toolListDiv.innerHTML = '

Error: Discovered tools data is not an array.

'; 62 | return; 63 | } 64 | if (!currentToolConfig || typeof currentToolConfig.tools !== 'object') { 65 | toolListDiv.innerHTML = '

Error: Tool configuration data is invalid.

'; 66 | return; 67 | } 68 | 69 | 70 | // Create a set of configured tool keys for quick lookup 71 | const configuredToolKeys = new Set(Object.keys(currentToolConfig.tools)); 72 | 73 | // Render discovered tools first, merging with config 74 | discoveredTools.forEach(tool => { 75 | const toolKey = `${tool.serverName}${serverToolnameSeparator}${tool.name}`; // Use the fetched separator 76 | const config = currentToolConfig.tools[toolKey] || {}; // Get config or empty object 77 | // For discovered tools, their server is considered active by the proxy at connection time 78 | renderToolEntry(toolKey, tool, config, false, true); // isConfigOnly = false, isServerActive = true 79 | configuredToolKeys.delete(toolKey); // Remove from set as it's handled 80 | }); 81 | 82 | // Render any remaining configured tools that were not discovered 83 | configuredToolKeys.forEach(toolKey => { 84 | const config = currentToolConfig.tools[toolKey]; 85 | // Use the fetched separator for splitting 86 | const serverKeyForConfigOnlyTool = toolKey.split(serverToolnameSeparator)[0]; 87 | let isServerActiveForConfigOnlyTool = true; // Default to true if server config not found or active flag is missing/true 88 | 89 | if (window.currentServerConfig && window.currentServerConfig.mcpServers && window.currentServerConfig.mcpServers[serverKeyForConfigOnlyTool]) { 90 | const serverConf = window.currentServerConfig.mcpServers[serverKeyForConfigOnlyTool]; 91 | if (serverConf.active === false || String(serverConf.active).toLowerCase() === 'false') { 92 | isServerActiveForConfigOnlyTool = false; 93 | } 94 | } 95 | console.warn(`Rendering configured tool "${toolKey}" which was not discovered. Associated server active status: ${isServerActiveForConfigOnlyTool}`); 96 | // We don't have the full tool definition here, just render based on config 97 | renderToolEntry(toolKey, null, config, true, isServerActiveForConfigOnlyTool); // Pass isConfigOnly and determined server active status 98 | }); 99 | 100 | if (toolListDiv.innerHTML === '') { 101 | toolListDiv.innerHTML = '

No tools discovered or configured.

'; 102 | } 103 | } 104 | 105 | function renderToolEntry(toolKey, toolDefinition, toolConfig, isConfigOnly = false, isServerActive = true) { // Added isServerActive 106 | if (!toolListDiv) return; // Guard 107 | const entryDiv = document.createElement('div'); 108 | entryDiv.classList.add('tool-entry'); 109 | entryDiv.classList.add('collapsed'); // Add collapsed class by default 110 | if (!isServerActive) { 111 | entryDiv.classList.add('tool-server-inactive'); 112 | entryDiv.title = 'This tool belongs to an inactive server. Enabling it will have no effect.'; 113 | } 114 | entryDiv.dataset.toolKey = toolKey; // Store the original key 115 | 116 | // Determine the name and description exposed to the model 117 | const exposedName = toolConfig.exposedName || toolKey; 118 | const exposedDescription = toolConfig.exposedDescription || toolDefinition?.description || ''; // Use override, fallback to original, then empty string 119 | 120 | // Get potential overrides from config for UI input fields 121 | const exposedNameOverride = toolConfig.exposedName || ''; 122 | const exposedDescriptionOverride = toolConfig.exposedDescription || ''; 123 | 124 | const isEnabled = toolConfig.enabled !== false; // Enabled by default 125 | const originalDescription = toolDefinition?.description || 'N/A'; // Original description for display 126 | 127 | entryDiv.innerHTML = ` 128 |
129 | 132 |

${toolKey}

133 | Exposed As: ${exposedName} 134 | 135 |
136 |
137 |
138 | 139 | Overrides the name exposed to AI models. Must be unique and contain only letters, numbers, _, - (not starting with a number). 140 | 141 |
142 |
143 | 144 | 145 |
146 |

Original Description: ${originalDescription}

147 | ${isConfigOnly ? '

This tool was configured but not discovered by any active server.

' : ''} 148 |
149 | `; 150 | 151 | toolListDiv.appendChild(entryDiv); // Append first, then query elements within it 152 | 153 | // Add click listener to the new Reset button 154 | const resetButton = entryDiv.querySelector('.reset-tool-overrides-button'); 155 | if (resetButton) { 156 | resetButton.addEventListener('click', (e) => { 157 | e.stopPropagation(); // Prevent a click on the button from also toggling collapse if it's in the header 158 | if (confirm(`Are you sure you want to reset all overrides for tool "${toolKey}"?\nThis will remove any custom settings for its name, description, and enabled state from the configuration. You will need to save the tool configuration to make this permanent.`)) { 159 | if (window.currentToolConfig && window.currentToolConfig.tools && window.currentToolConfig.tools[toolKey]) { 160 | delete window.currentToolConfig.tools[toolKey]; 161 | console.log(`Overrides for tool ${toolKey} marked for deletion.`); 162 | // Mark main tool config as dirty (if such a flag exists, or rely on main save button's behavior) 163 | // To reflect changes immediately, re-render the tools list 164 | // This will pick up the deleted config for this toolKey and render it with defaults 165 | renderTools(); 166 | // Optionally, provide a status message or highlight the main save button 167 | if (window.saveToolStatus) { // Ensure saveToolStatus is accessed via window or defined in this scope 168 | window.saveToolStatus.textContent = `Overrides for '${toolKey}' reset. Click "Save & Reload" to apply.`; 169 | window.saveToolStatus.style.color = 'orange'; 170 | setTimeout(() => { if (window.saveToolStatus) window.saveToolStatus.textContent = ''; }, 5000); 171 | } 172 | } else { 173 | // If the toolKey wasn't in currentToolConfig.tools, it means it was already using defaults. 174 | // However, the UI might show input values if the user typed them without saving. 175 | // Re-rendering will clear these UI-only changes. 176 | renderTools(); // Call renderTools to refresh the UI for this entry too 177 | alert(`Tool "${toolKey}" is already using default settings or has no saved overrides.`); 178 | } 179 | } 180 | }); 181 | } 182 | 183 | // Add click listener to the header (h3) to toggle collapse 184 | const headerH3 = entryDiv.querySelector('.tool-header h3'); 185 | if (headerH3) { 186 | headerH3.style.cursor = 'pointer'; // Indicate it's clickable 187 | headerH3.addEventListener('click', () => { 188 | entryDiv.classList.toggle('collapsed'); 189 | }); 190 | } 191 | } 192 | 193 | function initializeToolSaveListener() { 194 | if (!saveToolConfigButton || !toolListDiv || !saveToolStatus) return; // Guard 195 | 196 | // Regex for validating exposed tool name override 197 | const validToolNameRegex = /^[a-zA-Z_][a-zA-Z0-9_-]*$/; 198 | 199 | saveToolConfigButton.addEventListener('click', async () => { 200 | saveToolStatus.textContent = 'Validating and saving tool configuration...'; 201 | saveToolStatus.style.color = 'orange'; 202 | const newToolConfig = { tools: {} }; 203 | const entries = toolListDiv.querySelectorAll('.tool-entry'); 204 | let isValid = true; 205 | let errorMsg = ''; 206 | const exposedNames = new Set(); // To check for duplicates 207 | 208 | entries.forEach(entryDiv => { 209 | if (!isValid) return; // Stop processing if an error occurred 210 | 211 | const toolKey = entryDiv.dataset.toolKey; // Original key 212 | const enabledInput = entryDiv.querySelector('.tool-enabled-input'); 213 | const exposedNameInput = entryDiv.querySelector('.tool-exposedname-input'); 214 | const exposedDescriptionInput = entryDiv.querySelector('.tool-exposeddescription-input'); 215 | 216 | const exposedNameOverride = exposedNameInput.value.trim(); 217 | const exposedDescriptionOverride = exposedDescriptionInput.value.trim(); 218 | const isEnabled = enabledInput.checked; 219 | 220 | const finalExposedName = exposedNameOverride || toolKey; // Use override or fallback to original key 221 | 222 | // --- Validation --- 223 | // 1. Validate format of the override (if provided) 224 | if (exposedNameOverride && !validToolNameRegex.test(exposedNameOverride)) { 225 | isValid = false; 226 | errorMsg = `Invalid format for Exposed Tool Name Override "${exposedNameOverride}" for tool "${toolKey}". Use letters, numbers, _, - (cannot start with number).`; 227 | exposedNameInput.style.border = '1px solid red'; 228 | return; 229 | } else { 230 | exposedNameInput.style.border = ''; // Reset border on valid or empty 231 | } 232 | 233 | // 2. Check for duplicate exposed names (considering overrides) 234 | if (exposedNames.has(finalExposedName)) { 235 | isValid = false; 236 | errorMsg = `Duplicate Exposed Tool Name: "${finalExposedName}". Please ensure all exposed names (including overrides) are unique.`; 237 | // Highlight the input that caused the duplicate 238 | exposedNameInput.style.border = '1px solid red'; 239 | // Optionally, find and highlight the previous entry with the same name 240 | return; 241 | } 242 | exposedNames.add(finalExposedName); 243 | // --- End Validation --- 244 | 245 | 246 | const configData = { 247 | enabled: isEnabled, 248 | // Only store overrides if they are actually set 249 | exposedName: exposedNameOverride || undefined, 250 | exposedDescription: exposedDescriptionOverride || undefined, 251 | }; 252 | 253 | // Only store config if it differs from default (enabled=true, no overrides) 254 | // Or if it's explicitly disabled, or if overrides are set 255 | if (configData.enabled === false || configData.exposedName || configData.exposedDescription) { 256 | newToolConfig.tools[toolKey] = configData; 257 | } 258 | }); 259 | 260 | // If validation failed, show error and stop 261 | if (!isValid) { 262 | saveToolStatus.textContent = `Error: ${errorMsg}`; 263 | saveToolStatus.style.color = 'red'; 264 | setTimeout(() => { if(saveToolStatus) saveToolStatus.textContent = ''; saveToolStatus.style.color = 'green'; }, 7000); 265 | return; 266 | } 267 | 268 | // Proceed to save if valid 269 | try { 270 | saveToolStatus.textContent = 'Saving tool configuration...'; // Update status after validation 271 | const response = await fetch('/admin/tools/config', { 272 | method: 'POST', 273 | headers: { 'Content-Type': 'application/json' }, 274 | body: JSON.stringify(newToolConfig) 275 | }); 276 | const result = await response.json(); 277 | if (response.ok && result.success) { 278 | saveToolStatus.textContent = 'Tool configuration saved successfully.'; 279 | saveToolStatus.style.color = 'green'; 280 | window.currentToolConfig = newToolConfig; // Update global state 281 | 282 | // Trigger reload after successful save (assumes triggerReload is global) 283 | if (typeof window.triggerReload === 'function') { 284 | await window.triggerReload(saveToolStatus); // Pass the correct status element 285 | } else { 286 | console.error("triggerReload function not found."); 287 | saveToolStatus.textContent += ' Reload trigger function not found!'; 288 | saveToolStatus.style.color = 'red'; 289 | setTimeout(() => { saveToolStatus.textContent = ''; saveToolStatus.style.color = 'green'; }, 7000); 290 | } 291 | 292 | } else { 293 | saveToolStatus.textContent = `Error saving tool configuration: ${result.error || response.statusText}`; 294 | saveToolStatus.style.color = 'red'; 295 | setTimeout(() => { saveToolStatus.textContent = ''; saveToolStatus.style.color = 'green'; }, 5000); 296 | } 297 | } catch (error) { 298 | console.error("Error saving tool config:", error); 299 | saveToolStatus.textContent = `Network error saving tool configuration: ${error.message}`; 300 | saveToolStatus.style.color = 'red'; 301 | setTimeout(() => { saveToolStatus.textContent = ''; saveToolStatus.style.color = 'green'; }, 5000); 302 | } 303 | }); 304 | } 305 | 306 | // Expose functions needed by other modules or main script 307 | window.loadToolData = loadToolData; 308 | window.renderTools = renderTools; // Might not be needed globally 309 | window.renderToolEntry = renderToolEntry; // Might not be needed globally 310 | window.initializeToolSaveListener = initializeToolSaveListener; // To be called from main script 311 | 312 | console.log("tools.js loaded"); 313 | // --- Logic for Reset All Tool Overrides button --- 314 | function initializeResetAllToolOverridesListener() { 315 | const resetButton = document.getElementById('reset-all-tool-overrides-button'); 316 | // Ensure saveToolStatus is available, it's declared in script.js and expected to be global or on window 317 | const localSaveToolStatus = window.saveToolStatus || document.getElementById('save-tool-status'); 318 | 319 | if (!resetButton) { 320 | console.warn("Reset All Tool Overrides button not found in DOM."); 321 | return; 322 | } 323 | 324 | resetButton.addEventListener('click', async () => { 325 | if (confirm("Are you sure you want to reset ALL tool overrides?\nThis will clear any custom names, descriptions, and enabled/disabled states for all tools, reverting them to their defaults. You will need to click 'Save & Reload Tool Configuration' to make this permanent.")) { 326 | if (window.currentToolConfig) { 327 | window.currentToolConfig.tools = {}; // Clear all tool-specific configurations 328 | console.log("All tool overrides marked for deletion."); 329 | 330 | renderTools(); // Re-render the tools list to reflect the reset state 331 | 332 | if (localSaveToolStatus) { 333 | localSaveToolStatus.textContent = 'All tool overrides have been reset. Click "Save & Reload" to apply.'; 334 | localSaveToolStatus.style.color = 'orange'; 335 | setTimeout(() => { if (localSaveToolStatus) localSaveToolStatus.textContent = ''; }, 7000); 336 | } 337 | // Consider adding a global dirty flag if not already handled by the main save logic 338 | // e.g., window.isToolConfigDirty = true; 339 | } else { 340 | alert("Tool configuration not loaded yet. Please wait or try reloading."); 341 | } 342 | } 343 | }); 344 | } 345 | 346 | // Expose the new initializer to be called from script.js 347 | window.initializeResetAllToolOverridesListener = initializeResetAllToolOverridesListener; 348 | -------------------------------------------------------------------------------- /public/script.js: -------------------------------------------------------------------------------- 1 | // --- Global State Variables --- 2 | var currentServerConfig = {}; 3 | var currentToolConfig = { tools: {} }; 4 | var discoveredTools = []; 5 | var toolDataLoaded = false; 6 | var adminEventSource = null; // This is the local variable for the current EventSource instance 7 | var effectiveToolsFolder = 'tools'; // Default value if not fetched or empty 8 | window.effectiveToolsFolder = effectiveToolsFolder; // Expose globally 9 | window.adminEventSource = null; // Expose adminEventSource globally from the start and keep it as a data property 10 | window.isServerConfigDirty = false; // Initialize and expose globally 11 | 12 | // --- DOM Elements (Commonly used) --- 13 | const loginSection = document.getElementById('login-section'); 14 | const mainContent = document.getElementById('main-content'); 15 | const mainNav = document.getElementById('main-nav'); 16 | const loginForm = document.getElementById('login-form'); 17 | const loginError = document.getElementById('login-error'); 18 | const navServersButton = document.getElementById('nav-servers'); 19 | const navToolsButton = document.getElementById('nav-tools'); 20 | const navTerminalButton = document.getElementById('nav-terminal'); 21 | const logoutButton = document.getElementById('logout-button'); 22 | const serversSection = document.getElementById('servers-section'); 23 | const toolsSection = document.getElementById('tools-section'); 24 | const saveStatus = document.getElementById('save-status'); 25 | const saveToolStatus = document.getElementById('save-tool-status'); 26 | const addStdioButton = document.getElementById('add-stdio-server-button'); 27 | const addSseButton = document.getElementById('add-sse-server-button'); 28 | 29 | // Elements for Parse Config Modal 30 | const parseServerConfigButton = document.getElementById('parse-server-config-button'); 31 | const parseConfigModal = document.getElementById('parse-config-modal'); 32 | const closeParseModalButton = document.getElementById('close-parse-modal'); 33 | const jsonConfigInput = document.getElementById('json-config-input'); 34 | const executeParseConfigButton = document.getElementById('execute-parse-config-button'); 35 | const cancelParseConfigButton = document.getElementById('cancel-parse-config-button'); 36 | const parseConfigError = document.getElementById('parse-config-error'); 37 | 38 | 39 | // --- Admin SSE Connection & Handlers (Common) --- 40 | function connectAdminSSE() { 41 | if (window.adminEventSource && window.adminEventSource.readyState !== EventSource.CLOSED) { 42 | console.log("Admin SSE connection already open or connecting."); 43 | return; 44 | } 45 | console.log("Attempting to connect Admin SSE..."); 46 | adminEventSource = new EventSource('/admin/sse/updates'); 47 | window.adminEventSource = adminEventSource; 48 | 49 | adminEventSource.onopen = function() { console.log("Admin SSE connection opened successfully."); }; 50 | adminEventSource.onerror = function(err) { 51 | console.error("Admin SSE error:", err); 52 | if (adminEventSource) adminEventSource.close(); 53 | adminEventSource = null; 54 | window.adminEventSource = null; 55 | console.log("Admin SSE connection closed due to error."); 56 | }; 57 | adminEventSource.addEventListener('connected', function(event) { 58 | try { 59 | const data = JSON.parse(event.data); 60 | console.log("Admin SSE connected message:", data.message); 61 | } catch (e) { 62 | console.error("Error parsing 'connected' event data:", e, event.data); 63 | } 64 | }); 65 | adminEventSource.addEventListener('install_info', handleInstallUpdate); 66 | adminEventSource.addEventListener('install_stdout', handleInstallUpdate); 67 | adminEventSource.addEventListener('install_stderr', handleInstallUpdate); 68 | adminEventSource.addEventListener('install_error', handleInstallError); 69 | adminEventSource.addEventListener('install_complete', handleInstallComplete); 70 | console.log("Admin SSE event listeners added."); 71 | } 72 | 73 | function getInstallOutputElement(serverKey) { 74 | return document.getElementById(`install-output-${serverKey}`); 75 | } 76 | 77 | function appendToInstallOutput(serverKey, text, isError = false) { 78 | const outputElement = getInstallOutputElement(serverKey); 79 | if (outputElement) { 80 | const span = document.createElement('span'); 81 | const formattedText = text.replace(/\\n/g, '\n'); 82 | span.textContent = formattedText.endsWith('\n') ? formattedText : formattedText + '\n'; 83 | if (isError) { 84 | span.style.color = '#ff6b6b'; span.style.fontWeight = 'bold'; 85 | } else if (event && event.type === 'install_stderr') { 86 | span.style.color = '#ffa07a'; 87 | } else if (event && event.type === 'install_info') { 88 | span.style.color = '#87cefa'; 89 | } 90 | outputElement.appendChild(span); 91 | requestAnimationFrame(() => { outputElement.scrollTop = outputElement.scrollHeight; }); 92 | } 93 | } 94 | 95 | function handleInstallUpdate(event) { 96 | try { 97 | const data = JSON.parse(event.data); 98 | const textToAdd = data.output || data.message || ''; 99 | const isStdErr = event.type === 'install_stderr'; 100 | appendToInstallOutput(data.serverKey, textToAdd, isStdErr); 101 | } catch (e) { console.error("Error parsing install update event data:", e, event.data); } 102 | } 103 | 104 | function handleInstallError(event) { 105 | try { 106 | const data = JSON.parse(event.data); 107 | const errorText = `\n--- ERROR ---\n${data.error}\n-------------\n`; 108 | appendToInstallOutput(data.serverKey, errorText, true); 109 | const installButton = document.querySelector(`.install-button[data-server-key="${data.serverKey}"]`); 110 | if (installButton) { installButton.textContent = 'Install Failed'; installButton.disabled = false; } 111 | } catch (e) { console.error("Error parsing install error event data:", e, event.data); } 112 | } 113 | 114 | function handleInstallComplete(event) { 115 | try { 116 | const data = JSON.parse(event.data); 117 | const completeText = `\n--- Installation Complete (Exit Code: ${data.code}) ---\n${data.message}\n-------------\n`; 118 | appendToInstallOutput(data.serverKey, completeText, data.code !== 0); 119 | const installButton = document.querySelector(`.install-button[data-server-key="${data.serverKey}"]`); 120 | if (installButton) { installButton.textContent = data.code === 0 ? 'Install Complete' : 'Install Failed'; installButton.disabled = false; } 121 | } catch (e) { console.error("Error parsing install complete event data:", e, event.data); } 122 | } 123 | 124 | async function triggerReload(statusElement) { 125 | if (!statusElement) return; 126 | statusElement.textContent += ' Reloading configuration...'; 127 | statusElement.style.color = 'orange'; 128 | try { 129 | const reloadResponse = await fetch('/admin/server/reload', { method: 'POST' }); 130 | const reloadResult = await reloadResponse.json(); 131 | if (reloadResponse.ok && reloadResult.success) { 132 | statusElement.textContent = 'Configuration Saved & Reloaded Successfully!'; 133 | statusElement.style.color = 'green'; 134 | if (toolsSection && toolsSection.style.display === 'block' && typeof loadToolData === 'function') { 135 | toolDataLoaded = false; loadToolData(); 136 | } 137 | window.isServerConfigDirty = false; 138 | } else { 139 | statusElement.textContent = `Save successful, but failed to reload: ${reloadResult.error || reloadResponse.statusText}`; 140 | statusElement.style.color = 'red'; 141 | } 142 | } catch (reloadError) { 143 | const errorMessage = (reloadError instanceof Error) ? reloadError.message : String(reloadError); 144 | statusElement.textContent = `Save successful, but network error during reload: ${errorMessage}`; 145 | statusElement.style.color = 'red'; 146 | } finally { 147 | setTimeout(() => { if(statusElement) { statusElement.textContent = ''; statusElement.style.color = 'green'; } }, 7000); 148 | } 149 | } 150 | window.triggerReload = triggerReload; 151 | window.connectAdminSSE = connectAdminSSE; 152 | window.appendToInstallOutput = appendToInstallOutput; 153 | window.getInstallOutputElement = getInstallOutputElement; 154 | 155 | const showSection = (sectionId) => { 156 | document.querySelectorAll('.admin-section').forEach(section => { 157 | section.style.display = 'none'; 158 | }); 159 | document.querySelectorAll('#main-nav .nav-button').forEach(button => { 160 | button.classList.remove('active'); 161 | }); 162 | const targetSection = document.getElementById(sectionId); 163 | if (targetSection) { 164 | targetSection.style.display = 'block'; 165 | const sectionPrefix = sectionId.split('-')[0]; 166 | const activeButton = document.getElementById(`nav-${sectionPrefix}`); 167 | if (activeButton) { 168 | activeButton.classList.add('active'); 169 | } 170 | } else { 171 | console.warn(`Section with ID "${sectionId}" not found.`); 172 | } 173 | }; 174 | 175 | const checkLoginStatus = async () => { 176 | try { 177 | const response = await fetch('/admin/config'); 178 | if (response.ok) { 179 | handleLoginSuccess(); 180 | } else if (response.status === 401) { 181 | handleLogoutSuccess(); 182 | } else { 183 | loginError.textContent = `Error connecting (${response.status}). Server running?`; 184 | handleLogoutSuccess(); 185 | } 186 | } catch (error) { 187 | loginError.textContent = 'Network error connecting to server.'; 188 | handleLogoutSuccess(); 189 | } 190 | }; 191 | 192 | const handleLoginSuccess = async () => { 193 | if (logoutButton) logoutButton.style.display = 'inline-block'; 194 | loginSection.style.display = 'none'; 195 | mainNav.style.display = 'flex'; 196 | mainContent.style.display = 'block'; 197 | 198 | try { 199 | const envResponse = await fetch('/admin/environment'); 200 | if (envResponse.ok) { 201 | const envData = await envResponse.json(); 202 | window.effectiveToolsFolder = (envData.toolsFolder && envData.toolsFolder.trim() !== '') ? envData.toolsFolder.trim() : 'tools'; 203 | console.log("Effective TOOLS_FOLDER set to:", window.effectiveToolsFolder); 204 | } else { 205 | console.warn("Failed to fetch environment info, defaulting effectiveToolsFolder to 'tools'."); 206 | window.effectiveToolsFolder = 'tools'; 207 | } 208 | } catch (err) { 209 | console.error("Error fetching environment info (TOOLS_FOLDER):", err); 210 | window.effectiveToolsFolder = 'tools'; 211 | } 212 | 213 | showSection('servers-section'); 214 | if (typeof loadServerConfig === 'function') { 215 | await loadServerConfig(); 216 | window.isServerConfigDirty = false; 217 | } else { console.error("loadServerConfig function not found."); } 218 | toolDataLoaded = false; 219 | loginError.textContent = ''; 220 | connectAdminSSE(); 221 | 222 | if (typeof initializeServerSaveListener === 'function') { 223 | initializeServerSaveListener(); 224 | } else { console.error("initializeServerSaveListener function not found."); } 225 | if (typeof initializeToolSaveListener === 'function') { 226 | initializeToolSaveListener(); 227 | } else { console.error("initializeToolSaveListener function not found."); } 228 | if (typeof window.initializeResetAllToolOverridesListener === 'function') { // Call the new initializer 229 | window.initializeResetAllToolOverridesListener(); 230 | } else { console.error("initializeResetAllToolOverridesListener function not found on window."); } 231 | }; 232 | 233 | const handleLogoutSuccess = () => { 234 | if (logoutButton) logoutButton.style.display = 'none'; 235 | loginSection.style.display = 'block'; 236 | mainNav.style.display = 'none'; 237 | document.querySelectorAll('.admin-section').forEach(section => { section.style.display = 'none'; }); 238 | const serverList = document.getElementById('server-list'); if (serverList) serverList.innerHTML = ''; 239 | const toolList = document.getElementById('tool-list'); if (toolList) toolList.innerHTML = ''; 240 | currentServerConfig = {}; currentToolConfig = { tools: {} }; discoveredTools = []; toolDataLoaded = false; 241 | loginError.textContent = ''; 242 | if (adminEventSource) { 243 | adminEventSource.close(); 244 | adminEventSource = null; 245 | window.adminEventSource = null; 246 | console.log("Admin SSE closed on logout."); 247 | } 248 | window.isServerConfigDirty = false; 249 | }; 250 | 251 | function handleParseConfigExecute() { 252 | if (!jsonConfigInput || !parseConfigError) return; 253 | const jsonString = jsonConfigInput.value; 254 | parseConfigError.textContent = ''; 255 | 256 | try { 257 | const parsed = JSON.parse(jsonString); 258 | let serversToAdd = {}; 259 | 260 | if (parsed.mcpServers && typeof parsed.mcpServers === 'object') { 261 | serversToAdd = parsed.mcpServers; 262 | } else if (typeof parsed === 'object') { 263 | const keys = Object.keys(parsed); 264 | let allValuesAreServerConfs = keys.length > 0; 265 | for (const key of keys) { 266 | if (!(typeof parsed[key] === 'object' && parsed[key] !== null && (parsed[key].command || parsed[key].url))) { 267 | allValuesAreServerConfs = false; 268 | break; 269 | } 270 | } 271 | if (allValuesAreServerConfs) { 272 | serversToAdd = parsed; 273 | } else if (parsed.command || parsed.url) { 274 | const newKey = `parsed_server_${Date.now()}`; 275 | serversToAdd[newKey] = parsed; 276 | } else { 277 | throw new Error("Invalid JSON. Expected 'mcpServers' object, an object of server configurations, or a single server config object."); 278 | } 279 | } else { 280 | throw new Error("Invalid JSON input. Not an object."); 281 | } 282 | 283 | let serversAddedCount = 0; 284 | for (const key in serversToAdd) { 285 | if (Object.prototype.hasOwnProperty.call(serversToAdd, key)) { 286 | const serverConf = serversToAdd[key]; 287 | if (typeof serverConf !== 'object' || serverConf === null) { 288 | console.warn(`Skipping invalid server entry for key ${key} in parsed JSON.`); 289 | continue; 290 | } 291 | 292 | // If URL is provided but type is missing, default to SSE 293 | if (serverConf.url && !serverConf.type) { 294 | serverConf.type = 'sse'; 295 | console.log(`Auto-filled type 'sse' for server ${key} based on URL presence.`); 296 | } 297 | 298 | // Auto-fill installDirectory for Stdio servers if missing 299 | if (serverConf.type === 'stdio' && !serverConf.installDirectory) { 300 | serverConf.installDirectory = `${window.effectiveToolsFolder || 'tools'}/${key}`; 301 | console.log(`Auto-filled installDirectory for ${key}: ${serverConf.installDirectory}`); 302 | } 303 | 304 | if (typeof window.renderServerEntry === 'function') { 305 | window.renderServerEntry(key, serverConf, true); 306 | serversAddedCount++; 307 | } else { 308 | console.error("renderServerEntry function not found."); 309 | parseConfigError.textContent = "Error: UI function to add server not found."; 310 | return; 311 | } 312 | } 313 | } 314 | 315 | if (serversAddedCount > 0) { 316 | if (typeof window.addInstallButtonListeners === 'function') window.addInstallButtonListeners(); 317 | window.isServerConfigDirty = true; 318 | jsonConfigInput.value = ''; 319 | parseConfigModal.style.display = 'none'; 320 | alert(`${serversAddedCount} server(s) parsed and added to the UI. Remember to save the configuration.`); 321 | } else if (Object.keys(serversToAdd).length > 0) { 322 | parseConfigError.textContent = "No valid server entries found in the provided JSON."; 323 | } else { 324 | parseConfigError.textContent = "No servers found in the provided JSON to add."; 325 | } 326 | } catch (error) { 327 | console.error("Error parsing JSON config:", error); 328 | parseConfigError.textContent = `Error parsing JSON: ${error.message}`; 329 | } 330 | } 331 | 332 | document.addEventListener('DOMContentLoaded', () => { 333 | if (navServersButton) navServersButton.addEventListener('click', () => showSection('servers-section')); 334 | if (navToolsButton) { 335 | navToolsButton.addEventListener('click', () => { 336 | showSection('tools-section'); 337 | if (!toolDataLoaded && typeof loadToolData === 'function') loadToolData(); 338 | else if (typeof loadToolData !== 'function') console.error("loadToolData not found."); 339 | }); 340 | } 341 | if (navTerminalButton) navTerminalButton.addEventListener('click', () => window.location.href = 'terminal.html'); 342 | if (logoutButton) { 343 | logoutButton.addEventListener('click', async () => { 344 | try { 345 | const response = await fetch('/admin/logout', { method: 'POST' }); 346 | if (response.ok) handleLogoutSuccess(); else alert('Logout failed.'); 347 | } catch (error) { console.error("Logout error:", error); alert('An error occurred during logout.'); } 348 | }); 349 | } 350 | 351 | if (loginForm) { 352 | loginForm.addEventListener('submit', async (e) => { 353 | e.preventDefault(); loginError.textContent = ''; 354 | const username = loginForm.username.value; const password = loginForm.password.value; 355 | try { 356 | const response = await fetch('/admin/login', { 357 | method: 'POST', headers: { 'Content-Type': 'application/json' }, 358 | body: JSON.stringify({ username, password }) 359 | }); 360 | const result = await response.json(); 361 | if (response.ok && result.success) handleLoginSuccess(); 362 | else loginError.textContent = result.error || 'Login failed.'; 363 | } catch (error) { loginError.textContent = 'An error occurred during login.'; } 364 | }); 365 | } 366 | 367 | const addHttpButton = document.getElementById('add-http-server-button'); // Get the new button 368 | 369 | if (addStdioButton) { 370 | addStdioButton.addEventListener('click', () => { 371 | if (typeof window.renderServerEntry !== 'function' || typeof window.addInstallButtonListeners !== 'function') { 372 | console.error("renderServerEntry or addInstallButtonListeners not found."); return; 373 | } 374 | const newKey = `new_stdio_server_${Date.now()}`; 375 | const newServerConf = { 376 | type: "stdio", // Specify type 377 | name: "New Stdio Server", active: true, command: "your_command_here", args: [], env: {}, 378 | installDirectory: `${window.effectiveToolsFolder || 'tools'}/${newKey}` 379 | }; 380 | window.renderServerEntry(newKey, newServerConf, true); 381 | window.addInstallButtonListeners(); 382 | window.isServerConfigDirty = true; 383 | const serverList = document.getElementById('server-list'); 384 | serverList?.lastChild?.scrollIntoView({ behavior: 'smooth', block: 'center' }); 385 | }); 386 | } 387 | if (addSseButton) { 388 | addSseButton.addEventListener('click', () => { 389 | if (typeof window.renderServerEntry !== 'function' || typeof window.addInstallButtonListeners !== 'function') { 390 | console.error("renderServerEntry or addInstallButtonListeners not found."); return; 391 | } 392 | const newKey = `new_sse_server_${Date.now()}`; 393 | const newServerConf = { type: "sse", name: "New SSE Server", active: true, url: "http://localhost:3663/sse" }; // Specify type 394 | window.renderServerEntry(newKey, newServerConf, true); 395 | window.addInstallButtonListeners(); 396 | window.isServerConfigDirty = true; 397 | const serverList = document.getElementById('server-list'); 398 | serverList?.lastChild?.scrollIntoView({ behavior: 'smooth', block: 'center' }); 399 | }); 400 | } 401 | // Add event listener for the new HTTP button 402 | if (addHttpButton) { 403 | addHttpButton.addEventListener('click', () => { 404 | if (typeof window.renderServerEntry !== 'function' || typeof window.addInstallButtonListeners !== 'function') { 405 | console.error("renderServerEntry or addInstallButtonListeners not found."); return; 406 | } 407 | const newKey = `new_http_server_${Date.now()}`; 408 | const newServerConf = { type: "http", name: "New HTTP Server", active: true, url: "http://localhost:3663/mcp" }; // Specify type 409 | window.renderServerEntry(newKey, newServerConf, true); 410 | window.addInstallButtonListeners(); 411 | window.isServerConfigDirty = true; 412 | const serverList = document.getElementById('server-list'); 413 | serverList?.lastChild?.scrollIntoView({ behavior: 'smooth', block: 'center' }); 414 | }); 415 | } 416 | 417 | if (parseServerConfigButton) parseServerConfigButton.addEventListener('click', () => { 418 | if(parseConfigModal) parseConfigModal.style.display = 'block'; 419 | if(parseConfigError) parseConfigError.textContent = ''; 420 | }); 421 | if (closeParseModalButton) closeParseModalButton.addEventListener('click', () => { 422 | if(parseConfigModal) parseConfigModal.style.display = 'none'; 423 | if(parseConfigError) parseConfigError.textContent = ''; 424 | if(jsonConfigInput) jsonConfigInput.value = ''; 425 | }); 426 | if (cancelParseConfigButton) cancelParseConfigButton.addEventListener('click', () => { 427 | if(parseConfigModal) parseConfigModal.style.display = 'none'; 428 | if(parseConfigError) parseConfigError.textContent = ''; 429 | if(jsonConfigInput) jsonConfigInput.value = ''; 430 | }); 431 | if (executeParseConfigButton) executeParseConfigButton.addEventListener('click', handleParseConfigExecute); 432 | 433 | checkLoginStatus(); 434 | 435 | }); 436 | 437 | console.log("script.js loaded and initialized."); -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 3 | margin: 0; 4 | background-color: #f8f9fa; 5 | color: #212529; 6 | line-height: 1.5; 7 | } 8 | 9 | header { 10 | background-color: #343a40; 11 | color: white; 12 | padding: 0.75rem 1.5rem; 13 | display: flex; 14 | flex-direction: column; 15 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); 16 | } 17 | 18 | .header-top-row { 19 | display: flex; 20 | justify-content: space-between; 21 | align-items: center; 22 | width: 100%; 23 | padding-bottom: 0.5rem; 24 | } 25 | 26 | header .header-top-row h1 { /* Corrected to h1 */ 27 | margin: 0; 28 | font-size: 1.4rem; 29 | border-bottom: none; 30 | padding-bottom: 0; 31 | white-space: nowrap; 32 | overflow: hidden; 33 | text-overflow: ellipsis; 34 | flex-grow: 1; 35 | margin-right: 1rem; 36 | } 37 | 38 | .header-top-row #logout-button { 39 | background-color: #dc3545; 40 | color: white; 41 | padding: 0.4rem 0.8rem; 42 | margin-left: auto; 43 | margin-top: 0; 44 | margin-bottom: 0; 45 | margin-right: 0; 46 | font-size: 0.9rem; 47 | flex-shrink: 0; 48 | } 49 | .header-top-row #logout-button:hover { 50 | background-color: #c82333; 51 | } 52 | 53 | 54 | nav#main-nav { 55 | display: flex; 56 | flex-wrap: wrap; 57 | justify-content: flex-start; 58 | width: 100%; 59 | padding-top: 0.5rem; 60 | border-top: 1px solid #495057; 61 | } 62 | 63 | nav#main-nav button, nav#main-nav a.nav-button { 64 | background: none; 65 | border: none; 66 | color: #adb5bd; 67 | padding: 0.5rem 1rem; 68 | margin-right: 0.5rem; 69 | margin-bottom: 0.5rem; 70 | margin-left: 0; 71 | cursor: pointer; 72 | font-size: 0.9rem; 73 | border-radius: 4px; 74 | transition: background-color 0.2s ease, color 0.2s ease; 75 | text-decoration: none; 76 | display: inline-flex; 77 | align-items: center; 78 | } 79 | 80 | nav#main-nav button:hover, nav#main-nav a.nav-button:hover { 81 | color: white; 82 | background-color: #495057; 83 | } 84 | 85 | nav#main-nav button.active, nav#main-nav a.nav-button.active { 86 | color: white; 87 | font-weight: bold; 88 | background-color: #007bff; 89 | } 90 | 91 | main#main-content { 92 | padding: 1.5rem; 93 | } 94 | 95 | .admin-section, #login-section { 96 | background-color: #ffffff; 97 | padding: 1.5rem; 98 | margin-bottom: 1.5rem; 99 | border-radius: 8px; 100 | box-shadow: 0 0 15px rgba(0, 0, 0, 0.07); 101 | } 102 | 103 | #login-section { 104 | max-width: 450px; 105 | margin-left: auto; 106 | margin-right: auto; 107 | } 108 | 109 | .admin-section h2, #login-section h2 { 110 | margin-top: 0; 111 | margin-bottom: 1.5rem; 112 | padding-bottom: 0.5rem; 113 | border-bottom: 1px solid #dee2e6; 114 | } 115 | 116 | label { 117 | display: block; 118 | margin-bottom: 5px; 119 | font-weight: bold; 120 | } 121 | 122 | input[type="text"], 123 | input[type="password"], 124 | input[type="url"], 125 | input[type="number"], 126 | textarea { 127 | width: 100%; 128 | box-sizing: border-box; 129 | padding: 0.5rem 0.75rem; 130 | margin-bottom: 1rem; 131 | border: 1px solid #ced4da; 132 | border-radius: 4px; 133 | font-size: 1rem; 134 | transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 135 | } 136 | input:focus, textarea:focus { 137 | border-color: #80bdff; 138 | outline: 0; 139 | box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); 140 | } 141 | 142 | textarea { 143 | min-height: 60px; 144 | resize: vertical; 145 | font-family: monospace; 146 | } 147 | 148 | button, .add-button { 149 | display: inline-block; 150 | font-weight: 400; 151 | color: #fff; 152 | text-align: center; 153 | vertical-align: middle; 154 | cursor: pointer; 155 | -webkit-user-select: none; 156 | -moz-user-select: none; 157 | user-select: none; 158 | background-color: #007bff; 159 | border: 1px solid #007bff; 160 | padding: 0.5rem 1rem; 161 | font-size: 1rem; 162 | line-height: 1.5; 163 | border-radius: 0.25rem; 164 | transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 165 | margin-right: 0.5rem; 166 | margin-top: 0.5rem; 167 | } 168 | 169 | button:hover, .add-button:hover { 170 | color: #fff; 171 | background-color: #0056b3; 172 | border-color: #0056b3; 173 | } 174 | 175 | button:focus, .add-button:focus { 176 | outline: 0; 177 | box-shadow: 0 0 0 0.2rem rgba(38, 143, 255, 0.5); 178 | } 179 | 180 | button:disabled, .add-button:disabled { 181 | background-color: #6c757d; 182 | border-color: #6c757d; 183 | cursor: not-allowed; 184 | opacity: 0.65; 185 | } 186 | 187 | .add-button { 188 | background-color: #28a745; 189 | border-color: #28a745; 190 | } 191 | .add-button:hover { 192 | background-color: #218838; 193 | border-color: #1e7e34; 194 | } 195 | 196 | .error-message { 197 | color: red; 198 | font-weight: bold; 199 | margin-top: 10px; 200 | } 201 | 202 | .status-message { 203 | color: green; 204 | font-weight: bold; 205 | margin-top: 10px; 206 | } 207 | 208 | .server-entry, .tool-entry { 209 | border: 1px solid #e9ecef; 210 | padding: 1.5rem; 211 | margin-bottom: 1.5rem; 212 | border-radius: 6px; 213 | background-color: #ffffff; 214 | box-shadow: 0 1px 3px rgba(0,0,0,0.05); 215 | } 216 | 217 | /* Shared header styles for server and tool entries */ 218 | .server-entry .server-header, .tool-entry .tool-header { 219 | display: flex; 220 | align-items: center; /* Key for vertical alignment of items in the row */ 221 | margin-bottom: 1rem; 222 | /* justify-content: flex-start; Let items flow and use margins/flex-grow for spacing */ 223 | } 224 | 225 | .server-entry .server-header h3, .tool-entry .tool-header h3 { 226 | margin: 0; 227 | color: #007bff; 228 | cursor: pointer; 229 | flex-grow: 1; /* Allow h3 to take available space */ 230 | padding-right: 1rem; /* Space after h3, before next inline element */ 231 | } 232 | .server-entry .server-header h3:hover, .tool-entry .tool-header h3:hover { 233 | text-decoration: underline; 234 | } 235 | 236 | /* Style for the "Active" checkbox label in server header */ 237 | .server-entry .server-header .server-active-label { 238 | margin-right: 0.75rem; /* Space after checkbox, before H3 */ 239 | display: inline-flex; 240 | align-items: center; /* Align items within the label itself */ 241 | } 242 | .server-entry .server-header .server-active-label input[type="checkbox"] { 243 | margin: 0; 244 | vertical-align: middle; /* Helps align checkbox with potential (now removed) text in label */ 245 | } 246 | 247 | 248 | /* Specific to tool header for additional elements */ 249 | .tool-entry .tool-header { 250 | /* display: flex; align-items: center; are shared */ 251 | flex-wrap: wrap; /* Allow items to wrap to the next line if space is insufficient */ 252 | } 253 | 254 | .tool-entry .tool-header .tool-exposed-name { 255 | font-size: 0.85em; 256 | color: #6c757d; 257 | margin-left: 0.5rem; /* Space after H3 */ 258 | white-space: normal; /* Allow long text to wrap */ 259 | /* margin-right: auto; Remove this, as there's no element to push to the far right anymore */ 260 | /* Adding a small flex-shrink to allow it to shrink if needed, but h3 should grow more */ 261 | flex-shrink: 1; 262 | } 263 | 264 | /* Style for the "Enabled" checkbox label in tool header (now at the start) */ 265 | .tool-entry .tool-header .tool-enable-label { 266 | margin-right: 0.75rem; /* Space after checkbox, before H3 */ 267 | display: inline-flex; 268 | align-items: center; 269 | } 270 | .tool-entry .tool-header .tool-enable-label input[type="checkbox"] { 271 | margin: 0; 272 | vertical-align: middle; /* Consistent vertical alignment */ 273 | } 274 | 275 | /* Styles for tools belonging to an inactive server */ 276 | .tool-entry.tool-server-inactive .tool-header h3, 277 | .tool-entry.tool-server-inactive .tool-header .tool-exposed-name { 278 | color: #999; /* Gray out the text */ 279 | cursor: not-allowed; /* Indicate non-interactivity */ 280 | } 281 | .tool-entry.tool-server-inactive .tool-header .tool-enable-label { 282 | opacity: 0.6; /* Dim the checkbox label slightly */ 283 | cursor: not-allowed; 284 | } 285 | 286 | 287 | /* Delete button specific styling if needed, assuming it's the last element */ 288 | .server-entry .server-header .delete-button { 289 | margin-left: auto; /* Push delete button to the far right */ 290 | flex-shrink: 0; /* Prevent delete button from shrinking */ 291 | } 292 | 293 | 294 | /* Shared details styles for server and tool entries */ 295 | .server-entry .server-details, .tool-entry .tool-details { 296 | padding-left: 1rem; 297 | border-left: 2px solid #e9ecef; 298 | transition: max-height 0.3s ease-out, opacity 0.3s ease-out, margin-top 0.3s ease-out, padding-top 0.3s ease-out, padding-bottom 0.3s ease-out; 299 | overflow: hidden; 300 | max-height: 2000px; /* Arbitrary large number for expanded state */ 301 | opacity: 1; 302 | margin-top: 1rem; /* Add some space when expanded */ 303 | padding-top: 1rem; /* Add padding when expanded */ 304 | padding-bottom: 1rem; /* Add padding when expanded */ 305 | } 306 | 307 | .server-entry.collapsed .server-details, .tool-entry.collapsed .tool-details { 308 | max-height: 0; 309 | opacity: 0; 310 | margin-top: 0; 311 | padding-top: 0; 312 | padding-bottom: 0; 313 | border-left-width: 0; /* Hide border when collapsed for compactness */ 314 | padding-left: 0; /* Remove padding when collapsed */ 315 | } 316 | 317 | /* Reduce bottom margin of header when entry is collapsed for compactness */ 318 | .server-entry.collapsed .server-header, .tool-entry.collapsed .tool-header { 319 | margin-bottom: 0; 320 | } 321 | 322 | .server-entry label, .tool-entry label { 323 | font-weight: 500; 324 | color: #495057; 325 | margin-bottom: 0.25rem; 326 | display: block; 327 | } 328 | .server-entry label.inline-label { 329 | display: inline-flex; 330 | align-items: center; 331 | width: auto; 332 | margin-bottom: 1rem; 333 | } 334 | 335 | .server-entry input[type="checkbox"], .tool-entry input[type="checkbox"] { 336 | margin-right: 0.5rem; 337 | vertical-align: middle; 338 | width: auto; 339 | margin-bottom: 0; 340 | } 341 | .tool-entry label { 342 | display: inline-flex; 343 | align-items: center; 344 | font-weight: normal; 345 | width: 100%; 346 | } 347 | .tool-entry label strong { 348 | margin-right: 0.5rem; 349 | } 350 | 351 | .server-entry .delete-button, .server-entry .install-button { 352 | background-color: #ffc107; 353 | border-color: #ffc107; 354 | color: #212529; 355 | padding: 0.25rem 0.75rem; 356 | font-size: 0.875rem; 357 | margin: 0; 358 | flex-shrink: 0; 359 | } 360 | .server-entry .delete-button:hover { 361 | background-color: #e0a800; 362 | border-color: #d39e00; 363 | } 364 | .server-entry .install-button { 365 | background-color: #17a2b8; 366 | border-color: #17a2b8; 367 | color: white; 368 | margin-top: 0.5rem; 369 | } 370 | .server-entry .install-button:hover { 371 | background-color: #138496; 372 | border-color: #117a8b; 373 | } 374 | .server-entry .install-button:disabled { 375 | background-color: #6c757d; 376 | border-color: #6c757d; 377 | } 378 | 379 | .reload-button { 380 | background-color: #fd7e14; 381 | border-color: #fd7e14; 382 | color: white; 383 | } 384 | .reload-button:hover { 385 | background-color: #e67312; 386 | border-color: #d96c10; 387 | } 388 | .reload-button:disabled { 389 | background-color: #ffc99c; 390 | border-color: #ffc99c; 391 | } 392 | 393 | .cleanup-button { /* Styles for Reset All Tool Overrides and similar buttons */ 394 | background-color: #ffc107; /* Yellow/Orange for warning/cleanup actions */ 395 | border-color: #ffc107; 396 | color: #212529; /* Dark text for better contrast on yellow */ 397 | } 398 | .cleanup-button:hover { 399 | background-color: #e0a800; 400 | border-color: #d39e00; 401 | color: #212529; 402 | } 403 | .cleanup-button:focus { 404 | outline: 0; 405 | box-shadow: 0 0 0 0.2rem rgba(224, 168, 0, 0.5); /* Adjusted shadow color */ 406 | } 407 | 408 | 409 | hr { 410 | margin: 20px 0; 411 | border: 0; 412 | border-top: 1px solid #eee; 413 | } 414 | 415 | /* Styles for the footer button container in Tools section */ 416 | .tool-actions-footer { 417 | display: flex; 418 | justify-content: space-between; /* Pushes first item (save) to left, last item (reset) to right */ 419 | align-items: center; 420 | margin-top: 1rem; /* Space above the button row */ 421 | flex-wrap: wrap; /* Allow buttons to wrap on very narrow screens if needed, before media query kicks in */ 422 | } 423 | 424 | .tool-actions-footer button { 425 | margin-top: 0.5rem; /* Keep consistent top margin for buttons */ 426 | /* margin-right: 0; Remove default right margin from generic button if it interferes with space-between */ 427 | /* The default button style has margin-right: 0.5rem. For space-between, this is usually fine. */ 428 | /* If only two buttons, the space-between will handle it. If more, this might need adjustment. */ 429 | } 430 | 431 | /* Ensure the cleanup button (Reset All) doesn't have excessive left margin from generic button style if it's the rightmost */ 432 | /* Note: Specific styles for .cleanup-button within .tool-actions-footer were previously here but removed as they were empty or handled by parent layout. */ 433 | 434 | /* Ensure the save button (first child) doesn't have excessive right margin from generic button style */ 435 | 436 | 437 | /* Responsive adjustments for Tool Header on narrow screens */ 438 | @media screen and (max-width: 768px) { 439 | .tool-entry .tool-header { 440 | flex-direction: column; /* Stack items vertically */ 441 | align-items: flex-start; /* Align items to the left */ 442 | } 443 | 444 | .tool-entry .tool-header .tool-enable-label, 445 | .tool-entry .tool-header h3, 446 | .tool-entry .tool-header .tool-exposed-name, 447 | .tool-entry .tool-header .reset-tool-overrides-button { /* Added reset button here */ 448 | width: 100%; /* Make each item take full width in column layout */ 449 | margin-left: 0; 450 | margin-right: 0; 451 | padding-right: 0; /* Reset padding that was for row layout */ 452 | box-sizing: border-box; /* Ensure padding/border don't add to width */ 453 | } 454 | 455 | .tool-entry .tool-header .tool-enable-label { 456 | margin-bottom: 0.5rem; /* Space below checkbox label */ 457 | } 458 | 459 | .tool-entry .tool-header h3 { 460 | margin-bottom: 0.25rem; /* Space below H3 */ 461 | /* flex-grow: 0; Not strictly necessary in column, but good for clarity */ 462 | } 463 | 464 | .tool-entry .tool-header .tool-exposed-name { 465 | margin-bottom: 0.5rem; /* Space below "Exposed As" text before reset button */ 466 | /* font-size can remain as is or be adjusted if needed */ 467 | /* margin-left was reset above */ 468 | } 469 | 470 | .tool-entry .tool-header .reset-tool-overrides-button { 471 | /* width: auto; Allow button to size to its content, but still be left-aligned due to align-items: flex-start on parent */ 472 | /* align-self: flex-start; Explicitly align left if width is auto */ 473 | /* No margin-bottom needed if it's the last item in the stacked header before details */ 474 | margin-top: 0.25rem; /* Add a little space if it stacks below exposed-name */ 475 | } 476 | 477 | /* Responsive adjustments for the new .tool-actions-footer */ 478 | .tool-actions-footer { 479 | flex-direction: column; 480 | align-items: stretch; /* Make buttons full width */ 481 | } 482 | .tool-actions-footer button { 483 | width: 100%; 484 | margin-right: 0; /* Remove right margin when stacked */ 485 | } 486 | .tool-actions-footer button:not(:last-child) { 487 | margin-bottom: 1.5rem; /* Increased bottom margin for better spacing when stacked */ 488 | } 489 | } 490 | 491 | 492 | footer p { 493 | margin: 0.25rem 0; 494 | } 495 | .env-vars-container { 496 | margin-top: 5px; 497 | margin-bottom: 10px; 498 | padding-left: 15px; 499 | border-left: 2px solid #eee; 500 | } 501 | 502 | .env-var-row { 503 | display: flex; 504 | align-items: center; 505 | margin-bottom: 5px; 506 | } 507 | 508 | .env-var-row input[type="text"] { 509 | flex-grow: 1; 510 | margin: 0 5px; 511 | padding: 4px 6px; 512 | font-size: 0.9em; 513 | } 514 | 515 | .env-var-row span { 516 | margin: 0 5px; 517 | } 518 | 519 | .env-var-row .env-key-input { 520 | max-width: 150px; 521 | } 522 | 523 | .delete-env-var-button { 524 | padding: 3px 8px; 525 | font-size: 0.9em; 526 | cursor: pointer; 527 | border: 1px solid #f5c6cb; 528 | background-color: #f8d7da; 529 | color: #721c24; 530 | border-radius: 3px; 531 | margin-left: 5px; 532 | } 533 | 534 | .add-env-var-button { 535 | padding: 3px 8px; 536 | font-size: 0.9em; 537 | cursor: pointer; 538 | border: 1px solid #007bff; 539 | background-color: #007bff; 540 | color: #fff; 541 | border-radius: 3px; 542 | margin-left: 0; 543 | margin-top: 5px; 544 | transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out; 545 | } 546 | 547 | .delete-env-var-button:hover { 548 | background-color: #f1b0b7; 549 | } 550 | 551 | .add-env-var-button:hover { 552 | background-color: #0056b3; 553 | border-color: #0056b3; 554 | } 555 | 556 | /* Modal Styles */ 557 | .modal { 558 | position: fixed; 559 | z-index: 1000; 560 | left: 0; 561 | top: 0; 562 | width: 100%; 563 | height: 100%; 564 | overflow: auto; 565 | background-color: rgba(0,0,0,0.6); 566 | display: flex; 567 | align-items: center; 568 | justify-content: center; 569 | } 570 | 571 | .modal-content { 572 | background-color: #fefefe; 573 | margin: auto; 574 | padding: 25px; 575 | border: 1px solid #888; 576 | width: 80%; 577 | max-width: 700px; 578 | border-radius: 8px; 579 | box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19); 580 | position: relative; 581 | } 582 | 583 | .modal-content h2 { 584 | margin-top: 0; 585 | border-bottom: 1px solid #eee; 586 | padding-bottom: 10px; 587 | } 588 | 589 | .modal-content textarea { 590 | width: calc(100% - 20px); 591 | min-height: 200px; 592 | margin-bottom: 15px; 593 | font-family: monospace; 594 | font-size: 0.9em; 595 | padding: 10px; 596 | } 597 | 598 | .close-button { 599 | color: #aaa; 600 | float: right; 601 | font-size: 28px; 602 | font-weight: bold; 603 | position: absolute; 604 | top: 10px; 605 | right: 20px; 606 | } 607 | 608 | .close-button:hover, 609 | .close-button:focus { 610 | color: black; 611 | text-decoration: none; 612 | cursor: pointer; 613 | } 614 | 615 | .modal-actions { 616 | text-align: right; 617 | margin-top: 15px; 618 | } 619 | 620 | .modal-actions button { 621 | margin-left: 10px; 622 | } 623 | 624 | .modal-actions button[type="button"] { 625 | background-color: #6c757d; 626 | border-color: #6c757d; 627 | } 628 | .modal-actions button[type="button"]:hover { 629 | background-color: #5a6268; 630 | border-color: #545b62; 631 | } 632 | 633 | /* Responsive adjustments */ 634 | @media screen and (max-width: 768px) { 635 | header { 636 | padding: 0.75rem 1rem; 637 | } 638 | /* .header-top-row adjustments for mobile are handled by its default flex properties */ 639 | /* No specific overrides needed here for .header-top-row itself in this media query */ 640 | header .header-top-row h1 { /* Corrected to h1 */ 641 | font-size: 1.1rem; /* Smaller title on mobile */ 642 | white-space: normal; /* Allow title to wrap if very long */ 643 | } 644 | .header-top-row #logout-button { 645 | padding: 0.3rem 0.6rem; /* Smaller logout button */ 646 | font-size: 0.8rem; 647 | white-space: nowrap; /* Prevent "Logout" text from wrapping */ 648 | } 649 | nav#main-nav { 650 | justify-content: space-around; /* Distribute nav buttons more evenly */ 651 | padding-top: 0.75rem; 652 | } 653 | nav#main-nav button, nav#main-nav a.nav-button { 654 | margin: 0.25rem; 655 | padding: 0.4rem 0.6rem; /* Slightly smaller nav buttons */ 656 | font-size: 0.85rem; 657 | } 658 | 659 | .modal-content { 660 | width: 90%; 661 | padding: 20px; 662 | } 663 | .modal-content h2 { 664 | font-size: 1.2rem; 665 | } 666 | .modal-content textarea { 667 | min-height: 150px; 668 | } 669 | } 670 | 671 | footer { /* Basic footer style */ 672 | padding: 1.5rem 2rem; 673 | text-align: center; 674 | font-size: 0.9em; 675 | background-color: #e9ecef; 676 | color: #6c757d; 677 | border-top: 1px solid #dee2e6; 678 | margin-top: 2rem; /* Space above footer */ 679 | } 680 | 681 | /* Responsive adjustments for Tool Header on narrow screens */ 682 | @media screen and (max-width: 768px) { 683 | .tool-entry .tool-header { 684 | flex-direction: column; /* Stack items vertically */ 685 | align-items: flex-start; /* Align items to the left */ 686 | } 687 | 688 | .tool-entry .tool-header .tool-enable-label, 689 | .tool-entry .tool-header h3, 690 | .tool-entry .tool-header .tool-exposed-name, 691 | .tool-entry .tool-header .reset-tool-overrides-button { /* Added reset button here */ 692 | width: 100%; /* Make each item take full width in column layout */ 693 | margin-left: 0; 694 | margin-right: 0; 695 | padding-right: 0; /* Reset padding that was for row layout */ 696 | box-sizing: border-box; /* Ensure padding/border don't add to width */ 697 | } 698 | 699 | .tool-entry .tool-header .tool-enable-label { 700 | margin-bottom: 0.5rem; /* Space below checkbox label */ 701 | } 702 | 703 | .tool-entry .tool-header h3 { 704 | margin-bottom: 0.25rem; /* Space below H3 */ 705 | /* flex-grow: 0; /* Not strictly necessary in column, but good for clarity */ 706 | } 707 | 708 | .tool-entry .tool-header .tool-exposed-name { 709 | margin-bottom: 0.5rem; /* Space below "Exposed As" text before reset button */ 710 | /* font-size can remain as is or be adjusted if needed */ 711 | /* margin-left was reset above */ 712 | } 713 | 714 | .tool-entry .tool-header .reset-tool-overrides-button { 715 | /* width: auto; /* Allow button to size to its content, but still be left-aligned due to align-items: flex-start on parent */ 716 | /* align-self: flex-start; /* Explicitly align left if width is auto */ 717 | /* No margin-bottom needed if it's the last item in the stacked header before details */ 718 | margin-top: 0.25rem; /* Add a little space if it stacks below exposed-name */ 719 | } 720 | } 721 | 722 | footer p { 723 | margin: 0.25rem 0; 724 | } 725 | 726 | footer a { 727 | color: #007bff; 728 | text-decoration: none; 729 | } 730 | 731 | footer a:hover { 732 | text-decoration: underline; 733 | } 734 | /* Styles for Per-Tool Reset Button */ 735 | .reset-tool-overrides-button { 736 | background-color: #6c757d; /* Secondary/neutral color */ 737 | border-color: #6c757d; 738 | color: #fff; 739 | padding: 0.2rem 0.5rem; /* Smaller padding */ 740 | font-size: 0.8em; /* Smaller font */ 741 | margin-left: 0.75rem; /* Space from the "Exposed As" text */ 742 | line-height: 1.4; 743 | flex-shrink: 0; /* Prevent shrinking if header space is tight */ 744 | } 745 | .reset-tool-overrides-button:hover { 746 | background-color: #5a6268; 747 | border-color: #545b62; 748 | color: #fff; 749 | } -------------------------------------------------------------------------------- /public/servers.js: -------------------------------------------------------------------------------- 1 | // --- DOM Elements (Assumed to be globally accessible or passed) --- 2 | const serverListDiv = document.getElementById('server-list'); 3 | // saveConfigButton and saveStatus are obtained within initializeServerSaveListener 4 | 5 | // --- Server Configuration Management --- 6 | async function loadServerConfig() { 7 | const localSaveStatus = document.getElementById('save-status'); 8 | if (!localSaveStatus || !serverListDiv) { 9 | console.error("loadServerConfig: Missing essential DOM elements (saveStatus or serverListDiv)."); 10 | return; 11 | } 12 | localSaveStatus.textContent = 'Loading server configuration...'; 13 | try { 14 | const response = await fetch('/admin/config'); 15 | if (!response.ok) throw new Error(`Failed to fetch server config: ${response.status} ${response.statusText}`); 16 | window.currentServerConfig = await response.json(); 17 | renderServerConfig(window.currentServerConfig); 18 | // addInstallButtonListeners is called within renderServerConfig after rendering all entries 19 | localSaveStatus.textContent = 'Server configuration loaded.'; 20 | window.isServerConfigDirty = false; // Reset dirty flag after successful load 21 | setTimeout(() => { if(localSaveStatus) localSaveStatus.textContent = ''; }, 3000); 22 | } catch (error) { 23 | console.error("Error loading server config:", error); 24 | if(localSaveStatus) localSaveStatus.textContent = `Error loading server configuration: ${error.message}`; 25 | if(serverListDiv) serverListDiv.innerHTML = '

Could not load server configuration.

'; 26 | } 27 | } 28 | 29 | function renderServerConfig(config) { 30 | if (!serverListDiv) return; 31 | serverListDiv.innerHTML = ''; 32 | if (!config || typeof config !== 'object' || !config.mcpServers) { 33 | serverListDiv.innerHTML = '

Invalid server configuration format received.

'; 34 | return; 35 | } 36 | const servers = config.mcpServers; 37 | Object.keys(servers).sort().forEach(key => { 38 | renderServerEntry(key, servers[key]); 39 | }); 40 | addInstallButtonListeners(); // Ensure listeners are (re-)added after full render 41 | } 42 | 43 | function renderServerEntry(key, serverConf, startExpanded = false) { 44 | if (!serverListDiv) return; 45 | const entryDiv = document.createElement('div'); 46 | entryDiv.classList.add('server-entry'); 47 | if (!startExpanded) { 48 | entryDiv.classList.add('collapsed'); 49 | } 50 | entryDiv.dataset.serverKey = key; 51 | entryDiv.dataset.installDirManuallyEdited = 'false'; // Initialize flag 52 | 53 | let type = serverConf.type; 54 | if (!type) { // Infer type if not explicitly set (for backward compatibility or manual JSON editing) 55 | if (serverConf.url && !serverConf.command) type = 'sse'; 56 | else if (serverConf.command && !serverConf.url) type = 'stdio'; 57 | else type = 'unknown'; // Or handle as error 58 | } 59 | 60 | let displayType = 'Unknown'; 61 | if (type === 'sse') displayType = 'SSE'; 62 | else if (type === 'stdio') displayType = 'Stdio'; 63 | else if (type === 'http') displayType = 'HTTP'; 64 | else displayType = type.toUpperCase(); // Fallback for unknown but specified types 65 | 66 | entryDiv.dataset.serverType = type; // Store the actual type 67 | 68 | const headerDiv = document.createElement('div'); 69 | headerDiv.classList.add('server-header'); 70 | // Move Active checkbox to the header, at the beginning 71 | headerDiv.innerHTML = ` 72 | 75 |

${serverConf.name || key} (${displayType})

76 | 77 | `; 78 | entryDiv.appendChild(headerDiv); 79 | 80 | const detailsDiv = document.createElement('div'); 81 | detailsDiv.classList.add('server-details'); 82 | 83 | // Remove Active checkbox from detailsHtml 84 | let detailsHtml = ` 85 |
86 |
87 | `; 88 | 89 | if (type === 'sse' || type === 'http') { 90 | detailsHtml += ` 91 |
92 |
93 |
94 | `; 95 | // Add any type-specific fields for 'http' if they differ from 'sse' in the future 96 | } else if (type === 'stdio') { 97 | const baseInstallPath = (typeof window.effectiveToolsFolder === 'string' && window.effectiveToolsFolder.trim() !== '') ? window.effectiveToolsFolder.trim() : 'tools'; 98 | const defaultInstallDir = `${baseInstallPath}/${key}`; 99 | const installDirValue = serverConf.installDirectory !== undefined ? serverConf.installDirectory : defaultInstallDir; 100 | 101 | detailsHtml += ` 102 |
103 |
104 |
105 | 106 |
107 | 108 |
109 |
110 |
111 |
112 | 113 | 114 | `; 115 | } else { 116 | detailsHtml += `

Warning: Unknown server type configuration ('${type}').

`; 117 | } 118 | 119 | detailsDiv.innerHTML = detailsHtml; 120 | entryDiv.appendChild(detailsDiv); 121 | 122 | const envVarsContainer = detailsDiv.querySelector('.env-vars-container'); 123 | if (envVarsContainer && serverConf.env && typeof serverConf.env === 'object') { 124 | Object.entries(serverConf.env).forEach(([envKey, envValue]) => { 125 | addEnvVarRow(envVarsContainer, envKey, String(envValue)); 126 | }); 127 | } 128 | 129 | const addEnvVarButton = detailsDiv.querySelector('.add-env-var-button'); 130 | if (addEnvVarButton) { 131 | addEnvVarButton.addEventListener('click', () => { 132 | addEnvVarRow(envVarsContainer); 133 | window.isServerConfigDirty = true; 134 | }); 135 | } 136 | 137 | headerDiv.querySelector('h3').addEventListener('click', () => entryDiv.classList.toggle('collapsed')); 138 | headerDiv.querySelector('h3').style.cursor = 'pointer'; 139 | 140 | headerDiv.querySelector('.delete-button').addEventListener('click', (e) => { 141 | e.stopPropagation(); 142 | if (confirm(`Are you sure you want to delete server "${serverConf.name || key}"?`)) { 143 | entryDiv.remove(); 144 | window.isServerConfigDirty = true; 145 | } 146 | }); 147 | 148 | const installButton = detailsDiv.querySelector('.install-button'); 149 | const installDirInput = detailsDiv.querySelector('.server-install-dir-input'); 150 | 151 | const serverTypeFromDataset = entryDiv.dataset.serverType; 152 | if (serverTypeFromDataset === 'stdio' && installDirInput) { 153 | installDirInput.addEventListener('input', () => { 154 | entryDiv.dataset.installDirManuallyEdited = 'true'; // User is manually editing 155 | window.isServerConfigDirty = true; 156 | if (installButton) { 157 | const hasDir = !!installDirInput.value.trim(); 158 | installButton.disabled = !hasDir; 159 | installButton.title = installButton.disabled ? 'Install directory must be set to enable install button' : ''; 160 | } 161 | }); 162 | } 163 | 164 | const keyInput = detailsDiv.querySelector('.server-key-input'); 165 | if (serverTypeFromDataset === 'stdio' && keyInput && installDirInput) { 166 | keyInput.addEventListener('input', () => { 167 | window.isServerConfigDirty = true; 168 | const currentKey = keyInput.value.trim(); 169 | 170 | if (entryDiv.dataset.installDirManuallyEdited !== 'true') { 171 | if (currentKey) { 172 | const currentBaseInstallPath = (typeof window.effectiveToolsFolder === 'string' && window.effectiveToolsFolder.trim() !== '') ? window.effectiveToolsFolder.trim() : 'tools'; 173 | const newDynamicDefaultInstallDir = `${currentBaseInstallPath}/${currentKey}`; 174 | installDirInput.value = newDynamicDefaultInstallDir; 175 | if (installButton) { 176 | installButton.disabled = !newDynamicDefaultInstallDir.trim(); 177 | installButton.title = installButton.disabled ? 'Install directory must be set to enable install button' : ''; 178 | } 179 | } else { 180 | installDirInput.value = ''; 181 | if (installButton) { 182 | installButton.disabled = true; 183 | installButton.title = 'Install directory must be set to enable install button'; 184 | } 185 | } 186 | } 187 | }); 188 | } 189 | 190 | detailsDiv.querySelectorAll('input:not(.server-key-input):not(.server-install-dir-input), textarea').forEach(input => { 191 | input.addEventListener('input', () => { window.isServerConfigDirty = true; }); 192 | }); 193 | detailsDiv.querySelectorAll('input[type="checkbox"]').forEach(input => { 194 | input.addEventListener('change', () => { window.isServerConfigDirty = true; }); 195 | }); 196 | // Server key and install dir already have specific listeners that set dirty flag 197 | 198 | serverListDiv.appendChild(entryDiv); 199 | } 200 | 201 | function addInstallButtonListeners() { 202 | document.querySelectorAll('.install-button').forEach(button => { 203 | const newButton = button.cloneNode(true); 204 | button.parentNode.replaceChild(newButton, button); 205 | newButton.addEventListener('click', () => { 206 | const serverKey = newButton.dataset.serverKey; 207 | if (serverKey) { 208 | handleInstallClick(serverKey); 209 | } else { 210 | console.error("Install button clicked but serverKey is missing."); 211 | } 212 | }); 213 | }); 214 | } 215 | 216 | function addEnvVarRow(container, key = '', value = '') { 217 | if (!container) return; 218 | const rowDiv = document.createElement('div'); 219 | rowDiv.classList.add('env-var-row'); 220 | rowDiv.innerHTML = ` 221 | 222 | = 223 | 224 | 225 | `; 226 | rowDiv.querySelector('.delete-env-var-button').addEventListener('click', () => { 227 | rowDiv.remove(); 228 | window.isServerConfigDirty = true; 229 | }); 230 | rowDiv.querySelectorAll('input').forEach(input => { 231 | input.addEventListener('input', () => { window.isServerConfigDirty = true; }); 232 | }); 233 | container.appendChild(rowDiv); 234 | } 235 | 236 | async function handleInstallClick(serverKey) { 237 | if (window.isServerConfigDirty === true) { 238 | alert("Configuration has unsaved changes. Please save the server configuration before installing."); 239 | return; 240 | } 241 | 242 | const installButton = document.querySelector(`.install-button[data-server-key="${serverKey}"]`); 243 | const outputElement = typeof window.getInstallOutputElement === 'function' ? window.getInstallOutputElement(serverKey) : document.getElementById(`install-output-${serverKey}`); 244 | 245 | if (!outputElement || !installButton) { 246 | console.error(`Could not find install button or output area for ${serverKey}`); 247 | return; 248 | } 249 | 250 | if (!window.adminEventSource || window.adminEventSource.readyState !== EventSource.OPEN) { 251 | console.log("Admin SSE not connected, attempting to connect before install..."); 252 | if (typeof window.connectAdminSSE === 'function') { 253 | window.connectAdminSSE(); 254 | } else { 255 | console.error("connectAdminSSE function not found."); 256 | if(typeof window.appendToInstallOutput === 'function') { 257 | window.appendToInstallOutput(serverKey, "Error: Cannot establish connection for live updates.\n", true); 258 | } 259 | return; 260 | } 261 | } 262 | 263 | outputElement.innerHTML = ''; 264 | outputElement.style.display = 'block'; 265 | if(typeof window.appendToInstallOutput === 'function') { 266 | window.appendToInstallOutput(serverKey, `Starting installation check for ${serverKey}...\n`); 267 | } 268 | installButton.disabled = true; 269 | installButton.textContent = 'Installing...'; 270 | 271 | try { 272 | const response = await fetch(`/admin/server/install/${serverKey}`, { 273 | method: 'POST', 274 | headers: { 'Content-Type': 'application/json' }, 275 | }); 276 | const result = await response.json(); 277 | 278 | if (!response.ok || !result.success) { 279 | const errorMsg = `Error starting installation process: ${result.error || response.statusText}\n`; 280 | if(typeof window.appendToInstallOutput === 'function') window.appendToInstallOutput(serverKey, errorMsg, true); 281 | installButton.disabled = false; 282 | installButton.textContent = 'Install Failed'; 283 | return; 284 | } 285 | if(typeof window.appendToInstallOutput === 'function') { 286 | window.appendToInstallOutput(serverKey, `Installation process initiated. Waiting for live output via SSE...\n`); 287 | } 288 | } catch (error) { 289 | console.error(`Error initiating installation for ${serverKey}:`, error); 290 | const errorMsg = `Network error initiating installation: ${error.message}\n`; 291 | if(typeof window.appendToInstallOutput === 'function') window.appendToInstallOutput(serverKey, errorMsg, true); 292 | installButton.disabled = false; 293 | installButton.textContent = 'Install Failed'; 294 | } 295 | } 296 | 297 | 298 | function initializeServerSaveListener() { 299 | const localSaveConfigButton = document.getElementById('save-config-button'); 300 | const localServerListDiv = document.getElementById('server-list'); 301 | const localSaveStatus = document.getElementById('save-status'); 302 | 303 | if (!localSaveConfigButton || !localServerListDiv || !localSaveStatus) { 304 | console.error("Save listener setup failed: Missing crucial DOM elements for servers section."); 305 | return; 306 | } 307 | 308 | localSaveConfigButton.addEventListener('click', async () => { 309 | localSaveStatus.textContent = 'Saving server configuration...'; 310 | localSaveStatus.style.color = 'orange'; 311 | const newConfig = { mcpServers: {} }; 312 | const entries = localServerListDiv.querySelectorAll('.server-entry'); 313 | let isValid = true; 314 | let errorMsg = ''; 315 | 316 | entries.forEach(entryDiv => { 317 | if (!isValid) return; 318 | 319 | const newKeyInput = entryDiv.querySelector('.server-key-input'); 320 | const newKey = newKeyInput.value.trim(); 321 | 322 | if (!newKey) { 323 | isValid = false; errorMsg = 'Server Key cannot be empty.'; newKeyInput.style.border = '1px solid red'; return; 324 | } else { newKeyInput.style.border = ''; } 325 | 326 | if (newConfig.mcpServers.hasOwnProperty(newKey)) { 327 | isValid = false; errorMsg = `Duplicate Server Key: "${newKey}".`; newKeyInput.style.border = '1px solid red'; return; 328 | } 329 | 330 | const nameInput = entryDiv.querySelector('.server-name-input'); 331 | const activeInput = entryDiv.querySelector('.server-active-input'); 332 | const urlInput = entryDiv.querySelector('.server-url-input'); 333 | const apiKeyInput = entryDiv.querySelector('.server-apikey-input'); 334 | const bearerTokenInput = entryDiv.querySelector('.server-bearertoken-input'); 335 | const commandInput = entryDiv.querySelector('.server-command-input'); 336 | const argsInput = entryDiv.querySelector('.server-args-input'); 337 | const envVarsContainer = entryDiv.querySelector('.env-vars-container'); 338 | const installDirInputFromForm = entryDiv.querySelector('.server-install-dir-input'); // Renamed to avoid conflict 339 | const installCmdsInput = entryDiv.querySelector('.server-install-cmds-input'); 340 | 341 | const serverType = entryDiv.dataset.serverType || (urlInput ? 'sse' : (commandInput ? 'stdio' : 'unknown')); 342 | const serverData = { 343 | name: nameInput.value.trim() || undefined, 344 | active: activeInput.checked, 345 | type: serverType 346 | }; 347 | 348 | if (serverType === 'sse' || serverType === 'http') { 349 | serverData.url = urlInput.value.trim(); 350 | if (!serverData.url) { isValid = false; errorMsg = `URL required for ${serverType.toUpperCase()} server "${newKey}".`; urlInput.style.border = '1px solid red'; } 351 | else { urlInput.style.border = ''; } 352 | const apiKey = apiKeyInput.value.trim(); 353 | const bearerToken = bearerTokenInput.value.trim(); 354 | if (apiKey) serverData.apiKey = apiKey; 355 | if (bearerToken) serverData.bearerToken = bearerToken; 356 | } else if (serverType === 'stdio') { 357 | serverData.command = commandInput.value.trim(); 358 | if (!serverData.command) { isValid = false; errorMsg = `Command required for Stdio server "${newKey}".`; commandInput.style.border = '1px solid red'; } 359 | else { commandInput.style.border = ''; } 360 | const argsString = argsInput.value.trim(); 361 | serverData.args = argsString ? argsString.split(',').map(arg => arg.trim()).filter(arg => arg) : []; 362 | serverData.env = {}; 363 | if (envVarsContainer) { 364 | envVarsContainer.querySelectorAll('.env-var-row').forEach(row => { 365 | const envKeyInput = row.querySelector('.env-key-input'); 366 | const envValueInput = row.querySelector('.env-value-input'); 367 | const key = envKeyInput.value.trim(); 368 | const value = envValueInput.value; // Keep value as is, don't trim 369 | if (key) { 370 | if (serverData.env.hasOwnProperty(key)) { 371 | isValid = false; errorMsg = `Duplicate env key "${key}" for server "${newKey}".`; 372 | envKeyInput.style.border = '1px solid red'; 373 | } else { serverData.env[key] = value; envKeyInput.style.border = '';} 374 | } else if (value) { // Only error if value is present but key is not 375 | isValid = false; errorMsg = `Env key cannot be empty if value is set for server "${newKey}".`; 376 | envKeyInput.style.border = '1px solid red'; 377 | } else { 378 | envKeyInput.style.border = ''; // Clear border if both are empty 379 | } 380 | }); 381 | } 382 | if (!isValid) return; // Exit early if env var validation failed 383 | if (installDirInputFromForm && installCmdsInput) { 384 | const installDir = installDirInputFromForm.value.trim(); 385 | const installCmds = installCmdsInput.value.trim().split('\n').map(cmd => cmd.trim()).filter(cmd => cmd); 386 | if (installDir) { 387 | serverData.installDirectory = installDir; 388 | serverData.installCommands = installCmds; // Can be empty array 389 | } else if (installCmds.length > 0) { // Only error if commands exist but dir doesn't 390 | isValid = false; errorMsg = `Install Directory required if Install Commands provided for "${newKey}".`; 391 | installDirInputFromForm.style.border = '1px solid red'; 392 | } else { 393 | if (installDirInputFromForm) installDirInputFromForm.style.border = ''; // Clear border if both are empty 394 | } 395 | } 396 | } else { 397 | isValid = false; errorMsg = `Unknown or unhandled server type "${serverType}" for server "${newKey}".`; 398 | const header = entryDiv.querySelector('.server-header'); 399 | if(header) header.style.border = '1px solid red'; 400 | } 401 | 402 | if (isValid) { 403 | newConfig.mcpServers[newKey] = serverData; 404 | const header = entryDiv.querySelector('.server-header'); 405 | if(header) header.style.border = ''; 406 | } 407 | }); 408 | 409 | if (!isValid) { 410 | localSaveStatus.textContent = `Error: ${errorMsg}`; 411 | localSaveStatus.style.color = 'red'; 412 | setTimeout(() => { if(localSaveStatus) localSaveStatus.textContent = ''; localSaveStatus.style.color = 'green'; }, 5000); 413 | return; 414 | } 415 | 416 | try { 417 | const response = await fetch('/admin/config', { 418 | method: 'POST', 419 | headers: { 'Content-Type': 'application/json' }, 420 | body: JSON.stringify(newConfig) 421 | }); 422 | const result = await response.json(); 423 | if (response.ok && result.success) { 424 | localSaveStatus.textContent = 'Server configuration saved successfully.'; 425 | localSaveStatus.style.color = 'green'; 426 | window.currentServerConfig = newConfig; 427 | window.isServerConfigDirty = false; 428 | renderServerConfig(window.currentServerConfig); 429 | if (typeof window.triggerReload === 'function') { 430 | await window.triggerReload(localSaveStatus); 431 | } else { 432 | console.error("triggerReload function not found."); 433 | localSaveStatus.textContent += ' Reload trigger function not found!'; 434 | localSaveStatus.style.color = 'red'; 435 | setTimeout(() => { if(localSaveStatus) localSaveStatus.textContent = ''; localSaveStatus.style.color = 'green'; }, 7000); 436 | } 437 | } else { 438 | localSaveStatus.textContent = `Error saving: ${result.error || response.statusText}`; 439 | localSaveStatus.style.color = 'red'; 440 | setTimeout(() => { if(localSaveStatus) localSaveStatus.textContent = ''; localSaveStatus.style.color = 'green'; }, 5000); 441 | } 442 | } catch (error) { 443 | localSaveStatus.textContent = `Network error saving: ${error.message}`; 444 | localSaveStatus.style.color = 'red'; 445 | setTimeout(() => { if(localSaveStatus) localSaveStatus.textContent = ''; localSaveStatus.style.color = 'green'; }, 5000); 446 | } 447 | }); 448 | } 449 | 450 | // Expose functions to be called from script.js 451 | window.loadServerConfig = loadServerConfig; 452 | window.renderServerEntry = renderServerEntry; // Keep this exposed if script.js uses it directly 453 | window.addInstallButtonListeners = addInstallButtonListeners; 454 | window.handleInstallClick = handleInstallClick; 455 | window.initializeServerSaveListener = initializeServerSaveListener; 456 | 457 | // --- Helper function to add a new server entry of a specific type --- 458 | // This can be called by buttons in index.html (via script.js) 459 | window.addNewServerEntry = function(type) { 460 | if (!serverListDiv) { 461 | console.error("Cannot add new server: serverListDiv not found."); 462 | return; 463 | } 464 | let newKeyNumber = 1; 465 | while (window.currentServerConfig && window.currentServerConfig.mcpServers && window.currentServerConfig.mcpServers.hasOwnProperty(`new_${type}_server_${newKeyNumber}`)) { 466 | newKeyNumber++; 467 | } 468 | const newKey = `new_${type}_server_${newKeyNumber}`; 469 | 470 | const defaultConfig = { 471 | name: `New ${type.toUpperCase()} Server`, 472 | active: true, 473 | type: type 474 | }; 475 | 476 | if (type === 'stdio') { 477 | defaultConfig.command = ""; 478 | defaultConfig.args = []; 479 | defaultConfig.env = {}; 480 | } else if (type === 'sse' || type === 'http') { 481 | defaultConfig.url = ""; 482 | } 483 | 484 | // Add to current config in memory (optional, but good for consistency if not saving immediately) 485 | if (!window.currentServerConfig) window.currentServerConfig = { mcpServers: {} }; 486 | if (!window.currentServerConfig.mcpServers) window.currentServerConfig.mcpServers = {}; 487 | window.currentServerConfig.mcpServers[newKey] = defaultConfig; 488 | 489 | renderServerEntry(newKey, defaultConfig, true); // Render expanded 490 | window.isServerConfigDirty = true; 491 | const newEntryDiv = serverListDiv.querySelector(`.server-entry[data-server-key="${newKey}"]`); 492 | if (newEntryDiv) { 493 | newEntryDiv.scrollIntoView({ behavior: 'smooth', block: 'center' }); 494 | const keyInput = newEntryDiv.querySelector('.server-key-input'); 495 | if(keyInput) keyInput.focus(); 496 | } 497 | } 498 | 499 | console.log("servers.js loaded"); --------------------------------------------------------------------------------