Osy#betaRun it on Osyrin: osyrin.com

Reference / Testing

Calling your own MCP tool catalog from a test

Mcp.List() · Mcp.Call(tool, args) — each also takes apiKey: / bearer:, filled by Api.KeyFor(P) / Api.TokenFor(P)

`Mcp.*` opens a REAL MCP session against a tool catalog your own app publishes with `app.McpServer`, from inside a `[Test]`, and returns what it answered — the exact tools THIS credential's catalogs admit, or the result of calling one. The session goes through the same handler a real MCP client (an agent) reaches, so it exercises the real per-session tool registrar and the real `VisibleTo` gate, not a re-implementation of either.

preview1 example compiled by CItestingmcpagenttools

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 VisibleTo

reads 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#

Related

MCP tool server (app.McpServer)

`app.McpServer` exposes your app OUTWARD, to somebody else's agent, as a set of tools. Tools are grouped into named…

Calling your own REST API from a test

`Api.*` sends a real HTTP request to a route your own app publishes with `app.Apis`, from inside a `[Test]`, and…

Testing (real app, real data, real rules)

A test in Osy# is not a unit test with the world mocked out. It is your app, running against its own throwaway…

Assert

The assertions a test makes. Beyond the usual equality and null checks there are comparisons (Greater, Less, InRange)…

runas

Runs a block as a given principal, so security rules apply exactly as they would for that user. It is how you test that…

principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads

Two built-in `when` predicates say who a request is: `IsAuthenticated` is a signed-in user, `IsAnonymous` is an…