Summary#
app.McpServer publishes your application as a set of MCP tools for an agent to call. Mcp.*
is how you call it back — from your own .test.osy, over a real MCP session, into the same handler a real
connecting agent reaches.
That matters because the interesting question about a tool catalog is not "does the function work" — a direct call
under runas already proves that. It is what an agent with a given credential actually SEES and
can CALL, which is exactly what VisibleTo decides and a direct function call never touches:
var seen = Mcp.List(apiKey: Api.KeyFor(Chief));
Assert.True(seen.Tools.Any(t => t.Name == "OrderTotal"));Signature#
Mcp.List()
Mcp.Call(tool, args)
// every verb also takes a credential BY NAME — the SAME two `Api.*` uses, not a second mint
Mcp.List(apiKey: Api.KeyFor(Chief))
Mcp.Call("OrderTotal", "{\"subtotal\":100,\"tax\":8.5}", bearer: Api.TokenFor(Chief))tool is the tool's name, exactly as Mcp.List() reports it. args takes a string or a Json value — the same
rule [[testing-api-calls#signature|Api.Post's body]] follows, and for the same reason: an anonymous object
literal is already JSON text, so Mcp.Call("OrderTotal", new { subtotal = 100m, tax = 8.5m }) needs no serialize
step, and wrapping it in JsonSerializer.Serialize double-encodes it.
Description#
One credential door, not two#
MCP authenticates through the exact same door app.Apis does (X-API-Key / Authorization: Bearer), so there is
one way to mint a credential in a test, not two — Api.KeyFor(P) / Api.TokenFor(P), unchanged. There is no
Mcp.KeyFor.
Mcp.List() answers what THIS credential sees#
class McpToolInfo { public string Name; public string Description; }
class McpListResult { public List<McpToolInfo> Tools; }Tools is exactly the tool set a real MCP client's tools/list would receive for that credential — every catalog
whose VisibleTo policy the caller satisfies, and nothing else. There is no Catalog member: a ToolCatalog name
is a SERVER-SIDE registration grouping, and the MCP wire protocol's own Tool shape does not carry it — a real
connecting agent cannot see which catalog a tool came from either, so this class does not pretend to know more than
the protocol exposes.
Mcp.Call(tool, args) answers what the tool actually did#
class McpCallResult { public bool IsError; public string Text; }Text is every text content block the tool answered, joined — JsonSerializer.Deserialize<T>(r.Text) reads it into
a class you declare, the same move [[testing-api-calls#signature|Api.*'s Body]] takes. IsError is true for
every refusal shape — the tool was not visible to this credential, its arguments did not bind, the underlying
function/CRUD operation itself refused — a NORMAL return, never a throw. Calling a tool a credential cannot even
see answers IsError = true naming it not found, exactly as a real agent would be told: VisibleTo governs
tools/list and tools/call identically, because both read the same per-session tool set.
The one asymmetry with Api.*: no credential at all#
⚠ An app.Apis route with no Auth is reachable anonymously. An MCP session is not — ever, from outside local
dev. McpEndpoints refuses a connection carrying NEITHER X-API-Key nor a bearer token with a 401 before any
catalog's VisibleTo is even consulted — this is the platform's own MCP-OAuth-discovery challenge, unconditional,
and it is not something a ToolCatalog can opt out of. So:
var anon = Mcp.List(); // no apiKey:, no bearer:
Assert.False(anon.Tools.Any(t => t.Name == "OrderTotal")); // true — but for a DIFFERENT reason than VisibleToreads exactly like a VisibleTo refusal from inside a [Test] (Mcp.List()/Mcp.Call() answer an empty list /
IsError = true, never a throw, the same normal-return promise Api.* makes) — but the session itself never
reached the registrar. If you specifically need to prove VisibleTo excludes a caller, use a credential whose
principal fails the policy (a Staff role against an IsAdmin gate), not an absent one — an absent credential
proves the transport door, not the catalog's own rule.
Examples#
An admin-gated tool catalog, called with three different credentials — the admin sees and can call the tool, a signed-in non-admin sees neither:
enum AppRole { Staff, Admin }
[Principal] entity Agent {
[Unique, MaxLength(200)] string Email;
[MaxLength(255)] string? ApiKeyHash;
[MaxLength(255)] string? ApiKeyHash2;
AppRole Role;
security { allow read when IsAuthenticated; }
}
policy IsAdmin => user.Role == AppRole.Admin;
decimal OrderTotal(decimal subtotal, decimal tax) { return subtotal + tax; }
app.McpServer = new McpServer {
Catalogs = [
new ToolCatalog("admin-tools") { VisibleTo = IsAdmin, Tools = [ new Tool(OrderTotal) ] },
],
};
[TestFixture]
void Seed() {
new Agent { Email = "[email protected]", Role = AppRole.Admin };
new Agent { Email = "[email protected]", Role = AppRole.Staff };
}
principal Admin => Agent.Single(a => a.Email == "[email protected]");
principal Staff => Agent.Single(a => a.Email == "[email protected]");
[Test(Seed)]
void an_admin_sees_the_tool_and_a_staffer_does_not() {
Assert.True(Mcp.List(apiKey: Api.KeyFor(Admin)).Tools.Any(t => t.Name == "OrderTotal"));
Assert.False(Mcp.List(apiKey: Api.KeyFor(Staff)).Tools.Any(t => t.Name == "OrderTotal"));
}
[Test(Seed)]
void the_admin_can_call_it_and_the_staffer_cannot() {
var result = Mcp.Call("OrderTotal", new { subtotal = 100m, tax = 8.5m }, apiKey: Api.KeyFor(Admin));
Assert.False(result.IsError);
Assert.Contains("108.5", result.Text);
Assert.True(Mcp.Call("OrderTotal", new { subtotal = 100m, tax = 8.5m }, apiKey: Api.KeyFor(Staff)).IsError);
}See also#
- MCP tool server (app.McpServer) —
app.McpServer, the declarationMcp.*calls back into - Calling your own REST API from a test — the REST sibling;
Api.KeyFor/Api.TokenForare the ONE credential mint both share - principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads —
VisibleTois apolicy, defined over the same predicates asecurity {}rule uses - runas — the ENGINE-side principal rebind, for calling the underlying function directly instead of
through the catalog (proves the function; proves nothing about
VisibleTo)