Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ locals_without_parens = [
resource: 4,
skill: 2,
skill: 3,
agent: 2,
agent: 3,
embed_skills: 1,
input_schema: 1,
field: 2,
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,8 @@ jobs:

- name: Run MCP conformance suite
run: mix test --only conformance

- uses: astral-sh/setup-uv@v6

- name: Run A2A conformance suite (ACTS)
run: mix test --only acts
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,38 @@
## Unreleased

- New: serve [A2A](https://a2a-protocol.org) v1.0 agents. Declare them in a
router with `agent "research", MyApp.Agents.Research`, implement them with
`Phantom.A2A.Agent`, and serve them with `Phantom.A2A.Plug` over the
JSON-RPC and HTTP+JSON bindings, streaming over SSE with a pubsub. Agents store their own tasks and report
progress with `Phantom.A2A.publish/2`, numbering events with
`Phantom.A2A.Events.apply/2` so a client that subscribes mid-task gets no
duplicates. `Phantom.Session.allow_agents/2`
limits agents per session, and `Phantom.A2A.Test` drives agents in tests.
Routes with path params, such as `agent ":org/:agent_key", Mod, :resolve`,
serve agents found at runtime. Agents with `push_notifications: true`
push each event to clients' webhooks, which Phantom checks against private
networks. Agents can offer an extended card (`extended_card: true`) and
declare protocol extensions, and requests and push deliveries emit
`[:phantom, :a2a, :dispatch]` and `[:phantom, :a2a, :push]` telemetry spans.
Phantom passes the A2A conformance suite (ACTS) on both bindings, apart from
its client tests.
- New: the [A2A guide](guides/a2a.md), with storing tasks in Ecto, running
them with Oban, and retrying push deliveries.
- New: `Phantom.A2A.Client` calls A2A agents over either binding, with the
optional `{:req, "~> 0.5"}` dependency. With it, Phantom passes every ACTS
test on both bindings.
- New: `c:Phantom.Router.list_agents/2` lists the agents a session may use.
- New: `Phantom.A2A.Bridge` serves a router's MCP tools as an A2A agent.
Mount it with `agent "tools", Phantom.A2A.Bridge`, and clients call a tool
with a data part naming it. Tools that answer with a `Phantom.Tasks` become
A2A tasks backed by the router's task callbacks, and A2A clients waiting on
them get each `Phantom.Tracker.notify_task_updated/1`.
- New: `Phantom.Router.call_tool/4` takes `tasks: true` to let a tool answer
with a `Phantom.Tasks`.
- New: `c:Phantom.A2A.Agent.card/2` adjusts an agent's card for its route.
- New: `Phantom.Router.call_tool/4` calls a router's tool from your own code,
such as an A2A agent answering with the tools the router already has.

## 0.7.3 (2026-10-18)

- Fixup canceling work due to inactivity
Expand Down
148 changes: 148 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,154 @@ end
See `Phantom.Elicit` for what each protocol version sends, and for waiting
inline with `Phantom.Session.elicit/3`.

## Serving A2A agents

Phantom also serves [A2A](https://a2a-protocol.org) agents, for clients that
delegate a whole job to an agent rather than call its tools. Declare agents in
the same router, so they share `connect/2`:

```elixir
defmodule MyApp.MCP.Router do
use Phantom.Router,
name: "MyApp",
vsn: "1.0",
a2a: [well_known: "research"]

agent "research", MyApp.Agents.Research
agent "billing", MyApp.Agents.Billing
end
```

Each agent is a module that answers messages with a message, or with a task
it stores and runs:

```elixir
defmodule MyApp.Agents.Research do
use Phantom.A2A.Agent,
name: "MyApp Research",
description: "Designs and reviews research studies"

alias Phantom.A2A.{Message, Task}

@description "Review a study and suggest missing questions"
skill :study_review, tags: ["research"]

def handle_message(message, ctx) do
{:ok, _job} = MyApp.Reviews.start(ctx.task_ref, message, ctx.assigns.user)
{:reply, Task.submitted(ctx), ctx}
end

def get_task(task_id, ctx), do: MyApp.Reviews.get_a2a_task(task_id, ctx.assigns.user)
end
```

Agents found at runtime, such as ones each organization configures, take a
route with path params and a function that resolves them. It runs after
`connect/2`, so it can scope agents to the session, and returns the agent's
card and any state its handler needs:

```elixir
# In the router: /a2a/acme/triage, /a2a/acme/benchmarks, ...
agent ":org/:agent_key", MyApp.Agents.Hosted, :resolve

defmodule MyApp.Agents.Hosted do
use Phantom.A2A.Agent, name: "Hosted", description: "Agents configured per organization"

def resolve(%{"org" => org, "agent_key" => key}, session) do
case MyApp.Agents.get(session.assigns.user, org, key) do
nil ->
{:error, :not_found}

agent ->
{:ok,
%Phantom.A2A.Card{
name: agent.name,
description: agent.description,
default_input_modes: ["text/plain"],
default_output_modes: ["text/plain"]
}, agent}
end
end

# ctx.agent.params and ctx.agent.state hold the params and the resolved agent.
def handle_message(message, ctx) do
{:ok, run} = MyApp.Agents.start_run(ctx.agent.state, ctx.task_ref, message)
{:reply, Phantom.A2A.Task.submitted(ctx), ctx}
end
end
```

Wherever the work runs, save each change with `Phantom.A2A.Events.apply/2`,
then report it with `Phantom.A2A.publish/2`:

```elixir
event = Phantom.A2A.Task.status(:working, "Reading study…")
{:ok, task, event} = Phantom.A2A.Events.apply(stored_task, event)
MyApp.Reviews.save!(task)
Phantom.A2A.publish(task_ref, event)
```

Serve the agents with `Phantom.A2A.Plug`, without `Plug.Parsers`, so invalid
JSON gets the JSON-RPC error A2A requires. With a `pubsub`, agents stream
over SSE:

```elixir
get "/.well-known/agent-card.json", Phantom.A2A.Plug,
router: MyApp.MCP.Router,
mount: "/a2a"

scope "/a2a" do
forward "/", Phantom.A2A.Plug, router: MyApp.MCP.Router, pubsub: MyApp.PubSub
end
```

Requests go through `connect/2` as MCP requests do, with the JSON-RPC request
in `conn.body_params`. A client may name a `tenant`, such as an organization,
in each request; it is only the client's claim, so check it there and assign
what the session may use:

```elixir
def connect(session, conn) do
tenant = get_in(conn.body_params, ["params", "tenant"])

with {:ok, user} <- MyApp.Auth.verify(conn),
{:ok, org} <- MyApp.Orgs.for_user(user, tenant) do
{:ok, Phantom.Session.assign(session, user: user, org: org)}
else
_ -> {:forbidden, "Not allowed"}
end
end
```

With `push_notifications: true` on an agent, clients can register webhooks
for a task, and Phantom posts each event you publish to them. The agent stores
the configs; Phantom checks each webhook URL so it can't reach your private
network. See `Phantom.A2A.Push`.

To serve your MCP tools as an A2A agent without writing one, mount
`Phantom.A2A.Bridge`. A client calls a tool with a data part naming it, and
the card lists each tool with its input schema:

```elixir
agent "tools", Phantom.A2A.Bridge, tools: ~w[search_studies create_question]
```

To call other agents, use `Phantom.A2A.Client`, which needs the optional
`{:req, "~> 0.5"}` dependency:

```elixir
{:ok, client} = Phantom.A2A.Client.new("https://agents.example")
{:ok, reply} = Phantom.A2A.Client.send_message(client, "Hello")
```

Phantom serves the JSON-RPC and HTTP+JSON bindings: `SendMessage`,
`SendStreamingMessage`, `GetTask`, `ListTasks`, `CancelTask`,
`SubscribeToTask`, `GetExtendedAgentCard`, and the push notification config
methods. Agents can
declare protocol extensions, and each request is a
`[:phantom, :a2a, :dispatch]` telemetry span carrying the client's W3C trace
context. See the [A2A guide](guides/a2a.md).

## What PhantomMCP supports

Phantom will implement these MCP requests on your behalf:
Expand Down
Loading
Loading