Skip to content

bug: MCP Task TTL is measured from createdAt, so long-running tasks are deleted while still working #329

Description

@vnmchat13

Summary

TaskManager measures an MCP Task's TTL from createdAt rather than from last activity, and the cleanup sweep deletes expired tasks unconditionally. A task that is still legitimately working past its TTL is deleted mid-flight, after which tasks/result has nothing to return.

Reporting progress with ctx.task.updateProgress() does not reset the clock, which is surprising given progress reporting is the documented pattern for long-running work.

Environment

  • NitroStack package(s) and version(s): @nitrostack/core@1.0.14
  • Node.js version (node -v): v24.18.0
  • npm version (npm -v): 11.16.0
  • OS and version: macOS 26.2

Steps To Reproduce

  1. Define a tool with taskSupport: 'optional' whose handler legitimately runs longer than the TTL (e.g. it waits on an external system or a human).
  2. Invoke it task-augmented without specifying a TTL, so the default 300000 ms applies.
  3. Have the handler call ctx.task.updateProgress(...) periodically while it waits.
  4. Let it run past 5 minutes, then call tasks/result.

Expected Behavior

Either the task survives while it is actively reporting progress (TTL behaving as an idle timeout), or there is a documented server-side way for a handler to extend its own deadline.

Actual Behavior

The task is deleted by the cleanup sweep at createdAt + ttl regardless of activity. Retrieving it afterwards throws Task has expired (JSON-RPC −32602), even though the handler is still running.

Minimal Reproduction

@Tool({ name: 'wait_for_human', taskSupport: 'optional', inputSchema: z.object({}) })
async waitForHuman(_input: unknown, ctx: ExecutionContext) {
  // Waits on an out-of-band human decision — inherently unbounded.
  for (let i = 0; i < 200; i++) {
    ctx.task?.updateProgress(`still waiting… (${i * 5}s)`);
    await new Promise(r => setTimeout(r, 5000));
  }
  return { done: true };
}

Additional Context

Relevant implementation (dist/core/task.js):

  • ttl = params?.ttl ?? 300000
  • a cleanup interval runs every 30s deleting tasks where now - createdAt > ttl

Three things combine to make this sharp:

  1. Only the client can set the TTL (task: { ttl }). A server-side handler that knows it needs longer has no way to extend it.
  2. updateProgress() does not reset the clock, so a well-behaved long task that reports progress every second is still deleted at the deadline.
  3. Task storage is in-memory, so tasks are also lost on restart.

We hit this building a human-in-the-loop approval flow, where the wait is inherently unbounded because it depends on a person responding. We currently cap our wait below the default TTL to avoid it, but that puts an artificial ceiling on how long a human is allowed to take.

For context on why this may not have surfaced before: the sample apps in this repo that use Tasks — seer, Lab_triage_assistant, neurotwin-evolver, Reversible-Agent-Actions — all use them for bounded compute that completes comfortably inside the default TTL. The behaviour only becomes a problem when the wait is unbounded, such as when it depends on a person responding.

Suggested fix: treat the TTL as an idle timeout (reset it on updateProgress), and/or expose a way for the handler to extend its own deadline. Documenting the current semantics on the Tasks page would help in the meantime — the docs describe TTL as "time before a task may be deleted" without noting it is measured from creation rather than last activity.

Activity

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 isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions