Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,21 @@ list is in effect, these actions edit that local file; add remains blocked.

## Dropped Transports Reconnect in the Background

When a connected server's transport dies unexpectedly, the row goes
**reconnecting** instead of failed: the redial attempt and the retained tool
count stay on the row (for example, `acme — reconnecting · attempt 3 · 2
tools`), and backoff redials behind the scenes with a jittered delay that
grows per attempt and caps at 30 seconds. While reconnecting, the server's
tools stay mounted but fail fast — calling one returns "MCP server is
reconnecting; retry the call once it reports connected." rather than hanging
or dispatching against a dead transport.
Stdio and HTTP do not share a redial. A stdio child that exits is respawned
here: the row goes **reconnecting** instead of failed, the redial attempt and
the retained tool count stay on the row (for example, `acme — reconnecting ·
attempt 3 · 2 tools`), and backoff redials behind the scenes with a jittered
delay that grows per attempt and caps at 30 seconds. While that redial is in
progress, the server's tools stay mounted but fail fast — calling one returns
"MCP server is reconnecting; retry the call once it reports connected." rather
than hanging or dispatching against a dead transport.

An HTTP SSE drop does not start that second dial. The Streamable HTTP
transport resumes the stream itself (`reconnectionOptions`: 1s start, doubling,
30s cap). Those options have no random factor and no exhaustion callback, so
the delay is not jittered and a stream that stops retrying stays on the
connected row instead of flipping to failed. Tools stay live; only a real
stdio death uses the fail-fast stubs and attempt count above.

Pressing Enter on a failed or reconnecting row retries now with a single dial
instead of waiting for backoff, reusing the saved settings row — it never
Expand Down
14 changes: 7 additions & 7 deletions e2e/mcp-late-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,13 @@ function permissionGate() {

function linearClient(opts: {
inputSchema?: MCPClient["tools"][number]["inputSchema"];
call?: MCPClient["call"];
call?: (
toolName: string,
args: Record<string, unknown>,
signal: AbortSignal,
) => Promise<string>;
}): MCPClient {
const invoke = opts.call ?? (async () => "ISSUE-1");
return {
serverName: "linear",
tools: [
Expand All @@ -54,13 +59,8 @@ function linearClient(opts: {
inputSchema: opts.inputSchema ?? LATE_MCP_SCHEMA,
},
],
call: opts.call ?? (async () => "ISSUE-1"),
async callResult(toolName, args, signal) {
const text = await (opts.call ?? (async () => "ISSUE-1"))(
toolName,
args,
signal,
);
const text = await invoke(toolName, args, signal);
return {
blocks: [{ type: "text", text }],
isError: false,
Expand Down
4 changes: 0 additions & 4 deletions e2e/subagent-permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,6 @@ test.serial(
inputSchema: { type: "object", properties: {} },
},
],
call: async () => "changed",
callResult: async () => {
calls++;
return {
Expand Down Expand Up @@ -354,7 +353,6 @@ test.serial(
inputSchema: { type: "object", properties: {} },
},
],
call: async () => "changed",
callResult: async () => {
calls++;
return {
Expand Down Expand Up @@ -488,7 +486,6 @@ test.serial(
inputSchema: { type: "object", properties: {} },
},
],
call: async () => "changed",
callResult: async () => {
calls++;
return {
Expand Down Expand Up @@ -573,7 +570,6 @@ test("storing parent-gated MCP tools then wrapping again denies without requestA
inputSchema: { type: "object", properties: {} },
},
],
call: async () => "changed",
callResult: async () => {
calls++;
return {
Expand Down
20 changes: 16 additions & 4 deletions src/mcp/client-auth-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,11 @@ await mockMcpOAuthProviderModule(async (options: { serverURL: string }) => {
return authProvider;
});

const { connectMCPServer, fetchWithConnectAbort } = await import("./client.js");
const {
connectMCPServer,
fetchWithConnectAbort,
MCP_HTTP_RECONNECTION_OPTIONS,
} = await import("./client.js");

describe("HTTP MCP auth policy", () => {
beforeEach(() => {
Expand Down Expand Up @@ -186,7 +190,9 @@ describe("HTTP MCP auth policy", () => {
expect(providerCreates).toBe(0);
// No signal and no provider: leave redirectPolicy unset so the SDK
// default `same-origin` applies. Do not pin `follow`.
expect(transportOptions).toEqual([{}]);
expect(transportOptions).toEqual([
{ reconnectionOptions: MCP_HTTP_RECONNECTION_OPTIONS },
]);
});

test("closes a bound OAuth callback server when provider setup fails", async () => {
Expand Down Expand Up @@ -262,13 +268,14 @@ describe("HTTP MCP auth policy", () => {
if (!result.ok) return;
expect(transportOptions).toEqual([
{
reconnectionOptions: MCP_HTTP_RECONNECTION_OPTIONS,
authProvider,
requestInit: { signal: expect.any(AbortSignal) },
fetch: expect.any(Function),
},
]);

const call = result.client.call("ping", {}, abort.signal);
const call = result.client.callResult("ping", {}, abort.signal);
while (tokenRefreshSignals.length === 0) await Promise.resolve();
expect(tokenRefreshSignals[0]).toBeDefined();
expect(tokenRefreshSignals[0]?.aborted).toBe(false);
Expand Down Expand Up @@ -298,6 +305,7 @@ describe("HTTP MCP auth policy", () => {
expect(providerServerURL).toBe("https://custom.example/mcp?mode=full");
expect(transportOptions).toEqual([
{
reconnectionOptions: MCP_HTTP_RECONNECTION_OPTIONS,
authProvider,
requestInit: { signal: expect.any(AbortSignal) },
fetch: expect.any(Function),
Expand All @@ -322,7 +330,11 @@ describe("HTTP MCP auth policy", () => {
expect(transport.requestInit?.signal).not.toBe(connectAbort.signal);
expect(transport.fetch).toBeTypeOf("function");

const call = result.client.call("ping", {}, new AbortController().signal);
const call = result.client.callResult(
"ping",
{},
new AbortController().signal,
);
while (tokenRefreshSignals.length === 0) await Promise.resolve();
expect(tokenRefreshSignals[0]).not.toBe(connectAbort.signal);
expect(tokenRefreshSignals[0]?.aborted).toBe(false);
Expand Down
Loading
Loading