Skip to content

Task goroutine context is canceled when HTTP response is flushed (executeRegularToolAsTask) #897

Description

@alejandro5042

Description

When a tool registered with AddTool + WithTaskSupport(TaskSupportOptional) is called asynchronously (client sends task params), the handler's context gets canceled immediately after the CreateTaskResult response is written.

Root Cause

In server.go, executeRegularToolAsTask derives the task context from the HTTP request context:

// handleToolCall passes ctx derived from r.Context()
go s.executeRegularToolAsTask(ctx, entry, regularTool, request)

// executeRegularToolAsTask (line ~1793)
taskCtx, cancel := context.WithCancel(ctx)  // ctx is still bound to r.Context()

Once the HTTP handler returns (after writing the CreateTaskResult JSON response), the HTTP server cancels r.Context(), which cascades into taskCtx and kills the tool handler goroutine.

The same issue exists in executeTaskTool for AddTaskTool handlers.

Reproduction

  1. Register a tool with AddTool + WithTaskSupport(mcp.TaskSupportOptional)
  2. Have the handler do any work that takes more than a few milliseconds (e.g. an HTTP call to an LLM)
  3. Call the tool via streamable HTTP transport with task params
  4. Observe: handler fails with context canceled

Expected Behavior

The task goroutine should run to completion regardless of the HTTP request lifecycle. The context should only be canceled when:

  • The task's own timeout/TTL expires
  • A client sends tasks/cancel
  • The server shuts down

Suggested Fix

Use context.WithoutCancel (Go 1.21+) to detach from the HTTP request lifecycle while preserving context values (session info, etc.), then derive a cancellable context for task-level cancellation:

// executeRegularToolAsTask
taskCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))

This preserves:

  • Context values (session metadata, etc.)
  • Task-level cancellation via cancel() (wired to tasks/cancel)

While removing:

  • Unwanted cancellation cascade from the HTTP request lifecycle

Workaround

Handlers can work around this today by detaching at the top:

s.AddTool(tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
    ctx = context.WithoutCancel(ctx)  // detach from HTTP request lifecycle
    // ... long-running work ...
})

Downside: this also makes tasks/cancel non-functional for that handler.

Environment

  • mcp-go version: v0.47.1
  • Go version: 1.23+
  • Transport: streamable HTTP

Activity

  1. mark-iii-labs-huly commented on May 28, 2026

    @mark-iii-labs-huly

    Connected to Huly®: MCP_G-462

  2. jhonsferg commented on Jul 13, 2026

    @jhonsferg

    Ran into this independently while building an MCP server on top of mark3labs/mcp-go v0.56.0 - wanted to add a data point since the reproduction here happens over stdio, not just streamable HTTP, which changes the root-cause story a bit.

    Reproducing over stdio

    Registered a plain tool via AddTool + mcp.WithTaskSupport(mcp.TaskSupportOptional), enabled WithTaskCapabilities(true, true, true), and drove it over ServeStdio with raw JSON-RPC (no HTTP transport in the picture at all). Sending tools/call with a task param behaves exactly as described here: CreateTaskResult comes back immediately with status: "working", a notifications/tasks/status "completed" notification follows within milliseconds, and tasks/result then reports the handler's own error - context canceled - before any real work (an outbound HTTP call in my case) had a chance to finish.

    Where it actually comes from

    Since there's no net/http request in this path, it can't be r.Context() getting cancelled on response flush. Tracing it through request_handler.go:

    // HandleMessage
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()
    if baseMessage.ID != nil {
        key := inflightKey(ctx, baseMessage.ID)
        s.inflightCancels.Store(key, cancel)
        defer s.inflightCancels.Delete(key)
    }

    This wrapper exists to support notifications/cancelled for normal (non-task) calls, and it's harmless there since the handler runs to completion before HandleMessage returns. But for a task-augmented call, handleToolCall -> handleTaskAugmentedToolCall launches go s.executeRegularToolAsTask(ctx, ...) and then returns immediately so HandleMessage can send the CreateTaskResult response, which fires this defer cancel() right as (or before) the spawned goroutine's own context.WithCancel(ctx) gets a chance to observe a still-live parent.

    So the cancellation source here is transport-agnostic: it's HandleMessage's own inflight-cancellation bookkeeping, not something specific to the HTTP transport's request lifecycle. Streamable HTTP just happens to be the transport where people noticed it first, likely because r.Context() cancellation is a more familiar shape of this class of bug.

    On the fix in #900

    context.WithoutCancel(ctx) at the executeTaskTool/executeRegularToolAsTask boundary is the right fix regardless of which ancestor is doing the cancelling, since it detaches from cancellation propagation from any parent, not just an HTTP request context - so it should close this stdio case too once merged. Happy to help verify against the stdio repro if useful.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions