Skip to content

Toolnexus.A2a.agent_tools

Elixir · package toolnexus · SPEC §7A · elixir/lib/toolnexus/a2a.ex

@spec agent_tools(map()) :: [Toolnexus.Tool.t()]
def agent_tools(ag)
# ag = an Agent descriptor from Toolnexus.A2a.agent/1
# Tool.name = sanitize(card.name) <> "_" <> sanitize(skill.id || skill.name)
# Tool.source = "a2a"
# Tool.input_schema = %{"type"=>"object", "properties"=>%{"task"=>%{"type"=>"string", ...}},
# "required"=>["task"], "additionalProperties"=>false}

Fetches the descriptor’s Agent Card (GET <card>) and turns every entry in skills[] into one uniform Toolnexus.Tool. Each tool’s execute performs the whole §7A wire dance — one SendMessage then polling GetTask until a terminal state — and maps the finished Task to a ToolResult. A card fetch or parse failure raises; per-peer isolation is the toolkit’s job (the toolkit catches it and logs, contributing zero tools for that peer, never failing the whole build).

  • You have an Agent descriptor and want callable tools — this is the only function that actually performs I/O to resolve one; agent/1 itself never touches the network.
  • Wiring it into a toolkit by hand, without mcp_config’s agents block — call agent_tools/1 and pass the result to Toolnexus.Toolkit.register/2 or extra_tools:.
  • Inspecting what a peer advertises before deciding whether to add it — the returned tools’ name/description come straight from the card’s skills[].

1. The smallest useful call — resolve a card, no execution yet

Section titled “1. The smallest useful call — resolve a card, no execution yet”
alias Toolnexus.A2a
Application.ensure_all_started(:req)
defmodule DocsCardOnlyStub do
@behaviour Plug
import Plug.Conn
def call(conn, _opts) do
card = %{
"name" => "Weather Agent",
"skills" => [
%{"id" => "forecast", "description" => "Gives a forecast"},
%{"name" => "alerts!"}
]
}
conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(card))
end
def init(opts), do: opts
end
{:ok, sock} = :gen_tcp.listen(0, [])
{:ok, port} = :inet.port(sock)
:gen_tcp.close(sock)
url = "http://127.0.0.1:#{port}"
{:ok, _pid} = Bandit.start_link(plug: DocsCardOnlyStub, scheme: :http, ip: {127, 0, 0, 1}, port: port)
tools = A2a.agent_tools(A2a.agent(card: url <> "/.well-known/agent-card.json"))
true = Enum.map(tools, & &1.name) == ["Weather_Agent_forecast", "Weather_Agent_alerts_"]
[forecast, alerts] = tools
true = forecast.source == "a2a"
true = forecast.description == "Gives a forecast"
# a skill with no id/description falls back to its name
true = alerts.description == "alerts!"
IO.puts("ok: #{Enum.join(Enum.map(tools, & &1.name), ", ")}")

2. The full round trip — execute a resolved tool over real loopback HTTP

Section titled “2. The full round trip — execute a resolved tool over real loopback HTTP”
alias Toolnexus.{A2a, Context}
Application.ensure_all_started(:req)
defmodule DocsCalcAgentStub do
@behaviour Plug
import Plug.Conn
def init(agent), do: agent
def call(conn, agent) do
{:ok, body, conn} = read_body(conn)
st = Agent.get(agent, & &1)
case {conn.method, conn.request_path} do
{"GET", "/.well-known/agent-card.json"} ->
card = %{
"name" => "Calc Agent",
"url" => st.url,
"skills" => [%{"id" => "add", "description" => "Adds two numbers"}]
}
conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(card))
{"POST", _} ->
rpc = Jason.decode!(body)
result =
case rpc["method"] do
"SendMessage" ->
%{"id" => "t1", "status" => %{"state" => "submitted"}}
"GetTask" ->
%{
"id" => "t1",
"status" => %{"state" => "completed"},
"artifacts" => [%{"parts" => [%{"kind" => "text", "text" => "5"}]}]
}
end
payload = %{"jsonrpc" => "2.0", "id" => rpc["id"], "result" => result}
conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(payload))
end
end
end
{:ok, sock} = :gen_tcp.listen(0, [])
{:ok, port} = :inet.port(sock)
:gen_tcp.close(sock)
url = "http://127.0.0.1:#{port}"
{:ok, agent_state} = Agent.start_link(fn -> %{url: url <> "/"} end)
{:ok, _pid} = Bandit.start_link(plug: {DocsCalcAgentStub, agent_state}, scheme: :http, ip: {127, 0, 0, 1}, port: port)
[add] = A2a.agent_tools(A2a.agent(card: url <> "/.well-known/agent-card.json", poll_every: 20))
result = add.execute.(%{"task" => "add 2 and 3"}, %Context{})
false = result.is_error
true = result.output == "5"
true = result.metadata.agent == "Calc Agent"
true = result.metadata.task_id == "t1"
true = result.metadata.state == "completed"
IO.puts("ok: #{add.name} -> #{result.output}")

3. The full surface — a failing peer raises; the toolkit isolates it

Section titled “3. The full surface — a failing peer raises; the toolkit isolates it”
alias Toolnexus.{A2a, Toolkit}
Application.ensure_all_started(:req)
defmodule DocsDeadCardStub do
@behaviour Plug
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts), do: send_resp(conn, 500, "card lookup failed")
end
{:ok, sock} = :gen_tcp.listen(0, [])
{:ok, port} = :inet.port(sock)
:gen_tcp.close(sock)
url = "http://127.0.0.1:#{port}"
{:ok, _pid} = Bandit.start_link(plug: DocsDeadCardStub, scheme: :http, ip: {127, 0, 0, 1}, port: port)
card_url = url <> "/.well-known/agent-card.json"
# Calling agent_tools/1 directly raises — no isolation at this layer.
raised? =
try do
A2a.agent_tools(A2a.agent(card: card_url))
false
rescue
RuntimeError -> true
end
true = raised?
# create_toolkit!/1 (agents:) DOES isolate it — the peer contributes zero tools.
tk = Toolnexus.create_toolkit!(agents: [A2a.agent(card: card_url)], builtins: false)
true = Toolkit.tools(tk) == []
IO.puts("ok: agent_tools/1 raises directly, the toolkit isolates it")
Field Type What it is
name String.t() sanitize(card.name) <> "_" <> sanitize(skill.id || skill.name).
description String.t() The skill’s description, falling back to its name/id.
input_schema map() Always the fixed {task: string} shape (§7A).
source "a2a"
execute (args, ctx) -> ToolResult.t() SendMessage → poll GetTask → map the terminal Task.