mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 18:32:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2a1f825a5 | ||
|
|
e802c92ac6 | ||
|
|
92923f5cc6 | ||
|
|
c0d49b50fc | ||
|
|
faa52f0cf7 | ||
|
|
99dd770d45 | ||
|
|
ed699f0b7f | ||
|
|
5007b9eca2 |
No files matched your search
@@ -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.',
|
||||
|
||||
@@ -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"], /鏡像網路/);
|
||||
});
|
||||
@@ -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) могут показывать своё подтверждение инструментов.',
|
||||
|
||||
@@ -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)仍可能有自己的工具审批界面。',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user