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.
-
Install the AI extensions package
Terminal window dotnet add package Shiny.DocumentDb.Extensions.AI -
Create your JSON context (for AOT)
[JsonSerializable(typeof(Customer))][JsonSerializable(typeof(Order))]public partial class AppJsonContext : JsonSerializerContext; -
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));}); -
Pass the tools to your
IChatClientvar aiTools = serviceProvider.GetRequiredService<DocumentStoreAITools>();var options = new ChatOptions { Tools = aiTools.Tools.ToList() };var response = await chatClient.GetResponseAsync(messages, options);
Without a DI container
Section titled “Without a DI container”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() };Capabilities
Section titled “Capabilities”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 CRUDtools.AddType(jsonContext.Order, capabilities: DocumentAICapabilities.All);
// Specific operationstools.AddType(jsonContext.AuditLog, capabilities: DocumentAICapabilities.Get | DocumentAICapabilities.Query);Per-Type Configuration
Section titled “Per-Type Configuration”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);});Non-removable access filters
Section titled “Non-removable access filters”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 ordersThe 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
updaterequires anIdproperty. The update path re-fetches the stored record by itsIdto verify scope. Types whose identifier is mapped to a differently-named property (viacfg.MapIdProperty) can’t be re-fetched this way, so a scopedupdateon such a type throws.Get/Delete/Insert/Queryare unaffected.
Request-resolved filters
Section titled “Request-resolved filters”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.
Where the services come from
Section titled “Where the services come from”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 });It fails closed
Section titled “It fails closed”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.
Structured Filters
Section titled “Structured Filters”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.
Leaf comparisons
Section titled “Leaf comparisons”{ "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 |
Boolean combinators
Section titled “Boolean combinators”{ "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.
Generated Tools Reference
Section titled “Generated Tools Reference”Get by ID
Section titled “Get by ID”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 }.
Aggregate
Section titled “Aggregate”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 }.
Insert
Section titled “Insert”Creates a new document. Accepts a document object and returns { inserted: true, document: {...} } with the auto-generated ID populated.
Update
Section titled “Update”Replaces an existing document. Accepts a document object (must include the ID). Returns { updated: true }.
Delete
Section titled “Delete”Deletes a document by identifier. Returns { deleted: true } or { deleted: false } if not found.
Using with GitHub Copilot
Section titled “Using with GitHub Copilot”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 chatvar 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”


