DEV Community

AI Engineering Foundry
AI Engineering Foundry

Posted on

Build an MCP Server in C# That Safely Queries SQL Server

Build an MCP server in C#: read-only SQL Server tools, a T-SQL parser gate, cost and row limits, and setup for Claude Desktop, VS Code and Claude Code.

AI Engineering Foundry · October 10, 2026 · 22 min read
An MCP server lets Claude Desktop, VS Code, Claude Code or any other MCP host call code you wrote. Point one at SQL Server and the model can answer questions about your data directly, without you copying query results into a chat.

It also means a language model now holds a SQL tool. Most C# MCP tutorials stop at an echo tool or a weather lookup. This one builds a server that is safe to point at a real database, using the same defenses as the AI Database Agent: a database user that can only read, a real T-SQL parser instead of a regex, a cost limit, and a row cap. Then it shows what each of those did when we sent it hostile SQL.

The source is on GitHub:

Source on GitHub ↗
Tested with .NET 10 (SDK 10.0.401) · ModelContextProtocol 2.2.0 · Microsoft.Data.SqlClient 7.1.1 · Microsoft.SqlServer.TransactSql.ScriptDom 180.117.0 · SQL Server 2025 Developer (17.0) · the FoundryTrade sample database from the AI Database Agent. Tested through the SDK's own stdio MCP client (the same transport Claude Desktop uses).

In short:

• Install the ModelContextProtocol package, call AddMcpServer().WithStdioServerTransport(), and mark methods with [McpServerTool].
• Send logs to stderr. Over stdio, stdout is the protocol.
• Expose three narrow tools (list_tables, describe_table, run_query), not a generic "execute SQL".
• Every run_query call passes the same gate: a T-SQL parse with an allow-list, an estimated-cost check, then execution as a read-only database user with a row cap and timeout.
• ReadOnly = true on a tool is a hint to the host, not a control. The gate and the database user are the controls.
• Behind MCP you no longer own the agent loop. The host's model decides what to do with a rejection, so every check has to hold on every single call.

What MCP is, in one paragraph

The Model Context Protocol is an open standard for connecting AI applications to tools and data. A host (Claude Desktop, VS Code with Copilot, Claude Code, Cursor) starts or connects to a server, asks it which tools it offers, and lets its model call them. The messages are JSON-RPC. A local server usually runs as a child process and talks over stdio. A remote one is an HTTP endpoint (Streamable HTTP). You write the server once, and every MCP host can use it. That's the difference from plain function calling, where the tools live inside one application.

What we're building

MCP host (Claude Desktop / VS Code / Claude Code)
│ stdio, JSON-RPC
▼
SqlMcpServer (.NET 10 console app)
├── list_tables ← schema read as agent_reader
├── describe_table ← same cached schema
└── run_query ──► SqlValidator (ScriptDom allow-list)
──► estimated cost (SHOWPLAN_XML)
──► execute as agent_reader, 200-row cap, 15 s timeout
│
▼
SQL Server: FoundryTrade

The server never needs more than read access. The schema the model sees is read while impersonating the read-only user. That means the allow-list, the tool output and the database permissions can never disagree about what exists.

Step 1: Create the project

dotnet new console -n SqlMcpServer -f net10.0
cd SqlMcpServer
dotnet add package ModelContextProtocol
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Data.SqlClient
dotnet add package Microsoft.SqlServer.TransactSql.ScriptDom

ModelContextProtocol is the official C# SDK, maintained by the MCP project together with Microsoft. It brings the hosting and dependency-injection extensions. (ModelContextProtocol.AspNetCore is the HTTP variant. ModelContextProtocol.Core is the minimal client/low-level package.) The code here was built against 2.2.0. The SDK went through breaking changes during its 0.x previews, so check which version an older tutorial targets before copying its code.

Step 2: The host, and the stdout rule

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using SqlMcpServer;
var builder = Host.CreateApplicationBuilder(args);
// stdout carries the MCP protocol. Anything else written there corrupts it.
builder.Logging.ClearProviders();
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services.Configure(builder.Configuration.GetSection("SqlMcp"));
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithTools();
await builder.Build().RunAsync();

Over stdio, the host reads your process's standard output as a stream of JSON-RPC messages. A stray Console.WriteLine or a console log line in the middle of that stream breaks the session. LogToStandardErrorThreshold = LogLevel.Trace sends every log level to stderr. Hosts generally show stderr in their MCP logs, which is where you'll want it anyway.
WithTools() registers one class explicitly. WithToolsFromAssembly() also works, but explicit registration means a tool can't appear just because someone added an attribute somewhere.

The options are plain configuration:

public sealed class SqlMcpOptions
{
public string ConnectionString { get; set; } = "";
public string AgentUser { get; set; } = "agent_reader";
public int MaxRows { get; set; } = 200;
public int CommandTimeoutSeconds { get; set; } = 15;
public double MaxEstimatedCost { get; set; } = 200;
}

Because the generic host reads environment variables, the host application can pass the connection string as SqlMcp__ConnectionString. That keeps it out of source control.

Step 3: A database user that can only read

The database is the one layer that still holds if every other layer has a bug, so start there. This is the setup from the AI Database Agent. The full reasoning is in Preventing SQL Injection in AI-Generated SQL.

CREATE ROLE agent_readers AUTHORIZATION dbo;
GRANT SELECT ON SCHEMA::sales TO agent_readers;
GRANT SELECT ON SCHEMA::catalog TO agent_readers;
GRANT SELECT ON SCHEMA::inventory TO agent_readers;
GRANT SELECT ON hr.EmployeeDirectory TO agent_readers; -- a view of hr.Employees without Salary
DENY SELECT ON hr.Employees TO agent_readers;
GRANT SHOWPLAN TO agent_readers; -- for the cost check in step 5
CREATE USER agent_reader WITHOUT LOGIN WITH DEFAULT_SCHEMA = sales;
ALTER ROLE agent_readers ADD MEMBER agent_reader;

Nobody can connect as agent_reader. The MCP server connects with its own login and switches identity for each query. For that, its login needs IMPERSONATE on the user, and it should need little else:

-- the login the MCP server connects with (Windows or SQL authentication)
CREATE USER mcp_server FOR LOGIN [YOURDOMAIN\svc-mcp];
GRANT IMPERSONATE ON USER::agent_reader TO mcp_server;

Our test run used a Windows login with full rights, so we haven't yet confirmed this is the complete minimum. Try it with a dedicated login before relying on it.

The switch uses EXECUTE AS ... WITH COOKIE, so generated SQL can't REVERT back to the server's own permissions:

public sealed partial class AgentSession : IAsyncDisposable
{
private readonly byte[] _cookie;
public SqlConnection Connection { get; }
private AgentSession(SqlConnection connection, byte[] cookie)
{
Connection = connection;
_cookie = cookie;
}
public static async Task OpenAsync(SqlMcpOptions options, CancellationToken ct)
{
if (!SimpleIdentifier().IsMatch(options.AgentUser))
throw new InvalidOperationException("SqlMcp:AgentUser must be a simple identifier.");
var connection = new SqlConnection(options.ConnectionString);
await connection.OpenAsync(ct);
try
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = $"""
DECLARE @cookie varbinary(8000);
EXECUTE AS USER = N'{options.AgentUser}' WITH COOKIE INTO @cookie;
SELECT @cookie;
""";
var cookie = (byte[])(await cmd.ExecuteScalarAsync(ct))!;
return new AgentSession(connection, cookie);
}
catch
{
await connection.DisposeAsync();
throw;
}
}
public async ValueTask DisposeAsync()
{
try
{
await using var cmd = Connection.CreateCommand();
cmd.CommandText = "REVERT WITH COOKIE = @cookie;";
cmd.Parameters.Add("@cookie", System.Data.SqlDbType.VarBinary, 8000).Value = _cookie;
await cmd.ExecuteNonQueryAsync();
}
catch
{
// A pooled connection that is still impersonating must never be reused.
SqlConnection.ClearPool(Connection);
}
await Connection.DisposeAsync();
}
[GeneratedRegex("^[A-Za-z_][A-Za-z0-9_]{0,127}$")]
private static partial Regex SimpleIdentifier();
}

The user name is interpolated into SQL, which is why it comes from configuration and is checked against a strict identifier pattern first. The model never touches it.

Step 4: Three narrow tools, not one "execute SQL"

[McpServerToolType]
public sealed class SqlTools(SchemaCatalog catalog, QueryRunner runner)
{
[McpServerTool(Name = "list_tables", ReadOnly = true, Idempotent = true, OpenWorld = false)]
[Description("Lists the tables you can query, as schema.table names.")]
public async Task ListTables(CancellationToken ct)
{
var tables = await catalog.GetTablesAsync(ct);
return string.Join("\n", tables.Keys.Order(StringComparer.OrdinalIgnoreCase));
}
[McpServerTool(Name = "describe_table", ReadOnly = true, Idempotent = true, OpenWorld = false)]
[Description("Returns the columns and data types of one table.")]
public async Task DescribeTable(
[Description("Table name as schema.table, for example sales.Orders")] string table,
CancellationToken ct)
{
var tables = await catalog.GetTablesAsync(ct);
if (!tables.TryGetValue(table, out var info))
throw new McpException($"Unknown table '{table}'. Call list_tables to see what exists.");
return string.Join("\n", info.Columns.Select(c => $"{c.Name} {c.DataType}{(c.Nullable ? " NULL" : "")}"));
}
[McpServerTool(Name = "run_query", ReadOnly = true, Idempotent = true, OpenWorld = false)]
[Description("Runs one read-only T-SQL SELECT and returns at most 200 rows as JSON. " +
"Only tables from list_tables are allowed. Use TOP, WHERE and GROUP BY to keep results small.")]
public async Task RunQuery(
[Description("A single T-SQL SELECT statement")] string sql,
CancellationToken ct)
{
var result = await runner.RunAsync(sql, ct);
return JsonSerializer.Serialize(new
{
columns = result.Columns,
rows = result.Rows,
rowCount = result.Rows.Count,
truncated = result.Truncated,
});
}
}

A few things the SDK does for you here:

• Constructor injection works. SchemaCatalog and QueryRunner come from the container. CancellationToken is bound automatically and left out of the tool's input schema.
• [Description] becomes the schema the model reads. The run_query input schema the host receives is exactly {"type":"object","properties":{"sql":{"description":"A single T-SQL SELECT statement","type":"string"}},"required":["sql"]}. The tool description is the only documentation the model gets, so spend words there.
• McpException messages reach the model; other exceptions don't. If a tool throws an McpException, the result comes back with isError: true and your message, prefixed by the SDK with "An error occurred invoking 'run_query': ". Any other exception becomes a generic error with no details. That default matters here: a failed connection can't leak a server name or connection string into the chat.

Why three tools instead of one? With list_tables and describe_table, the model can find out what exists without writing SQL against system views, which the gate rejects anyway. And each tool's output is small and predictable. In Part 5 of the AI Database Agent series, the same kind of discovery tools let a 7B model work like an analyst, and also showed how rarely a small model chooses to use them.

The schema itself is read once, while impersonating a*gent_reader. INFORMATION_SCHEMA* only lists objects the current user has permissions on, so hidden tables never appear:

await using var session = await AgentSession.OpenAsync(options.Value, ct);
await using var cmd = session.Connection.CreateCommand();
// INFORMATION_SCHEMA only lists objects the current user has permissions on.
cmd.CommandText = """
SELECT c.TABLE_SCHEMA, c.TABLE_NAME, c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE
FROM INFORMATION_SCHEMA.COLUMNS c
JOIN INFORMATION_SCHEMA.TABLES t
ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME
WHERE t.TABLE_SCHEMA NOT IN ('sys', 'INFORMATION_SCHEMA')
ORDER BY c.TABLE_SCHEMA, c.TABLE_NAME, c.ORDINAL_POSITION;
""";

On FoundryTrade that returns 11 tables and views. hr.Employees (with the salary column) is not one of them, and hr.EmployeeDirectory is.

Tool annotations are hints, not controls

ReadOnly = true, Idempotent = true and OpenWorld = false become the MCP annotations readOnlyHint, idempotentHint and openWorldHint. Hosts can use them to decide, for example, whether to ask the user before a call. Set them honestly, because they're useful.

They don't enforce anything. The MCP specification itself says clients "MUST consider tool annotations to be untrusted unless they come from trusted servers". Nothing stops a tool marked ReadOnly from running an UPDATE. In this server, the read-only guarantee comes from the next step and from the database user, never from the attribute.

Step 5: The gate every query passes

public sealed class QueryRunner(IOptions options, SchemaCatalog catalog, ILogger logger)
{
private readonly SqlMcpOptions _options = options.Value;
public async Task RunAsync(string sql, CancellationToken ct)
{
var tables = await catalog.GetTablesAsync(ct);
var problems = SqlValidator.Validate(sql, new HashSet(tables.Keys, StringComparer.OrdinalIgnoreCase));
if (problems.Count > 0)
{
logger.LogWarning("Rejected query: {Problems} SQL: {Sql}", string.Join(" | ", problems), sql);
throw new McpException("Query rejected: " + string.Join(" ", problems));
}
await using var session = await AgentSession.OpenAsync(_options, ct);
var cost = await EstimateCostAsync(session.Connection, sql, ct);
if (cost > _options.MaxEstimatedCost)
throw new McpException(
$"Query rejected: estimated cost {cost:F1} is above the limit of {_options.MaxEstimatedCost}. Filter or aggregate more.");
await using var cmd = session.Connection.CreateCommand();
cmd.CommandText = sql;
cmd.CommandTimeout = _options.CommandTimeoutSeconds;
try
{
await using var reader = await cmd.ExecuteReaderAsync(ct);
var columns = Enumerable.Range(0, reader.FieldCount).Select(reader.GetName).ToList();
var rows = new List();
var truncated = false;
while (await reader.ReadAsync(ct))
{
if (rows.Count == _options.MaxRows) { truncated = true; break; }
var row = new object?[reader.FieldCount];
reader.GetValues(row);
rows.Add(row.Select(v => v is DBNull ? null : v).ToArray());
}
logger.LogInformation("Ran query: {Rows} rows, cost {Cost}", rows.Count, cost);
return new QueryResult(columns, rows, truncated, cost);
}
catch (SqlException ex)
{
// Errors the model can act on (bad column, conversion, timeout) go back as tool errors.
throw new McpException($"SQL Server error {ex.Number}: {ex.Message}");
}
}
private static async Task EstimateCostAsync(SqlConnection connection, string sql, CancellationToken ct)
{
await using var on = connection.CreateCommand();
on.CommandText = "SET SHOWPLAN_XML ON;";
await on.ExecuteNonQueryAsync(ct);
try
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = sql; // compiled, not executed
var plan = XDocument.Parse((string)(await cmd.ExecuteScalarAsync(ct))!);
return plan.Descendants()
.Where(e => e.Name.LocalName == "StmtSimple")
.Sum(e => double.Parse(e.Attribute("StatementSubTreeCost")?.Value ?? "0", CultureInfo.InvariantCulture));
}
catch (SqlException ex)
{
throw new McpException($"SQL Server error {ex.Number}: {ex.Message}");
}
finally
{
await using var off = connection.CreateCommand();
off.CommandText = "SET SHOWPLAN_XML OFF;";
await off.ExecuteNonQueryAsync(CancellationToken.None);
}
}
}

The order is deliberate:

1- Parse before touching the database. SqlValidator parses the statement with Microsoft's T-SQL parser (ScriptDom). It requires exactly one SELECT, no SELECT ... INTO, no OPENROWSET/OPENQUERY/table-valued functions, no three- or four-part names, no variables or user-defined functions, and only tables from the allow-list. It's the same validator, unchanged, that is walked through rule by rule in Preventing SQL Injection in AI-Generated SQL. The allow-list is the impersonated schema from Step 4.
2- Estimate before executing. With SHOWPLAN_XML on, SQL Server compiles the statement and returns the estimated plan without running it. That runs as agent_reader, hence GRANT SHOWPLAN. The finally block turns it off before the session reverts. While it's on, nothing executes, REVERT included.
3- Execute with limits. A 15-second command timeout, and the reader stops after 200 rows and reports truncated: true, so the model knows it didn't see everything.

Errors the model can fix (an invalid column, a conversion error) go back with the SQL Server error number, so the model can correct the query. Want to check what the model sent? Paste it into the SQL formatter, which also points to the exact line of a syntax error.

What happened when we attacked it

We connected to the server with the SDK's own MCP client over stdio, exactly as a host would, and sent it the requests below. The server ran against the FoundryTrade database (6,000 orders, 16,877 order lines). Times are wall-clock for the full tool call on a local machine.

Request sent to run_queryResultTimeTop 5 customers by order count (join + GROUP BY)5 rows139 ms (first query)SELECT USER_NAME(), SUSER_SNAME()agent_reader, and a SID instead of a login name12 msSELECT * FROM sales.OrderLines200 rows, truncated: true32 msUPDATE sales.Orders SET Status = 'Shipped' ...Rejected: exactly one SELECT allowed4 msDROP TABLE sales.PaymentsRejected: exactly one SELECT allowed2 msSELECT 1; DROP TABLE sales.PaymentsRejected: exactly one SELECT allowed1 msREVERT; SELECT * FROM hr.EmployeesRejected: exactly one SELECT allowedunder 1 msSELECT 1 AS x -- ; DROP TABLE ...Allowed, returns 1. The DROP is a comment4 msSELECT name FROM sys.server_principalsRejected: table does not exist or is not availableunder 1 msSELECT TOP (3) * FROM hr.EmployeesRejected: table does not exist or is not availableunder 1 msSELECT ... FROM FoundryTrade.sales.OrdersRejected: references to other databasesunder 1 msSELECT ... FROM AgentOps.dbo.AuditLogRejected: references to other databasesunder 1 msOPENROWSET(BULK 'C:\Windows\win.ini', SINGLE_CLOB)Rejected: table source BulkOpenRowset3 msSELECT * INTO dbo.Leak FROM sales.CustomersRejected: SELECT ... INTOunder 1 msCross join of OrderLines with itself, sortedRejected: estimated cost 3,834.9 (limit 200)21 msSELECT TOP (3) Nme FROM sales.CustomersSQL Server error 207: invalid column name 'Nme'11 ms

Four results are worth a closer look.

The comment trick was allowed, which is correct. A regex blocklist rejects SELECT 1 -- ; DROP TABLE x. The parser sees one harmless statement and a comment. That is the point of parsing: the gate judges what SQL Server will run, not which words appear in the text.

Hidden and non-existent tables get the same message. hr.Employees exists, and sys.server_principals exists, but the model is told both are "not available", the same as a misspelled table. The server doesn't confirm which objects are hidden.

The SUSER_SNAME() result is a SID. The query ran as a database user without a login, so there's no login name to return. It's the quickest way to confirm the impersonation really happened.

A cross join is not automatically expensive. We expected SELECT COUNT_BIG(*) FROM sales.OrderLines a CROSS JOIN sales.OrderLines b to hit the cost limit. It didn't. The optimizer estimated it as cheap, and it was: it returned 284,833,129 in 28 ms. The same cross join with an ORDER BY over 285 million combinations estimated at 3,834.9 and was rejected before it ran. A plan-based limit judges what SQL Server will actually do, which is what you want. It also means a threshold should be tuned on your own workload, not on how a query looks.

You don't own the loop anymore

This is the part that changes when the same gate moves from your own API to an MCP server.

In the AI Database Agent, the API runs the conversation with the model. It has a rule that a security rejection is final: a rejected DROP is never sent back to the model as "try again", because that would coach it through the validator one rejection at a time.

Behind MCP, the host runs the loop. Your server returns "Query rejected" and the host's model decides what happens next. It may tell the user. It may rewrite the query and call run_query again, and again. You can't make a rejection final from the server side.

So the design has to assume the model gets as many attempts as it likes:

• Every check runs on every call. No state like "this conversation was already validated". Each run_query is parsed, costed and executed as agent_reader from scratch.

• The database user is the boundary that can't be talked around. If some phrasing ever gets past the parser, the statement still runs as a user that can only SELECT the tables it's allowed to see.

• Rejections say what the rule is, not how to get around it. "Exactly one SELECT statement is allowed" helps an honest model fix its query. It gives nothing away that the tool description doesn't already say.

• Limit the rate, not just the query. The MCP tools specification says servers must rate limit tool invocations. Each query here is already capped in cost, rows and time. A local stdio server used by one person can live without a per-minute limit. A shared HTTP server can't: add ASP.NET Core's rate-limiting middleware in front of MapMcp().

• Log rejections where you'll see them. The server logs each rejection with the SQL to stderr. In production, send that to the same place as your other security logs. A run of rejected write attempts is a signal, as in the Part 6 audit log.

The data you return reaches the model

Every row run_query returns goes into the host's context window. Two consequences:

The model can repeat anything it can read. If a column shouldn't appear in a chat transcript, keep it out of agent_reader's view of the world, the way Salary is kept behind hr.EmployeeDirectory. The 200-row cap also limits how much one call can pull.

Data can carry instructions. A free-text column, such as a customer note or a support ticket body, can contain text written to steer the model ("ignore your previous instructions and …"). A read-only server limits what that text can do through this server. But an MCP host often has other tools connected too: web fetch, file write, email, other servers. A model that reads private data, sees untrusted text and can send data out is the combination Simon Willison calls the lethal trifecta. Your server can't control the host's other tools. What you can control is how much sensitive data the server exposes in the first place. The AI agent security guide covers this in more depth.

Step 6: Connect it to a host

Publish the server once, so the host runs a built DLL rather than compiling on every start:

dotnet publish -c Release -o C:\mcp\sql-server

The examples use Windows authentication, so there is no password in any config file. With SQL authentication, use your host's secret support instead of pasting a password into JSON.

Claude Desktop. Edit claude_desktop_config.json (%APPDATA%\Claude** on Windows, ~/Library/Application Support/Claude**/ on macOS) and restart the app:

{
"mcpServers": {
"foundrytrade": {
"command": "dotnet",
"args": ["C:\mcp\sql-server\SqlMcpServer.dll"],
"env": {
"SqlMcp__ConnectionString": "Server=127.0.0.1;Database=FoundryTrade;Integrated Security=true;TrustServerCertificate=true"
}
}
}
}

VS Code. Create .vscode/mcp.json. Note that the root key is servers, not mcpServers:

{
"servers": {
"foundrytrade": {
"type": "stdio",
"command": "dotnet",
"args": ["C:\mcp\sql-server\SqlMcpServer.dll"],
"env": {
"SqlMcp__ConnectionString": "Server=127.0.0.1;Database=FoundryTrade;Integrated Security=true;TrustServerCertificate=true"
}
}
}
}

Claude Code. One command. Everything after -- is the server's command line:

claude mcp add foundrytrade -e "SqlMcp__ConnectionString=Server=127.0.0.1;Database=FoundryTrade;Integrated Security=true;TrustServerCertificate=true" -- dotnet C:\mcp\sql-server\SqlMcpServer.dll

127.0.0.1 rather than localhost is deliberate. In the Part 6 traces, localhost cost a 2-second IPv6 attempt on every new connection.

Step 7: Test it without a model

You don't need a model to test an MCP server. The SDK includes a client, and it's the fastest way to check the tools, their schemas and the gate:

var transport = new StdioClientTransport(new StdioClientTransportOptions
{
Name = "foundrytrade",
Command = "dotnet",
Arguments = [serverDll],
EnvironmentVariables = new Dictionary
{
["SqlMcp__ConnectionString"] =
"Server=127.0.0.1;Database=FoundryTrade;Integrated Security=true;TrustServerCertificate=true",
},
});
await using var client = await McpClient.CreateAsync(transport);
foreach (var tool in await client.ListToolsAsync())
Console.WriteLine($"{tool.Name}: readOnly={tool.ProtocolTool.Annotations?.ReadOnlyHint}");
var result = await client.CallToolAsync("run_query",
new Dictionary { ["sql"] = "DROP TABLE sales.Payments" });
Console.WriteLine($"isError={result.IsError}"); // isError=True

This is how the attack table above was produced. Turn it into an xUnit test that runs in CI, and a change that weakens the gate fails the build instead of reaching a host. For poking at a server by hand, the MCP Inspector gives you a UI for the same operations.

When to use HTTP instead of stdio

Stdio is right when the server runs on the same machine as the host, for one user, with that user's (or a service's) database access. If several people should share one server, use ModelContextProtocol.AspNetCore, WithHttpTransport() and app.MapMcp().RequireAuthorization(), with real authentication in front. Then the question becomes whose data each call may see. One shared agent_reader is no longer enough, and per-user identity or row-level security has to come in. See the authorization section of the AI agent security guide. We haven't built the HTTP version of this server, so we're not showing untested code for it here.

Checklist

• Logs go to stderr, nothing else writes to stdout
• Tools registered explicitly (WithTools())
• Database user is read-only, has no login, and sensitive columns are behind views
• Server's own login can only CONNECT and IMPERSONATE
• Impersonation uses WITH COOKIE. A failed revert clears the pool
• Schema and allow-list are read as the read-only user
• Every query is parsed (ScriptDom), costed (SHOWPLAN_XML), capped (rows, timeout)
• Annotations set honestly, but nothing relies on them
• Rejections return the rule, never internal details. Other exceptions stay generic
• Rejections are logged and monitored
• A shared (HTTP) server has rate limiting and authentication
• Hostile-SQL tests run in CI through a real MCP client
• Connection strings come from the environment or a secret store, not
• committed JSON

FAQ

What NuGet package do I need for an MCP server in C#? ModelContextProtocol for a stdio server with hosting and dependency injection, or ModelContextProtocol.AspNetCore for an HTTP server. Both are part of the official C# SDK. This article uses version 2.2.0 on .NET 10. The package also targets .NET 8 and .NET Standard 2.0.

Why do my MCP server's responses break randomly? Usually because something writes to stdout: a log line, a Console.WriteLine, a library banner. Over stdio, stdout is the protocol channel. Send all logging to stderr.

Can the model run UPDATE or DELETE through this server? No. The parser rejects any statement that isn't a single SELECT, and even if it didn't, the query runs as a database user with only SELECT permission. In our tests, UPDATE, DROP, stacked statements and SELECT ... INTO were all rejected before reaching SQL Server.

Isn't ReadOnly = true enough? No. It sets readOnlyHint in the tool's annotations. That's information for the host, not enforcement, and the MCP spec tells clients not to trust annotations from untrusted servers.

Should I use an existing SQL Server MCP server instead? If one fits your needs, maybe. Ready-made database MCP servers exist. Build your own when you need control over exactly what is validated, which identity the query runs as, and which tables the model can see, or when you want the gate to be code your team can read and test.

Does this work with OpenAI or local models, not just Claude? MCP is host-independent: any MCP host can use this server, whatever model it runs. For tool calling with a local model inside your own .NET app, see Part 5 of the AI Database Agent series.

The complete server is on GitHub:

Source on GitHub ↗

Related

• Preventing SQL Injection in AI-Generated SQL: the validator and database user, rule by rule
• AI Agent Security: What Developers Need to Know
• Text-to-SQL in C# with ASP.NET Core and SQL Server: the same database, behind a web API instead of MCP
• AI Database Agent, Part 5: Tool Calling with a 7B Model
• More in AI Agents

Continue Learning

Want to explore how to connect AI applications with SQL Server using C# and MCP? Read the complete guide here:

Build an MCP Server in C# with SQL Server

Top comments (0)