Skip to content

feat(mcp): background MCP tasks → task_notification - #2

Open
HeavenllyDemon wants to merge 5 commits into
mcp-sdk-migrationfrom
mcp-background-tasks
Open

feat(mcp): background MCP tasks → task_notification#2
HeavenllyDemon wants to merge 5 commits into
mcp-sdk-migrationfrom
mcp-background-tasks

Conversation

@HeavenllyDemon

Copy link
Copy Markdown
Member

Stacked on #1base is mcp-sdk-migration, not main, so this diff shows only the tasks work. #1 must merge first.

A task-capable MCP tool now hands the turn back immediately and reports completion through the same task_notification path that backgrounded bash and detached subagents already use.

Server-directed, by design

Norma adds no opt-in surface and never touches a server-authored schema. client.experimental.tasks.callToolStream decides per call:

// sdk: experimental/tasks/client.js
task: options?.task ?? (clientInternal.isToolTask(params.name) ? {} : undefined)

If a tool wants to run in the background, that's the server's declaration to make. A non-task server takes the synchronous arm and is byte-identical to #1 — which is why every pre-existing MCP test passes unmodified.

callToolTask(name, args)
  ├─ not task-capable → 'result' only                 → unchanged behaviour
  └─ task-capable     → 'taskCreated'                 → register + return "Task started: …"
                        'taskStatus'* → 'result'|'error'
                        → settle → takeForNotification → task_notification → wake

What's here

File Role
mcp/task-registry.ts (new) Third sibling of bg-registry and bg-agent-registry. Lifecycle only, never throws, takeForNotification exactly-once.
mcp/client.ts callToolTask consumes the stream; returns at taskCreated so the turn isn't held open, drains the rest in the background. cancelTask passthrough.
mcp/manager.ts Owns the registry, fires onTaskSettled, shared renderBlocks.
engine.ts notifyMcpTaskCompletion+36 lines, 0 deletions, so every existing bg path is byte-identical.
daemon.ts One later-assigned closure, the same shape dispatchChildren already uses.
test/agent/mcp/fake-task-server.ts (new) Task-capable server on the SDK's own server half — no new dependency.

Security: server output lands in durable history

A task result is authored by a third-party MCP server and is persisted into session history that replays into every later turn. It goes through the same sanitisation notifyBgCompletion applies to subagent output, plus one step further: both ends of the tag are escaped, not just the closing one — escaping only </task-notification> still lets a payload emit a bare opening tag that reads as a nested block.

The injection test pins containment, not tag stripping: an MCP server returning markup is legitimate, so the guarantee is that hostile content cannot end the block early or fake a nested one, and stays inert inside <result>. (notifyBgCompletion escapes only the closing tag; the same hardening would suit it, but changing that path is out of scope here.)

Races handled

  • Abort: ctx.signal cancels both sides — cancelTask upstream so the server stops working, plus a local cancelled state.
  • Abort vs. settle: genuinely concurrent. The registry's first terminal state wins, so a straggling result can't rewrite a cancellation into a completion.
  • Exactly-once: both paths call onTaskSettled; takeForNotification is the single-consumer claim, so only one notification is ever appended. Pinned by test.
  • Image after its turn: ctx.attachImage belonged to a turn that has ended, so it degrades to a labelled placeholder instead of silently vanishing.

Getting a task to actually happen took three attempts

Recorded in the fixture, because each condition fails differently and one fails silently:

  1. tool declares execution: { taskSupport: "required" };
  2. server declares the tasks.requests.tools.call capability — without it isToolTask returns false early and the call runs synchronously with no error at all;
  3. server is constructed with taskStore in its ProtocolOptions — without it the SDK throws before reaching the handler, surfacing as a misleading Invalid task creation result: task undefined on the client.

Verified sequence: taskCreated → taskStatus(working) → taskStatus(completed) → result. A failing task arrives as the stream's error message, not a result with isError: true — so the error arm settles rather than throws once a task exists.

Verification

Result
core suite 4007 pass / 1 skip / 1 fail (pre-existing sandbox mktemp, reproduces on pristine 4d981b51)
MCP + notification suites 144 / 0
@norma/protocol / @norma/plugin-sdk 212 / 0, 23 / 0
engine-spawn.test.ts 101 pre-existing pass unmodified (the new setup({ mcp }) option defaults to undefined)
tsc --noEmit 6 errors, all pre-existing in approvals.test.ts, none in agent/mcp
verify:workflow PASS on the real compiled artifact
Size JS bundle +4,978 bytes; compiled binary unchanged at 71,404,898 (payload fits existing padding)

Not in scope

  • input_required interactive fulfilment — treated as terminal. QuestionBroker is the natural future wiring; Norma consumes no MCP elicitation today.
  • Task recovery across daemon restarts — the registry is engine-memory, exactly like bg-agent-registry. listTasks(cursor?) is the SDK primitive a future pass would use, and it's paginated.
  • Surfacing MCP tasks in task_get/task_list — those are Norma's to-do list, a different concept that shares the word.

Third sibling of bg-registry (bash) and bg-agent-registry (subagents), with
the same lifecycle-only, never-throws, takeForNotification-exactly-once
contract. First terminal state wins, so an abort racing the stream's own
settlement cannot be rewritten by a straggling result.

The test server is built on the SDK's own server half, so the task test story
costs no new dependency. Getting it to actually produce a task took three
attempts, and the fixture documents all three requirements because each fails
differently — most dangerously the missing server capability, which makes the
call run synchronously with no error at all.

Verified: taskCreated -> taskStatus(working) -> taskStatus(completed) -> result.
The SDK decides per call whether a tool runs as a task: it augments the request
only when the tool declares execution.taskSupport AND the server advertised a
tasks.requests.tools.call capability. So one path serves both arms, non-task
servers stay byte-identical, and Norma never touches a server-authored schema.

The task arm returns at taskCreated so the turn is not held open, then drains
the rest in the background to settle the handle.

A FAILING task arrives as the stream's `error` message rather than a `result`
with isError:true — verified live — so once a task exists the error arm settles
ok:false instead of throwing. Before a task is created there is nothing to
settle later, so that arm still throws, matching callTool/callToolContent.

callToolContent's inline block mapper is now the shared toBlocks helper.
A task-capable tool now returns a started-notice immediately instead of holding
the turn open, and settles later through onTaskSettled. A non-task server is
untouched: the SDK never augments its request, so it takes the sync arm and
every pre-existing MCP test passes unmodified.

ctx.signal aborts BOTH sides — cancelTask upstream so the server stops working,
plus a "cancelled" terminal state locally. The registry's first-terminal-state-
wins rule settles the genuine race between that abort and the stream's own
settlement, so the notification stays honest instead of a straggling result
rewriting it.

An image settling after its turn cannot be attached (ctx.attachImage belonged
to a turn that has ended), so it degrades to a labelled placeholder rather than
silently vanishing.

38 pass across the MCP suite, 56 across everything MCP-touching.
Sibling of notifyBgCompletion, with the same exactly-once claim and the same
wake discipline (defer while a turn runs, else start one). Pure addition —
engine.ts gains 36 lines and deletes none, so every existing bg path is
byte-identical.

The result is sanitized on the same passes, and for a stronger reason: it is
authored by a third-party MCP SERVER, not a subagent, and lands in durable
history replayed into every later turn. Both ends of the tag are escaped here,
not just the closing one: escaping only `</task-notification>` still lets a
payload emit a bare opening tag that reads as a nested block. notifyBgCompletion
escapes only the closing tag; the same hardening would suit it, but changing
that path is out of scope.

The injection test pins CONTAINMENT rather than tag stripping — an MCP server
returning markup is legitimate, so the guarantee is that hostile content cannot
end the block early or fake a nested one, and stays inert inside <result>.

setup() gains an optional `mcp` so a test can wire EngineConfig.mcp; default
undefined leaves all 101 pre-existing engine-spawn tests byte-identical.
Same later-assigned-closure shape as dispatchChildren and
engine?.transcriptPathFor: McpManager is constructed long before the engine
exists, so the callback is assigned after construction rather than passed in.
The closure only ever runs when a real task settles.

End to end now: a task-capable tool returns a started-notice, the turn
continues, and completion arrives as a task_notification that wakes the
session.
HeavenllyDemon added a commit that referenced this pull request Aug 19, 2026
…ents (checkpoint 7)

- Correct the transport-cost attribution: the scroll-storm test's onTile
  callback only measures base64.utf8.count and never calls
  Data(base64Encoded:), so the ~19ms/tile decode cost is ADDITIVE on top of
  the measured 25.97/36.81ms warm/cold numbers, not already included in
  them. Folded back in, warm lands at the sustained-scroll demand estimate
  rather than comfortably clearing it; verdict revised to "clears with
  modest headroom" with a T6 recommendation to pipeline decode off the
  wire-receive path.
- Update parseInvalidateTiles/parseModifiedStatus doc comments (OfficeWire.swift)
  with Task 4's real re-judgment: the part-defaulting leniency stays
  unjudged (zero real INVALIDATE_TILES firings observed against a
  view-only document), the ModifiedStatus non-"true" leniency is confirmed
  a safe fail-closed default with its mainline shape verified live but the
  leniency itself still unexercised by real data.
- Fix OfficeWireCodecTests.swift's now-stale claim that its parser table is
  "the ONLY exercise of this parsing logic anywhere" -- true only for
  parseInvalidateTiles now that the live probe cross-checks
  parseModifiedStatus against a real firing.
- Cross-reference the Debt #2 pin split from both tests, not just the new
  one: testGateXlsxRawTileHashMatchesTheGateTablePin now names itself the
  vendor-integrity half and points at its product-path sibling.
- Disclose the stderr capture mechanism's line-based truncation of
  multi-line LOK callback payloads (observed live: a bare "{" fragment)
  so it isn't mistaken for a real, complete payload shape.

No production behavior changes -- doc comments and test-comment honesty
only, per advisor review before this task's report was finalized.
HeavenllyDemon added a commit that referenced this pull request Aug 19, 2026
Fix round 1's first pass covered trap #1 (indexRange overflow) and trap
#2's non-trapping "huge but representable, exceeds the cap" variant over
the live helper, but not trap #2's own genuine multiplication-overflow
input -- caught on advisor review of the report before sending it back.
testOverflowingViewportAtTheFinestValidZoomIsRefusedAndTheHelperSurvives
drives it: zoomPPT at TileMath.maxZoomPPT (span 5 twips/tile), a length
just under Int64.max/2 (indexRange itself stays representable, ~4.6e18 <
Int64.max), producing a per-axis tile count around 9.2e17 whose SQUARE
overflows Int64 inside estimatedTileCount's own multiplication -- refused
as viewportTooLarge, helper survives a ping afterward. Same shape as
TileMathTests' pure testEstimatedTileCountAllFourOutcomes case (d),
now also proven live. All three trap inputs are now each driven over the
real wire, per the review's original instruction.

Targeted re-run (TileMath/TileCache/codec + the live classes): 92 tests,
0 failures.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant