# Calling your own MCP tool catalog from a test

> `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.

<!-- id: testing-mcp-calls · area: testing · stability: preview · html: https://osysharp.com/reference/testing/mcp-calls/ -->

## Summary        {#summary}
[`app.McpServer`](https://osysharp.com/reference/config/mcp-server/) 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`](https://osysharp.com/reference/testing/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:

```osy syntax
var seen = Mcp.List(apiKey: Api.KeyFor(Chief));
Assert.True(seen.Tools.Any(t => t.Name == "OrderTotal"));
```

## Signature      {#signature}
```osy title="the two verbs, and where a credential goes" syntax
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    {#description}

### One credential door, not two   {#credentials}
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   {#list}
```osy syntax
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   {#call}
```osy syntax
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   {#no-credential}
⚠ **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:

```osy syntax
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       {#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:

```osy title="VisibleTo gates List and Call identically" test app=testing-mcp-calls
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 = "admin@lab.test", Role = AppRole.Admin };
  new Agent { Email = "staff@lab.test", Role = AppRole.Staff };
}

principal Admin => Agent.Single(a => a.Email == "admin@lab.test");
principal Staff => Agent.Single(a => a.Email == "staff@lab.test");

[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       {#see-also}
- [MCP tool server (app.McpServer)](https://osysharp.com/reference/config/mcp-server/) — `app.McpServer`, the declaration `Mcp.*` calls back into
- [Calling your own REST API from a test](https://osysharp.com/reference/testing/api-calls/) — the REST sibling; `Api.KeyFor`/`Api.TokenFor` are the ONE credential mint both share
- [principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads](https://osysharp.com/reference/security/principal-predicates/) — `VisibleTo` is a `policy`, defined over the same predicates a `security {}`
  rule uses
- [runas](https://osysharp.com/reference/testing/runas/) — the ENGINE-side principal rebind, for calling the underlying function directly instead of
  through the catalog (proves the function; proves nothing about `VisibleTo`)
