Skip to content

[Bug]: Typed MCP tool failures return isError:false and violate the advertised success schema #15566

Description

@andrewfree

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

Summary

T3 returns declared MCP tool failures as successful CallToolResult envelopes. The body contains OrchestratorMcpFailure, but isError is false. A failed read-only lookup reproduces this without changing thread state.

This came up when an agent tried to settle its active thread. The lifecycle refusal is correct. This report concerns how the refusal is represented to the MCP client, not permitting active-thread settlement.

Steps to reproduce

  1. Use a running T3 thread with the built-in t3-code MCP tools.

  2. Call t3_queue_list with a syntactically valid thread ID that does not exist in the calling project:

    {"threadId":"00000000-0000-4000-8000-000000000000","limit":1}
  3. Inspect the complete MCP result, including isError. It returns the failure below with isError: false.

  4. As a success control, call t3_queue_list with {"limit":1} in the current thread. It returns the queue and isError: false.

  5. The same failure flag reproduces with t3_thread_read for that nonexistent ID. No pin, archive, settle, provider restart, or database mutation is needed.

Expected behavior

Declared tool execution failures have isError: true. Their representation should also be compatible with the advertised output schema so an MCP client can receive the domain refusal rather than a schema-validation error. Successful calls continue to have isError: false.

Keep lifecycle and authorization guards unchanged. This does not request exposing arbitrary exception text or private storage paths.

Actual behavior

The failed t3_queue_list call returns:

{
  "content": [{
    "type": "text",
    "text": "{\"_tag\":\"OrchestratorMcpFailure\",\"code\":\"orchestration_error\",\"message\":\"The operation could not be completed.\"}"
  }],
  "structuredContent": {
    "_tag": "OrchestratorMcpFailure",
    "code": "orchestration_error",
    "message": "The operation could not be completed."
  },
  "isError": false
}

The successful current-thread control returns structuredContent: {"items":[],"nextCursor":null}, also with isError: false.

Impact

Minor bug or occasional failure. Clients relying on the standard error flag cannot distinguish these failures from success. A schema-validating client can replace the useful refusal with a result-schema error.

T3's grouped tool-summary code recognizes _tag: "OrchestratorMcpFailure" independently in packages/shared/src/toolOutput.ts:77-86. Therefore this is not a claim that every T3 UI renders the call as successful. The Pi MCP bridge uses only the outer isError flag at piT3McpExtensionSource.ts:118-124,293; that consequence is source analysis, not a tested Pi session.

Version or commit

Live reproduction: 0.0.46-nightly.20261004.2644.

Source investigation: pingdotgg/t3code main at d60a71ef6e371d8b345ecb1f7575117d675187c7. I have not established that the installed nightly was built from this exact SHA. Both exhibit the relevant behavior.

Environment

T3 Code Nightly desktop, macOS 27.2, arm64, Codex provider. Isolated reproduction uses Node v26.10.0, effect@4.0.0-rc.115 as pinned by T3, and @modelcontextprotocol/sdk@1.32.0.

Logs or stack traces

An isolated call through the actual Effect Tool / Toolkit / McpServer.callTool code reproduces the failure flag. Replaying its unchanged tool definitions and results through the official MCP SDK over an in-memory transport gives:

returned_failure isError=false MCP error -32602: Structured content does not match the tool's output schema: data must have required property 'ok'
raised_failure isError=true accepted
success isError=false accepted

The raised_failure control uses the same declared error with failureMode: "error". It demonstrates that default error handling takes a different path. This is a dependency-level minimization, not a full T3 server integration test.

Diagnosis

At the pinned T3 revision:

  • Thread tools declare failure: OrchestratorMcpFailure and failureMode: "return". The organize tool does too at lines 48-50.
  • T3 registers the toolkit through McpServer.toolkit.
  • Effect Toolkit preserves the failed handler result as isFailure: true.
  • Effect's MCP adapter ignores that field and creates CallToolResult with isError: false. It advertises the success schema at lines 1547-1550. I verified the same implementation in the installed effect@4.0.0-rc.115 package used by the minimization. T3's Effect patch does not modify registerToolkit or this result serialization path.

The existing bounded public failure test asserts the failure body but not isError. Retaining its private-cause redaction is important.

Separately, the original active-settle refusal loses its reason through Effect.mapError(unavailable) in thread/handlers.ts:300. Fixing the envelope alone will not improve that message. That is context, not an additional requested fix in this issue.

Screenshots, recordings, or supporting files

The complete dependency-level reproduction is included below. It opens no listener, starts no provider, and reads no T3 state. Run in an empty temporary directory:

npm init -y
npm install --ignore-scripts --no-audit --no-fund effect@4.0.0-rc.115 @modelcontextprotocol/sdk@1.32.0
node repro.mjs
repro.mjs
import assert from 'node:assert/strict';
import { Effect, Layer, Schema } from 'effect';
import { McpSchema, McpServer, Tool, Toolkit } from 'effect/unstable/ai';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';

class Denied extends Schema.TaggedError()('Denied', { message: Schema.String }) {}
const modes = { returned_failure: 'return', raised_failure: 'error', success: 'return' };
const toolkit = Toolkit.make(...Object.entries(modes).map(([name, failureMode]) =>
  Tool.make(name, {
    parameters: Schema.Struct({ action: Schema.String }),
    success: Schema.Struct({ ok: Schema.Boolean }), failure: Denied, failureMode,
  })));
const handlers = toolkit.toLayer(Object.fromEntries(Object.keys(modes).map(name => [name,
  () => name === 'success' ? Effect.succeed({ ok: true }) :
    Effect.fail(new Denied({ message: 'Known domain refusal' })),
])));
const clientContext = McpSchema.McpServerClient.of({
  clientId: 1, protocolVersion: '2025-06-18', clientCapabilities: {},
  clientInfo: { name: 'repro', version: '1' },
  initializePayload: { protocolVersion: '2025-06-18', capabilities: {},
    clientInfo: { name: 'repro', version: '1' } }, getClient: Effect.die('unused'),
});
const captured = await Effect.runPromise(Effect.gen(function* () {
  const server = yield* McpServer.McpServer;
  const results = {};
  for (const name of Object.keys(modes)) {
    results[name] = yield* server.callTool({ name, arguments: { action: 'test' } }).pipe(
      Effect.provideService(McpSchema.McpServerClient, clientContext));
  }
  return { tools: server.tools.map(({ tool }) => tool), results };
}).pipe(Effect.provide(McpServer.toolkit(toolkit).pipe(
  Layer.provide(handlers), Layer.provideMerge(McpServer.McpServer.layer)))));
assert.equal(captured.results.returned_failure.isError, false);
assert.equal(captured.results.raised_failure.isError, true);
assert.equal(captured.results.success.isError, false);

// Replay the actual Effect outputs and tool definitions without rewriting either.
const server = new Server({ name: 'replay', version: '1' }, { capabilities: { tools: {} } });
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: captured.tools }));
server.setRequestHandler(CallToolRequestSchema, async ({ params }) => captured.results[params.name]);
const client = new Client({ name: 'repro-client', version: '1' });
const [a, b] = InMemoryTransport.createLinkedPair();
try {
  await server.connect(b);
  await client.connect(a);
  await client.listTools();
  for (const name of Object.keys(modes)) {
    let message;
    try {
      await client.callTool({ name, arguments: { action: 'test' } });
      message = 'accepted';
    } catch (error) { message = error.message; }
    assert.equal(message === 'accepted', name !== 'returned_failure');
    if (name === 'returned_failure') assert.match(message, /Structured content does not match/);
    console.log(name, 'isError=' + captured.results[name].isError, message);
  }
} finally { await client.close(); await server.close(); }

Workaround

An MCP consumer can inspect the known typed failure body in addition to isError. This only helps when the client exposes that body; it does not resolve output-schema validation. For settlement itself, wait for the run to finish and use the normal app lifecycle control.

Related reports and scope

Searched issues and PRs for isError, failureMode, OrchestratorMcpFailure, the generic error text, MCP failure/success, and output-schema failures. I found no report for this specific path.

Verification limits and authorship

Live read-only reproductions and isolated dependency/client tests were run. No full T3 integration suite, provider UI matrix, or fix was tested. No running installation or database was changed.

Investigation and reporting by GPT-6 Astra in the Codex harness through T3 Code, with independent source reviews from GPT-6.1 Sol, Cursor Auto, and Kimi K3 through their direct provider tools. This was not filed through npx t3 triage.

Activity

  1. juliusmarminge commented on Oct 4, 2026

    @juliusmarminge
    Member

    Note

    Grok responding on behalf of Julius.

    Triage

    Thanks for the careful reproduction and the dependency-level replay, @andrewfree. I confirmed this on current main (eac52f00). It isn't a duplicate of #13630, #15135, #15131, or #5583, and I didn't find another report for this path.

    What I found

    Declared MCP failures come back marked as successes.

    • Thread, queue, and the other built-in tools declare failure: OrchestratorMcpFailure with failureMode: "return" (apps/server/src/mcp/toolkits/thread/tools.ts). A missing thread in t3_queue_list fails in readThread before any queue read (apps/server/src/mcp/threadAccess.ts).
    • Effect's toolkit keeps that result as isFailure: true and encodes it with the failure schema.
    • McpServer.registerToolkit ignores isFailure. It always builds the CallToolResult with isError: false and advertises only the success schema as outputSchema. T3's Effect patch doesn't touch this path.
    • apps/server/src/mcp/toolkits/core.test.ts asserts the OrchestratorMcpFailure body but not isError.

    Switching to failureMode: "error" behaves differently. It does set isError: true, but only with error.message as text and no structured failure, so the typed body that test and packages/shared/src/toolOutput.ts rely on would be lost. T3's own tool summary already treats _tag: "OrchestratorMcpFailure" as a failure. The Pi bridge only checks the outer isError flag (piT3McpExtensionSource.ts).

    Your schema error follows from the same mismatch: structuredContent is the failure object while outputSchema describes the success object. A client that validates structuredContent even when isError is true would still reject the failure body against the success schema.

    As you noted, the generic settle message from Effect.mapError(unavailable) in thread/handlers.ts is a separate loss of detail that fixing the envelope won't restore.

    Likely fix area

    • The MCP adapter could map result.isFailure to isError: true and keep the encoded OrchestratorMcpFailure available to clients.
    • The advertised output schema would then need to accept that failure body, or the failure could be carried only in the text part (already a JSON.stringify of the body), depending on how much callers rely on the typed object.
    • In either case, the lifecycle and authorization checks and the bounded public message in the core test can stay as they are.

    A maintainer will decide on the fix direction.

  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Oct 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions