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 declaredpolicy. The catalog (and every tool in it) is only visible and callable to principals for whom the policy holds.VisibleTomust 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;Operationschooses which ofCreate/Read/Update/Deleteare 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 withChoice'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 asdefault(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_OrderlistsCustomerRef,NotesandShared— notMargin. An admin's lists all four. - A field hidden only on some rows (a rule with
where) is still described. WhetherNotesmay 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 anOrder, socreate_Order's arguments — andget_entity_schema, when the caller's catalog offersCreate— includeMargin, write-only;describe_Orderdoes 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#
- principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads — the
[Principal],[Role], andpolicythatVisibleTogates a catalog with - publishing a REST API (app.Apis) —
app.Apis, the HTTP surface that exposes the same functions and entities to non-agent callers - using Memory (semantic search) — the searchable knowledge that
new Tool(Knowledge.Search)exposes - Calling your own MCP tool catalog from a test —
Mcp.List/Mcp.Call: driving THIS catalog from your own.test.osy, over a real MCP session — what an agent with a given credential actually sees and can call