ToolMethodAttribute
C# · package Toolnexus · SPEC §6 · Annotations.cs
[AttributeUsage(AttributeTargets.Method)]public sealed class ToolMethodAttribute : Attribute{ public string Name { get; set; } = ""; // defaults to the method name public string Description { get; set; } = "";}
[AttributeUsage(AttributeTargets.Parameter)]public sealed class ParamAttribute : Attribute{ public string Name { get; set; } = ""; // defaults to the parameter name public string Description { get; set; } = ""; public bool Required { get; set; } = true;}Mark an ordinary public instance method [ToolMethod] and it becomes a tool: the JSON-Schema
InputSchema is inferred from the method’s own parameter types, [Param] adds a per-parameter
description or an explicit name/required flag, and a ToolContext parameter is recognized and
injected rather than turned into a schema property. Nothing here builds a tool by itself — the
attributes only mark the method; Tools.FromObject is what reads the
attributes and produces the ITool list.
When to use it
Section titled “When to use it”You already have (or are writing) a plain C# class whose methods are the logic you want the model to
call — a service, a repository, a set of internal operations. Annotating each method is less
ceremony than hand-writing a NativeTool.Of schema for every one of them, and the schema can’t drift
from the real parameter list because it’s read from that list.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest annotated method
Section titled “1. The smallest annotated method”using Toolnexus;
var service = new WeatherService();var tools = Tools.FromObject(service);
if (tools.Count != 1) throw new Exception($"expected 1 tool, got {tools.Count}");// A bare [ToolMethod] with no Name uses the method's own name verbatim — no casing change.if (tools[0].Name != "GetWeather") throw new Exception(tools[0].Name);if (tools[0].Source != "native") throw new Exception(tools[0].Source);
var res = await tools[0].ExecuteAsync(new Dictionary<string, object?> { ["city"] = "Chennai" });if (res.IsError || res.Output != "sunny in Chennai") throw new Exception(res.Output);
Console.WriteLine($"ok: {tools[0].Name} -> {res.Output}");
class WeatherService{ [ToolMethod(Description = "Current weather for a city")] public string GetWeather(string city) => $"sunny in {city}";}2. Custom name, [Param] descriptions, and an optional parameter
Section titled “2. Custom name, [Param] descriptions, and an optional parameter”using Toolnexus;
var svc = new SearchService();var tools = Tools.FromObject(svc);var search = tools.Single(t => t.Name == "search_docs");
var props = (IDictionary<string, object?>)search.InputSchema["properties"]!;var query = (IDictionary<string, object?>)props["query"]!;if (query["description"] as string != "What to search for") throw new Exception("query description");
var required = (IEnumerable<object?>)search.InputSchema["required"]!;// "limit" is optional (Required = false) — only "query" is required.if (!required.Contains("query") || required.Contains("limit")) throw new Exception("required set");
var withLimit = await search.ExecuteAsync(new Dictionary<string, object?> { ["query"] = "toolnexus", ["limit"] = 1 });if (withLimit.Output != "1 results for toolnexus") throw new Exception(withLimit.Output);
// Omitting an optional argument does NOT run the method's own C# default — reflection always// passes an explicit value, coerced from "missing" to the parameter type's zero value (0 for int).var omitted = await search.ExecuteAsync(new Dictionary<string, object?> { ["query"] = "toolnexus" });if (omitted.Output != "0 results for toolnexus") throw new Exception(omitted.Output);
Console.WriteLine($"ok: {withLimit.Output} | {omitted.Output}");
class SearchService{ [ToolMethod(Name = "search_docs", Description = "Search the documentation")] public string Search( [Param(Description = "What to search for")] string query, [Param(Description = "Max results", Required = false)] int limit) => $"{limit} results for {query}";}3. ToolContext injection, alongside a plain NativeTool
Section titled “3. ToolContext injection, alongside a plain NativeTool”using Toolnexus;
var ops = new OpsService();var reflected = Tools.FromObject(ops);var whoami = reflected.Single(t => t.Name == "Whoami");
var res = await whoami.ExecuteAsync(new Dictionary<string, object?>(), new ToolContext(timeoutMs: 2_500));if (res.Output != "timeout=2500") throw new Exception(res.Output);
// A method's ToolContext parameter is never part of the JSON schema — only real args are.if (((IDictionary<string, object?>)whoami.InputSchema["properties"]!).Count != 0) throw new Exception("ctx parameter leaked into the schema");
// Reflected tools sit alongside hand-built NativeTool instances in the same list — the model// can't tell them apart; both are Source == "native".var manual = NativeTool.Of("ping", "Health check", null, (IDictionary<string, object?> a) => "pong");var all = new List<ITool>(reflected) { manual };if (all.Select(t => t.Source).Distinct().Single() != "native") throw new Exception("source mismatch");
Console.WriteLine($"ok: {res.Output} | {string.Join(", ", all.Select(t => t.Name))}");
class OpsService{ [ToolMethod(Description = "Report the deadline the caller imposed")] public string Whoami(ToolContext? ctx) => $"timeout={ctx?.TimeoutMs?.ToString() ?? "none"}";}Members
Section titled “Members”| Member | Applies to | What it is |
|---|---|---|
ToolMethodAttribute.Name |
method | Tool name; defaults to method.Name. |
ToolMethodAttribute.Description |
method | What the model reads to decide whether to call it. |
ParamAttribute.Name |
parameter | Schema property name; defaults to the parameter name. |
ParamAttribute.Description |
parameter | Schema description for that property. |
ParamAttribute.Required |
parameter | Defaults to true. false drops it from required and lets a C# default apply. |
ToolContext parameter |
parameter | Recognized by type and injected — never becomes a schema property. |
See also
Section titled “See also”NativeTool.Of— Wrap a plain function with a name, description and schema — the shortest path from code you have to a tool the LLM can call.Tools.FromObject— Sweep a module or class and collect every function marked as a tool.