Toolnexus 0.25.0
dotnet add package Toolnexus --version 0.25.0
NuGet\Install-Package Toolnexus -Version 0.25.0
<PackageReference Include="Toolnexus" Version="0.25.0" />
<PackageVersion Include="Toolnexus" Version="0.25.0" />
<PackageReference Include="Toolnexus" />
paket add Toolnexus --version 0.25.0
#r "nuget: Toolnexus, 0.25.0"
#:package Toolnexus@0.25.0
#addin nuget:?package=Toolnexus&version=0.25.0
#tool nuget:?package=Toolnexus&version=0.25.0
toolnexus
Build an agent in a few lines. Give a toolkit some tools — built-ins, an mcp.json, a
skills/ folder, your own C# methods, HTTP endpoints, other agents — point a client at any LLM,
call RunAsync(), and the tool-calling loop runs to an answer. Every source is unified behind one
ITool interface and emitted in OpenAI / Anthropic / Gemini schema.
Right-sized. Not a framework (no builders to learn, no config to wade through), not a toy that falls over the moment you need streaming or a retry. Everything a real agent needs — the loop, hooks, streaming, retries, conversation memory — and nothing it doesn't.
The C#/.NET port of toolnexus — the same library,
byte-identical, also in JavaScript, Python, Go, Java, Elixir, Clojure and Rust. Built on the
official ModelContextProtocol SDK. Targets .NET 10.
Install
dotnet add package Toolnexus
Quick start
using Toolnexus;
// 1. a toolkit — the 10 built-in tools (bash/read/write/edit/grep/glob/…) are on by default
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options());
// 2. a client — point at any OpenAI- or Anthropic-style endpoint
var agent = LlmClient.Create(new LlmClient.Options
{
BaseUrl = "https://openrouter.ai/api/v1",
Style = "openai", // or "anthropic"
Model = "anthropic/claude-3.5-sonnet",
ApiKey = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY"),
});
// 3. run — tools called for you, looped to a final answer
var res = await agent.RunAsync("List the files in the current folder, then summarise the README.", tk);
Console.WriteLine(res.Text);
Add real tool sources by pointing at them:
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options
{
McpConfig = "mcp.json",
SkillsDir = new[] { "skills" },
});
Simple judgments
A thin layer over Classifier (SPEC §8B): an ordered question list, a state, bands, and a gate
that escalates (a §10 input Request) instead of deciding when unsure. The wire is unchanged.
var qs = new[] {
Judge.Noul("is_appropriate", "Does `message_received` contain inappropriate language?"),
Judge.Noul("does_this_help", "Does `message_received` help donkey kong win?"),
};
var state = Judge.State("You are Donkey Kong, you want to win.",
new Dictionary<string, object?> { ["message_received"] = "jump off the stage now" });
var a = await Judge.AskAsync(classifier, state, qs); // bands default 0.30 / 0.70
a["is_appropriate"].Band; // "yes" | "no" | "uncertain"
var o = await Judge.GateAsync(classifier, state, qs, new Policy {
Rules = new[] { Rule.Below("does_this_help", 0.3, "ignore") }, Default = "obey" });
// o.Escalated => o.Request is a §10 input Request
var ds = await classifier.EvaluateBatchAsync(states, Judge.Questions(qs)); // in order, fail-closed
Also: Judge.Context(ctx, message, extra), Judge.Choice / Judge.Score, answer.Value() /
answer.Choice(), Tape (record by call name, replay offline), Classifier.FromRecorded(...),
Question.ToWire(), public Decision.FromJson.
Batteries (§8B): ToolGuardClassifier, ToolRelevanceClassifier, SkillRelevanceClassifier,
ToolResultFilterClassifier, IsCompleteClassifier, AgentRouterClassifier,
ContentGuardClassifier, ModelRouterClassifier — e.g.
new ToolGuardClassifier(classifier, new ToolGuardOptions { OnError = OnError.Closed }), then
CheckAsync(...) standalone or AsHook(next) as a client hook. A BeforeLLM override may carry
Model for that turn only (the opt-in ModelRouterClassifier sets it).
API key by host (BREAKING in 0.22.0)
With no ApiKey, the client reads ApiKeyEnv (the NAME of an env var) if set, else ONLY the env
var of the BaseUrl host, over https: api.openai.com → OPENAI_API_KEY, api.anthropic.com →
ANTHROPIC_API_KEY, openrouter.ai → OPENROUTER_API_KEY. A key is never sent to another host.
A local server or proxy now needs one of the two:
var local = LlmClient.Create(new LlmClient.Options
{
BaseUrl = "http://localhost:11434/v1", Style = "openai", Model = "llama3.2",
ApiKey = "YOUR_KEY_HERE", // or: ApiKeyEnv = "MY_PROXY_KEY"
});
API key by host ·
ListModelsAsync uses the same rule.
Prompt cache and tool tiers
var agent = LlmClient.Create(new LlmClient.Options
{
BaseUrl = "https://api.anthropic.com", Style = "anthropic", Model = "claude-sonnet-4-5",
PromptCache = new PromptCacheOptions { Key = "session-42" }, // cache_control / prompt_cache_key
ToolTiers = new ToolTierOptions { Core = new[] { "read", "edit", "bash" } }, // the rest via tool_search / call_tool
});
// res.Usage.CacheReadTokens / CacheWriteTokens; agent.ToolTierPlan(tk); agent.ResetToolTiers(tk) at a new session
Only the core schemas are sent; the model loads the rest with tool_search and calls them by name
or through call_tool (hooks still see the real tool), so the tools array and the cached prefix
never change mid-session. Runnable: dotnet run --project examples/Toolnexus.Examples -- cache.
Prompt cache ·
Tool tiers
Host additions (0.22.0)
Options.WaitForCtx((request, ct) => …)— a pending question ends with the turn.McpSource.LoadAsync(cfg, new McpLoadOptions { OnServer = r => … })— each MCP server reported as it is ready.McpSource.ResourceTools()— opt-inlist_mcp_resources/read_mcp_resource.BeforeToolEvent.Hints(ReadOnly/Destructive/OpenWorld) on every builtin and MCP tool, for permission gates.- Opt-in builtins:
["background"] = new BackgroundShells()(bash_output,kill_shell; callCloseAllon shutdown),["webSearch"](your provider),["notebookEdit"] = true. grepcontext/before/after;read/grepreport truncation in metadata.Compaction.Options.OnCompact,Agent.AsToolWith(ctx => runtimeOptions)withRuntimeOptions.WaitFor,ToolContext.CallId,LlmClient.ListModelsAsync.- Not in C# yet: the ACP host hooks (Go only;
openspec/changes/add-go-host-asks).
Media generation
Generate images, video and speech (plus transcription, voice cloning and talking-head video)
from a JSON style instead of provider code. Async jobs are started and polled for you. The same
verbs become agent tools (generate_image, generate_video, generate_speech, …), and whatever
they produce lands in RunResult.files. examples/media-styles/a2e.json ships first and reads
A2E_API_KEY at call time. Its result URLs expire in 3–30 days, so save what you keep. SPEC §7G;
guide: https://muthuishere.github.io/toolnexus/media/.
var media = Media.Create(MediaStyle.Load("examples/media-styles/a2e.json"),
new MediaOptions { OnProgress = p => Console.WriteLine($"{p.Verb} {p.Status}") });
var img = await media.ImageAsync(new ImageInput("a red fox in fresh snow") { Width = 1024, Height = 768 });
var vid = await media.VideoAsync(new VideoInput("the fox blinks") { Image = img.Files[0].Url, Seconds = 5 });
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Media = media }); // generate_image, …
var res = await agent.RunAsync("Draw a fox, then animate it.", tk);
foreach (var f in res.Files) Console.WriteLine($"{f.From} {f.MimeType} {f.Url}");
Embeddable MCP server (SPEC §7C.1)
tk.McpHandler(cfg) returns an McpHttpHandler you mount on your own HTTP server, behind your own
TLS, CORS and OAuth. ServeAsync is unchanged. Every policy is a hook:
var handler = tk.McpHandler(new McpHandlerConfig
{
Authenticate = ex => VerifyBearer(ex.Header("Authorization")), // throw => 401 (+ WwwAuthenticate)
ListTools = ctx => ToolsFor((User)ctx.Identity!), // per request; others are "tool not found"
BeforeCall = (ctx, tool, args) => Task.FromResult(tool.Annotations?.DestructiveHint == true
? McpDecision.Confirm("Really delete?") // elicitation on the same POST
: McpDecision.Allow()),
OnCall = ev => Log(ev.Name, ev.Decision, ev.ResultBytes),
});
// HttpListener host: await handler.HandleAsync(httpListenerContext);
// any other server: subclass McpHttpExchange and call handler.HandleAsync(exchange).
handler.NotifyToolsChanged(); // tools/list_changed on every open GET stream
Tools and hooks read the request through McpContext (Identity, Request, SessionId, Meta =
params._meta); a tool uses McpContext.Current. Also: ServerInfo, resources/prompts hooks,
pluggable SessionIds, Stateless, ProtocolVersions (2025-06-18 default, 2025-11-25 opt-in).
ToolResult.Structured/Meta and ITool.OutputSchema/Annotations map to structuredContent,
_meta, outputSchema and annotations. Runnable: dotnet run --project examples/Toolnexus.Examples -- mcphandler.
MCP 2026-07-28 on the same handler (SPEC §7C.2, opt-in)
List "2026-07-28" in ProtocolVersions (alone, or next to "2025-06-18" to serve both wires on one
mount). The 2026 wire is stateless: server/discover, header validation, resultType + _meta
serverInfo, and MRTR instead of elicitation — a Confirm decision or a §10 tool suspension
(ToolResult.Pending) answers input_required, and the client's retry reaches the tool as
ToolContext.Answer. Without the opt-in the handler is byte-identical to §7C.1.
var auth = new McpServerAuth // OAuth 2.1 protected resource (RS256/JWKS by default)
{
Issuer = "https://login.example",
Scopes = new Dictionary<string, IReadOnlyList<string>> { ["*"] = ["mcp"], ["wipe"] = ["mcp:admin"] },
};
var handler = tk.McpHandler(new McpHandlerConfig
{
ProtocolVersions = ["2026-07-28", "2025-06-18"],
Authenticate = auth.AuthenticateAsync, // identity = McpPrincipal; 401/403 + exact challenge
ListSkills = _ => Task.FromResult(McpHttpHandler.LoadMcpSkills("skills")), // skills/list, skills/get, skill:// reads
});
// route GET /.well-known/oauth-protected-resource[/mcp] to auth.Metadata() (or auth.MetadataAsync(listenerCtx))
McpServerAuth.Verify replaces the JWT verifier (opaque tokens, introspection). Bring your own authorization
server: McpServerAuth verifies its tokens. The auto-approving dev authorization server the suite uses is test
tooling (tests/Toolnexus.Tests/McpDevAuth.cs), not part of the library.
Agents via ACP
toolnexus ships no client per coding agent — three vendor-neutral pieces instead (SPEC §8C):
tk.AsMcpServer(cfg?)serves the toolkit for a local agent onhttp://127.0.0.1:<ephemeral>/mcpbehind a fresh bearer token (Url,Token,Close()).AcpClient.StartAsync(new AcpStartOptions { Command, Arguments, McpServers, SessionMeta, ClientCapsMeta, Model, OnPermission, … })drives any ACP agent;McpServersentries are sent verbatim, so you build whichever entry your agent accepts.InProcess.Options.GenerateStream = acp.GenerateStreamAsyncstreams the agent's answer (Textdeltas,Thought, and its ownToolCall/ToolResultwithVia = "acp").
await using var srv = tk.AsMcpServer();
using var acp = await AcpClient.StartAsync(new AcpStartOptions
{
Command = "my-agent", Arguments = ["acp"],
McpServers = [new Dictionary<string, object?>
{
["type"] = "http", ["name"] = "toolnexus", ["url"] = srv.Url,
["headers"] = new[] { new Dictionary<string, object?> { ["name"] = "Authorization", ["value"] = "Bearer " + srv.Token } },
}],
});
var client = InProcess.CreateClient(new InProcess.Options { Model = "acp", GenerateStream = acp.GenerateStreamAsync });
Documentation
Everything else — the full surface, with runnable examples — lives on the docs site:
| Start here | Quickstart · Concepts · Install |
| Tool sources | MCP · Skills · Native · HTTP · Built-ins · A2A |
| The loop | Streaming · Memory · Suspension · Resilience · Observability |
| Agents | Sub-agents & teams · Personas · Typed decisions |
| API reference | C# |
| Cookbook | Zero to agent · MCP servers · Agent skills · Judge |
Contract across all eight ports: SPEC.md.
MCP 2026-07-28 + OAuth
McpSource.LoadAsync / Toolkit.CreateAsync speak MCP 2026-07-28 first (SPEC §2): every server gets
server/discover before anything else, over stdio or stateless Streamable HTTP, with no MCP SDK on that
path. A server that is not a 2026-07-28 server (a JSON-RPC error, HTTP 400/404/405/5xx, or a silent stdio
child) falls back to the frozen pre-2026 path, which still runs on the ModelContextProtocol SDK. Your
config stays the same.
- MRTR → §10:
input_requiredrounds go throughWaitFor. Without aWaitForthe tool returns{status:"pending"}; re-running it with theAnswerresumes that same round. Atools/callis never re-sent after a lost connection, a timeout or a 5xx. - Skills over MCP: skills a server publishes through the skills extension join the one
skilltool (baseskill://<name>/). - Lazy tool search: set
"toolSearch": true(and optionally"description") on a server. You then get<server>_search_toolsand<server>_call_toolinstead of every tool. - OAuth 2.1 (SPEC §2A): the per-server
authobject takesclientId,clientSecret,tokenAuthMethod,clientIds,clientMetadataUrl,redirectUri,mode(auto|pause),omitRefreshResourceandallowHttp. The client uses a pre-registered client id or a Client ID Metadata Document; dynamic client registration is never used. It does PKCE S256 +resource, iss/state checks, single-flight refresh and step-up.autoopens the browser and listens on loopback.pausesends a §10authorizationrequest; answer it withData = { ["callbackUrl"] = "<redirected URL>" }.
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options
{
McpConfig = "mcp.json",
TokenStore = myStore, // ITokenStore; default in-memory per load
OpenUrl = url => OpenBrowser(url), // auto mode; default is the OS opener
WaitFor = WaitFor, // MRTR input + pause-mode authorization
});
On the frozen fallback, the bearer goes onto the SDK's HTTP transport through a delegating handler. A 401
there runs the same flow once. Not yet on the 2026 path: list_mcp_resources / read_mcp_resource
(ResourceTools() only sees fallback servers), progress notifications, and the server side (serve).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- ModelContextProtocol (>= 1.4.0)
- YamlDotNet (>= 16.3.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.25.0 | 0 | 11/5/2026 |
| 0.24.0 | 59 | 11/3/2026 |
| 0.23.0 | 80 | 11/2/2026 |
| 0.22.0 | 77 | 11/1/2026 |
| 0.21.0 | 168 | 9/28/2026 |
| 0.20.0 | 113 | 9/23/2026 |
| 0.19.0 | 108 | 9/22/2026 |
| 0.18.1 | 106 | 9/21/2026 |
| 0.18.0 | 106 | 9/21/2026 |
| 0.17.0 | 125 | 9/2/2026 |
| 0.16.0 | 171 | 8/17/2026 |
| 0.15.0 | 128 | 8/17/2026 |
| 0.14.0 | 123 | 8/15/2026 |
| 0.13.0 | 137 | 8/2/2026 |
| 0.12.0 | 133 | 7/30/2026 |
| 0.10.0 | 124 | 7/19/2026 |
| 0.9.5 | 126 | 7/15/2026 |
| 0.9.4 | 140 | 7/14/2026 |
| 0.9.3 | 135 | 7/14/2026 |
| 0.9.0 | 139 | 7/13/2026 |