Apricot Framework

Authorization

A tool is gated by the attribute that would gate an endpoint, decided by the host's own pipeline.

This library does not know what a permission is. It knows two questions a call has to get past, asked in a fixed order:

  1. Does this tool exist for this caller? An IAgentToolFilter answers — a surface, a tenant, a feature flag. A refusal reads as not found: AgentToolFilteredException, which is an AgentToolNotFoundException, with a message that says no more than an unknown name would.
  2. May this caller run it? An IAgentToolAuthorizationFilter answers. A refusal reads as access denied: AgentToolAccessDeniedException, carrying the reason.
public interface IAgentToolAuthorizationFilter
{
    ValueTask<AgentToolAuthorizationDecision> AuthorizeAsync(
        AgentToolDescriptor tool, AgentToolContext context, CancellationToken cancellationToken = default);
}

Every filter runs before any authorization filter, whatever order they were registered in, and a tool a filter refused is never shown to authorization at all. Asking would be wrong twice over: a 403 for a tool out of scope tells the caller it exists, and an authorization handler that logs or spends a budget would do so for a tool the caller could not have reached.

Keeping them apart is also what lets a refusal mean something. A model told not found stops looking for the tool; told access denied, it can tell the user why. Folding both into one decision would have to pick one answer for two different situations.

Both are applied the same way — to the listing and to the call — so a caller is never offered something that then refuses them. Register either with AddAgentToolFilter<T>() / AddAgentToolAuthorizationFilter<T>(), singleton by default, scoped when the decision reads per-call state.

The attribute implementation

ApricotFramework.Agentic.Tools.AspNetCore implements it by reading the authorization a tool's registration carries — which begins with the attributes on the tool's own type:

[AgentToolType]
[Authorize(Policy = "tickets.read")]     // the whole family
public sealed class TicketTools(TicketStore tickets)
{
    [AgentTool("support_tickets_delete", Destructive = true)]
    [Authorize(Policy = "tickets.admin")]  // and this one narrows it
    public Task<TicketDeleted> Delete(long id) => …;
}

That is the same attribute that would sit on a controller action. It produces the same requirements, and those requirements reach the same handlers over the same stores — so a tool and the endpoint beside it cannot drift into deciding the same caller differently.

Enable it with one call:

builder.Services.AddAgentToolsWeb()
    .WithAuthorization();

Three shapes are read, all of them ASP.NET Core's own, and all of them combined rather than one winning:

MetadataWritten asResolved by
IAuthorizeData[Authorize], [Authorize(Policy = …)], [Authorize(Roles = …)]the host's IAuthorizationPolicyProvider
IAuthorizationRequirementDataan attribute carrying its own requirementsthe attribute itself
AuthorizationPolicyan object in the metadata, from RequireAuthorization or a sourcetaken as it is

A permission attribute, a scope attribute, a policy attribute or one nobody has written yet all work, and none of them reach this library. AuthenticationSchemes is ignored: acting on it means challenging a caller to authenticate again, and an invocation has no request to challenge.

Attributes are read from the holding class first and the method after, and from a type and everything it derives from, so a family of tools declares a shared requirement once and one method adds to it. They combine rather than replace, so the example above needs both.

Registrations carry it too

An attribute is only one way to say it. Anything a registration carries counts the same, which is what lets a tool nobody wrote an attribute for — one that is just a function, one fetched from a foreign server — be gated at all:

builder.Services.AddAgentTool(searchFunction, declaration,
    tool => tool.RequireAuthorization("tools.search"));

The overloads mirror minimal APIs: no argument for an authenticated caller, policy names, an IAuthorizeData, requirements, a built AuthorizationPolicy, or a builder callback. Each one adds, so RequireAuthorization on a type that already carries an attribute means both.

They are extension methods on the IAgentToolConventionBuilder the registration callback is handed, and that builder does not outlive the call. See Hosting for why.

For tools from a source, which have no registration to configure, attach the same attributes with AgentToolCuration.Adding — or, for the MCP source, with its Metadata option.

A tool that declares nothing is open

Nothing is refused for having said nothing. That is what the MCP SDK does with a tool carrying no authorization metadata, and it is the only answer that lets a public tool sit beside gated ones.

AuthorizationOptions.FallbackPolicy is deliberately not consulted. It governs endpoints; the transport endpoint carrying these calls has already been through it, and applying it a second time per tool would gate a tool on a policy written for a route. A host that wants every tool gated says so on the tools — or registers an authorization filter of its own.

[AllowAnonymous] waives whatever else the registration declares, so it beside [Authorize] opens the tool. ASP.NET Core's semantics, footgun included. It also keeps the tripwire quiet, because a gate the host waived on purpose is not a gate nothing is enforcing.

The tripwire

There is no setting to turn authorization on, and something better instead. If a tool carries authorization while nothing is enforcing it, the registry refuses to compose:

The tool 'support_tickets_get' carries authorization, but nothing in this host enforces it.
Call WithAuthorization(), add an IAgentToolAuthorizationFilter with
AddAgentToolAuthorizationFilter(), or register an AgentToolEnforcementMarker beside whatever
enforces it instead.

Forgetting the call would otherwise open every tool that thought it was gated, and the failure is silent: a tool with [Authorize] on it looks gated in the source and is not.

EnforcementDeclaredValidator is the one thing AddAgentToolsCore() registers without being asked. It lives in the core package, which does not reference ASP.NET Core — a console host federating MCP servers should not acquire a web framework to be told this — so it recognises a gate by looking for metadata implementing an interface named IAuthorizeData or IAuthorizationRequirementData in the Microsoft.AspNetCore.Authorization namespace. A name check is not pretty. The alternatives were worse: a dependency in the console path, or a tripwire registered by the very call it exists to catch you forgetting.

A host enforcing authorization its own way — a table, a service this library has never heard of — usually does it in an IAgentToolAuthorizationFilter, and registering one with AddAgentToolAuthorizationFilter<T>() is enough to satisfy the tripwire. Where enforcement lives outside the invoker altogether, say so with the marker:

builder.Services.AddSingleton<AgentToolEnforcementMarker>();

No caller is not a refusal

A context with no User is handed an unauthenticated ClaimsPrincipal and the requirements decide. Anything reached through [Authorize] denies it — that attribute carries the deny-anonymous requirement whether or not it names a policy — while a host's own requirement is left free to permit a call made by nobody, which is how a worker or a scheduler gets to run a tool.

The listing and the gate

The same two layers answer both questions, and that is the point rather than a convenience.

var offered = await executor.GetAvailableToolsAsync(cancellationToken);      // what could they call
await executor.InvokeCompleteAsync(name, argumentsJson, cancellationToken);  // let them call it

A tool is listed only when every filter and every authorization filter allows it, and a call is run only under the same condition, so a caller is never shown a tool that then refuses them, nor refused one it was shown. A separate listing rule would be a second implementation of the same decision, and the two would drift.

It lives on the invoker rather than the registry because the invoker is the one place holding both the tools and the decisions. The registry knows a caller only well enough to ask its sources, and keeping it that way is what stops the filtering from happening somewhere it cannot be kept honest.

The listing is ergonomics; the call is enforcement. A decision whose answer moves — keyed on the time of day, a budget being spent — can disagree with itself between the two, and where it does, showing a tool that then refuses is the better failure: a false positive costs one refused call, where a tool silently missing is one nobody can diagnose. Enforcement on invocation is unconditional and there is no setting for it.

That is also why the tools a chat client is handed re-enter the executor when called. A listing made twenty minutes ago is not a permission.

The descriptor is the resource

The whole descriptor is passed as the authorization resource, so a handler can narrow both on what the tool declares and on what the host said about it:

protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, McpSurfaceRequirement requirement)
{
    if (context.Resource is AgentToolDescriptor { Declaration.IsDestructive: true } && !context.User.IsInRole("operator"))
    {
        return Task.CompletedTask;      // no Succeed, so it is refused
    }

    context.Succeed(requirement);

    return Task.CompletedTask;
}

Handing over only the tool would have closed off the second half — and would have been useless for a tool that is a plain function, which has no type of its own to narrow on. Which is how a second, agent-facing gate gets written: a policy keyed on what the tool declares about itself — destructive, read-only, whatever a label adds — sitting beside the domain's own access check rather than mirroring it.

Surfaces are the host's own

There is no framework notion of which surface a tool belongs to, and no property on the context carrying which one a call arrived on. Both halves are the application's: the tool side is a label, and the caller side is a context of your own, built by your context factory.

public sealed class SupportDeskAgentToolContext : AgentToolContext
{
    public string? Surface { get; init; }
}

public sealed class SurfaceAgentToolFilter : IAgentToolFilter
{
    public ValueTask<AgentToolFilterDecision> EvaluateAsync(
        AgentToolDescriptor tool, AgentToolContext context, CancellationToken cancellationToken = default)
    {
        var surface = (context as SupportDeskAgentToolContext)?.Surface;

        if (string.IsNullOrWhiteSpace(surface))
        {
            return ValueTask.FromResult(AgentToolFilterDecision.Allow());
        }

        var declared = tool.Declaration.TryGetLabel<string>(SupportDeskLabels.Surfaces, out var value) ? value! : string.Empty;

        return ValueTask.FromResult(
            declared.Split(',', StringSplitOptions.TrimEntries).Contains(surface, StringComparer.Ordinal)
                ? AgentToolFilterDecision.Allow()
                : AgentToolFilterDecision.Deny($"The tool '{tool.Name}' is not offered on '{surface}'."));
    }
}

It is a filter rather than an authorization filter because a tool kept off a surface does not exist there: invoking it by name on that surface is not found, exactly as for a name nobody declared.

Reading it as (context as …)?.Surface keeps a filter working when a host passes a plain AgentToolContext — a test, a worker, another front end. AgentToolContext is not sealed for exactly this.

Which surfaces exist, whether a tool naming none is everywhere or nowhere, and whether the vocabulary is closed are all decisions a library would have to guess at, and a guess produces a declaration the host works around. Pin the vocabulary with a validator if you want it closed.

On this page