Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ jobs:

- name: Serves health, rejects requests without the token, runs as non-root
run: |
docker run -d --name app -e App__AuthToken=ci-token -p 8080:8080 sql-mcp
docker run -d --name app -e App__AgentTokens__ci=ci-token -p 8080:8080 sql-mcp
for i in $(seq 30); do curl -sf localhost:8080/health && break; sleep 1; done
test "$(curl -s -o /dev/null -w '%{http_code}' localhost:8080/health)" = 200
test "$(curl -s -o /dev/null -w '%{http_code}' -X POST localhost:8080/mcp)" = 401
Expand Down
82 changes: 56 additions & 26 deletions Http/HttpSecurity.cs
Original file line number Diff line number Diff line change
@@ -1,37 +1,53 @@
using System.Net;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;

namespace SqlMcp.Http;

/// <summary>
/// Protects the HTTP transport. Requests to /mcp and /api/* must carry
/// "Authorization: Bearer {App:AuthToken}" when a token is configured, and any request
/// with an Origin header must come from App:AllowedOrigins (the MCP spec requires
/// Origin checks so a web page can't reach a local server through DNS rebinding).
/// Without a token the server refuses to listen beyond loopback unless
/// App:AllowUnauthenticated is set.
/// Protects the HTTP transport. When App:AgentTokens maps agent names to tokens, requests
/// to /mcp and /api/* must carry "Authorization: Bearer {token}" for one of them, and the
/// request's user is that agent, so each call is logged under its name. Any request with
/// an Origin header must come from App:AllowedOrigins (the MCP spec requires Origin
/// checks so a web page can't reach a local server through DNS rebinding). Without
/// tokens the server refuses to listen beyond loopback unless App:AllowUnauthenticated
/// is set.
/// </summary>
public static class HttpSecurity
{
public const string DefaultUrls = "http://localhost:8080";

/// <summary>
/// Throws when the server would accept unauthenticated requests from other machines.
/// Throws, listing every problem, when an agent token is blank or shared, or when the
/// server would accept unauthenticated requests from other machines.
/// </summary>
public static void ValidateStartup(IConfiguration config)
{
if (!string.IsNullOrEmpty(config["App:AuthToken"]) || config.GetValue("App:AllowUnauthenticated", false))
return;

var exposed = ParseUrls(config["App:Urls"] ?? DefaultUrls).Where(u => !IsLoopback(u)).ToList();
if (exposed.Count > 0)
throw new InvalidOperationException(
$"Refusing to listen on {string.Join(", ", exposed)} without authentication. " +
"Set App:AuthToken (env App__AuthToken) to require a bearer token, bind to localhost, " +
"or set App:AllowUnauthenticated to true if something in front of this server authenticates.");
var errors = new List<string>();
var agents = ReadAgentTokens(config);
foreach (var (name, token) in agents.Where(a => string.IsNullOrWhiteSpace(a.Token)))
errors.Add($"Agent '{name}' has no token in App:AgentTokens.");
foreach (var shared in agents.Where(a => !string.IsNullOrWhiteSpace(a.Token)).GroupBy(a => a.Token).Where(g => g.Count() > 1))
errors.Add($"Agents {string.Join(", ", shared.Select(a => $"'{a.Name}'"))} share a token, so their calls can't be told apart.");

if (agents.Count == 0 && !config.GetValue("App:AllowUnauthenticated", false))
{
var exposed = ParseUrls(config["App:Urls"] ?? DefaultUrls).Where(u => !IsLoopback(u)).ToList();
if (exposed.Count > 0)
errors.Add($"Refusing to listen on {string.Join(", ", exposed)} without authentication. " +
"Give each agent a token under App:AgentTokens (env App__AgentTokens__<name>), bind to localhost, " +
"or set App:AllowUnauthenticated to true if something in front of this server authenticates.");
}

if (errors.Count > 0)
throw new InvalidOperationException("Invalid configuration:" + string.Concat(errors.Select(e => $"{Environment.NewLine} - {e}")));
}

/// <summary>App:AgentTokens as (agent name, token) pairs.</summary>
public static List<(string Name, string? Token)> ReadAgentTokens(IConfiguration config) =>
[.. config.GetSection("App:AgentTokens").GetChildren().Select(c => (c.Key, c.Value))];

public static IEnumerable<string> ParseUrls(string urls) =>
urls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

Expand All @@ -55,8 +71,10 @@ public static bool IsLoopback(string url)

public static void UseHttpSecurity(this WebApplication app)
{
var token = app.Configuration["App:AuthToken"];
var expected = string.IsNullOrEmpty(token) ? null : SHA256.HashData(Encoding.UTF8.GetBytes(token));
var agents = ReadAgentTokens(app.Configuration)
.Select(a => (a.Name, Hash: SHA256.HashData(Encoding.UTF8.GetBytes(a.Token!))))
.ToList();
var logger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger(typeof(HttpSecurity));
var allowedOrigins = new HashSet<string>(
app.Configuration.GetSection("App:AllowedOrigins").Get<string[]>() ?? [],
StringComparer.OrdinalIgnoreCase);
Expand All @@ -71,11 +89,17 @@ public static void UseHttpSecurity(this WebApplication app)
return;
}

if (expected is not null && RequiresAuth(context.Request.Path) && !HasValidToken(context.Request, expected))
if (agents.Count > 0 && RequiresAuth(context.Request.Path))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.Headers.WWWAuthenticate = "Bearer";
return;
if (FindAgent(context.Request, agents) is not { } agent)
{
logger.LogWarning("Rejected {Method} {Path} from {RemoteIp}: missing or unknown bearer token.",
context.Request.Method, context.Request.Path, context.Connection.RemoteIpAddress);
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.Headers.WWWAuthenticate = "Bearer";
return;
}
context.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, agent)], "Bearer"));
}

await next();
Expand All @@ -85,15 +109,21 @@ public static void UseHttpSecurity(this WebApplication app)
private static bool RequiresAuth(PathString path) =>
!path.Equals("/health", StringComparison.OrdinalIgnoreCase);

private static bool HasValidToken(HttpRequest request, byte[] expected)
/// <summary>The agent whose token the request carries, or null.</summary>
private static string? FindAgent(HttpRequest request, List<(string Name, byte[] Hash)> agents)
{
var header = request.Headers.Authorization.ToString();
const string prefix = "Bearer ";
if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return false;
return null;

// Compare fixed-length hashes so neither content nor length leaks through timing.
// Compare fixed-length hashes so neither content nor length leaks through timing,
// and check every agent so the time taken doesn't say which one matched.
var presented = SHA256.HashData(Encoding.UTF8.GetBytes(header[prefix.Length..].Trim()));
return CryptographicOperations.FixedTimeEquals(presented, expected);
string? match = null;
foreach (var (name, hash) in agents)
if (CryptographicOperations.FixedTimeEquals(presented, hash))
match = name;
return match;
}
}
4 changes: 2 additions & 2 deletions Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@
statusCode: ready ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable);
});

app.MapPost("/api/query", async (QueryService svc, QueryRequest req, CancellationToken ct) =>
app.MapPost("/api/query", async (QueryService svc, QueryRequest req, HttpContext context, CancellationToken ct) =>
{
var result = await svc.ExecuteAsync(req, ct);
var result = await svc.ExecuteAsync(req, context.User.Identity?.Name, ct);
if (result.IsError)
return Results.Problem(result.Error);
return Results.Ok(result.Value);
Expand Down
29 changes: 20 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Streamable HTTP:
"sql": {
"type": "http",
"url": "http://localhost:8080/mcp",
"headers": { "Authorization": "Bearer <App:AuthToken>" }
"headers": { "Authorization": "Bearer <the agent's token from App:AgentTokens>" }
}
}
}
Expand All @@ -86,9 +86,9 @@ All settings live in `appsettings.json`.
| Key | Default | Description |
|-----|---------|-------------|
| `App:Urls` | `http://localhost:8080` | Bind address(es), `;`-separated. Override with env var `App__Urls`. |
| `App:AuthToken` | _(none)_ | Bearer token required on `/mcp` and `/api/*`. Set it with env var `App__AuthToken` rather than in the file. Required to listen beyond localhost. |
| `App:AgentTokens` | `{}` | Each agent's name and the bearer token it sends on `/mcp` and `/api/*`, e.g. `{"reporting": "<token>"}`. Calls are logged under the agent's name (see [Logging](#logging)). Set tokens with env vars such as `App__AgentTokens__reporting` rather than in the file, and keep names to letters, digits and underscores so they work as variable names everywhere. A blank token, or one shared by two agents, stops the server at startup. Required to listen beyond localhost. |
| `App:AllowedOrigins` | `[]` | Browser origins allowed to call the server. Requests carrying any other `Origin` header are rejected. |
| `App:AllowUnauthenticated` | `false` | Allow listening beyond localhost without `AuthToken`, for when a proxy in front authenticates. |
| `App:AllowUnauthenticated` | `false` | Allow listening beyond localhost without `AgentTokens`, for when a proxy in front authenticates. |
| `App:MaxRows` | `1000` | Maximum rows returned per query, and tables per `list_tables`. Response includes `"truncated": true` when the limit is hit. |
| `App:QueryTimeoutSeconds` | `30` | Per-query timeout in seconds; a query that runs longer fails with "Query timed out after N s". Also how long a query waits for a free slot when `MaxConcurrentQueries` are already running. |
| `App:MaxValueLength` | `10000` | Longest string returned for a single value, in characters. Longer text and binary values are cut off and marked `… [truncated: N characters in total]`. |
Expand Down Expand Up @@ -133,7 +133,18 @@ For local development, put real connection strings in `appsettings.Development.j

### Logging

Each tool call is logged by `SqlMcp.Services.QueryService`: the database, the SQL (or the schema lookup), the names of any parameters, how long it took and how many rows it returned. Parameter values are never logged. Failures are logged as warnings. `appsettings.Production.json` keeps this category at `Information` while everything else is at `Warning`; set it to `Warning` to log only failures. In stdio mode all logs go to stderr.
Each tool call and `/api/query` request is logged by `SqlMcp.Services.QueryService`: the agent, the database, the SQL (or the schema lookup), the names of any parameters, how long it took and how many rows it returned (for `list_databases`, the agent and how many databases there are). Parameter values are never logged.

```
2026-10-18T14:02:11.384Z info: SqlMcp.Services.QueryService[0]
Ran query: SELECT id FROM orders on prod for reporting in 12 ms, 3 rows.
2026-10-18T14:02:40.019Z warn: SqlMcp.Services.QueryService[0]
Refused query: DELETE FROM orders on prod for reporting: Only SELECT queries (including CTEs) are permitted.
```

The agent is the name its token has in `App:AgentTokens`. Over stdio, or over HTTP without agent tokens, it is `unauthenticated`. Calls refused before they reach the database (an unknown database, a statement that isn't a read, invalid parameters) are logged as warnings with the SQL, so the log shows what each agent tried as well as what ran. Failures are warnings too, including a `describe_table` for a table that doesn't exist or matches more than one. A request with a missing or unknown token is logged as a warning by `SqlMcp.Http.HttpSecurity`, with its path and remote address but not the token.

`appsettings.Production.json` keeps `QueryService` at `Information` while everything else is at `Warning`; set it to `Warning` to log only refusals and failures. It also starts each entry with the UTC time; under systemd, journald records the time instead. In stdio mode all logs go to stderr.

### Environment variable overrides

Expand Down Expand Up @@ -249,7 +260,7 @@ curl -s http://localhost:8080/mcp \
{ "status": "unavailable", "databases": { "prod": "ok", "reporting": "Database 'reporting' is disabled because its login can modify data. The server log lists why." } }
```

It requires the bearer token when `App:AuthToken` is set, since it names the databases. Readiness checks aren't written to the query log.
It requires an agent's token when `App:AgentTokens` is set, since it names the databases. Readiness checks aren't written to the query log.

---

Expand Down Expand Up @@ -287,7 +298,7 @@ Queries come from AI agents, so every query passes three layers before anything

**What agents can still do:** read anything the login can read. Grant `SELECT` only on what agents should see. Two cases fall outside the audit: grants made after a passing audit (noticed within five minutes), and on SQL Server, CLR functions created with `EXTERNAL_ACCESS` or `UNSAFE` that the login can call.

**HTTP transport.** By default the server listens on `localhost` only. To listen beyond it, set `App:AuthToken`: clients then send `Authorization: Bearer <token>` on `/mcp` and `/api/*` (`/health` stays open for probes). The server refuses to start on a non-loopback address without a token unless `App:AllowUnauthenticated` is `true`. Requests that carry an `Origin` header not listed in `App:AllowedOrigins` are rejected, so a web page can't reach a local server through DNS rebinding. The stdio transport is only reachable by the process that launched it.
**HTTP transport.** By default the server listens on `localhost` only. To listen beyond it, give each agent its own token under `App:AgentTokens`: clients then send `Authorization: Bearer <token>` on `/mcp` and `/api/*` (`/health` stays open for probes), and their calls are logged under the agent's name. Revoking an agent means removing its entry and restarting. The server refuses to start on a non-loopback address without tokens unless `App:AllowUnauthenticated` is `true`. Requests that carry an `Origin` header not listed in `App:AllowedOrigins` are rejected, so a web page can't reach a local server through DNS rebinding. The stdio transport is only reachable by the process that launched it.

**Values in queries.** Agents should pass values through `parameters` (see [`query_database`](#query_database)) rather than splicing them into the SQL. Bound values can't change a query's structure, whatever quotes or keywords they contain. Statement validation still applies to the SQL text itself, and catches injected statements in SQL an agent built by concatenating strings.

Expand Down Expand Up @@ -316,13 +327,13 @@ No .NET runtime needed on the target machine.
```bash
docker build -t sql-mcp .
docker run -p 8080:8080 \
-e App__AuthToken="$(openssl rand -hex 32)" \
-e App__AgentTokens__reporting="$(openssl rand -hex 32)" \
-e DB_PROD_PASSWORD=secret \
-v /path/to/appsettings.json:/app/appsettings.json \
sql-mcp
```

The image listens on all interfaces inside the container, so it won't start without `App__AuthToken`. It runs as the base image's unprivileged `app` user (uid 1654), so a mounted `appsettings.json` must be readable by that user. Its `HEALTHCHECK` runs `dotnet sql-mcp.dll --healthcheck`, which calls `/health` on the configured port and exits 0 or 1. Use the same command for health checks anywhere curl isn't installed.
The image listens on all interfaces inside the container, so it won't start without at least one `App__AgentTokens__<name>`. It runs as the base image's unprivileged `app` user (uid 1654), so a mounted `appsettings.json` must be readable by that user. Its `HEALTHCHECK` runs `dotnet sql-mcp.dll --healthcheck`, which calls `/health` on the configured port and exits 0 or 1. Use the same command for health checks anywhere curl isn't installed.

### Linux — systemd service

Expand All @@ -339,7 +350,7 @@ WorkingDirectory=/opt/sql-mcp
Restart=always
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=DB_PROD_PASSWORD=secret
Environment=App__AuthToken=<long random token>
Environment=App__AgentTokens__reporting=<long random token>

[Install]
WantedBy=multi-user.target
Expand Down
Loading
Loading