# Beacon: cookbooks > Every Beacon recipe in one file, each one start to finish with a check that it works, plus the prompt library. Pick the recipe that matches the goal from the list on the first page, follow it in order, and ask before every decision a recipe marks as one. Published at https://moberghr.github.io/beacon/. Every page is also available as Markdown at .md. --- # Cookbooks > Task-shaped Beacon recipes, each one start to finish with a check that it works and a prompt you can hand to your coding agent: connect AI clients, publish tools, embed Beacon, single sign-on, alerting, data contracts and audit. Source: https://moberghr.github.io/beacon/cookbooks/ Each recipe takes one goal from nothing to a verified result: what you need, the steps, how to check it works, what to do when it does not, and where to go next. Each one opens with a prompt for your AI, because every recipe is also served as Markdown that an agent can follow. :::tip[Hand any recipe to your AI] ```text wrap Read https://moberghr.github.io/beacon/llms-cookbooks.txt, pick the recipe for: , tell me which one you picked, then follow it step by step and show me each "Check it works" result. Ask me before every decision a recipe marks as one. ``` Or point the agent at one recipe: every page is at its URL plus `.md`, for example `https://moberghr.github.io/beacon/cookbooks/connect-coding-agent.md`. ::: ## Start here | If you want to | Recipe | Time | |---|---|---| | Let Claude act in **your own application** as your users, through your endpoints | [MCP gateway for your application](https://moberghr.github.io/beacon/mcp/gateway/) | about 1 hour, plus the Entra registration | | Point **Claude Code, Cursor or VS Code** at a running Beacon | [Connect a coding agent](https://moberghr.github.io/beacon/cookbooks/connect-coding-agent/) | about 10 minutes | | Run **Beacon inside your application**, with its UI, monitoring and MCP server | [Embed Beacon (PostgreSQL + Warp)](https://moberghr.github.io/beacon/cookbooks/embed-postgresql-warp/) | about 45 minutes | | Get **good answers** from an agent connected to Beacon | [Ask your data with an agent](https://moberghr.github.io/beacon/cookbooks/ask-your-data/) | about 15 minutes | ## Connect AI to your data | Recipe | You end up with | Time | |---|---|---| | [Connect Claude Code, Cursor or VS Code](https://moberghr.github.io/beacon/cookbooks/connect-coding-agent/) | A coding agent calling Beacon's MCP tools with a project-scoped API key kept out of your files | 10 min | | [Add Beacon to claude.ai and Claude Desktop with Entra sign-in](https://moberghr.github.io/beacon/cookbooks/claude-connector-entra/) | A Claude custom connector whose users sign in with Microsoft Entra and reach their projects | 45 min | | [Ask your data questions with an AI agent](https://moberghr.github.io/beacon/cookbooks/ask-your-data/) | An agent that orients, grounds, validates its SQL with `dry_run`, and rates answers | 15 min | | [Publish an approved saved query as an MCP tool](https://moberghr.github.io/beacon/cookbooks/saved-query-tool/) | A reviewed, parameterised `q_` tool instead of free SQL | 20 min | | [Give an AI agent access to one project only](https://moberghr.github.io/beacon/cookbooks/agent-one-project/) | An agent confined to one restricted project, with masking and caps, and its calls in the audit | 20 min | ## Make your app AI-ready | Recipe | You end up with | Time | |---|---|---| | [MCP gateway for your application](https://moberghr.github.io/beacon/mcp/gateway/) | Your application as an MCP server: Entra sign-in, your endpoints as tools, your docs as grounding | 1 h | | [Expose an endpoint as an MCP tool, safely, and test it](https://moberghr.github.io/beacon/cookbooks/endpoint-as-tool/) | A self-service read tool and a confirmed write tool, with an end-to-end test | 30 min | | [Ground AI answers in your own docs and data model](https://moberghr.github.io/beacon/cookbooks/ground-with-your-docs/) | Your Markdown docs and EF Core model in the answers agents give | 25 min | ## Embed Beacon | Recipe | You end up with | Time | |---|---|---| | [Embed Beacon in an existing ASP.NET Core app (PostgreSQL + Warp)](https://moberghr.github.io/beacon/cookbooks/embed-postgresql-warp/) | Beacon's UI at `/beacon`, its API and MCP server, on your sign-in and job runner | 45 min | | [Embed Beacon on SQL Server with Hangfire](https://moberghr.github.io/beacon/cookbooks/embed-sqlserver-hangfire/) | The same on SQL Server, with your own Hangfire scheduler | 40 min | | [Sign in to Beacon with Microsoft Entra ID](https://moberghr.github.io/beacon/cookbooks/entra-sso/) | Single sign-on for Beacon's UI, with the first admin and a break-glass account | 30 min | ## Monitor & govern | Recipe | You end up with | Time | |---|---|---| | [Alert Slack or Teams when a nightly check fails](https://moberghr.github.io/beacon/cookbooks/alert-on-failed-check/) | A scheduled check that posts to a channel and opens a task when it finds rows | 20 min | | [Score a table with a data contract](https://moberghr.github.io/beacon/cookbooks/data-contract/) | A contract of quality rules, a score on a schedule, and an alert when it drops | 20 min | | [Ship the MCP audit and security events to your SIEM](https://moberghr.github.io/beacon/cookbooks/audit-to-siem/) | Every agent call and security event in your log pipeline, with traces and metrics | 30 min | ## Prompts The [prompt library](https://moberghr.github.io/beacon/cookbooks/prompts/) has ready-made prompts for coding agents in your repository, for asking your data questions through Beacon, for people using a gateway application's tools, and for admins. ## Missing a recipe? Open an [issue](https://github.com/moberghr/beacon/issues) with the goal you had and what you tried. For anything not covered yet, `llms-full.txt` gives an agent the whole documentation in one file. --- # Prompt library > Copy-paste prompts for working with Beacon through AI: for coding agents wiring Beacon into a repository, for people asking their data questions through Beacon's MCP tools, for people using a gateway application's tools, and for admins running Beacon. Source: https://moberghr.github.io/beacon/cookbooks/prompts/ Prompts that work, grouped by who types them and where. Replace the parts in ``. Each prompt names the Beacon page the AI should read, so the answer follows the current docs and not the model's memory of them. :::tip[The pattern behind every prompt] 1. **Point at the source:** a `…/llms-*.txt` bundle or a page's `.md` URL. 2. **Say the goal** in one sentence. 3. **Keep the decisions yours:** "ask me before every decision the guide marks as one". 4. **Ask for proof:** "show me each Check it works result", "show me the SQL before you run it". ::: ## In your repository (coding agents) For Claude Code, Cursor, Copilot or any agent that edits code. With Claude Code, the [Beacon plugin](https://moberghr.github.io/beacon/getting-started/with-ai/#the-claude-code-plugin) adds skills that do the same with less typing. ### Set Beacon up ```text wrap title="MCP gateway for this application" 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. Before writing code, read the app and tell me: how users sign in, where the users table and its Entra object id are, which database and migrator we use, and which endpoints look like self-service reads. Then ask me the decisions the guide lists. ``` ```text wrap title="Embed Beacon's data product" Integrate Beacon into this ASP.NET Core app, with for Beacon's own data and for jobs. Read https://moberghr.github.io/beacon/llms-integrate.txt and follow its checklist in order. Ask me before every decision it marks as one, keep secrets in user secrets, and show me each verification step. ``` ```text wrap title="Single sign-on with Microsoft Entra ID" Turn on Entra ID single sign-on for Beacon in this app. Read https://moberghr.github.io/beacon/cookbooks/entra-sso.md and follow it. Print the app registration steps for me to run in the portal instead of changing the tenant yourself. ``` ```text wrap title="Upgrade to Beacon 5.0" Upgrade this application from Beacon 4.x to 5.0. Read https://moberghr.github.io/beacon/getting-started/whats-new.md and apply every item under "Upgrade from 4.x" that applies. List the ones that did not apply and why. Build and run the tests afterwards. ``` ### Tools for agents ```text wrap title="Expose one endpoint as a tool" Make available to Claude as a Beacon MCP tool. Read https://moberghr.github.io/beacon/cookbooks/endpoint-as-tool.md first. Check the endpoint against its tool review checklist before marking it, bind the person's id from the caller, write an agent-facing description field by field, and add a Moberg.Beacon.Testing test that proves it refuses somebody else's data. ``` ```text wrap title="Review the tools we already expose" List every endpoint in this repository marked with WithBeaconTool or [BeaconTool]. For each, check it against the tool review checklist in https://moberghr.github.io/beacon/cookbooks/endpoint-as-tool.md and report: self-service or not, what data it returns, whether ReadOnly is declared, whether the person's id is bound from the caller, and whether a test pins it. Do not change anything yet. ``` ```text wrap title="Publish a saved query as a tool" Walk me through publishing our approved saved query "" as an MCP tool. Follow https://moberghr.github.io/beacon/cookbooks/saved-query-tool.md and tell me who has to do which step. ``` ### Fix and harden ```text wrap title="A startup error" Beacon refused to start with this message: . Read https://moberghr.github.io/beacon/integrate/troubleshooting.md and the page the message points to, explain the cause in two sentences, then fix it in this repository. ``` ```text wrap title="Claude cannot connect to our MCP endpoint" Claude cannot connect to https:///beacon/mcp. The error is: . Read https://moberghr.github.io/beacon/mcp/gateway.md (sections 6 to 8) and tell me what to check, in order. Then run: beacon-doctor mcp https:///beacon/mcp and explain every WARN and FAIL it prints. ``` ```text wrap title="Production review" Review this application's Beacon set-up against https://moberghr.github.io/beacon/integrate/production.md. Go through every checklist item and answer pass, fail or not applicable, with the file and line that shows it. Do not change anything; give me the list. ``` ## Ask your data For Claude, Claude Desktop, Cursor or any MCP client connected to Beacon's MCP server. The recipe [Ask your data questions with an AI agent](https://moberghr.github.io/beacon/cookbooks/ask-your-data/) explains the workflow behind these. ```text wrap title="Start a session" Use Beacon's get_context to describe my project: its data sources, the main tables and how well they are documented. Do not run any queries yet. ``` ```text wrap title="Find where something lives" Where does Beacon keep ? Search for it, read the documentation of the most likely tables, and tell me which columns hold the amount, the date and the customer. ``` ```text wrap title="Write the SQL, check it, then run it" I need . Get the grounding context for that question, write the SQL yourself, validate it with dry_run and show me the SQL and the verdict. Run it with query only after I say go. ``` ```text wrap title="A quick answer" Ask Beacon: "" Show me the SQL it generated with the answer. ``` ```text wrap title="Explain a number" Explain where the figure <12,430 active customers> comes from: the SQL, the tables it reads, and the filter that decides who counts. If Beacon has no definition of the term, say so instead of guessing. ``` ```text wrap title="Compare two periods" Compare in and by . Use one query, validate it with dry_run first, and point out the three biggest changes. Say if any result was truncated or masked. ``` ```text wrap title="Use the reviewed reports first" Which saved-query tools (q_…) do I have? If one of them answers "", run it instead of writing SQL, and tell me which one you used. ``` ```text wrap title="Rate the answer" That answer was . Record it with Beacon's feedback tool and the signal_id from the answer<, with this corrected SQL: …>. ``` ```text wrap title="A brief for your agent's instructions" Call get_context with format "agents_md" and turn the result into a short section I can paste into AGENTS.md: the data sources, the main tables and what they mean, and the rules for using Beacon's tools. ``` ## In an application with a Beacon gateway For people whose own application has a Beacon MCP gateway: Claude acts as you, through the application's own endpoints, and sees only what the application lets you see. ```text wrap title="What can you do here?" List the tools you have and, for each, one line on what it does and whether it changes anything. ``` ```text wrap title="A read" Show me my in , newest first, with their status. ``` ```text wrap title="A change (the client asks before it runs)" Request in . Before you call the tool, tell me exactly what you will send. ``` ## Running Beacon (admins) ```text wrap title="Design project boundaries" We have these teams and databases: . Read https://moberghr.github.io/beacon/security.md and propose Beacon projects, which data sources each links, who is a member with which role, and which projects stay restricted. Explain each choice in one line. ``` ```text wrap title="A key for one agent" I want an AI agent to read only the project. Follow https://moberghr.github.io/beacon/cookbooks/agent-one-project.md and give me the exact steps for our Beacon, the key settings and the project settings I should check. ``` ```text wrap title="Read the MCP audit" Here is an export of Beacon's MCP audit: . Read https://moberghr.github.io/beacon/mcp/observability-and-audit.md for what the fields mean, then summarise: which callers ran what, which calls were refused and why, and anything that looks unusual. ``` ```text wrap title="Lock it down" Read https://moberghr.github.io/beacon/mcp/server.md (Deployment locks and ceilings) and propose the Beacon:Mcp configuration for a regulated production deployment, with one line per setting on why. ``` ## Writing your own - **Name the page.** "Read `https://moberghr.github.io/beacon/.md`" is short, and the agent works from current docs. - **Ask for the plan first** on anything that changes code, a tenant or a deployment: "tell me what you will do, then wait". - **Name the tool when it matters.** "Validate it with `dry_run`" is more reliable than "be careful". - **Ask for evidence.** The SQL, the row count, the `beacon-doctor` output, the test that pins it. - **Never paste secrets.** Give environment variable names, not API keys or client secrets. --- # Connect Claude Code, Cursor or VS Code > Connect a coding agent to Beacon's MCP server with a project-limited API key kept out of your repository, and prove the connection with a first tool call. Source: https://moberghr.github.io/beacon/cookbooks/connect-coding-agent/ | | | |---|---| | **You end up with** | Claude Code, Cursor or VS Code calling Beacon's tools (`get_context`, `search`, `query` and the rest) against one project, with the key outside every committed file | | **Time** | about 10 minutes | | **You need** | A running Beacon with MCP mapped (`app.MapBeaconMcp()`, see [MCP and AI clients](https://moberghr.github.io/beacon/mcp/connect/#wiring)), a Beacon account with the Editor role or above, and a project with at least one data source | | **Applies to** | Beacon 5.0 · Full Beacon | :::tip[Let your AI do it] Paste this into your coding agent. It cannot create the key for you (keys are created in a signed-in browser session only), so it will stop and ask you for it. ```text wrap Read https://moberghr.github.io/beacon/cookbooks/connect-coding-agent.md and follow it step by step in this repository. Ask me for the Beacon URL and which client I use. I create the API key myself in the Beacon UI and put it in the BEACON_MCP_API_KEY environment variable: never ask me to paste the key into the chat, never write it into a file that is committed, and never commit .cursor/mcp.json or .vscode/mcp.json with a key in it. Show me the result of each "Check it works" item. ``` ::: ## Before you start | Decision | Options | Default | |---|---|---| | Client | Claude Code, Cursor, VS Code | The one you use; the steps are independent | | Where the config lives | The repository (shared with the team, no key inside) or your user profile (yours alone) | The repository, with the key read from an environment variable | | Projects the key reaches | One project, or several | One. With several, the agent must pass `project_id` on every call | | Expiry | A date within `Beacon:ApiKeys:MaxLifetimeDays` (365 by default) | 90 days | ## 1. Create an API key 1. In Beacon, open **API Keys** (`/beacon/api-keys`) and choose **Generate key**. 2. **Key name**: something that says where it is used, for example `Cursor on my laptop`. 3. **Scopes**: tick **Execute**. The MCP endpoint requires it; a `Read` key gets `403`. The checkbox is disabled unless you hold the Editor role or above. 4. **Projects**: pick the project the agent should use. At least one is required. 5. **Expiration date**: set a short one, or leave it empty for 90 days. 6. Generate the key and copy it (`sk-sem_…`). It is shown once; Beacon keeps only a hash. The key acts as you, in the projects you pick, and never with more than your own role allows. If you later lose the Editor role, the key acts as a Read key and Beacon's MCP endpoint refuses it (403) until you regain the role. More on this in [API keys](https://moberghr.github.io/beacon/security/api-keys/#scopes). ## 2. Put the key in an environment variable Set it in your shell profile or secret manager, not in the repository: ```bash export BEACON_MCP_API_KEY='sk-sem_…' ``` Start the client from a shell where the variable is set (an IDE launched from the dock does not see variables set only in a terminal session). ## 3. Add the server to your client Use `https://beacon.example.com/beacon/mcp` below; replace it with your Beacon's public URL. ### Claude Code Shared with the team: commit this `.mcp.json` at the repository root. Claude Code expands `${BEACON_MCP_API_KEY}` when it starts, so no key is in the file. ```json { "mcpServers": { "beacon": { "type": "http", "url": "https://beacon.example.com/beacon/mcp", "headers": { "Authorization": "Bearer ${BEACON_MCP_API_KEY}" } } } } ``` Only for you: add it from the command line instead. ```bash claude mcp add --transport http beacon https://beacon.example.com/beacon/mcp \ --header "Authorization: Bearer $BEACON_MCP_API_KEY" ``` Your shell expands `$BEACON_MCP_API_KEY` before Claude Code sees it, so the key is stored in Claude Code's own configuration under your user profile, not in the repository. Do not run this command in a directory whose configuration you commit. ### Cursor Put the file in your user profile, `~/.cursor/mcp.json`, so it is never in a repository: ```json { "mcpServers": { "beacon": { "url": "https://beacon.example.com/beacon/mcp", "headers": { "Authorization": "Bearer ${env:BEACON_MCP_API_KEY}" } } } } ``` If you want the project-level `.cursor/mcp.json` instead, add it to `.gitignore` or use the `${env:…}` form above. Check Cursor's own documentation if the `${env:…}` interpolation does not resolve in your version; the header and URL are all Beacon needs. ### VS Code `.vscode/mcp.json` prompts for the key once and keeps it out of the file: ```json { "inputs": [ { "type": "promptString", "id": "beacon-key", "description": "Beacon API key", "password": true } ], "servers": { "beacon": { "type": "http", "url": "https://beacon.example.com/beacon/mcp", "headers": { "Authorization": "Bearer ${input:beacon-key}" } } } } ``` ## 4. Keep the key out of git - [ ] `git grep sk-sem_` finds nothing in the repository. - [ ] `.cursor/mcp.json` is either absent or contains `${env:…}` only; `.vscode/mcp.json` contains `${input:…}` only. - [ ] If a key ever reached a commit, revoke it on **API Keys** (**Revoke**, effective on the next request) and create a new one. ## Check it works **From the client.** Restart it, then ask: *"Use Beacon's get_context to describe my project."* The answer lists the project's data sources and tables. In Claude Code, `/mcp` shows the `beacon` server as connected. - [ ] The client lists Beacon's built-in tools (all eight unless the host chose fewer): `get_context`, `search`, `get_documentation`, `get_query_context`, `ask`, `query`, `dry_run`, `feedback`. - [ ] `get_context` answers with your project. **From a terminal**, independent of any client. Beacon's discovery card needs no key: ```bash BEACON=https://beacon.example.com curl -s $BEACON/.well-known/mcp/server-card.json | jq '.tools[].name' ``` Then the three-step handshake with the key. `initialize` returns an `Mcp-Session-Id` header that the next calls must send: ```bash H=(-H "Authorization: Bearer $BEACON_MCP_API_KEY" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream") SESSION=$(curl -s -D - -o /dev/null "${H[@]}" $BEACON/beacon/mcp \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' \ | awk -F': ' 'tolower($1)=="mcp-session-id"{print $2}' | tr -d '\r') curl -s "${H[@]}" -H "Mcp-Session-Id: $SESSION" -H "MCP-Protocol-Version: 2025-06-18" $BEACON/beacon/mcp \ -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' curl -s "${H[@]}" -H "Mcp-Session-Id: $SESSION" -H "MCP-Protocol-Version: 2025-06-18" $BEACON/beacon/mcp \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' ``` - [ ] `initialize` returns a non-empty `$SESSION`. - [ ] `tools/list` returns the tools above (plus any `q_…` saved-query tools your project exposes). ## If something goes wrong | Symptom | Cause and fix | |---|---| | `401` with a `WWW-Authenticate` header | No or invalid key. The variable is empty in the client's environment, the key expired or was revoked, or the header is not `Authorization: Bearer sk-sem_…`. | | `403` at the door, or "Permission denied: this credential does not have the Execute scope" | The key has only `Read`, or you no longer hold the Editor role. Create an `Execute` key as a user who does. | | "No projects are available to you." | None of the key's projects is one you may still use. Check your project membership. | | "Multiple projects available (IDs: …)" | The key reaches several projects. Pass `project_id` on every call, or create a key limited to one project. | | "Access denied: you may read project N but not run ad-hoc queries in it." | You are a Viewer there. `ask`, `query` and `dry_run` need Editor. | | 400 "A new session can only be created by an initialize request" | A curl call skipped `initialize`, or `$SESSION` is empty. Rerun the handshake from step 1. | | The client shows no tools but the key works in curl | The client did not start with the variable. Start it from the shell where `BEACON_MCP_API_KEY` is set. | ## Next - [Ask your data questions with an AI agent](https://moberghr.github.io/beacon/cookbooks/ask-your-data/): the tool workflow and prompts that get good answers. - [Give an AI agent access to one project only](https://moberghr.github.io/beacon/cookbooks/agent-one-project/): restricted projects, masking and the audit. - [MCP server](https://moberghr.github.io/beacon/mcp/server/): every tool, argument and guardrail. - [API keys](https://moberghr.github.io/beacon/security/api-keys/): scopes, expiry and revocation. --- # Add Beacon to claude.ai and Claude Desktop with Entra sign-in > Register Beacon's MCP endpoint as a custom connector in Claude, with people signing in through Microsoft Entra ID and Beacon mapping each person to projects, and check the sign-in path with beacon-doctor. Source: https://moberghr.github.io/beacon/cookbooks/claude-connector-entra/ | | | |---|---| | **You end up with** | A custom connector in claude.ai and Claude Desktop that signs people in with Entra and gives them Beacon's tools in the projects you map them to | | **Time** | about 45 minutes, plus tenant admin consent | | **You need** | A running Beacon reachable over HTTPS (`Beacon:PublicBaseUrl` set, see [MCP and AI clients](https://moberghr.github.io/beacon/mcp/connect/#public-url-and-discovery)), Entra rights to create app registrations and grant admin consent, a project with data sources, and permission in Claude to add custom connectors (an organization owner may have to add the connector first) | | **Applies to** | Beacon 5.0 · Full Beacon (a Beacon with data sources, mapping Entra callers to projects). For your own application's endpoints as tools, use the [MCP gateway](https://moberghr.github.io/beacon/mcp/gateway/) instead | :::tip[Let your AI do it] This recipe is half Entra portal and half Beacon configuration, so ask your agent to **guide you** through it and to make only the repository changes. Paste this into your coding agent. ```text wrap Read https://moberghr.github.io/beacon/cookbooks/claude-connector-entra.md and guide me through it step by step. You may edit this repository's Beacon configuration and code. For everything in the Entra portal or in Claude, tell me exactly what to click and what value to enter, then wait for me to confirm. Never change tenant settings yourself, never ask me to paste a client secret or token into the chat, and keep secrets in user secrets or my secret store. Ask me before every decision the recipe marks as one, and show me the result of each "Check it works" item. ``` ::: ## Before you start | Decision | Options | Default | |---|---|---| | Who may connect | Users holding an app role, members of groups, or every user of the tenant | An app role (`Beacon.Mcp.User`) | | Projects they reach | One project for everyone, or extra projects per group or role (`GroupProjects`) | One project | | Beacon user per person | Provisioned on first call (`AutoProvision`, role `DefaultRoleName`) or none | Provisioned, role `Viewer` | | What they can run | `Viewer` reads the catalog and runs saved-query tools only; `ask`, `query` and `dry_run` need Editor | Raise `DefaultRoleName` to `Editor` only if people should run SQL | Provisioned people get no project membership. For a restricted project, add each of them as a member, or they reach nothing in it. See [What a caller reaches](https://moberghr.github.io/beacon/mcp/entra-callers/#what-a-caller-reaches). ## 1. Create the API app registration This is the registration whose tokens Beacon validates. Use your Beacon's existing one if there is one. 1. **Manifest**: set `api.requestedAccessTokenVersion` to `2`. Do this first; Entra accepts an HTTPS Application ID URI only for a v2 app. 2. **Expose an API → Application ID URI**: keep `api://` and add the exact MCP URL, `https://beacon.example.com/beacon/mcp`, to `identifierUris`. No trailing slash. Claude sends that URL as the `resource` parameter, and a mismatch fails with `AADSTS9010010`. Entra may require the host name to be a verified domain of the tenant. 3. **Expose an API → Add a scope**: `mcp.access`, delegated. Clients request it as `api:///mcp.access`. 4. **App roles**: create `Beacon.Mcp.User` for users or groups, and assign it to the people who may connect (**Enterprise applications → Users and groups**). The tokens Claude receives are v2 access tokens: `iss` is `https://login.microsoftonline.com//v2.0`, `aud` is the API's client id GUID, `scp` is `mcp.access`. ## 2. Create a client registration for Claude Entra has no Dynamic Client Registration, so Claude cannot register itself. 1. **New registration**, then **Authentication → Add a platform → Web**, redirect URI `https://claude.ai/api/mcp/auth_callback`. 2. **Certificates & secrets**: create a client secret and note its expiry. The connector stops working when it expires. Keep the value for step 4; do not commit it. 3. **API permissions → Add a permission → APIs my organization uses →** your API registration → **Delegated** → `mcp.access`. 4. **Grant admin consent** for the tenant. ## 3. Configure Beacon Bearer validation must name issuers and audiences once any caller is configured; the host refuses to start otherwise. ```csharp builder.Services.AddBeaconJwtAuthentication(x => { x.EnableBearerAuthentication = true; x.Validation.JwksEndpoint = "https://login.microsoftonline.com//discovery/v2.0/keys"; x.Validation.ValidIssuers = ["https://login.microsoftonline.com//v2.0"]; x.Validation.ValidAudiences = ["", "api://"]; }); ``` ```json { "Beacon": { "PublicBaseUrl": "https://beacon.example.com", "Mcp": { "ProtectedResource": { "AuthorizationServers": [ "https://login.microsoftonline.com//v2.0" ], "ScopesSupported": [ "api:///mcp.access", "offline_access" ] }, "Callers": { "AllowedTenants": [ "" ], "Users": { "Enabled": true, "RequiredRoles": [ "Beacon.Mcp.User" ], "ProjectIds": [ 4 ], "Scope": "Execute", "AutoProvision": true, "DefaultRoleName": "Viewer" } } } } } ``` | Key | Why it matters here | |---|---| | `Beacon:PublicBaseUrl` | The `resource` Claude sends is built from it: `https://beacon.example.com/beacon/mcp`. Without it, Beacon uses the request's `Host` header. | | `Mcp:ProtectedResource:AuthorizationServers` | Published in `/.well-known/oauth-protected-resource`. Use the **v2** issuer. Instead of listing it you can set `DeriveAuthorizationServerFromJwtIssuer` to `true`. | | `Mcp:ProtectedResource:ScopesSupported` | Without it Beacon publishes `Execute`, which Entra does not know. `offline_access` gives Claude a refresh token; without it people sign in again every hour, and Beacon logs a startup warning. | | `Mcp:Callers:AllowedTenants` | Required and non-empty once `Users:Enabled` is on. | | `Mcp:Callers:Users:RequiredRoles` / `RequiredGroups` | With neither set, startup fails unless `AllowAnyTenantUser` is `true`. Prefer app roles: Entra leaves the `groups` claim out for users in more than about 200 groups. | | `Mcp:Callers:Users:DefaultRoleName` | Must not be Admin or a functional role; startup fails otherwise. | Keep the app's own project list small: every accepted user gets `ProjectIds`. Add `GroupProjects` for more. All keys are described in [Entra ID callers](https://moberghr.github.io/beacon/mcp/entra-callers/#configuration). Deploy, then check the two discovery documents from any machine: ```bash curl -s https://beacon.example.com/.well-known/oauth-protected-resource | jq ``` - [ ] `resource` is `https://beacon.example.com/beacon/mcp`. - [ ] `authorization_servers[0]` is the v2 issuer and `scopes_supported` lists your scope and `offline_access`. ## 4. Add the connector in Claude Claude's menus change; if a label below differs, follow Anthropic's current custom-connector documentation. Beacon needs only the URL and the client registration's id and secret. 1. In claude.ai, open **Settings → Connectors** and **Add custom connector**. An organization owner may have to add it for the organization first. 2. Enter the server URL `https://beacon.example.com/beacon/mcp`. 3. In **Advanced settings**, enter the **OAuth Client ID** and **OAuth Client Secret** of the registration from step 2. 4. Connect. Claude opens the Entra sign-in; the person consents, or admin consent already covers it. Claude Desktop uses the connectors of the claude.ai account it is signed in to. ## Check the sign-in path with beacon-doctor `beacon-doctor` checks the endpoint the way a connector sees it: the 401 challenge, the protected-resource metadata, the authorization server's metadata and signing keys, PKCE and `offline_access`. ```bash dotnet tool install -g Moberg.Beacon.Doctor beacon-doctor mcp https://beacon.example.com/beacon/mcp ``` Each line starts with `PASS`, `WARN` or `FAIL`; the exit code is `1` when any check fails and `0` otherwise. Add `--json` for machine-readable output. With an Entra access token for your API (`--token `, or the `BEACON_MCP_TOKEN` environment variable) it also tries `initialize` and `tools/list`, which proves the token maps to a caller. The token is sent to that endpoint only. Without one, the `handshake` line warns that it was not tried. ## Check it works - [ ] `beacon-doctor` reports no `FAIL`. A `WARN` for `offline_access` means sign-in will repeat hourly: add it to `ScopesSupported`. - [ ] In Claude, the connector shows as connected after the Entra sign-in, and lists Beacon's tools. - [ ] Ask in a new chat: *"Use Beacon's get_context to describe my project."* The answer names your project. - [ ] In Beacon, a new user exists for the person (when `AutoProvision` is on), and their calls appear in the MCP audit (see [MCP observability and audit](https://moberghr.github.io/beacon/mcp/observability-and-audit/#reading-the-audit)). ## If something goes wrong | Symptom | Cause and fix | |---|---| | `AADSTS9010010` at sign-in | The `resource` Claude sent is not an Application ID URI of the API registration, or a requested scope belongs to another registration. Compare `resource` in `/.well-known/oauth-protected-resource` with `identifierUris`, character for character. | | `401` with `error="invalid_token"` | Beacon rejected the token. Compare `iss` and `aud` with `ValidIssuers` and `ValidAudiences`; a v2 token's `aud` is the client id GUID. | | `403` after sign-in | The token is valid but the person is not an accepted caller: check `Users:Enabled`, `RequiredRoles` or `RequiredGroups` (is the role assigned to them?) and `AllowedTenants`. | | "Access denied: you may read project N but not run ad-hoc queries in it." | The provisioned user is a `Viewer`. Raise `DefaultRoleName` or the user's role to Editor, and for a restricted project add them as an Editor member. | | "No projects are available to you." | `ProjectIds` is empty for this person, or the project is restricted and they are not a member. | | `503` with `Retry-After` | Beacon could not fetch the Entra signing keys and has none cached. Check outbound access to `login.microsoftonline.com`. | | Sign-in repeats every hour | `offline_access` is missing from `ScopesSupported`. | | Connector stopped working months later | The client secret in step 2 expired. Create a new one and update the connector. | | `beacon-doctor` warns about PKCE | Entra's metadata does not advertise PKCE although it supports it. Whether Claude enforces that is for Anthropic's documentation to say; the warning is not a Beacon fault. | ## Next - [Entra ID callers](https://moberghr.github.io/beacon/mcp/entra-callers/): system callers, token classification, replacing the mapper. - [Ask your data questions with an AI agent](https://moberghr.github.io/beacon/cookbooks/ask-your-data/): what to say once the connector works. - [MCP gateway](https://moberghr.github.io/beacon/mcp/gateway/): the same connector for your own application's endpoints, with no data sources. - [Give an AI agent access to one project only](https://moberghr.github.io/beacon/cookbooks/agent-one-project/): limiting what a caller reaches. --- # Ask your data questions with an AI agent > Use Beacon's MCP tools in the order that gives correct, checked answers: orient, find, ground, validate, run, explain and rate, with prompts you can paste and instructions to give your agent. Source: https://moberghr.github.io/beacon/cookbooks/ask-your-data/ | | | |---|---| | **You end up with** | An AI client that answers questions about your data by following Beacon's tool workflow, validates its SQL before running it, and records whether an answer was right | | **Time** | about 15 minutes | | **You need** | An AI client already connected to Beacon ([coding agent](https://moberghr.github.io/beacon/cookbooks/connect-coding-agent/) or [Claude connector](https://moberghr.github.io/beacon/cookbooks/claude-connector-entra/)) with Execute scope, and the Editor role in the project (a Viewer reads the catalog but cannot run SQL) | | **Applies to** | Beacon 5.0 · Full Beacon | :::tip[Let your AI do it] This recipe is for an AI client that is already connected to Beacon (Claude, Claude Desktop, Cursor), not for a coding agent working in a repository. Paste this into that client's chat. ```text wrap You are connected to Beacon's MCP tools. Follow https://moberghr.github.io/beacon/cookbooks/ask-your-data.md : start with get_context, find tables with search, ground yourself with get_query_context, validate every SQL statement with dry_run before query, and only use ask when I want a one-shot answer. Tell me which tool you are calling and why. Never invent table or column names. ``` ::: ## Before you start | Decision | Options | Default | |---|---|---| | Who writes the SQL | Your agent (`get_query_context` → `dry_run` → `query`), or Beacon (`ask`) | The agent, when you need to see and control the SQL; `ask` for quick business questions | | Which project | One, or several | If the credential reaches several projects, the agent passes `project_id` on every call | ## How the tools fit together Beacon sends this workflow to clients at `initialize` as server instructions, so most clients already know it. The tools, in order: | Step | Tool | Use it to | Key arguments | |---|---|---|---| | 1 | `get_context` | See the project's data sources, tables, quality and documentation status | `project_id`, `format` (`overview` or `agents_md`) | | 2 | `search` | Find tables, columns and documents by keyword | `query`, `max_results` (default 20, max 50), `offset` | | 2 | `get_documentation` | Read the documentation of a data source or one table | `datasource_name`, `table_name`, `schema_name`, `response_format` (`concise` or `detailed`), `document` | | 3 | `get_query_context` | Get schemas with sample values, join paths, verified examples and glossary terms ranked for one question | `question`, `datasource_name` or `datasource_id`, `max_chars` | | 4 | `dry_run` | Check SQL through every gate without running it | `sql`, `datasource_name` or `datasource_id` | | 5 | `query` | Run the validated SELECT | `sql`, `datasource_name` or `datasource_id`, `max_rows` (default 100) | | alt | `ask` | Let Beacon route, write and run the SQL | `question`, `execute` (default `true`) | | after | `feedback` | Record that an `ask` answer was `correct` or `incorrect` | `signal_id`, `verdict`, `corrected_sql`, `note` | Every tool also takes an optional `project_id`. Full argument tables: [MCP server](https://moberghr.github.io/beacon/mcp/server/#tools). If a reviewed saved query already answers the question, an approved `q_` tool is better than any hand-written SQL; see [Publish an approved saved query as an MCP tool](https://moberghr.github.io/beacon/cookbooks/saved-query-tool/). ## 1. Orient in a project ```text wrap title="Say to Claude" Use Beacon's get_context to describe my project: which data sources it has, the main tables, and how well they are documented. Do not run any queries yet. ``` `get_context` is audit-only and reads no rows. Look at the documentation status it reports: a table without documentation gives the agent less to work with. ## 2. Find where data lives ```text wrap title="Say to Claude" Where does Beacon keep information about refunds? Use search for "refund" and "chargeback", then read the documentation of the two most likely tables with get_documentation. Tell me which columns hold the amount and the date. ``` `search` matches keywords, and meaning too when a local embedder is configured. `get_documentation` with `table_name` returns columns, types, relationships and quality rules. ## 3. Write and validate SQL before running it ```text wrap title="Say to Claude" I want monthly refund totals for the last six months. Call get_query_context with that question, write the SQL yourself from what it returns, run it through dry_run, and show me the SQL and the dry_run verdict. Do not call query until I say go. ``` `get_query_context` returns real join paths and verified examples. Sections marked **(authoritative)** are human-verified. `dry_run` runs the read-only gate, a schema column check and the database's own validation, and reports every issue at once. It never executes the statement. When the verdict is valid it returns the exact SQL that would run, with the row limit applied. Then: ```text wrap title="Say to Claude" Go. Run that SQL with query, and tell me how many rows came back and whether the result was truncated. ``` If the result says more rows exist, raise `max_rows` or narrow the question: results are capped at the project's maximum row limit. ## 4. Ask a one-shot business question ```text wrap title="Say to Claude" Use Beacon's ask tool: "How many orders were placed last week, by country?" Show me the SQL it generated. ``` `ask` picks the data source, writes SQL grounded in your schema, runs it and ends its answer with a `_signal_id: N_` marker. Pass `execute: false` to get the SQL without running it. A question that spans data sources is joined in memory. Use `ask` for speed and the step-by-step flow when the SQL itself matters. ## 5. Explain a number ```text wrap title="Say to Claude" Explain where the "active customers" figure of 12,430 came from. Show the SQL behind it, the tables it reads, and any filter that decides who counts as active. If you cannot find it in Beacon, say so rather than guessing. ``` The agent can answer from the generated SQL of an `ask` it made, or by searching the documentation and glossary. If Beacon has no definition, a good answer says that. ## 6. Rate the answer ```text wrap title="Say to Claude" That answer is correct. Record it with Beacon's feedback tool using the signal_id from the ask response. ``` or, when you fixed the SQL: ```text wrap title="Say to Claude" That answer is wrong: it counted cancelled orders. Record it as incorrect with feedback, using this corrected SQL: . ``` An `incorrect` verdict keeps the answer out of the patterns Beacon learns. A `correct` one puts it on a list where an Admin may promote it to a verified example; feedback never promotes it by itself. Corrected SQL is stored only when it passes Beacon's checks and the project allows the feedback tool to store text. Only add a `note` when you ask for one. Details: [`feedback`](https://moberghr.github.io/beacon/mcp/server/#feedback). ## Write it into your agent's instructions Put this in `CLAUDE.md`, `AGENTS.md` or your client's custom instructions so every session starts with the workflow. `get_context` with `format: "agents_md"` generates a project brief you can add below it. ```md ## Beacon data tools - Start with `get_context`. Use `search` and `get_documentation` to find tables before writing SQL. - Write your own SQL like this: `get_query_context` for the question, then `dry_run`, then `query`. Never call `query` with SQL that `dry_run` has not accepted. Do not invent table or column names. - If an approved `q_` saved-query tool answers the question, call it instead of writing SQL. - Use `ask` only for quick one-shot questions; show the generated SQL with the answer. - Pass `project_id` on every call when the credential reaches more than one project. - Beacon is read-only. Do not try to write, alter or drop anything, and do not try to read other projects. - After a user confirms an `ask` answer is right or wrong, record it with `feedback` and the `signal_id`. Only include a `note` when the user asks for one. - Quote the SQL and row counts you used. If a result is truncated or masked, say so. ``` ## What the agent cannot do These are enforced by Beacon, not by the prompt. See [Safety and guardrails](https://moberghr.github.io/beacon/mcp/server/#safety--guardrails). | It cannot | Why | |---|---| | Write, alter or drop data | Only a single read-only statement passes the gate; `dry_run` shows which layer refused it. | | Reach another project | A project outside the credential's list answers "Access denied: you do not have access to project N." | | See unmasked PII | Columns detected as PII are masked in results; when Beacon cannot prove where a PII column went, it masks every column. | | Read the engine's catalog or another database | SQL naming another database, or reading `information_schema`, `pg_catalog` or similar, is refused (`cross_database`, `system_catalog`). | | Run SQL as a Viewer | `ask`, `query`, `dry_run` and `feedback` need Editor in the project. | | Return unlimited rows or run forever | Rows, result size, statement time and concurrent queries are capped per project. | | Teach Beacon on its own authority | `feedback` records a verdict; only an Admin promotes a verified example. | ## Check it works - [ ] `get_context` describes your project's data sources. - [ ] `dry_run` on a valid SELECT reports a valid verdict and the SQL with a row limit; on `DELETE FROM x` it reports an issue and nothing runs. - [ ] `query` returns rows, with PII columns masked when the project has detection on. - [ ] `ask` ends with `_signal_id: N_`, and `feedback` with that number answers that the verdict was recorded. - [ ] Each call appears in the MCP audit (see [Give an AI agent access to one project only](https://moberghr.github.io/beacon/cookbooks/agent-one-project/#see-what-the-agent-did)). ## If something goes wrong | Symptom | Cause and fix | |---|---| | "Access denied: you may read project N but not run ad-hoc queries in it." | The caller is a Viewer. Give the owner the Editor role (and Editor membership in a restricted project). | | "Multiple projects available (IDs: …)" | Pass `project_id` on every call, or use a key limited to one project. | | `dry_run` says the schema gate could not run | The data source has no extracted metadata yet, so the column check could not run and the verdict is invalid. Have the data source's metadata extracted, then try again. | | `dry_run` reports `cross_database` or `system_catalog` | The SQL names another database or reads the engine catalog. Rewrite it against the data source's own tables. | | `ask` answers "AI features not configured" | No LLM provider is configured on this Beacon. Use `get_query_context`, `dry_run` and `query` instead. | | Answers miss joins or misread a term | Beacon only knows what it was given. Register the join, add the glossary term, or promote a correct answer; see [Knowledge base](https://moberghr.github.io/beacon/mcp/knowledge-base/). | | Result is cut off | The result says it is truncated. Raise `max_rows` (up to the project's maximum) or narrow the question. | ## Next - [Prompt library](https://moberghr.github.io/beacon/cookbooks/prompts/): more prompts for specific jobs. - [Publish an approved saved query as an MCP tool](https://moberghr.github.io/beacon/cookbooks/saved-query-tool/): fixed, reviewed SQL for recurring questions. - [Ground Beacon with your docs](https://moberghr.github.io/beacon/cookbooks/ground-with-your-docs/): better answers from better documentation. - [MCP server](https://moberghr.github.io/beacon/mcp/server/): every tool and setting. --- # Publish an approved saved query as an MCP tool > Turn a reviewed, parameterized Beacon query into a fixed MCP tool such as q_loan_book_by_month, so agents call approved SQL instead of writing their own. Source: https://moberghr.github.io/beacon/cookbooks/saved-query-tool/ | | | |---|---| | **You end up with** | A saved query that agents call as the tool `q_`, always running the approved SQL with their arguments bound as database parameters | | **Time** | about 20 minutes, plus the reviewer's time | | **You need** | A query with at least one step you can edit, a second person with the **Approver** permission, a **SecurityAdmin** or an Admin who can read the query, and the [approval workflow](https://moberghr.github.io/beacon/security/approvals/) turned on by the host | | **Applies to** | Beacon 5.0 · Full Beacon | :::tip[Let your AI do it] Most of this is done in Beacon's UI by different people (author, approver, SecurityAdmin), so ask your agent to **guide you** through it and check each result. Paste this into your agent. ```text wrap Read https://moberghr.github.io/beacon/cookbooks/saved-query-tool.md and guide me through it step by step. Tell me which Beacon screen to open and what to enter, then wait for me to confirm. Ask me before every decision the recipe marks as one. Never approve a change for me, and never enable the tool yourself with an account that wrote or approved the query. Show me the result of each "Check it works" item. ``` ::: ## Before you start | Decision | Options | Default | |---|---|---| | Tool name | 3 to 41 characters: `a-z`, `0-9`, `_`, starting with a letter. Agents see `q_` | A short noun phrase such as `loan_book_by_month` | | Parameters | `Number`, `DateTime` or `String`, each with a description | Describe each one for the agent: the description becomes the argument's description | | Who enables it | A SecurityAdmin, or an Admin, who can read the query | With the approval workflow on, not the person who requested or approved the version | | Strict mode | `ApprovalWorkflow.RequireApprovedVersionToRun` on or off | On for regulated deployments: the approval must name the exact content hash | Every argument is required; the query model has no defaults. Only database data sources run as tools, not API sources. ## 1. Write the query with parameters Create or open the query and write the step SQL with `{name}` placeholders: ```sql SELECT date_trunc('month', booked_at) AS month, sum(principal) AS principal FROM loans WHERE booked_at >= {from} AND booked_at < {to} GROUP BY 1 ORDER BY 1 ``` Declare each parameter on the step with a type and a description (`from`: DateTime, "First day to include, ISO 8601"). The reserved name `project_id` cannot be used, a parameter must have a placeholder, and the same name must have the same type in every step. Run it with the editor's preview to check the SQL, then save. ## 2. Get a version approved With the approval workflow on, saving SQL files a version for approval and does not change the live query. 1. The author saves. The query shows that it awaits approval. 2. A different person with the **Approver** permission opens **Approvals** (`/beacon/approvals`), reads the step diff, optionally leaves a **Reviewer comment**, and chooses **Approve**. 3. The requester cannot approve their own change, and neither can anyone else who shaped it. Admins are not exempt. Only the query's **active** version, with an approval for exactly that version, can run as a tool. A later edit is pending again; the tool keeps running the approved version until the edit is approved. See [Which version runs](https://moberghr.github.io/beacon/mcp/saved-query-tools/#which-version-runs). ## 3. Expose it Do this as a SecurityAdmin (or an Admin) who is not the requester or approver of the version. An Admin may publish any query; anyone else only a query they can read (`404` otherwise). **In the UI.** Open the query's detail page and find the **MCP tool** card. It shows the approved version that calls will run (`v4`, for example) or why none can. Tick **Expose as MCP tool**, enter the **Tool name** (the card shows `q_`), optionally a **Description** (what the tool answers, as the agent reads it; up to 1000 characters), and **Save**. The status pill changes to **Exposed**. **Through the API**, as the same signed-in user: ```http PUT /beacon/api/queries/42/mcp-tool Content-Type: application/json { "enabled": true, "name": "loan_book_by_month", "description": "Outstanding loan book per month for a date range." } ``` The name is unique across all live queries. Enabling is refused with `400` when the name is taken or invalid, or when no approved version can run, and with `403` when you requested or approved the version while the workflow is on (unless the host allows self-approval). The query detail (`GET /beacon/api/queries/{id}`) carries `mcpToolEnabled`, `mcpToolName`, `mcpToolRunnableVersionNumber` and `mcpToolIssue`. The issue explains why a tool cannot be called: no approved version, a parameter shape that cannot be a tool, or no project that holds every data source the query reads. Disable it the same way with `"enabled": false`. Anyone who may publish can disable. ## 4. What the agent sees The tool appears in `tools/list` for callers whose project contains **every** data source the query reads and whose project role may run saved SQL (Viewers may). - The name `q_loan_book_by_month`, the description, and the approved version number. - An input schema built from the parameters: `number` for Number, an ISO 8601 `string` for DateTime, a `string` (up to 4000 characters) for String. Every parameter is required, and extra arguments are refused. - Annotations `readOnlyHint: true`, `destructiveHint: false`. A call returns a markdown table plus structured content: `columns`, `rows`, `rowCount`, `truncated` and the `versionNumber` that ran. **Many tools.** Up to 25 tools visible to one caller are listed by name (`SavedQueryTools.NamedToolLimit`, set in `AddBeaconServices(..., options => options.SavedQueryTools.NamedToolLimit = 40)`). Above that, the caller gets two tools instead: | Tool | Arguments | |---|---| | `search_saved_queries` | `query` (keywords; empty lists all), `limit` (default 10, max 50) | | `run_saved_query` | `name` (required), `arguments` (matching the input schema, with `project_id` when the schema lists one) | When a credential reaches the same tool through several projects, the schema adds `project_id` and calls must pass it. ## Check it works - [ ] The **MCP tool** card shows **Exposed** and the version number you approved. - [ ] From a connected client (see [Connect Claude Code, Cursor or VS Code](https://moberghr.github.io/beacon/cookbooks/connect-coding-agent/)), `tools/list` includes `q_loan_book_by_month` (or `search_saved_queries` above the limit). - [ ] Ask the agent: *"Call q_loan_book_by_month with from 2026-01-01 and to 2026-07-01."* It returns rows and the `versionNumber` you approved. - [ ] Edit the query without approving: the tool still returns the old version's numbers. - [ ] The call is in the MCP audit as `q_loan_book_by_month`. Saved-query calls are audit-only and record no learning signals. ## If something goes wrong | Symptom | Cause and fix | |---|---| | **Expose as MCP tool** is disabled, or `400` "cannot be exposed" | No approved active version, or the parameters cannot form a tool. The card names the reason. | | `403` when enabling | You requested or approved the version the tool would run. Ask a different SecurityAdmin or Admin. | | `403` for a non-SecurityAdmin | Publishing needs the SecurityAdmin permission or Admin. | | `404` when enabling | You cannot read the query, or it does not exist. | | Tool enabled but not listed | The approved version is not the active one, or no project in the caller's reach holds every data source the query reads. The card's `mcpToolIssue` says which. | | "The tool name '…' is already used" | Names are unique across live queries. Pick another. | | Call fails with a missing or unknown argument | Every parameter is required and nothing else is accepted; dates must be ISO 8601. | | Strict mode will not run an old tool | The approval predates content hashes. Restore the version and approve it again. | | `truncated: true` | More rows than the project's MCP row limit. Narrow the arguments or raise the limit in **MCP settings**. | ## Next - [Saved query tools](https://moberghr.github.io/beacon/mcp/saved-query-tools/): the security model, limits and a routine example. - [Approvals](https://moberghr.github.io/beacon/security/approvals/): who approves, strict mode and the API. - [Ask your data questions with an AI agent](https://moberghr.github.io/beacon/cookbooks/ask-your-data/): when to prefer a saved-query tool over generated SQL. --- # Give an AI agent access to one project only > Confine an agent to a single restricted project with a project-limited Execute key, set that project's masking and row limits, verify the agent cannot reach anything else, and read what it did in the MCP audit. Source: https://moberghr.github.io/beacon/cookbooks/agent-one-project/ | | | |---|---| | **You end up with** | An API key that reaches one project and nothing else, masked and row-capped results in that project, and an audit trail of every call the agent made | | **Time** | about 20 minutes | | **You need** | An Admin or UserAdmin (to restrict the project and add members), a project with its data sources linked, a Beacon user for the agent's key to act as, and an Auditor or Admin to read the audit | | **Applies to** | Beacon 5.0 · Full Beacon | :::tip[Let your AI do it] Most of this is done in Beacon's UI under different roles, so ask your agent to **guide you** and to check each result. Paste this into your agent. ```text wrap Read https://moberghr.github.io/beacon/cookbooks/agent-one-project.md and guide me through it step by step. Tell me which Beacon screen to open and what to enter, then wait for me to confirm. Ask me before every decision the recipe marks as one. Never ask me to paste the API key into the chat. Show me the result of each "Check it works" item. ``` ::: ## Before you start | Decision | Options | Default | |---|---|---| | Whose identity the key uses | A dedicated Beacon user for the agent, or your own | A dedicated user: the key can never reach more than its owner does, so a small owner means a small key | | The owner's role | Editor (the agent may run SQL) or Viewer (catalog and saved-query tools only) | Editor in this project, Viewer elsewhere | | Masking | PII detection on, plus custom patterns for your own identifiers | On; it is pinned on by `Beacon:Mcp:ForcePiiDetection` unless your host sets it to `false` | | Row cap | A project maximum below the global 1000 | The lowest value the agent needs | ## 1. Use a restricted project A restricted project exists only for its members and Admins; to anyone else it answers `404`. New projects are created restricted, with the creator as project admin. 1. Create the project (**Projects → New project**), or open an existing one. 2. On the project page, the **Access** card (UserAdmin or Admin) shows **Open** or **Restricted**. Choose **Restricted** and confirm. Add members before restricting an existing project, or its users lose access. 3. **Link data sources** with only the sources the agent needs. A resource reading several data sources is reachable only through a project that holds all of them. 4. On the **Members** tab, add the agent's Beacon user with the **Editor** role (or **Viewer** to limit it to the catalog and saved-query tools). :::caution Project access decides who may run SQL through Beacon, not what the database login can read. Give each data source a login that sees only what this project should see. Beacon also treats a data source that another open project links as reachable through that project; check the **Access** card's list of projects that share a source. ::: ## 2. Create a key limited to that project Sign in as the agent's user (keys are created in a signed-in browser session, never with another key), open **API Keys** (`/beacon/api-keys`) and choose **Generate key**: - **Key name**: for example `Reporting agent`. - **Scopes**: **Execute**. The MCP endpoint requires it; it needs the Editor role or above on the owner. - **Projects**: only this project. The picker lists projects the owner can read. - **Expiration date**: a short one. The default is 90 days. Copy the key (`sk-sem_…`) once, and store it in your secret store or an environment variable; see [Connect Claude Code, Cursor or VS Code](https://moberghr.github.io/beacon/cookbooks/connect-coding-agent/) for wiring it into a client. What the key can reach is the **intersection** of its project list and what its owner may still use, at no more than the owner's role there, and never the Admin bypass. Remove the owner's membership, and the key reaches nothing. See [Project restrictions](https://moberghr.github.io/beacon/security/api-keys/#project-restrictions). ## 3. Set the project's masking and limits Open **MCP settings** (`/beacon/mcp-settings`), switch the scope selector at the top from the global defaults to your project, and tick **Override** on each field you want to set for this project only: | Field (tab) | Set it to | Effect | |---|---|---| | **Max row limit** (Guardrails) | e.g. 200 | Ceiling on rows `query`, `ask` and saved-query tools return in this project. Must be at least 1. | | **Enable PII detection** and **Custom PII patterns** (Guardrails) | On, plus one regex per line | Masks detected PII columns in results. Add patterns for identifiers Beacon cannot detect. | | **Enforce read-only queries** (Guardrails) | On | Keep on; with `ForceReadOnly` (default) it cannot be turned off. | | **Statement timeout (s)**, **Max result size (bytes)** (Execution) | e.g. 15 and 65536 | Bounds each statement and its payload. | | **Max concurrent queries per API key** (Execution) | e.g. 2 | Limits parallel calls from this key. | | **Retain question and SQL text in signals and audit** (Execution) | Your policy | Whether the audit keeps what the agent asked and ran. Off keeps the audit structural only. | Editing needs the SecurityAdmin permission (an Auditor can read it). Deployment locks and ceilings in `Beacon:Mcp` can pin or cap these values from configuration; a locked field is hidden or disabled and says so. When a data source is shared with other projects, the strictest settings among them apply. See [Safety and guardrails](https://moberghr.github.io/beacon/mcp/server/#safety--guardrails). ## 4. See what the agent can and cannot reach Connect a client with the key and try, in this order: | Ask the agent | What happens | |---|---| | "Run `get_context`." | Describes this project. With a single-project key no `project_id` is needed. | | "Run `get_context` with `project_id` 99" (another project) | "Access denied: you do not have access to project 99." The call is audited. | | "Query the customers table." | Rows return with PII columns masked, capped at the row limit; the result says when it was truncated. | | "Run `DELETE FROM customers`" | Refused by the read-only gate. `dry_run` shows which layer refused it. | | "Query `information_schema.tables`" or another database | Refused (`system_catalog`, `cross_database`). | The key cannot change settings, manage keys, members or data sources: API keys never reach administrative routes, and they hold no functional permission. ## See what the agent did Every tool call, including refusals and errors, is written to the MCP audit with the key (`ApiKeyId`), its owner, the tool, the project, timing, row counts and the statements it ran (`sql` only when the project retains content). An Auditor or an Admin reads it: ```http GET /beacon/api/mcp/audit?from=2026-10-01T00:00:00Z&to=2026-10-08T00:00:00Z&projectId=4&tool=query&page=1&pageSize=100 ``` `from` and `to` are required (UTC, at most 93 days apart); `projectId`, `tool`, `callerHash` and `userId` filter. The rows are not filtered by project membership, so give the role only to people who should see every project's calls. An Auditor or Admin can also read it on the **MCP Audit** page (`/beacon/mcp-audit`), which calls this endpoint. Reading the audit is itself recorded in the [security audit trail](https://moberghr.github.io/beacon/security/audit-trail/), where creating and revoking keys is also logged. Details: [Reading the audit](https://moberghr.github.io/beacon/mcp/observability-and-audit/#reading-the-audit). To stop the agent at once, **Revoke** the key on **API Keys**. It takes effect on the next request. Disabling the owner revokes all of their keys. ## Check it works - [ ] The project page shows **Restricted**, and a user who is not a member gets "not found" for it. - [ ] The key lists exactly one project on **API Keys**. - [ ] A call naming another project is refused with "Access denied: you do not have access to project N." - [ ] A result from `query` has PII columns masked and no more rows than **Max row limit**. - [ ] A write statement is refused. - [ ] The audit export shows the calls above, including the refused ones, for this key. ## If something goes wrong | Symptom | Cause and fix | |---|---| | "No projects are available to you." | The key's project is not one its owner may still use: the owner is not a member of the restricted project, or was disabled. Add the membership. | | "Access denied: you may read project N but not run ad-hoc queries in it." | The owner is a Viewer there. Make them an Editor member for `ask`, `query` and `dry_run`. | | "Permission denied: this credential does not have the Execute scope" | The key is `Read`, or its owner lost the Editor role. | | Cannot tick **Execute** when creating the key | The signed-in user is a Viewer. Execute keys need the Editor role or above. | | The **Access** card is missing | It is shown to UserAdmins and Admins only, and is hidden when the host manages membership. | | Restricting answers `409` | Project membership is unavailable in this deployment (the host manages it, or user management is off). | | A settings field cannot be changed | A deployment lock pins it, or a ceiling caps it; only the host's configuration lifts that. | | The key stopped working with a message about owner inactivity | The host sets `Beacon:ApiKeys:MaxOwnerInactivityDays` and the owner has not signed in for that long. A service account must sign in now and then. | | Audit export answers `403` | The caller is neither an Auditor nor an Admin. | ## Next - [Authorization](https://moberghr.github.io/beacon/security/authorization/): project roles, 404 versus 403, shared data sources. - [API keys](https://moberghr.github.io/beacon/security/api-keys/): scopes, expiry, owner changes. - [MCP observability and audit](https://moberghr.github.io/beacon/mcp/observability-and-audit/): audit fields, retention, tracing. - [Audit to your SIEM](https://moberghr.github.io/beacon/cookbooks/audit-to-siem/): ship these events elsewhere. --- # 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. --- # Ground AI answers in your own docs and data model > Ship your Markdown documentation and your EF Core model with your application so AI agents find them through search, get_documentation and get_context, and check that an agent actually finds them. Source: https://moberghr.github.io/beacon/cookbooks/ground-with-your-docs/ | | | |---|---| | **You end up with** | Your documents imported into a Beacon project and, in full Beacon, your `DbContext` exposed as a read-only data source, plus a check that an agent finds both | | **Time** | about 25 minutes | | **You need** | An application with Beacon wired in ([MCP gateway](https://moberghr.github.io/beacon/mcp/gateway/) or [Integrate Beacon](https://moberghr.github.io/beacon/integrate/)), Markdown files in your repository, and for the data model part full Beacon with an EF Core `DbContext` and a read-only database login | | **Applies to** | Beacon 5.0 · Both. Documents: gateway and full Beacon. `ExposeDbContext`, the glossary and golden examples: full Beacon only, because the gateway has no data sources and no SQL tools | :::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/ground-with-your-docs.md and follow it step by step in this repository. Ask me whether this is the MCP gateway or full Beacon, which folder holds the docs, and which tables may be exposed. Never expose a table I did not name, never put a connection string in code, and show me the result of each "Check it works" item. ``` ::: ## Before you start | Decision | Options | Default | |---|---|---| | Where the documents live | A folder copied to the output, or embedded in the assembly | Embedded, so the deployment cannot lose them | | Expose the data model | Yes (full Beacon only), or documents only | Documents only | | Tables an agent may read | An explicit allow-list | None: `ExposeDbContext` is default-deny | | Columns hidden or masked | Excluded (invisible) or masked (visible, values `***`) | Mask national ids and account numbers, exclude secrets | ## 1. Write documents that ground well Each document is split into overlapping sentence windows (5 sentences, 1 overlap, by default), so a paragraph is retrieved on its own. Write for that. - [ ] One topic per page, with a `title:` in the frontmatter (else the first `# ` heading is used). - [ ] Each paragraph makes sense without the paragraphs around it: name the thing in full instead of "it". - [ ] Use the words your people use in questions, and say once what they mean: "active customer means ...". - [ ] Put definitions, rules and "where to find it" first, narrative last. - [ ] State units, time zones and currencies. - [ ] Keep each file under 512 KB (the default `MaxDocumentBytes`; larger files are skipped with a warning). - [ ] No secrets, no personal data, no real customer names. The documents are readable by every caller who can read the project. Only `.md`, `.markdown` and `.txt` files are imported. ## 2. Ship the documents Embed them in the assembly: ```xml ``` The `LogicalName` is required: without it MSBuild mangles folder names and joins them with dots. To copy a folder instead, see [Shipping the documents](https://moberghr.github.io/beacon/mcp/host-docs/#shipping-the-documents). ## 3. Register the documents In the gateway: ```csharp builder.Services.AddBeaconMcpGateway(builder.Configuration, x => { // ... x.Docs(y => y.FromEmbeddedResources(typeof(Program).Assembly, "docs/wiki/")); }); ``` The gateway sets the project name for you and syncs the host project the first time it is needed. You add no startup call. In full Beacon: ```csharp var beacon = builder.Services.AddBeaconServices(builder.Configuration, o => { /* your options */ }); beacon.ExposeDocs(o => { o.ProjectName = "Back Office"; // the project every Expose* call of this host uses o.FromEmbeddedResources(typeof(Program).Assembly, "docs/wiki/"); // or o.FromDirectory("docs/wiki").Exclude("drafts/**"); }); beacon.UseSqlServer(); // ends the builder chain: call ExposeDocs before it var app = builder.Build(); // ... apply migrations ... await app.Services.SyncBeaconHostAsync(); // data sources first, then documents ``` `SyncBeaconHostAsync` is idempotent: a document whose content hash is unchanged is left alone, a changed one is updated and re-chunked, a removed file is archived. It is safe to run from several replicas at once. ## 4. Expose the data model (full Beacon only) Skip this step in the gateway: it has no data sources, so there is nothing for `ExposeDbContext` to attach to and no SQL tool to ground. ```csharp beacon.ExposeDbContext(o => { o.Name = "Shop"; o.ProjectName = "Back Office"; // the same project as ExposeDocs o.ReadOnlyConnection("BeaconReadOnly") // ConnectionStrings:BeaconReadOnly, a read-only login .AllowTables("Loan", "LoanInstallment", "Customer") .ExcludeColumns(x => x.Table == "Customer" && x.Name.StartsWith("Kyc")) .MaskColumns(x => x.Name is "SSN" or "AccountNumber"); }); ``` Beacon reads the tables, columns, keys and foreign keys from the EF model without opening a connection, and takes descriptions from `HasComment` or, with `GenerateDocumentationFile` on, from your XML `` comments. Write those comments in business words: they become the column and table descriptions the agent reads. Columns whose names contain `password`, `secret`, `token` and similar words are excluded whatever you write. See [Host DbContext](https://moberghr.github.io/beacon/mcp/host-dbcontext/) for every option. ## 5. How it reaches agents | Tool | What the agent gets from your material | |---|---| | `search` | Documents as `[IMPORTED_DOC]` hits with their path and title, found by keyword, and by meaning when embeddings are enabled. Tables and columns too, in full Beacon. | | `get_documentation` | The project-level answer ends with a list of your documents. `document` takes a path or an exact title and returns that document in full. `datasource_name`, `table_name` and `schema_name` narrow to the data model. | | `get_context` | `format: "agents_md"` returns a project brief: data sources, key tables, glossary terms, document titles and how to use the tools. Built from the catalog, with no LLM call. | | `ask` (full Beacon) | A conceptual question is answered from project documentation, including your documents; a data question is grounded in the schema, sample values, glossary and verified examples. | | `get_query_context` (full Beacon) | The same grounding context `ask` would use, so an agent can write its own SQL. | Documents work with no LLM and no embedder: keyword search matches title, path and chunk text. Embeddings, when configured, add meaning-based matching ([Semantic retrieval](https://moberghr.github.io/beacon/mcp/knowledge-base/#semantic-retrieval)). ## 6. Teach the vocabulary and verified answers (full Beacon only) Two kinds of grounding are curated by an Admin after the data source exists: - **Business glossary**: terms, synonyms and a metric expression, through `/beacon/api/glossary` (API only, no page). The top matches (`GlossaryTopK`, default 5) enter the prompt. See [Business glossary](https://moberghr.github.io/beacon/mcp/knowledge-base/#business-glossary). - **Golden examples**: human-verified question to SQL pairs. An Admin promotes an answer rated `correct` on the **MCP Learning** page (`/beacon/mcp-learning`, tab **Golden candidates**). The top matches (`GoldenExemplarTopK`, default 4) enter the prompt as authoritative. See [Golden examples](https://moberghr.github.io/beacon/mcp/knowledge-base/#golden-examples-and-the-eval-harness). Both are scoped to the project, so another project that shares the data source never sees them. ## Check it works - [ ] Startup completes. In full Beacon `SyncBeaconHostAsync` ran after migrations. - [ ] `GET /beacon/api/projects/{id}/imported-documents` returns your documents (needs Read in the project). - [ ] Connect an MCP client ([Connect Claude Code, Cursor or VS Code](https://moberghr.github.io/beacon/cookbooks/connect-coding-agent/)) and say this: ```text wrap title="Say to Claude" Search the Back Office project for "active customer" and show me which documents match. Then read the best match in full with get_documentation. ``` - [ ] `search` answers with an `[IMPORTED_DOC]` line for your page, and `get_documentation` with `document` set to its path returns the text. - [ ] Full Beacon only: `get_documentation` with `table_name` returns the descriptions you wrote, and masked columns are flagged as masked. - [ ] Change one sentence in a document, redeploy, and the new text comes back from `get_documentation`. ## If something goes wrong | Symptom | Cause and fix | |---|---| | No documents are listed | The sync has not run (full Beacon: call `SyncBeaconHostAsync` after migrations), the folder is wrong, or the glob matched nothing. A configured directory that does not exist fails startup. | | Folder names look like `_40_web` or the path has dots | The embedded resources have no `LogicalName`. Set it as in step 2. | | A file is missing and the log has a size warning | It is over `MaxDocumentBytes` (512 KB). Split it. | | Two files with names that differ only by case, one missing | Paths compare ignoring case; the second is skipped with a warning naming its path. Rename one. | | The project is called `Back Office (host)` | A project that is not host-managed already had that name. The host project is created beside it. See [The host project](https://moberghr.github.io/beacon/mcp/host-docs/#the-host-project). | | `search` finds nothing for a paraphrase | Embeddings are off, so matching is by keyword. Use the people's own words in the document or enable embeddings. | | A table is not visible to the agent | It is not in `AllowTables` / `AllowEntity`, or the caller cannot read the project. | | A query on a host data source is refused for an unqualified table | Host data source SQL must be a single `SELECT` with schema-qualified table names, such as `dbo.Customer`. | ## Next - [Host docs](https://moberghr.github.io/beacon/mcp/host-docs/): every option, the sync rules and the REST routes for imported documents. - [Host DbContext](https://moberghr.github.io/beacon/mcp/host-dbcontext/): the allow-list, masking and the execution-time policy. - [Knowledge base and SQL grounding](https://moberghr.github.io/beacon/mcp/knowledge-base/): what goes into an `ask` prompt and who curates it. - [Ask your data questions](https://moberghr.github.io/beacon/cookbooks/ask-your-data/): use the grounding you just built. --- # Embed Beacon in an existing ASP.NET Core app (PostgreSQL + Warp) > Beacon's UI, API and MCP server running inside your ASP.NET Core application, storing its data in PostgreSQL and running its jobs on Moberg Warp, with a first admin and one monitored data source. Source: https://moberghr.github.io/beacon/cookbooks/embed-postgresql-warp/ | | | |---|---| | **You end up with** | Your ASP.NET Core app serving Beacon at `/beacon` (UI, REST API, MCP server), Beacon's schema created in PostgreSQL, scheduled jobs running on Warp, a first admin, and one data source added with a read-only login. Your own routes stay as they are. | | **Time** | about 45 minutes | | **You need** | An ASP.NET Core app on .NET 10, a PostgreSQL server with the [pgvector](https://github.com/pgvector/pgvector) extension available and an empty database for Beacon, and the right to create a read-only login on the database you want to monitor. Read [Integrate Beacon](https://moberghr.github.io/beacon/integrate/) first if you have not. | | **Applies to** | Beacon 5.0 · Full Beacon | :::tip[Let your AI do it] Paste this into Claude Code or another coding agent, in the repository of your app. ```text wrap Read https://moberghr.github.io/beacon/cookbooks/embed-postgresql-warp.md and follow it step by step in this repository. Ask me before every decision it marks as one, and show me the result of each "Check it works" item. Never write the connection string, the encryption key or the setup token into a file that is committed: use user-secrets or environment variables. Do not run the app against a database I have not named. ``` ::: ## Before you start | Decision | Options | Default | |---|---|---| | Job runner | Moberg Warp (this recipe), or another runner: see [Embed Beacon on SQL Server with Hangfire](https://moberghr.github.io/beacon/cookbooks/embed-sqlserver-hangfire/) | Warp | | Warp's database | The Beacon database, or a separate PostgreSQL database (`ConnectionStrings:WarpContext`) | The Beacon database | | Who signs in | Beacon's own user store (this recipe), or your app's sign-in: see [Sign-in & identity](https://moberghr.github.io/beacon/integrate/authentication/#your-app-owns-sign-in) | Beacon's user store | | Connectors | One per engine you monitor; this recipe adds PostgreSQL | PostgreSQL | ## 1. Add the packages ```bash dotnet add package Moberg.Beacon.Core.PostgreSql dotnet add package Moberg.Beacon.Api dotnet add package Moberg.Beacon.UI dotnet add package Moberg.Beacon.AI dotnet add package Moberg.Beacon.MCP dotnet add package Moberg.Beacon.Connector.PostgreSql dotnet add package Moberg.Warp.Core --version 3.11.0 dotnet add package Moberg.Warp.Worker --version 3.11.0 dotnet add package Moberg.Warp.Provider.PostgreSql --version 3.11.0 dotnet add package EFCore.NamingConventions dotnet add package Microsoft.EntityFrameworkCore.Design ``` `Moberg.Beacon.Core` comes with the others. Keep every `Moberg.Beacon.*` package on one version ([Packages](https://moberghr.github.io/beacon/integrate/packages/#versions)). Drop `Moberg.Beacon.AI` and `Moberg.Beacon.MCP` if you want neither; [Host wiring](https://moberghr.github.io/beacon/integrate/host-setup/#minimal-variants) lists what else goes. ## 2. Set the two secrets ```bash dotnet user-secrets init dotnet user-secrets set "ConnectionStrings:BeaconContext" "Host=localhost;Database=beacon;Username=beacon;Password=…" dotnet user-secrets set "Beacon:EncryptionKey" "$(openssl rand -base64 32)" ``` Create the database first (`CREATE DATABASE beacon;`). Beacon creates the schema and tables in it, not the database. Outside development the same two keys come from your secret store or from `ConnectionStrings__BeaconContext` and `Beacon__EncryptionKey`. Beacon refuses to start with a missing, short or non-random key. Keep the key: stored connection strings cannot be read without it. ## 3. Write the Warp side Beacon runs no jobs itself. Your host gives it an `IBeaconScheduler`, and each job marks its scope as system work before Beacon code runs. An unmarked job scope gets no project access and logs a warning ([the system-work rule](https://moberghr.github.io/beacon/integrate/background-jobs/#the-system-work-rule)). `WarpDbContext.cs` holds Warp's own tables (the `warp` schema), apart from Beacon's: ```csharp using Microsoft.EntityFrameworkCore; public sealed class WarpDbContext(DbContextOptions options) : DbContext(options); ``` `BeaconWarp.cs` registers Warp, applies its migrations and registers the recurring maintenance jobs: ```csharp using Beacon.Core.Authorization; using Beacon.Core.Worker; using Microsoft.EntityFrameworkCore; using Warp.Core; using Warp.Core.Data.Entities; using Warp.Core.Handlers; using Warp.Provider.PostgreSql; using Warp.Worker; public static class BeaconWarp { public static IServiceCollection AddBeaconWarp(this IServiceCollection services, IConfiguration configuration) { services.AddDbContext(x => x .UseNpgsql(configuration.GetConnectionString("WarpContext") ?? configuration.GetConnectionString("BeaconContext")) .UseSnakeCaseNamingConvention()); services.AddWarpServer(x => { x.UsePostgreSql(); x.PollingInterval = TimeSpan.FromSeconds(1); x.WorkerCount = Math.Min(Environment.ProcessorCount * 2, 20); // protects the connection pool // No x.AddRetry(): a failed check is investigated, not retried and notified twice. }); return services; } public static async Task StartBeaconWarpAsync(this WebApplication app) { using var scope = app.Services.CreateScope(); scope.ServiceProvider.GetRequiredService().Database.Migrate(); var recurring = scope.ServiceProvider.GetRequiredService(); await recurring.AddOrUpdateRecurringJob(new CleanupOldSignalsJob(), "mcp-learning-cleanup", "0 3 * * *"); // ... one line per job in the table below ... } } // Warp registers this behaviour for every job. It is what marks the job's scope as system work. public sealed class SystemWorkBehavior(ISystemWorkScope systemWork) : IPipelineBehavior where TRequest : IRequest { public Task HandleAsync(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { systemWork.MarkAsSystemWork(); return next(request, cancellationToken); } } public sealed class ExecuteSubscriptionQueryJob : IJob { public int SubscriptionId { get; init; } } public sealed class ExecuteSubscriptionQueryJobHandler(IJobService jobs) : IJobHandler { public Task HandleAsync(ExecuteSubscriptionQueryJob message, CancellationToken ct) => jobs.ExecuteQuery(message.SubscriptionId, ct); } public sealed class EvaluateDataContractJob : IJob { public int ContractId { get; init; } } public sealed class EvaluateDataContractJobHandler(IJobService jobs) : IJobHandler { public Task HandleAsync(EvaluateDataContractJob message, CancellationToken ct) => jobs.EvaluateDataContract(message.ContractId, ct); } public sealed class CleanupOldSignalsJob : IJob; public sealed class CleanupOldSignalsJobHandler(IJobService jobs) : IJobHandler { public Task HandleAsync(CleanupOldSignalsJob message, CancellationToken ct) => jobs.CleanupOldSignals(ct); } public sealed class BeaconScheduler(IRecurringJobPublisher recurring, WarpDbContext warp) : IBeaconScheduler { public Task AddOrUpdate(int subscriptionId, string subscriptionName, string cron) => recurring.AddOrUpdateRecurringJob(new ExecuteSubscriptionQueryJob { SubscriptionId = subscriptionId }, $"subscription-{subscriptionId}", cron); public Task Remove(int subscriptionId, string subscriptionName) => RemoveRecurring($"subscription-{subscriptionId}"); public Task AddOrUpdateDataQualityJob(int contractId, string contractName, string cron) => recurring.AddOrUpdateRecurringJob(new EvaluateDataContractJob { ContractId = contractId }, $"dq-{contractId}", cron); public Task RemoveDataQualityJob(int contractId, string contractName) => RemoveRecurring($"dq-{contractId}"); private async Task RemoveRecurring(string name) { var job = await warp.Set().Where(x => x.Name == name).FirstOrDefaultAsync(); if (job != null) { warp.Set().Remove(job); await warp.SaveChangesAsync(); } } } ``` Recurring jobs are keyed by the entity id, never by the name. Five of the other `IBeaconScheduler` members (`AddOrUpdateDataQualityJob`, `RemoveDataQualityJob`, `EnqueueProjectDocumentation`, `EnqueueAiActorThinkCycle`, `EnqueueMcpEval`) have default bodies that throw `NotImplementedException` until you implement them, and `EnqueueMetadataRefresh` and `EnqueueBackgroundTask` default to doing nothing (a background task then runs in the request that started it). Implement the ones for the features you use. Beacon also expects these recurring maintenance jobs. `mcp-learning-cleanup` is in the code above. Write one `IJob` plus handler for each of the others, in the same shape, and register each in `StartBeaconWarpAsync` with `recurring.AddOrUpdateRecurringJob(new XJob(), id, cron)` (`recurring` is the scope's `IRecurringJobPublisher`). The handler code is in [Background jobs](https://moberghr.github.io/beacon/integrate/background-jobs/#reference-implementation-moberg-warp). | Id | Cron | Handler calls | |---|---|---| | `mcp-learning-aggregate` | `0 */6 * * *` | `IJobService.AggregateLearnedPatterns` | | `mcp-learning-cleanup` | `0 3 * * *` | `IJobService.CleanupOldSignals` | | `mcp-audit-retention` | `30 3 * * *` | `IMcpAuditRetentionService.PurgeExpiredAsync` | | `security-audit-retention` | `45 3 * * *` | `ISecurityAuditRetentionService.PurgeExpiredAsync` | | `security-audit-verify` | `15 * * * *` | `ISecurityAuditVerificationService.VerifyNewEventsAsync` | | `mcp-embedding-reindex` | `0 */12 * * *` | `IJobService.ReindexEmbeddings` | | `mcp-docchunk-reindex` | `0 */12 * * *` | `IJobService.ReindexDocChunks` | Create Warp's initial migration once: `dotnet ef migrations add WarpInitial --context WarpDbContext`. ## 4. Register Beacon and map its endpoints `BeaconClaimsTransformation` is a small class you copy from [Host wiring](https://moberghr.github.io/beacon/integrate/host-setup/#the-claims-transformation): it adds the role claim from Beacon's user store. Then `Program.cs`: ```csharp using Beacon.AI; using Beacon.Api; using Beacon.Api.Authentication; using Beacon.Api.Endpoints; using Beacon.Connector.PostgreSql; using Beacon.Core; using Beacon.Core.Authentication.Providers; using Beacon.Core.PostgreSql; using Beacon.MCP; using Beacon.MCP.Discovery; using Beacon.UI; var builder = WebApplication.CreateBuilder(args); const string beaconBasePath = BeaconUiEndpointRouteBuilderExtensions.BasePath; // "/beacon" // ... your own services stay here ... builder.Services.AddBeaconWarp(builder.Configuration); builder.Services.AddBeaconHostInfrastructure(); builder.Services.AddBeaconServices(builder.Configuration, options => { options.AddBeaconScheduler(); options.BaseUrl = builder.Configuration["Beacon:BaseUrl"]; // public URL including /beacon, for links in notifications options.UseAI = true; options.Authorization.Enabled = true; options.Authentication.EnableLoginForm = true; options.AddAuthenticationProvider(); options.EnableUserManagement(); }) .AddPostgreSqlConnector() .UsePostgreSql(); // reads ConnectionStrings:BeaconContext; must end the chain builder.Services.AddBeaconCookieAuthentication(beaconBasePath); builder.Services.AddBeaconDataProtection(builder.Configuration, builder.Environment); builder.Services.AddBeaconOidcAuthentication(builder.Configuration); // does nothing unless Oidc:Enabled builder.Services.AddBeaconAI(builder.Configuration); builder.Services.AddBeaconMcp(); builder.Services.AddAntiforgery(x => { x.HeaderName = "X-XSRF-TOKEN"; x.Cookie.Name = ".Beacon.Antiforgery"; }); builder.Services.AddBeaconApiAuthorization(); builder.Services.AddBeaconApiServices(); var app = builder.Build(); var beaconConfiguration = app.Services.GetRequiredService(); Beacon.Core.ServiceConfiguration.UseBeacon(app.Services, createSchema: true); // The order is load-bearing. app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseMcpResourceMetadataChallenge(); app.UseBeaconAuthentication(); // API key, bearer JWT, UseAuthentication, Beacon.Auth cookie app.UseAuthorization(); app.UseMiddleware(beaconConfiguration, beaconBasePath); app.UseMiddleware(beaconConfiguration, beaconBasePath); app.UseAntiforgery(); app.MapBeaconApi(); app.MapLoginEndpoints(beaconBasePath, beaconConfiguration); app.MapSetupEndpoints(beaconBasePath); app.MapBeaconMcp(); app.MapMcpDiscovery(); app.MapBeaconUi(); // ... your own endpoints stay here ... await app.StartBeaconWarpAsync(); app.Run(); ``` What to know about this file: - `UseBeacon(app.Services, createSchema: true)` is the schema step. It creates the `beacon` schema if missing and applies the pending migrations, synchronously, before the host serves a request. One migration runs `CREATE EXTENSION IF NOT EXISTS vector;`, so the login needs the right to create it, or an administrator installs it first ([pgvector](https://moberghr.github.io/beacon/integrate/database/#pgvector-on-postgresql)). - With several instances, run migrations once from a deployment step instead: `await Beacon.Core.PostgreSql.BeaconPostgreSqlMigrator.MigrateAsync(connectionString);` in a migrator process that does not compose Beacon ([Metadata database](https://moberghr.github.io/beacon/integrate/database/#creating-and-migrating-the-schema)). - Beacon's middlewares act on `/beacon` only. Your routes keep your authentication. - Behind a proxy, add `UseForwardedHeaders` first ([Production checklist](https://moberghr.github.io/beacon/integrate/production/)). ## 5. Create the first admin Start the app. While no admin exists, Beacon prints a one-time setup token to the process console (standard error, not the log): ```text wrap Beacon first-run setup token (enter it on the setup page): 3f1c… ``` Open `https://localhost:/beacon/setup`, enter the token, then a user name and password (at least 8 characters, with upper case, lower case, a digit and a special character), and click **Create super admin**. With several replicas set `Beacon:UserManagement:SetupToken` (at least 32 characters) from your secret store, because each replica generates its own. Details: [First-run setup](https://moberghr.github.io/beacon/security/user-management/#first-run-setup). ## 6. Add a data source with a read-only login On the server you want to monitor, create a login that cannot write, for PostgreSQL: ```sql CREATE ROLE beacon_reader LOGIN PASSWORD '…'; ALTER ROLE beacon_reader SET default_transaction_read_only = on; GRANT USAGE ON SCHEMA public TO beacon_reader; GRANT SELECT ON ALL TABLES IN SCHEMA public TO beacon_reader; ``` In Beacon, open **Data Sources**, click **New data source**, pick **Database**, choose the **Database engine**, paste the **Connection string**, click **Test connection** (wait for **Connected**), then **Create data source**. Outside development Beacon refuses a connection that does not verify the server's certificate, for PostgreSQL `SSL Mode=VerifyFull` ([Transport security](https://moberghr.github.io/beacon/features/data-sources/#transport-security-tls)). ## Check it works - [ ] The app starts without `Implementation of IBeaconScheduler is required.` or an encryption-key error. - [ ] `psql` shows the `beacon` schema with tables, and `\dx` shows `vector`. - [ ] `https://localhost:/beacon/` redirects to `/beacon/setup`, and after step 5 to `/beacon/login`. Your own routes still answer as before. - [ ] `curl -k https://localhost:/beacon/api/health` returns `{"status":"ok"}`, also before the first admin exists. A `503` means Beacon cannot reach its database. - [ ] The data source shows **Connected** and its tables appear after the metadata load. - [ ] The `warp` schema exists and `warp.recurring_job` lists the maintenance jobs you registered. - [ ] After you create a subscription, `warp.recurring_job` lists `subscription-`. ## If something goes wrong | Symptom | Cause and fix | |---|---| | `Implementation of IBeaconScheduler is required.` | `options.AddBeaconScheduler()` is missing from the `AddBeaconServices` lambda. | | Startup refuses the encryption key | It must be base64 of at least 32 random bytes: `openssl rand -base64 32`. A `${…}` placeholder that was never expanded fails too. | | `extension "vector" is not available` | Install pgvector on the server, or on Azure Database for PostgreSQL add `VECTOR` to `azure.extensions`. | | Startup fails outside Development about Data Protection | Set `Beacon:DataProtection:ApplicationName` and chain `ProtectKeysWithCertificate(...)` or `ProtectKeysWithAzureKeyVault(...)`, or configure your own Data Protection and drop `AddBeaconDataProtection` ([Host wiring](https://moberghr.github.io/beacon/integrate/host-setup/#data-protection)). | | Warning that a scope is not marked as system work, and jobs do nothing | A job ran without `ISystemWorkScope.MarkAsSystemWork()`. Check that `SystemWorkBehavior` is compiled into your host project, which is where Warp's source generator registers it. | | `CS9137` when you add `AddOpenApi()` | Reference `Microsoft.AspNetCore.OpenApi` directly ([Packages](https://moberghr.github.io/beacon/integrate/packages/#build-time-notes)). | | The data source is refused for its connection string | The connection does not verify the server over TLS. Fix the connection string; do not look for a way to turn the check off. | ## Next - [Host wiring](https://moberghr.github.io/beacon/integrate/host-setup/): every option of `AddBeaconServices`, the middleware table and the routes. - [Sign in to Beacon with Microsoft Entra ID](https://moberghr.github.io/beacon/cookbooks/entra-sso/): single sign-on for the UI. - [Production checklist](https://moberghr.github.io/beacon/integrate/production/): proxy, TLS, key rotation, backups. - [MCP gateway for your application](https://moberghr.github.io/beacon/mcp/gateway/): if you want AI clients on your own endpoints, not on data sources. --- # Embed Beacon on SQL Server with Hangfire > Beacon inside your ASP.NET Core app with its own data in SQL Server and its scheduled jobs on Hangfire: what differs from the PostgreSQL and Warp recipe, and the IBeaconScheduler you write for Hangfire. Source: https://moberghr.github.io/beacon/cookbooks/embed-sqlserver-hangfire/ | | | |---|---| | **You end up with** | Your ASP.NET Core app serving Beacon at `/beacon`, Beacon's schema created in SQL Server, and subscriptions, data-quality checks and maintenance running as Hangfire jobs. | | **Time** | about 40 minutes | | **You need** | An ASP.NET Core app on .NET 10, the [PostgreSQL and Warp recipe](https://moberghr.github.io/beacon/cookbooks/embed-postgresql-warp/) open beside this one, an empty SQL Server database for Beacon, and a login that may create a schema in it. | | **Applies to** | Beacon 5.0 · Full Beacon | :::tip[Let your AI do it] Paste this into Claude Code or another coding agent, in the repository of your app. ```text wrap Read https://moberghr.github.io/beacon/cookbooks/embed-sqlserver-hangfire.md and follow it step by step in this repository. It lists only what differs from https://moberghr.github.io/beacon/cookbooks/embed-postgresql-warp.md, so read both. Ask me before every decision either one marks as one, and show me the result of each "Check it works" item. Never write a connection string, the encryption key or the setup token into a committed file. Tell me clearly that the Hangfire scheduler is code we write in our host: Beacon does not ship one. ``` ::: This recipe lists only the differences from [the PostgreSQL and Warp recipe](https://moberghr.github.io/beacon/cookbooks/embed-postgresql-warp/). Follow that recipe for the secrets, the middleware order, first-run setup and the first data source, and swap in what is below. ## Before you start | Decision | Options | Default | |---|---|---| | Hangfire's storage | The Beacon database (Hangfire keeps its tables in its own `HangFire` schema), or a separate SQL Server database | A separate database if you can; the Beacon database otherwise | | Which data sources you monitor | Add the matching connector for each engine. `Moberg.Beacon.Connector.SqlServer` for SQL Server | The engines you actually monitor | | Several app instances | Run Beacon's migrations once from a deployment step, not from every replica | A deployment step | ## 1. Swap the packages ```bash dotnet add package Moberg.Beacon.Core.SqlServer dotnet add package Moberg.Beacon.Api dotnet add package Moberg.Beacon.UI dotnet add package Moberg.Beacon.AI dotnet add package Moberg.Beacon.MCP dotnet add package Moberg.Beacon.Connector.SqlServer dotnet add package Hangfire.AspNetCore dotnet add package Hangfire.SqlServer ``` `Moberg.Beacon.Core.SqlServer` stores **Beacon's own data** in SQL Server. `Moberg.Beacon.Connector.SqlServer` lets Beacon **monitor** a SQL Server database. Add the connector only if you monitor one. The packages `Moberg.Warp.*`, `EFCore.NamingConventions` and the `Warp*` files are not needed. ## 2. Change the registration In the `AddBeaconServices` chain, replace the PostgreSQL calls: ```csharp using Beacon.Connector.SqlServer; // AddSqlServerConnector using Beacon.Core.SqlServer; // UseSqlServer builder.Services.AddBeaconServices(builder.Configuration, options => { options.AddBeaconScheduler(); // the class from step 3 // ... the rest of the options as in the PostgreSQL recipe ... }) .AddSqlServerConnector() .UseSqlServer(); // reads ConnectionStrings:BeaconContext; must end the chain ``` `ConnectionStrings:BeaconContext` is now a SQL Server connection string. Everything lives in the fixed schema `beacon`; `Beacon:Schema` accepts nothing else. Replace `AddBeaconWarp(...)` and `StartBeaconWarpAsync()` with Hangfire's registration and the startup of step 4: ```csharp builder.Services.AddHangfire(x => x.UseSqlServerStorage(builder.Configuration.GetConnectionString("HangfireContext") ?? builder.Configuration.GetConnectionString("BeaconContext"))); builder.Services.AddHangfireServer(); ``` Keep `AddBeaconHostInfrastructure<...>()`, the cookie, Data Protection, API, MCP and UI calls and the whole middleware pipeline exactly as in the other recipe. Data Protection keeps its keys in Beacon's database on either provider. ## 3. Write the Hangfire scheduler Beacon ships no job runner and no Hangfire integration. `IBeaconScheduler` is the contract between Beacon and your runner, and this class is **your code**, in your host. The members, from `src/Beacon.Core/Worker/IBeaconScheduler.cs`: | Member | Needed for | |---|---| | `AddOrUpdate(subscriptionId, subscriptionName, cron)` and `Remove(subscriptionId, subscriptionName)` | Subscriptions. Required: they have no default body. | | `AddOrUpdateDataQualityJob(contractId, contractName, cron)` and `RemoveDataQualityJob(contractId, contractName)` | Data contracts. Throw `NotImplementedException` until you implement them. | | `EnqueueProjectDocumentation(projectId, userId, notifyUserId)` | AI documentation. Returns your runner's job id. Throws until implemented. | | `EnqueueAiActorThinkCycle(actorId, subscriptionId)` | AI actors. Returns a job id. Throws until implemented. | | `EnqueueMcpEval(runId)` | MCP evaluation runs. Returns a job id. Throws until implemented. | | `EnqueueMetadataRefresh(dataSourceId)` | Re-scan after a data source was pointed at another target. Optional: the default returns `null` and Beacon logs that the old schema stays until a refresh. | | `EnqueueBackgroundTask(taskId, notifyUserId)` | A task a user started and follows (a background metadata refresh); your job calls `IBackgroundTaskService.ExecuteAsync(taskId, ct)`. Optional: the default returns `null` and Beacon runs the task in the request. | `cron` is a standard 5-field expression, already validated by Beacon. Key recurring jobs by the entity id, never by the name. Every job opens its own DI scope, marks it as system work with `ISystemWorkScope.MarkAsSystemWork()` **before** it calls Beacon, and calls one `IJobService` method. A scope that was not marked gets no project access and logs a warning. Work done for a user, such as a subscription's run, is still executed as that user: Beacon sets the owner itself ([the system-work rule](https://moberghr.github.io/beacon/integrate/background-jobs/#the-system-work-rule)). Do not resolve the current user in your wrapper. ```csharp using Beacon.Core.Authorization; using Beacon.Core.Worker; using Hangfire; [AutomaticRetry(Attempts = 0)] // a failed check is investigated; a retry would notify twice public sealed class BeaconHangfireJobs(ISystemWorkScope systemWork, IJobService jobs) { public Task ExecuteSubscription(int subscriptionId, CancellationToken ct) { systemWork.MarkAsSystemWork(); return jobs.ExecuteQuery(subscriptionId, ct); } public Task EvaluateContract(int contractId, CancellationToken ct) { systemWork.MarkAsSystemWork(); return jobs.EvaluateDataContract(contractId, ct); } public Task RunMcpEval(int runId, CancellationToken ct) { systemWork.MarkAsSystemWork(); return jobs.RunMcpEval(runId, ct); } public Task CleanupOldSignals(CancellationToken ct) { systemWork.MarkAsSystemWork(); return jobs.CleanupOldSignals(ct); } // AggregateLearnedPatterns, ReindexEmbeddings and ReindexDocChunks follow the same shape. } public sealed class HangfireBeaconScheduler(IRecurringJobManager recurring, IBackgroundJobClient background) : IBeaconScheduler { public Task AddOrUpdate(int subscriptionId, string subscriptionName, string cron) { recurring.AddOrUpdate($"subscription-{subscriptionId}", x => x.ExecuteSubscription(subscriptionId, CancellationToken.None), cron); return Task.CompletedTask; } public Task Remove(int subscriptionId, string subscriptionName) { recurring.RemoveIfExists($"subscription-{subscriptionId}"); return Task.CompletedTask; } public Task AddOrUpdateDataQualityJob(int contractId, string contractName, string cron) { recurring.AddOrUpdate($"dq-{contractId}", x => x.EvaluateContract(contractId, CancellationToken.None), cron); return Task.CompletedTask; } public Task RemoveDataQualityJob(int contractId, string contractName) { recurring.RemoveIfExists($"dq-{contractId}"); return Task.CompletedTask; } public Task EnqueueMcpEval(int runId) => Task.FromResult(background.Enqueue(x => x.RunMcpEval(runId, CancellationToken.None))); } ``` Hangfire's ASP.NET Core integration creates one DI scope per job and builds `BeaconHangfireJobs` in it, so the `ISystemWorkScope` the wrapper marks is the one `IJobService` reads. Hangfire fills the `CancellationToken` argument with its shutdown token at run time. The Hangfire calls above (`AddOrUpdate`, `RemoveIfExists`, `Enqueue`, `[AutomaticRetry]`) come from Hangfire, not from Beacon: check them against the Hangfire version you install. For AI documentation and AI actors, add wrapper methods that call `IProjectDocumentationService.GenerateDocumentationAsync(projectId, userId, ct)` and `AiActorService.ExecuteThinkCycleBackgroundAsync(actorId, subscriptionId)` ([Background jobs](https://moberghr.github.io/beacon/integrate/background-jobs/#reference-implementation-moberg-warp) names their namespaces). ## 4. Register the maintenance jobs Beacon expects these recurring jobs. Register each at startup, after `app.Build()`, with `IRecurringJobManager`: | Id | Cron | Calls | |---|---|---| | `mcp-learning-aggregate` | `0 */6 * * *` | `IJobService.AggregateLearnedPatterns` | | `mcp-learning-cleanup` | `0 3 * * *` | `IJobService.CleanupOldSignals` | | `mcp-audit-retention` | `30 3 * * *` | `IMcpAuditRetentionService.PurgeExpiredAsync` | | `security-audit-retention` | `45 3 * * *` | `ISecurityAuditRetentionService.PurgeExpiredAsync` | | `security-audit-verify` | `15 * * * *` | `ISecurityAuditVerificationService.VerifyNewEventsAsync` | | `mcp-embedding-reindex` | `0 */12 * * *` | `IJobService.ReindexEmbeddings` | | `mcp-docchunk-reindex` | `0 */12 * * *` | `IJobService.ReindexDocChunks` | ```csharp var recurring = app.Services.GetRequiredService(); recurring.AddOrUpdate("mcp-learning-cleanup", x => x.CleanupOldSignals(CancellationToken.None), "0 3 * * *"); // ... one line per row of the table, each with its own wrapper method ... ``` While `Beacon:PreviousEncryptionKeys` lists a retired key, also run `IStoredValueReEncryptionService.ReEncryptAsync` once per start ([Background jobs](https://moberghr.github.io/beacon/integrate/background-jobs/#recurring-maintenance-jobs)). The Warp dashboard and the live `JobStatusChanged` progress push are features of the reference host's Warp setup; with Hangfire the UI still works, it just shows no live progress for documentation jobs. ## 5. Create the schema in SQL Server Beacon does not migrate itself. For one instance, call this after `builder.Build()`, as in the other recipe: ```csharp Beacon.Core.ServiceConfiguration.UseBeacon(app.Services, createSchema: true); ``` It creates the `beacon` schema when it is missing and applies the pending migrations. The login needs the right to create the schema and the tables in it. If it cannot, create the `beacon` schema as an administrator and call `UseBeacon(app.Services)` without `createSchema`. With several instances, or a separate migrator process that does not compose Beacon, call the standalone migrator once from a deployment step: ```csharp await Beacon.Core.SqlServer.BeaconSqlServerMigrator.MigrateAsync(connectionString); ``` It creates the schema and applies the migrations, and it is idempotent. Both paths write the history table `beacon.__EFMigrationsHistory`. SQL Server needs no extension. ## 6. Know what SQL Server does without pgvector On PostgreSQL, Beacon keeps embeddings in a `vector(384)` column with an HNSW index. On SQL Server there is no such column: Beacon stores the embedding bytes and computes similarity over them in memory. The embedding and document re-index jobs still run. As on PostgreSQL, retrieval is semantic only when you add `Moberg.Beacon.AI.Embeddings.Onnx` and call `AddBeaconOnnxEmbeddings()`; without it retrieval stays keyword-based. With it, SQL Server has no vector index to lean on, so the similarity work grows with the number of indexed items. ## 7. Monitoring SQL Server: the read-only login For a data source on SQL Server, create a login that can only read. Beacon's SQL Server path relies on its SQL checks and this login, because SQL Server has no read-only session ([Data sources](https://moberghr.github.io/beacon/features/data-sources/)): ```sql CREATE LOGIN beacon_reader WITH PASSWORD = '…'; USE YourDatabase; CREATE USER beacon_reader FOR LOGIN beacon_reader; ALTER ROLE db_datareader ADD MEMBER beacon_reader; ``` The connection string must verify the server: `Encrypt` Mandatory or Strict and no `TrustServerCertificate`, for example `Server=db.example.com;Database=YourDatabase;User Id=beacon_reader;Password=…;Encrypt=True;TrustServerCertificate=False`. Beacon refuses a connection that does not ([Transport security](https://moberghr.github.io/beacon/features/data-sources/#transport-security-tls)). ## Check it works - [ ] The app starts. No `Implementation of IBeaconScheduler is required.`, no encryption-key error. - [ ] In SQL Server, the `beacon` schema holds Beacon's tables and `beacon.__EFMigrationsHistory` lists the migrations. - [ ] `/beacon/setup`, the first admin and `curl -k https://localhost:/beacon/api/health` behave as in the other recipe. - [ ] The Hangfire tables exist (schema `HangFire`) and the Hangfire server is running. - [ ] After you create a subscription, `subscription-` is in the Hangfire recurring jobs. After you delete it, the entry is gone. - [ ] Create a subscription with a short schedule and wait for its run: the Hangfire job succeeds, with no "not marked as system work" warning in the log. ## If something goes wrong | Symptom | Cause and fix | |---|---| | A warning that a scope is not marked as system work, and the job does nothing | The job did not call `MarkAsSystemWork()` before Beacon code. Check that every wrapper method does. | | `NotImplementedException` mentioning scheduling or background enqueue | You use a feature (data contracts, AI documentation, AI actors, MCP evals) whose `IBeaconScheduler` member you have not implemented. | | A failed check notifies twice | Hangfire retried it. Keep `[AutomaticRetry(Attempts = 0)]` on the wrapper class. | | The migration fails with a permission error | The login cannot create the schema or tables. Create the `beacon` schema as an administrator and call `UseBeacon` without `createSchema`. | | Startup fails on `Beacon:Schema` | The schema is fixed at `beacon`. Remove the key. | | Jobs run twice with several instances | Hangfire stores recurring jobs in its database and fires each once across servers. Check that every instance uses the same Hangfire storage. | ## Next - [Embed Beacon in an existing ASP.NET Core app](https://moberghr.github.io/beacon/cookbooks/embed-postgresql-warp/): the steps this recipe leaves out. - [Background jobs](https://moberghr.github.io/beacon/integrate/background-jobs/): the scheduler contract and every job Beacon expects. - [Metadata database](https://moberghr.github.io/beacon/integrate/database/): both providers, migrations and generating SQL scripts. - [Production checklist](https://moberghr.github.io/beacon/integrate/production/): proxy, TLS, key rotation and backups. --- # Sign in to Beacon with Microsoft Entra ID > Single sign-on for Beacon's UI with Microsoft Entra ID: an app registration, a tenant allow-list, guests blocked, MFA required, Entra app roles mapped to functional permissions, and a break-glass account when local passwords are off. Source: https://moberghr.github.io/beacon/cookbooks/entra-sso/ | | | |---|---| | **You end up with** | People in your tenant sign in to Beacon's UI with **Continue with single sign-on**. Other tenants and guests are refused, MFA is required, new users arrive with no access until an Admin grants a role, and one emergency account still signs in with a password. | | **Time** | about 30 minutes | | **You need** | A running Beacon host with user management on and the first admin created ([Embed Beacon](https://moberghr.github.io/beacon/cookbooks/embed-postgresql-warp/) or the sample host), permission to register an application in your Entra tenant, and a way to set host configuration and secrets. | | **Applies to** | Beacon 5.0 · Full Beacon | :::tip[Let your AI do it] Part of this recipe is clicking in the Entra admin center, so the agent edits your host's configuration and guides you through the portal. Paste this into Claude Code or another coding agent, in the repository of your Beacon host. ```text wrap Read https://moberghr.github.io/beacon/cookbooks/entra-sso.md and follow it step by step in this repository. Ask me before every decision it marks as one. Edit only non-secret configuration; tell me which user-secrets or environment variables to set and let me type the client secret myself: never ask me to paste it into this chat. Do not change anything in my Entra tenant: tell me what to click and wait for me. Show me the result of each "Check it works" item, and do not turn local password sign-in off until I have confirmed the break-glass sign-in. ``` ::: ## Before you start | Decision | Options | Default | |---|---|---| | Who may sign in | Anyone in your tenant, or only members of a group or holders of an app role (`RequiredGroups`, `RequiredRoles`) | Your tenant, plus a group if you can | | Role a new user gets | None, or a role such as `Viewer` (never `Admin` or a functional role: startup fails) | None: an Admin grants access after the first sign-in | | MFA | `RequireMfa` on or off | On, after you have seen `mfa` in a test sign-in | | Local passwords | On, or off with one break-glass account | Off once SSO works | | Entra app roles to Beacon permissions | Map them, or assign functional roles on the Users page | Assign on the Users page unless your identity team owns the roles | ## 1. Register the application in Entra In the Microsoft Entra admin center, open **App registrations** and choose **New registration**. 1. **Name**: `Beacon`. **Supported account types**: single tenant. 2. **Redirect URI**: platform **Web**, value `https://beacon.example.com/signin-oidc`. This is your app's public origin plus Beacon's `CallbackPath`, which defaults to `/signin-oidc`. It is **not** under `/beacon`. Register one per environment (add `https://localhost:/signin-oidc` for development). 3. Under **Certificates & secrets**, create a client secret. Copy its **value** now. 4. From **Overview**, note the **Application (client) ID** and the **Directory (tenant) ID**. 5. Under **Token configuration**, add the optional claim `acct` to the ID token. Beacon uses it to tell a guest from a member; without it a guest is recognised only when the token names a foreign identity provider, and Beacon logs a warning once. Beacon uses the authorization code flow with PKCE and does not keep the tokens. ## 2. Configure Beacon Store the secret outside source control: ```bash dotnet user-secrets set "Beacon:Authentication:Oidc:ClientSecret" "" # elsewhere: the environment variable Beacon__Authentication__Oidc__ClientSecret ``` Then the rest, in `appsettings.json` (the keys are the ones in `src/Beacon.SampleProject/appsettings.json`): ```json { "Beacon": { "Authentication": { "Oidc": { "Enabled": true, "Authority": "https://login.microsoftonline.com//v2.0", "ClientId": "", "AllowedTenants": [ "" ], "BlockGuests": true, "RequireMfa": false } } } } ``` Your host must call `builder.Services.AddBeaconOidcAuthentication(builder.Configuration);`. The [embed recipe](https://moberghr.github.io/beacon/cookbooks/embed-postgresql-warp/#4-register-beacon-and-map-its-endpoints) and the sample host already do. What these keys do (the full table is in [Sign-in & identity](https://moberghr.github.io/beacon/integrate/authentication/#oidc--sso)): | Key | Effect | |---|---| | `Enabled`, `Authority`, `ClientId`, `ClientSecret` | All four are required once enabled, or the host does not start. | | `AllowedTenants` | Entra tenant ids (`tid`) that may sign in. Required: with SSO on and no tenant listed the host does not start. `AllowAnyTenant: true` is only for providers that issue no `tid`, and logs a warning with an Entra authority. | | `BlockGuests` | Default `true`. Refuses Entra B2B guests. | | `RequiredGroups`, `RequiredRoles` | When either is set, the user needs at least one listed group object id or app role. Entra puts groups in the token only if you add a groups claim under **Token configuration**. | | `DefaultRoleName` | The role a user gets at their first sign-in. Unset: no role. Cannot be `Admin` or the role of a functional permission. | | `RequireMfa`, `AcceptedAcrValues` | See step 4. | | `CallbackPath` | Change it only if you register a different redirect URI. | Admission runs before Beacon looks anyone up or creates a user: tenant first, then the guest block, then the group or role requirement. A person who fails any of them never gets a Beacon user. Behind a reverse proxy, configure `UseForwardedHeaders` so Beacon builds an `https` redirect URI ([Production checklist](https://moberghr.github.io/beacon/integrate/production/#reverse-proxy)). ## 3. Sign in and grant the first roles 1. Restart the host. The login page now shows **Continue with single sign-on**. 2. Click it and sign in with an account from your tenant. The first sign-in creates the user; with no `DefaultRoleName` the page says **Your account has no access yet.** 3. Sign in as the admin you created at first-run setup, open **Users**, edit the new user and assign a role. Beacon matches a returning user by the token's `sub` and issuer, and it always takes roles from its own store: it removes every role, group and `roles` claim the token carries before building the session. **An Entra role or group named `Admin` does not make anyone a Beacon Admin.** While Beacon manages users, Admin standing comes from the stored user's live roles, and only an existing Admin can assign the Admin role. A UserAdmin cannot. Nobody is provisioned before the first-run setup has created the super admin. ## 4. Require MFA `RequireMfa: true` refuses an SSO sign-in unless the ID token's `amr` claim contains `mfa`, or its `acr` claim is one of `AcceptedAcrValues`. Enforce MFA in Entra with a Conditional Access policy, and let Beacon check that it happened. 1. Sign in once with `RequireMfa` off and look at the ID token's `amr` claim. [Single sign-on admission](https://moberghr.github.io/beacon/security/user-management/#single-sign-on-admission) advises requesting `amr` as an optional ID-token claim under **Token configuration**. 2. When `mfa` is there, set `"RequireMfa": true`. A sign-in without it lands on the login page with a message that the sign-in did not use multi-factor authentication. `RequireMfa` covers the interactive SSO sign-in only. It does not apply to bearer tokens, MCP callers, API keys or password sign-in, break-glass included. Require MFA for tokens in Entra. ## 5. Map Entra app roles to functional permissions (optional) Beacon's six functional permissions (`Approver`, `Auditor`, `SecurityAdmin`, `UserAdmin`, `DataSourceAdmin`, `MigrationOperator`) can come from Entra app roles at sign-in. They carry no project data and are never `Admin`, `Editor` or `Viewer`. 1. In the app registration, open **App roles** and create roles, for example with the value `Beacon.Auditor`. Assign people or groups to them under **Enterprise applications**, **Users and groups**. 2. Map them: ```json { "Beacon": { "Authorization": { "IdpRoles": { "Enabled": true, "Map": { "Beacon.Auditor": "Auditor", "Beacon.SecurityAdmin": "SecurityAdmin" }, "ClaimLifetimeMinutes": 60 } } } } ``` A mapped permission is added to the session when the user signs in and is never stored. It counts for `ClaimLifetimeMinutes` (default 60, at most 1440), does not show on the Users page and never reaches an API key; signing in again renews it. The host does not start when `Enabled` is true with an empty `Map`, or a value that is not one of the six permission names. More in [Access control](https://moberghr.github.io/beacon/integrate/access-control/). ## 6. Turn local passwords off and keep a break-glass account Do this last. Confirm first that SSO works and that the super admin from setup can sign in with a password. ```json { "Beacon": { "UserManagement": { "LocalLogin": { "Enabled": false, "BreakGlassUserNames": [ "" ] } } } } ``` With `Enabled: false` the login page shows no password form, and only the listed accounts may still sign in with a password. A name is a stored, enabled, internal user name, matched exactly and case-sensitively; at most three. Each break-glass sign-in logs `BreakGlassSignIn` (event 7119) as a warning: treat it as an incident. The host does not start if local login is off and nobody could sign in. Open `/beacon/login?local=1` to reach the password form. `HostOwnsSignIn` is not for this recipe. It is for a host whose own identity signs people in and which decides whom Beacon provisions: it removes first-run setup and the super admin. Leave it off ([User management](https://moberghr.github.io/beacon/security/user-management/#when-the-host-owns-sign-in)). ## Check it works - [ ] The host starts. No `Beacon:Authentication:Oidc is Enabled but ...` error. - [ ] `curl -s https://beacon.example.com/beacon/api/auth/sso` answers `{"enabled":true,...}`, and the login page shows **Continue with single sign-on**. - [ ] An account from your tenant signs in and the user appears on **Users** with no role (or `DefaultRoleName`). - [ ] An account from another tenant, and a guest, are sent back to `/beacon/login` with "Your account is not permitted to use Beacon." - [ ] With `RequireMfa` on, a sign-in without MFA is refused with the multi-factor message, and one with MFA works. - [ ] With `IdpRoles` on, a user holding `Beacon.Auditor` can open the pages that permission opens. - [ ] With local login off, the break-glass account signs in at `/beacon/login?local=1`, and every other password sign-in is refused. ## If something goes wrong | Symptom | Cause and fix | |---|---| | Entra shows `AADSTS50011` (reply URL does not match) | The registered redirect URI is not exactly `/signin-oidc`, including `https` and the port. Behind a proxy, fix forwarded headers so Beacon sends `https`. | | Startup: `... admits every tenant` | List your tenant in `AllowedTenants`. | | Startup: `DefaultRoleName cannot be the Admin role ...` | Pick `Viewer` or `Editor`, or leave it unset. | | Startup: local login is off and nobody could sign in | List a break-glass user name, or turn SSO on, or set `LocalLogin:Enabled` back to `true`. | | Everyone is sent back with "Your account is not permitted ..." | Tenant, guest, group or role admission failed. The host log has one Warning with the reason, the tenant and a hash of the subject, never the name. | | "Single sign-on failed" | The token was not valid for the app (wrong client secret, expired secret, wrong authority). Check the host log for `OIDC remote failure`. | | Signed in but "Your account has no access yet." | The new user has no role. An Admin assigns one on **Users**, or set `DefaultRoleName`. | | First SSO sign-in is refused before setup | Nobody is provisioned until first-run setup has created the super admin. Complete setup first. | | An Entra `Admin` role does nothing in Beacon | By design: roles come from Beacon's store. Assign the Admin role on **Users**. | | Mapped permission disappeared | It lasts `ClaimLifetimeMinutes` after the sign-in. Sign in again. | ## Next - [Sign-in & identity](https://moberghr.github.io/beacon/integrate/authentication/): every OIDC option, sessions, bearer tokens and API keys. - [Authorization](https://moberghr.github.io/beacon/security/authorization/): global roles, functional permissions and project access. - [User management](https://moberghr.github.io/beacon/security/user-management/): roles, first-run setup and the Users page. - [Connect Claude through Entra](https://moberghr.github.io/beacon/mcp/entra-callers/): MCP callers signing in with Entra tokens, which `Oidc:McpJwksEndpoint` and `Beacon:Mcp:Callers` handle separately from this sign-in. --- # Alert Slack or Teams when a nightly check fails > A saved query that returns the rows breaking a rule, a nightly subscription that posts them to Slack or Microsoft Teams, and a task that opens on failure and closes itself when the check passes again. Source: https://moberghr.github.io/beacon/cookbooks/alert-on-failed-check/ | | | |---|---| | **You end up with** | A nightly check on one of your databases. When it finds rows that break a rule, a message lands in a Slack or Teams channel and a task opens; when a later run finds none, the task resolves itself. | | **Time** | about 20 minutes | | **You need** | A running Beacon with a [data source](https://moberghr.github.io/beacon/features/data-sources/) for the database (or the rights to add one), a Slack or Teams channel you may add a webhook to, and the right to create queries and subscriptions. An Admin or SecurityAdmin creates the recipient. | | **Applies to** | Beacon 5.0 · Full Beacon | :::tip[Let your AI do it] This recipe is done in Beacon's web UI, so the agent guides you instead of doing it. Paste this into Claude or another assistant. ```text wrap Guide me through https://moberghr.github.io/beacon/cookbooks/alert-on-failed-check.md one step at a time. Read it first. Ask me for the database, the rule to check and the channel before each step that needs them. After each step, ask me to confirm what I see on screen against the recipe's "Check it works" list before moving on. Never ask me to paste a webhook URL or a connection string into this chat: I type those into Beacon myself. ``` ::: ## Before you start | Decision | Options | Default | |---|---|---| | What counts as a failure | A query that returns **the rows that break the rule**; any row is a failure | Required: Beacon has no separate "check condition" | | When to message you | **When result count changes**, **Every execution** or **When result count increases** | **When result count changes**: one message when the problem appears or changes, not one every night | | Channel | Slack, Microsoft Teams, or both as two recipients | Required | | Track it as a task | On or off | On: the task is the record of whether anyone dealt with it | A subscription has no pass or fail setting of its own. It runs a query and looks at the row count. So write the check as a query that returns the offenders, and an empty result means the check passed. ## 1. Add the data source Skip this if the database is already connected. 1. Open **Data Sources** and click **New data source**. 2. Pick the **Data source type**, enter a **Name** and the connection details, and pick the **Projects** to link it into. 3. Click **Test connection**, then **Create data source**. Use a read-only login: Beacon only ever reads. Details and TLS requirements are in [Data Sources](https://moberghr.github.io/beacon/features/data-sources/#create-a-data-source). ## 2. Write the check as a query 1. Open **Queries**, click **New query**, and enter a **Query name** and a **Description**. The name is what appears in the alert title. 2. In the first step, set a **Step name**, the **Target database** (your data source) and the **SQL query**. 3. Click **Save & run** to see the result. ```sql -- Orders marked completed but not paid: any row is a failure. SELECT id, status, payment_status FROM orders WHERE status = 'completed' AND payment_status <> 'paid' ``` - [ ] A healthy database returns no rows. - [ ] The columns are ones you are happy to post to a channel. Beacon masks columns it detects as PII before rows leave, but leave out what the channel does not need. Keep the window in the SQL itself (`created_at >= now() - interval '1 day'`). A task resolves on a run with zero rows, so a window that is empty only because the night was quiet closes it too early. See [Queries](https://moberghr.github.io/beacon/features/queries/) for parameters and multi-step checks. ## 3. Create the Slack or Teams recipient A recipient is one destination on one channel. Only an Admin or a SecurityAdmin creates one. 1. Create the webhook: - **Slack:** create an app with **Incoming Webhooks** on, add a webhook to the channel, copy its URL. - **Teams:** in the channel, create a Workflows (Power Automate) flow that starts when a webhook request is received and posts the card it receives. Copy its URL. Old `outlook.office.com` connector URLs are refused. 2. In Beacon, open **Recipients** and click **Add recipient**. 3. Set **Notification type** to **Slack** or **Teams**, enter a **Name**, and paste the URL into **Slack webhook URL** or **Teams webhook URL**. Click **Create recipient**. Beacon stores the URL encrypted and shows it masked afterwards. It must be HTTPS on a vendor host (`hooks.slack.com`, or Teams hosts on `*.logic.azure.com`, `*.environment.api.powerplatform.com` or `*.webhook.office.com`). The full rules are in [Destination policy](https://moberghr.github.io/beacon/features/notifications/#destination-policy). :::note[Four-eyes approval of destinations] If your Beacon has the [approval workflow](https://moberghr.github.io/beacon/security/approvals/) on, a new recipient receives nothing until a second person (an Approver or Admin other than you) approves exactly where it delivers, on **Approvals** under **Recipients**. The dialog then says "Submitted for approval". Ask the approver to compare the fingerprint on the **Recipients** page with yours. Until it is approved, a run that has no other recipient is recorded as **Skipped**. See [Approval of new and changed destinations](https://moberghr.github.io/beacon/features/notifications/#approval-of-new-and-changed-destinations). ::: - [ ] The recipient appears in the **Recipients** list and is not marked **Awaiting approval**. ## 4. Schedule the check 1. Open the query and click **Add subscription** (or open **Subscriptions** and click **New subscription**). 2. **Query:** set the **Cron expression**. `0 2 * * *` runs every night at 02:00. Fill in the query's parameters, if any. 3. **Notify:** set **Send notification** (see the table above), pick your recipient under **Recipients**, and tick **Create a task and keep it open while the query returns rows**. 4. **Results:** leave **Show query text in notification** off unless the channel should see the SQL. 5. Click **Create subscription**. You cannot edit a subscription's schedule or trigger afterwards. To change them, create a new subscription and archive the old one. Recipients can be added and removed later. The subscription runs as you. If you lose access to the query's data source, its runs are skipped; an Admin can **Change owner**. See [Subscriptions](https://moberghr.github.io/beacon/features/subscriptions/#scheduled-runs-run-as-the-owner). ## 5. Test it On the subscription's page, click **Test now**. It runs the subscription once, as you, and delivers to its recipients without changing the schedule. To see a failure, run a test while the check returns rows (insert a bad row in a test database, or temporarily loosen the SQL). To see it pass, run a test on a clean result. ## What the message contains - **Title:** `[Beacon] {data source} - {query name}`. - **Rows:** a preview of the result's first rows, after PII masking, and the total row count. Slack shows up to 25 rows and 5 columns as a text table; Teams shows an Adaptive Card with the first 10 rows and 3 columns. - **The query text**, only if you turned **Show query text in notification** on. - **A link back to Beacon**, when the host has set a base URL that ends in `/beacon`. Rows that reach recipients are masked by the PII settings of the projects that link the data source. See [What a run reads and delivers](https://moberghr.github.io/beacon/features/subscriptions/#what-a-run-reads-and-delivers). ## 6. Watch it - **Tasks** lists one open task for the subscription while the check keeps failing. Assign it to yourself, then **Resolve** it with notes when you have fixed the cause. A run with zero rows resolves it automatically with "Auto-resolved: Query returned 0 results"; a later failure opens a new task. See [Tasks](https://moberghr.github.io/beacon/features/tasks/). - **Control Tower** shows the subscription's health (Green, Amber, Red, **Stalled**), its success rate and its open tasks. A run with no rows counts as a success. See [Control Tower](https://moberghr.github.io/beacon/features/control-tower/). - **Notifications** lists every run with its status. Only **Sent** reaches the channel; **No results**, **Below threshold** and **Silenced** notify nobody by design. Over the REST API, the same steps are `POST /beacon/api/recipients`, `POST /beacon/api/queries`, `PUT /beacon/api/queries/{id}`, `POST /beacon/api/subscriptions` and `POST /beacon/api/subscriptions/{id}/execute`. See [Routes](https://moberghr.github.io/beacon/reference/routes/). ## Check it works - [ ] **Test now** on a failing check posts a message to the channel with the title `[Beacon] {data source} - {query name}`. - [ ] A task for the subscription is listed under **Tasks** as **Unresolved**. - [ ] **Test now** on a clean result posts nothing, the run shows **No results**, and the task is **Resolved**. - [ ] The subscription is **Green** in **Control Tower** after its first scheduled run. ## If something goes wrong | Symptom | Cause and fix | |---|---| | Saving the recipient is refused | The URL is outside the destination policy: not HTTPS, not a vendor host, or a retired Teams connector URL. The message names the problem. | | **Test now** answers `409` | Every recipient waits for approval. Approve it on **Approvals**, or attach one that is approved. | | Run shows **Failed** | Delivery to a recipient failed. Beacon does not retry; check the webhook, then use **Test now** or wait for the next run. | | Run shows **Silenced** | The trigger did not fire: with **When result count changes**, the count equals the last run's. Pick **Every execution** if you want a message each night. | | Run shows **Skipped** | The owner may no longer run the query, the query change is not approved, or every recipient waits for approval. The run's reason says which. | | The link in the message is broken | The base URL is missing `/beacon`. An Admin sets it under **Admin Settings**. | | The task closed but the problem persists | The query returned zero rows on one run. Widen its time window. | ## Next - [Subscriptions](https://moberghr.github.io/beacon/features/subscriptions/): triggers, thresholds, attachments and anomaly detection - [Notifications](https://moberghr.github.io/beacon/features/notifications/): every channel, webhook bodies and delivery history - [Approvals](https://moberghr.github.io/beacon/security/approvals/): four-eyes review of queries and destinations - [Score a table with a data contract](https://moberghr.github.io/beacon/cookbooks/data-contract/): scheduled quality rules with a score and alerts --- # Score a table with a data contract > A data contract on one table: rules for freshness, volume, nulls, uniqueness and more, evaluated on a schedule into a 0 to 100 score, with an alert to Slack, Teams or email when the score drops. Source: https://moberghr.github.io/beacon/cookbooks/data-contract/ | | | |---|---| | **You end up with** | A contract on one table with a handful of rules, evaluated on a cron schedule. Each evaluation stores a weighted score, the table shows a trend, and a drop below your threshold alerts a channel. | | **Time** | about 20 minutes | | **You need** | A PostgreSQL, SQL Server or MySQL [data source](https://moberghr.github.io/beacon/features/data-sources/) whose metadata is loaded, and the right to run saved SQL on it through a project. For alerts, a [recipient](https://moberghr.github.io/beacon/features/notifications/#recipients). A Custom SQL rule needs an Admin. | | **Applies to** | Beacon 5.0 · Full Beacon | :::tip[Let your AI do it] This recipe is done in Beacon's web UI, so the agent guides you instead of doing it. Paste this into Claude or another assistant. ```text wrap Guide me through https://moberghr.github.io/beacon/cookbooks/data-contract.md one step at a time. Read it first. Ask me which table to cover and which problems matter most (stale data, missing rows, nulls, duplicates, orphans) before proposing rules, and show me each rule's JSON configuration for approval before I type it in. After each step, ask me to confirm what I see against the "Check it works" list. Do not suggest a Custom SQL rule unless I am an Admin and a built-in rule type cannot express the check. ``` ::: ## Before you start | Decision | Options | Default | |---|---|---| | Table | One table per contract: a schema and a table of the data source | Required | | Rules | Freshness, Volume, Null rate, Uniqueness, Referential, Range, Pattern, Custom SQL | Start with two or three | | Schedule | A 5-field cron expression | `0 */6 * * *` (every 6 hours) | | Alert | **Alert on failure** and a **Failure threshold (%)** | On, 80 | Data contracts evaluate on PostgreSQL, SQL Server and MySQL only. On another engine the rules fail. The table and each rule's columns must exist in the data source's stored metadata: refresh the metadata first when the table is new. ## 1. Open the Data Quality page Open **Data Quality** and click **New contract**. ## 2. Describe the contract | Field | What to enter | |---|---| | **Contract name** | For example `Orders freshness and volume` | | **Data source** | Your database data source | | **Schema name** / **Table name** | For example `public` / `orders` | | **Cron expression** | `0 */6 * * *`, or `0 3 * * *` for nightly | | **Failure threshold (%)** | `80` is the default; alerts fire when the score is below it | | **Enabled** | On, so the schedule is active | | **Alert on failure** | On, then pick **Notification recipients** | ## 3. Add rules Click **Add rule** for each. Each has a **Name**, **Type**, optional **Column**, **Severity**, **Weight** and a **Configuration (JSON)**. You do not write the schema or table: Beacon takes them from the contract. | Type | Checks | Configuration | |---|---|---| | **Freshness** | Newest timestamp is not older than a limit | `{"column": "updated_at", "maxAgeMinutes": 60}` | | **Volume** | Row count in range | `{"minRows": 100}` | | **Null rate** | NULL percentage at most a limit | `{"column": "email", "maxNullPercent": 5}` | | **Uniqueness** | No duplicate values | `{"column": "id"}` | | **Referential** | Every value exists in a reference table | `{"column": "user_id", "referenceTable": "users", "referenceColumn": "id"}` | | **Range** | Numbers between `min` and `max` | `{"column": "age", "min": "0", "max": "150"}` | | **Pattern** | Every value matches a pattern | `{"column": "email", "pattern": "^[^@]+@[^@]+$"}` | | **Custom SQL** | Any check you can write as SQL | See [Custom SQL rules](#custom-sql-rules) | Pattern behaves differently by engine: a regular expression on PostgreSQL and MySQL, `LIKE` wildcards on SQL Server. **Severity** multiplies a rule's influence (Low ×1, Medium ×2, High ×3, Critical ×4) and **Weight** (0.1 to 10, default 1) tunes it within a severity. Make the rules whose failure you cannot tolerate Critical. A passing Critical freshness rule and a failing Low volume rule, both weight 1, score `(100×4 + 0×1) / (4+1) = 80%`. Most rules score 100 or 0; Null rate and Range lose points in proportion to the overrun. See [Scoring](https://moberghr.github.io/beacon/features/data-quality/#scoring). Click **Create**. An enabled contract is scheduled at once. ## 4. Evaluate it now On the contract's page, click **Evaluate now**. It runs every enabled rule once as you, stores the evaluation and updates the table's score. It sends no alert: only scheduled runs alert. The page's tabs show the result: - **Rules**: the rule set. - **Evaluations**: the history, newest first, with the overall score and pass and fail counts. - **Latest results**: per rule, pass or fail, score, expected and actual value and message. A rule that cannot run (a bad configuration, a missing column, a timeout of 60 seconds) is recorded as failed with score 0, and the other rules still run. ## 5. Alert on a drop With **Alert on failure** on and a recipient attached, a **scheduled** evaluation scoring below the threshold sends a notification titled `[Data Quality] {contract name}` to each recipient. It carries the overall score, the threshold, the table, pass and fail counts and every failed rule with its expected and actual value. To check it end to end, set **Failure threshold (%)** to 100 with a rule that currently fails, wait for the next scheduled run (or set a near cron time), and watch the channel. Then put the threshold back. The table's trend is **Improving**, **Stable** or **Degrading** by more than 1 point against the previous score. See [Table scores and trends](https://moberghr.github.io/beacon/features/data-quality/#table-scores-and-trends). ## Custom SQL rules Use one when no built-in type fits. Only an **Admin** can create, change or delete a contract that contains one. 1. Add a rule of type **Custom SQL**. 2. Put a single read-only `SELECT` in the configuration: ```json {"sql": "SELECT CASE WHEN COUNT(*) = 0 THEN 1 ELSE 0 END AS passed, COUNT(*) AS actual_value, 'orders without a customer' AS message FROM orders WHERE customer_id IS NULL"} ``` - It must return `passed` (`1` or `0`). `score` (0 to 100), `actual_value` and `message` are optional. - Do not set your own `LIMIT`, `TOP` or `FETCH`: Beacon reads one row. - Name only the data source's own database. Other qualifiers are refused. - PII in `actual_value` and `message` is masked before it is stored or alerted. :::note[Approval] If your Beacon has the [approval workflow](https://moberghr.github.io/beacon/security/approvals/) on, a Custom SQL rule that is new or whose SQL changes waits for a second person (an Approver or Admin other than you) and is not evaluated until then. The save says "Submitted for approval", the rule shows **Awaiting approval**, and the reviewer decides on **Approvals** under **Custom SQL rules**. Evaluations that leave a waiting rule out are marked **Partial** and do not replace the table's score. See [Approval of Custom SQL](https://moberghr.github.io/beacon/features/data-quality/#approval-of-custom-sql). ::: ## Over the REST API The same contract can be managed under `/beacon/api/data-quality`: `POST /contracts` to create, `POST /contracts/{id}/evaluate` to run now, `GET /contracts/{id}/evaluations` for the history and `GET /overview` for scores per data source. A scoped API key needs the Execute scope for writes. See [Data Quality → REST API](https://moberghr.github.io/beacon/features/data-quality/#rest-api). ## Check it works - [ ] The contract page shows **Latest score** after **Evaluate now**, and **Evaluations** lists the run. - [ ] **Latest results** shows each enabled rule as pass or fail with an actual value. - [ ] The **Data Quality** overview shows the table with its score. - [ ] After a scheduled run below the threshold, the recipient's channel gets `[Data Quality] {contract name}`. ## If something goes wrong | Symptom | Cause and fix | |---|---| | Saving says "Table … is not in the data source's metadata" | The table or a column is not in the stored metadata. Refresh the data source's metadata, or fix the spelling. | | "Data contract's data source must be a database type" | The data source is not a database (for example an API). Pick a database data source. | | A rule fails with "Rule type … is not supported on …" | The engine is not PostgreSQL, SQL Server or MySQL. | | "Invalid rule configuration" | The JSON is incomplete for the type. Compare it with the table above. | | "Custom SQL must not set its own LIMIT, TOP or FETCH" | Remove the outer row limit. | | "Rule timed out." | The check ran over 60 seconds. Narrow it or index the column. | | No alert despite a low score | Alerts need **Alert on failure**, a recipient, a score strictly below the threshold and a scheduled run. A recipient waiting for approval receives nothing. | | **Owner not authorized** on the contract | The owner may no longer run saved SQL on the data source, so evaluations are skipped. An Admin or UserAdmin uses **Change owner**. | | "Every enabled rule … is a Custom SQL rule waiting for approval" | Approve it on **Approvals**, or enable a rule that does not wait. | ## Next - [Data Quality](https://moberghr.github.io/beacon/features/data-quality/): ownership, scoring and every rule option - [Alert Slack or Teams when a nightly check fails](https://moberghr.github.io/beacon/cookbooks/alert-on-failed-check/): the query-based alternative - [Notifications](https://moberghr.github.io/beacon/features/notifications/): channels and destination approval - [Approvals](https://moberghr.github.io/beacon/security/approvals/#migration-jobs-custom-sql-rules-and-recipients): reviewing Custom SQL rules --- # Ship the MCP audit and security events to your SIEM > Send Beacon's MCP audit events and security log events to your log pipeline, turn on OpenTelemetry traces and metrics, set retention and fail-closed audit, and read the MCP audit in the UI. Source: https://moberghr.github.io/beacon/cookbooks/audit-to-siem/ | | | |---|---| | **You end up with** | The `Beacon.Audit` log stream and the security log events in your SIEM, traces and metrics in your collector, retention and content locks set, and alerts on the signals that matter | | **Time** | about 30 minutes | | **You need** | A running Beacon host ([Integrate Beacon](https://moberghr.github.io/beacon/integrate/) or the [MCP gateway](https://moberghr.github.io/beacon/mcp/gateway/)), a log pipeline that reads container stdout (CloudWatch, a collector or a SIEM agent), and for traces an OpenTelemetry collector | | **Applies to** | Beacon 5.0 · Both. The MCP audit (events 9100 to 9103, the UI page, tracing and metrics) applies to the gateway too. The security audit trail (9104 to 9114) and its page are full Beacon: the gateway serves no security audit API or page | :::tip[Let your AI do it] Paste this into Claude Code (or another coding agent) in your Beacon host's repository. ```text wrap Read https://moberghr.github.io/beacon/cookbooks/audit-to-siem.md and follow it step by step in this repository. Ask me which log pipeline and collector we use, and what retention policy applies. Never commit keys or endpoints with credentials, never turn off an audit setting, and show me the result of each "Check it works" item. ``` ::: ## Before you start | Decision | Options | Default | |---|---|---| | Fail closed | Withhold a tool result when its audit row was not written (`Audit:Required`), or return it anyway | Fail closed where an unaudited answer is not acceptable | | MCP audit retention | Keep forever, or purge rows older than N days | Forever (`null`): set it to your policy | | Audit content | Keep the question and SQL of every call (`RequireContent`), let each project decide, or keep none (`ForceNoContentRetention`) | Each project decides. The two locks cannot both be on | | Traces and metrics | OTLP to a collector, or none | None: Beacon registers no exporter | ## 1. Turn on structured logs Beacon writes its audit events as structured log events under the category **`Beacon.Audit`**. JSON console logs carry the event id, category, level and trace ids with no OpenTelemetry needed: ```json { "Logging": { "Console": { "FormatterName": "json", "FormatterOptions": { "IncludeScopes": true } } } } ``` Make sure the log level for the category lets Information through (`"Beacon.Audit": "Information"` under `Logging:LogLevel`): the copies of committed events are Information, and a filtered category drops them. Ship the stream with whatever reads your container's stdout, such as the `awslogs` driver into CloudWatch Logs. ## 2. Know what to route `Beacon.Audit` holds identifiers, counts and digests only: never parameters, SQL, a question or a raw error message. | EventId | Name | Level | Meaning | |---|---|---|---| | 9100 | `McpToolAudited` | Information | One per MCP tool call, with `Tool`, `ProjectId`, `CallerKind`, `CallerHash`, `DurationMs`, `Rows`, `ErrorClass`, `TraceId`; `Persisted` says whether the row was written | | 9101 | `McpToolResultWithheld` | Warning | A result was withheld because its audit row failed (needs `Audit:Required`) | | 9102 | `McpAuditPurged` | Information | The MCP audit retention purge ran | | 9103 | `McpAuditRead` | Information | Someone read the MCP audit | | 9104 | `SecurityAuditRecorded` | Information | A security audit event was committed (copy of the row; match by `EventGuid`) | | 9105 | `SecurityAuditRead` | Information | The security trail was read or verified | | 9106 | `SecurityAuditPurged` | Information or Warning | The security trail's retention purge ran | | 9107 | `SecurityAuditWriteFailed` | Error | A security event could not be stored | | 9108 | `SecurityAuditVerificationFailed` | Error | Stored security events failed verification | | 9109 | `SecurityAuditCheckpoint` | Information | One per scheduled verification run: keep these, they anchor the trail outside the database | | 9110 | `SecurityAuditPurgeFailed` | Error | The security trail's purge did not run | | 9111 | `McpAuditVerificationFailed` | Error | Stored MCP audit rows failed verification | | 9112 | `McpAuditCheckpoint` | Information | One per MCP audit verification run: keep these with the 9109 checkpoints | | 9113 | `AuditTableVerificationFailed` | Error | Stored execution log or settings history rows failed verification (`Store` names the table) | | 9114 | `AuditTableCheckpoint` | Information | One per sealed table per verification run: keep these with the 9109 checkpoints | The full field list is in [MCP observability and audit](https://moberghr.github.io/beacon/mcp/observability-and-audit/#the-beaconaudit-log-event-catalogue) and [Shipping to a SIEM](https://moberghr.github.io/beacon/security/audit-trail/#shipping-to-a-siem). The structured security log events 7101 to 7123 (membership and role changes, sessions, break-glass sign-ins, refused access, row data sent to an LLM, and more) have their own ids: route them too. The list is in the [security model](https://moberghr.github.io/beacon/security/#12-audit-and-security-events). Alert at least on 7119 (break-glass), 7113 and 7114 (role changes, new users), 7115 (write targets) and 7116 (a statement tried to end SQL Server's read-only transaction). The copy is best-effort: the database is the source of truth, and the 9107 lines and the 9109 checkpoints let you notice when the two disagree. ## 3. Set retention, content and fail-closed audit ```jsonc { "Beacon": { "Mcp": { "Audit": { "Required": true, // withhold a result whose audit row was not written "RetentionDays": 400, // purge MCP audit rows older than this; null keeps them forever "RequestIdHeader": "X-Request-Id", "RequireContent": false // true pins question and SQL retention on, for everyone } }, "Audit": { "RetentionDays": 400 // security audit trail; at least 365; unset keeps events forever } } } ``` - `Required: true` replaces the result with *Result withheld: the audit record for this call could not be written.* and logs 9101. The query has already run by then: the guarantee is that no unaudited data leaves Beacon. - `RequireContent: true` keeps the question and the SQL of every statement a call ran until the retention purge. Review that against your privacy policy first. It contradicts `Beacon:Mcp:ForceNoContentRetention`, and the host does not start with both on. The gateway pins no content retention unless you set `RetainToolContent = true`. - Both purges are jobs you schedule: the MCP purge is `IMcpAuditRetentionService.PurgeExpiredAsync`, the security trail has `ISecurityAuditRetentionService.PurgeExpiredAsync` and an hourly `ISecurityAuditVerificationService.VerifyNewEventsAsync`. Set `Beacon:Audit:IntegrityKey` from a secret store before you change the encryption key. See [Retention](https://moberghr.github.io/beacon/security/audit-trail/#retention) and [Keys and rotation](https://moberghr.github.io/beacon/security/audit-trail/#keys-and-rotation). ## 4. Turn on traces and metrics (optional) Beacon registers no exporter. Reference the OpenTelemetry packages your platform needs and add Beacon's sources: ```csharp builder.Services.AddOpenTelemetry() .ConfigureResource(x => x.AddService(serviceName: "your-app")) .WithTracing(x => x .AddAspNetCoreInstrumentation() .AddBeaconInstrumentation() // source: "Beacon" .AddBeaconMcpSdkTracing() // source: "Experimental.ModelContextProtocol", status descriptions cleared .AddOtlpExporter()) .WithMetrics(x => x .AddAspNetCoreInstrumentation() .AddBeaconInstrumentation() // meters: "Beacon" and "Experimental.ModelContextProtocol" .AddOtlpExporter()); ``` Set `OTEL_EXPORTER_OTLP_ENDPOINT` (and `OTEL_SERVICE_NAME`) in the environment: Beacon reads no endpoint or credential itself. Beacon tags the MCP SDK's own `tools/call` span with `beacon.project.id`, `beacon.caller.hash`, `beacon.error.class` and similar, so one MCP call is one trace. Each audit row carries its `TraceId`, which joins the SIEM, the trace and the row. Alert on any increment of these counters: | Counter | Meaning | |---|---| | `beacon.mcp.audit.write_failures` | An MCP audit row was not written | | `beacon.security_audit.write_failures` | A security event was not stored, or a purge did not run | | `beacon.security_audit.verification_failures` | Stored security events failed verification | `beacon.mcp.tool.calls` (tagged `gen_ai.tool.name`, `beacon.outcome`) and `beacon.mcp.tool.rows` give usage and error rates. Tool names on the metric are bucketed (`q_*`, `api_*`) so caller-chosen names cannot create unbounded series. `beacon.tool.input` (the tool input on the span) stays off unless both `Beacon:Telemetry:CaptureContent` and the project's content retention allow it. A collector that receives OTLP and exports to X-Ray and CloudWatch metrics: ```yaml receivers: otlp: protocols: http: grpc: processors: batch: exporters: awsxray: awsemf: service: pipelines: traces: receivers: [otlp] processors: [batch] exporters: [awsxray] metrics: receivers: [otlp] processors: [batch] exporters: [awsemf] ``` The collector holds the cloud credentials, never Beacon. ## 5. Read the audit in Beacon The **MCP Audit** page (`/beacon/mcp-audit`, in the sidebar for people with the Auditor permission) lists tool calls with time, tool, project, caller, user, duration, rows and outcome. Filter by From and To (UTC), Tool, Project id, User id and Caller hash. The gateway's admin UI serves it too. Reading the audit is recorded: 9103 and, in full Beacon, a security audit event. The same data is available as a paged JSON export for an Auditor or an Admin (sign in as one): ```text wrap GET https://beacon.example.com/beacon/api/mcp/audit?from=2026-10-01T00:00:00Z&to=2026-10-21T00:00:00Z&pageSize=100 ``` `from` and `to` are required and at most 93 days apart; `pageSize` is 1 to 500. Rows carry `contentHash` and their `statements`, with `sql` only where the project retained content. Full Beacon also has a **Security Audit** page for the security trail. ## Check it works - [ ] Make one MCP tool call. Your SIEM shows a `Beacon.Audit` event 9100 for it, and its `TraceId` matches the **MCP Audit** row. - [ ] In full Beacon, change a project member. A 9104 event arrives with an `EventGuid` that matches the Security Audit row. - [ ] With a collector configured, a tool call shows one trace tagged with `beacon.project.id`. - [ ] With `Audit:Required` on, a tool result is never returned without its row; a failure shows as 9101 and an increment of `beacon.mcp.audit.write_failures`. - [ ] The retention jobs run: 9102 (and 9106 in full Beacon) appear after the purge. ## If something goes wrong | Symptom | Cause and fix | |---|---| | No `Beacon.Audit` events in the SIEM | The category is filtered below Information, or the log driver does not ship stdout. Check `Logging:LogLevel` for `Beacon.Audit`. | | 9100 events but no 9104 | 9104 is full Beacon only and best-effort: it is missing when the category is filtered or the process stops between commit and log. | | Host fails to start with an options error | `RetentionDays` must be greater than 0 (security trail: 365 to 36500), `RequestIdHeader` must be a valid header token, and `RequireContent` cannot be on with `ForceNoContentRetention`. | | No `CorrelationId` on old rows | Rows written before it existed carry none; every later row and 9100 event has one Beacon minted. | | `UpstreamRequestId` is empty | The header is absent, longer than 128 characters, or has characters outside `[A-Za-z0-9._:-]`. | | No traces | `AddBeaconInstrumentation()` is not on the tracer provider, or `OTEL_EXPORTER_OTLP_ENDPOINT` is unset. | | Logs in the dispatched endpoint carry no trace id | Host endpoint dispatch does not flow `Activity.Current`. See [Trace continuity](https://moberghr.github.io/beacon/mcp/observability-and-audit/#trace-continuity-into-host-endpoint-dispatch). | | The audit page is missing from the sidebar | The user lacks the Auditor permission. | ## Next - [MCP observability and audit](https://moberghr.github.io/beacon/mcp/observability-and-audit/): every field, tag and instrument. - [Security audit trail](https://moberghr.github.io/beacon/security/audit-trail/): the event catalogue, integrity and verification. - [Production checklist](https://moberghr.github.io/beacon/integrate/production/#audit-and-observability): what to set before real users. --- # 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. |