Skip to content

A2A.ParseAgentsConfig

C# · package Toolnexus · SPEC §7A · A2A.cs

public static List<Agent> ParseAgentsConfig(object? block)

Parses an agents config block — a map of id → agent config, mirroring the mcpServers shape (§2) — into a list of Agent descriptors, skipping disabled entries. The config key is only an identifier; a tool’s name prefix always comes from the fetched card’s own name, never from the config key.

You’re loading a shared config file (or a parsed Dictionary<string, object?>) that declares remote peers the way it declares MCP servers, and want the same enabled/disabled precedence and shape validation McpSource.ParseConfig gives you for mcpServers. Toolkit.CreateAsync already calls this for you when a top-level agents key is present on McpConfig — reach for it directly when you want the parsed list before deciding what to do with it.

1. The smallest useful call — one entry, defaults applied

Section titled “1. The smallest useful call — one entry, defaults applied”
using Toolnexus;
var block = new Dictionary<string, object?>
{
["oracle"] = new Dictionary<string, object?> { ["card"] = "http://localhost:9/.well-known/agent-card.json" },
};
var agents = A2A.ParseAgentsConfig(block);
if (agents.Count != 1) throw new Exception($"expected 1 agent, got {agents.Count}");
if (agents[0].Card != "http://localhost:9/.well-known/agent-card.json") throw new Exception(agents[0].Card);
// Timeout/PollEvery unset here — the caller (or AgentTools) applies its own defaults.
if (agents[0].Timeout != null) throw new Exception("expected no explicit timeout");
Console.WriteLine($"ok: {agents[0].Card}");

2. The realistic case — disabled entries skipped, key is not the prefix

Section titled “2. The realistic case — disabled entries skipped, key is not the prefix”
using Toolnexus;
var block = new Dictionary<string, object?>
{
// The config key "billing_desk" is just an identifier — it never becomes the tool prefix.
["billing_desk"] = new Dictionary<string, object?>
{
["card"] = "http://localhost:9/.well-known/agent-card.json",
["headers"] = new Dictionary<string, object?> { ["Authorization"] = "Bearer ${DESK_TOKEN}" },
["timeout"] = 15_000,
["pollEvery"] = 250,
},
// MCP's isEnabled precedence: disabled:true wins over enabled.
["retired"] = new Dictionary<string, object?>
{
["card"] = "http://localhost:9/retired-card.json",
["disabled"] = true,
},
// enabled:false also skips.
["paused"] = new Dictionary<string, object?>
{
["card"] = "http://localhost:9/paused-card.json",
["enabled"] = false,
},
// No "card" ⇒ not a valid agent entry, silently skipped.
["malformed"] = new Dictionary<string, object?> { ["timeout"] = 1000 },
};
var agents = A2A.ParseAgentsConfig(block);
if (agents.Count != 1) throw new Exception($"expected 1 agent, got {agents.Count}");
var a = agents[0];
if (a.Timeout != 15_000 || a.PollEvery != 250) throw new Exception($"{a.Timeout},{a.PollEvery}");
// Header VALUES are preserved raw here — ${ENV} expansion happens later, at call time.
if (a.Headers?["Authorization"] != "Bearer ${DESK_TOKEN}") throw new Exception(a.Headers?["Authorization"]);
Console.WriteLine($"ok: {agents.Count} enabled agent, headers unexpanded until call time");

3. Full surface — feeding the parsed list into a live Toolkit

Section titled “3. Full surface — feeding the parsed list into a live Toolkit”
using Toolnexus;
using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """
{"choices":[{"message":{"content":"reported"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}
"""));
await using var peer = await Toolkit.CreateAsync(new Toolkit.Options
{
Builtins = false,
Skills = new List<SkillSource.SkillDef> { new("status", "reports status", "Report status.") },
});
var peerClient = LlmClient.Create(new LlmClient.Options
{
BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k",
});
var handle = await peer.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions
{
Client = peerClient,
A2A = new A2AConfig { Name = "monitor" },
});
// A whole McpConfig-shaped document — mcpServers + a sibling top-level "agents" block, exactly
// how Toolkit.CreateAsync reads it (McpSource.ParseConfig handles "mcpServers"; A2A.ParseAgentsConfig
// handles "agents").
var config = new Dictionary<string, object?>
{
["mcpServers"] = new Dictionary<string, object?>(),
["agents"] = new Dictionary<string, object?>
{
["monitor"] = new Dictionary<string, object?> { ["card"] = handle.Url + "/.well-known/agent-card.json" },
},
};
var parsed = A2A.ParseAgentsConfig(((IDictionary<string, object?>)config)["agents"]);
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, Agents = parsed });
var tool = tk.Get("monitor_status") ?? throw new Exception("expected monitor_status to be registered");
var result = await tool.ExecuteAsync(new Dictionary<string, object?> { ["task"] = "how's it going?" });
if (result.IsError || result.Output != "reported") throw new Exception(result.Output);
await handle.StopAsync();
Console.WriteLine($"ok: {tool.Name} -> {result.Output}");
sealed class Stub : IDisposable
{
readonly System.Net.HttpListener _listener = new();
readonly CancellationTokenSource _cts = new();
public int Port { get; }
public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<System.Net.HttpListenerContext> handler)
{
var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
probe.Start();
Port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
_listener.Prefixes.Add($"http://127.0.0.1:{Port}/");
_listener.Start();
_ = Task.Run(async () =>
{
while (!_cts.IsCancellationRequested)
{
System.Net.HttpListenerContext ctx;
try { ctx = await _listener.GetContextAsync(); }
catch { break; }
try { handler(ctx); } catch { }
}
});
}
public static void Json(System.Net.HttpListenerContext ctx, int status, string body)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(body);
ctx.Response.StatusCode = status;
ctx.Response.ContentType = "application/json";
ctx.Response.ContentLength64 = bytes.Length;
ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
ctx.Response.OutputStream.Close();
}
public void Dispose()
{
_cts.Cancel();
try { _listener.Stop(); } catch { }
try { _listener.Close(); } catch { }
}
}
Parameter Type What it is
block object? The agents value off a parsed config document — a map of id → { card, headers?, timeout?, pollEvery?, enabled?/disabled? }. Anything else ⇒ empty list.
  • A2A.Agent — Point at a remote agent’s card and use it exactly like a local tool.
  • A2A.AgentTools — Expand a remote agent card into one tool per advertised skill.