An MCP server in C# that exposes a SQL Server database to an LLM client as a set of read-only tools. Built to answer a specific question: what does it take to let a model query a production-shaped database without giving it a way to change anything?
The interesting part is not the plumbing — the MCP C# SDK handles that. It is the safety layer:
model-authored SQL is parsed into a T-SQL abstract syntax tree and admitted only if it is exactly
one SELECT, with no INTO, no EXEC, and no OPENROWSET. That is an allow-list over an AST,
not a keyword denylist over a string, so a statement type the parser does not recognise as a plain
SELECT fails closed.
| Tool | Purpose |
|---|---|
list_tables |
Every visible table with schema and approximate row count |
describe_table |
Columns, types, nullability, primary key, foreign keys |
sample_rows |
A capped sample so the model can see the data shape before querying |
query |
One read-only SELECT, row- and byte-capped |
Demo mode — no SQL Server needed. This is the fastest way to see it work:
sqlite3 demo/dealership.db < demo/seed.sql
dotnet buildThen in VS Code, open the Command Palette → MCP: List Servers → start sqlserver-tools-demo.
Open Copilot Chat in Agent mode and ask something like "which dealerships have open service
orders and how many labour hours are outstanding?" — the model will call list_tables, then
describe_table, then compose a SELECT.
Against SQL Server. Start the sqlserver-tools server instead; VS Code will prompt for a
connection string and keep it out of the workspace file. Point it at a login granted
db_datareader and nothing else — the guard is defence in depth, not the only defence.
All configuration is environment variables, so no connection string ever reaches source control.
| Variable | Default | Notes |
|---|---|---|
MCP_DB_PROVIDER |
SqlServer |
SqlServer or Sqlite |
MCP_DB_CONNECTION |
(required) | Use a read-only login |
MCP_MAX_ROWS |
200 |
Hard ceiling; a tool call asking for more is clamped |
MCP_MAX_RESPONSE_BYTES |
262144 |
Protects the client's context window |
MCP_COMMAND_TIMEOUT |
15 |
Seconds |
MCP_AUDIT_PATH |
(unset) | JSON Lines audit file. Unset means the audit trail goes to stderr, not that it is off |
MCP_AUDIT_FAIL_OPEN |
false |
If the audit sink throws, the default is to fail the tool call. Set true to log a warning and let the call through instead |
MCP_REDACTION_CONFIG |
(unset) | Path to a JSON column-redaction config. Unset means redaction is off, logged as a warning at startup |
Logging goes to stderr, always. With the stdio transport, stdout is the protocol stream.
A single Console.WriteLine corrupts it, and the failure surfaces on the client as an opaque JSON
parse error. Program.cs sets LogToStandardErrorThreshold to Trace for this reason.
The guard only polices model-authored SQL. Catalogue queries in Db/Dialect.cs are fixed and
parameterised, written by us, and go straight to the gateway. Running our own queries through a
guard designed for untrusted input would be theatre.
Caps are enforced in one place. SqlGateway.ExecuteAsync is the only path to the database, and
it applies the row cap, the byte cap and the timeout on every call. A new tool cannot forget them.
Truncation is reported, not hidden. When a result is cut short the response says so and says why, so the model can narrow the query rather than silently reasoning over a partial answer.
Two guards, one interface. SQL Server gets AST parsing via ScriptDom. SQLite has no
equivalent parser available, so it gets a conservative tokeniser that strips comments and string
literals before checking keywords — otherwise SELECT 'do not delete this row' would be rejected
and SELECT 1; /* */ DROP TABLE x might not be.
Two redactors, one interface — same split as the guards, for the same reason.
IColumnRedactor has an AstColumnRedactor (SQL Server) and a NameOnlyColumnRedactor (SQLite).
The AST-based one is strictly more capable — it resolves aliases and SELECT * correctly — but
requires the same parser SQLite doesn't have. See Redaction.
Every call to list_tables, describe_table, sample_rows or query produces exactly one
JSON Lines record — one JSON object per line, append-only — whether it was allowed, rejected by
the guard, or errored. Rejections are the point: this is the log that answers "who asked what,
and can you prove it," and a rejected attempt is at least as interesting as a successful one.
{"timestamp":"2026-08-07T14:02:37.902Z","sessionId":"7e2f9a1c4b6d4e2f8a1c4b6d4e2f8a1c","tool":"query","statement":"DELETE FROM ServiceOrders WHERE Status = 'closed'","outcome":"rejected","rejectionReason":"Only SELECT is permitted; statement begins with 'delete'.","truncated":false,"elapsedMs":1,"provider":"Sqlite"}More examples, including an allowed and an error record, are in
demo/audit-sample.jsonl.
The record never carries result data. AuditRecord (src/McpSqlServerTools/Audit) has no
field that can hold a row or a field value — rowCount is the only number that survives from a
query result, everything else is metadata about the request. An audit log that contains the data
it is auditing is a second, unaccounted-for copy of the sensitive thing.
Fails closed by default. If the sink can't write — disk full, bad path, permissions — the
tool call fails with an explanatory error rather than completing unrecorded. An audit trail with
silent gaps is worse than no audit trail, because it looks complete. MCP_AUDIT_FAIL_OPEN=true
is the explicit, named override: the call goes through and a warning is logged instead.
Audited in a decorator around the tools, not in SqlGateway.ExecuteAsync. A rejected
statement never reaches the gateway — the guard stops it first — and list_tables /
describe_table never go through the guard at all, so a gateway-level hook would miss exactly
the outcomes that matter most. Audit/ToolAudit.RunAsync is the single place every tool method
routes through instead, so a new tool can't forget to be audited without also forgetting how to
return a result.
The audit file is separate from ILogger, even when both happen to point at stderr (the
default, MCP_AUDIT_PATH unset). One is human-readable operational logging; the other is an
append-only compliance record with a fixed schema. Conflating them would mean a log-format change
silently breaking whatever ingests the audit trail.
Masking a column's output is not enough on its own. If Email is masked but the model can
still run SELECT COUNT(*) FROM Customers WHERE Email = 'someone@example.com', it can confirm
whether an address exists in the table, one guess at a time — the predicate leaks what the output
mask was supposed to hide. So there are two enforcement points, not one:
- Output masking, in
SqlGateway.ExecuteAsync. Selecting a redacted column is allowed; the value comes back masked. - Predicate rejection, in the guard. Using a redacted column in
WHERE,JOIN ON,GROUP BY,HAVINGorORDER BYis not allowed — the whole query is rejected, naming the column, before it runs.
Configure it with MCP_REDACTION_CONFIG, e.g. demo/redaction.json:
{
"redactions": [
{ "table": "Customers", "column": "Email", "strategy": "mask" },
{ "table": "Customers", "column": "Phone", "strategy": "hash" },
{ "table": "Vehicles", "column": "Vin", "strategy": "partial", "keepLast": 4 }
]
}Table and column names match case-insensitively. Three strategies:
| Strategy | Output |
|---|---|
mask |
Fixed placeholder ([REDACTED]) |
hash |
Stable, unsalted SHA-256 prefix — equal inputs always hash equal, so a join or a GROUP BY on the hashed column still works without the model ever seeing the real value |
partial |
The last keepLast characters, everything before them replaced with * |
$ query: SELECT Name, ContactEmail, ContactPhone FROM Dealerships WHERE Id = 1
{"columns":["Name","ContactEmail","ContactPhone"],"rowCount":1,
"rows":[["Lakeshore Motors","[REDACTED]","139c5834a7edf12d"]], ...}
Output masking is resolved from the AST, not the result column name. reader.GetName(i)
reports whatever the query aliased a column to, so a naive "mask any output column literally
named Email" check misses SELECT Email AS e. AstColumnRedactor parses the statement with
ScriptDom, walks the FROM clause to map aliases back to real table names, and traces every plain
SELECT-list item (including a qualified one like c.Email) back to its (table, column) before
deciding whether to mask it. SELECT * has no named columns in the AST at all — that case falls
back to matching the reader's column name against whichever tables are in scope for the star.
Provider asymmetry — SQL Server gets both protections, SQLite gets one. Predicate rejection
needs the same AST that output masking uses; ScriptDomReadOnlyGuard (SQL Server) has it,
ConservativeReadOnlyGuard (SQLite, see Design notes) is a keyword tokenizer
with no table/column resolution at all. So on SQLite: output masking still runs, but as a
name-only match with no AST to trace an alias through (NameOnlyColumnRedactor), and
WHERE Email = 'x' is not rejected — it runs and comes back masked, which does not stop the
equality check from confirming the guess. The server logs a warning about this at startup
whenever redaction is configured on the SQLite provider. See Known limits.
The audit log never records the value being redacted, or the guess against it.
AuditRecord has no field that can hold a row
value, so masked output was already safe. A rejected predicate is the harder case: the literal
being compared is the sensitive guess ("does Email = 'someone@example.com' match anything?"),
so logging the raw statement would defeat the rejection. The guard returns a sanitized copy of
the statement — the whole offending clause blanked out, not just the literal, since carving out
exactly the literal for every predicate shape (IN, LIKE, BETWEEN, IS NULL, ...) is a lot
of casework for a query that never runs — and QueryTools logs that instead of the original.
dotnet testThe guard tests are the ones that matter. They cover the bypasses that a naive implementation gets
wrong: keywords inside string literals, keywords inside comments, keywords as bracketed
identifiers, SELECT ... INTO, EXEC inside an otherwise valid select, and semicolon-separated
batches with the write in second position.
sample_rowsinterpolates a quoted table name because a table name cannot be a bound parameter. The identifier is delimiter-escaped and the resulting statement is still passed through the guard, but a bound parameter would be stronger if the provider allowed it.- Row estimates on SQL Server come from
sys.partitionsand are approximate by design. - No result caching. Repeated identical queries hit the database each time.
- Redaction predicate rejection is SQL-Server-only. SQLite has no AST parser in this project
(see Design notes), so
WHERE/JOIN ON/GROUP BY/HAVING/ORDER BYon a redacted column is not rejected there — only output masking applies. A startup warning is logged when this gap is actually relevant (redaction configured, provider is SQLite). - Redaction output masking on SQLite is name-only, with no alias resolution.
SELECT Email AS e FROM Customersis masked correctly on SQL Server (traced via the AST) but not on SQLite — the reader reports the column ase, and there is no parser on that provider left to traceeback toEmail. - An unqualified column in a multi-table, no-alias-prefix query is not masked or checked.
AstColumnRedactorand the predicate guard resolvec.Emailvia the FROM-clause alias map, but a bareEmailwith more than one table in scope and no qualifier has no way to know which table it came from without a real binder/catalog. It is left alone rather than guessed at. Always qualifying joined columns avoids this; the demo does. - Only a bare column reference is traced for masking or predicate rejection.
SELECT UPPER(Email)orWHERE Email LIKE '%x%''s wrapping expression is not specially understood — the column reference inside is still found by the predicate check (so theLIKEcase is still rejected), but a derived SELECT-list value likeUPPER(Email)is not masked, because there is no single source column to trace a function call back to. - The redaction hash strategy is unsalted. Equal inputs must hash equal for joins/
GROUP BYon the hash to work, but for a low-cardinality column (a phone number, a small zip-code range) that same property makes it brute-forceable offline — hash every candidate and compare. A keyed HMAC would close that at the cost of a secret to manage and hashes that no longer match anything computed outside this process; not implemented here. - The AST-based redactor re-parses the SQL text independently of the read-only guard, so a
query/sample_rowscall parses the same statement twice. Fine at this scale; a shared per-request parse would remove the duplication if it ever mattered.