Hosting
Registration, the scope a call runs in, who the caller is, and where to wrap.
builder.Services.AddAgentToolsWeb() // the machinery, with a web host's defaults
.WithAuthorization() // gate tools on what they declare
.DecorateInvoker<AuditingInvoker>(); // and what wraps every call
builder.Services.AddAgentToolType<TicketTools>(); // a class whose methods are tools
builder.Services.AddAgentTool<TicketsListTool>(); // a hand-written AgentTool, for a sequence
builder.Services.AddAgentToolValidator<ToolNamingConvention>(); // your rules, checked at startupThere is nothing to configure and there are no flags. Registering something is what makes it apply, and a host that wants none of a thing says so by not asking rather than by unpicking a registration.
Two kinds of registration, and why they look different
The split is deliberate, and it is about whether order carries meaning.
With… on the builder | ecosystem configuration | replaces what came before, or nests inside it |
Add… on the services | additive wiring | independent; put it in whatever file owns it |
Who the caller is, whether a gate applies, what wraps an invocation — each either overrides the last or wraps it, so the answer depends on sequence. Making those a chain means you cannot write them out of order, which is better than a rule about which registration wins.
A tool, a source, a validator, a filter are not like that. They accumulate, order-free, and a host
should be free to register them beside the code they belong to rather than dragging everything to
one composition root. So they stay Add… on IServiceCollection and work whether the machinery
has been composed yet or not.
public static IAgentToolsBuilder AddSupportDeskAgentTools(this IServiceCollection services)
{
services.AddAgentToolType<TicketTools>(); // additive: anywhere, any order
services.AddAgentToolValidator<NamingConvention>();
return services.AddAgentToolsCore() // and the decisions, handed back
.WithAuthorization(); // so the host can go on saying its own
}A library of tools returns the builder; the host continues the chain with what only it knows.
Entry points
AddAgentToolsCore() | the machinery and no opinions. The caller is nobody until you say otherwise |
AddAgentToolsWeb() | the same, plus the caller taken from the request |
Web exists because there is no host reachable over HTTP where "nobody is calling" is right, and
forgetting to say so used to be silent. It deliberately does not bring authorization: that
needs IAuthorizationService, which only exists if the host called AddAuthorization(), and a
host with genuinely open tools is a real configuration. Forgetting that is already caught by
the tripwire.
The un-suffixed name is left free for your own composition root, the way AddGrpcClientsCore
leaves AddGrpcClients.
Registration says everything inside the call
services.AddAgentToolType<TicketTools>();
services.AddAgentToolType<TicketAdminTools>(tool => tool.RequireAuthorization("tickets.admin"));
services.AddAgentTool(searchFunction, declaration, tool => tool.RequireAuthorization("tools.external"));No per-tool builder is handed back. One that outlives its call can be mutated after the thing it
configures has been read, and the reading happens in a different phase from the writing. So
anything else to say about a tool is said in the callback — where RequireAuthorization and
WithMetadata are extension methods on IAgentToolConventionBuilder.
Attributes are usually the better place anyway: they travel with the tool rather than with one composition root. For a method tool they are read from the holding class first and the method after, so a family declares a shared requirement once and a method narrows it.
A tool is built once per call
Both registrations put the type in the container as scoped and resolve it again for every
call — AddAgentToolType<T>() through the function factory's target hook, and AddAgentTool<T>()
through a ScopedAgentTool, which reads the declaration once from an instance built at
composition and then resolves a fresh instance per invocation.
[AgentToolType]
public sealed class TicketTools(TicketStore tickets) // scoped, and it worksWhich means:
- A listing constructs nothing. Offering twenty tools does not mean building twenty objects on every request.
- An invocation constructs one, in the scope the call opened, and disposes it with that scope.
- A declaration is fixed at first read. A tool whose description genuinely varies by caller belongs in a source, not in a property.
One consequence worth stating: a hand-written AgentTool is not reachable by its own CLR type
from a listing, because no instance of it exists until somebody calls it.
ScopedAgentTool.ToolType is what remains of that. Narrowing on what a tool declares, or on what
the host said about it, is the better habit — and the only one that works for a tool that never
had a class, which is most of them.
Scanning
For a host with enough tools that listing them becomes its own maintenance problem:
builder.Services.AddAgentToolsFromAssembly(); // the calling assembly
builder.Services.AddAgentToolsFromAssemblyContaining<TicketTools>();
builder.Services.AddAgentToolsFromAssembly(assembly, type => type.Namespace!.StartsWith("SupportDesk.Tools"));Finds both kinds: classes carrying [AgentToolType], and classes deriving from AgentTool.
Skipped: abstract bases, open generics, and anything carrying [AgentToolIgnore] — which works on
a method as well as a class, so one tool can be held back without moving it out of its holder.
Internal types are not skipped, since a host may reasonably keep its tools internal.
Two things to know before reaching for it. A curated list is better where you can have one — naming three tools makes the list itself the surface, and somebody has to decide to add to it. And scan narrowly: an assembly that also holds tools written for a test is how a surface acquires tools nobody meant to offer, which is what the predicate is for.
Who is asking
One host, one answer:
builder.Services.AddAgentToolsWeb(); // the request's principal
builder.Services.AddAgentToolsCore().WithContext<Mine>(); // yourspublic interface IAgentToolContextFactory
{
ValueTask<AgentToolContext> CreateAsync(IServiceProvider scopedServices, CancellationToken cancellationToken = default);
}An HTTP host reads the request's principal; an MCP server over stdio has no caller at all and says
so; a host with state worth naming returns a context derived from AgentToolContext:
public sealed class SupportDeskAgentToolContextFactory(IHttpContextAccessor accessor) : IAgentToolContextFactory
{
public ValueTask<AgentToolContext> CreateAsync(IServiceProvider scopedServices, CancellationToken cancellationToken = default)
{
var http = accessor.HttpContext;
var user = http?.User;
return ValueTask.FromResult<AgentToolContext>(new SupportDeskAgentToolContext
{
User = user?.Identity?.IsAuthenticated == true ? user : null,
Services = scopedServices,
Surface = http?.Request.Query["surface"].FirstOrDefault()
});
}
}WithContext replaces rather than adds. Two answers to "who is asking" is not a
configuration, it is a bug — and because it is a link in a chain, the last one written is plainly
the one that applies, with no rule to remember about which registration wins.
AddAgentToolsCore() alone leaves DefaultAgentToolContextFactory: a scope, and no caller.
Null is not anonymous. A host with no notion of a person leaves User unset, and the host's own
filters decide what that means rather than this library assuming.
Invoking
var offered = await executor.GetAvailableToolsAsync(cancellationToken);
var json = await executor.InvokeCompleteAsync(name, argumentsJson, cancellationToken);
await foreach (var chunk in executor.InvokeAsync(name, argumentsJson, cancellationToken)) { … }
var functions = await executor.GetAvailableFunctionsAsync(cancellationToken); // for ChatOptions.ToolsThere is no context parameter, and that is the point. Opening a scope and deciding who the caller is are one question a host answers once, not something every MCP handler, gRPC service and HTTP endpoint re-derives — differently, with one of them eventually getting it wrong.
Per call the executor opens an IServiceScope, asks the context factory over it, and runs the
call inside both. The scope lives until the last item has been yielded or the caller stops
reading, whichever comes first — a tool holding a unit of work open across a long sequence is the
ordinary case, and disposing at the first item would break it.
InvokeCompleteAsync collects a sequence tool's items into an array, which is what its output
schema describes either way. A failure part way through fails the whole call and discards what
arrived: handing a model a truncated result it cannot recognise as truncated is worse than handing
it an error.
GetAvailableFunctionsAsync returns functions that go back through the executor when called, so a
conversation that lists once and calls twenty minutes later still runs in a fresh scope — and is
filtered again, so a tool revoked since is refused rather than run.
Wrapping
Two seams, because two different things want wrapping.
builder.Services.AddAgentToolsWeb()
.DecorateExecutor<RateLimitingExecutor>() // about the surface
.DecorateInvoker<AuditingInvoker>(); // needs the callerIAgentToolExecutor is outside the scope and has no context, which is right for a rate limit, a
span, or a cap on how many tools a listing offers. IAgentToolInvoker is inside it and holds the
AgentToolContext, which is right for an audit record or a per-person budget.
public sealed class AuditingInvoker(IAgentToolInvoker inner, IAudit audit)
: DelegatingAgentToolInvoker(inner)
{
public override async Task<string> InvokeCompleteAsync(
string name, string? argumentsJson, AgentToolContext context, CancellationToken ct)
{
await audit.RecordAsync(context.User, name, ct);
return await base.InvokeCompleteAsync(name, argumentsJson, context, ct);
}
}Wrappers apply in the order registered, each around the last, so the one registered last is the one a caller reaches first.
Two things worth knowing:
Both are registered behind their interface only. Resolving a concrete type would reach past whatever is wrapped around it, and a rate limit or an audit log nobody reaches is worse than not having one.
Override both invocation methods, or neither. InvokeAsync and InvokeCompleteAsync are
separate calls rather than one expressed in terms of the other, so a wrapper overriding only the
streaming one misses every caller that cannot stream. A rate limit on one of the two is a rate
limit on neither.
The invoker is resolved from the call's own scope, so a filter, an authorization filter or a wrapper that reads per-call state can be registered scoped, and is built once per call:
builder.Services.AddAgentToolFilter<TenantAgentToolFilter>(ServiceLifetime.Scoped);
builder.Services.AddAgentToolAuthorizationFilter<EntitlementAgentToolAuthorizationFilter>(ServiceLifetime.Scoped);Filters run first and decide what exists for the caller; authorization filters run after and decide what they may run. See Authorization for why the two are kept apart.
Errors
Failures are AgentToolException and its derivatives — AgentToolNotFoundException (and
AgentToolFilteredException, the same answer for a tool a filter kept from this caller),
AgentToolArgumentException, AgentToolAccessDeniedException, AgentToolDeclarationException,
AgentToolNotInvocableException. Deliberately not a host's error type: what a failed tool call
should look like is an application's decision, and a library that picked one would be argued with
by every host that wanted another.
Map them at your surface — the MCP and gRPC packages already do. The distinction worth preserving is refusal versus fault: a refusal that reads as a fault invites a model to retry it, and an agent will accept the invitation.
Reaching them from a surface
Nothing in the core opens a port. The transports are packages of their own — MCP, gRPC — and an HTTP surface is a handful of endpoints over the executor:
app.MapGet("/tools", async (IAgentToolExecutor executor, CancellationToken cancellationToken) =>
Results.Ok((await executor.GetAvailableToolsAsync(cancellationToken)).Select(Describe)));
app.MapPost("/tools/{name}", async (IAgentToolExecutor executor, HttpContext http, string name, CancellationToken cancellationToken) =>
Results.Text(await executor.InvokeCompleteAsync(name, await ReadBody(http), cancellationToken), "application/json"));