fix(mcp): delegate connect timeout to SDK to restore slow SSE servers (#14348)

### What this PR does

Before this PR:

- `McpService.initClient` wrapped `initTransport()` + `client.connect()`
in a hard-coded 60s `Promise.race`. When the timeout fired, it rejected
with `Connection timed out after 60s` but left the losing transport /
socket / subprocess running in the background.
- Legitimate SSE and streamableHttp servers whose initialization exceeds
60s (slow networks, remote handshakes) were broken on v1.9.1 even though
they worked on v1.8.4.

After this PR:

- The hard-coded blanket timeout and its `Promise.race` wrapper are
removed.
- The MCP `initialize` request now receives a `RequestOptions.timeout`
derived from `server.timeout` (seconds, default 60). When the initialize
call hangs, the SDK aborts it through its own cancellation path — no
leaked transports.
- `transport.start()` latency is bounded by the underlying `fetch` /
child process, matching v1.8.4 behavior. Slow but successful SSE
handshakes are no longer killed.
- OAuth flow (`handleAuth`) is unaffected because it runs after the
`UnauthorizedError` catch and is outside the timed request.

Fixes #14345

### Why we need it and why it was done in this way

The following tradeoffs were made:

- Reused the existing `server.timeout` field (already exposed in MCP
settings and already used for tool-call requests) instead of introducing
a new `connectTimeout` knob. This keeps the config surface small and
gives the user the same dial they already understand.
- Delegated the timeout to the SDK (`client.connect(transport, { timeout
})`) instead of wrapping it externally. The SDK cancels the in-flight
`initialize` request through `AbortSignal`, so no resources leak on
timeout.
- Accepted that `transport.start()` is not covered by this timeout (SDK
does not expose one). This is intentional: it is the exact behavior of
v1.8.4, which has been fine in practice, and the underlying `fetch` /
`spawn` already fail fast on unreachable targets.

The following alternatives were considered:

- Pure revert of #13928's timeout block. Would fix #14345 just as well,
but would also remove the protection against a truly unresponsive
`initialize` response. The SDK-level timeout keeps that protection while
being configurable and non-destructive.
- Increasing the constant to e.g. 180s. Would only push the
false-positive threshold out; does not address the `Promise.race` leak
or the lack of per-server control.

Links to places where the discussion took place:
https://github.com/CherryHQ/cherry-studio/discussions/14345

### Breaking changes

None. Default timeout remains 60s (unchanged for users who never set
`server.timeout`). Users affected by the regression can now raise
`server.timeout` to accommodate their specific SSE / streamableHttp
server.

### Special notes for your reviewer

- This branch is `hotfix/*` targeting `main`, scope kept minimal per the
code-freeze policy: no refactor, only the timeout semantics change.
- The flomo built-in server added in the same original PR (#13928) is
intentionally not touched.
- Pre-existing `SSEClientTransport` deprecation warnings are not
introduced by this change.

### Checklist

This checklist is not enforcing, but it's a reminder of items that could
be relevant to every PR.
Approvers are expected to review this list.

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [x] Refactor: You have [left the code cleaner than you found it (Boy
Scout
Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [x] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [x] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. Check this only
when the PR introduces or changes a user-facing feature or behavior.
- [x] Self-review: I have reviewed my own code (e.g., via
[`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`,
or GitHub UI) before requesting review from others

### Release note

```release-note
Fix MCP SSE / streamableHttp servers failing with "Connection timed out after 60s" on v1.9.1 when their initialization exceeds 60s. The MCP connect timeout now honors each server's configurable `timeout` setting and is handled via the SDK's own cancellation path.
```

---------

Signed-off-by: suyao <sy20010504@gmail.com>
This commit is contained in:
SuYao
2026-04-22 21:25:10 +08:00
committed by GitHub
parent 5d98273d86
commit 5cc1b9c410

View File

@@ -19,6 +19,7 @@ import {
type StreamableHTTPClientTransportOptions
} from '@modelcontextprotocol/sdk/client/streamableHttp'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js'
import { McpError, type Tool as SDKTool } from '@modelcontextprotocol/sdk/types'
// Import notification schemas from MCP SDK
import {
@@ -68,8 +69,10 @@ type CallToolArgs = { server: MCPServer; name: string; args: any; callId?: strin
const logger = loggerService.withContext('MCPService')
/** Timeout for MCP server connection (transport init + client connect), in milliseconds. */
const MCP_CONNECTION_TIMEOUT_MS = 60_000
// Minimum timeout for the MCP `initialize` request. Connect runs once per activation,
// so a generous floor avoids false positives on slow SSE/streamableHttp handshakes while
// still letting users raise it further via `server.timeout`.
const MCP_CONNECT_TIMEOUT_FLOOR_MS = 180_000
// Redact potentially sensitive fields in objects (headers, tokens, api keys)
function redactSensitive(input: any): any {
@@ -596,33 +599,29 @@ class McpService {
}
try {
const connectWithTimeout = async () => {
const transport = await initTransport()
try {
await client.connect(transport)
} catch (error: any) {
if (
error instanceof Error &&
(error.name === 'UnauthorizedError' || error.message.includes('Unauthorized'))
) {
logger.debug(`Authentication required for server: ${server.name}`)
await handleAuth(client, transport as SSEClientTransport | StreamableHTTPClientTransport)
} else {
throw error
}
const transport = await initTransport()
// Bound the MCP `initialize` request so a non-responsive server fails fast via the
// SDK's own abort path instead of hanging. Use a 180s floor (activation runs once,
// generous headroom is cheap) while still honoring larger `server.timeout` values
// that the user explicitly configured. transport.start() latency remains bounded
// by the underlying fetch / child_process, matching v1.8.4 behavior.
const connectOptions: RequestOptions = {
timeout: Math.max((server.timeout ?? 0) * 1000, MCP_CONNECT_TIMEOUT_FLOOR_MS)
}
try {
await client.connect(transport, connectOptions)
} catch (error: any) {
if (
error instanceof Error &&
(error.name === 'UnauthorizedError' || error.message.includes('Unauthorized'))
) {
logger.debug(`Authentication required for server: ${server.name}`)
await handleAuth(client, transport as SSEClientTransport | StreamableHTTPClientTransport)
} else {
throw error
}
}
await Promise.race([
connectWithTimeout(),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`Connection timed out after ${MCP_CONNECTION_TIMEOUT_MS / 1000}s`)),
MCP_CONNECTION_TIMEOUT_MS
)
)
])
this.emitServerLog(server, {
timestamp: Date.now(),
level: 'info',