MCP Server
Point Claude Code, Claude Desktop, Copilot or any other MCP client at a Shiny.DocumentDb store and let it explore and query the data — safely.
{ "mcpServers": { "documentdb": { "command": "shiny-documentdb-mcp", "args": ["--provider", "sqlite", "--connection", "Data Source=app.db"] } }}> which orders over $5k are still unfulfilled? → documentdb__order_query { filter: { and: [ … ] }, limit: 20 }Two packages ship:
| Use it when | |
|---|---|
ShinyDocDbMcp (dotnet tool, stdio) |
A local desktop client on your own machine |
Shiny.DocumentDb.Mcp (library, Streamable HTTP) |
A server your team shares, behind your own auth |
The tools an MCP client calls are the same Shiny.DocumentDb.Extensions.AI tools
an IChatClient calls — one implementation, one security model, one set of tests. A fix to the filter
translator fixes both lanes. This package is the transport, the resources, the prompts and the safety
defaults that make sense when the caller is an arbitrary agent rather than your own chat client.
Sample
Section titled “Sample”samples/Sample.McpServer is a runnable server over the same seeded data as the OData and REST samples —
read-only tools, a hidden property, a per-caller scope driven by a request header, plus the resources and
prompts. Browse http://localhost:5097/ for client configuration and things to ask it.
The tool
Section titled “The tool”dotnet tool install -g ShinyDocDbMcpshiny-documentdb-mcp --profile prod-readonly # a saved ShinyDocDbMyAdmin connectionshiny-documentdb-mcp --provider sqlite --connection "Data Source=app.db"shiny-documentdb-mcp --config ./documentdb-mcp.json # collections, scopes, capabilitiesSaved profiles are the ones the admin tools already hold, decrypted by the same key. The MCP package stores no credentials of its own and never accepts them as tool arguments. A profile marked read-only in the admin tool stays read-only here.
| Option | Meaning |
|---|---|
--table <name> |
The documents table. Default documents |
--types A,B |
Expose only these TypeName discriminators. Default: discover them all |
--max-page-size <n> |
Cap on rows per query. Default 100 |
--allow-writes |
Unlock insert/update/delete — see the two locks |
The tool has no compiled document classes, so what it exposes comes from the data: it reads the distinct
TypeName discriminators out of the table and publishes each as a schema-free
JSON collection in open-field mode. That is enough for exploration; declare
the fields in a config file for anything you would rather the model did not go looking through.
Config file
Section titled “Config file”{ "table": "documents", "maxPageSize": 50, "collections": [ { "name": "Order", "idProperty": "id", "description": "Customer orders", "where": "tenantId == 'acme'", // non-removable, invisible to the model "fields": [ { "path": "status", "type": "string" }, { "path": "total", "type": "number" }, { "path": "placed", "type": "date" } ] } ]}A config file cannot express a lambda, so the tool supports static scope clauses only. Request-resolved scopes need the library.
The library
Section titled “The library”For a shared server, host it in an ASP.NET app that already has a store:
builder.Services .AddDocumentDbMcpServer(mcp => { mcp.AddType<Order>(AppJsonContext.Default.Order, capabilities: DocumentAICapabilities.ReadOnly, t => t .Where(o => o.TenantId == "acme") // static, invisible .Where<ITenantContext>((tenant, _) => o => o.TenantId == tenant.TenantId) // resolved per call .IgnoreProperties(o => o.InternalNotes) .MaxPageSize(50));
mcp.AddCollection("intake-forms", capabilities: DocumentAICapabilities.ReadOnly, c => c.AllowAnyField()); mcp.ExposeResources(); mcp.ExposePrompts(); }) .WithHttpTransport();
app.MapDocumentDbMcp("/mcp").RequireAuthorization("mcp");AddDocumentDbMcpServer is the SDK’s AddMcpServer() call — don’t call both — and it returns the SDK’s
IMcpServerBuilder, so the transport is your choice (.WithHttpTransport() here, .WithStdioServerTransport()
for a console host). Its builder is IDocumentAIToolBuilder plus the MCP-only knobs, so everything on the
AI tools page applies verbatim.
The safety model
Section titled “The safety model”- Allowlist only. A type or collection you don’t register is invisible.
- Read-only by default. Writes need two locks: the per-type capability flag and the server-wide
AllowWrites()(or--allow-writes). A registration that asks for a write capability without the second lock is a startup error, not a silently-downgraded server. - Non-removable scope.
.Where(...)per type is pushed into the query for query/count/aggregate and enforced in memory for get/delete/insert/update. The model cannot see it, remove it, or override it — and an out-of-scope id is “not found”, never “forbidden”, so existence is not leaked. - Page caps (
MaxPageSize, default 100) so a model cannot pull a table into its context. - Property hiding (
IgnoreProperties) for secrets and blobs; encrypted properties are excluded automatically. - Audit. Every tool call is logged with the tool name, the caller, a SHA-256 digest of the arguments, the outcome and the duration — plus the store’s own spans and metrics. Arguments are digested rather than written out: a filter can carry customer data, and an audit log that quietly becomes a second copy of the database is its own incident.
- No raw SQL tool. Not even read-only, not even opt-in.
- No schema mutation. No create/drop table, no index management — the admin tools own that, interactively.
Per-caller scope
Section titled “Per-caller scope”The static .Where(o => o.TenantId == "acme") is fixed at registration: enough for a single-tenant server,
useless for a shared one, because “which rows may this caller see” lives in a service that only exists per
request. So the scope has a resolved form — see
request-resolved filters for the full surface. It fails
closed: a filter that throws, a service that will not resolve, or a call with no services attached fails
the tool call. It never degrades into running the query unscoped.
Per transport:
- HTTP (library): a real per-caller scope. The authenticated principal is reachable via
IHttpContextAccessoroffctx.Services. This is the multi-tenant story and the reason the feature exists. - stdio (the tool): one process, one OS user, no HTTP identity. A resolved filter there can read configuration, but it is not per-caller authorization — don’t treat it as one.
Resources
Section titled “Resources”ExposeResources() publishes four, so a model can orient itself in one round trip instead of guessing tool
arguments:
| URI | Content |
|---|---|
documentdb://types |
Every exposed type/collection, its capabilities, its fields, and its tool names |
documentdb://types/{name}/schema |
JSON Schema for one type |
documentdb://types/{name}/sample |
A few real documents — the fastest way to learn the shape |
documentdb://stats |
Provider, capability flags (vector/full-text/spatial/transactions), per-type counts |
The two that touch data (sample, and the counts in stats) go through the registered tools, so the
scope, the page caps and the hidden properties apply to a resource read exactly as they do to a tool call. A
resource that read the store directly would be a hole in the scope. documentdb://types tells the model that
a scope exists (so “no results” is not a mystery) but never what it is.
Without ExposeResources() the capability is not advertised at all — resources/list is not a method the
server answers.
Prompts
Section titled “Prompts”ExposePrompts() publishes two:
explain-collection— “describe what lives in{name}and how to filter it”build-filter— turns a natural-language ask into a{name}_querycall, with the filter grammar inlined
Both are built from what the server already exposes and read no data, so they need no scope — and neither one mentions the scope’s contents.
What the model can and cannot see
Section titled “What the model can and cannot see”| Sees | Registered types, their allowed fields, JSON schemas, sample documents, the tools its capabilities allow |
| Never sees | Unregistered types, ignored properties, encrypted values, the scope predicate — not in the schema, not in a tool description, not in an error message |
| Can do | Query, count, aggregate, get-by-id (within the scope) — plus writes only when both locks are open |
| Cannot do | Raw SQL, schema changes, removing the scope, exceeding the page cap |


