# Beacon: MCP gateway guide > Everything needed to give AI clients governed access to your own ASP.NET Core application with Beacon: your users sign in with Entra, your endpoints become tools that run as them, your docs become grounding, no data sources. Start with the first page: it is the whole recipe, step by step; the pages after it give every option of the parts it uses. Published at https://moberghr.github.io/beacon/. Every page is also available as Markdown at .md. --- # MCP gateway for your application > Give AI clients (Claude, Claude Desktop, Cowork, Claude Code) governed access to your own ASP.NET Core application: your users sign in with Entra, your endpoints become tools that run as them, your docs become grounding. One package, about thirty lines, no data sources. Source: https://moberghr.github.io/beacon/mcp/gateway/ This page is the whole recipe for one use case: **an MCP server for your own application.** People connect Claude to `https://your-app/beacon/mcp`, sign in with Microsoft Entra, and Claude calls a set of your existing endpoints **as that person**, through your own authorization, with every call audited. Beacon's data product (data sources, SQL, saved queries, subscriptions) is not part of it. If that is what you want, follow [Integrate Beacon](https://moberghr.github.io/beacon/integrate/) instead. :::tip[Using Claude Code or another coding agent?] With Claude Code, install Beacon's plugin: `/plugin marketplace add moberghr/beacon`, then `/plugin install beacon@moberg-beacon`. Its skills (`beacon-gateway`, `beacon-tool`, `beacon-entra`) follow this guide. With any agent, point it at this guide in one file: ```text wrap Add an MCP gateway to this ASP.NET Core app with Beacon. Read https://moberghr.github.io/beacon/llms-gateway.txt first and follow it step by step. Ask me before every decision it marks as one. ``` ::: ## Decisions to make first An agent should ask these, not guess. | Decision | Options | Default | |---|---|---| | Who may connect | Every active user of your application, or a subset | Every active user your users table knows | | What a tool call may do | Self-service only (the person's own records), or what the person may do in the browser | **Self-service only**: an agent reads other people's free text and sends what it reads to a model | | Which endpoints become tools | Any endpoint with authorization metadata | Reads of the person's own data, plus writes the person would do themselves | | Admin UI for the audit and settings | On (behind one of your policies) or off | On | | Beacon's database | PostgreSQL (with pgvector available) or SQL Server; the app's database in schema `beacon`, or a separate one | The app's own database, schema `beacon` | ## 1. Packages and secrets ```bash dotnet add package Moberg.Beacon.Gateway dotnet add package Moberg.Beacon.Core.PostgreSql # or Moberg.Beacon.Core.SqlServer dotnet add package Moberg.Beacon.Testing # in the test project ``` `Beacon:EncryptionKey` is required: base64 of 32 random bytes (`openssl rand -base64 32`), from user secrets or your secret store, never a committed file. It keys the caller hashes in the audit. Set `Beacon:PublicBaseUrl` to the URL clients use (`https://your-app`): the discovery documents and challenges advertise `/beacon/mcp`. ## 2. Who a token is: `IMcpHostUserResolver` Beacon validates the Entra token (tenant, audience, delegated, scope, client). Your application decides who the person is and what a tool call carries: ```csharp internal sealed class AppMcpUsers(AppDbContext db) : IMcpHostUserResolver { public async Task FindAsync(McpTokenIdentity token, CancellationToken ct) { var users = await db.Users .Where(x => x.ObjectId == token.ObjectId) .Where(x => x.IsActive) .Select(x => new { x.Id, x.DisplayName }) .Take(2) .ToListAsync(ct); // Exactly one, or nobody: an object id two active users carry does not say which of them this is. if (users.Count != 1) { return null; } return new McpHostUser( users[0].Id.ToString(), users[0].DisplayName, [new Claim("app_user_id", users[0].Id.ToString())]); // the claims your own sign-in adds } } ``` - Look users up by `ObjectId` (`oid`), never by `sub`: the token was issued to another client, so its `sub` differs. - Never create or link a user here. Someone who has never signed in to your application is refused. - It runs on every tool call, so deactivating a person in your application cuts MCP off at their next call. ## 3. Program.cs ```csharp // Register any IClaimsTransformation of your own BEFORE the gateway (it runs yours first). builder.Services.AddBeaconMcpGateway(builder.Configuration, x => { x.ProjectName = "app"; // Beacon's project for your tools and docs x.Database(db => db.UsePostgreSql(sp => sp.GetRequiredService())); // or db.UsePostgreSql() with ConnectionStrings:BeaconContext x.Entra(builder.Configuration.GetSection("AzureAd")) // TenantId, ClientId (Microsoft.Identity.Web's section) .AllowClients(builder.Configuration.GetSection("McpAccess:AllowedClientIds")); // optional: only Claude's client x.Users(); x.Tools(y => { y.AllowWriteTools = true; // only if you mark write tools y.BindFromCaller("userId", "app_user_id"); // the person's own id, never the agent's }); x.Docs(y => y.FromEmbeddedResources(typeof(Program).Assembly, "app-docs/")); // optional grounding x.AdminUi(y => // optional { y.HostPolicy = "beacon.admin"; // one of YOUR authorization policies y.HostUserId = z => z.FindFirst("app_user_id")?.Value; y.SignInPath = "/Account/SignIn"; }); }); var app = builder.Build(); app.UseBeaconMcpGateway(); // before UseAuthentication // ... UseStaticFiles (the admin UI's assets), UseAuthentication, UseAuthorization ... app.MapBeaconMcpGateway(); // /beacon/mcp, discovery documents, the admin UI ``` `UseBeaconMcpGateway()` also adds Beacon's security headers and its Host and Origin check on `/beacon` (an OpenID Connect callback always passes the check), as `UseBeaconAuthentication()` does for a full host. See [Security headers and the origin check](https://moberghr.github.io/beacon/integrate/host-setup/#security-headers-and-the-origin-check). What you get, and what you do not have to write: - **Callers:** delegated tokens of your tenant and registration with the `mcp.access` scope (`RequireScope` changes it), whose person your resolver knows. App-only tokens, ID tokens, other tenants and other scopes are refused. Entra roles and groups are never read. - **Tools:** your marked endpoints as `api_`, plus `get_context`, `search` and `get_documentation` for your docs. No SQL tools, no saved-query tools, no data sources. Each tool's description ends with a note that it runs with the caller's permissions in your application; when your endpoints run with less (self-service only, say), word it yourself with `y.DescriptionNote` in `x.Tools(...)` (see [Host endpoint tools](https://moberghr.github.io/beacon/mcp/host-endpoint-tools/)). - **Locks:** read-only and no content retention are pinned in code (`RetainToolContent = true` keeps call content). - **Your session never counts on `/beacon/mcp`,** and a bearer token never counts on your routes. The MCP route answers 401/403 itself, never your sign-in redirect: you edit no cookie events. - **Discovery:** `/.well-known/oauth-protected-resource` publishes your tenant's v2 issuer and `api:///mcp.access` plus `offline_access` (so clients refresh instead of re-prompting hourly). It names the server `Beacon MCP` (the server card and `initialize`: `Beacon`) and links Beacon's docs unless you set `Beacon:Mcp:ProtectedResource:ResourceName` and `ResourceDocumentation` (an https URL) to your application's. The server card describes the gateway (your endpoints and documents); `x.ServerDescription` words it yourself. - **The host project** is synced the first time it is needed; the `beacon-mcp-gateway` health check reports Degraded until then. No hosted service, no startup step. - **Admin UI** at `/beacon`: the MCP audit, the MCP settings, the learning views and your project, for holders of `HostPolicy`, signed in with your own sign-in. Every other Beacon endpoint answers 404. ## 4. Migrations Beacon's tables live in schema `beacon`. Apply them from your migration step, not from every web replica: ```csharp await Beacon.Core.PostgreSql.BeaconPostgreSqlMigrator.MigrateAsync(dataSource); // or (connectionString) await Beacon.Core.SqlServer.BeaconSqlServerMigrator.MigrateAsync(connectionString); ``` On PostgreSQL one migration creates the `vector` extension: allow it (Azure Database for PostgreSQL: add `VECTOR` to `azure.extensions`, which replaces the whole list). Sharing your `NpgsqlDataSource` needs connection reset on and multiplexing off; Beacon refuses one that does either. ## 5. Mark endpoints as tools On a minimal API endpoint (`using Beacon.MCP.HostEndpoints;` for `WithBeaconTool`), a controller action, or a handler class whose framework copies class attributes into the endpoint (a Moberg Warp handler; `using Beacon.Core.HostEndpoints;` for `[BeaconTool]`): ```csharp app.MapGet("/api/my/orders", (int userId, OrderService orders) => orders.Of(userId)) .RequireAuthorization() .WithBeaconTool("my_orders", "The signed-in user's orders, newest first: id, placedAt (UTC), status, total.", readOnly: true); [Authorize] [BeaconTool("request_leave", Description = "...", ReadOnly = false, Destructive = false, Idempotent = false)] [WarpHttpPost("/api/time-off/requests")] public sealed class RequestLeaveCommandHandler(...) { ... } ``` The rules an agent follows for every tool: 1. **Self-service.** A tool reads or changes the person's own records. Never mark an admin endpoint or one gated by a permission a person might hold over others. 2. **Data minimisation.** If an endpoint returns other people's data to some callers (a manager's view, a list by sequential id), do not mark it: add a `/api/my/...` sibling that filters on the caller and takes no subject. 3. **Authorization metadata.** The endpoint needs `[Authorize]` or a policy; startup refuses one without (unless you have a fallback policy). The call runs through the endpoint's own authorization as the user your resolver returned. 4. **The person's id is bound, not asked.** `BindFromCaller("userId", "app_user_id")` takes `userId` out of the tool's schema and fills it from the caller; an agent sending it is refused. A name no tool takes is warned at startup. 5. **Descriptions are for the agent:** what comes back, field by field; what each argument means; every refusal; for a write tool, "call it only when the person asked for this change". Beacon appends the route and the read/write flag. 6. **`ReadOnly` is always declared.** A write tool (`ReadOnly = false`) needs `AllowWriteTools`, is offered to signed-in people only, makes clients ask before each call, and is audited as `api_:write`. ### Recognising an agent call A tool call's principal has the authentication type `BeaconMcpUser`. Use it to hold agents to less than the browser — self-service only, whatever the person may do there: ```csharp if (httpContext.User.IsBeaconMcpCall()) { // no permissions, no manager grants: the person's own records only } ``` ### Refusals reach the agent as your problem details Tool calls run your `IExceptionHandler`s, as `UseExceptionHandler()` does for the browser: a refusal your application maps to a 403/404/400 problem reaches the agent as that status and body. An exception no handler takes is a generic error. ## 6. Entra app registration On **your application's existing registration** (the `AzureAd:ClientId` one): - [ ] Manifest: `api.requestedAccessTokenVersion = 2`. First — Entra only accepts an HTTPS Application ID URI on a v2 app. - [ ] Expose an API: keep `api://` and add the exact MCP URL `https:///beacon/mcp` to `identifierUris`. Claude sends it as the `resource`; a mismatch fails with **`AADSTS9010010`**. The domain must be a verified domain of the tenant (`*.azurecontainerapps.io` and similar cannot be). - [ ] Expose an API: add the delegated scope **`mcp.access`**. **A client registration for Claude** (Entra has no dynamic client registration): - [ ] New app registration, Web platform, redirect URI `https://claude.ai/api/mcp/auth_callback`. - [ ] A client secret (record its expiry: the connector stops when it lapses). - [ ] API permissions: your API's `mcp.access` (delegated) and `offline_access`; grant admin consent. - [ ] Give the client id and secret to whoever adds the connector in Claude; optionally list the client id in `McpAccess:AllowedClientIds`. ## 7. Test it ```csharp var tokens = new McpTestTokens(tenantId, clientId); var users = new McpTestUsers().Add(objectId: "oid-1", id: "42", displayName: "Ada", new Claim("app_user_id", "42")); builder.Services.TrustMcpTestTokens(tokens); // after AddBeaconMcpGateway, in the test host only builder.Services.UseMcpTestUsers(users); await using var client = await McpTestClient.ConnectAsync(app.GetTestClient(), tokens.For("oid-1")); var tools = await client.ListToolsAsync(); var result = await client.CallToolAsync("api_my_orders"); ``` For trying the gateway by hand on a laptop without Entra, run a local OIDC mock (for example `ghcr.io/navikt/mock-oauth2-server`) that mints delegated tokens with your tenant id, audience, an `oid` and `scp=mcp.access`, and point `AzureAd:Instance`/`TenantId` validation at it in Development only. The client is the official MCP SDK's, so a test goes through the bearer middleware, the caller mapping, the route policy, the MCP filters and audit, and your endpoint's own authorization. Listing and calling tools needs Beacon's database (project access and the audit live there): run those tests against a real PostgreSQL or SQL Server with Beacon's schema migrated. Pin, in your own tests: exactly which endpoints are tools (and which write), that each refuses somebody else's data, and that an agent call is self-service only. ## 8. Verify a deployment `beacon-doctor` walks the sign-in path the way an MCP client does and prints PASS/WARN/FAIL (exit code 1 on a failure): ```bash dotnet tool install -g Moberg.Beacon.Doctor beacon-doctor mcp https://your-app/beacon/mcp # the challenge, discovery, Entra metadata, absent documents beacon-doctor mcp https://your-app/beacon/mcp --token "$TOKEN" # plus initialize and tools/list as that person ``` By hand: - [ ] `POST /beacon/mcp` without a token → 401 with `WWW-Authenticate: Bearer resource_metadata=...`. - [ ] `/.well-known/oauth-protected-resource` → your v2 issuer, `api:///mcp.access`, `offline_access`, and a `resource` equal, character for character, to an `identifierUris` entry. - [ ] `/health` → `beacon-mcp-gateway` Healthy. - [ ] Claude connects, lists your tools, a read answers, and a write tool asks before it runs. - [ ] After an hour the connector refreshes silently. | Symptom | Cause | |---|---| | `AADSTS9010010` | The MCP URL is not an `identifierUris` entry (exactly), or the domain is not verified. | | 401 `invalid_token` | Wrong issuer or audience: check `AzureAd:TenantId` / `ClientId`, and that the token is v2. | | 403 at `/beacon/mcp` | The token is valid but not a caller: wrong scope or client, the person unknown to your resolver, or the host project not synced yet. The log has the reason with the caller hash. | | 503 with `Retry-After` | Entra's signing keys could not be fetched. | | Re-prompted to sign in every hour | `offline_access` missing from `scopes_supported` or not consented. | | A tool error "failed with an unexpected error" | The endpoint threw and no `IExceptionHandler` took it. | | Startup: "relies on '…' being its only authentication scheme" | Name your default: `AddAuthentication("YourScheme")`. | | Startup: "Register your IClaimsTransformation before calling AddBeaconMcpGateway" | Move your registration above the gateway's. | --- # Host Endpoint Tools > Expose selected endpoints of the host application as MCP tools (read-only by default, write tools behind an explicit opt-in) invoked in-process as the caller through the endpoint's own model binding, filters and authorization policies. Source: https://moberghr.github.io/beacon/mcp/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](#write-tools)). ## Quick start ```csharp 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](https://moberghr.github.io/beacon/mcp/host-docs/#the-host-project)). The tools appear once the host has run `SyncBeaconHostAsync` and the project exists. Mark a controller action: ```csharp [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: ```csharp 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](https://moberghr.github.io/warp/) handler with a `[WarpHttp*]` route: ```csharp [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> { // ... } ``` 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](#arguments-the-caller-fills)) is the self-service pattern and is not warned. ### 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`: ```csharp .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 - 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](#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_`, `search_saved_queries`, `run_saved_query`, while saved-query tools are served) and the endpoint tools (the `api_` 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 `` (enable `GenerateDocumentationFile`; `` 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: ```csharp o.DescriptionNote = tool => tool.ReadOnly ? $"Self-service endpoint {tool.HttpMethod} {tool.Route}: it sees only your own records." : tool.DefaultNote; ``` ### 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 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: ```csharp 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` overload reads the value any other way. Your endpoint still checks ownership as it does for the browser. ### 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](#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 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: ```csharp .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_ 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](#antiforgery)), so it is not a defence here. - **Audit.** Write calls are audited under the tool name `api_:write` (see [Audit and privacy](#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 Endpoint tools need a **mapped MCP caller**, an Entra user or system identity from [Entra ID callers](https://moberghr.github.io/beacon/mcp/entra-callers/), configured for the tools' project and allowed to read it by its [project access](https://moberghr.github.io/beacon/security/authorization/#project-access): a user caller reaches a restricted project only while its Beacon user is a member (see [What a caller reaches](https://moberghr.github.io/beacon/mcp/entra-callers/#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): ```csharp builder.Services.AddSingleton(); internal sealed class HostPrincipalFactory(IServiceScopeFactory scopes) : IMcpHostPrincipalFactory { public async Task 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(); 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 .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`): ```json "Beacon": { "Mcp": { "Callers": { "Systems": [ { "Name": "routine-runner", "ClientId": "", "ProjectIds": [ 3 ], "HostClaims": [ { "Type": "http://schemas.example.com/claims/permission", "Value": "1007" } ] } ] } } } ``` ## How a call is dispatched 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](#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 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`): ```csharp Task InvokeAsync(HttpContext syntheticContext, McpCaller caller, Func 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: ```csharp builder.Services.AddSingleton(); internal sealed class HostIdentityMiddleware : IHostEndpointDispatchMiddleware { public async Task InvokeAsync(HttpContext ctx, McpCaller caller, Func next, CancellationToken cancellationToken) { // Identity.Set takes a ClaimsIdentity: the identity of the principal IMcpHostPrincipalFactory built. Identity.Set((ClaimsIdentity)ctx.User.Identity!); await next(); } } ``` ### 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 - 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 `IExceptionHandler`s 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. ## Audit and privacy Every call (success, forbidden, refused, invalid arguments, timeout, error) writes an MCP audit row with the tool name `api_` (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_: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](https://moberghr.github.io/beacon/mcp/server/) 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 - 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. --- # Expose an endpoint as an MCP tool, safely, and test it > Turn one self-service endpoint of your ASP.NET Core application into a read tool and one into a write tool, bind the person's id from the caller, and pin the behaviour with an end-to-end test. Source: https://moberghr.github.io/beacon/cookbooks/endpoint-as-tool/ | | | |---|---| | **You end up with** | A read tool (`api_my_orders`) and a write tool (`api_request_leave`) on your MCP server, the person's id filled from the caller, refusals that reach the agent, and a test that lists the tools, calls one and checks another person's data is refused | | **Time** | about 30 minutes | | **You need** | An ASP.NET Core application with an MCP gateway ([MCP gateway](https://moberghr.github.io/beacon/mcp/gateway/)) or full Beacon with `AddHostEndpointTools` ([Host endpoint tools](https://moberghr.github.io/beacon/mcp/host-endpoint-tools/)); a PostgreSQL or SQL Server database with Beacon's schema migrated for the test | | **Applies to** | Beacon 5.0 · Both (gateway: `x.Tools(...)`; full Beacon: `AddBeaconMcp().AddHostEndpointTools(...)`) | :::tip[Let your AI do it] Paste this into Claude Code (or another coding agent) in your application's repository. ```text wrap Read https://moberghr.github.io/beacon/cookbooks/endpoint-as-tool.md and follow it step by step in this repository. Ask me which endpoint to expose and whether it is self-service before marking anything. Ask me before every decision the recipe marks as one. Never mark an admin endpoint, never commit secrets, and show me the result of each "Check it works" item. ``` ::: ## Before you start | Decision | Options | Default | |---|---|---| | Which endpoint | One that reads or changes the signed-in person's own records | A `/api/my/...` read | | Read or write | `ReadOnly = true`, or `ReadOnly = false` (needs `AllowWriteTools`) | Read | | How the person's id arrives | Bound from a claim of the call's principal (`BindFromCaller`) | Bound, never asked of the agent | | How the agent is held to self-service | Only mark self-service endpoints, and check `IsBeaconMcpCall()` where one endpoint also serves the browser | Mark only self-service endpoints | ## 1. Pick the endpoint and check it against the review list Mark an endpoint only when every line holds. If one fails, add a `/api/my/...` sibling that filters on the caller and takes no subject, and mark that. - [ ] **Self-service.** It reads or changes the person's own records. It is not an admin endpoint and is not gated by a permission a person may hold over others. - [ ] **Data minimisation.** It never returns other people's data to some callers (a manager's view, a list by sequential id). An agent reads free text and sends what it reads to a model. - [ ] **Authorization metadata.** It has `[Authorize]` or a policy. Startup refuses an endpoint with none, unless the application has a fallback policy. Never `[AllowAnonymous]`. - [ ] **Bound id.** The person's id is not an argument the agent supplies. - [ ] **Description.** Written for an agent (step 3). - [ ] **`ReadOnly` declared.** Always, on every tool. Beacon never infers it from the HTTP verb. ## 2. Bind the person's id from the caller An agent does not know the person's id in your application. Bind the argument to a claim of the principal the call runs as, so it leaves the tool's schema and carries the caller's own value. In the gateway, the claim is one your `IMcpHostUserResolver` returns in `McpHostUser.Claims`: ```csharp x.Tools(y => { y.AllowWriteTools = true; // only because step 4 marks a write tool y.BindFromCaller("userId", "app_user_id"); }); ``` In full Beacon, the same call is on the options of `AddHostEndpointTools`, and the claim comes from the `IMcpHostPrincipalFactory` you register (see [Who the call runs as](https://moberghr.github.io/beacon/mcp/host-endpoint-tools/#who-the-call-runs-as)): ```csharp builder.Services.AddBeaconMcp() .AddHostEndpointTools(y => { y.ProjectName = "app"; y.AllowWriteTools = true; y.BindFromCaller("userId", "app_user_id"); }); ``` On every tool, a route, query, header or form value named `userId` (ignoring case), or a top-level property of a JSON body with that name, is removed from the schema. An agent that sends it anyway is refused with `Invalid arguments: userId is filled from the caller and cannot be sent.` A principal with no value for the claim refuses the call. A bound name that no tool takes is warned at startup, which usually means a typo. ## 3. Mark a read tool ```csharp using Beacon.MCP.HostEndpoints; app.MapGet("/api/my/orders", (int userId, OrderService orders) => orders.Of(userId)) .RequireAuthorization() .WithBeaconTool( "my_orders", "The signed-in user's own orders, newest first. Returns id, placedAt (UTC, ISO 8601), status " + "(pending, shipped, cancelled) and total (EUR). Takes no arguments. Returns an empty list when the user has no orders.", readOnly: true); ``` The name must match `^[a-z][a-z0-9_]{2,47}$` and be unique. MCP exposes it as `api_my_orders`. Write the description for the agent, not for a developer: - What comes back, field by field, with units and time zones. - What each argument means, and what leaving it out does. - Every refusal the endpoint can produce. Beacon appends the method and route to every description. Your endpoint still checks ownership as it does for the browser: the bound id is the caller's, and the endpoint decides what that person may see. ## 4. Mark a write tool A write tool is off until `AllowWriteTools` is on (step 2). Declare `ReadOnly = false` and state `Destructive` and `Idempotent`: ```csharp using Beacon.Core.HostEndpoints; [Authorize] [BeaconTool("request_leave", Description = "Request leave for the signed-in user. Arguments: from and to (dates, yyyy-MM-dd, inclusive) and " + "reason (optional). Returns the new request's id and status. Refused (400) when the dates overlap an existing " + "request. Call it only when the person asked for this change.", ReadOnly = false, Destructive = false, Idempotent = false)] [HttpPost("/api/time-off/requests")] public IActionResult RequestLeave([FromBody] RequestLeaveBody body) { /* validate, check balance, save */ } ``` `[BeaconTool]` goes on a controller action, or on a handler class whose framework copies class attributes into its endpoint (a Moberg Warp handler). On an MVC controller class it is refused at startup. A minimal API uses `.WithBeaconTool(name, description, readOnly: false, destructive: false, idempotent: false)`. What the opt-in changes: - The tool carries `readOnlyHint = false`, so clients such as Claude Desktop and Claude Code ask the person before each call. Beacon adds no confirmation step of its own. - It is listed to, and runs for, a signed-in user only. An app-only caller never sees it. - The audit records it as `api_request_leave:write`. - The endpoint is the authority: it validates input and applies its own business rules and record-level authorization. ## 5. Hold agents to self-service A tool call runs your endpoint as a principal with the authentication type `BeaconMcpUser`. When one endpoint serves both the browser and agents, use it to give agents less than the browser: ```csharp using Beacon.Core.Mcp; if (httpContext.User.IsBeaconMcpCall()) { // no permissions, no manager grants: the person's own records only } ``` ## 6. Make refusals reach the agent Tool calls run your `IExceptionHandler`s in registration order, as `UseExceptionHandler()` does for the browser. A deliberate refusal your application maps to a 403, 404 or 400 problem reaches the agent as that status and body. An exception no handler takes becomes the generic error `The host endpoint behind api_ failed with an unexpected error.` Set `UseHostExceptionHandlers = false` on the tool options to always answer the generic error. ## 7. Test it end to end Add `Moberg.Beacon.Testing` to the test project. In the test host, after the gateway registration, trust test tokens and register in-memory people: ```csharp var tokens = new McpTestTokens(tenantId, clientId); var users = new McpTestUsers() .Add(objectId: "oid-ada", id: "42", displayName: "Ada", new Claim("app_user_id", "42")) .Add(objectId: "oid-bob", id: "43", displayName: "Bob", new Claim("app_user_id", "43")); builder.Services.TrustMcpTestTokens(tokens); // after AddBeaconMcpGateway, in the test host only builder.Services.UseMcpTestUsers(users); await using var ada = await McpTestClient.ConnectAsync(app.GetTestClient(), tokens.For("oid-ada")); // 1. Exactly the expected tools, and the bound id is not in the schema var tools = await ada.ListToolsAsync(); tools.Select(x => x.Name).Should().BeEquivalentTo("api_my_orders", "api_request_leave", "get_context", "search", "get_documentation"); tools.Single(x => x.Name == "api_my_orders").InputSchema.GetRawText().Should().NotContain("userId"); // 2. A call returns only the caller's data var mine = await ada.CallToolAsync("api_my_orders"); mine.IsError.Should().NotBe(true); McpTestClient.Text(mine).Should().Contain("order-of-ada").And.NotContain("order-of-bob"); // 3. Naming somebody else's id is refused var other = await ada.CallToolAsync("api_my_orders", new Dictionary { ["userId"] = 43 }); other.IsError.Should().BeTrue(); McpTestClient.Text(other).Should().Contain("filled from the caller"); ``` The client is the official MCP SDK's, so the test goes through the bearer middleware, the caller mapping, the route policy, the MCP filters and the audit, and your endpoint's own authorization. Listing and calling tools needs Beacon's database (project access and the audit live there): run these tests against a real PostgreSQL or SQL Server with Beacon's schema migrated. `McpTestClient.Json(result)` returns a successful call's structured content when you want to assert on fields instead of text. Keep a pinned list of the application's tools, so adding a tool, or turning a read into a write, fails a test until someone reviews it. ## Check it works - [ ] The application starts. A missing `ReadOnly`, a bad name, an endpoint without authorization or `ReadOnly = false` without `AllowWriteTools` fails startup with a message that names the endpoint. - [ ] The test lists exactly the tools you marked, and `api_my_orders` has no `userId` argument. - [ ] A call as Ada returns Ada's data only; a call as Bob returns Bob's. - [ ] A call that sends `userId` is refused. - [ ] In Claude, the write tool asks before it runs, and the MCP audit shows it as `api_request_leave:write`. ## If something goes wrong | Symptom | Cause and fix | |---|---| | Startup names an endpoint and says `ReadOnly` was not declared | Set `ReadOnly = true` or `false`, or pass `readOnly:` to `.WithBeaconTool`. The verb is never trusted. | | Startup refuses `ReadOnly = false` | Set `AllowWriteTools = true` in the tool options. | | A Warning names a read-only endpoint on POST (or PUT, PATCH, DELETE) | Expected for a read over POST (a grid or a search with a body); it is still exposed. An endpoint that changes data is a write tool (`ReadOnly = false`). `AllowReadOnlyPost = false` exposes read-only tools on GET and HEAD only. | | A Warning says an endpoint requires only an authenticated user | Every mapped caller can call it. A self-service endpoint binds the person's id from the caller (`BindFromCaller`) and is not warned; otherwise give it a policy or roles. | | A call returns `Too many host endpoint calls are in progress` | The caller (`MaxConcurrentCallsPerCaller`, default 16) or all callers (`MaxConcurrentCalls`, default 64) stayed at their limit for the whole `QueueWait` (default 5 s). A timed-out call counts until its endpoint ends: pass `RequestAborted` / the `CancellationToken` down to your queries. | | A call says a route value does not satisfy the route constraint | The value fails the route's constraint (`{id:int}`, `regex(...)`), as it would on a real request. | | Startup refuses an endpoint with no authorization | Add `[Authorize]` or a policy, or define a fallback policy. | | Startup refuses a policy that names authentication schemes | List the scheme in `TrustedAuthenticationSchemes` only if an authorized MCP caller is an acceptable stand-in for it. Never list a step-up or MFA scheme. | | The tool is missing from the list | The caller is not mapped, cannot read the project, or the host project is not synced yet. A write tool is also hidden from system callers. | | Every call returns `Forbidden.` | The endpoint's policy denied the principal. Check the claims your resolver or principal factory sets. In full Beacon, host middleware does not run on a dispatched call: see [dispatch middleware](https://moberghr.github.io/beacon/mcp/host-endpoint-tools/#host-middleware-and-ambient-state-dispatch-middleware). | | `... failed with an unexpected error` | The endpoint threw and no `IExceptionHandler` took it. | | A bound name is warned at startup | No tool takes that argument. Check the spelling against the endpoint's parameter. | ## Next - [Host endpoint tools](https://moberghr.github.io/beacon/mcp/host-endpoint-tools/): every argument source, the tool catalog above `NamedToolLimit`, dispatch and audit details. - [MCP gateway](https://moberghr.github.io/beacon/mcp/gateway/): the whole gateway recipe, including the Entra registration and `beacon-doctor`. - [Ground AI answers in your own docs and data model](https://moberghr.github.io/beacon/cookbooks/ground-with-your-docs/): give the agent documents that explain your tools. --- # Host Docs > Ship the host application's markdown documentation with it and import it into a Beacon project. The documents are searchable by MCP agents with or without embeddings, readable through get_documentation, and summarised in an AGENTS.md project brief. Source: https://moberghr.github.io/beacon/mcp/host-docs/ A host application often keeps its own documentation next to the code: a `docs/wiki/` folder of generated markdown pages, runbooks, conventions. `ExposeDocs` imports those documents into a Beacon project at startup so MCP agents can `search` them and read them with `get_documentation`, alongside the host's data ([Host DbContext](https://moberghr.github.io/beacon/mcp/host-dbcontext/)). It works with `UseAI = false`: no LLM and no embedder is needed. ## Quick start ```csharp var beacon = builder.Services.AddBeaconServices(builder.Configuration, o => { /* your options */ }); beacon.ExposeDbContext(o => o .ReadOnlyConnection("BeaconReadOnly") .AllowTables("Loan", "LoanInstallment", "Customer")); beacon.ExposeDocs(o => { o.ProjectName = "HostContext"; // the project ExposeDbContext uses (its Name/ProjectName) o.FromDirectory("docs/wiki") // relative to ContentRootPath; default glob **/*.md .Exclude("drafts/**"); }); beacon.UseSqlServer(); // ends the builder chain: call ExposeDocs first var app = builder.Build(); // ... apply migrations ... await app.Services.SyncBeaconHostAsync(); // syncs ExposeDbContext data sources, then ExposeDocs documents ``` `SyncBeaconHostAsync` is the single startup entrypoint for everything a host declares. It runs the `ExposeDbContext` sync first (exactly what `SyncBeaconHostDataSourcesAsync` does; that call still works on its own) and then imports the documents. It is a plain startup call, not a hosted service. Both calls are **safe to run from several replicas at once**: the whole sync holds one lock in Beacon's own database (PostgreSQL `pg_try_advisory_lock` on a fixed key, SQL Server `sp_getapplock` with a session owner), so replicas starting together run it one after another instead of racing each other's inserts. A replica waits up to 5 minutes for the lock. A unique-key race that still happens (a row created outside the lock) re-runs the idempotent sync once instead of failing startup. Other providers run the sync unlocked and log a warning. A host that coordinates startup another way can register its own `IHostSyncLock`. ### The host project `ExposeDbContext`, `ExposeDocs` and `AddHostEndpointTools` all resolve their `ProjectName` through one shared resolver, so they always land on the same project. The host project is identified by a **host key** (`Project.HostManagedKey = host:{ProjectName, trimmed and lower-cased}`), never by its display name, which is neither unique nor protected: - On first sync the host project is created with the key. If a project that is **not** host-managed already uses the name (for example one a Beacon user created by hand), it is left untouched and the host project is created as `"{ProjectName} (host)"` (then `"(host 2)"`, …), with a warning in the log. A user project with the same name never receives the host database, documents or endpoint tools. - Renaming the host project in the UI is fine: it is still found by its key. - An archived host project is restored, because the host still declares it. - Changing `ProjectName` creates (or claims) the project for the new name, and the host data source is **unlinked from every other project**, including the project of the old name, so it loses access to the host database. ## Shipping the documents The docs are not part of the build output by default, so the host has to ship them. Either copy them next to the app: ```xml ``` ```csharp o.FromDirectory("docs/wiki"); ``` `FromDirectory` resolves a relative path against `IHostEnvironment.ContentRootPath`. Make sure that is the output directory in your deployment (it is when the app is started from its own folder), or pass an absolute path such as `Path.Combine(AppContext.BaseDirectory, "docs/wiki")`. Or embed them in the assembly: ```xml ``` ```csharp o.FromEmbeddedResources(typeof(Program).Assembly, "docs/wiki/"); ``` The part of the resource name after the prefix is the document path (backslashes become `/`). Without a `LogicalName`, MSBuild mangles folder names (`40-web` becomes `_40_web`) and joins them with dots, so set one. ## Options | Option | Meaning | |---|---| | `ProjectName` | **Required.** The host project the documents attach to, found by its host key (see [The host project](#the-host-project)); created when missing. | | `FromDirectory(path, params globs)` | Files under `path` (relative to the content root, or absolute) matching the globs. Default glob `**/*.md`. | | `FromEmbeddedResources(assembly, prefix)` | Manifest resources whose name starts with `prefix`. | | `Exclude(params globs)` | Skips documents whose relative path matches (applies to every source of this call). | | `MaxDocumentBytes` | Per-document cap, default 512 KB. Larger files are skipped with a warning that names only the path. | `ExposeDocs` may be called more than once, for several folders or for several projects. Only `.md`, `.markdown` and `.txt` files are ingested, whatever the glob matches. Globs must be relative and may not contain `..`; a file that resolves outside the configured directory, and any symbolic link inside it (file or folder), is rejected. A configured directory that does not exist fails startup, so a broken deployment cannot silently archive every imported document. ## What the sync does Each document is stored as a `ProjectImportedDocument`, keyed by project, source (`docs:dir:docs/wiki`, `docs:res:{Assembly}:{prefix}`) and relative path. - **Title**: the frontmatter `title`, else the first `# ` heading (outside code fences), else the file name. - **Frontmatter**: a leading `---` YAML block is stripped from the body and kept as JSON (flat keys, quoted strings, `[a, b]` and `- item` lists, such as a `sources:` list). - **Idempotent**: a document whose content hash is unchanged is left alone; nothing is written when nothing changed. A changed document is updated in place, a removed file is archived, and a file that comes back is un-archived. - **Case-insensitive paths**: source and path are matched ignoring letter case, as SQL Server's unique index compares them. Renaming `Guide.md` to `guide.md` updates the existing row (it takes the new casing). Two files whose paths differ only by case (possible on a case-sensitive file system or in embedded resources) cannot both be stored: the first is imported and the other is skipped with a warning that names its path. - **Chunked for search**: new and changed documents are split into sentence-window chunks (the same chunker and `DocChunkWindowSentences` / `DocChunkOverlapSentences` settings as generated documentation). Keyword search works on them immediately. When the local embedder and semantic retrieval are enabled, the sync then embeds the new chunks through the doc-chunk re-index, and the recurring `mcp-docchunk-reindex` job keeps them current. An embedding failure is logged and does not fail startup. The documents belong to one project and are only ever visible to callers authorized for that project. ## On the MCP surface - **`get_documentation`**: the project-level response (no arguments) ends with an *Imported documents* listing (title + path). `document: ""` (or an exact title) returns one document in full; a title shared by several documents is an error that names their paths. - **`search`**: imported documents match on title, path or chunk text (any word of the query; the documents holding the most words first) and appear as `- **[IMPORTED_DOC]** \`path\` (title)`, followed by ` -- snippet` when there is one, with or without embeddings. - **`get_context` with `format: "agents_md"`**: a deterministic *project brief* in markdown to drop into an agent workspace's `AGENTS.md`: the project description, data sources (host-managed ones marked read-only), up to 30 key tables (best-described and most-related first), masked columns, up to 30 glossary terms, the imported document titles, and how to use Beacon's tools (`get_query_context` → `dry_run` → `query`, read-only, masked columns stay masked; a project without data sources is pointed at `search` and `get_documentation` instead). Built from the catalog only, with no LLM call, and audited like every tool call. The brief opens with a notice that catalog text is untrusted data, prints the project description as one truncated code-span line, and renders every other catalog-derived field (names, identifiers, document paths, glossary text, tool descriptions) as data: on one line, with hidden characters shown as `�`, identifiers and paths in code spans no backtick can close, and Markdown delimiters escaped. It states the read-only and PII-detection guarantees from the project's effective MCP settings (it says so when either is switched off); the masked columns it lists are a host-managed source's host-policy masked columns, which stay masked whatever the PII detection setting. The REST API exposes the same documents: `GET /beacon/api/projects/{id}/imported-documents` (listing) and `GET /beacon/api/projects/{id}/imported-documents/{documentId}` (one document with its content). Both need Read in the project under [project access](https://moberghr.github.io/beacon/security/authorization/#project-access), for sessions and API keys alike; a project the caller cannot read answers `404`. ## Example: data and docs in one project ```csharp beacon.ExposeDbContext(o => { o.Name = "BackOffice"; o.ProjectName = "Back Office"; o.ReadOnlyConnection("BeaconReadOnly").AllowTables("Loan", "LoanInstallment", "Merchant", "Customer"); }); beacon.ExposeDocs(o => { o.ProjectName = "Back Office"; o.FromDirectory("docs/wiki"); }); ``` With the `Content Include` item above, the pages land in `docs/wiki/` next to the host's assembly. On each start only the pages that changed since the last deployment are re-chunked. --- # Troubleshooting > Startup failures, empty pages, 401s and 404s, jobs that do nothing, MCP clients that cannot connect: symptoms, causes and fixes for an embedded Beacon. Source: https://moberghr.github.io/beacon/integrate/troubleshooting/ ## The host does not start | Message (or symptom) | Cause | Fix | |---|---|---| | `Implementation of IBeaconScheduler is required.` | No scheduler registered. | `options.AddBeaconScheduler()`. → [Background jobs](https://moberghr.github.io/beacon/integrate/background-jobs/) | | `CS9137` (interceptors) at build | `AddOpenApi`/`MapOpenApi` without a direct `Microsoft.AspNetCore.OpenApi` reference. | Add the package to your project, or drop the two calls. | | `CS0122 'RoleService' is inaccessible` | A claims transformation copied from the reference host uses Beacon's internal role constants. | Use the version on [Host wiring](https://moberghr.github.io/beacon/integrate/host-setup/#the-claims-transformation). | | An `InvalidOperationException` about `Beacon:EncryptionKey` | Key missing, too short, not random, a published example, or a `${…}` placeholder. | `openssl rand -base64 32` into a secret store / `Beacon__EncryptionKey`. | | `Connection string 'BeaconContext' is not configured` | `UsePostgreSql()`/`UseSqlServer()` without `ConnectionStrings:BeaconContext`. | Set it, or pass a connection string. In development check that user-secrets load (they only load in `Development`). | | An error about `Beacon:Schema` | The key is set to something other than `beacon`. | Remove it. The schema is fixed. | | `Beacon:DataProtection:ApplicationName must be set outside Development` | `AddBeaconDataProtection` outside `Development` without an application name. | Set it, or run with `ASPNETCORE_ENVIRONMENT=Development` locally. | | Data Protection keys "persisted without encryption at rest" | Outside `Development` the key ring has no protector. | Chain `ProtectKeysWithCertificate(...)` / `ProtectKeysWithAzureKeyVault(...)`, or set `Beacon:DataProtection:AllowUnencryptedKeys` deliberately. | | `Beacon:UserManagement:SetupToken must be at least 32 characters` | Short setup token. | Use a longer one or unset it. | | Local login off and "nobody could sign in" | `LocalLogin:Enabled=false` without SSO, external login or a break-glass name. | Enable OIDC, configure `ExternalLogin`, or add a break-glass name. | | OIDC: missing Authority/ClientId/ClientSecret or admission rule | `Oidc:Enabled` without required values or without `AllowedTenants` (or `AllowAnyTenant`). | Complete the section. | | `Beacon:Mcp:Callers` validation errors | Callers configured without `AllowedTenants`, without bearer JWT validation, or with both `ClientId` and `ObjectId`. | See [Connect AI clients](https://moberghr.github.io/beacon/mcp/connect/#option-b-entra-id-callers). | | `Beacon host endpoint tools are misconfigured:` | A `[BeaconTool]` endpoint without `ReadOnly`, without authorization metadata, with an untrusted auth scheme, or a write tool without `AllowWriteTools`. | Fix each listed endpoint. | | `AddHostEndpointTools can only be called once.` | Called twice. | Call it once, after `AddBeaconMcp()`. | | `The MCP tool name '…' is claimed by both …` | The saved-query tools and the host endpoint tools (or another tool source) serve the same name or name prefix. | Rename the endpoint tool. (An attribute tool with such a name only logs a warning; calls to it reach the attribute tool.) | | Options validation for `Beacon:Query`, `Beacon:Mcp`, `Beacon:ApiKeys`, `Beacon:Notifications`, `Beacon:Authorization`… | A value is out of range or malformed. | The message names the key; see [Configuration](https://moberghr.github.io/beacon/getting-started/configuration/). | ## Database | Symptom | Cause | Fix | |---|---|---| | `3D000: database "…" does not exist` | The database itself is missing; `UseBeacon` creates the schema, not the database. | `CREATE DATABASE beacon;` (or `POSTGRES_DB=beacon` on the container). | | `relation "…" does not exist` / `Invalid object name 'beacon.…'` | Beacon's schema was never migrated. | Call `Beacon.Core.ServiceConfiguration.UseBeacon(app.Services, createSchema: true)` at startup. → [Metadata database](https://moberghr.github.io/beacon/integrate/database/) | | `extension "vector" is not available` | pgvector is not installed for this PostgreSQL server. | Install pgvector (Azure: allow `VECTOR` in `azure.extensions`), or use an image that has it (e.g. `pgvector/pgvector`). | | `permission denied to create extension "vector"` | The migrating login cannot create extensions. | Create it once as an administrator: `CREATE EXTENSION vector;`. | | `type "vector" does not exist` with your own `NpgsqlDataSource` | The extension lives in a schema that is not on the search path. | Install it in `public` (Beacon's search path is `beacon, public`). | ## The UI | Symptom | Cause | Fix | |---|---|---| | 404 at `/` | The UI lives at `/beacon`. | Open `/beacon`, or redirect `/` to it yourself. | | 404 for `/beacon` or blank page; asset 404s | `MapBeaconUi()` or `UseStaticFiles()` missing; or, building from source, the React app was not built (`Debug` builds skip it). | Add both calls; build `Release`, or `-p:BuildReactInDebug=true`. | | Endless redirect to `/login` (not `/beacon/login`) | `AddBeaconCookieAuthentication("/")`. | Pass `"/beacon"`. | | Always redirected to `/beacon/setup` (the health check and the MCP discovery documents still answer) | User management on and no administrator yet. | Complete first-run setup with the setup token (printed once to the console, or `Beacon:UserManagement:SetupToken`). | | Signed in, but every API call answers 401 | Middleware order: `UseBeaconAuthentication` must run before `UseAuthorization`. | Follow the [pipeline order](https://moberghr.github.io/beacon/integrate/host-setup/#the-middleware-pipeline). | | Saves fail with 400 "antiforgery" | No `AddAntiforgery`/`UseAntiforgery`, or a different header name without the SPA header. | Configure antiforgery with `HeaderName = "X-XSRF-TOKEN"`. | | An error shows as "Internal server error" in the UI | Business-rule exceptions are not mapped to problem+json. | Add the [error handler](https://moberghr.github.io/beacon/integrate/host-setup/#error-responses). | | A project, query or data source is missing for a user | Project access: the project is restricted and they are not a member, or the data source is not linked to a project they can read. | Add them as a member or link the data source. Restricted resources answer 404 on purpose. | | No live updates | Realtime off, or `AddBeaconApiServices()` missing, or a proxy blocks WebSockets on `/beacon/api/hub`. | Check `realtimeEnabled` in `GET /beacon/api/auth/me`. | ## Scheduled checks | Symptom | Cause | Fix | |---|---|---| | Subscriptions never run | Your scheduler does not register the recurring job, or no worker is running. | Check `AddOrUpdate` and your runner's dashboard. | | Jobs run but do nothing; log warns about a scope without project access | The job's scope was not marked as system work. | Call `ISystemWorkScope.MarkAsSystemWork()` first in every job. | | Run recorded as **Skipped** | The subscription's owner can no longer run it (removed from the project, role lowered, disabled). | Give it a new owner (UserAdmin or Admin) or restore access. | | Run refused: "not approved" | Approval workflow on and the query's current version is not approved. | An Approver approves the change. | | `NotImplementedException: … not implemented in this host` | The feature calls a scheduler member you left at its default. | Implement `AddOrUpdateDataQualityJob`, `EnqueueProjectDocumentation`, `EnqueueAiActorThinkCycle` or `EnqueueMcpEval`. | | Notification links 404 | `BaseUrl` lacks `/beacon`. | Set it to `https://host/beacon`. | | A recipient receives nothing | Destination not allowed by `Beacon:Notifications`, or awaiting approval. | Check the startup policy log line and **Approvals**. | ## MCP | Symptom | Cause | Fix | |---|---|---| | 401 | No or invalid key/token. | Send `Authorization: Bearer sk-sem_…`. | | 403 | Read-scoped key, an unmapped JWT caller, or a signed-in browser session. | Use an Execute key or map the caller; browsers use the MCP playground. | | "No projects are available to you." | The key names no reachable project. | Create the key with a project. | | "Project access could not be determined; retry the call." | A transient failure while resolving access. | Retry; check the database. | | Discovery documents show the wrong host | `Beacon:PublicBaseUrl` unset behind a proxy. | Set it. | | Calls fail randomly across instances | Legacy MCP sessions without affinity. | Enable session affinity for `/beacon/mcp`. | Still stuck? Compare your `Program.cs` with `src/Beacon.SampleProject/Program.cs`, and [open an issue](https://github.com/moberghr/beacon/issues).