mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-07 07:03:00 +08:00
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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user