Host Endpoint Tools
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).
Quick start
Section titled “Quick start”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.
Authentication schemes
Section titled “Authentication schemes”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.
What gets exposed
Section titled “What gets exposed”-
Only endpoints carrying
[BeaconTool]/.WithBeaconTool(...). Everything else is invisible. -
The name must match
^[a-z][a-z0-9_]{2,47}$and be unique.ReadOnlymust be declared: a missing flag fails startup with a message naming the endpoint, and so doesReadOnly = falseunlessAllowWriteToolsis on (see Write tools). So do bad or duplicate names,Destructive = trueon 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 inTrustedAuthenticationSchemes(see above). -
ReadOnly = trueon a verb other than GET or HEAD (GET is used when the endpoint takes several verbs) logs a startup Warning naming the endpoint. WithAllowReadOnlyPoston (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 (theapi_<name>tools discovered, orsearch_api/call_apiin 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. Intools/listthe saved-query tools come before the endpoint tools (AddBeaconMcp()first, thenAddHostEndpointTools). -
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, orAddEndpointsApiExplorer()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>(enableGenerateDocumentationFile;<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, setDescriptionNote: it gets each tool’s name, method, route andReadOnlyflag and returns the note,nullor an empty string ends the description at the summary, andtool.DefaultNoteis Beacon’s own text:o.DescriptionNote = tool => tool.ReadOnly? $"Self-service endpoint {tool.HttpMethod} {tool.Route}: it sees only your own records.": tool.DefaultNote;
Arguments
Section titled “Arguments”| Bound from | Tool argument |
|---|---|
| Route | one argument per route value, required unless optional in the template; URL-encoded into the path |
| Query / form, simple types | one 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 parameters | not arguments |
Unknown and missing required arguments are rejected before anything is dispatched.
Arguments the caller fills
Section titled “Arguments the caller fills”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.
Named tools or a catalog
Section titled “Named tools or a catalog”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.
Write tools
Section titled “Write tools”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(defaultfalse) is the host-level switch.ReadOnlymust 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(defaultfalse),idempotentHint = Idempotent(defaultfalse) andopenWorldHint = false. DeclareDestructive = truefor 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 = falseis 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 withForbidden: api_<name> changes data in the host application, and write tools run only for a signed-in user; a system caller cannot call them., whateverHostClaimsit 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
IMcpHostPrincipalFactorybuilds: 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.
Who the call runs as
Section titled “Who the call runs as”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
BeaconMcpSystemwith the caller’s configuredHostClaimsplus its name. - User caller → identity
BeaconMcpUserwith name, email, preferred username and object id copied from the MCP principal, plus anyHostClaimsthe caller mapper assigned (none with the configured mapper). The default gives a user no host permissions, and deliberately does not copy Entrarolesorgroups: 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" } ]} ] } } }How a call is dispatched
Section titled “How a call is dispatched”- The caller, project and arguments are checked; the host principal is built.
The call then takes a slot: at most
MaxConcurrentCallsPerCallercalls per MCP caller andMaxConcurrentCallsin total are in flight. A call past either limit waits up toQueueWait(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 itsRequestAbortedtoken keeps it after a timeout. - A new DI scope and a new
HttpContextare 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 orapplication/x-www-form-urlencodedbody. Nothing of the MCP request is forwarded: noAuthorizationheader, no cookies, no connection info. It also sends anAcceptheader preferring JSON (application/json, text/plain;q=0.9, */*;q=0.1) andX-Requested-With: XMLHttpRequest. - 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 customIRouteConstraint, evaluated without anHttpContext), a/in a single-segment value, or a.or..segment in any route value is refused as invalid arguments before anything runs. - 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’sIAuthorizationPolicyProvider, evaluated withIPolicyEvaluator.[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’sFallbackPolicy; with no fallback policy either it is refused at startup (and at dispatch). A denial is the tool errorForbidden.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. - The registered
IHostEndpointDispatchMiddlewarechain runs, in registration order, around the endpoint (see below). - The endpoint’s
RequestDelegateruns; for MVC that is the whole action pipeline: model binding, validation, authorization/action/exception filters, the result. - The response is captured into a buffer capped at
MaxResponseBytes; the request is aborted afterRequestTimeoutor when the MCP call is cancelled. Aborting signalsRequestAborted; 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(); }}Antiforgery
Section titled “Antiforgery”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.
Responses
Section titled “Responses”- 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, asUseExceptionHandler()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). SetUseHostExceptionHandlers = falseto always answer the generic error. - Over
MaxResponseBytes→ a tool error; narrow the request.
Audit and privacy
Section titled “Audit and privacy”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).
Limits
Section titled “Limits”- Write tools are off unless
AllowWriteToolsis 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 anIHostEndpointDispatchMiddleware. A global MVCAuthorizeFilterwhose 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
localhostand 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.