Skip to content
Menu

Expose an endpoint as an MCP tool, safely, and test it

View .md
You end up withA 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
Timeabout 30 minutes
You needAn ASP.NET Core application with an MCP gateway (MCP gateway) or full Beacon with AddHostEndpointTools (Host endpoint tools); a PostgreSQL or SQL Server database with Beacon’s schema migrated for the test
Applies toBeacon 5.0 · Both (gateway: x.Tools(...); full Beacon: AddBeaconMcp().AddHostEndpointTools(...))
DecisionOptionsDefault
Which endpointOne that reads or changes the signed-in person’s own recordsA /api/my/... read
Read or writeReadOnly = true, or ReadOnly = false (needs AllowWriteTools)Read
How the person’s id arrivesBound from a claim of the call’s principal (BindFromCaller)Bound, never asked of the agent
How the agent is held to self-serviceOnly mark self-service endpoints, and check IsBeaconMcpCall() where one endpoint also serves the browserMark only self-service endpoints

1. Pick the endpoint and check it against the review list

Section titled “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.

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:

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):

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.

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.

A write tool is off until AllowWriteTools is on (step 2). Declare ReadOnly = false and state Destructive and Idempotent:

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.

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:

using Beacon.Core.Mcp;
if (httpContext.User.IsBeaconMcpCall())
{
// no permissions, no manager grants: the person's own records only
}

Tool calls run your IExceptionHandlers 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_<name> failed with an unexpected error. Set UseHostExceptionHandlers = false on the tool options to always answer the generic error.

Add Moberg.Beacon.Testing to the test project. In the test host, after the gateway registration, trust test tokens and register in-memory people:

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<string, object?> { ["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.

  • 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.
SymptomCause and fix
Startup names an endpoint and says ReadOnly was not declaredSet ReadOnly = true or false, or pass readOnly: to .WithBeaconTool. The verb is never trusted.
Startup refuses ReadOnly = falseSet 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 userEvery 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 progressThe 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 constraintThe value fails the route’s constraint ({id:int}, regex(...)), as it would on a real request.
Startup refuses an endpoint with no authorizationAdd [Authorize] or a policy, or define a fallback policy.
Startup refuses a policy that names authentication schemesList 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 listThe 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.
... failed with an unexpected errorThe endpoint threw and no IExceptionHandler took it.
A bound name is warned at startupNo tool takes that argument. Check the spelling against the endpoint’s parameter.