Skip to content
Menu

Host Endpoint Tools

View .md

A host application already has endpoints that answer the questions an agent asks: a back-office grid of a customer’s loans, a lookup by id. AddHostEndpointTools turns the endpoints you mark into MCP tools. Each call runs in-process, through the endpoint’s real pipeline (model binding, action filters, authorization policies), as a host identity built for the MCP caller, never as Beacon, and never with the caller’s own token.

Every exposed endpoint declares whether it is read-only; Beacon never infers it from the HTTP verb, because back offices read over POST (Kendo grids, [FromForm] filters). A read-only endpoint on any verb other than GET or HEAD is named in a startup Warning; set AllowReadOnlyPost = false to expose read-only tools on GET and HEAD only. By default only read-only endpoints are exposed; endpoints that change data become tools only when the host opts in with AllowWriteTools (see Write tools).

builder.Services.AddBeaconMcp()
.AddHostEndpointTools(o =>
{
o.ProjectName = "Back Office"; // the tools belong to this Beacon project
o.MaxResponseBytes = 1_048_576; // default 1 MiB
o.NamedToolLimit = 20; // above this: search_api + call_api
o.RequestTimeout = TimeSpan.FromSeconds(30); // default 30 s
o.MaxConcurrentCalls = 64; // default 64 calls in flight in total
o.MaxConcurrentCallsPerCaller = 16; // default 16 per MCP caller
o.QueueWait = TimeSpan.FromSeconds(5); // how long a call over a limit waits before it is refused
// o.AllowReadOnlyPost = false; // default true; false: read-only tools on GET/HEAD only
// o.TrustedAuthenticationSchemes = ["..."]; // default none; see "Authentication schemes" below
// o.AllowWriteTools = true; // default false; see "Write tools" below
// o.DescriptionNote = tool => "..."; // default Beacon's note; see "Description" below
});

ProjectName names the host project, the same one ExposeDbContext and ExposeDocs use. Beacon finds it by its host key (Project.HostManagedKey = host:{name, lower-cased}), never by its display name, so a project a Beacon user creates with the same name never gets the tools (see Host documentation). The tools appear once the host has run SyncBeaconHostAsync and the project exists.

Mark a controller action:

[HttpPost]
[Authorize] // required: [Permission] is a filter, not authorization metadata
[Permission(AdminPermission.ViewLoans)] // the host's own filter: runs in the action pipeline (needs a dispatch middleware, below)
[BeaconTool("customer_loans", Description = "Loans of one customer, newest first.", ReadOnly = true)]
public IActionResult CustomerLoans([DataSourceRequest] DataSourceRequest request, int customerId)
{
// ...
return Json(loans.ToDataSourceResult(request));
}

Or a minimal API endpoint:

app.MapGet("/api/loans/{id:int}", (int id, LoanService loans) => loans.Find(id))
.RequireAuthorization("ViewLoans")
.WithBeaconTool("loan_by_id", "One loan by id.", readOnly: true);

Or a handler class whose framework copies class attributes into its endpoint’s metadata, such as a Moberg Warp handler with a [WarpHttp*] route:

[Authorize]
[BeaconTool("my_assets", Description = "The signed-in user's assets, newest first.", ReadOnly = true)]
[WarpHttpGet("/api/my/assets")]
public sealed class ListMyAssetsQueryHandler(AppDbContext db) : IRequestHandler<ListMyAssetsQuery, IReadOnlyList<AssetListItem>>
{
// ...
}

On an MVC controller class [BeaconTool] is refused at startup: it would mark every action under one name, so it goes on the action.

The tool is exposed as api_customer_loans (or api_loan_by_id, api_my_assets).

The endpoint must carry authorization metadata ([Authorize] on the action or controller, a policy, or [AllowAnonymous]), or the host must have a fallback authorization policy; otherwise startup refuses it. A permission check implemented as a filter (like the [Permission] filter above) is not metadata. A host that has neither [Authorize] on its controllers nor a fallback policy puts [Authorize] on every exposed action, next to [BeaconTool] and its own permission filter, as above.

An exposed endpoint whose only requirement is an authenticated user ([Authorize] or RequireAuthorization() with no policy or roles, under the default policy, or a fallback policy that only requires authentication) is logged as a startup Warning naming it: every mapped MCP caller of the project can call it. It is still exposed, since a permission filter like [Permission] may check the rest; give it a policy or roles if nothing does. A tool that binds an argument from the caller (Arguments the caller fills) is the self-service pattern and is not warned.

Beacon authorizes the dispatched call against the host principal it built for the MCP caller; it never runs an authentication handler. A policy that names authentication schemes ([Authorize(AuthenticationSchemes = "StepUp")], new AuthorizationPolicyBuilder("PartnerApi"), a default or fallback policy with schemes) would therefore be evaluated as if that scheme had authenticated the caller: a step-up or MFA scheme would be bypassed. So an exposed endpoint whose combined policy names any scheme fails startup, naming the endpoint and the schemes, unless the host lists each scheme in TrustedAuthenticationSchemes:

.AddHostEndpointTools(o =>
{
o.ProjectName = "Back Office";
o.TrustedAuthenticationSchemes = ["Cookies"]; // only if the MCP caller is an acceptable stand-in for it
});

List a scheme only when an authorized MCP caller really should count as authenticated by it. Never list a step-up, MFA or partner scheme.

  • Only endpoints carrying [BeaconTool] / .WithBeaconTool(...). Everything else is invisible.

  • The name must match ^[a-z][a-z0-9_]{2,47}$ and be unique. ReadOnly must be declared: a missing flag fails startup with a message naming the endpoint, and so does ReadOnly = false unless AllowWriteTools is on (see Write tools). So do bad or duplicate names, Destructive = true on a read-only tool, file-upload parameters, [AsParameters], an endpoint with no authorization at all, and an endpoint whose policy names an authentication scheme that is not in TrustedAuthenticationSchemes (see above).

  • ReadOnly = true on a verb other than GET or HEAD (GET is used when the endpoint takes several verbs) logs a startup Warning naming the endpoint. With AllowReadOnlyPost on (the default) it is still exposed; with it off it is not. A read-only endpoint on PUT, PATCH or DELETE most likely changes data: declare it a write tool.

  • Each tool name has one owner, checked once the pipeline is built. A name the saved-query tools (q_<name>, search_saved_queries, run_saved_query, while saved-query tools are served) and the endpoint tools (the api_<name> tools discovered, or search_api / call_api in catalog mode) both claim fails startup, naming it. An attribute tool the host adds with such a name only logs a warning: calls to that name reach the attribute tool. In tools/list the saved-query tools come before the endpoint tools (AddBeaconMcp() first, then AddHostEndpointTools).

  • Endpoints are discovered when the host’s pipeline is built (they do not exist at DI time), from the EndpointDataSource. Parameters come from ApiExplorer when it describes the endpoint ([ApiController] controllers, or AddEndpointsApiExplorer() for minimal APIs), otherwise from the action’s parameters and binding attributes. Swagger / Swashbuckle is never needed.

  • Description: the attribute’s Description, else the action’s XML doc <summary> (enable GenerateDocumentationFile; <param> docs describe the arguments), else the endpoint’s summary/description metadata, else a generic “Calls POST /route” line. Every description ends with a note naming the method and route and saying the tool runs with the caller’s host permissions (a write tool’s also says it changes data and is to be called only when the user asked). When the endpoints run with less than the caller’s own permissions, or you word it otherwise, set DescriptionNote: it gets each tool’s name, method, route and ReadOnly flag and returns the note, null or an empty string ends the description at the summary, and tool.DefaultNote is Beacon’s own text:

    o.DescriptionNote = tool => tool.ReadOnly
    ? $"Self-service endpoint {tool.HttpMethod} {tool.Route}: it sees only your own records."
    : tool.DefaultNote;
Bound fromTool argument
Routeone argument per route value, required unless optional in the template; URL-encoded into the path
Query / form, simple typesone argument per value; collections as arrays (sent as repeated keys)
Query / form, a model ([FromForm] LoanFilter filter, or an unannotated model on a plain MVC controller)flattened: one argument per property (CustomerId, Period.From, up to 3 levels); collections of objects are skipped
[FromBody]one argument named after the parameter, its schema from System.Text.Json’s JsonSchemaExporter
[FromHeader]one argument named after the header. Authorization, Cookie, Host and content headers are refused
A custom model binder (Kendo’s [DataSourceRequest])one object argument whose members are sent as raw fields: { "page": 1, "pageSize": 20, "sort": "CreatedDate-desc" }
CancellationToken, HttpContext, [FromServices], DI-injected minimal API parametersnot arguments

Unknown and missing required arguments are rejected before anything is dispatched.

An agent does not know the person’s id in your application, so an endpoint that takes it (/api/my/assets?userId=…, a body with RequestingUserId) would otherwise need “leave it out, or pass 0, to mean me” logic. Bind such an argument to the caller instead:

builder.Services.AddBeaconMcp()
.AddHostEndpointTools(x =>
{
x.ProjectName = "Shop";
x.BindFromCaller("userId", "shop_user_id"); // a claim of the host principal the call runs as
x.BindFromCaller("requestingUserId", "shop_user_id");
});

On every tool, a route, query, header or form value with that name (compared ignoring case), or a top-level property of its JSON body, is left out of the input schema and of its required list, and the call carries the caller’s value — a body property written as the type its schema declares. An agent that sends a bound name anyway — as an argument, as a raw field of a custom-binder model, or as a body property in any casing — is refused, on every tool, since a custom binder or a dictionary could still read it. A principal without a value refuses the call, and a bound name that no tool takes is warned at startup (most likely a typo). A Func<ClaimsPrincipal, string?> overload reads the value any other way. Your endpoint still checks ownership as it does for the browser.

Up to NamedToolLimit tools, each is its own MCP tool. Above it, agents get two tools instead, so a large host does not flood the tool list: search_api (keywords → matching tools with their input schemas) and call_api (a name plus arguments). Read-only tools carry readOnlyHint = true, destructiveHint = false, idempotentHint = true, openWorldHint = false; write tools carry the hints in Write tools. search_api is always read-only. call_api is read-only only while every tool it can reach for this caller is; once it can reach a write tool it carries readOnlyHint = false (and destructiveHint = true if any of them is destructive), so clients confirm every call_api call. Each search_api result carries read_only (and, for a write tool, destructive and idempotent). The eight built-in tools are unaffected.

A host can let agents change data through its own endpoints: an HR app letting a user book or withdraw their own leave from Claude Desktop. This is off unless the host opts in, and each endpoint still declares itself:

.AddHostEndpointTools(o =>
{
o.ProjectName = "Employee Portal";
o.AllowWriteTools = true; // without it, ReadOnly = false fails startup
});
[HttpPost("leave")]
[Authorize(Policy = "Employee")]
[BeaconTool("book_leave", Description = "Book leave for yourself.", ReadOnly = false)]
public IActionResult BookLeave([FromBody] BookLeaveRequest request) { ... }
[HttpDelete("leave/{id:int}")]
[Authorize(Policy = "Employee")]
[BeaconTool("withdraw_leave", Description = "Withdraw one of your leave requests.",
ReadOnly = false, Destructive = true, Idempotent = true)]
public IActionResult WithdrawLeave(int id) { ... }
// Minimal API: .WithBeaconTool("withdraw_leave", "...", readOnly: false, destructive: true, idempotent: true)
  • Opt-in. AllowWriteTools (default false) is the host-level switch. ReadOnly must still be declared on every endpoint; Beacon never infers it from the verb, in either direction.
  • Annotations. A write tool carries readOnlyHint = false, destructiveHint = Destructive (default false), idempotentHint = Idempotent (default false) and openWorldHint = false. Declare Destructive = true for an endpoint that deletes or irreversibly overwrites data. Its description says it changes data and should be called only when the user asked for the change.
  • Confirmation is the client’s. readOnlyHint = false is what makes MCP clients (Claude Desktop, Claude Code) ask the user before running the tool. Beacon does not add its own confirmation step: MCP elicitation is not used for write tools, so a client that auto-approves tools runs them without asking.
  • User callers only. A write tool is listed to, and runs for, a mapped Entra user caller only. A system caller (an app-only token or service user) never sees write tools (not in the tool list, not in search_api) and a call to one is refused with Forbidden: api_<name> changes data in the host application, and write tools run only for a signed-in user; a system caller cannot call them., whatever HostClaims it holds. A system identity acts with standing permissions and has no person who asked for the change and no client to confirm it. API-key callers cannot call any endpoint tool.
  • The endpoint is the authority. Beacon checks the caller, the project and the endpoint’s authorization policy, exactly as for reads, and adds nothing else. The endpoint must do its own input validation, business rules and record-level authorization (“only your own leave”) against the host principal your IMcpHostPrincipalFactory builds: treat a tool call like any other request from that user. Antiforgery passes the same way as for reads (see Antiforgery), so it is not a defence here.
  • Audit. Write calls are audited under the tool name api_<name>:write (see Audit and privacy).

IBeaconToolMetadata.Destructive and Idempotent are default interface members: a host’s own implementation of the interface keeps compiling and behaves as false for both.

Endpoint tools need a mapped MCP caller, an Entra user or system identity from Entra ID callers, configured for the tools’ project and allowed to read it by its project access: a user caller reaches a restricted project only while its Beacon user is a member (see What a caller reaches). API-key callers and callers outside the project neither see nor can call them (fail closed); neither can anyone while the project does not exist.

IMcpHostPrincipalFactory builds the host principal from the caller:

  • System caller → identity BeaconMcpSystem with the caller’s configured HostClaims plus its name.
  • User caller → identity BeaconMcpUser with name, email, preferred username and object id copied from the MCP principal, plus any HostClaims the caller mapper assigned (none with the configured mapper). The default gives a user no host permissions, and deliberately does not copy Entra roles or groups: a host role that happens to share a name (Admin) would otherwise be granted implicitly. Map users to host permissions in your own factory. The name, email and preferred username are display data: Entra lets a tenant change them, and a guest’s email comes from its home tenant, so never map a user to host permissions by one of them. Use the object id (oid) together with the tenant id (tid).
  • null → the call is refused.

A host whose permissions live in its own user store registers its own factory. The example below maps the Entra user to the host’s admin user and its AdminPermission set. The host’s permission claim is HostClaimTypes.Permission (http://schemas.example.com/claims/permission) and its value is the permission’s integer value (AdminPermission.ViewLoans.ToValueString() → "1007"), which is what the host’s Identity.HasPermission compares against. The admin lookup is a placeholder: use whatever service the host already has for loading an active admin and their permissions (it stores the Entra tenant id and object id on the admin record):

builder.Services.AddSingleton<IMcpHostPrincipalFactory, HostPrincipalFactory>();
internal sealed class HostPrincipalFactory(IServiceScopeFactory scopes) : IMcpHostPrincipalFactory
{
public async Task<ClaimsPrincipal?> CreateAsync(McpCaller caller, ClaimsPrincipal mcpPrincipal, CancellationToken ct)
{
if (caller.Kind == McpCallerKind.System)
{
// HostClaims come from Beacon:Mcp:Callers:Systems[*]:HostClaims: keep the set minimal.
return new ClaimsPrincipal(new ClaimsIdentity(caller.HostClaims, "BeaconMcpSystem"));
}
// Key the lookup on the immutable object id (and the tenant id), never on email, UPN or preferred username.
var objectId = mcpPrincipal.FindFirst("oid")?.Value;
var tenantId = mcpPrincipal.FindFirst("tid")?.Value;
await using var scope = scopes.CreateAsyncScope();
// PLACEHOLDER: your own lookup of an active admin user and their AdminPermission set.
var admins = scope.ServiceProvider.GetRequiredService<IYourAdminLookup>();
var admin = objectId == null || tenantId == null
? null
: await admins.FindActiveAdminByEntraIdAsync(tenantId, objectId, ct);
if (admin == null)
{
return null; // not an admin: refused
}
var claims = admin.Permissions // IEnumerable<AdminPermission>
.Select(x => new Claim(HostClaimTypes.Permission, x.ToValueString()))
.Append(new Claim(ClaimTypes.Name, admin.UserName));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "BeaconMcpUser"));
}
}

System identity host claims, in configuration (the value is the integer AdminPermission value; 1007 is ViewLoans):

"Beacon": { "Mcp": { "Callers": { "Systems": [ {
"Name": "routine-runner",
"ClientId": "<routine-runner-app-client-id>",
"ProjectIds": [ 3 ],
"HostClaims": [ { "Type": "http://schemas.example.com/claims/permission", "Value": "1007" } ]
} ] } } }
  1. The caller, project and arguments are checked; the host principal is built. The call then takes a slot: at most MaxConcurrentCallsPerCaller calls per MCP caller and MaxConcurrentCalls in total are in flight. A call past either limit waits up to QueueWait (5 s) for a slot, then is refused (“Too many host endpoint calls are in progress; retry shortly.”) and audited. The slot is held until the endpoint really ends, so an endpoint that ignores its RequestAborted token keeps it after a timeout.
  2. A new DI scope and a new HttpContext are created. The request carries the method, the route filled from the endpoint’s route pattern, the query string, the declared headers only, and a JSON or application/x-www-form-urlencoded body. Nothing of the MCP request is forwarded: no Authorization header, no cookies, no connection info. It also sends an Accept header preferring JSON (application/json, text/plain;q=0.9, */*;q=0.1) and X-Requested-With: XMLHttpRequest.
  3. The endpoint and route values are set as routing would set them, and checked as routing would check them first: a value that fails the route’s constraints ({id:int}, {slug:regex(...)}, a custom IRouteConstraint, evaluated without an HttpContext), a / in a single-segment value, or a . or .. segment in any route value is refused as invalid arguments before anything runs.
  4. Authorization runs before the endpoint, exactly as the authorization middleware would, against the host principal: the endpoint’s [Authorize] / policy / requirement metadata combined through the host’s IAuthorizationPolicyProvider, evaluated with IPolicyEvaluator. [AllowAnonymous] is honoured. No authentication handler runs, which is why a policy naming an untrusted scheme is refused at startup (see Authentication schemes). An endpoint without authorization metadata falls back to the host’s FallbackPolicy; with no fallback policy either it is refused at startup (and at dispatch). A denial is the tool error Forbidden. with no detail. An endpoint marked [AllowAnonymous] is exposable and runs without an authorization check: the host itself declared it public, so exposing it to an authorized MCP caller grants nothing new.
  5. The registered IHostEndpointDispatchMiddleware chain runs, in registration order, around the endpoint (see below).
  6. The endpoint’s RequestDelegate runs; for MVC that is the whole action pipeline: model binding, validation, authorization/action/exception filters, the result.
  7. The response is captured into a buffer capped at MaxResponseBytes; the request is aborted after RequestTimeout or when the MCP call is cancelled. Aborting signals RequestAborted; an endpoint that ignores it keeps running, with its DI scope and its call slot, until it ends, so slow endpoints that do not flow their cancellation token are bounded by the concurrency limits, not by the timeout.

Host code that reads IHttpContextAccessor sees the dispatched request. The dispatch runs in its own execution context, so the MCP request’s context (and the audit identity) is untouched.

Host middleware and ambient state: dispatch middleware

Section titled “Host middleware and ambient state: dispatch middleware”

Host middleware does not run on dispatch: only the endpoint’s own pipeline. When the endpoint depends on state a middleware sets, register an IHostEndpointDispatchMiddleware (namespace Beacon.Core.HostEndpoints, shipped in Moberg.Beacon.MCP):

Task InvokeAsync(HttpContext syntheticContext, McpCaller caller, Func<Task> next, CancellationToken cancellationToken);

Registered middlewares are chained in registration order, with the endpoint’s request delegate as the innermost next. The chain starts after User, RequestServices, the endpoint and route values are set and after Beacon’s authorization check. As in ASP.NET Core middleware, ambient state set before await next() (Thread.CurrentPrincipal, an AsyncLocal) is visible to the endpoint and its filters; async work before it is fine. A middleware that does not call next refuses the call; one that throws fails it. Both return a generic tool error and are audited. The dispatch runs in its own execution context, so the MCP request’s own ambient principal is never changed.

Take a host whose [Permission(AdminPermission.X)] is an IAsyncAuthorizationFilter that checks Identity.Current.HasPermission(...), where Identity.Current reads Thread.CurrentPrincipal, which the host’s own middleware sets with Identity.Set(identity): it takes a ClaimsIdentity, not a principal. Without a dispatch middleware every tool call would be denied. The factory above maps the Entra user to admin permissions; the middleware makes that principal ambient:

builder.Services.AddSingleton<IHostEndpointDispatchMiddleware, HostIdentityMiddleware>();
internal sealed class HostIdentityMiddleware : IHostEndpointDispatchMiddleware
{
public async Task InvokeAsync(HttpContext ctx, McpCaller caller, Func<Task> next, CancellationToken cancellationToken)
{
// Identity.Set takes a ClaimsIdentity: the identity of the principal IMcpHostPrincipalFactory built.
Identity.Set((ClaimsIdentity)ctx.User.Identity!);
await next();
}
}

For a call with a body (a write tool, or a read-only tool over POST), Beacon mints a real antiforgery token pair for the host principal with the host’s own IAntiforgery and puts it on the dispatched request (the cookie token as a cookie, the request token in the configured header, or the form field when the header is disabled). The host’s own validation ([ValidateAntiForgeryToken], a global AutoValidateAntiforgeryTokenAttribute, minimal API form endpoints) therefore runs and passes. For minimal API endpoints that require validation, Beacon validates the request the way UseAntiforgery() does and records the result. Nothing is exempted and real browser requests are validated exactly as before: the tokens are minted server-side and never leave the process (CSRF cannot apply to a request that never came from a browser). A host with a custom anti-CSRF scheme (a bespoke header check, an IAntiforgeryAdditionalDataProvider that inspects the request) must make sure it accepts these requests.

  • 2xx JSON → the JSON as text plus structuredContent. MCP structured content must be an object, so a top-level JSON array is wrapped as { "items": [...] } (the text content is the unwrapped JSON).
  • 2xx text → text. An empty body → a “no content” line.
  • Any other content type → a tool error.
  • Non-2xx → a tool error with the status and a short body excerpt.
  • An exception in the endpoint → your IExceptionHandlers run, in registration order, as UseExceptionHandler() runs them for a browser request (host middleware does not run for a dispatched call). The response the first one writes — the 403, 404 or 400 problem your application maps a deliberate refusal to — is the tool error, its body passed to the agent as the handler wrote it. A handler that takes the exception without setting a status answers 500. An exception no handler takes is a generic tool error (the message is not returned or logged). Set UseHostExceptionHandlers = false to always answer the generic error.
  • Over MaxResponseBytes → a tool error; narrow the request.

Every call (success, forbidden, refused, invalid arguments, timeout, error) writes an MCP audit row with the tool name api_<name> (or search_api), the project, the duration, the caller kind and hash, and the outcome. A call to a write tool is recorded as api_<name>:write (whether it was called by name or through call_api, and also when it was refused) so writes can be filtered in the audit log (tool=api_book_leave:write) without a schema change. Metrics still bucket it under api_*. The arguments are stored only as the project’s content-retention settings allow: under the retention lock they are reduced to their size. Argument values and response bodies are never written to logs.

get_context format=agents_md lists the project’s endpoint tools in the brief (name and first description line): the ones the caller’s tool list offers, as the overview does. Write tools are marked (writes).

  • Write tools are off unless AllowWriteTools is on, run for user callers only, and rely on the client’s tool approval for confirmation (no Beacon-side approval or elicitation step).
  • Host middleware does not run: only the endpoint’s own pipeline. Recreate what the endpoint needs from it (an ambient principal, a tenant, a culture, an admin user loaded into HttpContext.Items) in an IHostEndpointDispatchMiddleware. A global MVC AuthorizeFilter whose policy names authentication schemes re-authenticates by scheme, finds no cookie and denies; express such rules as endpoint metadata ([Authorize] / policy attributes) instead.
  • Endpoint metadata that names an authentication scheme fails startup unless the scheme is in TrustedAuthenticationSchemes.
  • File uploads and downloads are not supported; collections of objects in form models are not flattened.
  • The request host is localhost and there is no remote IP address; do not rely on either for authorization.
  • Parameter names from custom binders are not known, so their fields are free-form.