diff --git a/README.md b/README.md index 3fb97fd..548cf96 100644 --- a/README.md +++ b/README.md @@ -121,18 +121,18 @@ Example `config/tool_config.json`: ```json { "tools": { - "unique-server-key1--tool-name-from-server": { + "unique-server-key1__tool-name-from-server": { "enabled": true, "displayName": "My Custom Tool Name", "description": "A more user-friendly description." }, - "another-sse-server--another-tool": { + "another-sse-server__another-tool": { "enabled": false } } } ``` -- Keys are in the format `--`. +- Keys are in the format ``, where `` is the value of the `SERVER_TOOLNAME_SEPERATOR` environment variable (defaults to `__`). - `enabled`: (Optional, default: `true`) Set to `false` to hide this tool from clients connecting to the proxy. - `displayName`: (Optional) Override the tool's name in client UIs. - `description`: (Optional) Override the tool's description. @@ -174,6 +174,230 @@ Example `config/tool_config.json`: export TOOLS_FOLDER=/srv/mcp_tools ``` +- **`SERVER_TOOLNAME_SEPERATOR`**: (Optional) Defines the separator used to combine the server name and tool name when generating the unique key for tools (e.g., `server-key__tool-name`). This key is used internally and in the `tool_config.json` file. + - Default: `__`. + - Must be at least 2 characters long and contain only letters (a-z, A-Z), numbers (0-9), hyphens (`-`), and underscores (`_`). + - If the provided value is invalid, the default (`__`) will be used, and a warning will be logged. + ```bash + export SERVER_TOOLNAME_SEPERATOR="___" # Example: using triple underscore + ``` + +- **`LOGGING`**: (Optional) Controls the minimum log level output by the server. + - Possible values (case-insensitive): `error`, `warn`, `info`, `debug`. + - Logs at the specified level and all levels above it will be shown. + - Default: `info`. + ```bash + export LOGGING="debug" + ``` + +- **`RETRY_SSE_TOOL_CALL`**: (Optional) Controls whether to enable retries for SSE tool calls. Set to `"true"` to enable, `"false"` to disable. Default: `true`. See the "Enhanced Reliability Features" section for details. + ```bash + export RETRY_SSE_TOOL_CALL="true" + ``` +- **`SSE_TOOL_CALL_MAX_RETRIES`**: (Optional) Maximum number of retry attempts for SSE tool calls (after the initial failure). Default: `2`. See the "Enhanced Reliability Features" section for details. + ```bash + export SSE_TOOL_CALL_MAX_RETRIES="2" + ``` +- **`SSE_TOOL_CALL_RETRY_DELAY_BASE_MS`**: (Optional) Base delay in milliseconds for SSE tool call retries, used in exponential backoff. Default: `300`. See the "Enhanced Reliability Features" section for details. + ```bash + export SSE_TOOL_CALL_RETRY_DELAY_BASE_MS="300" + ``` +- **`RETRY_HTTP_TOOL_CALL`**: (Optional) Controls whether to retry on HTTP tool call connection errors. Set to `"true"` to enable, `"false"` to disable. Default: `true`. See the "Enhanced Reliability Features" section for details. + ```bash + export RETRY_HTTP_TOOL_CALL="true" + ``` +- **`HTTP_TOOL_CALL_MAX_RETRIES`**: (Optional) Maximum number of retry attempts for HTTP tool calls (after the initial failure). Default: `2`. See the "Enhanced Reliability Features" section for details. + ```bash + export HTTP_TOOL_CALL_MAX_RETRIES="3" + ``` +- **`HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS`**: (Optional) Base delay in milliseconds for HTTP tool call retries, used in exponential backoff. Default: `300`. See the "Enhanced Reliability Features" section for details. + ```bash + export HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS="500" + ``` +- **`RETRY_STDIO_TOOL_CALL`**: (Optional) Controls whether to retry on Stdio tool call connection errors (attempts to restart the process). Set to `"true"` to enable, `"false"` to disable. Default: `true`. See the "Enhanced Reliability Features" section for details. + ```bash + export RETRY_STDIO_TOOL_CALL="true" + ``` +- **`STDIO_TOOL_CALL_MAX_RETRIES`**: (Optional) Maximum number of retry attempts for Stdio tool calls (after the initial failure). Default: `2`. See the "Enhanced Reliability Features" section for details. + ```bash + export STDIO_TOOL_CALL_MAX_RETRIES="5" + ``` +- **`STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS`**: (Optional) Base delay in milliseconds for Stdio tool call retries, used in exponential backoff. Default: `300`. See the "Enhanced Reliability Features" section for details. + ```bash + export STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS="1000" + ``` + +## Enhanced Reliability Features + +The MCP Proxy Server includes features to improve its resilience and the reliability of interactions with backend MCP services, ensuring smoother operations and more consistent tool execution. + +### 1. Error Propagation +The proxy server ensures that errors originating from backend MCP services are consistently propagated to the requesting client. These errors are formatted as standard JSON-RPC error responses, making it easier for clients to handle them uniformly. + +### 2. SSE Tool Call Retry +When a `tools/call` operation is made to an SSE-based backend server, and the underlying connection is lost or experiences an error (including timeouts), the proxy server implements a retry mechanism. + +**Retry Mechanism:** +If an initial SSE tool call fails due to a connection error or timeout, the proxy will attempt to re-establish the connection to the SSE backend. If reconnection is successful, it will then retry the original `tools/call` request using an exponential backoff strategy, similar to HTTP and Stdio retries. This means the delay before each subsequent retry attempt increases exponentially, with a small amount of jitter (randomness) added. + +**Configuration:** +These settings are primarily controlled by environment variables. Values in `config/mcp_server.json` under the `proxy` object for these specific keys will be overridden by environment variables if set. + +- **`RETRY_SSE_TOOL_CALL`** (environment variable): + - Set to `"true"` to enable retries for SSE tool calls. + - Set to `"false"` to disable this feature. + - **Default Behavior:** `true` (if the environment variable is not set, is empty, or is an invalid value). + +- **`SSE_TOOL_CALL_MAX_RETRIES`** (environment variable): + - Specifies the maximum number of retry attempts *after* the initial failed attempt. For example, if set to `"2"`, there will be one initial attempt and up to two retry attempts, totaling a maximum of three attempts. + - **Default Behavior:** `2` (if the environment variable is not set, is empty, or is not a valid integer). + +- **`SSE_TOOL_CALL_RETRY_DELAY_BASE_MS`** (environment variable): + - The base delay in milliseconds used in the exponential backoff calculation. The delay before the *n*-th retry (0-indexed) is roughly `SSE_TOOL_CALL_RETRY_DELAY_BASE_MS * (2^n) + jitter`. + - **Default Behavior:** `300` (milliseconds) (if the environment variable is not set, is empty, or is not a valid integer). + +**Example (Environment Variables):** +```bash +export RETRY_SSE_TOOL_CALL="true" +export SSE_TOOL_CALL_MAX_RETRIES="3" +export SSE_TOOL_CALL_RETRY_DELAY_BASE_MS="500" +``` + +### 3. HTTP Request Retry for Tool Calls +For `tools/call` operations directed to HTTP-based backend servers, the proxy implements a retry mechanism for connection errors (e.g., "failed to fetch", network timeouts). + +**Retry Mechanism:** +If an initial HTTP request fails due to a connection error, the proxy will retry the request using an exponential backoff strategy. This means the delay before each subsequent retry attempt increases exponentially, with a small amount of jitter (randomness) added to prevent thundering herd scenarios. + +**Configuration:** +These settings are primarily controlled by environment variables. + +- **`RETRY_HTTP_TOOL_CALL`** (environment variable): + - Set to `"true"` to enable retries for HTTP tool calls. + - Set to `"false"` to disable this feature. + - **Default Behavior:** `true` (if the environment variable is not set, is empty, or is an invalid value). + +- **`HTTP_TOOL_CALL_MAX_RETRIES`** (environment variable): + - Specifies the maximum number of retry attempts *after* the initial failed attempt. For example, if set to `"2"`,there will be one initial attempt and up to two retry attempts, totaling a maximum of three attempts. + - **Default Behavior:** `2` (if the environment variable is not set, is empty, or is not a valid integer). + +- **`HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS`** (environment variable): + - The base delay in milliseconds used in the exponential backoff calculation. The delay before the *n*-th retry (0-indexed) is roughly `HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS * (2^n) + jitter`. + - **Default Behavior:** `300` (milliseconds) (if the environment variable is not set, is empty, or is not a valid integer). + +### 4. Stdio Connection Retry for Tool Calls +For `tools/call` operations directed to Stdio-based backend servers, the proxy implements a retry mechanism for connection errors (e.g., process crash or unresponsiveness). + +**Retry Mechanism:** +If an initial Stdio connection or tool call fails, the proxy will attempt to restart the Stdio process and retry the request. This mechanism follows an exponential backoff strategy similar to HTTP retries. + +**Configuration:** +These settings are primarily controlled by environment variables. + +- **`RETRY_STDIO_TOOL_CALL`** (environment variable): + - Set to `"true"` to enable Stdio tool call retries. + - Set to `"false"` to disable this feature. + - **Default Behavior:** `true` (if the environment variable is not set, is empty, or is an invalid value). + +- **`STDIO_TOOL_CALL_MAX_RETRIES`** (environment variable): + - Specifies the maximum number of retry attempts *after* the initial failed attempt. For example, if set to `"2"`,there will be one initial attempt and up to two retry attempts, totaling a maximum of three attempts. + - **Default Behavior:** `2` (if the environment variable is not set, is empty, or is not a valid integer). + +- **`STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS`** (environment variable): + - The base delay in milliseconds used in the exponential backoff calculation. The delay before the *n*-th retry (0-indexed) is roughly `STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS * (2^n) + jitter`. + - **Default Behavior:** `300` (milliseconds) (if the environment variable is not set, is empty, or is not a valid integer). + +**General Notes on Environment Variable Parsing:** +- Boolean environment variables (`RETRY_SSE_TOOL_CALL`, `RETRY_HTTP_TOOL_CALL`, `RETRY_STDIO_TOOL_CALL`) are considered `true` if their lowercase value is exactly `"true"`. Any other value (including empty or not set) results in the default being applied or `false` if the default is `false` (though for these specific variables, the default is `true`)。 +- Numeric environment variables (`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`) are parsed as base-10 integers. If parsing fails (e.g., the value is not a number, or the variable is empty/not set), the default value is used。 + +## Development + +The MCP Proxy Server includes features to improve its resilience and the reliability of interactions with backend MCP services, ensuring smoother operations and more consistent tool execution. + +### 1. Error Propagation +The proxy server ensures that errors originating from backend MCP services are consistently propagated to the requesting client. These errors are formatted as standard JSON-RPC error responses, making it easier for clients to handle them uniformly. + +### 2. SSE Connection Retry for Tool Calls +When a `tools/call` operation is made to an SSE-based backend server, and the underlying connection is lost or experiences an error, the proxy server will automatically attempt to: +1. Re-establish the connection to the SSE backend. +2. If reconnection is successful, it will retry the original `tools/call` request **once**. + +This behavior helps mitigate transient network issues that might temporarily disrupt SSE connections. + +**Configuration:** +This feature is primarily controlled by the **`RETRY_SSE_TOOL_CALL_ON_DISCONNECT`** environment variable. +- **`RETRY_SSE_TOOL_CALL_ON_DISCONNECT`** (environment variable): + - Set to `"true"` to enable the automatic reconnect and retry. + - Set to `"false"` to disable this feature. + - **Default Behavior:** `true` (if the environment variable is not set, is empty, or is an invalid value). + - *Note: If this setting is also present in `config/mcp_server.json` under `proxy`, the environment variable takes precedence.* + +**Example (Environment Variable):** +```bash +export RETRY_SSE_TOOL_CALL_ON_DISCONNECT="true" +``` +*(The JSON example for `mcp_server.json` under "Proxy Behavior Configuration" illustrates where other proxy settings might go, but this specific setting is best managed via its environment variable.)* + +### 3. HTTP Request Retry for Tool Calls +For `tools/call` operations directed to HTTP-based backend servers, the proxy implements a retry mechanism for connection errors (e.g., "failed to fetch", network timeouts). + +**Retry Mechanism:** +If an initial HTTP request fails due to a connection error, the proxy will retry the request using an exponential backoff strategy. This means the delay before each subsequent retry attempt increases exponentially, with a small amount of jitter (randomness) added to prevent thundering herd scenarios. + +**Configuration:** +These settings are primarily controlled by environment variables. Values in `config/mcp_server.json` under the `proxy` object for these specific keys will be overridden by environment variables if set. + +- **`RETRY_HTTP_TOOL_CALL`** (environment variable): + - Set to `"true"` to enable retries for HTTP tool calls. + - Set to `"false"` to disable this feature. + - **Default Behavior:** `true` (if the environment variable is not set, is empty, or is an invalid value). + +- **`HTTP_TOOL_CALL_MAX_RETRIES`** (environment variable): + - Specifies the maximum number of retry attempts *after* the initial failed attempt. For example, if set to `"2"`, there will be one initial attempt and up to two retry attempts, totaling a maximum of three attempts. + - **Default Behavior:** `2` (if the environment variable is not set, is empty, or is not a valid integer). + +- **`HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS`** (environment variable): + - The base delay in milliseconds used in the exponential backoff calculation. The delay before the *n*-th retry (0-indexed) is roughly `HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS * (2^n) + jitter`. + - **Default Behavior:** `300` (milliseconds) (if the environment variable is not set, is empty, or is not a valid integer). + +**General Notes on Environment Variable Parsing:** +- Boolean environment variables (`RETRY_SSE_TOOL_CALL`, `RETRY_HTTP_TOOL_CALL`, `RETRY_STDIO_TOOL_CALL`) are considered `true` if their lowercase value is exactly `"true"`. Any other value (including empty or not set) results in the default being applied or `false` if the default is `false` (though for these specific variables, the default is `true`). +- Numeric environment variables (`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`) are parsed as base-10 integers. If parsing fails (e.g., the value is not a number, or the variable is empty/not set), the default value is used. + +**Example (Environment Variables):** +```bash +export RETRY_HTTP_TOOL_CALL="true" +export HTTP_TOOL_CALL_MAX_RETRIES="3" +export HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS="500" +``` + +### 4. Stdio Connection Retry for Tool Calls +For `tools/call` operations directed to Stdio-based backend servers, the proxy implements a retry mechanism for connection errors (e.g., process crash or unresponsiveness). + +**Retry Mechanism:** +If an initial Stdio connection or tool call fails, the proxy will attempt to restart the Stdio process and retry the request. This mechanism follows an exponential backoff strategy similar to HTTP retries. + +**Configuration:** +These settings are primarily controlled by environment variables. + +- **`RETRY_STDIO_TOOL_CALL`** (environment variable): + - Set to `"true"` to enable Stdio tool call retries. + - Set to `"false"` to disable this feature. + - **Default Behavior:** `true` (if the environment variable is not set, is empty, or is an invalid value). + +- **`STDIO_TOOL_CALL_MAX_RETRIES`** (environment variable): + - Specifies the maximum number of retry attempts *after* the initial failed attempt. For example, if set to `"2"`, there will be one initial attempt and up to two retry attempts, totaling a maximum of three attempts. + - **Default Behavior:** `2` (if the environment variable is not set, is empty, or is not a valid integer). + +- **`STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS`** (environment variable): + - The base delay in milliseconds used in the exponential backoff calculation. The delay before the *n*-th retry (0-indexed) is roughly `STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS * (2^n) + jitter`. + - **Default Behavior:** `300` (milliseconds) (if the environment variable is not set, is empty, or is not a valid integer). + +**General Notes on Environment Variable Parsing:** +- Boolean environment variables (`RETRY_SSE_TOOL_CALL`, `RETRY_HTTP_TOOL_CALL`, `RETRY_STDIO_TOOL_CALL`) are considered `true` if their lowercase value is exactly `"true"`. Any other value (including empty or not set) results in the default being applied or `false` if the default is `false` (though for these specific variables, the default is `true`)。 +- Numeric environment variables (`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`) are parsed as base-10 integers. If parsing fails (e.g., the value is not a number, or the variable is empty/not set), the default value is used. + ## Development Install dependencies: diff --git a/README_ZH.md b/README_ZH.md index dc829a9..8f6aa7f 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -122,18 +122,18 @@ ```json { "tools": { - "unique-server-key1--tool-name-from-server": { + "unique-server-key1__tool-name-from-server": { "enabled": true, "displayName": "我的自定义工具名称", "description": "一个更友好的描述。" }, - "another-sse-server--another-tool": { + "another-sse-server__another-tool": { "enabled": false } } } ``` -- 键的格式为 `--`。 +- 键的格式为 ``,其中 `` 是 `SERVER_TOOLNAME_SEPERATOR` 环境变量的值(默认为 `__`)。 - `enabled`: (可选, 默认: `true`) 设置为 `false` 以向连接到代理的客户端隐藏此工具。 - `displayName`: (可选) 在客户端 UI 中覆盖工具的名称。 - `description`: (可选) 覆盖工具的描述。 @@ -175,6 +175,143 @@ export TOOLS_FOLDER=/srv/mcp_tools ``` +- **`SERVER_TOOLNAME_SEPERATOR`**: (可选) 定义用于组合服务器名称和工具名称以生成工具唯一键的分隔符(例如 `server-key__tool-name`)。此键在内部和 `tool_config.json` 文件中使用。 + - 默认值:`__`。 + - 必须至少包含 2 个字符,且只能包含字母(a-z, A-Z)、数字(0-9)、连字符(`-`)和下划线(`_`)。 + - 如果提供的值无效,将使用默认值(`__`)并记录警告。 + ```bash + export SERVER_TOOLNAME_SEPERATOR="___" # 示例:使用三个下划线 + ``` + +- **`LOGGING`**: (可选) 控制服务器输出的最低日志级别。 + - 可能的值(不区分大小写):`error`, `warn`, `info`, `debug`。 + - 将显示指定级别及以上的所有日志。 + - 默认值:`info`。 + ```bash + export LOGGING="debug" + ``` + +- **`RETRY_SSE_TOOL_CALL`**: (可选) 控制 SSE 工具调用失败时是否自动重连并重试。设置为 `"true"` 启用,`"false"` 禁用。默认: `true`。有关详细信息,请参阅“增强的可靠性特性”部分。 + ```bash + export RETRY_SSE_TOOL_CALL="true" + ``` +- **`SSE_TOOL_CALL_MAX_RETRIES`**: (可选) SSE 工具调用最大重试次数(在初始失败后)。默认: `2`。有关详细信息,请参阅“增强的可靠性特性”部分。 + ```bash + export SSE_TOOL_CALL_MAX_RETRIES="2" + ``` +- **`SSE_TOOL_CALL_RETRY_DELAY_BASE_MS`**: (可选) SSE 工具调用重试延迟基准(毫秒),用于指数退避。默认: `300`。有关详细信息,请参阅“增强的可靠性特性”部分。 + ```bash + export SSE_TOOL_CALL_RETRY_DELAY_BASE_MS="300" + ``` +- **`RETRY_HTTP_TOOL_CALL`**: (可选) 控制 HTTP 工具调用连接错误时是否重试。设置为 `"true"` 启用,`"false"` 禁用。默认: `true`。有关详细信息,请参阅“增强的可靠性特性”部分。 + ```bash + export RETRY_HTTP_TOOL_CALL="true" + ``` +- **`HTTP_TOOL_CALL_MAX_RETRIES`**: (可选) HTTP 工具调用最大重试次数(在初始失败后)。默认: `2`。有关详细信息,请参阅“增强的可靠性特性”部分。 + ```bash + export HTTP_TOOL_CALL_MAX_RETRIES="3" + ``` +- **`HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS`**: (可选) HTTP 工具调用重试延迟基准(毫秒),用于指数退避。默认: `300`。有关详细信息,请参阅“增强的可靠性特性”部分。 + ```bash + export HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS="500" + ``` +- **`RETRY_STDIO_TOOL_CALL`**: (可选) 控制 Stdio 工具调用连接错误时是否重试(尝试重启进程)。设置为 `"true"` 启用,`"false"` 禁用。默认: `true`。有关详细信息,请参阅“增强的可靠性特性”部分。 + ```bash + export RETRY_STDIO_TOOL_CALL="true" + ``` +- **`STDIO_TOOL_CALL_MAX_RETRIES`**: (可选) Stdio 工具调用最大重试次数(在初始失败后)。默认: `2`。有关详细信息,请参阅“增强的可靠性特性”部分。 + ```bash + export STDIO_TOOL_CALL_MAX_RETRIES="5" + ``` +- **`STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS`**: (可选) Stdio 工具调用重试延迟基准(毫秒),用于指数退避。默认: `300`。有关详细信息,请参阅“增强的可靠性特性”部分。 + ```bash + export STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS="1000" + ``` + +## 增强的可靠性特性 + +MCP 代理服务器包含多项特性,用以提升其自身弹性以及与后端 MCP 服务交互的可靠性,确保更平稳的操作和更一致的工具执行。 + +### 1. 错误传播 +代理服务器确保从后端 MCP 服务产生的错误能够一致地传播给请求客户端。这些错误被格式化为标准的 JSON-RPC 错误响应,使客户端更容易统一处理它们。 + +### 2. SSE 工具调用的连接重试 +当对基于 SSE 的后端服务器执行 `tools/call` 操作时,如果底层连接丢失或遇到错误(包括超时),代理服务器将实现重试机制。 + +**重试机制:** +如果初始 SSE 工具调用因连接错误或超时而失败,代理将尝试重新建立与 SSE 后端的连接。如果重新连接成功,它将使用指数退避策略重试原始的 `tools/call` 请求,类似于 HTTP 和 Stdio 重试。这意味着每次后续重试尝试之前的延迟会指数级增加,并加入少量抖动(随机性)。 + +**配置:** +这些设置主要通过环境变量控制。如果 `config/mcp_server.json` 中 `proxy` 对象下存在这些特定键的值,它们将被环境变量覆盖。 + +- **`RETRY_SSE_TOOL_CALL`** (环境变量): + - 设置为 `"true"` 以启用 SSE 工具调用的重试。 + - 设置为 `"false"` 以禁用此功能。 + - **默认行为:** `true` (如果环境变量未设置、为空或为无效值)。 + +- **`SSE_TOOL_CALL_MAX_RETRIES`** (环境变量): + - 指定在初次失败尝试*之后*的最大重试次数。例如,如果设置为 `"2"`,则会有一次初始尝试和最多两次重试尝试,总共最多三次尝试。 + - **默认行为:** `2` (如果环境变量未设置、为空或不是一个有效的整数)。 + +- **`SSE_TOOL_CALL_RETRY_DELAY_BASE_MS`** (环境变量): + - 用于指数退避计算的基准延迟(以毫秒为单位)。第 *n* 次重试(0索引)之前的延迟大约是 `SSE_TOOL_CALL_RETRY_DELAY_BASE_MS * (2^n) + 抖动`。 + - **默认行为:** `300` (毫秒) (如果环境变量未设置、为空或不是一个有效的整数)。 + +**示例 (环境变量):** +```bash +export RETRY_SSE_TOOL_CALL="true" +export SSE_TOOL_CALL_MAX_RETRIES="3" +export SSE_TOOL_CALL_RETRY_DELAY_BASE_MS="500" +``` + +### 3. HTTP 工具调用的请求重试 +对于定向到基于 HTTP 的后端服务器的 `tools/call` 操作,代理服务器为连接错误(例如,“failed to fetch”、网络超时)实现了一套重试机制。 + +**重试机制:** +如果初始 HTTP 请求因连接错误而失败,代理将使用指数退避策略重试该请求。这意味着每次后续重试尝试之前的延迟会指数级增加,并加入少量抖动(随机性)以防止“惊群效应”。 + +**配置:** +这些设置主要通过环境变量控制。 + +- **`RETRY_HTTP_TOOL_CALL`** (环境变量): + - 设置为 `"true"` 以启用 HTTP 工具调用的重试。 + - 设置为 `"false"` 以禁用此功能。 + - **默认行为:** `true` (如果环境变量未设置、为空或为无效值)。 + +- **`HTTP_TOOL_CALL_MAX_RETRIES`** (环境变量): + - 指定在初次失败尝试*之后*的最大重试次数。例如,如果设置为 `"2"`,则会有一次初始尝试和最多两次重试尝试,总共最多三次尝试。 + - **默认行为:** `2` (如果环境变量未设置、为空或不是一个有效的整数)。 + +- **`HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS`** (环境变量): + - 用于指数退避计算的基准延迟(以毫秒为单位)。第 *n* 次重试(0索引)之前的延迟大约是 `HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS * (2^n) + 抖动`。 + - **默认行为:** `300` (毫秒) (如果环境变量未设置、为空或不是一个有效的整数)。 + +### 4. Stdio 工具调用的连接重试 +对于指向基于 Stdio 的后端服务器的 `tools/call` 操作,代理实现了针对连接错误(例如,进程崩溃或无响应)的重试机制。 + +**重试机制:** +如果初始 Stdio 连接或工具调用失败,代理将尝试重新启动 Stdio 进程并重试请求。此机制类似于 HTTP 重试,使用指数退避策略。 + +**配置:** +这些设置主要由环境变量控制。 + +- **`RETRY_STDIO_TOOL_CALL`** (环境变量): + - 设置为 `"true"` 以启用 Stdio 工具调用重试。 + - 设置为 `"false"` 以禁用此功能。 + - **默认行为:** `true` (如果环境变量未设置、为空或为无效值)。 + +- **`STDIO_TOOL_CALL_MAX_RETRIES`** (环境变量): + - 指定在初次失败尝试*之后*的最大重试尝试次数。例如,如果设置为 `"2"`,则将有一次初始尝试和最多两次重试尝试,总共最多三次尝试。 + - **默认行为:** `2` (如果环境变量未设置、为空或不是一个有效的整数)。 + +- **`STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS`** (环境变量): + - 用于指数退避计算的基准延迟(以毫秒为单位)。第 *n* 次重试(从 0 开始索引)之前的延迟大约是 `STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS * (2^n) + 抖动`。 + - **默认行为:** `300` (毫秒) (如果环境变量未设置、为空或不是一个有效的整数)。 + +**环境变量解析通用说明:** +- 布尔环境变量(`RETRY_SSE_TOOL_CALL`,`RETRY_HTTP_TOOL_CALL`,`RETRY_STDIO_TOOL_CALL`)如果其小写值恰好是 `"true"`,则被视为 `true`。任何其他值(包括空或未设置)将应用默认值,或者如果默认值为 `false` 则为 `false`(尽管对于这些特定变量,默认值为 `true`)。 +- 数字环境变量(`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`)被解析为十进制整数。如果解析失败(例如,值不是数字,或变量为空/未设置),则使用默认值。 + ## 开发 安装依赖: diff --git a/public/tools.js b/public/tools.js index b365b4a..dacbf3e 100644 --- a/public/tools.js +++ b/public/tools.js @@ -4,6 +4,7 @@ const saveToolConfigButton = document.getElementById('save-tool-config-button'); // const saveToolStatus = document.getElementById('save-tool-status'); // Removed: Declared in script.js // Note: Assumes currentToolConfig and discoveredTools variables are globally accessible from script.js or passed. // Note: Assumes triggerReload function is globally accessible from script.js or passed. +let serverToolnameSeparator = '__'; // Default separator // --- Tool Configuration Management --- async function loadToolData() { @@ -11,14 +12,16 @@ async function loadToolData() { saveToolStatus.textContent = 'Loading tool data...'; window.toolDataLoaded = false; // Reset flag during load attempt (use global flag) try { - // Fetch both discovered tools and tool config concurrently - const [toolsResponse, configResponse] = await Promise.all([ + // Fetch discovered tools, tool config, and environment info concurrently + const [toolsResponse, configResponse, envResponse] = await Promise.all([ fetch('/admin/tools/list'), - fetch('/admin/tools/config') + fetch('/admin/tools/config'), + fetch('/admin/environment') // Fetch environment info ]); if (!toolsResponse.ok) throw new Error(`Failed to fetch discovered tools: ${toolsResponse.statusText}`); if (!configResponse.ok) throw new Error(`Failed to fetch tool config: ${configResponse.statusText}`); + if (!envResponse.ok) throw new Error(`Failed to fetch environment info: ${envResponse.statusText}`); // Check env response const toolsResult = await toolsResponse.json(); window.discoveredTools = toolsResult.tools || []; // Expecting { tools: [...] } (use global var) @@ -26,9 +29,12 @@ async function loadToolData() { window.currentToolConfig = await configResponse.json(); // Use global var if (!window.currentToolConfig || typeof window.currentToolConfig !== 'object' || !window.currentToolConfig.tools) { console.warn("Received invalid tool configuration format, initializing empty.", window.currentToolConfig); - window.currentToolConfig = { tools: {} }; // Initialize if invalid or empty + window.currentToolConfig = { tools: {} }; // Initialize if invalid or empty } + const envResult = await envResponse.json(); // Parse environment info + serverToolnameSeparator = envResult.serverToolnameSeparator || '__'; // Update separator + console.log(`Using server toolname separator from backend: "${serverToolnameSeparator}"`); renderTools(); // Render using both discovered and configured data window.toolDataLoaded = true; // Set global flag only after successful load and render @@ -66,7 +72,7 @@ function renderTools() { // Render discovered tools first, merging with config discoveredTools.forEach(tool => { - const toolKey = `${tool.serverName}--${tool.name}`; // Unique key + const toolKey = `${tool.serverName}${serverToolnameSeparator}${tool.name}`; // Use the fetched separator const config = currentToolConfig.tools[toolKey] || {}; // Get config or empty object // For discovered tools, their server is considered active by the proxy at connection time renderToolEntry(toolKey, tool, config, false, true); // isConfigOnly = false, isServerActive = true @@ -76,7 +82,8 @@ function renderTools() { // Render any remaining configured tools that were not discovered configuredToolKeys.forEach(toolKey => { const config = currentToolConfig.tools[toolKey]; - const serverKeyForConfigOnlyTool = toolKey.split('--')[0]; + // Use the fetched separator for splitting + const serverKeyForConfigOnlyTool = toolKey.split(serverToolnameSeparator)[0]; let isServerActiveForConfigOnlyTool = true; // Default to true if server config not found or active flag is missing/true if (window.currentServerConfig && window.currentServerConfig.mcpServers && window.currentServerConfig.mcpServers[serverKeyForConfigOnlyTool]) { diff --git a/src/client.ts b/src/client.ts index 7404dbe..bd21998 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5,28 +5,33 @@ import { StreamableHTTPClientTransport, StreamableHTTPClientTransportOptions } f import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import { TransportConfig, isSSEConfig, isStdioConfig, isHttpConfig } from './config.js'; import { EventSource } from 'eventsource'; +import { logger } from './logger.js'; // Import logger functions const sleep = (time: number) => new Promise(resolve => setTimeout(() => resolve(), time)) export interface ConnectedClient { client: Client; cleanup: () => Promise; name: string; + config: TransportConfig; // Added config + transportType: 'sse' | 'stdio' | 'http'; // Added transportType } -const createClient = (name: string, transportConfig: TransportConfig): { client: Client | undefined, transport: Transport | undefined } => { +const createClient = (name: string, transportConfig: TransportConfig): { client: Client | undefined, transport: Transport | undefined, transportType: 'sse' | 'stdio' | 'http' | undefined } => { let transport: Transport | null = null; + let transportType: 'sse' | 'stdio' | 'http' | undefined = undefined; try { if (isSSEConfig(transportConfig)) { + transportType = 'sse'; const transportOptions: SSEClientTransportOptions = {}; let customHeaders: Record | undefined; if (transportConfig.bearerToken) { customHeaders = { 'Authorization': `Bearer ${transportConfig.bearerToken}` }; - console.log(` Using Bearer Token for SSE connection to ${name}`); + logger.debug(` Using Bearer Token for SSE connection to ${name}`); // Changed to debug } else if (transportConfig.apiKey) { customHeaders = { 'X-Api-Key': transportConfig.apiKey }; - console.log(` Using X-Api-Key for SSE connection to ${name}`); + logger.debug(` Using X-Api-Key for SSE connection to ${name}`); // Changed to debug } if (customHeaders) { @@ -52,75 +57,77 @@ const createClient = (name: string, transportConfig: TransportConfig): { client: } transport = new SSEClientTransport(new URL(transportConfig.url), transportOptions); - } else if (isStdioConfig(transportConfig)) { - const mergedEnv = { - ...process.env, - ...transportConfig.env - }; - const filteredEnv: Record = {}; - for (const key in mergedEnv) { - if (Object.prototype.hasOwnProperty.call(mergedEnv, key) && mergedEnv[key] !== undefined) { - filteredEnv[key] = mergedEnv[key] as string; - } - } - transport = new StdioClientTransport({ - command: transportConfig.command, - args: transportConfig.args, - env: filteredEnv - }); - } else if (isHttpConfig(transportConfig)) { - const transportOptions: StreamableHTTPClientTransportOptions = {}; - let customHeaders: Record | undefined; + } else if (isStdioConfig(transportConfig)) { + transportType = 'stdio'; + const mergedEnv = { + ...process.env, + ...transportConfig.env + }; + const filteredEnv: Record = {}; + for (const key in mergedEnv) { + if (Object.prototype.hasOwnProperty.call(mergedEnv, key) && mergedEnv[key] !== undefined) { + filteredEnv[key] = mergedEnv[key] as string; + } + } + transport = new StdioClientTransport({ + command: transportConfig.command, + args: transportConfig.args, + env: filteredEnv + }); + } else if (isHttpConfig(transportConfig)) { + transportType = 'http'; + const transportOptions: StreamableHTTPClientTransportOptions = {}; + let customHeaders: Record | undefined; - if (transportConfig.bearerToken) { - customHeaders = { 'Authorization': `Bearer ${transportConfig.bearerToken}` }; - console.log(` Using Bearer Token for StreamableHTTP connection to ${name}`); - } else if (transportConfig.apiKey) { - customHeaders = { 'X-Api-Key': transportConfig.apiKey }; - console.log(` Using X-Api-Key for StreamableHTTP connection to ${name}`); - } + if (transportConfig.bearerToken) { + customHeaders = { 'Authorization': `Bearer ${transportConfig.bearerToken}` }; + logger.debug(` Using Bearer Token for StreamableHTTP connection to ${name}`); // Changed to debug + } else if (transportConfig.apiKey) { + customHeaders = { 'X-Api-Key': transportConfig.apiKey }; + logger.debug(` Using X-Api-Key for StreamableHTTP connection to ${name}`); // Changed to debug + } - if (customHeaders) { - transportOptions.requestInit = { headers: customHeaders }; - } - // Note: StreamableHTTPClientTransport handles session ID internally if configured. - // We might pass transportConfig.sessionId if we want to force a specific one. - transport = new StreamableHTTPClientTransport(new URL(transportConfig.url), transportOptions); - } else { - console.error(`Invalid or unknown transport type in configuration for server: ${name}`); - } - } catch (error) { - let transportType = 'unknown'; - if (isSSEConfig(transportConfig)) transportType = 'sse'; - else if (isStdioConfig(transportConfig)) transportType = 'stdio'; - else if (isHttpConfig(transportConfig)) transportType = 'http'; - console.error(`Failed to create transport ${transportType} to ${name}:`, error); - } + if (customHeaders) { + transportOptions.requestInit = { headers: customHeaders }; + } + // Note: StreamableHTTPClientTransport handles session ID internally if configured. + // We might pass transportConfig.sessionId if we want to force a specific one. + transport = new StreamableHTTPClientTransport(new URL(transportConfig.url), transportOptions); + } else { + logger.error(`Invalid or unknown transport type in configuration for server: ${name}`); // Changed to error + } + } catch (error) { + let transportType = 'unknown'; + if (isSSEConfig(transportConfig)) transportType = 'sse'; + else if (isStdioConfig(transportConfig)) transportType = 'stdio'; + else if (isHttpConfig(transportConfig)) transportType = 'http'; + logger.error(`Failed to create transport ${transportType} to ${name}:`, error); // Changed to error + } - if (!transport) { - console.warn(`Transport for ${name} not available.`); - return { transport: undefined, client: undefined }; - } + if (!transport || !transportType) { // Also check transportType + logger.warn(`Transport or transportType for ${name} not available.`); // Changed to warn + return { transport: undefined, client: undefined, transportType: undefined }; + } - const client = new Client({ - name: 'mcp-proxy-client', - version: '1.0.0', - }, { - capabilities: { - prompts: {}, - resources: { subscribe: true }, - tools: {} - } - }); + const client = new Client({ + name: 'mcp-proxy-client', + version: '1.0.0', + }, { + capabilities: { + prompts: {}, + resources: { subscribe: true }, + tools: {} + } + }); - return { client, transport } + return { client, transport, transportType } } export const createClients = async (mcpServers: Record): Promise => { const clients: ConnectedClient[] = []; for (const [name, transportConfig] of Object.entries(mcpServers)) { - console.log(`Connecting to server: ${name}`); + logger.log(`Connecting to server: ${name}`); // Changed to log const waitFor = 2500; const retries = 3; @@ -129,18 +136,21 @@ export const createClients = async (mcpServers: Record) while (retry) { - const { client, transport } = createClient(name, transportConfig); - if (!client || !transport) { + const { client, transport, transportType } = createClient(name, transportConfig); // Capture transportType + if (!client || !transport || !transportType) { // Check transportType + logger.warn(`Skipping client ${name} due to failed client/transport creation.`); // Changed to warn break; } try { await client.connect(transport); - console.log(`Connected to server: ${name}`); + logger.log(`Connected to server: ${name}`); // Changed to log clients.push({ client, name: name, + config: transportConfig, // Store config + transportType: transportType, // Store transportType cleanup: async () => { await transport.close(); } @@ -148,15 +158,15 @@ export const createClients = async (mcpServers: Record) break - } catch (error) { - console.error(`Failed to connect to ${name}:`, error); + } catch (error: any) { + logger.error(`Failed to connect to ${name}: ${error.message}`); // Log error message count++; retry = (count < retries); if (retry) { try { await client.close(); } catch { } - console.log(`Retry connection to ${name} in ${waitFor}ms (${count}/${retries})`); + logger.log(`Retry connection to ${name} in ${waitFor}ms (${count}/${retries})`); // Changed to log await sleep(waitFor); } } @@ -167,3 +177,127 @@ export const createClients = async (mcpServers: Record) return clients; }; + +// No longer using ReconnectedClientResult, returning full ConnectedClient-like structure +// but as a direct object, which refreshBackendConnection will use to create a full ConnectedClient. + +export async function reconnectSingleClient( + name: string, + transportConfig: TransportConfig, + existingCleanup?: () => Promise +): Promise> { // Returns the parts needed to reconstruct a ConnectedClient + logger.log(`Attempting to reconnect client: ${name}`); // Changed to log + + if (existingCleanup) { + try { + await existingCleanup(); + logger.log(`Existing client ${name} cleaned up before reconnecting.`); // Changed to log + } catch (e: any) { + logger.warn(`Error during cleanup of existing client ${name} before reconnect: ${e.message}`); // Changed to warn + } + } + + let transport: Transport | null = null; + let determinedTransportType: 'sse' | 'stdio' | 'http' | undefined = undefined; + + try { + if (isSSEConfig(transportConfig)) { + determinedTransportType = 'sse'; + const transportOptions: SSEClientTransportOptions = {}; + let customHeaders: Record | undefined; + if (transportConfig.bearerToken) { + customHeaders = { 'Authorization': `Bearer ${transportConfig.bearerToken}` }; + logger.debug(` Using Bearer Token for SSE connection to ${name} (reconnect)`); // Changed to debug + } else if (transportConfig.apiKey) { + customHeaders = { 'X-Api-Key': transportConfig.apiKey }; + logger.debug(` Using X-Api-Key for SSE connection to ${name} (reconnect)`); // Changed to debug + } + if (customHeaders) { + transportOptions.requestInit = { headers: customHeaders }; + const headersToAdd = customHeaders; // Closure for fetch + transportOptions.eventSourceInit = { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const originalHeaders = new Headers(init?.headers || {}); + for (const key in headersToAdd) { + originalHeaders.set(key, headersToAdd[key]); + } + return fetch(input, { ...init, headers: originalHeaders }); + }, + } as any; + } + transport = new SSEClientTransport(new URL(transportConfig.url), transportOptions); + } else if (isStdioConfig(transportConfig)) { + determinedTransportType = 'stdio'; + const mergedEnv = { ...process.env, ...transportConfig.env }; + const filteredEnv: Record = {}; + for (const key in mergedEnv) { + if (Object.prototype.hasOwnProperty.call(mergedEnv, key) && mergedEnv[key] !== undefined) { + filteredEnv[key] = mergedEnv[key] as string; + } + } + transport = new StdioClientTransport({ + command: transportConfig.command, + args: transportConfig.args, + env: filteredEnv + }); + logger.debug(` Configured Stdio transport for ${name} (reconnect)`); // Changed to debug + } else if (isHttpConfig(transportConfig)) { + determinedTransportType = 'http'; + const transportOptions: StreamableHTTPClientTransportOptions = {}; + let customHeaders: Record | undefined; + if (transportConfig.bearerToken) { + customHeaders = { 'Authorization': `Bearer ${transportConfig.bearerToken}` }; + logger.debug(` Using Bearer Token for StreamableHTTP connection to ${name} (reconnect)`); // Changed to debug + } else if (transportConfig.apiKey) { + customHeaders = { 'X-Api-Key': transportConfig.apiKey }; + logger.debug(` Using X-Api-Key for StreamableHTTP connection to ${name} (reconnect)`); // Changed to debug + } + if (customHeaders) { + transportOptions.requestInit = { headers: customHeaders }; + } + transport = new StreamableHTTPClientTransport(new URL(transportConfig.url), transportOptions); + } else { + throw new Error(`Invalid or unknown transport type in configuration for server: ${name}`); + } + } catch (error: any) { + logger.error(`Failed to create transport for ${name} during reconnect: ${error.message}`); // Changed to error + throw error; + } + + if (!transport || !determinedTransportType) { // Check determinedTransportType as well + throw new Error(`Transport or transport type for ${name} could not be created during reconnect.`); + } + + const newSdkClient = new Client({ + name: 'mcp-proxy-client-reconnect', + version: '1.0.1', + }, { + capabilities: { prompts: {}, resources: { subscribe: true }, tools: {} } + }); + + try { + await newSdkClient.connect(transport); + logger.log(`Successfully reconnected to server: ${name}`); // Changed to log + const finalTransport = transport; // Capture for closure + return { + client: newSdkClient, + config: transportConfig, // Return config + transportType: determinedTransportType, // Return transportType + cleanup: async () => { + if (finalTransport) { + await finalTransport.close(); + } + } + }; + } catch (error: any) { + logger.error(`Failed to connect to ${name} during reconnect attempt: ${error.message}`); // Changed to error + try { + if (transport) { + await transport.close(); + } + } catch (closeError: any) { + logger.warn(`Failed to close transport for ${name} after reconnect failure: ${closeError.message}`); // Changed to warn + } + throw error; + } +} diff --git a/src/config.ts b/src/config.ts index c5f1fa7..7ca223e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ import { readFile } from 'fs/promises'; import { resolve } from 'path'; +import { logger } from './logger.js'; export type TransportConfigStdio = { type: 'stdio'; @@ -34,8 +35,25 @@ export type TransportConfigHTTP = { export type TransportConfig = (TransportConfigStdio | TransportConfigSSE | TransportConfigHTTP) & { name?: string, active?: boolean, type: 'stdio' | 'sse' | 'http' }; +export interface ProxySettings { + retrySseToolCall?: boolean; // Renamed from retrySseToolCallOnDisconnect + sseToolCallMaxRetries?: number; + sseToolCallRetryDelayBaseMs?: number; + retryHttpToolCall?: boolean; + httpToolCallMaxRetries?: number; + httpToolCallRetryDelayBaseMs?: number; + retryStdioToolCall?: boolean; + stdioToolCallMaxRetries?: number; + stdioToolCallRetryDelayBaseMs?: number; +} + +export const DEFAULT_SERVER_TOOLNAME_SEPERATOR = '__'; // Changed default separator +export const SERVER_TOOLNAME_SEPERATOR_ENV_VAR = 'SERVER_TOOLNAME_SEPERATOR'; + export interface Config { mcpServers: Record; + proxy?: ProxySettings; + serverToolnameSeparator?: string; // Added for the separator } @@ -64,6 +82,39 @@ export function isHttpConfig(config: TransportConfig): config is TransportConfig export const loadConfig = async (): Promise => { + // Define standard defaults for specific environment-overrideable proxy settings + // This is moved here to be in scope for both try and catch blocks. + const defaultEnvProxySettings = { + retrySseToolCall: true, // Renamed from retrySseToolCallOnDisconnect + sseToolCallMaxRetries: 2, + sseToolCallRetryDelayBaseMs: 300, + retryHttpToolCall: true, + httpToolCallMaxRetries: 2, + httpToolCallRetryDelayBaseMs: 300, + retryStdioToolCall: true, + stdioToolCallMaxRetries: 2, + stdioToolCallRetryDelayBaseMs: 300, + }; + + let serverToolnameSeparator = DEFAULT_SERVER_TOOLNAME_SEPERATOR; + const envSeparator = process.env[SERVER_TOOLNAME_SEPERATOR_ENV_VAR]; + const separatorRegex = /^[a-zA-Z0-9_-]+$/; // Regex for valid characters + + if (envSeparator !== undefined && envSeparator.trim() !== '') { + const trimmedSeparator = envSeparator.trim(); + if (trimmedSeparator.length >= 2 && separatorRegex.test(trimmedSeparator)) { + serverToolnameSeparator = trimmedSeparator; + logger.log(`Using server toolname separator from environment variable ${SERVER_TOOLNAME_SEPERATOR_ENV_VAR}: "${serverToolnameSeparator}"`); + } else { + 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}".`); + serverToolnameSeparator = DEFAULT_SERVER_TOOLNAME_SEPERATOR; + } + } else { + logger.log(`Environment variable ${SERVER_TOOLNAME_SEPERATOR_ENV_VAR} not set or empty. Using default separator: "${DEFAULT_SERVER_TOOLNAME_SEPERATOR}".`); + serverToolnameSeparator = DEFAULT_SERVER_TOOLNAME_SEPERATOR; + } + + try { const configPath = resolve(process.cwd(), 'config', 'mcp_server.json'); console.log(`Attempting to load configuration from: ${configPath}`); @@ -71,44 +122,255 @@ export const loadConfig = async (): Promise => { const parsedConfig = JSON.parse(fileContents) as Config; if (typeof parsedConfig !== 'object' || parsedConfig === null || typeof parsedConfig.mcpServers !== 'object') { - throw new Error('Invalid config format: mcpServers object not found.'); + throw new Error('Invalid config format: mcpServers object not found.'); } - return parsedConfig; - } catch (error) { - console.error(`Error loading config/mcp_server.json:`, error); - return { mcpServers: {} }; - } -}; + // Initialize proxy object on parsedConfig if it doesn't exist + parsedConfig.proxy = parsedConfig.proxy || {}; + // Override with environment variables or defaults for the specific settings -export const loadToolConfig = async (): Promise => { - const defaultConfig: ToolConfig = { tools: {} }; - try { - const configPath = resolve(process.cwd(), 'config', 'tool_config.json'); - console.log(`Attempting to load tool configuration from: ${configPath}`); - const fileContents = await readFile(configPath, 'utf-8'); - const parsedConfig = JSON.parse(fileContents) as ToolConfig; + // SSE Retry Settings + const sseRetryEnv = process.env.RETRY_SSE_TOOL_CALL; // Changed env var name + if (sseRetryEnv && sseRetryEnv.trim() !== '') { + parsedConfig.proxy.retrySseToolCall = sseRetryEnv.toLowerCase() === 'true'; // Changed property name + } else { + parsedConfig.proxy.retrySseToolCall = defaultEnvProxySettings.retrySseToolCall; // Changed property name + } - if (typeof parsedConfig !== 'object' || parsedConfig === null || typeof parsedConfig.tools !== 'object') { - console.warn('Invalid tool_config.json format: "tools" object not found or invalid. Using default.'); - return defaultConfig; + const sseMaxRetriesEnv = process.env.SSE_TOOL_CALL_MAX_RETRIES; + if (sseMaxRetriesEnv && sseMaxRetriesEnv.trim() !== '') { + const numVal = parseInt(sseMaxRetriesEnv, 10); + if (!isNaN(numVal)) { + parsedConfig.proxy.sseToolCallMaxRetries = numVal; + } else { + logger.warn(`Invalid value for SSE_TOOL_CALL_MAX_RETRIES: "${sseMaxRetriesEnv}". Using default: ${defaultEnvProxySettings.sseToolCallMaxRetries}.`); + parsedConfig.proxy.sseToolCallMaxRetries = defaultEnvProxySettings.sseToolCallMaxRetries; + } + } else { + parsedConfig.proxy.sseToolCallMaxRetries = defaultEnvProxySettings.sseToolCallMaxRetries; } - for (const toolKey in parsedConfig.tools) { - if (typeof parsedConfig.tools[toolKey]?.enabled !== 'boolean') { - console.warn(`Invalid setting for tool "${toolKey}" in tool_config.json: 'enabled' is missing or not a boolean. Assuming enabled.`); - } + + const sseDelayBaseEnv = process.env.SSE_TOOL_CALL_RETRY_DELAY_BASE_MS; + if (sseDelayBaseEnv && sseDelayBaseEnv.trim() !== '') { + const numVal = parseInt(sseDelayBaseEnv, 10); + if (!isNaN(numVal)) { + parsedConfig.proxy.sseToolCallRetryDelayBaseMs = numVal; + } else { + logger.warn(`Invalid value for SSE_TOOL_CALL_RETRY_DELAY_BASE_MS: "${sseDelayBaseEnv}". Using default: ${defaultEnvProxySettings.sseToolCallRetryDelayBaseMs}.`); + parsedConfig.proxy.sseToolCallRetryDelayBaseMs = defaultEnvProxySettings.sseToolCallRetryDelayBaseMs; + } + } else { + parsedConfig.proxy.sseToolCallRetryDelayBaseMs = defaultEnvProxySettings.sseToolCallRetryDelayBaseMs; + } + + + // HTTP Retry Settings + const httpRetryEnv = process.env.RETRY_HTTP_TOOL_CALL; + if (httpRetryEnv && httpRetryEnv.trim() !== '') { + parsedConfig.proxy.retryHttpToolCall = httpRetryEnv.toLowerCase() === 'true'; + } else { + parsedConfig.proxy.retryHttpToolCall = defaultEnvProxySettings.retryHttpToolCall; + } + + const maxRetriesEnv = process.env.HTTP_TOOL_CALL_MAX_RETRIES; + if (maxRetriesEnv && maxRetriesEnv.trim() !== '') { + const numVal = parseInt(maxRetriesEnv, 10); + if (!isNaN(numVal)) { + parsedConfig.proxy.httpToolCallMaxRetries = numVal; + } else { + logger.warn(`Invalid value for HTTP_TOOL_CALL_MAX_RETRIES: "${maxRetriesEnv}". Using default: ${defaultEnvProxySettings.httpToolCallMaxRetries}.`); + parsedConfig.proxy.httpToolCallMaxRetries = defaultEnvProxySettings.httpToolCallMaxRetries; + } + } else { + parsedConfig.proxy.httpToolCallMaxRetries = defaultEnvProxySettings.httpToolCallMaxRetries; + } + + const delayBaseEnv = process.env.HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS; + if (delayBaseEnv && delayBaseEnv.trim() !== '') { + const numVal = parseInt(delayBaseEnv, 10); + if (!isNaN(numVal)) { + parsedConfig.proxy.httpToolCallRetryDelayBaseMs = numVal; + } else { + logger.warn(`Invalid value for HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS: "${delayBaseEnv}". Using default: ${defaultEnvProxySettings.httpToolCallRetryDelayBaseMs}.`); + parsedConfig.proxy.httpToolCallRetryDelayBaseMs = defaultEnvProxySettings.httpToolCallRetryDelayBaseMs; + } + } else { + parsedConfig.proxy.httpToolCallRetryDelayBaseMs = defaultEnvProxySettings.httpToolCallRetryDelayBaseMs; } - console.log(`Successfully loaded tool configuration for ${Object.keys(parsedConfig.tools).length} tools.`); + // STDIO Retry Settings + const stdioRetryEnv = process.env.RETRY_STDIO_TOOL_CALL; + if (stdioRetryEnv && stdioRetryEnv.trim() !== '') { + parsedConfig.proxy.retryStdioToolCall = stdioRetryEnv.toLowerCase() === 'true'; + } else { + parsedConfig.proxy.retryStdioToolCall = defaultEnvProxySettings.retryStdioToolCall; + } + + const stdioMaxRetriesEnv = process.env.STDIO_TOOL_CALL_MAX_RETRIES; + if (stdioMaxRetriesEnv && stdioMaxRetriesEnv.trim() !== '') { + const numVal = parseInt(stdioMaxRetriesEnv, 10); + if (!isNaN(numVal)) { + parsedConfig.proxy.stdioToolCallMaxRetries = numVal; + } else { + logger.warn(`Invalid value for STDIO_TOOL_CALL_MAX_RETRIES: "${stdioMaxRetriesEnv}". Using default: ${defaultEnvProxySettings.stdioToolCallMaxRetries}.`); + parsedConfig.proxy.stdioToolCallMaxRetries = defaultEnvProxySettings.stdioToolCallMaxRetries; + } + } else { + parsedConfig.proxy.stdioToolCallMaxRetries = defaultEnvProxySettings.stdioToolCallMaxRetries; + } + + const stdioDelayBaseEnv = process.env.STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS; + if (stdioDelayBaseEnv && stdioDelayBaseEnv.trim() !== '') { + const numVal = parseInt(stdioDelayBaseEnv, 10); + if (!isNaN(numVal)) { + parsedConfig.proxy.stdioToolCallRetryDelayBaseMs = numVal; + } else { + logger.warn(`Invalid value for STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS: "${stdioDelayBaseEnv}". Using default: ${defaultEnvProxySettings.stdioToolCallRetryDelayBaseMs}.`); + parsedConfig.proxy.stdioToolCallRetryDelayBaseMs = defaultEnvProxySettings.stdioToolCallRetryDelayBaseMs; + } + } else { + parsedConfig.proxy.stdioToolCallRetryDelayBaseMs = defaultEnvProxySettings.stdioToolCallRetryDelayBaseMs; + } + + logger.log("Loaded config with final proxy settings (after env overrides):", JSON.stringify(parsedConfig.proxy).slice(1, -1)); + + // Add the determined separator to the config object + parsedConfig.serverToolnameSeparator = serverToolnameSeparator; + return parsedConfig; + } catch (error: any) { - if (error.code === 'ENOENT') { - console.log('config/tool_config.json not found. Using default (all tools enabled).'); - } else { - console.error(`Error loading config/tool_config.json:`, error); - console.warn('Using default tool configuration (all tools enabled) due to error.'); + logger.error(`Error loading config/mcp_server.json: ${error.message}`); + + // If file loading fails, initialize with environment variables or defaults for proxy settings + const proxySettingsFromEnvOrDefaults: ProxySettings = { + retrySseToolCall: defaultEnvProxySettings.retrySseToolCall, + sseToolCallMaxRetries: defaultEnvProxySettings.sseToolCallMaxRetries, // Default for SSE max retries + sseToolCallRetryDelayBaseMs: defaultEnvProxySettings.sseToolCallRetryDelayBaseMs, // Default for SSE retry delay + retryHttpToolCall: defaultEnvProxySettings.retryHttpToolCall, + httpToolCallMaxRetries: defaultEnvProxySettings.httpToolCallMaxRetries, + httpToolCallRetryDelayBaseMs: defaultEnvProxySettings.httpToolCallRetryDelayBaseMs, + retryStdioToolCall: defaultEnvProxySettings.retryStdioToolCall, + stdioToolCallMaxRetries: defaultEnvProxySettings.stdioToolCallMaxRetries, + stdioToolCallRetryDelayBaseMs: defaultEnvProxySettings.stdioToolCallRetryDelayBaseMs, + }; + + // SSE Retry Settings (during error handling) + const sseRetryEnvCatch = process.env.RETRY_SSE_TOOL_CALL; // Changed env var name + if (sseRetryEnvCatch && sseRetryEnvCatch.trim() !== '') { + proxySettingsFromEnvOrDefaults.retrySseToolCall = sseRetryEnvCatch.toLowerCase() === 'true'; // Changed property name + } + + const sseMaxRetriesEnvCatch = process.env.SSE_TOOL_CALL_MAX_RETRIES; + if (sseMaxRetriesEnvCatch && sseMaxRetriesEnvCatch.trim() !== '') { + const numVal = parseInt(sseMaxRetriesEnvCatch, 10); + if (!isNaN(numVal)) { + proxySettingsFromEnvOrDefaults.sseToolCallMaxRetries = numVal; + } else { + logger.warn(`Invalid value for SSE_TOOL_CALL_MAX_RETRIES: "${sseMaxRetriesEnvCatch}" (during error handling). Using default: ${defaultEnvProxySettings.sseToolCallMaxRetries}.`); + } + } + + const sseDelayBaseEnvCatch = process.env.SSE_TOOL_CALL_RETRY_DELAY_BASE_MS; + if (sseDelayBaseEnvCatch && sseDelayBaseEnvCatch.trim() !== '') { + const numVal = parseInt(sseDelayBaseEnvCatch, 10); + if (!isNaN(numVal)) { + proxySettingsFromEnvOrDefaults.sseToolCallRetryDelayBaseMs = numVal; + } else { + logger.warn(`Invalid value for SSE_TOOL_CALL_RETRY_DELAY_BASE_MS: "${sseDelayBaseEnvCatch}" (during error handling). Using default: ${defaultEnvProxySettings.sseToolCallRetryDelayBaseMs}.`); + } + } + + // HTTP Retry Settings (during error handling) + const httpRetryEnvCatch = process.env.RETRY_HTTP_TOOL_CALL; + if (httpRetryEnvCatch && httpRetryEnvCatch.trim() !== '') { + proxySettingsFromEnvOrDefaults.retryHttpToolCall = httpRetryEnvCatch.toLowerCase() === 'true'; + } + + const maxRetriesEnvCatch = process.env.HTTP_TOOL_CALL_MAX_RETRIES; + if (maxRetriesEnvCatch && maxRetriesEnvCatch.trim() !== '') { + const numVal = parseInt(maxRetriesEnvCatch, 10); + if (!isNaN(numVal)) { + proxySettingsFromEnvOrDefaults.httpToolCallMaxRetries = numVal; + } else { + logger.warn(`Invalid value for HTTP_TOOL_CALL_MAX_RETRIES: "${maxRetriesEnvCatch}" (during error handling). Using default: ${defaultEnvProxySettings.httpToolCallMaxRetries}.`); + } + } + + const delayBaseEnvCatch = process.env.HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS; + if (delayBaseEnvCatch && delayBaseEnvCatch.trim() !== '') { + const numVal = parseInt(delayBaseEnvCatch, 10); + if (!isNaN(numVal)) { + proxySettingsFromEnvOrDefaults.httpToolCallRetryDelayBaseMs = numVal; + } else { + logger.warn(`Invalid value for HTTP_TOOL_CALL_RETRY_DELAY_BASE_MS: "${delayBaseEnvCatch}" (during error handling). Using default: ${defaultEnvProxySettings.httpToolCallRetryDelayBaseMs}.`); + } + } + + // STDIO Retry Settings (during error handling) + const stdioRetryEnvCatch = process.env.RETRY_STDIO_TOOL_CALL; + if (stdioRetryEnvCatch && stdioRetryEnvCatch.trim() !== '') { + proxySettingsFromEnvOrDefaults.retryStdioToolCall = stdioRetryEnvCatch.toLowerCase() === 'true'; + } + + const stdioMaxRetriesEnvCatch = process.env.STDIO_TOOL_CALL_MAX_RETRIES; + if (stdioMaxRetriesEnvCatch && stdioMaxRetriesEnvCatch.trim() !== '') { + const numVal = parseInt(stdioMaxRetriesEnvCatch, 10); + if (!isNaN(numVal)) { + proxySettingsFromEnvOrDefaults.stdioToolCallMaxRetries = numVal; + } else { + logger.warn(`Invalid value for STDIO_TOOL_CALL_MAX_RETRIES: "${stdioMaxRetriesEnvCatch}" (during error handling). Using default: ${defaultEnvProxySettings.stdioToolCallMaxRetries}.`); + } + } + + const stdioDelayBaseEnvCatch = process.env.STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS; + if (stdioDelayBaseEnvCatch && stdioDelayBaseEnvCatch.trim() !== '') { + const numVal = parseInt(stdioDelayBaseEnvCatch, 10); + if (!isNaN(numVal)) { + proxySettingsFromEnvOrDefaults.stdioToolCallRetryDelayBaseMs = numVal; + } else { + logger.warn(`Invalid value for STDIO_TOOL_CALL_RETRY_DELAY_BASE_MS: "${stdioDelayBaseEnvCatch}" (during error handling). Using default: ${defaultEnvProxySettings.stdioToolCallRetryDelayBaseMs}.`); + } + } + + logger.log("Using proxy settings from environment/defaults due to mcp_server.json load error:", proxySettingsFromEnvOrDefaults); + return { + mcpServers: {}, + proxy: proxySettingsFromEnvOrDefaults, + serverToolnameSeparator: serverToolnameSeparator, // Add the determined separator here too + }; + } +}; + + +export const loadToolConfig = async (): Promise => { + const defaultConfig: ToolConfig = { tools: {} }; +try { + const configPath = resolve(process.cwd(), 'config', 'tool_config.json'); + logger.log(`Attempting to load tool configuration from: ${configPath}`); + const fileContents = await readFile(configPath, 'utf-8'); + const parsedConfig = JSON.parse(fileContents) as ToolConfig; + + if (typeof parsedConfig !== 'object' || parsedConfig === null || typeof parsedConfig.tools !== 'object') { + logger.warn('Invalid tool_config.json format: "tools" object not found or invalid. Using default.'); + return defaultConfig; + } + for (const toolKey in parsedConfig.tools) { + if (typeof parsedConfig.tools[toolKey]?.enabled !== 'boolean') { + logger.warn(`Invalid setting for tool "${toolKey}" in tool_config.json: 'enabled' is missing or not a boolean. Assuming enabled.`); } - return defaultConfig; + } + + logger.log(`Successfully loaded tool configuration for ${Object.keys(parsedConfig.tools).length} tools.`); + return parsedConfig; +} catch (error: any) { + if (error.code === 'ENOENT') { + logger.log('config/tool_config.json not found. Using default (all tools enabled).'); + } else { + logger.error(`Error loading config/tool_config.json: ${error.message}`); + logger.warn('Using default tool configuration (all tools enabled) due to error.'); } + return defaultConfig; +} }; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index baafe85..8bf9b3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { logger } from './logger.js'; import { createServer } from "./mcp-proxy.js"; async function main() { @@ -17,6 +18,6 @@ async function main() { } main().catch((error) => { - console.error("Server error:", error); + logger.error("Server error:", error.message); process.exit(1); }); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..c9b51d8 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,66 @@ +function formatTimestamp(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const day = now.getDate().toString().padStart(2, '0'); + const hours = now.getHours().toString().padStart(2, '0'); + const minutes = now.getMinutes().toString().padStart(2, '0'); + const seconds = now.getSeconds().toString().padStart(2, '0'); + const milliseconds = now.getMilliseconds().toString().padStart(3, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`; +} + +enum LogLevel { + Error, + Warn, + Info, + Debug, +} + +function getLogLevel(envVar: string | undefined): LogLevel { + switch (envVar?.toLowerCase()) { + case 'debug': + return LogLevel.Debug; + case 'info': + return LogLevel.Info; + case 'warn': + return LogLevel.Warn; + case 'error': + return LogLevel.Error; + default: + return LogLevel.Info; // Default to Info level + } +} + +const currentLogLevel = getLogLevel(process.env.LOGGING); + +function log(...args: any[]): void { + if (currentLogLevel >= LogLevel.Info) { + console.log(`[${formatTimestamp()}] [INFO]`, ...args); + } +} + +function warn(...args: any[]): void { + if (currentLogLevel >= LogLevel.Warn) { + console.warn(`[${formatTimestamp()}] [WARN]`, ...args); + } +} + +function error(...args: any[]): void { + if (currentLogLevel >= LogLevel.Error) { + console.error(`[${formatTimestamp()}] [ERROR]`, ...args); + } +} + +function debug(...args: any[]): void { + if (currentLogLevel >= LogLevel.Debug) { + console.debug(`[${formatTimestamp()}] [DEBUG]`, ...args); + } +} + +export const logger = { + log, + warn, + error, + debug, +}; \ No newline at end of file diff --git a/src/mcp-proxy.ts b/src/mcp-proxy.ts index 235e2c0..e3abd6f 100644 --- a/src/mcp-proxy.ts +++ b/src/mcp-proxy.ts @@ -1,4 +1,5 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { DEFAULT_REQUEST_TIMEOUT_MSEC } from "@modelcontextprotocol/sdk/shared/protocol.js"; // Import the constant import { CallToolRequestSchema, GetPromptRequestSchema, @@ -15,10 +16,12 @@ import { ListResourceTemplatesResultSchema, ResourceTemplate, CompatibilityCallToolResultSchema, - GetPromptResultSchema + GetPromptResultSchema, + McpError } from "@modelcontextprotocol/sdk/types.js"; -import { createClients, ConnectedClient } from './client.js'; -import { Config, loadConfig, TransportConfig, isSSEConfig, isStdioConfig, ToolConfig, loadToolConfig } from './config.js'; +import { createClients, ConnectedClient, reconnectSingleClient } from './client.js'; +import { logger } from './logger.js'; +import { Config, loadConfig, TransportConfig, isSSEConfig, isStdioConfig, isHttpConfig, ToolConfig, loadToolConfig, DEFAULT_SERVER_TOOLNAME_SEPERATOR } from './config.js'; import { z } from 'zod'; import * as eventsource from 'eventsource'; @@ -31,46 +34,71 @@ const toolToClientMap = new Map(); const promptToClientMap = new Map(); let currentToolConfig: ToolConfig = { tools: {} }; // Store loaded tool config +let currentActiveServersConfig: Record = {}; // Added for retry logic +let currentSeparator: string = DEFAULT_SERVER_TOOLNAME_SEPERATOR; // Store the current separator + +// Define Global Default Proxy Settings +const defaultProxySettingsFull: Required> = { + retrySseToolCall: true, // Renamed from retrySseToolCallOnDisconnect + sseToolCallMaxRetries: 2, + sseToolCallRetryDelayBaseMs: 300, + retryHttpToolCall: true, + httpToolCallMaxRetries: 2, + httpToolCallRetryDelayBaseMs: 300, + retryStdioToolCall: true, + stdioToolCallMaxRetries: 2, + stdioToolCallRetryDelayBaseMs: 300, +}; + +let currentProxyConfig: Required> = { ...defaultProxySettingsFull }; // Initialize with full defaults // --- Function to update backend connections and maps --- export const updateBackendConnections = async (newServerConfig: Config, newToolConfig: ToolConfig) => { - console.log("Starting update of backend connections..."); + logger.log("Starting update of backend connections..."); currentToolConfig = newToolConfig; // Update stored tool config + currentProxyConfig = { // Update currentProxyConfig using full defaults + ...defaultProxySettingsFull, + ...(newServerConfig.proxy || {}), + }; + // Update the current separator from the new config + currentSeparator = newServerConfig.serverToolnameSeparator || DEFAULT_SERVER_TOOLNAME_SEPERATOR; + logger.log(`Using server toolname separator: "${currentSeparator}"`); - const activeServersConfig: Record = {}; + const activeServersConfigLocal: Record = {}; // Renamed to avoid conflict with module-level for (const serverKey in newServerConfig.mcpServers) { if (Object.prototype.hasOwnProperty.call(newServerConfig.mcpServers, serverKey)) { const serverConf = newServerConfig.mcpServers[serverKey]; const isActive = !(serverConf.active === false || String(serverConf.active).toLowerCase() === 'false'); if (isActive) { - activeServersConfig[serverKey] = serverConf; + activeServersConfigLocal[serverKey] = serverConf; } else { - const serverName = serverConf.name || (isSSEConfig(serverConf) ? serverConf.url : isStdioConfig(serverConf) ? serverConf.command : serverKey); - console.log(`Skipping inactive server during update: ${serverName}`); + const serverName = serverKey; + logger.log(`Skipping inactive server during update: ${serverName}`); } } } + currentActiveServersConfig = activeServersConfigLocal; // Update module-level variable - const newClientKeys = new Set(Object.keys(activeServersConfig)); + const newClientKeys = new Set(Object.keys(activeServersConfigLocal)); const currentClientKeys = new Set(currentConnectedClients.map(c => c.name)); const clientsToRemove = currentConnectedClients.filter(c => !newClientKeys.has(c.name)); const clientsToKeep = currentConnectedClients.filter(c => newClientKeys.has(c.name)); - const keysToAdd = Object.keys(activeServersConfig).filter(key => !currentClientKeys.has(key)); + const keysToAdd = Object.keys(activeServersConfigLocal).filter(key => !currentClientKeys.has(key)); - console.log(`Clients to remove: ${clientsToRemove.map(c => c.name).join(', ') || 'None'}`); - console.log(`Clients to keep: ${clientsToKeep.map(c => c.name).join(', ') || 'None'}`); - console.log(`Server keys to add: ${keysToAdd.join(', ') || 'None'}`); + logger.log(`Clients to remove: ${clientsToRemove.map(c => c.name).join(', ') || 'None'}`); + logger.log(`Clients to keep: ${clientsToKeep.map(c => c.name).join(', ') || 'None'}`); + logger.log(`Server keys to add: ${keysToAdd.join(', ') || 'None'}`); // 1. Cleanup removed clients if (clientsToRemove.length > 0) { - console.log(`Cleaning up ${clientsToRemove.length} removed clients...`); + logger.log(`Cleaning up ${clientsToRemove.length} removed clients...`); await Promise.all(clientsToRemove.map(async ({ name, cleanup }) => { try { await cleanup(); - console.log(` Cleaned up client: ${name}`); - } catch (error) { - console.error(` Error cleaning up client ${name}:`, error); + logger.log(` Cleaned up client: ${name}`); + } catch (error: any) { + logger.error(` Error cleaning up client ${name}: ${error.message}`); } })); } @@ -79,18 +107,18 @@ export const updateBackendConnections = async (newServerConfig: Config, newToolC let newlyConnectedClients: ConnectedClient[] = []; if (keysToAdd.length > 0) { const configToAdd: Record = {}; - keysToAdd.forEach(key => { configToAdd[key] = activeServersConfig[key]; }); - console.log(`Connecting ${keysToAdd.length} new clients...`); + keysToAdd.forEach(key => { configToAdd[key] = activeServersConfigLocal[key]; }); + logger.log(`Connecting ${keysToAdd.length} new clients...`); newlyConnectedClients = await createClients(configToAdd); - console.log(`Successfully connected to ${newlyConnectedClients.length} out of ${keysToAdd.length} new clients.`); + logger.log(`Successfully connected to ${newlyConnectedClients.length} out of ${keysToAdd.length} new clients.`); } // 3. Update the main list currentConnectedClients = [...clientsToKeep, ...newlyConnectedClients]; - console.log(`Total active clients after update: ${currentConnectedClients.length}`); + logger.log(`Total active clients after update: ${currentConnectedClients.length}`); // 4. Clear and repopulate maps immediately (important for consistency) - console.log("Clearing and repopulating internal maps (tools, resources, prompts)..."); + logger.log("Clearing and repopulating internal maps (tools, resources, prompts)..."); toolToClientMap.clear(); resourceToClientMap.clear(); promptToClientMap.clear(); @@ -101,7 +129,7 @@ export const updateBackendConnections = async (newServerConfig: Config, newToolC const result = await connectedClient.client.request({ method: 'tools/list', params: {} }, ListToolsResultSchema); if (result.tools && result.tools.length > 0) { for (const tool of result.tools) { - const qualifiedName = `${connectedClient.name}--${tool.name}`; // Changed separator to -- + const qualifiedName = `${connectedClient.name}${currentSeparator}${tool.name}`; // Use the current separator const toolSettings = currentToolConfig.tools[qualifiedName]; const isEnabled = !toolSettings || toolSettings.enabled !== false; if (isEnabled) { @@ -112,11 +140,11 @@ export const updateBackendConnections = async (newServerConfig: Config, newToolC } } catch (error: any) { if (!(error?.name === 'McpError' && error?.code === -32601)) { // Ignore 'Method not found' - console.error(`Error fetching tools from ${connectedClient.name} during map update:`, error?.message || error); + logger.error(`Error fetching tools from ${connectedClient.name} during map update:`, error?.message || error); } } } - console.log(` Updated tool map with ${toolToClientMap.size} enabled tools.`); + logger.log(` Updated tool map with ${toolToClientMap.size} enabled tools.`); // Repopulate Resources Map for (const connectedClient of currentConnectedClients) { @@ -127,11 +155,11 @@ export const updateBackendConnections = async (newServerConfig: Config, newToolC } } catch (error: any) { if (!(error?.name === 'McpError' && error?.code === -32601)) { // Ignore 'Method not found' - console.error(`Error fetching resources from ${connectedClient.name} during map update:`, error?.message || error); + logger.error(`Error fetching resources from ${connectedClient.name} during map update:`, error?.message || error); } } } - console.log(` Updated resource map with ${resourceToClientMap.size} resources.`); + logger.log(` Updated resource map with ${resourceToClientMap.size} resources.`); // Repopulate Prompts Map for (const connectedClient of currentConnectedClients) { @@ -142,14 +170,140 @@ export const updateBackendConnections = async (newServerConfig: Config, newToolC } } catch (error: any) { if (!(error?.name === 'McpError' && error?.code === -32601)) { // Ignore 'Method not found' - console.error(`Error fetching prompts from ${connectedClient.name} during map update:`, error?.message || error); + logger.error(`Error fetching prompts from ${connectedClient.name} during map update:`, error?.message || error); } } } - console.log(` Updated prompt map with ${promptToClientMap.size} prompts.`); - console.log("Backend connections update finished."); + logger.log(` Updated prompt map with ${promptToClientMap.size} prompts.`); + logger.log("Backend connections update finished."); }; +async function refreshBackendConnection(serverKey: string, serverConfig: TransportConfig): Promise { + logger.log(`Attempting to refresh backend connection for server: ${serverKey}`); + const existingClientIndex = currentConnectedClients.findIndex(c => c.name === serverKey); + let oldCleanup: (() => Promise) | undefined = undefined; + let existingConfig: TransportConfig | undefined = currentConnectedClients[existingClientIndex]?.config; + + if (existingClientIndex !== -1 && currentConnectedClients[existingClientIndex]) { + oldCleanup = currentConnectedClients[existingClientIndex].cleanup; + existingConfig = currentConnectedClients[existingClientIndex].config; + } else { + // Fallback to currentActiveServersConfig if not found in currentConnectedClients (should be rare for refresh) + existingConfig = currentActiveServersConfig[serverKey]; + } + + if (!existingConfig) { + logger.error(`Configuration for server ${serverKey} not found. Cannot refresh.`); + return false; + } + // Use the passed serverConfig if available (e.g. from initial load), otherwise fallback to existingConfig. + // The `serverConfig` parameter in refreshBackendConnection might be more up-to-date if called during a config reload. + const configToUse = serverConfig || existingConfig; + + + try { + // reconnectSingleClient returns Omit + const reconnectedClientParts = await reconnectSingleClient(serverKey, configToUse, oldCleanup); + + const newConnectedClientEntry: ConnectedClient = { + ...reconnectedClientParts, // Spread the parts (client, cleanup, config, transportType) + name: serverKey, // Add the name back + }; + + if (existingClientIndex !== -1) { + currentConnectedClients[existingClientIndex] = newConnectedClientEntry; + logger.log(`Updated existing client entry for ${serverKey} in currentConnectedClients.`); + } else { + currentConnectedClients.push(newConnectedClientEntry); + logger.log(`Added new client entry for ${serverKey} to currentConnectedClients (this path might be taken if client was previously removed due to error).`); + } + + // Clear existing entries for this client + for (const [key, value] of toolToClientMap.entries()) { + if (value.client.name === serverKey) { + toolToClientMap.delete(key); + } + } + for (const [key, value] of resourceToClientMap.entries()) { + // Assuming value is ConnectedClient, so value.name is the server key + if (value.name === serverKey) { + resourceToClientMap.delete(key); + } + } + for (const [key, value] of promptToClientMap.entries()) { + // Assuming value is ConnectedClient, so value.name is the server key + if (value.name === serverKey) { + promptToClientMap.delete(key); + } + } + logger.log(`Cleared map entries for ${serverKey}.`); + + // Repopulate maps for the reconnected client + const connectedClient = newConnectedClientEntry; + try { + const result = await connectedClient.client.request({ method: 'tools/list', params: {} }, ListToolsResultSchema); + if (result.tools && result.tools.length > 0) { + for (const tool of result.tools) { + const qualifiedName = `${connectedClient.name}${currentSeparator}${tool.name}`; // Use the current separator + const toolSettings = currentToolConfig.tools[qualifiedName]; + const isEnabled = !toolSettings || toolSettings.enabled !== false; + if (isEnabled) { + toolToClientMap.set(qualifiedName, { client: connectedClient, toolInfo: tool }); + } + } + } + } catch (error: any) { + if (!(error?.name === 'McpError' && error?.code === -32601)) { + logger.error(`Error fetching tools from ${connectedClient.name} during refresh:`, error?.message || error); + } + } + + try { + const result = await connectedClient.client.request({ method: 'resources/list', params: {} }, ListResourcesResultSchema); + if (result.resources) { + result.resources.forEach(resource => resourceToClientMap.set(resource.uri, connectedClient)); + } + } catch (error: any) { + if (!(error?.name === 'McpError' && error?.code === -32601)) { + logger.error(`Error fetching resources from ${connectedClient.name} during refresh:`, error?.message || error); + } + } + + try { + const result = await connectedClient.client.request({ method: 'prompts/list', params: {} }, ListPromptsResultSchema); + if (result.prompts) { + result.prompts.forEach(prompt => promptToClientMap.set(prompt.name, connectedClient)); + } + } catch (error: any) { + if (!(error?.name === 'McpError' && error?.code === -32601)) { + logger.error(`Error fetching prompts from ${connectedClient.name} during refresh:`, error?.message || error); + } + } + logger.log(`Repopulated maps for ${serverKey}.`); + return true; + + } catch (error: any) { + logger.error(`Failed to refresh backend connection for ${serverKey}: ${error.message}`); + // If refresh failed, we remove the client to prevent further attempts with a known bad state. + // This also cleans up its entries from the maps. + if (existingClientIndex !== -1) { + currentConnectedClients.splice(existingClientIndex, 1); + } + // Clear any potentially lingering map entries if refresh failed mid-way + for (const [key, value] of toolToClientMap.entries()) { + if (value.client.name === serverKey) toolToClientMap.delete(key); + } + for (const [key, value] of resourceToClientMap.entries()) { + if (value.name === serverKey) resourceToClientMap.delete(key); + } + for (const [key, value] of promptToClientMap.entries()) { + if (value.name === serverKey) promptToClientMap.delete(key); + } + logger.log(`Removed client ${serverKey} and its map entries after failed refresh.`); + return false; + } +} + // --- Function to get current proxy state --- export const getCurrentProxyState = () => { // Return copies or relevant info to avoid direct mutation @@ -167,19 +321,59 @@ export const getCurrentProxyState = () => { }; }); // Could add resources and prompts here if needed by admin UI later - return { tools }; + // Also return the current separator for the frontend + return { tools, serverToolnameSeparator: currentSeparator }; }; +// Helper function to identify connection errors +const isConnectionError = (err: any): boolean => { + if (err && err.message) { + const lowerMessage = err.message.toLowerCase(); + return lowerMessage.includes("disconnected") || + lowerMessage.includes("not connected") || + lowerMessage.includes("connection closed") || + lowerMessage.includes("transport is closed") || // SDK specific + lowerMessage.includes("failed to fetch") || + lowerMessage.includes("not found") || //Error POSTING session not found + lowerMessage.includes("404") || + lowerMessage.includes("eof") || // Network level + lowerMessage.includes("tls") || // TLS handshake + lowerMessage.includes("timeout") || + lowerMessage.includes("timed out"); + } + return false; +}; // --- Server Creation --- export const createServer = async () => { // Load initial config - const initialServerConfig = await loadConfig(); + const initialServerConfig = await loadConfig(); // This now includes proxy settings const initialToolConfig = await loadToolConfig(); + // Initialize currentActiveServersConfig AND currentProxyConfig from the initial load + const initialActiveServers: Record = {}; + for (const serverKey in initialServerConfig.mcpServers) { + if (Object.prototype.hasOwnProperty.call(initialServerConfig.mcpServers, serverKey)) { + const serverConf = initialServerConfig.mcpServers[serverKey]; + const isActive = !(serverConf.active === false || String(serverConf.active).toLowerCase() === 'false'); + if (isActive) { + initialActiveServers[serverKey] = serverConf; + } + } + } + currentActiveServersConfig = initialActiveServers; + // Update currentProxyConfig using initialServerConfig and global defaults + currentProxyConfig = { + ...defaultProxySettingsFull, + ...(initialServerConfig.proxy || {}), + }; + + // Perform initial connection and map population await updateBackendConnections(initialServerConfig, initialToolConfig); + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); // Define sleep + // Create the main proxy server instance const server = new Server( { @@ -200,7 +394,7 @@ export const createServer = async () => { // Note: InitializeRequest is handled by the SDK's Server default behavior. server.setRequestHandler(ListToolsRequestSchema, async (request) => { - console.log("Received tools/list request - applying overrides from config"); + logger.log("Received tools/list request - applying overrides from config"); const enabledTools: Tool[] = []; // Access the globally stored tool config which includes overrides const toolOverrides = currentToolConfig.tools || {}; @@ -220,7 +414,7 @@ export const createServer = async () => { inputSchema: toolInfo.inputSchema, // Schema is never overridden }); } - console.log(`Returning ${enabledTools.length} enabled tools with applied overrides.`); + logger.log(`Returning ${enabledTools.length} enabled tools with applied overrides.`); return { tools: enabledTools }; }); @@ -247,37 +441,121 @@ export const createServer = async () => { // If no entry was found after checking all enabled tools and their potential overrides if (!mapEntry || !originalQualifiedName) { - console.error(`Attempted to call tool with exposed name "${requestedExposedName}", but no corresponding enabled tool or override configuration found.`); - throw new Error(`Unknown or disabled tool: ${requestedExposedName}`); + const errorMessage = `Attempted to call tool with exposed name "${requestedExposedName}", but no corresponding enabled tool or override configuration found.`; + logger.error(errorMessage); + throw new McpError(-32601, errorMessage); // Method not found error code } // Now we have the correct mapEntry and the originalQualifiedName - const { client: clientForTool, toolInfo } = mapEntry; // toolInfo here is the correct one from the found mapEntry + let { client: clientForTool, toolInfo } = mapEntry; // toolInfo here is the correct one from the found mapEntry const originalToolNameForBackend = toolInfo.name; // The actual name the backend server expects (from the original toolInfo) - try { - // Log using the exposed name and the original name for clarity - console.log(`Received tool call for exposed name '${requestedExposedName}' (original qualified name: '${originalQualifiedName}'). Forwarding to server '${clientForTool.name}' as tool '${originalToolNameForBackend}'`); + // --- Retry Logic --- + // Use HTTP retry settings for SSE as a fallback for retry count and delay + const maxRetries = clientForTool.transportType === 'sse' ? (currentProxyConfig.retrySseToolCall ? currentProxyConfig.sseToolCallMaxRetries : 0) : // Use SSE specific max retries, check retrySseToolCall + clientForTool.transportType === 'stdio' ? (currentProxyConfig.retryStdioToolCall ? currentProxyConfig.stdioToolCallMaxRetries : 0) : + clientForTool.transportType === 'http' ? (currentProxyConfig.retryHttpToolCall ? currentProxyConfig.httpToolCallMaxRetries : 0) : 0; + const retryDelayBaseMs = clientForTool.transportType === 'sse' ? currentProxyConfig.sseToolCallRetryDelayBaseMs : // Use SSE specific retry delay + clientForTool.transportType === 'stdio' ? (currentProxyConfig.retryStdioToolCall ? currentProxyConfig.stdioToolCallRetryDelayBaseMs : 0) : // Added check for stdio retry enabled + clientForTool.transportType === 'http' ? (currentProxyConfig.retryHttpToolCall ? currentProxyConfig.httpToolCallRetryDelayBaseMs : 0) : 0; // Added check for http retry enabled + + let lastError: any = null; + + // Loop includes the initial attempt (attempt 0) plus maxRetries + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + const delay = retryDelayBaseMs * Math.pow(2, attempt - 1) + (Math.random() * retryDelayBaseMs * 0.5); + logger.log(`Tool call failed for '${requestedExposedName}'. Attempt ${attempt}/${maxRetries}. Retrying in ${delay.toFixed(0)}ms...`); + await sleep(delay); + + // For SSE, attempt reconnect before retrying the call if the last error was a connection error + if (clientForTool.transportType === 'sse' && isConnectionError(lastError)) { + logger.log(`SSE connection error for tool '${requestedExposedName}' on server '${clientForTool.name}'. Attempting reconnect before retry.`); + const clientTransportConfig = currentActiveServersConfig[clientForTool.name]; + if (!clientTransportConfig) { + logger.error(`Cannot retry SSE: TransportConfig for server '${clientForTool.name}' not found.`); + // If config is missing, we can't reconnect, so break retry loop + break; + } + const refreshed = await refreshBackendConnection(clientForTool.name, clientTransportConfig); + if (refreshed) { + logger.log(`Successfully reconnected to server '${clientForTool.name}' via SSE.`); + // Update clientForTool and toolInfo references after refresh + const newMapEntry = toolToClientMap.get(originalQualifiedName); + if (!newMapEntry) { + logger.error(`Tool '${originalQualifiedName}' not found in map after successful SSE refresh for server '${clientForTool.name}'.`); + // If tool disappears after refresh, something is wrong, break retry loop + break; + } + clientForTool = newMapEntry.client; + toolInfo = newMapEntry.toolInfo; + } else { + logger.error(`SSE Reconnection to server '${clientForTool.name}' failed.`); + // If reconnect fails, throw an error to exit the retry loop and propagate + throw new McpError(-32000, `SSE Reconnection to server '${clientForTool.name}' failed for tool '${requestedExposedName}'.`); + } + } + } - // Access the actual MCP client via clientForTool.client - return await clientForTool.client.request( - { - method: 'tools/call', - params: { - name: originalToolNameForBackend, // Send the original tool name (from toolInfo.name) to the backend - arguments: args || {}, - _meta: { - progressToken: request.params._meta?.progressToken + try { + logger.log(`Forwarding tool call for exposed name '${requestedExposedName}' (original qualified name: '${originalQualifiedName}'). Forwarding to server '${clientForTool.name}' as tool '${originalToolNameForBackend}' (Attempt ${attempt + 1})`); + // Explicitly set a timeout for the request using SDK's RequestOptions + const backendResponse = await clientForTool.client.request( + { + method: 'tools/call', + params: { name: originalToolNameForBackend, arguments: args || {}, _meta: { progressToken: request.params._meta?.progressToken } } + }, + CompatibilityCallToolResultSchema, + { timeout: DEFAULT_REQUEST_TIMEOUT_MSEC } // Set timeout explicitly + ); + logger.log(`[Tool Call] Backend response received for '${requestedExposedName}'. Passing to SDK Server.`); + return backendResponse; // Success! Return the response. + } catch (error: any) { + lastError = error; + logger.warn(`Attempt ${attempt + 1} to call tool '${requestedExposedName}' failed: ${error.message}`); + + // Check if this error warrants a retry based on type and configuration + const isRetryableError = isConnectionError(error) || (error?.name === 'McpError' && error?.code === -32001); // Consider timeout as retryable + const shouldRetry = (clientForTool.transportType === 'sse' && currentProxyConfig.retrySseToolCall && isRetryableError) || // Check retrySseToolCall + (clientForTool.transportType === 'stdio' && currentProxyConfig.retryStdioToolCall && isRetryableError) || + (clientForTool.transportType === 'http' && currentProxyConfig.retryHttpToolCall && isRetryableError); + + + if (!shouldRetry && attempt === 0) { + // If it's the first attempt and not a retryable error type, re-throw immediately + logger.error(`Tool call for '${requestedExposedName}' failed with non-retryable error on first attempt: ${error.message}`, error); + // If the error is already an McpError, re-throw it directly. Otherwise, wrap it. + if (error instanceof McpError) { + throw error; + } else { + throw new McpError(error?.code || -32000, error.message || 'An unknown error occurred', error?.data); + } } - } - }, - CompatibilityCallToolResultSchema - ); - } catch (error) { - console.error(`Error calling tool through ${clientForTool.name}:`, error); // Access name via clientForTool - throw error; + + if (!shouldRetry && attempt > 0) { + // If it's a subsequent attempt and the error is no longer retryable (e.g., backend returned a specific error after reconnect) + logger.error(`Tool call for '${requestedExposedName}' failed with non-retryable error after retries: ${error.message}`, error); + // If the error is already an McpError, re-throw it directly. Otherwise, wrap it. + if (error instanceof McpError) { + throw error; + } else { + throw new McpError(error?.code || -32000, error.message || 'An unknown error occurred', error?.data); + } + } + + // If it's a retryable error and we are within maxRetries, the loop continues. + // If it's a retryable error but we are at maxRetries, the loop will exit after this iteration. + } } - }); + + // If the loop finishes without returning, it means all retries failed. + const errorMessage = `Error calling tool '${requestedExposedName}' after ${maxRetries} retries (on backend server '${clientForTool.name}', original tool name '${originalToolNameForBackend}'): ${lastError?.message || 'An unknown error occurred'}`; + logger.error(errorMessage, lastError); + // Ensure a structured McpError is returned to the client + throw new McpError(lastError?.code || -32000, errorMessage, lastError?.data); +}); + +// ... rest of the file ... server.setRequestHandler(GetPromptRequestSchema, async (request) => { const { name } = request.params; @@ -288,7 +566,7 @@ export const createServer = async () => { } try { - console.log('Forwarding prompt request:', name); + logger.log('Forwarding prompt request:', name); const response = await clientForPrompt.client.request( { @@ -304,16 +582,17 @@ export const createServer = async () => { GetPromptResultSchema ); - console.log('Prompt result:', response); + logger.log('Prompt result:', response); return response; - } catch (error) { - console.error(`Error getting prompt from ${clientForPrompt.name}:`, error); - throw error; + } catch (error: any) { + const errorMessage = `Error getting prompt '${name}' from backend server '${clientForPrompt.name}': ${error.message || 'An unknown error occurred'}`; + logger.error(errorMessage, error); + throw new Error(errorMessage); } }); server.setRequestHandler(ListPromptsRequestSchema, async (request) => { - console.log("Received prompts/list request - returning from cached map"); + logger.log("Received prompts/list request - returning from cached map"); // Directly use the pre-populated map const allPrompts: z.infer['prompts'] = []; for (const [name, connectedClient] of promptToClientMap.entries()) { @@ -323,16 +602,16 @@ export const createServer = async () => { description: `[${connectedClient.name}] Prompt (details omitted in list)`, inputSchema: {}, }); - } - console.log(`Returning ${allPrompts.length} prompts from map.`); - return { - prompts: allPrompts, + } + logger.log(`Returning ${allPrompts.length} prompts from map.`); + return { + prompts: allPrompts, nextCursor: undefined // Caching doesn't support pagination easily here }; }); server.setRequestHandler(ListResourcesRequestSchema, async (request) => { - console.log("Received resources/list request - returning from cached map"); + logger.log("Received resources/list request - returning from cached map"); const allResources: z.infer['resources'] = []; for (const [uri, connectedClient] of resourceToClientMap.entries()) { // Simplified response @@ -343,7 +622,7 @@ export const createServer = async () => { methods: [], // Cannot know methods without asking client }); } - console.log(`Returning ${allResources.length} resources from map.`); + logger.log(`Returning ${allResources.length} resources from map.`); return { resources: allResources, nextCursor: undefined // Caching doesn't support pagination easily here @@ -370,9 +649,10 @@ export const createServer = async () => { }, ReadResourceResultSchema ); - } catch (error) { - console.error(`Error reading resource from ${clientForResource.name}:`, error); - throw error; + } catch (error: any) { + const errorMessage = `Error reading resource '${uri}' from backend server '${clientForResource.name}': ${error.message || 'An unknown error occurred'}`; + logger.error(errorMessage, error); + throw new Error(errorMessage); } }); @@ -408,9 +688,15 @@ export const createServer = async () => { const isMethodNotFoundError = error?.name === 'McpError' && error?.code === -32601; if (isMethodNotFoundError) { - console.warn(`Warning: Method 'resources/templates/list' not found on server ${connectedClient.name}. Proceeding without templates from this source.`); + logger.warn(`Warning: Method 'resources/templates/list' not found on server ${connectedClient.name}. Proceeding without templates from this source.`); } else { - console.error(`Error fetching resource templates from ${connectedClient.name}:`, error?.message || error); + // Standardize error propagation for other errors + const errorMessage = `Error fetching resource templates from backend server '${connectedClient.name}': ${error.message || 'An unknown error occurred'}`; + logger.error(errorMessage, error); // Log the detailed error + // We are in a loop, so we might not want to throw and stop the whole process. + // Instead, we log the error and continue to try fetching from other clients. + // If we needed to inform the client that partial data occurred, we'd need a different strategy. + // For now, just logging and continuing. If *all* sources fail, the client gets an empty list. } } } @@ -423,13 +709,13 @@ export const createServer = async () => { // Cleanup function needs to handle the *current* list of clients const cleanup = async () => { - console.log(`Cleaning up ${currentConnectedClients.length} connected clients...`); + logger.log(`Cleaning up ${currentConnectedClients.length} connected clients...`); await Promise.all(currentConnectedClients.map(async ({ name, cleanup: clientCleanup }) => { try { await clientCleanup(); - console.log(` Cleaned up client: ${name}`); - } catch(error) { - console.error(` Error cleaning up client ${name}:`, error); + logger.log(` Cleaned up client: ${name}`); + } catch(error: any) { + logger.error(` Error cleaning up client ${name}: ${error.message}`); } })); currentConnectedClients = []; // Clear the list after cleanup diff --git a/src/sse.ts b/src/sse.ts index e1508bf..22b7307 100644 --- a/src/sse.ts +++ b/src/sse.ts @@ -16,6 +16,7 @@ import { fileURLToPath } from 'url'; import { Tool, ListToolsResultSchema, JSONRPCMessage, JSONRPCError } from "@modelcontextprotocol/sdk/types.js"; // Import loadToolConfig as well import { Config, loadConfig, isStdioConfig, loadToolConfig } from './config.js'; +import { logger } from './logger.js'; // Import terminal router and related types/variables for shutdown import { terminalRouter, activeTerminals, TERMINAL_OUTPUT_SSE_CONNECTIONS, ActiveTerminal } from './terminal.js'; @@ -54,14 +55,14 @@ const allowedTokensRaw = process.env.ALLOWED_TOKENS || ""; // Renamed const allowedTokens = new Set(allowedTokensRaw.split(',').map(t => t.trim()).filter(t => t.length > 0)); const authEnabled = allowedKeys.size > 0 || allowedTokens.size > 0; -console.log(`MCP Endpoint Authentication: ${authEnabled ? `Enabled. ${allowedKeys.size} key(s) and ${allowedTokens.size} token(s) configured.` : 'Disabled.'}`); +logger.log(`MCP Endpoint Authentication: ${authEnabled ? `Enabled. ${allowedKeys.size} key(s) and ${allowedTokens.size} token(s) configured.` : 'Disabled.'}`); const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin'; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'password'; const SESSION_SECRET_ENV = process.env.SESSION_SECRET; // Read from env if (ADMIN_PASSWORD === 'password') { - console.warn("WARNING: Using default admin password. Set ADMIN_PASSWORD environment variable for security."); + logger.warn("WARNING: Using default admin password. Set ADMIN_PASSWORD environment variable for security."); } // SESSION_SECRET warning is handled in getSessionSecret @@ -73,7 +74,7 @@ const enableAdminUI = typeof rawEnableAdminUI === 'string' && (rawEnableAdminUI. async function getSessionSecret(): Promise { if (SESSION_SECRET_ENV && SESSION_SECRET_ENV !== 'unsafe-default-secret' && SESSION_SECRET_ENV.trim() !== '') { - console.log("Using session secret from SESSION_SECRET environment variable."); + logger.log("Using session secret from SESSION_SECRET environment variable."); return SESSION_SECRET_ENV; } @@ -81,18 +82,18 @@ async function getSessionSecret(): Promise { await access(SECRET_FILE_PATH); const secretFromFile = await readFile(SECRET_FILE_PATH, 'utf-8'); if (secretFromFile.trim() !== '') { - console.log("Read existing session secret from file."); + logger.log("Read existing session secret from file."); return secretFromFile.trim(); } // If file exists but is empty, proceed to generate a new one. - console.log("Session secret file exists but is empty. Generating a new one..."); + logger.log("Session secret file exists but is empty. Generating a new one..."); } catch (error: any) { if (error.code !== 'ENOENT') { - console.error("Error accessing session secret file, attempting to generate new:", error); + logger.error("Error accessing session secret file, attempting to generate new:", error); // Proceed to generate new one if access failed for other reasons than not found } else { // File does not exist, normal path to generate new. - console.log("Session secret file not found. Generating a new one..."); + logger.log("Session secret file not found. Generating a new one..."); } } @@ -101,11 +102,11 @@ async function getSessionSecret(): Promise { try { await mkdir(path.dirname(SECRET_FILE_PATH), { recursive: true }); await writeFile(SECRET_FILE_PATH, newSecret, { encoding: 'utf-8', mode: 0o600 }); - console.log(`New session secret generated and saved to ${SECRET_FILE_PATH}. It's recommended to set this value in the SESSION_SECRET environment variable for persistence across container restarts or deployments.`); + logger.log(`New session secret generated and saved to ${SECRET_FILE_PATH}. It's recommended to set this value in the SESSION_SECRET environment variable for persistence across container restarts or deployments.`); return newSecret; } catch (writeError) { - console.error("FATAL: Could not write new session secret file:", writeError); - console.warn("WARNING: Falling back to a temporary, insecure session secret. Admin UI sessions will not persist."); + logger.error("FATAL: Could not write new session secret file:", writeError); + logger.warn("WARNING: Falling back to a temporary, insecure session secret. Admin UI sessions will not persist."); return 'temporary-insecure-secret-' + crypto.randomBytes(16).toString('hex'); // Fallback, but not ideal } } @@ -115,11 +116,11 @@ async function getSessionSecret(): Promise { const adminSseConnections = new Map(); if (enableAdminUI) { - console.log("Admin UI is ENABLED."); + logger.log("Admin UI is ENABLED."); // Use global ADMIN_USERNAME and ADMIN_PASSWORD defined earlier. if (ADMIN_PASSWORD === 'password') { // Use global ADMIN_PASSWORD - console.warn("WARNING: Using default admin password. Set ADMIN_USERNAME and ADMIN_PASSWORD environment variables for security."); + logger.warn("WARNING: Using default admin password. Set ADMIN_USERNAME and ADMIN_PASSWORD environment variables for security."); } const sessionSecret = await getSessionSecret(); @@ -148,10 +149,10 @@ if (enableAdminUI) { const { username, password } = req.body; if (username === ADMIN_USERNAME && password === ADMIN_PASSWORD) { req.session.user = { username: username }; - console.log(`Admin user '${username}' logged in.`); + logger.log(`Admin user '${username}' logged in.`); res.json({ success: true }); } else { - console.warn(`Failed admin login attempt for username: '${username}'`); + logger.warn(`Failed admin login attempt for username: '${username}'`); res.status(401).json({ success: false, error: 'Invalid credentials' }); } }); @@ -160,10 +161,10 @@ if (enableAdminUI) { const username = req.session.user?.username; req.session.destroy((err) => { if (err) { - console.error("Error destroying session:", err); + logger.error("Error destroying session:", err); return res.status(500).json({ success: false, error: 'Failed to logout' }); } - console.log(`Admin user '${username}' logged out.`); + logger.log(`Admin user '${username}' logged out.`); res.clearCookie('connect.sid'); res.json({ success: true }); }); @@ -171,12 +172,12 @@ if (enableAdminUI) { app.get('/admin/config', isAuthenticated, async (req, res) => { try { - console.log("Admin request: GET /admin/config"); + logger.log("Admin request: GET /admin/config"); const configData = await readFile(CONFIG_PATH, 'utf-8'); res.setHeader('Content-Type', 'application/json'); res.send(configData); } catch (error: any) { - console.error(`Error reading config file at ${CONFIG_PATH}:`, error); + logger.error(`Error reading config file at ${CONFIG_PATH}:`, error); if (error.code === 'ENOENT') { res.status(404).json({ error: 'Configuration file not found.' }); } else { @@ -187,7 +188,7 @@ if (enableAdminUI) { app.post('/admin/config', isAuthenticated, async (req, res) => { try { - console.log("Admin request: POST /admin/config"); + logger.log("Admin request: POST /admin/config"); const newConfigData = req.body; if (typeof newConfigData !== 'object' || newConfigData === null) { @@ -196,10 +197,10 @@ if (enableAdminUI) { const configString = JSON.stringify(newConfigData, null, 2); await writeFile(CONFIG_PATH, configString, 'utf-8'); - console.log(`Configuration file updated successfully by admin '${req.session.user?.username}'.`); + logger.log(`Configuration file updated successfully by admin '${req.session.user?.username}'.`); res.json({ success: true }); } catch (error) { - console.error(`Error writing config file at ${CONFIG_PATH}:`, error); + logger.error(`Error writing config file at ${CONFIG_PATH}:`, error); res.status(500).json({ error: 'Failed to write configuration file.' }); } }); @@ -207,31 +208,31 @@ if (enableAdminUI) { // Updated to use getCurrentProxyState app.get('/admin/tools/list', isAuthenticated, async (req, res) => { - console.log("Admin request: GET /admin/tools/list"); + logger.log("Admin request: GET /admin/tools/list"); try { // Get the current tool state from the proxy module const { tools } = getCurrentProxyState(); // The tools returned are already simplified for the UI - console.log(`Admin tools/list: Returning ${tools.length} discovered tools from proxy state.`); + logger.log(`Admin tools/list: Returning ${tools.length} discovered tools from proxy state.`); res.json({ tools }); // Return the simplified list directly } catch (error: any) { - console.error(`Admin tools/list: Error getting proxy state:`, error?.message || error); + logger.error(`Admin tools/list: Error getting proxy state:`, error?.message || error); res.status(500).json({ error: 'Failed to retrieve tool list from proxy state.' }); } }); app.get('/admin/tools/config', isAuthenticated, async (req, res) => { try { - console.log("Admin request: GET /admin/tools/config"); + logger.log("Admin request: GET /admin/tools/config"); const toolConfigData = await readFile(TOOL_CONFIG_PATH, 'utf-8'); res.setHeader('Content-Type', 'application/json'); res.send(toolConfigData); } catch (error: any) { if (error.code === 'ENOENT') { - console.log(`Tool config file ${TOOL_CONFIG_PATH} not found, returning empty config.`); + logger.log(`Tool config file ${TOOL_CONFIG_PATH} not found, returning empty config.`); res.json({ tools: {} }); } else { - console.error(`Error reading tool config file at ${TOOL_CONFIG_PATH}:`, error); + logger.error(`Error reading tool config file at ${TOOL_CONFIG_PATH}:`, error); res.status(500).json({ error: 'Failed to read tool configuration file.' }); } } @@ -239,7 +240,7 @@ if (enableAdminUI) { app.post('/admin/tools/config', isAuthenticated, async (req, res) => { try { - console.log("Admin request: POST /admin/tools/config"); + logger.log("Admin request: POST /admin/tools/config"); const newToolConfigData = req.body; if (typeof newToolConfigData !== 'object' || newToolConfigData === null || typeof newToolConfigData.tools !== 'object') { @@ -248,16 +249,16 @@ if (enableAdminUI) { const configString = JSON.stringify(newToolConfigData, null, 2); await writeFile(TOOL_CONFIG_PATH, configString, 'utf-8'); - console.log(`Tool configuration file updated successfully by admin '${req.session.user?.username}'.`); + logger.log(`Tool configuration file updated successfully by admin '${req.session.user?.username}'.`); res.json({ success: true, message: "Configuration saved. Restart proxy server to apply changes." }); } catch (error) { - console.error(`Error writing tool config file at ${TOOL_CONFIG_PATH}:`, error); + logger.error(`Error writing tool config file at ${TOOL_CONFIG_PATH}:`, error); res.status(500).json({ error: 'Failed to write tool configuration file.' }); } }); // Renamed endpoint and updated logic for in-process reload app.post('/admin/server/reload', isAuthenticated, async (req, res) => { - console.log(`Admin request: POST /admin/server/reload by user '${req.session.user?.username}'`); + logger.log(`Admin request: POST /admin/server/reload by user '${req.session.user?.username}'`); try { // Load the latest configurations const latestServerConfig = await loadConfig(); @@ -266,20 +267,28 @@ if (enableAdminUI) { // Trigger the update process in mcp-proxy await updateBackendConnections(latestServerConfig, latestToolConfig); - console.log("Configuration reload completed successfully."); + logger.log("Configuration reload completed successfully."); res.json({ success: true, message: 'Server configuration reloaded successfully.' }); } catch (error: any) { - console.error("Error during configuration reload:", error); + logger.error("Error during configuration reload:", error); res.status(500).json({ success: false, error: 'Failed to reload server configuration.', details: error.message }); } }); // New endpoint to provide environment info like TOOLS_FOLDER to the frontend - app.get('/admin/environment', isAuthenticated, (req, res) => { - res.json({ - toolsFolder: process.env.TOOLS_FOLDER || "" - }); + app.get('/admin/environment', isAuthenticated, async (req, res) => { + try { + // Load config to get the current separator + const config = await loadConfig(); + res.json({ + toolsFolder: process.env.TOOLS_FOLDER || "", + serverToolnameSeparator: config.serverToolnameSeparator // Expose the separator + }); + } catch (error: any) { + logger.error("Error fetching environment info for admin UI:", error); + res.status(500).json({ error: "Failed to fetch environment information." }); + } }); @@ -289,8 +298,8 @@ if (enableAdminUI) { const adminSessionId = req.session.id; // Get current admin's session ID const clientId = req.ip || `admin-${Date.now()}`; // For logging - console.log(`[${clientId}] Admin request: POST /admin/server/install/${serverKey} for session ${adminSessionId}`); - console.warn(`[${clientId}] SECURITY WARNING: Attempting to execute installation commands for server '${serverKey}'.`); + logger.log(`[${clientId}] Admin request: POST /admin/server/install/${serverKey} for session ${adminSessionId}`); + logger.warn(`[${clientId}] SECURITY WARNING: Attempting to execute installation commands for server '${serverKey}'.`); // Immediately respond to the HTTP request res.json({ success: true, message: `Installation process for '${serverKey}' started. Check for live updates.` }); @@ -306,10 +315,10 @@ if (enableAdminUI) { // Ensure data is stringified to handle objects and special characters adminRes.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch (e) { - console.error(`[${clientId}] Failed to send admin SSE event ${event} for session ${adminSessionId}:`, e); + logger.error(`[${clientId}] Failed to send admin SSE event ${event} for session ${adminSessionId}:`, e); } } else if (adminSessionId) { // Log warning only if we expected a connection - console.warn(`[${clientId}] No active admin SSE connection found for session ${adminSessionId} to send event ${event}.`); + logger.warn(`[${clientId}] No active admin SSE connection found for session ${adminSessionId} to send event ${event}.`); } }; @@ -318,10 +327,12 @@ if (enableAdminUI) { const serverConfig = config.mcpServers[serverKey]; if (!serverConfig) { + logger.error(`Server configuration not found for key: ${serverKey}`); sendAdminSseEvent('install_error', { serverKey, error: `Server configuration not found for key: ${serverKey}` }); return; } if (!isStdioConfig(serverConfig)) { + logger.error(`Installation commands only supported for stdio servers.`); sendAdminSseEvent('install_error', { serverKey, error: `Installation commands only supported for stdio servers.` }); return; } @@ -331,19 +342,22 @@ if (enableAdminUI) { if (installDirectory) { // 1. From mcp_server.json absoluteInstallDir = path.resolve(installDirectory); // path.resolve handles both absolute and relative (to cwd) + logger.log(`Using 'installDirectory' from config: ${absoluteInstallDir}`); sendAdminSseEvent('install_info', { serverKey, message: `Using 'installDirectory' from config: ${absoluteInstallDir}` }); } else if (process.env.TOOLS_FOLDER && process.env.TOOLS_FOLDER.trim() !== '') { // 2. From TOOLS_FOLDER env var absoluteInstallDir = path.resolve(process.env.TOOLS_FOLDER.trim(), serverKey); + logger.log(`Using 'TOOLS_FOLDER' env var ('${process.env.TOOLS_FOLDER.trim()}'). Target server directory: ${absoluteInstallDir}`); sendAdminSseEvent('install_info', { serverKey, message: `Using 'TOOLS_FOLDER' env var ('${process.env.TOOLS_FOLDER.trim()}'). Target server directory: ${absoluteInstallDir}` }); } else { // 3. Default to a 'tools' subfolder in the project's current working directory absoluteInstallDir = path.resolve(process.cwd(), 'tools', serverKey); + logger.log(`No 'installDirectory' in config or 'TOOLS_FOLDER' env var. Defaulting to project's 'tools' subfolder. Target server directory: ${absoluteInstallDir}`); sendAdminSseEvent('install_info', { serverKey, message: `No 'installDirectory' in config or 'TOOLS_FOLDER' env var. Defaulting to project's 'tools' subfolder. Target server directory: ${absoluteInstallDir}` }); } // Commands should be executed in the parent directory of the server's specific folder const executionCwd = path.dirname(absoluteInstallDir); - console.log(`[${clientId}] Target server installation directory for ${serverKey}: ${absoluteInstallDir}`); - console.log(`[${clientId}] Execution CWD for install commands of ${serverKey}: ${executionCwd}`); + logger.log(`[${clientId}] Target server installation directory for ${serverKey}: ${absoluteInstallDir}`); + logger.log(`[${clientId}] Execution CWD for install commands of ${serverKey}: ${executionCwd}`); sendAdminSseEvent('install_info', { serverKey, message: `Install commands will be executed in: ${executionCwd}` }); // Ensure executionCwd (parent directory for installation) exists @@ -351,6 +365,7 @@ if (enableAdminUI) { await mkdir(executionCwd, { recursive: true }); sendAdminSseEvent('install_info', { serverKey, message: `Ensured execution directory exists: ${executionCwd}` }); } catch (mkdirError: any) { + logger.error(`Failed to create execution directory '${executionCwd}': ${mkdirError.message}`); sendAdminSseEvent('install_error', { serverKey, error: `Failed to create execution directory '${executionCwd}': ${mkdirError.message}` }); throw mkdirError; } @@ -358,22 +373,27 @@ if (enableAdminUI) { // 1. Check if the specific server directory (absoluteInstallDir) already exists try { await access(absoluteInstallDir); + logger.log(`Target server directory '${absoluteInstallDir}' already exists. Installation skipped.`); sendAdminSseEvent('install_info', { serverKey, message: `Target server directory '${absoluteInstallDir}' already exists. Installation skipped.` }); sendAdminSseEvent('install_complete', { serverKey, code: 0, message: "Already installed." }); return; // Stop if already installed } catch (error: any) { if (error.code !== 'ENOENT') { + logger.error(`Error checking target server directory '${absoluteInstallDir}': ${error.message}`); sendAdminSseEvent('install_error', { serverKey, error: `Error checking target server directory '${absoluteInstallDir}': ${error.message}` }); throw error; // Rethrow unexpected errors } + logger.log(`Target server directory '${absoluteInstallDir}' does not exist. Proceeding with installation commands...`); sendAdminSseEvent('install_info', { serverKey, message: `Target server directory '${absoluteInstallDir}' does not exist. Proceeding with installation commands...` }); } // 2. Execute install commands using spawn for live output const commandsToRun = installCommands && Array.isArray(installCommands) ? installCommands : []; if (commandsToRun.length > 0) { + logger.log(`Executing ${commandsToRun.length} installation command(s) in ${executionCwd}...`); sendAdminSseEvent('install_info', { serverKey, message: `Executing ${commandsToRun.length} installation command(s) in ${executionCwd}...` }); for (const command of commandsToRun) { + logger.log(`Executing: ${command}`); sendAdminSseEvent('install_info', { serverKey, message: `Executing: ${command}` }); const commandParts = command.split(' '); @@ -389,14 +409,14 @@ if (enableAdminUI) { // Stream stdout child.stdout.on('data', (data) => { const output = data.toString(); - console.log(`[${clientId}] Install stdout (${serverKey}): ${output.trim()}`); + logger.log(`[${clientId}] Install stdout (${serverKey}): ${output.trim()}`); sendAdminSseEvent('install_stdout', { serverKey, output }); }); // Stream stderr child.stderr.on('data', (data) => { const output = data.toString(); - console.error(`[${clientId}] Install stderr (${serverKey}): ${output.trim()}`); + logger.error(`[${clientId}] Install stderr (${serverKey}): ${output.trim()}`); sendAdminSseEvent('install_stderr', { serverKey, output }); }); @@ -404,7 +424,7 @@ if (enableAdminUI) { const exitCode = await new Promise((resolve, reject) => { child.on('close', resolve); child.on('error', (err) => { - console.error(`[${clientId}] Failed to start command "${command}":`, err); + logger.error(`[${clientId}] Failed to start command "${command}":`, err); reject(err); }); }); @@ -414,10 +434,13 @@ if (enableAdminUI) { sendAdminSseEvent('install_error', { serverKey, error: errorMsg, command, exitCode }); throw new Error(errorMsg); } + logger.log(`Command "${command}" completed successfully.`); sendAdminSseEvent('install_info', { serverKey, message: `Command "${command}" completed successfully.` }); } + logger.log(`All installation commands executed successfully.`); sendAdminSseEvent('install_info', { serverKey, message: `All installation commands executed successfully.` }); } else { + logger.log(`No installation commands provided.`); sendAdminSseEvent('install_info', { serverKey, message: `No installation commands provided.` }); } @@ -425,13 +448,17 @@ if (enableAdminUI) { // This is important if installCommands were supposed to create it (e.g., git clone serverKey). try { await access(absoluteInstallDir); + logger.log(`Confirmed target server directory exists: ${absoluteInstallDir}`); sendAdminSseEvent('install_info', { serverKey, message: `Confirmed target server directory exists: ${absoluteInstallDir}` }); } catch (error: any) { if (error.code === 'ENOENT') { // If it still doesn't exist (e.g. no commands, or commands didn't create it) + logger.log(`Target server directory ${absoluteInstallDir} not found after commands. If commands were expected to create it, check them. Creating directory now.`); sendAdminSseEvent('install_info', { serverKey, message: `Target server directory ${absoluteInstallDir} not found after commands. If commands were expected to create it, check them. Creating directory now.` }); await mkdir(absoluteInstallDir, { recursive: true }); // Create it as a fallback. + logger.log(`Successfully created target server directory ${absoluteInstallDir}.`); sendAdminSseEvent('install_info', { serverKey, message: `Successfully created target server directory ${absoluteInstallDir}.` }); } else { // Other access error + logger.error(`Error after commands, verifying/creating directory '${absoluteInstallDir}': ${error.message}`); sendAdminSseEvent('install_error', { serverKey, error: `Error after commands, verifying/creating directory '${absoluteInstallDir}': ${error.message}` }); throw error; } @@ -441,7 +468,7 @@ if (enableAdminUI) { sendAdminSseEvent('install_complete', { serverKey, code: 0, message: "Installation process completed successfully." }); } catch (error: any) { - console.error(`[${clientId}] Error during server installation process for '${serverKey}':`, error); + logger.error(`[${clientId}] Error during server installation process for '${serverKey}':`, error); if (!error.message?.includes('failed with exit code') && !error.message?.includes('Failed to create execution directory') && !error.message?.includes('Failed to create installation directory') && @@ -461,7 +488,7 @@ if (enableAdminUI) { return; } - console.log(`[Admin SSE] Connection received for session: ${sessionId}`); + logger.log(`[Admin SSE] Connection received for session: ${sessionId}`); // Set SSE headers res.writeHead(200, { @@ -475,12 +502,12 @@ if (enableAdminUI) { // Store connection adminSseConnections.set(sessionId, res); - console.log(`[Admin SSE] Connection stored for session ${sessionId}. Total admin connections: ${adminSseConnections.size}`); + logger.log(`[Admin SSE] Connection stored for session ${sessionId}. Total admin connections: ${adminSseConnections.size}`); // Remove connection on close req.on('close', () => { adminSseConnections.delete(sessionId); - console.log(`[Admin SSE] Connection closed for session ${sessionId}. Total admin connections: ${adminSseConnections.size}`); + logger.log(`[Admin SSE] Connection closed for session ${sessionId}. Total admin connections: ${adminSseConnections.size}`); }); }); @@ -489,7 +516,7 @@ if (enableAdminUI) { // Static file serving for admin UI should also be inside the if block - console.log(`Serving static admin files from: ${publicPath}`); + logger.log(`Serving static admin files from: ${publicPath}`); app.use('/admin', express.static(publicPath)); app.get('/admin', (req, res) => { @@ -506,7 +533,7 @@ if (enableAdminUI) { app.get("/sse", async (req, res) => { const clientId = req.ip || `client-${Date.now()}`; - console.log(`[${clientId}] SSE connection received`); + logger.log(`[${clientId}] SSE connection received`); if (authEnabled) { let authenticated = false; @@ -516,10 +543,10 @@ app.get("/sse", async (req, res) => { if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.substring('Bearer '.length).trim(); if (allowedTokens.has(token)) { - console.log(`[${clientId}] Authorized SSE connection using Bearer Token.`); + logger.log(`[${clientId}] Authorized SSE connection using Bearer Token.`); authenticated = true; } else { - console.warn(`[${clientId}] Unauthorized SSE connection attempt. Invalid Bearer Token.`); + logger.warn(`[${clientId}] Unauthorized SSE connection attempt. Invalid Bearer Token.`); } } @@ -530,16 +557,16 @@ app.get("/sse", async (req, res) => { const providedKey = headerKey || queryKey; if (providedKey && allowedKeys.has(providedKey)) { - console.log(`[${clientId}] Authorized SSE connection using ${headerKey ? 'header' : 'query'} API Key.`); + logger.log(`[${clientId}] Authorized SSE connection using ${headerKey ? 'header' : 'query'} API Key.`); authenticated = true; } else if (providedKey) { - console.warn(`[${clientId}] Unauthorized SSE connection attempt. Invalid API Key.`); + logger.warn(`[${clientId}] Unauthorized SSE connection attempt. Invalid API Key.`); } } // If authentication is enabled but no valid credentials were provided if (!authenticated) { - console.warn(`[${clientId}] Unauthorized SSE connection attempt. No valid credentials provided.`); + logger.warn(`[${clientId}] Unauthorized SSE connection attempt. No valid credentials provided.`); res.status(401).send('Unauthorized'); return; } @@ -554,23 +581,23 @@ app.get("/sse", async (req, res) => { // If client provides a session_id in query, and it exists on the server, // it implies an attempt to reconnect or a stale client. Clean up the old one. if (sessionIdFromClientQuery && sseTransports.has(sessionIdFromClientQuery)) { - console.log(`[${clientId}] Client provided existing session ID: ${sessionIdFromClientQuery}. Closing and removing old transport.`); + logger.log(`[${clientId}] Client provided existing session ID: ${sessionIdFromClientQuery}. Closing and removing old transport.`); const existingTransport = sseTransports.get(sessionIdFromClientQuery)!; sseTransports.delete(sessionIdFromClientQuery); // Remove old one from map if (typeof existingTransport.close === 'function') { existingTransport.close().catch(err => - console.warn(`[${clientId}] Non-critical error closing existing transport for session ${sessionIdFromClientQuery}:`, err) + logger.warn(`[${clientId}] Non-critical error closing existing transport for session ${sessionIdFromClientQuery}:`, err) ); } - console.log(`[${clientId}] Old transport for session ${sessionIdFromClientQuery} removed. Active sessions: ${sseTransports.size}`); + logger.log(`[${clientId}] Old transport for session ${sessionIdFromClientQuery} removed. Active sessions: ${sseTransports.size}`); } else if (sessionIdFromClientQuery) { - console.log(`[${clientId}] Client provided session ID ${sessionIdFromClientQuery}, but no active session found for it. A new session will be created.`); + logger.log(`[${clientId}] Client provided session ID ${sessionIdFromClientQuery}, but no active session found for it. A new session will be created.`); } // Always create a new SSEServerTransport. // It will generate its own internal sessionId, which will be sent to the client via the 'endpoint' event. // The client is expected to use this server-provided sessionId for subsequent POST /message requests. - console.log(`[${clientId}] Creating new SSEServerTransport...`); + logger.log(`[${clientId}] Creating new SSEServerTransport...`); clientTransport = new SSEServerTransport("/message", res); actualTransportSessionId = clientTransport.sessionId; // Get the ID generated by the transport itself @@ -579,43 +606,43 @@ app.get("/sse", async (req, res) => { } sseTransports.set(actualTransportSessionId, clientTransport); // Store the new transport with its own generated ID - console.log(`[${clientId}] New SSE transport created. Actual Session ID for this connection: ${actualTransportSessionId}. Client initially provided: ${sessionIdFromClientQuery || 'none'}. Active sessions: ${sseTransports.size}`); + logger.log(`[${clientId}] New SSE transport created. Actual Session ID for this connection: ${actualTransportSessionId}. Client initially provided: ${sessionIdFromClientQuery || 'none'}. Active sessions: ${sseTransports.size}`); const currentTransport = clientTransport; // To use in closures for onclose/onerror const currentSessionId = actualTransportSessionId; // To use in closures currentTransport.onerror = (err: any) => { - console.error(`[${clientId}] SSE transport error for session ${currentSessionId}: ${err?.stack || err?.message || err}`); + logger.error(`[${clientId}] SSE transport error for session ${currentSessionId}: ${err?.stack || err?.message || err}`); if (sseTransports.has(currentSessionId)) { sseTransports.delete(currentSessionId); - console.log(`[${clientId}] Transport for session ${currentSessionId} removed due to error. Active sessions: ${sseTransports.size}`); + logger.log(`[${clientId}] Transport for session ${currentSessionId} removed due to error. Active sessions: ${sseTransports.size}`); } }; currentTransport.onclose = () => { - console.log(`[${clientId}] SSE client disconnected for session ${currentSessionId}.`); + logger.log(`[${clientId}] SSE client disconnected for session ${currentSessionId}.`); if (sseTransports.has(currentSessionId)) { sseTransports.delete(currentSessionId); - console.log(`[${clientId}] Transport for session ${currentSessionId} removed on close. Active sessions: ${sseTransports.size}`); + logger.log(`[${clientId}] Transport for session ${currentSessionId} removed on close. Active sessions: ${sseTransports.size}`); } }; - console.log(`[${clientId}] Attempting server.connect for new transport with session ${currentSessionId}...`); + logger.log(`[${clientId}] Attempting server.connect for new transport with session ${currentSessionId}...`); await server.connect(currentTransport); - console.log(`[${clientId}] SSE client connected successfully via server.connect for session ${currentSessionId}.`); + logger.log(`[${clientId}] SSE client connected successfully via server.connect for session ${currentSessionId}.`); } catch (error: any) { const logSessionIdOnError = actualTransportSessionId || sessionIdFromClientQuery || "unknown_during_error_handling"; - console.error(`[${clientId}] Failed during SSE setup or connection for session attempt related to ${logSessionIdOnError}:`, error); + logger.error(`[${clientId}] Failed during SSE setup or connection for session attempt related to ${logSessionIdOnError}:`, error); // If a transport was created and added to the map, ensure it's cleaned up on error. if (actualTransportSessionId && sseTransports.has(actualTransportSessionId)) { sseTransports.delete(actualTransportSessionId); - console.log(`[${clientId}] Transport for session ${actualTransportSessionId} removed due to setup/connection error. Active sessions: ${sseTransports.size}`); + logger.log(`[${clientId}] Transport for session ${actualTransportSessionId} removed due to setup/connection error. Active sessions: ${sseTransports.size}`); } // Ensure clientTransport (if partially created) is closed on error. if (clientTransport && typeof clientTransport.close === 'function') { - clientTransport.close().catch((e: any) => console.error(`[${clientId}] Error closing transport for session ${logSessionIdOnError} after connection failure:`, e)); + clientTransport.close().catch((e: any) => logger.error(`[${clientId}] Error closing transport for session ${logSessionIdOnError} after connection failure:`, e)); } if (!res.headersSent) { res.status(500).send('Failed to establish SSE connection'); @@ -628,7 +655,7 @@ app.get("/sse", async (req, res) => { app.all("/mcp", async (req, res) => { // Changed to app.all to handle GET for SSE and POST for messages const clientId = req.ip || `client-http-${Date.now()}`; - console.log(`[${clientId}] Received ${req.method} request on /mcp`); + logger.log(`[${clientId}] Received ${req.method} request on /mcp`); // Authentication check (similar to /sse) if (authEnabled) { @@ -637,10 +664,10 @@ app.all("/mcp", async (req, res) => { // Changed to app.all to handle GET for SS if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.substring('Bearer '.length).trim(); if (allowedTokens.has(token)) { - console.log(`[${clientId}] Authorized /mcp connection using Bearer Token.`); + logger.log(`[${clientId}] Authorized /mcp connection using Bearer Token.`); authenticated = true; } else { - console.warn(`[${clientId}] Unauthorized /mcp (Bearer) for ${req.method}. Invalid Token.`); + logger.warn(`[${clientId}] Unauthorized /mcp (Bearer) for ${req.method}. Invalid Token.`); } } if (!authenticated && allowedKeys.size > 0) { @@ -648,14 +675,14 @@ app.all("/mcp", async (req, res) => { // Changed to app.all to handle GET for SS const queryKey = req.query.key as string | undefined; const providedKey = headerKey || queryKey; if (providedKey && allowedKeys.has(providedKey)) { - console.log(`[${clientId}] Authorized /mcp connection using ${headerKey ? 'header' : 'query'} API Key.`); + logger.log(`[${clientId}] Authorized /mcp connection using ${headerKey ? 'header' : 'query'} API Key.`); authenticated = true; } else if (providedKey) { - console.warn(`[${clientId}] Unauthorized /mcp (API Key) for ${req.method}. Invalid Key.`); + logger.warn(`[${clientId}] Unauthorized /mcp (API Key) for ${req.method}. Invalid Key.`); } } if (!authenticated) { - console.warn(`[${clientId}] Unauthorized /mcp for ${req.method}. No valid credentials.`); + logger.warn(`[${clientId}] Unauthorized /mcp for ${req.method}. No valid credentials.`); res.status(401).send('Unauthorized'); return; } @@ -668,7 +695,7 @@ app.all("/mcp", async (req, res) => { // Changed to app.all to handle GET for SS if (clientProvidedSessionId) { httpTransport = streamableHttpTransports.get(clientProvidedSessionId); if (!httpTransport) { - console.warn(`[${clientId}] /mcp: Client provided Mcp-Session-Id '${clientProvidedSessionId}', but no active transport found. Responding 404.`); + logger.warn(`[${clientId}] /mcp: Client provided Mcp-Session-Id '${clientProvidedSessionId}', but no active transport found. Responding 404.`); if (!res.headersSent) { res.status(404).json({ jsonrpc: "2.0", @@ -678,11 +705,11 @@ app.all("/mcp", async (req, res) => { // Changed to app.all to handle GET for SS } return; } - console.log(`[${clientId}] /mcp: Using existing transport for Mcp-Session-Id: ${clientProvidedSessionId}`); + logger.log(`[${clientId}] /mcp: Using existing transport for Mcp-Session-Id: ${clientProvidedSessionId}`); } else { // No Mcp-Session-Id from client, or it's an InitializeRequest that might not have one yet. // Create a new transport. The transport itself will generate a session ID. - console.log(`[${clientId}] /mcp: No Mcp-Session-Id from client, or new session. Creating new StreamableHTTPServerTransport.`); + logger.log(`[${clientId}] /mcp: No Mcp-Session-Id from client, or new session. Creating new StreamableHTTPServerTransport.`); const tempGeneratedIdForEarlyMap = `pending-${crypto.randomBytes(8).toString('hex')}`; let capturedHttpTransportInstance: StreamableHTTPServerTransport | null = null; // To ensure closure captures the correct instance @@ -690,12 +717,12 @@ app.all("/mcp", async (req, res) => { // Changed to app.all to handle GET for SS sessionIdGenerator: () => crypto.randomUUID(), // Use crypto.randomUUID for session ID generation enableJsonResponse: false, onsessioninitialized: (sdkGeneratedSessionId: string) => { - console.log(`[${clientId}] /mcp: SDK 'onsessioninitialized' called. SDK Session ID: ${sdkGeneratedSessionId}`); + logger.log(`[${clientId}] /mcp: SDK 'onsessioninitialized' called. SDK Session ID: ${sdkGeneratedSessionId}`); if (capturedHttpTransportInstance) { // The SDK has now initialized the session and `capturedHttpTransportInstance.sessionId` should be set. // Verify it matches sdkGeneratedSessionId for sanity. if (capturedHttpTransportInstance.sessionId !== sdkGeneratedSessionId) { - console.warn(`[${clientId}] /mcp: Discrepancy! sdkGeneratedSessionId (${sdkGeneratedSessionId}) vs transport.sessionId (${capturedHttpTransportInstance.sessionId}). Using sdkGeneratedSessionId.`); + logger.warn(`[${clientId}] /mcp: Discrepancy! sdkGeneratedSessionId (${sdkGeneratedSessionId}) vs transport.sessionId (${capturedHttpTransportInstance.sessionId}). Using sdkGeneratedSessionId.`); } const finalSessionId = sdkGeneratedSessionId; // Use the ID from the callback @@ -707,12 +734,12 @@ app.all("/mcp", async (req, res) => { // Changed to app.all to handle GET for SS if (transportSessionIdToUse === tempGeneratedIdForEarlyMap) { transportSessionIdToUse = finalSessionId; } - console.log(`[${clientId}] /mcp: Transport map updated. Temp ID '${tempGeneratedIdForEarlyMap}' replaced with final '${finalSessionId}'. Active: ${streamableHttpTransports.size}`); + logger.log(`[${clientId}] /mcp: Transport map updated. Temp ID '${tempGeneratedIdForEarlyMap}' replaced with final '${finalSessionId}'. Active: ${streamableHttpTransports.size}`); } else { - console.error(`[${clientId}] /mcp: Mismatch during onsessioninitialized! Temp ID ${tempGeneratedIdForEarlyMap} found but instance differs.`); + logger.error(`[${clientId}] /mcp: Mismatch during onsessioninitialized! Temp ID ${tempGeneratedIdForEarlyMap} found but instance differs.`); if (!streamableHttpTransports.has(finalSessionId) || streamableHttpTransports.get(finalSessionId) !== capturedHttpTransportInstance) { streamableHttpTransports.set(finalSessionId, capturedHttpTransportInstance); - console.warn(`[${clientId}] /mcp: Force-mapped transport with final ID '${finalSessionId}' due to instance mismatch.`); + logger.warn(`[${clientId}] /mcp: Force-mapped transport with final ID '${finalSessionId}' due to instance mismatch.`); } } } else { @@ -721,11 +748,11 @@ app.all("/mcp", async (req, res) => { // Changed to app.all to handle GET for SS if (transportSessionIdToUse === tempGeneratedIdForEarlyMap) { transportSessionIdToUse = finalSessionId; } - console.log(`[${clientId}] /mcp: Transport (re)added to map with final ID '${finalSessionId}' (temp not found or instance check). Active: ${streamableHttpTransports.size}`); + logger.log(`[${clientId}] /mcp: Transport (re)added to map with final ID '${finalSessionId}' (temp not found or instance check). Active: ${streamableHttpTransports.size}`); } } } else { - console.error(`[${clientId}] /mcp: onsessioninitialized called but capturedHttpTransportInstance is null. SDK SessionId: ${sdkGeneratedSessionId}`); + logger.error(`[${clientId}] /mcp: onsessioninitialized called but capturedHttpTransportInstance is null. SDK SessionId: ${sdkGeneratedSessionId}`); } }, }; @@ -736,14 +763,14 @@ app.all("/mcp", async (req, res) => { // Changed to app.all to handle GET for SS // Store with a temporary ID. This will be updated by onsessioninitialized when the SDK provides the actual session ID. transportSessionIdToUse = tempGeneratedIdForEarlyMap; streamableHttpTransports.set(tempGeneratedIdForEarlyMap, httpTransport); - console.log(`[${clientId}] /mcp: New transport created. Stored with temp ID: ${tempGeneratedIdForEarlyMap}. Active transports: ${streamableHttpTransports.size}`); + logger.log(`[${clientId}] /mcp: New transport created. Stored with temp ID: ${tempGeneratedIdForEarlyMap}. Active transports: ${streamableHttpTransports.size}`); const currentTransportForHandlers = httpTransport; // Use this specific instance in handlers currentTransportForHandlers.onerror = (error: Error) => { // Use currentTransportForHandlers.sessionId if available, otherwise fallback to transportSessionIdToUse (which might be temp or final) const idToClean = currentTransportForHandlers.sessionId || transportSessionIdToUse; - console.error(`[${clientId}] /mcp: StreamableHTTPServerTransport error for session related to ${idToClean}:`, error); + logger.error(`[${clientId}] /mcp: StreamableHTTPServerTransport error for session related to ${idToClean}:`, error); if (streamableHttpTransports.get(tempGeneratedIdForEarlyMap) === currentTransportForHandlers) { streamableHttpTransports.delete(tempGeneratedIdForEarlyMap); @@ -751,26 +778,26 @@ app.all("/mcp", async (req, res) => { // Changed to app.all to handle GET for SS if (currentTransportForHandlers.sessionId && streamableHttpTransports.get(currentTransportForHandlers.sessionId) === currentTransportForHandlers) { streamableHttpTransports.delete(currentTransportForHandlers.sessionId); } - console.log(`[${clientId}] /mcp: Transport for session related to ${idToClean} removed due to error. Active: ${streamableHttpTransports.size}`); + logger.log(`[${clientId}] /mcp: Transport for session related to ${idToClean} removed due to error. Active: ${streamableHttpTransports.size}`); }; currentTransportForHandlers.onclose = () => { const idToClean = currentTransportForHandlers.sessionId || transportSessionIdToUse; - console.log(`[${clientId}] /mcp: StreamableHTTPServerTransport closed for session related to ${idToClean}.`); + logger.log(`[${clientId}] /mcp: StreamableHTTPServerTransport closed for session related to ${idToClean}.`); if (streamableHttpTransports.get(tempGeneratedIdForEarlyMap) === currentTransportForHandlers) { streamableHttpTransports.delete(tempGeneratedIdForEarlyMap); } if (currentTransportForHandlers.sessionId && streamableHttpTransports.get(currentTransportForHandlers.sessionId) === currentTransportForHandlers) { streamableHttpTransports.delete(currentTransportForHandlers.sessionId); } - console.log(`[${clientId}] /mcp: Transport for session related to ${idToClean} removed on close. Active: ${streamableHttpTransports.size}`); + logger.log(`[${clientId}] /mcp: Transport for session related to ${idToClean} removed on close. Active: ${streamableHttpTransports.size}`); }; try { await server.connect(currentTransportForHandlers); - console.log(`[${clientId}] /mcp: New transport (temp ID: ${transportSessionIdToUse}, awaiting final SDK sessionId) connected to server.`); + logger.log(`[${clientId}] /mcp: New transport (temp ID: ${transportSessionIdToUse}, awaiting final SDK sessionId) connected to server.`); } catch (connectError: any) { - console.error(`[${clientId}] /mcp: Failed to connect new transport to server:`, connectError); + logger.error(`[${clientId}] /mcp: Failed to connect new transport to server:`, connectError); streamableHttpTransports.delete(tempGeneratedIdForEarlyMap); // Clean up temp entry if (!res.headersSent) { res.status(500).json({ @@ -786,7 +813,7 @@ app.all("/mcp", async (req, res) => { // Changed to app.all to handle GET for SS if (!httpTransport) { // This case should ideally be caught earlier if clientProvidedSessionId was present but not found. // If it's a new session and httpTransport somehow didn't get created. - console.error(`[${clientId}] /mcp: Transport is unexpectedly undefined before handling request.`); + logger.error(`[${clientId}] /mcp: Transport is unexpectedly undefined before handling request.`); if (!res.headersSent) { res.status(500).json({ jsonrpc: "2.0", @@ -797,7 +824,7 @@ app.all("/mcp", async (req, res) => { // Changed to app.all to handle GET for SS return; } - console.log(`[${clientId}] /mcp: About to call transport.handleRequest for session ${transportSessionIdToUse || httpTransport.sessionId} - Method: ${req.method}`); + logger.log(`[${clientId}] /mcp: About to call transport.handleRequest for session ${transportSessionIdToUse || httpTransport.sessionId} - Method: ${req.method}`); try { // The SDK's StreamableHTTPServerTransport.handleRequest should: // - For new sessions (e.g., on InitializeRequest), establish the session, @@ -805,10 +832,10 @@ app.all("/mcp", async (req, res) => { // Changed to app.all to handle GET for SS // - For existing sessions, use the provided Mcp-Session-Id. // - Handle both POST (for client messages) and GET (for server-initiated SSE streams). await httpTransport.handleRequest(req, res, req.body); - console.log(`[${clientId}] /mcp: transport.handleRequest completed for session ${transportSessionIdToUse || httpTransport.sessionId}. Response stream managed by transport.`); + logger.log(`[${clientId}] /mcp: transport.handleRequest completed for session ${transportSessionIdToUse || httpTransport.sessionId}. Response stream managed by transport.`); } catch (error: any) { const idToLog = transportSessionIdToUse || httpTransport.sessionId; - console.error(`[${clientId}] /mcp: Error during transport.handleRequest for session ${idToLog}:`, error); + logger.error(`[${clientId}] /mcp: Error during transport.handleRequest for session ${idToLog}:`, error); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ @@ -823,26 +850,26 @@ app.all("/mcp", async (req, res) => { // Changed to app.all to handle GET for SS }); app.post("/message", async (req, res) => { const sessionId = req.query.sessionId as string; - console.log(`Received POST /message for Session ID: ${sessionId}`); + logger.log(`Received POST /message for Session ID: ${sessionId}`); if (!sessionId) { - console.error("POST /message error: Missing sessionId query parameter."); + logger.error("POST /message error: Missing sessionId query parameter."); return res.status(400).send({ error: "Missing sessionId query parameter" }); } const transport = sseTransports.get(sessionId); if (!transport) { - console.error(`POST /message error: No active transport found for Session ID: ${sessionId}`); + logger.error(`POST /message error: No active transport found for Session ID: ${sessionId}`); return res.status(404).send({ error: `No active session found for ID ${sessionId}` }); } - console.log(`Found transport for session ${sessionId}. Handling POST message...`); + logger.log(`Found transport for session ${sessionId}. Handling POST message...`); try { await transport.handlePostMessage(req, res, req.body); - console.log(`Successfully handled POST for session ${sessionId}`); + logger.log(`Successfully handled POST for session ${sessionId}`); } catch (error: any) { - console.error(`Error in transport.handlePostMessage for session ${sessionId}:`, error); + logger.error(`Error in transport.handlePostMessage for session ${sessionId}:`, error); if (!res.headersSent) { res.status(500).send({ error: "Failed to process message via transport" }); } @@ -854,62 +881,62 @@ const PORT = process.env.PORT || 3663; expressServer.listen(PORT, () => { const baseUrl = `http://localhost:${PORT}`; - console.log(`MCP Proxy Server is running.`); - console.log(`SSE endpoint: ${baseUrl}/sse`); - console.log(`Streamable HTTP (MCP) endpoint: ${baseUrl}/mcp`); + logger.log(`MCP Proxy Server is running.`); + logger.log(`SSE endpoint: ${baseUrl}/sse`); + logger.log(`Streamable HTTP (MCP) endpoint: ${baseUrl}/mcp`); if (authEnabled && allowedKeys.size > 0) { const firstKey = allowedKeys.values().next().value; - console.log(`Example authenticated SSE endpoint: ${baseUrl}/sse?key=${firstKey}`); - console.log(`Example authenticated MCP endpoint: ${baseUrl}/mcp?key=${firstKey} (or use X-Api-Key header)`); + logger.log(`Example authenticated SSE endpoint: ${baseUrl}/sse?key=${firstKey}`); + logger.log(`Example authenticated MCP endpoint: ${baseUrl}/mcp?key=${firstKey} (or use X-Api-Key header)`); } if (enableAdminUI) { - console.log(`Admin UI available at ${baseUrl}/admin`); + logger.log(`Admin UI available at ${baseUrl}/admin`); } }); const shutdown = async (signal: string) => { - console.log(`\nReceived ${signal}. Shutting down gracefully...`); + logger.log(`\nReceived ${signal}. Shutting down gracefully...`); try { - console.log("Closing MCP Server (disconnecting transports)..."); + logger.log("Closing MCP Server (disconnecting transports)..."); await server.close(); - console.log("MCP Server closed."); + logger.log("MCP Server closed."); - console.log("Cleaning up backend clients..."); + logger.log("Cleaning up backend clients..."); await cleanup(); - console.log("Backend clients cleaned up."); + logger.log("Backend clients cleaned up."); // Kill any active terminal processes - console.log("Killing active terminal sessions..."); + logger.log("Killing active terminal sessions..."); // Add type annotations for the forEach callback parameters activeTerminals.forEach((term: ActiveTerminal, id: string) => { - console.log(`Killing terminal ${id} (PID: ${term.ptyProcess.pid})`); + logger.log(`Killing terminal ${id} (PID: ${term.ptyProcess.pid})`); term.ptyProcess.kill(); }); activeTerminals.clear(); TERMINAL_OUTPUT_SSE_CONNECTIONS.clear(); // Also clear SSE connections for terminals - console.log("Active terminal sessions killed."); + logger.log("Active terminal sessions killed."); - console.log("Closing HTTP server..."); + logger.log("Closing HTTP server..."); expressServer.close((err) => { if (err) { - console.error("Error closing HTTP server:", err); + logger.error("Error closing HTTP server:", err); process.exit(1); } else { - console.log("HTTP server closed."); + logger.log("HTTP server closed."); process.exit(0); } }); setTimeout(() => { - console.error("Graceful shutdown timed out. Forcing exit."); + logger.error("Graceful shutdown timed out. Forcing exit."); process.exit(1); }, 10000); // Increased timeout slightly } catch (error) { - console.error("Error during graceful shutdown:", error); + logger.error("Error during graceful shutdown:", error); process.exit(1); } };