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 |
38 |
39 |
40 |
41 |
42 |
Server Configuration
43 |
Edit the MCP server connections. Changes require a server restart to take effect.
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
Tool Configuration
59 |
Enable or disable specific tools provided by the connected servers. Changes require reloading the configuration to take effect.
60 |
61 |
62 |
Loading tools...
63 |
64 |
65 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | ×
76 |
Parse Server Configuration from JSON
77 |
Paste your JSON configuration below. It should be an object with an `mcpServers` key, an object of server configurations, or a single server configuration object.
78 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
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 = '
';
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 = '