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
105 changes: 105 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
name: CI

on:
push:
branches: [master]
pull_request:

permissions:
contents: read

jobs:
test:
name: Build and test against all four databases
runs-on: ubuntu-latest
timeout-minutes: 30

services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: pw
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-retries 30

mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: pw
MYSQL_DATABASE: t
ports: ["3306:3306"]
options: >-
--health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -ppw" --health-interval 5s --health-retries 30

sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
env:
ACCEPT_EULA: "Y"
MSSQL_SA_PASSWORD: Str0ng!Passw0rd
ports: ["1433:1433"]
options: >-
--health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'Str0ng!Passw0rd' -C -Q 'SELECT 1'"
--health-interval 10s --health-retries 30

oracle:
image: gvenzl/oracle-free:23-slim-faststart
env:
ORACLE_PASSWORD: pw
ports: ["1521:1521"]
options: >-
--health-cmd healthcheck.sh --health-interval 10s --health-retries 60

env:
DOTNET_NOLOGO: "true"
SQLMCP_TEST_POSTGRES: Host=localhost;Port=5432;Username=postgres;Password=pw
SQLMCP_TEST_MYSQL: Server=localhost;Port=3306;Database=t;User Id=root;Password=pw
SQLMCP_TEST_SQLSERVER: Server=localhost,1433;User Id=sa;Password=Str0ng!Passw0rd;TrustServerCertificate=true
SQLMCP_TEST_ORACLE: Data Source=localhost:1521/FREEPDB1;User Id=system;Password=pw

steps:
- uses: actions/checkout@v4

- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x

# Service containers can't take server flags; the tests create a function with binary logging on.
- name: Allow MySQL function creation
run: docker exec ${{ job.services.mysql.id }} mysql -uroot -ppw -e "SET GLOBAL log_bin_trust_function_creators = 1"

- run: dotnet restore sql-mcp.sln

- run: dotnet build sql-mcp.sln --no-restore -c Release -warnaserror

- name: Check for vulnerable packages
run: |
dotnet list sql-mcp.sln package --vulnerable --include-transitive | tee vulnerable.txt
! grep -q "has the following vulnerable packages" vulnerable.txt

# Fails if any integration test was skipped, so a database that didn't come up can't pass silently.
- name: Test
run: |
dotnet test sql-mcp.sln --no-build -c Release | tee test.txt
grep -qE "Skipped: +0," test.txt

docker:
name: Build and smoke-test the image
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4

- run: docker build -t sql-mcp .

- name: Refuses to start without a token
run: "! docker run --rm sql-mcp"

- 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
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
test "$(docker exec app id -u)" != 0
docker exec app dotnet sql-mcp.dll --healthcheck
8 changes: 6 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY *.csproj .
RUN dotnet restore
COPY sql-mcp.csproj .
RUN dotnet restore sql-mcp.csproj
COPY . .
RUN dotnet publish sql-mcp.csproj -c Release -o /app/publish --no-restore

Expand All @@ -11,4 +11,8 @@ COPY --from=build /app/publish .
ENV ASPNETCORE_ENVIRONMENT=Production
ENV App__Urls=http://*:8080
EXPOSE 8080
# The base image's unprivileged "app" user; 8080 needs no root.
USER $APP_UID
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD ["dotnet", "sql-mcp.dll", "--healthcheck"]
ENTRYPOINT ["dotnet", "sql-mcp.dll"]
43 changes: 43 additions & 0 deletions Http/HealthCheck.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Text.RegularExpressions;

namespace SqlMcp.Http;

/// <summary>
/// "sql-mcp --healthcheck" asks the running server's /health endpoint and exits 0 if it
/// answers 200, else 1. The container image uses it as its HEALTHCHECK, since the
/// ASP.NET base image has no curl or wget.
/// </summary>
public static partial class HealthCheck
{
public static async Task<int> RunAsync(IConfiguration config)
{
var url = LocalUrl(config["App:Urls"] ?? HttpSecurity.DefaultUrls);
try
{
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
using var response = await client.GetAsync(new Uri(new Uri(url), "/health"));
return response.IsSuccessStatusCode ? 0 : 1;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or UriFormatException)
{
Console.Error.WriteLine($"Health check of {url} failed: {ex.Message}");
return 1;
}
}

/// <summary>
/// The first bound URL, with a wildcard host ("*", "+", "0.0.0.0", "[::]") replaced by
/// localhost so it can be dialled.
/// </summary>
public static string LocalUrl(string urls)
{
var url = HttpSecurity.ParseUrls(urls).First().TrimEnd('/');
var m = HostPattern().Match(url);
return m.Success && m.Groups["host"].Value is "*" or "+" or "0.0.0.0" or "[::]"
? m.Groups["scheme"].Value + "localhost" + m.Groups["rest"].Value
: url;
}

[GeneratedRegex(@"^(?<scheme>[a-z]+://)(?<host>\[[^\]]*\]|[^:/]+)(?<rest>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex HostPattern();
}
4 changes: 3 additions & 1 deletion Models/QueryRequest.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
namespace SqlMcp.Models;

using System.Text.Json;
using System.Text.Json.Serialization;

public record QueryRequest(
[property: JsonPropertyName("dbName")] string DbName,
[property: JsonPropertyName("sql")] string Sql);
[property: JsonPropertyName("sql")] string Sql,
[property: JsonPropertyName("parameters")] Dictionary<string, JsonElement>? Parameters = null);
49 changes: 47 additions & 2 deletions Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@
// appsettings.json next to the executable rather than relative to the cwd.
var contentRoot = AppContext.BaseDirectory;

if (args.Contains("--healthcheck"))
{
var config = new ConfigurationBuilder()
.SetBasePath(contentRoot)
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.AddCommandLine(args.Where(a => a != "--healthcheck").ToArray())
.Build();
return await HealthCheck.RunAsync(config);
}

if (args.Contains("--stdio"))
{
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
Expand All @@ -22,7 +33,9 @@
.WithStdioServerTransport()
.WithToolsFromAssembly();

await builder.Build().RunAsync();
var host = builder.Build();
ExitOnInvalidConfig(() => host.Services.GetRequiredService<QueryService>());
await host.RunAsync();
}
else
{
Expand All @@ -42,13 +55,28 @@

var app = builder.Build();

HttpSecurity.ValidateStartup(app.Configuration);
ExitOnInvalidConfig(() =>
{
HttpSecurity.ValidateStartup(app.Configuration);
app.Services.GetRequiredService<QueryService>();
});
app.UseHttpSecurity();

app.MapMcp("/mcp");

// Liveness: the process is up. Open, so probes need no token.
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));

// Readiness: every database connects and passes the login audit. Requires the
// token when one is set, since it names the databases and why any are down.
app.MapGet("/health/ready", async (QueryService svc, CancellationToken ct) =>
{
var databases = await svc.CheckDatabasesAsync(ct);
var ready = databases.Values.All(v => v == "ok");
return Results.Json(new { status = ready ? "ready" : "unavailable", databases },
statusCode: ready ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable);
});

app.MapPost("/api/query", async (QueryService svc, QueryRequest req, CancellationToken ct) =>
{
var result = await svc.ExecuteAsync(req, ct);
Expand All @@ -61,5 +89,22 @@
await app.RunAsync(urls);
}

return 0;

// Resolving QueryService validates the config, so a bad one stops the server before it
// serves anything, with the problems listed and no stack trace.
static void ExitOnInvalidConfig(Action validate)
{
try
{
validate();
}
catch (InvalidOperationException ex)
{
Console.Error.WriteLine(ex.Message);
Environment.Exit(1);
}
}

// Visible to WebApplicationFactory in the tests.
public partial class Program;
Loading
Loading