Skip to content

fix(mcp-gateway): chat creation rejects a parentThreadId that isn't a chat - #175

Merged
jayleaton merged 2 commits into
mainfrom
fix/reject-unknown-parent-thread
Oct 31, 2026
Merged

jayleaton merged 2 commits into
mainfrom
fix/reject-unknown-parent-thread

Conversation

@jayleaton

@jayleaton jayleaton commented Oct 31, 2026 •

Copy link
Copy Markdown
Owner

Problem

t3_create_thread / t3_create_and_start_thread accepted any explicit parentThreadId. When the parent's environment differs from the target, the target server cannot check it (the orchestrator only validates same-environment parents), so it was stored as-is. On 30 Oct a Captain passed mcp:b33476a4-…, an MCP request namespace copied out of a delegate_task ID, with parentEnvironmentId set to its own environment. Six children were stored with a parent that doesn't exist and never appeared under Captain.

Fix

When parentThreadId is given explicitly, the gateway reads it on the parent's environment (parentEnvironmentId, or the target) through the existing getThreadOrNotFound:

  • A real parent costs one bounded getThread snapshot (server row and turn limits), not the full thread list.
  • Only when that read fails does it list threads, to tell a missing thread from other errors (relays pass errors on as text).

If the parent is missing, archived, or deleted, the call fails with invalid_input before any chat is created:

parentThreadId is not a chat on environment ; omit parentThreadId to nest under the calling chat.

Other errors propagate. No change when the parent is omitted (it still defaults to the calling chat) or set to null (standalone).

To see archived and deleted parents, gatewayThreadProjection and the McpGatewayRelayThread relay schema now carry archivedAt / deletedAt. Both fields are optional, so older apps still decode, and their archived parents pass the check.

Other paths checked:

  • t3_set_thread_parent already rejects missing/archived same-env parents in the orchestrator (requireValidParentThread).
  • Server-side t3_thread_launch / create_threads only ever parent to the calling chat, so they take no explicit parent.

Evidence

Extended parents chats a T3 chat creates on any environment in tools.test.ts. It asserts that a fake mcp:<uuid> parent (cross-env and same-env) and an archived parent are rejected with that message, and that createThread isn't called. Default, explicit-existing, and null cases are unchanged.

  • Each new assertion fails without its fix (promise resolved instead of rejecting).
  • Gateway tests 111/111, runtimePort.test.ts 29/29.
  • Typecheck is clean for mcp-gateway, client-runtime, and contracts.

Risk

Low. One bounded read, only on creates with an explicit parent. Two optional fields are added to the gateway thread projection.

Done by Claude Opus 5.5 in Claude Code (T3 Code).

🤖 Generated with Claude Code

… chat

An explicit parentThreadId on another environment was stored unchecked,
because the target server can only verify parents it owns. A Captain passed
an MCP request namespace (mcp:<uuid>) as the parent, and its children never
appeared under it. The gateway now looks the parent up on its environment
and fails with invalid_input when it is not a chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:S labels Oct 31, 2026
@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Codex Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Codex Live turn WebSocket wire 1.2 KiB 1.2 KiB 0 B (0.0%) 2.0 KiB ✅
Codex Live turn WebSocket decoded 21.6 KiB 21.6 KiB 0 B (0.0%) 24.4 KiB ✅
Codex Live turn messages 2 2 0 (0.0%) 8 ✅
Claude Total thread wire 5.0 KiB 5.1 KiB +6 B (+0.1%) 6.8 KiB ✅
Claude Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Claude Live turn WebSocket wire 1.3 KiB 1.3 KiB +6 B (+0.5%) 2.0 KiB ✅
Claude Live turn WebSocket decoded 21.9 KiB 21.9 KiB 0 B (0.0%) 24.4 KiB ✅
Claude Live turn messages 2 2 0 (0.0%) 8 ✅

Baseline: a2160b9 · PR result: cbd2530 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

The parent check listed every thread shell on the parent's environment
(about 5,000 on a busy one). It now reads the parent with the bounded
getThread snapshot and lists only when that read fails, to tell a missing
thread from any other error (relays pass errors on as text). The gateway
projection and relay schema now carry archivedAt and deletedAt, so archived
and deleted parents stay rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jayleaton
jayleaton marked this pull request as ready for review October 31, 2026 02:56
@jayleaton
jayleaton merged commit 6080534 into main Oct 31, 2026
28 of 29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:S vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant