Tools that are not classes
A function, a delegate or a foreign tool, offered on terms this host sets — without anything wrapping it.
Not every tool is something somebody wrote here. A delegate, something assembled from
configuration, a tool read off an MCP server, a tool another service owns — all of them are
Microsoft.Extensions.AI.AIFunction or can be described as one, and all of them go into the
registry as they are.
builder.Services.AddAgentTool(
function,
new AgentToolDeclaration
{
Name = "weather_search",
Description = "Searches a third party weather index.",
IsReadOnly = true,
IsDestructive = false,
IsOpenWorld = true,
},
tool => tool.RequireAuthorization("tools.external"));The declaration sits beside the function, not on it
An AgentToolDescriptor holds three things:
AIFunctionDeclaration Tool { get; } // the function, exactly as it arrived
IAgentToolDeclaration Declaration { get; } // name, title, prose, behaviour, labels
IReadOnlyList<object> Metadata { get; } // whatever the host saidA hand-written AgentTool satisfies both of the first two itself, so registering it fills them
from one object. A method tool has its declaration built from its attributes. A bare function has
neither, so the declaration is supplied beside it.
This is why there is no wrapper type — no FunctionAgentTool, no RenamedAgentTool, no
DelegatingAgentTool. Three things follow from that, and they are the reason for the shape:
Identity survives. A consumer reaching through a listed tool for what is really behind it
still finds it. That matters most for an MCP client's tool, where
descriptor.Tool.GetService<McpClientTool>() is how the SDK gets back to its own type — and an
object in the way would have had to remember to forward it.
Renaming is not a wrapper. A foreign search will collide with, or shadow, something of
yours. Offering it under another name is a different declaration over the same function:
descriptor.With(AgentToolDeclaration.From(descriptor.Declaration) with { Name = "weather_search" })which is exactly what AgentToolCuration.Prefixing does.
So is pinning prose. A foreign tool's name, description and schema go straight into your
model's prompt, and the server can change all three after you vetted them. That is a
prompt-injection surface with no local equivalent, and overriding Description with text you
control is the cheap mitigation:
descriptor.With(AgentToolDeclaration.From(descriptor.Declaration) with
{
Description = "Searches a third party weather index. Returns at most ten results."
})What the function cannot say for itself
AgentToolDeclaration requires Name, IsReadOnly and IsDestructive, for the same reason
those are abstract on AgentTool: a default would be the one nobody notices, and a policy keyed
on "destructive" is worth nothing if a tool can arrive without having said. Title falls back to
the name, IsIdempotent to IsReadOnly, and ResultKind is Whole — a function returns one
value, whatever else is claimed about it.
Assume the worst about a missing hint. The MCP annotations are optional and advisory.
Defaulting an absent DestructiveHint to true costs you a stricter gate; defaulting it to
false costs you a deletion. The MCP source
does the former, and so should you.
ResultKind is Whole and cannot be anything else: a function returns one value, and the
registry refuses a declaration claiming otherwise. Only an AgentTool
can produce items.
Gate it yourself. A function has no class to carry an attribute, so the registration callback is the only thing standing between your callers and it.
Something described but not runnable
Descriptor.Tool is typed as AIFunctionDeclaration, not AIFunction, so a descriptor can carry
a tool this host has no way to invoke — a catalogue listing what a fleet offers without holding a
transport to any of it. Listing one is fine; calling it throws AgentToolNotInvocableException,
rather than every listing having to pretend it could.
descriptor.AsFunction() returns the function, or null where only a description is held.
Reaching the invocation from a function
A function written against this library takes the caller and the scope from the arguments, where the AI abstractions already keep such things:
var function = AIFunctionFactory.Create(
(AIFunctionArguments arguments) =>
{
var context = arguments.RequireAgentToolContext(); // who is asking
var services = arguments.Services; // the scope this call opened
…
},
"inspect",
"Looks at its own invocation.");GetAgentToolContext() is the forgiving form. GetPayload() returns the caller's own JSON where
there was any — which is what a tool should prefer over the named arguments when it has a shape of
its own to deserialise, because nothing has rounded a number in it.