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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ All settings live in `appsettings.json`.
| `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: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. Also how long a query waits for a free slot when `MaxConcurrentQueries` are already running. |
| `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]`. |
| `App:MaxResultBytes` | `4000000` | Approximate size limit for one query's result. Rows stop being added once it's reached, and the response says `"truncated": true`. |
| `App:MaxConcurrentQueries` | `8` | Queries that may run at once against each database. Further queries wait up to `QueryTimeoutSeconds`, then fail with "Database 'x' is busy". |
Expand Down
16 changes: 16 additions & 0 deletions Services/QueryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -300,13 +300,29 @@ private async Task<Result<T, string>> RunWithRetryAsync<T>(DbConfig db, Func<Fun
{
await Task.Delay(OracleRetryDelays[attempt], ct);
}
catch (Exception ex) when (IsTimeout(ex))
{
return new(default, $"Query timed out after {_queryTimeoutSeconds} s (App:QueryTimeoutSeconds). Narrow it with a WHERE clause or an index, or ask for fewer rows.");
}
catch (Exception ex)
{
return new(default, $"Query error: {ex.Message}");
}
}
}

// How each driver reports a command that ran past CommandTimeout. Cancellation the
// caller asked for is rethrown before this is reached, so a cancel here is a timeout.
private static bool IsTimeout(Exception ex) => ex switch
{
OperationCanceledException or TimeoutException => true,
Npgsql.NpgsqlException { InnerException: TimeoutException } => true,
Microsoft.Data.SqlClient.SqlException { Number: -2 } => true,
MySqlException { ErrorCode: MySqlErrorCode.CommandTimeoutExpired } => true,
OracleException { Number: 1013 } => true,
_ => false,
};

private async Task<Result<T, string>> RunOnceAsync<T>(DbConfig db, Func<Func<DbCommand>, Task<T>> work, CancellationToken ct)
{
await using var conn = db.GetFactory().CreateConnection()!;
Expand Down
15 changes: 15 additions & 0 deletions tests/SqlMcp.Tests/QueryServiceIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,21 @@ public async Task Returns_awkward_values_as_json(DbProvider provider, string sql
Assert.Contains(expected, json);
}

[SkippableTheory]
[InlineData(DbProvider.Postgres, "SELECT pg_sleep(10) AS s")]
[InlineData(DbProvider.MySql, "SELECT COUNT(*) AS n FROM information_schema.columns a, information_schema.columns b, information_schema.columns c")]
[InlineData(DbProvider.SqlServer, "WITH n AS (SELECT CAST(1 AS bigint) AS i UNION ALL SELECT i + 1 FROM n WHERE i < 100000000) SELECT MAX(i) AS m FROM n OPTION (MAXRECURSION 0)")]
[InlineData(DbProvider.Oracle, "SELECT COUNT(*) AS n FROM all_objects a, all_objects b, all_objects c")]
public async Task Says_when_a_query_ran_past_the_timeout(DbProvider provider, string sql)
{
var db = RequireDb(provider);
var svc = Service(db, settings: new() { ["App:QueryTimeoutSeconds"] = "1" });

var result = await svc.ExecuteAsync(new("test", sql));

Assert.Equal("Query timed out after 1 s (App:QueryTimeoutSeconds). Narrow it with a WHERE clause or an index, or ask for fewer rows.", result.Error);
}

[SkippableFact]
public async Task Rejects_queries_beyond_the_concurrency_limit()
{
Expand Down
Loading