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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- Every tool parameter now has a short description listing allowed values or defaults (`section`, `format`, `order_by`, `limit` and its `max_rows` cap, `offset`, `target`, …), so agents no longer discover them from error round trips. The restricted `tools/list` array grows from 6,780 to 10,516 bytes.
- SQL errors are now actionable without exposing data values. Errors for statements submitted to `execute_sql` and `explain_query` include `position`, PostgreSQL's 1-based character position mapped into the submitted statement (previously absent; the server's paging/EXPLAIN wrapper would also have shifted it). SQLSTATE class `42` errors (syntax, undefined/ambiguous objects, privileges) that PostgreSQL positions inside the submitted statement, and `42501` privilege errors, add `server_message` and `server_hint` when not raised inside a routine or internal query; execution-time class `42` errors that can echo row values (`current_setting(col)`, `col::regclass`, `query_to_xml`) stay withheld. Class `23` integrity errors not raised from a routine add `constraint`. All other PostgreSQL messages, hints and details remain withheld, and the fallback summary no longer claims the message is withheld when `server_message` is returned. Existing `code`, `message` and `sql_state` fields are unchanged.
- `execute_sql` and `explain_query` accept optional `parameters`: up to 256 JSON scalars bound to `$1..$n`, never interpolated into the SQL. Each value is sent as PostgreSQL `unknown`, so its type is inferred from context like a quoted literal; cast when ambiguous. Objects and arrays return `invalid_parameters`, and new fixed summaries cover too few values (`08P01`) and an undeterminable parameter type (`42P18`, which also results from extra values or a skipped `$n`). Paging with `next_offset` must resend the same `sql` and `parameters`. Existing calls are unchanged.
- Performance: every tool call saves one network round trip. The transaction setup (`SET TRANSACTION READ ONLY`/`READ WRITE`, `standard_conforming_strings`, statement and lock timeouts) now travels in the same batch as the first statement. With 25 ms added to each server response, median `execute_sql` latency fell from 106.8 ms to 80.8 ms. `SqlSession` no longer exposes its connection, transaction or any batch: it builds and owns every batch itself and prepends any pending setup, so no caller can run SQL before the transaction mode is set.
- Show a grouped README badge table covering build/tests, security scanning, supply chain, registries, platforms, stack versions, features and project activity. Feature and practice badges link to the documentation or configuration that supports them; stack versions are read live from `global.json` and `Directory.Packages.props`.
- Release npm job: install npm 12.2.0 from its registry tarball after a pinned SHA-512 check matching the published `dist.integrity`, replacing the unpinned `npm install --global` flagged by Scorecard Pinned-Dependencies.
- Release npm job: accept npm 12's one-element JSON array from `npm view <name>@<version> version --json`. Previously an already-published version failed with "requires an element of type 'String'" instead of being reported as not republished.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,8 @@ An extended run added 3,200 successful queries, 50 errors, 50 JSON plans and one

For one synthetic 32-row response over 2,000 warmed serialization calls, allocated bytes per response fell from 36,304 to 27,736 (23.6%) after replacing the temporary JSON document/clone round trip with `SerializeToElement`. This is an allocation measurement, not a throughput or model-token claim.

Each tool call sends the transaction setup (`SET TRANSACTION READ ONLY`/`READ WRITE` and timeouts) in the same network batch as its first statement, so a single-statement call costs two round trips (setup + statement, then `ROLLBACK`/`COMMIT`) instead of three. Through a local proxy adding 25 ms to each server response, the median `execute_sql` latency fell from 106.8 ms to 80.8 ms (60 calls each); savings scale with the network round-trip time and are not a throughput claim.

SDK/provider payload logging is disabled even at debug/trace levels; host diagnostics stay on stderr. Query results, metadata and workload text may still contain sensitive data readable by the role. This is not general-purpose data redaction. See [SECURITY.md](SECURITY.md).

</details>
Expand Down
5 changes: 3 additions & 2 deletions src/PostgreSqlMcp.Core/DatabaseLockCheck.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,11 @@ private static Task<List<string>> ReadTextAsync(SqlExecutor executor, string dat
IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct) =>
executor.WithSessionAsync(database, async (session, token) =>
{
await using var command = new NpgsqlCommand(sql, session.Connection, session.Transaction);
var command = new NpgsqlBatchCommand(sql);
if (parameters is not null)
foreach (var (name, value) in parameters) command.Parameters.AddWithValue(name, value ?? DBNull.Value);
await using var reader = await command.ExecuteReaderAsync(token).ConfigureAwait(false);
await using SessionReader result = await session.ExecuteReaderAsync([command], System.Data.CommandBehavior.Default, token).ConfigureAwait(false);
NpgsqlDataReader reader = result.Reader;
var values = new List<string>();
while (await reader.ReadAsync(token).ConfigureAwait(false)) values.Add(reader.GetString(0));
return values;
Expand Down
5 changes: 3 additions & 2 deletions src/PostgreSqlMcp.Core/SecurityWarnings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,9 @@ public static async Task<IReadOnlyList<string>> RolesAsync(DatabaseRegistry regi
{
List<string> found = await executor.WithSessionAsync(registry.GetDiscoveryDatabase(target), async (session, token) =>
{
await using var command = new NpgsqlCommand(RoleSql, session.Connection, session.Transaction);
await using var reader = await command.ExecuteReaderAsync(token).ConfigureAwait(false);
await using SessionReader result = await session.ExecuteReaderAsync([new NpgsqlBatchCommand(RoleSql)],
System.Data.CommandBehavior.Default, token).ConfigureAwait(false);
NpgsqlDataReader reader = result.Reader;
var values = new List<string>();
while (await reader.ReadAsync(token).ConfigureAwait(false)) values.Add(reader.GetString(0));
return values;
Expand Down
109 changes: 98 additions & 11 deletions src/PostgreSqlMcp.Core/SqlExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,15 @@ public async Task<T> WithSessionAsync<T>(string database, Func<SqlSession, Cance
source = await registry.AcquireAsync(selection, token).ConfigureAwait(false);
await using var connection = await source.Source.OpenConnectionAsync(token).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(token).ConfigureAwait(false);
// Control statements are fixed server text. User SQL cannot end this transaction.
await using (var setup = new NpgsqlCommand($"SET TRANSACTION {(readOnly ? "READ ONLY" : "READ WRITE")}; SET LOCAL standard_conforming_strings=on; SET LOCAL statement_timeout='{options.QueryTimeout * 1000}ms'; SET LOCAL lock_timeout='{options.QueryTimeout * 1000}ms'", connection, transaction)
{ CommandTimeout = options.QueryTimeout })
await setup.ExecuteNonQueryAsync(token).ConfigureAwait(false);
var session = new SqlSession(database, connection, transaction, options, readOnly);
// Control statements are fixed server text. User SQL cannot end this transaction. The session sends them
// in the same batch as its first statement, so setup costs no extra network round trip.
var session = new SqlSession(database, connection, transaction, options, readOnly,
[
readOnly ? "SET TRANSACTION READ ONLY" : "SET TRANSACTION READ WRITE",
"SET LOCAL standard_conforming_strings=on",
$"SET LOCAL statement_timeout='{options.QueryTimeout * 1000}ms'",
$"SET LOCAL lock_timeout='{options.QueryTimeout * 1000}ms'"
]);
T result = await action(session, token).ConfigureAwait(false);
if (readOnly) await transaction.RollbackAsync(token).ConfigureAwait(false);
else await transaction.CommitAsync(token).ConfigureAwait(false);
Expand All @@ -55,11 +59,73 @@ public async Task<T> WithSessionAsync<T>(string database, Func<SqlSession, Cance
public void Dispose() => _calls.Dispose();
}

public sealed class SqlSession(string database, NpgsqlConnection connection, NpgsqlTransaction transaction,
ServerOptions options, bool readOnly)
/// <summary>
/// One server-owned transaction. The connection, transaction and batches are not exposed: every statement goes through
/// <see cref="ExecuteReaderAsync"/>, which builds the batch itself and prepends the pending transaction setup
/// (READ ONLY/READ WRITE and timeouts) to the first one, so no caller can run SQL before the transaction mode is set.
/// </summary>
public sealed class SqlSession
{
public NpgsqlConnection Connection { get; } = connection;
public NpgsqlTransaction Transaction { get; } = transaction;
private readonly string database;
private readonly NpgsqlConnection connection;
private readonly NpgsqlTransaction transaction;
private readonly ServerOptions options;
private readonly bool readOnly;
private string[]? pendingSetup;

internal SqlSession(string database, NpgsqlConnection connection, NpgsqlTransaction transaction,
ServerOptions options, bool readOnly, string[] setup)
{
this.database = database;
this.connection = connection;
this.transaction = transaction;
this.options = options;
this.readOnly = readOnly;
pendingSetup = setup;
}

/// <summary>
/// Runs <paramref name="commands"/> as one batch, preceded by the transaction setup if it has not reached the server yet.
/// Dispose the result to release the reader and the batch.
/// </summary>
public async Task<SessionReader> ExecuteReaderAsync(IReadOnlyList<NpgsqlBatchCommand> commands, CommandBehavior behavior,
CancellationToken ct, int? timeout = null)
{
var batch = new NpgsqlBatch(connection, transaction) { Timeout = timeout ?? options.QueryTimeout };
// Each batch command is one extended-protocol statement, so the setup is one command per statement.
string[]? setup = pendingSetup;
if (setup is not null)
foreach (string statement in setup) batch.BatchCommands.Add(new NpgsqlBatchCommand(statement));
foreach (NpgsqlBatchCommand command in commands) batch.BatchCommands.Add(command);
try
{
NpgsqlDataReader reader = await batch.ExecuteReaderAsync(behavior, ct).ConfigureAwait(false);
pendingSetup = null;
return new SessionReader(batch, reader);
}
catch (PostgresException) when (setup is not null)
{
// The server processed the batch: either the setup ran or the transaction is aborted. Otherwise
// (cancelled before sending, I/O failure) the setup stays pending for any later statement.
pendingSetup = null;
await batch.DisposeAsync().ConfigureAwait(false);
throw;
}
catch
{
await batch.DisposeAsync().ConfigureAwait(false);
throw;
}
}

/// <summary>Runs one fixed control statement and discards its result.</summary>
public async Task ExecuteAsync(string sql, CancellationToken ct, int? timeout = null)
{
await using SessionReader result = await ExecuteReaderAsync([new NpgsqlBatchCommand(sql)], CommandBehavior.Default, ct, timeout).ConfigureAwait(false);
}

/// <summary>Marks this session's pooled connections for disposal instead of reuse.</summary>
public void ClearPool() => NpgsqlConnection.ClearPool(connection);

/// <param name="callerStatement">The SQL came from the tool caller, so PostgreSQL error positions are reported relative to it.</param>
/// <param name="positional">Caller values for $1..$n (see <see cref="SqlParameters"/>); exclusive with named parameters.</param>
Expand Down Expand Up @@ -88,12 +154,13 @@ public async Task<QueryPage> QueryAsync(string sql, IReadOnlyDictionary<string,
private async Task<QueryPage> ReadAsync(string sql, IReadOnlyDictionary<string, object?>? parameters, IReadOnlyList<string?>? positional,
int rowLimit, int offset, bool serverPage, CancellationToken ct)
{
await using var command = new NpgsqlCommand(sql, Connection, Transaction) { CommandTimeout = options.QueryTimeout };
var command = new NpgsqlBatchCommand(sql);
if (parameters is not null)
foreach (var (name, value) in parameters) command.Parameters.AddWithValue(name, value ?? DBNull.Value);
else if (positional is not null)
SqlParameters.Bind(command.Parameters, positional);
await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess, ct).ConfigureAwait(false);
await using SessionReader result = await ExecuteReaderAsync([command], CommandBehavior.SequentialAccess, ct).ConfigureAwait(false);
NpgsqlDataReader reader = result.Reader;
if (reader.FieldCount > 128) throw new ToolException("result_too_wide", "Select at most 128 columns.");
var columns = new ColumnInfo[reader.FieldCount];
for (int col = 0; col < columns.Length; col++)
Expand Down Expand Up @@ -187,3 +254,23 @@ private async Task<QueryPage> ReadAsync(string sql, IReadOnlyDictionary<string,
truncated, reason, clips);
}
}

/// <summary>A result reader that owns the batch it reads from; disposing it releases both.</summary>
public sealed class SessionReader : IAsyncDisposable
{
private readonly NpgsqlBatch batch;

internal SessionReader(NpgsqlBatch batch, NpgsqlDataReader reader)
{
this.batch = batch;
Reader = reader;
}

public NpgsqlDataReader Reader { get; }

public async ValueTask DisposeAsync()
{
try { await Reader.DisposeAsync().ConfigureAwait(false); }
finally { await batch.DisposeAsync().ConfigureAwait(false); }
}
}
Loading
Loading