[Bug]: ACP v2 sessions receive stdio MCP servers without advertising stdio support #35830
Send to my agentSummary#
An ACP v2 agent that does not advertise stdio MCP support can fail to start a thread because T3 still sends it a stdio server declaration. T3 should choose a transport the agent advertised and omit an unsupported declaration. A deterministic fake-peer run reproduced failed session creation, while the same peer advertising stdio support started successfully.
Before submitting#
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
- Removed credentials, tokens, private paths, and sensitive data.
Area#
apps/server
Steps to reproduce#
- Use a clean checkout of the stated commit with its locked dependencies installed.
- Run the script below against that checkout. It calls the production runtime and creates a local newline-delimited JSON-RPC peer.
- The control advertises ACP v2 stdio support and accepts
session/new; the failing input omits that capability and rejects a supplied stdio declaration.
The defect reproduced in one recorded run; the nearby advertised-stdio control succeeded in that same run.
Save this as repro-mcp.mjs outside the checkout and run node repro-mcp.mjs /path/to/t3code. It requires Node with TypeScript stripping support and Git. It prints their versions and the target revision, creates its peer and isolated HOME, TMPDIR, T3CODE_HOME and working directory under a temporary directory, removes them on exit, and leaves the checkout unchanged. Exit 0 means the unsupported declaration is absent and setup succeeds, 1 means the defect reproduced, and 2 means the setup or control failed.
Reproduction script: repro-mcp.mjs
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
import { execFileSync } from 'node:child_process';
const repo = resolve(process.argv[2]);
const scratch = mkdtempSync(join(tmpdir(), 'acp-mcp-repro-'));
let code = 2;
try {
for (const dir of ['home', 'tmp', 'state', 'cwd']) mkdirSync(join(scratch, dir));
Object.assign(process.env, { HOME: join(scratch, 'home'), TMPDIR: join(scratch, 'tmp'), T3CODE_HOME: join(scratch, 'state') });
process.chdir(join(scratch, 'cwd'));
const require = createRequire(join(repo, 'apps/server/package.json'));
const load = (name) => import(pathToFileURL(require.resolve(name)).href);
const Effect = await load('effect/Effect');
const Exit = await load('effect/Exit');
const Layer = await load('effect/Layer');
const NodeServices = await load('@effect/platform-node/NodeServices');
const NodeCrypto = await load('@effect/platform-node/NodeCrypto');
const Runtime = await import(pathToFileURL(join(repo, 'packages/provider-acp/src/server/AcpSessionRuntime.ts')).href);
console.log('Node ' + process.version);
console.log(execFileSync('git', ['--version'], { encoding: 'utf8' }).trim());
console.log('Revision ' + execFileSync('git', ['-C', repo, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim());
console.log('Effect ' + JSON.parse(readFileSync(require.resolve('effect/package.json'))).version);
const peer = join(scratch, 'peer.mjs');
writeFileSync(peer, `import { createInterface } from 'node:readline';
const supports = process.argv[2] === 'supported';
for await (const line of createInterface({input:process.stdin})) {
const r=JSON.parse(line); if(r.id===undefined)continue;
let result={},error;
if(r.method==='initialize') result={protocolVersion:2,capabilities:{session:{mcp:supports?{stdio:{}}:{}}},info:{name:'fixture',version:'1'}};
else if(r.method==='session/new') {
if(!supports && r.params.mcpServers?.some(s=>s.type==='stdio')) error={code:-32602,message:'Stdio MCP transport not advertised'};
else result={sessionId:'fixture-session'};
}
else error={code:-32601,message:'Unsupported fixture method'};
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,...(error?{error}:{result})})+'\\n');
}
`);
const run = async (supports) => {
let sent;
const exit = await Effect.runPromiseExit(Effect.gen(function* () {
const runtime = yield* Runtime.make({
spawn: { command: process.execPath, args: [peer, supports ? 'supported' : 'unsupported'], extendEnv: false, env: { HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, T3CODE_HOME: process.env.T3CODE_HOME } },
cwd: join(scratch, 'cwd'), clientInfo: { name: 'fixture-client', version: '1' },
mcpServers: [{ type: 'stdio', name: 't3-code', command: process.execPath, args: [] }],
requestLogger: event => Effect.sync(() => { if(event.method==='session/new' && event.status==='started') sent=event.payload; }),
});
return yield* runtime.start();
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))));
console.log(JSON.stringify({ advertisedStdio: supports, sentServers: sent?.mcpServers, outcome: exit._tag }));
return { exit, sent };
};
const control = await run(true);
if (!Exit.isSuccess(control.exit)) throw new Error('Control failed');
const failing = await run(false);
if (Exit.isSuccess(failing.exit) && (!failing.sent?.mcpServers?.length)) code = 0;
else if (Exit.isFailure(failing.exit) && failing.sent?.mcpServers?.some(s=>s.type==='stdio')) code = 1;
} catch (error) { console.log('Setup error: ' + error.message); }
finally { rmSync(scratch, {recursive:true,force:true}); }
console.log(code === 0 ? 'RESULT: expected behavior' : code === 1 ? 'RESULT: defect reproduced' : 'RESULT: inconclusive');
process.exitCode = code;
Expected behavior#
Only send a stdio MCP declaration to an ACP v2 agent that advertises capabilities.session.mcp.stdio. This is already required by T3's own pinned schema-v2.0.0-alpha.3: the McpCapabilities definition defines omitted or null stdio capability as unadvertised, and the McpServer stdio variant limits its availability to agents with session.mcp.stdio. The current session-setup transport check states the same client requirement. This report relies on the pinned schema, rather than introducing a later documentation requirement. ACP v1 differs: its pinned schema requires every agent to support stdio, so an unconditional v1 fallback is outside this finding.
Actual behavior#
Calling the production AcpSessionRuntime.make(...).start() with a stdio MCP server and an ACP v2 peer advertising capabilities.session.mcp: {} still sends that stdio server in session/new. The fixture rejects that unadvertised transport with JSON-RPC -32602, and the runtime fails startup. The otherwise identical peer advertising stdio: {} starts successfully. This is a runtime reproduction with a deterministic local peer, without a real provider, login, network endpoint, or paid prompt.
Impact#
Blocks work completely
Impact: P1 - Valid ACP v2 deployments that omit stdio and have no selected ACP or HTTP alternative cannot open a T3 session when the normal T3 MCP declaration is present.
The failure is deterministic for the tested peer. The normal adapter constructs the stdio fallback whenever a thread has a T3 MCP session. No practical user setting that suppresses that declaration on this path was established; changing the peer to advertise a transport it cannot support is not a workaround. NOT_ENOUGH_INFO: the number of real deployed v2 agents with these capabilities was not measured; an initialization capture from those deployments would establish prevalence. The successful control establishes that the test's process launch and protocol setup work.
Version or commit#
T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93; pinned ACP v2 schema schema-v2.0.0-alpha.3 and v1 schema schema-v1.21.0.
Environment#
Node v26.12.1, Git 2.56.0, Darwin 27.2.0, arm64, repository-pinned Effect 4.1.3. No real ACP agent was launched.
Interface: API: AcpSessionRuntime.make and start; also used by the local/Registry ACP adapter's thread setup.
Earliest failure stage: Integration or transport
Evidence#
Recorded script output, with the installed Node executable path replaced by [REDACTED]:
Node v26.12.1
git version 2.56.0
Revision 59e64eef881632a23f89c4138bcf37f23d92bf93
Effect 4.1.3
{"advertisedStdio":true,"sentServers":[{"type":"stdio","name":"t3-code","command":"[REDACTED]","args":[]}],"outcome":"Success"}
{"advertisedStdio":false,"sentServers":[{"type":"stdio","name":"t3-code","command":"[REDACTED]","args":[]}],"outcome":"Failure"}
RESULT: defect reproduced
packages/effect-acp/src/client.ts#L597-L602 correctly normalizes the v2 capability to stdio: false. packages/provider-acp/src/server/AcpSessionRuntime.ts#L2148-L2161 checks ACP and HTTP capabilities but returns the fallback list without checking stdio. packages/provider-acp/src/server/AcpSessionRuntime.ts#L2257-L2271 passes that list to session/new. The normal adapter fallback declaration comes from packages/provider-acp/src/server/adapter.ts#L888-L906. The fixture never launches the declared MCP command; it only validates its transport against the initialization capability.
Expected source: the linked upstream contract. Failure source: the production runtime/adapter run with the supplied fake peer. Evidence provenance: observed. Local verification: reproduced. Reproduction completeness: complete for the fake-peer case. Missing fact: live-deployment prevalence was not measured.
References#
- https://github.com/pingdotgg/t3code/issues/35320 — concerns an ACP v1 OpenClaw bridge rejecting all per-session MCP; this finding concerns explicit transport gating in the pinned ACP v2 contract.
- https://github.com/pingdotgg/t3code/pull/35392 — proposes tolerating agents that reject per-session MCP; the v2 client must also respect its negotiated stdio capability before sending setup.
Restoration check#
The script's advertised-stdio control should continue to succeed. The peer without stdio support should receive no stdio declaration and should start successfully, making the script print RESULT: expected behavior and exit 0. An absent MCP capability must not make an otherwise valid session unusable through an unsupported declaration.
tanstack-source added labelsbug
github-actions[bot](GitHub profile, opens in a new tab) added labelsvouch:denounced
Sign in to join this conversation.