Osy#betaRun it on Osyrin: osyrin.com

Reference / Config

MCP tool server (app.McpServer)

app.McpServer = new McpServer { Catalogs = [ new ToolCatalog("Name") { VisibleTo = Policy, Tools = [ … ] } ] };

`app.McpServer` exposes your app OUTWARD, to somebody else's agent, as a set of tools. Tools are grouped into named `Catalogs`, each gated by a `VisibleTo` policy (who may see it). A tool is one of: `new Tool(Function)` (call one of your functions), `new CrudTool<Entity>() { Operations = [...] }` (create/read/update/delete an entity), or `new Tool(Knowledge.Search)` (semantic search). A singleton. For the INWARD direction — a tool your OWN declared agent may call — see `agent-skills`; that is a `skill`, not a `Tool`.

stable2 examples compiled by CIconfigmcpagenttools

Summary#

⚠ This page is the OUTWARD direction. If you are looking for what your own declared agent can call, you want A skill — a typed job an agent can be given, its check and its tools (Agents kit) — a tool an agent of yours may reach for is a skill, and it is granted with Skills = [ … ]. There is no Tools = member on an agent; Tools belongs to a ToolCatalog, below.

app.McpServer exposes your application to an MCP (Model Context Protocol) client — typically an AI agent — as a set of tools the agent may call. As with the REST surface, you expose what you already have rather than write a tool handler: a tool is one of your functions, an entity's CRUD operations, or a semantic search over your knowledge. Tools are grouped into named catalogs, and each catalog is gated by a VisibleTo policy that decides which principals may see and call it. app.McpServer is a singleton — an app has at most one MCP server.

app.McpServer = new McpServer {
  Catalogs = [
    new ToolCatalog("admin-tools") {
      Description = "Admin-only tools",
      VisibleTo   = IsAdmin,
      Tools = [ new Tool(OrderTotal) ],
    },
  ],
};

Signature#

app.McpServer = new McpServer {
  Catalogs = [                        // one entry per named group of tools
    new ToolCatalog("Name") {         // the catalog's name, shown to the client
      Description = "…",              // optional; describes the catalog to the agent
      VisibleTo   = IsAdmin,          // a declared `policy` — who may see this catalog
      Tools = [                       // the tools this catalog exposes
        new Tool(OrderTotal),                                    // call a function
        new CrudTool<Order>() { Operations = [CrudOp.Read] },    // CRUD over an entity
        new Tool(Knowledge.Search),                              // semantic search
      ],
    },
  ],
};

app.McpServer is a singleton; its Catalogs is a list — an app may group its tools into several independently-gated catalogs.

Description#

A ToolCatalog has:

  • "Name" — the catalog's name, passed to the constructor, shown to the MCP client.
  • Description (optional) — prose describing the catalog to the agent.
  • VisibleTo — the name of a declared policy. The catalog (and every tool in it) is only visible and callable to principals for whom the policy holds. VisibleTo must name a declared policy — an undeclared name is a compile error that lists your policies. Any declared policy works: one over the caller's role (policy IsAdmin => user.Role == AppRole.Admin;), over a role grant, or over your own data (policy IsMember => Membership.Any(m => m.User == user && m.Active);). A caller the policy does not admit — and a caller who is not signed in, unless the policy admits visitors ([[#no-principal]]) — does not see the catalog at all.
  • Tools — a list of tools. A tool is one of three kinds:
    • new Tool(Function) — exposes one of your functions as a callable tool. The function must be declared — an unknown name is a compile error that lists your functions.
    • new CrudTool<Entity>() { Operations = [CrudOp.…] } — exposes create/read/update/delete over one entity; Operations chooses which of Create / Read / Update / Delete are available. The entity must be defined in your app.
    • new Tool(Knowledge.Search) — exposes semantic (vector) search over your app's searchable knowledge.

To see what your app exposes without connecting a client, run osy model --json: its mcpCatalogs lists each catalog with its visibleTo policy and its tools (kind is function, crud or builtin; name is the function or the entity; operations is a CRUD tool's mask, e.g. "CR").

What an agent is told about a function tool#

An agent picks a tool by its description and fills its arguments from their schema. Both come from the function itself:

/// Put a question to the person you are working for, and get their decision.
/// <param name="question">The question, as one sentence.</param>
/// <param name="options">Two to four answers to choose from.</param>
string Ask(string question, List<Choice> options) { … }

class Choice {
  /// The answer, in a few words.
  public string Label = "";
  /// What choosing it costs.
  public string Tradeoff = "";
}
  • The description is the function's /// comment — its sentences, or its <summary> when it uses C#'s tags. new Tool(Ask) { Description = "…" } replaces it for that catalog; a function with no comment is described as "Execute the Ask function", which tells an agent nothing, so write the comment. The comment is kept up to its first 1,000 characters.
  • Each argument's description is its <param name="…"> line.
  • A list, array, set or dictionary argument is described as one — List<string> is an array of strings, List<Choice> an array of objects with Choice's members (their /// comments as descriptions, the members that cannot be null as required), a nested class as an object. It is the document Json.SchemaOf<T>() — the JSON Schema of a type answers for the same type, so what an agent sends is what the function receives.
  • A parameter with a default is optional — not in required, with its value as default (int times = 1 → "default": 1, an enum member by its name). An agent that leaves it out gets that value, as a call in Osy# does.

Who may connect#

A deployed app's tool server answers only a caller who presents a key the app issued (X-API-Key) or a signed-in session. On your local platform a caller with no key is let in as a signed-out visitor — it sees list_types, whoami and whatever a catalog leaves open to everyone — so that an app can be tried from an MCP client before it has accounts.

Two tools every caller has. whoami answers the caller's own account id (userId, empty for a signed-out visitor), their roles and whether they are signed in — nothing about anybody else. list_types answers the data types this caller's own tools take or return, and the types those refer to through members the caller may read, each with its member count (members hidden from the caller on every row are not counted — see below). A caller who can see no tool is told no types, and a type no tool of theirs touches is never named — so a key handed to an agent for six tools about one record does not learn the rest of the app's shape. The summary a session is sent when it connects lists the same types, with the fields that caller may read. People using the app's own pages are not affected; an administrator's key is told more only because more catalogs are visible to it.

One exception: an app that uses the computer it runs on (use Osysharp.Host) answers no caller without a key, locally too. Such an app can start programs and open files, and a tool client is any program on the machine, not a page of the app. A connection with no key gets 401 and a sentence saying which header to send.

A catalog for anyone, in an app nobody signs in to#

VisibleTo needs a declared [Principal] only when its policy asks about the caller — names user. A policy that never does compiles in an app with no [Principal] at all. "Anyone" is said the way it is everywhere else, IsAnonymous || IsAuthenticated (principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads); with no principal every caller is anonymous, so the catalog is open to every caller that reaches the tool server ([[#who-may-connect]]):

entity Recipe {
  [Required, MaxLength(120)] string Title;
  security { allow read when IsAnonymous || IsAuthenticated; }
}

policy Anyone => IsAnonymous || IsAuthenticated;

/// How many recipes there are.
int CountRecipes() { return Recipe.Count(); }

app.McpServer = new McpServer {
  Catalogs = [
    new ToolCatalog("recipes") {
      Description = "Read the recipe book",
      VisibleTo = Anyone,
      Tools = [ new Tool(CountRecipes), new CrudTool<Recipe>() { Operations = [CrudOp.Read] } ],
    },
  ],
};

A policy that names user — policy Gate => user != null; — still needs someone to be: in an app with no [Principal] it is refused, and the refusal says to declare one.

A connected client follows your deploys#

An MCP client keeps its connection open for as long as it runs — an editor all day, an agent host for days. When you deploy a new version of the app, the client's next call runs on the new version: the tool it calls is the new one, over the data as it is now, and the client is sent notifications/tools/list_changed so it fetches the new tool list. A tool the new version no longer has answers "Tool '…' not found". The same happens when the caller's roles change: the next call acts with the roles they have now. Nobody has to reconnect.

The summary a session was sent when it connected is not re-sent; a client that wants the new one reconnects.

What a caller is told about a record's fields#

Every place the tool server describes a record to a caller — describe_Order, get_entity_schema, the arguments of create_Order and update_Order, the argument of a function tool that takes an Order, list_types' member counts, and the summary a session is sent when it connects — follows the record's own security {} block, as that caller:

entity Order {
  [MaxLength(200)] string CustomerRef;
  decimal Margin;
  [MaxLength(500)] string? Notes;
  bool Shared;
  security {
    allow read when IsAuthenticated;
    allow create when IsAuthenticated;
    deny read Margin when !IsAdmin;     // hidden on EVERY row from a non-admin
    deny read Notes;                    // hidden…
    allow read Notes where Shared;      // …except on a row that says so
  }
}
  • A field the caller may read on no row is not named, and not counted. A non-admin's describe_Order lists CustomerRef, Notes and Shared — not Margin. An admin's lists all four.
  • A field hidden only on some rows (a rule with where) is still described. Whether Notes may be read depends on the row, and a description has no row to ask; its value is still withheld on every row that hides it.
  • A field the caller may write but not read is named only where it is written, marked "writeOnly": true. Here a non-admin may create an Order, so create_Order's arguments — and get_entity_schema, when the caller's catalog offers Create — include Margin, write-only; describe_Order does not. A field is offered for writing when the caller may create the record (create_…, and a function argument, which builds the record from its fields) or may update that field (update_…). Where neither holds, it is not named anywhere.

The data itself was never at risk — a read returns nothing the block withholds. What this decides is the description: a key that may never see a field is not told its name, type or length.

A catalog may not mix CrudTool and PolymorphicTool — the compiler refuses it; give each its own catalog. Each Tools entry is exactly one tool. You write no tool schema, no argument parsing, and no dispatch: the signature of the function and the shape of the entity are the contract.

Examples#

A complete app that exposes one function and one entity's full CRUD as an admin-only catalog. An admin is somebody signed in, so this catalog needs a declared [Principal], its [Role] enum, and the policy that VisibleTo references (a catalog for anyone needs none of them — [[#no-principal]]):

entity Order { [Required, MaxLength(200)] string CustomerRef; decimal Total; }

[Role] enum AppRole { Staff, Admin }
[Principal] entity User {
  [MaxLength(200)] string Email;
}
[RoleGrant] entity RoleGrant { [Required] User Member; [Required] AppRole Role; }

policy IsAdmin => RoleGrant.Any(g => g.Member == user && g.Role == AppRole.Admin);

decimal OrderTotal(decimal subtotal, decimal tax) { return subtotal + tax; }

app.McpServer = new McpServer {
  Catalogs = [
    new ToolCatalog("admin-tools") {
      Description = "Admin-only tools",
      VisibleTo = IsAdmin,
      Tools = [
        new Tool(OrderTotal),
        new CrudTool<Order>() { Operations = [CrudOp.Create, CrudOp.Read, CrudOp.Update, CrudOp.Delete] },
      ],
    },
  ],
};

See also#

Related

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…

publishing a REST API (app.Apis)

`app.Apis` publishes your app to a third party over HTTP. You never write a controller or an endpoint handler — you…

using Memory (semantic search)

Opt into semantic (vector) search over your app's content. `using Memory;` adds a searchable store to the app…

A skill — a typed job an agent can be given, its check and its tools (Agents kit)

A skill is a class: what goes in, what comes back, and the app's own functions the agent may use to get there. The…

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…