NativeTool.Of
C# · package Toolnexus · SPEC §6 · NativeTool.cs
// synchronous, ignores the contextpublic static NativeTool Of(string name, string description, IDictionary<string, object?>? inputSchema, Func<IDictionary<string, object?>, object?> fn)
// synchronous, receives the contextpublic static NativeTool Of(string name, string description, IDictionary<string, object?>? inputSchema, Func<IDictionary<string, object?>, ToolContext?, object?> fn)
// asynchronouspublic static NativeTool OfAsync(string name, string description, IDictionary<string, object?>? inputSchema, Func<IDictionary<string, object?>, ToolContext?, Task<object?>> fn)The shortest path from code you already have to a tool the model can call. Give it a name, a
description, a JSON-Schema object and a function; you get back an ITool
with Source set to "native", indistinguishable from an MCP tool as far as the LLM is concerned.
When to use it
Section titled “When to use it”For anything that is your logic rather than someone else’s server: a database lookup, a calculation, a call into an internal service, a lookup in a dictionary you have in memory. It is also the fastest way to add one tool to a toolkit that is otherwise all MCP and skills.
Why this and not the alternative
Section titled “Why this and not the alternative”NativeTool is a sealed class with a private constructor; Of / OfAsync are the only way in.
Examples
Section titled “Examples”1. A function with a schema
Section titled “1. A function with a schema”using Toolnexus;
var add = NativeTool.Of( "add", "Add two numbers", new Dictionary<string, object?> { ["type"] = "object", ["properties"] = new Dictionary<string, object?> { ["a"] = new Dictionary<string, object?> { ["type"] = "number" }, ["b"] = new Dictionary<string, object?> { ["type"] = "number" }, }, ["required"] = new[] { "a", "b" }, }, // The explicit parameter type picks the no-context overload. (IDictionary<string, object?> a) => (Convert.ToDouble(a["a"]) + Convert.ToDouble(a["b"])).ToString());
if (add.Name != "add") throw new Exception(add.Name);if (add.Source != "native") throw new Exception(add.Source);
var res = await add.ExecuteAsync(new Dictionary<string, object?> { ["a"] = 2, ["b"] = 3 });if (res.IsError || res.Output != "5") throw new Exception(res.Output);
Console.WriteLine($"ok: {add.Name} -> {res.Output}");Arguments arrive as whatever your JSON layer produced — long, double, string,
JsonElement. Convert defensively (Convert.ToDouble, ?.ToString()) rather than casting.
2. Return values, failure, and no arguments at all
Section titled “2. Return values, failure, and no arguments at all”The return value is coerced: a string becomes the output verbatim, a ToolResult is passed
through untouched, null becomes "", and anything else is JSON-serialized. A thrown exception
becomes an error result — it never escapes to your loop.
using Toolnexus;
// A non-string return is JSON-serialized for you.var reading = NativeTool.Of("reading", "Latest sensor reading", null, (IDictionary<string, object?> a) => new Dictionary<string, object?> { ["celsius"] = 21.5, ["ok"] = true });
var r1 = await reading.ExecuteAsync(new Dictionary<string, object?>());if (r1.IsError || r1.Output != """{"celsius":21.5,"ok":true}""") throw new Exception(r1.Output);
// Passing null for the schema gives the canonical empty-object schema.if (reading.InputSchema["type"] as string != "object") throw new Exception("type");if (reading.InputSchema["additionalProperties"] is not false) throw new Exception("additionalProperties");
// Return a ToolResult when you want metadata, or a deliberate error.var lookup = NativeTool.Of("lookup", "Look a user up by id", null, (IDictionary<string, object?> a) => a["id"] as string == "42" ? ToolResult.Ok("Ada", new Dictionary<string, object?> { ["title"] = "user 42" }) : ToolResult.Error("no such user"));
var hit = await lookup.ExecuteAsync(new Dictionary<string, object?> { ["id"] = "42" });if (hit.Metadata?["title"] as string != "user 42") throw new Exception("metadata");
var missing = await lookup.ExecuteAsync(new Dictionary<string, object?> { ["id"] = "7" });if (!missing.IsError || missing.Output != "no such user") throw new Exception(missing.Output);
// A throw is caught and turned into an error result — the model sees the message and can retry.var risky = NativeTool.Of("risky", "Throws on purpose", null, (IDictionary<string, object?> a) => throw new InvalidOperationException("upstream is down"));
var boom = await risky.ExecuteAsync(new Dictionary<string, object?>());if (!boom.IsError || boom.Output != "upstream is down") throw new Exception(boom.Output);
Console.WriteLine($"ok: {r1.Output} | {hit.Output} | {boom.Output}");3. Async work, the context, and secrets
Section titled “3. Async work, the context, and secrets”OfAsync for anything that awaits. The ToolContext is nullable and carries a timeout and a
cancellation token — honour them for long work.
using Toolnexus;
// Real credentials are read from the environment at call time and never logged or// baked into the tool. YOUR_KEY_HERE is a placeholder, not a key.Environment.SetEnvironmentVariable("DOCS_DEMO_TOKEN", "YOUR_KEY_HERE");
var fetchReport = NativeTool.OfAsync( "fetch_report", "Fetch a report by id", new Dictionary<string, object?> { ["type"] = "object", ["properties"] = new Dictionary<string, object?> { ["id"] = new Dictionary<string, object?> { ["type"] = "string" } }, ["required"] = new[] { "id" }, }, async (a, ctx) => { var token = Environment.GetEnvironmentVariable("DOCS_DEMO_TOKEN"); if (string.IsNullOrEmpty(token)) return ToolResult.Error("DOCS_DEMO_TOKEN is not set");
// Cooperative cancellation: the loop can stop a slow tool. await Task.Delay(1, ctx?.CancellationToken ?? default); if (ctx?.IsCancelled == true) return ToolResult.Error("cancelled");
return $"report {a["id"]} (authenticated)"; });
var ok = await fetchReport.ExecuteAsync(new Dictionary<string, object?> { ["id"] = "q3" }, new ToolContext(timeoutMs: 5_000));if (ok.IsError || ok.Output != "report q3 (authenticated)") throw new Exception(ok.Output);if (ok.Output.Contains("YOUR_KEY_HERE")) throw new Exception("never echo a credential back to the model");
// The context overload of Of() covers the synchronous case.var whoami = NativeTool.Of("whoami", "Report the deadline the caller imposed", null, (IDictionary<string, object?> a, ToolContext? ctx) => $"timeout={ctx?.TimeoutMs?.ToString() ?? "none"}");
var withCtx = await whoami.ExecuteAsync(new Dictionary<string, object?>(), new ToolContext(timeoutMs: 1_500));if (withCtx.Output != "timeout=1500") throw new Exception(withCtx.Output);
// ctx is optional — callers may omit it entirely.var noCtx = await whoami.ExecuteAsync(new Dictionary<string, object?>());if (noCtx.Output != "timeout=none") throw new Exception(noCtx.Output);
// A native tool goes into a toolkit alongside every other source, via ExtraTools.// (The toolkit also carries the built-in tools by default, so don't assert on a count.)await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List<ITool> { fetchReport, whoami } });if (tk.Get("fetch_report") == null || tk.Get("whoami") == null) throw new Exception($"toolkit: {string.Join(",", tk.Tools().Select(t => t.Name))}");
Console.WriteLine($"ok: {ok.Output} | {withCtx.Output} | toolkit has {tk.Tools().Count} tools");Parameters
Section titled “Parameters”| Parameter | Type | What it is |
|---|---|---|
name |
string |
What the model calls. Must match [a-zA-Z0-9_-] — see Tools.Sanitize. |
description |
string |
What the model reads to decide whether to call it. Write it for the model. |
inputSchema |
IDictionary<string, object?>? |
A JSON-Schema object. null ⇒ {"type":"object","properties":{},"additionalProperties":false}. |
fn |
delegate | Your function. Return string, ToolResult, null, or anything JSON-serializable. |
See also
Section titled “See also”ToolMethodAttribute— derive the schema from the method signatureTools.FromObject— sweep a class for annotated methodsITool·ToolResult·ToolContextHttpTool.Of— the same idea when the logic is a REST call