Skip to content
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

AI Tools

Expose your document store operations as Microsoft.Extensions.AI tool functions that LLM agents can call directly. Register the document types you want to expose, control which operations are allowed, and restrict field visibility — all with a fluent builder API.

The AI layer sits on top of IDocumentStore and does not modify its behavior. It wraps your registered types with AIFunction instances that translate LLM requests into store operations.

  1. Install the AI extensions package

    Terminal window
    dotnet add package Shiny.DocumentDb.Extensions.AI
  2. Create your JSON context (for AOT)

    [JsonSerializable(typeof(Customer))]
    [JsonSerializable(typeof(Order))]
    public partial class AppJsonContext : JsonSerializerContext;
  3. Register the document store and AI tools

    var jsonContext = new AppJsonContext(new JsonSerializerOptions
    {
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    });
    services.AddDocumentStore(opts =>
    {
    opts.DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db");
    opts.JsonSerializerOptions = jsonContext.Options;
    });
    services.AddDocumentStoreAITools(tools =>
    {
    tools.AddType(
    jsonContext.Customer,
    capabilities: DocumentAICapabilities.All,
    configure: b => b
    .Description("Customer records with contact info")
    .MaxPageSize(50)
    );
    tools.AddType(
    jsonContext.Order,
    capabilities: DocumentAICapabilities.ReadOnly,
    configure: b => b
    .Description("Customer orders")
    .Property(o => o.Status, "Order status: Pending, Shipped, Delivered, Cancelled")
    .IgnoreProperties(o => o.InternalNotes)
    );
    });
  4. Pass the tools to your IChatClient

    var aiTools = serviceProvider.GetRequiredService<DocumentStoreAITools>();
    var options = new ChatOptions { Tools = aiTools.Tools.ToList() };
    var response = await chatClient.GetResponseAsync(messages, options);

No DI is required — call CreateAITools directly on a store you built yourself (new DocumentStore(options), MAUI, scripts). It uses the same builder and returns the same DocumentStoreAITools:

using var store = new DocumentStore(options);
var aiTools = store.CreateAITools(tools =>
{
tools.AddType(jsonContext.Customer, capabilities: DocumentAICapabilities.All);
tools.AddType(jsonContext.Order, capabilities: DocumentAICapabilities.ReadOnly);
});
var chatOptions = new ChatOptions { Tools = aiTools.Tools.ToList() };

Control which operations the LLM can perform on each type using the DocumentAICapabilities flags enum:

Flag Tool Generated Description
Get {type}_get_by_id Fetch a single document by ID
Query {type}_query Query with structured filters, sorting, and paging
Count {type}_count Count documents with optional filter
Aggregate {type}_aggregate Compute sum/min/max/avg/count over documents
Insert {type}_insert Create a new document
Update {type}_update Replace an existing document
Delete {type}_delete Delete a document by ID

Convenience combinations:

Combination Includes
ReadOnly Get, Query, Count, Aggregate
All All seven operations
// Read-only access (default)
tools.AddType(jsonContext.Customer, capabilities: DocumentAICapabilities.ReadOnly);
// Full CRUD
tools.AddType(jsonContext.Order, capabilities: DocumentAICapabilities.All);
// Specific operations
tools.AddType(jsonContext.AuditLog, capabilities: DocumentAICapabilities.Get | DocumentAICapabilities.Query);

The builder callback lets you control descriptions, field visibility, and page size limits:

tools.AddType(jsonContext.Customer, capabilities: DocumentAICapabilities.All, configure: b =>
{
// Type-level description used in tool descriptions and JSON schema
b.Description("Customer records with contact information");
// Override the description for a specific property
b.Property(c => c.Age, "Customer's age in years");
b.Property(c => c.Status, "Active, Inactive, or Suspended");
// Restrict which properties the LLM can see/filter on
// Option A: Only expose listed properties (allowlist)
b.AllowProperties(c => c.Id, c => c.Name, c => c.Email, c => c.Age);
// Option B: Hide specific properties (blocklist)
b.IgnoreProperties(c => c.InternalNotes, c => c.PasswordHash);
// Cap the maximum page size for query results (default 100)
b.MaxPageSize(50);
});

AllowProperties/IgnoreProperties control which fields the LLM sees. To control which rows (documents) it can reach, register a fixed predicate with Where. It is a hard, server-side scope boundary: the model never sees it, cannot disable it, and cannot widen past it — it is AND-combined with whatever filter the model supplies. Call Where more than once to require several conditions.

tools.AddType(jsonContext.Order, capabilities: DocumentAICapabilities.All, configure: b => b
.Where(o => o.TenantId == "acme") // the model only ever works within this tenant…
.Where(o => !o.IsArchived)); // …and never sees archived orders

The filter is enforced on every tool for the type, not just queries:

Tool Enforcement
query / count / aggregate The predicate is pushed into the store query, so out-of-scope documents are never returned or counted — even if the model’s filter names them explicitly.
get_by_id An out-of-scope id returns { found: false } — indistinguishable from a missing document.
delete An out-of-scope id is refused and returns { deleted: false }; the document is left untouched.
insert A document that would fall outside the filter is rejected and never written.
update The incoming document must satisfy the filter (the model cannot move a record out of scope) and the stored record being replaced must already be in scope (the model cannot overwrite a document it isn’t allowed to see). Returns { updated: false } when the stored record is out of scope.

This overload of Where is static — fixed at registration, because AI tools are typically singletons. For a scope that differs per caller, see request-resolved filters below.

The predicate is evaluated with the same compile-free, AOT-safe machinery the store uses — keep it to the LINQ constructs the store can translate (the same set supported by a normal Query<T>().Where(...)).

Scoped update requires an Id property. The update path re-fetches the stored record by its Id to verify scope. Types whose identifier is mapped to a differently-named property (via cfg.MapIdProperty) can’t be re-fetched this way, so a scoped update on such a type throws. Get/Delete/Insert/Query are unaffected.

A static scope is enough for a single-tenant server and useless for a shared one: the answer to “which rows may this caller see” lives in an ITenantContext, a permission cache or a current-user accessor that only exists per request. So Where also takes a callback, resolved on every tool call from the call’s own service provider:

tools.AddType(jsonContext.Order, configure: b => b
.Where(o => o.TenantId == "acme") // static — unchanged
.Where(ctx => o => o.Region == ctx.GetRequiredService<ICurrentUser>().Region) // resolved per call
.Where<ITenantContext>((tenant, _) => o => o.TenantId == tenant.TenantId) // resolve-a-service form
.Where<IPermissionService>(async (perms, ctx) => // async form
{
var ids = await perms.GetVisibleCustomerIdsAsync(ctx.CancellationToken);
return o => ids.Contains(o.CustomerId);
}));

Everything about the static form still holds: AND-combined with every other filter, enforced on every tool (query push-down and the in-memory checks the write paths make), and completely invisible to the model — absent from the JSON schema, absent from the tool description, and never echoed in an error message.

The provider is AIFunctionArguments.Services. The MCP server sets it from the per-request scope for you. On the plain IChatClient lane you set it yourself:

using var scope = serviceProvider.CreateScope();
var result = await tool.InvokeAsync(new AIFunctionArguments(args) { Services = scope.ServiceProvider });

This is the whole point of the feature, so there is no soft edge:

Situation Result
The filter throws The tool call fails. No query runs
GetRequiredService<T> finds nothing The tool call fails
The call carries no Services The tool call fails, naming the missing plumbing
The filter returns null The tool call fails — return o => false to deny everything

It never degrades into “run without that predicate”: an unscoped query is the exact outcome this exists to prevent. AddDocumentStoreAITools also asserts at startup that every Where<TService> service is registered, so a missing registration is a boot error rather than a failed tool call in production.

Registrations that use only the static form pay nothing — the interpreted predicates are still built once, in the tool’s constructor, and Services is never touched.

The query, count, and aggregate tools accept a structured filter parameter that supports nested boolean logic. The LLM constructs filter objects; the library translates them to LINQ expressions against the document store.

{ "field": "age", "op": "gt", "value": 30 }
Operator Description
eq Equals
ne Not equals
gt Greater than
gte Greater than or equal
lt Less than
lte Less than or equal
contains String contains (string fields only)
startsWith String starts with (string fields only)
in Value is in array
{
"and": [
{ "field": "age", "op": "gte", "value": 18 },
{ "field": "status", "op": "eq", "value": "Active" }
]
}
{
"or": [
{ "field": "city", "op": "eq", "value": "Portland" },
{ "field": "city", "op": "eq", "value": "Seattle" }
]
}
{
"not": { "field": "status", "op": "eq", "value": "Cancelled" }
}

Combinators can be nested arbitrarily.

Fetches a single document by its identifier. Returns { found: true, document: {...} } or { found: false }.

Queries documents with optional filter, sorting, and pagination. Parameters:

Parameter Type Description
filter object Structured filter (optional)
orderBy string Field name to sort by (optional)
orderDirection string "asc" or "desc" (default "asc")
limit integer Max results to return (default 50, capped at MaxPageSize)
offset integer Number of results to skip (default 0)

Returns { count, offset, limit, documents: [...] }.

Counts documents matching an optional filter. Returns { count }.

Computes a scalar aggregate over documents. Parameters:

Parameter Type Description
function string "count", "sum", "min", "max", or "avg"
field string Numeric field to aggregate (required for sum/min/max/avg)
filter object Structured filter (optional)

Returns { function, field, value }.

Creates a new document. Accepts a document object and returns { inserted: true, document: {...} } with the auto-generated ID populated.

Replaces an existing document. Accepts a document object (must include the ID). Returns { updated: true }.

Deletes a document by identifier. Returns { deleted: true } or { deleted: false } if not found.

The Sample.CopilotConsole project in the repository demonstrates a complete interactive chat application that authenticates with GitHub Copilot and uses the DocumentDb AI tools to let the LLM query and manage documents through natural language:

// Seed data, authenticate with Copilot, then chat
var aiTools = host.Services.GetRequiredService<DocumentStoreAITools>();
var options = new ChatOptions { Tools = aiTools.Tools.ToList() };
while (true)
{
Console.Write("You: ");
var input = Console.ReadLine();
history.Add(new ChatMessage(ChatRole.User, input));
var response = await chatClient.GetResponseAsync(history, options);
Console.WriteLine($"Copilot: {response.Text}");
}

Try prompts like:

  • “How many customers do we have?”
  • “Show me all pending orders”
  • “What’s the average customer age?”
  • “Add a new customer named Dave, age 40, email dave@example.com
  • “Delete customer cust-3”