Skip to content

feat(tasks): serve the MCP Tasks extension - #35

Merged
dbernheisel merged 7 commits into
mainfrom
ext-tasks
Oct 5, 2026
Merged

dbernheisel merged 7 commits into
mainfrom
ext-tasks

Conversation

@dbernheisel

@dbernheisel dbernheisel commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Add Phantom.Tasks. A tool returns {:reply, %Phantom.Tasks{}, session} to answer tools/call with a task (lib/phantom/tasks.ex, Router.finalize_result/5).
  • Phantom does not store tasks. The router implements get_task/2 to enable the extension. update_task/3, cancel_task/2, and authorize_task_subscriptions/2 are optional (lib/phantom/router.ex).
  • Dispatch tasks/get, tasks/update, and tasks/cancel under MCP 2026-07-28. Return -32021 when the request does not declare the extension, -32602 for an unknown task, and -32601 when the router does not implement tasks.
  • Require Mcp-Name to equal taskId on tasks/* (lib/phantom/plug.ex).
  • Accept taskIds on subscriptions/listen. Phantom.Tracker.notify_task_updated/1 sends notifications/tasks. Each stream fetches the task with get_task/2 before it sends, and stops following a finished task.
  • Add Phantom.Session.tasks_supported?/1, Phantom.Request.missing_task_capability/0, Phantom.Elicit.to_input_request/1, and Phantom.Test task helpers.
  • Add Phantom.Session.progress_ref/1. It gives a JSON-safe reference to the current request. A job on any node sends progress with Phantom.Session.notify_progress/4. The request stream subscribes to a PubSub topic for the request before the handler runs.
  • Run the 10 tasks scenarios of the conformance suite on the single and the distributed topology. They must pass.
  • Add guides/tasks.md with examples for Ecto, Oban, and Oban Pro.
  • Deferred: a limit on the length of taskIds, a capability check on task inputRequests, and removal of the duplicated fail-closed and notification blocks that the resource code also has.

Evidence

Run with MIX_ENV=test iex -S mix. The conformance router runs over Bandit. Req sends the requests over HTTP. The first task asks for input, gets it, and completes. The second task is cancelled while it works.

iex
iex> Phantom.Cache.register(Phantom.Conformance.MCP.Router)
iex> {:ok, _} = Bandit.start_link(plug: {Phantom.Test.ClusterPlug, phantom_opts: [router: Phantom.Conformance.MCP.Router, validate_origin: false]}, port: 4199)
iex> meta = %{"io.modelcontextprotocol/protocolVersion" => "2026-07-28", "io.modelcontextprotocol/clientCapabilities" => %{"elicitation" => %{}, "extensions" => %{"io.modelcontextprotocol/tasks" => %{}}}}
iex> mcp = fn method, params, name -> Req.post!("http://127.0.0.1:4199/", json: %{jsonrpc: "2.0", id: 1, method: method, params: Map.put(params, "_meta", meta)}, headers: [{"accept", "application/json, text/event-stream"}, {"mcp-protocol-version", "2026-07-28"}, {"mcp-method", method}, {"mcp-name", name}]).body |> String.split("\n") |> Enum.find_value(fn "data: " <> json -> JSON.decode!(json); _ -> nil end) |> then(&(&1["result"] || &1["error"])) |> Map.delete("_meta") end
iex> %{"taskId" => id} = mcp.("tools/call", %{"name" => "confirm_delete", "arguments" => %{"filename" => "report.csv"}}, "confirm_delete")
%{
  "createdAt" => "2026-10-04T23:04:52.691146Z",
  "lastUpdatedAt" => "2026-10-04T23:04:52.691146Z",
  "pollIntervalMs" => 200,
  "resultType" => "task",
  "status" => "input_required",
  "taskId" => "p4l1VSc4Z8r8RFWTwLmcsJsISFyKGfXJOGopJPFOyBo",
  "ttlMs" => 300000
}
iex> mcp.("tasks/get", %{"taskId" => id}, id)
%{
  "createdAt" => "2026-10-04T23:04:52.691146Z",
  "inputRequests" => %{
    "confirm" => %{
      "method" => "elicitation/create",
      "params" => %{
        "message" => "Delete report.csv?",
        "requestedSchema" => %{
          "properties" => %{"confirm" => %{"type" => "boolean"}},
          "required" => ["confirm"],
          "type" => "object"
        }
      }
    }
  },
  "lastUpdatedAt" => "2026-10-04T23:04:52.691146Z",
  "pollIntervalMs" => 200,
  "resultType" => "complete",
  "status" => "input_required",
  "taskId" => "p4l1VSc4Z8r8RFWTwLmcsJsISFyKGfXJOGopJPFOyBo",
  "ttlMs" => 300000
}
iex> mcp.("tasks/update", %{"taskId" => id, "inputResponses" => %{"confirm" => %{"action" => "accept", "content" => %{"confirm" => true}}}}, id)
%{"resultType" => "complete"}
iex> mcp.("tasks/get", %{"taskId" => id}, id)
%{
  "createdAt" => "2026-10-04T23:04:52.691146Z",
  "lastUpdatedAt" => "2026-10-04T23:04:52.696437Z",
  "pollIntervalMs" => 200,
  "result" => %{
    "content" => [%{"text" => "Deleted report.csv", "type" => "text"}]
  },
  "resultType" => "complete",
  "status" => "completed",
  "taskId" => "p4l1VSc4Z8r8RFWTwLmcsJsISFyKGfXJOGopJPFOyBo",
  "ttlMs" => 300000
}
iex> %{"taskId" => slow} = mcp.("tools/call", %{"name" => "slow_compute", "arguments" => %{"seconds" => 60, "label" => "demo"}}, "slow_compute")
%{
  "createdAt" => "2026-10-04T23:04:52.697175Z",
  "lastUpdatedAt" => "2026-10-04T23:04:52.697175Z",
  "pollIntervalMs" => 200,
  "resultType" => "task",
  "status" => "working",
  "taskId" => "3uYS-CwMwXR54a31TV8Hw7GTYO2QqJIOM77G6vNXcRA",
  "ttlMs" => 300000
}
iex> mcp.("tasks/cancel", %{"taskId" => slow}, slow)
%{"resultType" => "complete"}
iex> mcp.("tasks/get", %{"taskId" => slow}, slow)
%{
  "createdAt" => "2026-10-04T23:04:52.697175Z",
  "lastUpdatedAt" => "2026-10-04T23:04:52.697534Z",
  "pollIntervalMs" => 200,
  "resultType" => "complete",
  "status" => "cancelled",
  "statusMessage" => "Cancelled by the client",
  "taskId" => "3uYS-CwMwXR54a31TV8Hw7GTYO2QqJIOM77G6vNXcRA",
  "ttlMs" => 300000
}
iex> mcp.("tasks/get", %{"taskId" => "nope"}, "nope")
%{"code" => -32602, "message" => "Failed to retrieve task: Task not found"}

Conformance: mix test test/conformance/conformance_test.exs --include conformance passes 30 of 30, which includes the 10 tasks scenarios on both topologies.

@dbernheisel
dbernheisel force-pushed the ext-tasks branch 2 times, most recently from 2cef5f6 to 1e4b64f Compare October 4, 2026 23:33
A tool answers tools/call with a task by returning
{:reply, %Phantom.Tasks{}, session}. Phantom does not store tasks: the
router implements get_task/2 (and optionally update_task/3,
cancel_task/2, authorize_task_subscriptions/2) for tasks/get,
tasks/update, and tasks/cancel under MCP 2026-07-28.

Phantom.Tracker.notify_task_updated/1 sends notifications/tasks to
subscriptions/listen streams, which fetch the task with get_task/2.
The conformance suite's tasks scenarios now run and must pass. A guide
covers storing and running tasks with Ecto, Oban, and Oban Pro.
@dbernheisel
dbernheisel marked this pull request as ready for review October 5, 2026 00:55
The formatter output differs between Elixir versions, so only the newest
version in the matrix checks it.
The suite's summary names only the scenario, which is not enough to
diagnose a failure that occurs only in CI.
A response to a server-initiated request, such as elicitation/create,
could reach a node whose Phantom.Tracker had not yet replicated the
waiting request. The lookup gave up after 2 seconds and dropped the
response, so the elicitation hung. The waiting session process now
subscribes to a topic for the request, and the response is broadcast
to it. The Tracker lookup remains for servers without PubSub.
The most specific matching media range decides, as RFC 9110 specifies,
so "*/*;q=0.5, text/event-stream;q=0" refuses the stream.
A client can send logging/setLevel or resources/subscribe before its GET
stream subscribes on another node. The single broadcast reached no
stream, so the call failed with "Connection closed" or "SSE stream not
open". The call now repeats with backoff until a stream answers or the
timeout passes.
@dbernheisel
dbernheisel merged commit 2a8d38f into main Oct 5, 2026
6 checks passed
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