Skip to content

Commit 354765d

Browse files
committed
fix(serve): address rounds 5-7 review items — build, security, correctness (T2.8 #4514)
Build breakers (Critical): - events.ts: add missing /** JSDoc opener for DaemonMcpServerAddedData - events.ts: add missing `: undefined` arm in followup_suggestion ternary - events.ts: close isFollowupSuggestionData function body (missing ); }) Security (Critical): - acpAgent: strip authProviderType, includeTools, excludeTools, cwd from runtime-added server configs (prevents SSRF via cloud creds leak and arbitrary cwd spawn) - mcp-client-manager: reject servers in excludedMcpServers blocklist - acpAgent: add Array.isArray guard to config validation Correctness: - mcp-client-manager: identity-check on pooledConnections.delete in remove (prevents concurrent add+remove race deleting NEW pool entry) - mcp-client-manager: add client.disconnect() in catch block for standalone path (prevents transport/process leak) - mcp-client-manager: add consecutiveFailures, isReconnecting, dropRefusalEntry cleanup in removeRuntimeMcpServer - mcp-client-manager: emit mcp-client-update on spawn failure cleanup - mcp-client-manager: extract exitCode from error when available - mcp-client-manager: fix replaced=true → false for same-fingerprint idempotent re-add (no transport was torn down) - server.ts: whitelist error fields in sendBridgeError responses (prevent unbounded internal ACP data spread) - bridge.ts: remove dead try/catch in addRuntimeMcpServer (all branches just re-threw) - bridge.ts: add try/catch to removeRuntimeMcpServer for error mapping - bridge.ts: narrow AddOk.transport to literal union type SDK / DX: - DaemonClient: add timeoutMs param to addRuntimeMcpServer (default 330s, matching restartMcpServer — prevents 30s SDK timeout vs 5min bridge) - mcp-client-manager: add debugLogger.info at method entry Docs: - qwen-serve.md: clarify replaced:true vs replaced:false semantics
1 parent c7ca9e4 commit 354765d

8 files changed

Lines changed: 95 additions & 59 deletions

File tree

docs/users/qwen-serve.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,7 @@ Response (200) — success:
543543
}
544544
```
545545
546-
- `replaced: true` — a runtime entry with the same name already existed and was replaced (old connection torn down, new one established).
546+
- `replaced: true` — a runtime entry with the same name already existed and the config fingerprint differs; old connection torn down, new one established. When the fingerprint matches (idempotent re-add), `replaced` is `false`.
547547
- `shadowedSettings: true` — a settings-defined server with the same name exists; the runtime entry now shadows it. The settings entry is untouched and re-emerges if the runtime entry is later removed.
548548
- `toolCount` — number of tools discovered on the newly connected server.
549549

packages/acp-bridge/src/bridge.ts

Lines changed: 31 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3616,7 +3616,7 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge {
36163616
}
36173617
type AddOk = {
36183618
name: string;
3619-
transport: string;
3619+
transport: 'stdio' | 'sse' | 'http' | 'tcp' | 'sdk';
36203620
replaced: boolean;
36213621
shadowedSettings: boolean;
36223622
toolCount: number;
@@ -3627,37 +3627,17 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge {
36273627
skipped: true;
36283628
reason: 'budget_warning_only';
36293629
};
3630-
let response: AddOk | AddSkip;
3631-
try {
3632-
response = (await Promise.race([
3633-
withTimeout(
3634-
info.connection.extMethod(
3635-
SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeAdd,
3636-
{ name, config, originatorClientId },
3637-
),
3638-
MCP_RESTART_TIMEOUT_MS,
3630+
const response = (await Promise.race([
3631+
withTimeout(
3632+
info.connection.extMethod(
36393633
SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeAdd,
3634+
{ name, config, originatorClientId },
36403635
),
3641-
getChannelClosedReject(info),
3642-
])) as AddOk | AddSkip;
3643-
} catch (err) {
3644-
// Re-instantiate structured ACP error payloads into typed bridge
3645-
// errors so `sendBridgeError` maps them to stable HTTP codes.
3646-
const data = (err as { data?: unknown })?.data;
3647-
if (data && typeof data === 'object') {
3648-
const kind = (data as { errorKind?: unknown }).errorKind;
3649-
if (kind === 'mcp_budget_would_exceed') {
3650-
throw err;
3651-
}
3652-
if (kind === 'mcp_server_spawn_failed') {
3653-
throw err;
3654-
}
3655-
if (kind === 'invalid_config') {
3656-
throw err;
3657-
}
3658-
}
3659-
throw err;
3660-
}
3636+
MCP_RESTART_TIMEOUT_MS,
3637+
SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeAdd,
3638+
),
3639+
getChannelClosedReject(info),
3640+
])) as AddOk | AddSkip;
36613641
// Emit event on success (non-skip)
36623642
const addSkipped = (response as { skipped?: boolean }).skipped === true;
36633643
if (!addSkipped) {
@@ -3696,17 +3676,29 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge {
36963676
originatorClientId: string;
36973677
};
36983678
type RemoveSkip = { name: string; skipped: true; reason: 'not_present' };
3699-
const response = (await Promise.race([
3700-
withTimeout(
3701-
info.connection.extMethod(
3679+
let response: RemoveOk | RemoveSkip;
3680+
try {
3681+
response = (await Promise.race([
3682+
withTimeout(
3683+
info.connection.extMethod(
3684+
SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeRemove,
3685+
{ name, originatorClientId },
3686+
),
3687+
MCP_RESTART_TIMEOUT_MS,
37023688
SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeRemove,
3703-
{ name, originatorClientId },
37043689
),
3705-
MCP_RESTART_TIMEOUT_MS,
3706-
SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeRemove,
3707-
),
3708-
getChannelClosedReject(info),
3709-
])) as RemoveOk | RemoveSkip;
3690+
getChannelClosedReject(info),
3691+
])) as RemoveOk | RemoveSkip;
3692+
} catch (err) {
3693+
const data = (err as { data?: unknown })?.data;
3694+
if (data && typeof data === 'object') {
3695+
const kind = (data as { errorKind?: unknown }).errorKind;
3696+
if (kind === 'acp_channel_unavailable') {
3697+
throw err;
3698+
}
3699+
}
3700+
throw err;
3701+
}
37103702
// Emit event on success (non-skip)
37113703
const removeSkipped =
37123704
(response as { skipped?: boolean }).skipped === true;

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2504,7 +2504,7 @@ class QwenAgent implements Agent {
25042504
'Invalid or missing name',
25052505
);
25062506
}
2507-
if (!config || typeof config !== 'object') {
2507+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
25082508
throw RequestError.invalidParams(
25092509
undefined,
25102510
'Invalid or missing config',
@@ -2528,11 +2528,17 @@ class QwenAgent implements Agent {
25282528
}
25292529
try {
25302530
// Strip security-sensitive fields — runtime-added servers must
2531-
// not bypass permission gates via trust:true from HTTP body
2532-
const { trust: _stripped, ...safeConfig } = config as Record<
2533-
string,
2534-
unknown
2535-
>;
2531+
// not bypass permission gates via trust:true, leak cloud creds
2532+
// via authProviderType, manipulate tool filtering, or spawn in
2533+
// arbitrary directories
2534+
const {
2535+
trust: _trust,
2536+
authProviderType: _auth,
2537+
includeTools: _inc,
2538+
excludeTools: _exc,
2539+
cwd: _cwd,
2540+
...safeConfig
2541+
} = config as Record<string, unknown>;
25362542
const result = await manager.addRuntimeMcpServer(
25372543
name,
25382544
safeConfig as MCPServerConfig,

packages/cli/src/serve/server.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3620,10 +3620,11 @@ function sendBridgeErrorImpl(
36203620
if (data && typeof data === 'object') {
36213621
const kind = (data as { errorKind?: unknown }).errorKind;
36223622
if (kind === 'mcp_budget_would_exceed') {
3623+
const d = data as { serverName?: string };
36233624
res.status(409).json({
36243625
error: errorMessage(err),
36253626
code: 'mcp_budget_would_exceed',
3626-
...(data as object),
3627+
serverName: d.serverName,
36273628
});
36283629
return;
36293630
}
@@ -3646,18 +3647,19 @@ function sendBridgeErrorImpl(
36463647
return;
36473648
}
36483649
if (kind === 'invalid_config') {
3650+
const d = data as { serverName?: string; reason?: string };
36493651
res.status(400).json({
36503652
error: errorMessage(err),
36513653
code: 'invalid_config',
3652-
...(data as object),
3654+
serverName: d.serverName,
3655+
reason: d.reason,
36533656
});
36543657
return;
36553658
}
36563659
if (kind === 'acp_channel_unavailable') {
36573660
res.status(503).json({
36583661
error: errorMessage(err),
36593662
code: 'acp_channel_unavailable',
3660-
...(data as object),
36613663
});
36623664
return;
36633665
}

packages/core/src/tools/mcp-client-manager.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3373,11 +3373,11 @@ describe('McpClientManager — addRuntimeMcpServer / removeRuntimeMcpServer (T2.
33733373
'client-4',
33743374
);
33753375

3376-
// pool.acquire should NOT have been re-called (idempotent replace)
3376+
// pool.acquire should NOT have been re-called (idempotent no-op)
33773377
expect(acquireSpy).not.toHaveBeenCalled();
33783378
expect(result).toMatchObject({
33793379
name: 'dup-srv',
3380-
replaced: true,
3380+
replaced: false,
33813381
toolCount: 3,
33823382
});
33833383
});

packages/core/src/tools/mcp-client-manager.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2649,6 +2649,18 @@ export class McpClientManager {
26492649
config: MCPServerConfig,
26502650
originatorClientId: string,
26512651
): Promise<AddRuntimeMcpServerResult> {
2652+
// Reject explicitly excluded servers
2653+
if (this.cliConfig.isMcpServerDisabled(name)) {
2654+
throw new InvalidMcpConfigError(
2655+
name,
2656+
`server '${name}' is in excludedMcpServers and cannot be added at runtime`,
2657+
);
2658+
}
2659+
2660+
debugLogger.info(
2661+
`addRuntimeMcpServer: ${name} (transport=${mcpTransportOf(config)}, client=${originatorClientId})`,
2662+
);
2663+
26522664
// Validate config minimally: must have at least one transport field
26532665
const transport = mcpTransportOf(config);
26542666
if (transport === 'unknown') {
@@ -2668,14 +2680,13 @@ export class McpClientManager {
26682680
const newConnId = connectionIdOf(name, config);
26692681
const existingConn = this.pooledConnections.get(name);
26702682
if (existingConn && existingConn.id === newConnId) {
2671-
// Same fingerprint — just update the Config overlay (in case
2672-
// non-transport fields like trust/includeTools changed).
2683+
// Same fingerprint — no transport churn, just update Config overlay
26732684
this.cliConfig.addRuntimeMcpServer(name, config);
26742685
const toolCount = existingConn.toolsSnapshot.length;
26752686
return {
26762687
name,
26772688
transport,
2678-
replaced: true,
2689+
replaced: false,
26792690
shadowedSettings,
26802691
toolCount,
26812692
originatorClientId,
@@ -2812,14 +2823,28 @@ export class McpClientManager {
28122823
this.releaseSlotName(name);
28132824
}
28142825
// Clean up any partial state (including tools from partial discover)
2826+
this.toolRegistry.removeMcpToolsByServer(name);
28152827
this.pooledConnections.delete(name);
2828+
const failedClient = this.clients.get(name);
2829+
if (failedClient) {
2830+
try {
2831+
await failedClient.disconnect();
2832+
} catch {
2833+
/* best effort */
2834+
}
2835+
}
28162836
this.clients.delete(name);
2817-
this.toolRegistry.removeMcpToolsByServer(name);
28182837
this.stopHealthCheck(name);
2838+
this.eventEmitter?.emit('mcp-client-update', this.clients);
28192839

28202840
const message = err instanceof Error ? err.message : String(err);
28212841
const isTimeout = message.includes('timed out');
2842+
const exitCode =
2843+
err instanceof Error && 'exitCode' in err
2844+
? (err as { exitCode?: number }).exitCode
2845+
: undefined;
28222846
throw new McpServerSpawnFailedError(name, {
2847+
exitCode,
28232848
stderr: message,
28242849
timeout: isTimeout,
28252850
});
@@ -2860,15 +2885,17 @@ export class McpClientManager {
28602885
const settingsServers = this.cliConfig.getSettingsMcpServers() ?? {};
28612886
const wasShadowingSettings = name in settingsServers;
28622887

2863-
// Release pool connection
2888+
// Release pool connection (identity-check prevents race with concurrent add)
28642889
const poolConn = this.pooledConnections.get(name);
28652890
if (poolConn) {
28662891
try {
28672892
poolConn.release();
28682893
} catch {
28692894
/* best effort */
28702895
}
2871-
this.pooledConnections.delete(name);
2896+
if (this.pooledConnections.get(name) === poolConn) {
2897+
this.pooledConnections.delete(name);
2898+
}
28722899
}
28732900

28742901
// Disconnect standalone client
@@ -2883,10 +2910,13 @@ export class McpClientManager {
28832910
this.eventEmitter?.emit('mcp-client-update', this.clients);
28842911
}
28852912

2886-
// Cleanup: tool registry, status, health check (mirrors removeServer)
2913+
// Cleanup: tool registry, status, health check, diagnostics (mirrors removeServer)
28872914
this.toolRegistry.removeMcpToolsByServer(name);
28882915
removeMCPServerStatus(name);
28892916
this.stopHealthCheck(name);
2917+
this.consecutiveFailures.delete(name);
2918+
this.isReconnecting.delete(name);
2919+
this.dropRefusalEntry(name);
28902920

28912921
// Release budget slot
28922922
const budget = this.pool?.getBudget();

packages/sdk-typescript/src/daemon/DaemonClient.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1204,7 +1204,7 @@ export class DaemonClient {
12041204
*/
12051205
async addRuntimeMcpServer(
12061206
request: DaemonRuntimeMcpAddRequest,
1207-
opts?: { clientId?: string },
1207+
opts?: { clientId?: string; timeoutMs?: number },
12081208
): Promise<DaemonRuntimeMcpAddResult> {
12091209
return await this.fetchWithTimeout(
12101210
`${this.baseUrl}/workspace/mcp/servers`,
@@ -1222,6 +1222,7 @@ export class DaemonClient {
12221222
}
12231223
return (await res.json()) as DaemonRuntimeMcpAddResult;
12241224
},
1225+
opts?.timeoutMs ?? MCP_RESTART_DEFAULT_TIMEOUT_MS,
12251226
);
12261227
}
12271228

packages/sdk-typescript/src/daemon/events.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,7 @@ export interface DaemonFollowupSuggestionData {
630630
[key: string]: unknown;
631631
}
632632

633+
/**
633634
* T2.8 (#4514). Fired when `POST /workspace/mcp/servers` succeeds,
634635
* including both fresh additions and replace-on-existing-name. The
635636
* event fans out to every active session SSE bus.
@@ -1319,6 +1320,7 @@ export function asKnownDaemonEvent(
13191320
case 'followup_suggestion':
13201321
return isFollowupSuggestionData(event.data)
13211322
? (event as DaemonFollowupSuggestionEvent)
1323+
: undefined;
13221324
case 'mcp_server_added':
13231325
return isMcpServerAddedData(event.data)
13241326
? (event as DaemonMcpServerAddedEvent)
@@ -2382,6 +2384,9 @@ function isFollowupSuggestionData(
23822384
isNonEmptyString(value['sessionId']) &&
23832385
isNonEmptyString(value['suggestion']) &&
23842386
isNonEmptyString(value['promptId'])
2387+
);
2388+
}
2389+
23852390
function isMcpServerAddedData(
23862391
value: unknown,
23872392
): value is DaemonMcpServerAddedData {

0 commit comments

Comments
 (0)