Skip to content

Commit 32acb36

Browse files
0.2.4
Align PersistentMcpBridge with nuwaclaw: warmup on serve start, sticky until stop. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b2b8aa5 commit 32acb36

15 files changed

Lines changed: 234 additions & 87 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.4] - 2026-08-06
11+
12+
### Changed
13+
14+
- Align PersistentMcpBridge with nuwaclaw: warmup on Gateway/`serve` (and Console) start; keep running until active stop. `claude`/`codex` ephemeral MCP stay raw stdio; persistent defaults go through proxy→Bridge. `status` `mcp-proxy` reads `/health.mcpBridge` (running / not running only).
15+
1016
## [0.2.3] - 2026-08-06
1117

1218
### Fixed

docs/gateway.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ Codex ACP 平台运行时不可用。请重新安装 nuwa-cli。
190190
2. Gateway 启动参数 `--api-key``--base-url``--model`
191191
3. 用户电脑已有环境变量和 `~/.claude` / `~/.codex` 配置。
192192

193-
没有下发的字段不会写入空值,也不会覆盖用户本地配置。MCP 处理由 `rewriteMcpServersForEngine` 统一负责:先以内置默认服务(`chrome-devtools`,persistent)为底,叠加 ACP 下发的 `mcpServers`(同名以动态为准),并把所有 stdio 的 `npx` 改写为 `node + npx-cli.js`(避免 Windows 控制台闪屏)。codex / claude 两个引擎的 adapter(`@nuwax-ai/nuwax-codex-acp-ts` / `claude-code-acp-ts`)原生支持 ACP stdio MCP,直接下发合并后的原始 stdio 入口(stdio MCP 的环境变量随各 MCP 进程下发);只有其它或未知引擎才经 `@nuwax-ai/mcp-proxy-ts` 改写成 proxy 入口,并起 `PersistentMcpBridge` 长驻托管 persistent server(默认 `chrome-devtools`,可用 `NUWACLI_MCP_PERSISTENT` 追加)
193+
没有下发的字段不会写入空值,也不会覆盖用户本地配置。MCP 处理由 `rewriteMcpServersForEngine` 统一负责:先以内置默认服务(`chrome-devtools`,persistent)为底,叠加 ACP 下发的 `mcpServers`(同名以动态为准),并把所有 stdio 的 `npx` 改写为 `node + npx-cli.js`(避免 Windows 控制台闪屏)。**PersistentMcpBridge 在 Gateway/`serve` 启动时 warmup,跨 session 常驻,仅 `stop --all` / serve 退出时关闭**(对齐 nuwaclaw)。`claude` / `codex`:ephemeral MCP 下发原始 stdio;persistent(如 `chrome-devtools`)经 proxy 接 Bridge URL,避免与 Bridge 双开。其它/未知引擎整表经 `@nuwax-ai/mcp-proxy-ts` 改写成 proxy 入口。可用 `NUWACLI_MCP_PERSISTENT` 追加长驻名。`status``mcp-proxy` 行读取 Gateway `/health.mcpBridge`,只表示 Bridge 是否已启动
194194

195195
引擎映射:`claude-code` / `claude-code-acp-ts` → Claude;`codex` / `codex-cli` / `codex-acp` / `nuwax-codex-acp` → Codex。没有下发 command、command 未命中,或下发当前 CLI 不支持的引擎时,统一使用 Codex。已有会话固定使用创建时的引擎,后续 prompt 不会中途切换。
196196

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@nuwax-ai/nuwa-cli",
3-
"version": "0.2.3",
3+
"version": "0.2.4",
44
"packageManager": "npm@10.9.2",
55
"workspaces": [
66
"packages/lanproxy"

site/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,7 @@ <h1 class="h-display">
519519
</div>
520520

521521
<div class="hero-badges">
522-
<span><span class="dot"></span> v<b>0.2.2</b> <span class="zh">稳定版</span><span class="en">stable</span></span>
522+
<span><span class="dot"></span> v<b>0.2.4</b> <span class="zh">稳定版</span><span class="en">stable</span></span>
523523
<span><span class="dot"></span> Node <b>≥22</b></span>
524524
<span><span class="dot"></span> Windows · macOS · Linux</span>
525525
<span><span class="dot"></span> arm64 / x64</span>
@@ -989,7 +989,7 @@ <h4><span class="zh">资源</span><span class="en">Resources</span></h4>
989989
<span>© 2026 Nuwax · <span class="zh">女娲炼石,补齐终端天空</span><span class="en">Mending the terminal sky</span></span>
990990
<span class="links">
991991
<a href="https://github.com/nuwax-ai/nuwa-cli/blob/main/LICENSE" target="_blank" rel="noopener">MIT</a>
992-
<span>v0.2.2 stable</span>
992+
<span>v0.2.4 stable</span>
993993
</span>
994994
</div>
995995
</div>

src/commands/login.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import { printGatewayStatusLine } from "../core/serve/statusView.js";
1919
import {
2020
findServeProcessIds,
2121
stopServeProcesses,
22-
discoverMcpProxyProcesses,
2322
} from "../core/processes/serveSingleton.js";
2423
import {
2524
listRegisteredProcesses,
@@ -295,7 +294,7 @@ async function printServeStatus(): Promise<void> {
295294
const status = await getServeStatus();
296295
printGatewayStatusLine(status);
297296

298-
// 子服务状态:file-server / lanproxy(来自进程注册表)+ mcp-proxy(按需 spawn,扫描发现
297+
// 子服务:file-server / lanproxy(注册表)+ mcp-proxy(Gateway /health 上报的 Bridge
299298
const sep = t("common.labelSep");
300299
const registered = listRegisteredProcesses();
301300
printChildServiceLine(
@@ -306,12 +305,14 @@ async function printServeStatus(): Promise<void> {
306305
"lanproxy",
307306
registered.filter((r) => r.kind === "lanproxy"),
308307
);
309-
const mcpProxyPids = discoverMcpProxyProcesses();
310-
const onDemand = t("status.onDemand");
308+
// Bridge 随 Gateway 进程;Gateway 未运行则视为未启动。
309+
const mcpRunning =
310+
(status.state === "running" || status.state === "unhealthy") &&
311+
status.mcpBridge === true;
311312
console.log(
312-
mcpProxyPids.length > 0
313-
? `mcp-proxy${sep}${pc.green(t("status.running"))} PID ${mcpProxyPids.join(", ")}${onDemand}`
314-
: `mcp-proxy${sep}${pc.dim(t("status.noInstance"))}${onDemand}`,
313+
mcpRunning
314+
? `mcp-proxy${sep}${pc.green(t("status.running"))}`
315+
: `mcp-proxy${sep}${pc.dim(t("status.notRunning"))}`,
315316
);
316317

317318
// Console:仅运行时展示,未运行则不显示该行。

src/core/mcp/proxyRewrite.ts

Lines changed: 137 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
/**
22
* nuwa-cli ↔ @nuwax-ai/mcp-proxy-ts Host Adapter 封装。
33
*
4-
* - 默认合并 Electron 同款 DEFAULT(chrome-devtools persistent)
5-
* - 把 ACP mcpServers 改写成「每 server 一个 proxy stdio 入口」
6-
* - 管理 PersistentMcpBridge(persistent 标记的 stdio server)
4+
* 对齐 nuwaclaw:
5+
* - PersistentMcpBridge(chrome-devtools 等 persistent)= Hub/serve 级单例
6+
* 「启动后一直在,直到主动 stop」
7+
* - serve 启动时 warmup;session 结束不杀 Bridge
8+
* - claude/codex:ephemeral 仍下发原始 stdio;persistent 经 proxy 接 Bridge URL
9+
* (避免与 Bridge 各起一份 chrome-devtools)
10+
* - 其它引擎:整表改写成 proxy stdio(含 Bridge URL)
711
*/
812

913
import type { McpServer } from "@agentclientprotocol/sdk";
@@ -30,7 +34,7 @@ const mcpBridgeLogger: McpProxyLogger = {
3034
error: (...args) => debugLog("mcp-bridge", args.map(String).join(" ")),
3135
};
3236

33-
/** Hub 级 PersistentMcpBridge 单例(管理在 @nuwax-ai/agent-kit);无 persistent server 时不启动。 */
37+
/** Hub 级 PersistentMcpBridge 单例(管理在 @nuwax-ai/agent-kit)。 */
3438
const persistentBridge = createPersistentBridge({
3539
create: (logger) => new PersistentMcpBridge(logger),
3640
logger: mcpBridgeLogger,
@@ -71,6 +75,28 @@ function persistentNamesFromEnv(): Set<string> {
7175
);
7276
}
7377

78+
/** 对 stdio 条目做 Windows npx→node 解析。 */
79+
function resolveStdioEntry(entry: HostStdioServerEntry): HostStdioServerEntry {
80+
const resolved = resolveStdioNoWindow(entry.command, entry.args ?? []);
81+
return { ...entry, command: resolved.command, args: resolved.args };
82+
}
83+
84+
/**
85+
* serve 启动时用于 warmup 的默认 persistent 集合(仅 DEFAULT,含 npx 解析)。
86+
* 动态 ACP / NUWACLI_MCP_PERSISTENT 追加在 rewrite 时再并入。
87+
*/
88+
export function buildDefaultPersistentServers(): Record<
89+
string,
90+
HostStdioServerEntry
91+
> {
92+
const out: Record<string, HostStdioServerEntry> = {};
93+
for (const [name, entry] of Object.entries(DEFAULT_MCP_PROXY_SERVERS)) {
94+
if (!entry.persistent) continue;
95+
out[name] = { ...resolveStdioEntry(entry), persistent: true };
96+
}
97+
return out;
98+
}
99+
74100
/**
75101
* Host map → ACP 数组形态(无 proxy 改写时的回退)。
76102
*/
@@ -105,6 +131,42 @@ function hostMapToAcpServers(
105131
return out;
106132
}
107133

134+
function proxyCommandsToAcpServers(
135+
rewritten: Record<
136+
string,
137+
{ command: string; args: string[]; env?: Record<string, string> }
138+
>,
139+
): McpServer[] {
140+
return Object.entries(rewritten).map(([name, entry]) => ({
141+
name,
142+
command: entry.command,
143+
args: entry.args,
144+
env: entry.env
145+
? Object.entries(entry.env).map(([n, v]) => ({ name: n, value: v }))
146+
: [],
147+
}));
148+
}
149+
150+
/** 从已 merge 的 map 抽出 persistent stdio 子集。 */
151+
function extractPersistentServers(
152+
merged: Record<string, HostMcpServerEntry>,
153+
): Record<string, HostStdioServerEntry> {
154+
const persistentNames = persistentNamesFromEnv();
155+
const persistent: Record<string, HostStdioServerEntry> = {};
156+
for (const [name, item] of Object.entries(merged)) {
157+
if (isHostRemoteEntry(item)) continue;
158+
if (persistentNames.has(name) || item.persistent) {
159+
persistent[name] = {
160+
command: item.command,
161+
args: item.args,
162+
env: item.env,
163+
persistent: true,
164+
};
165+
}
166+
}
167+
return persistent;
168+
}
169+
108170
/**
109171
* 确保 PersistentMcpBridge 已托管指定的 persistent stdio servers。
110172
* 单例管理在 @nuwax-ai/agent-kit(createPersistentBridge)。
@@ -115,6 +177,30 @@ export async function ensurePersistentMcpBridge(
115177
return persistentBridge.ensureStarted(servers);
116178
}
117179

180+
/**
181+
* serve / Gateway 启动时预热 Bridge(对齐 nuwaclaw warmup)。
182+
* 失败只打日志,不阻断 serve;后续 rewrite 仍会再 ensure。
183+
*/
184+
export async function warmupPersistentMcpBridge(): Promise<void> {
185+
const servers = buildDefaultPersistentServers();
186+
if (Object.keys(servers).length === 0) return;
187+
try {
188+
await ensurePersistentMcpBridge(servers);
189+
debugLog("mcp-proxy", "PersistentMcpBridge warmed", {
190+
servers: Object.keys(servers),
191+
});
192+
} catch (err) {
193+
debugLog("mcp-proxy", "PersistentMcpBridge warmup failed", {
194+
error: err instanceof Error ? err.message : String(err),
195+
});
196+
}
197+
}
198+
199+
/** status 用:Bridge 是否已启动(与「有无临时 mcp-proxy-ts 进程」无关)。 */
200+
export function isPersistentMcpBridgeRunning(): boolean {
201+
return persistentBridge.isRunning();
202+
}
203+
118204
/** serve / hub 关闭时停止 bridge,避免子进程残留。 */
119205
export async function stopPersistentMcpBridge(): Promise<void> {
120206
await persistentBridge.stop();
@@ -127,6 +213,7 @@ export async function stopPersistentMcpBridge(): Promise<void> {
127213
* - 始终以 DEFAULT(chrome-devtools persistent)为底,再叠加 ACP 动态 MCP
128214
* - 空列表仍注入默认服务(仅 chrome-devtools)
129215
* - NUWACLI_MCP_PERSISTENT 可追加其它长驻名
216+
* - Bridge 按 Hub 生命周期常驻;claude/codex 的 persistent 走 proxy→Bridge URL
130217
*/
131218
export async function rewriteMcpServersForEngine(
132219
servers: McpServer[] | undefined,
@@ -157,8 +244,7 @@ export async function rewriteMcpServersForEngine(
157244
// npx warmup + npm update already do the same). Remote entries pass through.
158245
for (const [name, entry] of Object.entries(merged)) {
159246
if (isHostRemoteEntry(entry)) continue;
160-
const resolved = resolveStdioNoWindow(entry.command, entry.args ?? []);
161-
merged[name] = { ...entry, command: resolved.command, args: resolved.args };
247+
merged[name] = resolveStdioEntry(entry);
162248
}
163249
const passthroughResolved = (passthrough as AcpMcpServer[]).map((server) => {
164250
if (!("command" in server)) return server;
@@ -170,16 +256,53 @@ export async function rewriteMcpServersForEngine(
170256
return passthroughResolved as McpServer[];
171257
}
172258

173-
// claude-code-acp-ts 与 nuwax-codex-acp 均原生支持 ACP stdio MCP(各自把
174-
// mcpServers 转成内部 MCP 配置:claude-code-acp-ts acp-agent.js、codex
175-
// codex_agent.rs build_session_config)。mcp-proxy-ts proxy 桥接会把 server
176-
// 改写成 proxy 入口形态,engine 注册不上原始 server name(codex "unknown MCP
177-
// server")或工具不加载(claude)。直接下发原始 stdio 入口(DEFAULT + ACP)。
259+
const persistent = extractPersistentServers(merged);
260+
// 无论引擎:先确保 Bridge 起来(Hub 级常驻;与 session 无关)。
261+
const runningBridge = await ensurePersistentMcpBridge(persistent);
262+
263+
const proxyScriptPath = resolveProxyEntry();
264+
265+
// claude / codex:ephemeral 原生 stdio;persistent 经 proxy 接 Bridge,避免双开。
178266
if (engine === "codex" || engine === "claude") {
179-
return [...hostMapToAcpServers(merged), ...(passthroughResolved as McpServer[])];
267+
const ephemeral: Record<string, HostMcpServerEntry> = {};
268+
const persistentOnly: Record<string, HostMcpServerEntry> = {};
269+
for (const [name, entry] of Object.entries(merged)) {
270+
if (persistent[name]) persistentOnly[name] = entry;
271+
else ephemeral[name] = entry;
272+
}
273+
274+
const out: McpServer[] = [...hostMapToAcpServers(ephemeral)];
275+
276+
if (Object.keys(persistentOnly).length > 0) {
277+
if (proxyScriptPath && runningBridge) {
278+
const rewritten = rewriteServersToProxyCommands(persistentOnly, {
279+
proxyScriptPath,
280+
nodeBinPath: process.execPath,
281+
configDir: mcpProxyConfigDir(),
282+
logDir: mcpProxyLogDir(),
283+
projectId,
284+
bridge: runningBridge,
285+
});
286+
if (rewritten) out.push(...proxyCommandsToAcpServers(rewritten));
287+
else out.push(...hostMapToAcpServers(persistentOnly));
288+
} else {
289+
// 无 proxy 脚本或 Bridge 未起:退回原始 stdio(可能与 warmup 失败并存)
290+
out.push(...hostMapToAcpServers(persistentOnly));
291+
}
292+
}
293+
294+
debugLog(
295+
"mcp-proxy",
296+
`${engine}: ephemeral stdio + persistent via bridge/proxy`,
297+
{
298+
ephemeral: Object.keys(ephemeral),
299+
persistent: Object.keys(persistentOnly),
300+
bridgeRunning: persistentBridge.isRunning(),
301+
},
302+
);
303+
return [...out, ...(passthroughResolved as McpServer[])];
180304
}
181305

182-
const proxyScriptPath = resolveProxyEntry();
183306
if (!proxyScriptPath) {
184307
debugLog(
185308
"mcp-proxy",
@@ -188,22 +311,6 @@ export async function rewriteMcpServersForEngine(
188311
return [...hostMapToAcpServers(merged), ...(passthroughResolved as McpServer[])];
189312
}
190313

191-
const persistentNames = persistentNamesFromEnv();
192-
const persistent: Record<string, HostStdioServerEntry> = {};
193-
for (const [name, item] of Object.entries(merged)) {
194-
if (isHostRemoteEntry(item)) continue;
195-
if (persistentNames.has(name) || item.persistent) {
196-
persistent[name] = {
197-
command: item.command,
198-
args: item.args,
199-
env: item.env,
200-
persistent: true,
201-
};
202-
}
203-
}
204-
205-
const runningBridge = await ensurePersistentMcpBridge(persistent);
206-
207314
const rewritten = rewriteServersToProxyCommands(merged, {
208315
proxyScriptPath,
209316
nodeBinPath: process.execPath,
@@ -217,15 +324,7 @@ export async function rewriteMcpServersForEngine(
217324
return [...hostMapToAcpServers(merged), ...(passthroughResolved as McpServer[])];
218325
}
219326

220-
const out: McpServer[] = Object.entries(rewritten).map(([name, entry]) => ({
221-
name,
222-
command: entry.command,
223-
args: entry.args,
224-
// ACP McpServerStdio.env 必填(不可为 undefined)
225-
env: entry.env
226-
? Object.entries(entry.env).map(([n, v]) => ({ name: n, value: v }))
227-
: [],
228-
}));
327+
const out = proxyCommandsToAcpServers(rewritten);
229328

230329
debugLog(
231330
"mcp-proxy",

0 commit comments

Comments
 (0)