Repository navigation
Description
Summary
The ACP terminal fallback drops server requests that arrive before a tool result in an event stream. It should answer those requests while retaining the matching tool response. A fake-fetch reproduction sends a server ping followed by the successful tool result: the CLI prints the result and ends the session without ever replying to the ping.
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
- Invoke the production
acp-mcp-callfast path with synthetic environment credentials and fake fetch. - Return a successful initialize response, then an empty HTTP 202 for the initialized notification.
- Return two SSE events for the tool call: a server
pingrequest with its own ID and then the matching successful tool response. - Verify that the tool result is printed as a nearby control, and inspect all captured POSTs for a response to the server ping.
Reproduced deterministically in one recorded run of this script. Save the following as repro-ping.mjs outside the checkout and run node repro-ping.mjs /path/to/t3code from a clean checkout of the stated commit with its locked dependencies installed. It requires Node with TypeScript stripping support, imports the production implementation, supplies fake fetch responses without network access, creates disposable HOME/TMPDIR/T3CODE_HOME directories, removes them on exit, and leaves the checkout unchanged. Exit 0 means restored behavior, 1 means the defect reproduced, and 2 means setup or the nearby control failed.
Reproduction script: repro-ping.mjs
import { mkdtemp, mkdir, rm, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
const repo = resolve(process.argv[2]);
const scratch = await mkdtemp(join(tmpdir(), 'mcp-repro-'));
let status = 2;
try {
for (const name of ['home', 'tmp', 'state']) await mkdir(join(scratch, name));
process.env.HOME = join(scratch, 'home');
process.env.TMPDIR = join(scratch, 'tmp');
process.env.T3CODE_HOME = join(scratch, 'state');
const require = createRequire(join(repo, 'apps/server/package.json'));
const Effect = await import(require.resolve('effect/Effect'));
const { mcpToolSession, runAcpMcpCliFastPath } = await import(pathToFileURL(join(repo, 'apps/server/src/mcp/AcpMcpStdioBridge.ts')));
console.log(`Node ${process.version}; effect ${JSON.parse(await readFile(require.resolve('effect/package.json'), 'utf8')).version}`);
const sent = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_url, init) => {
if (init.method === 'DELETE') return new Response(null, { status: 204 });
const request = JSON.parse(init.body);
sent.push(request);
if (request.method === 'initialize') return Response.json({ jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'test', version: '1' } } }, { headers: { 'mcp-session-id': 'test-session' } });
if (request.method === 'notifications/initialized' || request.id === 'server-ping') return new Response(null, { status: 202 });
const result = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: 'ok' }] } };
const ping = { jsonrpc: '2.0', id: 'server-ping', method: 'ping' };
return new Response(`data: ${JSON.stringify(ping)}\n\ndata: ${JSON.stringify(result)}\n\n`, { headers: { 'content-type': 'text/event-stream' } });
};
try {
process.env.T3_ACP_MCP_ENDPOINT = 'http://127.0.0.1:1/mcp';
process.env.T3_ACP_MCP_AUTHORIZATION = 'Bearer test';
await runAcpMcpCliFastPath('acp-mcp-call', ['echo', '{}']);
const replied = sent.some(message => message.id === 'server-ping' && message.result !== undefined);
const methods = sent.filter(message => message.method).map(message => message.method);
if (methods.join(',') !== 'initialize,notifications/initialized,tools/call') throw new Error('handshake control failed');
console.log('control: initialized session and tool call completed');
console.log(`reply to interleaved server ping: ${replied}`);
status = replied ? 0 : 1;
} finally { globalThis.fetch = originalFetch; }
} catch (error) { console.error(error.message); status = 2; }
finally { await rm(scratch, { recursive: true, force: true }); }
console.log(status === 0 ? 'RESULT: expected behavior' : status === 1 ? 'RESULT: defect reproduced' : 'RESULT: inconclusive');
process.exitCode = status;Expected behavior
The helper must respond to a received request with its matching ID rather than silently dropping it. MCP 2025-06-18 Sending Messages to the Server expressly permits server requests before the response on a POST's SSE stream. MCP ping behavior requires the receiver of a ping to promptly return an empty result. This report does not request periodic ping initiation or support for unadvertised sampling, roots, or elicitation features. More generally, JSON-RPC 2.0 section 5 requires a response for a request, including an error for an unavailable method.
Actual behavior
runAcpMcpCliFastPath('acp-mcp-call', ['echo', '{}']) completes its handshake and prints the tool result, but the captured HTTP messages contain no response to the interleaved {"jsonrpc":"2.0","id":"server-ping","method":"ping"} request. The synthetic peer supplies that request before the tool response in one SSE body. This is a run of the public CLI implementation with global fetch substituted; no real network endpoint was involved.
Impact
Minor bug or occasional failure
Impact: P2 - A valid server request goes unanswered on the terminal MCP path even when the surrounding tool call succeeds. A peer can consequently treat this client as unresponsive; that policy is allowed by the ping specification, but no production peer timeout or disconnection was measured.
The observed failure is in mcpToolSession, used by acp-mcp-call. The stdio bridge has a separate streaming forwarding path. NOT_ENOUGH_INFO: whether the current T3 server emits pings during real tool calls, and a practical workaround for a terminal-only caller, were not established; a live trace would establish reach and workaround availability. No failure of a negotiated optional client capability is claimed.
Version or commit
T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93.
Environment
Node v26.12.1, macOS 27.2, arm64; repository-pinned [email protected]. No live provider, account, or server was used.
Evidence
Actual output from the script:
Node v26.12.1; effect 4.1.3
{"content":[{"type":"text","text":"ok"}]}
control: initialized session and tool call completed
reply to interleaved server ping: false
RESULT: defect reproduced
Source ownership: apps/server/src/mcp/AcpMcpStdioBridge.ts#L115-L134 collects the entire response stream and selects only the envelope whose ID matches the helper's outgoing request; it neither dispatches nor replies to the other request envelopes. apps/server/src/mcp/AcpMcpStdioBridge.ts#L169-L179 scopes this helper to the terminal fallback, and apps/server/src/mcp/AcpMcpStdioBridge.ts#L375-L408 invokes it and prints its result. The fake peer sends protocol-valid SSE messages and accepts responses with empty HTTP 202. Effect only runs T3's response-consumption code here; the MCP server adapters are not on this path.
Restoration check
The supplied script should capture a matching empty-result response POST for the interleaved server ping, keep the original successful tool result, and exit 0.
Workaround
No terminal-only workaround was verified. The production stdio bridge uses a separate streaming path; this report does not claim a live workaround test.
References
All-state searches for MCP ping, server requests, SSE, and acp-mcp-call, including the active author's reports, found no matching open issue. Searches will be refreshed immediately before submission.
Activity