Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 a2a1f825a5 fix: release rejected HTTP handshakes that never open a session
A 406 or 415 from the SDK returns normally before onsessioninitialized. Detach the approval client and close the MCP server when that happens so rejected initializes do not accumulate.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:56:55 +00:00
Cursor Agentand陈大猫 e802c92ac6 fix: accept bracketed IPv6 origins
Node reports URL.hostname as [::1], so an otherwise valid loopback or wildcard Origin was rejected. Compare the address with the brackets removed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:48:36 +00:00
Cursor Agentand陈大猫 92923f5cc6 fix: return 404 when an HTTP session id is unknown
GET and DELETE with a nonempty session id that is no longer active now return 404, matching POST and the Streamable HTTP session contract. A missing session id stays 400.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:42:12 +00:00
Cursor Agentand陈大猫 c0d49b50fc fix: expire idle Streamable HTTP sessions
JSON responses do not keep a socket open, so a client that never sends DELETE used to pin an MCP session until the listener stopped. Sweep sessions that have been idle for 15 minutes, and skip any session with a request still in flight.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:36:20 +00:00
Cursor Agentand陈大猫 faa52f0cf7 fix: recheck the bearer token after the HTTP body arrives
A half-open POST already passed auth before readBody. Recheck the live token before dispatch so a rotation during that wait cannot still run a tool.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:28:45 +00:00
Cursor Agentand陈大猫 99dd770d45 fix: advertise a real client host and return 413 intact
Wildcard binds no longer copy 127.0.0.1 into client configs; the URL stays empty until a reachable client address is set. Oversized POSTs receive a JSON-RPC 413 before the socket is closed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:24:10 +00:00
Cursor Agentand陈大猫 ed699f0b7f fix: close HTTP sockets before stop and export the Codex bearer
Destroy tracked connections before awaiting server.close so disable does not hang on a half-open request. Prefix the Codex snippet with export so the child process receives the token.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:13:11 +00:00
Cursor Agentand陈大猫 5007b9eca2 feat: add optional Streamable HTTP for External MCP
Serve the existing catalog tools and approval path on a loopback HTTP
endpoint so clients that cannot spawn the stdio launcher, such as Codex
in WSL, can connect with a bearer token. The listener stays off until
the user enables it.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:05:57 +00:00
23 changed files with 2552 additions and 60 deletions

No files matched your search

+32
View File
@@ -376,6 +376,38 @@ export const enAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': 'External MCP help',
'ai.externalMcp.security': 'Security',
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': 'Serve the same External MCP tools over Streamable HTTP for clients that cannot launch the stdio CLI, such as Codex inside WSL. Off by default.',
'ai.externalMcp.http.enable': 'Enable HTTP',
'ai.externalMcp.http.port': 'Port',
'ai.externalMcp.http.portHint': 'Fixed port, default 47391. Client URLs stay the same across restarts. Allowed range: 1024–65535.',
'ai.externalMcp.http.allowCustomBind': 'Listen on another address',
'ai.externalMcp.http.allowCustomBind.description': 'By default the endpoint binds to 127.0.0.1. Turn this on for WSL2 NAT and set 0.0.0.0 or the vEthernet IP.',
'ai.externalMcp.http.listenAddress': 'Listen address',
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
'ai.externalMcp.http.clientHost': 'Client address',
'ai.externalMcp.http.clientHost.description': 'Used in copied configs when the listener is 0.0.0.0 or ::. Enter the IP clients can reach, such as the Windows host IP.',
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
'ai.externalMcp.http.clientHostRequired': 'Enter the IP clients should connect to. 0.0.0.0 is not a reachable address.',
'ai.externalMcp.http.invalidClientHost': 'Client address must be a reachable IP, not 0.0.0.0 or ::.',
'ai.externalMcp.http.warning': 'Listening beyond 127.0.0.1 exposes External MCP to other machines that can reach this address. Keep the bearer token private and do not publish this port.',
'ai.externalMcp.http.url': 'URL',
'ai.externalMcp.http.token': 'Bearer token',
'ai.externalMcp.http.regenerate': 'Regenerate',
'ai.externalMcp.http.regenerateHint': 'Issues a new bearer token. HTTP clients must use the new token. The stdio discovery file is updated; clients that are already connected over stdio stay connected.',
'ai.externalMcp.http.wslHint': 'With WSL mirrored networking, localhost works. Otherwise listen on 0.0.0.0 or the vEthernet IP and set Client address to the Windows host IP.',
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
'ai.externalMcp.http.claudeCommand': 'Claude Code',
'ai.externalMcp.http.genericSnippet': 'Generic JSON',
'ai.externalMcp.http.envLine': 'Codex token env',
'ai.externalMcp.http.notListening': 'Not listening',
'ai.externalMcp.http.listening': 'Listening',
'ai.externalMcp.http.portInUse': 'Port {port} is already in use.',
'ai.externalMcp.http.requiresExternalMcp': 'Turn on External MCP before this endpoint will listen.',
'ai.externalMcp.http.invalidPort': 'Enter a port from 1024 to 65535.',
'ai.externalMcp.http.invalidAddress': 'Listen address must be an IP, such as 0.0.0.0 or the vEthernet IP.',
'ai.externalMcp.http.listenFailed': 'HTTP failed to listen.',
'ai.externalMcp.http.legacySseNote': 'Legacy HTTP+SSE (2024-11-05) is not offered. Use this Streamable HTTP URL.',
'ai.externalMcp.permissionMode': 'Current permission mode: {mode}',
'ai.externalMcp.permissionMode.label': 'Write permission mode',
'ai.externalMcp.permissionMode.hint': 'Same setting as Settings → AI → Safety. Auto runs Netcatty write tools without Netcatty approval prompts; Confirm asks each time. External clients (Codex / Claude / Grok) may still show their own tool approval UI.',
+32
View File
@@ -367,6 +367,38 @@ export const esAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
'ai.externalMcp.security': 'Seguridad',
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': 'Expone las mismas herramientas de MCP externo por Streamable HTTP para clientes que no pueden iniciar el CLI stdio, como Codex dentro de WSL. Desactivado de forma predeterminada.',
'ai.externalMcp.http.enable': 'Activar HTTP',
'ai.externalMcp.http.port': 'Puerto',
'ai.externalMcp.http.portHint': 'Puerto fijo, predeterminado 47391. Las URL de los clientes se mantienen al reiniciar. Rango permitido: 1024–65535.',
'ai.externalMcp.http.allowCustomBind': 'Escuchar en otra dirección',
'ai.externalMcp.http.allowCustomBind.description': 'De forma predeterminada el extremo se enlaza a 127.0.0.1. Actívalo para NAT de WSL2 y usa 0.0.0.0 o la IP de vEthernet.',
'ai.externalMcp.http.listenAddress': 'Dirección de escucha',
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
'ai.externalMcp.http.clientHost': 'Dirección del cliente',
'ai.externalMcp.http.clientHost.description': 'Se usa en las configuraciones copiadas cuando la escucha es 0.0.0.0 o ::. Introduce una IP alcanzable, como la IP del host de Windows.',
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
'ai.externalMcp.http.clientHostRequired': 'Introduce la IP a la que deben conectarse los clientes. 0.0.0.0 no es una dirección alcanzable.',
'ai.externalMcp.http.invalidClientHost': 'La dirección del cliente debe ser una IP alcanzable, no 0.0.0.0 ni ::.',
'ai.externalMcp.http.warning': 'Escuchar fuera de 127.0.0.1 expone el MCP externo a otros equipos que puedan alcanzar esta dirección. Mantén el bearer token en privado y no publiques este puerto.',
'ai.externalMcp.http.url': 'URL',
'ai.externalMcp.http.token': 'Bearer token',
'ai.externalMcp.http.regenerate': 'Regenerar',
'ai.externalMcp.http.regenerateHint': 'Emite un bearer token nuevo. Los clientes HTTP deben usar el token nuevo. El archivo de descubrimiento stdio se actualiza; los clientes stdio ya conectados siguen conectados.',
'ai.externalMcp.http.wslHint': 'Con la red reflejada de WSL, localhost funciona. Si no, escucha en 0.0.0.0 o la IP de vEthernet y pon la IP del host de Windows en Dirección del cliente.',
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
'ai.externalMcp.http.claudeCommand': 'Claude Code',
'ai.externalMcp.http.genericSnippet': 'JSON genérico',
'ai.externalMcp.http.envLine': 'Variable de entorno del token de Codex',
'ai.externalMcp.http.notListening': 'Sin escucha',
'ai.externalMcp.http.listening': 'Escuchando',
'ai.externalMcp.http.portInUse': 'El puerto {port} ya está en uso.',
'ai.externalMcp.http.requiresExternalMcp': 'Activa el MCP externo para que este extremo empiece a escuchar.',
'ai.externalMcp.http.invalidPort': 'Introduce un puerto entre 1024 y 65535.',
'ai.externalMcp.http.invalidAddress': 'La dirección de escucha debe ser una IP, como 0.0.0.0 o la IP de vEthernet.',
'ai.externalMcp.http.listenFailed': 'HTTP no pudo escuchar.',
'ai.externalMcp.http.legacySseNote': 'No se ofrece el HTTP+SSE heredado (2024-11-05). Usa esta URL de Streamable HTTP.',
'ai.externalMcp.permissionMode': 'Modo de permisos actual: {mode}',
'ai.externalMcp.permissionMode.label': 'Modo de permiso de escritura',
'ai.externalMcp.permissionMode.hint': 'La misma configuración que Configuración → IA → Seguridad. Automático ejecuta las herramientas de escritura de Netcatty sin solicitar aprobación de Netcatty; Confirmar pregunta cada vez. Los clientes externos (Codex / Claude / Grok) aún pueden mostrar su propia interfaz de aprobación de herramientas.',
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import test from "node:test";
import en from "./en.ts";
import ru from "./ru.ts";
import es from "./es.ts";
import zhCN from "./zh-CN.ts";
import zhTW from "./zh-TW.ts";
const KEYS = [
"ai.externalMcp.http.title",
"ai.externalMcp.http.description",
"ai.externalMcp.http.enable",
"ai.externalMcp.http.port",
"ai.externalMcp.http.portHint",
"ai.externalMcp.http.allowCustomBind",
"ai.externalMcp.http.allowCustomBind.description",
"ai.externalMcp.http.listenAddress",
"ai.externalMcp.http.listenAddress.placeholder",
"ai.externalMcp.http.clientHost",
"ai.externalMcp.http.clientHost.description",
"ai.externalMcp.http.clientHost.placeholder",
"ai.externalMcp.http.clientHostRequired",
"ai.externalMcp.http.invalidClientHost",
"ai.externalMcp.http.warning",
"ai.externalMcp.http.url",
"ai.externalMcp.http.token",
"ai.externalMcp.http.regenerate",
"ai.externalMcp.http.regenerateHint",
"ai.externalMcp.http.wslHint",
"ai.externalMcp.http.codexSnippet",
"ai.externalMcp.http.claudeCommand",
"ai.externalMcp.http.genericSnippet",
"ai.externalMcp.http.envLine",
"ai.externalMcp.http.notListening",
"ai.externalMcp.http.listening",
"ai.externalMcp.http.portInUse",
"ai.externalMcp.http.requiresExternalMcp",
"ai.externalMcp.http.invalidPort",
"ai.externalMcp.http.invalidAddress",
"ai.externalMcp.http.listenFailed",
"ai.externalMcp.http.legacySseNote",
] as const;
test("External MCP HTTP strings exist in every locale", () => {
for (const [name, messages] of Object.entries({ en, es, "zh-CN": zhCN, "zh-TW": zhTW, ru })) {
const missing = KEYS.filter((key) => !messages[key]);
assert.deepEqual(missing, [], `${name} is missing HTTP MCP labels`);
assert.match(messages["ai.externalMcp.http.portInUse"], /\{port\}/);
}
assert.match(zhTW["ai.externalMcp.http.port"], /連接埠/);
assert.match(zhTW["ai.externalMcp.http.listenAddress"], /位址/);
assert.match(zhTW["ai.externalMcp.http.clientHost"], /位址/);
assert.doesNotMatch(zhTW["ai.externalMcp.http.clientHost"], /地址/);
assert.doesNotMatch(zhTW["ai.externalMcp.http.port"], /端口/);
assert.doesNotMatch(zhTW["ai.externalMcp.http.listenAddress"], /地址/);
assert.match(zhTW["ai.externalMcp.http.wslHint"], /鏡像網路/);
});
+32
View File
@@ -323,6 +323,38 @@ export const ruAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
'ai.externalMcp.security': 'Безопасность',
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': 'Отдает те же инструменты External MCP по Streamable HTTP клиентам, которые не могут запустить stdio CLI, например Codex в WSL. По умолчанию выключено.',
'ai.externalMcp.http.enable': 'Включить HTTP',
'ai.externalMcp.http.port': 'Порт',
'ai.externalMcp.http.portHint': 'Фиксированный порт, по умолчанию 47391. URL клиентов не меняется после перезапуска. Допустимый диапазон: 1024–65535.',
'ai.externalMcp.http.allowCustomBind': 'Слушать другой адрес',
'ai.externalMcp.http.allowCustomBind.description': 'По умолчанию endpoint привязан к 127.0.0.1. Включите для WSL2 NAT и укажите 0.0.0.0 или IP vEthernet.',
'ai.externalMcp.http.listenAddress': 'Адрес прослушивания',
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
'ai.externalMcp.http.clientHost': 'Адрес клиента',
'ai.externalMcp.http.clientHost.description': 'Подставляется в копируемые конфиги, если слушатель 0.0.0.0 или ::. Укажите доступный IP, например IP узла Windows.',
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
'ai.externalMcp.http.clientHostRequired': 'Укажите IP, к которому должны подключаться клиенты. 0.0.0.0 недоступен для подключения.',
'ai.externalMcp.http.invalidClientHost': 'Адрес клиента должен быть доступным IP, а не 0.0.0.0 или ::.',
'ai.externalMcp.http.warning': 'Прослушивание не только 127.0.0.1 открывает External MCP другим машинам, которые могут достичь этого адреса. Храните bearer token в секрете и не публикуйте этот порт.',
'ai.externalMcp.http.url': 'URL',
'ai.externalMcp.http.token': 'Bearer token',
'ai.externalMcp.http.regenerate': 'Создать заново',
'ai.externalMcp.http.regenerateHint': 'Выпускает новый bearer token. HTTP-клиенты должны использовать новый token. Файл discovery для stdio обновляется; уже подключенные stdio-клиенты остаются подключенными.',
'ai.externalMcp.http.wslHint': 'При mirrored networking в WSL достаточно localhost. Иначе слушайте 0.0.0.0 или IP vEthernet и укажите IP узла Windows как адрес клиента.',
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
'ai.externalMcp.http.claudeCommand': 'Claude Code',
'ai.externalMcp.http.genericSnippet': 'Общий JSON',
'ai.externalMcp.http.envLine': 'Переменная окружения token для Codex',
'ai.externalMcp.http.notListening': 'Не слушает',
'ai.externalMcp.http.listening': 'Слушает',
'ai.externalMcp.http.portInUse': 'Порт {port} уже занят.',
'ai.externalMcp.http.requiresExternalMcp': 'Сначала включите External MCP, чтобы endpoint начал слушать.',
'ai.externalMcp.http.invalidPort': 'Введите порт от 1024 до 65535.',
'ai.externalMcp.http.invalidAddress': 'Адрес прослушивания должен быть IP, например 0.0.0.0 или IP vEthernet.',
'ai.externalMcp.http.listenFailed': 'Не удалось начать прослушивание HTTP.',
'ai.externalMcp.http.legacySseNote': 'Устаревший HTTP+SSE (2024-11-05) не предоставляется. Используйте этот URL Streamable HTTP.',
'ai.externalMcp.permissionMode': 'Текущий режим разрешений: {mode}',
'ai.externalMcp.permissionMode.label': 'Режим разрешений на запись',
'ai.externalMcp.permissionMode.hint': 'Тот же параметр, что Settings → AI → Safety. Auto выполняет write-инструменты Netcatty без запросов; Confirm спрашивает каждый раз. Внешние клиенты (Codex / Claude / Grok) могут показывать своё подтверждение инструментов.',
+32
View File
@@ -376,6 +376,38 @@ export const zhCNAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
'ai.externalMcp.security': '安全',
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': '用 Streamable HTTP 提供同一套对外 MCP 工具,方便无法启动 stdio CLI 的客户端,例如 WSL 里的 Codex。默认关闭。',
'ai.externalMcp.http.enable': '启用 HTTP',
'ai.externalMcp.http.port': '端口',
'ai.externalMcp.http.portHint': '固定端口,默认 47391。重启后客户端地址保持不变。允许范围:1024–65535。',
'ai.externalMcp.http.allowCustomBind': '监听其他地址',
'ai.externalMcp.http.allowCustomBind.description': '默认只绑定 127.0.0.1。WSL2 NAT 可改为监听 0.0.0.0 或 vEthernet IP。',
'ai.externalMcp.http.listenAddress': '监听地址',
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
'ai.externalMcp.http.clientHost': '客户端地址',
'ai.externalMcp.http.clientHost.description': '监听 0.0.0.0 或 :: 时,复制的配置会使用这个地址。请填写客户端能访问的 IP,例如 Windows 主机 IP。',
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
'ai.externalMcp.http.clientHostRequired': '请填写客户端要连接的 IP。0.0.0.0 不是可连接的地址。',
'ai.externalMcp.http.invalidClientHost': '客户端地址必须是可连接的 IP,不能是 0.0.0.0 或 ::。',
'ai.externalMcp.http.warning': '监听 127.0.0.1 以外的地址会让能访问这台电脑的其他设备使用对外 MCP。请保管 bearer token,不要把这个端口暴露到公网。',
'ai.externalMcp.http.url': 'URL',
'ai.externalMcp.http.token': 'Bearer token',
'ai.externalMcp.http.regenerate': '重新生成',
'ai.externalMcp.http.regenerateHint': '生成新的 bearer token。HTTP 客户端必须改用新 token。stdio 的 discovery 文件会一起更新;已经通过 stdio 连上的客户端保持连接。',
'ai.externalMcp.http.wslHint': 'WSL 若使用镜像网络,localhost 即可连接。否则请监听 0.0.0.0 或 vEthernet IP,并把客户端地址设为 Windows 主机 IP。',
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
'ai.externalMcp.http.claudeCommand': 'Claude Code',
'ai.externalMcp.http.genericSnippet': '通用 JSON',
'ai.externalMcp.http.envLine': 'Codex token 环境变量',
'ai.externalMcp.http.notListening': '未监听',
'ai.externalMcp.http.listening': '监听中',
'ai.externalMcp.http.portInUse': '端口 {port} 已被占用。',
'ai.externalMcp.http.requiresExternalMcp': '请先打开对外 MCP,HTTP 端点才会开始监听。',
'ai.externalMcp.http.invalidPort': '请输入 1024 到 65535 的端口。',
'ai.externalMcp.http.invalidAddress': '监听地址必须是 IP,例如 0.0.0.0 或 vEthernet IP。',
'ai.externalMcp.http.listenFailed': 'HTTP 监听失败。',
'ai.externalMcp.http.legacySseNote': '不提供旧版 HTTP+SSE(2024-11-05)。请使用这个 Streamable HTTP 地址。',
'ai.externalMcp.permissionMode': '当前权限模式:{mode}',
'ai.externalMcp.permissionMode.label': '写操作权限模式',
'ai.externalMcp.permissionMode.hint': '与「设置 → AI → 安全」为同一项。自动:Netcatty 写操作不再弹本应用审批;确认:每次询问。外部客户端(Codex / Claude / Grok)仍可能有自己的工具审批界面。',
+32
View File
@@ -366,6 +366,38 @@ export const zhTWAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
'ai.externalMcp.security': '安全',
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': '以 Streamable HTTP 提供同一套對外 MCP 工具,給無法啟動 stdio CLI 的用戶端使用,例如 WSL 裡的 Codex。預設關閉。',
'ai.externalMcp.http.enable': '啟用 HTTP',
'ai.externalMcp.http.port': '連接埠',
'ai.externalMcp.http.portHint': '固定連接埠,預設 47391。重新啟動後用戶端網址維持不變。允許範圍:1024–65535。',
'ai.externalMcp.http.allowCustomBind': '監聽其他位址',
'ai.externalMcp.http.allowCustomBind.description': '預設只綁定 127.0.0.1。WSL2 NAT 可改為監聽 0.0.0.0 或 vEthernet IP。',
'ai.externalMcp.http.listenAddress': '監聽位址',
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
'ai.externalMcp.http.clientHost': '用戶端位址',
'ai.externalMcp.http.clientHost.description': '監聽 0.0.0.0 或 :: 時,複製的設定會使用這個位址。請填用戶端連得到的 IP,例如 Windows 主機 IP。',
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
'ai.externalMcp.http.clientHostRequired': '請填用戶端要連線的 IP。0.0.0.0 不是可連線的位址。',
'ai.externalMcp.http.invalidClientHost': '用戶端位址必須是可連線的 IP,不能是 0.0.0.0 或 ::。',
'ai.externalMcp.http.warning': '監聽 127.0.0.1 以外的位址,會讓能連到這台電腦的其他裝置使用對外 MCP。請保管 bearer token,不要把這個連接埠暴露到公開網路。',
'ai.externalMcp.http.url': 'URL',
'ai.externalMcp.http.token': 'Bearer token',
'ai.externalMcp.http.regenerate': '重新產生',
'ai.externalMcp.http.regenerateHint': '產生新的 bearer token。HTTP 用戶端必須改用新 token。stdio 的 discovery 檔會一併更新;已經透過 stdio 連上的用戶端維持連線。',
'ai.externalMcp.http.wslHint': 'WSL 若使用鏡像網路,localhost 即可連線。否則請監聽 0.0.0.0 或 vEthernet IP,並把用戶端位址設成 Windows 主機 IP。',
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
'ai.externalMcp.http.claudeCommand': 'Claude Code',
'ai.externalMcp.http.genericSnippet': '通用 JSON',
'ai.externalMcp.http.envLine': 'Codex token 環境變數',
'ai.externalMcp.http.notListening': '未監聽',
'ai.externalMcp.http.listening': '監聽中',
'ai.externalMcp.http.portInUse': '連接埠 {port} 已被占用。',
'ai.externalMcp.http.requiresExternalMcp': '請先開啟對外 MCP,HTTP 端點才會開始監聽。',
'ai.externalMcp.http.invalidPort': '請輸入 1024 到 65535 的連接埠。',
'ai.externalMcp.http.invalidAddress': '監聽位址必須是 IP,例如 0.0.0.0 或 vEthernet IP。',
'ai.externalMcp.http.listenFailed': 'HTTP 監聽失敗。',
'ai.externalMcp.http.legacySseNote': '不提供舊版 HTTP+SSE(2024-11-05)。請使用這個 Streamable HTTP 網址。',
'ai.externalMcp.permissionMode': '目前權限模式:{mode}',
'ai.externalMcp.permissionMode.label': '寫入權限模式',
'ai.externalMcp.permissionMode.hint': '與「設定 → AI → 安全」為同一項。自動:Netcatty 寫入操作不再彈本應用核准;確認:每次詢問。外部用戶端(Codex / Claude / Grok)仍可能有自己的工具核准介面。',
@@ -3,6 +3,17 @@ import { describe, it } from 'node:test';
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
readExternalMcpHttpAllowCustomBind,
readExternalMcpHttpEnabled,
readExternalMcpHttpListenAddress,
readExternalMcpHttpPort,
readExternalMcpHttpSettings,
writeExternalMcpHttpAllowCustomBind,
writeExternalMcpHttpEnabled,
writeExternalMcpHttpListenAddress,
writeExternalMcpHttpPort,
} from "./useExternalMcpToggleState.ts";
import {
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
bumpExternalMcpEnableGenerationForTests,
@@ -414,3 +425,30 @@ describe('syncExternalMcpStartupState generation guard', () => {
}
});
});
describe("external MCP HTTP settings", () => {
it("defaults HTTP off on loopback and round-trips the explicit listen option", () => {
const restore = installMemoryLocalStorage();
try {
assert.deepEqual(readExternalMcpHttpSettings(), {
enabled: false,
port: 47391,
allowCustomBind: false,
listenAddress: "0.0.0.0",
clientHost: "",
});
assert.equal(readExternalMcpHttpEnabled(), false);
writeExternalMcpHttpEnabled(true);
assert.equal(writeExternalMcpHttpPort(80), 47391);
assert.equal(writeExternalMcpHttpPort(48080), 48080);
writeExternalMcpHttpAllowCustomBind(true);
writeExternalMcpHttpListenAddress("172.22.0.1");
assert.equal(readExternalMcpHttpEnabled(), true);
assert.equal(readExternalMcpHttpPort(), 48080);
assert.equal(readExternalMcpHttpAllowCustomBind(), true);
assert.equal(readExternalMcpHttpListenAddress(), "172.22.0.1");
} finally {
restore();
}
});
});
@@ -3,6 +3,11 @@ import {
STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT,
STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED,
STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN,
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND,
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST,
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED,
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS,
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT,
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
@@ -10,6 +15,7 @@ import {
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
} from '../../infrastructure/config/storageKeys';
import type { AIPermissionMode } from '../../infrastructure/ai/types';
import { DEFAULT_EXTERNAL_MCP_HTTP_PORT } from '../../domain/externalMcpHttp';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
@@ -104,10 +110,23 @@ export function waitForExternalMcpStartupReady(hash?: string): Promise<void> {
return externalMcpStartupReadyWaitPromise;
}
export type ExternalMcpHttpSettings = {
enabled: boolean;
port: number;
allowCustomBind: boolean;
listenAddress: string;
clientHost: string;
};
type ExternalMcpConfig = {
mode: ExternalMcpMode;
idleTimeoutMinutes: number;
sessionIdleTimeoutMinutes: number;
httpEnabled?: boolean;
httpPort?: number;
httpAllowCustomBind?: boolean;
httpListenAddress?: string;
httpClientHost?: string;
};
type ExternalMcpBridge = {
@@ -241,6 +260,109 @@ export function writeSessionIdleTimeoutMinutes(minutes: number): number {
return normalized;
}
const MIN_EXTERNAL_MCP_HTTP_PORT = 1024;
const MAX_EXTERNAL_MCP_HTTP_PORT = 65535;
export function normalizeExternalMcpHttpPort(value: number | null): number {
if (!Number.isInteger(value)) return DEFAULT_EXTERNAL_MCP_HTTP_PORT;
if (value < MIN_EXTERNAL_MCP_HTTP_PORT || value > MAX_EXTERNAL_MCP_HTTP_PORT) {
return DEFAULT_EXTERNAL_MCP_HTTP_PORT;
}
return value;
}
export function normalizeExternalMcpHttpListenAddress(value: string | null): string {
const address = (value ?? '').trim();
if (!address) return '0.0.0.0';
return address;
}
export function readExternalMcpHttpEnabled(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED) ?? false;
}
export function writeExternalMcpHttpEnabled(enabled: boolean): boolean {
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED, enabled);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED);
return enabled;
}
export function readExternalMcpHttpPort(): number {
return normalizeExternalMcpHttpPort(
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT),
);
}
export function writeExternalMcpHttpPort(port: number): number {
const normalized = normalizeExternalMcpHttpPort(port);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT);
return normalized;
}
export function readExternalMcpHttpAllowCustomBind(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND) ?? false;
}
export function writeExternalMcpHttpAllowCustomBind(allowCustomBind: boolean): boolean {
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND, allowCustomBind);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND);
return allowCustomBind;
}
export function readExternalMcpHttpListenAddress(): string {
return normalizeExternalMcpHttpListenAddress(
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS),
);
}
export function writeExternalMcpHttpListenAddress(listenAddress: string): string {
const normalized = normalizeExternalMcpHttpListenAddress(listenAddress);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS);
return normalized;
}
export function normalizeExternalMcpHttpClientHost(value: string | null): string {
return (value ?? '').trim();
}
export function readExternalMcpHttpClientHost(): string {
return normalizeExternalMcpHttpClientHost(
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST),
);
}
export function writeExternalMcpHttpClientHost(clientHost: string): string {
const normalized = normalizeExternalMcpHttpClientHost(clientHost);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST);
return normalized;
}
export function readExternalMcpHttpSettings(): ExternalMcpHttpSettings {
return {
enabled: readExternalMcpHttpEnabled(),
port: readExternalMcpHttpPort(),
allowCustomBind: readExternalMcpHttpAllowCustomBind(),
listenAddress: readExternalMcpHttpListenAddress(),
clientHost: readExternalMcpHttpClientHost(),
};
}
function httpConfigFromSettings(settings: ExternalMcpHttpSettings): Pick<
ExternalMcpConfig,
'httpEnabled' | 'httpPort' | 'httpAllowCustomBind' | 'httpListenAddress' | 'httpClientHost'
> {
return {
httpEnabled: settings.enabled,
httpPort: settings.port,
httpAllowCustomBind: settings.allowCustomBind,
httpListenAddress: settings.listenAddress,
httpClientHost: settings.clientHost,
};
}
export function shouldStartExternalMcpOnStartup({
enabled,
mode,
@@ -263,11 +385,19 @@ export function createExternalMcpStartupSyncPlan({
mode,
idleTimeoutMinutes,
sessionIdleTimeoutMinutes,
http = {
enabled: false,
port: DEFAULT_EXTERNAL_MCP_HTTP_PORT,
allowCustomBind: false,
listenAddress: '0.0.0.0',
clientHost: '',
},
}: {
enabled: boolean;
mode: ExternalMcpMode;
idleTimeoutMinutes: number;
sessionIdleTimeoutMinutes: number;
http?: ExternalMcpHttpSettings;
}): ExternalMcpStartupSyncPlan {
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
const storedEnabled = runtimeEnabled;
@@ -276,6 +406,7 @@ export function createExternalMcpStartupSyncPlan({
mode,
idleTimeoutMinutes,
sessionIdleTimeoutMinutes,
...httpConfigFromSettings(http),
},
runtimeEnabled,
storedEnabled,
@@ -289,14 +420,17 @@ export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
mode: readExternalMcpMode(),
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
http: readExternalMcpHttpSettings(),
});
}
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
const http = readExternalMcpHttpSettings();
void bridge?.externalMcpSetConfig?.({
mode: readExternalMcpMode(),
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
...httpConfigFromSettings(http),
});
}
@@ -0,0 +1,62 @@
import React from "react";
import { Check, Copy } from "lucide-react";
import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
type CopyableCodeBlockProps = {
label?: string;
value: string;
copyKey: string;
copied: string | null;
onCopy: (key: string, text: string) => void;
copyLabel: string;
copiedLabel: string;
emptyLabel?: string;
className?: string;
};
export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
label,
value,
copyKey,
copied,
onCopy,
copyLabel,
copiedLabel,
emptyLabel,
className,
}) => {
const display = value || emptyLabel || "";
const canCopy = Boolean(value);
const isCopied = copied === copyKey;
return (
<div className={cn("space-y-1.5", className)}>
{label ? (
<div className="text-xs font-medium text-muted-foreground">{label}</div>
) : null}
<div className="group relative rounded-md border border-border/60 bg-muted/20">
<pre
className={cn(
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
!value && "text-muted-foreground",
)}
>
{display}
</pre>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canCopy}
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => void onCopy(copyKey, value)}
aria-label={isCopied ? copiedLabel : copyLabel}
title={isCopied ? copiedLabel : copyLabel}
>
{isCopied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
</Button>
</div>
</div>
);
};
+70 -59
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
import { HelpCircle, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import {
readExternalMcpApprovalAlertMode,
@@ -14,7 +14,18 @@ import {
writeExternalMcpMode,
writeExternalMcpSilentSessions,
writeSessionIdleTimeoutMinutes,
readExternalMcpHttpAllowCustomBind,
readExternalMcpHttpClientHost,
readExternalMcpHttpEnabled,
readExternalMcpHttpListenAddress,
readExternalMcpHttpPort,
writeExternalMcpHttpAllowCustomBind,
writeExternalMcpHttpClientHost,
writeExternalMcpHttpEnabled,
writeExternalMcpHttpListenAddress,
writeExternalMcpHttpPort,
type ExternalMcpApprovalAlertMode,
type ExternalMcpHttpSettings,
type ExternalMcpMode,
useExternalMcpToggleState,
} from "../../../../application/state/useExternalMcpToggleState";
@@ -22,70 +33,14 @@ import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui";
import { CopyableCodeBlock } from "./CopyableCodeBlock";
import { ExternalMcpHttpSection, type ExternalMcpHttpViewStatus } from "./ExternalMcpHttpSection";
import { getBridge } from "./types";
type ExternalMcpClient = "codex" | "claude" | "grok" | "cursor" | "other";
const CLIENT_TABS: ExternalMcpClient[] = ["codex", "claude", "grok", "cursor", "other"];
type CopyableCodeBlockProps = {
label?: string;
value: string;
copyKey: string;
copied: string | null;
onCopy: (key: string, text: string) => void;
copyLabel: string;
copiedLabel: string;
emptyLabel?: string;
className?: string;
};
const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
label,
value,
copyKey,
copied,
onCopy,
copyLabel,
copiedLabel,
emptyLabel,
className,
}) => {
const display = value || emptyLabel || "";
const canCopy = Boolean(value);
const isCopied = copied === copyKey;
return (
<div className={cn("space-y-1.5", className)}>
{label ? (
<div className="text-xs font-medium text-muted-foreground">{label}</div>
) : null}
<div className="group relative rounded-md border border-border/60 bg-muted/20">
<pre
className={cn(
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
!value && "text-muted-foreground",
)}
>
{display}
</pre>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canCopy}
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => void onCopy(copyKey, value)}
aria-label={isCopied ? copiedLabel : copyLabel}
title={isCopied ? copiedLabel : copyLabel}
>
{isCopied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
</Button>
</div>
</div>
);
};
type ExternalMcpStatus = {
ok: boolean;
enabled?: boolean;
@@ -100,6 +55,7 @@ type ExternalMcpStatus = {
sessionIdleTimeoutMinutes?: number;
permissionMode?: string;
error?: string | null;
http?: ExternalMcpHttpViewStatus | null;
};
type ClientSetupStatus = {
@@ -305,6 +261,14 @@ export const ExternalMcpCard: React.FC = () => {
);
const [sessionIdleTimeoutMinutes, setSessionIdleTimeoutRaw] = useState<number>(() => readSessionIdleTimeoutMinutes());
const [silentSessions, setSilentSessionsRaw] = useState<boolean>(() => readExternalMcpSilentSessions());
const [httpSettings, setHttpSettings] = useState<ExternalMcpHttpSettings>(() => ({
enabled: readExternalMcpHttpEnabled(),
port: readExternalMcpHttpPort(),
allowCustomBind: readExternalMcpHttpAllowCustomBind(),
listenAddress: readExternalMcpHttpListenAddress(),
clientHost: readExternalMcpHttpClientHost(),
}));
const [isRotatingHttpToken, setIsRotatingHttpToken] = useState(false);
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex");
const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null);
@@ -359,6 +323,42 @@ export const ExternalMcpCard: React.FC = () => {
writeExternalMcpSilentSessions(nextSilentSessions);
}, []);
const applyHttpSettings = useCallback(async (next: ExternalMcpHttpSettings) => {
const normalized: ExternalMcpHttpSettings = {
enabled: writeExternalMcpHttpEnabled(next.enabled),
port: writeExternalMcpHttpPort(next.port),
allowCustomBind: writeExternalMcpHttpAllowCustomBind(next.allowCustomBind),
listenAddress: writeExternalMcpHttpListenAddress(next.listenAddress),
clientHost: writeExternalMcpHttpClientHost(next.clientHost),
};
setHttpSettings(normalized);
const nextStatus = await getBridge()?.externalMcpSetConfig?.({
httpEnabled: normalized.enabled,
httpPort: normalized.port,
httpAllowCustomBind: normalized.allowCustomBind,
httpListenAddress: normalized.listenAddress,
httpClientHost: normalized.clientHost,
});
if (nextStatus && typeof nextStatus === "object") {
setStatus(nextStatus as ExternalMcpStatus);
}
}, []);
const rotateHttpToken = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.externalMcpSetConfig) return;
setIsRotatingHttpToken(true);
setActionMessage(null);
try {
const nextStatus = await bridge.externalMcpSetConfig({ rotateHttpToken: true });
if (nextStatus && typeof nextStatus === "object") {
setStatus(nextStatus as ExternalMcpStatus);
}
} finally {
setIsRotatingHttpToken(false);
}
}, []);
const refreshStatus = useCallback(async (options?: { quiet?: boolean; clients?: boolean }) => {
const bridge = getBridge();
const includeClients = options?.clients !== false;
@@ -806,6 +806,17 @@ export const ExternalMcpCard: React.FC = () => {
</SettingRow>
</SettingCard>
<ExternalMcpHttpSection
externalMcpEnabled={enabled}
settings={httpSettings}
status={status?.http || null}
copied={copied}
rotating={isRotatingHttpToken}
onCopy={copyText}
onChange={(next) => { void applyHttpSettings(next); }}
onRotateToken={() => { void rotateHttpToken(); }}
/>
<div className="space-y-2">
<div className="flex min-h-8 items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
@@ -0,0 +1,323 @@
import React, { useState } from "react";
import {
buildClaudeHttpAddCommand,
buildCodexHttpEnvLine,
buildCodexHttpToml,
buildExternalMcpHttpUrl,
buildGenericHttpMcpJson,
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
} from "../../../../domain/externalMcpHttp";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
import { SettingCard, SettingRow, Toggle } from "../../settings-ui";
import { CopyableCodeBlock } from "./CopyableCodeBlock";
export type ExternalMcpHttpViewStatus = {
enabled?: boolean;
listening?: boolean;
port?: number;
listenAddress?: string;
allowCustomBind?: boolean;
clientHost?: string;
url?: string | null;
bearerToken?: string | null;
state?: string;
error?: string | null;
errorCode?: string | null;
};
export type ExternalMcpHttpViewSettings = {
enabled: boolean;
port: number;
allowCustomBind: boolean;
listenAddress: string;
clientHost: string;
};
type ExternalMcpHttpSectionProps = {
externalMcpEnabled: boolean;
settings: ExternalMcpHttpViewSettings;
status: ExternalMcpHttpViewStatus | null;
copied: string | null;
rotating: boolean;
onCopy: (key: string, text: string) => void;
onChange: (next: ExternalMcpHttpViewSettings) => void;
onRotateToken: () => void;
};
function isWildcardAddress(value: string): boolean {
const address = value.trim();
return address === "0.0.0.0" || address === "::";
}
function isIpAddress(value: string): boolean {
const address = value.trim();
const ipv4 = address.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4) {
return ipv4.slice(1).every((part) => Number(part) <= 255 && String(Number(part)) === part);
}
return address.includes(":") && /^[0-9a-fA-F:]+$/.test(address);
}
function parsePort(value: string): number | null {
if (!/^\d+$/.test(value.trim())) return null;
const port = Number(value);
if (!Number.isInteger(port) || port < 1024 || port > 65535) return null;
return port;
}
export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
externalMcpEnabled,
settings,
status,
copied,
rotating,
onCopy,
onChange,
onRotateToken,
}) => {
const { t } = useI18n();
const [portDraft, setPortDraft] = useState(String(settings.port || DEFAULT_EXTERNAL_MCP_HTTP_PORT));
const [addressDraft, setAddressDraft] = useState(settings.listenAddress || "0.0.0.0");
const [clientHostDraft, setClientHostDraft] = useState(settings.clientHost || "");
const [portError, setPortError] = useState(false);
const [addressError, setAddressError] = useState(false);
const [clientHostError, setClientHostError] = useState(false);
const listenAddress = settings.allowCustomBind
? (status?.listenAddress || settings.listenAddress)
: "127.0.0.1";
const advertisedHost = status?.clientHost ?? settings.clientHost;
const url = status
? (status.url || "")
: buildExternalMcpHttpUrl(listenAddress, settings.port, advertisedHost);
const showClientHost = settings.allowCustomBind && (
isWildcardAddress(settings.listenAddress) || isWildcardAddress(addressDraft)
);
const token = status?.bearerToken || "";
const listening = Boolean(status?.listening);
const statusLabel = listening
? t("ai.externalMcp.http.listening")
: t("ai.externalMcp.http.notListening");
const statusClass = status?.errorCode
? "text-destructive"
: listening
? "text-emerald-500"
: "text-muted-foreground";
let errorText = "";
if (portError) errorText = t("ai.externalMcp.http.invalidPort");
else if (addressError) errorText = t("ai.externalMcp.http.invalidAddress");
else if (clientHostError) errorText = t("ai.externalMcp.http.invalidClientHost");
else if (status?.errorCode === "client_host_required") errorText = t("ai.externalMcp.http.clientHostRequired");
else if (status?.errorCode === "port_in_use") {
errorText = t("ai.externalMcp.http.portInUse", { port: String(status.port ?? settings.port) });
} else if (status?.errorCode === "invalid_address") errorText = t("ai.externalMcp.http.invalidAddress");
else if (status?.errorCode === "invalid_port") errorText = t("ai.externalMcp.http.invalidPort");
else if (status?.errorCode === "listen_failed") errorText = status.error || t("ai.externalMcp.http.listenFailed");
const commitPort = () => {
const port = parsePort(portDraft);
if (port == null) {
setPortError(true);
return;
}
setPortError(false);
setPortDraft(String(port));
if (port !== settings.port) onChange({ ...settings, port });
};
const commitClientHost = () => {
const clientHost = clientHostDraft.trim();
if (clientHost && (!isIpAddress(clientHost) || isWildcardAddress(clientHost))) {
setClientHostError(true);
return;
}
setClientHostError(false);
setClientHostDraft(clientHost);
if (clientHost !== settings.clientHost) onChange({ ...settings, clientHost });
};
const commitAddress = () => {
const address = addressDraft.trim();
if (!isIpAddress(address)) {
setAddressError(true);
return;
}
setAddressError(false);
setAddressDraft(address);
if (address !== settings.listenAddress) onChange({ ...settings, listenAddress: address });
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.http.title")}</div>
<div className={cn("text-xs font-medium", statusClass)}>{statusLabel}</div>
</div>
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.description")}</p>
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.legacySseNote")}</p>
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
<SettingRow
anchorId="ai-external-mcp-http"
label={t("ai.externalMcp.http.enable")}
>
<Toggle
checked={settings.enabled}
onChange={(enabled) => onChange({ ...settings, enabled })}
ariaLabel={t("ai.externalMcp.http.enable")}
/>
</SettingRow>
<SettingRow
label={t("ai.externalMcp.http.port")}
description={t("ai.externalMcp.http.portHint")}
>
<input
type="number"
aria-label={t("ai.externalMcp.http.port")}
min={1024}
max={65535}
value={portDraft}
onChange={(event) => {
setPortDraft(event.currentTarget.value);
setPortError(false);
}}
onBlur={commitPort}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
className="w-24 rounded-md border border-border/60 bg-background px-2 py-1 text-sm"
/>
</SettingRow>
<SettingRow
label={t("ai.externalMcp.http.allowCustomBind")}
description={t("ai.externalMcp.http.allowCustomBind.description")}
>
<Toggle
checked={settings.allowCustomBind}
onChange={(allowCustomBind) => onChange({ ...settings, allowCustomBind })}
ariaLabel={t("ai.externalMcp.http.allowCustomBind")}
/>
</SettingRow>
{settings.allowCustomBind ? (
<SettingRow label={t("ai.externalMcp.http.listenAddress")}>
<input
type="text"
aria-label={t("ai.externalMcp.http.listenAddress")}
placeholder={t("ai.externalMcp.http.listenAddress.placeholder")}
value={addressDraft}
onChange={(event) => {
setAddressDraft(event.currentTarget.value);
setAddressError(false);
}}
onBlur={commitAddress}
className="w-36 rounded-md border border-border/60 bg-background px-2 py-1 font-mono text-sm"
/>
</SettingRow>
) : null}
{showClientHost ? (
<SettingRow
label={t("ai.externalMcp.http.clientHost")}
description={t("ai.externalMcp.http.clientHost.description")}
>
<input
type="text"
aria-label={t("ai.externalMcp.http.clientHost")}
placeholder={t("ai.externalMcp.http.clientHost.placeholder")}
value={clientHostDraft}
onChange={(event) => {
setClientHostDraft(event.currentTarget.value);
setClientHostError(false);
}}
onBlur={commitClientHost}
className="w-36 rounded-md border border-border/60 bg-background px-2 py-1 font-mono text-sm"
/>
</SettingRow>
) : null}
</SettingCard>
{settings.allowCustomBind ? (
<p className="text-xs text-amber-500 leading-5">{t("ai.externalMcp.http.warning")}</p>
) : null}
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.wslHint")}</p>
{settings.enabled && !externalMcpEnabled ? (
<p className="text-xs text-amber-500">{t("ai.externalMcp.http.requiresExternalMcp")}</p>
) : null}
{errorText ? <p className="text-xs text-destructive">{errorText}</p> : null}
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
<CopyableCodeBlock
label={t("ai.externalMcp.http.url")}
value={url}
copyKey="http-url"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.http.token")}
value={token}
copyKey="http-token"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.regenerateHint")}</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={!externalMcpEnabled || !settings.enabled || rotating}
onClick={onRotateToken}
>
{t("ai.externalMcp.http.regenerate")}
</Button>
</div>
<CopyableCodeBlock
label={t("ai.externalMcp.http.codexSnippet")}
value={buildCodexHttpToml(url)}
copyKey="http-codex"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.http.envLine")}
value={token ? buildCodexHttpEnvLine(token) : ""}
copyKey="http-env"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.http.claudeCommand")}
value={token ? buildClaudeHttpAddCommand(url, token) : ""}
copyKey="http-claude"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.http.genericSnippet")}
value={token ? buildGenericHttpMcpJson(url, token) : ""}
copyKey="http-json"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
</div>
</div>
);
};
+6
View File
@@ -177,6 +177,12 @@ export interface NetcattyAiBridge {
mode?: 'temporary' | 'persistent';
idleTimeoutMinutes?: number;
sessionIdleTimeoutMinutes?: number;
httpEnabled?: boolean;
httpPort?: number;
httpAllowCustomBind?: boolean;
httpListenAddress?: string;
httpClientHost?: string;
rotateHttpToken?: boolean;
}) => Promise<Record<string, unknown>>;
externalMcpGetUniversalSetupPrompt?: () => Promise<{
ok: boolean;
+54
View File
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import test from "node:test";
import {
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
buildClaudeHttpAddCommand,
buildCodexHttpEnvLine,
buildCodexHttpToml,
buildExternalMcpHttpUrl,
buildGenericHttpMcpJson,
} from "./externalMcpHttp.ts";
const require = createRequire(import.meta.url);
const security = require("../electron/bridges/externalMcp/httpSecurity.cjs");
test("HTTP client URL stays on loopback unless a specific listen address is set", () => {
assert.equal(DEFAULT_EXTERNAL_MCP_HTTP_PORT, security.DEFAULT_EXTERNAL_MCP_HTTP_PORT);
assert.equal(
buildExternalMcpHttpUrl("127.0.0.1", 47391),
"http://127.0.0.1:47391/mcp",
);
assert.equal(buildExternalMcpHttpUrl("0.0.0.0", 47391), "");
assert.equal(
buildExternalMcpHttpUrl("0.0.0.0", 47391, "172.22.0.1"),
"http://172.22.0.1:47391/mcp",
);
assert.equal(
buildExternalMcpHttpUrl("172.22.0.1", 47391),
"http://172.22.0.1:47391/mcp",
);
});
test("HTTP snippets use Codex env bearer form, Claude http transport, and generic JSON", () => {
const url = "http://127.0.0.1:47391/mcp";
const token = "abc123";
const toml = buildCodexHttpToml(url);
assert.match(toml, /\[mcp_servers\.netcatty-http\]/);
assert.match(toml, /url = "http:\/\/127\.0\.0\.1:47391\/mcp"/);
assert.match(toml, /bearer_token_env_var = "NETCATTY_MCP_BEARER_TOKEN"/);
assert.equal(buildCodexHttpEnvLine(token), "export NETCATTY_MCP_BEARER_TOKEN='abc123'");
assert.equal(
buildCodexHttpEnvLine("a'b"),
"export NETCATTY_MCP_BEARER_TOKEN='a'\\''b'",
);
assert.equal(
buildClaudeHttpAddCommand(url, token),
"claude mcp add --transport http netcatty-http 'http://127.0.0.1:47391/mcp' --header 'Authorization: Bearer abc123'",
);
const generic = JSON.parse(buildGenericHttpMcpJson(url, token));
assert.equal(generic.mcpServers["netcatty-http"].type, "http");
assert.equal(generic.mcpServers["netcatty-http"].url, url);
assert.equal(generic.mcpServers["netcatty-http"].headers.Authorization, "Bearer abc123");
});
+58
View File
@@ -0,0 +1,58 @@
export const EXTERNAL_MCP_HTTP_BEARER_ENV = "NETCATTY_MCP_BEARER_TOKEN";
export const DEFAULT_EXTERNAL_MCP_HTTP_PORT = 47391;
export const DEFAULT_EXTERNAL_MCP_HTTP_HOST = "127.0.0.1";
export const EXTERNAL_MCP_HTTP_PATH = "/mcp";
function formatHostForUrl(host: string): string {
if (host.includes(":") && !host.startsWith("[")) return `[${host}]`;
return host;
}
function isWildcardBind(address: string): boolean {
return address === "0.0.0.0" || address === "::";
}
export function buildExternalMcpHttpUrl(listenAddress: string, port: number, clientHost = ""): string {
const host = isWildcardBind(listenAddress) ? clientHost.trim() : listenAddress;
if (!host || isWildcardBind(host)) return "";
return `http://${formatHostForUrl(host)}:${port}${EXTERNAL_MCP_HTTP_PATH}`;
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
export function buildCodexHttpToml(url: string): string {
return [
"[mcp_servers.netcatty-http]",
`url = ${JSON.stringify(url)}`,
`bearer_token_env_var = ${JSON.stringify(EXTERNAL_MCP_HTTP_BEARER_ENV)}`,
].join("\n");
}
export function buildCodexHttpEnvLine(token: string): string {
return `export ${EXTERNAL_MCP_HTTP_BEARER_ENV}=${shellQuote(token)}`;
}
export function buildClaudeHttpAddCommand(url: string, token: string): string {
return [
"claude mcp add --transport http netcatty-http",
shellQuote(url),
"--header",
shellQuote(`Authorization: Bearer ${token}`),
].join(" ");
}
export function buildGenericHttpMcpJson(url: string, token: string): string {
return JSON.stringify({
mcpServers: {
"netcatty-http": {
type: "http",
url,
headers: {
Authorization: `Bearer ${token}`,
},
},
},
}, null, 2);
}
+9
View File
@@ -714,6 +714,15 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
labelKey: "ai.externalMcp.title",
keywords: ["mcp"],
},
{
id: "ai-external-mcp-http",
tab: "ai",
aiSubTab: "tools",
labelKey: "ai.externalMcp.http.title",
descriptionKey: "ai.externalMcp.http.description",
sectionKey: "ai.externalMcp.title",
keywords: ["http", "sse", "streamable", "wsl", "codex"],
},
{
id: "ai-external-mcp-approval-alert",
tab: "ai",
@@ -0,0 +1,201 @@
"use strict";
const crypto = require("node:crypto");
const net = require("node:net");
const DEFAULT_EXTERNAL_MCP_HTTP_PORT = 47391;
const DEFAULT_EXTERNAL_MCP_HTTP_HOST = "127.0.0.1";
const EXTERNAL_MCP_HTTP_PATH = "/mcp";
const MIN_EXTERNAL_MCP_HTTP_PORT = 1024;
const MAX_EXTERNAL_MCP_HTTP_PORT = 65535;
const EXTERNAL_MCP_HTTP_BEARER_ENV = "NETCATTY_MCP_BEARER_TOKEN";
function isWildcardBind(address) {
return address === "0.0.0.0" || address === "::";
}
function unbracketHostname(hostname) {
const host = String(hostname || "");
if (host.startsWith("[") && host.endsWith("]") && host.length > 2) return host.slice(1, -1);
return host;
}
function isLoopbackHost(hostname) {
const host = unbracketHostname(hostname).toLowerCase();
return host === "localhost" || host === "127.0.0.1" || host === "::1";
}
function isIpLiteral(hostname) {
return net.isIP(unbracketHostname(hostname)) !== 0;
}
function normalizeHttpPort(value) {
const parsed = typeof value === "number" ? value : Number(String(value ?? "").trim());
if (!Number.isInteger(parsed) || parsed < MIN_EXTERNAL_MCP_HTTP_PORT || parsed > MAX_EXTERNAL_MCP_HTTP_PORT) {
return {
ok: false,
errorCode: "invalid_port",
error: `Enter a port from ${MIN_EXTERNAL_MCP_HTTP_PORT} to ${MAX_EXTERNAL_MCP_HTTP_PORT}.`,
};
}
return { ok: true, port: parsed };
}
function normalizeListenAddress(value) {
const address = String(value ?? "").trim();
if (!address || net.isIP(address) === 0) {
return {
ok: false,
errorCode: "invalid_address",
error: "Listen address must be an IP address such as 0.0.0.0 or a vEthernet address.",
};
}
return { ok: true, address };
}
function resolveEffectiveListenAddress({ allowCustomBind, listenAddress }) {
if (!allowCustomBind) return DEFAULT_EXTERNAL_MCP_HTTP_HOST;
const normalized = normalizeListenAddress(listenAddress);
if (!normalized.ok) {
const error = new Error(normalized.error);
error.code = "INVALID_ADDRESS";
error.errorCode = normalized.errorCode;
throw error;
}
return normalized.address;
}
function formatHostForUrl(host) {
if (host.includes(":") && !host.startsWith("[")) return `[${host}]`;
return host;
}
function normalizeClientHost(value) {
const address = String(value ?? "").trim();
if (!address) return { ok: true, address: "" };
if (net.isIP(address) === 0 || isWildcardBind(address)) {
return {
ok: false,
errorCode: "invalid_client_host",
error: "Client address must be a reachable IP, not 0.0.0.0 or ::.",
};
}
return { ok: true, address };
}
function buildClientUrl(listenAddress, port, clientHost) {
const host = isWildcardBind(listenAddress) ? String(clientHost || "").trim() : listenAddress;
if (!host || isWildcardBind(host) || net.isIP(host) === 0) return null;
return `http://${formatHostForUrl(host)}:${port}${EXTERNAL_MCP_HTTP_PATH}`;
}
function parseHostHeader(hostHeader) {
const raw = String(hostHeader || "").trim();
if (!raw) return null;
if (raw.startsWith("[")) {
const end = raw.indexOf("]");
if (end <= 1) return null;
const hostname = raw.slice(1, end);
const rest = raw.slice(end + 1);
if (rest === "") return { hostname, port: null };
if (!rest.startsWith(":")) return null;
const port = rest.slice(1);
if (!/^\d+$/.test(port)) return null;
return { hostname, port };
}
const colon = raw.lastIndexOf(":");
if (colon === -1) return { hostname: raw, port: null };
const hostname = raw.slice(0, colon);
const port = raw.slice(colon + 1);
if (!hostname || !/^\d+$/.test(port)) return null;
return { hostname, port };
}
function isAllowedHostname(hostname, listenAddress) {
const host = unbracketHostname(hostname);
if (isLoopbackHost(host)) return true;
if (isWildcardBind(listenAddress)) return isIpLiteral(host);
return host.toLowerCase() === String(listenAddress || "").toLowerCase();
}
function invalidHost() {
return { ok: false, status: 421, errorCode: "host", message: "Invalid Host header" };
}
function invalidOrigin() {
return { ok: false, status: 403, errorCode: "origin", message: "Invalid Origin header" };
}
function validateHostHeader(hostHeader, listenAddress, port) {
const parsed = parseHostHeader(hostHeader);
if (!parsed || parsed.port == null) return invalidHost();
if (parsed.port !== String(port)) return invalidHost();
if (!isAllowedHostname(parsed.hostname, listenAddress)) return invalidHost();
return { ok: true };
}
function validateOriginHeader(originHeader, listenAddress) {
if (originHeader == null || originHeader === "") return { ok: true };
let url;
try {
url = new URL(originHeader);
} catch {
return invalidOrigin();
}
if (url.protocol !== "http:" && url.protocol !== "https:") return invalidOrigin();
if (url.username || url.password) return invalidOrigin();
if (!isAllowedHostname(url.hostname, listenAddress)) return invalidOrigin();
return { ok: true };
}
function validateRequestHeaders({ hostHeader, originHeader, listenAddress, port }) {
const host = validateHostHeader(hostHeader, listenAddress, port);
if (!host.ok) return host;
return validateOriginHeader(originHeader, listenAddress);
}
function readBearerToken(authorizationHeader) {
if (typeof authorizationHeader !== "string") return "";
const match = /^Bearer\s+(\S+)\s*$/i.exec(authorizationHeader.trim());
return match ? match[1] : "";
}
function tokensEqual(presented, expected) {
if (typeof expected !== "string" || expected.length === 0) return false;
const presentedText = typeof presented === "string" ? presented : "";
const presentedHash = crypto.createHash("sha256").update(presentedText).digest();
const expectedHash = crypto.createHash("sha256").update(expected).digest();
return crypto.timingSafeEqual(presentedHash, expectedHash);
}
function describeListenError(error, port) {
if (error?.code === "EADDRINUSE") {
return { errorCode: "port_in_use", error: `Port ${port} is already in use.` };
}
if (error?.code === "INVALID_ADDRESS" || error?.errorCode === "invalid_address") {
return { errorCode: "invalid_address", error: error.message };
}
return { errorCode: "listen_failed", error: error?.message || String(error) };
}
module.exports = {
DEFAULT_EXTERNAL_MCP_HTTP_HOST,
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
EXTERNAL_MCP_HTTP_BEARER_ENV,
EXTERNAL_MCP_HTTP_PATH,
MAX_EXTERNAL_MCP_HTTP_PORT,
MIN_EXTERNAL_MCP_HTTP_PORT,
buildClientUrl,
describeListenError,
normalizeClientHost,
isAllowedHostname,
isLoopbackHost,
isWildcardBind,
normalizeHttpPort,
normalizeListenAddress,
parseHostHeader,
readBearerToken,
resolveEffectiveListenAddress,
tokensEqual,
validateRequestHeaders,
};
@@ -0,0 +1,137 @@
"use strict";
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const {
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
buildClientUrl,
normalizeHttpPort,
normalizeListenAddress,
readBearerToken,
resolveEffectiveListenAddress,
tokensEqual,
validateRequestHeaders,
} = require("./httpSecurity.cjs");
describe("external MCP HTTP security", () => {
it("keeps an unlikely fixed default port and rejects privileged or empty ports", () => {
assert.equal(DEFAULT_EXTERNAL_MCP_HTTP_PORT, 47391);
assert.equal(normalizeHttpPort(47391).port, 47391);
assert.equal(normalizeHttpPort("8080").port, 8080);
assert.equal(normalizeHttpPort(80).ok, false);
assert.equal(normalizeHttpPort(0).ok, false);
assert.equal(normalizeHttpPort("").ok, false);
assert.equal(normalizeHttpPort(65536).errorCode, "invalid_port");
});
it("binds loopback unless the custom address option is explicit", () => {
assert.equal(resolveEffectiveListenAddress({
allowCustomBind: false,
listenAddress: "0.0.0.0",
}), "127.0.0.1");
assert.equal(resolveEffectiveListenAddress({
allowCustomBind: true,
listenAddress: "0.0.0.0",
}), "0.0.0.0");
assert.equal(resolveEffectiveListenAddress({
allowCustomBind: true,
listenAddress: "172.22.0.1",
}), "172.22.0.1");
assert.equal(normalizeListenAddress("windows-host").ok, false);
assert.throws(
() => resolveEffectiveListenAddress({ allowCustomBind: true, listenAddress: "localhost" }),
(error) => error.code === "INVALID_ADDRESS",
);
});
it("does not advertise loopback for a wildcard bind", () => {
assert.equal(buildClientUrl("0.0.0.0", 47391), null);
assert.equal(buildClientUrl("::", 47391, "127.0.0.1"), "http://127.0.0.1:47391/mcp");
assert.equal(buildClientUrl("0.0.0.0", 47391, "172.22.0.1"), "http://172.22.0.1:47391/mcp");
assert.equal(buildClientUrl("0.0.0.0", 47391, "0.0.0.0"), null);
assert.equal(buildClientUrl("172.22.0.1", 47391), "http://172.22.0.1:47391/mcp");
});
it("accepts only an exact bearer token", () => {
assert.equal(readBearerToken("Bearer secret-token"), "secret-token");
assert.equal(readBearerToken("bearer secret-token"), "secret-token");
assert.equal(readBearerToken("Basic secret-token"), "");
assert.equal(readBearerToken("Bearer"), "");
assert.equal(tokensEqual("secret-token", "secret-token"), true);
assert.equal(tokensEqual("wrong", "secret-token"), false);
assert.equal(tokensEqual("", "secret-token"), false);
assert.equal(tokensEqual("secret-token", ""), false);
});
it("rejects DNS-rebinding host and origin values", () => {
const base = { listenAddress: "127.0.0.1", port: 47391 };
assert.equal(validateRequestHeaders({
...base,
hostHeader: "127.0.0.1:47391",
originHeader: undefined,
}).ok, true);
assert.equal(validateRequestHeaders({
...base,
hostHeader: "localhost:47391",
originHeader: "http://localhost:6274",
}).ok, true);
assert.equal(validateRequestHeaders({
...base,
hostHeader: "evil.example:47391",
originHeader: undefined,
}).status, 421);
assert.equal(validateRequestHeaders({
...base,
hostHeader: "127.0.0.1:47391",
originHeader: "https://evil.example",
}).status, 403);
assert.equal(validateRequestHeaders({
...base,
hostHeader: "127.0.0.1:80",
originHeader: undefined,
}).status, 421);
assert.equal(validateRequestHeaders({
...base,
hostHeader: "",
originHeader: undefined,
}).status, 421);
assert.equal(validateRequestHeaders({
listenAddress: "0.0.0.0",
port: 47391,
hostHeader: "172.22.0.1:47391",
originHeader: undefined,
}).ok, true);
assert.equal(validateRequestHeaders({
listenAddress: "0.0.0.0",
port: 47391,
hostHeader: "evil.example:47391",
originHeader: undefined,
}).status, 421);
assert.equal(validateRequestHeaders({
listenAddress: "172.22.0.1",
port: 47391,
hostHeader: "172.22.0.1:47391",
originHeader: "http://172.22.0.1:6274",
}).ok, true);
assert.equal(validateRequestHeaders({
listenAddress: "172.22.0.1",
port: 47391,
hostHeader: "8.8.8.8:47391",
originHeader: undefined,
}).status, 421);
assert.equal(validateRequestHeaders({
listenAddress: "::1",
port: 47391,
hostHeader: "[::1]:47391",
originHeader: "http://[::1]:6274",
}).ok, true);
assert.equal(validateRequestHeaders({
listenAddress: "::",
port: 47391,
hostHeader: "[2001:db8::1]:47391",
originHeader: "http://[2001:db8::1]:6274",
}).ok, true);
});
});
+550
View File
@@ -0,0 +1,550 @@
"use strict";
/**
* Optional Streamable HTTP endpoint for External MCP.
* Tool calls go through the same progressive MCP registry and
* mcpServerBridge.dispatchBuiltinRpc path as the stdio launcher.
* Legacy HTTP+SSE (protocol 2024-11-05, GET /sse + POST /messages) is not
* mounted: it is a second transport, not a free extra of Streamable HTTP.
*/
const http = require("node:http");
const crypto = require("node:crypto");
const { AsyncLocalStorage } = require("node:async_hooks");
const { EventEmitter } = require("node:events");
const { EXTERNAL_MCP_CHAT_SESSION_ID } = require("../../cli/externalMcpDiscoveryPath.cjs");
const { normalizeMcpJsonRpcMessage } = require("../../mcp/normalizeMcpCallArguments.cjs");
const {
DEFAULT_EXTERNAL_MCP_HTTP_HOST,
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
EXTERNAL_MCP_HTTP_PATH,
readBearerToken,
tokensEqual,
validateRequestHeaders,
} = require("./httpSecurity.cjs");
const MAX_BODY_BYTES = 4 * 1024 * 1024;
const DEFAULT_SESSION_IDLE_MS = 15 * 60 * 1000;
const DEFAULT_SESSION_SWEEP_MS = 60 * 1000;
const requestContext = new AsyncLocalStorage();
class ExternalMcpHttpClient extends EventEmitter {
constructor() {
super();
this.destroyed = false;
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.emit("close");
}
}
function trackConnections(server) {
const owned = new Set();
server.on("connection", (socket) => {
owned.add(socket);
socket.on("close", () => owned.delete(socket));
});
return owned;
}
function closeHttpServer(server, owned) {
return new Promise((resolve) => {
// requestTimeout is disabled so a half-open POST does not get cut off
// during a long tool call. close() waits for those sockets, so destroy
// them before awaiting the callback or disable/reconfigure hangs.
server.close(() => resolve());
if (!owned) return;
for (const socket of owned) socket.destroy();
owned.clear();
});
}
function jsonRpcError(message, code = -32000) {
return { jsonrpc: "2.0", error: { code, message }, id: null };
}
function sendJson(res, status, body, extraHeaders) {
if (res.headersSent || res.writableEnded) return;
const payload = JSON.stringify(body);
res.writeHead(status, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
"Cache-Control": "no-store",
...extraHeaders,
});
res.end(payload);
}
function headerValue(req, name) {
const value = req.headers[name];
if (Array.isArray(value)) return value[0] || "";
return typeof value === "string" ? value : "";
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
let settled = false;
const fail = (error) => {
if (settled) return;
settled = true;
reject(error);
};
req.on("data", (chunk) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
fail(Object.assign(new Error("Payload too large"), { statusCode: 413 }));
return;
}
chunks.push(chunk);
});
req.on("end", () => {
if (settled) return;
settled = true;
resolve(Buffer.concat(chunks).toString("utf8"));
});
req.on("error", fail);
});
}
function loadMcpRuntime() {
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
const { StreamableHTTPServerTransport } = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
const { isInitializeRequest } = require("@modelcontextprotocol/sdk/types.js");
const { getCatalogToolDescription } = require("../../mcp/catalogToolMetadata.cjs");
const { NETCATTY_MCP_SERVER_INSTRUCTIONS } = require("../../mcp/netcattyMcpInstructions.cjs");
const { registerProgressiveMcpTools } = require("../../mcp/progressiveMcpTools.cjs");
return {
McpServer,
StreamableHTTPServerTransport,
isInitializeRequest,
getCatalogToolDescription,
NETCATTY_MCP_SERVER_INSTRUCTIONS,
registerProgressiveMcpTools,
};
}
function createExternalMcpHttpServer(deps = {}) {
const chatSessionId = deps.chatSessionId || EXTERNAL_MCP_CHAT_SESSION_ID;
const dispatchRpc = deps.dispatchRpc;
const getPermissionMode = deps.getPermissionMode || (() => "confirm");
const isEnabled = deps.isEnabled || (() => true);
const recordActivity = deps.recordActivity || (() => {});
const attachExternalClient = deps.attachExternalClient || ((client) => () => client.destroy());
const getToken = deps.getToken || (() => "");
const sessionIdleMs = Number.isFinite(deps.sessionIdleMs) && deps.sessionIdleMs >= 0
? deps.sessionIdleMs
: DEFAULT_SESSION_IDLE_MS;
const sessionSweepMs = Number.isFinite(deps.sessionSweepMs) && deps.sessionSweepMs > 0
? deps.sessionSweepMs
: DEFAULT_SESSION_SWEEP_MS;
let mcpRuntime = null;
let httpServer = null;
let listenAddress = DEFAULT_EXTERNAL_MCP_HTTP_HOST;
let listenPort = null;
let sessions = new Map();
let activeSockets = new Set();
let startChain = Promise.resolve();
let generation = 0;
let sweepTimer = null;
function runtime() {
if (!mcpRuntime) mcpRuntime = loadMcpRuntime();
return mcpRuntime;
}
function guardWriteOperation() {
if (getPermissionMode() === "observer") {
return 'Operation denied: permission mode is "observer" (read-only). Change to "confirm" or "auto" in Settings → AI → Safety to allow this action.';
}
return null;
}
async function rpcCall(method, params) {
if (!isEnabled()) {
throw new Error("External MCP is disabled. Re-enable it in Netcatty Settings → AI.");
}
if (typeof dispatchRpc !== "function") {
throw new Error("External MCP HTTP dispatch is unavailable.");
}
try {
recordActivity();
} catch {
// Activity bookkeeping must not fail the tool call.
}
const store = requestContext.getStore();
return await dispatchRpc(method, {
...(params || {}),
chatSessionId,
}, {
externalClient: store?.externalClient || null,
});
}
function createSessionServer() {
const {
McpServer,
getCatalogToolDescription,
NETCATTY_MCP_SERVER_INSTRUCTIONS,
registerProgressiveMcpTools,
} = runtime();
const server = new McpServer({
name: "netcatty-remote-hosts",
version: "1.0.0",
}, {
instructions: NETCATTY_MCP_SERVER_INSTRUCTIONS,
});
server.resource(
"environment",
"netcatty://context",
{ description: "Current Netcatty workspace context: connected hosts, session IDs, and environment description." },
async () => {
const ctx = await rpcCall("netcatty/getContext", { chatSessionId });
return {
contents: [{
uri: "netcatty://context",
mimeType: "application/json",
text: JSON.stringify(ctx, null, 2),
}],
};
},
);
registerProgressiveMcpTools(server, {
rpcCall,
scopeParams: { chatSessionId },
guardWriteOperation,
catalogDescription(toolName, fallback) {
return getCatalogToolDescription(toolName) || fallback;
},
});
return server;
}
function dropSession(sessionId) {
const session = sessions.get(sessionId);
if (!session || session.closing) return;
session.closing = true;
sessions.delete(sessionId);
try {
session.detach?.();
} catch {
// Best-effort approval-client detach.
}
void Promise.resolve(session.server?.close?.()).catch(() => {});
}
function expireIdleSessions() {
const now = Date.now();
for (const [sessionId, session] of sessions) {
if (session.inFlight > 0) continue;
if (now - session.lastActivityAt < sessionIdleMs) continue;
dropSession(sessionId);
}
}
function startSessionSweep() {
if (sweepTimer) clearInterval(sweepTimer);
sweepTimer = setInterval(expireIdleSessions, sessionSweepMs);
sweepTimer.unref?.();
}
function stopSessionSweep() {
if (!sweepTimer) return;
clearInterval(sweepTimer);
sweepTimer = null;
}
async function withSession(session, run) {
session.inFlight += 1;
session.lastActivityAt = Date.now();
try {
await run();
} finally {
session.inFlight = Math.max(0, session.inFlight - 1);
session.lastActivityAt = Date.now();
}
}
async function closeSessions() {
const current = Array.from(sessions.values());
sessions = new Map();
await Promise.all(current.map(async (session) => {
try {
await session.transport?.close?.();
} catch {
// Closing a finished transport is best-effort.
}
try {
session.detach?.();
} catch {
// Best-effort approval-client detach.
}
try {
await session.server?.close?.();
} catch {
// Best-effort MCP server close.
}
}));
}
async function openSession(req, res, body) {
const { StreamableHTTPServerTransport } = runtime();
const externalClient = new ExternalMcpHttpClient();
let detach = () => externalClient.destroy();
try {
const attached = attachExternalClient(externalClient);
if (typeof attached === "function") detach = attached;
} catch {
detach = () => externalClient.destroy();
}
const server = createSessionServer();
const session = {
server,
externalClient,
detach,
transport: null,
sessionId: null,
lastActivityAt: Date.now(),
inFlight: 0,
closing: false,
};
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
onsessioninitialized: (sessionId) => {
session.sessionId = sessionId;
sessions.set(sessionId, session);
},
});
session.transport = transport;
transport.onclose = () => {
if (session.sessionId) dropSession(session.sessionId);
};
transport.onmessage = (message) => {
normalizeMcpJsonRpcMessage(message);
};
try {
await server.connect(transport);
await withSession(session, () => (
requestContext.run({ externalClient }, () => transport.handleRequest(req, res, body))
));
// The SDK answers 406/415 before onsessioninitialized. That is not a throw,
// so the synthetic approval client and MCP server would otherwise stay up.
if (!session.sessionId) {
detach();
try {
await server.close?.();
} catch {
// Ignore close failures after a rejected handshake.
}
}
} catch (error) {
if (session.sessionId) dropSession(session.sessionId);
else detach();
try {
await server.close?.();
} catch {
// Ignore close failures after a failed handshake.
}
throw error;
}
}
async function routeRequest(req, res) {
const pathname = new URL(req.url || "/", "http://127.0.0.1").pathname;
if (pathname !== EXTERNAL_MCP_HTTP_PATH) {
sendJson(res, 404, jsonRpcError("Not found"));
return;
}
if (!isEnabled()) {
sendJson(res, 503, jsonRpcError("External MCP is disabled."));
return;
}
const presented = readBearerToken(headerValue(req, "authorization"));
if (!tokensEqual(presented, getToken())) {
sendJson(res, 401, jsonRpcError("Unauthorized", -32001));
return;
}
const headerCheck = validateRequestHeaders({
hostHeader: headerValue(req, "host"),
originHeader: headerValue(req, "origin"),
listenAddress,
port: listenPort,
});
if (!headerCheck.ok) {
sendJson(res, headerCheck.status, jsonRpcError(headerCheck.message));
return;
}
const method = req.method || "GET";
if (method === "POST") {
let raw = "";
try {
raw = await readBody(req);
} catch (error) {
if (error.statusCode === 413) {
res.once("finish", () => req.destroy());
}
sendJson(res, error.statusCode || 400, jsonRpcError(error.statusCode === 413 ? "Payload too large" : "Invalid request body"));
return;
}
// Auth ran before the body so a half-open POST could not stall the
// socket forever. Recheck after the body arrives so a token rotated
// while this request was incomplete cannot still dispatch.
if (!tokensEqual(presented, getToken())) {
sendJson(res, 401, jsonRpcError("Unauthorized", -32001));
return;
}
let body;
try {
body = raw ? JSON.parse(raw) : undefined;
} catch {
sendJson(res, 400, jsonRpcError("Invalid JSON"));
return;
}
const sessionId = headerValue(req, "mcp-session-id");
if (sessionId) {
const session = sessions.get(sessionId);
if (!session) {
sendJson(res, 404, jsonRpcError("Session not found"));
return;
}
await withSession(session, () => requestContext.run({ externalClient: session.externalClient }, () => (
session.transport.handleRequest(req, res, body)
)));
return;
}
if (!runtime().isInitializeRequest(body)) {
sendJson(res, 400, jsonRpcError("Bad Request: missing session"));
return;
}
await openSession(req, res, body);
return;
}
if (method === "GET" || method === "DELETE") {
const sessionId = headerValue(req, "mcp-session-id");
if (!sessionId) {
sendJson(res, 400, jsonRpcError("Bad Request: missing session"));
return;
}
const session = sessions.get(sessionId);
if (!session) {
sendJson(res, 404, jsonRpcError("Session not found"));
return;
}
await withSession(session, () => requestContext.run({ externalClient: session.externalClient }, () => (
session.transport.handleRequest(req, res)
)));
return;
}
sendJson(res, 405, jsonRpcError("Method not allowed"), { Allow: "GET, POST, DELETE" });
}
async function onRequest(req, res) {
try {
await routeRequest(req, res);
} catch (error) {
if (!res.headersSent) {
sendJson(res, 500, jsonRpcError(error?.message || "Internal error"));
}
}
}
async function startUnlocked({ host, port }) {
const nextGeneration = ++generation;
await stopUnlocked(nextGeneration);
if (nextGeneration !== generation) return getState();
listenAddress = host || DEFAULT_EXTERNAL_MCP_HTTP_HOST;
const server = http.createServer((req, res) => {
void onRequest(req, res);
});
server.requestTimeout = 0;
server.timeout = 0;
const ownedSockets = trackConnections(server);
try {
await new Promise((resolve, reject) => {
const fail = (error) => {
server.off("listening", succeed);
reject(error);
};
const succeed = () => {
server.off("error", fail);
resolve();
};
server.once("error", fail);
server.once("listening", succeed);
server.listen(port, listenAddress);
});
} catch (error) {
await closeHttpServer(server, ownedSockets);
throw error;
}
if (nextGeneration !== generation) {
await closeHttpServer(server, ownedSockets);
return getState();
}
httpServer = server;
activeSockets = ownedSockets;
startSessionSweep();
const address = server.address();
listenPort = address && typeof address === "object" ? address.port : port;
return getState();
}
async function stopUnlocked(expectedGeneration) {
stopSessionSweep();
const server = httpServer;
const ownedSockets = activeSockets;
httpServer = null;
activeSockets = new Set();
listenPort = null;
await closeSessions();
if (server) {
await closeHttpServer(server, ownedSockets);
}
if (expectedGeneration != null && expectedGeneration !== generation) return;
}
function start(options) {
const run = () => startUnlocked(options || {});
const result = startChain.then(run, run);
startChain = result.then(() => {}, () => {});
return result;
}
function stop() {
const nextGeneration = ++generation;
const run = () => stopUnlocked(nextGeneration);
const result = startChain.then(run, run);
startChain = result.then(() => {}, () => {});
return result;
}
function getState() {
return {
listening: Boolean(httpServer),
host: listenAddress,
port: listenPort,
};
}
return {
start,
stop,
getState,
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
};
}
module.exports = {
createExternalMcpHttpServer,
ExternalMcpHttpClient,
};
@@ -0,0 +1,373 @@
"use strict";
const http = require("node:http");
const net = require("node:net");
const { describe, it, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
const { StreamableHTTPClientTransport } = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
const { createExternalMcpHttpServer } = require("./httpServer.cjs");
const { EXTERNAL_MCP_CHAT_SESSION_ID } = require("../../cli/externalMcpDiscoveryPath.cjs");
const TOKEN = "test-bearer-token";
function postJson(url, { token, host, origin, body, sessionId, accept }) {
return new Promise((resolve, reject) => {
const target = new URL(url);
const payload = JSON.stringify(body);
const headers = {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"Content-Length": Buffer.byteLength(payload),
};
if (accept) headers.Accept = accept;
if (token) headers.Authorization = `Bearer ${token}`;
if (host) headers.Host = host;
if (origin) headers.Origin = origin;
if (sessionId) headers["mcp-session-id"] = sessionId;
const req = http.request({
hostname: target.hostname,
port: target.port,
path: target.pathname,
method: "POST",
headers,
}, (res) => {
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
resolve({
status: res.statusCode,
body: Buffer.concat(chunks).toString("utf8"),
sessionId: res.headers["mcp-session-id"] || "",
});
});
});
req.on("error", reject);
req.write(payload);
req.end();
});
}
function requestSession(url, method, sessionId) {
return new Promise((resolve, reject) => {
const target = new URL(url);
const headers = {
Accept: "application/json, text/event-stream",
Authorization: `Bearer ${TOKEN}`,
};
if (sessionId) headers["mcp-session-id"] = sessionId;
const req = http.request({
hostname: target.hostname,
port: target.port,
path: target.pathname,
method,
headers,
}, (res) => {
res.resume();
res.on("end", () => resolve({ status: res.statusCode }));
});
req.on("error", reject);
req.end();
});
}
async function startServer(overrides = {}) {
const calls = [];
const server = createExternalMcpHttpServer({
getToken: () => TOKEN,
isEnabled: () => true,
dispatchRpc: async (method, params, context) => {
calls.push({ method, params, context });
if (method === "netcatty/exec") {
return { ok: true, stdout: "from-handler", stderr: "", exitCode: 0 };
}
if (method === "netcatty/getContext") {
return { hosts: [] };
}
return { ok: true };
},
...overrides,
});
const state = await server.start({ host: "127.0.0.1", port: 0 });
return {
server,
calls,
url: `http://127.0.0.1:${state.port}/mcp`,
port: state.port,
};
}
describe("external MCP streamable HTTP server", () => {
let active = null;
afterEach(async () => {
if (active) {
await active.server.stop();
active = null;
}
});
it("rejects missing and wrong bearer tokens with the same status", async () => {
active = await startServer();
const body = { jsonrpc: "2.0", id: 1, method: "initialize", params: {} };
const missing = await postJson(active.url, { body });
const wrong = await postJson(active.url, { token: "nope", body });
assert.equal(missing.status, 401);
assert.equal(wrong.status, 401);
assert.equal(missing.body, wrong.body);
assert.equal(active.calls.length, 0);
});
it("rejects a request whose token was rotated while the body was incomplete", async () => {
let token = TOKEN;
let releaseAuth = () => {};
const authSeen = new Promise((resolve) => {
releaseAuth = resolve;
});
let tokenReads = 0;
active = await startServer({
getToken: () => {
tokenReads += 1;
if (tokenReads === 1) releaseAuth();
return token;
},
});
const payload = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {},
});
const result = await new Promise((resolve, reject) => {
const target = new URL(active.url);
const req = http.request({
hostname: target.hostname,
port: target.port,
path: target.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"Content-Length": Buffer.byteLength(payload),
Authorization: `Bearer ${TOKEN}`,
},
}, (res) => {
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
resolve({
status: res.statusCode,
body: Buffer.concat(chunks).toString("utf8"),
});
});
});
req.on("error", reject);
req.write(payload.slice(0, 8));
authSeen.then(() => {
token = "rotated-token";
req.end(payload.slice(8));
}).catch(reject);
});
await Promise.race([
authSeen,
new Promise((_, reject) => setTimeout(() => reject(new Error("auth was not checked")), 2000)),
]);
assert.equal(result.status, 401);
assert.ok(tokenReads >= 2);
assert.equal(active.calls.length, 0);
});
it("detaches the approval client when initialize is rejected before a session exists", async () => {
const attached = [];
active = await startServer({
attachExternalClient: (client) => {
attached.push(client);
return () => client.destroy();
},
});
const rejected = await postJson(active.url, {
token: TOKEN,
accept: "application/json",
body: {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "reject-test", version: "1.0.0" },
},
},
});
assert.equal(rejected.status, 406);
assert.equal(attached.length, 1);
assert.equal(attached[0].destroyed, true);
assert.equal(active.calls.length, 0);
});
it("rejects a rebinding Host and a foreign Origin before any tool dispatch", async () => {
active = await startServer();
const body = { jsonrpc: "2.0", id: 1, method: "initialize", params: {} };
const host = await postJson(active.url, {
token: TOKEN,
host: `evil.example:${active.port}`,
body,
});
const origin = await postJson(active.url, {
token: TOKEN,
origin: "https://evil.example",
body,
});
assert.equal(host.status, 421);
assert.equal(origin.status, 403);
assert.equal(active.calls.length, 0);
});
it("lists tools and dispatches terminal_execute through the shared RPC handler", async () => {
active = await startServer();
const client = new Client({ name: "netcatty-http-test", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(new URL(active.url), {
requestInit: { headers: { Authorization: `Bearer ${TOKEN}` } },
});
try {
await client.connect(transport);
const tools = await client.listTools();
assert.ok(tools.tools.some((tool) => tool.name === "terminal_execute"));
const result = await client.callTool({
name: "terminal_execute",
arguments: { sessionId: "sess-1", command: "pwd" },
});
assert.match(JSON.stringify(result), /from-handler/);
const execCall = active.calls.find((call) => call.method === "netcatty/exec");
assert.ok(execCall, "terminal_execute did not reach dispatchRpc");
assert.equal(execCall.params.chatSessionId, EXTERNAL_MCP_CHAT_SESSION_ID);
assert.equal(execCall.params.sessionId, "sess-1");
assert.equal(execCall.params.command, "pwd");
assert.ok(execCall.context.externalClient);
} finally {
await client.close();
}
});
it("expires an HTTP session that never sends DELETE", async () => {
active = await startServer({ sessionIdleMs: 40, sessionSweepMs: 15 });
const initialized = await postJson(active.url, {
token: TOKEN,
body: {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "expire-test", version: "1.0.0" },
},
},
});
assert.equal(initialized.status, 200);
assert.ok(initialized.sessionId);
const live = await postJson(active.url, {
token: TOKEN,
sessionId: initialized.sessionId,
body: { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
});
assert.equal(live.status, 200);
await new Promise((resolve) => setTimeout(resolve, 120));
const expired = await postJson(active.url, {
token: TOKEN,
sessionId: initialized.sessionId,
body: { jsonrpc: "2.0", id: 3, method: "tools/list", params: {} },
});
assert.equal(expired.status, 404);
const expiredGet = await requestSession(active.url, "GET", initialized.sessionId);
const expiredDelete = await requestSession(active.url, "DELETE", initialized.sessionId);
assert.equal(expiredGet.status, 404);
assert.equal(expiredDelete.status, 404);
const missing = await requestSession(active.url, "GET", "");
assert.equal(missing.status, 400);
});
it("is not listening after stop", async () => {
active = await startServer();
const port = active.port;
await active.server.stop();
await assert.rejects(
postJson(`http://127.0.0.1:${port}/mcp`, {
token: TOKEN,
body: { jsonrpc: "2.0", id: 1, method: "initialize", params: {} },
}),
(error) => error.code === "ECONNREFUSED",
);
assert.equal(active.server.getState().listening, false);
active = null;
});
it("returns 413 before closing an oversized request", async () => {
active = await startServer();
const payload = Buffer.alloc((4 * 1024 * 1024) + 64, 0x61);
const result = await new Promise((resolve, reject) => {
const target = new URL(active.url);
const req = http.request({
hostname: target.hostname,
port: target.port,
path: target.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"Content-Length": payload.length,
Authorization: `Bearer ${TOKEN}`,
},
}, (res) => {
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
resolve({
status: res.statusCode,
body: Buffer.concat(chunks).toString("utf8"),
});
});
});
req.on("error", reject);
req.end(payload);
});
assert.equal(result.status, 413);
assert.match(result.body, /Payload too large/);
});
it("stop finishes while a client holds an incomplete request", async () => {
active = await startServer();
const socket = net.connect(active.port, "127.0.0.1");
await new Promise((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
socket.write(`POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:${active.port}\r\nContent-Length: 100\r\n\r\n`);
let hung = false;
const timer = setTimeout(() => {
hung = true;
}, 2000);
await active.server.stop();
clearTimeout(timer);
socket.destroy();
assert.equal(hung, false);
assert.equal(active.server.getState().listening, false);
active = null;
});
it("reports port in use instead of switching ports", async () => {
const blocker = http.createServer((_req, res) => res.end("busy"));
await new Promise((resolve) => blocker.listen(0, "127.0.0.1", resolve));
const port = blocker.address().port;
const server = createExternalMcpHttpServer({ getToken: () => TOKEN });
active = { server, calls: [], url: "", port };
await assert.rejects(
server.start({ host: "127.0.0.1", port }),
(error) => error.code === "EADDRINUSE",
);
assert.equal(server.getState().listening, false);
await new Promise((resolve) => blocker.close(() => resolve()));
});
});
+204 -1
View File
@@ -25,6 +25,18 @@ const {
DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES,
normalizeSessionIdleTimeoutMinutes,
} = require("./mcpServerBridge/sessionIdleManager.cjs");
const { createExternalMcpHttpServer } = require("./externalMcp/httpServer.cjs");
const {
DEFAULT_EXTERNAL_MCP_HTTP_HOST,
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
buildClientUrl,
describeListenError,
isWildcardBind,
normalizeClientHost,
normalizeHttpPort,
normalizeListenAddress,
resolveEffectiveListenAddress,
} = require("./externalMcp/httpSecurity.cjs");
const EXTERNAL_MCP_MODE_TEMPORARY = "temporary";
const EXTERNAL_MCP_MODE_PERSISTENT = "persistent";
@@ -60,6 +72,7 @@ function createExternalMcpController(options = {}) {
readSkillContent: options.readSkillContent || readBundledNetcattySkillContent,
buildUniversalSetupPrompt: options.buildUniversalSetupPrompt || buildUniversalSetupPrompt,
randomBytes: options.randomBytes || ((size) => crypto.randomBytes(size)),
createHttpServer: options.createHttpServer || createExternalMcpHttpServer,
Date: options.Date || Date,
setTimeout: options.setTimeout || setTimeout,
clearTimeout: options.clearTimeout || clearTimeout,
@@ -86,6 +99,17 @@ function createExternalMcpController(options = {}) {
let codexSetup = null;
let claudeSetup = null;
let grokSetup = null;
let httpEnabled = false;
let httpPort = DEFAULT_EXTERNAL_MCP_HTTP_PORT;
let httpAllowCustomBind = false;
let httpListenAddress = "0.0.0.0";
let httpClientHost = "";
let httpListening = false;
let httpError = null;
let httpErrorCode = null;
let httpRuntime = null;
let httpApplyTail = Promise.resolve();
let httpGeneration = 0;
function getNow() {
return deps.Date.now();
@@ -143,6 +167,133 @@ function createExternalMcpController(options = {}) {
return bridge.getScopedSessionIds(deps.chatSessionId).length;
}
function getHttpRuntime() {
if (!httpRuntime) {
httpRuntime = deps.createHttpServer({
chatSessionId: deps.chatSessionId,
getToken: () => lastKnownToken || "",
isEnabled: () => isEnabled(),
recordActivity,
getPermissionMode: () => getBridge()?.getPermissionMode?.() || "confirm",
attachExternalClient: (client) => {
const bridge = getBridge();
if (typeof bridge?.attachExternalMcpClient === "function") {
return bridge.attachExternalMcpClient(client);
}
return () => client?.destroy?.();
},
dispatchRpc: async (method, params, context) => {
const bridge = getBridge();
if (typeof bridge?.dispatchBuiltinRpc !== "function") {
throw new Error("MCP bridge dispatch is unavailable.");
}
return await bridge.dispatchBuiltinRpc(method, {
...(params || {}),
chatSessionId: deps.chatSessionId,
}, {
externalSocket: context?.externalClient || null,
});
},
});
}
return httpRuntime;
}
function effectiveHttpListenAddress() {
try {
return resolveEffectiveListenAddress({
allowCustomBind: httpAllowCustomBind,
listenAddress: httpListenAddress,
});
} catch {
return httpAllowCustomBind ? httpListenAddress : DEFAULT_EXTERNAL_MCP_HTTP_HOST;
}
}
function buildHttpStatus() {
let httpState = "disabled";
if (httpEnabled && httpError) httpState = "error";
else if (httpEnabled && httpListening) httpState = "running";
else if (httpEnabled && enabled) httpState = "starting";
const listenAddress = effectiveHttpListenAddress();
const url = buildClientUrl(listenAddress, httpPort, httpClientHost);
let error = httpError;
let errorCode = httpErrorCode;
if (!errorCode && isWildcardBind(listenAddress) && !url) {
errorCode = "client_host_required";
error = "Enter the IP clients should connect to. 0.0.0.0 is not a reachable address.";
}
return {
enabled: httpEnabled,
listening: httpListening,
port: httpPort,
listenAddress,
allowCustomBind: httpAllowCustomBind,
clientHost: httpClientHost,
url,
bearerToken: httpEnabled && enabled && lastKnownToken ? lastKnownToken : null,
state: httpState,
error,
errorCode,
};
}
function scheduleHttpApply() {
const generation = ++httpGeneration;
const run = () => applyHttpOnce(generation);
httpApplyTail = httpApplyTail.then(run, run);
return httpApplyTail;
}
async function applyHttpOnce(generation) {
if (!enabled || !httpEnabled) {
if (httpRuntime) {
try {
await httpRuntime.stop();
} catch {
// Stopping a listener that never bound is best-effort.
}
}
if (generation !== httpGeneration) return;
httpListening = false;
if (!httpEnabled) {
httpError = null;
httpErrorCode = null;
}
return;
}
let host = DEFAULT_EXTERNAL_MCP_HTTP_HOST;
try {
host = resolveEffectiveListenAddress({
allowCustomBind: httpAllowCustomBind,
listenAddress: httpListenAddress,
});
await getHttpRuntime().start({ host, port: httpPort });
if (generation !== httpGeneration) {
await getHttpRuntime().stop();
return;
}
httpListening = true;
httpError = null;
httpErrorCode = null;
} catch (listenError) {
if (generation !== httpGeneration) return;
httpListening = false;
const described = describeListenError(listenError, httpPort);
httpError = described.error;
httpErrorCode = described.errorCode;
try {
await getHttpRuntime().stop();
} catch {
// The failed bind should not leave a socket behind.
}
}
}
function whenHttpSettled() {
return httpApplyTail;
}
function buildStatus() {
const bridge = getBridge();
const hostRunning = Boolean(bridge && typeof bridge.getOrCreateHost === "function" && lastKnownPort);
@@ -164,6 +315,7 @@ function createExternalMcpController(options = {}) {
permissionMode: bridge?.getPermissionMode?.() || "confirm",
hostRunning,
error,
http: buildHttpStatus(),
};
}
@@ -215,6 +367,7 @@ function createExternalMcpController(options = {}) {
if (typeof sessionSyncHandler === "function") {
await sessionSyncHandler();
}
await scheduleHttpApply();
}
async function stopActiveRuntime() {
@@ -290,6 +443,7 @@ function createExternalMcpController(options = {}) {
// Best-effort cancel flag.
}
}
await scheduleHttpApply();
}
async function setEnabledUnlocked(nextEnabled) {
@@ -408,6 +562,51 @@ function createExternalMcpController(options = {}) {
scheduleIdleShutdown();
}
let httpChanged = false;
if (config.httpEnabled != null && Boolean(config.httpEnabled) !== httpEnabled) {
httpEnabled = Boolean(config.httpEnabled);
httpChanged = true;
}
if (config.httpPort != null) {
const normalizedPort = normalizeHttpPort(config.httpPort);
if (normalizedPort.ok && normalizedPort.port !== httpPort) {
httpPort = normalizedPort.port;
httpError = null;
httpErrorCode = null;
httpChanged = true;
}
}
if (config.httpAllowCustomBind != null && Boolean(config.httpAllowCustomBind) !== httpAllowCustomBind) {
httpAllowCustomBind = Boolean(config.httpAllowCustomBind);
httpChanged = true;
}
if (config.httpListenAddress != null) {
const normalizedAddress = normalizeListenAddress(config.httpListenAddress);
if (normalizedAddress.ok && normalizedAddress.address !== httpListenAddress) {
httpListenAddress = normalizedAddress.address;
httpChanged = true;
} else if (!normalizedAddress.ok && (httpAllowCustomBind || config.httpAllowCustomBind)) {
httpError = normalizedAddress.error;
httpErrorCode = normalizedAddress.errorCode;
}
}
if (config.httpClientHost != null) {
const normalizedClientHost = normalizeClientHost(config.httpClientHost);
if (normalizedClientHost.ok && normalizedClientHost.address !== httpClientHost) {
httpClientHost = normalizedClientHost.address;
}
}
if (config.rotateHttpToken === true && isEnabled()) {
const bridge = getBridge();
if (typeof bridge?.issueExternalMcpAuthToken === "function") {
lastKnownToken = bridge.issueExternalMcpAuthToken();
writeDiscoveryFromBridge();
}
}
if (httpChanged) {
void scheduleHttpApply();
}
return buildStatus();
}
@@ -482,6 +681,7 @@ function createExternalMcpController(options = {}) {
state = "disabled";
lastKnownPort = null;
lastKnownToken = null;
void scheduleHttpApply();
}
async function getCodexStatus() {
@@ -550,7 +750,9 @@ function createExternalMcpController(options = {}) {
});
ipcMain.handle("netcatty:external-mcp:set-config", async (event, payload) => {
if (!guard(event)) return { ok: false, error: "Unauthorized IPC sender" };
return setConfig(payload || {});
setConfig(payload || {});
await whenHttpSettled();
return getStatus();
});
ipcMain.handle("netcatty:external-mcp:get-universal-setup-prompt", async (event) => {
if (!guard(event)) return { ok: false, error: "Unauthorized IPC sender" };
@@ -602,6 +804,7 @@ function createExternalMcpController(options = {}) {
getGrokStatus,
addToGrok,
getUniversalSetupPrompt,
whenHttpSettled,
EXTERNAL_MCP_CHAT_SESSION_ID: deps.chatSessionId,
};
}
@@ -228,4 +228,95 @@ describe("externalMcpController", () => {
assert.equal(status.state, "running");
assert.ok(written.length >= 2);
});
it("does not listen on HTTP until the toggle is on, and stops when External MCP is disabled", async () => {
const events = [];
let listening = false;
const { controller } = createController({
createHttpServer: () => ({
start: async () => {
listening = true;
events.push("start");
},
stop: async () => {
listening = false;
events.push("stop");
},
getState: () => ({ listening }),
}),
});
await controller.setEnabled(true);
await controller.whenHttpSettled();
assert.equal(controller.getStatus().http.listening, false);
assert.equal(controller.getStatus().http.enabled, false);
assert.deepEqual(events, []);
controller.setConfig({ httpEnabled: true, httpPort: 47391 });
await controller.whenHttpSettled();
assert.equal(controller.getStatus().http.listening, true);
assert.equal(controller.getStatus().http.url, "http://127.0.0.1:47391/mcp");
assert.equal(controller.getStatus().http.bearerToken, "tok-1");
controller.setConfig({ httpAllowCustomBind: true, httpListenAddress: "0.0.0.0" });
await controller.whenHttpSettled();
assert.equal(controller.getStatus().http.url, null);
assert.equal(controller.getStatus().http.errorCode, "client_host_required");
controller.setConfig({ httpClientHost: "172.22.0.1" });
assert.equal(controller.getStatus().http.url, "http://172.22.0.1:47391/mcp");
await controller.setEnabled(false);
await controller.whenHttpSettled();
assert.equal(listening, false);
assert.equal(controller.getStatus().http.listening, false);
assert.equal(controller.getStatus().http.bearerToken, null);
assert.deepEqual(events, ["start", "start", "stop"]);
});
it("routes HTTP dispatch through the bridge handler and rotates the shared external token", async () => {
let token = "tok-0";
let issued = 0;
const calls = [];
const bridge = createFakeBridge();
bridge.issueExternalMcpAuthToken = () => {
issued += 1;
token = `tok-${issued}`;
return token;
};
bridge.getExternalMcpAuthToken = () => token;
bridge.dispatchBuiltinRpc = async (method, params, context) => {
calls.push({ method, params, socket: context?.externalSocket || null });
return { ok: true };
};
let captured = null;
const { controller } = createController({
mcpServerBridge: bridge,
createHttpServer: (deps) => {
captured = deps;
return {
start: async () => {},
stop: async () => {},
getState: () => ({ listening: true }),
};
},
});
await controller.setEnabled(true);
controller.setConfig({ httpEnabled: true, rotateHttpToken: true });
await controller.whenHttpSettled();
assert.equal(controller.getStatus().http.bearerToken, "tok-2");
assert.equal(written.at(-1).options.token, "tok-2");
const client = { id: "http-session" };
const result = await captured.dispatchRpc("netcatty/exec", {
sessionId: "sess-9",
command: "pwd",
chatSessionId: "attacker",
}, { externalClient: client });
assert.equal(result.ok, true);
assert.equal(calls[0].method, "netcatty/exec");
assert.equal(calls[0].params.chatSessionId, EXTERNAL_MCP_CHAT_SESSION_ID);
assert.equal(calls[0].params.sessionId, "sess-9");
assert.equal(calls[0].socket, client);
});
});
+14
View File
@@ -83,6 +83,19 @@ function markExternalMcpSocket(socket) {
}
}
function attachExternalMcpClient(client) {
markExternalMcpSocket(client);
return () => {
if (!client || client.destroyed) return;
client.destroyed = true;
try {
client.emit?.("close");
} catch {
// Synthetic HTTP clients may not be emitters.
}
};
}
function issueExternalMcpAuthToken() {
externalAuthToken = crypto.randomBytes(32).toString("hex");
return externalAuthToken;
@@ -2549,6 +2562,7 @@ module.exports = {
setExternalMcpHooks,
disconnectExternalMcpClients,
issueExternalMcpAuthToken,
attachExternalMcpClient,
revokeExternalMcpAuthToken,
getExternalMcpAuthToken,
syncLiveSessionsToExternalScope,
+10
View File
@@ -229,6 +229,16 @@ export const STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES = 'netcatty_ai_session_
export const STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS = 'netcatty_ai_external_mcp_silent_sessions_v1';
/** External MCP confirm-mode approval alert: off, unfocused, or always. Default unfocused. */
export const STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT = 'netcatty_ai_external_mcp_approval_alert_v1';
/** External MCP Streamable HTTP listener. Off unless the user enables it. */
export const STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED = 'netcatty_ai_external_mcp_http_enabled_v1';
/** Fixed Streamable HTTP port so client configs stay stable across restarts. */
export const STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT = 'netcatty_ai_external_mcp_http_port_v1';
/** When true, HTTP may bind an address other than 127.0.0.1. */
export const STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND = 'netcatty_ai_external_mcp_http_allow_custom_bind_v1';
/** IP address used only when custom bind is enabled. */
export const STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS = 'netcatty_ai_external_mcp_http_listen_address_v1';
/** Address copied into client configs when the listener is a wildcard bind. */
export const STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST = 'netcatty_ai_external_mcp_http_client_host_v1';
/** AI panel diagnostic hide list (comma-separated part names). */
export const STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE = 'netcatty.aiDebug.hide';
/** AI panel React profiler toggle. */