0.7.3 (2026-10-18)
- Fixup canceling work due to inactivity
0.7.2 (2026-10-17)
- Tool and prompt handlers run with the dispatching process in
$callers, as in aTask. Mocking and stubbing libraries and the Ecto SQL sandbox, which find their owner through$callers, now reach handlers called withPhantom.Testwithout global or shared mode. Phantom.Plugno longer callsPhantom.Router.connect/2after it has already rejected a request, such as aPOSTwithout a JSON body or a CORS preflight. Aconnect/2that rejected the request too raisedPlug.Conn.AlreadySentError.
0.7.1 (2026-10-08)
- A GET SSE stream for a session that already has one replaces it instead of
being refused with
409 Conflict. A client that drops its stream is not noticed until the next write, so its reconnect was refused, failing clients such as Cursor. - A tool error reply (
isError: true) from a tool with anoutput_schemais no longer checked against the schema. The client gets the tool's error text instead ofInvalid tool output: $: expected object.
0.7.0 (2026-10-04)
- New: the Tasks extension (
io.modelcontextprotocol/tasks) under MCP 2026-07-28. A tool returns{:reply, %Phantom.Tasks{}, session}to answer with a task, and the router implementsPhantom.Router.get_task/2, and optionallyupdate_task/3,cancel_task/2, andauthorize_task_subscriptions/2, fortasks/get,tasks/update, andtasks/cancel. Phantom does not store tasks. See the Tasks guide for Ecto and Oban examples. - New:
Phantom.Session.tasks_supported?/1, andPhantom.Tracker.notify_task_updated/1. Eachsubscriptions/listenstream that follows the task fetches it withget_task/2and sendsnotifications/tasks. - New:
Phantom.Test.get_task/3,update_task/4,cancel_task/3,assert_task/2, and a:tasksoption forcall_tool/4. - New:
Phantom.Session.progress_ref/1returns a JSON-safe reference to the current request.Phantom.Session.notify_progress/4accepts it, so a job on any node can report progress to a waitingtools/callorprompts/getthrough PubSub. - New:
Phantom.Elicit.to_input_request/1renders one embeddedelicitation/createrequest to place under your owninputRequestskey, such as a task's input requests. - New:
Phantom.Request.missing_task_capability/0, the error for a tool that only runs as a task when the client did not declare the extension. Phantom.Request.missing_capability/1accepts aClientCapabilitiesmap, for capabilities whose names contain dots.- New: Skills, with the MCP Skills extension (
io.modelcontextprotocol/skills).Phantom.Router.skill/3routes askill://path to an action that returns aPhantom.Skill, andPhantom.Skill.embed_skills/1compiles skill directories, rendering.eexfiles with assigns. Phantom servesskills/list,skills/get,resources/read, andresources/directory/read. Files render lazily, and caching is opt-in withPhantom.Skill.with_cache/2.Phantom.Router.list_skills/2decides which skillsskills/listreturns.Phantom.Cache.add_skill/2adds a skill at runtime. Theskill://scheme is reserved for skills. ParsingSKILL.mdfrontmatter needs the optional:yamerldependency. See the Skills guide.
Streamable HTTP transport fixes for the protocol versions before 2026-07-28:
- New:
Phantom.Plugoptionvalidate_session: trueanswers404 Not Foundto a request whosemcp-session-idthe server did not issue, or that was terminated withDELETE, so the client initializes again. It is off by default because Phantom only keeps sessions in memory; apps that persist sessions keep answering{:not_found, message}fromconnect/2. - Reject requests with an unsupported
MCP-Protocol-Versionheader with400 Bad Request. Requests without the header are still accepted. - Respond
200 OKinstead of202 Acceptedwhen opening a GET SSE stream. - Respond
406 Not Acceptableto GET requests whoseAcceptheader excludestext/event-stream. A missingAccept,text/*, or*/*is allowed. - Accept a client's JSON-RPC error response (for example, to
elicitation/create) with202 Accepted. The waitingSession.elicit/3returns:error; it used to wait until it timed out. Over stdio too. - Respond
400 Bad Requestto an invalid JSON-RPC response instead of202 Accepted. - Allow
DELETEin CORSaccess-control-allow-methods. - Stop using JSON-RPC ids as SSE event ids, since SSE event ids must be unique within a session.
0.6.0 (2026-10-04)
- New:
Phantom.Testfor unit-testing MCP routers without an HTTP transport.build_session(router, protocol_version: "2026-07-28")tests the stateless protocol;expect_elicit/1answers itsinput_requiredresults. - New:
Phantom.Router.authorize_resource_subscriptions/2authorizes resource subscriptions (resources/subscribeandsubscriptions/listen) and is re-checked before each update notification. Resources are allowed by default. - New:
Phantom.Tracker.notify_resources_updated/1notifies a batch of updated resources with one authorization call per subscribed session. - New:
Phantom.Prompt.input_required/1. Prompt handlers can return aninput_requiredresult under MCP 2026-07-28. - Session messages reach the session's stream on any node immediately.
logging/setLevel,resources/subscribe,resources/unsubscribe, and client logs used to fail or be lost when they reached a node beforePhantom.Trackerhad replicated the session from another node; they now go through a per-session PubSub topic in that case. logging/setLevelreplies once the level is applied, and no longer writes an empty{}event to the session stream.notifications/cancelledreaches the request it names: a session's in-flighttools/callorprompts/get, or a statelesssubscriptions/listenstream sent by the same principal (session.assigns[:request_state_principal]). It used to be cast to the cancellation's own request process and crashed it.- An HTTP
DELETEcloses the session's open streams on every node; it previously only untracked streams on the node that received it. - The
initializeresponse stream closes after the response, as the spec recommends. Server-initiated messages (notifications, logs, resource updates) go to the session's GET stream; clients that never open one no longer receive them on theinitializestream. A client's GET stream no longer gets 409 because theinitializestream held the session. - New:
Phantom.Plug:hostsoption rejects requests whoseHostheader is not allowed (403), protecting servers bound to localhost from DNS rebinding. Defaults to:all. - Origin validation accepts requests without an
Originheader. Only browsers send one, so validation previously rejected every non-browser client unless it was turned off. - Map-based
input_schemaandoutput_schemareach clients as given, so$schema,$defs,$ref,allOf,if/then/else,additionalProperties, and other JSON Schema keywords are kept. They previously raised at compile time or were dropped. Session.elicit/3re-entry passes the accepted content to the resumed handler under every protocol version. Before 2026-07-28 it passed the whole client response, so resume clauses never matched and the handler asked the user again indefinitely.- Elicitation enums keep their
default, and titled multi-select enums useitems.anyOfas the spec requires (wasitems.oneOf, which clients rejected). New:
connect/2may return{:not_found | 404, message}to answer with HTTP 404. Record terminated sessions interminate/1and reject them this way, as the MCP spec requires 404 for a terminated session ID.- Resource URIs keep their host.
resource "https://example.com/x/:id", ...now advertiseshttps://example.com/x/{id}andresource_uri/3buildshttps://example.com/x/1(both previously dropped the host), and only URIs with that host match it (previously any host did). Host-only URIs such asmyapp://settingscan now be defined, and reading an unknown one returns "Resource not found" instead of crashing. - MCP 2026-07-28 fixes found by the conformance suite:
- -32021 errors name
requiredCapabilitiesas a ClientCapabilities object and are sent with HTTP status 400. - A
MCP-Protocol-Versionheader that disagrees with_metais a -32020 header mismatch, checked before the -32022 unsupported version. - Whitespace around
Mcp-*header values is ignored, and a value is only decoded as Base64 when it has both the=?base64?prefix and?=suffix.
- -32021 errors name
- Initial support for MCP 2026-07-28 stateless core.
Phantom.Session.elicit/3uses true stateless re-entry: the handler returns{:noreply, Session.elicit(session, elicit, state: %{...})}, Phantom authenticates and encrypts that state intorequestState, and any node can re-invoke the handler withsession.statepopulated. Inlineawait: true, andSession.elicit/3from a process the handler started, work under both protocols: under stateless core the process waits on its node while the client answers aninput_requiredresult, and the follow-up call resumes it.Phantom.Tool.input_required/2is the lower-level builder for constructing aninput_requiredresult map directly (skipping Task suspension).- Existing legacy code that called
Session.elicit/3without opts continues to work unchanged — the default under legacy is still inline blocking.
- Tools/call now always dispatches in a spawned Task. Tool crashes are
isolated to the Task (the HTTP/session process keeps serving) and
surface via the
[:phantom, :dispatch, :exception]telemetry event. - New:
Phantom.Session.respond_error/3finalizes a pending request with a JSON-RPC error from an async task. - New:
Phantom.Routeraccepts:secret_key_baseand:request_state_saltfor the encryptedrequestStatecodec. Both required when supporting MCP2026-07-28. - New:
Phantom.RequestState(Plug.Crypto-backed encode/decode of the continuation blob) andPhantom.Session.stateless?/1predicate. - New:
Phantom.Request.with_cache/2annotates any result withttlMs/cacheScope. - W3C trace context (
traceparent/tracestate/baggage) from_metais automatically surfaced on the[:phantom, :dispatch]telemetry span undermetadata.trace_context. Wire your tracer to that event (see "Distributed tracing" in the README). - MCP
2026-07-28adds three optional headers —mcp-protocol-version,mcp-method,mcp-name— that upstream infrastructure (load balancers, WAFs, gateways) can route on without inspecting the JSON-RPC body. Phantom passes them through; no server-side configuration needed.
For existing users
If you only target legacy MCP clients (≤ 2025-11-25): your existing
code works unchanged. No changes required. The protocol-aware default for
Session.elicit/3 preserves the historical inline-blocking behavior on
legacy.
# Existing handler — unchanged, still works.
def my_tool(params, session) do
case Session.elicit(session, @elicit_name) do
{:ok, %{"action" => "accept", "content" => content}} ->
{:reply, Tool.text("Hello \#{content["name"]}"), session}
{:ok, _rejected} ->
{:reply, Tool.error("Rejected"), session}
:not_supported ->
{:reply, Tool.text("Hello stranger"), session}
end
endIf you want to also support modern MCP 2026-07-28 clients:
Step 1. Add :secret_key_base and :request_state_salt to your router.
Phantom encrypts the multi-round-trip requestState blob with Plug.Crypto;
nodes serving the same router must share both values.
use Phantom.Router,
name: "MyApp",
vsn: "1.0",
secret_key_base: Application.compile_env(:my_app, :secret_key_base),
request_state_salt: "myapp request_state v1":secret_key_baseis a high-entropy binary ≥ 64 bytes. Generate one with:crypto.strong_rand_bytes(64) |> Base.encode64().:request_state_saltis a stable string of your choosing — it's the HKDF salt used to derive a key specifically for requestState blobs. Doesn't need to be secret, but rotating it invalidates all in-flight blobs.
The router raises at compile time if the key is too short, if one is set without the other, and warns if both are missing while tools or prompts are defined.
Step 2. Pick a migration shape for your Session.elicit/3 calls.
Existing calls without :await work under legacy because legacy defaults
to inline blocking, but the same call under 2026-07-28 would return the
re-entry tagged tuple instead — which your existing case {:ok, _}
clauses don't match.
The smallest change is to add await: true everywhere you currently call
Session.elicit/3 for blocking behavior:
# Before: implicit inline blocking, legacy-only
{:ok, response} = Session.elicit(session, elicit)
# After: explicit inline blocking, under either protocol
{:ok, response} = Session.elicit(session, elicit, await: true)Under 2026-07-28, await: true keeps the process waiting on its node
until the client's follow-up call; re-entry (below) keeps nothing in memory.
Recommendation for new PhantomMCP users targeting modern MCP clients
For greenfield code, use the re-entry pattern rather than await: true.
Re-entry is the natural shape for stateless: the handler is invoked again
with session.state populated, no suspended Task, no Phantom.Tracker
required for cross-node routing.
use Phantom.Router,
name: "MyApp",
vsn: "1.0",
secret_key_base: Application.compile_env(:my_app, :secret_key_base)
tool :delete_file do
field :path, :string, required: true
end
# Resume clause — runs on the second invocation.
def delete_file(
%{"confirm" => "yes"},
%Phantom.Session{state: %{step: :confirming, path: path}} = session
) do
File.rm!(path)
{:reply, Tool.text("Deleted \#{path}"), session}
end
def delete_file(%{"confirm" => _}, session),
do: {:reply, Tool.text("Cancelled"), session}
# First-call clause — ask the client.
def delete_file(%{"path" => path}, session) do
{:noreply,
Phantom.Session.elicit(
session,
Phantom.Elicit.form(%{
message: "Really delete \#{path}?",
requested_schema: [
%{name: "confirm", type: :enum, enum: ["yes", "no"], required: true}
]
}),
state: %{step: :confirming, path: path}
)}
endWhy re-entry over inline await: true:
- Truly stateless on the wire —
stateis encrypted intorequestStateand travels with the client. Any node can serve any follow-up call, and a node restart loses nothing; an inlineawait: truewaits in a process that a restart or:timeoutends. - No resource pinning — re-entry has no in-memory state between requests.
- Pattern-match clarity — the resume clause is a function head, not a
caseblock buried in the middle of a function. - Multi-step state machines read naturally — each step is its own
re-entry clause matching on a different
stepatom.
Reserve await: true for cases where the inline ergonomics are
genuinely simpler (short interactions, no multi-step flow, no need for
distribution beyond one node).
0.5.3 (2026-09-04)
- Switch UUID dependency from
uuidv7touuid_v7to avoid module name collisions with other Hex packages that also defineUUIDv7(e.g. when used alongsideposthog). Call sites are unchanged (UUIDv7.generate/0).
0.5.2 (2026-08-20)
- Remove
Connection: keep-alivein response headers in SSE stream. This is handled in other infrastructure. (thanks @merhard) - Omit
nextCursorin pagination responses if there are no more. Simply the presence, even if null, can trip up clients. (thanks @merhard) - Allow standalone
Phantom.Tool.JSONSchemamodules. (thanks @merhard)
0.5.1 (2026-08-11)
- Fix Resource subscriptions' responses when they have empty responses. (thanks @merhard)
0.5.0 (2026-07-27)
- Add
Phantom.Appwhich represents a Plug for mounting MCP Apps, which are embedded and self-contained resources in desktop clients. - Fix 202 response for SSE connections for notifications (thanks @anagrius)
0.4.5 (2026-04-29)
- Fix
Plug.Conn.AlreadySentErrorwhen a second SSE GET arrives for an existing session. The conflict response (409 -32000) is now returned cleanly without attempting to write streaming headers on the sent conn.
0.4.4 (2026-04-13)
- Defend from potential elicitation replication lag
- Track Elicitations to ensure duplicate requests are not sent
0.4.3 (2026-04-03)
- Fix invalid response when client request an invalid resource_uri
0.4.2 (2026-04-03)
- Elicitation requests can use the POSTs connection. This should fix hung-up elicitations.
- Phoenix.Tracker can take some time to replicate, so add retries when session metadata is not available
- Improve cross-nodes tests
0.4.1 (2026-04-02)
- Add additional Logging when dispatching requests to unalive PIDs
- Catch exits due to calling unalive PIDs (thanks @davydog187)
- Fix Phantom.Tracker
- Fixup dialyzer specs
- Providing nil to binary response content (eg, image, audio) will now raise instead of encoding
<<>>.
0.4.0 (2026-03-27)
Add
Phantom.Stdioadapter for local-only clients (e.g. Claude Desktop). Add{Phantom.Stdio, router: MyApp.MCP.Router}to your supervision tree. SeePhantom.Stdiofor more details.Add
Phantom.Iconsupport for server info, tools, and prompts per MCP 2025-11-25 specification. Icons can be set at the router level withuse Phantom.Router, icons: [...]or per-tool/prompt.Server now declares support for MCP spec
2025-11-25.Elicitation support is fully implemented.
Phantom.Session.elicit/3now blocks until the client responds (with configurable timeout) and works across both HTTP and stdio transports. SeePhantom.Elicit.Phantom.Trackernow works withoutphoenix_pubsubfor stdio transport, falling back to process dictionary for session metadata.Fixed bugs with rendering embedded_resources
New tool DSL with
doblock to provide input schemas using an Ecto.Schema-like syntax. For example, before you had to manually write the JSONSchema input schema:tool :validated_echo_tool, description: "Echo with validation", input_schema: %{ required: ~w[message], properties: %{ message: %{type: "string", description: "Foo bar"}, count: %{type: "integer", description: "Foo bar"}, tags: %{type: "array", items: %{type: :string}, description: "Foo bar"} } }But now you can declare it with a
doblock:tool :validated_echo_tool, description: "Echo with validation" do field :message, :string, required: true, description: "Foo bar" field :count, :integer, default: 1, description: "Foo bar" field :tags, {:array, :string}, description: "Foo bar" endThe
doblock also supports nested maps, custom validators, and all JSON Schema types. The old map-basedinput_schemasyntax continues to work. SeePhantom.Tool.JSONSchemafor more info.
0.3.4 (2026-02-24)
Breaking When using
Phantom.Plug, pass theconnto the router connect callback instead of a map with params and headers keys. Upgrade and make this adjustment in your connect callback:# Before def connect(session, context) do %{params: params, headers: headers} = context # ... end # After def connect(session, conn) do %{query_params: params, req_headers: headers} = conn # ... end
0.3.3 (2026-02-22)
- Fixup Cache key mismatch
- Fixup updating state in async returns
- Fixup running without
phoenix_pubsub
0.3.2 (2025-07-04)
- Fix error message referring to wrong arity.
- Allow nil origin when Plug options is set to
origins: :all. - Better error handling when
Phantom.Trackeris not in the supervision tree. Phantom.MCP will now emit a Logger warning when Phantom.Tracker can be used, but is not in the supervision tree. - Fix terminate bug introduced in 0.3.1
0.3.1 (2025-07-03)
- Add
[:phantom, :plug, :request, :terminate]telemetry event. - Improve docs
0.3.0 (2025-06-29)
- Move logging functions from
Phantom.SessionintoPhantom.ClientLogger. - Rename
Phantom.Trackerfunctions to be clearer and more straightforward. - Consolidate distributed logic into
Phantom.Trackersuch as PubSub topics. - Add ability to add tools, prompts, resources in runtime easily. You can call
Phantom.Cache.add_tool(router_module, tool_spec). The spec can be built withPhantom.Tool.build/1, the function takes a very similar shape to the corresponding macro fromPhantom.MCP.Router. This will also trigger notifications to clients of tool or prompt list updates. - Handle paginatin for 100+ tools and prompts.
- Change
connect/2callback to receive request headers and query params from the Plug adapter. The signature is now%{headers: list({header, value}), params: map()}where before it was justlist({header, value}). Phantom.Tool.build,Phantom.Prompt.buildandPhantom.ResourceTemplate.buildnow do more and thePhantom.Routermacros do less. This is so runtime can have a consistent experience with compiled declarations. For example, you mayPhantom.ResourceTemplate.build(...)with the same arguments as you would with the router macros, and then callPhantom.Cache.add_resource_template(...)and have the same affect as using theresource ...macro in aPhantom.Routerrouter.- Fixed building tool annontations.
- Fixed resource subscription response and implemented unsubscribe method.
- Improve documentation
0.2.3 (2025-06-24)
- Fix the
initializerequest status code and headers. In 0.2.2 it worked with mcp-inspector but not with Claude Desktop or Zed. Now it works with all.
0.2.2 (2025-06-22)
- Fix the
initializerequest. It should have kept the SSE stream open. - Fix bugs
0.2.1 (2025-06-21)
- Fix default
list_resources/2callback and default implementation.
0.2.0 (2025-06-17)
Phantom MCP released!