Skip to content

Commit f383ef3

Browse files
committed
feat(serve): add MCP server restart route with budget guard (#4175 Wave 4 PR 17)
Adds POST /workspace/mcp/:server/restart — strict-gated mutation route that performs a single-server MCP restart through the ACP child's `McpClientManager.discoverMcpToolsForServer`. Pre-checks the live budget snapshot from PR 14 v1 (#4247) so a restart on a budget-saturated workspace returns a soft refusal rather than triggering a `BudgetExhaustedError` cascade through the discovery loop. Decision logic (ACP-side, in `qwen/control/workspace/mcp/restart` extMethod): - Server not in `getMcpServers()` → JSON-RPC `resourceNotFound` → HTTP 404 - Server in `excludedMcpServers` → 200 with `{skipped:true, reason:'disabled'}` - `manager.isServerDiscovering(name)` → 200 with `{reason:'in_flight'}` - Mode is `enforce`, server not in `reservedSlots`, total ≥ budget → 200 with `{reason:'budget_would_exceed'}` - Otherwise: `discoverMcpToolsForServer(name, config)`, return `{restarted:true, durationMs}` Soft refusals still return 200 because the route understood the request and reached a deterministic answer about why no restart happened. Only hard "we cannot answer" cases (unknown server, no live ACP child) escalate to non-2xx. This mirrors PR 14 v1's discovery-time refusal contract: refusals don't throw, they get recorded. Bridge: - New `restartMcpServer(serverName, originatorClientId)` forwards through the new `SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart` extMethod against the live `liveChannelInfo()` channel - Throws `SessionNotFoundError` (mapped to HTTP 404) when no ACP child is alive — restart inherently requires a live `McpClientManager` instance - Fan-outs `mcp_server_restarted` (success) or `mcp_server_restart_refused` (skip) to every live session SSE bus Core: - New public `McpClientManager.isServerDiscovering(serverName): boolean` — reads `serverDiscoveryPromises.has(name)` so the daemon can short-circuit a redundant restart with `skipped:in_flight` instead of awaiting the original discovery promise (HTTP latency stays bounded) SDK additions: - `DaemonClient.restartMcpServer(serverName, clientId?)` with URL-encoded server name - `DaemonMcpRestartResult` discriminated union, two new typed events (`DaemonMcpServerRestartedEvent`, `DaemonMcpServerRestartRefusedEvent`) with runtime guards, reducer integration on `DaemonSessionViewState` (`mcpRestartCount` / `lastMcpRestart` / `mcpRestartRefusedCount` / `lastMcpRestartRefused`) New capability tag `workspace_mcp_restart` (always-on, since v1). 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
1 parent 18b08b9 commit f383ef3

14 files changed

Lines changed: 592 additions & 1 deletion

File tree

integration-tests/cli/qwen-serve-routes.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ describe('qwen serve — capabilities envelope', () => {
227227
'session_approval_mode_control',
228228
'workspace_tool_toggle',
229229
'workspace_init',
230+
'workspace_mcp_restart',
230231
]);
231232
});
232233
});

packages/cli/src/acp-integration/acpAgent.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,6 +1437,71 @@ class QwenAgent implements Agent {
14371437
sessionId,
14381438
)) as unknown as Record<string, unknown>;
14391439
}
1440+
case SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart: {
1441+
// #4175 Wave 4 PR 17. Single-server MCP restart with budget
1442+
// pre-check from PR 14 v1's accounting snapshot. Soft skips
1443+
// (in_flight, disabled, budget_would_exceed) come back as
1444+
// structured 200 responses; hard errors (server not in
1445+
// config, manager unavailable) propagate as JSON-RPC errors
1446+
// that the bridge translates to HTTP 4xx/5xx.
1447+
const serverName = params['serverName'];
1448+
if (typeof serverName !== 'string' || serverName.length === 0) {
1449+
throw RequestError.invalidParams(
1450+
undefined,
1451+
'Invalid or missing serverName',
1452+
);
1453+
}
1454+
const servers = this.config.getMcpServers() ?? {};
1455+
if (!Object.prototype.hasOwnProperty.call(servers, serverName)) {
1456+
throw RequestError.resourceNotFound(`mcpServer:${serverName}`);
1457+
}
1458+
if (this.config.isMcpServerDisabled(serverName)) {
1459+
return {
1460+
serverName,
1461+
restarted: false,
1462+
skipped: true,
1463+
reason: 'disabled' as const,
1464+
};
1465+
}
1466+
const manager = this.config.getToolRegistry()?.getMcpClientManager();
1467+
if (!manager) {
1468+
throw RequestError.internalError(
1469+
undefined,
1470+
'McpClientManager unavailable on this Config',
1471+
);
1472+
}
1473+
if (manager.isServerDiscovering(serverName)) {
1474+
return {
1475+
serverName,
1476+
restarted: false,
1477+
skipped: true,
1478+
reason: 'in_flight' as const,
1479+
};
1480+
}
1481+
const accounting = manager.getMcpClientAccounting();
1482+
const budget = manager.getMcpClientBudget();
1483+
const mode = manager.getMcpBudgetMode();
1484+
if (
1485+
mode === 'enforce' &&
1486+
budget !== undefined &&
1487+
!accounting.reservedSlots.includes(serverName) &&
1488+
accounting.total >= budget
1489+
) {
1490+
return {
1491+
serverName,
1492+
restarted: false,
1493+
skipped: true,
1494+
reason: 'budget_would_exceed' as const,
1495+
};
1496+
}
1497+
const start = Date.now();
1498+
await manager.discoverMcpToolsForServer(serverName, this.config);
1499+
return {
1500+
serverName,
1501+
restarted: true,
1502+
durationMs: Date.now() - start,
1503+
};
1504+
}
14401505
case SERVE_CONTROL_EXT_METHODS.sessionApprovalMode: {
14411506
// #4175 Wave 4 PR 17: remote callers change a live session's
14421507
// approval mode via this ACP extMethod. `Config.setApprovalMode`

packages/cli/src/serve/capabilities.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,17 @@ export const SERVE_CAPABILITY_REGISTRY = {
131131
// the file, the caller should follow up with
132132
// `POST /session/:id/prompt`.
133133
workspace_init: { since: 'v1' },
134+
// #4175 Wave 4 PR 17. `POST /workspace/mcp/:server/restart` performs
135+
// a single-server MCP restart (disconnect + reconnect + rediscover)
136+
// through the ACP child's `McpClientManager`. Pre-checks the live
137+
// budget snapshot from PR 14 v1: when the target server is not
138+
// already in `reservedSlots` AND the live count would exceed the
139+
// configured budget under `enforce` mode, returns 200 with
140+
// `{restarted:false, skipped:true, reason:'budget_would_exceed'}`
141+
// rather than triggering a refusal cascade. Other skip reasons:
142+
// `'in_flight'` (concurrent discovery in progress), `'disabled'`
143+
// (server is configured but explicitly disabled).
144+
workspace_mcp_restart: { since: 'v1' },
134145
// Issue #4175 PR 15. Daemon was booted with `--require-auth` (or
135146
// `requireAuth: true`), so even loopback callers must carry a bearer
136147
// token. Advertised CONDITIONALLY — only when the flag is on — so

packages/cli/src/serve/httpAcpBridge.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,37 @@ export interface HttpAcpBridge {
509509
action: 'created' | 'overwrote';
510510
}>;
511511

512+
/**
513+
* Restart a configured MCP server through the ACP child's
514+
* `McpClientManager`. Pre-checks the live budget snapshot from PR 14
515+
* v1 and returns a structured "skipped" response (200 OK) for soft
516+
* refusals (in-flight discovery, server disabled, restart would
517+
* push live count over budget under `enforce` mode). Hard errors
518+
* (server not configured at all, `McpClientManager` unavailable)
519+
* propagate as ACP errors → mapped to HTTP 4xx/5xx by the route.
520+
*
521+
* On success, fan-outs `mcp_server_restarted` to every live session
522+
* SSE bus with `{serverName, durationMs}`. On soft skip, fan-outs
523+
* `mcp_server_restart_refused` with `{serverName, reason}`.
524+
*
525+
* Throws `SessionNotFoundError`-like (via `SERVE_CONTROL_EXT_METHODS`
526+
* routing) when no ACP channel is alive — restart requires a live
527+
* `McpClientManager` instance, which only exists inside a spawned
528+
* ACP child.
529+
*/
530+
restartMcpServer(
531+
serverName: string,
532+
originatorClientId: string | undefined,
533+
): Promise<
534+
| { serverName: string; restarted: true; durationMs: number }
535+
| {
536+
serverName: string;
537+
restarted: false;
538+
skipped: true;
539+
reason: 'in_flight' | 'disabled' | 'budget_would_exceed';
540+
}
541+
>;
542+
512543
/**
513544
* Kill the agent process for the session and remove it from the maps.
514545
* Used by the HTTP route layer to reap orphans created when a client
@@ -3764,6 +3795,59 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge {
37643795
return { toolName, enabled };
37653796
},
37663797

3798+
async restartMcpServer(serverName, originatorClientId) {
3799+
// #4175 Wave 4 PR 17. The restart logic lives inside the ACP
3800+
// child (it owns the `McpClientManager`); the bridge's role is
3801+
// to (a) pick a live channel to forward through, (b) translate
3802+
// the structured response back into the typed result, (c) fan
3803+
// out the appropriate event to every session bus. Soft refusals
3804+
// (skipped:true) come back as a normal response; hard errors
3805+
// (server not configured, manager unavailable) are propagated
3806+
// as JSON-RPC errors that the route maps via sendBridgeError.
3807+
const info = liveChannelInfo();
3808+
if (!info) {
3809+
throw new SessionNotFoundError(`mcp:${serverName}`);
3810+
}
3811+
const response = (await Promise.race([
3812+
withTimeout(
3813+
info.connection.extMethod(
3814+
SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart,
3815+
{ serverName },
3816+
),
3817+
initTimeoutMs,
3818+
SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart,
3819+
),
3820+
getChannelClosedReject(info),
3821+
])) as
3822+
| { serverName: string; restarted: true; durationMs: number }
3823+
| {
3824+
serverName: string;
3825+
restarted: false;
3826+
skipped: true;
3827+
reason: 'in_flight' | 'disabled' | 'budget_would_exceed';
3828+
};
3829+
if (response.restarted === true) {
3830+
broadcastWorkspaceEvent({
3831+
type: 'mcp_server_restarted',
3832+
data: {
3833+
serverName: response.serverName,
3834+
durationMs: response.durationMs,
3835+
},
3836+
...(originatorClientId ? { originatorClientId } : {}),
3837+
});
3838+
} else {
3839+
broadcastWorkspaceEvent({
3840+
type: 'mcp_server_restart_refused',
3841+
data: {
3842+
serverName: response.serverName,
3843+
reason: response.reason,
3844+
},
3845+
...(originatorClientId ? { originatorClientId } : {}),
3846+
});
3847+
}
3848+
return response;
3849+
},
3850+
37673851
async initWorkspace(initOpts, originatorClientId) {
37683852
// #4175 Wave 4 PR 17. Mechanical scaffold of an empty `QWEN.md`
37693853
// (or whatever `getCurrentGeminiMdFilename()` returns under

packages/cli/src/serve/server.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ const EXPECTED_STAGE1_FEATURES = [
120120
'session_approval_mode_control',
121121
'workspace_tool_toggle',
122122
'workspace_init',
123+
'workspace_mcp_restart',
123124
] as const;
124125

125126
// Issue #4175 PR 15. `require_auth` is registered but conditionally
@@ -204,6 +205,18 @@ interface FakeBridgeOpts {
204205
initOpts: { force?: boolean },
205206
originatorClientId: string | undefined,
206207
) => Promise<{ path: string; action: 'created' | 'overwrote' }>;
208+
restartMcpServerImpl?: (
209+
serverName: string,
210+
originatorClientId: string | undefined,
211+
) => Promise<
212+
| { serverName: string; restarted: true; durationMs: number }
213+
| {
214+
serverName: string;
215+
restarted: false;
216+
skipped: true;
217+
reason: 'in_flight' | 'disabled' | 'budget_would_exceed';
218+
}
219+
>;
207220
closeImpl?: (
208221
sessionId: string,
209222
context?: BridgeClientRequestContext,
@@ -279,6 +292,10 @@ interface FakeBridge extends HttpAcpBridge {
279292
initOpts: { force?: boolean };
280293
originatorClientId?: string;
281294
}>;
295+
restartMcpServerCalls: Array<{
296+
serverName: string;
297+
originatorClientId?: string;
298+
}>;
282299
closeCalls: Array<{
283300
sessionId: string;
284301
context?: BridgeClientRequestContext;
@@ -442,6 +459,14 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
442459
path: path.resolve(WS_BOUND, 'QWEN.md'),
443460
action: 'created' as const,
444461
}));
462+
const restartMcpServerCalls: FakeBridge['restartMcpServerCalls'] = [];
463+
const restartMcpServerImpl =
464+
opts.restartMcpServerImpl ??
465+
(async (serverName: string) => ({
466+
serverName,
467+
restarted: true as const,
468+
durationMs: 42,
469+
}));
445470
const closeImpl = opts.closeImpl ?? (async () => {});
446471
const updateMetadataImpl =
447472
opts.updateMetadataImpl ??
@@ -480,6 +505,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
480505
setApprovalModeCalls,
481506
setToolEnabledCalls,
482507
initWorkspaceCalls,
508+
restartMcpServerCalls,
483509
closeCalls,
484510
updateMetadataCalls,
485511
heartbeatCalls,
@@ -627,6 +653,13 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
627653
});
628654
return initWorkspaceImpl(initOpts, originatorClientId);
629655
},
656+
async restartMcpServer(serverName, originatorClientId) {
657+
restartMcpServerCalls.push({
658+
serverName,
659+
...(originatorClientId !== undefined ? { originatorClientId } : {}),
660+
});
661+
return restartMcpServerImpl(serverName, originatorClientId);
662+
},
630663
async closeSession(sessionId, context) {
631664
closeCalls.push({ sessionId, ...(context ? { context } : {}) });
632665
return closeImpl(sessionId, context);
@@ -2358,6 +2391,100 @@ describe('createServeApp', () => {
23582391
});
23592392
});
23602393

2394+
describe('POST /workspace/mcp/:server/restart (#4175 Wave 4 PR 17)', () => {
2395+
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
2396+
const auth = (req: request.Test): request.Test =>
2397+
req
2398+
.set('Host', `127.0.0.1:${tokenOpts.port}`)
2399+
.set('Authorization', 'Bearer secret');
2400+
2401+
it('401 on no-token daemon: strict gate refuses without bearer auth', async () => {
2402+
const bridge = fakeBridge();
2403+
const app = createServeApp(baseOpts, undefined, { bridge });
2404+
const res = await request(app)
2405+
.post('/workspace/mcp/docs/restart')
2406+
.set('Host', `127.0.0.1:${baseOpts.port}`)
2407+
.send({});
2408+
expect(res.status).toBe(401);
2409+
expect(bridge.restartMcpServerCalls).toHaveLength(0);
2410+
});
2411+
2412+
it('200 with restarted:true on success', async () => {
2413+
const bridge = fakeBridge();
2414+
const app = createServeApp(tokenOpts, undefined, { bridge });
2415+
const res = await auth(
2416+
request(app).post('/workspace/mcp/docs/restart'),
2417+
).send({});
2418+
expect(res.status).toBe(200);
2419+
expect(res.body).toEqual({
2420+
serverName: 'docs',
2421+
restarted: true,
2422+
durationMs: 42,
2423+
});
2424+
expect(bridge.restartMcpServerCalls).toHaveLength(1);
2425+
expect(bridge.restartMcpServerCalls[0]?.serverName).toBe('docs');
2426+
});
2427+
2428+
it('200 on soft skip with structured reason', async () => {
2429+
const bridge = fakeBridge({
2430+
restartMcpServerImpl: async (serverName) => ({
2431+
serverName,
2432+
restarted: false as const,
2433+
skipped: true as const,
2434+
reason: 'budget_would_exceed' as const,
2435+
}),
2436+
});
2437+
const app = createServeApp(tokenOpts, undefined, { bridge });
2438+
const res = await auth(
2439+
request(app).post('/workspace/mcp/docs/restart'),
2440+
).send({});
2441+
expect(res.status).toBe(200);
2442+
expect(res.body).toEqual({
2443+
serverName: 'docs',
2444+
restarted: false,
2445+
skipped: true,
2446+
reason: 'budget_would_exceed',
2447+
});
2448+
});
2449+
2450+
it('passes client identity into the bridge', async () => {
2451+
const bridge = fakeBridge();
2452+
const app = createServeApp(tokenOpts, undefined, { bridge });
2453+
await auth(request(app).post('/workspace/mcp/docs/restart'))
2454+
.set('X-Qwen-Client-Id', 'client-1')
2455+
.send({});
2456+
expect(bridge.restartMcpServerCalls[0]?.originatorClientId).toBe(
2457+
'client-1',
2458+
);
2459+
});
2460+
2461+
it('decodes URL-encoded server names', async () => {
2462+
const bridge = fakeBridge();
2463+
const app = createServeApp(tokenOpts, undefined, { bridge });
2464+
// Server name with hyphen + dot is a legitimate stdio MCP config key.
2465+
const res = await auth(
2466+
request(app).post(
2467+
`/workspace/mcp/${encodeURIComponent('foo-bar.io')}/restart`,
2468+
),
2469+
).send({});
2470+
expect(res.status).toBe(200);
2471+
expect(bridge.restartMcpServerCalls[0]?.serverName).toBe('foo-bar.io');
2472+
});
2473+
2474+
it('404 when bridge reports SessionNotFoundError (no live channel)', async () => {
2475+
const bridge = fakeBridge({
2476+
restartMcpServerImpl: async () => {
2477+
throw new SessionNotFoundError('mcp:docs');
2478+
},
2479+
});
2480+
const app = createServeApp(tokenOpts, undefined, { bridge });
2481+
const res = await auth(
2482+
request(app).post('/workspace/mcp/docs/restart'),
2483+
).send({});
2484+
expect(res.status).toBe(404);
2485+
});
2486+
});
2487+
23612488
describe('POST /workspace/tools/:name/enable (#4175 Wave 4 PR 17)', () => {
23622489
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
23632490
const auth = (req: request.Test): request.Test =>

0 commit comments

Comments
 (0)