From 0c35408a8275052d99fbc23877afcde5a80bce51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Wed, 3 Jun 2026 14:34:52 +0200 Subject: [PATCH 01/38] =?UTF-8?q?fix:=20v3.16.4=20=E2=80=94=20JSON=20comma?= =?UTF-8?q?nd=20params=20accept=20json/jsonb/text=20(not=20just=20json)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON payloads passed to user-authored SQL commands were bound with a hardcoded NpgsqlDbType.Json, so a function declaring the matching parameter as jsonb or text failed with PostgreSQL 42883 "function does not exist" — despite the docs advertising text/json/jsonb. PostgreSQL function-overload resolution uses only implicit casts, and there is no implicit cast from json to jsonb or text. Bind these values as NpgsqlDbType.Unknown instead. PostgreSQL resolves an untyped parameter server-side via the target type's input function, so json, jsonb, and text all work. The value is already a string (or DBNull) at every site, so this is a drop-in, strictly-additive change: existing json-typed functions are unaffected. Sites fixed (21 bindings): - ExternalAuth LoginCommand ($4 provider data, $5 analytics) - CsvUploadHandler / ExcelUploadHandler row-command metadata + json data - Fido2 endpoints (user_context, analytics, claims, challenge body) across login, registration, add-passkey, and challenge-options commands Tests: - JsonParameterBindingContractTests — locks the Npgsql/PG resolution behaviour (json-only for Json binding; json/jsonb/text for Unknown, incl. NULL and round-trip integrity). - CsvUploadMetaParamTypeTests — end-to-end CSV upload with the row-command metadata parameter declared json, jsonb, and text. Full suite: 2100 passing. --- .../Handlers/CsvUploadHandler.cs | 5 +- NpgsqlRestClient/ExcelUploadHandler.cs | 6 +- NpgsqlRestClient/ExternalAuth.cs | 8 +- .../Fido2/Endpoints/AddPasskeyEndpoint.cs | 8 +- .../Endpoints/AddPasskeyOptionsEndpoint.cs | 4 +- .../Fido2/Endpoints/LoginEndpoint.cs | 8 +- .../Fido2/Endpoints/LoginOptionsEndpoint.cs | 2 +- .../Fido2/Endpoints/RegistrationEndpoint.cs | 8 +- .../Endpoints/RegistrationOptionsEndpoint.cs | 2 +- .../JsonParameterBindingContractTests.cs | 131 ++++++++++++++++++ .../CsvUploadMetaParamTypeTests.cs | 128 +++++++++++++++++ changelog/v3.16.4.md | 31 +++++ 12 files changed, 320 insertions(+), 21 deletions(-) create mode 100644 NpgsqlRestTests/ParamTypeTests/JsonParameterBindingContractTests.cs create mode 100644 NpgsqlRestTests/UploadTests/CsvUploadMetaParamTypeTests.cs create mode 100644 changelog/v3.16.4.md diff --git a/NpgsqlRest/UploadHandlers/Handlers/CsvUploadHandler.cs b/NpgsqlRest/UploadHandlers/Handlers/CsvUploadHandler.cs index 871d0ce3..0a395f3b 100644 --- a/NpgsqlRest/UploadHandlers/Handlers/CsvUploadHandler.cs +++ b/NpgsqlRest/UploadHandlers/Handlers/CsvUploadHandler.cs @@ -82,7 +82,10 @@ public async Task UploadAsync(NpgsqlConnection connection, HttpContext c if (paramCount >= 1) command.Parameters.Add(NpgsqlRestParameter.CreateParamWithType(NpgsqlDbType.Integer)); if (paramCount >= 2) command.Parameters.Add(NpgsqlRestParameter.CreateParamWithType(NpgsqlDbType.Text | NpgsqlDbType.Array)); if (paramCount >= 3) command.Parameters.Add(new NpgsqlParameter()); - if (paramCount >= 4) command.Parameters.Add(NpgsqlRestParameter.CreateParamWithType(NpgsqlDbType.Json)); + // Unknown (not Json): PostgreSQL resolves $4 server-side via the target type's input function, + // so the row_command's metadata parameter may be declared json, jsonb OR text. A hardcoded Json + // would only match a json parameter (jsonb/text -> 42883 "function does not exist"). + if (paramCount >= 4) command.Parameters.Add(NpgsqlRestParameter.CreateParamWithType(NpgsqlDbType.Unknown)); // Build user claims JSON once (reused for all rows) string? userClaimsJson = null; diff --git a/NpgsqlRestClient/ExcelUploadHandler.cs b/NpgsqlRestClient/ExcelUploadHandler.cs index e2499c4c..c1a241b0 100644 --- a/NpgsqlRestClient/ExcelUploadHandler.cs +++ b/NpgsqlRestClient/ExcelUploadHandler.cs @@ -110,7 +110,8 @@ public async Task UploadAsync(NpgsqlConnection connection, HttpContext c { if (dataAsJson is true) { - command.Parameters.Add(NpgsqlRestParameter.CreateParamWithType(NpgsqlDbType.Json)); + // Unknown (not Json): let the row_command's data parameter be json, jsonb or text. + command.Parameters.Add(NpgsqlRestParameter.CreateParamWithType(NpgsqlDbType.Unknown)); } else { @@ -118,7 +119,8 @@ public async Task UploadAsync(NpgsqlConnection connection, HttpContext c } } if (paramCount >= 3) command.Parameters.Add(new NpgsqlParameter()); - if (paramCount >= 4) command.Parameters.Add(NpgsqlRestParameter.CreateParamWithType(NpgsqlDbType.Json)); + // Unknown (not Json): let the row_command's metadata parameter be json, jsonb or text. + if (paramCount >= 4) command.Parameters.Add(NpgsqlRestParameter.CreateParamWithType(NpgsqlDbType.Unknown)); // Build user claims JSON once (reused for all rows) string? userClaimsJson = null; diff --git a/NpgsqlRestClient/ExternalAuth.cs b/NpgsqlRestClient/ExternalAuth.cs index 41a401ea..f4e30399 100644 --- a/NpgsqlRestClient/ExternalAuth.cs +++ b/NpgsqlRestClient/ExternalAuth.cs @@ -392,7 +392,10 @@ private async Task ProcessAsync( if (paramCount >= 4) command.Parameters.Add(new NpgsqlParameter() { Value = infoNode is not null ? infoContent.ToString() : DBNull.Value, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + // Unknown (not Json): PostgreSQL resolves it server-side via the target type's input + // function, so the LoginCommand's data parameter may be declared json, jsonb OR text + // (text/json/jsonb as documented). A hardcoded Json matches only a json parameter. + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); if (paramCount >= 5) { @@ -409,7 +412,8 @@ private async Task ProcessAsync( command.Parameters.Add(new NpgsqlParameter() { Value = analyticsData.ToJsonString(), - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + // Unknown (not Json): allow the analytics parameter to be json, jsonb or text. + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); } } diff --git a/NpgsqlRestClient/Fido2/Endpoints/AddPasskeyEndpoint.cs b/NpgsqlRestClient/Fido2/Endpoints/AddPasskeyEndpoint.cs index 70e81809..a40c0ef2 100644 --- a/NpgsqlRestClient/Fido2/Endpoints/AddPasskeyEndpoint.cs +++ b/NpgsqlRestClient/Fido2/Endpoints/AddPasskeyEndpoint.cs @@ -210,7 +210,7 @@ await WriteErrorResponseAsync(context, HttpStatusCode.BadRequest, storeCommand.Parameters.Add(new NpgsqlParameter { Value = request.UserContext, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); } @@ -228,7 +228,7 @@ await WriteErrorResponseAsync(context, HttpStatusCode.BadRequest, storeCommand.Parameters.Add(new NpgsqlParameter { Value = analyticsData?.ToJsonString() ?? (object)DBNull.Value, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); } catch @@ -236,7 +236,7 @@ await WriteErrorResponseAsync(context, HttpStatusCode.BadRequest, storeCommand.Parameters.Add(new NpgsqlParameter { Value = DBNull.Value, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); } } @@ -245,7 +245,7 @@ await WriteErrorResponseAsync(context, HttpStatusCode.BadRequest, storeCommand.Parameters.Add(new NpgsqlParameter { Value = DBNull.Value, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); } } diff --git a/NpgsqlRestClient/Fido2/Endpoints/AddPasskeyOptionsEndpoint.cs b/NpgsqlRestClient/Fido2/Endpoints/AddPasskeyOptionsEndpoint.cs index b4ab942d..99223572 100644 --- a/NpgsqlRestClient/Fido2/Endpoints/AddPasskeyOptionsEndpoint.cs +++ b/NpgsqlRestClient/Fido2/Endpoints/AddPasskeyOptionsEndpoint.cs @@ -94,7 +94,7 @@ await WriteErrorResponseAsync(context, HttpStatusCode.Unauthorized, command.Parameters.Add(new NpgsqlParameter { Value = claimsParam, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); } if (paramCount >= 2) @@ -102,7 +102,7 @@ await WriteErrorResponseAsync(context, HttpStatusCode.Unauthorized, command.Parameters.Add(new NpgsqlParameter { Value = bodyParam ?? (object)DBNull.Value, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); } diff --git a/NpgsqlRestClient/Fido2/Endpoints/LoginEndpoint.cs b/NpgsqlRestClient/Fido2/Endpoints/LoginEndpoint.cs index cddc9d8b..8eb3070e 100644 --- a/NpgsqlRestClient/Fido2/Endpoints/LoginEndpoint.cs +++ b/NpgsqlRestClient/Fido2/Endpoints/LoginEndpoint.cs @@ -229,7 +229,7 @@ await WriteErrorResponseAsync(context, HttpStatusCode.Unauthorized, completeCommand.Parameters.Add(new NpgsqlParameter { Value = userContext ?? (object)DBNull.Value, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); } if (paramCount >= 4) @@ -246,7 +246,7 @@ await WriteErrorResponseAsync(context, HttpStatusCode.Unauthorized, completeCommand.Parameters.Add(new NpgsqlParameter { Value = analyticsData?.ToJsonString() ?? (object)DBNull.Value, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); } catch @@ -254,7 +254,7 @@ await WriteErrorResponseAsync(context, HttpStatusCode.Unauthorized, completeCommand.Parameters.Add(new NpgsqlParameter { Value = DBNull.Value, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); } } @@ -263,7 +263,7 @@ await WriteErrorResponseAsync(context, HttpStatusCode.Unauthorized, completeCommand.Parameters.Add(new NpgsqlParameter { Value = DBNull.Value, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); } } diff --git a/NpgsqlRestClient/Fido2/Endpoints/LoginOptionsEndpoint.cs b/NpgsqlRestClient/Fido2/Endpoints/LoginOptionsEndpoint.cs index 33d91340..6c24ebef 100644 --- a/NpgsqlRestClient/Fido2/Endpoints/LoginOptionsEndpoint.cs +++ b/NpgsqlRestClient/Fido2/Endpoints/LoginOptionsEndpoint.cs @@ -85,7 +85,7 @@ public async Task InvokeAsync(HttpContext context) command.Parameters.Add(new NpgsqlParameter { Value = bodyJson ?? (object)DBNull.Value, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); } diff --git a/NpgsqlRestClient/Fido2/Endpoints/RegistrationEndpoint.cs b/NpgsqlRestClient/Fido2/Endpoints/RegistrationEndpoint.cs index f48b4c07..52623899 100644 --- a/NpgsqlRestClient/Fido2/Endpoints/RegistrationEndpoint.cs +++ b/NpgsqlRestClient/Fido2/Endpoints/RegistrationEndpoint.cs @@ -202,7 +202,7 @@ await WriteErrorResponseAsync(context, HttpStatusCode.BadRequest, storeCommand.Parameters.Add(new NpgsqlParameter { Value = request.UserContext, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); } @@ -220,7 +220,7 @@ await WriteErrorResponseAsync(context, HttpStatusCode.BadRequest, storeCommand.Parameters.Add(new NpgsqlParameter { Value = analyticsData?.ToJsonString() ?? (object)DBNull.Value, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); } catch @@ -228,7 +228,7 @@ await WriteErrorResponseAsync(context, HttpStatusCode.BadRequest, storeCommand.Parameters.Add(new NpgsqlParameter { Value = DBNull.Value, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); } } @@ -237,7 +237,7 @@ await WriteErrorResponseAsync(context, HttpStatusCode.BadRequest, storeCommand.Parameters.Add(new NpgsqlParameter { Value = DBNull.Value, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); } } diff --git a/NpgsqlRestClient/Fido2/Endpoints/RegistrationOptionsEndpoint.cs b/NpgsqlRestClient/Fido2/Endpoints/RegistrationOptionsEndpoint.cs index 593dbf57..c7ccdc99 100644 --- a/NpgsqlRestClient/Fido2/Endpoints/RegistrationOptionsEndpoint.cs +++ b/NpgsqlRestClient/Fido2/Endpoints/RegistrationOptionsEndpoint.cs @@ -112,7 +112,7 @@ await WriteErrorResponseAsync(context, HttpStatusCode.BadRequest, command.Parameters.Add(new NpgsqlParameter { Value = jsonParam, - NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Json + NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown }); CommandLogger.LogCommand(command, ctx.Logger, LogRegistrationOptions); diff --git a/NpgsqlRestTests/ParamTypeTests/JsonParameterBindingContractTests.cs b/NpgsqlRestTests/ParamTypeTests/JsonParameterBindingContractTests.cs new file mode 100644 index 00000000..d9ce4ea8 --- /dev/null +++ b/NpgsqlRestTests/ParamTypeTests/JsonParameterBindingContractTests.cs @@ -0,0 +1,131 @@ +using Npgsql; +using NpgsqlTypes; + +namespace NpgsqlRestTests.ParamTypeTests; + +/// +/// Locks the PostgreSQL/Npgsql parameter-binding mechanism that the JSON-payload bindings in +/// ExternalAuth, the Fido2 endpoints and the upload handlers depend on. +/// +/// Today those sites bind a JSON string with NpgsqlDbType.Json. PostgreSQL function-overload +/// resolution then matches ONLY a json-typed parameter; a jsonb or text parameter +/// throws 42883 "function does not exist" (no implicit cast from json to jsonb/text). The planned +/// fix switches those bindings to NpgsqlDbType.Unknown, which is resolved server-side through the +/// target type's input function and therefore matches json, jsonb AND text. +/// +/// These tests prove both halves against a real connection, so the fix's premise cannot silently drift +/// (e.g. a future Npgsql/PostgreSQL change to coercion rules). They exercise the platform mechanism, not +/// NpgsqlRest code, so they are GREEN both before and after the fix - a permanent guard. +/// +public class JsonParameterBindingContractTests +{ + private const string Payload = "{\"k\":1}"; + + private static async Task OpenAsync() + { + var conn = new NpgsqlConnection(Database.GetIinitialConnectionString()); + await conn.OpenAsync(); + return conn; + } + + /// + /// Creates a single-overload temp function pg_temp.contract_<suffix>(a text, b <pgType>) + /// that echoes b::text, then calls it binding b = with the given + /// NpgsqlDbType. Distinct per-type names avoid creating overloads that would muddy resolution. + /// + private static async Task CallWithBinding( + NpgsqlConnection conn, string pgType, object value, NpgsqlDbType? bindType) + { + var suffix = pgType.Replace(" ", "_"); + await using (var create = conn.CreateCommand()) + { + create.CommandText = + $"create or replace function pg_temp.contract_{suffix}(a text, b {pgType}) " + + "returns text language sql as $$ select b::text $$;"; + await create.ExecuteNonQueryAsync(); + } + await using var call = conn.CreateCommand(); + call.CommandText = $"select pg_temp.contract_{suffix}($1, $2)"; + call.Parameters.Add(new NpgsqlParameter { Value = "p", NpgsqlDbType = NpgsqlDbType.Text }); + var p = new NpgsqlParameter { Value = value }; + if (bindType.HasValue) + { + p.NpgsqlDbType = bindType.Value; + } + call.Parameters.Add(p); + var result = await call.ExecuteScalarAsync(); + return result is null || result is DBNull ? null : (string)result; + } + + // ---- Today's behaviour: NpgsqlDbType.Json matches ONLY a json parameter ---- + + [Fact] + public async Task JsonBinding_Matches_JsonParameter() + { + await using var conn = await OpenAsync(); + (await CallWithBinding(conn, "json", Payload, NpgsqlDbType.Json)).Should().Be("{\"k\":1}"); + } + + [Fact] + public async Task JsonBinding_Fails_Against_JsonbParameter_42883() + { + await using var conn = await OpenAsync(); + var act = async () => await CallWithBinding(conn, "jsonb", Payload, NpgsqlDbType.Json); + (await act.Should().ThrowAsync()).Which.SqlState.Should().Be("42883"); + } + + [Fact] + public async Task JsonBinding_Fails_Against_TextParameter_42883() + { + await using var conn = await OpenAsync(); + var act = async () => await CallWithBinding(conn, "text", Payload, NpgsqlDbType.Json); + (await act.Should().ThrowAsync()).Which.SqlState.Should().Be("42883"); + } + + // ---- The fix: NpgsqlDbType.Unknown matches json, jsonb AND text ---- + + [Fact] + public async Task UnknownBinding_Matches_JsonParameter() + { + await using var conn = await OpenAsync(); + (await CallWithBinding(conn, "json", Payload, NpgsqlDbType.Unknown)).Should().Be("{\"k\":1}"); + } + + [Fact] + public async Task UnknownBinding_Matches_JsonbParameter() + { + await using var conn = await OpenAsync(); + // jsonb normalises whitespace on output + (await CallWithBinding(conn, "jsonb", Payload, NpgsqlDbType.Unknown)).Should().Be("{\"k\": 1}"); + } + + [Fact] + public async Task UnknownBinding_Matches_TextParameter() + { + await using var conn = await OpenAsync(); + (await CallWithBinding(conn, "text", Payload, NpgsqlDbType.Unknown)).Should().Be("{\"k\":1}"); + } + + // ---- NULL handling: the current code passes DBNull for absent payloads ---- + + [Theory] + [InlineData("json")] + [InlineData("jsonb")] + [InlineData("text")] + public async Task UnknownBinding_WithDbNull_PassesNull(string pgType) + { + await using var conn = await OpenAsync(); + (await CallWithBinding(conn, pgType, DBNull.Value, NpgsqlDbType.Unknown)).Should().BeNull(); + } + + // ---- Round-trip integrity: nested quotes / arrays survive the unknown binding ---- + + [Fact] + public async Task UnknownBinding_PreservesNestedQuotesAndArrays_IntoJsonb() + { + await using var conn = await OpenAsync(); + const string payload = "{\"name\":\"O'Brien\",\"arr\":[1,2,3]}"; + var result = await CallWithBinding(conn, "jsonb", payload, NpgsqlDbType.Unknown); + result.Should().Be("{\"arr\": [1, 2, 3], \"name\": \"O'Brien\"}"); + } +} diff --git a/NpgsqlRestTests/UploadTests/CsvUploadMetaParamTypeTests.cs b/NpgsqlRestTests/UploadTests/CsvUploadMetaParamTypeTests.cs new file mode 100644 index 00000000..425ac5a8 --- /dev/null +++ b/NpgsqlRestTests/UploadTests/CsvUploadMetaParamTypeTests.cs @@ -0,0 +1,128 @@ +using System.Net.Http.Headers; +using Npgsql; + +namespace NpgsqlRestTests; + +public static partial class Database +{ + // Setup for CsvUploadMetaParamTypeTests. Each endpoint's row_command passes the per-file metadata + // as $4 - the binding CsvUploadHandler hardcodes to NpgsqlDbType.Json (CsvUploadHandler.cs:85). + // The three process-row functions declare that 4th parameter as json / jsonb / text respectively. + // Today only the json variant resolves; jsonb and text throw 42883. After the Json->Unknown fix all + // three must succeed. (Auto-invoked by the Database static constructor.) + public static void CsvUploadMetaParamTypeSetup() + { + script.Append(@" + create table csv_meta_json_tbl (idx int, meta text); + create table csv_meta_jsonb_tbl (idx int, meta text); + create table csv_meta_text_tbl (idx int, meta text); + + create function csv_meta_json_row(_index int, _row text[], _prev int, _meta json) + returns int language plpgsql as $$ + begin insert into csv_meta_json_tbl(idx, meta) values (_index, _meta::text); return _index; end; + $$; + create function csv_meta_jsonb_row(_index int, _row text[], _prev int, _meta jsonb) + returns int language plpgsql as $$ + begin insert into csv_meta_jsonb_tbl(idx, meta) values (_index, _meta::text); return _index; end; + $$; + create function csv_meta_text_row(_index int, _row text[], _prev int, _meta text) + returns int language plpgsql as $$ + begin insert into csv_meta_text_tbl(idx, meta) values (_index, _meta); return _index; end; + $$; + + create function csv_meta_json_upload(_meta json = null) returns json language plpgsql as $$ + begin return _meta; end; $$; + comment on function csv_meta_json_upload(json) is ' + upload for csv + param _meta is upload metadata + row_command = select csv_meta_json_row($1,$2,$3,$4) + '; + + create function csv_meta_jsonb_upload(_meta json = null) returns json language plpgsql as $$ + begin return _meta; end; $$; + comment on function csv_meta_jsonb_upload(json) is ' + upload for csv + param _meta is upload metadata + row_command = select csv_meta_jsonb_row($1,$2,$3,$4) + '; + + create function csv_meta_text_upload(_meta json = null) returns json language plpgsql as $$ + begin return _meta; end; $$; + comment on function csv_meta_text_upload(json) is ' + upload for csv + param _meta is upload metadata + row_command = select csv_meta_text_row($1,$2,$3,$4) + '; + "); + } +} + +/// +/// Real end-to-end coverage for the row_command's $4 metadata binding in CsvUploadHandler. +/// The handler hardcodes NpgsqlDbType.Json for $4, so a row_command whose function declares that +/// parameter as jsonb or text fails with PostgreSQL 42883 today - even though the +/// external-auth/upload docs imply text/json/jsonb are interchangeable. These tests assert the TARGET +/// behaviour: all three resolve and the metadata round-trips. Before the Json->Unknown fix the json case +/// passes (regression guard) while the jsonb/text cases fail; after the fix all three pass. +/// +[Collection("TestFixture")] +public class CsvUploadMetaParamTypeTests(TestFixture test) +{ + private static MultipartFormDataContent BuildCsv(string fileName) + { + var sb = new StringBuilder(); + sb.AppendLine("Id,Name,Value"); + sb.AppendLine("10,Item 1,666"); + sb.AppendLine("11,Item 2,999"); + var contentBytes = Encoding.UTF8.GetBytes(sb.ToString()); + var formData = new MultipartFormDataContent(); + var byteContent = new ByteArrayContent(contentBytes); + byteContent.Headers.ContentType = new MediaTypeHeaderValue("text/csv"); + formData.Add(byteContent, "file", fileName); + return formData; + } + + private static async Task StoredFileNameCount(string table, string fileName) + { + using var connection = Database.CreateConnection(); + await connection.OpenAsync(); + // meta is stored as text in every variant; parse it as jsonb to read fileName uniformly. + using var command = new NpgsqlCommand( + $"select count(*) from {table} where (meta::jsonb)->>'fileName' = @f", connection); + command.Parameters.AddWithValue("f", fileName); + return Convert.ToInt32(await command.ExecuteScalarAsync()); + } + + [Fact] + public async Task RowCommand_Meta_Json_Works() + { + const string fileName = "meta-json.csv"; + using var formData = BuildCsv(fileName); + using var result = await test.Client.PostAsync("/api/csv-meta-json-upload/", formData); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + (await StoredFileNameCount("csv_meta_json_tbl", fileName)).Should().BeGreaterThan(0); + } + + [Fact] + public async Task RowCommand_Meta_Jsonb_Works() + { + const string fileName = "meta-jsonb.csv"; + using var formData = BuildCsv(fileName); + using var result = await test.Client.PostAsync("/api/csv-meta-jsonb-upload/", formData); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + (await StoredFileNameCount("csv_meta_jsonb_tbl", fileName)).Should().BeGreaterThan(0); + } + + [Fact] + public async Task RowCommand_Meta_Text_Works() + { + const string fileName = "meta-text.csv"; + using var formData = BuildCsv(fileName); + using var result = await test.Client.PostAsync("/api/csv-meta-text-upload/", formData); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + (await StoredFileNameCount("csv_meta_text_tbl", fileName)).Should().BeGreaterThan(0); + } +} diff --git a/changelog/v3.16.4.md b/changelog/v3.16.4.md new file mode 100644 index 00000000..d0b2e5f1 --- /dev/null +++ b/changelog/v3.16.4.md @@ -0,0 +1,31 @@ +# Changelog v3.16.4 (2026-06-03) + +## Version [3.16.4](https://github.com/NpgsqlRest/NpgsqlRest/tree/3.16.4) (2026-06-03) + +[Full Changelog](https://github.com/NpgsqlRest/NpgsqlRest/compare/3.16.3...3.16.4) + +Bug-fix release. JSON payloads passed to user-authored SQL commands were bound with a hardcoded `json` type, so a function declaring the matching parameter as `jsonb` or `text` failed at runtime with PostgreSQL `42883` *"function does not exist"* — even though the documentation states `text`/`json`/`jsonb` are all acceptable. The binding now uses an untyped (`unknown`) parameter, which PostgreSQL resolves server-side via the target type's input function, so `json`, `jsonb`, and `text` all work. + +## What changed + +### JSON command parameters accept `json`, `jsonb`, or `text` + +Several places bind a JSON string into a user-configured SQL command and previously forced the receiving parameter to be `json`: + +- **External auth** `Auth.External.LoginCommand` — `$4` (provider data) and `$5` (analytics). The docs (and the docs' own example function, which declares `_data jsonb`) advertised `text/json/jsonb`, but only `json` actually worked. +- **CSV / Excel upload** row commands — the per-row metadata parameter (`$4`, and the Excel JSON data parameter `$2`). +- **Passkey / Fido2** endpoints — the `user_context`, analytics, claims, and challenge-body parameters across the registration, authentication, add-passkey, and challenge-options commands. + +The root cause: PostgreSQL function-overload resolution uses only *implicit* casts, and there is no implicit cast from `json` to `jsonb` or `text`. Binding the value as `unknown` (instead of `json`) defers type resolution to the server, which parses the string with the declared parameter type's input function. As a result: + +- Functions declaring the parameter as `json` continue to work unchanged. +- Functions declaring it as `jsonb` now work (previously `42883`). +- Functions declaring it as `text` now work (previously `42883`). +- `NULL` payloads and values containing quotes/arrays round-trip correctly. + +This is **fully backward compatible** — existing `json`-typed functions are unaffected; the change only *adds* the previously-documented `jsonb` and `text` support. + +### Tests + +- A binding-contract test that locks the PostgreSQL/Npgsql resolution behaviour the fix relies on (`json`-only for the old binding; `json`/`jsonb`/`text` for the new one, including `NULL` and round-trip integrity). +- End-to-end CSV upload tests covering a row-command metadata parameter declared as `json`, `jsonb`, and `text`. From c453c1f3b61e4b24e6087834988e978cab1a0152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Thu, 4 Jun 2026 15:29:33 +0200 Subject: [PATCH 02/38] =?UTF-8?q?fix:=20v3.16.4=20=E2=80=94=20optional=20{?= =?UTF-8?q?NAME}=20/=20required=20{!NAME}=20env-var=20placeholders=20in=20?= =?UTF-8?q?config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Config:ParseEnvironmentVariables enabled, a config value wired to an unset environment variable (e.g. "Enabled": "{GITHUB_AUTH_ENABLED}") left an unresolved {GITHUB_AUTH_ENABLED} token and crashed startup: "Invalid boolean value '{GITHUB_AUTH_ENABLED}' for configuration key 'Enabled'". Introduce two placeholder forms, resolved centrally by a new Config.ResolveEnv and applied to every config value type (bool, int, string, enum, arrays, dicts): - {NAME} optional: substituted when set; left untouched when unset, so typed bool/int reads fall back to their default instead of crashing and non-env brace syntax (Serilog OutputTemplate "{Timestamp} {Message}") is preserved. - {!NAME} required: substituted when set; throws a clear startup error naming the variable when unset. Genuinely invalid values (e.g. "maybe" for a bool) still throw. Also fixes a latent bug in GetConfigEnum, which computed the substituted value but then passed the raw, unsubstituted value to the enum parser. Tests: EnvVarPlaceholderConfigTests covers optional/required across ResolveEnv, GetConfigBool/GetConfigInt/GetConfigStr, Serilog-template preservation, and invalid-value-still-throws. Full suite: 2115 passing. --- NpgsqlRestClient/Config.cs | 129 +++++++++---- .../EnvVarPlaceholderConfigTests.cs | 173 ++++++++++++++++++ changelog/v3.16.4.md | 23 ++- 3 files changed, 291 insertions(+), 34 deletions(-) create mode 100644 NpgsqlRestTests/ConfigTests/EnvVarPlaceholderConfigTests.cs diff --git a/NpgsqlRestClient/Config.cs b/NpgsqlRestClient/Config.cs index ff37af38..636ac2b7 100644 --- a/NpgsqlRestClient/Config.cs +++ b/NpgsqlRestClient/Config.cs @@ -1,7 +1,6 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; -using NpgsqlRest; namespace NpgsqlRestClient; @@ -133,9 +132,16 @@ public bool GetConfigBool(string key, IConfiguration? subsection = null, bool de return defaultVal; } - var value = EnvDict is not null ? - Formatter.FormatString(section.Value.AsSpan(), EnvDict).ToString() : - section.Value; + // {NAME} optional -> empty when the env var is unset; {!NAME} required -> throws here if unset. + var value = ResolveEnv(section.Value) ?? string.Empty; + + // An optional env var that is unset resolves to empty (and, with env parsing off, can remain a + // literal {TOKEN}). Either way there is no value to parse, so fall back to the default rather + // than crashing startup - e.g. a feature flag wired to "{GITHUB_AUTH_ENABLED}" defaults to off. + if (string.IsNullOrEmpty(value) || HasUnresolvedToken(value)) + { + return defaultVal; + } // Handle various boolean representations return value.ToLowerInvariant() switch @@ -146,6 +152,76 @@ public bool GetConfigBool(string key, IConfiguration? subsection = null, bool de }; } + // True when the value still contains a {TOKEN} placeholder, i.e. an environment variable that was + // not resolved because it is not set. Typed config values (bool/int) never legitimately contain + // braces, so an unresolved token is unambiguously a missing-env-var, not a real value. + private static bool HasUnresolvedToken(string value) + { + var open = value.IndexOf('{'); + return open >= 0 && value.IndexOf('}', open + 1) > open; + } + + /// + /// Substitutes environment-variable placeholders in a configuration value. Applies to every config + /// value type (bool, int, string, enum, arrays, dictionaries): + /// + /// {NAME} - optional: replaced with the variable's value when set; when it is not + /// set the placeholder is left untouched (so non-env brace syntax such as Serilog output templates + /// "{Timestamp}" is preserved, and typed readers like fall back to their + /// default rather than crashing). + /// {!NAME} - required: replaced with the variable's value, or throws at startup when it is not set. + /// + /// Returns the input unchanged when environment-variable parsing is disabled ( is null, + /// i.e. Config:ParseEnvironmentVariables is false). + /// + public string? ResolveEnv(string? value) + { + if (value is null || EnvDict is null || value.IndexOf('{') < 0) + { + return value; + } + var sb = new StringBuilder(value.Length); + int i = 0; + while (i < value.Length) + { + var c = value[i]; + if (c != '{') + { + sb.Append(c); + i++; + continue; + } + var close = value.IndexOf('}', i + 1); + if (close < 0) + { + sb.Append(value, i, value.Length - i); // unterminated brace - emit the rest verbatim + break; + } + var token = value.Substring(i + 1, close - i - 1); + var required = token.StartsWith('!'); + var name = required ? token[1..] : token; + if (EnvDict.TryGetValue(name, out var envValue)) + { + sb.Append(envValue); + } + else if (required) + { + throw new InvalidOperationException( + $"Required environment variable '{name}' (referenced as '{{!{name}}}' in configuration) is not set."); + } + else + { + // optional + missing -> leave the placeholder untouched. This preserves the historical + // behaviour (only known env vars were ever substituted), so legitimate non-env brace + // syntax - Serilog output templates, etc. - is not destroyed. Typed bool/int readers + // treat a leftover {TOKEN} as "not configured" via HasUnresolvedToken. + sb.Append('{').Append(token).Append('}'); + } + i = close + 1; + } + return sb.ToString(); + } + public string? GetConfigStr(string key, IConfiguration? subsection = null) { var section = subsection?.GetSection(key) ?? Cfg.GetSection(key); @@ -153,9 +229,7 @@ public bool GetConfigBool(string key, IConfiguration? subsection = null, bool de { return null; } - return EnvDict is not null ? - Formatter.FormatString(section.Value.AsSpan(), EnvDict).ToString() : - section.Value; + return ResolveEnv(section.Value); } public int? GetConfigInt(string key, IConfiguration? subsection = null) @@ -165,10 +239,13 @@ public bool GetConfigBool(string key, IConfiguration? subsection = null, bool de { return null; } - var configValue = EnvDict is not null ? - Formatter.FormatString(section.Value.AsSpan(), EnvDict).ToString() : - section.Value; - return int.TryParse(configValue, out var value) ? value : + var configValue = ResolveEnv(section.Value) ?? string.Empty; + // Unset env var (empty or, with env parsing off, an unresolved {TOKEN}) → treat as not configured. + if (string.IsNullOrEmpty(configValue) || HasUnresolvedToken(configValue)) + { + return null; + } + return int.TryParse(configValue, out var value) ? value : throw new InvalidOperationException($"Invalid integer value '{configValue}' for configuration key '{key}'."); } @@ -179,10 +256,8 @@ public bool GetConfigBool(string key, IConfiguration? subsection = null, bool de { return default; } - var value = EnvDict is not null ? - Formatter.FormatString(section.Value.AsSpan(), EnvDict).ToString() : - section.Value; - return GetEnum(section?.Value); + var value = ResolveEnv(section.Value); + return GetEnum(value); } public T? GetEnum(string? value) @@ -217,15 +292,9 @@ public bool GetConfigBool(string key, IConfiguration? subsection = null, bool de return null; } - if (EnvDict is not null) - { - return children - .Where(c => string.IsNullOrEmpty(c.Value) is false) - .Select(c => Formatter.FormatString(c.Value.AsSpan(), EnvDict).ToString()); - } return children .Where(c => string.IsNullOrEmpty(c.Value) is false) - .Select(c => c.Value!); + .Select(c => ResolveEnv(c.Value)!); } public T? GetConfigFlag(string key, IConfiguration? subsection = null) @@ -298,7 +367,7 @@ public bool GetConfigBool(string key, IConfiguration? subsection = null, bool de { continue; } - var name = EnvDict is not null ? Formatter.FormatString(c.Value.AsSpan(), EnvDict).ToString() : c.Value!; + var name = ResolveEnv(c.Value)!; result[name] = null; } } @@ -310,7 +379,7 @@ public bool GetConfigBool(string key, IConfiguration? subsection = null, bool de { continue; // skip nested objects/arrays - only scalar name→default pairs are valid here } - var def = EnvDict is not null ? Formatter.FormatString(c.Value.AsSpan(), EnvDict).ToString() : c.Value; + var def = ResolveEnv(c.Value); result[c.Key] = def; } } @@ -324,11 +393,7 @@ public bool GetConfigBool(string key, IConfiguration? subsection = null, bool de { if (section.Value is not null) { - var value = EnvDict is not null ? - Formatter.FormatString(section.Value.AsSpan(), EnvDict).ToString() : - section.Value; - - result.TryAdd(section.Key, value); + result.TryAdd(section.Key, ResolveEnv(section.Value)!); } } return result.Count == 0 ? null : result; @@ -960,9 +1025,7 @@ private static void AddTrailingCommaToLastLine(List block) { return null; } - var value = EnvDict is not null ? - Formatter.FormatString(section.Value.AsSpan(), EnvDict).ToString() : - section.Value; + var value = ResolveEnv(section.Value) ?? string.Empty; if (bool.TryParse(value, out bool boolean)) { return JsonValue.Create(boolean); @@ -1097,7 +1160,7 @@ private void CollectTransformedValues(IConfigurationSection section, string pref if (child.Value is not null) { - dict[key] = Formatter.FormatString(child.Value.AsSpan(), EnvDict!).ToString(); + dict[key] = ResolveEnv(child.Value)!; } CollectTransformedValues(child, key, dict); diff --git a/NpgsqlRestTests/ConfigTests/EnvVarPlaceholderConfigTests.cs b/NpgsqlRestTests/ConfigTests/EnvVarPlaceholderConfigTests.cs new file mode 100644 index 00000000..d687cf0c --- /dev/null +++ b/NpgsqlRestTests/ConfigTests/EnvVarPlaceholderConfigTests.cs @@ -0,0 +1,173 @@ +using Microsoft.Extensions.Configuration; +using NpgsqlRestClient; + +namespace NpgsqlRestTests.ConfigTests; + +/// +/// Environment-variable placeholders in configuration values (with Config:ParseEnvironmentVariables +/// enabled), applied to every config value type: +/// +/// {NAME} - optional: resolves to the variable's value when set; left untouched when not set +/// (so non-env brace syntax like Serilog templates survives, and typed bool/int reads fall back to default). +/// {!NAME} - required: resolves to the variable's value, or throws at startup when it is not set. +/// +/// Previously a missing {NAME} crashed typed reads +/// (e.g. "Invalid boolean value '{GITHUB_AUTH_ENABLED}' for configuration key 'Enabled'"). +/// +public class EnvVarPlaceholderConfigTests +{ + // Builds a Config with ParseEnvironmentVariables enabled, capturing the given env vars into EnvDict. + private static Config CreateConfig(Dictionary envVars) + { + foreach (var kvp in envVars) + { + Environment.SetEnvironmentVariable(kvp.Key, kvp.Value); + } + var config = new Config(); + var tempFile = Path.GetTempFileName(); + File.WriteAllText(tempFile, """{ "Config": { "ParseEnvironmentVariables": true } }"""); + try + { + config.Build([tempFile], []); + } + finally + { + File.Delete(tempFile); + foreach (var kvp in envVars) + { + Environment.SetEnvironmentVariable(kvp.Key, null); + } + } + return config; + } + + private static IConfigurationSection Section(string key, string value) => + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { $"Auth:{key}", value } }) + .Build() + .GetSection("Auth"); + + // ---- ResolveEnv directly (the shared resolver used by every config read) ---- + + [Fact] + public void ResolveEnv_Optional_Present_Substitutes() + { + var config = CreateConfig(new() { { "BUILD_LABEL", "demo" } }); + config.ResolveEnv("label={BUILD_LABEL}").Should().Be("label=demo"); + } + + [Fact] + public void ResolveEnv_Optional_Missing_LeftUntouched_PreservesNonEnvBraces() + { + var config = CreateConfig([]); + // unset optional token is preserved verbatim - this is what keeps Serilog templates intact + config.ResolveEnv("label={BUILD_LABEL};").Should().Be("label={BUILD_LABEL};"); + config.ResolveEnv("[{Timestamp:HH:mm:ss} {Message}]").Should().Be("[{Timestamp:HH:mm:ss} {Message}]"); + } + + [Fact] + public void ResolveEnv_Required_Present_Substitutes() + { + var config = CreateConfig(new() { { "DB_HOST", "db.internal" } }); + config.ResolveEnv("host={!DB_HOST}").Should().Be("host=db.internal"); + } + + [Fact] + public void ResolveEnv_Required_Missing_Throws_NamingVariable() + { + var config = CreateConfig([]); + var act = () => config.ResolveEnv("host={!DB_HOST}"); + act.Should().Throw() + .WithMessage("*DB_HOST*not set*"); + } + + [Fact] + public void ResolveEnv_Mixed_OptionalAndRequired() + { + var config = CreateConfig(new() { { "REQ", "r" } }); // OPT not set + // required resolves; unset optional is left untouched + config.ResolveEnv("{!REQ}/{OPT}").Should().Be("r/{OPT}"); + } + + // ---- GetConfigBool ---- + + [Fact] + public void GetConfigBool_Optional_Missing_ReturnsDefaultFalse() + { + var config = CreateConfig([]); + config.GetConfigBool("Enabled", Section("Enabled", "{GITHUB_AUTH_ENABLED}")).Should().BeFalse(); + } + + [Fact] + public void GetConfigBool_Optional_Missing_HonoursProvidedDefault() + { + var config = CreateConfig([]); + config.GetConfigBool("Enabled", Section("Enabled", "{GITHUB_AUTH_ENABLED}"), defaultVal: true).Should().BeTrue(); + } + + [Fact] + public void GetConfigBool_Required_Missing_Throws() + { + var config = CreateConfig([]); + var act = () => config.GetConfigBool("Enabled", Section("Enabled", "{!GITHUB_AUTH_ENABLED}")); + act.Should().Throw().WithMessage("*GITHUB_AUTH_ENABLED*not set*"); + } + + [Fact] + public void GetConfigBool_Resolved_ParsesValue() + { + var config = CreateConfig(new() { { "GITHUB_AUTH_ENABLED", "true" } }); + config.GetConfigBool("Enabled", Section("Enabled", "{GITHUB_AUTH_ENABLED}")).Should().BeTrue(); + } + + [Fact] + public void GetConfigBool_GenuinelyInvalidValue_StillThrows() + { + var config = CreateConfig([]); + var act = () => config.GetConfigBool("Enabled", Section("Enabled", "maybe")); + act.Should().Throw().WithMessage("*Invalid boolean value*"); + } + + // ---- GetConfigInt ---- + + [Fact] + public void GetConfigInt_Optional_Missing_ReturnsNull() + { + var config = CreateConfig([]); + config.GetConfigInt("Port", Section("Port", "{SOME_PORT}")).Should().BeNull(); + } + + [Fact] + public void GetConfigInt_Required_Missing_Throws() + { + var config = CreateConfig([]); + var act = () => config.GetConfigInt("Port", Section("Port", "{!SOME_PORT}")); + act.Should().Throw().WithMessage("*SOME_PORT*not set*"); + } + + [Fact] + public void GetConfigInt_GenuinelyInvalidValue_StillThrows() + { + var config = CreateConfig([]); + var act = () => config.GetConfigInt("Port", Section("Port", "not-a-number")); + act.Should().Throw().WithMessage("*Invalid integer value*"); + } + + // ---- GetConfigStr (string type also honours optional/required) ---- + + [Fact] + public void GetConfigStr_Optional_Missing_LeftUntouched() + { + var config = CreateConfig([]); + // strings preserve an unset optional token verbatim (use {!NAME} to require it instead) + config.GetConfigStr("Name", Section("Name", "{MISSING}")).Should().Be("{MISSING}"); + } + + [Fact] + public void GetConfigStr_Required_Missing_Throws() + { + var config = CreateConfig([]); + var act = () => config.GetConfigStr("Name", Section("Name", "{!MISSING}")); + act.Should().Throw().WithMessage("*MISSING*not set*"); + } +} diff --git a/changelog/v3.16.4.md b/changelog/v3.16.4.md index d0b2e5f1..ec5fdf6b 100644 --- a/changelog/v3.16.4.md +++ b/changelog/v3.16.4.md @@ -4,7 +4,9 @@ [Full Changelog](https://github.com/NpgsqlRest/NpgsqlRest/compare/3.16.3...3.16.4) -Bug-fix release. JSON payloads passed to user-authored SQL commands were bound with a hardcoded `json` type, so a function declaring the matching parameter as `jsonb` or `text` failed at runtime with PostgreSQL `42883` *"function does not exist"* — even though the documentation states `text`/`json`/`jsonb` are all acceptable. The binding now uses an untyped (`unknown`) parameter, which PostgreSQL resolves server-side via the target type's input function, so `json`, `jsonb`, and `text` all work. +Bug-fix release with two fixes: JSON command parameters now accept `json`/`jsonb`/`text`, and environment-variable placeholders in config gain optional (`{NAME}`) / required (`{!NAME}`) semantics so a missing variable no longer crashes startup. + +**JSON command parameters.** JSON payloads passed to user-authored SQL commands were bound with a hardcoded `json` type, so a function declaring the matching parameter as `jsonb` or `text` failed at runtime with PostgreSQL `42883` *"function does not exist"* — even though the documentation states `text`/`json`/`jsonb` are all acceptable. The binding now uses an untyped (`unknown`) parameter, which PostgreSQL resolves server-side via the target type's input function, so `json`, `jsonb`, and `text` all work. ## What changed @@ -25,7 +27,26 @@ The root cause: PostgreSQL function-overload resolution uses only *implicit* cas This is **fully backward compatible** — existing `json`-typed functions are unaffected; the change only *adds* the previously-documented `jsonb` and `text` support. +### Optional `{NAME}` and required `{!NAME}` environment-variable placeholders + +With `Config:ParseEnvironmentVariables` enabled (the default), config values support two placeholder forms, for **every** value type (bool, int, string, enum, arrays, dictionaries): + +- **`{NAME}` — optional.** Substituted with the variable's value when set. When it is **not** set, the placeholder is left untouched — so typed `bool`/`int` reads fall back to their default instead of crashing, and legitimate non-env brace syntax (e.g. Serilog `OutputTemplate` `"{Timestamp:HH:mm:ss} {Message}"`) is preserved. +- **`{!NAME}` — required.** Substituted with the variable's value, or **throws a clear error at startup** when it is not set: `Required environment variable 'NAME' (referenced as '{!NAME}' in configuration) is not set.` + +This fixes a startup crash: previously a missing optional var left an unresolved `{GITHUB_AUTH_ENABLED}` token that `GetConfigBool` rejected with `Invalid boolean value '{GITHUB_AUTH_ENABLED}' for configuration key 'Enabled'`. Now: + +```jsonc +"Enabled": "{GITHUB_AUTH_ENABLED}" // env unset → feature defaults to off (no crash) +"Enabled": "{!GITHUB_AUTH_ENABLED}" // env unset → startup error naming the variable +``` + +Genuinely invalid *values* (e.g. `"maybe"` for a bool) still throw, so typos in the value are still caught. + +> Note: a *misspelled* optional variable name (`{GITHUB_AUTH_ENABLD}`) is left as a literal token and a typed flag defaults to off — use `{!NAME}` when a value must be present. + ### Tests - A binding-contract test that locks the PostgreSQL/Npgsql resolution behaviour the fix relies on (`json`-only for the old binding; `json`/`jsonb`/`text` for the new one, including `NULL` and round-trip integrity). - End-to-end CSV upload tests covering a row-command metadata parameter declared as `json`, `jsonb`, and `text`. +- Config tests covering optional `{NAME}` (resolves when set, left untouched / defaults when not — including Serilog-template preservation) and required `{!NAME}` (throws when unset) across `GetConfigBool`/`GetConfigInt`/`GetConfigStr` and the `ResolveEnv` resolver, plus genuinely invalid values still throwing. From 74582bfc66a3a06954bcb7ace25122ee83d72601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Sun, 7 Jun 2026 13:09:52 +0200 Subject: [PATCH 03/38] chore: open v3.17.0 changelog (migrate from unreleased v3.16.4) 3.16.4 was never released; its bug-fixes (json/jsonb/text command params, optional {NAME} / required {!NAME} env-var placeholders) ship as part of 3.17.0. Rename changelog/v3.16.4.md -> changelog/v3.17.0.md and reframe the header. 3.17.0 is the MCP support release line. --- changelog/{v3.16.4.md => v3.17.0.md} | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) rename changelog/{v3.16.4.md => v3.17.0.md} (88%) diff --git a/changelog/v3.16.4.md b/changelog/v3.17.0.md similarity index 88% rename from changelog/v3.16.4.md rename to changelog/v3.17.0.md index ec5fdf6b..a48d5a59 100644 --- a/changelog/v3.16.4.md +++ b/changelog/v3.17.0.md @@ -1,10 +1,12 @@ -# Changelog v3.16.4 (2026-06-03) +# Changelog v3.17.0 -## Version [3.16.4](https://github.com/NpgsqlRest/NpgsqlRest/tree/3.16.4) (2026-06-03) +## Version [3.17.0](https://github.com/NpgsqlRest/NpgsqlRest/tree/3.17.0) (unreleased) -[Full Changelog](https://github.com/NpgsqlRest/NpgsqlRest/compare/3.16.3...3.16.4) +[Full Changelog](https://github.com/NpgsqlRest/NpgsqlRest/compare/3.16.3...3.17.0) -Bug-fix release with two fixes: JSON command parameters now accept `json`/`jsonb`/`text`, and environment-variable placeholders in config gain optional (`{NAME}`) / required (`{!NAME}`) semantics so a missing variable no longer crashes startup. +> v3.17.0 rolls up the bug-fixes originally developed as the unreleased 3.16.4 (documented below) and is the release line for MCP (Model Context Protocol) support. Feature entries are added as that work lands. + +The rolled-up bug-fixes: JSON command parameters now accept `json`/`jsonb`/`text`, and environment-variable placeholders in config gain optional (`{NAME}`) / required (`{!NAME}`) semantics so a missing variable no longer crashes startup. **JSON command parameters.** JSON payloads passed to user-authored SQL commands were bound with a hardcoded `json` type, so a function declaring the matching parameter as `jsonb` or `text` failed at runtime with PostgreSQL `42883` *"function does not exist"* — even though the documentation states `text`/`json`/`jsonb` are all acceptable. The binding now uses an untyped (`unknown`) parameter, which PostgreSQL resolves server-side via the target type's input function, so `json`, `jsonb`, and `text` all work. From 3ed38b276990cdf97160feb99ac4ccb9cd7aa341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Sun, 7 Jun 2026 13:10:40 +0200 Subject: [PATCH 04/38] refactor: extract comment-parsing primitives to shared NpgsqlRest.Common MCP-support prep (Phase 0). Moves StrEquals/StrEqualsToArray/SplitWords/ SplitWordsLower/SplitBySeparatorChar into NpgsqlRest.Common/CommentPrimitives.cs, shared via linked source (internal type, no separate dll/package) compiled into both NpgsqlRest core and NpgsqlRest.SqlFileSource. Core imports them project-wide via global usings; SqlFileSource's @single*/@skip* matching now uses the shared StrEqualsToArray. Adds 22 characterization tests. No behavior change. --- NpgsqlRest.Common/CommentPrimitives.cs | 113 ++++++++++++++++++ .../Defaults/CommentParsers/Utilities.cs | 83 +------------ NpgsqlRest/Defaults/DefaultCommentParser.cs | 1 - NpgsqlRest/NpgsqlRest.csproj | 16 +++ .../ParserTests/CommentPrimitivesTests.cs | 103 ++++++++++++++++ .../NpgsqlRest.SqlFileSource.csproj | 9 ++ .../NpgsqlRest.SqlFileSource/SqlFileParser.cs | 10 +- 7 files changed, 248 insertions(+), 87 deletions(-) create mode 100644 NpgsqlRest.Common/CommentPrimitives.cs create mode 100644 NpgsqlRestTests/ParserTests/CommentPrimitivesTests.cs diff --git a/NpgsqlRest.Common/CommentPrimitives.cs b/NpgsqlRest.Common/CommentPrimitives.cs new file mode 100644 index 00000000..7ae8ae88 --- /dev/null +++ b/NpgsqlRest.Common/CommentPrimitives.cs @@ -0,0 +1,113 @@ +namespace NpgsqlRest.Common; + +/// +/// Shared comment/annotation parsing string primitives, compiled directly into each consuming +/// assembly (NpgsqlRest core, NpgsqlRest.SqlFileSource, ...) via linked source — NOT a separate +/// assembly or NuGet package. Declared internal on purpose: each consumer gets its own copy, +/// avoiding duplicate-public-type collisions across the core→plugin reference boundary. +/// +/// +internal static class CommentPrimitives +{ + // Canonical comment-annotation word separators (space, comma). + private static readonly char[] WordSeparators = [' ', ',']; + + /// + /// Case-insensitive compare with an optional leading '@' stripped from + /// (so "@authorize" equals "authorize"). The '@' is NOT stripped from . + /// + public static bool StrEquals(string str1, string str2) + { + var s1 = str1.Length > 0 && str1[0] == '@' ? str1[1..] : str1; + return s1.Equals(str2, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Case-insensitive match of (optional leading '@' stripped) against any + /// of the supplied aliases. + /// + public static bool StrEqualsToArray(string str, params string[] arr) + { + var s = str.Length > 0 && str[0] == '@' ? str[1..] : str; + for (var i = 0; i < arr.Length; i++) + { + if (s.Equals(arr[i], StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + + /// + /// Split into trimmed words on space/comma, removing empty entries. Null input yields an empty + /// array. Case preserved. Extension method. + /// + public static string[] SplitWords(this string? str) + { + if (str is null) + { + return []; + } + return [.. str + .Split(WordSeparators, StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + ]; + } + + /// + /// As but each word is lower-cased (invariant). Extension method. + /// + public static string[] SplitWordsLower(this string? str) + { + if (str is null) + { + return []; + } + return [.. str + .Split(WordSeparators, StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim().ToLowerInvariant()) + ]; + } + + /// + /// Splits on the first occurrence of into a trimmed + /// key/value pair. Returns false when the separator is absent, or when the key part contains a + /// character that is not valid in a name (letters, digits, '-', '_', '@'). + /// + public static bool SplitBySeparatorChar(string str, char sep, out string part1, out string part2) + { + part1 = null!; + part2 = null!; + if (str.Contains(sep) is false) + { + return false; + } + + var parts = str.Split(sep, 2); + if (parts.Length == 2) + { + part1 = parts[0].Trim(); + part2 = parts[1].Trim(); + if (ContainsInvalidNameCharacter(part1)) + { + return false; + } + return true; + } + return false; + } + + private static bool ContainsInvalidNameCharacter(string input) + { + foreach (char c in input) + { + // Allow '@' as a prefix (e.g., "@timeout = 30s"), plus '-' and '_'. + if (char.IsLetterOrDigit(c) is false && c != '-' && c != '_' && c != '@') + { + return true; + } + } + return false; + } +} diff --git a/NpgsqlRest/Defaults/CommentParsers/Utilities.cs b/NpgsqlRest/Defaults/CommentParsers/Utilities.cs index 625c1bdd..7b816d75 100644 --- a/NpgsqlRest/Defaults/CommentParsers/Utilities.cs +++ b/NpgsqlRest/Defaults/CommentParsers/Utilities.cs @@ -50,86 +50,9 @@ private static void TrackAnnotation(string label) return result; } - public static bool StrEquals(string str1, string str2) - { - // Support optional @ prefix for annotations (e.g., "@authorize" == "authorize") - var s1 = str1.Length > 0 && str1[0] == '@' ? str1[1..] : str1; - return s1.Equals(str2, StringComparison.OrdinalIgnoreCase); - } - - public static bool StrEqualsToArray(string str, params string[] arr) - { - // Support optional @ prefix for annotations (e.g., "@authorize" matches "authorize") - var s = str.Length > 0 && str[0] == '@' ? str[1..] : str; - for (var i = 0; i < arr.Length; i++) - { - if (s.Equals(arr[i], StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - return false; - } - - public static string[] SplitWordsLower(this string str) - { - if (str is null) - { - return []; - } - return [.. str - .Split(WordSeparators, StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Trim().ToLowerInvariant()) - ]; - } - - public static string[] SplitWords(this string str) - { - if (str is null) - { - return []; - } - return [.. str - .Split(WordSeparators, StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Trim()) - ]; - } - - public static bool SplitBySeparatorChar(string str, char sep, out string part1, out string part2) - { - part1 = null!; - part2 = null!; - if (str.Contains(sep) is false) - { - return false; - } - - var parts = str.Split(sep, 2); - if (parts.Length == 2) - { - part1 = parts[0].Trim(); - part2 = parts[1].Trim(); - if (ContainsValidNameCharacter(part1)) - { - return false; - } - return true; - } - return false; - } - - private static bool ContainsValidNameCharacter(string input) - { - foreach (char c in input) - { - // Allow @ as first character for annotation prefix (e.g., "@timeout = 30s") - if (char.IsLetterOrDigit(c) is false && c != '-' && c != '_' && c != '@') - { - return true; - } - } - return false; - } + // String primitives (StrEquals / StrEqualsToArray / SplitWords / SplitWordsLower / + // SplitBySeparatorChar) live in the shared NpgsqlRest.Common.CommentPrimitives and are + // imported project-wide via global usings (see NpgsqlRest.csproj) — called directly here. /// /// For SQL file routines, update column type descriptors when @param annotations have diff --git a/NpgsqlRest/Defaults/DefaultCommentParser.cs b/NpgsqlRest/Defaults/DefaultCommentParser.cs index 1d42fa9b..f458aa57 100644 --- a/NpgsqlRest/Defaults/DefaultCommentParser.cs +++ b/NpgsqlRest/Defaults/DefaultCommentParser.cs @@ -5,7 +5,6 @@ namespace NpgsqlRest.Defaults; internal static partial class DefaultCommentParser { private static readonly char[] NewlineSeparator = ['\r', '\n']; - private static readonly char[] WordSeparators = [Consts.Space, Consts.Comma]; // All annotation keys moved to their respective handler files in CommentParsers directory diff --git a/NpgsqlRest/NpgsqlRest.csproj b/NpgsqlRest/NpgsqlRest.csproj index db97b44f..6f216f06 100644 --- a/NpgsqlRest/NpgsqlRest.csproj +++ b/NpgsqlRest/NpgsqlRest.csproj @@ -66,4 +66,20 @@ + + + + + Common\%(Filename)%(Extension) + + + + + + + + diff --git a/NpgsqlRestTests/ParserTests/CommentPrimitivesTests.cs b/NpgsqlRestTests/ParserTests/CommentPrimitivesTests.cs new file mode 100644 index 00000000..405283c2 --- /dev/null +++ b/NpgsqlRestTests/ParserTests/CommentPrimitivesTests.cs @@ -0,0 +1,103 @@ +using NpgsqlRest.Common; + +namespace NpgsqlRestTests.ParserTests; + +// Characterization tests pinning the behavior of the shared comment-parsing string primitives +// (NpgsqlRest.Common.CommentPrimitives) — extracted from DefaultCommentParser in MCP plan Phase 0. +// They document behavior, quirks included. +public class CommentPrimitivesTests +{ + // ---- StrEquals: optional leading '@' stripped from str1, then OrdinalIgnoreCase compare ---- + + [Theory] + [InlineData("authorize", "authorize", true)] + [InlineData("@authorize", "authorize", true)] // '@' prefix on str1 is stripped + [InlineData("AUTHORIZE", "authorize", true)] // case-insensitive + [InlineData("authorize", "AUTHORIZE", true)] + [InlineData("authorize", "auth", false)] + [InlineData("authorize", "@authorize", false)] // '@' is NOT stripped from str2 + [InlineData("@", "", true)] // "@" -> "" equals "" + [InlineData("", "", true)] + public void StrEquals_MatchesCurrentBehavior(string str1, string str2, bool expected) + { + CommentPrimitives.StrEquals(str1, str2).Should().Be(expected); + } + + // ---- StrEqualsToArray: '@' stripped from str, then OrdinalIgnoreCase match against any ---- + + [Fact] + public void StrEqualsToArray_MatchesAnyAlias() + { + CommentPrimitives.StrEqualsToArray("cached", "cached", "cache").Should().BeTrue(); + CommentPrimitives.StrEqualsToArray("@cache", "cached", "cache").Should().BeTrue(); + CommentPrimitives.StrEqualsToArray("CACHE", "cached", "cache").Should().BeTrue(); + } + + [Fact] + public void StrEqualsToArray_NoMatch_ReturnsFalse() + { + CommentPrimitives.StrEqualsToArray("x", "a", "b").Should().BeFalse(); + } + + [Fact] + public void StrEqualsToArray_EmptyArray_ReturnsFalse() + { + CommentPrimitives.StrEqualsToArray("anything").Should().BeFalse(); + } + + // ---- SplitWords: split on space/comma, RemoveEmptyEntries, trim, preserve case ---- + + [Fact] + public void SplitWords_SplitsOnSpaceAndComma_Trimmed_PreservingCase() + { + "a, b, c".SplitWords().Should().Equal("a", "b", "c"); + "a b".SplitWords().Should().Equal("a", "b"); + " a , , b ".SplitWords().Should().Equal("a", "b"); // empty entries removed + "Foo Bar".SplitWords().Should().Equal("Foo", "Bar"); // case preserved + } + + [Fact] + public void SplitWords_NullOrEmpty_ReturnsEmpty() + { + ((string)null!).SplitWords().Should().BeEmpty(); + "".SplitWords().Should().BeEmpty(); + } + + // ---- SplitWordsLower: same as SplitWords but lowercased ---- + + [Fact] + public void SplitWordsLower_LowercasesEachWord() + { + "Foo, BAR".SplitWordsLower().Should().Equal("foo", "bar"); + "MixedCase Word".SplitWordsLower().Should().Equal("mixedcase", "word"); + } + + [Fact] + public void SplitWordsLower_NullOrEmpty_ReturnsEmpty() + { + ((string)null!).SplitWordsLower().Should().BeEmpty(); + "".SplitWordsLower().Should().BeEmpty(); + } + + // ---- SplitBySeparatorChar: split on first separator; false if absent or part1 has invalid name char ---- + // Valid name chars: letter, digit, '-', '_', '@'. Anything else in part1 => not a key/value pair. + + [Theory] + [InlineData("key = value", '=', true, "key", "value")] + [InlineData("Content-Type: application/json", ':', true, "Content-Type", "application/json")] // '-' allowed + [InlineData("@timeout = 30s", '=', true, "@timeout", "30s")] // '@' allowed + [InlineData("no separator here", '=', false, null, null)] // no separator + [InlineData("a b = value", '=', false, null, null)] // space in part1 -> invalid + [InlineData("key=", '=', true, "key", "")] // empty value + [InlineData("=value", '=', true, "", "value")] // empty key + public void SplitBySeparatorChar_MatchesCurrentBehavior(string input, char sep, bool expected, string? p1, string? p2) + { + var result = CommentPrimitives.SplitBySeparatorChar(input, sep, out var part1, out var part2); + result.Should().Be(expected); + if (expected) + { + part1.Should().Be(p1); + part2.Should().Be(p2); + } + } +} diff --git a/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj b/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj index 7b6d7cb3..5dfced83 100644 --- a/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj +++ b/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj @@ -52,4 +52,13 @@ + + + + + Common\%(Filename)%(Extension) + + diff --git a/plugins/NpgsqlRest.SqlFileSource/SqlFileParser.cs b/plugins/NpgsqlRest.SqlFileSource/SqlFileParser.cs index 56432198..718658b5 100644 --- a/plugins/NpgsqlRest.SqlFileSource/SqlFileParser.cs +++ b/plugins/NpgsqlRest.SqlFileSource/SqlFileParser.cs @@ -1,3 +1,5 @@ +using NpgsqlRest.Common; + namespace NpgsqlRest.SqlFileSource; /// @@ -464,18 +466,14 @@ private static void ExtractPerCommandAnnotations(string comment, int statementIn var s = trimmed.StartsWith('@') ? trimmed[1..] : trimmed; // @single / @single_record / @single_result - if (s.Equals("single", StringComparison.OrdinalIgnoreCase) || - s.Equals("single_record", StringComparison.OrdinalIgnoreCase) || - s.Equals("single_result", StringComparison.OrdinalIgnoreCase)) + if (CommentPrimitives.StrEqualsToArray(s, "single", "single_record", "single_result")) { result.SingleCommands.Add(statementIndex); continue; } // @skip / @skip_result / @no_result - if (s.Equals("skip", StringComparison.OrdinalIgnoreCase) || - s.Equals("skip_result", StringComparison.OrdinalIgnoreCase) || - s.Equals("no_result", StringComparison.OrdinalIgnoreCase)) + if (CommentPrimitives.StrEqualsToArray(s, "skip", "skip_result", "no_result")) { result.SkipCommands.Add(statementIndex); continue; From 71d83fcc68159e9bdcc584aec860d8d7ed11283d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Sun, 7 Jun 2026 13:21:54 +0200 Subject: [PATCH 05/38] chore: remove dead local assignments (no behavior change) Removes unused/unnecessary assignments flagged by Roslyn IDE0059: originalUrl/originalMethod in DefaultCommentParser, a redundant singleStarPi reset in Parser, and unused arguments/indent in Config. No unused private members found anywhere in the codebase. Full suite green (2137). --- NpgsqlRest/Defaults/DefaultCommentParser.cs | 2 -- NpgsqlRest/Parser.cs | 1 - NpgsqlRestClient/Config.cs | 2 -- 3 files changed, 5 deletions(-) diff --git a/NpgsqlRest/Defaults/DefaultCommentParser.cs b/NpgsqlRest/Defaults/DefaultCommentParser.cs index f458aa57..c68d279b 100644 --- a/NpgsqlRest/Defaults/DefaultCommentParser.cs +++ b/NpgsqlRest/Defaults/DefaultCommentParser.cs @@ -17,8 +17,6 @@ internal static partial class DefaultCommentParser return routineEndpoint; } - var originalUrl = routineEndpoint.Path; - var originalMethod = routineEndpoint.Method; var originalParamType = routineEndpoint.RequestParamType; var comment = routine.Comment; diff --git a/NpgsqlRest/Parser.cs b/NpgsqlRest/Parser.cs index c10e9ec4..394365ef 100644 --- a/NpgsqlRest/Parser.cs +++ b/NpgsqlRest/Parser.cs @@ -144,7 +144,6 @@ public static bool IsPatternMatch(string name, string pattern) pi2 = singleStarPi; continue; } - singleStarPi = -1; } if (doubleStarPi >= 0) { diff --git a/NpgsqlRestClient/Config.cs b/NpgsqlRestClient/Config.cs index 636ac2b7..e6badbaa 100644 --- a/NpgsqlRestClient/Config.cs +++ b/NpgsqlRestClient/Config.cs @@ -24,7 +24,6 @@ public void Build(string[] args, string[] skip) var tempBuilder = new ConfigurationBuilder(); IConfigurationRoot tempCfg; - var arguments = new Out(); var (configFiles, commandLineArgs) = BuildFromArgs(args); if (configFiles.Count > 0) @@ -521,7 +520,6 @@ private string SubstituteTemplateValues(JsonObject merged) else if (changedPaths.TryGetValue(currentPath, out var newVal)) { // Substitute the value - var indent = line.TrimEnd('\r')[..^(line.TrimEnd('\r').Length - line.TrimEnd('\r').Length + line.TrimEnd('\r').Length - line.TrimEnd('\r').TrimStart().Length)]; var endsWithComma = trimmed.TrimEnd().EndsWith(','); var inlineComment = ""; From a7843c66c57998323518f40641b5705b942918ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Mon, 8 Jun 2026 11:09:20 +0200 Subject: [PATCH 06/38] =?UTF-8?q?feat:=20v3.17.0=20=E2=80=94=20plugin=20co?= =?UTF-8?q?mment-annotation=20extension=20points=20on=20RoutineEndpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neutral, plugin-facing extension points so plugins can own their comment annotations during core's single parse pass, without re-walking the comment: - IEndpointCreateHandler.HandleCommentLine(endpoint, line, words, wordsLower): new default-interface method (non-breaking). Core's comment parser offers each line it does not recognize as a built-in directive to registered handlers; the first to return a non-null label claims it, and core logs it centrally (same format/gating as built-in annotations). Tokens are pre-split by core. - RoutineEndpoint.Items (IDictionary, lazy) + TryGetItem (non-allocating read): generic per-endpoint property bag for plugin metadata. - RoutineEndpoint.UnhandledCommentLines: comment lines no handler claimed (prose). Core attaches no meaning to any of these — they are the extension surface for OpenAPI/MCP/future plugins. --- NpgsqlRest/Defaults/DefaultCommentParser.cs | 42 +++++++++--- NpgsqlRest/IEndpointCreateHandler.cs | 17 +++++ NpgsqlRest/RoutineEndpoint.cs | 36 +++++++++++ .../UnhandledCommentLinesTests.cs | 59 +++++++++++++++++ .../Setup/UnhandledCommentLinesTestFixture.cs | 64 +++++++++++++++++++ 5 files changed, 208 insertions(+), 10 deletions(-) create mode 100644 NpgsqlRestTests/CommentTests/UnhandledCommentLinesTests.cs create mode 100644 NpgsqlRestTests/Setup/UnhandledCommentLinesTestFixture.cs diff --git a/NpgsqlRest/Defaults/DefaultCommentParser.cs b/NpgsqlRest/Defaults/DefaultCommentParser.cs index c68d279b..37ec279d 100644 --- a/NpgsqlRest/Defaults/DefaultCommentParser.cs +++ b/NpgsqlRest/Defaults/DefaultCommentParser.cs @@ -42,6 +42,10 @@ internal static partial class DefaultCommentParser string[] lines = comment.Split(NewlineSeparator, StringSplitOptions.RemoveEmptyEntries); routineEndpoint.CommentWordLines = new string[lines.Length][]; bool hasHttpTag = false; + // Accumulates comment lines that are NOT recognized as built-in directives. Exposed via + // RoutineEndpoint.UnhandledCommentLines for plugins (e.g. MCP) to parse their own + // annotations and/or derive a human description. Core attaches no meaning to them. + List? unhandledCommentLines = null; for (var i = 0; i < lines.Length; i++) { string line = lines[i].Trim(); @@ -112,16 +116,6 @@ internal static partial class DefaultCommentParser TrackAnnotation(line); } - // openapi - // openapi hide | hidden | ignore - // openapi tag tag1, tag2, ... - // openapi tags tag1, tag2, ... - else if (haveTag is true && StrEquals(wordsLower[0], OpenApiKey)) - { - HandleOpenApi(routineEndpoint, wordsLower, words, len, description); - TrackAnnotation(line); - } - // HTTP // HTTP [ GET | POST | PUT | DELETE ] // HTTP [ GET | POST | PUT | DELETE ] path @@ -509,7 +503,35 @@ internal static partial class DefaultCommentParser HandleNestedJson(routineEndpoint, description); TrackAnnotation(line); } + else + { + // Not a built-in directive — offer it to plugin comment handlers in a single + // pass (no second iteration / re-tokenization per plugin). First handler to + // claim it (non-null label) wins; core logs it centrally, consistent with + // built-in annotation logging. If no handler claims it, it's prose → surfaced + // via RoutineEndpoint.UnhandledCommentLines. + string? pluginLabel = null; + foreach (var handler in Options.EndpointCreateHandlers) + { + pluginLabel = handler.HandleCommentLine(routineEndpoint, line, words, wordsLower); + if (pluginLabel is not null) + { + break; + } + } + if (pluginLabel is not null) + { + CommentLogger?.LogTrace("Plugin comment annotation '{Label}' for {Description}", pluginLabel, description); + } + else + { + (unhandledCommentLines ??= []).Add(line); + } + } } + + routineEndpoint.UnhandledCommentLines = unhandledCommentLines?.ToArray(); + if (disabled) { Logger?.CommentDisabled(description); diff --git a/NpgsqlRest/IEndpointCreateHandler.cs b/NpgsqlRest/IEndpointCreateHandler.cs index 055ed3eb..a50d82c2 100644 --- a/NpgsqlRest/IEndpointCreateHandler.cs +++ b/NpgsqlRest/IEndpointCreateHandler.cs @@ -9,6 +9,23 @@ public interface IEndpointCreateHandler /// current NpgsqlRest options void Setup(IApplicationBuilder builder, NpgsqlRestOptions options) { } + /// + /// Called by the core comment parser, within its single parse pass, for each comment line + /// that core did NOT recognize as a built-in directive. Lets a plugin claim its own + /// annotations (e.g. openapi …, mcp …) without a second pass over the comment. + /// + /// If this plugin owns the line, apply it (typically by storing into + /// ) and return a short label describing what was set — + /// core logs it centrally (consistent with built-in annotation logging) and stops offering + /// the line to other handlers. Return null if the line is not this plugin's + /// annotation; it is then offered to the next handler, and finally surfaced via + /// (prose) if no handler claims it. + /// + /// / are pre-tokenized by core (split + /// on space/comma; the lower-cased variant for keyword matching) so handlers do not re-tokenize. + /// + string? HandleCommentLine(RoutineEndpoint endpoint, string line, string[] words, string[] wordsLower) => null; + /// /// After successful endpoint creation. /// diff --git a/NpgsqlRest/RoutineEndpoint.cs b/NpgsqlRest/RoutineEndpoint.cs index c259c31c..5c750d41 100644 --- a/NpgsqlRest/RoutineEndpoint.cs +++ b/NpgsqlRest/RoutineEndpoint.cs @@ -137,6 +137,42 @@ public string? BodyParameterName /// (InternalRequestHandler). It is NOT registered as an HTTP route. /// public bool InternalOnly { get; set; } = false; + + /// + /// Comment lines that were NOT recognized as built-in NpgsqlRest directives, in order, with + /// original case (trimmed). Null when the comment had no such lines. This is the extension point + /// for plugins (e.g. NpgsqlRest.Mcp): a plugin parses its own annotations out of these lines in + /// its endpoint-create handler, and treats the remainder as the human-readable description. + /// Core itself attaches no meaning to these lines. + /// + public string[]? UnhandledCommentLines { get; set; } = null; + + private Dictionary? _items; + + /// + /// Generic per-endpoint property bag for plugin-attached metadata, namespaced by key + /// (e.g. "mcp", "openapi"). Core attaches no meaning to its contents — it is the typed + /// extension point for plugins (parse from in an + /// endpoint-create handler, stash the result here). Populated at build time, read-only at + /// runtime. Lazily allocated, so endpoints with no plugin metadata cost nothing. + /// + public IDictionary Items => _items ??= new(StringComparer.Ordinal); + + /// + /// Non-allocating read of an entry. Returns false (without allocating the + /// bag) when no items have been stored. Use this for reads on the hot/common path so endpoints + /// with no plugin metadata cost nothing. + /// + public bool TryGetItem(string key, out object? value) + { + if (_items is not null) + { + return _items.TryGetValue(key, out value); + } + value = null; + return false; + } + /// /// When true, encrypt ALL text parameters using the default data protector. /// diff --git a/NpgsqlRestTests/CommentTests/UnhandledCommentLinesTests.cs b/NpgsqlRestTests/CommentTests/UnhandledCommentLinesTests.cs new file mode 100644 index 00000000..8e8b8a81 --- /dev/null +++ b/NpgsqlRestTests/CommentTests/UnhandledCommentLinesTests.cs @@ -0,0 +1,59 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +public static partial class Database +{ + // Test functions for the neutral UnhandledCommentLines core feature, in an isolated `cmt` + // schema (the shared Program/IncludeSchemas list excludes `cmt`, so these never appear elsewhere). + public static void UnhandledCommentLinesTests() + { + script.Append(@" +create schema if not exists cmt; + +-- prose + an unknown (plugin-style) annotation are unhandled; HTTP and authorize are consumed +create function cmt.unhandled_mixed() returns text language sql as 'select ''mixed'''; +comment on function cmt.unhandled_mixed() is ' +HTTP GET +This is a human description. +@custom_plugin_flag +authorize'; + +-- only built-in directives -> nothing unhandled +create function cmt.unhandled_none() returns text language sql as 'select ''none'''; +comment on function cmt.unhandled_none() is ' +HTTP GET +authorize'; +"); + } +} + +[Collection("UnhandledCommentLinesFixture")] +public class UnhandledCommentLinesTests(UnhandledCommentLinesTestFixture test) +{ + [Fact] + public void Unhandled_lines_capture_prose_and_unknown_annotations_in_order() + { + var e = test.Endpoints["unhandled_mixed"]; + e.UnhandledCommentLines.Should().Equal("This is a human description.", "@custom_plugin_flag"); + } + + [Fact] + public void Built_in_directives_are_not_unhandled() + { + var e = test.Endpoints["unhandled_none"]; + e.UnhandledCommentLines.Should().BeNull(); + } + + [Fact] + public void Items_property_bag_is_writable_and_round_trips_typed_values() + { + var e = test.Endpoints["unhandled_mixed"]; + e.Items["test:flag"] = true; + e.Items["test:tags"] = new[] { "a", "b" }; + + e.Items.TryGetValue("test:flag", out var flag).Should().BeTrue(); + flag.Should().Be(true); + (e.Items["test:tags"] as string[]).Should().Equal("a", "b"); + } +} diff --git a/NpgsqlRestTests/Setup/UnhandledCommentLinesTestFixture.cs b/NpgsqlRestTests/Setup/UnhandledCommentLinesTestFixture.cs new file mode 100644 index 00000000..608fa7d2 --- /dev/null +++ b/NpgsqlRestTests/Setup/UnhandledCommentLinesTestFixture.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; + +namespace NpgsqlRestTests.Setup; + +[CollectionDefinition("UnhandledCommentLinesFixture")] +public class UnhandledCommentLinesFixtureCollection : ICollectionFixture { } + +/// +/// Fixture for the neutral RoutineEndpoint.UnhandledCommentLines core feature — the comment lines +/// that core did NOT recognize as built-in directives, exposed for plugins (e.g. NpgsqlRest.Mcp) to +/// parse their own annotations and/or derive a description. Scoped to an isolated `cmt` schema +/// (excluded from every other fixture's schema list) and captures parsed endpoints via the +/// EndpointsCreated callback so tests can assert the metadata directly. +/// +public class UnhandledCommentLinesTestFixture : IDisposable +{ + private readonly WebApplication _app; + private readonly HttpClient _client; + + public HttpClient Client => _client; + + /// Fully-parsed endpoints captured at build time, keyed by routine name. + public Dictionary Endpoints { get; } = new(StringComparer.Ordinal); + + public UnhandledCommentLinesTestFixture() + { + Database.Create(); + var connectionString = Database.CreateAdditional("unhandled_comment_lines_test"); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); // random available port + + _app = builder.Build(); + + _app.UseNpgsqlRest(new NpgsqlRestOptions(connectionString) + { + IncludeSchemas = ["cmt"], + CommentsMode = CommentsMode.ParseAll, + EndpointsCreated = endpoints => + { + foreach (var endpoint in endpoints) + { + Endpoints[endpoint.Routine.Name] = endpoint; + } + } + }); + + _app.StartAsync().GetAwaiter().GetResult(); + + var serverAddress = _app.Urls.First(); + _client = new HttpClient { BaseAddress = new Uri(serverAddress) }; + _client.Timeout = TimeSpan.FromHours(1); + } + +#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize + public void Dispose() +#pragma warning restore CA1816 // Dispose methods should call SuppressFinalize + { + _client.Dispose(); + _app.StopAsync().GetAwaiter().GetResult(); + _app.DisposeAsync().GetAwaiter().GetResult(); + } +} From 0517ef716908ed4e6c04c89ccc750c54b8284651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Mon, 8 Jun 2026 11:09:41 +0200 Subject: [PATCH 07/38] =?UTF-8?q?refactor:=20v3.17.0=20=E2=80=94=20move=20?= =?UTF-8?q?OpenAPI=20annotations=20off=20core=20RoutineEndpoint=20into=20t?= =?UTF-8?q?he=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING (C# API only): removes public RoutineEndpoint.OpenApiHide / OpenApiTags and the core 'openapi' comment-annotation handler. The OpenApi plugin now parses 'openapi hide' / 'openapi tag' itself via IEndpointCreateHandler.HandleCommentLine (core's single parse pass) and stores the result in RoutineEndpoint.Items. - No change for annotation users: the 'openapi ...' comment annotations behave as before (now effective only when the OpenApi plugin is loaded). - Affected only if you set endpoint.OpenApiHide / OpenApiTags in code (e.g. via the EndpointCreated callback) — use the 'openapi' comment annotation instead. Core is now OpenAPI-agnostic. --- .../Defaults/CommentParsers/OpenApiHandler.cs | 72 ------------------- NpgsqlRest/RoutineEndpoint.cs | 15 ---- .../OpenApiTests/OpenApiFilterTests.cs | 15 +++- changelog/v3.17.0.md | 12 ++++ .../NpgsqlRest.OpenApi.csproj | 9 +++ plugins/NpgsqlRest.OpenApi/OpenApi.cs | 56 ++++++++++++++- 6 files changed, 88 insertions(+), 91 deletions(-) delete mode 100644 NpgsqlRest/Defaults/CommentParsers/OpenApiHandler.cs diff --git a/NpgsqlRest/Defaults/CommentParsers/OpenApiHandler.cs b/NpgsqlRest/Defaults/CommentParsers/OpenApiHandler.cs deleted file mode 100644 index 50b1640e..00000000 --- a/NpgsqlRest/Defaults/CommentParsers/OpenApiHandler.cs +++ /dev/null @@ -1,72 +0,0 @@ -namespace NpgsqlRest.Defaults; - -internal static partial class DefaultCommentParser -{ - /// - /// Annotation: openapi - /// Syntax: openapi hide - /// openapi hidden - /// openapi ignore - /// openapi tag [tag1, tag2, tag3 [, ...]] - /// openapi tags [tag1, tag2, tag3 [, ...]] - /// - /// Description: Per-routine controls for the OpenAPI document. Consumed by the - /// NpgsqlRest.OpenApi plugin; safe to leave on a routine even when the plugin is not loaded - /// (the annotation simply sets properties on that the plugin reads). - /// - /// hide / hidden / ignore — exclude this routine from the OpenAPI document - /// while keeping the HTTP endpoint fully functional. Use to keep internal-only endpoints out of a - /// document published to partners. - /// - /// tag / tags — override the default schema-name tag. Multiple tags can be supplied - /// comma-separated. Tags drive grouping in tools like Swagger UI / ReDoc, so this is the natural - /// way to slice "partner-facing" vs "internal" endpoints in a single document. - /// - private const string OpenApiKey = "openapi"; - private static readonly string[] OpenApiHideSubKey = ["hide", "hidden", "ignore"]; - private static readonly string[] OpenApiTagSubKey = ["tag", "tags"]; - - private static void HandleOpenApi( - RoutineEndpoint endpoint, - string[] wordsLower, - string[] words, - int len, - string description) - { - // Bare `openapi` (no sub-command) is treated as `openapi hide` — the path of least surprise for - // a routine the author wants out of the docs entirely. - if (len < 2) - { - endpoint.OpenApiHide = true; - CommentLogger?.LogTrace("Endpoint {Description} marked as OpenAPI-hidden (bare `openapi` annotation)", description); - return; - } - - var sub = wordsLower[1]; - if (StrEqualsToArray(sub, OpenApiHideSubKey)) - { - endpoint.OpenApiHide = true; - CommentLogger?.LogTrace("Endpoint {Description} marked as OpenAPI-hidden", description); - } - else if (StrEqualsToArray(sub, OpenApiTagSubKey)) - { - if (len < 3) - { - CommentLogger?.LogWarning( - "Endpoint {Description}: `openapi {Sub}` requires at least one tag value — ignored.", - description, sub); - return; - } - // Preserve original casing for tag values. wordsLower is lowercased; words is original. - endpoint.OpenApiTags = [.. words[2..]]; - CommentLogger?.LogTrace("Endpoint {Description} OpenAPI tags set: {Tags}", - description, string.Join(", ", endpoint.OpenApiTags)); - } - else - { - CommentLogger?.LogWarning( - "Endpoint {Description}: unknown `openapi {Sub}` sub-command — expected `hide` or `tag `.", - description, sub); - } - } -} diff --git a/NpgsqlRest/RoutineEndpoint.cs b/NpgsqlRest/RoutineEndpoint.cs index 5c750d41..c248f140 100644 --- a/NpgsqlRest/RoutineEndpoint.cs +++ b/NpgsqlRest/RoutineEndpoint.cs @@ -194,21 +194,6 @@ public bool TryGetItem(string key, out object? value) /// Instead of executing the routine, it removes the cached entry for the given parameters. /// public bool InvalidateCache { get; set; } = false; - /// - /// When true, this endpoint is excluded from the OpenAPI document produced by the - /// NpgsqlRest.OpenApi plugin. The HTTP endpoint itself is unaffected — only its appearance - /// in the generated spec. Set via the openapi hide / openapi hidden / openapi ignore - /// comment annotation. Ignored when the OpenAPI plugin is not loaded. - /// - public bool OpenApiHide { get; set; } = false; - /// - /// Tags emitted on this endpoint in the OpenAPI document. When null, the plugin defaults to the - /// routine's schema name (existing behavior). When set (via openapi tag <name> / - /// openapi tags <a>,<b>), these values replace the default. Drives grouping in - /// Swagger UI / ReDoc, so this is the lever for "partner" vs "internal" sections in a shared - /// document. Ignored when the OpenAPI plugin is not loaded. - /// - public string[]? OpenApiTags { get; set; } = null; /// /// Dictionary of parameter names to SQL expressions that resolve their values server-side. diff --git a/NpgsqlRestTests/OpenApiTests/OpenApiFilterTests.cs b/NpgsqlRestTests/OpenApiTests/OpenApiFilterTests.cs index 07b82e8b..6f68d4b5 100644 --- a/NpgsqlRestTests/OpenApiTests/OpenApiFilterTests.cs +++ b/NpgsqlRestTests/OpenApiTests/OpenApiFilterTests.cs @@ -91,8 +91,19 @@ private static RoutineEndpoint MakeEndpoint( bodyParameterName: null, textResponseNullHandling: TextResponseNullHandling.EmptyString, queryStringNullHandling: QueryStringNullHandling.EmptyString); - endpoint.OpenApiHide = openApiHide; - endpoint.OpenApiTags = openApiTags; + // openapi hide/tags are parsed by the plugin's HandleCommentLine hook (which core invokes + // during its single parse pass) and stashed in endpoint.Items. Drive that hook directly so + // the test exercises the real parse path rather than pre-seeding Items. + var plugin = new OpenApi(new OpenApiOptions()); + if (openApiHide) + { + plugin.HandleCommentLine(endpoint, "openapi hide", ["openapi", "hide"], ["openapi", "hide"]); + } + if (openApiTags is { Length: > 0 }) + { + var words = new[] { "openapi", "tag" }.Concat(openApiTags).ToArray(); + plugin.HandleCommentLine(endpoint, "openapi tag " + string.Join(", ", openApiTags), words, words.Select(w => w.ToLowerInvariant()).ToArray()); + } return endpoint; } diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index a48d5a59..553eb121 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -12,6 +12,18 @@ The rolled-up bug-fixes: JSON command parameters now accept `json`/`jsonb`/`text ## What changed +### Plugin extension points on `RoutineEndpoint` (new) + OpenAPI annotation handling (⚠️ breaking API) + +Neutral, plugin-facing extension points were added so plugins can own their comment annotations without leaking plugin concepts into core, and without each plugin re-walking the comment: + +- **`IEndpointCreateHandler.HandleCommentLine(...)`** (new default-interface method) — core invokes it, in its single comment-parse pass, for each line it doesn't recognize as a built-in directive. A plugin claims its own annotation (returns a log label; core logs it centrally — consistent with built-in annotation logging) or returns null. Tokens are pre-split by core, so plugins don't re-tokenize. Non-breaking (default method). +- **`RoutineEndpoint.Items`** (`IDictionary`, lazy) + **`TryGetItem`** (non-allocating read) — a generic per-endpoint property bag for plugin-attached metadata, namespaced by key (e.g. `"openapi:hide"`, `"mcp"`). The `HttpContext.Items` pattern. Plugins parse in `HandleCommentLine` and stash the typed result here. +- **`RoutineEndpoint.UnhandledCommentLines`** (`string[]?`) — comment lines neither core nor any plugin handler claimed (i.e. prose), original case, in order. Plugins use it to derive a human description. Core attaches no meaning. + +**⚠️ Breaking (C# API only):** the public properties **`RoutineEndpoint.OpenApiHide` and `RoutineEndpoint.OpenApiTags` were removed**, along with the core `openapi` comment-annotation handler. The OpenAPI plugin now parses the `openapi hide` / `openapi hidden` / `openapi ignore` / `openapi tag <…>` annotations itself (from `UnhandledCommentLines`). +- **No change for annotation users:** the `openapi …` comment annotations behave exactly as before (only effective when the OpenAPI plugin is loaded — previously they set core properties regardless). +- **Affected only if** you set `endpoint.OpenApiHide` / `endpoint.OpenApiTags` in code (e.g. via the `EndpointCreated` callback). Use the `openapi` comment annotation instead. + ### JSON command parameters accept `json`, `jsonb`, or `text` Several places bind a JSON string into a user-configured SQL command and previously forced the receiving parameter to be `json`: diff --git a/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj b/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj index 52957486..33e69fbc 100644 --- a/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj +++ b/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj @@ -61,4 +61,13 @@ + + + + + Common\%(Filename)%(Extension) + + diff --git a/plugins/NpgsqlRest.OpenApi/OpenApi.cs b/plugins/NpgsqlRest.OpenApi/OpenApi.cs index ccdd94d8..bed953d1 100644 --- a/plugins/NpgsqlRest.OpenApi/OpenApi.cs +++ b/plugins/NpgsqlRest.OpenApi/OpenApi.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using System.Text.RegularExpressions; using Npgsql; +using NpgsqlRest.Common; using static NpgsqlRest.NpgsqlRestOptions; namespace NpgsqlRest.OpenAPI; @@ -115,11 +116,16 @@ private static Regex SimilarToRegex(string pattern) public void Handle(RoutineEndpoint endpoint) { + // openapi hide/tags were parsed during core's single comment pass (HandleCommentLine) and + // stashed in Items. Core is OpenAPI-agnostic. + var openApiHide = endpoint.TryGetItem(ItemHide, out var hideVal) && hideVal is true; + var openApiTags = endpoint.TryGetItem(ItemTags, out var tagsVal) ? tagsVal as string[] : null; + // Filter gate — applied in order of decreasing specificity. First match short-circuits. // The endpoint itself remains registered with NpgsqlRest; only its documentation is skipped. // Per-endpoint opt-out from comment annotation (`openapi hide`). - if (endpoint.OpenApiHide) + if (openApiHide) { return; } @@ -179,7 +185,7 @@ public void Handle(RoutineEndpoint endpoint) // Tag selection: explicit `openapi tag/tags` annotation values win over the default schema // grouping. Drives the section headings in Swagger UI / ReDoc. - if (endpoint.OpenApiTags is { Length: > 0 } customTags) + if (openApiTags is { Length: > 0 } customTags) { var tagsArray = new JsonArray(); foreach (var tag in customTags) @@ -453,6 +459,52 @@ public void Handle(RoutineEndpoint endpoint) pathItem![method] = operation; } + private const string ItemHide = "openapi:hide"; + private const string ItemTags = "openapi:tags"; + + /// + /// Claims the `openapi` comment annotations during core's single parse pass and stashes the + /// result in RoutineEndpoint.Items (read later in Handle): + /// openapi -> hide (bare form) + /// openapi hide | hidden | ignore -> hide + /// openapi tag | tags a, b, ... -> tag overrides (original casing preserved) + /// Returns a log label when claimed, null otherwise. + /// + public string? HandleCommentLine(RoutineEndpoint endpoint, string line, string[] words, string[] wordsLower) + { + if (wordsLower.Length == 0 || !CommentPrimitives.StrEquals(wordsLower[0], "openapi")) + { + return null; + } + + if (wordsLower.Length < 2) + { + endpoint.Items[ItemHide] = true; // bare `openapi` + return "openapi hide"; + } + + var sub = wordsLower[1]; + if (CommentPrimitives.StrEqualsToArray(sub, "hide", "hidden", "ignore")) + { + endpoint.Items[ItemHide] = true; + return "openapi hide"; + } + + if (CommentPrimitives.StrEqualsToArray(sub, "tag", "tags")) + { + if (wordsLower.Length < 3) + { + return "openapi tag (ignored: no tag value)"; + } + var tags = words[2..]; // original casing for tag values + endpoint.Items[ItemTags] = tags; + return "openapi tags: " + string.Join(", ", tags); + } + + // Recognized `openapi` keyword but unknown sub-command — claim it so it isn't treated as prose. + return $"openapi (ignored: unknown sub-command '{sub}')"; + } + public void Cleanup() { if (openApiOptions.FileName is null && openApiOptions.UrlPath is null) From 3aaf68817d0e13d027148dc192f0fc8699b9bf4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Mon, 8 Jun 2026 14:16:50 +0200 Subject: [PATCH 08/38] =?UTF-8?q?feat:=20v3.17.0=20=E2=80=94=20CommentsMod?= =?UTF-8?q?e.OnlyAnnotated=20+=20exposure-aware=20comment=20hook;=20defaul?= =?UTF-8?q?t=20to=20OnlyAnnotated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes endpoint creation beyond HTTP, so plugin-annotated (e.g. mcp) routines can be created with no HTTP tag: - CommentsMode.OnlyAnnotated: create an endpoint when the comment has an HTTP tag OR a plugin requests one. OnlyWithHttpTag kept as a back-compat alias (identical behavior). - IEndpointCreateHandler.HandleCommentLine now returns CommentLineResult(Label, RequestsEndpoint). RequestsEndpoint=true (exposure intent, e.g. mcp) creates the endpoint even without an HTTP tag; modifiers (openapi hide, authorize) return false so they never spawn an endpoint by themselves. - OpenApi plugin adapted to CommentLineResult (modifiers); SqlFileSource pre-filter now treats OnlyAnnotated and OnlyWithHttpTag identically. - Client config now defaults to OnlyAnnotated (appsettings/template/defaults/Program); existing OnlyWithHttpTag configs are unaffected. Schema + docs updated. Full suite green. --- .../CommentParsers/AnnotationReference.cs | 2 +- NpgsqlRest/Defaults/DefaultCommentParser.cs | 22 ++++++++++----- NpgsqlRest/Enums.cs | 12 +++++++-- NpgsqlRest/IEndpointCreateHandler.cs | 27 ++++++++++++++----- NpgsqlRestClient/ConfigDefaults.cs | 4 +-- NpgsqlRestClient/ConfigSchemaGenerator.cs | 6 ++--- NpgsqlRestClient/ConfigTemplate.cs | 11 ++++---- NpgsqlRestClient/Program.cs | 2 +- NpgsqlRestClient/appsettings.json | 11 ++++---- changelog/v3.17.0.md | 3 ++- plugins/NpgsqlRest.OpenApi/OpenApi.cs | 15 ++++++----- .../NpgsqlRest.SqlFileSource/SqlFileSource.cs | 11 +++++--- 12 files changed, 83 insertions(+), 43 deletions(-) diff --git a/NpgsqlRest/Defaults/CommentParsers/AnnotationReference.cs b/NpgsqlRest/Defaults/CommentParsers/AnnotationReference.cs index 60e42f85..a9544173 100644 --- a/NpgsqlRest/Defaults/CommentParsers/AnnotationReference.cs +++ b/NpgsqlRest/Defaults/CommentParsers/AnnotationReference.cs @@ -26,7 +26,7 @@ static JsonArray ToJsonArray(string[] values) ["name"] = "http", ["aliases"] = ToJsonArray1(HttpKey), ["syntax"] = "http [GET|POST|PUT|DELETE] [path]", - ["description"] = "Enable endpoint and configure HTTP method and/or path. Required when CommentsMode is OnlyWithHttpTag." + ["description"] = "Enable endpoint and configure HTTP method and/or path. Required (for HTTP exposure) when CommentsMode is OnlyAnnotated (or its alias OnlyWithHttpTag)." }); annotations.Add((JsonNode)new JsonObject diff --git a/NpgsqlRest/Defaults/DefaultCommentParser.cs b/NpgsqlRest/Defaults/DefaultCommentParser.cs index 37ec279d..9b7cf9be 100644 --- a/NpgsqlRest/Defaults/DefaultCommentParser.cs +++ b/NpgsqlRest/Defaults/DefaultCommentParser.cs @@ -24,7 +24,7 @@ internal static partial class DefaultCommentParser bool haveTag = true; if (string.IsNullOrEmpty(comment)) { - if (Options.CommentsMode == CommentsMode.OnlyWithHttpTag) + if (Options.CommentsMode is CommentsMode.OnlyWithHttpTag or CommentsMode.OnlyAnnotated) { return null; } @@ -42,6 +42,9 @@ internal static partial class DefaultCommentParser string[] lines = comment.Split(NewlineSeparator, StringSplitOptions.RemoveEmptyEntries); routineEndpoint.CommentWordLines = new string[lines.Length][]; bool hasHttpTag = false; + // True when a plugin handler claims a line with RequestsEndpoint=true (exposure intent) — + // under OnlyWithHttpTag/OnlyAnnotated this creates the endpoint even with no HTTP tag. + bool anyHandlerRequestedEndpoint = false; // Accumulates comment lines that are NOT recognized as built-in directives. Exposed via // RoutineEndpoint.UnhandledCommentLines for plugins (e.g. MCP) to parse their own // annotations and/or derive a human description. Core attaches no meaning to them. @@ -510,18 +513,22 @@ internal static partial class DefaultCommentParser // claim it (non-null label) wins; core logs it centrally, consistent with // built-in annotation logging. If no handler claims it, it's prose → surfaced // via RoutineEndpoint.UnhandledCommentLines. - string? pluginLabel = null; + CommentLineResult? handled = null; foreach (var handler in Options.EndpointCreateHandlers) { - pluginLabel = handler.HandleCommentLine(routineEndpoint, line, words, wordsLower); - if (pluginLabel is not null) + handled = handler.HandleCommentLine(routineEndpoint, line, words, wordsLower); + if (handled is not null) { break; } } - if (pluginLabel is not null) + if (handled is { } result) { - CommentLogger?.LogTrace("Plugin comment annotation '{Label}' for {Description}", pluginLabel, description); + CommentLogger?.LogTrace("Plugin comment annotation '{Label}' for {Description}", result.Label, description); + if (result.RequestsEndpoint) + { + anyHandlerRequestedEndpoint = true; + } } else { @@ -537,7 +544,8 @@ internal static partial class DefaultCommentParser Logger?.CommentDisabled(description); return null; } - if (Options.CommentsMode == CommentsMode.OnlyWithHttpTag && !hasHttpTag) + if (Options.CommentsMode is CommentsMode.OnlyWithHttpTag or CommentsMode.OnlyAnnotated + && !hasHttpTag && !anyHandlerRequestedEndpoint) { return null; } diff --git a/NpgsqlRest/Enums.cs b/NpgsqlRest/Enums.cs index 58ea799d..9097e8b4 100644 --- a/NpgsqlRest/Enums.cs +++ b/NpgsqlRest/Enums.cs @@ -38,9 +38,17 @@ public enum CommentsMode /// ParseAll, /// - /// Creates only endpoints from routines containing a comment with HTTP tag and and configures endpoint meta data. + /// Legacy alias of , kept for back-compat. Behaves identically: an + /// endpoint is created when its comment has an HTTP tag OR a plugin requests an endpoint (e.g. `mcp`). /// - OnlyWithHttpTag + OnlyWithHttpTag, + /// + /// Creates only endpoints whose comment opts the routine in via a recognized EXPOSURE tag — an + /// HTTP tag (HTTP endpoint) or a plugin annotation that requests an endpoint (e.g. `mcp`, which can + /// be HTTP+MCP, or MCP-only when combined with `internal`). Transport-agnostic. Modifier-only + /// comments (e.g. just `authorize`, `cached`, or `openapi hide` on a non-HTTP routine) create nothing. + /// + OnlyAnnotated } public enum RequestHeadersMode diff --git a/NpgsqlRest/IEndpointCreateHandler.cs b/NpgsqlRest/IEndpointCreateHandler.cs index a50d82c2..78061c26 100644 --- a/NpgsqlRest/IEndpointCreateHandler.cs +++ b/NpgsqlRest/IEndpointCreateHandler.cs @@ -15,16 +15,23 @@ void Setup(IApplicationBuilder builder, NpgsqlRestOptions options) { } /// annotations (e.g. openapi …, mcp …) without a second pass over the comment. /// /// If this plugin owns the line, apply it (typically by storing into - /// ) and return a short label describing what was set — - /// core logs it centrally (consistent with built-in annotation logging) and stops offering - /// the line to other handlers. Return null if the line is not this plugin's - /// annotation; it is then offered to the next handler, and finally surfaced via + /// ) and return a with a + /// short label — core logs it centrally (consistent with built-in annotation logging) and + /// stops offering the line to other handlers. Return null if the line is not this + /// plugin's annotation; it is then offered to the next handler, and finally surfaced via /// (prose) if no handler claims it. /// /// / are pre-tokenized by core (split /// on space/comma; the lower-cased variant for keyword matching) so handlers do not re-tokenize. + /// + /// Set to true when the annotation means the + /// routine should be exposed as an endpoint (e.g. mcp) — under + /// this lets a routine with no HTTP tag still be + /// created. Leave it false for pure modifiers (e.g. openapi hide), which must NOT by + /// themselves cause an endpoint to be created. + /// /// - string? HandleCommentLine(RoutineEndpoint endpoint, string line, string[] words, string[] wordsLower) => null; + CommentLineResult? HandleCommentLine(RoutineEndpoint endpoint, string line, string[] words, string[] wordsLower) => null; /// /// After successful endpoint creation. @@ -41,4 +48,12 @@ void Cleanup(RoutineEndpoint[] endpoints) { } /// void Cleanup() { } } -} \ No newline at end of file + + /// + /// Result of when a plugin claims a comment + /// line. Label is a short description logged centrally by core (consistent with built-in + /// annotation logging). RequestsEndpoint = true signals the routine should be created as an + /// endpoint even without an HTTP tag (exposure intent); false = modifier only. + /// + public readonly record struct CommentLineResult(string Label, bool RequestsEndpoint = false); +} diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index 2c7c4dc9..dab3fbdc 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -765,7 +765,7 @@ private static JsonObject GetNpgsqlRestDefaults() ["NameNotSimilarTo"] = null, ["IncludeNames"] = null, ["ExcludeNames"] = null, - ["CommentsMode"] = "OnlyWithHttpTag", + ["CommentsMode"] = "OnlyAnnotated", ["UrlPathPrefix"] = "/api", ["KebabCaseUrls"] = true, ["CamelCaseNames"] = true, @@ -1048,7 +1048,7 @@ private static JsonObject GetSqlFileSourceDefaults() { ["Enabled"] = false, ["FilePattern"] = "", - ["CommentsMode"] = "OnlyWithHttpTag", + ["CommentsMode"] = "OnlyAnnotated", ["CommentScope"] = "All", ["ErrorMode"] = "Exit", ["ResultPrefix"] = "result", diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index 2fe640ad..2a46b950 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -309,7 +309,7 @@ public static partial class ConfigSchemaGenerator ["NpgsqlRest:NameNotSimilarTo"] = "Filter names NOT similar to this parameter or `null` to ignore this parameter.", ["NpgsqlRest:IncludeNames"] = "List of names to be included or `null` to ignore this parameter.", ["NpgsqlRest:ExcludeNames"] = "List of names to be excluded or `null` to ignore this parameter.", - ["NpgsqlRest:CommentsMode"] = "Configure how the comment annotations will behave. `Ignore` will create all endpoints and ignore comment annotations. `ParseAll` will create all endpoints and parse comment annotations to alter the endpoint. `OnlyWithHttpTag` (default) will only create endpoints that contain the `HTTP` tag in the comments and then parse comment annotations.", + ["NpgsqlRest:CommentsMode"] = "Configure how comment annotations behave and which routines become endpoints. `Ignore` creates all endpoints and ignores annotations. `ParseAll` creates all endpoints and parses annotations. `OnlyAnnotated` (default) creates only routines whose comment has a recognized exposure tag — an `HTTP` tag, or a plugin annotation that requests an endpoint (e.g. `mcp`); modifier-only comments (e.g. just `authorize`) create nothing. `OnlyWithHttpTag` is a backward-compatible alias of `OnlyAnnotated` (identical behavior; existing configs keep working).", ["NpgsqlRest:UrlPathPrefix"] = "The URL prefix string for every URL created by the default URL builder or `null` to ignore the URL prefix.", ["NpgsqlRest:KebabCaseUrls"] = "Convert all URL paths to kebab-case from the original PostgreSQL names.", ["NpgsqlRest:CamelCaseNames"] = "Convert all parameter names to camel case from the original PostgreSQL paramater names.", @@ -494,7 +494,7 @@ public static partial class ConfigSchemaGenerator ["NpgsqlRest:SqlFileSource"] = "SQL file source for generating REST API endpoints from .sql files.", ["NpgsqlRest:SqlFileSource:Enabled"] = "Enable or disable SQL file source endpoints. Default is false.", ["NpgsqlRest:SqlFileSource:FilePattern"] = "Glob pattern for SQL files, e.g. \"sql/**/*.sql\", \"queries/*.sql\".\nSupports * (any chars), ** (recursive, any including /), ? (single char).\nEmpty string disables the feature.", - ["NpgsqlRest:SqlFileSource:CommentsMode"] = "How comment annotations are processed for SQL file endpoints.\nPossible values: Ignore, ParseAll, OnlyWithHttpTag.\nDefault: OnlyWithHttpTag — SQL files must contain an explicit HTTP annotation (e.g., '-- HTTP GET') to become endpoints.", + ["NpgsqlRest:SqlFileSource:CommentsMode"] = "How comment annotations are processed for SQL file endpoints.\nPossible values: Ignore, ParseAll, OnlyAnnotated, OnlyWithHttpTag.\nDefault: OnlyAnnotated (OnlyWithHttpTag is a back-compat alias) — SQL files must contain an explicit HTTP annotation (e.g., '-- HTTP GET') to become endpoints.", ["NpgsqlRest:SqlFileSource:CommentScope"] = "Which comments in the SQL file to parse as annotations.\nPossible values: All (default — all comments), Header (only comments before the first statement).", ["NpgsqlRest:SqlFileSource:ErrorMode"] = "Behavior when a SQL file fails to parse or describe.\nPossible values: Exit (default — log error, exit process), Skip (log error, continue).", ["NpgsqlRest:SqlFileSource:ResultPrefix"] = "Prefix for result keys in multi-command JSON responses.\nDefault keys are \"result1\", \"result2\", etc. Override per-result with the positional @result annotation in the SQL file.", @@ -538,7 +538,7 @@ public static partial class ConfigSchemaGenerator ["CacheOptions:Type"] = ["Memory", "Redis", "Hybrid"], // NpgsqlRest core - ["NpgsqlRest:CommentsMode"] = ["Ignore", "ParseAll", "OnlyWithHttpTag"], + ["NpgsqlRest:CommentsMode"] = ["Ignore", "ParseAll", "OnlyAnnotated", "OnlyWithHttpTag"], ["NpgsqlRest:DefaultHttpMethod"] = ["GET", "PUT", "POST", "DELETE", "PATCH", "HEAD", "OPTIONS"], ["NpgsqlRest:DefaultRequestParamType"] = ["QueryString", "BodyJson"], ["NpgsqlRest:QueryStringNullHandling"] = ["Ignore", "EmptyString", "NullLiteral"], diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index f1036b81..8398a387 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -1884,9 +1884,9 @@ public static partial class ConfigSchemaGenerator // "ExcludeNames": null, // - // Configure how the comment annotations will behave. `Ignore` will create all endpoints and ignore comment annotations. `ParseAll` will create all endpoints and parse comment annotations to alter the endpoint. `OnlyWithHttpTag` (default) will only create endpoints that contain the `HTTP` tag in the comments and then parse comment annotations. + // Configure how comment annotations behave and which routines become endpoints. `Ignore` creates all endpoints and ignores annotations. `ParseAll` creates all endpoints and parses annotations. `OnlyAnnotated` (default) creates only routines whose comment has a recognized exposure tag — an `HTTP` tag, or a plugin annotation that requests an endpoint (e.g. `mcp`); modifier-only comments (e.g. just `authorize`) create nothing. `OnlyWithHttpTag` is a backward-compatible alias of `OnlyAnnotated` (identical behavior; existing configs keep working). // - "CommentsMode": "OnlyWithHttpTag", + "CommentsMode": "OnlyAnnotated", // // The URL prefix string for every URL created by the default URL builder or `null` to ignore the URL prefix. // @@ -2814,10 +2814,11 @@ public static partial class ConfigSchemaGenerator "FilePattern": "", // // How comment annotations are processed for SQL file endpoints. - // Possible values: Ignore, ParseAll, OnlyWithHttpTag. - // OnlyWithHttpTag requires an explicit HTTP annotation (e.g., "-- HTTP GET") in the file. + // Possible values: Ignore, ParseAll, OnlyAnnotated, OnlyWithHttpTag. + // OnlyAnnotated (default; OnlyWithHttpTag is a back-compat alias) requires an explicit + // HTTP annotation (e.g., "-- HTTP GET") for a SQL file to become an endpoint. // - "CommentsMode": "OnlyWithHttpTag", + "CommentsMode": "OnlyAnnotated", // // Which comments in the SQL file to parse as annotations. // Possible values: All (default), Header (only comments before the first statement). diff --git a/NpgsqlRestClient/Program.cs b/NpgsqlRestClient/Program.cs index 0d141a06..82bd5f72 100644 --- a/NpgsqlRestClient/Program.cs +++ b/NpgsqlRestClient/Program.cs @@ -450,7 +450,7 @@ DefaultRequestParamType = config.GetConfigEnum("DefaultRequestParamType", config.NpgsqlRestCfg), QueryStringNullHandling = config.GetConfigEnum("QueryStringNullHandling", config.NpgsqlRestCfg) ?? QueryStringNullHandling.Ignore, TextResponseNullHandling = config.GetConfigEnum("TextResponseNullHandling", config.NpgsqlRestCfg) ?? TextResponseNullHandling.EmptyString, - CommentsMode = config.GetConfigEnum("CommentsMode", config.NpgsqlRestCfg) ?? CommentsMode.OnlyWithHttpTag, + CommentsMode = config.GetConfigEnum("CommentsMode", config.NpgsqlRestCfg) ?? CommentsMode.OnlyAnnotated, RequestHeadersMode = config.GetConfigEnum("RequestHeadersMode", config.NpgsqlRestCfg) ?? RequestHeadersMode.Parameter, RequestHeadersContextKey = config.GetConfigStr("RequestHeadersContextKey", config.NpgsqlRestCfg) ?? "request.headers", RequestHeadersParameterName = config.GetConfigStr("RequestHeadersParameterName", config.NpgsqlRestCfg) ?? "_headers", diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index 42251d47..4d9bcf7a 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -1875,9 +1875,9 @@ // "ExcludeNames": null, // - // Configure how the comment annotations will behave. `Ignore` will create all endpoints and ignore comment annotations. `ParseAll` will create all endpoints and parse comment annotations to alter the endpoint. `OnlyWithHttpTag` (default) will only create endpoints that contain the `HTTP` tag in the comments and then parse comment annotations. + // Configure how comment annotations behave and which routines become endpoints. `Ignore` creates all endpoints and ignores annotations. `ParseAll` creates all endpoints and parses annotations. `OnlyAnnotated` (default) creates only routines whose comment has a recognized exposure tag — an `HTTP` tag, or a plugin annotation that requests an endpoint (e.g. `mcp`); modifier-only comments (e.g. just `authorize`) create nothing. `OnlyWithHttpTag` is a backward-compatible alias of `OnlyAnnotated` (identical behavior; existing configs keep working). // - "CommentsMode": "OnlyWithHttpTag", + "CommentsMode": "OnlyAnnotated", // // The URL prefix string for every URL created by the default URL builder or `null` to ignore the URL prefix. // @@ -2805,10 +2805,11 @@ "FilePattern": "", // // How comment annotations are processed for SQL file endpoints. - // Possible values: Ignore, ParseAll, OnlyWithHttpTag. - // OnlyWithHttpTag requires an explicit HTTP annotation (e.g., "-- HTTP GET") in the file. + // Possible values: Ignore, ParseAll, OnlyAnnotated, OnlyWithHttpTag. + // OnlyAnnotated (default; OnlyWithHttpTag is a back-compat alias) requires an explicit + // HTTP annotation (e.g., "-- HTTP GET") for a SQL file to become an endpoint. // - "CommentsMode": "OnlyWithHttpTag", + "CommentsMode": "OnlyAnnotated", // // Which comments in the SQL file to parse as annotations. // Possible values: All (default), Header (only comments before the first statement). diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index 553eb121..879b76c8 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -16,9 +16,10 @@ The rolled-up bug-fixes: JSON command parameters now accept `json`/`jsonb`/`text Neutral, plugin-facing extension points were added so plugins can own their comment annotations without leaking plugin concepts into core, and without each plugin re-walking the comment: -- **`IEndpointCreateHandler.HandleCommentLine(...)`** (new default-interface method) — core invokes it, in its single comment-parse pass, for each line it doesn't recognize as a built-in directive. A plugin claims its own annotation (returns a log label; core logs it centrally — consistent with built-in annotation logging) or returns null. Tokens are pre-split by core, so plugins don't re-tokenize. Non-breaking (default method). +- **`IEndpointCreateHandler.HandleCommentLine(...)`** (new default-interface method) — core invokes it, in its single comment-parse pass, for each line it doesn't recognize as a built-in directive. A plugin claims its own annotation by returning a **`CommentLineResult`** (a log label core logs centrally + `RequestsEndpoint`); returns null to pass. Tokens are pre-split by core, so plugins don't re-tokenize. Non-breaking (default method). - **`RoutineEndpoint.Items`** (`IDictionary`, lazy) + **`TryGetItem`** (non-allocating read) — a generic per-endpoint property bag for plugin-attached metadata, namespaced by key (e.g. `"openapi:hide"`, `"mcp"`). The `HttpContext.Items` pattern. Plugins parse in `HandleCommentLine` and stash the typed result here. - **`RoutineEndpoint.UnhandledCommentLines`** (`string[]?`) — comment lines neither core nor any plugin handler claimed (i.e. prose), original case, in order. Plugins use it to derive a human description. Core attaches no meaning. +- **`CommentsMode.OnlyAnnotated`** (new) — transport-agnostic successor to `OnlyWithHttpTag`: creates an endpoint when its comment has an HTTP tag **or** a plugin requests one (via `CommentLineResult.RequestsEndpoint`, e.g. `mcp`). Lets an `mcp`-annotated routine be exposed as an MCP tool with no HTTP route, while modifier-only comments (e.g. just `authorize` or `openapi hide`) create nothing. The NpgsqlRest **client now defaults to `OnlyAnnotated`** (both `NpgsqlRest:CommentsMode` and `NpgsqlRest:SqlFileSource:CommentsMode`); existing configs using `OnlyWithHttpTag` are unaffected — it is kept as a back-compat alias with identical behavior. **⚠️ Breaking (C# API only):** the public properties **`RoutineEndpoint.OpenApiHide` and `RoutineEndpoint.OpenApiTags` were removed**, along with the core `openapi` comment-annotation handler. The OpenAPI plugin now parses the `openapi hide` / `openapi hidden` / `openapi ignore` / `openapi tag <…>` annotations itself (from `UnhandledCommentLines`). - **No change for annotation users:** the `openapi …` comment annotations behave exactly as before (only effective when the OpenAPI plugin is loaded — previously they set core properties regardless). diff --git a/plugins/NpgsqlRest.OpenApi/OpenApi.cs b/plugins/NpgsqlRest.OpenApi/OpenApi.cs index bed953d1..d3f93a37 100644 --- a/plugins/NpgsqlRest.OpenApi/OpenApi.cs +++ b/plugins/NpgsqlRest.OpenApi/OpenApi.cs @@ -468,9 +468,10 @@ public void Handle(RoutineEndpoint endpoint) /// openapi -> hide (bare form) /// openapi hide | hidden | ignore -> hide /// openapi tag | tags a, b, ... -> tag overrides (original casing preserved) - /// Returns a log label when claimed, null otherwise. + /// Returns a result when claimed (RequestsEndpoint stays false — these are document-only + /// modifiers and must NOT by themselves create an endpoint), null otherwise. /// - public string? HandleCommentLine(RoutineEndpoint endpoint, string line, string[] words, string[] wordsLower) + public CommentLineResult? HandleCommentLine(RoutineEndpoint endpoint, string line, string[] words, string[] wordsLower) { if (wordsLower.Length == 0 || !CommentPrimitives.StrEquals(wordsLower[0], "openapi")) { @@ -480,29 +481,29 @@ public void Handle(RoutineEndpoint endpoint) if (wordsLower.Length < 2) { endpoint.Items[ItemHide] = true; // bare `openapi` - return "openapi hide"; + return new CommentLineResult("openapi hide"); } var sub = wordsLower[1]; if (CommentPrimitives.StrEqualsToArray(sub, "hide", "hidden", "ignore")) { endpoint.Items[ItemHide] = true; - return "openapi hide"; + return new CommentLineResult("openapi hide"); } if (CommentPrimitives.StrEqualsToArray(sub, "tag", "tags")) { if (wordsLower.Length < 3) { - return "openapi tag (ignored: no tag value)"; + return new CommentLineResult("openapi tag (ignored: no tag value)"); } var tags = words[2..]; // original casing for tag values endpoint.Items[ItemTags] = tags; - return "openapi tags: " + string.Join(", ", tags); + return new CommentLineResult("openapi tags: " + string.Join(", ", tags)); } // Recognized `openapi` keyword but unknown sub-command — claim it so it isn't treated as prose. - return $"openapi (ignored: unknown sub-command '{sub}')"; + return new CommentLineResult($"openapi (ignored: unknown sub-command '{sub}')"); } public void Cleanup() diff --git a/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs b/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs index de780d1a..b8daf2b8 100644 --- a/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs +++ b/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs @@ -94,9 +94,14 @@ private static (Routine, IRoutineSourceParameterFormatter)? ProcessFile( var parseResult = SqlFileParser.Parse(content, options.CommentScope); - // When CommentsMode is OnlyWithHttpTag, skip files that don't have an HTTP tag - // BEFORE attempting to describe — avoids errors on non-endpoint SQL files (migrations, utility scripts, etc.) - if (options.CommentsMode == NpgsqlRest.CommentsMode.OnlyWithHttpTag && !HasHttpTag(parseResult.Comment)) + // When CommentsMode gates endpoint creation (OnlyAnnotated, or its back-compat alias + // OnlyWithHttpTag), skip files without an HTTP tag BEFORE attempting to describe — avoids + // errors on non-endpoint SQL files (migrations, utility scripts, etc.). + // NOTE: this pre-filter is HTTP-tag-only, so a SQL file exposed solely via a non-HTTP plugin + // annotation (e.g. `mcp` with no HTTP tag) is currently skipped here. Revisit if/when SQL-file + // routines need to be MCP-only. + if (options.CommentsMode is NpgsqlRest.CommentsMode.OnlyWithHttpTag or NpgsqlRest.CommentsMode.OnlyAnnotated + && !HasHttpTag(parseResult.Comment)) { return null; } From 0c1b584e95c45dfff21f65057c1eb865206137f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Mon, 8 Jun 2026 14:17:02 +0200 Subject: [PATCH 09/38] =?UTF-8?q?feat:=20v3.17.0=20=E2=80=94=20NpgsqlRest.?= =?UTF-8?q?Mcp=20plugin:=20@mcp=20annotation=20projection=20(increment=201?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New plugin (IEndpointCreateHandler) that claims the mcp comment annotations during core's single parse pass and records per-endpoint metadata for later catalog/serving: - mcp opt the routine in as an MCP tool (description derived from prose) - mcp opt in; is the tool description (overrides prose) - mcp_name name explicit tool name - MCP-only (no HTTP route) is composed with the core 'internal' annotation. HandleCommentLine stores McpToolInfo in RoutineEndpoint.Items["mcp"] and returns RequestsEndpoint=true so an mcp-only routine is created under OnlyAnnotated. Catalog (tools/list) and the JSON-RPC /mcp endpoint come in later increments. Adds the project to the solution + test project; McpPluginTestFixture (OnlyAnnotated, Mcp + OpenApi handlers) + tests covering opt-in, inline description, tool name, mcp+internal MCP-only, and that modifier-only comments create no endpoint. --- NpgsqlRest.sln | 91 ++++++++++++ .../McpTests/McpPluginAnnotationTests.cs | 132 ++++++++++++++++++ NpgsqlRestTests/NpgsqlRestTests.csproj | 1 + NpgsqlRestTests/Setup/McpPluginTestFixture.cs | 71 ++++++++++ plugins/NpgsqlRest.Mcp/Mcp.cs | 84 +++++++++++ plugins/NpgsqlRest.Mcp/McpOptions.cs | 25 ++++ plugins/NpgsqlRest.Mcp/McpToolInfo.cs | 20 +++ plugins/NpgsqlRest.Mcp/NpgsqlRest.Mcp.csproj | 55 ++++++++ 8 files changed, 479 insertions(+) create mode 100644 NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs create mode 100644 NpgsqlRestTests/Setup/McpPluginTestFixture.cs create mode 100644 plugins/NpgsqlRest.Mcp/Mcp.cs create mode 100644 plugins/NpgsqlRest.Mcp/McpOptions.cs create mode 100644 plugins/NpgsqlRest.Mcp/McpToolInfo.cs create mode 100644 plugins/NpgsqlRest.Mcp/NpgsqlRest.Mcp.csproj diff --git a/NpgsqlRest.sln b/NpgsqlRest.sln index 7f13eed5..e77e28e7 100644 --- a/NpgsqlRest.sln +++ b/NpgsqlRest.sln @@ -37,48 +37,138 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NpgsqlRest.OpenApi", "plugi EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NpgsqlRest.SqlFileSource", "plugins\NpgsqlRest.SqlFileSource\NpgsqlRest.SqlFileSource.csproj", "{C9DF26C1-5B83-4E6C-BF29-E89629BD45EF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NpgsqlRest.Mcp", "plugins\NpgsqlRest.Mcp\NpgsqlRest.Mcp.csproj", "{3F535AAB-7F76-47E0-A2FE-AD140242F742}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {65089EC7-0F4D-479F-8327-31121E2348CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {65089EC7-0F4D-479F-8327-31121E2348CB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65089EC7-0F4D-479F-8327-31121E2348CB}.Debug|x64.ActiveCfg = Debug|Any CPU + {65089EC7-0F4D-479F-8327-31121E2348CB}.Debug|x64.Build.0 = Debug|Any CPU + {65089EC7-0F4D-479F-8327-31121E2348CB}.Debug|x86.ActiveCfg = Debug|Any CPU + {65089EC7-0F4D-479F-8327-31121E2348CB}.Debug|x86.Build.0 = Debug|Any CPU {65089EC7-0F4D-479F-8327-31121E2348CB}.Release|Any CPU.ActiveCfg = Release|Any CPU {65089EC7-0F4D-479F-8327-31121E2348CB}.Release|Any CPU.Build.0 = Release|Any CPU + {65089EC7-0F4D-479F-8327-31121E2348CB}.Release|x64.ActiveCfg = Release|Any CPU + {65089EC7-0F4D-479F-8327-31121E2348CB}.Release|x64.Build.0 = Release|Any CPU + {65089EC7-0F4D-479F-8327-31121E2348CB}.Release|x86.ActiveCfg = Release|Any CPU + {65089EC7-0F4D-479F-8327-31121E2348CB}.Release|x86.Build.0 = Release|Any CPU {2C5E44A6-D7B5-43E7-A2B7-DBD5E49292DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2C5E44A6-D7B5-43E7-A2B7-DBD5E49292DA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C5E44A6-D7B5-43E7-A2B7-DBD5E49292DA}.Debug|x64.ActiveCfg = Debug|Any CPU + {2C5E44A6-D7B5-43E7-A2B7-DBD5E49292DA}.Debug|x64.Build.0 = Debug|Any CPU + {2C5E44A6-D7B5-43E7-A2B7-DBD5E49292DA}.Debug|x86.ActiveCfg = Debug|Any CPU + {2C5E44A6-D7B5-43E7-A2B7-DBD5E49292DA}.Debug|x86.Build.0 = Debug|Any CPU {2C5E44A6-D7B5-43E7-A2B7-DBD5E49292DA}.Release|Any CPU.ActiveCfg = Release|Any CPU {2C5E44A6-D7B5-43E7-A2B7-DBD5E49292DA}.Release|Any CPU.Build.0 = Release|Any CPU + {2C5E44A6-D7B5-43E7-A2B7-DBD5E49292DA}.Release|x64.ActiveCfg = Release|Any CPU + {2C5E44A6-D7B5-43E7-A2B7-DBD5E49292DA}.Release|x64.Build.0 = Release|Any CPU + {2C5E44A6-D7B5-43E7-A2B7-DBD5E49292DA}.Release|x86.ActiveCfg = Release|Any CPU + {2C5E44A6-D7B5-43E7-A2B7-DBD5E49292DA}.Release|x86.Build.0 = Release|Any CPU {2D8EAE94-E992-4CA0-BB86-91738ABB429B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2D8EAE94-E992-4CA0-BB86-91738ABB429B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2D8EAE94-E992-4CA0-BB86-91738ABB429B}.Debug|x64.ActiveCfg = Debug|Any CPU + {2D8EAE94-E992-4CA0-BB86-91738ABB429B}.Debug|x64.Build.0 = Debug|Any CPU + {2D8EAE94-E992-4CA0-BB86-91738ABB429B}.Debug|x86.ActiveCfg = Debug|Any CPU + {2D8EAE94-E992-4CA0-BB86-91738ABB429B}.Debug|x86.Build.0 = Debug|Any CPU {2D8EAE94-E992-4CA0-BB86-91738ABB429B}.Release|Any CPU.ActiveCfg = Release|Any CPU {2D8EAE94-E992-4CA0-BB86-91738ABB429B}.Release|Any CPU.Build.0 = Release|Any CPU + {2D8EAE94-E992-4CA0-BB86-91738ABB429B}.Release|x64.ActiveCfg = Release|Any CPU + {2D8EAE94-E992-4CA0-BB86-91738ABB429B}.Release|x64.Build.0 = Release|Any CPU + {2D8EAE94-E992-4CA0-BB86-91738ABB429B}.Release|x86.ActiveCfg = Release|Any CPU + {2D8EAE94-E992-4CA0-BB86-91738ABB429B}.Release|x86.Build.0 = Release|Any CPU {9D0D7807-9633-49A7-81D6-F2DD722DCC04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9D0D7807-9633-49A7-81D6-F2DD722DCC04}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D0D7807-9633-49A7-81D6-F2DD722DCC04}.Debug|x64.ActiveCfg = Debug|Any CPU + {9D0D7807-9633-49A7-81D6-F2DD722DCC04}.Debug|x64.Build.0 = Debug|Any CPU + {9D0D7807-9633-49A7-81D6-F2DD722DCC04}.Debug|x86.ActiveCfg = Debug|Any CPU + {9D0D7807-9633-49A7-81D6-F2DD722DCC04}.Debug|x86.Build.0 = Debug|Any CPU {9D0D7807-9633-49A7-81D6-F2DD722DCC04}.Release|Any CPU.ActiveCfg = Release|Any CPU {9D0D7807-9633-49A7-81D6-F2DD722DCC04}.Release|Any CPU.Build.0 = Release|Any CPU + {9D0D7807-9633-49A7-81D6-F2DD722DCC04}.Release|x64.ActiveCfg = Release|Any CPU + {9D0D7807-9633-49A7-81D6-F2DD722DCC04}.Release|x64.Build.0 = Release|Any CPU + {9D0D7807-9633-49A7-81D6-F2DD722DCC04}.Release|x86.ActiveCfg = Release|Any CPU + {9D0D7807-9633-49A7-81D6-F2DD722DCC04}.Release|x86.Build.0 = Release|Any CPU {94DF762B-9C25-4E27-95C6-DED177A2FDB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {94DF762B-9C25-4E27-95C6-DED177A2FDB0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94DF762B-9C25-4E27-95C6-DED177A2FDB0}.Debug|x64.ActiveCfg = Debug|Any CPU + {94DF762B-9C25-4E27-95C6-DED177A2FDB0}.Debug|x64.Build.0 = Debug|Any CPU + {94DF762B-9C25-4E27-95C6-DED177A2FDB0}.Debug|x86.ActiveCfg = Debug|Any CPU + {94DF762B-9C25-4E27-95C6-DED177A2FDB0}.Debug|x86.Build.0 = Debug|Any CPU {94DF762B-9C25-4E27-95C6-DED177A2FDB0}.Release|Any CPU.ActiveCfg = Release|Any CPU {94DF762B-9C25-4E27-95C6-DED177A2FDB0}.Release|Any CPU.Build.0 = Release|Any CPU + {94DF762B-9C25-4E27-95C6-DED177A2FDB0}.Release|x64.ActiveCfg = Release|Any CPU + {94DF762B-9C25-4E27-95C6-DED177A2FDB0}.Release|x64.Build.0 = Release|Any CPU + {94DF762B-9C25-4E27-95C6-DED177A2FDB0}.Release|x86.ActiveCfg = Release|Any CPU + {94DF762B-9C25-4E27-95C6-DED177A2FDB0}.Release|x86.Build.0 = Release|Any CPU {88607FEC-9FED-45D7-B629-92BBDBC682C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {88607FEC-9FED-45D7-B629-92BBDBC682C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {88607FEC-9FED-45D7-B629-92BBDBC682C7}.Debug|x64.ActiveCfg = Debug|Any CPU + {88607FEC-9FED-45D7-B629-92BBDBC682C7}.Debug|x64.Build.0 = Debug|Any CPU + {88607FEC-9FED-45D7-B629-92BBDBC682C7}.Debug|x86.ActiveCfg = Debug|Any CPU + {88607FEC-9FED-45D7-B629-92BBDBC682C7}.Debug|x86.Build.0 = Debug|Any CPU {88607FEC-9FED-45D7-B629-92BBDBC682C7}.Release|Any CPU.ActiveCfg = Release|Any CPU {88607FEC-9FED-45D7-B629-92BBDBC682C7}.Release|Any CPU.Build.0 = Release|Any CPU + {88607FEC-9FED-45D7-B629-92BBDBC682C7}.Release|x64.ActiveCfg = Release|Any CPU + {88607FEC-9FED-45D7-B629-92BBDBC682C7}.Release|x64.Build.0 = Release|Any CPU + {88607FEC-9FED-45D7-B629-92BBDBC682C7}.Release|x86.ActiveCfg = Release|Any CPU + {88607FEC-9FED-45D7-B629-92BBDBC682C7}.Release|x86.Build.0 = Release|Any CPU {C47CABBE-0CF1-4A22-AF8D-720DCDF991D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C47CABBE-0CF1-4A22-AF8D-720DCDF991D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C47CABBE-0CF1-4A22-AF8D-720DCDF991D0}.Debug|x64.ActiveCfg = Debug|Any CPU + {C47CABBE-0CF1-4A22-AF8D-720DCDF991D0}.Debug|x64.Build.0 = Debug|Any CPU + {C47CABBE-0CF1-4A22-AF8D-720DCDF991D0}.Debug|x86.ActiveCfg = Debug|Any CPU + {C47CABBE-0CF1-4A22-AF8D-720DCDF991D0}.Debug|x86.Build.0 = Debug|Any CPU {C47CABBE-0CF1-4A22-AF8D-720DCDF991D0}.Release|Any CPU.ActiveCfg = Release|Any CPU {C47CABBE-0CF1-4A22-AF8D-720DCDF991D0}.Release|Any CPU.Build.0 = Release|Any CPU + {C47CABBE-0CF1-4A22-AF8D-720DCDF991D0}.Release|x64.ActiveCfg = Release|Any CPU + {C47CABBE-0CF1-4A22-AF8D-720DCDF991D0}.Release|x64.Build.0 = Release|Any CPU + {C47CABBE-0CF1-4A22-AF8D-720DCDF991D0}.Release|x86.ActiveCfg = Release|Any CPU + {C47CABBE-0CF1-4A22-AF8D-720DCDF991D0}.Release|x86.Build.0 = Release|Any CPU {E43845E8-55F3-49EB-80E8-BE35B939FF1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E43845E8-55F3-49EB-80E8-BE35B939FF1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E43845E8-55F3-49EB-80E8-BE35B939FF1C}.Debug|x64.ActiveCfg = Debug|Any CPU + {E43845E8-55F3-49EB-80E8-BE35B939FF1C}.Debug|x64.Build.0 = Debug|Any CPU + {E43845E8-55F3-49EB-80E8-BE35B939FF1C}.Debug|x86.ActiveCfg = Debug|Any CPU + {E43845E8-55F3-49EB-80E8-BE35B939FF1C}.Debug|x86.Build.0 = Debug|Any CPU {E43845E8-55F3-49EB-80E8-BE35B939FF1C}.Release|Any CPU.ActiveCfg = Release|Any CPU {E43845E8-55F3-49EB-80E8-BE35B939FF1C}.Release|Any CPU.Build.0 = Release|Any CPU + {E43845E8-55F3-49EB-80E8-BE35B939FF1C}.Release|x64.ActiveCfg = Release|Any CPU + {E43845E8-55F3-49EB-80E8-BE35B939FF1C}.Release|x64.Build.0 = Release|Any CPU + {E43845E8-55F3-49EB-80E8-BE35B939FF1C}.Release|x86.ActiveCfg = Release|Any CPU + {E43845E8-55F3-49EB-80E8-BE35B939FF1C}.Release|x86.Build.0 = Release|Any CPU {C9DF26C1-5B83-4E6C-BF29-E89629BD45EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C9DF26C1-5B83-4E6C-BF29-E89629BD45EF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C9DF26C1-5B83-4E6C-BF29-E89629BD45EF}.Debug|x64.ActiveCfg = Debug|Any CPU + {C9DF26C1-5B83-4E6C-BF29-E89629BD45EF}.Debug|x64.Build.0 = Debug|Any CPU + {C9DF26C1-5B83-4E6C-BF29-E89629BD45EF}.Debug|x86.ActiveCfg = Debug|Any CPU + {C9DF26C1-5B83-4E6C-BF29-E89629BD45EF}.Debug|x86.Build.0 = Debug|Any CPU {C9DF26C1-5B83-4E6C-BF29-E89629BD45EF}.Release|Any CPU.ActiveCfg = Release|Any CPU {C9DF26C1-5B83-4E6C-BF29-E89629BD45EF}.Release|Any CPU.Build.0 = Release|Any CPU + {C9DF26C1-5B83-4E6C-BF29-E89629BD45EF}.Release|x64.ActiveCfg = Release|Any CPU + {C9DF26C1-5B83-4E6C-BF29-E89629BD45EF}.Release|x64.Build.0 = Release|Any CPU + {C9DF26C1-5B83-4E6C-BF29-E89629BD45EF}.Release|x86.ActiveCfg = Release|Any CPU + {C9DF26C1-5B83-4E6C-BF29-E89629BD45EF}.Release|x86.Build.0 = Release|Any CPU + {3F535AAB-7F76-47E0-A2FE-AD140242F742}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3F535AAB-7F76-47E0-A2FE-AD140242F742}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3F535AAB-7F76-47E0-A2FE-AD140242F742}.Debug|x64.ActiveCfg = Debug|Any CPU + {3F535AAB-7F76-47E0-A2FE-AD140242F742}.Debug|x64.Build.0 = Debug|Any CPU + {3F535AAB-7F76-47E0-A2FE-AD140242F742}.Debug|x86.ActiveCfg = Debug|Any CPU + {3F535AAB-7F76-47E0-A2FE-AD140242F742}.Debug|x86.Build.0 = Debug|Any CPU + {3F535AAB-7F76-47E0-A2FE-AD140242F742}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3F535AAB-7F76-47E0-A2FE-AD140242F742}.Release|Any CPU.Build.0 = Release|Any CPU + {3F535AAB-7F76-47E0-A2FE-AD140242F742}.Release|x64.ActiveCfg = Release|Any CPU + {3F535AAB-7F76-47E0-A2FE-AD140242F742}.Release|x64.Build.0 = Release|Any CPU + {3F535AAB-7F76-47E0-A2FE-AD140242F742}.Release|x86.ActiveCfg = Release|Any CPU + {3F535AAB-7F76-47E0-A2FE-AD140242F742}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -91,6 +181,7 @@ Global {88607FEC-9FED-45D7-B629-92BBDBC682C7} = {168D4570-4DD0-4E85-83E4-889B7495370A} {E43845E8-55F3-49EB-80E8-BE35B939FF1C} = {168D4570-4DD0-4E85-83E4-889B7495370A} {C9DF26C1-5B83-4E6C-BF29-E89629BD45EF} = {168D4570-4DD0-4E85-83E4-889B7495370A} + {3F535AAB-7F76-47E0-A2FE-AD140242F742} = {168D4570-4DD0-4E85-83E4-889B7495370A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C7C9B024-BFBB-414E-BD34-3CBF21C6C275} diff --git a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs new file mode 100644 index 00000000..429f4655 --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs @@ -0,0 +1,132 @@ +using NpgsqlRest.Mcp; +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +public static partial class Database +{ + // MCP plugin annotation test functions, in an isolated `mcp` schema (the shared + // Program/IncludeSchemas list excludes `mcp`, so these never appear in any other fixture). + // The fixture runs in CommentsMode.OnlyAnnotated with the Mcp + OpenApi handlers loaded. + public static void McpPluginAnnotationTests() + { + script.Append(@" +create schema if not exists mcp; + +-- @mcp opt-in (no inline text) -> description derived from prose; keeps HTTP route +create function mcp.tool_basic() returns text language sql as 'select ''basic'''; +comment on function mcp.tool_basic() is ' +HTTP GET +Fetch basic data for the agent. +@mcp'; + +-- @mcp -> inline description override +create function mcp.tool_inline_desc() returns text language sql as 'select ''d'''; +comment on function mcp.tool_inline_desc() is ' +HTTP GET +@mcp Cancel a booking and release the room.'; + +-- @mcp_name -> explicit tool name +create function mcp.tool_named() returns text language sql as 'select ''n'''; +comment on function mcp.tool_named() is ' +HTTP GET +@mcp +@mcp_name cancel_booking'; + +-- MCP-only: @mcp + @internal, NO HTTP tag -> created (mcp requests endpoint) + InternalOnly +create function mcp.tool_mcp_only() returns text language sql as 'select ''o'''; +comment on function mcp.tool_mcp_only() is ' +@mcp +@internal'; + +-- @mcp alone, no HTTP tag, no @internal -> created (mcp requests endpoint), HTTP route kept +create function mcp.tool_mcp_no_http() returns text language sql as 'select ''nh'''; +comment on function mcp.tool_mcp_no_http() is ' +@mcp'; + +-- HTTP only (no @mcp) -> created via HTTP tag, no MCP metadata +create function mcp.tool_http_only() returns text language sql as 'select ''h'''; +comment on function mcp.tool_http_only() is ' +HTTP GET +A normal endpoint, not exposed to MCP.'; + +-- modifier only (authorize), no HTTP, no exposure -> NOT created under OnlyAnnotated +create function mcp.tool_modifier_only() returns text language sql as 'select ''m'''; +comment on function mcp.tool_modifier_only() is ' +authorize'; + +-- plugin modifier only (openapi hide), no HTTP, no exposure -> NOT created under OnlyAnnotated +create function mcp.tool_openapi_hide_only() returns text language sql as 'select ''oh'''; +comment on function mcp.tool_openapi_hide_only() is ' +openapi hide'; +"); + } +} + +[Collection("McpPluginFixture")] +public class McpPluginAnnotationTests(McpPluginTestFixture test) +{ + private static McpToolInfo? Info(RoutineEndpoint e) + => e.TryGetItem(Mcp.ItemsKey, out var v) ? v as McpToolInfo : null; + + [Fact] + public void Mcp_opt_in_records_metadata_keeps_http_and_leaves_prose() + { + var e = test.Endpoints["tool_basic"]; + var info = Info(e); + info.Should().NotBeNull(); + info!.Enabled.Should().BeTrue(); + info.ToolName.Should().BeNull(); + info.Description.Should().BeNull(); // no inline text -> derived from prose later + e.InternalOnly.Should().BeFalse(); // HTTP route kept + e.UnhandledCommentLines.Should().Equal("Fetch basic data for the agent."); + } + + [Fact] + public void Mcp_inline_text_is_the_description() + { + var info = Info(test.Endpoints["tool_inline_desc"])!; + info.Enabled.Should().BeTrue(); + info.Description.Should().Be("Cancel a booking and release the room."); + } + + [Fact] + public void Mcp_name_sets_tool_name() + { + var info = Info(test.Endpoints["tool_named"])!; + info.Enabled.Should().BeTrue(); + info.ToolName.Should().Be("cancel_booking"); + } + + [Fact] + public void Mcp_plus_internal_is_mcp_only_no_http_route() + { + var e = test.Endpoints["tool_mcp_only"]; // created despite no HTTP tag (OnlyAnnotated) + Info(e)!.Enabled.Should().BeTrue(); + e.InternalOnly.Should().BeTrue(); // MCP-only: no public HTTP route + } + + [Fact] + public void Mcp_alone_creates_endpoint_and_keeps_http() + { + var e = test.Endpoints["tool_mcp_no_http"]; // created despite no HTTP tag (mcp requests it) + Info(e)!.Enabled.Should().BeTrue(); + e.InternalOnly.Should().BeFalse(); // HTTP+MCP (no @internal) + } + + [Fact] + public void Http_only_endpoint_has_no_mcp_metadata() + { + var e = test.Endpoints["tool_http_only"]; + Info(e).Should().BeNull(); + e.InternalOnly.Should().BeFalse(); + } + + [Fact] + public void Modifier_only_comments_do_not_create_an_endpoint() + { + // OnlyAnnotated: a comment with only a modifier (no HTTP tag, no exposure request) creates nothing. + test.Endpoints.ContainsKey("tool_modifier_only").Should().BeFalse(); // `authorize` (core modifier) + test.Endpoints.ContainsKey("tool_openapi_hide_only").Should().BeFalse(); // `openapi hide` (plugin modifier) + } +} diff --git a/NpgsqlRestTests/NpgsqlRestTests.csproj b/NpgsqlRestTests/NpgsqlRestTests.csproj index 44af181c..7ce209b1 100644 --- a/NpgsqlRestTests/NpgsqlRestTests.csproj +++ b/NpgsqlRestTests/NpgsqlRestTests.csproj @@ -38,6 +38,7 @@ + diff --git a/NpgsqlRestTests/Setup/McpPluginTestFixture.cs b/NpgsqlRestTests/Setup/McpPluginTestFixture.cs new file mode 100644 index 00000000..86e9f83e --- /dev/null +++ b/NpgsqlRestTests/Setup/McpPluginTestFixture.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using NpgsqlRest.Mcp; +using NpgsqlRest.OpenAPI; + +namespace NpgsqlRestTests.Setup; + +[CollectionDefinition("McpPluginFixture")] +public class McpPluginFixtureCollection : ICollectionFixture { } + +/// +/// Fixture for the NpgsqlRest.Mcp plugin annotation layer (Phase 2 increment 1). Loads the Mcp +/// plugin as an endpoint-create handler so its HandleCommentLine hook runs during core's +/// comment-parse pass, scoped to an isolated mcp schema (excluded from every other fixture). +/// Captures the parsed endpoints via EndpointsCreated so tests can assert the per-endpoint +/// stored in RoutineEndpoint.Items["mcp"], plus InternalOnly and the +/// prose left in UnhandledCommentLines. +/// +public class McpPluginTestFixture : IDisposable +{ + private readonly WebApplication _app; + private readonly HttpClient _client; + + public HttpClient Client => _client; + + /// Fully-parsed endpoints captured at build time, keyed by routine name. + public Dictionary Endpoints { get; } = new(StringComparer.Ordinal); + + public McpPluginTestFixture() + { + Database.Create(); + var connectionString = Database.CreateAdditional("mcp_plugin_test"); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); // random available port + + _app = builder.Build(); + + _app.UseNpgsqlRest(new NpgsqlRestOptions(connectionString) + { + IncludeSchemas = ["mcp"], + // OnlyAnnotated: an endpoint is created only if its comment has an HTTP tag OR a plugin + // requests an endpoint (e.g. `mcp`). OpenApi is loaded too so we can assert that a pure + // modifier (`openapi hide`) on a non-HTTP routine does NOT spawn an endpoint. + CommentsMode = CommentsMode.OnlyAnnotated, + EndpointCreateHandlers = [new Mcp(new McpOptions { Enabled = true }), new OpenApi(new OpenApiOptions())], + EndpointsCreated = endpoints => + { + foreach (var endpoint in endpoints) + { + Endpoints[endpoint.Routine.Name] = endpoint; + } + } + }); + + _app.StartAsync().GetAwaiter().GetResult(); + + var serverAddress = _app.Urls.First(); + _client = new HttpClient { BaseAddress = new Uri(serverAddress) }; + _client.Timeout = TimeSpan.FromHours(1); + } + +#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize + public void Dispose() +#pragma warning restore CA1816 // Dispose methods should call SuppressFinalize + { + _client.Dispose(); + _app.StopAsync().GetAwaiter().GetResult(); + _app.DisposeAsync().GetAwaiter().GetResult(); + } +} diff --git a/plugins/NpgsqlRest.Mcp/Mcp.cs b/plugins/NpgsqlRest.Mcp/Mcp.cs new file mode 100644 index 00000000..42979fcd --- /dev/null +++ b/plugins/NpgsqlRest.Mcp/Mcp.cs @@ -0,0 +1,84 @@ +using NpgsqlRest.Common; + +namespace NpgsqlRest.Mcp; + +/// +/// MCP server plugin. Projects opted-in PostgreSQL routines as MCP tools. +/// +/// Annotation layer (this increment): claims the mcp* comment annotations during core's +/// single comment-parse pass (), records per-endpoint metadata in +/// (), and applies MCP-only routing by +/// setting . Catalog generation and the /mcp endpoint are +/// added in later increments. +/// +/// Annotations: +/// mcp opt this routine in as an MCP tool; description = comment prose (derived) +/// mcp <text> opt in; <text> is the tool description (overrides derived prose) +/// mcp_name name explicit tool name (otherwise derived from the routine name) +/// +/// MCP-only (tool exposed, no public HTTP route) is composed with the core `internal` annotation: +/// `mcp` + `internal`. (With , `mcp` alone creates the +/// endpoint even without an HTTP tag.) +/// +public class Mcp(McpOptions options) : IEndpointCreateHandler +{ + /// Key under which is stored in . + public const string ItemsKey = "mcp"; + + private readonly McpOptions _options = options; + + public CommentLineResult? HandleCommentLine(RoutineEndpoint endpoint, string line, string[] words, string[] wordsLower) + { + if (wordsLower.Length == 0) + { + return null; + } + + var key = wordsLower[0]; + + // mcp_name + if (CommentPrimitives.StrEquals(key, "mcp_name")) + { + var info = GetOrAdd(endpoint); + info.Enabled = true; + if (words.Length > 1) + { + info.ToolName = words[1]; + } + return new CommentLineResult(string.Concat("mcp_name ", info.ToolName), RequestsEndpoint: true); + } + + // mcp | mcp — text (rest of line, case preserved) becomes the tool description. + if (CommentPrimitives.StrEquals(key, "mcp")) + { + var info = GetOrAdd(endpoint); + info.Enabled = true; + var text = RemainderAfterFirstWord(line); + if (!string.IsNullOrWhiteSpace(text)) + { + info.Description = text; + return new CommentLineResult(string.Concat("mcp: ", text), RequestsEndpoint: true); + } + return new CommentLineResult("mcp", RequestsEndpoint: true); + } + + return null; + } + + private static McpToolInfo GetOrAdd(RoutineEndpoint endpoint) + { + if (endpoint.TryGetItem(ItemsKey, out var value) && value is McpToolInfo existing) + { + return existing; + } + var info = new McpToolInfo(); + endpoint.Items[ItemsKey] = info; + return info; + } + + private static string? RemainderAfterFirstWord(string line) + { + var idx = line.IndexOfAny([' ', '\t']); + return idx < 0 ? null : line[(idx + 1)..].Trim(); + } +} diff --git a/plugins/NpgsqlRest.Mcp/McpOptions.cs b/plugins/NpgsqlRest.Mcp/McpOptions.cs new file mode 100644 index 00000000..5f021774 --- /dev/null +++ b/plugins/NpgsqlRest.Mcp/McpOptions.cs @@ -0,0 +1,25 @@ +namespace NpgsqlRest.Mcp; + +/// +/// Options for the MCP (Model Context Protocol) server plugin. Disabled by default. Tools are never +/// auto-exposed — only routines opted in with the mcp comment annotation become MCP tools. +/// (Serving-related options — auth/PRM, structured content, filters — are added with the /mcp +/// endpoint in a later increment.) +/// +public class McpOptions +{ + /// Enable or disable the MCP endpoint. Disabled by default. + public bool Enabled { get; set; } = false; + + /// URL path for the MCP endpoint (Streamable HTTP, single endpoint). + public string UrlPath { get; set; } = "/mcp"; + + /// serverInfo.name reported in the initialize handshake. Null = database name (or "NpgsqlRest"). + public string? ServerName { get; set; } = null; + + /// serverInfo.version reported in the initialize handshake. Null = application version. + public string? ServerVersion { get; set; } = null; + + /// Optional server-level instructions returned in the initialize handshake. + public string? Instructions { get; set; } = null; +} diff --git a/plugins/NpgsqlRest.Mcp/McpToolInfo.cs b/plugins/NpgsqlRest.Mcp/McpToolInfo.cs new file mode 100644 index 00000000..7013e257 --- /dev/null +++ b/plugins/NpgsqlRest.Mcp/McpToolInfo.cs @@ -0,0 +1,20 @@ +namespace NpgsqlRest.Mcp; + +/// +/// Per-endpoint MCP metadata, parsed from the routine's mcp* comment annotations and stored +/// in under the key. Read later when +/// building the MCP tool catalog. (MCP-only routing is applied directly via +/// , not stored here.) +/// +public sealed class McpToolInfo +{ + /// This routine is opted in as an MCP tool (any mcp* annotation sets this). + public bool Enabled { get; set; } + + /// Explicit tool name from mcp_name; null = derive from the routine name. + public string? ToolName { get; set; } + + /// Explicit tool description from mcp_description / mcp_desc; null = derive + /// from the comment prose (). + public string? Description { get; set; } +} diff --git a/plugins/NpgsqlRest.Mcp/NpgsqlRest.Mcp.csproj b/plugins/NpgsqlRest.Mcp/NpgsqlRest.Mcp.csproj new file mode 100644 index 00000000..1ea60335 --- /dev/null +++ b/plugins/NpgsqlRest.Mcp/NpgsqlRest.Mcp.csproj @@ -0,0 +1,55 @@ + + + + Library + net10.0 + enable + enable + true + $(NoWarn);1591 + true + NpgsqlRest.Mcp + Vedran Bilopavlović + VB-Consulting + MCP (Model Context Protocol) server for NpgsqlRest — projects opted-in PostgreSQL routines as MCP tools. Implements MCP 2025-11-25. + api;mcp;model-context-protocol;ai;agent;llm;postgres;dotnet;database;rest;server;postgresql;npgsqlrest;pgsql;pg;tools + https://github.com/NpgsqlRest/NpgsqlRest/NpgsqlRest.Mcp + https://github.com/NpgsqlRest/NpgsqlRest + https://github.com/NpgsqlRest/NpgsqlRest/blob/master/changelog.md + NpgsqlRest.Mcp + LICENSE + true + 1.0.0 + 1.0.0 + 1.0.0 + 1.0.0 + 14 + + + + true + + + + + + + + + True + + + + + + + + + + + + Common\%(Filename)%(Extension) + + + From 6f8dc6f1f50a35a6e63178fe2c22c6ea7eeda2c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Mon, 8 Jun 2026 14:52:24 +0200 Subject: [PATCH 10/38] =?UTF-8?q?feat:=20v3.17.0=20=E2=80=94=20MCP=20tool?= =?UTF-8?q?=20catalog=20+=20shared=20TypeDescriptor=E2=86=92JSON-schema=20?= =?UTF-8?q?mapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NpgsqlRest.Common/SchemaMapper.cs: PG TypeDescriptor → JSON Schema fragment, moved out of the OpenApi plugin; OpenApi now uses the shared mapper (dedup). - Mcp plugin builds a tool catalog (Mcp.Tools: tool name → tools/list Tool object) during endpoint creation: - name: mcp_name override, else routine name (collisions logged; overload disambiguation TODO) - description: inline 'mcp ', else comment prose (UnhandledCommentLines), else routine name + a build warning - inputSchema: from routine parameters, EXCLUDING claim-sourced / IP / virtual / server-resolved params; required = parameters with no default (TypeDescriptor.HasDefault) - annotations: readOnlyHint (GET), destructiveHint (DELETE) Tests: description derivation + fallback, inputSchema required/optional, resolved-param exclusion, GET read-only hint, catalog membership. Full suite green (2152). --- NpgsqlRest.Common/SchemaMapper.cs | 91 +++++++++++++++ .../McpTests/McpPluginAnnotationTests.cs | 68 +++++++++++ NpgsqlRestTests/Setup/McpPluginTestFixture.cs | 7 +- plugins/NpgsqlRest.Mcp/Mcp.cs | 110 ++++++++++++++++++ plugins/NpgsqlRest.OpenApi/OpenApi.cs | 94 ++------------- 5 files changed, 282 insertions(+), 88 deletions(-) create mode 100644 NpgsqlRest.Common/SchemaMapper.cs diff --git a/NpgsqlRest.Common/SchemaMapper.cs b/NpgsqlRest.Common/SchemaMapper.cs new file mode 100644 index 00000000..1faebed3 --- /dev/null +++ b/NpgsqlRest.Common/SchemaMapper.cs @@ -0,0 +1,91 @@ +using System.Text.Json.Nodes; + +namespace NpgsqlRest.Common; + +/// +/// Maps a PostgreSQL to a JSON Schema fragment (type + format). Shared +/// by the OpenApi and Mcp plugins via linked source (internal, own copy per assembly). AOT-safe — +/// builds System.Text.Json.Nodes only. +/// +internal static class SchemaMapper +{ + public static JsonObject GetSchemaForType(TypeDescriptor type) + { + var schema = new JsonObject(); + + if (type.IsArray) + { + schema["type"] = "array"; + var itemType = new TypeDescriptor(type.Type, type.HasDefault); + schema["items"] = GetSchemaForType(itemType); + return schema; + } + + if (type.IsNumeric) + { + if (type.Type.Contains("int", StringComparison.OrdinalIgnoreCase)) + { + schema["type"] = "integer"; + if (type.Type.Contains("big", StringComparison.OrdinalIgnoreCase) || + type.Type == "int8") + { + schema["format"] = "int64"; + } + else + { + schema["format"] = "int32"; + } + } + else + { + schema["type"] = "number"; + if (type.Type == "real" || type.Type == "float4") + { + schema["format"] = "float"; + } + else if (type.Type == "double precision" || type.Type == "float8") + { + schema["format"] = "double"; + } + } + return schema; + } + + if (type.IsBoolean) + { + schema["type"] = "boolean"; + return schema; + } + + if (type.IsDateTime) + { + schema["type"] = "string"; + schema["format"] = "date-time"; + return schema; + } + + if (type.IsDate) + { + schema["type"] = "string"; + schema["format"] = "date"; + return schema; + } + + if (type.Type == "uuid") + { + schema["type"] = "string"; + schema["format"] = "uuid"; + return schema; + } + + if (type.IsJson) + { + schema["type"] = "object"; + return schema; + } + + // Default to string + schema["type"] = "string"; + return schema; + } +} diff --git a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs index 429f4655..ff4cd5ec 100644 --- a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs +++ b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs @@ -59,6 +59,25 @@ comment on function mcp.tool_modifier_only() is ' create function mcp.tool_openapi_hide_only() returns text language sql as 'select ''oh'''; comment on function mcp.tool_openapi_hide_only() is ' openapi hide'; + +-- params: required (no default) + optional (default) -> inputSchema properties + required +create function mcp.tool_params(id int, label text default 'x') returns text language sql as 'select ''p'''; +comment on function mcp.tool_params(int, text) is ' +HTTP GET +@mcp Fetch by id.'; + +-- a server-resolved param must be excluded from inputSchema (agent must not supply it) +create function mcp.tool_resolved(id int, secret text) returns text language sql as 'select ''r'''; +comment on function mcp.tool_resolved(int, text) is ' +HTTP GET +@mcp +secret = select ''sekret'''; + +-- no inline text and no prose -> description falls back to the routine name +create function mcp.tool_nodesc() returns text language sql as 'select ''nd'''; +comment on function mcp.tool_nodesc() is ' +HTTP GET +@mcp'; "); } } @@ -130,3 +149,52 @@ public void Modifier_only_comments_do_not_create_an_endpoint() test.Endpoints.ContainsKey("tool_openapi_hide_only").Should().BeFalse(); // `openapi hide` (plugin modifier) } } + +[Collection("McpPluginFixture")] +public class McpToolCatalogTests(McpPluginTestFixture test) +{ + [Fact] + public void Catalog_contains_only_mcp_tools_keyed_by_tool_name() + { + test.Tools.Should().ContainKey("tool_basic"); + test.Tools.Should().ContainKey("cancel_booking"); // @mcp_name override + test.Tools.Should().NotContainKey("tool_named"); // superseded by the explicit name + test.Tools.Should().NotContainKey("tool_http_only"); // HTTP, not MCP + test.Tools.Should().NotContainKey("tool_modifier_only"); // not created at all + } + + [Fact] + public void Description_derives_from_prose_inline_text_and_falls_back_to_name() + { + test.Tools["tool_basic"]!["description"]!.GetValue().Should().Be("Fetch basic data for the agent."); + test.Tools["tool_inline_desc"]!["description"]!.GetValue().Should().Be("Cancel a booking and release the room."); + test.Tools["tool_nodesc"]!["description"]!.GetValue().Should().Be("tool_nodesc"); // fallback + } + + [Fact] + public void InputSchema_lists_params_and_marks_only_non_default_as_required() + { + var schema = test.Tools["tool_params"]!["inputSchema"]!; + var props = schema["properties"]!.AsObject(); + props.ContainsKey("id").Should().BeTrue(); + props.ContainsKey("label").Should().BeTrue(); + props["id"]!["type"]!.GetValue().Should().Be("integer"); + + var required = schema["required"]!.AsArray().Select(n => n!.GetValue()).ToArray(); + required.Should().Equal("id"); // `label` has a DEFAULT → optional, not required + } + + [Fact] + public void Server_resolved_params_are_excluded_from_inputSchema() + { + var props = test.Tools["tool_resolved"]!["inputSchema"]!["properties"]!.AsObject(); + props.ContainsKey("id").Should().BeTrue(); + props.ContainsKey("secret").Should().BeFalse(); // resolved server-side → agent must not supply it + } + + [Fact] + public void Get_tools_carry_read_only_hint() + { + test.Tools["tool_basic"]!["annotations"]!["readOnlyHint"]!.GetValue().Should().BeTrue(); + } +} diff --git a/NpgsqlRestTests/Setup/McpPluginTestFixture.cs b/NpgsqlRestTests/Setup/McpPluginTestFixture.cs index 86e9f83e..f950cd45 100644 --- a/NpgsqlRestTests/Setup/McpPluginTestFixture.cs +++ b/NpgsqlRestTests/Setup/McpPluginTestFixture.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Nodes; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using NpgsqlRest.Mcp; @@ -20,12 +21,16 @@ public class McpPluginTestFixture : IDisposable { private readonly WebApplication _app; private readonly HttpClient _client; + private readonly Mcp _mcp = new(new McpOptions { Enabled = true }); public HttpClient Client => _client; /// Fully-parsed endpoints captured at build time, keyed by routine name. public Dictionary Endpoints { get; } = new(StringComparer.Ordinal); + /// The MCP tool catalog produced by the plugin (tool name → tools/list Tool object). + public IReadOnlyDictionary Tools => _mcp.Tools; + public McpPluginTestFixture() { Database.Create(); @@ -43,7 +48,7 @@ public McpPluginTestFixture() // requests an endpoint (e.g. `mcp`). OpenApi is loaded too so we can assert that a pure // modifier (`openapi hide`) on a non-HTTP routine does NOT spawn an endpoint. CommentsMode = CommentsMode.OnlyAnnotated, - EndpointCreateHandlers = [new Mcp(new McpOptions { Enabled = true }), new OpenApi(new OpenApiOptions())], + EndpointCreateHandlers = [_mcp, new OpenApi(new OpenApiOptions())], EndpointsCreated = endpoints => { foreach (var endpoint in endpoints) diff --git a/plugins/NpgsqlRest.Mcp/Mcp.cs b/plugins/NpgsqlRest.Mcp/Mcp.cs index 42979fcd..dd9da2dd 100644 --- a/plugins/NpgsqlRest.Mcp/Mcp.cs +++ b/plugins/NpgsqlRest.Mcp/Mcp.cs @@ -1,4 +1,7 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; using NpgsqlRest.Common; +using static NpgsqlRest.NpgsqlRestOptions; namespace NpgsqlRest.Mcp; @@ -27,6 +30,15 @@ public class Mcp(McpOptions options) : IEndpointCreateHandler private readonly McpOptions _options = options; + private readonly Dictionary _tools = new(StringComparer.Ordinal); + + /// + /// The MCP tool catalog, keyed by tool name. Built during endpoint creation () + /// from the routines opted in via the `mcp` annotation. Each value is a tools/list `Tool` object + /// (name, description, inputSchema, annotations). + /// + public IReadOnlyDictionary Tools => _tools; + public CommentLineResult? HandleCommentLine(RoutineEndpoint endpoint, string line, string[] words, string[] wordsLower) { if (wordsLower.Length == 0) @@ -65,6 +77,104 @@ public class Mcp(McpOptions options) : IEndpointCreateHandler return null; } + public void Handle(RoutineEndpoint endpoint) + { + if (!endpoint.TryGetItem(ItemsKey, out var value) || value is not McpToolInfo info || !info.Enabled) + { + return; + } + + var tool = BuildTool(endpoint, info); + var name = tool["name"]!.GetValue(); + if (!_tools.TryAdd(name, tool)) + { + // Tool names must be unique (e.g. overloaded routines collide). Keep the first; log the rest. + // TODO: overload disambiguation (mcp_name, or a typed/arity suffix). + Logger?.LogWarning("MCP tool name '{Name}' is already in use ({Schema}.{Routine} skipped).", + name, endpoint.Routine.Schema, endpoint.Routine.Name); + } + } + + private JsonObject BuildTool(RoutineEndpoint endpoint, McpToolInfo info) + { + var routine = endpoint.Routine; + var name = string.IsNullOrWhiteSpace(info.ToolName) ? routine.Name : info.ToolName!; + + var description = info.Description; + if (string.IsNullOrWhiteSpace(description)) + { + description = DeriveDescription(endpoint); + } + if (string.IsNullOrWhiteSpace(description)) + { + description = routine.Name; + Logger?.LogWarning("MCP tool '{Name}' has no description — provide `mcp ` or comment prose so agents call it well.", name); + } + + var properties = new JsonObject(); + var required = new JsonArray(); + foreach (var p in routine.Parameters) + { + if (IsExcludedFromInput(p, endpoint)) + { + continue; + } + properties[p.ConvertedName] = SchemaMapper.GetSchemaForType(p.TypeDescriptor); + // A parameter is required unless it has a default (PG DEFAULT, tracked on the type + // descriptor) or an explicit annotation default. + if (!p.TypeDescriptor.HasDefault && p.DefaultValue is null) + { + required.Add((JsonNode?)p.ConvertedName); + } + } + + var inputSchema = new JsonObject + { + ["type"] = "object", + ["properties"] = properties, + }; + if (required.Count > 0) + { + inputSchema["required"] = required; + } + + // Safety hints derived from the HTTP method. GET → read-only; DELETE → destructive. + var annotations = new JsonObject { ["readOnlyHint"] = endpoint.Method == Method.GET }; + if (endpoint.Method == Method.DELETE) + { + annotations["destructiveHint"] = true; + } + + return new JsonObject + { + ["name"] = name, + ["description"] = description, + ["inputSchema"] = inputSchema, + ["annotations"] = annotations, + }; + } + + /// + /// Parameters that the agent must NOT supply are excluded from inputSchema: claim-sourced, IP, + /// virtual, or server-resolved (via a resolved-parameter SQL expression). + /// + private static bool IsExcludedFromInput(NpgsqlRestParameter p, RoutineEndpoint endpoint) + { + if (p.IsFromUserClaims || p.IsIpAddress || p.IsVirtual) + { + return true; + } + var resolved = endpoint.ResolvedParameterExpressions; + return resolved is not null + && (resolved.ContainsKey(p.ActualName) || resolved.ContainsKey(p.ConvertedName)); + } + + private static string? DeriveDescription(RoutineEndpoint endpoint) + { + var lines = endpoint.UnhandledCommentLines; + return lines is { Length: > 0 } ? string.Join('\n', lines) : null; + } + private static McpToolInfo GetOrAdd(RoutineEndpoint endpoint) { if (endpoint.TryGetItem(ItemsKey, out var value) && value is McpToolInfo existing) diff --git a/plugins/NpgsqlRest.OpenApi/OpenApi.cs b/plugins/NpgsqlRest.OpenApi/OpenApi.cs index d3f93a37..5b56cdd6 100644 --- a/plugins/NpgsqlRest.OpenApi/OpenApi.cs +++ b/plugins/NpgsqlRest.OpenApi/OpenApi.cs @@ -225,7 +225,7 @@ public void Handle(RoutineEndpoint endpoint) if (routineParam != null) { - pathParameter["schema"] = GetSchemaForType(routineParam.TypeDescriptor); + pathParameter["schema"] = SchemaMapper.GetSchemaForType(routineParam.TypeDescriptor); } else { @@ -275,7 +275,7 @@ public void Handle(RoutineEndpoint endpoint) ["name"] = param.ConvertedName, ["in"] = "query", ["required"] = !param.TypeDescriptor.HasDefault, - ["schema"] = GetSchemaForType(param.TypeDescriptor) + ["schema"] = SchemaMapper.GetSchemaForType(param.TypeDescriptor) }; parameters.Add((JsonNode)parameter); @@ -314,7 +314,7 @@ public void Handle(RoutineEndpoint endpoint) } } - properties![param.ConvertedName] = GetSchemaForType(param.TypeDescriptor); + properties![param.ConvertedName] = SchemaMapper.GetSchemaForType(param.TypeDescriptor); if (!param.TypeDescriptor.HasDefault) { required.Add((JsonNode?)JsonValue.Create(param.ConvertedName)); @@ -366,7 +366,7 @@ public void Handle(RoutineEndpoint endpoint) { ["text/plain"] = new JsonObject { - ["schema"] = GetSchemaForType(bodyParam.TypeDescriptor) + ["schema"] = SchemaMapper.GetSchemaForType(bodyParam.TypeDescriptor) } } }; @@ -559,86 +559,6 @@ public void Cleanup() } } - private JsonObject GetSchemaForType(TypeDescriptor type) - { - var schema = new JsonObject(); - - if (type.IsArray) - { - schema["type"] = "array"; - var itemType = new TypeDescriptor(type.Type, type.HasDefault); - schema["items"] = GetSchemaForType(itemType); - return schema; - } - - if (type.IsNumeric) - { - if (type.Type.Contains("int", StringComparison.OrdinalIgnoreCase)) - { - schema["type"] = "integer"; - if (type.Type.Contains("big", StringComparison.OrdinalIgnoreCase) || - type.Type == "int8") - { - schema["format"] = "int64"; - } - else - { - schema["format"] = "int32"; - } - } - else - { - schema["type"] = "number"; - if (type.Type == "real" || type.Type == "float4") - { - schema["format"] = "float"; - } - else if (type.Type == "double precision" || type.Type == "float8") - { - schema["format"] = "double"; - } - } - return schema; - } - - if (type.IsBoolean) - { - schema["type"] = "boolean"; - return schema; - } - - if (type.IsDateTime) - { - schema["type"] = "string"; - schema["format"] = "date-time"; - return schema; - } - - if (type.IsDate) - { - schema["type"] = "string"; - schema["format"] = "date"; - return schema; - } - - if (type.Type == "uuid") - { - schema["type"] = "string"; - schema["format"] = "uuid"; - return schema; - } - - if (type.IsJson) - { - schema["type"] = "object"; - return schema; - } - - // Default to string - schema["type"] = "string"; - return schema; - } - private JsonObject GetResponseSchema(Routine routine) { if (routine.ReturnsSet) @@ -653,7 +573,7 @@ private JsonObject GetResponseSchema(Routine routine) var properties = itemSchema["properties"] as JsonObject; for (int i = 0; i < routine.ColumnCount; i++) { - properties![routine.ColumnNames[i]] = GetSchemaForType(routine.ColumnsTypeDescriptor[i]); + properties![routine.ColumnNames[i]] = SchemaMapper.GetSchemaForType(routine.ColumnsTypeDescriptor[i]); } return new JsonObject @@ -674,7 +594,7 @@ private JsonObject GetResponseSchema(Routine routine) var properties = schema["properties"] as JsonObject; for (int i = 0; i < routine.ColumnCount; i++) { - properties![routine.ColumnNames[i]] = GetSchemaForType(routine.ColumnsTypeDescriptor[i]); + properties![routine.ColumnNames[i]] = SchemaMapper.GetSchemaForType(routine.ColumnsTypeDescriptor[i]); } return schema; @@ -682,7 +602,7 @@ private JsonObject GetResponseSchema(Routine routine) else if (routine.ColumnCount == 1) { // Returns single value - return GetSchemaForType(routine.ColumnsTypeDescriptor[0]); + return SchemaMapper.GetSchemaForType(routine.ColumnsTypeDescriptor[0]); } // Default From 5d140a514622285020f726d58f0e64f770fbf193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Mon, 8 Jun 2026 15:52:15 +0200 Subject: [PATCH 11/38] =?UTF-8?q?feat:=20v3.17.0=20=E2=80=94=20MCP=20/mcp?= =?UTF-8?q?=20JSON-RPC=20endpoint=20(initialize/ping/tools/list)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serves the MCP server over Streamable HTTP at McpOptions.UrlPath (default /mcp), registered via _builder.Use in Cleanup. Implements the MCP 2025-11-25 lifecycle subset: initialize (protocolVersion / capabilities.tools / serverInfo), notifications/* -> 202, ping -> {}, tools/list -> the catalog. Unknown method -> JSON-RPC -32601; malformed -> -32700; non-POST -> 405; MCP-Protocol-Version header. Mcp is now a partial class (Mcp.cs = annotation+catalog, McpServer.cs = transport). McpJsonContext (source-gen) serializes responses AOT-safely. 5 HTTP tests. Full suite green (2157). tools/call execution comes next (needs the public invoker). --- NpgsqlRestTests/McpTests/McpServerTests.cs | 63 +++++++++ plugins/NpgsqlRest.Mcp/Mcp.cs | 2 +- plugins/NpgsqlRest.Mcp/McpJsonContext.cs | 8 ++ plugins/NpgsqlRest.Mcp/McpServer.cs | 142 +++++++++++++++++++++ 4 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 NpgsqlRestTests/McpTests/McpServerTests.cs create mode 100644 plugins/NpgsqlRest.Mcp/McpJsonContext.cs create mode 100644 plugins/NpgsqlRest.Mcp/McpServer.cs diff --git a/NpgsqlRestTests/McpTests/McpServerTests.cs b/NpgsqlRestTests/McpTests/McpServerTests.cs new file mode 100644 index 00000000..be16a72a --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpServerTests.cs @@ -0,0 +1,63 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +[Collection("McpPluginFixture")] +public class McpServerTests(McpPluginTestFixture test) +{ + private async Task RpcAsync(string json) + { + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await test.Client.PostAsync("/mcp", content); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(); + return JsonNode.Parse(body)!; + } + + [Fact] + public async Task Initialize_returns_protocol_capabilities_and_serverinfo() + { + var r = await RpcAsync("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"""); + r["jsonrpc"]!.GetValue().Should().Be("2.0"); + r["id"]!.GetValue().Should().Be(1); + var result = r["result"]!; + result["protocolVersion"]!.GetValue().Should().Be("2025-11-25"); + result["capabilities"]!["tools"].Should().NotBeNull(); // tools capability advertised + result["serverInfo"]!["name"]!.GetValue().Should().Be("NpgsqlRest"); + } + + [Fact] + public async Task Tools_list_returns_the_catalog() + { + var r = await RpcAsync("""{"jsonrpc":"2.0","id":2,"method":"tools/list"}"""); + var tools = r["result"]!["tools"]!.AsArray(); + var basic = tools.FirstOrDefault(t => t!["name"]!.GetValue() == "tool_basic"); + basic.Should().NotBeNull(); + basic!["description"]!.GetValue().Should().Be("Fetch basic data for the agent."); + basic["inputSchema"]!["type"]!.GetValue().Should().Be("object"); + } + + [Fact] + public async Task Ping_returns_empty_result() + { + var r = await RpcAsync("""{"jsonrpc":"2.0","id":3,"method":"ping"}"""); + r["result"]!.AsObject().Count.Should().Be(0); + } + + [Fact] + public async Task Unknown_method_returns_jsonrpc_method_not_found() + { + var r = await RpcAsync("""{"jsonrpc":"2.0","id":4,"method":"bogus/method"}"""); + r["error"]!["code"]!.GetValue().Should().Be(-32601); + r["id"]!.GetValue().Should().Be(4); + } + + [Fact] + public async Task Initialized_notification_returns_202_with_no_body() + { + using var content = new StringContent( + """{"jsonrpc":"2.0","method":"notifications/initialized"}""", Encoding.UTF8, "application/json"); + using var response = await test.Client.PostAsync("/mcp", content); + response.StatusCode.Should().Be(HttpStatusCode.Accepted); + } +} diff --git a/plugins/NpgsqlRest.Mcp/Mcp.cs b/plugins/NpgsqlRest.Mcp/Mcp.cs index dd9da2dd..79d19b74 100644 --- a/plugins/NpgsqlRest.Mcp/Mcp.cs +++ b/plugins/NpgsqlRest.Mcp/Mcp.cs @@ -23,7 +23,7 @@ namespace NpgsqlRest.Mcp; /// `mcp` + `internal`. (With , `mcp` alone creates the /// endpoint even without an HTTP tag.) /// -public class Mcp(McpOptions options) : IEndpointCreateHandler +public partial class Mcp(McpOptions options) : IEndpointCreateHandler { /// Key under which is stored in . public const string ItemsKey = "mcp"; diff --git a/plugins/NpgsqlRest.Mcp/McpJsonContext.cs b/plugins/NpgsqlRest.Mcp/McpJsonContext.cs new file mode 100644 index 00000000..f9b82e90 --- /dev/null +++ b/plugins/NpgsqlRest.Mcp/McpJsonContext.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace NpgsqlRest.Mcp; + +// AOT-safe serialization of the JSON-RPC response objects (built as System.Text.Json.Nodes). +[JsonSerializable(typeof(JsonObject))] +internal partial class McpJsonContext : JsonSerializerContext; diff --git a/plugins/NpgsqlRest.Mcp/McpServer.cs b/plugins/NpgsqlRest.Mcp/McpServer.cs new file mode 100644 index 00000000..d109555d --- /dev/null +++ b/plugins/NpgsqlRest.Mcp/McpServer.cs @@ -0,0 +1,142 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace NpgsqlRest.Mcp; + +/// +/// MCP server transport: serves the JSON-RPC endpoint (Streamable HTTP, single endpoint) at +/// . Stateless, single application/json response — no SSE. +/// Implements the MCP 2025-11-25 lifecycle subset: initialize, notifications/initialized, ping, +/// tools/list. (tools/call arrives in a later increment.) +/// +public partial class Mcp +{ + private const string ProtocolVersion = "2025-11-25"; + + private IApplicationBuilder _builder = default!; + + public void Setup(IApplicationBuilder builder, NpgsqlRestOptions options) => _builder = builder; + + public void Cleanup() + { + if (!_options.Enabled) + { + return; + } + + var path = _options.UrlPath; + _builder.Use(async (context, next) => + { + if (string.Equals(context.Request.Path, path, StringComparison.Ordinal)) + { + await HandleAsync(context); + return; + } + await next(context); + }); + } + + private async Task HandleAsync(HttpContext context) + { + // POST carries JSON-RPC. (GET would open an SSE stream — not supported in the stateless model.) + if (!HttpMethods.IsPost(context.Request.Method)) + { + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + return; + } + + string body; + using (var reader = new StreamReader(context.Request.Body)) + { + body = await reader.ReadToEndAsync(context.RequestAborted); + } + + JsonNode? request; + try + { + request = JsonNode.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); + } + catch (JsonException) + { + await WriteResponseAsync(context, ErrorEnvelope(null, -32700, "Parse error")); + return; + } + + var method = request?["method"]?.GetValue(); + var id = request?["id"]?.DeepClone(); + + // Notifications carry no id and expect no response body. + if (method is not null && method.StartsWith("notifications/", StringComparison.Ordinal)) + { + context.Response.StatusCode = StatusCodes.Status202Accepted; + return; + } + + JsonObject? result = method switch + { + "initialize" => BuildInitializeResult(), + "ping" => new JsonObject(), + "tools/list" => BuildToolsListResult(), + _ => null, + }; + + if (result is null) + { + await WriteResponseAsync(context, ErrorEnvelope(id, -32601, $"Method not found: {method}")); + return; + } + + await WriteResponseAsync(context, new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["result"] = result, + }); + } + + private JsonObject BuildInitializeResult() + { + var result = new JsonObject + { + ["protocolVersion"] = ProtocolVersion, + ["capabilities"] = new JsonObject { ["tools"] = new JsonObject() }, + ["serverInfo"] = new JsonObject + { + ["name"] = string.IsNullOrWhiteSpace(_options.ServerName) ? "NpgsqlRest" : _options.ServerName, + ["version"] = string.IsNullOrWhiteSpace(_options.ServerVersion) ? "1.0.0" : _options.ServerVersion, + }, + }; + if (!string.IsNullOrWhiteSpace(_options.Instructions)) + { + result["instructions"] = _options.Instructions; + } + return result; + } + + private JsonObject BuildToolsListResult() + { + var tools = new JsonArray(); + foreach (var tool in _tools.Values) + { + tools.Add(tool.DeepClone()); // catalog entries are owned by _tools; clone before reparenting + } + return new JsonObject { ["tools"] = tools }; + } + + private static JsonObject ErrorEnvelope(JsonNode? id, int code, string message) => new() + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["error"] = new JsonObject { ["code"] = code, ["message"] = message }, + }; + + private static async Task WriteResponseAsync(HttpContext context, JsonObject payload) + { + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "application/json"; + context.Response.Headers["MCP-Protocol-Version"] = ProtocolVersion; + await context.Response.WriteAsync(JsonSerializer.Serialize(payload, McpJsonContext.Default.JsonObject), context.RequestAborted); + } +} From 05f93db0ef3938ee925491fbd16ccf49c7e50669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Mon, 8 Jun 2026 15:59:38 +0200 Subject: [PATCH 12/38] =?UTF-8?q?refactor:=20v3.17.0=20=E2=80=94=20drop=20?= =?UTF-8?q?vestigial=20InternalsVisibleTo=20to=20NpgsqlRest.TsClient=20plu?= =?UTF-8?q?gin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TsClient uses only core's public API (verified: clean no-incremental build with the IVT removed). Removing the plugin-facing InternalsVisibleTo keeps the core→plugin boundary public-only — no plugin reaches into core internals. Test-project IVTs (NpgsqlRestTests, BenchmarkTests) and the embedded client (NpgsqlRestClient) are kept. Full suite green (2157). --- NpgsqlRest/NpgsqlRest.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/NpgsqlRest/NpgsqlRest.csproj b/NpgsqlRest/NpgsqlRest.csproj index 6f216f06..4766dbd3 100644 --- a/NpgsqlRest/NpgsqlRest.csproj +++ b/NpgsqlRest/NpgsqlRest.csproj @@ -62,7 +62,6 @@ - From d26fde60d8eb223085ecdee2c5338ec59d9e8560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Mon, 8 Jun 2026 16:07:57 +0200 Subject: [PATCH 13/38] =?UTF-8?q?feat:=20v3.17.0=20=E2=80=94=20MCP=20tools?= =?UTF-8?q?/call=20execution=20via=20public=20RoutineInvoker=20(+=20Claims?= =?UTF-8?q?Principal=20forwarding)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public invoker (the supported surface for plugins/host code; no plugin IVT): - RoutineInvoker.InvokeAsync(method, path, headers?, body?, contentType?, user?, ct) → RoutineInvokeResult, running the full endpoint pipeline against a synthetic request. - InternalRequestHandler.ExecuteAsync gained a trailing ClaimsPrincipal? user param that sets context.User — the auth bridge: execution-level authorize + claims-to-param binding run as the forwarded principal. Existing callers unaffected (optional trailing). MCP tools/call (McpServer): - maps tool name → endpoint; BuildInvocation projects the flat arguments onto the endpoint's HTTP shape (path-param substitution, then query string or JSON body); - invokes via RoutineInvoker forwarding the /mcp request's principal; - envelope { content:[{type:text,text:body}], isError:!success }. Unknown tool → JSON-RPC -32602 (structural); execution failure → isError result (business). MCP server is now functionally complete for v1 (initialize / tools/list / tools/call, executing real routines). 3 tools/call tests; full suite green (2160). --- .../HttpClientType/InternalRequestHandler.cs | 10 ++- NpgsqlRest/RoutineInvoker.cs | 48 +++++++++++ NpgsqlRestTests/McpTests/McpServerTests.cs | 27 ++++++ plugins/NpgsqlRest.Mcp/Mcp.cs | 9 +- plugins/NpgsqlRest.Mcp/McpServer.cs | 82 +++++++++++++++++++ 5 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 NpgsqlRest/RoutineInvoker.cs diff --git a/NpgsqlRest/HttpClientType/InternalRequestHandler.cs b/NpgsqlRest/HttpClientType/InternalRequestHandler.cs index a831edc6..6e5ae1e5 100644 --- a/NpgsqlRest/HttpClientType/InternalRequestHandler.cs +++ b/NpgsqlRest/HttpClientType/InternalRequestHandler.cs @@ -44,7 +44,8 @@ internal static async Task ExecuteAsync( Dictionary? headers, string? body, string? contentType, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + System.Security.Claims.ClaimsPrincipal? user = null) { if (_endpointHandlers is null || _serviceProvider is null) { @@ -76,6 +77,13 @@ internal static async Task ExecuteAsync( var responseBody = new NonClosingMemoryStream(); var context = new DefaultHttpContext { RequestServices = scope.ServiceProvider }; + // Forward the caller's principal so execution-level @authorize / claims-to-parameter binding + // runs as the real user (e.g. an MCP tool call carrying the validated bearer principal). + if (user is not null) + { + context.User = user; + } + // Request setup context.Request.Method = method; context.Request.Scheme = "http"; diff --git a/NpgsqlRest/RoutineInvoker.cs b/NpgsqlRest/RoutineInvoker.cs new file mode 100644 index 00000000..0d9ba516 --- /dev/null +++ b/NpgsqlRest/RoutineInvoker.cs @@ -0,0 +1,48 @@ +using System.Security.Claims; +using NpgsqlRest.HttpClientType; + +namespace NpgsqlRest; + +/// +/// Result of a programmatic routine invocation via . +/// +public readonly record struct RoutineInvokeResult(int StatusCode, string? Body, string? ContentType, bool IsSuccess); + +/// +/// Public entry point for invoking an NpgsqlRest endpoint in-process (no network hop), running the +/// full endpoint pipeline against a synthetic request. This is the supported surface for plugins +/// (e.g. NpgsqlRest.Mcp's tools/call) and host code to execute routines — so plugins never need +/// access to core internals. +/// +/// Available only after UseNpgsqlRest has built the endpoints (see ). +/// Pass to run as a specific principal — execution-level authorization +/// (`authorize`) and claims-to-parameter binding then apply as for a real authenticated request. +/// +/// +public static class RoutineInvoker +{ + /// True once endpoints have been built and internal invocation is wired up. + public static bool IsAvailable => InternalRequestHandler.IsAvailable; + + /// + /// Invoke an endpoint by HTTP method + path (path may include a query string; templated paths + /// like /api/x/{id} are matched by passing a concrete path). Returns the rendered response. + /// + public static async Task InvokeAsync( + string method, + string path, + IDictionary? headers = null, + string? body = null, + string? contentType = null, + ClaimsPrincipal? user = null, + CancellationToken cancellationToken = default) + { + var headerDict = headers as Dictionary + ?? (headers is null ? null : new Dictionary(headers)); + + var response = await InternalRequestHandler.ExecuteAsync( + method, path, headerDict, body, contentType, cancellationToken, user); + + return new RoutineInvokeResult(response.StatusCode, response.Body, response.ContentType, response.IsSuccess); + } +} diff --git a/NpgsqlRestTests/McpTests/McpServerTests.cs b/NpgsqlRestTests/McpTests/McpServerTests.cs index be16a72a..2ed57524 100644 --- a/NpgsqlRestTests/McpTests/McpServerTests.cs +++ b/NpgsqlRestTests/McpTests/McpServerTests.cs @@ -60,4 +60,31 @@ public async Task Initialized_notification_returns_202_with_no_body() using var response = await test.Client.PostAsync("/mcp", content); response.StatusCode.Should().Be(HttpStatusCode.Accepted); } + + [Fact] + public async Task Tools_call_executes_the_routine_and_returns_text_content() + { + var r = await RpcAsync("""{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"tool_basic","arguments":{}}}"""); + var result = r["result"]!; + result["isError"]!.GetValue().Should().BeFalse(); + result["content"]!.AsArray()[0]!["type"]!.GetValue().Should().Be("text"); + result["content"]!.AsArray()[0]!["text"]!.GetValue().Should().Be("basic"); + } + + [Fact] + public async Task Tools_call_passes_arguments_through() + { + // tool_params(id int, label default) ignores its args (returns 'p'); this exercises the + // query-string build path without error. + var r = await RpcAsync("""{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"tool_params","arguments":{"id":5}}}"""); + r["result"]!["isError"]!.GetValue().Should().BeFalse(); + r["result"]!["content"]!.AsArray()[0]!["text"]!.GetValue().Should().Be("p"); + } + + [Fact] + public async Task Tools_call_unknown_tool_is_a_jsonrpc_error() + { + var r = await RpcAsync("""{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"nope","arguments":{}}}"""); + r["error"]!["code"]!.GetValue().Should().Be(-32602); + } } diff --git a/plugins/NpgsqlRest.Mcp/Mcp.cs b/plugins/NpgsqlRest.Mcp/Mcp.cs index 79d19b74..9c9d0a78 100644 --- a/plugins/NpgsqlRest.Mcp/Mcp.cs +++ b/plugins/NpgsqlRest.Mcp/Mcp.cs @@ -32,6 +32,9 @@ public partial class Mcp(McpOptions options) : IEndpointCreateHandler private readonly Dictionary _tools = new(StringComparer.Ordinal); + // tool name → endpoint, for tools/call execution. + private readonly Dictionary _toolEndpoints = new(StringComparer.Ordinal); + /// /// The MCP tool catalog, keyed by tool name. Built during endpoint creation () /// from the routines opted in via the `mcp` annotation. Each value is a tools/list `Tool` object @@ -86,7 +89,11 @@ public void Handle(RoutineEndpoint endpoint) var tool = BuildTool(endpoint, info); var name = tool["name"]!.GetValue(); - if (!_tools.TryAdd(name, tool)) + if (_tools.TryAdd(name, tool)) + { + _toolEndpoints[name] = endpoint; + } + else { // Tool names must be unique (e.g. overloaded routines collide). Keep the first; log the rest. // TODO: overload disambiguation (mcp_name, or a typed/arity suffix). diff --git a/plugins/NpgsqlRest.Mcp/McpServer.cs b/plugins/NpgsqlRest.Mcp/McpServer.cs index d109555d..e12a2cd9 100644 --- a/plugins/NpgsqlRest.Mcp/McpServer.cs +++ b/plugins/NpgsqlRest.Mcp/McpServer.cs @@ -74,6 +74,12 @@ private async Task HandleAsync(HttpContext context) return; } + if (method == "tools/call") + { + await HandleToolCallAsync(context, request, id); + return; + } + JsonObject? result = method switch { "initialize" => BuildInitializeResult(), @@ -96,6 +102,82 @@ private async Task HandleAsync(HttpContext context) }); } + private async Task HandleToolCallAsync(HttpContext context, JsonNode? request, JsonNode? id) + { + var prms = request?["params"]; + var name = prms?["name"]?.GetValue(); + if (name is null || !_toolEndpoints.TryGetValue(name, out var endpoint)) + { + // Structural failure → JSON-RPC error (not a tool result). + await WriteResponseAsync(context, ErrorEnvelope(id, -32602, $"Unknown tool: {name}")); + return; + } + + var (httpMethod, path, body, contentType) = BuildInvocation(endpoint, prms?["arguments"] as JsonObject); + + // Forward the /mcp request's principal so the routine's authorize/claims binding applies. + var invoke = await RoutineInvoker.InvokeAsync( + httpMethod, path, headers: null, body: body, contentType: contentType, + user: context.User, cancellationToken: context.RequestAborted); + + // Business/execution outcome → a normal result with isError; the routine's response is the + // text content verbatim. (Only structural problems above use a JSON-RPC error.) + var content = new JsonArray(); + content.Add(new JsonObject { ["type"] = "text", ["text"] = invoke.Body ?? string.Empty }); + + var toolResult = new JsonObject + { + ["content"] = content, + ["isError"] = !invoke.IsSuccess, + }; + + await WriteResponseAsync(context, new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["result"] = toolResult, + }); + } + + /// + /// Maps a flat MCP arguments object onto the endpoint's HTTP shape: path-parameter substitution, + /// then query string (QueryString endpoints) or a JSON body (BodyJson endpoints). + /// + private static (string Method, string Path, string? Body, string? ContentType) BuildInvocation( + RoutineEndpoint endpoint, JsonObject? arguments) + { + var args = arguments is null ? new JsonObject() : (JsonObject)arguments.DeepClone(); + var path = endpoint.Path; + + if (endpoint.PathParameters is { Length: > 0 } pathParams) + { + foreach (var pp in pathParams) + { + if (args.TryGetPropertyValue(pp, out var v) && v is not null) + { + path = path.Replace("{" + pp + "}", Uri.EscapeDataString(v.ToString())); + args.Remove(pp); + } + } + } + + var method = endpoint.Method.ToString(); + + if (endpoint.RequestParamType == RequestParamType.BodyJson) + { + return (method, path, args.ToJsonString(), "application/json"); + } + + // QueryString endpoints (GET/DELETE by default). + if (args.Count > 0) + { + var query = string.Join("&", args.Select(kv => + string.Concat(Uri.EscapeDataString(kv.Key), "=", Uri.EscapeDataString(kv.Value?.ToString() ?? string.Empty)))); + path = string.Concat(path, path.Contains('?') ? "&" : "?", query); + } + return (method, path, null, null); + } + private JsonObject BuildInitializeResult() { var result = new JsonObject From 35fb6e308047b519266c509fa40bfafedde7db94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Mon, 8 Jun 2026 16:12:12 +0200 Subject: [PATCH 14/38] =?UTF-8?q?feat:=20v3.17.0=20=E2=80=94=20wire=20Npgs?= =?UTF-8?q?qlRest.Mcp=20into=20the=20standalone=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NpgsqlRestClient now references the Mcp plugin and registers it from a McpOptions config section in CreateCodeGenHandlers (Enabled / UrlPath / ServerName / ServerVersion / Instructions). Disabled by default — when McpOptions:Enabled is not true the server is not registered (no behavior change). Full suite green (2160). Discoverability (appsettings/ConfigTemplate/ConfigDefaults/schema McpOptions section) and docs follow in a separate change. --- NpgsqlRestClient/App.cs | 16 ++++++++++++++++ NpgsqlRestClient/NpgsqlRestClient.csproj | 1 + 2 files changed, 17 insertions(+) diff --git a/NpgsqlRestClient/App.cs b/NpgsqlRestClient/App.cs index 64caad08..b8eb07c9 100644 --- a/NpgsqlRestClient/App.cs +++ b/NpgsqlRestClient/App.cs @@ -10,6 +10,7 @@ using NpgsqlRest.UploadHandlers; using NpgsqlRest.Auth; using NpgsqlRest.OpenAPI; +using NpgsqlRest.Mcp; namespace NpgsqlRestClient; @@ -576,6 +577,21 @@ public List CreateCodeGenHandlers(string connectionStrin _builder.ClientLogger?.LogDebug("TypeScript client code generation enabled. FilePath={FilePath}", ts.FilePath); } + var mcpCfg = _config.NpgsqlRestCfg.GetSection("McpOptions"); + if (mcpCfg is not null && _config.GetConfigBool("Enabled", mcpCfg) is true) + { + handlers.Add(new Mcp(new McpOptions + { + Enabled = true, + UrlPath = _config.GetConfigStr("UrlPath", mcpCfg) ?? "/mcp", + ServerName = _config.GetConfigStr("ServerName", mcpCfg), + ServerVersion = _config.GetConfigStr("ServerVersion", mcpCfg), + Instructions = _config.GetConfigStr("Instructions", mcpCfg), + })); + _builder.ClientLogger?.LogDebug("MCP server enabled. UrlPath={UrlPath}", + _config.GetConfigStr("UrlPath", mcpCfg) ?? "/mcp"); + } + return handlers; } diff --git a/NpgsqlRestClient/NpgsqlRestClient.csproj b/NpgsqlRestClient/NpgsqlRestClient.csproj index b3a11edb..0fc8aea3 100644 --- a/NpgsqlRestClient/NpgsqlRestClient.csproj +++ b/NpgsqlRestClient/NpgsqlRestClient.csproj @@ -31,6 +31,7 @@ + From 0aa89d3377bba43834fbe517a2c3ac203f60e96b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Mon, 8 Jun 2026 16:37:27 +0200 Subject: [PATCH 15/38] =?UTF-8?q?feat:=20v3.17.0=20=E2=80=94=20McpOptions?= =?UTF-8?q?=20config=20section=20+=20ServerName/ServerVersion/ToolDescript?= =?UTF-8?q?ionSuffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5b: surface the MCP plugin's options through the client config-description system (appsettings.json, ConfigTemplate, ConfigDefaults, ConfigSchemaGenerator), so they appear in --config, --config-schema, and the JSON schema. McpOptions is disabled by default. Refinements: - ServerName: when null, the initialize serverInfo.name falls back to the database name from the connection string (mirrors the OpenAPI document title), then to "NpgsqlRest". McpServer.Setup now stores options.ConnectionString. - ServerVersion: concrete default "1.0.0" (config + McpOptions); null/blank still falls back to "1.0.0". - ToolDescriptionSuffix (new McpOptions key): optional short text appended to every tool description in tools/list; complements Instructions. null = no-op. Tests: ToolDescriptionSuffix appended to every tool; initialize reports the DB name. Config round-trip stays byte-identical. --- NpgsqlRestClient/App.cs | 1 + NpgsqlRestClient/ConfigDefaults.cs | 14 ++++++++ NpgsqlRestClient/ConfigSchemaGenerator.cs | 6 ++++ NpgsqlRestClient/ConfigTemplate.cs | 28 +++++++++++++++- NpgsqlRestClient/appsettings.json | 28 +++++++++++++++- .../McpTests/McpPluginAnnotationTests.cs | 15 +++++++++ NpgsqlRestTests/McpTests/McpServerTests.cs | 3 +- changelog/v3.17.0.md | 16 ++++++++++ plugins/NpgsqlRest.Mcp/Mcp.cs | 5 +++ plugins/NpgsqlRest.Mcp/McpOptions.cs | 11 +++++-- plugins/NpgsqlRest.Mcp/McpServer.cs | 32 +++++++++++++++++-- 11 files changed, 152 insertions(+), 7 deletions(-) diff --git a/NpgsqlRestClient/App.cs b/NpgsqlRestClient/App.cs index b8eb07c9..ce3027e1 100644 --- a/NpgsqlRestClient/App.cs +++ b/NpgsqlRestClient/App.cs @@ -587,6 +587,7 @@ public List CreateCodeGenHandlers(string connectionStrin ServerName = _config.GetConfigStr("ServerName", mcpCfg), ServerVersion = _config.GetConfigStr("ServerVersion", mcpCfg), Instructions = _config.GetConfigStr("Instructions", mcpCfg), + ToolDescriptionSuffix = _config.GetConfigStr("ToolDescriptionSuffix", mcpCfg), })); _builder.ClientLogger?.LogDebug("MCP server enabled. UrlPath={UrlPath}", _config.GetConfigStr("UrlPath", mcpCfg) ?? "/mcp"); diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index dab3fbdc..5207ab16 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -821,6 +821,7 @@ private static JsonObject GetNpgsqlRestDefaults() ["AuthenticationOptions"] = GetAuthenticationOptionsDefaults(), ["HttpFileOptions"] = GetHttpFileOptionsDefaults(), ["OpenApiOptions"] = GetOpenApiOptionsDefaults(), + ["McpOptions"] = GetMcpOptionsDefaults(), ["ClientCodeGen"] = GetClientCodeGenDefaults(), ["HttpClientOptions"] = GetHttpClientOptionsDefaults(), ["ProxyOptions"] = GetProxyOptionsDefaults(), @@ -969,6 +970,19 @@ private static JsonObject GetOpenApiOptionsDefaults() }; } + private static JsonObject GetMcpOptionsDefaults() + { + return new JsonObject + { + ["Enabled"] = false, + ["UrlPath"] = "/mcp", + ["ServerName"] = null, + ["ServerVersion"] = "1.0.0", + ["Instructions"] = null, + ["ToolDescriptionSuffix"] = null + }; + } + private static JsonObject GetClientCodeGenDefaults() { return new JsonObject diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index 2a46b950..eecd53c1 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -437,6 +437,12 @@ public static partial class ConfigSchemaGenerator ["NpgsqlRest:OpenApiOptions:NameSimilarTo"] = "PostgreSQL-style SIMILAR TO pattern matched against the routine name. When set, only routines whose name matches are documented in the OpenAPI spec. `_` matches one char, `%` matches any sequence; the rest of SIMILAR TO syntax (|, *, +, ?, (...), [...]) is supported. Anchored. Default null = no name filter.", ["NpgsqlRest:OpenApiOptions:NameNotSimilarTo"] = "PostgreSQL-style SIMILAR TO pattern matched against the routine name for EXCLUSION in the OpenAPI spec. Same syntax as NameSimilarTo. Applied alongside it — both must pass. Default null = no name exclusion.", ["NpgsqlRest:OpenApiOptions:RequiresAuthorizationOnly"] = "When true, only authenticated endpoints are documented in the OpenAPI spec. Anonymous endpoints (health, login, probes) are omitted. Default false = document everything.", + ["NpgsqlRest:McpOptions"] = "Enable or disable the MCP (Model Context Protocol) server endpoint. Disabled by default. Tools are never auto-exposed: only routines explicitly opted in with the `mcp` comment annotation become MCP tools. Implements MCP specification 2025-11-25.", + ["NpgsqlRest:McpOptions:UrlPath"] = "URL path for the MCP endpoint (Streamable HTTP, single JSON-RPC endpoint). Default \"/mcp\".", + ["NpgsqlRest:McpOptions:ServerName"] = "serverInfo.name reported in the MCP initialize handshake. When null, the database name from the connection string is used (falling back to \"NpgsqlRest\").", + ["NpgsqlRest:McpOptions:ServerVersion"] = "serverInfo.version reported in the MCP initialize handshake.", + ["NpgsqlRest:McpOptions:Instructions"] = "Optional server-level instructions returned in the MCP initialize handshake (high-level guidance for the agent). Null = omitted.", + ["NpgsqlRest:McpOptions:ToolDescriptionSuffix"] = "Optional text appended to every MCP tool description. Null = no-op. Use for short shared context the agent should always see (e.g. \"Read-only Acme CRM.\"); for longer server-wide guidance prefer Instructions.", ["NpgsqlRest:ClientCodeGen"] = "Enable or disable the generation of TypeScript/Javascript client source code files for NpgsqlRest endpoints.", ["NpgsqlRest:ClientCodeGen:FilePath"] = "File path for the generated code. Set to null to skip the code generation. Use {0} to set schema name when BySchema is true", ["NpgsqlRest:ClientCodeGen:FileOverwrite"] = "Force file overwrite.", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index 8398a387..1c3efbac 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -2564,7 +2564,33 @@ public static partial class ConfigSchemaGenerator // "RequiresAuthorizationOnly": false }, - + // + // Enable or disable the MCP (Model Context Protocol) server endpoint. Disabled by default. Tools are NEVER auto-exposed: only routines explicitly opted in with the `mcp` comment annotation become MCP tools. Implements MCP specification 2025-11-25. + // + "McpOptions": { + "Enabled": false, + // + // URL path for the MCP endpoint (Streamable HTTP, single JSON-RPC endpoint). + // + "UrlPath": "/mcp", + // + // serverInfo.name reported in the MCP initialize handshake. When null, the database name from the connection string is used (falling back to "NpgsqlRest"). + // + "ServerName": null, + // + // serverInfo.version reported in the MCP initialize handshake. + // + "ServerVersion": "1.0.0", + // + // Optional server-level instructions returned in the MCP initialize handshake (high-level guidance for the agent). + // + "Instructions": null, + // + // Optional text appended to every MCP tool description. Null = no-op. Use for short shared context the agent should always see (e.g. "Read-only Acme CRM."); for longer server-wide guidance prefer Instructions. + // + "ToolDescriptionSuffix": null + }, + // // Enable or disable the generation of TypeScript/Javascript client source code files for NpgsqlRest endpoints. // diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index 4d9bcf7a..cdacdb59 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -2555,7 +2555,33 @@ // "RequiresAuthorizationOnly": false }, - + // + // Enable or disable the MCP (Model Context Protocol) server endpoint. Disabled by default. Tools are NEVER auto-exposed: only routines explicitly opted in with the `mcp` comment annotation become MCP tools. Implements MCP specification 2025-11-25. + // + "McpOptions": { + "Enabled": false, + // + // URL path for the MCP endpoint (Streamable HTTP, single JSON-RPC endpoint). + // + "UrlPath": "/mcp", + // + // serverInfo.name reported in the MCP initialize handshake. When null, the database name from the connection string is used (falling back to "NpgsqlRest"). + // + "ServerName": null, + // + // serverInfo.version reported in the MCP initialize handshake. + // + "ServerVersion": "1.0.0", + // + // Optional server-level instructions returned in the MCP initialize handshake (high-level guidance for the agent). + // + "Instructions": null, + // + // Optional text appended to every MCP tool description. Null = no-op. Use for short shared context the agent should always see (e.g. "Read-only Acme CRM."); for longer server-wide guidance prefer Instructions. + // + "ToolDescriptionSuffix": null + }, + // // Enable or disable the generation of TypeScript/Javascript client source code files for NpgsqlRest endpoints. // diff --git a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs index ff4cd5ec..2379ca59 100644 --- a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs +++ b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs @@ -171,6 +171,21 @@ public void Description_derives_from_prose_inline_text_and_falls_back_to_name() test.Tools["tool_nodesc"]!["description"]!.GetValue().Should().Be("tool_nodesc"); // fallback } + [Fact] + public void ToolDescriptionSuffix_is_appended_to_every_tool_description() + { + // A fresh plugin instance with a suffix, fed the same parsed endpoints. Handle() only reads the + // endpoint and writes to its own catalog, so the fixture's suffix-less Tools are unaffected. + var mcp = new Mcp(new McpOptions { Enabled = true, ToolDescriptionSuffix = "(Acme demo — read-only.)" }); + mcp.Handle(test.Endpoints["tool_basic"]); // derived-prose description + mcp.Handle(test.Endpoints["tool_nodesc"]); // name-fallback description + + mcp.Tools["tool_basic"]!["description"]!.GetValue() + .Should().Be("Fetch basic data for the agent. (Acme demo — read-only.)"); + mcp.Tools["tool_nodesc"]!["description"]!.GetValue() + .Should().Be("tool_nodesc (Acme demo — read-only.)"); + } + [Fact] public void InputSchema_lists_params_and_marks_only_non_default_as_required() { diff --git a/NpgsqlRestTests/McpTests/McpServerTests.cs b/NpgsqlRestTests/McpTests/McpServerTests.cs index 2ed57524..a8199f78 100644 --- a/NpgsqlRestTests/McpTests/McpServerTests.cs +++ b/NpgsqlRestTests/McpTests/McpServerTests.cs @@ -23,7 +23,8 @@ public async Task Initialize_returns_protocol_capabilities_and_serverinfo() var result = r["result"]!; result["protocolVersion"]!.GetValue().Should().Be("2025-11-25"); result["capabilities"]!["tools"].Should().NotBeNull(); // tools capability advertised - result["serverInfo"]!["name"]!.GetValue().Should().Be("NpgsqlRest"); + // ServerName is unset in the fixture, so it falls back to the database name from the connection string. + result["serverInfo"]!["name"]!.GetValue().Should().Be("npgsql_rest_test"); } [Fact] diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index 879b76c8..a4a3fefe 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -12,6 +12,22 @@ The rolled-up bug-fixes: JSON command parameters now accept `json`/`jsonb`/`text ## What changed +### MCP (Model Context Protocol) server — headline feature (new `NpgsqlRest.Mcp` plugin) + +NpgsqlRest can now project explicitly opted-in PostgreSQL routines as **MCP tools**, so an AI agent can discover them (`tools/list`) and execute them (`tools/call`) over the [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-11-25) (spec **2025-11-25**). The entire MCP layer lives in a new plugin — **core stays protocol-agnostic** and gains no MCP-specific code (it is built on the neutral extension points described below). + +- **Opt-in, never automatic.** No routine is exposed unless its PostgreSQL comment carries the `mcp` annotation. Nothing is auto-published. + - `mcp` — expose this routine as a tool; the description is derived from the routine's comment prose. + - `mcp ` — expose, with `` as the tool description (override). + - `mcp_name ` — override the tool name (defaults to the routine name). + - **MCP-only (no HTTP route):** compose with the existing `internal` annotation (`mcp` + `internal`) — the routine becomes an MCP tool with no REST endpoint. All other annotations (`authorize`, parameter handling, etc.) apply equally. +- **Single Streamable-HTTP JSON-RPC endpoint** (default `/mcp`, POST). Implements the lifecycle (`initialize` advertising the `tools` capability + `serverInfo`, `notifications/initialized` → `202`, `ping`), `tools/list` (catalog with JSON-Schema `inputSchema` derived from the routine's parameters), and `tools/call`. +- **`tools/call` executes the real routine** through the same invocation pipeline as the HTTP endpoint, **forwarding the authenticated `ClaimsPrincipal`** so `authorize` role checks apply. Results use the MCP envelope (`{ "content": [{ "type": "text", "text": … }], "isError": … }`). Two error channels per spec: business failures → `isError: true` in the result; structural failures (unknown method, unknown tool, parse error) → JSON-RPC errors (`-32601` / `-32602` / `-32700`). +- **AOT-safe.** The JSON-RPC layer is hand-rolled over `System.Text.Json.Nodes` with a source-generated `JsonSerializerContext` — no reflection-based serialization, consistent with the project's `PublishAot` build. +- **Config (`NpgsqlRest:McpOptions`, disabled by default):** `Enabled` (default `false`), `UrlPath` (default `/mcp`), `ServerName` (initialize `serverInfo.name`; null → database name from the connection string, falling back to `"NpgsqlRest"`), `ServerVersion` (default `"1.0.0"`), `Instructions` (optional server-level guidance returned at `initialize`), `ToolDescriptionSuffix` (optional short text appended to every tool description — complements `Instructions` for context the agent should see per-tool). Surfaced in `--config`, `--config-schema`, and the JSON-schema description set. + +> Endpoint-level auth: the `/mcp` endpoint is currently protected by whatever authentication the host configures; an OAuth 2.1 Resource Server profile (RFC 9728 / RFC 8707) for the endpoint is a follow-up within this release line. + ### Plugin extension points on `RoutineEndpoint` (new) + OpenAPI annotation handling (⚠️ breaking API) Neutral, plugin-facing extension points were added so plugins can own their comment annotations without leaking plugin concepts into core, and without each plugin re-walking the comment: diff --git a/plugins/NpgsqlRest.Mcp/Mcp.cs b/plugins/NpgsqlRest.Mcp/Mcp.cs index 9c9d0a78..ec1920ba 100644 --- a/plugins/NpgsqlRest.Mcp/Mcp.cs +++ b/plugins/NpgsqlRest.Mcp/Mcp.cs @@ -117,6 +117,11 @@ private JsonObject BuildTool(RoutineEndpoint endpoint, McpToolInfo info) description = routine.Name; Logger?.LogWarning("MCP tool '{Name}' has no description — provide `mcp ` or comment prose so agents call it well.", name); } + // Optional shared suffix injected into every tool description (McpOptions.ToolDescriptionSuffix). + if (!string.IsNullOrWhiteSpace(_options.ToolDescriptionSuffix)) + { + description = $"{description} {_options.ToolDescriptionSuffix.Trim()}"; + } var properties = new JsonObject(); var required = new JsonArray(); diff --git a/plugins/NpgsqlRest.Mcp/McpOptions.cs b/plugins/NpgsqlRest.Mcp/McpOptions.cs index 5f021774..ec485950 100644 --- a/plugins/NpgsqlRest.Mcp/McpOptions.cs +++ b/plugins/NpgsqlRest.Mcp/McpOptions.cs @@ -17,9 +17,16 @@ public class McpOptions /// serverInfo.name reported in the initialize handshake. Null = database name (or "NpgsqlRest"). public string? ServerName { get; set; } = null; - /// serverInfo.version reported in the initialize handshake. Null = application version. - public string? ServerVersion { get; set; } = null; + /// serverInfo.version reported in the initialize handshake. Defaults to "1.0.0"; a null/blank value also falls back to "1.0.0". + public string? ServerVersion { get; set; } = "1.0.0"; /// Optional server-level instructions returned in the initialize handshake. public string? Instructions { get; set; } = null; + + /// + /// Optional text appended (as a suffix) to every MCP tool's description. Null = no-op. Use for short, + /// shared per-tool context the agent should always see (e.g. "Read-only Acme CRM."). For longer + /// server-wide guidance prefer , which is returned once at initialize. + /// + public string? ToolDescriptionSuffix { get; set; } = null; } diff --git a/plugins/NpgsqlRest.Mcp/McpServer.cs b/plugins/NpgsqlRest.Mcp/McpServer.cs index e12a2cd9..762887a9 100644 --- a/plugins/NpgsqlRest.Mcp/McpServer.cs +++ b/plugins/NpgsqlRest.Mcp/McpServer.cs @@ -2,6 +2,7 @@ using System.Text.Json.Nodes; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Npgsql; namespace NpgsqlRest.Mcp; @@ -16,8 +17,13 @@ public partial class Mcp private const string ProtocolVersion = "2025-11-25"; private IApplicationBuilder _builder = default!; + private string? _connectionString; - public void Setup(IApplicationBuilder builder, NpgsqlRestOptions options) => _builder = builder; + public void Setup(IApplicationBuilder builder, NpgsqlRestOptions options) + { + _builder = builder; + _connectionString = options.ConnectionString; + } public void Cleanup() { @@ -186,7 +192,7 @@ private JsonObject BuildInitializeResult() ["capabilities"] = new JsonObject { ["tools"] = new JsonObject() }, ["serverInfo"] = new JsonObject { - ["name"] = string.IsNullOrWhiteSpace(_options.ServerName) ? "NpgsqlRest" : _options.ServerName, + ["name"] = GetServerName(), ["version"] = string.IsNullOrWhiteSpace(_options.ServerVersion) ? "1.0.0" : _options.ServerVersion, }, }; @@ -197,6 +203,28 @@ private JsonObject BuildInitializeResult() return result; } + /// + /// serverInfo.name for the initialize handshake. Explicit wins; + /// otherwise the database name from the connection string is used (mirrors the OpenAPI document title), + /// falling back to "NpgsqlRest" when it cannot be resolved. + /// + private string GetServerName() + { + if (!string.IsNullOrWhiteSpace(_options.ServerName)) + { + return _options.ServerName; + } + if (!string.IsNullOrWhiteSpace(_connectionString)) + { + var database = new NpgsqlConnectionStringBuilder(_connectionString).Database; + if (!string.IsNullOrWhiteSpace(database)) + { + return database; + } + } + return "NpgsqlRest"; + } + private JsonObject BuildToolsListResult() { var tools = new JsonArray(); From 622c43d2a68724077278cb72186648fc2011656f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Mon, 8 Jun 2026 16:49:08 +0200 Subject: [PATCH 16/38] =?UTF-8?q?feat:=20v3.17.0=20=E2=80=94=20MCP=20OAuth?= =?UTF-8?q?=202.1=20Resource=20Server=20(PRM=20+=20transport=20gate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4a: /mcp acts as an OAuth 2.1 Resource Server (bring-your-own Authorization Server). Token validation reuses the host's bearer auth; NpgsqlRest is not an Authorization Server. - McpAuthorizationOptions (McpOptions.Authorization): RequireAuthorization, AuthorizationServers, ScopesSupported, Audience, ProtectedResourceMetadataPath. - Protected Resource Metadata (RFC 9728) served at /.well-known/oauth-protected-resource{UrlPath} when an AS is configured; resource = Audience ?? request-origin + UrlPath (RFC 8707); bearer_methods_supported = ["header"]. - Transport gate: RequireAuthorization + unauthenticated principal -> 401 with WWW-Authenticate: Bearer resource_metadata="..." (RFC 9728 5.1). The PRM document stays anonymous even when the gate is on. - Wired in NpgsqlRestClient (Authorization sub-section) + full 4-file config set (appsettings/ConfigTemplate/ConfigDefaults/ConfigSchemaGenerator); --config round-trip stays byte-identical. Tests: PRM document shape; 401 challenge points at the PRM; PRM anonymous under the gate (new McpAuthGateTestFixture). Layer-2 (403 insufficient_scope + tools/list filtering) is a follow-up. --- NpgsqlRestClient/App.cs | 9 +++ NpgsqlRestClient/ConfigDefaults.cs | 10 ++- NpgsqlRestClient/ConfigSchemaGenerator.cs | 6 ++ NpgsqlRestClient/ConfigTemplate.cs | 27 ++++++- NpgsqlRestClient/appsettings.json | 27 ++++++- NpgsqlRestTests/McpTests/McpAuthGateTests.cs | 30 +++++++ NpgsqlRestTests/McpTests/McpServerTests.cs | 19 +++++ .../Setup/McpAuthGateTestFixture.cs | 63 +++++++++++++++ NpgsqlRestTests/Setup/McpPluginTestFixture.cs | 12 ++- changelog/v3.17.0.md | 7 +- .../NpgsqlRest.Mcp/McpAuthorizationOptions.cs | 36 +++++++++ plugins/NpgsqlRest.Mcp/McpOptions.cs | 3 + plugins/NpgsqlRest.Mcp/McpServer.cs | 80 +++++++++++++++++++ 13 files changed, 323 insertions(+), 6 deletions(-) create mode 100644 NpgsqlRestTests/McpTests/McpAuthGateTests.cs create mode 100644 NpgsqlRestTests/Setup/McpAuthGateTestFixture.cs create mode 100644 plugins/NpgsqlRest.Mcp/McpAuthorizationOptions.cs diff --git a/NpgsqlRestClient/App.cs b/NpgsqlRestClient/App.cs index ce3027e1..edeb62fb 100644 --- a/NpgsqlRestClient/App.cs +++ b/NpgsqlRestClient/App.cs @@ -580,6 +580,7 @@ public List CreateCodeGenHandlers(string connectionStrin var mcpCfg = _config.NpgsqlRestCfg.GetSection("McpOptions"); if (mcpCfg is not null && _config.GetConfigBool("Enabled", mcpCfg) is true) { + var mcpAuthCfg = mcpCfg.GetSection("Authorization"); handlers.Add(new Mcp(new McpOptions { Enabled = true, @@ -588,6 +589,14 @@ public List CreateCodeGenHandlers(string connectionStrin ServerVersion = _config.GetConfigStr("ServerVersion", mcpCfg), Instructions = _config.GetConfigStr("Instructions", mcpCfg), ToolDescriptionSuffix = _config.GetConfigStr("ToolDescriptionSuffix", mcpCfg), + Authorization = new McpAuthorizationOptions + { + RequireAuthorization = _config.GetConfigBool("RequireAuthorization", mcpAuthCfg), + AuthorizationServers = [.. _config.GetConfigEnumerable("AuthorizationServers", mcpAuthCfg) ?? []], + ScopesSupported = [.. _config.GetConfigEnumerable("ScopesSupported", mcpAuthCfg) ?? []], + Audience = _config.GetConfigStr("Audience", mcpAuthCfg), + ProtectedResourceMetadataPath = _config.GetConfigStr("ProtectedResourceMetadataPath", mcpAuthCfg), + }, })); _builder.ClientLogger?.LogDebug("MCP server enabled. UrlPath={UrlPath}", _config.GetConfigStr("UrlPath", mcpCfg) ?? "/mcp"); diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index 5207ab16..3690ca80 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -979,7 +979,15 @@ private static JsonObject GetMcpOptionsDefaults() ["ServerName"] = null, ["ServerVersion"] = "1.0.0", ["Instructions"] = null, - ["ToolDescriptionSuffix"] = null + ["ToolDescriptionSuffix"] = null, + ["Authorization"] = new JsonObject + { + ["RequireAuthorization"] = false, + ["AuthorizationServers"] = new JsonArray(), + ["ScopesSupported"] = new JsonArray(), + ["Audience"] = null, + ["ProtectedResourceMetadataPath"] = null + } }; } diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index eecd53c1..85c973d8 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -443,6 +443,12 @@ public static partial class ConfigSchemaGenerator ["NpgsqlRest:McpOptions:ServerVersion"] = "serverInfo.version reported in the MCP initialize handshake.", ["NpgsqlRest:McpOptions:Instructions"] = "Optional server-level instructions returned in the MCP initialize handshake (high-level guidance for the agent). Null = omitted.", ["NpgsqlRest:McpOptions:ToolDescriptionSuffix"] = "Optional text appended to every MCP tool description. Null = no-op. Use for short shared context the agent should always see (e.g. \"Read-only Acme CRM.\"); for longer server-wide guidance prefer Instructions.", + ["NpgsqlRest:McpOptions:Authorization"] = "OAuth 2.1 Resource Server settings. Token validation reuses the host's bearer authentication; these keys configure the transport gate and the Protected Resource Metadata document (RFC 9728). NpgsqlRest is not an Authorization Server — point AuthorizationServers at an external IdP.", + ["NpgsqlRest:McpOptions:Authorization:RequireAuthorization"] = "When true, every MCP request requires an authenticated principal. When false (default), anonymous is allowed and a tool's own `authorize` annotation still gates it per call.", + ["NpgsqlRest:McpOptions:Authorization:AuthorizationServers"] = "Authorization Server issuer URL(s) advertised in the Protected Resource Metadata. When empty, no PRM document is served.", + ["NpgsqlRest:McpOptions:Authorization:ScopesSupported"] = "Optional scopes advertised in the Protected Resource Metadata (scopes_supported).", + ["NpgsqlRest:McpOptions:Authorization:Audience"] = "Canonical resource URI tokens must target (RFC 8707 audience) and the PRM \"resource\" value. Null = derived from the request (scheme + host + UrlPath).", + ["NpgsqlRest:McpOptions:Authorization:ProtectedResourceMetadataPath"] = "Path the Protected Resource Metadata document is served at. Null = the RFC 9728 well-known path derived from UrlPath.", ["NpgsqlRest:ClientCodeGen"] = "Enable or disable the generation of TypeScript/Javascript client source code files for NpgsqlRest endpoints.", ["NpgsqlRest:ClientCodeGen:FilePath"] = "File path for the generated code. Set to null to skip the code generation. Use {0} to set schema name when BySchema is true", ["NpgsqlRest:ClientCodeGen:FileOverwrite"] = "Force file overwrite.", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index 1c3efbac..14569f04 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -2588,7 +2588,32 @@ public static partial class ConfigSchemaGenerator // // Optional text appended to every MCP tool description. Null = no-op. Use for short shared context the agent should always see (e.g. "Read-only Acme CRM."); for longer server-wide guidance prefer Instructions. // - "ToolDescriptionSuffix": null + "ToolDescriptionSuffix": null, + // + // OAuth 2.1 Resource Server settings. Token validation reuses the host's bearer authentication; these keys configure the transport gate and the Protected Resource Metadata document (RFC 9728). NpgsqlRest is not an Authorization Server — point AuthorizationServers at an external IdP. + // + "Authorization": { + // + // When true, every MCP request requires an authenticated principal. When false (default), anonymous is allowed and a tool's own `authorize` annotation still gates it per call. + // + "RequireAuthorization": false, + // + // Authorization Server issuer URL(s) advertised in the Protected Resource Metadata. When empty, no PRM document is served. + // + "AuthorizationServers": [], + // + // Optional scopes advertised in the Protected Resource Metadata (scopes_supported). + // + "ScopesSupported": [], + // + // Canonical resource URI tokens must target (RFC 8707 audience) and the PRM "resource" value. Null = derived from the request (scheme + host + UrlPath). + // + "Audience": null, + // + // Path the Protected Resource Metadata document is served at. Null = the RFC 9728 well-known path derived from UrlPath. + // + "ProtectedResourceMetadataPath": null + } }, // diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index cdacdb59..6296519a 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -2579,7 +2579,32 @@ // // Optional text appended to every MCP tool description. Null = no-op. Use for short shared context the agent should always see (e.g. "Read-only Acme CRM."); for longer server-wide guidance prefer Instructions. // - "ToolDescriptionSuffix": null + "ToolDescriptionSuffix": null, + // + // OAuth 2.1 Resource Server settings. Token validation reuses the host's bearer authentication; these keys configure the transport gate and the Protected Resource Metadata document (RFC 9728). NpgsqlRest is not an Authorization Server — point AuthorizationServers at an external IdP. + // + "Authorization": { + // + // When true, every MCP request requires an authenticated principal. When false (default), anonymous is allowed and a tool's own `authorize` annotation still gates it per call. + // + "RequireAuthorization": false, + // + // Authorization Server issuer URL(s) advertised in the Protected Resource Metadata. When empty, no PRM document is served. + // + "AuthorizationServers": [], + // + // Optional scopes advertised in the Protected Resource Metadata (scopes_supported). + // + "ScopesSupported": [], + // + // Canonical resource URI tokens must target (RFC 8707 audience) and the PRM "resource" value. Null = derived from the request (scheme + host + UrlPath). + // + "Audience": null, + // + // Path the Protected Resource Metadata document is served at. Null = the RFC 9728 well-known path derived from UrlPath. + // + "ProtectedResourceMetadataPath": null + } }, // diff --git a/NpgsqlRestTests/McpTests/McpAuthGateTests.cs b/NpgsqlRestTests/McpTests/McpAuthGateTests.cs new file mode 100644 index 00000000..21c0924b --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpAuthGateTests.cs @@ -0,0 +1,30 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +[Collection("McpAuthGateFixture")] +public class McpAuthGateTests(McpAuthGateTestFixture test) +{ + [Fact] + public async Task Unauthenticated_request_is_rejected_with_401_and_prm_challenge() + { + using var content = new StringContent( + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}""", Encoding.UTF8, "application/json"); + using var response = await test.Client.PostAsync("/mcp", content); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + var challenge = response.Headers.WwwAuthenticate.ToString(); + challenge.Should().StartWith("Bearer"); + // RFC 9728 §5.1: point the client at the Protected Resource Metadata document for this resource. + challenge.Should().Contain("resource_metadata="); + challenge.Should().Contain("/.well-known/oauth-protected-resource/mcp"); + } + + [Fact] + public async Task Protected_resource_metadata_itself_stays_anonymous() + { + // The PRM document is discovery — it must be reachable without a token even when the gate is on. + using var response = await test.Client.GetAsync("/.well-known/oauth-protected-resource/mcp"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } +} diff --git a/NpgsqlRestTests/McpTests/McpServerTests.cs b/NpgsqlRestTests/McpTests/McpServerTests.cs index a8199f78..b954ca51 100644 --- a/NpgsqlRestTests/McpTests/McpServerTests.cs +++ b/NpgsqlRestTests/McpTests/McpServerTests.cs @@ -88,4 +88,23 @@ public async Task Tools_call_unknown_tool_is_a_jsonrpc_error() var r = await RpcAsync("""{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"nope","arguments":{}}}"""); r["error"]!["code"]!.GetValue().Should().Be(-32602); } + + [Fact] + public async Task Protected_resource_metadata_is_served_at_the_well_known_path() + { + // RFC 9728: the well-known path is "/.well-known/oauth-protected-resource" + the resource path ("/mcp"). + using var response = await test.Client.GetAsync("/.well-known/oauth-protected-resource/mcp"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType!.MediaType.Should().Be("application/json"); + + var doc = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + // resource is derived from the request origin + UrlPath (no explicit Audience configured). + doc["resource"]!.GetValue().Should().EndWith("/mcp"); + doc["authorization_servers"]!.AsArray().Select(n => n!.GetValue()) + .Should().Equal("https://as.example.com"); + doc["scopes_supported"]!.AsArray().Select(n => n!.GetValue()) + .Should().Equal("mcp.read", "mcp.write"); + doc["bearer_methods_supported"]!.AsArray().Select(n => n!.GetValue()) + .Should().Equal("header"); + } } diff --git a/NpgsqlRestTests/Setup/McpAuthGateTestFixture.cs b/NpgsqlRestTests/Setup/McpAuthGateTestFixture.cs new file mode 100644 index 00000000..c9fc5ea8 --- /dev/null +++ b/NpgsqlRestTests/Setup/McpAuthGateTestFixture.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using NpgsqlRest.Mcp; + +namespace NpgsqlRestTests.Setup; + +[CollectionDefinition("McpAuthGateFixture")] +public class McpAuthGateFixtureCollection : ICollectionFixture { } + +/// +/// Fixture for the MCP OAuth 2.1 Resource Server transport gate. The Mcp plugin runs with +/// RequireAuthorization = true and an Authorization Server configured, but the host registers +/// NO authentication middleware — so every request is unauthenticated and must be rejected with 401 +/// and a Protected Resource Metadata (RFC 9728) challenge. +/// +public class McpAuthGateTestFixture : IDisposable +{ + private readonly WebApplication _app; + private readonly HttpClient _client; + + public HttpClient Client => _client; + + public McpAuthGateTestFixture() + { + Database.Create(); + var connectionString = Database.CreateAdditional("mcp_auth_gate_test"); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + _app = builder.Build(); + + _app.UseNpgsqlRest(new NpgsqlRestOptions(connectionString) + { + IncludeSchemas = ["mcp"], + CommentsMode = CommentsMode.OnlyAnnotated, + EndpointCreateHandlers = + [ + new Mcp(new McpOptions + { + Enabled = true, + Authorization = new McpAuthorizationOptions + { + RequireAuthorization = true, + AuthorizationServers = ["https://as.example.com"], + } + }) + ] + }); + + _app.StartAsync().GetAwaiter().GetResult(); + _client = new HttpClient { BaseAddress = new Uri(_app.Urls.First()) }; + _client.Timeout = TimeSpan.FromHours(1); + } + +#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize + public void Dispose() +#pragma warning restore CA1816 // Dispose methods should call SuppressFinalize + { + _client.Dispose(); + _app.StopAsync().GetAwaiter().GetResult(); + _app.DisposeAsync().GetAwaiter().GetResult(); + } +} diff --git a/NpgsqlRestTests/Setup/McpPluginTestFixture.cs b/NpgsqlRestTests/Setup/McpPluginTestFixture.cs index f950cd45..fe83cbd5 100644 --- a/NpgsqlRestTests/Setup/McpPluginTestFixture.cs +++ b/NpgsqlRestTests/Setup/McpPluginTestFixture.cs @@ -21,7 +21,17 @@ public class McpPluginTestFixture : IDisposable { private readonly WebApplication _app; private readonly HttpClient _client; - private readonly Mcp _mcp = new(new McpOptions { Enabled = true }); + private readonly Mcp _mcp = new(new McpOptions + { + Enabled = true, + // OAuth 2.1 Resource Server config: an AS is configured, so the Protected Resource Metadata + // (RFC 9728) document is served. RequireAuthorization stays false — anonymous requests still work. + Authorization = new McpAuthorizationOptions + { + AuthorizationServers = ["https://as.example.com"], + ScopesSupported = ["mcp.read", "mcp.write"], + } + }); public HttpClient Client => _client; diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index a4a3fefe..b6cbdc37 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -24,9 +24,12 @@ NpgsqlRest can now project explicitly opted-in PostgreSQL routines as **MCP tool - **Single Streamable-HTTP JSON-RPC endpoint** (default `/mcp`, POST). Implements the lifecycle (`initialize` advertising the `tools` capability + `serverInfo`, `notifications/initialized` → `202`, `ping`), `tools/list` (catalog with JSON-Schema `inputSchema` derived from the routine's parameters), and `tools/call`. - **`tools/call` executes the real routine** through the same invocation pipeline as the HTTP endpoint, **forwarding the authenticated `ClaimsPrincipal`** so `authorize` role checks apply. Results use the MCP envelope (`{ "content": [{ "type": "text", "text": … }], "isError": … }`). Two error channels per spec: business failures → `isError: true` in the result; structural failures (unknown method, unknown tool, parse error) → JSON-RPC errors (`-32601` / `-32602` / `-32700`). - **AOT-safe.** The JSON-RPC layer is hand-rolled over `System.Text.Json.Nodes` with a source-generated `JsonSerializerContext` — no reflection-based serialization, consistent with the project's `PublishAot` build. -- **Config (`NpgsqlRest:McpOptions`, disabled by default):** `Enabled` (default `false`), `UrlPath` (default `/mcp`), `ServerName` (initialize `serverInfo.name`; null → database name from the connection string, falling back to `"NpgsqlRest"`), `ServerVersion` (default `"1.0.0"`), `Instructions` (optional server-level guidance returned at `initialize`), `ToolDescriptionSuffix` (optional short text appended to every tool description — complements `Instructions` for context the agent should see per-tool). Surfaced in `--config`, `--config-schema`, and the JSON-schema description set. +- **OAuth 2.1 Resource Server (bring-your-own Authorization Server).** Token validation reuses the host's bearer authentication; NpgsqlRest is not an Authorization Server. Configured under `McpOptions:Authorization`: + - **Protected Resource Metadata (RFC 9728)** is served at `/.well-known/oauth-protected-resource{UrlPath}` (advertising `resource`, `authorization_servers`, optional `scopes_supported`, `bearer_methods_supported`) whenever an Authorization Server is configured. + - **`RequireAuthorization`** gates the endpoint: an unauthenticated request gets **HTTP 401** with `WWW-Authenticate: Bearer resource_metadata="…"` (RFC 9728 §5.1) so the client can discover the AS. The PRM document itself stays anonymous. The `resource` value defaults to the request origin + `UrlPath` (overridable via `Audience`, RFC 8707). +- **Config (`NpgsqlRest:McpOptions`, disabled by default):** `Enabled` (default `false`), `UrlPath` (default `/mcp`), `ServerName` (initialize `serverInfo.name`; null → database name from the connection string, falling back to `"NpgsqlRest"`), `ServerVersion` (default `"1.0.0"`), `Instructions` (optional server-level guidance returned at `initialize`), `ToolDescriptionSuffix` (optional short text appended to every tool description — complements `Instructions` for context the agent should see per-tool), and the `Authorization` object (`RequireAuthorization`, `AuthorizationServers`, `ScopesSupported`, `Audience`, `ProtectedResourceMetadataPath`). Surfaced in `--config`, `--config-schema`, and the JSON-schema description set. -> Endpoint-level auth: the `/mcp` endpoint is currently protected by whatever authentication the host configures; an OAuth 2.1 Resource Server profile (RFC 9728 / RFC 8707) for the endpoint is a follow-up within this release line. +> Internal (Layer-2) authorization — `403 insufficient_scope` wire shaping and `tools/list` filtering to the caller's permitted tools — is a follow-up within this release line. Per-tool `authorize` role checks already apply on `tools/call` via the forwarded principal. ### Plugin extension points on `RoutineEndpoint` (new) + OpenAPI annotation handling (⚠️ breaking API) diff --git a/plugins/NpgsqlRest.Mcp/McpAuthorizationOptions.cs b/plugins/NpgsqlRest.Mcp/McpAuthorizationOptions.cs new file mode 100644 index 00000000..5fbda8aa --- /dev/null +++ b/plugins/NpgsqlRest.Mcp/McpAuthorizationOptions.cs @@ -0,0 +1,36 @@ +namespace NpgsqlRest.Mcp; + +/// +/// OAuth 2.1 Resource Server settings for the MCP endpoint (bring-your-own Authorization Server). +/// Token validation reuses the host's bearer authentication; these keys drive the transport gate +/// and the Protected Resource Metadata document (RFC 9728 / RFC 8707). NpgsqlRest does not act as an +/// Authorization Server — point at an external IdP (or NpgsqlRest's +/// own JWT login acting separately). +/// +public class McpAuthorizationOptions +{ + /// + /// True = every MCP request requires an authenticated principal (the host's bearer middleware must + /// have populated context.User). False (default) = anonymous allowed; a tool's own + /// authorize annotation still gates it per call. + /// + public bool RequireAuthorization { get; set; } = false; + + /// Authorization Server issuer URL(s) advertised in the Protected Resource Metadata. When empty, no PRM document is served. + public string[] AuthorizationServers { get; set; } = []; + + /// Optional scopes advertised in the Protected Resource Metadata (scopes_supported). + public string[] ScopesSupported { get; set; } = []; + + /// + /// Canonical resource URI tokens must target (RFC 8707 audience) and the resource value in the + /// PRM document. Null = derived from the request (scheme + host + ). + /// + public string? Audience { get; set; } = null; + + /// + /// Path the Protected Resource Metadata document is served at. Null = the RFC 9728 well-known path + /// derived from the MCP URL path (/.well-known/oauth-protected-resource + UrlPath). + /// + public string? ProtectedResourceMetadataPath { get; set; } = null; +} diff --git a/plugins/NpgsqlRest.Mcp/McpOptions.cs b/plugins/NpgsqlRest.Mcp/McpOptions.cs index ec485950..e1306469 100644 --- a/plugins/NpgsqlRest.Mcp/McpOptions.cs +++ b/plugins/NpgsqlRest.Mcp/McpOptions.cs @@ -29,4 +29,7 @@ public class McpOptions /// server-wide guidance prefer , which is returned once at initialize. /// public string? ToolDescriptionSuffix { get; set; } = null; + + /// OAuth 2.1 Resource Server settings: the transport authorization gate and Protected Resource Metadata (RFC 9728). + public McpAuthorizationOptions Authorization { get; set; } = new(); } diff --git a/plugins/NpgsqlRest.Mcp/McpServer.cs b/plugins/NpgsqlRest.Mcp/McpServer.cs index 762887a9..282149a4 100644 --- a/plugins/NpgsqlRest.Mcp/McpServer.cs +++ b/plugins/NpgsqlRest.Mcp/McpServer.cs @@ -33,8 +33,17 @@ public void Cleanup() } var path = _options.UrlPath; + // Protected Resource Metadata (RFC 9728) is served only when an Authorization Server is configured + // — without one the document carries no useful discovery information. + var servePrm = _options.Authorization.AuthorizationServers.Length > 0; + var prmPath = ProtectedResourceMetadataPath(); _builder.Use(async (context, next) => { + if (servePrm && string.Equals(context.Request.Path, prmPath, StringComparison.Ordinal)) + { + await HandleProtectedResourceMetadataAsync(context); + return; + } if (string.Equals(context.Request.Path, path, StringComparison.Ordinal)) { await HandleAsync(context); @@ -44,8 +53,79 @@ public void Cleanup() }); } + /// The RFC 9728 well-known path for this resource (overridable via config). + private string ProtectedResourceMetadataPath() => + _options.Authorization.ProtectedResourceMetadataPath + ?? "/.well-known/oauth-protected-resource" + _options.UrlPath; + + private async Task HandleProtectedResourceMetadataAsync(HttpContext context) + { + if (!HttpMethods.IsGet(context.Request.Method)) + { + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + return; + } + + var auth = _options.Authorization; + // The canonical resource URI tokens must target (RFC 8707). Explicit Audience wins; otherwise + // derive it from the request so it matches the live origin. + var resource = string.IsNullOrWhiteSpace(auth.Audience) + ? $"{context.Request.Scheme}://{context.Request.Host}{_options.UrlPath}" + : auth.Audience; + + var authServers = new JsonArray(); + foreach (var server in auth.AuthorizationServers) + { + authServers.Add((JsonNode?)server); + } + var doc = new JsonObject + { + ["resource"] = resource, + ["authorization_servers"] = authServers, + ["bearer_methods_supported"] = new JsonArray((JsonNode?)"header"), + }; + if (auth.ScopesSupported.Length > 0) + { + var scopes = new JsonArray(); + foreach (var scope in auth.ScopesSupported) + { + scopes.Add((JsonNode?)scope); + } + doc["scopes_supported"] = scopes; + } + + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(doc, McpJsonContext.Default.JsonObject), context.RequestAborted); + } + + /// + /// 401 with the RFC 9728 §5.1 WWW-Authenticate: Bearer challenge. When an Authorization Server + /// is configured, the challenge carries resource_metadata so the client can discover it. + /// + private void WriteUnauthorized(HttpContext context) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + var challenge = "Bearer"; + if (_options.Authorization.AuthorizationServers.Length > 0) + { + var prm = $"{context.Request.Scheme}://{context.Request.Host}{ProtectedResourceMetadataPath()}"; + challenge = $"Bearer resource_metadata=\"{prm}\""; + } + context.Response.Headers.Append("WWW-Authenticate", challenge); + } + private async Task HandleAsync(HttpContext context) { + // Transport authorization gate (OAuth 2.1 Resource Server). When RequireAuthorization is on, the + // host's bearer middleware must have authenticated the principal; otherwise reject with 401 and + // point the client at the Protected Resource Metadata (RFC 9728) so it can discover the AS. + if (_options.Authorization.RequireAuthorization && context.User?.Identity?.IsAuthenticated != true) + { + WriteUnauthorized(context); + return; + } + // POST carries JSON-RPC. (GET would open an SSE stream — not supported in the stateless model.) if (!HttpMethods.IsPost(context.Request.Method)) { From 2ec90ffded1a3dc3a5aedfe492bd7bcf442d000a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Mon, 8 Jun 2026 17:11:33 +0200 Subject: [PATCH 17/38] =?UTF-8?q?feat:=20v3.17.0=20=E2=80=94=20MCP=20per-t?= =?UTF-8?q?ool=20authorization=20on=20tools/call=20(401/403)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4b: tools/call translates the execution pipeline's authorization outcome into spec HTTP challenges, reusing core's authorize/role check (no security logic duplicated in the plugin): - Tool needs authentication, called anonymously -> 401 with WWW-Authenticate: Bearer resource_metadata="..." (RFC 9728 5.1). - Authenticated but insufficient role -> 403 with WWW-Authenticate: Bearer error="insufficient_scope" (RFC 6750 3.1), adding scope/resource_metadata when configured. tools/list still lists every opted-in tool (the spec's per-principal filtering is a SHOULD) so tools stay discoverable; authorization is enforced on call. Tests: new mcp.tool_authorized (@mcp + @authorize admin); anonymous tools/call -> 401 (McpServerTests); new McpAuthRoleTestFixture (cookie auth + /login-as?role=) with admin->success and guest->403 insufficient_scope (McpAuthRoleTests). --- NpgsqlRestTests/McpTests/McpAuthRoleTests.cs | 41 ++++++++++ .../McpTests/McpPluginAnnotationTests.cs | 7 ++ NpgsqlRestTests/McpTests/McpServerTests.cs | 14 ++++ .../Setup/McpAuthRoleTestFixture.cs | 79 +++++++++++++++++++ changelog/v3.17.0.md | 3 +- plugins/NpgsqlRest.Mcp/McpServer.cs | 34 ++++++++ 6 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 NpgsqlRestTests/McpTests/McpAuthRoleTests.cs create mode 100644 NpgsqlRestTests/Setup/McpAuthRoleTestFixture.cs diff --git a/NpgsqlRestTests/McpTests/McpAuthRoleTests.cs b/NpgsqlRestTests/McpTests/McpAuthRoleTests.cs new file mode 100644 index 00000000..1d36fb56 --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpAuthRoleTests.cs @@ -0,0 +1,41 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +[Collection("McpAuthRoleFixture")] +public class McpAuthRoleTests(McpAuthRoleTestFixture test) +{ + private const string CallAuthorized = + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"tool_authorized","arguments":{}}}"""; + + [Fact] + public async Task Authenticated_with_the_required_role_executes_the_tool() + { + using var client = test.CreateClient(); + (await client.GetAsync("/login-as?role=admin")).EnsureSuccessStatusCode(); + + using var content = new StringContent(CallAuthorized, Encoding.UTF8, "application/json"); + using var response = await client.PostAsync("/mcp", content); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var r = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + r["result"]!["isError"]!.GetValue().Should().BeFalse(); + r["result"]!["content"]!.AsArray()[0]!["text"]!.GetValue().Should().Be("secret"); + } + + [Fact] + public async Task Authenticated_with_the_wrong_role_is_rejected_with_403_insufficient_scope() + { + using var client = test.CreateClient(); + (await client.GetAsync("/login-as?role=guest")).EnsureSuccessStatusCode(); + + using var content = new StringContent(CallAuthorized, Encoding.UTF8, "application/json"); + using var response = await client.PostAsync("/mcp", content); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + var challenge = response.Headers.WwwAuthenticate.ToString(); + challenge.Should().Contain("error=\"insufficient_scope\""); + challenge.Should().Contain("scope=\"mcp.read\""); + challenge.Should().Contain("resource_metadata="); + } +} diff --git a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs index 2379ca59..fdf23847 100644 --- a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs +++ b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs @@ -78,6 +78,13 @@ HTTP GET comment on function mcp.tool_nodesc() is ' HTTP GET @mcp'; + +-- role-restricted tool: exercises the tools/call auth translation (401 anonymous, 403 wrong role) +create function mcp.tool_authorized() returns text language sql as 'select ''secret'''; +comment on function mcp.tool_authorized() is ' +HTTP GET +@mcp Admin-only data. +@authorize admin'; "); } } diff --git a/NpgsqlRestTests/McpTests/McpServerTests.cs b/NpgsqlRestTests/McpTests/McpServerTests.cs index b954ca51..0d64535b 100644 --- a/NpgsqlRestTests/McpTests/McpServerTests.cs +++ b/NpgsqlRestTests/McpTests/McpServerTests.cs @@ -89,6 +89,20 @@ public async Task Tools_call_unknown_tool_is_a_jsonrpc_error() r["error"]!["code"]!.GetValue().Should().Be(-32602); } + [Fact] + public async Task Tools_call_on_an_authorized_tool_by_anonymous_caller_returns_401() + { + // tool_authorized has `@authorize admin`; the fixture has no auth middleware, so the forwarded + // principal is anonymous and the execution pipeline returns 401 → translated to an HTTP challenge. + using var content = new StringContent( + """{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"tool_authorized","arguments":{}}}""", + Encoding.UTF8, "application/json"); + using var response = await test.Client.PostAsync("/mcp", content); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + response.Headers.WwwAuthenticate.ToString().Should().Contain("resource_metadata="); + } + [Fact] public async Task Protected_resource_metadata_is_served_at_the_well_known_path() { diff --git a/NpgsqlRestTests/Setup/McpAuthRoleTestFixture.cs b/NpgsqlRestTests/Setup/McpAuthRoleTestFixture.cs new file mode 100644 index 00000000..5cfd95e7 --- /dev/null +++ b/NpgsqlRestTests/Setup/McpAuthRoleTestFixture.cs @@ -0,0 +1,79 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using NpgsqlRest.Mcp; + +namespace NpgsqlRestTests.Setup; + +[CollectionDefinition("McpAuthRoleFixture")] +public class McpAuthRoleFixtureCollection : ICollectionFixture { } + +/// +/// Fixture for MCP Layer-2 authorization (per-tool roles). Cookie auth is wired with a "/login-as" +/// endpoint that signs in a principal carrying a single role claim. The MCP server runs with +/// RequireAuthorization=false (anonymous discovery allowed) but the tool_authorized tool carries +/// @authorize admin, so a wrong-role caller is rejected on tools/call with 403 insufficient_scope. +/// +public class McpAuthRoleTestFixture : IDisposable +{ + private readonly WebApplication _app; + + public string ServerAddress { get; } + + public McpAuthRoleTestFixture() + { + Database.Create(); + var connectionString = Database.CreateAdditional("mcp_auth_role_test"); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddAuthentication().AddCookie(); + _app = builder.Build(); + + // Sign the caller in with a single "role" claim taken from the query string. + _app.MapGet("/login-as", (string role) => Results.SignIn(new ClaimsPrincipal(new ClaimsIdentity( + claims: [new Claim("role", role)], + authenticationType: CookieAuthenticationDefaults.AuthenticationScheme)))); + + _app.UseNpgsqlRest(new NpgsqlRestOptions(connectionString) + { + IncludeSchemas = ["mcp"], + CommentsMode = CommentsMode.OnlyAnnotated, + AuthenticationOptions = new() { DefaultRoleClaimType = "role" }, + EndpointCreateHandlers = + [ + new Mcp(new McpOptions + { + Enabled = true, + Authorization = new McpAuthorizationOptions + { + // Gate off — anonymous discovery is allowed; per-tool `authorize` still applies. + RequireAuthorization = false, + AuthorizationServers = ["https://as.example.com"], + ScopesSupported = ["mcp.read"], + } + }) + ] + }); + + _app.StartAsync().GetAwaiter().GetResult(); + ServerAddress = _app.Urls.First(); + } + + public HttpClient CreateClient() + { + var handler = new HttpClientHandler { UseCookies = true, CookieContainer = new System.Net.CookieContainer() }; + return new HttpClient(handler) { BaseAddress = new Uri(ServerAddress), Timeout = TimeSpan.FromMinutes(5) }; + } + +#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize + public void Dispose() +#pragma warning restore CA1816 // Dispose methods should call SuppressFinalize + { + _app.StopAsync().GetAwaiter().GetResult(); + _app.DisposeAsync().GetAwaiter().GetResult(); + } +} diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index b6cbdc37..5b5f9ee0 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -27,9 +27,10 @@ NpgsqlRest can now project explicitly opted-in PostgreSQL routines as **MCP tool - **OAuth 2.1 Resource Server (bring-your-own Authorization Server).** Token validation reuses the host's bearer authentication; NpgsqlRest is not an Authorization Server. Configured under `McpOptions:Authorization`: - **Protected Resource Metadata (RFC 9728)** is served at `/.well-known/oauth-protected-resource{UrlPath}` (advertising `resource`, `authorization_servers`, optional `scopes_supported`, `bearer_methods_supported`) whenever an Authorization Server is configured. - **`RequireAuthorization`** gates the endpoint: an unauthenticated request gets **HTTP 401** with `WWW-Authenticate: Bearer resource_metadata="…"` (RFC 9728 §5.1) so the client can discover the AS. The PRM document itself stays anonymous. The `resource` value defaults to the request origin + `UrlPath` (overridable via `Audience`, RFC 8707). + - **Per-tool authorization** on `tools/call`: the forwarded principal runs the routine's `authorize`/role check in the execution pipeline. A tool that needs authentication called anonymously → **HTTP 401** (PRM challenge); authenticated but lacking the role → **HTTP 403** `WWW-Authenticate: Bearer error="insufficient_scope"` (RFC 6750 §3.1, with `scope`/`resource_metadata` when configured). No authorization logic is duplicated in the plugin — it reuses core's check. - **Config (`NpgsqlRest:McpOptions`, disabled by default):** `Enabled` (default `false`), `UrlPath` (default `/mcp`), `ServerName` (initialize `serverInfo.name`; null → database name from the connection string, falling back to `"NpgsqlRest"`), `ServerVersion` (default `"1.0.0"`), `Instructions` (optional server-level guidance returned at `initialize`), `ToolDescriptionSuffix` (optional short text appended to every tool description — complements `Instructions` for context the agent should see per-tool), and the `Authorization` object (`RequireAuthorization`, `AuthorizationServers`, `ScopesSupported`, `Audience`, `ProtectedResourceMetadataPath`). Surfaced in `--config`, `--config-schema`, and the JSON-schema description set. -> Internal (Layer-2) authorization — `403 insufficient_scope` wire shaping and `tools/list` filtering to the caller's permitted tools — is a follow-up within this release line. Per-tool `authorize` role checks already apply on `tools/call` via the forwarded principal. +> `tools/list` lists every opted-in tool and does not filter to the caller's permitted subset (a SHOULD in the spec); authorization is enforced on `tools/call`. Listing all tools keeps them discoverable so an agent can prompt for authentication. Per-principal filtering may be added later. ### Plugin extension points on `RoutineEndpoint` (new) + OpenAPI annotation handling (⚠️ breaking API) diff --git a/plugins/NpgsqlRest.Mcp/McpServer.cs b/plugins/NpgsqlRest.Mcp/McpServer.cs index 282149a4..561f405b 100644 --- a/plugins/NpgsqlRest.Mcp/McpServer.cs +++ b/plugins/NpgsqlRest.Mcp/McpServer.cs @@ -115,6 +115,26 @@ private void WriteUnauthorized(HttpContext context) context.Response.Headers.Append("WWW-Authenticate", challenge); } + /// + /// 403 with a WWW-Authenticate: Bearer error="insufficient_scope" challenge (RFC 6750 §3.1): + /// the principal is authenticated but lacks the permission the tool's authorize requires. + /// + private void WriteForbidden(HttpContext context) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + var challenge = "Bearer error=\"insufficient_scope\""; + if (_options.Authorization.ScopesSupported.Length > 0) + { + challenge += $", scope=\"{string.Join(' ', _options.Authorization.ScopesSupported)}\""; + } + if (_options.Authorization.AuthorizationServers.Length > 0) + { + var prm = $"{context.Request.Scheme}://{context.Request.Host}{ProtectedResourceMetadataPath()}"; + challenge += $", resource_metadata=\"{prm}\""; + } + context.Response.Headers.Append("WWW-Authenticate", challenge); + } + private async Task HandleAsync(HttpContext context) { // Transport authorization gate (OAuth 2.1 Resource Server). When RequireAuthorization is on, the @@ -206,6 +226,20 @@ private async Task HandleToolCallAsync(HttpContext context, JsonNode? request, J httpMethod, path, headers: null, body: body, contentType: contentType, user: context.User, cancellationToken: context.RequestAborted); + // Authorization outcome from the execution pipeline (core ran the tool's `authorize`/role check + // against the forwarded principal) → spec-level HTTP challenges, not a tool result. 401 = the tool + // needs authentication; 403 = authenticated but insufficient permission (RFC 9728/6750). + if (invoke.StatusCode == StatusCodes.Status401Unauthorized) + { + WriteUnauthorized(context); + return; + } + if (invoke.StatusCode == StatusCodes.Status403Forbidden) + { + WriteForbidden(context); + return; + } + // Business/execution outcome → a normal result with isError; the routine's response is the // text content verbatim. (Only structural problems above use a JSON-RPC error.) var content = new JsonArray(); From df4513176bb2a1e4dd8bb9bde00c653a8bca4258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Mon, 8 Jun 2026 17:21:41 +0200 Subject: [PATCH 18/38] =?UTF-8?q?feat:=20v3.17.0=20=E2=80=94=20MCP=20non-a?= =?UTF-8?q?pplicable-feature=20warning=20+=20AOT=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Warn at endpoint-creation time when a routine annotated 'mcp' also uses a feature with no MCP-tool equivalent (login/logout/basic auth/upload/SSE). The tool is still exposed; the warning flags it likely won't behave over JSON-RPC. Tested via a log-capturing fixture + isolated mcp_warn schema. - Fix IL2026/IL3050 in tools/call: content.Add(new JsonObject{...}) bound the non-AOT-safe generic JsonArray.Add; cast to JsonNode? to bind the non-generic overload. Verified clean via dotnet publish -p:PublishAot=true (remaining publish warnings are pre-existing third-party). --- .../McpTests/McpFeatureWarnTests.cs | 37 +++++++++++++ .../Setup/McpFeatureWarnTestFixture.cs | 54 +++++++++++++++++++ changelog/v3.17.0.md | 3 +- plugins/NpgsqlRest.Mcp/Mcp.cs | 23 ++++++++ plugins/NpgsqlRest.Mcp/McpServer.cs | 3 +- 5 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 NpgsqlRestTests/McpTests/McpFeatureWarnTests.cs create mode 100644 NpgsqlRestTests/Setup/McpFeatureWarnTestFixture.cs diff --git a/NpgsqlRestTests/McpTests/McpFeatureWarnTests.cs b/NpgsqlRestTests/McpTests/McpFeatureWarnTests.cs new file mode 100644 index 00000000..d8adb4c8 --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpFeatureWarnTests.cs @@ -0,0 +1,37 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +public static partial class Database +{ + // Isolated schema for the non-applicable-feature warning test. `mcp_warn` is excluded from every + // other fixture (they include only "mcp"/"public"), so this routine never pollutes other catalogs. + public static void McpFeatureWarnTests() + { + script.Append(@" +create schema if not exists mcp_warn; + +-- @mcp on a login endpoint: login is an auth flow that does not translate to an MCP tool call. +-- (login routines must return a named result set, not a scalar/void.) +create function mcp_warn.tool_login() returns table(status int, name_identifier text) +language sql as 'select 200, ''42'''; +comment on function mcp_warn.tool_login() is ' +HTTP POST +login +@mcp Sign in.'; +"); + } +} + +[Collection("McpFeatureWarnFixture")] +public class McpFeatureWarnTests(McpFeatureWarnTestFixture test) +{ + [Fact] + public void Mcp_annotation_on_a_non_applicable_feature_logs_a_warning() + { + var warning = test.StartupLogs.FirstOrDefault(l => + l.Message.Contains("does not apply to MCP tools") && l.Message.Contains("login")); + warning.Should().NotBeNull("the plugin should warn when `@mcp` is placed on a login routine"); + warning!.Message.Should().Contain("mcp_warn.tool_login"); + } +} diff --git a/NpgsqlRestTests/Setup/McpFeatureWarnTestFixture.cs b/NpgsqlRestTests/Setup/McpFeatureWarnTestFixture.cs new file mode 100644 index 00000000..1328789b --- /dev/null +++ b/NpgsqlRestTests/Setup/McpFeatureWarnTestFixture.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using NpgsqlRest.Mcp; + +namespace NpgsqlRestTests.Setup; + +[CollectionDefinition("McpFeatureWarnFixture")] +public class McpFeatureWarnFixtureCollection : ICollectionFixture { } + +/// +/// Fixture for the MCP non-applicable-feature warning. The isolated mcp_warn schema holds a +/// routine that is annotated @mcp but is also a login endpoint — a feature that does not +/// translate to an MCP tool call. The plugin should log a warning at endpoint-creation time. Logs are +/// captured so the test can assert the warning fired. +/// +public class McpFeatureWarnTestFixture : IDisposable +{ + private readonly WebApplication _app; + private readonly LogCollector _logCollector = new(); + + public IReadOnlyList StartupLogs { get; } + + public McpFeatureWarnTestFixture() + { + Database.Create(); + var connectionString = Database.CreateAdditional("mcp_feature_warn_test"); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Logging.ClearProviders(); + builder.Logging.SetMinimumLevel(LogLevel.Trace); + builder.Logging.AddProvider(new CollectingLoggerProvider(_logCollector)); + _app = builder.Build(); + + _app.UseNpgsqlRest(new NpgsqlRestOptions(connectionString) + { + IncludeSchemas = ["mcp_warn"], + CommentsMode = CommentsMode.OnlyAnnotated, + EndpointCreateHandlers = [new Mcp(new McpOptions { Enabled = true })] + }); + + _app.StartAsync().GetAwaiter().GetResult(); + StartupLogs = _logCollector.Snapshot(); + } + +#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize + public void Dispose() +#pragma warning restore CA1816 // Dispose methods should call SuppressFinalize + { + _app.StopAsync().GetAwaiter().GetResult(); + _app.DisposeAsync().GetAwaiter().GetResult(); + } +} diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index 5b5f9ee0..a9d59e7a 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -23,7 +23,8 @@ NpgsqlRest can now project explicitly opted-in PostgreSQL routines as **MCP tool - **MCP-only (no HTTP route):** compose with the existing `internal` annotation (`mcp` + `internal`) — the routine becomes an MCP tool with no REST endpoint. All other annotations (`authorize`, parameter handling, etc.) apply equally. - **Single Streamable-HTTP JSON-RPC endpoint** (default `/mcp`, POST). Implements the lifecycle (`initialize` advertising the `tools` capability + `serverInfo`, `notifications/initialized` → `202`, `ping`), `tools/list` (catalog with JSON-Schema `inputSchema` derived from the routine's parameters), and `tools/call`. - **`tools/call` executes the real routine** through the same invocation pipeline as the HTTP endpoint, **forwarding the authenticated `ClaimsPrincipal`** so `authorize` role checks apply. Results use the MCP envelope (`{ "content": [{ "type": "text", "text": … }], "isError": … }`). Two error channels per spec: business failures → `isError: true` in the result; structural failures (unknown method, unknown tool, parse error) → JSON-RPC errors (`-32601` / `-32602` / `-32700`). -- **AOT-safe.** The JSON-RPC layer is hand-rolled over `System.Text.Json.Nodes` with a source-generated `JsonSerializerContext` — no reflection-based serialization, consistent with the project's `PublishAot` build. +- **AOT-safe.** The JSON-RPC layer is hand-rolled over `System.Text.Json.Nodes` with a source-generated `JsonSerializerContext` — no reflection-based serialization, consistent with the project's `PublishAot` build (verified via `dotnet publish -p:PublishAot=true`). +- **Diagnostics.** A routine annotated `mcp` that also uses a feature with no MCP-tool equivalent (`login`, `logout`, basic auth, `upload`, SSE) logs a build-time warning — the tool is still exposed, but the warning flags that it likely won't behave as expected over JSON-RPC. - **OAuth 2.1 Resource Server (bring-your-own Authorization Server).** Token validation reuses the host's bearer authentication; NpgsqlRest is not an Authorization Server. Configured under `McpOptions:Authorization`: - **Protected Resource Metadata (RFC 9728)** is served at `/.well-known/oauth-protected-resource{UrlPath}` (advertising `resource`, `authorization_servers`, optional `scopes_supported`, `bearer_methods_supported`) whenever an Authorization Server is configured. - **`RequireAuthorization`** gates the endpoint: an unauthenticated request gets **HTTP 401** with `WWW-Authenticate: Bearer resource_metadata="…"` (RFC 9728 §5.1) so the client can discover the AS. The PRM document itself stays anonymous. The `resource` value defaults to the request origin + `UrlPath` (overridable via `Audience`, RFC 8707). diff --git a/plugins/NpgsqlRest.Mcp/Mcp.cs b/plugins/NpgsqlRest.Mcp/Mcp.cs index ec1920ba..95431cc6 100644 --- a/plugins/NpgsqlRest.Mcp/Mcp.cs +++ b/plugins/NpgsqlRest.Mcp/Mcp.cs @@ -89,6 +89,7 @@ public void Handle(RoutineEndpoint endpoint) var tool = BuildTool(endpoint, info); var name = tool["name"]!.GetValue(); + WarnIfNonApplicableFeature(endpoint, name); if (_tools.TryAdd(name, tool)) { _toolEndpoints[name] = endpoint; @@ -102,6 +103,28 @@ public void Handle(RoutineEndpoint endpoint) } } + /// + /// Warns when a routine opted in with mcp also carries a feature that does not translate to an + /// MCP tool call (auth flows, file upload, SSE). The tool is still exposed — this only flags that it + /// likely won't behave as expected over JSON-RPC. + /// + private static void WarnIfNonApplicableFeature(RoutineEndpoint endpoint, string toolName) + { + var feature = + endpoint.Login ? "login" : + endpoint.Logout ? "logout" : + endpoint.BasicAuth is not null ? "basic auth" : + endpoint.Upload ? "upload" : + endpoint.SseEventsPath is not null ? "server-sent events" : + null; + if (feature is not null) + { + Logger?.LogWarning( + "MCP tool '{Name}' ({Schema}.{Routine}) is annotated `mcp` but uses a feature that does not apply to MCP tools ({Feature}). The tool is exposed but may not behave as expected over JSON-RPC.", + toolName, endpoint.Routine.Schema, endpoint.Routine.Name, feature); + } + } + private JsonObject BuildTool(RoutineEndpoint endpoint, McpToolInfo info) { var routine = endpoint.Routine; diff --git a/plugins/NpgsqlRest.Mcp/McpServer.cs b/plugins/NpgsqlRest.Mcp/McpServer.cs index 561f405b..2eecf345 100644 --- a/plugins/NpgsqlRest.Mcp/McpServer.cs +++ b/plugins/NpgsqlRest.Mcp/McpServer.cs @@ -243,7 +243,8 @@ private async Task HandleToolCallAsync(HttpContext context, JsonNode? request, J // Business/execution outcome → a normal result with isError; the routine's response is the // text content verbatim. (Only structural problems above use a JSON-RPC error.) var content = new JsonArray(); - content.Add(new JsonObject { ["type"] = "text", ["text"] = invoke.Body ?? string.Empty }); + // Cast to JsonNode? to bind the non-generic JsonArray.Add (the generic Add is not AOT/trim-safe). + content.Add((JsonNode?)new JsonObject { ["type"] = "text", ["text"] = invoke.Body ?? string.Empty }); var toolResult = new JsonObject { From d13bbeccfe2278a679e7a60d68b825ebd9f1488e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Tue, 9 Jun 2026 12:04:50 +0200 Subject: [PATCH 19/38] fix(core): include plugin comment annotations in the endpoint annotations summary Built-in comment annotations are tracked and shown in the per-endpoint Debug "{routine} annotations: [...]" summary, but plugin-claimed lines (handled via IEndpointCreateHandler.HandleCommentLine) were logged only at Trace and omitted from the summary. Track them too, so plugin annotations (e.g. mcp, openapi) are as visible as built-ins. --- NpgsqlRest/Defaults/DefaultCommentParser.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NpgsqlRest/Defaults/DefaultCommentParser.cs b/NpgsqlRest/Defaults/DefaultCommentParser.cs index 9b7cf9be..84faee20 100644 --- a/NpgsqlRest/Defaults/DefaultCommentParser.cs +++ b/NpgsqlRest/Defaults/DefaultCommentParser.cs @@ -525,6 +525,9 @@ internal static partial class DefaultCommentParser if (handled is { } result) { CommentLogger?.LogTrace("Plugin comment annotation '{Label}' for {Description}", result.Label, description); + // Include plugin-claimed annotations (e.g. `mcp`, `openapi …`) in the per-endpoint + // annotations summary, so they're as visible as built-in annotations. + TrackAnnotation(line); if (result.RequestsEndpoint) { anyHandlerRequestedEndpoint = true; From 483604dbd9fb78b438e8ad67753bc76aaf5e707c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Tue, 9 Jun 2026 12:05:27 +0200 Subject: [PATCH 20/38] =?UTF-8?q?feat:=20v3.17.0=20=E2=80=94=20MCP=20struc?= =?UTF-8?q?turedContent/outputSchema,=20transport=20&=20auth=20compliance,?= =?UTF-8?q?=20SqlFileSource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/call now returns structuredContent (always a JSON object, per MCP 2025-11-25), mapped from the routine's return shape: scalar -> {value}, record/single -> object, set -> {items}; multi-command SQL files -> the per-result-set object. The text block carries the serialized structuredContent. Each tool declares an outputSchema derived from its return columns (nullable-safe; omitted for void/raw/multi-command). Responses use relaxed JSON escaping; the source-gen JsonContext is dropped in favour of JsonNode.ToJsonString. Streamable HTTP transport compliance: Origin validation (DNS-rebinding -> 403, with McpOptions.AllowedOrigins), MCP-Protocol-Version validation (-> 400), non-object/batch body -> -32600. Authorization: scope on the 401 challenge (not just 403), RFC 8707 audience binding (aud claim), and the startup catalog summary + no-auth-scheme warning. Confirmed and tested across both endpoint sources (database routines and SqlFileSource, single- and multi-command) and a broad edge-case suite: return types (null/empty/void/ bool/numeric/json/array/custom-composite), argument mapping (query/body/path; typed/ optional/null/json/array/unicode/special-chars), claim-to-parameter binding via the forwarded principal, tool-name collisions, and concurrent calls. MCP tests assert full response bodies. Changelog reorganised (features / breaking / fixes / tests). AOT-clean (dotnet publish -p:PublishAot=true); full suite green. --- NpgsqlRestClient/App.cs | 1 + NpgsqlRestClient/ConfigDefaults.cs | 1 + NpgsqlRestClient/ConfigSchemaGenerator.cs | 1 + NpgsqlRestClient/ConfigTemplate.cs | 4 + NpgsqlRestClient/appsettings.json | 4 + NpgsqlRestTests/McpTests/McpAudienceTests.cs | 37 +++ NpgsqlRestTests/McpTests/McpAuthGateTests.cs | 9 + NpgsqlRestTests/McpTests/McpAuthRoleTests.cs | 6 +- NpgsqlRestTests/McpTests/McpClaimTests.cs | 50 +++ NpgsqlRestTests/McpTests/McpEdgeCaseTests.cs | 147 +++++++++ .../McpTests/McpFeatureWarnTests.cs | 22 ++ NpgsqlRestTests/McpTests/McpParameterTests.cs | 152 +++++++++ .../McpTests/McpPluginAnnotationTests.cs | 54 ++-- NpgsqlRestTests/McpTests/McpProtocolTests.cs | 206 ++++++++++++ NpgsqlRestTests/McpTests/McpServerTests.cs | 74 ++--- NpgsqlRestTests/McpTests/McpSqlFileTests.cs | 39 +++ .../McpTests/McpStructuredContentTests.cs | 147 +++++++++ NpgsqlRestTests/McpTests/McpToolNameTests.cs | 43 +++ .../Setup/McpAudienceTestFixture.cs | 77 +++++ .../Setup/McpAuthGateTestFixture.cs | 9 + NpgsqlRestTests/Setup/McpClaimTestFixture.cs | 73 +++++ .../Setup/McpSqlFileTestFixture.cs | 79 +++++ .../Setup/McpToolNameTestFixture.cs | 55 ++++ changelog/v3.17.0.md | 106 +++--- plugins/NpgsqlRest.Mcp/Mcp.cs | 9 +- plugins/NpgsqlRest.Mcp/McpJsonContext.cs | 8 - plugins/NpgsqlRest.Mcp/McpOptions.cs | 8 + plugins/NpgsqlRest.Mcp/McpServer.cs | 306 ++++++++++++++++-- 28 files changed, 1567 insertions(+), 160 deletions(-) create mode 100644 NpgsqlRestTests/McpTests/McpAudienceTests.cs create mode 100644 NpgsqlRestTests/McpTests/McpClaimTests.cs create mode 100644 NpgsqlRestTests/McpTests/McpEdgeCaseTests.cs create mode 100644 NpgsqlRestTests/McpTests/McpParameterTests.cs create mode 100644 NpgsqlRestTests/McpTests/McpProtocolTests.cs create mode 100644 NpgsqlRestTests/McpTests/McpSqlFileTests.cs create mode 100644 NpgsqlRestTests/McpTests/McpStructuredContentTests.cs create mode 100644 NpgsqlRestTests/McpTests/McpToolNameTests.cs create mode 100644 NpgsqlRestTests/Setup/McpAudienceTestFixture.cs create mode 100644 NpgsqlRestTests/Setup/McpClaimTestFixture.cs create mode 100644 NpgsqlRestTests/Setup/McpSqlFileTestFixture.cs create mode 100644 NpgsqlRestTests/Setup/McpToolNameTestFixture.cs delete mode 100644 plugins/NpgsqlRest.Mcp/McpJsonContext.cs diff --git a/NpgsqlRestClient/App.cs b/NpgsqlRestClient/App.cs index edeb62fb..1a006822 100644 --- a/NpgsqlRestClient/App.cs +++ b/NpgsqlRestClient/App.cs @@ -589,6 +589,7 @@ public List CreateCodeGenHandlers(string connectionStrin ServerVersion = _config.GetConfigStr("ServerVersion", mcpCfg), Instructions = _config.GetConfigStr("Instructions", mcpCfg), ToolDescriptionSuffix = _config.GetConfigStr("ToolDescriptionSuffix", mcpCfg), + AllowedOrigins = [.. _config.GetConfigEnumerable("AllowedOrigins", mcpCfg) ?? []], Authorization = new McpAuthorizationOptions { RequireAuthorization = _config.GetConfigBool("RequireAuthorization", mcpAuthCfg), diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index 3690ca80..418f2a47 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -980,6 +980,7 @@ private static JsonObject GetMcpOptionsDefaults() ["ServerVersion"] = "1.0.0", ["Instructions"] = null, ["ToolDescriptionSuffix"] = null, + ["AllowedOrigins"] = new JsonArray(), ["Authorization"] = new JsonObject { ["RequireAuthorization"] = false, diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index 85c973d8..6d8f1471 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -443,6 +443,7 @@ public static partial class ConfigSchemaGenerator ["NpgsqlRest:McpOptions:ServerVersion"] = "serverInfo.version reported in the MCP initialize handshake.", ["NpgsqlRest:McpOptions:Instructions"] = "Optional server-level instructions returned in the MCP initialize handshake (high-level guidance for the agent). Null = omitted.", ["NpgsqlRest:McpOptions:ToolDescriptionSuffix"] = "Optional text appended to every MCP tool description. Null = no-op. Use for short shared context the agent should always see (e.g. \"Read-only Acme CRM.\"); for longer server-wide guidance prefer Instructions.", + ["NpgsqlRest:McpOptions:AllowedOrigins"] = "Allowed values of the HTTP Origin header (DNS-rebinding protection for the Streamable HTTP transport). A request whose Origin is present but matches neither this list nor the server's own origin is rejected with 403. Requests without an Origin header (e.g. server-to-server) are allowed. Empty = only same-origin browser requests pass.", ["NpgsqlRest:McpOptions:Authorization"] = "OAuth 2.1 Resource Server settings. Token validation reuses the host's bearer authentication; these keys configure the transport gate and the Protected Resource Metadata document (RFC 9728). NpgsqlRest is not an Authorization Server — point AuthorizationServers at an external IdP.", ["NpgsqlRest:McpOptions:Authorization:RequireAuthorization"] = "When true, every MCP request requires an authenticated principal. When false (default), anonymous is allowed and a tool's own `authorize` annotation still gates it per call.", ["NpgsqlRest:McpOptions:Authorization:AuthorizationServers"] = "Authorization Server issuer URL(s) advertised in the Protected Resource Metadata. When empty, no PRM document is served.", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index 14569f04..d64c8bb9 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -2590,6 +2590,10 @@ public static partial class ConfigSchemaGenerator // "ToolDescriptionSuffix": null, // + // Allowed values of the HTTP Origin header (DNS-rebinding protection for the Streamable HTTP transport). A request whose Origin is present but matches neither this list nor the server's own origin is rejected with 403. Requests without an Origin header (e.g. server-to-server) are allowed. Empty = only same-origin browser requests pass. + // + "AllowedOrigins": [], + // // OAuth 2.1 Resource Server settings. Token validation reuses the host's bearer authentication; these keys configure the transport gate and the Protected Resource Metadata document (RFC 9728). NpgsqlRest is not an Authorization Server — point AuthorizationServers at an external IdP. // "Authorization": { diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index 6296519a..b7eedddc 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -2581,6 +2581,10 @@ // "ToolDescriptionSuffix": null, // + // Allowed values of the HTTP Origin header (DNS-rebinding protection for the Streamable HTTP transport). A request whose Origin is present but matches neither this list nor the server's own origin is rejected with 403. Requests without an Origin header (e.g. server-to-server) are allowed. Empty = only same-origin browser requests pass. + // + "AllowedOrigins": [], + // // OAuth 2.1 Resource Server settings. Token validation reuses the host's bearer authentication; these keys configure the transport gate and the Protected Resource Metadata document (RFC 9728). NpgsqlRest is not an Authorization Server — point AuthorizationServers at an external IdP. // "Authorization": { diff --git a/NpgsqlRestTests/McpTests/McpAudienceTests.cs b/NpgsqlRestTests/McpTests/McpAudienceTests.cs new file mode 100644 index 00000000..00b3ee02 --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpAudienceTests.cs @@ -0,0 +1,37 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +[Collection("McpAudienceFixture")] +public class McpAudienceTests(McpAudienceTestFixture test) +{ + private const string Ping = """{"jsonrpc":"2.0","id":1,"method":"ping"}"""; + + private static async Task PostPingAsync(HttpClient client) + { + var content = new StringContent(Ping, Encoding.UTF8, "application/json"); + return await client.PostAsync("/mcp", content); + } + + [Fact] + public async Task A_token_whose_audience_matches_is_accepted() + { + using var client = test.CreateClient(); + (await client.GetAsync($"/login-as?aud={Uri.EscapeDataString(McpAudienceTestFixture.Audience)}")).EnsureSuccessStatusCode(); + + using var response = await PostPingAsync(client); + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync()).Should().Be("""{"jsonrpc":"2.0","id":1,"result":{}}"""); + } + + [Fact] + public async Task A_token_issued_for_a_different_resource_is_rejected_with_401() + { + using var client = test.CreateClient(); + (await client.GetAsync("/login-as?aud=https://other.example/api")).EnsureSuccessStatusCode(); + + using var response = await PostPingAsync(client); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + response.Headers.WwwAuthenticate.ToString().Should().Contain("resource_metadata="); + } +} diff --git a/NpgsqlRestTests/McpTests/McpAuthGateTests.cs b/NpgsqlRestTests/McpTests/McpAuthGateTests.cs index 21c0924b..84a86474 100644 --- a/NpgsqlRestTests/McpTests/McpAuthGateTests.cs +++ b/NpgsqlRestTests/McpTests/McpAuthGateTests.cs @@ -27,4 +27,13 @@ public async Task Protected_resource_metadata_itself_stays_anonymous() using var response = await test.Client.GetAsync("/.well-known/oauth-protected-resource/mcp"); response.StatusCode.Should().Be(HttpStatusCode.OK); } + + [Fact] + public void RequireAuthorization_without_an_auth_scheme_logs_a_startup_warning() + { + // This fixture enables RequireAuthorization but registers no authentication scheme, so the plugin + // should warn at startup that every request will be 401. + test.StartupLogs.Should().Contain(l => + l.Message.Contains("RequireAuthorization is enabled but no authentication scheme is registered")); + } } diff --git a/NpgsqlRestTests/McpTests/McpAuthRoleTests.cs b/NpgsqlRestTests/McpTests/McpAuthRoleTests.cs index 1d36fb56..720bc436 100644 --- a/NpgsqlRestTests/McpTests/McpAuthRoleTests.cs +++ b/NpgsqlRestTests/McpTests/McpAuthRoleTests.cs @@ -18,9 +18,9 @@ public async Task Authenticated_with_the_required_role_executes_the_tool() using var response = await client.PostAsync("/mcp", content); response.StatusCode.Should().Be(HttpStatusCode.OK); - var r = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; - r["result"]!["isError"]!.GetValue().Should().BeFalse(); - r["result"]!["content"]!.AsArray()[0]!["text"]!.GetValue().Should().Be("secret"); + // tool_authorized returns the scalar text 'secret'. + (await response.Content.ReadAsStringAsync()).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"value\":\"secret\"}"}],"isError":false,"structuredContent":{"value":"secret"}}}"""); } [Fact] diff --git a/NpgsqlRestTests/McpTests/McpClaimTests.cs b/NpgsqlRestTests/McpTests/McpClaimTests.cs new file mode 100644 index 00000000..f0fc7567 --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpClaimTests.cs @@ -0,0 +1,50 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +public static partial class Database +{ + // Claim-binding tool, isolated in the `mcp_claim` schema. `_user_id` is mapped to the + // `name_identifier` claim (see McpClaimTestFixture), so it binds from the caller's principal. + public static void McpClaimTools() + { + script.Append(@" +create schema if not exists mcp_claim; + +create function mcp_claim.claim_echo(_user_id text) returns text +language sql as 'select coalesce(_user_id, ''anonymous'')'; +comment on function mcp_claim.claim_echo(text) is ' +HTTP GET +@mcp Returns the caller user id from their claim.'; +"); + } +} + +[Collection("McpClaimFixture")] +public class McpClaimTests(McpClaimTestFixture test) +{ + private const string Call = + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"claim_echo","arguments":{}}}"""; + + [Fact] + public async Task A_claim_mapped_parameter_binds_from_the_forwarded_principal() + { + using var client = test.CreateClient(); + (await client.GetAsync("/login-as?uid=42")).EnsureSuccessStatusCode(); + + using var content = new StringContent(Call, Encoding.UTF8, "application/json"); + using var response = await client.PostAsync("/mcp", content); + response.StatusCode.Should().Be(HttpStatusCode.OK); + // _user_id was never supplied as an argument — it bound from the name_identifier claim. + (await response.Content.ReadAsStringAsync()).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"value\":\"42\"}"}],"isError":false,"structuredContent":{"value":"42"}}}"""); + } + + [Fact] + public void A_claim_mapped_parameter_is_hidden_from_inputSchema() + { + // claim_echo's only parameter is claim-sourced, so the agent must not (and cannot) supply it. + test.Tools["claim_echo"]!.ToJsonString().Should().Be( + """{"name":"claim_echo","description":"Returns the caller user id from their claim.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"value":{"type":["string","null"]}}}}"""); + } +} diff --git a/NpgsqlRestTests/McpTests/McpEdgeCaseTests.cs b/NpgsqlRestTests/McpTests/McpEdgeCaseTests.cs new file mode 100644 index 00000000..f085b0fb --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpEdgeCaseTests.cs @@ -0,0 +1,147 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +public static partial class Database +{ + // Edge-case tools: NULL/empty/void results, non-text scalar result types, and NULL/JSON parameters. + public static void McpEdgeCaseTools() + { + script.Append(@" +create schema if not exists mcp; + +create function mcp.edge_null_scalar() returns int language sql as 'select null::int'; +comment on function mcp.edge_null_scalar() is ' +HTTP GET +@mcp A null scalar.'; + +create function mcp.edge_empty_set() returns setof int language sql as 'select 1 where false'; +comment on function mcp.edge_empty_set() is ' +HTTP GET +@mcp An empty set.'; + +create function mcp.edge_void() returns void language plpgsql as 'begin end'; +comment on function mcp.edge_void() is ' +HTTP POST +@mcp A void routine.'; + +create function mcp.edge_bool() returns bool language sql as 'select true'; +comment on function mcp.edge_bool() is ' +HTTP GET +@mcp A boolean.'; + +create function mcp.edge_numeric() returns numeric language sql as 'select 3.14::numeric'; +comment on function mcp.edge_numeric() is ' +HTTP GET +@mcp A numeric.'; + +create function mcp.edge_json() returns json language sql as 'select ''{""a"":1,""b"":[2,3]}''::json'; +comment on function mcp.edge_json() is ' +HTTP GET +@mcp A json scalar.'; + +create function mcp.edge_nullarg(x int) returns text language sql as 'select coalesce(x::text, ''WAS_NULL'')'; +comment on function mcp.edge_nullarg(int) is ' +HTTP POST +@mcp Reports whether its argument was null.'; + +create function mcp.edge_jsonparam(data json) returns json language sql as 'select data'; +comment on function mcp.edge_jsonparam(json) is ' +HTTP POST +@mcp Echoes a json argument.'; +"); + } +} + +/// +/// MCP wire edge cases: NULL/empty/void results, non-text scalar result types, NULL/JSON arguments, and +/// malformed/unusual JSON-RPC inputs (batch array, missing name, non-integer id). +/// +[Collection("McpPluginFixture")] +public class McpEdgeCaseTests(McpPluginTestFixture test) +{ + private async Task<(HttpStatusCode Status, string Body)> PostAsync(string json) + { + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await test.Client.PostAsync("/mcp", content); + return (response.StatusCode, await response.Content.ReadAsStringAsync()); + } + + private async Task CallAsync(string tool, string arguments = "{}") + { + var json = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"" + + tool + "\",\"arguments\":" + arguments + "}}"; + var (status, body) = await PostAsync(json); + status.Should().Be(HttpStatusCode.OK); + return body; + } + + // ---- result shapes ---------------------------------------------------- + + [Fact] + public async Task Null_scalar_result_has_empty_text_and_no_structured_content() + => (await CallAsync("edge_null_scalar")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":""}],"isError":false}}"""); + + [Fact] + public async Task Empty_set_result_is_an_empty_items_array() + => (await CallAsync("edge_empty_set")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"items\":[]}"}],"isError":false,"structuredContent":{"items":[]}}}"""); + + [Fact] + public async Task Void_result_has_empty_text_and_no_structured_content() + => (await CallAsync("edge_void")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":""}],"isError":false}}"""); + + [Fact] + public async Task Boolean_result_is_a_json_boolean() + => (await CallAsync("edge_bool")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"value\":true}"}],"isError":false,"structuredContent":{"value":true}}}"""); + + [Fact] + public async Task Numeric_result_is_a_json_number() + => (await CallAsync("edge_numeric")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"value\":3.14}"}],"isError":false,"structuredContent":{"value":3.14}}}"""); + + [Fact] + public async Task Json_scalar_result_is_embedded_as_a_parsed_object() + => (await CallAsync("edge_json")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"value\":{\"a\":1,\"b\":[2,3]}}"}],"isError":false,"structuredContent":{"value":{"a":1,"b":[2,3]}}}}"""); + + // ---- argument shapes -------------------------------------------------- + + [Fact] + public async Task Null_argument_binds_as_sql_null() + => (await CallAsync("edge_nullarg", """{"x":null}""")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"value\":\"WAS_NULL\"}"}],"isError":false,"structuredContent":{"value":"WAS_NULL"}}}"""); + + [Fact] + public async Task Json_argument_round_trips() + => (await CallAsync("edge_jsonparam", """{"data":{"k":[1,2]}}""")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"value\":{\"k\":[1,2]}}"}],"isError":false,"structuredContent":{"value":{"k":[1,2]}}}}"""); + + // ---- malformed / unusual JSON-RPC ------------------------------------- + + [Fact] + public async Task A_batch_array_request_is_rejected_as_invalid_not_a_crash() + { + // MCP 2025-11-25 removed JSON-RPC batching; an array body is invalid → -32600, not an HTTP 500. + var (status, body) = await PostAsync("""[{"jsonrpc":"2.0","id":1,"method":"ping"}]"""); + status.Should().Be(HttpStatusCode.OK); + body.Should().Be("""{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid Request"}}"""); + } + + [Fact] + public async Task Tools_call_with_no_name_is_an_unknown_tool_error() + { + var (_, body) = await PostAsync("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"arguments":{}}}"""); + body.Should().Be("""{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"Unknown tool: "}}"""); + } + + [Fact] + public async Task A_string_id_is_echoed_back_as_a_string() + { + var (_, body) = await PostAsync("""{"jsonrpc":"2.0","id":"abc-1","method":"ping"}"""); + body.Should().Be("""{"jsonrpc":"2.0","id":"abc-1","result":{}}"""); + } +} diff --git a/NpgsqlRestTests/McpTests/McpFeatureWarnTests.cs b/NpgsqlRestTests/McpTests/McpFeatureWarnTests.cs index d8adb4c8..5648d801 100644 --- a/NpgsqlRestTests/McpTests/McpFeatureWarnTests.cs +++ b/NpgsqlRestTests/McpTests/McpFeatureWarnTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using NpgsqlRestTests.Setup; namespace NpgsqlRestTests; @@ -34,4 +35,25 @@ public void Mcp_annotation_on_a_non_applicable_feature_logs_a_warning() warning.Should().NotBeNull("the plugin should warn when `@mcp` is placed on a login routine"); warning!.Message.Should().Contain("mcp_warn.tool_login"); } + + [Fact] + public void Mcp_annotation_appears_in_the_endpoint_annotations_summary() + { + // The per-endpoint "annotations: [...]" Debug summary should list the mcp annotation alongside the + // built-in ones, so it is as visible in the console as HTTP/authorize/etc. + var summary = test.StartupLogs.FirstOrDefault(l => l.Message.Contains("annotations: [")); + summary.Should().NotBeNull("each created endpoint logs an annotations summary"); + summary!.Message.Should().Contain("Sign in."); // tool_login's `@mcp Sign in.` annotation + } + + [Fact] + public void The_exposed_tool_catalog_is_logged_at_startup() + { + // An Information-level summary lists the exposed tools so operators see the catalog without Debug. + // This fixture exposes exactly one tool (tool_login). + var catalog = test.StartupLogs.FirstOrDefault(l => l.Message.Contains("tool(s) exposed")); + catalog.Should().NotBeNull("the plugin should log a one-line MCP tool-catalog summary at startup"); + catalog!.Level.Should().Be(LogLevel.Information); + catalog.Message.Should().Contain("tool_login"); + } } diff --git a/NpgsqlRestTests/McpTests/McpParameterTests.cs b/NpgsqlRestTests/McpTests/McpParameterTests.cs new file mode 100644 index 00000000..83abb233 --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpParameterTests.cs @@ -0,0 +1,152 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +public static partial class Database +{ + // Tools that actually USE and RETURN their parameters, so tools/call argument mapping is observable: + // multiple typed args, a path parameter, an optional (DEFAULT) parameter, and a POST JSON body. + public static void McpParameterTools() + { + script.Append(@" +create schema if not exists mcp; + +-- multiple typed (int) params via GET query string; computes a result so the bound values are visible. +create function mcp.param_sum(a int, b int) returns int language sql as 'select a + b'; +comment on function mcp.param_sum(int, int) is ' +HTTP GET +@mcp Add two numbers.'; + +-- optional parameter with a DEFAULT: omitted → default applies; supplied → overrides. +create function mcp.param_opts(req text, opt text default 'D') returns text language sql as 'select req || ''/'' || opt'; +comment on function mcp.param_opts(text, text) is ' +HTTP GET +@mcp Join a required and an optional value.'; + +-- path parameter: `id` binds from the URL path segment, not the query string. +create function mcp.param_path(id int) returns int language sql as 'select id'; +comment on function mcp.param_path(int) is ' +HTTP GET /api/mcp-item/{id} +@mcp Fetch an item by path id.'; + +-- POST tool with two params → arguments are mapped onto a JSON request body. +create function mcp.param_body(first text, second text) returns text language sql as 'select first || ''-'' || second'; +comment on function mcp.param_body(text, text) is ' +HTTP POST +@mcp Concatenate two values via a JSON body.'; + +-- array parameter AND array result: the int[] argument maps into the JSON body and round-trips back. +create function mcp.param_array(vals int[]) returns int[] language sql as 'select vals'; +comment on function mcp.param_array(int[]) is ' +HTTP POST +@mcp Echo an int array.'; +"); + } +} + +/// +/// tools/call argument mapping: arguments flow to the routine as a query string (GET/DELETE), a JSON +/// body (POST/PUT), or path-segment substitution — and typed/optional parameters bind correctly. Each +/// tool returns a value derived from its arguments, so the round-trip is asserted in the full body. +/// +[Collection("McpPluginFixture")] +public class McpParameterTests(McpPluginTestFixture test) +{ + private async Task CallAsync(string requestJson) + { + using var content = new StringContent(requestJson, Encoding.UTF8, "application/json"); + using var response = await test.Client.PostAsync("/mcp", content); + response.StatusCode.Should().Be(HttpStatusCode.OK); + return await response.Content.ReadAsStringAsync(); + } + + [Fact] + public async Task Multiple_typed_int_arguments_bind_and_compute_via_query_string() + { + (await CallAsync("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"param_sum","arguments":{"a":2,"b":3}}}""")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"value\":5}"}],"isError":false,"structuredContent":{"value":5}}}"""); + } + + [Fact] + public async Task An_omitted_optional_argument_uses_the_postgres_default() + { + (await CallAsync("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"param_opts","arguments":{"req":"a"}}}""")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"value\":\"a/D\"}"}],"isError":false,"structuredContent":{"value":"a/D"}}}"""); + } + + [Fact] + public async Task A_supplied_optional_argument_overrides_the_default() + { + (await CallAsync("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"param_opts","arguments":{"req":"a","opt":"b"}}}""")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"value\":\"a/b\"}"}],"isError":false,"structuredContent":{"value":"a/b"}}}"""); + } + + [Fact] + public async Task A_path_parameter_argument_is_substituted_into_the_url() + { + (await CallAsync("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"param_path","arguments":{"id":7}}}""")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"value\":7}"}],"isError":false,"structuredContent":{"value":7}}}"""); + } + + [Fact] + public async Task Multiple_arguments_map_to_a_json_body_for_a_post_tool() + { + (await CallAsync("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"param_body","arguments":{"first":"x","second":"y"}}}""")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"value\":\"x-y\"}"}],"isError":false,"structuredContent":{"value":"x-y"}}}"""); + } + + [Fact] + public async Task Argument_values_with_special_characters_round_trip_through_the_query_string() + { + // demo_echo returns its argument verbatim; '&', '=' and spaces must survive URL-encoding. + (await CallAsync("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"demo_echo","arguments":{"message":"a & b = c"}}}""")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"value\":\"a & b = c\"}"}],"isError":false,"structuredContent":{"value":"a & b = c"}}}"""); + } + + [Fact] + public async Task Unicode_and_emoji_arguments_round_trip() + { + // Non-ASCII survives the round-trip (query-string percent-encoding + relaxed encoder). BMP chars + // (é, Cyrillic) are emitted as literal UTF-8; astral-plane chars (the 🚀 emoji) as a \uXXXX\uXXXX + // surrogate-pair escape — both faithfully decode back to the original. + (await CallAsync("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"demo_echo","arguments":{"message":"héllo 🚀 мир"}}}""")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"value\":\"héllo \\uD83D\\uDE80 мир\"}"}],"isError":false,"structuredContent":{"value":"héllo \uD83D\uDE80 мир"}}}"""); + } + + [Fact] + public async Task An_array_argument_round_trips_through_the_json_body() + { + (await CallAsync("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"param_array","arguments":{"vals":[1,2,3]}}}""")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"value\":[1,2,3]}"}],"isError":false,"structuredContent":{"value":[1,2,3]}}}"""); + } + + [Fact] + public async Task Concurrent_tools_calls_do_not_cross_talk() + { + // The catalog is read-only after startup and invocation is stateless — fire many parallel calls + // with distinct arguments and confirm each response carries its own value (no shared-state bleed). + async Task<(int I, string Body)> Call(int i) + { + var req = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"demo_echo\"," + + "\"arguments\":{\"message\":\"c" + i + "\"}}}"; + return (i, await CallAsync(req)); + } + + var results = await Task.WhenAll(Enumerable.Range(0, 24).Select(Call)); + + foreach (var (i, body) in results) + { + body.Should().Be( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"value\\\":\\\"c" + + i + "\\\"}\"}],\"isError\":false,\"structuredContent\":{\"value\":\"c" + i + "\"}}}"); + } + } + + [Fact] + public void An_array_parameter_is_declared_as_an_array_in_inputSchema() + { + // Arrays render precisely in inputSchema; the array-typed result uses a permissive outputSchema value. + test.Tools["param_array"]!.ToJsonString().Should().Be( + """{"name":"param_array","description":"Echo an int array.","inputSchema":{"type":"object","properties":{"vals":{"type":"array","items":{"type":"integer","format":"int32"}}},"required":["vals"]},"annotations":{"readOnlyHint":false},"outputSchema":{"type":"object","properties":{"value":{}}}}"""); + } +} diff --git a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs index fdf23847..273f71a1 100644 --- a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs +++ b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs @@ -170,53 +170,47 @@ public void Catalog_contains_only_mcp_tools_keyed_by_tool_name() test.Tools.Should().NotContainKey("tool_modifier_only"); // not created at all } + // The full tool definitions. (tool_basic's plain definition is asserted in McpServerTests' tools/list.) + // GET tools carry readOnlyHint:true; outputSchema is derived from the return type. + [Fact] - public void Description_derives_from_prose_inline_text_and_falls_back_to_name() + public void Description_derives_from_inline_mcp_text() { - test.Tools["tool_basic"]!["description"]!.GetValue().Should().Be("Fetch basic data for the agent."); - test.Tools["tool_inline_desc"]!["description"]!.GetValue().Should().Be("Cancel a booking and release the room."); - test.Tools["tool_nodesc"]!["description"]!.GetValue().Should().Be("tool_nodesc"); // fallback + test.Tools["tool_inline_desc"]!.ToJsonString().Should().Be( + """{"name":"tool_inline_desc","description":"Cancel a booking and release the room.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"value":{"type":["string","null"]}}}}"""); } [Fact] - public void ToolDescriptionSuffix_is_appended_to_every_tool_description() + public void Description_falls_back_to_the_routine_name_when_there_is_no_text() { - // A fresh plugin instance with a suffix, fed the same parsed endpoints. Handle() only reads the - // endpoint and writes to its own catalog, so the fixture's suffix-less Tools are unaffected. - var mcp = new Mcp(new McpOptions { Enabled = true, ToolDescriptionSuffix = "(Acme demo — read-only.)" }); - mcp.Handle(test.Endpoints["tool_basic"]); // derived-prose description - mcp.Handle(test.Endpoints["tool_nodesc"]); // name-fallback description - - mcp.Tools["tool_basic"]!["description"]!.GetValue() - .Should().Be("Fetch basic data for the agent. (Acme demo — read-only.)"); - mcp.Tools["tool_nodesc"]!["description"]!.GetValue() - .Should().Be("tool_nodesc (Acme demo — read-only.)"); + test.Tools["tool_nodesc"]!.ToJsonString().Should().Be( + """{"name":"tool_nodesc","description":"tool_nodesc","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"value":{"type":["string","null"]}}}}"""); } [Fact] - public void InputSchema_lists_params_and_marks_only_non_default_as_required() + public void ToolDescriptionSuffix_is_appended_to_every_tool_description() { - var schema = test.Tools["tool_params"]!["inputSchema"]!; - var props = schema["properties"]!.AsObject(); - props.ContainsKey("id").Should().BeTrue(); - props.ContainsKey("label").Should().BeTrue(); - props["id"]!["type"]!.GetValue().Should().Be("integer"); - - var required = schema["required"]!.AsArray().Select(n => n!.GetValue()).ToArray(); - required.Should().Equal("id"); // `label` has a DEFAULT → optional, not required + // A fresh plugin instance with a suffix, fed the same parsed endpoint. Handle() only reads the + // endpoint and writes to its own catalog, so the fixture's suffix-less Tools are unaffected. + var mcp = new Mcp(new McpOptions { Enabled = true, ToolDescriptionSuffix = "(Acme demo, read-only.)" }); + mcp.Handle(test.Endpoints["tool_basic"]); + mcp.Tools["tool_basic"]!.ToJsonString().Should().Be( + """{"name":"tool_basic","description":"Fetch basic data for the agent. (Acme demo, read-only.)","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"value":{"type":["string","null"]}}}}"""); } [Fact] - public void Server_resolved_params_are_excluded_from_inputSchema() + public void InputSchema_lists_params_and_marks_only_non_default_as_required() { - var props = test.Tools["tool_resolved"]!["inputSchema"]!["properties"]!.AsObject(); - props.ContainsKey("id").Should().BeTrue(); - props.ContainsKey("secret").Should().BeFalse(); // resolved server-side → agent must not supply it + // tool_params(id int, label text default 'x'): both params listed; only `id` (no DEFAULT) required. + test.Tools["tool_params"]!.ToJsonString().Should().Be( + """{"name":"tool_params","description":"Fetch by id.","inputSchema":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"label":{"type":"string"}},"required":["id"]},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"value":{"type":["string","null"]}}}}"""); } [Fact] - public void Get_tools_carry_read_only_hint() + public void Server_resolved_params_are_excluded_from_inputSchema() { - test.Tools["tool_basic"]!["annotations"]!["readOnlyHint"]!.GetValue().Should().BeTrue(); + // tool_resolved(id int, secret text): `secret` is resolved server-side, so it is absent from inputSchema. + test.Tools["tool_resolved"]!.ToJsonString().Should().Be( + """{"name":"tool_resolved","description":"tool_resolved","inputSchema":{"type":"object","properties":{"id":{"type":"integer","format":"int32"}},"required":["id"]},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"value":{"type":["string","null"]}}}}"""); } } diff --git a/NpgsqlRestTests/McpTests/McpProtocolTests.cs b/NpgsqlRestTests/McpTests/McpProtocolTests.cs new file mode 100644 index 00000000..6b18e973 --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpProtocolTests.cs @@ -0,0 +1,206 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +public static partial class Database +{ + // Demonstrative tools for the end-to-end / edge-case MCP protocol tests. Added to the same isolated + // `mcp` schema as the annotation tests (excluded from every other fixture). These exercise argument + // flow (GET query string + POST JSON body), method hints (DELETE), and the business-error channel. + public static void McpProtocolDemoTools() + { + script.Append(@" +create schema if not exists mcp; + +-- echoes its argument back verbatim → shows arguments reach the routine and the result returns as-is. +create function mcp.demo_echo(message text) returns text language sql as 'select message'; +comment on function mcp.demo_echo(text) is ' +HTTP GET +@mcp Echo the message back.'; + +-- POST tool → arguments are mapped onto a JSON request body. +create function mcp.demo_create(label text) returns text language sql as 'select ''created: '' || label'; +comment on function mcp.demo_create(text) is ' +HTTP POST +@mcp Create a labelled item.'; + +-- DELETE tool → tools/list advertises destructiveHint. +create function mcp.demo_remove(id int) returns text language sql as 'select ''removed'''; +comment on function mcp.demo_remove(int) is ' +HTTP DELETE +@mcp Remove an item by id.'; + +-- raises → exercises the business-error channel (isError:true result, NOT a JSON-RPC error). +create function mcp.demo_fail() returns text language plpgsql as $$ +begin + raise exception 'boom'; +end; +$$; +comment on function mcp.demo_fail() is ' +HTTP GET +@mcp Always fails.'; +"); + } +} + +/// +/// End-to-end / edge-case walk-through of the MCP wire protocol against a live endpoint. Reads top to +/// bottom as a description of how the server behaves: lifecycle, the two error channels, argument +/// mapping for GET vs POST, method-derived hints, renamed tools, and malformed input. +/// +[Collection("McpPluginFixture")] +public class McpProtocolTests(McpPluginTestFixture test) +{ + /// POSTs to /mcp and returns the status and the raw response body. + private async Task<(HttpStatusCode Status, string Body)> PostAsync(string json) + { + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await test.Client.PostAsync("/mcp", content); + return (response.StatusCode, await response.Content.ReadAsStringAsync()); + } + + // ---- Lifecycle --------------------------------------------------------- + + [Fact] + public async Task Initialize_negotiates_the_protocol_version_and_advertises_tools() + { + var (status, body) = await PostAsync("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"""); + status.Should().Be(HttpStatusCode.OK); + body.Should().Be("""{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"npgsql_rest_test","version":"1.0.0"}}}"""); + } + + // ---- Argument mapping -------------------------------------------------- + + [Fact] + public async Task Tools_call_passes_arguments_to_a_GET_tool_via_query_string_and_returns_the_result() + { + // demo_echo(message) returns the message verbatim → it flows through the query string and back. + var (status, body) = await PostAsync( + """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"demo_echo","arguments":{"message":"hello agent"}}}"""); + status.Should().Be(HttpStatusCode.OK); + body.Should().Be("""{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{\"value\":\"hello agent\"}"}],"isError":false,"structuredContent":{"value":"hello agent"}}}"""); + } + + [Fact] + public async Task Tools_call_maps_arguments_to_a_JSON_body_for_a_POST_tool() + { + var (status, body) = await PostAsync( + """{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"demo_create","arguments":{"label":"widget"}}}"""); + status.Should().Be(HttpStatusCode.OK); + body.Should().Be("""{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\"value\":\"created: widget\"}"}],"isError":false,"structuredContent":{"value":"created: widget"}}}"""); + } + + [Fact] + public async Task Tools_call_with_no_arguments_object_is_treated_as_empty() + { + var (status, body) = await PostAsync( + """{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"tool_basic"}}"""); + status.Should().Be(HttpStatusCode.OK); + body.Should().Be("""{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"{\"value\":\"basic\"}"}],"isError":false,"structuredContent":{"value":"basic"}}}"""); + } + + [Fact] + public async Task Tools_call_by_a_renamed_tool_executes_under_the_mcp_name() + { + // tool_named (returns 'n') is published as `cancel_booking` via @mcp_name. + var (_, ok) = await PostAsync( + """{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"cancel_booking","arguments":{}}}"""); + ok.Should().Be("""{"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":"{\"value\":\"n\"}"}],"isError":false,"structuredContent":{"value":"n"}}}"""); + + // ...so calling the original routine name is an unknown tool. + var (_, gone) = await PostAsync( + """{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"tool_named","arguments":{}}}"""); + gone.Should().Be("""{"jsonrpc":"2.0","id":6,"error":{"code":-32602,"message":"Unknown tool: tool_named"}}"""); + } + + // ---- Method-derived hints --------------------------------------------- + + [Fact] + public async Task Tools_list_marks_a_DELETE_tool_as_destructive() + { + // Assert the full shape of the DELETE tool (catalog grows, so target the one tool). + var (_, body) = await PostAsync("""{"jsonrpc":"2.0","id":7,"method":"tools/list"}"""); + var remove = JsonNode.Parse(body)!["result"]!["tools"]!.AsArray() + .First(t => t!["name"]!.GetValue() == "demo_remove"); + remove!.ToJsonString().Should().Be("""{"name":"demo_remove","description":"Remove an item by id.","inputSchema":{"type":"object","properties":{"id":{"type":"integer","format":"int32"}},"required":["id"]},"annotations":{"readOnlyHint":false,"destructiveHint":true},"outputSchema":{"type":"object","properties":{"value":{"type":["string","null"]}}}}"""); + } + + // ---- Error channels ---------------------------------------------------- + + [Fact] + public async Task Tools_call_business_failure_is_a_result_with_isError_true_not_a_jsonrpc_error() + { + // demo_fail raises → business-error channel: transport 200, isError:true, the ProblemDetails + // serialized into the text block (title = the exception message, detail = the SQLSTATE), and NO + // structuredContent. It is NOT a structural JSON-RPC error. + var (status, body) = await PostAsync( + """{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"demo_fail","arguments":{}}}"""); + status.Should().Be(HttpStatusCode.OK); + body.Should().Be("""{"jsonrpc":"2.0","id":8,"result":{"content":[{"type":"text","text":"{\"type\":\"https://tools.ietf.org/html/rfc9110#section-15.5.1\",\"title\":\"boom\",\"status\":400,\"detail\":\"P0001\"}"}],"isError":true}}"""); + } + + [Fact] + public async Task Malformed_json_is_a_jsonrpc_parse_error() + { + var (status, body) = await PostAsync("{ this is not json "); + status.Should().Be(HttpStatusCode.OK); + body.Should().Be("""{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}"""); + } + + [Fact] + public async Task Unknown_method_is_method_not_found() + { + var (_, body) = await PostAsync("""{"jsonrpc":"2.0","id":9,"method":"does/not/exist"}"""); + body.Should().Be("""{"jsonrpc":"2.0","id":9,"error":{"code":-32601,"message":"Method not found: does/not/exist"}}"""); + } + + // ---- Transport --------------------------------------------------------- + + [Fact] + public async Task A_GET_to_the_mcp_endpoint_is_method_not_allowed() + { + using var response = await test.Client.GetAsync("/mcp"); + response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); + } + + private static HttpRequestMessage Ping(string? origin = null, string? protocolVersion = null) + { + var req = new HttpRequestMessage(HttpMethod.Post, "/mcp") + { + Content = new StringContent("""{"jsonrpc":"2.0","id":1,"method":"ping"}""", Encoding.UTF8, "application/json"), + }; + if (origin is not null) req.Headers.TryAddWithoutValidation("Origin", origin); + if (protocolVersion is not null) req.Headers.TryAddWithoutValidation("MCP-Protocol-Version", protocolVersion); + return req; + } + + [Fact] + public async Task A_request_from_an_untrusted_origin_is_forbidden() + { + // DNS-rebinding protection: a present, non-matching Origin is rejected. + using var response = await test.Client.SendAsync(Ping(origin: "https://evil.example.com")); + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task A_request_from_the_servers_own_origin_is_allowed() + { + var self = test.Client.BaseAddress!.GetLeftPart(UriPartial.Authority); + using var response = await test.Client.SendAsync(Ping(origin: self)); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task An_unsupported_protocol_version_header_is_a_bad_request() + { + using var response = await test.Client.SendAsync(Ping(protocolVersion: "1999-01-01")); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task The_negotiated_protocol_version_header_is_accepted() + { + using var response = await test.Client.SendAsync(Ping(protocolVersion: "2025-11-25")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } +} diff --git a/NpgsqlRestTests/McpTests/McpServerTests.cs b/NpgsqlRestTests/McpTests/McpServerTests.cs index 0d64535b..bcf84741 100644 --- a/NpgsqlRestTests/McpTests/McpServerTests.cs +++ b/NpgsqlRestTests/McpTests/McpServerTests.cs @@ -5,52 +5,44 @@ namespace NpgsqlRestTests; [Collection("McpPluginFixture")] public class McpServerTests(McpPluginTestFixture test) { - private async Task RpcAsync(string json) + /// POSTs a JSON-RPC request to /mcp, asserts 200, and returns the raw response body. + private async Task RpcAsync(string json) { using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var response = await test.Client.PostAsync("/mcp", content); response.StatusCode.Should().Be(HttpStatusCode.OK); - var body = await response.Content.ReadAsStringAsync(); - return JsonNode.Parse(body)!; + return await response.Content.ReadAsStringAsync(); } [Fact] public async Task Initialize_returns_protocol_capabilities_and_serverinfo() { - var r = await RpcAsync("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"""); - r["jsonrpc"]!.GetValue().Should().Be("2.0"); - r["id"]!.GetValue().Should().Be(1); - var result = r["result"]!; - result["protocolVersion"]!.GetValue().Should().Be("2025-11-25"); - result["capabilities"]!["tools"].Should().NotBeNull(); // tools capability advertised - // ServerName is unset in the fixture, so it falls back to the database name from the connection string. - result["serverInfo"]!["name"]!.GetValue().Should().Be("npgsql_rest_test"); + // ServerName is unset in the fixture, so serverInfo.name falls back to the database name. + var body = await RpcAsync("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"""); + body.Should().Be("""{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"npgsql_rest_test","version":"1.0.0"}}}"""); } [Fact] public async Task Tools_list_returns_the_catalog() { - var r = await RpcAsync("""{"jsonrpc":"2.0","id":2,"method":"tools/list"}"""); - var tools = r["result"]!["tools"]!.AsArray(); - var basic = tools.FirstOrDefault(t => t!["name"]!.GetValue() == "tool_basic"); - basic.Should().NotBeNull(); - basic!["description"]!.GetValue().Should().Be("Fetch basic data for the agent."); - basic["inputSchema"]!["type"]!.GetValue().Should().Be("object"); + // The catalog grows as demo tools are added, so assert the full shape of one representative tool. + var r = JsonNode.Parse(await RpcAsync("""{"jsonrpc":"2.0","id":2,"method":"tools/list"}"""))!; + var basic = r["result"]!["tools"]!.AsArray().First(t => t!["name"]!.GetValue() == "tool_basic"); + basic!.ToJsonString().Should().Be("""{"name":"tool_basic","description":"Fetch basic data for the agent.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"value":{"type":["string","null"]}}}}"""); } [Fact] public async Task Ping_returns_empty_result() { - var r = await RpcAsync("""{"jsonrpc":"2.0","id":3,"method":"ping"}"""); - r["result"]!.AsObject().Count.Should().Be(0); + var body = await RpcAsync("""{"jsonrpc":"2.0","id":3,"method":"ping"}"""); + body.Should().Be("""{"jsonrpc":"2.0","id":3,"result":{}}"""); } [Fact] public async Task Unknown_method_returns_jsonrpc_method_not_found() { - var r = await RpcAsync("""{"jsonrpc":"2.0","id":4,"method":"bogus/method"}"""); - r["error"]!["code"]!.GetValue().Should().Be(-32601); - r["id"]!.GetValue().Should().Be(4); + var body = await RpcAsync("""{"jsonrpc":"2.0","id":4,"method":"bogus/method"}"""); + body.Should().Be("""{"jsonrpc":"2.0","id":4,"error":{"code":-32601,"message":"Method not found: bogus/method"}}"""); } [Fact] @@ -60,33 +52,31 @@ public async Task Initialized_notification_returns_202_with_no_body() """{"jsonrpc":"2.0","method":"notifications/initialized"}""", Encoding.UTF8, "application/json"); using var response = await test.Client.PostAsync("/mcp", content); response.StatusCode.Should().Be(HttpStatusCode.Accepted); + (await response.Content.ReadAsStringAsync()).Should().BeEmpty(); } [Fact] - public async Task Tools_call_executes_the_routine_and_returns_text_content() + public async Task Tools_call_executes_the_routine_and_returns_text_and_structured_content() { - var r = await RpcAsync("""{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"tool_basic","arguments":{}}}"""); - var result = r["result"]!; - result["isError"]!.GetValue().Should().BeFalse(); - result["content"]!.AsArray()[0]!["type"]!.GetValue().Should().Be("text"); - result["content"]!.AsArray()[0]!["text"]!.GetValue().Should().Be("basic"); + // tool_basic returns the scalar text 'basic' → structuredContent { "value": "basic" }; the text + // content block carries that serialized (with relaxed JSON escaping, so quotes are \"). + var body = await RpcAsync("""{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"tool_basic","arguments":{}}}"""); + body.Should().Be("""{"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":"{\"value\":\"basic\"}"}],"isError":false,"structuredContent":{"value":"basic"}}}"""); } [Fact] public async Task Tools_call_passes_arguments_through() { - // tool_params(id int, label default) ignores its args (returns 'p'); this exercises the - // query-string build path without error. - var r = await RpcAsync("""{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"tool_params","arguments":{"id":5}}}"""); - r["result"]!["isError"]!.GetValue().Should().BeFalse(); - r["result"]!["content"]!.AsArray()[0]!["text"]!.GetValue().Should().Be("p"); + // tool_params(id int, label default) ignores its args (returns 'p'); exercises the query-string path. + var body = await RpcAsync("""{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"tool_params","arguments":{"id":5}}}"""); + body.Should().Be("""{"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"text","text":"{\"value\":\"p\"}"}],"isError":false,"structuredContent":{"value":"p"}}}"""); } [Fact] public async Task Tools_call_unknown_tool_is_a_jsonrpc_error() { - var r = await RpcAsync("""{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"nope","arguments":{}}}"""); - r["error"]!["code"]!.GetValue().Should().Be(-32602); + var body = await RpcAsync("""{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"nope","arguments":{}}}"""); + body.Should().Be("""{"jsonrpc":"2.0","id":7,"error":{"code":-32602,"message":"Unknown tool: nope"}}"""); } [Fact] @@ -111,14 +101,10 @@ public async Task Protected_resource_metadata_is_served_at_the_well_known_path() response.StatusCode.Should().Be(HttpStatusCode.OK); response.Content.Headers.ContentType!.MediaType.Should().Be("application/json"); - var doc = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; - // resource is derived from the request origin + UrlPath (no explicit Audience configured). - doc["resource"]!.GetValue().Should().EndWith("/mcp"); - doc["authorization_servers"]!.AsArray().Select(n => n!.GetValue()) - .Should().Equal("https://as.example.com"); - doc["scopes_supported"]!.AsArray().Select(n => n!.GetValue()) - .Should().Equal("mcp.read", "mcp.write"); - doc["bearer_methods_supported"]!.AsArray().Select(n => n!.GetValue()) - .Should().Equal("header"); + // resource is derived from the request origin (host:port vary) + UrlPath, no explicit Audience. + var origin = test.Client.BaseAddress!.GetLeftPart(UriPartial.Authority); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Be("{\"resource\":\"" + origin + "/mcp\",\"authorization_servers\":[\"https://as.example.com\"]," + + "\"bearer_methods_supported\":[\"header\"],\"scopes_supported\":[\"mcp.read\",\"mcp.write\"]}"); } } diff --git a/NpgsqlRestTests/McpTests/McpSqlFileTests.cs b/NpgsqlRestTests/McpTests/McpSqlFileTests.cs new file mode 100644 index 00000000..1c607ddd --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpSqlFileTests.cs @@ -0,0 +1,39 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +/// +/// MCP works with SqlFileSource endpoints (generated from .sql files), not just database routines — +/// for both single-command and multi-command SQL files. +/// +[Collection("McpSqlFileFixture")] +public class McpSqlFileTests(McpSqlFileTestFixture test) +{ + private async Task CallAsync(string tool) + { + var json = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"" + + tool + "\",\"arguments\":{}}}"; + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await test.Client.PostAsync("/mcp", content); + response.StatusCode.Should().Be(HttpStatusCode.OK); + return await response.Content.ReadAsStringAsync(); + } + + [Fact] + public void SqlFile_tools_are_in_the_catalog() => string.Join(",", test.Tools.Keys.OrderBy(k => k)).Should().Be("mcp_sql_multi,mcp_sql_single"); + + [Fact] + public async Task Single_command_sql_file_tool_executes() + => (await CallAsync("mcp_sql_single")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"items\":[42]}"}],"isError":false,"structuredContent":{"items":[42]}}}"""); + + [Fact] + public async Task Multi_command_sql_file_tool_executes_and_returns_each_result_set() + => (await CallAsync("mcp_sql_multi")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"result1\":[1],\"result2\":[2]}"}],"isError":false,"structuredContent":{"result1":[1],"result2":[2]}}}"""); + + [Fact] + public void Multi_command_tool_omits_outputSchema_since_its_shape_is_not_derivable() + => test.Tools["mcp_sql_multi"]!.ToJsonString().Should().Be( + """{"name":"mcp_sql_multi","description":"Multi-command SQL file tool.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true}}"""); +} diff --git a/NpgsqlRestTests/McpTests/McpStructuredContentTests.cs b/NpgsqlRestTests/McpTests/McpStructuredContentTests.cs new file mode 100644 index 00000000..3cbfae18 --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpStructuredContentTests.cs @@ -0,0 +1,147 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +public static partial class Database +{ + // The four PostgreSQL return shapes, each opted in as an MCP tool. The structuredContent the server + // produces is asserted in McpStructuredContentTests. All live in the isolated `mcp` schema. + public static void McpStructuredContentTools() + { + script.Append(@" +create schema if not exists mcp; + +-- 1) single scalar value -> structuredContent { ""value"": 42 } +create function mcp.sc_scalar() returns int language sql as 'select 42'; +comment on function mcp.sc_scalar() is ' +HTTP GET +@mcp A single number.'; + +-- 2) single record (set collapsed with `single`) -> structuredContent { ""total"": 1234, ""status"": ""paid"" } +create function mcp.sc_record() returns table(total int, status text) language sql as 'select 1234, ''paid'''; +comment on function mcp.sc_record() is ' +HTTP GET +@mcp A single record. +single'; + +-- 3) set of scalar values -> structuredContent { ""items"": [1, 2, 3] } +create function mcp.sc_values() returns setof int language sql as 'select * from (values (1),(2),(3)) t(v)'; +comment on function mcp.sc_values() is ' +HTTP GET +@mcp A set of numbers.'; + +-- 4) set of records / rows -> structuredContent { ""items"": [ {id,name}, ... ] } +create function mcp.sc_rows() returns table(id int, name text) language sql as 'select * from (values (1,''a''),(2,''b'')) t(id,name)'; +comment on function mcp.sc_rows() is ' +HTTP GET +@mcp A set of rows.'; + +-- 5) a column that is an ARRAY OF a custom composite type — nested result serialization. +create type mcp.tag as (label text, score int); +create function mcp.sc_composite_array() returns table(id int, tags mcp.tag[]) +language sql as 'select 1, array[row(''x'',10)::mcp.tag, row(''y'',20)::mcp.tag]'; +comment on function mcp.sc_composite_array() is ' +HTTP GET +@mcp One row whose column is an array of composite values.'; +"); + } +} + +/// +/// Spec (MCP 2025-11-25) requires structuredContent to be a JSON object. This walks the four +/// PostgreSQL return shapes and asserts the wrapping rule: a single value → { "value": … }, a +/// single record → the object itself, a set → { "items": [ … ] }. The text content block carries +/// the serialized structuredContent (the spec's backward-compatibility recommendation). +/// +[Collection("McpPluginFixture")] +public class McpStructuredContentTests(McpPluginTestFixture test) +{ + /// POSTs a tools/call for the given tool (no arguments) and returns the raw response body. + private async Task CallAsync(string tool) + { + var json = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"" + + tool + "\",\"arguments\":{}}}"; + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await test.Client.PostAsync("/mcp", content); + response.StatusCode.Should().Be(HttpStatusCode.OK); + return await response.Content.ReadAsStringAsync(); + } + + /// Fetches tools/list and returns the named tool's full definition object as a JSON string. + private async Task ToolDefAsync(string tool) + { + using var content = new StringContent("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""", Encoding.UTF8, "application/json"); + using var response = await test.Client.PostAsync("/mcp", content); + var tools = JsonNode.Parse(await response.Content.ReadAsStringAsync())!["result"]!["tools"]!.AsArray(); + return tools.First(t => t!["name"]!.GetValue() == tool)!.ToJsonString(); + } + + // ---- structuredContent on tools/call (text block carries the same JSON, relaxed-escaped) ---------- + + [Fact] + public async Task Single_scalar_value_is_wrapped_as_value_object() + { + (await CallAsync("sc_scalar")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"value\":42}"}],"isError":false,"structuredContent":{"value":42}}}"""); + } + + [Fact] + public async Task Single_record_is_the_object_itself() + { + (await CallAsync("sc_record")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"total\":1234,\"status\":\"paid\"}"}],"isError":false,"structuredContent":{"total":1234,"status":"paid"}}}"""); + } + + [Fact] + public async Task Set_of_scalar_values_is_wrapped_as_items() + { + (await CallAsync("sc_values")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"items\":[1,2,3]}"}],"isError":false,"structuredContent":{"items":[1,2,3]}}}"""); + } + + [Fact] + public async Task Set_of_records_is_wrapped_as_items() + { + (await CallAsync("sc_rows")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"items\":[{\"id\":1,\"name\":\"a\"},{\"id\":2,\"name\":\"b\"}]}"}],"isError":false,"structuredContent":{"items":[{"id":1,"name":"a"},{"id":2,"name":"b"}]}}}"""); + } + + [Fact] + public async Task A_column_that_is_an_array_of_composite_values_is_wrapped_as_items() + { + // A custom composite type (mcp.tag) inside an array column serializes as nested objects; the set + // is wrapped as { "items": [ … ] } like any other set of rows. + (await CallAsync("sc_composite_array")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"items\":[{\"id\":1,\"tags\":[{\"label\":\"x\",\"score\":10},{\"label\":\"y\",\"score\":20}]}]}"}],"isError":false,"structuredContent":{"items":[{"id":1,"tags":[{"label":"x","score":10},{"label":"y","score":20}]}]}}}"""); + } + + // ---- outputSchema in the tools/list tool definition (structuredContent MUST conform) -------------- + + [Fact] + public async Task Tool_definition_for_a_single_scalar_declares_a_nullable_value_output() + { + (await ToolDefAsync("sc_scalar")).Should().Be( + """{"name":"sc_scalar","description":"A single number.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"value":{"type":["integer","null"],"format":"int32"}}}}"""); + } + + [Fact] + public async Task Tool_definition_for_a_single_record_declares_each_nullable_column() + { + (await ToolDefAsync("sc_record")).Should().Be( + """{"name":"sc_record","description":"A single record.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"total":{"type":["integer","null"],"format":"int32"},"status":{"type":["string","null"]}}}}"""); + } + + [Fact] + public async Task Tool_definition_for_a_set_of_values_declares_an_items_array() + { + (await ToolDefAsync("sc_values")).Should().Be( + """{"name":"sc_values","description":"A set of numbers.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"items":{"type":"array","items":{"type":["integer","null"],"format":"int32"}}}}}"""); + } + + [Fact] + public async Task Tool_definition_for_a_set_of_rows_declares_an_items_array_of_objects() + { + (await ToolDefAsync("sc_rows")).Should().Be( + """{"name":"sc_rows","description":"A set of rows.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":["integer","null"],"format":"int32"},"name":{"type":["string","null"]}}}}}}}"""); + } +} diff --git a/NpgsqlRestTests/McpTests/McpToolNameTests.cs b/NpgsqlRestTests/McpTests/McpToolNameTests.cs new file mode 100644 index 00000000..f1b91236 --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpToolNameTests.cs @@ -0,0 +1,43 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +public static partial class Database +{ + // Two routines forced to the same tool name (via @mcp_name) in the isolated `mcp_names` schema. + public static void McpToolNameTools() + { + script.Append(@" +create schema if not exists mcp_names; + +create function mcp_names.dup_a() returns text language sql as 'select ''a'''; +comment on function mcp_names.dup_a() is ' +HTTP GET +@mcp First routine. +@mcp_name dup_tool'; + +create function mcp_names.dup_b() returns text language sql as 'select ''b'''; +comment on function mcp_names.dup_b() is ' +HTTP GET +@mcp Second routine. +@mcp_name dup_tool'; +"); + } +} + +[Collection("McpToolNameFixture")] +public class McpToolNameTests(McpToolNameTestFixture test) +{ + [Fact] + public void Colliding_tool_names_keep_one_and_warn_about_the_rest() + { + // Both dup_a and dup_b map to `dup_tool`; the catalog keeps a single entry... + test.Tools.Should().ContainKey("dup_tool"); + test.Tools.Keys.Count(k => k == "dup_tool").Should().Be(1); + + // ...and the collision is logged as a warning naming the skipped routine. + var warning = test.StartupLogs.FirstOrDefault(l => + l.Message.Contains("already in use") && l.Message.Contains("dup_tool")); + warning.Should().NotBeNull("a colliding tool name should be logged"); + } +} diff --git a/NpgsqlRestTests/Setup/McpAudienceTestFixture.cs b/NpgsqlRestTests/Setup/McpAudienceTestFixture.cs new file mode 100644 index 00000000..a32b2790 --- /dev/null +++ b/NpgsqlRestTests/Setup/McpAudienceTestFixture.cs @@ -0,0 +1,77 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using NpgsqlRest.Mcp; + +namespace NpgsqlRestTests.Setup; + +[CollectionDefinition("McpAudienceFixture")] +public class McpAudienceFixtureCollection : ICollectionFixture { } + +/// +/// Fixture for MCP token audience binding (RFC 8707). The server is configured with a canonical +/// Audience; the "/login-as" endpoint signs the caller in with a single aud claim taken +/// from the query string, so tests can present a token whose audience matches or differs. +/// +public class McpAudienceTestFixture : IDisposable +{ + public const string Audience = "https://mcp.test/resource"; + + private readonly WebApplication _app; + + public string ServerAddress { get; } + + public McpAudienceTestFixture() + { + Database.Create(); + var connectionString = Database.CreateAdditional("mcp_audience_test"); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddAuthentication().AddCookie(); + _app = builder.Build(); + + _app.MapGet("/login-as", (string aud) => Results.SignIn(new ClaimsPrincipal(new ClaimsIdentity( + claims: [new Claim("aud", aud)], + authenticationType: CookieAuthenticationDefaults.AuthenticationScheme)))); + + _app.UseNpgsqlRest(new NpgsqlRestOptions(connectionString) + { + IncludeSchemas = ["mcp"], + CommentsMode = CommentsMode.OnlyAnnotated, + EndpointCreateHandlers = + [ + new Mcp(new McpOptions + { + Enabled = true, + Authorization = new McpAuthorizationOptions + { + RequireAuthorization = true, + AuthorizationServers = ["https://as.example.com"], + Audience = Audience, + } + }) + ] + }); + + _app.StartAsync().GetAwaiter().GetResult(); + ServerAddress = _app.Urls.First(); + } + + public HttpClient CreateClient() + { + var handler = new HttpClientHandler { UseCookies = true, CookieContainer = new System.Net.CookieContainer() }; + return new HttpClient(handler) { BaseAddress = new Uri(ServerAddress), Timeout = TimeSpan.FromMinutes(5) }; + } + +#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize + public void Dispose() +#pragma warning restore CA1816 // Dispose methods should call SuppressFinalize + { + _app.StopAsync().GetAwaiter().GetResult(); + _app.DisposeAsync().GetAwaiter().GetResult(); + } +} diff --git a/NpgsqlRestTests/Setup/McpAuthGateTestFixture.cs b/NpgsqlRestTests/Setup/McpAuthGateTestFixture.cs index c9fc5ea8..d36dfe3c 100644 --- a/NpgsqlRestTests/Setup/McpAuthGateTestFixture.cs +++ b/NpgsqlRestTests/Setup/McpAuthGateTestFixture.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; using NpgsqlRest.Mcp; namespace NpgsqlRestTests.Setup; @@ -17,9 +18,13 @@ public class McpAuthGateTestFixture : IDisposable { private readonly WebApplication _app; private readonly HttpClient _client; + private readonly LogCollector _logCollector = new(); public HttpClient Client => _client; + /// Logs emitted during build/startup (used to assert the no-auth-scheme warning). + public IReadOnlyList StartupLogs { get; } + public McpAuthGateTestFixture() { Database.Create(); @@ -27,6 +32,9 @@ public McpAuthGateTestFixture() var builder = WebApplication.CreateBuilder(); builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Logging.ClearProviders(); + builder.Logging.SetMinimumLevel(LogLevel.Trace); + builder.Logging.AddProvider(new CollectingLoggerProvider(_logCollector)); _app = builder.Build(); _app.UseNpgsqlRest(new NpgsqlRestOptions(connectionString) @@ -48,6 +56,7 @@ public McpAuthGateTestFixture() }); _app.StartAsync().GetAwaiter().GetResult(); + StartupLogs = _logCollector.Snapshot(); _client = new HttpClient { BaseAddress = new Uri(_app.Urls.First()) }; _client.Timeout = TimeSpan.FromHours(1); } diff --git a/NpgsqlRestTests/Setup/McpClaimTestFixture.cs b/NpgsqlRestTests/Setup/McpClaimTestFixture.cs new file mode 100644 index 00000000..b546a3bd --- /dev/null +++ b/NpgsqlRestTests/Setup/McpClaimTestFixture.cs @@ -0,0 +1,73 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json.Nodes; +using NpgsqlRest.Mcp; + +namespace NpgsqlRestTests.Setup; + +[CollectionDefinition("McpClaimFixture")] +public class McpClaimFixtureCollection : ICollectionFixture { } + +/// +/// Confirms that a claim-mapped routine parameter binds from the forwarded principal on tools/call (the +/// point of forwarding the ClaimsPrincipal), and that such a parameter is hidden from inputSchema. The +/// isolated mcp_claim schema holds claim_echo(_user_id) mapped to the name_identifier +/// claim. "/login-as?uid=" signs the caller in with that claim. +/// +public class McpClaimTestFixture : IDisposable +{ + private readonly WebApplication _app; + private readonly Mcp _mcp = new(new McpOptions { Enabled = true }); + + public string ServerAddress { get; } + public IReadOnlyDictionary Tools => _mcp.Tools; + + public McpClaimTestFixture() + { + Database.Create(); + var connectionString = Database.CreateAdditional("mcp_claim_test"); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddAuthentication().AddCookie(); + _app = builder.Build(); + + _app.MapGet("/login-as", (string uid) => Results.SignIn(new ClaimsPrincipal(new ClaimsIdentity( + claims: [new Claim("name_identifier", uid)], + authenticationType: CookieAuthenticationDefaults.AuthenticationScheme)))); + + _app.UseNpgsqlRest(new NpgsqlRestOptions(connectionString) + { + IncludeSchemas = ["mcp_claim"], + CommentsMode = CommentsMode.OnlyAnnotated, + AuthenticationOptions = new() + { + DefaultUserIdClaimType = "name_identifier", + UseUserParameters = true, + ParameterNameClaimsMapping = new() { { "_user_id", "name_identifier" } }, + }, + EndpointCreateHandlers = [_mcp], + }); + + _app.StartAsync().GetAwaiter().GetResult(); + ServerAddress = _app.Urls.First(); + } + + public HttpClient CreateClient() + { + var handler = new HttpClientHandler { UseCookies = true, CookieContainer = new System.Net.CookieContainer() }; + return new HttpClient(handler) { BaseAddress = new Uri(ServerAddress), Timeout = TimeSpan.FromHours(1) }; + } + +#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize + public void Dispose() +#pragma warning restore CA1816 // Dispose methods should call SuppressFinalize + { + _app.StopAsync().GetAwaiter().GetResult(); + _app.DisposeAsync().GetAwaiter().GetResult(); + } +} diff --git a/NpgsqlRestTests/Setup/McpSqlFileTestFixture.cs b/NpgsqlRestTests/Setup/McpSqlFileTestFixture.cs new file mode 100644 index 00000000..1eace27c --- /dev/null +++ b/NpgsqlRestTests/Setup/McpSqlFileTestFixture.cs @@ -0,0 +1,79 @@ +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using NpgsqlRest.Mcp; +using NpgsqlRest.SqlFileSource; + +namespace NpgsqlRestTests.Setup; + +[CollectionDefinition("McpSqlFileFixture")] +public class McpSqlFileFixtureCollection : ICollectionFixture { } + +/// +/// Confirms MCP works with the SqlFileSource (endpoints generated from .sql files), not just +/// database routines: a single-command and a multi-command SQL file, each opted in with `@mcp`. +/// +public class McpSqlFileTestFixture : IDisposable +{ + private readonly WebApplication _app; + private readonly HttpClient _client; + private readonly string _sqlDir; + private readonly Mcp _mcp = new(new McpOptions { Enabled = true }); + + public HttpClient Client => _client; + public IReadOnlyDictionary Tools => _mcp.Tools; + + public McpSqlFileTestFixture() + { + var connectionString = Database.Create(); + + _sqlDir = Path.Combine(Path.GetTempPath(), "npgsqlrest_mcp_sql_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_sqlDir); + + // Single command. + File.WriteAllText(Path.Combine(_sqlDir, "mcp_sql_single.sql"), """ + -- HTTP GET + -- @mcp Single-command SQL file tool. + select 42 as answer; + """); + + // Multiple commands in one file. + File.WriteAllText(Path.Combine(_sqlDir, "mcp_sql_multi.sql"), """ + -- HTTP GET + -- @mcp Multi-command SQL file tool. + select 1 as a; + select 2 as b; + """); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + _app = builder.Build(); + + _app.UseNpgsqlRest(new NpgsqlRestOptions(connectionString) + { + CommentsMode = CommentsMode.OnlyAnnotated, + EndpointSources = + [ + new SqlFileSource(new SqlFileSourceOptions + { + FilePattern = _sqlDir.Replace('\\', '/') + "/**/*.sql", + CommentsMode = CommentsMode.OnlyAnnotated, + }) + ], + EndpointCreateHandlers = [_mcp], + }); + + _app.StartAsync().GetAwaiter().GetResult(); + _client = new HttpClient { BaseAddress = new Uri(_app.Urls.First()), Timeout = TimeSpan.FromHours(1) }; + } + +#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize + public void Dispose() +#pragma warning restore CA1816 // Dispose methods should call SuppressFinalize + { + _client.Dispose(); + _app.StopAsync().GetAwaiter().GetResult(); + _app.DisposeAsync().GetAwaiter().GetResult(); + try { Directory.Delete(_sqlDir, recursive: true); } catch { /* best effort */ } + } +} diff --git a/NpgsqlRestTests/Setup/McpToolNameTestFixture.cs b/NpgsqlRestTests/Setup/McpToolNameTestFixture.cs new file mode 100644 index 00000000..09a7b683 --- /dev/null +++ b/NpgsqlRestTests/Setup/McpToolNameTestFixture.cs @@ -0,0 +1,55 @@ +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using NpgsqlRest.Mcp; + +namespace NpgsqlRestTests.Setup; + +[CollectionDefinition("McpToolNameFixture")] +public class McpToolNameFixtureCollection : ICollectionFixture { } + +/// +/// Tool-name collision handling. The isolated mcp_names schema has two routines forced to the +/// same tool name via @mcp_name; the plugin must keep one and warn about the other. +/// +public class McpToolNameTestFixture : IDisposable +{ + private readonly WebApplication _app; + private readonly LogCollector _logCollector = new(); + private readonly Mcp _mcp = new(new McpOptions { Enabled = true }); + + public IReadOnlyList StartupLogs { get; } + public IReadOnlyDictionary Tools => _mcp.Tools; + + public McpToolNameTestFixture() + { + Database.Create(); + var connectionString = Database.CreateAdditional("mcp_toolname_test"); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Logging.ClearProviders(); + builder.Logging.SetMinimumLevel(LogLevel.Trace); + builder.Logging.AddProvider(new CollectingLoggerProvider(_logCollector)); + _app = builder.Build(); + + _app.UseNpgsqlRest(new NpgsqlRestOptions(connectionString) + { + IncludeSchemas = ["mcp_names"], + CommentsMode = CommentsMode.OnlyAnnotated, + EndpointCreateHandlers = [_mcp], + }); + + _app.StartAsync().GetAwaiter().GetResult(); + StartupLogs = _logCollector.Snapshot(); + } + +#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize + public void Dispose() +#pragma warning restore CA1816 // Dispose methods should call SuppressFinalize + { + _app.StopAsync().GetAwaiter().GetResult(); + _app.DisposeAsync().GetAwaiter().GetResult(); + } +} diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index a9d59e7a..6b75825d 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -4,85 +4,87 @@ [Full Changelog](https://github.com/NpgsqlRest/NpgsqlRest/compare/3.16.3...3.17.0) -> v3.17.0 rolls up the bug-fixes originally developed as the unreleased 3.16.4 (documented below) and is the release line for MCP (Model Context Protocol) support. Feature entries are added as that work lands. +The headline of this release is **MCP (Model Context Protocol) support** — NpgsqlRest can project explicitly opted-in PostgreSQL routines as MCP tools that an AI agent can discover and call. Supporting that, the release adds neutral plugin extension points, makes one breaking change to the OpenAPI C# API, and ships two configuration/runtime fixes. -The rolled-up bug-fixes: JSON command parameters now accept `json`/`jsonb`/`text`, and environment-variable placeholders in config gain optional (`{NAME}`) / required (`{!NAME}`) semantics so a missing variable no longer crashes startup. +## New Features -**JSON command parameters.** JSON payloads passed to user-authored SQL commands were bound with a hardcoded `json` type, so a function declaring the matching parameter as `jsonb` or `text` failed at runtime with PostgreSQL `42883` *"function does not exist"* — even though the documentation states `text`/`json`/`jsonb` are all acceptable. The binding now uses an untyped (`unknown`) parameter, which PostgreSQL resolves server-side via the target type's input function, so `json`, `jsonb`, and `text` all work. +### MCP (Model Context Protocol) server — new `NpgsqlRest.Mcp` plugin -## What changed +NpgsqlRest can now expose opted-in PostgreSQL routines as **MCP tools**, so an AI agent can discover them (`tools/list`) and execute them (`tools/call`) over the [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-11-25) (spec **2025-11-25**). The entire MCP layer lives in the new plugin — **core stays protocol-agnostic**, built only on the neutral extension points below. -### MCP (Model Context Protocol) server — headline feature (new `NpgsqlRest.Mcp` plugin) +**Opt-in, never automatic.** A routine becomes a tool only when its PostgreSQL comment carries the `mcp` annotation: -NpgsqlRest can now project explicitly opted-in PostgreSQL routines as **MCP tools**, so an AI agent can discover them (`tools/list`) and execute them (`tools/call`) over the [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-11-25) (spec **2025-11-25**). The entire MCP layer lives in a new plugin — **core stays protocol-agnostic** and gains no MCP-specific code (it is built on the neutral extension points described below). +- `mcp` — expose as a tool; description derived from the comment prose. +- `mcp ` — expose, with `` as the description. +- `mcp_name ` — override the tool name (default: the routine name). +- `mcp` + `internal` — expose as an MCP tool with **no** HTTP route. All other annotations (`authorize`, parameter handling, …) apply unchanged. -- **Opt-in, never automatic.** No routine is exposed unless its PostgreSQL comment carries the `mcp` annotation. Nothing is auto-published. - - `mcp` — expose this routine as a tool; the description is derived from the routine's comment prose. - - `mcp ` — expose, with `` as the tool description (override). - - `mcp_name ` — override the tool name (defaults to the routine name). - - **MCP-only (no HTTP route):** compose with the existing `internal` annotation (`mcp` + `internal`) — the routine becomes an MCP tool with no REST endpoint. All other annotations (`authorize`, parameter handling, etc.) apply equally. -- **Single Streamable-HTTP JSON-RPC endpoint** (default `/mcp`, POST). Implements the lifecycle (`initialize` advertising the `tools` capability + `serverInfo`, `notifications/initialized` → `202`, `ping`), `tools/list` (catalog with JSON-Schema `inputSchema` derived from the routine's parameters), and `tools/call`. -- **`tools/call` executes the real routine** through the same invocation pipeline as the HTTP endpoint, **forwarding the authenticated `ClaimsPrincipal`** so `authorize` role checks apply. Results use the MCP envelope (`{ "content": [{ "type": "text", "text": … }], "isError": … }`). Two error channels per spec: business failures → `isError: true` in the result; structural failures (unknown method, unknown tool, parse error) → JSON-RPC errors (`-32601` / `-32602` / `-32700`). -- **AOT-safe.** The JSON-RPC layer is hand-rolled over `System.Text.Json.Nodes` with a source-generated `JsonSerializerContext` — no reflection-based serialization, consistent with the project's `PublishAot` build (verified via `dotnet publish -p:PublishAot=true`). -- **Diagnostics.** A routine annotated `mcp` that also uses a feature with no MCP-tool equivalent (`login`, `logout`, basic auth, `upload`, SSE) logs a build-time warning — the tool is still exposed, but the warning flags that it likely won't behave as expected over JSON-RPC. -- **OAuth 2.1 Resource Server (bring-your-own Authorization Server).** Token validation reuses the host's bearer authentication; NpgsqlRest is not an Authorization Server. Configured under `McpOptions:Authorization`: - - **Protected Resource Metadata (RFC 9728)** is served at `/.well-known/oauth-protected-resource{UrlPath}` (advertising `resource`, `authorization_servers`, optional `scopes_supported`, `bearer_methods_supported`) whenever an Authorization Server is configured. - - **`RequireAuthorization`** gates the endpoint: an unauthenticated request gets **HTTP 401** with `WWW-Authenticate: Bearer resource_metadata="…"` (RFC 9728 §5.1) so the client can discover the AS. The PRM document itself stays anonymous. The `resource` value defaults to the request origin + `UrlPath` (overridable via `Audience`, RFC 8707). - - **Per-tool authorization** on `tools/call`: the forwarded principal runs the routine's `authorize`/role check in the execution pipeline. A tool that needs authentication called anonymously → **HTTP 401** (PRM challenge); authenticated but lacking the role → **HTTP 403** `WWW-Authenticate: Bearer error="insufficient_scope"` (RFC 6750 §3.1, with `scope`/`resource_metadata` when configured). No authorization logic is duplicated in the plugin — it reuses core's check. -- **Config (`NpgsqlRest:McpOptions`, disabled by default):** `Enabled` (default `false`), `UrlPath` (default `/mcp`), `ServerName` (initialize `serverInfo.name`; null → database name from the connection string, falling back to `"NpgsqlRest"`), `ServerVersion` (default `"1.0.0"`), `Instructions` (optional server-level guidance returned at `initialize`), `ToolDescriptionSuffix` (optional short text appended to every tool description — complements `Instructions` for context the agent should see per-tool), and the `Authorization` object (`RequireAuthorization`, `AuthorizationServers`, `ScopesSupported`, `Audience`, `ProtectedResourceMetadataPath`). Surfaced in `--config`, `--config-schema`, and the JSON-schema description set. +**The endpoint.** A single Streamable-HTTP JSON-RPC endpoint (default `/mcp`, POST only). It implements the lifecycle (`initialize` → protocol version + `tools` capability + `serverInfo`; `notifications/initialized` → `202`; `ping`), `tools/list` (with a JSON-Schema `inputSchema` per tool, derived from the routine's parameters), and `tools/call`. Transport rules per spec: the `Origin` header is validated (DNS-rebinding protection — a present, untrusted origin → `403`); a present `MCP-Protocol-Version` other than `2025-11-25` → `400`; `GET` → `405` (no SSE). -> `tools/list` lists every opted-in tool and does not filter to the caller's permitted subset (a SHOULD in the spec); authorization is enforced on `tools/call`. Listing all tools keeps them discoverable so an agent can prompt for authentication. Per-principal filtering may be added later. +**Calling a tool.** `tools/call` runs the routine through the same pipeline as the HTTP endpoint, forwarding the authenticated principal so `authorize` checks apply. Arguments map to the routine as a query string (GET/DELETE), a JSON body (POST/PUT), or path-segment substitution. The result carries: -### Plugin extension points on `RoutineEndpoint` (new) + OpenAPI annotation handling (⚠️ breaking API) +- **`structuredContent`** (always a JSON object): a single value → `{ "value": … }`; a record/composite (or a set collapsed with `single`) → the object itself; a set → `{ "items": [ … ] }`. The `text` content block carries the same JSON, serialized (backward-compatibility). +- **`outputSchema`** (declared on the tool) derived from the routine's return columns, nullable-aware so results always conform. +- Two error channels: business failures → `isError: true` in the result; structural failures (unknown method/tool, malformed request) → JSON-RPC errors. -Neutral, plugin-facing extension points were added so plugins can own their comment annotations without leaking plugin concepts into core, and without each plugin re-walking the comment: +**Authorization — OAuth 2.1 Resource Server** (bring-your-own Authorization Server; token validation reuses the host's bearer authentication — NpgsqlRest is not an Authorization Server). Configured under `McpOptions:Authorization`: -- **`IEndpointCreateHandler.HandleCommentLine(...)`** (new default-interface method) — core invokes it, in its single comment-parse pass, for each line it doesn't recognize as a built-in directive. A plugin claims its own annotation by returning a **`CommentLineResult`** (a log label core logs centrally + `RequestsEndpoint`); returns null to pass. Tokens are pre-split by core, so plugins don't re-tokenize. Non-breaking (default method). -- **`RoutineEndpoint.Items`** (`IDictionary`, lazy) + **`TryGetItem`** (non-allocating read) — a generic per-endpoint property bag for plugin-attached metadata, namespaced by key (e.g. `"openapi:hide"`, `"mcp"`). The `HttpContext.Items` pattern. Plugins parse in `HandleCommentLine` and stash the typed result here. -- **`RoutineEndpoint.UnhandledCommentLines`** (`string[]?`) — comment lines neither core nor any plugin handler claimed (i.e. prose), original case, in order. Plugins use it to derive a human description. Core attaches no meaning. -- **`CommentsMode.OnlyAnnotated`** (new) — transport-agnostic successor to `OnlyWithHttpTag`: creates an endpoint when its comment has an HTTP tag **or** a plugin requests one (via `CommentLineResult.RequestsEndpoint`, e.g. `mcp`). Lets an `mcp`-annotated routine be exposed as an MCP tool with no HTTP route, while modifier-only comments (e.g. just `authorize` or `openapi hide`) create nothing. The NpgsqlRest **client now defaults to `OnlyAnnotated`** (both `NpgsqlRest:CommentsMode` and `NpgsqlRest:SqlFileSource:CommentsMode`); existing configs using `OnlyWithHttpTag` are unaffected — it is kept as a back-compat alias with identical behavior. +- **Protected Resource Metadata (RFC 9728)** served at `/.well-known/oauth-protected-resource{UrlPath}` when an Authorization Server is configured. +- **`RequireAuthorization`** gates the endpoint → `401` with `WWW-Authenticate: Bearer resource_metadata="…"` (RFC 9728 §5.1) so the client can discover the AS; the PRM document itself stays anonymous. +- **Audience binding (RFC 8707):** with a canonical `Audience` configured, a token must carry it (`aud` claim) or it is rejected with `401`. +- **Per-tool authorization:** the routine's `authorize`/role check runs on `tools/call` → `401` if called anonymously, `403` `insufficient_scope` if the role is missing (RFC 6750 §3.1; challenges include `scope` and `resource_metadata`). No authorization logic is duplicated in the plugin — it reuses core's check. -**⚠️ Breaking (C# API only):** the public properties **`RoutineEndpoint.OpenApiHide` and `RoutineEndpoint.OpenApiTags` were removed**, along with the core `openapi` comment-annotation handler. The OpenAPI plugin now parses the `openapi hide` / `openapi hidden` / `openapi ignore` / `openapi tag <…>` annotations itself (from `UnhandledCommentLines`). -- **No change for annotation users:** the `openapi …` comment annotations behave exactly as before (only effective when the OpenAPI plugin is loaded — previously they set core properties regardless). -- **Affected only if** you set `endpoint.OpenApiHide` / `endpoint.OpenApiTags` in code (e.g. via the `EndpointCreated` callback). Use the `openapi` comment annotation instead. +**Configuration** — `NpgsqlRest:McpOptions`, disabled by default, surfaced in `--config`, `--config-schema`, and the JSON schema: `Enabled`, `UrlPath` (`/mcp`), `ServerName` (null → database name → `"NpgsqlRest"`), `ServerVersion` (`"1.0.0"`), `Instructions`, `ToolDescriptionSuffix`, `AllowedOrigins`, and the `Authorization` object (`RequireAuthorization`, `AuthorizationServers`, `ScopesSupported`, `Audience`, `ProtectedResourceMetadataPath`). -### JSON command parameters accept `json`, `jsonb`, or `text` +**Diagnostics & current limitations.** + +- Enabling MCP does **not** enable authentication — it is configured separately (the host's `Auth` section). If `RequireAuthorization` is on but no authentication scheme is registered, a startup warning is logged. +- A routine annotated `mcp` that also uses a feature with no MCP equivalent (`login`, `logout`, basic auth, `upload`, SSE) logs a build-time warning. +- `tools/list` lists every opted-in tool and does not yet filter to the caller's permitted subset (a SHOULD in the spec); authorization is still enforced on `tools/call`. +- The JSON-RPC layer is hand-rolled over `System.Text.Json.Nodes` with relaxed escaping (conventional `application/json` output) — no reflection-based serialization, AOT-safe (verified via `dotnet publish -p:PublishAot=true`). + +### Plugin extension points on `RoutineEndpoint` -Several places bind a JSON string into a user-configured SQL command and previously forced the receiving parameter to be `json`: +Neutral, plugin-facing hooks were added so a plugin can own its comment annotations without leaking plugin concepts into core (both MCP and the OpenAPI plugin are now built on these): -- **External auth** `Auth.External.LoginCommand` — `$4` (provider data) and `$5` (analytics). The docs (and the docs' own example function, which declares `_data jsonb`) advertised `text/json/jsonb`, but only `json` actually worked. -- **CSV / Excel upload** row commands — the per-row metadata parameter (`$4`, and the Excel JSON data parameter `$2`). -- **Passkey / Fido2** endpoints — the `user_context`, analytics, claims, and challenge-body parameters across the registration, authentication, add-passkey, and challenge-options commands. +- **`IEndpointCreateHandler.HandleCommentLine(...)`** (new default-interface method) — core offers each unrecognized comment line to handlers within its single parse pass; a handler claims it by returning a `CommentLineResult` (a log label + `RequestsEndpoint`). Tokens are pre-split by core. Non-breaking. +- **`RoutineEndpoint.Items`** (lazy `IDictionary`) + **`TryGetItem`** — a per-endpoint property bag for plugin metadata (the `HttpContext.Items` pattern), namespaced by key. +- **`RoutineEndpoint.UnhandledCommentLines`** (`string[]?`) — comment prose that neither core nor any handler claimed. +- **`CommentsMode.OnlyAnnotated`** (new) — creates an endpoint when the comment has an HTTP tag **or** a plugin requests one (so an `mcp`-only routine can exist with no HTTP route). The **client now defaults to `OnlyAnnotated`**; existing `OnlyWithHttpTag` configs are unaffected — it is kept as an identical-behavior alias. -The root cause: PostgreSQL function-overload resolution uses only *implicit* casts, and there is no implicit cast from `json` to `jsonb` or `text`. Binding the value as `unknown` (instead of `json`) defers type resolution to the server, which parses the string with the declared parameter type's input function. As a result: +## Breaking Changes -- Functions declaring the parameter as `json` continue to work unchanged. -- Functions declaring it as `jsonb` now work (previously `42883`). -- Functions declaring it as `text` now work (previously `42883`). -- `NULL` payloads and values containing quotes/arrays round-trip correctly. +### ⚠️ OpenAPI annotation handling moved out of core (C# API only) -This is **fully backward compatible** — existing `json`-typed functions are unaffected; the change only *adds* the previously-documented `jsonb` and `text` support. +The public properties **`RoutineEndpoint.OpenApiHide` and `RoutineEndpoint.OpenApiTags` were removed**, along with the core `openapi` comment-annotation handler. The OpenAPI plugin now parses `openapi hide` / `hidden` / `ignore` / `tag <…>` itself (from `UnhandledCommentLines`). + +- **No change for annotation users** — the `openapi …` comment annotations behave exactly as before (and, as before, only take effect when the OpenAPI plugin is loaded). +- **Affected only if** your code sets `endpoint.OpenApiHide` / `endpoint.OpenApiTags` directly (e.g. in an `EndpointCreated` callback) — use the `openapi` comment annotation instead. + +## Fixes + +### JSON command parameters accept `json`, `jsonb`, or `text` + +JSON payloads passed to user-authored SQL commands were bound with a hardcoded `json` type, so a function declaring the receiving parameter as `jsonb` or `text` failed at runtime with PostgreSQL `42883` *"function does not exist"* — even though the documentation states all three are acceptable. + +The binding now uses an untyped (`unknown`) parameter, which PostgreSQL resolves server-side via the target type's input function. Affected commands: external-auth `Auth.External.LoginCommand` (`$4` provider data, `$5` analytics), CSV/Excel upload row commands (per-row metadata, Excel JSON data), and the Passkey/Fido2 commands. **Fully backward compatible** — `json`-typed parameters are unchanged; `jsonb` and `text` now also work, and `NULL` / quoted / array values round-trip correctly. ### Optional `{NAME}` and required `{!NAME}` environment-variable placeholders With `Config:ParseEnvironmentVariables` enabled (the default), config values support two placeholder forms, for **every** value type (bool, int, string, enum, arrays, dictionaries): -- **`{NAME}` — optional.** Substituted with the variable's value when set. When it is **not** set, the placeholder is left untouched — so typed `bool`/`int` reads fall back to their default instead of crashing, and legitimate non-env brace syntax (e.g. Serilog `OutputTemplate` `"{Timestamp:HH:mm:ss} {Message}"`) is preserved. -- **`{!NAME}` — required.** Substituted with the variable's value, or **throws a clear error at startup** when it is not set: `Required environment variable 'NAME' (referenced as '{!NAME}' in configuration) is not set.` +- **`{NAME}` — optional.** Substituted with the variable's value when set; left untouched when not — so typed `bool`/`int` reads fall back to their default instead of crashing, and legitimate non-env brace syntax (e.g. a Serilog `OutputTemplate`) is preserved. +- **`{!NAME}` — required.** Substituted with the value, or **throws a clear startup error** naming the variable when it is not set. -This fixes a startup crash: previously a missing optional var left an unresolved `{GITHUB_AUTH_ENABLED}` token that `GetConfigBool` rejected with `Invalid boolean value '{GITHUB_AUTH_ENABLED}' for configuration key 'Enabled'`. Now: +This fixes a startup crash: previously a missing optional variable left an unresolved `{NAME}` token that a typed read (e.g. `GetConfigBool`) rejected. Genuinely invalid *values* (e.g. `"maybe"` for a bool) still throw. ```jsonc "Enabled": "{GITHUB_AUTH_ENABLED}" // env unset → feature defaults to off (no crash) "Enabled": "{!GITHUB_AUTH_ENABLED}" // env unset → startup error naming the variable ``` -Genuinely invalid *values* (e.g. `"maybe"` for a bool) still throw, so typos in the value are still caught. - -> Note: a *misspelled* optional variable name (`{GITHUB_AUTH_ENABLD}`) is left as a literal token and a typed flag defaults to off — use `{!NAME}` when a value must be present. - -### Tests +## Tests -- A binding-contract test that locks the PostgreSQL/Npgsql resolution behaviour the fix relies on (`json`-only for the old binding; `json`/`jsonb`/`text` for the new one, including `NULL` and round-trip integrity). -- End-to-end CSV upload tests covering a row-command metadata parameter declared as `json`, `jsonb`, and `text`. -- Config tests covering optional `{NAME}` (resolves when set, left untouched / defaults when not — including Serilog-template preservation) and required `{!NAME}` (throws when unset) across `GetConfigBool`/`GetConfigInt`/`GetConfigStr` and the `ResolveEnv` resolver, plus genuinely invalid values still throwing. +- An MCP test suite covering the lifecycle, `tools/list` / `tools/call`, `structuredContent` and `outputSchema` across return shapes (scalar, record, set, array, custom composite), parameter mapping (query / body / path; typed, optional, null, and json arguments), authorization (PRM, 401/403, audience binding), transport rules, and protocol edge cases. +- A binding-contract test locking the PostgreSQL/Npgsql resolution the JSON-parameter fix relies on (`json`-only for the old binding; `json`/`jsonb`/`text` for the new one, including `NULL` and round-trip integrity), plus end-to-end CSV upload tests for a row-command metadata parameter declared as `json`, `jsonb`, and `text`. +- Config tests for optional `{NAME}` (resolves when set; left untouched / defaults when not — including Serilog-template preservation) and required `{!NAME}` (throws when unset) across `GetConfigBool` / `GetConfigInt` / `GetConfigStr` and the `ResolveEnv` resolver. diff --git a/plugins/NpgsqlRest.Mcp/Mcp.cs b/plugins/NpgsqlRest.Mcp/Mcp.cs index 95431cc6..4926dba9 100644 --- a/plugins/NpgsqlRest.Mcp/Mcp.cs +++ b/plugins/NpgsqlRest.Mcp/Mcp.cs @@ -180,13 +180,20 @@ private JsonObject BuildTool(RoutineEndpoint endpoint, McpToolInfo info) annotations["destructiveHint"] = true; } - return new JsonObject + var tool = new JsonObject { ["name"] = name, ["description"] = description, ["inputSchema"] = inputSchema, ["annotations"] = annotations, }; + // outputSchema describes the structuredContent the tool returns (MCP 2025-11-25 §Output Schema). + var outputSchema = BuildOutputSchema(endpoint); + if (outputSchema is not null) + { + tool["outputSchema"] = outputSchema; + } + return tool; } /// diff --git a/plugins/NpgsqlRest.Mcp/McpJsonContext.cs b/plugins/NpgsqlRest.Mcp/McpJsonContext.cs deleted file mode 100644 index f9b82e90..00000000 --- a/plugins/NpgsqlRest.Mcp/McpJsonContext.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; - -namespace NpgsqlRest.Mcp; - -// AOT-safe serialization of the JSON-RPC response objects (built as System.Text.Json.Nodes). -[JsonSerializable(typeof(JsonObject))] -internal partial class McpJsonContext : JsonSerializerContext; diff --git a/plugins/NpgsqlRest.Mcp/McpOptions.cs b/plugins/NpgsqlRest.Mcp/McpOptions.cs index e1306469..e262db64 100644 --- a/plugins/NpgsqlRest.Mcp/McpOptions.cs +++ b/plugins/NpgsqlRest.Mcp/McpOptions.cs @@ -32,4 +32,12 @@ public class McpOptions /// OAuth 2.1 Resource Server settings: the transport authorization gate and Protected Resource Metadata (RFC 9728). public McpAuthorizationOptions Authorization { get; set; } = new(); + + /// + /// Allowed values of the HTTP Origin header (DNS-rebinding protection, required by the + /// Streamable HTTP transport). A request whose Origin is present but matches neither this + /// list nor the server's own origin is rejected with 403. Requests without an Origin header + /// (e.g. server-to-server) are allowed. Empty (default) = only same-origin browser requests pass. + /// + public string[] AllowedOrigins { get; set; } = []; } diff --git a/plugins/NpgsqlRest.Mcp/McpServer.cs b/plugins/NpgsqlRest.Mcp/McpServer.cs index 2eecf345..52b04b9a 100644 --- a/plugins/NpgsqlRest.Mcp/McpServer.cs +++ b/plugins/NpgsqlRest.Mcp/McpServer.cs @@ -1,8 +1,14 @@ +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Npgsql; +using NpgsqlRest.Common; +using static NpgsqlRest.NpgsqlRestOptions; namespace NpgsqlRest.Mcp; @@ -16,6 +22,10 @@ public partial class Mcp { private const string ProtocolVersion = "2025-11-25"; + // Relaxed escaping: MCP responses are application/json consumed by MCP clients (never embedded in + // HTML), so emit conventional JSON (e.g. \" instead of ", and no over-escaping of < > &). + private static readonly JsonSerializerOptions JsonOutput = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + private IApplicationBuilder _builder = default!; private string? _connectionString; @@ -32,6 +42,21 @@ public void Cleanup() return; } + WarnIfAuthRequiredButNoAuthScheme(); + + // One-line startup summary of the catalog (Information level), so operators can see which tools + // are exposed without enabling Debug. + if (_tools.Count > 0) + { + Logger?.LogInformation("MCP: {Count} tool(s) exposed at {Path} — {Tools}", + _tools.Count, _options.UrlPath, string.Join(", ", _tools.Keys)); + } + else + { + Logger?.LogInformation("MCP enabled at {Path}, but no routines are annotated `mcp` — no tools exposed.", + _options.UrlPath); + } + var path = _options.UrlPath; // Protected Resource Metadata (RFC 9728) is served only when an Authorization Server is configured // — without one the document carries no useful discovery information. @@ -53,6 +78,26 @@ public void Cleanup() }); } + /// + /// Startup guardrail: RequireAuthorization only works if the host has authentication wired — + /// enabling MCP does not enable auth. With no registered scheme, every request would be 401, so warn. + /// + private void WarnIfAuthRequiredButNoAuthScheme() + { + if (!_options.Authorization.RequireAuthorization) + { + return; + } + var schemeProvider = _builder.ApplicationServices.GetService(); + var hasScheme = schemeProvider is not null && schemeProvider.GetAllSchemesAsync().GetAwaiter().GetResult().Any(); + if (!hasScheme) + { + Logger?.LogWarning( + "MCP Authorization.RequireAuthorization is enabled but no authentication scheme is registered — every request to {Path} will return 401. Enabling MCP does not enable authentication; configure it separately (e.g. the client's Auth section / JWT bearer).", + _options.UrlPath); + } + } + /// The RFC 9728 well-known path for this resource (overridable via config). private string ProtectedResourceMetadataPath() => _options.Authorization.ProtectedResourceMetadataPath @@ -96,47 +141,93 @@ private async Task HandleProtectedResourceMetadataAsync(HttpContext context) context.Response.StatusCode = StatusCodes.Status200OK; context.Response.ContentType = "application/json"; - await context.Response.WriteAsync(JsonSerializer.Serialize(doc, McpJsonContext.Default.JsonObject), context.RequestAborted); + await context.Response.WriteAsync(doc.ToJsonString(JsonOutput), context.RequestAborted); } /// - /// 401 with the RFC 9728 §5.1 WWW-Authenticate: Bearer challenge. When an Authorization Server - /// is configured, the challenge carries resource_metadata so the client can discover it. + /// 401: authentication is required or the token is invalid (RFC 9728 §5.1 challenge). /// private void WriteUnauthorized(HttpContext context) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; - var challenge = "Bearer"; - if (_options.Authorization.AuthorizationServers.Length > 0) - { - var prm = $"{context.Request.Scheme}://{context.Request.Host}{ProtectedResourceMetadataPath()}"; - challenge = $"Bearer resource_metadata=\"{prm}\""; - } - context.Response.Headers.Append("WWW-Authenticate", challenge); + context.Response.Headers.Append("WWW-Authenticate", BearerChallenge(context, error: null)); } /// - /// 403 with a WWW-Authenticate: Bearer error="insufficient_scope" challenge (RFC 6750 §3.1): - /// the principal is authenticated but lacks the permission the tool's authorize requires. + /// 403: the principal is authenticated but lacks the permission the tool's authorize requires + /// (RFC 6750 §3.1 error="insufficient_scope"). /// private void WriteForbidden(HttpContext context) { context.Response.StatusCode = StatusCodes.Status403Forbidden; - var challenge = "Bearer error=\"insufficient_scope\""; + context.Response.Headers.Append("WWW-Authenticate", BearerChallenge(context, error: "insufficient_scope")); + } + + /// + /// Builds the WWW-Authenticate: Bearer challenge with scope (when scopes are configured, + /// per the spec's SHOULD) and resource_metadata (when an Authorization Server is configured, so + /// the client can discover it — RFC 9728 §5.1). Used for both 401 and 403 responses. + /// + private string BearerChallenge(HttpContext context, string? error) + { + var parts = new List(3); + if (error is not null) + { + parts.Add($"error=\"{error}\""); + } if (_options.Authorization.ScopesSupported.Length > 0) { - challenge += $", scope=\"{string.Join(' ', _options.Authorization.ScopesSupported)}\""; + parts.Add($"scope=\"{string.Join(' ', _options.Authorization.ScopesSupported)}\""); } if (_options.Authorization.AuthorizationServers.Length > 0) { var prm = $"{context.Request.Scheme}://{context.Request.Host}{ProtectedResourceMetadataPath()}"; - challenge += $", resource_metadata=\"{prm}\""; + parts.Add($"resource_metadata=\"{prm}\""); } - context.Response.Headers.Append("WWW-Authenticate", challenge); + return parts.Count == 0 ? "Bearer" : "Bearer " + string.Join(", ", parts); + } + + /// + /// DNS-rebinding protection. A request with no Origin header (server-to-server) is allowed; a + /// present Origin must match a configured entry or the + /// server's own origin, otherwise it is rejected. + /// + private bool IsOriginAllowed(HttpContext context) + { + var origin = context.Request.Headers.Origin.ToString(); + if (string.IsNullOrEmpty(origin)) + { + return true; + } + foreach (var allowed in _options.AllowedOrigins) + { + if (string.Equals(origin, allowed, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + var self = $"{context.Request.Scheme}://{context.Request.Host}"; + return string.Equals(origin, self, StringComparison.OrdinalIgnoreCase); } private async Task HandleAsync(HttpContext context) { + // DNS-rebinding protection (Streamable HTTP transport MUST): reject a present-but-untrusted Origin. + if (!IsOriginAllowed(context)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + // Protocol-version header (transport MUST): a present header that is not the version we speak is + // a 400. An absent header is allowed (the initialize request carries none, and older clients omit it). + var requestedVersion = context.Request.Headers["MCP-Protocol-Version"].ToString(); + if (!string.IsNullOrEmpty(requestedVersion) && !string.Equals(requestedVersion, ProtocolVersion, StringComparison.Ordinal)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + // Transport authorization gate (OAuth 2.1 Resource Server). When RequireAuthorization is on, the // host's bearer middleware must have authenticated the principal; otherwise reject with 401 and // point the client at the Protected Resource Metadata (RFC 9728) so it can discover the AS. @@ -146,6 +237,19 @@ private async Task HandleAsync(HttpContext context) return; } + // Token audience binding (RFC 8707): when a canonical Audience is configured, an authenticated + // token MUST carry it — reject tokens issued for a different resource. (Signature/expiry are + // validated by the host's bearer middleware; this enforces the audience the PRM advertises.) + if (!string.IsNullOrEmpty(_options.Authorization.Audience) + && context.User?.Identity?.IsAuthenticated == true + && !context.User.Claims.Any(c => + string.Equals(c.Type, "aud", StringComparison.Ordinal) && + string.Equals(c.Value, _options.Authorization.Audience, StringComparison.Ordinal))) + { + WriteUnauthorized(context); + return; + } + // POST carries JSON-RPC. (GET would open an SSE stream — not supported in the stateless model.) if (!HttpMethods.IsPost(context.Request.Method)) { @@ -170,8 +274,16 @@ private async Task HandleAsync(HttpContext context) return; } - var method = request?["method"]?.GetValue(); - var id = request?["id"]?.DeepClone(); + // The body must be a single JSON-RPC object. Batches (JSON arrays) were removed in MCP 2025-11-25, + // and any other shape is invalid — reject cleanly rather than letting member access throw. + if (request is not JsonObject) + { + await WriteResponseAsync(context, ErrorEnvelope(null, -32600, "Invalid Request")); + return; + } + + var method = request["method"]?.GetValue(); + var id = request["id"]?.DeepClone(); // Notifications carry no id and expect no response body. if (method is not null && method.StartsWith("notifications/", StringComparison.Ordinal)) @@ -240,17 +352,27 @@ private async Task HandleToolCallAsync(HttpContext context, JsonNode? request, J return; } - // Business/execution outcome → a normal result with isError; the routine's response is the - // text content verbatim. (Only structural problems above use a JSON-RPC error.) + // Business/execution outcome → a normal result with isError. (Only structural problems above use + // a JSON-RPC error.) On success we build structuredContent (always a JSON object, per spec) and, + // for backward compatibility, put its serialized JSON in the text content block. + var structured = invoke.IsSuccess && !endpoint.Raw + ? BuildStructuredContent(endpoint, invoke.Body) + : null; + var text = structured?.ToJsonString(JsonOutput) ?? invoke.Body ?? string.Empty; + var content = new JsonArray(); // Cast to JsonNode? to bind the non-generic JsonArray.Add (the generic Add is not AOT/trim-safe). - content.Add((JsonNode?)new JsonObject { ["type"] = "text", ["text"] = invoke.Body ?? string.Empty }); + content.Add((JsonNode?)new JsonObject { ["type"] = "text", ["text"] = text }); var toolResult = new JsonObject { ["content"] = content, ["isError"] = !invoke.IsSuccess, }; + if (structured is not null) + { + toolResult["structuredContent"] = structured; + } await WriteResponseAsync(context, new JsonObject { @@ -260,6 +382,146 @@ private async Task HandleToolCallAsync(HttpContext context, JsonNode? request, J }); } + /// + /// Builds the MCP structuredContent object from the routine's response body. Per spec it is + /// always a JSON object: a single scalar value → { "value": … }; a single record/composite → + /// the object itself; a set of rows/values → { "items": [ … ] }. Returns null when there is + /// nothing to structure (void routine, empty body, or an unparseable JSON payload). + /// + private static JsonObject? BuildStructuredContent(RoutineEndpoint endpoint, string? body) + { + var routine = endpoint.Routine; + if (routine.IsVoid || string.IsNullOrWhiteSpace(body)) + { + return null; + } + + // A set returns a JSON array → wrap as { "items": [...] }. (`single` collapses a set to one row.) + if (routine.ReturnsSet && !endpoint.ReturnSingleRecord) + { + return TryParseJson(body) switch + { + JsonArray array => new JsonObject { ["items"] = array }, + // A multi-command SQL file returns an object keyed per result set ({ "result1": [...], … }) — + // already a valid structuredContent object. + JsonObject obj => obj, + _ => null, + }; + } + + // A single scalar value → { "value": ... }; a single record/composite → the object itself. + if (routine.ColumnCount == 1 && !routine.ReturnsRecordType) + { + var type = routine.ColumnsTypeDescriptor.Length > 0 ? routine.ColumnsTypeDescriptor[0] : null; + return new JsonObject { ["value"] = ScalarValue(body, type) }; + } + + return TryParseJson(body) as JsonObject; + } + + /// + /// Builds the tool's outputSchema (declared in tools/list) describing the structuredContent it + /// returns. Mirrors 's shape decisions exactly so results always + /// conform (MCP 2025-11-25: when an outputSchema is provided, results MUST conform). Returns null for + /// void/raw routines (which emit no structuredContent). Leaf value schemas allow null (PG columns are + /// nullable); array/json/composite columns use a permissive schema so conformance is guaranteed. + /// + private static JsonObject? BuildOutputSchema(RoutineEndpoint endpoint) + { + var routine = endpoint.Routine; + // No schema for void/raw, or for a multi-command SQL file (its per-result-set object shape, e.g. + // { "result1": […], "result2": […] }, can't be derived from the single-set column metadata). + if (routine.IsVoid || endpoint.Raw || routine.IsMultiCommand) + { + return null; + } + + if (routine.ReturnsSet && !endpoint.ReturnSingleRecord) + { + var element = routine.ColumnCount == 1 && !routine.ReturnsRecordType + ? NullableValueSchema(ColumnType(routine, 0)) // set of scalar values + : RecordSchema(routine); // set of rows + return ObjectSchema("items", new JsonObject { ["type"] = "array", ["items"] = element }); + } + + if (routine.ColumnCount == 1 && !routine.ReturnsRecordType) + { + return ObjectSchema("value", NullableValueSchema(ColumnType(routine, 0))); + } + + return RecordSchema(routine); + } + + private static JsonObject ObjectSchema(string property, JsonNode propertySchema) => new() + { + ["type"] = "object", + ["properties"] = new JsonObject { [property] = propertySchema }, + }; + + private static JsonObject RecordSchema(Routine routine) + { + var properties = new JsonObject(); + for (var i = 0; i < routine.ColumnCount && i < routine.ColumnNames.Length; i++) + { + properties[routine.ColumnNames[i]] = NullableValueSchema(ColumnType(routine, i)); + } + return new JsonObject { ["type"] = "object", ["properties"] = properties }; + } + + private static TypeDescriptor? ColumnType(Routine routine, int index) => + index < routine.ColumnsTypeDescriptor.Length ? routine.ColumnsTypeDescriptor[index] : null; + + /// + /// A JSON Schema for a single column value, with null allowed. Arrays/json/composite columns + /// (whose JSON form varies) get a permissive empty schema so a NULL or any payload still conforms. + /// + private static JsonNode NullableValueSchema(TypeDescriptor? type) + { + if (type is null || type.IsArray || type.IsJson || type.IsCompositeType) + { + return new JsonObject(); + } + var schema = SchemaMapper.GetSchemaForType(type); + if (schema["type"] is JsonValue typeValue && typeValue.TryGetValue(out var typeName)) + { + schema["type"] = new JsonArray { (JsonNode?)typeName, (JsonNode?)"null" }; + } + return schema; + } + + private static JsonNode? TryParseJson(string body) + { + try + { + return JsonNode.Parse(body); + } + catch (JsonException) + { + return null; + } + } + + /// + /// A scalar column value for structuredContent.value: numeric/boolean/json/array values are + /// embedded as their JSON form; everything else (text, dates, etc.) as a JSON string. + /// + private static JsonNode? ScalarValue(string body, TypeDescriptor? type) + { + if (type is not null) + { + // PostgreSQL renders a scalar boolean as t/f, which is not valid JSON — map it explicitly. + if (type.IsBoolean) + { + return JsonValue.Create(body is "t" or "true"); + } + if ((type.IsNumeric || type.IsJson || type.IsArray) && TryParseJson(body) is { } parsed) + { + return parsed; + } + } + return JsonValue.Create(body); + } + /// /// Maps a flat MCP arguments object onto the endpoint's HTTP shape: path-parameter substitution, /// then query string (QueryString endpoints) or a JSON body (BodyJson endpoints). @@ -362,6 +624,6 @@ private static async Task WriteResponseAsync(HttpContext context, JsonObject pay context.Response.StatusCode = StatusCodes.Status200OK; context.Response.ContentType = "application/json"; context.Response.Headers["MCP-Protocol-Version"] = ProtocolVersion; - await context.Response.WriteAsync(JsonSerializer.Serialize(payload, McpJsonContext.Default.JsonObject), context.RequestAborted); + await context.Response.WriteAsync(payload.ToJsonString(JsonOutput), context.RequestAborted); } } From 65db66618d293a488805a4958ebc44601837a820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Tue, 9 Jun 2026 12:23:26 +0200 Subject: [PATCH 21/38] =?UTF-8?q?fix:=20v3.17.0=20=E2=80=94=20MCP=20descri?= =?UTF-8?q?ption=20combines=20inline=20@mcp=20text=20with=20comment=20pros?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `@mcp ` set the description to and short-circuited the comment-prose fallback, so `@mcp ` followed by prose lines silently dropped the prose. BuildTool now concatenates the inline text (lead line) with the prose (UnhandledCommentLines): either alone is used as-is, both combine, and only when there is neither does it fall back to the routine name. --- .../McpTests/McpPluginAnnotationTests.cs | 13 +++++++++++++ plugins/NpgsqlRest.Mcp/Mcp.cs | 13 ++++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs index 273f71a1..7297a4ed 100644 --- a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs +++ b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs @@ -85,6 +85,14 @@ comment on function mcp.tool_authorized() is ' HTTP GET @mcp Admin-only data. @authorize admin'; + +-- inline `@mcp ` AND comment prose -> description combines both (inline as the lead line) +create function mcp.tool_inline_and_prose() returns text language sql as 'select ''ip'''; +comment on function mcp.tool_inline_and_prose() is ' +HTTP GET +@mcp Lead description. +More detail line one. +More detail line two.'; "); } } @@ -187,6 +195,11 @@ public void Description_falls_back_to_the_routine_name_when_there_is_no_text() """{"name":"tool_nodesc","description":"tool_nodesc","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"value":{"type":["string","null"]}}}}"""); } + [Fact] + public void Inline_mcp_text_and_comment_prose_combine_into_the_description() + => test.Tools["tool_inline_and_prose"]!.ToJsonString().Should().Be( + """{"name":"tool_inline_and_prose","description":"Lead description.\nMore detail line one.\nMore detail line two.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"value":{"type":["string","null"]}}}}"""); + [Fact] public void ToolDescriptionSuffix_is_appended_to_every_tool_description() { diff --git a/plugins/NpgsqlRest.Mcp/Mcp.cs b/plugins/NpgsqlRest.Mcp/Mcp.cs index 4926dba9..65db3782 100644 --- a/plugins/NpgsqlRest.Mcp/Mcp.cs +++ b/plugins/NpgsqlRest.Mcp/Mcp.cs @@ -130,11 +130,14 @@ private JsonObject BuildTool(RoutineEndpoint endpoint, McpToolInfo info) var routine = endpoint.Routine; var name = string.IsNullOrWhiteSpace(info.ToolName) ? routine.Name : info.ToolName!; - var description = info.Description; - if (string.IsNullOrWhiteSpace(description)) - { - description = DeriveDescription(endpoint); - } + // Description = inline `@mcp ` (if any) followed by the comment prose (UnhandledCommentLines). + // Either alone is used as-is; both are combined with the inline text as the lead line. Falling back + // to the routine name only when there is neither. + var inline = string.IsNullOrWhiteSpace(info.Description) ? null : info.Description; + var prose = DeriveDescription(endpoint); + var description = inline is not null && prose is not null + ? string.Concat(inline, "\n", prose) + : inline ?? prose; if (string.IsNullOrWhiteSpace(description)) { description = routine.Name; From 37bc023d30a91900038c12ec78c97232ca05371e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Tue, 9 Jun 2026 12:46:49 +0200 Subject: [PATCH 22/38] =?UTF-8?q?feat:=20v3.17.0=20=E2=80=94=20opt-in=20MC?= =?UTF-8?q?P=20tools/list=20role=20filtering=20(FilterToolsByRole)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit McpOptions.Authorization.FilterToolsByRole (default false): when true, tools/list hides tools the calling principal could not run (their routine's authorize/role check would deny it). Default false keeps the full list discoverable; tools/call enforces authorization regardless. The check reuses one source of truth: RoutineEndpoint.IsCallableBy / HasAuthorizeRoleMatch. The request-time 403 role check in NpgsqlRestEndpoint is refactored to call HasAuthorizeRoleMatch (behaviour-identical), so the filter and enforcement can never drift. McpServer stores the auth options at Setup and filters tools/list by the forwarded principal. Wired through the client + the 4-file config set. Tests: anonymous tools/list hides an `@authorize`d tool while listing public ones; admin sees it; a wrong role does not. --- NpgsqlRest/NpgsqlRestEndpoint.cs | 33 ++++--------- NpgsqlRest/RoutineEndpoint.cs | 47 ++++++++++++++++++- NpgsqlRestClient/App.cs | 1 + NpgsqlRestClient/ConfigDefaults.cs | 3 +- NpgsqlRestClient/ConfigSchemaGenerator.cs | 1 + NpgsqlRestClient/ConfigTemplate.cs | 6 ++- NpgsqlRestClient/appsettings.json | 6 ++- NpgsqlRestTests/McpTests/McpAuthRoleTests.cs | 40 ++++++++++++++++ .../Setup/McpAuthRoleTestFixture.cs | 2 + changelog/v3.17.0.md | 4 +- .../NpgsqlRest.Mcp/McpAuthorizationOptions.cs | 7 +++ plugins/NpgsqlRest.Mcp/McpServer.cs | 17 +++++-- 12 files changed, 133 insertions(+), 34 deletions(-) diff --git a/NpgsqlRest/NpgsqlRestEndpoint.cs b/NpgsqlRest/NpgsqlRestEndpoint.cs index cd90cd63..7238d3b4 100644 --- a/NpgsqlRest/NpgsqlRestEndpoint.cs +++ b/NpgsqlRest/NpgsqlRestEndpoint.cs @@ -1532,32 +1532,15 @@ await Results.Problem( return; } - if (endpoint.AuthorizeRoles is not null) + if (endpoint.AuthorizeRoles is not null + && endpoint.HasAuthorizeRoleMatch(context.User, Options.AuthenticationOptions) is false) { - bool ok = false; - foreach (var claim in context.User?.Claims ?? []) - { - if ( - string.Equals(claim.Type, Options.AuthenticationOptions.DefaultUserIdClaimType, StringComparison.Ordinal) || - string.Equals(claim.Type, Options.AuthenticationOptions.DefaultNameClaimType, StringComparison.Ordinal) || - string.Equals(claim.Type, Options.AuthenticationOptions.DefaultRoleClaimType, StringComparison.Ordinal)) - { - if (endpoint.AuthorizeRoles.Contains(claim.Value) is true) - { - ok = true; - break; - } - } - } - if (ok is false) - { - await Results.Problem( - type: null, - statusCode: (int)HttpStatusCode.Forbidden, - title: "Forbidden", - detail: null).ExecuteAsync(context); - return; - } + await Results.Problem( + type: null, + statusCode: (int)HttpStatusCode.Forbidden, + title: "Forbidden", + detail: null).ExecuteAsync(context); + return; } } diff --git a/NpgsqlRest/RoutineEndpoint.cs b/NpgsqlRest/RoutineEndpoint.cs index c248f140..8efe0638 100644 --- a/NpgsqlRest/RoutineEndpoint.cs +++ b/NpgsqlRest/RoutineEndpoint.cs @@ -1,4 +1,6 @@ -using Microsoft.Extensions.Primitives; +using System.Security.Claims; +using Microsoft.Extensions.Primitives; +using NpgsqlRest.Auth; namespace NpgsqlRest; @@ -220,6 +222,49 @@ public bool TryGetItem(string key, out object? value) /// public bool HasPathParameters => PathParameters is not null && PathParameters.Length > 0; + /// + /// Whether may invoke this endpoint, given its authorization annotations. + /// Login endpoints are always callable; otherwise an authenticated principal is required when the + /// endpoint requires authorization or restricts roles, and a matching role claim when roles are set. + /// This is the single source of truth shared by the request-time authorization check and the MCP + /// tools/list role filter. + /// + public bool IsCallableBy(ClaimsPrincipal? user, NpgsqlRestAuthenticationOptions auth) + { + if (Login) + { + return true; + } + if ((RequiresAuthorization || AuthorizeRoles is not null) && user?.Identity?.IsAuthenticated is not true) + { + return false; + } + return HasAuthorizeRoleMatch(user, auth); + } + + /// + /// True when is unset, or has a user-id / name / + /// role claim whose value is one of the authorized roles. + /// + public bool HasAuthorizeRoleMatch(ClaimsPrincipal? user, NpgsqlRestAuthenticationOptions auth) + { + if (AuthorizeRoles is null) + { + return true; + } + foreach (var claim in user?.Claims ?? []) + { + if ((string.Equals(claim.Type, auth.DefaultUserIdClaimType, StringComparison.Ordinal) || + string.Equals(claim.Type, auth.DefaultNameClaimType, StringComparison.Ordinal) || + string.Equals(claim.Type, auth.DefaultRoleClaimType, StringComparison.Ordinal)) + && AuthorizeRoles.Contains(claim.Value)) + { + return true; + } + } + return false; + } + /// /// Ensures the PathParametersHashSet is initialized for fast lookups. /// Call this after setting PathParameters. diff --git a/NpgsqlRestClient/App.cs b/NpgsqlRestClient/App.cs index 1a006822..d863f1dc 100644 --- a/NpgsqlRestClient/App.cs +++ b/NpgsqlRestClient/App.cs @@ -597,6 +597,7 @@ public List CreateCodeGenHandlers(string connectionStrin ScopesSupported = [.. _config.GetConfigEnumerable("ScopesSupported", mcpAuthCfg) ?? []], Audience = _config.GetConfigStr("Audience", mcpAuthCfg), ProtectedResourceMetadataPath = _config.GetConfigStr("ProtectedResourceMetadataPath", mcpAuthCfg), + FilterToolsByRole = _config.GetConfigBool("FilterToolsByRole", mcpAuthCfg), }, })); _builder.ClientLogger?.LogDebug("MCP server enabled. UrlPath={UrlPath}", diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index 418f2a47..547f9539 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -987,7 +987,8 @@ private static JsonObject GetMcpOptionsDefaults() ["AuthorizationServers"] = new JsonArray(), ["ScopesSupported"] = new JsonArray(), ["Audience"] = null, - ["ProtectedResourceMetadataPath"] = null + ["ProtectedResourceMetadataPath"] = null, + ["FilterToolsByRole"] = false } }; } diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index 6d8f1471..ca99a6df 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -450,6 +450,7 @@ public static partial class ConfigSchemaGenerator ["NpgsqlRest:McpOptions:Authorization:ScopesSupported"] = "Optional scopes advertised in the Protected Resource Metadata (scopes_supported).", ["NpgsqlRest:McpOptions:Authorization:Audience"] = "Canonical resource URI tokens must target (RFC 8707 audience) and the PRM \"resource\" value. Null = derived from the request (scheme + host + UrlPath).", ["NpgsqlRest:McpOptions:Authorization:ProtectedResourceMetadataPath"] = "Path the Protected Resource Metadata document is served at. Null = the RFC 9728 well-known path derived from UrlPath.", + ["NpgsqlRest:McpOptions:Authorization:FilterToolsByRole"] = "When true, tools/list hides tools the calling principal could not run (their routine's authorize/role check would deny it). When false (default), every opted-in tool is listed (discoverable) and authorization is enforced on tools/call.", ["NpgsqlRest:ClientCodeGen"] = "Enable or disable the generation of TypeScript/Javascript client source code files for NpgsqlRest endpoints.", ["NpgsqlRest:ClientCodeGen:FilePath"] = "File path for the generated code. Set to null to skip the code generation. Use {0} to set schema name when BySchema is true", ["NpgsqlRest:ClientCodeGen:FileOverwrite"] = "Force file overwrite.", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index d64c8bb9..eee0f1af 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -2616,7 +2616,11 @@ public static partial class ConfigSchemaGenerator // // Path the Protected Resource Metadata document is served at. Null = the RFC 9728 well-known path derived from UrlPath. // - "ProtectedResourceMetadataPath": null + "ProtectedResourceMetadataPath": null, + // + // When true, tools/list hides tools the calling principal could not run (their routine's authorize/role check would deny it). When false (default), every opted-in tool is listed (discoverable) and authorization is enforced on tools/call. + // + "FilterToolsByRole": false } }, diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index b7eedddc..b575b1ad 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -2607,7 +2607,11 @@ // // Path the Protected Resource Metadata document is served at. Null = the RFC 9728 well-known path derived from UrlPath. // - "ProtectedResourceMetadataPath": null + "ProtectedResourceMetadataPath": null, + // + // When true, tools/list hides tools the calling principal could not run (their routine's authorize/role check would deny it). When false (default), every opted-in tool is listed (discoverable) and authorization is enforced on tools/call. + // + "FilterToolsByRole": false } }, diff --git a/NpgsqlRestTests/McpTests/McpAuthRoleTests.cs b/NpgsqlRestTests/McpTests/McpAuthRoleTests.cs index 720bc436..a7b2a090 100644 --- a/NpgsqlRestTests/McpTests/McpAuthRoleTests.cs +++ b/NpgsqlRestTests/McpTests/McpAuthRoleTests.cs @@ -38,4 +38,44 @@ public async Task Authenticated_with_the_wrong_role_is_rejected_with_403_insuffi challenge.Should().Contain("scope=\"mcp.read\""); challenge.Should().Contain("resource_metadata="); } + + // ---- tools/list role filtering (FilterToolsByRole = true) ------------- + + private const string ListRequest = """{"jsonrpc":"2.0","id":1,"method":"tools/list"}"""; + + private static async Task> ListToolNamesAsync(HttpClient client) + { + using var content = new StringContent(ListRequest, Encoding.UTF8, "application/json"); + using var response = await client.PostAsync("/mcp", content); + var tools = JsonNode.Parse(await response.Content.ReadAsStringAsync())!["result"]!["tools"]!.AsArray(); + return tools.Select(t => t!["name"]!.GetValue()).ToList(); + } + + [Fact] + public async Task Anonymous_tools_list_hides_role_restricted_tools() + { + using var client = test.CreateClient(); // not logged in + var names = await ListToolNamesAsync(client); + names.Should().Contain("tool_basic"); // anonymous-callable tool is listed + names.Should().NotContain("tool_authorized"); // `@authorize admin` tool is hidden + } + + [Fact] + public async Task Authenticated_tools_list_includes_tools_for_the_callers_role() + { + using var client = test.CreateClient(); + (await client.GetAsync("/login-as?role=admin")).EnsureSuccessStatusCode(); + var names = await ListToolNamesAsync(client); + names.Should().Contain("tool_authorized"); // admin sees the admin tool + } + + [Fact] + public async Task Authenticated_with_the_wrong_role_still_does_not_see_the_tool() + { + using var client = test.CreateClient(); + (await client.GetAsync("/login-as?role=guest")).EnsureSuccessStatusCode(); + var names = await ListToolNamesAsync(client); + names.Should().Contain("tool_basic"); + names.Should().NotContain("tool_authorized"); // guest lacks `admin` + } } diff --git a/NpgsqlRestTests/Setup/McpAuthRoleTestFixture.cs b/NpgsqlRestTests/Setup/McpAuthRoleTestFixture.cs index 5cfd95e7..880c1364 100644 --- a/NpgsqlRestTests/Setup/McpAuthRoleTestFixture.cs +++ b/NpgsqlRestTests/Setup/McpAuthRoleTestFixture.cs @@ -54,6 +54,8 @@ public McpAuthRoleTestFixture() RequireAuthorization = false, AuthorizationServers = ["https://as.example.com"], ScopesSupported = ["mcp.read"], + // tools/list hides tools the caller can't run (tool_authorized requires `admin`). + FilterToolsByRole = true, } }) ] diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index 6b75825d..7c451424 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -34,13 +34,13 @@ NpgsqlRest can now expose opted-in PostgreSQL routines as **MCP tools**, so an A - **Audience binding (RFC 8707):** with a canonical `Audience` configured, a token must carry it (`aud` claim) or it is rejected with `401`. - **Per-tool authorization:** the routine's `authorize`/role check runs on `tools/call` → `401` if called anonymously, `403` `insufficient_scope` if the role is missing (RFC 6750 §3.1; challenges include `scope` and `resource_metadata`). No authorization logic is duplicated in the plugin — it reuses core's check. -**Configuration** — `NpgsqlRest:McpOptions`, disabled by default, surfaced in `--config`, `--config-schema`, and the JSON schema: `Enabled`, `UrlPath` (`/mcp`), `ServerName` (null → database name → `"NpgsqlRest"`), `ServerVersion` (`"1.0.0"`), `Instructions`, `ToolDescriptionSuffix`, `AllowedOrigins`, and the `Authorization` object (`RequireAuthorization`, `AuthorizationServers`, `ScopesSupported`, `Audience`, `ProtectedResourceMetadataPath`). +**Configuration** — `NpgsqlRest:McpOptions`, disabled by default, surfaced in `--config`, `--config-schema`, and the JSON schema: `Enabled`, `UrlPath` (`/mcp`), `ServerName` (null → database name → `"NpgsqlRest"`), `ServerVersion` (`"1.0.0"`), `Instructions`, `ToolDescriptionSuffix`, `AllowedOrigins`, and the `Authorization` object (`RequireAuthorization`, `AuthorizationServers`, `ScopesSupported`, `Audience`, `ProtectedResourceMetadataPath`, `FilterToolsByRole`). **Diagnostics & current limitations.** - Enabling MCP does **not** enable authentication — it is configured separately (the host's `Auth` section). If `RequireAuthorization` is on but no authentication scheme is registered, a startup warning is logged. - A routine annotated `mcp` that also uses a feature with no MCP equivalent (`login`, `logout`, basic auth, `upload`, SSE) logs a build-time warning. -- `tools/list` lists every opted-in tool and does not yet filter to the caller's permitted subset (a SHOULD in the spec); authorization is still enforced on `tools/call`. +- `tools/list` lists every opted-in tool by default (keeping them discoverable); set `Authorization.FilterToolsByRole` to hide tools the caller can't run. Authorization is enforced on `tools/call` regardless. - The JSON-RPC layer is hand-rolled over `System.Text.Json.Nodes` with relaxed escaping (conventional `application/json` output) — no reflection-based serialization, AOT-safe (verified via `dotnet publish -p:PublishAot=true`). ### Plugin extension points on `RoutineEndpoint` diff --git a/plugins/NpgsqlRest.Mcp/McpAuthorizationOptions.cs b/plugins/NpgsqlRest.Mcp/McpAuthorizationOptions.cs index 5fbda8aa..59ab41c7 100644 --- a/plugins/NpgsqlRest.Mcp/McpAuthorizationOptions.cs +++ b/plugins/NpgsqlRest.Mcp/McpAuthorizationOptions.cs @@ -33,4 +33,11 @@ public class McpAuthorizationOptions /// derived from the MCP URL path (/.well-known/oauth-protected-resource + UrlPath). /// public string? ProtectedResourceMetadataPath { get; set; } = null; + + /// + /// When true, tools/list hides tools the calling principal could not run (their routine's + /// authorize/role check would deny it). False (default) lists every opted-in tool — keeping + /// them discoverable — and authorization is enforced on tools/call regardless. + /// + public bool FilterToolsByRole { get; set; } = false; } diff --git a/plugins/NpgsqlRest.Mcp/McpServer.cs b/plugins/NpgsqlRest.Mcp/McpServer.cs index 52b04b9a..c76f2e84 100644 --- a/plugins/NpgsqlRest.Mcp/McpServer.cs +++ b/plugins/NpgsqlRest.Mcp/McpServer.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; @@ -7,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Npgsql; +using NpgsqlRest.Auth; using NpgsqlRest.Common; using static NpgsqlRest.NpgsqlRestOptions; @@ -28,11 +30,13 @@ public partial class Mcp private IApplicationBuilder _builder = default!; private string? _connectionString; + private NpgsqlRestAuthenticationOptions _authOptions = new(); public void Setup(IApplicationBuilder builder, NpgsqlRestOptions options) { _builder = builder; _connectionString = options.ConnectionString; + _authOptions = options.AuthenticationOptions; } public void Cleanup() @@ -302,7 +306,7 @@ private async Task HandleAsync(HttpContext context) { "initialize" => BuildInitializeResult(), "ping" => new JsonObject(), - "tools/list" => BuildToolsListResult(), + "tools/list" => BuildToolsListResult(context.User), _ => null, }; @@ -602,11 +606,18 @@ private string GetServerName() return "NpgsqlRest"; } - private JsonObject BuildToolsListResult() + private JsonObject BuildToolsListResult(ClaimsPrincipal? user) { + // Optional role filter: hide tools the caller couldn't run (their routine's authorize/role check + // would deny). Off by default — the list stays fully discoverable and tools/call enforces anyway. + var filter = _options.Authorization.FilterToolsByRole; var tools = new JsonArray(); - foreach (var tool in _tools.Values) + foreach (var (name, tool) in _tools) { + if (filter && _toolEndpoints.TryGetValue(name, out var endpoint) && !endpoint.IsCallableBy(user, _authOptions)) + { + continue; + } tools.Add(tool.DeepClone()); // catalog entries are owned by _tools; clone before reparenting } return new JsonObject { ["tools"] = tools }; From 8507b4c496398b1d5c738bc511143955c821acb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Tue, 9 Jun 2026 13:34:57 +0200 Subject: [PATCH 23/38] =?UTF-8?q?test:=20v3.17.0=20=E2=80=94=20MCP=20compo?= =?UTF-8?q?site-type=20parameter=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document how composite-type routine parameters render in MCP inputSchema: scalar composite params are flattened by NpgsqlRest into typed scalar fields (p mcp.point -> pX/pY), while array-of-composite params carry no element metadata on the parameter descriptor and render as an array of strings (the value still binds). Two tests in McpToolCatalogTests pin both behaviors. --- .../McpTests/McpPluginAnnotationTests.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs index 7297a4ed..8fa3b9d3 100644 --- a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs +++ b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs @@ -93,6 +93,19 @@ HTTP GET @mcp Lead description. More detail line one. More detail line two.'; + +-- composite-type parameter -> NpgsqlRest flattens it into typed scalar params (pX, pY) +create type mcp.point as (x int, y int); +create function mcp.tool_composite_param(p mcp.point) returns text language sql as 'select ''ok'''; +comment on function mcp.tool_composite_param(mcp.point) is ' +HTTP POST +@mcp Takes a composite point.'; + +-- array-of-composite parameter -> can't flatten; should render as an array of objects (not a string) +create function mcp.tool_point_array(pts mcp.point[]) returns text language sql as 'select ''ok'''; +comment on function mcp.tool_point_array(mcp.point[]) is ' +HTTP POST +@mcp Takes an array of points.'; "); } } @@ -195,6 +208,19 @@ public void Description_falls_back_to_the_routine_name_when_there_is_no_text() """{"name":"tool_nodesc","description":"tool_nodesc","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"value":{"type":["string","null"]}}}}"""); } + [Fact] + public void Composite_type_parameter_is_flattened_into_typed_scalar_fields() + => test.Tools["tool_composite_param"]!["inputSchema"]!.ToJsonString().Should().Be( + """{"type":"object","properties":{"pX":{"type":"integer","format":"int32"},"pY":{"type":"integer","format":"int32"}},"required":["pX","pY"]}"""); + + [Fact] + public void Array_of_composite_parameter_renders_as_an_array_of_strings() + // Known limitation: parameter TypeDescriptors don't carry composite-element field metadata, so an + // array-of-composite argument is described as an array of strings (the value still binds). Scalar + // composite params, by contrast, are flattened into typed fields (see the test above). + => test.Tools["tool_point_array"]!["inputSchema"]!.ToJsonString().Should().Be( + """{"type":"object","properties":{"pts":{"type":"array","items":{"type":"string"}}},"required":["pts"]}"""); + [Fact] public void Inline_mcp_text_and_comment_prose_combine_into_the_description() => test.Tools["tool_inline_and_prose"]!.ToJsonString().Should().Be( From cc8ea31837dbfa4894df627de7b38dd4251645dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Tue, 9 Jun 2026 13:35:05 +0200 Subject: [PATCH 24/38] =?UTF-8?q?feat:=20v3.17.0=20=E2=80=94=20MCP=20rate?= =?UTF-8?q?=20limiting=20(warn=20+=20McpOptions.RateLimiterPolicy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A routine's rate_limiter annotation does not carry to MCP: tools/call invokes the routine directly, bypassing per-route middleware. Two changes: - Build-time warning when an mcp-annotated routine also has rate_limiter, pointing operators at McpOptions.RateLimiterPolicy. - McpOptions.RateLimiterPolicy: name of a host-registered ASP.NET rate-limiter policy applied to the whole /mcp endpoint. When set, /mcp (and the PRM path) are served as mapped endpoints so RequireRateLimiting can attach; both GET and POST are mapped so HandleAsync keeps emitting the in-handler 405 (behavior identical to the prior middleware). Falls back to middleware on hosts without endpoint routing, logging that the policy cannot apply there. Wired through App.cs + the 4-file config set (appsettings.json, ConfigTemplate, ConfigDefaults, ConfigSchemaGenerator). New McpRateLimiterTests + fixture prove a 1-permit policy throttles the endpoint (1st 200, 2nd 429). Full suite green, AOT-clean. --- NpgsqlRestClient/App.cs | 1 + NpgsqlRestClient/ConfigDefaults.cs | 1 + NpgsqlRestClient/ConfigSchemaGenerator.cs | 1 + NpgsqlRestClient/ConfigTemplate.cs | 4 + NpgsqlRestClient/appsettings.json | 4 + .../McpTests/McpFeatureWarnTests.cs | 16 ++++ .../McpTests/McpRateLimiterTests.cs | 29 +++++++ .../Setup/McpRateLimiterTestFixture.cs | 80 +++++++++++++++++++ changelog/v3.17.0.md | 3 +- plugins/NpgsqlRest.Mcp/Mcp.cs | 9 +++ plugins/NpgsqlRest.Mcp/McpOptions.cs | 9 +++ plugins/NpgsqlRest.Mcp/McpServer.cs | 26 ++++++ 12 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 NpgsqlRestTests/McpTests/McpRateLimiterTests.cs create mode 100644 NpgsqlRestTests/Setup/McpRateLimiterTestFixture.cs diff --git a/NpgsqlRestClient/App.cs b/NpgsqlRestClient/App.cs index d863f1dc..a00a93ce 100644 --- a/NpgsqlRestClient/App.cs +++ b/NpgsqlRestClient/App.cs @@ -589,6 +589,7 @@ public List CreateCodeGenHandlers(string connectionStrin ServerVersion = _config.GetConfigStr("ServerVersion", mcpCfg), Instructions = _config.GetConfigStr("Instructions", mcpCfg), ToolDescriptionSuffix = _config.GetConfigStr("ToolDescriptionSuffix", mcpCfg), + RateLimiterPolicy = _config.GetConfigStr("RateLimiterPolicy", mcpCfg), AllowedOrigins = [.. _config.GetConfigEnumerable("AllowedOrigins", mcpCfg) ?? []], Authorization = new McpAuthorizationOptions { diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index 547f9539..0c3bda4f 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -980,6 +980,7 @@ private static JsonObject GetMcpOptionsDefaults() ["ServerVersion"] = "1.0.0", ["Instructions"] = null, ["ToolDescriptionSuffix"] = null, + ["RateLimiterPolicy"] = null, ["AllowedOrigins"] = new JsonArray(), ["Authorization"] = new JsonObject { diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index ca99a6df..984ae59a 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -443,6 +443,7 @@ public static partial class ConfigSchemaGenerator ["NpgsqlRest:McpOptions:ServerVersion"] = "serverInfo.version reported in the MCP initialize handshake.", ["NpgsqlRest:McpOptions:Instructions"] = "Optional server-level instructions returned in the MCP initialize handshake (high-level guidance for the agent). Null = omitted.", ["NpgsqlRest:McpOptions:ToolDescriptionSuffix"] = "Optional text appended to every MCP tool description. Null = no-op. Use for short shared context the agent should always see (e.g. \"Read-only Acme CRM.\"); for longer server-wide guidance prefer Instructions.", + ["NpgsqlRest:McpOptions:RateLimiterPolicy"] = "Name of an ASP.NET rate-limiter policy applied to the whole /mcp endpoint. Null = no limiting. A routine's own rate_limiter annotation does not carry to MCP (tools/call bypasses route middleware), so this is how MCP traffic is throttled. The named policy must be registered on the host (AddRateLimiter + UseRateLimiter); an unregistered name surfaces as the framework's error when a request hits the endpoint.", ["NpgsqlRest:McpOptions:AllowedOrigins"] = "Allowed values of the HTTP Origin header (DNS-rebinding protection for the Streamable HTTP transport). A request whose Origin is present but matches neither this list nor the server's own origin is rejected with 403. Requests without an Origin header (e.g. server-to-server) are allowed. Empty = only same-origin browser requests pass.", ["NpgsqlRest:McpOptions:Authorization"] = "OAuth 2.1 Resource Server settings. Token validation reuses the host's bearer authentication; these keys configure the transport gate and the Protected Resource Metadata document (RFC 9728). NpgsqlRest is not an Authorization Server — point AuthorizationServers at an external IdP.", ["NpgsqlRest:McpOptions:Authorization:RequireAuthorization"] = "When true, every MCP request requires an authenticated principal. When false (default), anonymous is allowed and a tool's own `authorize` annotation still gates it per call.", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index eee0f1af..3e1cf3c0 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -2590,6 +2590,10 @@ public static partial class ConfigSchemaGenerator // "ToolDescriptionSuffix": null, // + // Name of an ASP.NET rate-limiter policy applied to the whole /mcp endpoint. Null = no limiting. A routine's own rate_limiter annotation does not carry to MCP (tools/call bypasses route middleware), so this is how MCP traffic is throttled. The named policy must be registered on the host (AddRateLimiter + UseRateLimiter); an unregistered name surfaces as the framework's error when a request hits the endpoint. + // + "RateLimiterPolicy": null, + // // Allowed values of the HTTP Origin header (DNS-rebinding protection for the Streamable HTTP transport). A request whose Origin is present but matches neither this list nor the server's own origin is rejected with 403. Requests without an Origin header (e.g. server-to-server) are allowed. Empty = only same-origin browser requests pass. // "AllowedOrigins": [], diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index b575b1ad..3ff15617 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -2581,6 +2581,10 @@ // "ToolDescriptionSuffix": null, // + // Name of an ASP.NET rate-limiter policy applied to the whole /mcp endpoint. Null = no limiting. A routine's own rate_limiter annotation does not carry to MCP (tools/call bypasses route middleware), so this is how MCP traffic is throttled. The named policy must be registered on the host (AddRateLimiter + UseRateLimiter); an unregistered name surfaces as the framework's error when a request hits the endpoint. + // + "RateLimiterPolicy": null, + // // Allowed values of the HTTP Origin header (DNS-rebinding protection for the Streamable HTTP transport). A request whose Origin is present but matches neither this list nor the server's own origin is rejected with 403. Requests without an Origin header (e.g. server-to-server) are allowed. Empty = only same-origin browser requests pass. // "AllowedOrigins": [], diff --git a/NpgsqlRestTests/McpTests/McpFeatureWarnTests.cs b/NpgsqlRestTests/McpTests/McpFeatureWarnTests.cs index 5648d801..8b32a7cc 100644 --- a/NpgsqlRestTests/McpTests/McpFeatureWarnTests.cs +++ b/NpgsqlRestTests/McpTests/McpFeatureWarnTests.cs @@ -20,6 +20,13 @@ comment on function mcp_warn.tool_login() is ' HTTP POST login @mcp Sign in.'; + +-- @mcp on a routine with a rate_limiter: route-level rate limiting does not carry to MCP. +create function mcp_warn.tool_rate_limited() returns text language sql as 'select ''x'''; +comment on function mcp_warn.tool_rate_limited() is ' +HTTP GET +@mcp Rate-limited routine. +rate_limiter mcp_warn_policy'; "); } } @@ -46,6 +53,15 @@ public void Mcp_annotation_appears_in_the_endpoint_annotations_summary() summary!.Message.Should().Contain("Sign in."); // tool_login's `@mcp Sign in.` annotation } + [Fact] + public void A_rate_limiter_annotation_on_an_mcp_routine_logs_a_warning() + { + var warning = test.StartupLogs.FirstOrDefault(l => + l.Message.Contains("route-level rate limiting does not apply to MCP") && l.Message.Contains("tool_rate_limited")); + warning.Should().NotBeNull("the plugin should warn that a routine's rate_limiter doesn't carry to MCP"); + warning!.Level.Should().Be(LogLevel.Warning); + } + [Fact] public void The_exposed_tool_catalog_is_logged_at_startup() { diff --git a/NpgsqlRestTests/McpTests/McpRateLimiterTests.cs b/NpgsqlRestTests/McpTests/McpRateLimiterTests.cs new file mode 100644 index 00000000..eedd4b80 --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpRateLimiterTests.cs @@ -0,0 +1,29 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +/// +/// Verifies throttles the whole /mcp +/// endpoint. The fixture registers a fixed-window policy of one permit per long window, so the second +/// request is rejected by ASP.NET's rate limiter (429) before the JSON-RPC handler runs. +/// +[Collection("McpRateLimiterFixture")] +public class McpRateLimiterTests(McpRateLimiterTestFixture test) +{ + private async Task InitializeAsync() + { + using var content = new StringContent( + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}""", Encoding.UTF8, "application/json"); + return await test.Client.PostAsync("/mcp", content); + } + + [Fact] + public async Task RateLimiterPolicy_throttles_the_mcp_endpoint() + { + using var first = await InitializeAsync(); + first.StatusCode.Should().Be(HttpStatusCode.OK); // first request consumes the single permit + + using var second = await InitializeAsync(); + second.StatusCode.Should().Be(HttpStatusCode.TooManyRequests); // second is rejected by the rate limiter + } +} diff --git a/NpgsqlRestTests/Setup/McpRateLimiterTestFixture.cs b/NpgsqlRestTests/Setup/McpRateLimiterTestFixture.cs new file mode 100644 index 00000000..8b2576b7 --- /dev/null +++ b/NpgsqlRestTests/Setup/McpRateLimiterTestFixture.cs @@ -0,0 +1,80 @@ +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NpgsqlRest.Mcp; + +namespace NpgsqlRestTests.Setup; + +[CollectionDefinition("McpRateLimiterFixture")] +public class McpRateLimiterFixtureCollection : ICollectionFixture { } + +/// +/// Fixture proving is applied to the whole /mcp endpoint. +/// The host registers a fixed-window policy that allows a single request per (long) window; the second +/// request to /mcp must be rejected by the rate limiter with 429. +/// +public class McpRateLimiterTestFixture : IDisposable +{ + public const string PolicyName = "mcp_test_policy"; + + private readonly WebApplication _app; + private readonly HttpClient _client; + + public HttpClient Client => _client; + + public McpRateLimiterTestFixture() + { + Database.Create(); + var connectionString = Database.CreateAdditional("mcp_rate_limiter_test"); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Logging.ClearProviders(); + + // One permit per (long) window, no queue → the second request inside the window is rejected. + builder.Services.AddRateLimiter(o => + { + o.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + o.AddFixedWindowLimiter(PolicyName, opt => + { + opt.PermitLimit = 1; + opt.Window = TimeSpan.FromMinutes(10); + opt.QueueLimit = 0; + }); + }); + + _app = builder.Build(); + _app.UseRateLimiter(); + + _app.UseNpgsqlRest(new NpgsqlRestOptions(connectionString) + { + IncludeSchemas = ["mcp"], + CommentsMode = CommentsMode.OnlyAnnotated, + EndpointCreateHandlers = + [ + new Mcp(new McpOptions + { + Enabled = true, + RateLimiterPolicy = PolicyName, + }) + ] + }); + + _app.StartAsync().GetAwaiter().GetResult(); + _client = new HttpClient { BaseAddress = new Uri(_app.Urls.First()) }; + _client.Timeout = TimeSpan.FromHours(1); + } + +#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize + public void Dispose() +#pragma warning restore CA1816 // Dispose methods should call SuppressFinalize + { + _client.Dispose(); + _app.StopAsync().GetAwaiter().GetResult(); + _app.DisposeAsync().GetAwaiter().GetResult(); + } +} diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index 7c451424..e4f6b293 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -34,12 +34,13 @@ NpgsqlRest can now expose opted-in PostgreSQL routines as **MCP tools**, so an A - **Audience binding (RFC 8707):** with a canonical `Audience` configured, a token must carry it (`aud` claim) or it is rejected with `401`. - **Per-tool authorization:** the routine's `authorize`/role check runs on `tools/call` → `401` if called anonymously, `403` `insufficient_scope` if the role is missing (RFC 6750 §3.1; challenges include `scope` and `resource_metadata`). No authorization logic is duplicated in the plugin — it reuses core's check. -**Configuration** — `NpgsqlRest:McpOptions`, disabled by default, surfaced in `--config`, `--config-schema`, and the JSON schema: `Enabled`, `UrlPath` (`/mcp`), `ServerName` (null → database name → `"NpgsqlRest"`), `ServerVersion` (`"1.0.0"`), `Instructions`, `ToolDescriptionSuffix`, `AllowedOrigins`, and the `Authorization` object (`RequireAuthorization`, `AuthorizationServers`, `ScopesSupported`, `Audience`, `ProtectedResourceMetadataPath`, `FilterToolsByRole`). +**Configuration** — `NpgsqlRest:McpOptions`, disabled by default, surfaced in `--config`, `--config-schema`, and the JSON schema: `Enabled`, `UrlPath` (`/mcp`), `ServerName` (null → database name → `"NpgsqlRest"`), `ServerVersion` (`"1.0.0"`), `Instructions`, `ToolDescriptionSuffix`, `RateLimiterPolicy`, `AllowedOrigins`, and the `Authorization` object (`RequireAuthorization`, `AuthorizationServers`, `ScopesSupported`, `Audience`, `ProtectedResourceMetadataPath`, `FilterToolsByRole`). **Diagnostics & current limitations.** - Enabling MCP does **not** enable authentication — it is configured separately (the host's `Auth` section). If `RequireAuthorization` is on but no authentication scheme is registered, a startup warning is logged. - A routine annotated `mcp` that also uses a feature with no MCP equivalent (`login`, `logout`, basic auth, `upload`, SSE) logs a build-time warning. +- A routine's `rate_limiter` annotation does not carry to MCP (`tools/call` bypasses route middleware); pairing it with `mcp` logs a build-time warning. Use `McpOptions:RateLimiterPolicy` (a host-registered ASP.NET rate-limiter policy) to throttle the whole `/mcp` endpoint. - `tools/list` lists every opted-in tool by default (keeping them discoverable); set `Authorization.FilterToolsByRole` to hide tools the caller can't run. Authorization is enforced on `tools/call` regardless. - The JSON-RPC layer is hand-rolled over `System.Text.Json.Nodes` with relaxed escaping (conventional `application/json` output) — no reflection-based serialization, AOT-safe (verified via `dotnet publish -p:PublishAot=true`). diff --git a/plugins/NpgsqlRest.Mcp/Mcp.cs b/plugins/NpgsqlRest.Mcp/Mcp.cs index 65db3782..2f7bcf94 100644 --- a/plugins/NpgsqlRest.Mcp/Mcp.cs +++ b/plugins/NpgsqlRest.Mcp/Mcp.cs @@ -123,6 +123,15 @@ private static void WarnIfNonApplicableFeature(RoutineEndpoint endpoint, string "MCP tool '{Name}' ({Schema}.{Routine}) is annotated `mcp` but uses a feature that does not apply to MCP tools ({Feature}). The tool is exposed but may not behave as expected over JSON-RPC.", toolName, endpoint.Routine.Schema, endpoint.Routine.Name, feature); } + + // A routine's `rate_limiter` policy applies to its HTTP route, not to MCP — tools/call invokes the + // routine directly, bypassing route middleware. Use McpOptions.RateLimiterPolicy for the /mcp endpoint. + if (endpoint.RateLimiterPolicy is not null) + { + Logger?.LogWarning( + "MCP tool '{Name}' ({Schema}.{Routine}) has a `rate_limiter` annotation, but route-level rate limiting does not apply to MCP calls. Set McpOptions.RateLimiterPolicy to throttle the /mcp endpoint.", + toolName, endpoint.Routine.Schema, endpoint.Routine.Name); + } } private JsonObject BuildTool(RoutineEndpoint endpoint, McpToolInfo info) diff --git a/plugins/NpgsqlRest.Mcp/McpOptions.cs b/plugins/NpgsqlRest.Mcp/McpOptions.cs index e262db64..5f401ff8 100644 --- a/plugins/NpgsqlRest.Mcp/McpOptions.cs +++ b/plugins/NpgsqlRest.Mcp/McpOptions.cs @@ -30,6 +30,15 @@ public class McpOptions /// public string? ToolDescriptionSuffix { get; set; } = null; + /// + /// Name of an ASP.NET rate-limiter policy applied to the whole /mcp endpoint. Null = no limiting. + /// A routine's own rate_limiter annotation does not carry to MCP (tools/call bypasses route + /// middleware), so this is how MCP traffic is throttled. The named policy must be registered on the + /// host (AddRateLimiter(o => o.AddPolicy("name", …)) + UseRateLimiter()); an unregistered + /// name surfaces as the framework's error when a request hits the endpoint. + /// + public string? RateLimiterPolicy { get; set; } = null; + /// OAuth 2.1 Resource Server settings: the transport authorization gate and Protected Resource Metadata (RFC 9728). public McpAuthorizationOptions Authorization { get; set; } = new(); diff --git a/plugins/NpgsqlRest.Mcp/McpServer.cs b/plugins/NpgsqlRest.Mcp/McpServer.cs index c76f2e84..f5afd761 100644 --- a/plugins/NpgsqlRest.Mcp/McpServer.cs +++ b/plugins/NpgsqlRest.Mcp/McpServer.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Npgsql; @@ -66,6 +67,31 @@ public void Cleanup() // — without one the document carries no useful discovery information. var servePrm = _options.Authorization.AuthorizationServers.Length > 0; var prmPath = ProtectedResourceMetadataPath(); + + // Prefer mapped endpoints over middleware: an ASP.NET rate-limiter policy (RateLimiterPolicy) can + // only be attached to a mapped endpoint via RequireRateLimiting — not to a middleware path. Map both + // GET and POST so HandleAsync keeps emitting the in-handler 405 for GET (rather than a routing 405), + // preserving the transport behavior. Fall back to middleware on hosts without endpoint routing. + if (_builder is IEndpointRouteBuilder routes) + { + var mcp = routes.MapMethods(path, ["GET", "POST"], HandleAsync); + if (!string.IsNullOrWhiteSpace(_options.RateLimiterPolicy)) + { + mcp.RequireRateLimiting(_options.RateLimiterPolicy); + } + if (servePrm) + { + routes.MapMethods(prmPath, ["GET"], HandleProtectedResourceMetadataAsync); + } + return; + } + + if (!string.IsNullOrWhiteSpace(_options.RateLimiterPolicy)) + { + Logger?.LogWarning( + "MCP RateLimiterPolicy '{Policy}' is configured but the host does not use endpoint routing, so it will not be applied to {Path}.", + _options.RateLimiterPolicy, _options.UrlPath); + } _builder.Use(async (context, next) => { if (servePrm && string.Equals(context.Request.Path, prmPath, StringComparison.Ordinal)) From 317027bceeb3e712cad3096a99538c3a9e822f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Tue, 9 Jun 2026 13:56:35 +0200 Subject: [PATCH 25/38] fix: HybridCache 'Cache key contains invalid content' on nullable cached params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cached routine with a nullable parameter, called with that parameter null, built a cache key containing the \x00 null marker. HybridCache rejects such keys, so the lookup/write threw, was caught and logged, and the call silently fell back to no-cache (correct data, but no cache hit and no stampede protection — and two error log entries per call). - HybridCacheWrapper now always hashes the key into a SHA-256 hex string before passing it to HybridCache, so keys are valid regardless of source content. Removed the now-dead CacheOptions field/ctor param (UseHashedCacheKeys / HashKeyThreshold stay a Redis-backend optimization; no-op for Hybrid now). - Null marker changed from \x00NULL\x00 to \x1FNULL\x1F (delimited by the existing separator, collision-free), dropping the \x00 byte that is also hostile to Redis keys and log collectors across all backends. Two regression tests wire the real HybridCacheWrapper and assert a null-param call (scalar and array) is served from cache on the second hit; verified they fail (counter 2) with the bug reintroduced. Full suite green. --- NpgsqlRest/NpgsqlRestParameter.cs | 5 +- NpgsqlRestClient/Builder.cs | 2 +- NpgsqlRestClient/HybridCacheWrapper.cs | 21 +++-- .../CacheHybridNullParamTests.cs | 86 +++++++++++++++++++ .../Setup/CacheHybridNullTestFixture.cs | 65 ++++++++++++++ changelog/v3.17.0.md | 6 ++ 6 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 NpgsqlRestTests/RoutineCacheTests/CacheHybridNullParamTests.cs create mode 100644 NpgsqlRestTests/Setup/CacheHybridNullTestFixture.cs diff --git a/NpgsqlRest/NpgsqlRestParameter.cs b/NpgsqlRest/NpgsqlRestParameter.cs index 90b0d77e..d0c61dcb 100644 --- a/NpgsqlRest/NpgsqlRestParameter.cs +++ b/NpgsqlRest/NpgsqlRestParameter.cs @@ -96,7 +96,10 @@ public NpgsqlRestParameter( } private const char CacheKeySeparator = '\x1F'; // Unit Separator - non-printable ASCII character - private const string CacheKeyNull = "\x00NULL\x00"; // Distinct marker for null/DBNull values + // Null/DBNull marker, delimited by the Unit Separator (the same byte used between params). Avoids + // \x00, which is hostile across backends (Redis keys, HybridCache key validation, log collectors). + // Collision-free: a real value can never contain \x1F, so it can never produce this marker. + private const string CacheKeyNull = "\x1FNULL\x1F"; internal string GetCacheStringValue() { diff --git a/NpgsqlRestClient/Builder.cs b/NpgsqlRestClient/Builder.cs index e6c677ab..710becdd 100644 --- a/NpgsqlRestClient/Builder.cs +++ b/NpgsqlRestClient/Builder.cs @@ -2083,7 +2083,7 @@ public CacheOptions BuildCacheOptions(WebApplication app, CacheType configuredCa try { var hybridCache = app.Services.GetRequiredService(); - backendsByType[CacheType.Hybrid] = new HybridCacheWrapper(hybridCache, Logger, options); + backendsByType[CacheType.Hybrid] = new HybridCacheWrapper(hybridCache, Logger); var useRedisBackend = _config.GetConfigBool("HybridCacheUseRedisBackend", cacheCfg, false); var redisConfiguration = _config.GetConfigStr("RedisConfiguration", cacheCfg); if (useRedisBackend && !string.IsNullOrEmpty(redisConfiguration)) diff --git a/NpgsqlRestClient/HybridCacheWrapper.cs b/NpgsqlRestClient/HybridCacheWrapper.cs index 302ef9da..ee6424d2 100644 --- a/NpgsqlRestClient/HybridCacheWrapper.cs +++ b/NpgsqlRestClient/HybridCacheWrapper.cs @@ -7,19 +7,22 @@ public class HybridCacheWrapper : IRoutineCache { private readonly HybridCache _cache; private readonly ILogger? _logger; - private readonly CacheOptions _cacheOptions; - public HybridCacheWrapper(HybridCache cache, ILogger? logger = null, CacheOptions? cacheOptions = null) + public HybridCacheWrapper(HybridCache cache, ILogger? logger = null) { _cache = cache; _logger = logger; - _cacheOptions = cacheOptions ?? new CacheOptions(); _logger?.LogInformation("HybridCache wrapper initialized"); } - private string GetEffectiveKey(string key) + // HybridCache rejects keys containing control bytes (notably \x00, which NpgsqlRest's null marker + // historically used, and which is unsafe across Redis keys / log collectors) with + // "Cache key contains invalid content." We always hash into a 64-char hex string so the wrapper is a + // correct adapter regardless of source-key content. (UseHashedCacheKeys / HashKeyThreshold remain a + // Redis-memory optimization for the Redis backend; they are a no-op here since this always hashes.) + private static string GetSafeKey(string key) { - return CacheKeyHasher.GetEffectiveKey(key, _cacheOptions); + return CacheKeyHasher.ComputeHash(key); } public bool Get(RoutineEndpoint endpoint, string key, out object? result) @@ -28,7 +31,7 @@ public bool Get(RoutineEndpoint endpoint, string key, out object? result) try { - var effectiveKey = GetEffectiveKey(key); + var effectiveKey = GetSafeKey(key); // HybridCache uses async API, we need to block here since IRoutineCache is synchronous var task = _cache.GetOrCreateAsync( @@ -62,7 +65,7 @@ public void AddOrUpdate(RoutineEndpoint endpoint, string key, object? value, Tim { try { - var effectiveKey = GetEffectiveKey(key); + var effectiveKey = GetSafeKey(key); var stringValue = value?.ToString(); var expiry = overrideExpiration ?? endpoint.CacheExpiresIn; @@ -93,7 +96,7 @@ public void AddOrUpdate(RoutineEndpoint endpoint, string key, object? value, Tim // share ONE factory invocation (its built-in stampede protection). Values are stored as the // serialized string form, matching AddOrUpdate above (binary payloads are not supported by the // Hybrid backend, same as before). The factory's exceptions propagate without being cached. - var effectiveKey = GetEffectiveKey(key); + var effectiveKey = GetSafeKey(key); var expiry = overrideExpiration ?? endpoint.CacheExpiresIn; var options = expiry.HasValue ? new HybridCacheEntryOptions { Expiration = expiry.Value, LocalCacheExpiration = expiry.Value } @@ -110,7 +113,7 @@ public bool Remove(string key) { try { - var effectiveKey = GetEffectiveKey(key); + var effectiveKey = GetSafeKey(key); var task = _cache.RemoveAsync(effectiveKey); task.AsTask().GetAwaiter().GetResult(); diff --git a/NpgsqlRestTests/RoutineCacheTests/CacheHybridNullParamTests.cs b/NpgsqlRestTests/RoutineCacheTests/CacheHybridNullParamTests.cs new file mode 100644 index 00000000..d75ce97d --- /dev/null +++ b/NpgsqlRestTests/RoutineCacheTests/CacheHybridNullParamTests.cs @@ -0,0 +1,86 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +public static partial class Database +{ + // HybridCache null-parameter cache-key fix. Functions in the shared `public` schema, `chn_` prefix + // (the CacheHybridNullTestFixture maps only `chn_%`). Each cached function records one row per actual + // execution so a test can prove the second (cache-hit) call did NOT re-execute. Pre-fix, the \x00 + // null marker made HybridCache reject the key and silently bypass the cache, so the count would be 2. + public static void CacheHybridNullParamTests() + { + script.Append(@" +create table chn_scalar_calls (id int generated always as identity primary key); +create function chn_scalar(a int default null, b int default null) returns text language sql as $$ + insert into chn_scalar_calls default values; + select 'scalar-result'; +$$; +comment on function chn_scalar(int, int) is ' +HTTP GET +cached a, b +cache_expires_in 30s'; +create function chn_scalar_count() returns bigint language sql as 'select count(*) from chn_scalar_calls'; +comment on function chn_scalar_count() is 'HTTP GET'; + +create table chn_array_calls (id int generated always as identity primary key); +create function chn_array(thing_ids int[] default null, exp_ids int[] default null) returns text language sql as $$ + insert into chn_array_calls default values; + select 'array-result'; +$$; +comment on function chn_array(int[], int[]) is ' +HTTP GET +cached thing_ids, exp_ids +cache_expires_in 30s'; +create function chn_array_count() returns bigint language sql as 'select count(*) from chn_array_calls'; +comment on function chn_array_count() is 'HTTP GET'; +"); + } +} + +/// +/// Regression tests for the HybridCache "Cache key contains invalid content" bug: a cached routine with a +/// nullable parameter, called with that parameter null, built a cache key containing the \x00 null marker, +/// which HybridCache rejected — silently bypassing the cache. The fix hashes every key in the wrapper (and +/// drops \x00 from the marker source-side). These prove the call succeeds AND is served from cache the +/// second time (the per-execution counter stays at 1). +/// +[Collection("CacheHybridNullFixture")] +public class CacheHybridNullParamTests(CacheHybridNullTestFixture test) +{ + [Fact] + public async Task Nullable_scalar_param_null_does_not_throw_and_caches() + { + using var client = test.CreateClient(); + + // `b` absent -> null -> the null marker goes into the cache key. + using var r1 = await client.GetAsync("/api/chn-scalar?a=1"); + r1.StatusCode.Should().Be(HttpStatusCode.OK); + (await r1.Content.ReadAsStringAsync()).Should().Be("scalar-result"); + + using var r2 = await client.GetAsync("/api/chn-scalar?a=1"); + r2.StatusCode.Should().Be(HttpStatusCode.OK); + (await r2.Content.ReadAsStringAsync()).Should().Be("scalar-result"); + + using var count = await client.GetAsync("/api/chn-scalar-count"); + (await count.Content.ReadAsStringAsync()).Should().Be("1"); // second call was a cache hit, not a re-run + } + + [Fact] + public async Task Nullable_array_param_null_does_not_throw_and_caches() + { + using var client = test.CreateClient(); + + // The original reproducer: `expIds` absent -> null array element of the cache key. + using var r1 = await client.GetAsync("/api/chn-array?thingIds=43"); + r1.StatusCode.Should().Be(HttpStatusCode.OK); + (await r1.Content.ReadAsStringAsync()).Should().Be("array-result"); + + using var r2 = await client.GetAsync("/api/chn-array?thingIds=43"); + r2.StatusCode.Should().Be(HttpStatusCode.OK); + (await r2.Content.ReadAsStringAsync()).Should().Be("array-result"); + + using var count = await client.GetAsync("/api/chn-array-count"); + (await count.Content.ReadAsStringAsync()).Should().Be("1"); + } +} diff --git a/NpgsqlRestTests/Setup/CacheHybridNullTestFixture.cs b/NpgsqlRestTests/Setup/CacheHybridNullTestFixture.cs new file mode 100644 index 00000000..26e44589 --- /dev/null +++ b/NpgsqlRestTests/Setup/CacheHybridNullTestFixture.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NpgsqlRestClient; + +namespace NpgsqlRestTests.Setup; + +[CollectionDefinition("CacheHybridNullFixture")] +public class CacheHybridNullFixtureCollection : ICollectionFixture { } + +/// +/// Fixture for the HybridCache null-parameter cache-key fix. Wires the real +/// as the routine cache backend (HybridCache rejected keys containing the old \x00 null marker with +/// "Cache key contains invalid content", silently bypassing the cache). Functions live in the shared +/// public schema under the chn_ prefix so only they are mapped. +/// +public class CacheHybridNullTestFixture : IDisposable +{ + private readonly WebApplication _app; + + public string ServerAddress { get; } + + public CacheHybridNullTestFixture() + { + var connectionString = Database.Create(); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Logging.ClearProviders(); +#pragma warning disable EXTEXP0018 // HybridCache is experimental + builder.Services.AddHybridCache(); +#pragma warning restore EXTEXP0018 + _app = builder.Build(); + + var hybridCache = _app.Services.GetRequiredService(); + + _app.UseNpgsqlRest(new(connectionString) + { + IncludeSchemas = ["public"], + NameSimilarTo = "chn_%", + CommentsMode = CommentsMode.ParseAll, + RequiresAuthorization = false, + CacheOptions = new() + { + DefaultRoutineCache = new HybridCacheWrapper(hybridCache), + } + }); + + _app.StartAsync().GetAwaiter().GetResult(); + ServerAddress = _app.Urls.First(); + } + + public HttpClient CreateClient() + => new() { BaseAddress = new Uri(ServerAddress), Timeout = TimeSpan.FromMinutes(5) }; + +#pragma warning disable CA1816 + public void Dispose() +#pragma warning restore CA1816 + { + _app.StopAsync().GetAwaiter().GetResult(); + _app.DisposeAsync().GetAwaiter().GetResult(); + } +} diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index e4f6b293..2e24d718 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -64,6 +64,12 @@ The public properties **`RoutineEndpoint.OpenApiHide` and `RoutineEndpoint.OpenA ## Fixes +### HybridCache `Cache key contains invalid content` on nullable cached params + +When `CacheOptions.Type` was `Hybrid` and a cached routine had a nullable parameter, every call where that parameter was null logged `Microsoft.Extensions.Caching.Hybrid: Cache key contains invalid content` and silently bypassed the cache — the endpoint still ran against the DB and returned correct data, but lost the cache hit and stampede protection for that key. Root cause: NpgsqlRest's internal cache-key encoding used a null byte (`\x00`) in its null marker, which HybridCache rejects. + +`HybridCacheWrapper` now hashes every key into a SHA-256 hex string before passing it to HybridCache, so keys are valid regardless of source content; the null marker source-side also no longer uses `\x00` (it is delimited by the existing `\x1F` separator), which is friendlier to Redis keys and log collectors across all backends. The `UseHashedCacheKeys` / `HashKeyThreshold` options keep their original purpose (Redis-backend key length / memory) and are simply a no-op for the Hybrid backend now. **No user action required** — Hybrid in-memory entries are flushed on restart. + ### JSON command parameters accept `json`, `jsonb`, or `text` JSON payloads passed to user-authored SQL commands were bound with a hardcoded `json` type, so a function declaring the receiving parameter as `jsonb` or `text` failed at runtime with PostgreSQL `42883` *"function does not exist"* — even though the documentation states all three are acceptable. From eda94ebb475da54fd9dd0d369a2bd66891600135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Tue, 9 Jun 2026 15:15:10 +0200 Subject: [PATCH 26/38] =?UTF-8?q?feat:=20v3.17.0=20=E2=80=94=20MCP=20@mcp?= =?UTF-8?q?=5Fdescription=20annotation=20+=20explicit=20description=20prec?= =?UTF-8?q?edence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an explicit, authoritative description annotation for MCP tools and make the description rules unambiguous about what is a description vs a regular comment. - @mcp_description (alias @mcp_desc): explicit description; opts the routine in on its own. McpToolInfo gains InlineText (inline @mcp ) distinct from Description (explicit @mcp_description). - Description precedence (highest-priority source present wins, regardless of the order lines appear; an explicit description SUPPRESSES the comment-prose fallback so unrelated comment lines never leak in): @mcp_description > inline @mcp > comment prose > routine name. This replaces the previous inline+prose combine behavior (MCP is unreleased). Tests: inline text now recorded as InlineText; the old combine test asserts inline suppresses prose; new tool_explicit_desc proves @mcp_description wins over both inline text and prose. Full suite green. --- .../McpTests/McpPluginAnnotationTests.cs | 27 +++++++--- changelog/v3.17.0.md | 5 +- plugins/NpgsqlRest.Mcp/Mcp.cs | 52 ++++++++++++++----- plugins/NpgsqlRest.Mcp/McpToolInfo.cs | 8 ++- 4 files changed, 68 insertions(+), 24 deletions(-) diff --git a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs index 8fa3b9d3..02d43440 100644 --- a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs +++ b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs @@ -86,13 +86,20 @@ HTTP GET @mcp Admin-only data. @authorize admin'; --- inline `@mcp ` AND comment prose -> description combines both (inline as the lead line) +-- inline `@mcp ` is explicit -> it is the description and SUPPRESSES the comment prose below it create function mcp.tool_inline_and_prose() returns text language sql as 'select ''ip'''; comment on function mcp.tool_inline_and_prose() is ' HTTP GET @mcp Lead description. -More detail line one. -More detail line two.'; +This prose is ignored because an explicit description is present.'; + +-- `@mcp_description` is authoritative: it wins over inline `@mcp ` and suppresses the prose +create function mcp.tool_explicit_desc() returns text language sql as 'select ''ed'''; +comment on function mcp.tool_explicit_desc() is ' +HTTP GET +@mcp inline text that must be ignored +@mcp_description The authoritative tool description. +This prose must also be ignored.'; -- composite-type parameter -> NpgsqlRest flattens it into typed scalar params (pX, pY) create type mcp.point as (x int, y int); @@ -130,11 +137,12 @@ public void Mcp_opt_in_records_metadata_keeps_http_and_leaves_prose() } [Fact] - public void Mcp_inline_text_is_the_description() + public void Mcp_inline_text_is_recorded_as_the_inline_description() { var info = Info(test.Endpoints["tool_inline_desc"])!; info.Enabled.Should().BeTrue(); - info.Description.Should().Be("Cancel a booking and release the room."); + info.InlineText.Should().Be("Cancel a booking and release the room."); + info.Description.Should().BeNull(); // no explicit @mcp_description } [Fact] @@ -222,9 +230,14 @@ public void Array_of_composite_parameter_renders_as_an_array_of_strings() """{"type":"object","properties":{"pts":{"type":"array","items":{"type":"string"}}},"required":["pts"]}"""); [Fact] - public void Inline_mcp_text_and_comment_prose_combine_into_the_description() + public void Inline_mcp_text_is_the_description_and_suppresses_comment_prose() => test.Tools["tool_inline_and_prose"]!.ToJsonString().Should().Be( - """{"name":"tool_inline_and_prose","description":"Lead description.\nMore detail line one.\nMore detail line two.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"value":{"type":["string","null"]}}}}"""); + """{"name":"tool_inline_and_prose","description":"Lead description.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"value":{"type":["string","null"]}}}}"""); + + [Fact] + public void Mcp_description_is_authoritative_over_inline_text_and_prose() + => test.Tools["tool_explicit_desc"]!.ToJsonString().Should().Be( + """{"name":"tool_explicit_desc","description":"The authoritative tool description.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true},"outputSchema":{"type":"object","properties":{"value":{"type":["string","null"]}}}}"""); [Fact] public void ToolDescriptionSuffix_is_appended_to_every_tool_description() diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index 2e24d718..60cda4ad 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -15,10 +15,13 @@ NpgsqlRest can now expose opted-in PostgreSQL routines as **MCP tools**, so an A **Opt-in, never automatic.** A routine becomes a tool only when its PostgreSQL comment carries the `mcp` annotation: - `mcp` — expose as a tool; description derived from the comment prose. -- `mcp ` — expose, with `` as the description. +- `mcp ` — expose, with `` as an inline (explicit) description. +- `mcp_description ` (alias `mcp_desc`) — explicit, authoritative description. - `mcp_name ` — override the tool name (default: the routine name). - `mcp` + `internal` — expose as an MCP tool with **no** HTTP route. All other annotations (`authorize`, parameter handling, …) apply unchanged. + Description precedence — the highest-priority source that is present wins, **regardless of the order the lines appear** in the comment; an explicit description **suppresses** the comment-prose fallback, so unrelated comment lines never leak in: `mcp_description` › inline `mcp ` › comment prose › routine name. + **The endpoint.** A single Streamable-HTTP JSON-RPC endpoint (default `/mcp`, POST only). It implements the lifecycle (`initialize` → protocol version + `tools` capability + `serverInfo`; `notifications/initialized` → `202`; `ping`), `tools/list` (with a JSON-Schema `inputSchema` per tool, derived from the routine's parameters), and `tools/call`. Transport rules per spec: the `Origin` header is validated (DNS-rebinding protection — a present, untrusted origin → `403`); a present `MCP-Protocol-Version` other than `2025-11-25` → `400`; `GET` → `405` (no SSE). **Calling a tool.** `tools/call` runs the routine through the same pipeline as the HTTP endpoint, forwarding the authenticated principal so `authorize` checks apply. Arguments map to the routine as a query string (GET/DELETE), a JSON body (POST/PUT), or path-segment substitution. The result carries: diff --git a/plugins/NpgsqlRest.Mcp/Mcp.cs b/plugins/NpgsqlRest.Mcp/Mcp.cs index 2f7bcf94..99f88bc2 100644 --- a/plugins/NpgsqlRest.Mcp/Mcp.cs +++ b/plugins/NpgsqlRest.Mcp/Mcp.cs @@ -15,9 +15,14 @@ namespace NpgsqlRest.Mcp; /// added in later increments. /// /// Annotations: -/// mcp opt this routine in as an MCP tool; description = comment prose (derived) -/// mcp <text> opt in; <text> is the tool description (overrides derived prose) -/// mcp_name name explicit tool name (otherwise derived from the routine name) +/// mcp opt this routine in as an MCP tool; description = comment prose (derived) +/// mcp <text> opt in; <text> is an inline (explicit) tool description +/// mcp_description <text> explicit, authoritative description (alias: mcp_desc) +/// mcp_name name explicit tool name (otherwise derived from the routine name) +/// +/// Description precedence — highest-priority source present wins, regardless of the order the lines appear; +/// an explicit description suppresses the comment-prose fallback, so unrelated comment lines never leak in: +/// mcp_description > inline mcp <text> > comment prose > routine name. /// /// MCP-only (tool exposed, no public HTTP route) is composed with the core `internal` annotation: /// `mcp` + `internal`. (With , `mcp` alone creates the @@ -63,8 +68,10 @@ public partial class Mcp(McpOptions options) : IEndpointCreateHandler return new CommentLineResult(string.Concat("mcp_name ", info.ToolName), RequestsEndpoint: true); } - // mcp | mcp — text (rest of line, case preserved) becomes the tool description. - if (CommentPrimitives.StrEquals(key, "mcp")) + // mcp_description | mcp_desc — explicit, authoritative description (rest of line, + // case preserved). Wins over inline `mcp ` and suppresses the comment-prose fallback, so + // unrelated comment lines never leak into the description. + if (CommentPrimitives.StrEquals(key, "mcp_description") || CommentPrimitives.StrEquals(key, "mcp_desc")) { var info = GetOrAdd(endpoint); info.Enabled = true; @@ -72,6 +79,19 @@ public partial class Mcp(McpOptions options) : IEndpointCreateHandler if (!string.IsNullOrWhiteSpace(text)) { info.Description = text; + } + return new CommentLineResult(string.Concat("mcp_description: ", text), RequestsEndpoint: true); + } + + // mcp | mcp — opt in; (rest of line, case preserved) is an inline description. + if (CommentPrimitives.StrEquals(key, "mcp")) + { + var info = GetOrAdd(endpoint); + info.Enabled = true; + var text = RemainderAfterFirstWord(line); + if (!string.IsNullOrWhiteSpace(text)) + { + info.InlineText = text; return new CommentLineResult(string.Concat("mcp: ", text), RequestsEndpoint: true); } return new CommentLineResult("mcp", RequestsEndpoint: true); @@ -139,18 +159,22 @@ private JsonObject BuildTool(RoutineEndpoint endpoint, McpToolInfo info) var routine = endpoint.Routine; var name = string.IsNullOrWhiteSpace(info.ToolName) ? routine.Name : info.ToolName!; - // Description = inline `@mcp ` (if any) followed by the comment prose (UnhandledCommentLines). - // Either alone is used as-is; both are combined with the inline text as the lead line. Falling back - // to the routine name only when there is neither. - var inline = string.IsNullOrWhiteSpace(info.Description) ? null : info.Description; - var prose = DeriveDescription(endpoint); - var description = inline is not null && prose is not null - ? string.Concat(inline, "\n", prose) - : inline ?? prose; + // Description precedence — highest-priority source present wins, regardless of the order the lines + // appear in the comment; an explicit description suppresses the prose fallback, so unrelated comment + // lines never leak in: + // 1. `@mcp_description ` — explicit, authoritative (info.Description) + // 2. inline `@mcp ` — explicit (info.InlineText) + // 3. comment prose (UnhandledCommentLines) — zero-annotation fallback + // 4. the routine name — last resort (with a warning) + var explicitText = + !string.IsNullOrWhiteSpace(info.Description) ? info.Description : + !string.IsNullOrWhiteSpace(info.InlineText) ? info.InlineText : + null; + var description = explicitText ?? DeriveDescription(endpoint); if (string.IsNullOrWhiteSpace(description)) { description = routine.Name; - Logger?.LogWarning("MCP tool '{Name}' has no description — provide `mcp ` or comment prose so agents call it well.", name); + Logger?.LogWarning("MCP tool '{Name}' has no description — provide `mcp `, `mcp_description `, or comment prose so agents call it well.", name); } // Optional shared suffix injected into every tool description (McpOptions.ToolDescriptionSuffix). if (!string.IsNullOrWhiteSpace(_options.ToolDescriptionSuffix)) diff --git a/plugins/NpgsqlRest.Mcp/McpToolInfo.cs b/plugins/NpgsqlRest.Mcp/McpToolInfo.cs index 7013e257..fe828df8 100644 --- a/plugins/NpgsqlRest.Mcp/McpToolInfo.cs +++ b/plugins/NpgsqlRest.Mcp/McpToolInfo.cs @@ -14,7 +14,11 @@ public sealed class McpToolInfo /// Explicit tool name from mcp_name; null = derive from the routine name. public string? ToolName { get; set; } - /// Explicit tool description from mcp_description / mcp_desc; null = derive - /// from the comment prose (). + /// Explicit, authoritative tool description from mcp_description / mcp_desc. + /// When set it wins over everything and suppresses the comment-prose fallback. public string? Description { get; set; } + + /// Inline description from mcp <text>. Used when is not + /// set; like it, an inline description is explicit and suppresses the comment-prose fallback. + public string? InlineText { get; set; } } From 7e1fddc856fb7d0a2653fdc23b4c615f5074b7d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Wed, 10 Jun 2026 10:56:07 +0200 Subject: [PATCH 27/38] fix: bare @cached used only the routine name as the cache key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @cached without an explicit parameter list is documented to key on all routine parameters, but CachedParams was only populated when a list was given. Left null, the 8 cache-key append blocks in NpgsqlRestEndpoint.cs (all guarded by CachedParams is not null) were skipped, so the key was just the routine identifier — every call returned the first cached response regardless of input until the TTL expired. Affected all backends (Memory/Redis/Hybrid); explicit parameter lists were unaffected. Fix (in HandleCached): when no list is given, set CachedParams to the full parameter set ([.. routine.OriginalParamsHash]); the downstream append blocks then key on every parameter without changes. Three regression tests: bare @cached gives distinct entries per value, bare @cached on a paramless routine shares one entry, explicit @cached _x keys only _x. Verified the distinct-values test fails with the fix reverted. Full suite green. --- .../Defaults/CommentParsers/CachedHandler.cs | 8 ++ .../RoutineCacheTests/CacheAllParamsTests.cs | 107 ++++++++++++++++++ changelog/v3.17.0.md | 4 + 3 files changed, 119 insertions(+) create mode 100644 NpgsqlRestTests/RoutineCacheTests/CacheAllParamsTests.cs diff --git a/NpgsqlRest/Defaults/CommentParsers/CachedHandler.cs b/NpgsqlRest/Defaults/CommentParsers/CachedHandler.cs index 674d3f7a..cdd3af04 100644 --- a/NpgsqlRest/Defaults/CommentParsers/CachedHandler.cs +++ b/NpgsqlRest/Defaults/CommentParsers/CachedHandler.cs @@ -41,6 +41,14 @@ private static void HandleCached( } endpoint.CachedParams = result; } + else + { + // Bare `cached` (no parameter list) keys on EVERY routine parameter, as documented. Materialize + // the full set here: the cache-key builder in NpgsqlRestEndpoint.cs only appends a parameter's + // value when CachedParams contains it, so a null set would key on the routine identifier alone + // and serve one entry to every call regardless of input. + endpoint.CachedParams = [.. routine.OriginalParamsHash]; + } CommentLogger?.CommentCached(description, endpoint.CachedParams ?? []); } diff --git a/NpgsqlRestTests/RoutineCacheTests/CacheAllParamsTests.cs b/NpgsqlRestTests/RoutineCacheTests/CacheAllParamsTests.cs new file mode 100644 index 00000000..551db82f --- /dev/null +++ b/NpgsqlRestTests/RoutineCacheTests/CacheAllParamsTests.cs @@ -0,0 +1,107 @@ +namespace NpgsqlRestTests; + +public static partial class Database +{ + // Regression coverage for bare `@cached` (no explicit parameter list). It is documented to key on ALL + // routine parameters; the bug was that it keyed on the routine identifier only, so every call shared one + // entry. Each function records one row per actual execution so a test can prove cache hits vs misses. + public static void CacheAllParamsTests() + { + script.Append(@" +create table cache_allparams_calls (id int generated always as identity primary key, label text not null); + +-- bare `cached` over a routine WITH a param: distinct values must produce distinct cache entries. +create function cache_allparams_search(_p text default null) returns text language sql as $$ + insert into cache_allparams_calls (label) values ('search:' || coalesce(_p, '')); + select 'r:' || coalesce(_p, ''); +$$; +comment on function cache_allparams_search(text) is 'HTTP GET +cached'; + +-- bare `cached` over a routine with NO params: a single shared entry (one execution across calls). +create function cache_allparams_noparam() returns text language sql as $$ + insert into cache_allparams_calls (label) values ('noparam'); + select 'noparam-result'; +$$; +comment on function cache_allparams_noparam() is 'HTTP GET +cached'; + +-- explicit `cached _x`: only `_x` keys the cache; `_y` does not. Verifies the fix didn't break the list path. +create function cache_allparams_explicit(_x text default null, _y text default null) returns text language sql as $$ + insert into cache_allparams_calls (label) values ('explicit:' || coalesce(_x, '') || ':' || coalesce(_y, '')); + select 'r'; +$$; +comment on function cache_allparams_explicit(text, text) is 'HTTP GET +cached _x'; +"); + } +} + +[Collection("TestFixture")] +public class CacheAllParamsTests(TestFixture test) +{ + private static async Task CountAsync(string label) + { + await using var conn = Database.CreateConnection(); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "select count(*) from cache_allparams_calls where label = $1"; + var p = cmd.CreateParameter(); + p.Value = label; + cmd.Parameters.Add(p); + return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + } + + [Fact] + public async Task Bare_cached_keys_on_all_params_so_distinct_values_get_distinct_entries() + { + // First call for p=a executes and caches under a key that includes p. + using (var a1 = await test.Client.GetAsync("/api/cache-allparams-search/?p=a")) + { + a1.StatusCode.Should().Be(HttpStatusCode.OK); + (await a1.Content.ReadAsStringAsync()).Should().Be("r:a"); + } + // Same value again → served from cache, no second execution. + using (var a2 = await test.Client.GetAsync("/api/cache-allparams-search/?p=a")) + (await a2.Content.ReadAsStringAsync()).Should().Be("r:a"); + + // Different value → distinct cache entry → its own execution and its own correct response. + // (Pre-fix this returned "r:a" from the single routine-keyed entry.) + using (var b1 = await test.Client.GetAsync("/api/cache-allparams-search/?p=b")) + { + b1.StatusCode.Should().Be(HttpStatusCode.OK); + (await b1.Content.ReadAsStringAsync()).Should().Be("r:b"); + } + + (await CountAsync("search:a")).Should().Be(1, "two p=a calls must collapse to one execution"); + (await CountAsync("search:b")).Should().Be(1, "p=b is a distinct key and must execute on its own"); + } + + [Fact] + public async Task Bare_cached_with_no_params_uses_a_single_shared_entry() + { + using (var r1 = await test.Client.GetAsync("/api/cache-allparams-noparam/")) + (await r1.Content.ReadAsStringAsync()).Should().Be("noparam-result"); + using (var r2 = await test.Client.GetAsync("/api/cache-allparams-noparam/")) + (await r2.Content.ReadAsStringAsync()).Should().Be("noparam-result"); + + (await CountAsync("noparam")).Should().Be(1, "a paramless cached routine has exactly one cache entry"); + } + + [Fact] + public async Task Explicit_cached_list_keys_only_the_listed_params() + { + // `cached _x` → only _x is in the key. x=a,y=1 then x=a,y=2 share the entry (y is ignored). + using (var r1 = await test.Client.GetAsync("/api/cache-allparams-explicit/?x=a&y=1")) + r1.StatusCode.Should().Be(HttpStatusCode.OK); + using (var r2 = await test.Client.GetAsync("/api/cache-allparams-explicit/?x=a&y=2")) + r2.StatusCode.Should().Be(HttpStatusCode.OK); + // Different x → distinct key → executes. + using (var r3 = await test.Client.GetAsync("/api/cache-allparams-explicit/?x=b&y=1")) + r3.StatusCode.Should().Be(HttpStatusCode.OK); + + (await CountAsync("explicit:a:1")).Should().Be(1, "x=a executed once; the x=a,y=2 call is a cache hit (y not keyed)"); + (await CountAsync("explicit:a:2")).Should().Be(0, "x=a,y=2 must hit the x=a entry, not execute"); + (await CountAsync("explicit:b:1")).Should().Be(1, "x=b is a distinct key and must execute"); + } +} diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index 60cda4ad..ee8a08a2 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -67,6 +67,10 @@ The public properties **`RoutineEndpoint.OpenApiHide` and `RoutineEndpoint.OpenA ## Fixes +### Bare `@cached` (no parameter list) used only the routine name as the cache key + +`@cached` without an explicit parameter list is documented to key on **all** routine parameters, but the implementation left the cache-key parameter set empty — so the key was just the routine identifier, and **every call returned the first response cached for that routine regardless of inputs** until the TTL expired (a search/filter endpoint would serve the first query's results to every subsequent query). Endpoints that listed parameters explicitly (`@cached p1, p2`) were unaffected. All cache backends (`Memory`, `Redis`, `Hybrid`) were affected. Fixed by treating "no list" as "every parameter" at annotation-parse time. + ### HybridCache `Cache key contains invalid content` on nullable cached params When `CacheOptions.Type` was `Hybrid` and a cached routine had a nullable parameter, every call where that parameter was null logged `Microsoft.Extensions.Caching.Hybrid: Cache key contains invalid content` and silently bypassed the cache — the endpoint still ran against the DB and returned correct data, but lost the cache hit and stampede protection for that key. Root cause: NpgsqlRest's internal cache-key encoding used a null byte (`\x00`) in its null marker, which HybridCache rejects. From 5359cc201916c2cfd9ed96ab67e3c2421941a340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Wed, 10 Jun 2026 11:53:25 +0200 Subject: [PATCH 28/38] =?UTF-8?q?feat:=20{name}=20annotation=20substitutio?= =?UTF-8?q?n=20=E2=80=94=20case-insensitive,=20typo=20warning,=20env-var?= =?UTF-8?q?=20allowlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improves the {name} parameter-value substitution used in response headers, custom parameters, and HTTP custom type URL/headers/body: - Case-insensitive matching. The substitution lookup is now built case-insensitively (OrdinalIgnoreCase), so {userId}/{USERID}/{userid} resolve the same parameter - consistent with the resolved-parameter SQL expression resolver and PostgreSQL identifier folding. - Unknown-placeholder warning. A {name} that looks like an identifier but matches no parameter is left literal at request time (unchanged) and now also logs a build-time warning, so typos like {_fil} surface at startup. Covers response headers and custom parameters; non-identifier braces ({0}, JSON {"a":1}) are never treated as placeholders, so HTTP-type JSON bodies don't false-positive. - Allowlisted environment variables (new). {name} can now resolve an allowlisted env var (NpgsqlRestOptions.SubstitutionEnvironmentVariables; client config NpgsqlRest:AvailableEnvVars - array or name->default), alongside parameters. Only listed names are read from the environment (the allowlist is the security boundary), resolved once at startup, raw values, case-insensitive. A routine parameter of the same name takes precedence. Allowlisted names are treated as known by the typo warning. Use for outbound API keys / per-pod server names without routing them through request parameters; values used in response headers are client-visible, so reserve secrets for outbound HTTP-type calls. Tests: case-insensitive header resolution, typo warning, non-identifier no-warn, env-var resolves in a response header (case-insensitively), parameter-wins-on-collision, allowlisted-env-var no-warn. 4-file config consistency + CLI round-trip green; AOT-clean. Full suite green. --- .../PlaceholderValidationHandler.cs | 81 +++++++++++++ NpgsqlRest/Defaults/DefaultCommentParser.cs | 2 + NpgsqlRest/Log.cs | 3 + NpgsqlRest/NpgsqlRestEndpoint.cs | 14 ++- NpgsqlRest/Options/NpgsqlRestOptions.cs | 12 ++ NpgsqlRestClient/Builder.cs | 23 ++++ NpgsqlRestClient/ConfigDefaults.cs | 1 + NpgsqlRestClient/ConfigSchemaGenerator.cs | 1 + NpgsqlRestClient/ConfigTemplate.cs | 4 + NpgsqlRestClient/Program.cs | 1 + NpgsqlRestClient/appsettings.json | 4 + .../PlaceholderSubstitutionTests.cs | 112 ++++++++++++++++++ .../PlaceholderSubstitutionTestFixture.cs | 65 ++++++++++ changelog/v3.17.0.md | 20 ++++ 14 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 NpgsqlRest/Defaults/CommentParsers/PlaceholderValidationHandler.cs create mode 100644 NpgsqlRestTests/CommentTests/PlaceholderSubstitutionTests.cs create mode 100644 NpgsqlRestTests/Setup/PlaceholderSubstitutionTestFixture.cs diff --git a/NpgsqlRest/Defaults/CommentParsers/PlaceholderValidationHandler.cs b/NpgsqlRest/Defaults/CommentParsers/PlaceholderValidationHandler.cs new file mode 100644 index 00000000..3275a64e --- /dev/null +++ b/NpgsqlRest/Defaults/CommentParsers/PlaceholderValidationHandler.cs @@ -0,0 +1,81 @@ +namespace NpgsqlRest.Defaults; + +internal static partial class DefaultCommentParser +{ + /// + /// Build-time validation for `{name}` parameter-value placeholders (response headers, custom parameters). + /// At request time an unknown placeholder is left as literal text, so a typo silently ships e.g. + /// `{_fil}` into a header/path. This warns when a value contains a placeholder that LOOKS like an + /// identifier but matches no routine parameter (converted or original name, or a custom type name). + /// Non-identifier braces (e.g. JSON `{"a":1}`) are ignored to avoid false positives. + /// + private static void WarnUnknownPlaceholders(Routine routine, string value, string description) + { + if (string.IsNullOrEmpty(value) || value.IndexOf(Consts.OpenBrace) < 0) + { + return; + } + + var span = value.AsSpan(); + var pos = 0; + while (pos < span.Length) + { + var open = span[pos..].IndexOf(Consts.OpenBrace); + if (open < 0) + { + break; + } + open += pos; + var rel = span[(open + 1)..].IndexOf(Consts.CloseBrace); + if (rel < 0) + { + break; + } + var close = open + 1 + rel; + var token = span[(open + 1)..close]; + pos = close + 1; + + // Only flag tokens that look like a parameter name; skip literal/JSON braces. + if (IsPlaceholderIdentifier(token) && !IsKnownParameter(routine, token)) + { + Logger?.CommentUnknownPlaceholder(description, token.ToString()); + } + } + } + + private static bool IsPlaceholderIdentifier(ReadOnlySpan token) + { + if (token.Length == 0 || !(char.IsAsciiLetter(token[0]) || token[0] == '_')) + { + return false; + } + for (var i = 1; i < token.Length; i++) + { + if (!(char.IsAsciiLetterOrDigit(token[i]) || token[i] == '_')) + { + return false; + } + } + return true; + } + + // Matches the exact keys the request-time lookup is built from (ActualName, ConvertedName, + // CustomTypeName, and allowlisted env-var names), case-insensitively — consistent with the + // case-insensitive substitution. + private static bool IsKnownParameter(Routine routine, ReadOnlySpan token) + { + foreach (var p in routine.Parameters) + { + if (token.Equals(p.ActualName, StringComparison.OrdinalIgnoreCase) || + token.Equals(p.ConvertedName, StringComparison.OrdinalIgnoreCase) || + (p.TypeDescriptor.CustomTypeName is not null && + token.Equals(p.TypeDescriptor.CustomTypeName, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + // Allowlisted env vars are also valid placeholders (the dict is case-insensitive). + return Options.SubstitutionEnvironmentVariables is { Count: > 0 } envVars + && envVars.ContainsKey(token.ToString()); + } +} diff --git a/NpgsqlRest/Defaults/DefaultCommentParser.cs b/NpgsqlRest/Defaults/DefaultCommentParser.cs index 84faee20..43338744 100644 --- a/NpgsqlRest/Defaults/DefaultCommentParser.cs +++ b/NpgsqlRest/Defaults/DefaultCommentParser.cs @@ -82,6 +82,7 @@ internal static partial class DefaultCommentParser else { HandleCustomParameter(routineEndpoint, customParamName, customParamValue, description); + WarnUnknownPlaceholders(routine, customParamValue, description); } TrackAnnotation(line); } @@ -91,6 +92,7 @@ internal static partial class DefaultCommentParser else if (haveTag is true && SplitBySeparatorChar(line, Consts.Colon, out var headerName, out var headerValue)) { HandleHeader(routineEndpoint, headerName, headerValue, description); + WarnUnknownPlaceholders(routine, headerValue, description); TrackAnnotation(line); } diff --git a/NpgsqlRest/Log.cs b/NpgsqlRest/Log.cs index cf1ae6d9..764be303 100644 --- a/NpgsqlRest/Log.cs +++ b/NpgsqlRest/Log.cs @@ -125,6 +125,9 @@ public static partial class Log [LoggerMessage(Level = LogLevel.Warning, Message = "{description} has set CACHED PARAMETER NAME to {param} by the comment annotation, but that parameter doesn't exists on this routine either converted or original. This cache parameter will be ignored.")] public static partial void CommentInvalidCacheParam(this ILogger logger, string description, string param); + [LoggerMessage(Level = LogLevel.Warning, Message = "{description} uses a parameter placeholder '{placeholder}' in an annotation value, but no routine parameter matches it (checked converted and original names). The placeholder will be left as literal text — check for a typo.")] + public static partial void CommentUnknownPlaceholder(this ILogger logger, string description, string placeholder); + [LoggerMessage(Level = LogLevel.Trace, Message = "{description} has set CACHED with parameters {cachedParams} by the comment annotation.")] public static partial void CommentCached(this ILogger logger, string description, IEnumerable cachedParams); diff --git a/NpgsqlRest/NpgsqlRestEndpoint.cs b/NpgsqlRest/NpgsqlRestEndpoint.cs index 7238d3b4..2ed6ffa7 100644 --- a/NpgsqlRest/NpgsqlRestEndpoint.cs +++ b/NpgsqlRest/NpgsqlRestEndpoint.cs @@ -1621,7 +1621,19 @@ await Results.Problem( Dictionary.AlternateLookup>? lookup = null; if (endpoint.HeadersNeedParsing is true || endpoint.CustomParamsNeedParsing || HttpClientTypes.NeedsParsing) { - Dictionary replacements = new(command.Parameters.Count * 2); + // Case-insensitive so `{name}` placeholders match parameter names regardless of casing, + // consistent with the resolved-parameter SQL expression resolver (Formatter.ParameterizeSqlExpression). + var envVars = Options.SubstitutionEnvironmentVariables; + Dictionary replacements = new(command.Parameters.Count * 2 + (envVars?.Count ?? 0), StringComparer.OrdinalIgnoreCase); + // Allowlisted env vars first (base); parameter values added below overwrite them on a name + // collision, so a routine parameter always wins over an env var of the same name. + if (envVars is not null) + { + foreach (var (name, value) in envVars) + { + replacements[name] = value; + } + } for (var i = 0; i < command.Parameters.Count; i++) { var value = command.Parameters[i].Value == DBNull.Value ? "" : command.Parameters[i].Value?.ToString() ?? ""; diff --git a/NpgsqlRest/Options/NpgsqlRestOptions.cs b/NpgsqlRest/Options/NpgsqlRestOptions.cs index 4c98d280..719b1807 100644 --- a/NpgsqlRest/Options/NpgsqlRestOptions.cs +++ b/NpgsqlRest/Options/NpgsqlRestOptions.cs @@ -349,6 +349,18 @@ public NpgsqlRestOptions(NpgsqlDataSource dataSource) /// public Dictionary CustomRequestHeaders { get; set; } = []; + /// + /// Resolved environment-variable values (name → value) made available to `{name}` placeholder + /// substitution in annotation values (response headers, custom parameters, HTTP custom type calls), + /// alongside the routine's parameters. This is an allowlist: only names present here can be referenced; + /// any other `{name}` is not resolved from the environment. Names are matched case-insensitively, and a + /// routine parameter of the same name takes precedence. Null/empty disables env-var substitution. + /// (The standalone client populates this from the `NpgsqlRest:AvailableEnvVars` config, reading the + /// process environment once at startup.) Note: a value used in a response header is sent to the client, + /// so reserve this for non-secret values (e.g. server/environment name) when used in responses. + /// + public Dictionary? SubstitutionEnvironmentVariables { get; set; } = null; + /// /// Endpoint sources list. Includes routine sources (functions, procedures), CRUD sources (tables, views), /// and any other sources like SQL file source. Default contains a single RoutineSource. diff --git a/NpgsqlRestClient/Builder.cs b/NpgsqlRestClient/Builder.cs index 710becdd..36d4dae2 100644 --- a/NpgsqlRestClient/Builder.cs +++ b/NpgsqlRestClient/Builder.cs @@ -1860,6 +1860,29 @@ public Dictionary GetCustomHeaders() return result; } + /// + /// Resolves the `NpgsqlRest:AvailableEnvVars` allowlist into name → value pairs for `{name}` annotation + /// substitution. Reads the process environment once, here, at startup. Array form lists names (missing + /// env var → empty string); object form maps name → default (env var wins → default → empty). Returns + /// null when none are configured. Case-insensitive (matches the substitution lookup). + /// + public Dictionary? GetSubstitutionEnvVars() + { + var names = _config.GetConfigNameDefaults("AvailableEnvVars", _config.NpgsqlRestCfg); + if (names is null || names.Count == 0) + { + return null; + } + var result = new Dictionary(names.Count, StringComparer.OrdinalIgnoreCase); + foreach (var (name, def) in names) + { + // present env var wins → configured default → empty string. Raw value (not JSON-escaped): + // these are injected into headers / URLs / bodies, not into HTML/JS content. + result[name] = Environment.GetEnvironmentVariable(name) ?? def ?? string.Empty; + } + return result; + } + public Dictionary GetSseResponseHeaders() { var result = new Dictionary(); diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index 0c3bda4f..d4f461df 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -801,6 +801,7 @@ private static JsonObject GetNpgsqlRestDefaults() ), ["InstanceIdRequestHeaderName"] = null, ["CustomRequestHeaders"] = new JsonObject(), + ["AvailableEnvVars"] = new JsonArray(), ["ExecutionIdHeaderName"] = "X-NpgsqlRest-ID", ["DefaultServerSentEventsEventNoticeLevel"] = "INFO", ["ServerSentEventsResponseHeaders"] = new JsonObject(), diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index 984ae59a..5a87d6db 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -337,6 +337,7 @@ public static partial class ConfigSchemaGenerator ["NpgsqlRest:BeforeRoutineCommands:Parameters:Name"] = "Source-dependent name. For `Claim`: the claim type. For `RequestHeader`: the header name. Ignored for `IpAddress`.", ["NpgsqlRest:InstanceIdRequestHeaderName"] = "Add the unique NpgsqlRest instance id request header with this name to the response or set to null to ignore.", ["NpgsqlRest:CustomRequestHeaders"] = "Custom request headers dictionary that will be added to NpgsqlRest requests. Note: these values are added to the request headers dictionary before they are sent as a context or parameter to the PostgreSQL routine and as such not visible to the browser debugger.", + ["NpgsqlRest:AvailableEnvVars"] = "Allowlist of environment variable names available to {name} placeholder substitution in comment annotation values (response headers, custom parameters, HTTP custom type calls), alongside the routine's parameters.\nResolved once at startup (a value change requires a restart). Array form lists names (a missing variable becomes the empty string); object form maps name->default where the default is used when the variable is absent.\nNames are matched case-insensitively, and a routine parameter of the same name takes precedence.\nSECURITY: a value used in a RESPONSE header is sent to the client - reserve secrets (API keys, tokens) for outbound HTTP custom type calls, and use response headers only for non-secret values (e.g. server/environment name).", ["NpgsqlRest:ExecutionIdHeaderName"] = "Name of the request ID header that will be used to track requests. This is used to correlate requests with server event streaming connection ids.", ["NpgsqlRest:DefaultServerSentEventsEventNoticeLevel"] = "Default server-sent event notice message level: INFO, NOTICE, WARNING.\nWhen SSE path is set, generate SSE events for PostgreSQL notice messages with this level or higher.\nThis can be overridden for individual endpoints using comment annotations.", ["NpgsqlRest:ServerSentEventsResponseHeaders"] = "Collection of custom server-sent events response headers that will be added to the response when connected to the endpoint that is configured to return server-sent events.", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index 3e1cf3c0..00e3be3c 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -2016,6 +2016,10 @@ public static partial class ConfigSchemaGenerator "CustomRequestHeaders": { }, // + // Allowlist of environment variable names available to {name} placeholder substitution in comment annotation values (response headers, custom parameters, HTTP custom type calls), alongside the routine's parameters. Resolved once at startup (a value change requires a restart). Array form lists names (a missing variable becomes the empty string); object form maps name -> default {"WEATHER_API_KEY":""} where the default is used when the variable is absent. Names are matched case-insensitively, and a routine parameter of the same name takes precedence. SECURITY: a value used in a RESPONSE header is sent to the client - reserve secrets (API keys, tokens) for outbound HTTP custom type calls, and use response headers only for non-secret values (e.g. server/environment name). + // + "AvailableEnvVars": [], + // // Name of the request ID header that will be used to track requests. This is used to correlate requests with server event streaming connection ids. // "ExecutionIdHeaderName": "X-NpgsqlRest-ID", diff --git a/NpgsqlRestClient/Program.cs b/NpgsqlRestClient/Program.cs index 82bd5f72..f4096532 100644 --- a/NpgsqlRestClient/Program.cs +++ b/NpgsqlRestClient/Program.cs @@ -465,6 +465,7 @@ AuthenticationOptions = authenticationOptions, EndpointCreateHandlers = appInstance.CreateCodeGenHandlers(connectionString, args), CustomRequestHeaders = builder.GetCustomHeaders(), + SubstitutionEnvironmentVariables = builder.GetSubstitutionEnvVars(), ExecutionIdHeaderName = config.GetConfigStr("ExecutionIdHeaderName", config.NpgsqlRestCfg) ?? "X-NpgsqlRest-ID", DefaultSseEventNoticeLevel = config.GetConfigEnum("DefaultServerSentEventsEventNoticeLevel", config.NpgsqlRestCfg) ?? PostgresNoticeLevels.INFO, SseResponseHeaders = builder.GetSseResponseHeaders(), diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index 3ff15617..f395abe4 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -2007,6 +2007,10 @@ "CustomRequestHeaders": { }, // + // Allowlist of environment variable names available to {name} placeholder substitution in comment annotation values (response headers, custom parameters, HTTP custom type calls), alongside the routine's parameters. Resolved once at startup (a value change requires a restart). Array form lists names (a missing variable becomes the empty string); object form maps name -> default {"WEATHER_API_KEY":""} where the default is used when the variable is absent. Names are matched case-insensitively, and a routine parameter of the same name takes precedence. SECURITY: a value used in a RESPONSE header is sent to the client - reserve secrets (API keys, tokens) for outbound HTTP custom type calls, and use response headers only for non-secret values (e.g. server/environment name). + // + "AvailableEnvVars": [], + // // Name of the request ID header that will be used to track requests. This is used to correlate requests with server event streaming connection ids. // "ExecutionIdHeaderName": "X-NpgsqlRest-ID", diff --git a/NpgsqlRestTests/CommentTests/PlaceholderSubstitutionTests.cs b/NpgsqlRestTests/CommentTests/PlaceholderSubstitutionTests.cs new file mode 100644 index 00000000..6d71923f --- /dev/null +++ b/NpgsqlRestTests/CommentTests/PlaceholderSubstitutionTests.cs @@ -0,0 +1,112 @@ +using Microsoft.Extensions.Logging; +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +public static partial class Database +{ + // `{name}` parameter-value substitution in annotation values. Functions in the shared `public` schema, + // `phsub_` prefix (the PlaceholderSubstitutionTestFixture maps only `phsub_%`). + public static void PlaceholderSubstitutionTests() + { + script.Append(@" +-- Case-insensitive matching: placeholder {_FILE} (upper) resolves param _file. (#1) +create function phsub_casing(_file text) returns text language sql as 'select ''ok'''; +comment on function phsub_casing(text) is ' +HTTP GET +X-Filename: {_FILE}'; + +-- Unknown placeholder {_fil} (typo of _file) -> build-time warning. (#2) +create function phsub_typo(_file text) returns text language sql as 'select ''ok'''; +comment on function phsub_typo(text) is ' +HTTP GET +Content-Disposition: attachment; filename={_fil}'; + +-- Non-identifier braces ({0}) must NOT warn (heuristic skips literal/JSON-like braces). (#2) +create function phsub_nonident(_x int) returns text language sql as 'select ''ok'''; +comment on function phsub_nonident(int) is ' +HTTP GET +Cache-Control: max-age={0}'; + +-- env var resolves into a response header (per-pod server name); case-insensitive form too. (#3 env) +create function phsub_env(_x int) returns text language sql as 'select ''ok'''; +comment on function phsub_env(int) is ' +HTTP GET +X-Server: {SERVER_NAME} +X-Server-Lc: {server_name}'; + +-- name collision: a routine parameter wins over an allowlisted env var of the same name. (#3 precedence) +create function phsub_collision(_region text) returns text language sql as 'select ''ok'''; +comment on function phsub_collision(text) is ' +HTTP GET +X-Region: {region}'; +"); + } +} + +[Collection("PlaceholderSubstitutionFixture")] +public class PlaceholderSubstitutionTests(PlaceholderSubstitutionTestFixture test) +{ + // #1 — case-insensitive matching + [Fact] + public async Task Placeholder_matching_is_case_insensitive() + { + using var client = test.CreateClient(); + using var response = await client.GetAsync("/api/phsub-casing/?file=q1.csv"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + // {_FILE} (uppercase) resolved the _file parameter; pre-fix this stayed the literal "{_FILE}". + response.Headers.GetValues("X-Filename").Single().Should().Be("q1.csv"); + } + + // #2 — unknown placeholder warns at build time + [Fact] + public void Unknown_placeholder_logs_a_build_time_warning() + { + var warning = test.StartupLogs.FirstOrDefault(l => + l.Level == LogLevel.Warning && + l.Message.Contains("parameter placeholder '_fil'") && + l.Message.Contains("no routine parameter matches")); + warning.Should().NotBeNull("a placeholder that matches no parameter should warn so typos are caught"); + } + + // #2 — the heuristic must not flag non-identifier braces + [Fact] + public void Non_identifier_braces_do_not_warn() + { + test.StartupLogs.Any(l => + l.Level == LogLevel.Warning && + l.Message.Contains("parameter placeholder '0'")) + .Should().BeFalse("{0} is not an identifier and must not be treated as a parameter placeholder"); + } + + // #3 — allowlisted env var resolves into a response header, case-insensitively + [Fact] + public async Task Allowlisted_env_var_resolves_in_a_response_header() + { + using var client = test.CreateClient(); + using var response = await client.GetAsync("/api/phsub-env/?x=1"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.GetValues("X-Server").Single().Should().Be("pod-7"); + response.Headers.GetValues("X-Server-Lc").Single().Should().Be("pod-7"); // {server_name} matched SERVER_NAME + } + + // #3 — an allowlisted env-var placeholder must not trigger the unknown-placeholder warning + [Fact] + public void Allowlisted_env_var_placeholder_does_not_warn() + { + test.StartupLogs.Any(l => + l.Level == LogLevel.Warning && + l.Message.Contains("parameter placeholder 'SERVER_NAME'")) + .Should().BeFalse("an allowlisted env var is a valid placeholder and must not warn"); + } + + // #3 — a routine parameter wins over an allowlisted env var of the same name + [Fact] + public async Task Parameter_wins_over_env_var_on_name_collision() + { + using var client = test.CreateClient(); + using var response = await client.GetAsync("/api/phsub-collision/?region=us"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.GetValues("X-Region").Single().Should().Be("us"); // the param value, not "env-region" + } +} diff --git a/NpgsqlRestTests/Setup/PlaceholderSubstitutionTestFixture.cs b/NpgsqlRestTests/Setup/PlaceholderSubstitutionTestFixture.cs new file mode 100644 index 00000000..4c55399c --- /dev/null +++ b/NpgsqlRestTests/Setup/PlaceholderSubstitutionTestFixture.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; + +namespace NpgsqlRestTests.Setup; + +[CollectionDefinition("PlaceholderSubstitutionFixture")] +public class PlaceholderSubstitutionFixtureCollection : ICollectionFixture { } + +/// +/// Fixture for `{name}` parameter-value substitution in annotation values: case-insensitive matching +/// (request-time), and the build-time warning for an unknown placeholder. Live server + captured startup +/// logs. Functions are in the shared `public` schema under the `phsub_` prefix. +/// +public class PlaceholderSubstitutionTestFixture : IDisposable +{ + private readonly WebApplication _app; + private readonly LogCollector _logCollector = new(); + + public string ServerAddress { get; } + public IReadOnlyList StartupLogs { get; } + + public PlaceholderSubstitutionTestFixture() + { + var connectionString = Database.Create(); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Logging.ClearProviders(); + builder.Logging.SetMinimumLevel(LogLevel.Trace); + builder.Logging.AddProvider(new CollectingLoggerProvider(_logCollector)); + + _app = builder.Build(); + + _app.UseNpgsqlRest(new(connectionString) + { + IncludeSchemas = ["public"], + NameSimilarTo = "phsub[_]%", + CommentsMode = CommentsMode.ParseAll, + RequiresAuthorization = false, + // Allowlisted env vars available to {name} substitution. Case-insensitive (as the client builds it). + // `region` deliberately collides with the phsub_collision routine's `_region` parameter to test precedence. + SubstitutionEnvironmentVariables = new(StringComparer.OrdinalIgnoreCase) + { + ["SERVER_NAME"] = "pod-7", + ["region"] = "env-region", + }, + }); + + _app.StartAsync().GetAwaiter().GetResult(); + StartupLogs = _logCollector.Snapshot(); + ServerAddress = _app.Urls.First(); + } + + public HttpClient CreateClient() + => new() { BaseAddress = new Uri(ServerAddress), Timeout = TimeSpan.FromMinutes(5) }; + +#pragma warning disable CA1816 + public void Dispose() +#pragma warning restore CA1816 + { + _app.StopAsync().GetAwaiter().GetResult(); + _app.DisposeAsync().GetAwaiter().GetResult(); + } +} diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index ee8a08a2..330bc4d4 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -56,6 +56,19 @@ Neutral, plugin-facing hooks were added so a plugin can own its comment annotati - **`RoutineEndpoint.UnhandledCommentLines`** (`string[]?`) — comment prose that neither core nor any handler claimed. - **`CommentsMode.OnlyAnnotated`** (new) — creates an endpoint when the comment has an HTTP tag **or** a plugin requests one (so an `mcp`-only routine can exist with no HTTP route). The **client now defaults to `OnlyAnnotated`**; existing `OnlyWithHttpTag` configs are unaffected — it is kept as an identical-behavior alias. +### `{name}` annotation substitution can resolve allowlisted environment variables + +The `{name}` placeholders in annotation values (response headers, custom parameters, HTTP custom type URL/headers/body) could only resolve **request parameters**. They can now also resolve **allowlisted environment variables**, so e.g. an outbound API key or a per-pod server name doesn't have to be routed through a request parameter: + +```sql +comment on type weather_api is 'GET https://api.example.com/v1/current?city={_city} +Authorization: Bearer {WEATHER_API_KEY}'; +``` + +- **Opt-in allowlist** `NpgsqlRest:AvailableEnvVars` (mirrors `StaticFiles:ParseContentOptions:AvailableEnvVars`): array of names, or an object of `name → default`. Only listed names are ever read from the environment — the allowlist is the security boundary. (C# API: `NpgsqlRestOptions.SubstitutionEnvironmentVariables`, a resolved `name → value` dictionary.) +- Resolved **once at startup**, matched **case-insensitively**, injected as the **raw** value. A **routine parameter of the same name takes precedence**. +- **Security:** a value substituted into a *response* header is sent to the client — reserve secrets for outbound HTTP-type calls / custom parameters, and use response headers only for non-secret values (e.g. server/environment name). + ## Breaking Changes ### ⚠️ OpenAPI annotation handling moved out of core (C# API only) @@ -67,6 +80,13 @@ The public properties **`RoutineEndpoint.OpenApiHide` and `RoutineEndpoint.OpenA ## Fixes +### `{name}` parameter-value placeholders: case-insensitive matching + typo warning + +The `{name}` placeholders that inject a request's parameter values into annotation values (response headers incl. `Content-Type`, custom parameters such as upload paths, and HTTP custom type URL/headers/body) had two rough edges: + +- **Case sensitivity was inconsistent.** Substitution matched names case-sensitively, while the related resolved-parameter SQL expression resolver matched case-insensitively. Substitution is now **case-insensitive** too (`{userId}`, `{USERID}`, `{userid}` all resolve the same parameter), consistent with PostgreSQL identifier folding. +- **Typos were silent.** An unknown placeholder is left as literal text at request time (unchanged), but a misspelling like `{_fil}` for `{_file}` shipped silently into a header/path. NpgsqlRest now logs a **build-time warning** naming the unknown placeholder. The check covers response headers and custom parameters and only flags identifier-shaped tokens, so `{0}` and JSON-like `{"a":1}` are never mistaken for placeholders. + ### Bare `@cached` (no parameter list) used only the routine name as the cache key `@cached` without an explicit parameter list is documented to key on **all** routine parameters, but the implementation left the cache-key parameter set empty — so the key was just the routine identifier, and **every call returned the first response cached for that routine regardless of inputs** until the TTL expired (a search/filter endpoint would serve the first query's results to every subsequent query). Endpoints that listed parameters explicitly (`@cached p1, p2`) were unaffected. All cache backends (`Memory`, `Redis`, `Hybrid`) were affected. Fixed by treating "no list" as "every parameter" at annotation-parse time. From 99c4ceee893a81d2d9bcc411b6f8221529a73d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Fri, 12 Jun 2026 12:06:29 +0200 Subject: [PATCH 29/38] =?UTF-8?q?fix:=20security/correctness=20hardening?= =?UTF-8?q?=20=E2=80=94=20safer=20config=20defaults,=20passkey=20diagnosti?= =?UTF-8?q?cs,=20400=20on=20malformed=20JSON=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cors:AllowCredentials defaults to false (was true); enable deliberately with explicit origins - Passkey UserVerificationRequirement/ResidentKeyRequirement code defaults aligned to appsettings.json ('required', were 'preferred') - ConnectionSettings:TestConnectionStrings code default aligned to appsettings.json (true) - CBOR attestation decode failures log a Warning (exception + payload length, never the payload); indefinite-length CBOR arrays decoded correctly - Startup warning when PasskeyAuth is enabled with empty RelyingPartyOrigins (origin validation accepts any origin in that state) - Malformed JSON request body returns 400 Bad Request instead of 404 Not Found (+ tests) --- NpgsqlRest/NpgsqlRestEndpoint.cs | 4 +- NpgsqlRestClient/Builder.cs | 15 +++-- NpgsqlRestClient/ConfigDefaults.cs | 8 +-- NpgsqlRestClient/ConfigSchemaGenerator.cs | 2 +- NpgsqlRestClient/ConfigTemplate.cs | 4 +- NpgsqlRestClient/Fido2/CborDecoder.cs | 21 ++++++- NpgsqlRestClient/Fido2/PasskeyAuth.cs | 1 + NpgsqlRestClient/appsettings.json | 4 +- .../BodyTests/InvalidJsonBodyTests.cs | 60 +++++++++++++++++++ changelog/v3.17.0.md | 19 ++++++ 10 files changed, 124 insertions(+), 14 deletions(-) create mode 100644 NpgsqlRestTests/BodyTests/InvalidJsonBodyTests.cs diff --git a/NpgsqlRest/NpgsqlRestEndpoint.cs b/NpgsqlRest/NpgsqlRestEndpoint.cs index 2ed6ffa7..a12be203 100644 --- a/NpgsqlRest/NpgsqlRestEndpoint.cs +++ b/NpgsqlRest/NpgsqlRestEndpoint.cs @@ -987,9 +987,11 @@ await Options.ValidateParametersAsync(new ParameterValidationValues( { if (bodyDict is null) { + // Body was present but is not a parseable JSON object - this is a client error, + // not a routing miss (parse failures are logged above via CouldNotParseJson). shouldCommit = false; uploadHandler?.OnError(connection, context, null); - context.Response.StatusCode = StatusCodes.Status404NotFound; + context.Response.StatusCode = StatusCodes.Status400BadRequest; await context.Response.CompleteAsync(); return; } diff --git a/NpgsqlRestClient/Builder.cs b/NpgsqlRestClient/Builder.cs index 36d4dae2..8fb0b747 100644 --- a/NpgsqlRestClient/Builder.cs +++ b/NpgsqlRestClient/Builder.cs @@ -1332,8 +1332,8 @@ public void BuildPasskeyAuthentication() LoginOptionsPath = _config.GetConfigStr("LoginOptionsPath", passkeyCfg) ?? "/api/passkey/login/options", LoginPath = _config.GetConfigStr("LoginPath", passkeyCfg) ?? "/api/passkey/login", ChallengeTimeoutMinutes = _config.GetConfigInt("ChallengeTimeoutMinutes", passkeyCfg) ?? 5, - UserVerificationRequirement = _config.GetConfigStr("UserVerificationRequirement", passkeyCfg) ?? "preferred", - ResidentKeyRequirement = _config.GetConfigStr("ResidentKeyRequirement", passkeyCfg) ?? "preferred", + UserVerificationRequirement = _config.GetConfigStr("UserVerificationRequirement", passkeyCfg) ?? "required", + ResidentKeyRequirement = _config.GetConfigStr("ResidentKeyRequirement", passkeyCfg) ?? "required", AttestationConveyance = _config.GetConfigStr("AttestationConveyance", passkeyCfg) ?? "none", // GROUP 1: Challenge Commands ChallengeAddExistingUserCommand = _config.GetConfigStr("ChallengeAddExistingUserCommand", passkeyCfg) ?? "select * from passkey_challenge_add_existing($1,$2)", @@ -1371,6 +1371,13 @@ public void BuildPasskeyAuthentication() PasskeyConfig.RelyingPartyOrigins.Length > 0 ? string.Join(", ", PasskeyConfig.RelyingPartyOrigins) : "(any)", PasskeyConfig.UserVerificationRequirement, PasskeyConfig.ResidentKeyRequirement); + + if (PasskeyConfig.RelyingPartyOrigins.Length == 0) + { + ClientLogger?.LogWarning( + "Passkey Authentication is enabled but RelyingPartyOrigins is empty: WebAuthn origin validation will accept ANY origin. " + + "Set Auth:PasskeyAuth:RelyingPartyOrigins to your application origin(s) in production."); + } } public bool BuildCors() @@ -1421,7 +1428,7 @@ public bool BuildCors() ClientLogger?.LogDebug("CORS policy allows headers: {allowedHeaders}", allowedHeaders); } - if (_config.GetConfigBool("AllowCredentials", corsCfg, true) is true) + if (_config.GetConfigBool("AllowCredentials", corsCfg, false) is true) { ClientLogger?.LogDebug("CORS policy allows credentials."); builder.AllowCredentials(); @@ -1761,7 +1768,7 @@ public bool BuildForwardedHeaders() string.Join(",", retryOptions.Strategy.ErrorCodes)); } } - if (_config.GetConfigBool("TestConnectionStrings", _config.ConnectionSettingsCfg) is true) + if (_config.GetConfigBool("TestConnectionStrings", _config.ConnectionSettingsCfg, true) is true) { using var conn = new NpgsqlConnection(connectionString); try diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index d4f461df..4d10165e 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -73,7 +73,7 @@ public static JsonObject GetDefaults() { ["SetApplicationNameInConnection"] = true, ["UseJsonApplicationName"] = false, - ["TestConnectionStrings"] = false, + ["TestConnectionStrings"] = true, ["RetryOptions"] = new JsonObject { ["Enabled"] = true, @@ -322,8 +322,8 @@ private static JsonObject GetAuthDefaults() ["LoginOptionsPath"] = "/api/passkey/login/options", ["LoginPath"] = "/api/passkey/login", ["ChallengeTimeoutMinutes"] = 5, - ["UserVerificationRequirement"] = "preferred", - ["ResidentKeyRequirement"] = "preferred", + ["UserVerificationRequirement"] = "required", + ["ResidentKeyRequirement"] = "required", ["AttestationConveyance"] = "none", ["ChallengeAddExistingUserCommand"] = "select * from passkey_challenge_add_existing($1,$2)", ["ChallengeRegistrationCommand"] = "select * from passkey_challenge_registration($1)", @@ -450,7 +450,7 @@ private static JsonObject GetCorsDefaults() ["AllowedOrigins"] = new JsonArray(), ["AllowedMethods"] = CreateStringArray("*"), ["AllowedHeaders"] = CreateStringArray("*"), - ["AllowCredentials"] = true, + ["AllowCredentials"] = false, ["PreflightMaxAgeSeconds"] = 600 }; } diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index 5a87d6db..220368e6 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -205,7 +205,7 @@ public static partial class ConfigSchemaGenerator ["Cors:AllowedOrigins"] = "List of allowed origins for CORS requests. Empty array allows no origins.", ["Cors:AllowedMethods"] = "List of allowed HTTP methods for CORS requests.", ["Cors:AllowedHeaders"] = "List of allowed headers for CORS requests.", - ["Cors:AllowCredentials"] = "Allow credentials (cookies, authorization headers) in CORS requests.", + ["Cors:AllowCredentials"] = "Allow credentials (cookies, authorization headers) in CORS requests. Disabled by default; enable deliberately and only together with an explicit AllowedOrigins list (never with wildcard origins).", ["Cors:PreflightMaxAgeSeconds"] = "Maximum age in seconds for preflight request caching (10 minutes).", ["SecurityHeaders"] = "Security Headers: Adds HTTP security headers to all responses to protect against common web vulnerabilities.\nThese headers instruct browsers how to handle your content securely.\nNote: X-Frame-Options is automatically handled by the Antiforgery middleware when enabled (see Antiforgery.SuppressXFrameOptionsHeader).\nReference: https://owasp.org/www-project-secure-headers/", ["SecurityHeaders:Enabled"] = "Enable security headers middleware. When enabled, configured headers are added to all HTTP responses.", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index 00e3be3c..ba3bbebc 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -1167,8 +1167,10 @@ public static partial class ConfigSchemaGenerator ], // // Allow credentials (cookies, authorization headers) in CORS requests. + // Disabled by default: credentials must be enabled deliberately and only together with + // an explicit AllowedOrigins list (never with wildcard origins). // - "AllowCredentials": true, + "AllowCredentials": false, // // Maximum age in seconds for preflight request caching (10 minutes). // diff --git a/NpgsqlRestClient/Fido2/CborDecoder.cs b/NpgsqlRestClient/Fido2/CborDecoder.cs index 002b0851..5894fb6a 100644 --- a/NpgsqlRestClient/Fido2/CborDecoder.cs +++ b/NpgsqlRestClient/Fido2/CborDecoder.cs @@ -4,6 +4,8 @@ namespace NpgsqlRestClient.Fido2; public static class CborDecoder { + internal static ILogger? Logger; + public static AttestationObject? DecodeAttestationObject(byte[] data) { try @@ -46,8 +48,12 @@ public static class CborDecoder AttStmt = attStmt }; } - catch + catch (Exception e) { + // Never log the payload itself (attacker-controlled); length + exception is enough to diagnose. + Logger?.LogWarning(e, + "Failed to decode CBOR attestation object ({Length} bytes): {Message}", + data?.Length ?? 0, e.Message); return null; } } @@ -106,8 +112,19 @@ CborReaderState.SinglePrecisionFloat or private static object?[] ReadArray(CborReader reader) { var length = reader.ReadStartArray(); - var result = new object?[length ?? 0]; + if (length is null) + { + // Indefinite-length array (legal in lax conformance mode): read until end marker. + var items = new List(); + while (reader.PeekState() != CborReaderState.EndArray) + { + items.Add(ReadValue(reader)); + } + reader.ReadEndArray(); + return [.. items]; + } + var result = new object?[length.Value]; for (int i = 0; i < result.Length; i++) { result[i] = ReadValue(reader); diff --git a/NpgsqlRestClient/Fido2/PasskeyAuth.cs b/NpgsqlRestClient/Fido2/PasskeyAuth.cs index 7a65ffa2..fff3fca0 100644 --- a/NpgsqlRestClient/Fido2/PasskeyAuth.cs +++ b/NpgsqlRestClient/Fido2/PasskeyAuth.cs @@ -20,6 +20,7 @@ public static void UsePasskeyAuth( } Logger = clientLogger; + CborDecoder.Logger = clientLogger; // Resolve command retry strategy from config RetryStrategy? commandRetryStrategy = null; diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index f395abe4..382ea589 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -1158,8 +1158,10 @@ ], // // Allow credentials (cookies, authorization headers) in CORS requests. + // Disabled by default: credentials must be enabled deliberately and only together with + // an explicit AllowedOrigins list (never with wildcard origins). // - "AllowCredentials": true, + "AllowCredentials": false, // // Maximum age in seconds for preflight request caching (10 minutes). // diff --git a/NpgsqlRestTests/BodyTests/InvalidJsonBodyTests.cs b/NpgsqlRestTests/BodyTests/InvalidJsonBodyTests.cs new file mode 100644 index 00000000..e68a6eb4 --- /dev/null +++ b/NpgsqlRestTests/BodyTests/InvalidJsonBodyTests.cs @@ -0,0 +1,60 @@ +#pragma warning disable CS8602 // Dereference of a possibly null reference. +namespace NpgsqlRestTests; + +public static partial class Database +{ + public static void InvalidJsonBodyTests() + { + script.Append(@" + create function invalid_json_body_test( + _i int, + _t text + ) + returns text + language sql as $$select _i || '-' || _t;$$; +"); + } +} + +[Collection("TestFixture")] +public class InvalidJsonBodyTests(TestFixture test) +{ + [Fact] + public async Task Test_invalid_json_body_returns_400() + { + string body = """ + { + "i": 666, + "t": "numberofthebeast" + """; // truncated JSON - missing closing brace + using var content = new StringContent(body, Encoding.UTF8, "application/json"); + using var response = await test.Client.PostAsync("/api/invalid-json-body-test/", content); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Test_non_object_json_body_returns_400() + { + string body = """["not", "an", "object"]"""; + using var content = new StringContent(body, Encoding.UTF8, "application/json"); + using var response = await test.Client.PostAsync("/api/invalid-json-body-test/", content); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Test_valid_json_body_still_works() + { + string body = """ + { + "i": 666, + "t": "numberofthebeast" + } + """; + using var content = new StringContent(body, Encoding.UTF8, "application/json"); + using var response = await test.Client.PostAsync("/api/invalid-json-body-test/", content); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType.MediaType.Should().Be("text/plain"); + var result = await response.Content.ReadAsStringAsync(); + result.Should().Be("666-numberofthebeast"); + } +} diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index 330bc4d4..82fbbbbc 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -71,6 +71,14 @@ Authorization: Bearer {WEATHER_API_KEY}'; ## Breaking Changes +### ⚠️ Safer configuration defaults: CORS credentials, passkey requirements, connection testing + +Three configuration defaults changed as part of a security/consistency audit of the shipped `appsettings.json` against the in-code defaults. **You are affected only if your custom configuration omits these keys** — set them explicitly to keep the old behavior. + +- **`Cors:AllowCredentials` now defaults to `false`** (was `true`). Credentials (cookies, authorization headers) in cross-origin requests must now be enabled deliberately, and only together with an explicit `AllowedOrigins` list. This only matters when `Cors:Enabled` is `true`. +- **`Auth:PasskeyAuth:UserVerificationRequirement` and `ResidentKeyRequirement` code defaults are now `"required"`** (were `"preferred"`). The shipped `appsettings.json` already said `"required"` — the in-code fallback and `--config` defaults disagreed; they now match the stronger, documented posture. +- **`ConnectionSettings:TestConnectionStrings` code default is now `true`** (was `false`). Same class of fix: the shipped `appsettings.json` already said `true`; the in-code default now agrees, so connection strings are tested at startup even when the key is omitted. + ### ⚠️ OpenAPI annotation handling moved out of core (C# API only) The public properties **`RoutineEndpoint.OpenApiHide` and `RoutineEndpoint.OpenApiTags` were removed**, along with the core `openapi` comment-annotation handler. The OpenAPI plugin now parses `openapi hide` / `hidden` / `ignore` / `tag <…>` itself (from `UnhandledCommentLines`). @@ -80,6 +88,16 @@ The public properties **`RoutineEndpoint.OpenApiHide` and `RoutineEndpoint.OpenA ## Fixes +### Malformed JSON request body now returns `400 Bad Request` (was `404 Not Found`) + +When an endpoint expects a JSON body and the request body is present but not a parseable JSON **object** (truncated JSON, a bare array/string, …), the response is now **`400 Bad Request`**. Previously the failed parse fell through to parameter matching and surfaced as a misleading `404 Not Found`. Parse failures were and still are logged; valid requests and empty-body handling are unchanged. + +### Passkey/WebAuthn diagnostics: CBOR decode failures are no longer silent + +- A malformed WebAuthn attestation object now logs a **Warning** naming the decode failure (exception + payload length — never the payload itself) instead of failing silently into a generic `attestation_invalid` error. This gives operators an audit trail for both debugging and attack detection. +- **Indefinite-length CBOR arrays** (legal in the lax conformance mode the decoder uses) are now decoded correctly; previously they failed the whole attestation. +- A **startup warning** is logged when Passkey authentication is enabled with an empty `RelyingPartyOrigins` list — in that state WebAuthn origin validation accepts *any* origin, which is not recommended for production. + ### `{name}` parameter-value placeholders: case-insensitive matching + typo warning The `{name}` placeholders that inject a request's parameter values into annotation values (response headers incl. `Content-Type`, custom parameters such as upload paths, and HTTP custom type URL/headers/body) had two rough edges: @@ -122,3 +140,4 @@ This fixes a startup crash: previously a missing optional variable left an unres - An MCP test suite covering the lifecycle, `tools/list` / `tools/call`, `structuredContent` and `outputSchema` across return shapes (scalar, record, set, array, custom composite), parameter mapping (query / body / path; typed, optional, null, and json arguments), authorization (PRM, 401/403, audience binding), transport rules, and protocol edge cases. - A binding-contract test locking the PostgreSQL/Npgsql resolution the JSON-parameter fix relies on (`json`-only for the old binding; `json`/`jsonb`/`text` for the new one, including `NULL` and round-trip integrity), plus end-to-end CSV upload tests for a row-command metadata parameter declared as `json`, `jsonb`, and `text`. - Config tests for optional `{NAME}` (resolves when set; left untouched / defaults when not — including Serilog-template preservation) and required `{!NAME}` (throws when unset) across `GetConfigBool` / `GetConfigInt` / `GetConfigStr` and the `ResolveEnv` resolver. +- Malformed-JSON body tests: truncated JSON and non-object JSON → `400`; valid body unchanged. From b7cfe2e03775e6241aafdf5f1d99005a80523ee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Fri, 12 Jun 2026 12:16:50 +0200 Subject: [PATCH 30/38] =?UTF-8?q?chore:=20governance=20pack=20=E2=80=94=20?= =?UTF-8?q?security=20policy,=20contributing,=20dependabot,=20version=20si?= =?UTF-8?q?ngle-source,=20CI=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SECURITY.md: private vulnerability reporting (enabled on repo), SLA, scope - CONTRIBUTING.md: build/test setup, test conventions, AOT constraints, PR expectations - Issue templates (bug/feature/config), PR template, dependabot.yml (weekly, grouped NuGet minor+patch) - Version single source: version.txt -> Directory.Build.props (core+client csproj), release workflow reads it per job (hardcoded RELEASE_VERSION removed), npm-publish syncs package.json, postinstall.js derives download URL from its own package version - RELEASING.md: full release checklist, version model, secrets, hotfix procedure - CI: PostgreSQL 15/16/17 matrix in test.yml; AOT smoke test (--version + --validate against live PostgreSQL) in build-linux before asset upload - README: Contributing and Security sections link the new docs --- .github/ISSUE_TEMPLATE/bug_report.yml | 45 +++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 +++ .github/ISSUE_TEMPLATE/feature_request.yml | 20 ++++++++ .github/dependabot.yml | 20 ++++++++ .github/pull_request_template.md | 16 ++++++ .github/workflows/build-test-publish.yml | 41 ++++++++++++++-- .github/workflows/test.yml | 6 ++- CONTRIBUTING.md | 57 ++++++++++++++++++++++ Directory.Build.props | 10 ++++ NpgsqlRest/NpgsqlRest.csproj | 8 +-- NpgsqlRestClient/NpgsqlRestClient.csproj | 2 +- README.md | 6 ++- RELEASING.md | 49 +++++++++++++++++++ SECURITY.md | 44 +++++++++++++++++ npm/package.json | 2 +- npm/postinstall.js | 4 +- version.txt | 1 + 17 files changed, 327 insertions(+), 12 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 CONTRIBUTING.md create mode 100644 Directory.Build.props create mode 100644 RELEASING.md create mode 100644 SECURITY.md create mode 100644 version.txt diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..42a2ebfd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,45 @@ +name: Bug report +description: Something doesn't work as documented +labels: ["bug"] +body: + - type: input + id: version + attributes: + label: Version + description: NpgsqlRest version (binary `--version`, NuGet package, Docker tag, or npm version) + placeholder: "3.17.0 (npgsqlrest-linux64)" + validations: + required: true + - type: textarea + id: repro + attributes: + label: Minimal reproduction + description: The SQL (create function + comment annotations, or the .sql file) and the relevant config section + placeholder: | + ```sql + create function my_func(_id int) returns text language sql as $$ select 'x' $$; + comment on function my_func is 'HTTP GET'; + ``` + ```json + { "NpgsqlRest": { ... } } + ``` + validations: + required: true + - type: textarea + id: request + attributes: + label: Request and actual response + description: The HTTP request you made and the full response you got (status + body) + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected response + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant log output + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..e7f88c4e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://github.com/NpgsqlRest/NpgsqlRest/security/advisories/new + about: Please report security issues privately - do NOT open a public issue. See SECURITY.md. + - name: Documentation + url: https://npgsqlrest.com + about: Guides, config reference, and annotation reference. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..33930163 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,20 @@ +name: Feature request +description: Suggest a new capability or improvement +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: Describe the use case, not just the mechanism + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: If you have a shape in mind (annotation, config key, behavior), sketch it + - type: textarea + id: alternatives + attributes: + label: Workarounds you've considered diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..c29c1eb5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + groups: + nuget-minor-patch: + update-types: ["minor", "patch"] + open-pull-requests-limit: 5 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "npm" + directory: "/npm" + schedule: + interval: "weekly" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..c4685e73 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +## What does this PR do? + + + +## Test plan + + + +- [ ] Tests added/updated (full-string response assertions, SQL setup co-located — see CONTRIBUTING.md) +- [ ] `dotnet test` green locally + +## Checklist + +- [ ] Changelog entry added to `changelog/v.md` (user-facing changes) +- [ ] Breaking change? → called out in the changelog under "Breaking Changes" +- [ ] AOT/trim-safe (no reflection-based serialization or libraries) diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml index 7e16ec39..3e5b8e75 100644 --- a/.github/workflows/build-test-publish.yml +++ b/.github/workflows/build-test-publish.yml @@ -7,9 +7,6 @@ on: branches: [ master ] workflow_dispatch: -env: - RELEASE_VERSION: v3.16.3 - # Add workflow-level permissions permissions: contents: write @@ -58,6 +55,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + - name: Set release version from version.txt + run: echo "RELEASE_VERSION=v$(cat version.txt)" >> "$GITHUB_ENV" - name: Extract changelog for this version id: changelog run: | @@ -136,6 +135,20 @@ jobs: runs-on: ubuntu-22.04 permissions: contents: write + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@v4 - name: Setup .NET10 @@ -144,6 +157,18 @@ jobs: dotnet-version: 10.0.x - name: Build Linux AOT run: dotnet publish ./NpgsqlRestClient/NpgsqlRestClient.csproj -r linux-x64 -c Release + - name: AOT smoke test (config validation + database connection) + run: | + cat > smoke.appsettings.json <<'SMOKE' + { + "ConnectionStrings": { + "Default": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres" + } + } + SMOKE + BIN=./NpgsqlRestClient/bin/Release/net10.0/linux-x64/publish/NpgsqlRestClient + "$BIN" --version + "$BIN" smoke.appsettings.json --validate - name: Upload Linux executable uses: actions/upload-release-asset@v1 env: @@ -219,6 +244,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + - name: Set release version from version.txt + run: echo "RELEASE_VERSION=v$(cat version.txt)" >> "$GITHUB_ENV" - name: Download Linux executable artifact uses: actions/download-artifact@v4 with: @@ -256,6 +283,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + - name: Set release version from version.txt + run: echo "RELEASE_VERSION=v$(cat version.txt)" >> "$GITHUB_ENV" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub @@ -284,6 +313,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + - name: Set release version from version.txt + run: echo "RELEASE_VERSION=v$(cat version.txt)" >> "$GITHUB_ENV" - name: Download Linux ARM64 executable artifact uses: actions/download-artifact@v4 with: @@ -321,6 +352,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + - name: Set release version from version.txt + run: echo "RELEASE_VERSION=v$(cat version.txt)" >> "$GITHUB_ENV" - name: Download Linux executable artifact uses: actions/download-artifact@v4 with: @@ -360,6 +393,8 @@ jobs: uses: actions/setup-node@v4 with: node-version: '24' + - name: Sync npm package version from version.txt + run: cd npm && npm version "$(cat ../version.txt)" --no-git-tag-version --allow-same-version - name: Publish to npm with OIDC uses: JS-DevTools/npm-publish@v4 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 301f7121..37f8c0fa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,11 +12,15 @@ on: jobs: test: runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + postgres-version: [15, 16, 17] steps: - name: Install PostgreSQL uses: vb-consulting/postgresql-action@v1 with: - postgresql version: '17' + postgresql version: '${{ matrix.postgres-version }}' postgresql user: 'postgres' postgresql password: 'postgres' - uses: actions/checkout@v4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..0a85e456 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing to NpgsqlRest + +Thank you for considering a contribution! Bug reports, documentation fixes, tests, and features are all welcome. + +## Quick orientation + +| Part | Path | What it is | +|---|---|---| +| Core library | `NpgsqlRest/` | The middleware: PostgreSQL routines/SQL files → REST endpoints (NuGet) | +| Client app | `NpgsqlRestClient/` | The shipped binary: config-driven host (auth, caching, static files, …) | +| Plugins | `plugins/` | TsClient, SqlFileSource, CrudSource, OpenApi, HttpFiles, Mcp (independent NuGet versions) | +| Tests | `NpgsqlRestTests/` | Integration tests against a real PostgreSQL | +| Docs | [npgsqlrest-docs](https://github.com/NpgsqlRest/npgsqlrest-docs) | Documentation website (separate repo) | + +## Building and testing + +Prerequisites: **.NET 10 SDK** and a local **PostgreSQL** (any recent version; CI tests 15/16/17) listening on `localhost:5432` with user `postgres` / password `postgres`. The test run creates and drops its own database (`npgsql_rest_test`); connection constants live in `NpgsqlRestTests/Setup/Database.cs`. + +```sh +dotnet build +dotnet test NpgsqlRestTests/NpgsqlRestTests.csproj +``` + +## Test conventions (please follow these — PRs that don't will be asked to change) + +- **Assert full response strings**, not fragments. A test pins the exact wire output. +- **SQL setup lives in the same file as the assertions**: add a `public static void YourTests()` method on the `Database` partial class appending your `create function …` to the shared script (it is auto-registered via reflection), with the `[Collection("TestFixture")]` test class below it. Copy the pattern from any file in `NpgsqlRestTests/BodyTests/`. +- Pitfalls: don't name test routines after PostgreSQL built-ins (`to_date`, …); `NameSimilarTo` treats `_` as a wildcard; response JSON keys are camelCase. +- Tests must be deterministic — no fixed sleeps; await observable conditions with timeouts. + +## Code constraints + +- **AOT/trim-safe only.** The client publishes with `PublishAot=true` + `TrimMode=full`: no reflection-based serialization or libraries, JSON via source-generated/`System.Text.Json.Nodes` patterns. (Note: `JsonArray.Add(x)` needs an explicit `(JsonNode?)` cast.) +- Match the surrounding code's style and comment density. Comments explain constraints, not narrate lines. +- Public API of the core library is a published NuGet surface — breaking changes need a strong justification and a changelog entry under "Breaking Changes". + +## Pull requests + +1. Open an issue first for anything non-trivial — agreeing on the approach saves everyone time. +2. Include tests for behavior changes (see conventions above). +3. Add a changelog entry to `changelog/v.md` describing the change in user-facing terms. +4. Keep PRs focused — one logical change per PR. +5. CI must be green (build + full test suite on PostgreSQL 15/16/17). + +## Reporting bugs + +Use the bug-report issue template. The single most useful thing you can provide is a **minimal reproduction**: the SQL (`create function …` + comment annotations or `.sql` file), the relevant config section, the request, and the expected vs. actual full response. + +## Security issues + +**Do not open public issues for vulnerabilities** — see [SECURITY.md](SECURITY.md). + +## Labels + +- `good-first-issue` — small, well-scoped, a good entry point +- `help-wanted` — larger items looking for a contributor +- `roadmap` — planned by the maintainer diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..c9915540 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + + $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)version.txt').Trim()) + + diff --git a/NpgsqlRest/NpgsqlRest.csproj b/NpgsqlRest/NpgsqlRest.csproj index 4766dbd3..0493f60c 100644 --- a/NpgsqlRest/NpgsqlRest.csproj +++ b/NpgsqlRest/NpgsqlRest.csproj @@ -29,10 +29,10 @@ true README.MD bin\$(Configuration)\$(AssemblyName).xml - 3.16.2 - 3.16.2 - 3.16.2 - 3.16.2 + $(NpgsqlRestProductVersion) + $(NpgsqlRestProductVersion) + $(NpgsqlRestProductVersion) + $(NpgsqlRestProductVersion) true diff --git a/NpgsqlRestClient/NpgsqlRestClient.csproj b/NpgsqlRestClient/NpgsqlRestClient.csproj index 0fc8aea3..6b49adda 100644 --- a/NpgsqlRestClient/NpgsqlRestClient.csproj +++ b/NpgsqlRestClient/NpgsqlRestClient.csproj @@ -10,7 +10,7 @@ true true full - 3.16.3 + $(NpgsqlRestProductVersion) diff --git a/README.md b/README.md index d1456d19..dc80d3f1 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,11 @@ NpgsqlRest is built and maintained by [Vedran Bilopavlović](https://www.linkedi ## Contributing -Contributions are welcome. Open a pull request with a description of your changes. +Contributions are welcome — see **[CONTRIBUTING.md](CONTRIBUTING.md)** for how to build, run the tests, and what a good PR looks like. Small, well-scoped issues are labeled `good-first-issue`. + +## Security + +Please report vulnerabilities privately via [GitHub Private Vulnerability Reporting](https://github.com/NpgsqlRest/NpgsqlRest/security/advisories/new) — see **[SECURITY.md](SECURITY.md)**. Do not open public issues for security problems. ## License diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000..622ad98c --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,49 @@ +# Releasing NpgsqlRest + +The complete release procedure. Written so that someone who has never cut a release can do it. + +## Version model + +- **One product version** for: core library (NuGet `NpgsqlRest`), the `NpgsqlRestClient` binary, release binaries/Docker tags, and the `npgsqlrest` npm package. +- **Single source of truth: [`version.txt`](version.txt)** in the repo root. Everything derives from it: + - `Directory.Build.props` reads it into `$(NpgsqlRestProductVersion)` → core + client `.csproj` versions + - `.github/workflows/build-test-publish.yml` reads it per job (`RELEASE_VERSION=v$(cat version.txt)`) → release tag, release name, changelog file lookup, Docker tags + - the `npm-publish` job syncs `npm/package.json` from it before publishing + - `npm/postinstall.js` downloads the binary from the release tag **matching its own package version** (`v${package.json version}`) — no hardcoded version anywhere in the npm package +- **Plugins (`plugins/*`) version independently** — bump a plugin's `.csproj` version only when that plugin changes. NuGet publish uses `--skip-duplicate`, so unchanged plugin versions are skipped automatically. + +## Release checklist + +1. **Finish the changelog**: `changelog/v.md` must exist — the release notes are extracted from it verbatim (title line is skipped). Cover: headline, new features, breaking changes (⚠️), fixes, tests. +2. **Bump the version**: edit `version.txt` (e.g. `3.18.0`). Nothing else needs editing. Sanity check locally: + ```sh + dotnet pack NpgsqlRest/NpgsqlRest.csproj -c Release -o /tmp/packcheck # expect NpgsqlRest..nupkg + ``` +3. **Bump plugin versions** in `plugins/*/​*.csproj` for any plugin that changed since its last published version. +4. **Full test suite green** locally (`dotnet test`, needs PostgreSQL on `localhost:5432`, `postgres`/`postgres`). +5. **Merge/push to `master`.** That's the trigger. The workflow then automatically: + - builds + runs the full test suite (PostgreSQL 17 service container), + - publishes all `.nupkg` to NuGet (`--skip-duplicate`), + - creates the GitHub release `v` with notes from `changelog/v.md`, + - builds AOT binaries: win-x64, linux-x64 (+ **smoke test**: `--version` + `--validate` against a live PostgreSQL), linux-arm64, osx-arm64 — and uploads them as release assets together with `appsettings.json`, + - builds + pushes Docker images: `vbilopav/npgsqlrest:{v,latest}{,-aot,-jit,-arm,-bun}`, + - publishes the npm package (version synced from `version.txt`, OIDC trusted publishing). +6. **After the release**: verify the GitHub release page, `docker pull vbilopav/npgsqlrest:latest`, `npm view npgsqlrest version`, and NuGet listing. +7. **Docs**: update the docs site (separate repo, `npgsqlrest-docs`) — changelog page + any feature pages. + +## Required repository secrets + +| Secret | Used by | +|---|---| +| `NUGET_API_KEY` | NuGet publish | +| `DOCKER_HUB_USERNAME` / `DOCKER_HUB_TOKEN` | Docker Hub push | +| (npm: OIDC trusted publishing — no token; configured on npmjs.com for the `npgsqlrest` package) | npm publish | + +## CI overview + +- **`test.yml`** — runs on every push/PR to non-master branches: build + full suite on a **PostgreSQL 15/16/17 matrix**. +- **`build-test-publish.yml`** — runs on push/PR to `master`: the full release pipeline above. ⚠️ Note: it runs on `pull_request` to master too — the NuGet publish step is effectively a no-op there (secrets are not exposed to fork PRs), but be aware when reviewing runs. + +## Hotfix procedure + +1. Branch from the release tag, fix, add `changelog/v.md`, bump `version.txt`, test, merge to `master`. The pipeline does the rest. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..bf44d0e8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,44 @@ +# Security Policy + +## Supported Versions + +Security fixes are provided for the **latest released minor version** of NpgsqlRest (and its plugins). Older versions do not receive backported fixes — upgrade to the latest release. + +| Version | Supported | +|---|---| +| Latest 3.x release | ✅ | +| Older releases | ❌ | + +## Reporting a Vulnerability + +**Please do NOT open a public issue for security vulnerabilities.** + +Report privately via **[GitHub Private Vulnerability Reporting](https://github.com/NpgsqlRest/NpgsqlRest/security/advisories/new)** — this creates a private advisory visible only to the maintainer. + +What to include: + +- The affected component (core library, `NpgsqlRestClient` binary, a specific plugin, Docker image, or npm package) and version. +- A description of the vulnerability and its impact. +- Steps to reproduce — a minimal config + SQL setup is ideal. +- Any suggested fix, if you have one. + +## What to Expect + +- **Acknowledgement** within **7 days**. +- An assessment and, for confirmed vulnerabilities, a **fix or documented mitigation targeted within 90 days** (usually much sooner; severity drives priority). +- **Coordinated disclosure**: we ask that you keep the report private until a fixed release is available. You will be credited in the advisory and changelog unless you prefer otherwise. + +## Scope + +In scope: + +- The NpgsqlRest core library (NuGet: `NpgsqlRest`) +- The `NpgsqlRestClient` application (release binaries, `vbilopav/npgsqlrest` Docker images, `npgsqlrest` npm package) +- Official plugins in this repository (`plugins/`) + +Out of scope: + +- The documentation website +- Example/demo code (`examples/`) +- Vulnerabilities in dependencies with no NpgsqlRest-specific exploitation path (report those upstream; we still appreciate a heads-up so we can update) +- Issues requiring a hostile configuration explicitly documented as unsafe (e.g., empty `RelyingPartyOrigins` in production, wildcard CORS) diff --git a/npm/package.json b/npm/package.json index 5b9f773e..484cd0f8 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "npgsqlrest", - "version": "3.16.3", + "version": "3.17.0", "description": "Automatic REST API for PostgreSQL Databases Client Build", "scripts": { "postinstall": "node postinstall.js", diff --git a/npm/postinstall.js b/npm/postinstall.js index 6d3316b9..7acba7c0 100644 --- a/npm/postinstall.js +++ b/npm/postinstall.js @@ -5,7 +5,9 @@ const path = require("path"); const os = require("os"); const https = require("https"); -const downloadFrom = "https://github.com/NpgsqlRest/NpgsqlRest/releases/download/v3.16.3/"; +// The binary release tag always matches the npm package version (see RELEASING.md). +const version = require(path.join(__dirname, "package.json")).version; +const downloadFrom = `https://github.com/NpgsqlRest/NpgsqlRest/releases/download/v${version}/`; // Download binary next to this script, not to ../.bin/ const binDir = path.join(__dirname, "bin"); diff --git a/version.txt b/version.txt new file mode 100644 index 00000000..f85bf6e3 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +3.17.0 \ No newline at end of file From 342d18b34603d8a330e60b96a7783e068f2efca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Fri, 12 Jun 2026 12:47:47 +0200 Subject: [PATCH 31/38] =?UTF-8?q?fix:=20SSE=20scope=20hints=20were=20not?= =?UTF-8?q?=20enforced=20=E2=80=94=20hint-scoped=20events=20were=20deliver?= =?UTF-8?q?ed=20to=20every=20subscriber?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Events published with RAISE ... USING HINT = 'authorize ...' were delivered to ALL connected SSE subscribers, including subscribers without the named role and unauthenticated subscribers. The hint was parsed correctly, but the scope authorization checks were 'else if' branches chained to the hint-parsing 'if', so they were skipped whenever a hint was present. Endpoint-level sse_scope annotation scoping (no hint) was not affected. Fixed by decoupling enforcement from hint parsing so the Matching/Authorize checks always run on the effective scope. Regression test proves delivery by ordering across three differently-authenticated subscribers (role-scoped, authenticated-scoped, unscoped). --- NpgsqlRest/NpgsqlRestSseEventSource.cs | 7 +- NpgsqlRestTests/SseTests/SseScopeTests.cs | 124 ++++++++++++++++++++++ changelog/v3.17.0.md | 11 ++ 3 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 NpgsqlRestTests/SseTests/SseScopeTests.cs diff --git a/NpgsqlRest/NpgsqlRestSseEventSource.cs b/NpgsqlRest/NpgsqlRestSseEventSource.cs index 55e68e6a..6a5c8bbb 100644 --- a/NpgsqlRest/NpgsqlRestSseEventSource.cs +++ b/NpgsqlRest/NpgsqlRestSseEventSource.cs @@ -113,8 +113,11 @@ public async Task InvokeAsync(HttpContext context) words?[0], string.Join(", ", Enum.GetNames()), hint); } } - - else if (scope == SseEventsScope.Matching) + + // NOTE: deliberately NOT an `else if` - the scope checks below must run regardless of + // whether the scope came from the endpoint annotation or was overridden by the RAISE hint. + // (Chaining them as `else if` delivered hint-scoped events to EVERY subscriber.) + if (scope == SseEventsScope.Matching) { if (context.User?.Identity?.IsAuthenticated is false && (endpoint?.RequiresAuthorization is true || endpoint?.AuthorizeRoles is not null)) diff --git a/NpgsqlRestTests/SseTests/SseScopeTests.cs b/NpgsqlRestTests/SseTests/SseScopeTests.cs new file mode 100644 index 00000000..b1531042 --- /dev/null +++ b/NpgsqlRestTests/SseTests/SseScopeTests.cs @@ -0,0 +1,124 @@ +namespace NpgsqlRestTests +{ + public static partial class Database + { + public static void SseScopeTests() + { + script.Append(""" + + -- Anonymous-subscribable event stream; per-event filtering comes from publish-side hints. + create function ssecope_events() + returns void + language plpgsql immutable + as $$ begin perform 1; end $$; + comment on function ssecope_events() is ' + HTTP GET + sse_subscribe + '; + + -- Publisher: emits the message with an optional scope hint (mirrors the documented + -- RAISE ... USING HINT = ''authorize '' production pattern). + create procedure ssecope_publish(_msg text, _hint text) + language plpgsql + as $$ + begin + if _hint is null then + raise info '%', _msg; + else + raise info '%', _msg using hint = _hint; + end if; + end $$; + comment on procedure ssecope_publish(text, text) is ' + HTTP POST + sse_publish + '; + + create function ssecope_login_a() + returns table (name_identifier int, name text, role text[]) + language sql as $$ + select 9001, 'scope_user_a', array['role_a'] + $$; + comment on function ssecope_login_a() is 'login'; + + create function ssecope_login_b() + returns table (name_identifier int, name text, role text[]) + language sql as $$ + select 9002, 'scope_user_b', array['role_b'] + $$; + comment on function ssecope_login_b() is 'login'; + """); + } + } +} + +namespace NpgsqlRestTests.SseTests +{ + using NpgsqlRestTests.Setup; + + [Collection("TestFixture")] + public class SseScopeTests(TestFixture test) + { + private const string SubscribeUrl = "/api/ssecope-events/info"; + private const string PublishUrl = "/api/ssecope-publish"; + + private static StringContent Publish(string msg, string? hint) => + new(hint is null + ? $"{{\"msg\":\"{msg}\",\"hint\":null}}" + : $"{{\"msg\":\"{msg}\",\"hint\":\"{hint}\"}}", + Encoding.UTF8, "application/json"); + + /// + /// Per-event scoping via RAISE ... USING HINT: 'authorize role_a' must deliver ONLY to + /// subscribers whose claims include role_a; bare 'authorize' must deliver only to + /// authenticated subscribers. Non-delivery is proven by ordering, not by waiting: each + /// excluded subscriber's FIRST received event must be the later, broader one. + /// + [Fact] + public async Task Hint_Scoped_Events_Are_Delivered_Only_To_Matching_Subscribers() + { + // Three subscribers: A (role_a), B (role_b), Anon (not authenticated). + using var clientA = test.Application.CreateClient(); + using var loginA = await clientA.PostAsync("/api/ssecope-login-a/", null); + loginA.StatusCode.Should().Be(HttpStatusCode.OK); + + using var clientB = test.Application.CreateClient(); + using var loginB = await clientB.PostAsync("/api/ssecope-login-b/", null); + loginB.StatusCode.Should().Be(HttpStatusCode.OK); + + using var clientAnon = test.Application.CreateClient(); + + await using var sseA = await SseTestClient.OpenAsync(clientA, SubscribeUrl); + await sseA.WaitForRegisteredAsync(); + await using var sseB = await SseTestClient.OpenAsync(clientB, SubscribeUrl); + await sseB.WaitForRegisteredAsync(); + await using var sseAnon = await SseTestClient.OpenAsync(clientAnon, SubscribeUrl); + await sseAnon.WaitForRegisteredAsync(); + + // 1. Scoped to role_a only. + using (var p = await clientA.PostAsync(PublishUrl, Publish("only-role-a", "authorize role_a"))) + p.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent); + + // 2. Scoped to any authenticated subscriber. + using (var p = await clientA.PostAsync(PublishUrl, Publish("any-authenticated", "authorize"))) + p.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent); + + // 3. Unscoped terminator: delivered to everyone (subscribe endpoint is anonymous, default scope). + using (var p = await clientA.PostAsync(PublishUrl, Publish("everyone", null))) + p.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent); + + // A (role_a) receives all three, in order. + (await sseA.ReadDataLineAsync(TimeSpan.FromSeconds(10))).Should().Be("only-role-a"); + (await sseA.ReadDataLineAsync(TimeSpan.FromSeconds(10))).Should().Be("any-authenticated"); + (await sseA.ReadDataLineAsync(TimeSpan.FromSeconds(10))).Should().Be("everyone"); + + // B (role_b) must NOT get the role_a event: its first event is the authenticated one. + (await sseB.ReadDataLineAsync(TimeSpan.FromSeconds(10))).Should().Be("any-authenticated", + "an event hinted 'authorize role_a' must not be delivered to a subscriber without role_a"); + (await sseB.ReadDataLineAsync(TimeSpan.FromSeconds(10))).Should().Be("everyone"); + + // Anonymous must get NEITHER scoped event: its first event is the unscoped one. + (await sseAnon.ReadDataLineAsync(TimeSpan.FromSeconds(10))).Should().Be("everyone", + "events hinted 'authorize …' must never be delivered to unauthenticated subscribers"); + } + } +} diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index 82fbbbbc..26f46f6d 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -88,6 +88,14 @@ The public properties **`RoutineEndpoint.OpenApiHide` and `RoutineEndpoint.OpenA ## Fixes +### 🔴 Security: SSE scope hints were not enforced — hint-scoped events were delivered to every subscriber + +Events published with a per-event scope override — `RAISE INFO ... USING HINT = 'authorize'` or `USING HINT = 'authorize ...'` — were delivered to **all** connected SSE subscribers, including subscribers without the named role **and unauthenticated subscribers**. The hint was parsed correctly, but a control-flow bug (`else if` chaining) skipped the authorization checks whenever a hint was present. Endpoint-level scoping via the `sse_scope` annotation (no hint) was NOT affected. + +**Impact:** any deployment using the documented per-user/per-role `USING HINT` pattern (e.g. private user messages or role-targeted notifications over SSE) was broadcasting those events to every connected subscriber. **Upgrade is strongly recommended** for anyone using SSE with hint-based scoping. + +Fixed by decoupling scope enforcement from hint parsing so the `Matching`/`Authorize` checks always run on the effective scope. Covered by new tests proving delivery-by-ordering: a role-scoped event reaches only matching subscribers, a bare `authorize` event reaches only authenticated subscribers, and anonymous subscribers receive neither. + ### Malformed JSON request body now returns `400 Bad Request` (was `404 Not Found`) When an endpoint expects a JSON body and the request body is present but not a parseable JSON **object** (truncated JSON, a bare array/string, …), the response is now **`400 Bad Request`**. Previously the failed parse fell through to parameter matching and surfaced as a misleading `404 Not Found`. Parse failures were and still are logged; valid requests and empty-body handling are unchanged. @@ -141,3 +149,6 @@ This fixes a startup crash: previously a missing optional variable left an unres - A binding-contract test locking the PostgreSQL/Npgsql resolution the JSON-parameter fix relies on (`json`-only for the old binding; `json`/`jsonb`/`text` for the new one, including `NULL` and round-trip integrity), plus end-to-end CSV upload tests for a row-command metadata parameter declared as `json`, `jsonb`, and `text`. - Config tests for optional `{NAME}` (resolves when set; left untouched / defaults when not — including Serilog-template preservation) and required `{!NAME}` (throws when unset) across `GetConfigBool` / `GetConfigInt` / `GetConfigStr` and the `ResolveEnv` resolver. - Malformed-JSON body tests: truncated JSON and non-object JSON → `400`; valid body unchanged. +- SSE hardening suite: hint-scope authorization (the security-fix regression test — role-scoped, authenticated-scoped, and unscoped delivery across three differently-authenticated subscribers), multi-subscriber exactly-once fan-out, per-stream publish ordering, and subscriber-disconnect resilience. +- Cache concurrency races: TTL-expiry under concurrent bursts (exactly one execution per cache window) and concurrent invalidation + read storms (no errors, cache coherent after). +- CRUD endpoint authorization parity: table-comment `authorize`/roles enforced across select/insert/update/delete variants (401 anonymous, 403 wrong role, full cycle with the right role). From 6eed5e307e1459d1dcc148996152fdc381ef3cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Fri, 12 Jun 2026 12:47:47 +0200 Subject: [PATCH 32/38] test: SSE concurrency/ordering/disconnect resilience, cache TTL-expiry and invalidation races, CRUD auth parity - SseConcurrencyTests: multi-subscriber exactly-once fan-out, per-stream publish ordering, subscriber disconnect does not affect survivors or late joiners - CacheConcurrencyRaceTests: concurrent bursts across TTL expiry coalesce to one execution per cache window; concurrent invalidation+read storm stays error-free and coherent - CrudAuthTests: table-comment authorize/roles enforced on every CRUD variant (401 anonymous, 403 wrong role, full cycle with the matching role) --- NpgsqlRestClient/NpgsqlRestClient.csproj | 6 +- NpgsqlRestTests/CrudTests/CrudAuthTests.cs | 110 +++++++++++++++ NpgsqlRestTests/NpgsqlRestTests.csproj | 4 +- .../CacheConcurrencyRaceTests.cs | 112 +++++++++++++++ .../SseTests/SseConcurrencyTests.cs | 133 ++++++++++++++++++ RELEASING.md | 7 +- 6 files changed, 366 insertions(+), 6 deletions(-) create mode 100644 NpgsqlRestTests/CrudTests/CrudAuthTests.cs create mode 100644 NpgsqlRestTests/RoutineCacheTests/CacheConcurrencyRaceTests.cs create mode 100644 NpgsqlRestTests/SseTests/SseConcurrencyTests.cs diff --git a/NpgsqlRestClient/NpgsqlRestClient.csproj b/NpgsqlRestClient/NpgsqlRestClient.csproj index 6b49adda..03e0a02b 100644 --- a/NpgsqlRestClient/NpgsqlRestClient.csproj +++ b/NpgsqlRestClient/NpgsqlRestClient.csproj @@ -16,11 +16,11 @@ - + - - + + diff --git a/NpgsqlRestTests/CrudTests/CrudAuthTests.cs b/NpgsqlRestTests/CrudTests/CrudAuthTests.cs new file mode 100644 index 00000000..00f4ade2 --- /dev/null +++ b/NpgsqlRestTests/CrudTests/CrudAuthTests.cs @@ -0,0 +1,110 @@ +namespace NpgsqlRestTests; + +public static partial class Database +{ + // Auth parity for plugin-generated CRUD endpoints: `authorize` (with and without roles) from the + // TABLE comment must protect every generated variant exactly like it protects routine endpoints. + public static void CrudAuthTests() + { + script.Append(""" + create table crud_auth_protected ( + id int primary key, + name text + ); + insert into crud_auth_protected values (1,'one'),(2,'two'); + + comment on table crud_auth_protected is ' + authorize crud_role + '; + + create table crud_auth_other_role ( + id int primary key, + name text + ); + insert into crud_auth_other_role values (1,'one'); + + comment on table crud_auth_other_role is ' + authorize some_other_role + '; + + create function crud_auth_login() + returns table ( + name_identifier int, + name text, + role text[] + ) + language sql as $$ + select + 777 as name_identifier, + 'crud_auth_user' as name, + array['crud_role'] as role + $$; + comment on function crud_auth_login() is 'login'; + """); + } +} + +[Collection("TestFixture")] +public class CrudAuthTests(TestFixture test) +{ + private static StringContent Json(string body) => + new(body, Encoding.UTF8, "application/json"); + + [Fact] + public async Task Anonymous_Requests_Get_401_On_Every_Crud_Variant() + { + using var client = test.Application.CreateClient(); + + using var select = await client.GetAsync("/api/crud-auth-protected/"); + select.StatusCode.Should().Be(HttpStatusCode.Unauthorized, "select must require authorization"); + + using var insert = await client.PutAsync("/api/crud-auth-protected/", Json("{\"id\":3,\"name\":\"three\"}")); + insert.StatusCode.Should().Be(HttpStatusCode.Unauthorized, "insert must require authorization"); + + using var update = await client.PostAsync("/api/crud-auth-protected/", Json("{\"id\":1,\"name\":\"hacked\"}")); + update.StatusCode.Should().Be(HttpStatusCode.Unauthorized, "update must require authorization"); + + using var delete = await client.DeleteAsync("/api/crud-auth-protected/?id=1"); + delete.StatusCode.Should().Be(HttpStatusCode.Unauthorized, "delete must require authorization"); + + // And nothing actually changed. + using var loggedIn = test.Application.CreateClient(); + using var login = await loggedIn.PostAsync("/api/crud-auth-login/", null); + login.StatusCode.Should().Be(HttpStatusCode.OK); + using var verify = await loggedIn.GetAsync("/api/crud-auth-protected/?id=1"); + (await verify.Content.ReadAsStringAsync()).Should().Be("[{\"id\":1,\"name\":\"one\"}]"); + } + + [Fact] + public async Task Wrong_Role_Gets_403_Right_Role_Succeeds() + { + using var client = test.Application.CreateClient(); + using var login = await client.PostAsync("/api/crud-auth-login/", null); + login.StatusCode.Should().Be(HttpStatusCode.OK); + + // The logged-in user has crud_role, not some_other_role. + using var forbidden = await client.GetAsync("/api/crud-auth-other-role/"); + forbidden.StatusCode.Should().Be(HttpStatusCode.Forbidden, + "an authenticated user without the required role must get 403 on a CRUD endpoint"); + + // The protected table with the matching role works for every variant. + using var select = await client.GetAsync("/api/crud-auth-protected/?id=2"); + select.StatusCode.Should().Be(HttpStatusCode.OK); + (await select.Content.ReadAsStringAsync()).Should().Be("[{\"id\":2,\"name\":\"two\"}]"); + + using var insert = await client.PutAsync("/api/crud-auth-protected/", Json("{\"id\":10,\"name\":\"ten\"}")); + insert.StatusCode.Should().Be(HttpStatusCode.NoContent); + + using var update = await client.PostAsync("/api/crud-auth-protected/", Json("{\"id\":10,\"name\":\"ten-updated\"}")); + update.StatusCode.Should().Be(HttpStatusCode.NoContent); + + using var check = await client.GetAsync("/api/crud-auth-protected/?id=10"); + (await check.Content.ReadAsStringAsync()).Should().Be("[{\"id\":10,\"name\":\"ten-updated\"}]"); + + using var delete = await client.DeleteAsync("/api/crud-auth-protected/?id=10"); + delete.StatusCode.Should().Be(HttpStatusCode.NoContent); + + using var gone = await client.GetAsync("/api/crud-auth-protected/?id=10"); + (await gone.Content.ReadAsStringAsync()).Should().Be("[]"); + } +} diff --git a/NpgsqlRestTests/NpgsqlRestTests.csproj b/NpgsqlRestTests/NpgsqlRestTests.csproj index 7ce209b1..fcd52b6b 100644 --- a/NpgsqlRestTests/NpgsqlRestTests.csproj +++ b/NpgsqlRestTests/NpgsqlRestTests.csproj @@ -16,8 +16,8 @@ - - + + diff --git a/NpgsqlRestTests/RoutineCacheTests/CacheConcurrencyRaceTests.cs b/NpgsqlRestTests/RoutineCacheTests/CacheConcurrencyRaceTests.cs new file mode 100644 index 00000000..72ded682 --- /dev/null +++ b/NpgsqlRestTests/RoutineCacheTests/CacheConcurrencyRaceTests.cs @@ -0,0 +1,112 @@ +namespace NpgsqlRestTests; + +public static partial class Database +{ + // Concurrency across the two cache state transitions the stampede tests don't cover: + // (1) entry EXPIRY under a concurrent burst, (2) explicit INVALIDATION racing concurrent reads. + // Each function records one row per actual execution so tests can prove exact execution counts. + public static void CacheConcurrencyRaceTests() + { + script.Append(@" +create table cache_race_calls (id int generated always as identity primary key, label text not null); + +-- short TTL + execution delay so a burst overlaps the execution window +create function cache_race_expiry(_k text) +returns text +language sql +as $$ + insert into cache_race_calls (label) values ('expiry:' || _k); + select pg_sleep(0.2); + select 'r:' || _k; +$$; +comment on function cache_race_expiry(text) is 'HTTP GET +cached _k +cache_expires 1 seconds'; + +create function cache_race_invalidate(_k text) +returns text +language sql +as $$ + insert into cache_race_calls (label) values ('inv:' || _k); + select 'r:' || _k; +$$; +comment on function cache_race_invalidate(text) is 'HTTP GET +cached _k'; +"); + } +} + +[Collection("TestFixture")] +public class CacheConcurrencyRaceTests(TestFixture test) +{ + private static async Task CountCallsAsync(string label) + { + await using var conn = Database.CreateConnection(); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "select count(*) from cache_race_calls where label = $1"; + var p = cmd.CreateParameter(); + p.Value = label; + cmd.Parameters.Add(p); + return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + } + + [Fact] + public async Task Concurrent_Bursts_Across_TTL_Expiry_Execute_Exactly_Once_Per_Window() + { + const string key = "ttl-window"; + const string url = $"/api/cache-race-expiry/?k={key}"; + + // Burst 1 (cold): all requests coalesce to a single execution. + var burst1 = await Task.WhenAll(Enumerable.Range(0, 25).Select(_ => test.Client.GetStringAsync(url))); + burst1.Should().AllBe($"r:{key}"); + (await CountCallsAsync($"expiry:{key}")).Should().Be(1, "a cold concurrent burst must coalesce to one execution"); + + // Let the 1-second TTL pass with margin. + await Task.Delay(TimeSpan.FromSeconds(1.6)); + + // Burst 2 (expired entry): again exactly one new execution, never one per request. + var burst2 = await Task.WhenAll(Enumerable.Range(0, 25).Select(_ => test.Client.GetStringAsync(url))); + burst2.Should().AllBe($"r:{key}"); + (await CountCallsAsync($"expiry:{key}")).Should().Be(2, "a burst against an expired entry must coalesce to one re-execution"); + } + + [Fact] + public async Task Concurrent_Invalidations_And_Reads_Never_Error_And_Stay_Consistent() + { + const string key = "inv-race"; + const string readUrl = $"/api/cache-race-invalidate/?k={key}"; + const string invalidateUrl = $"/api/cache-race-invalidate/invalidate?k={key}"; + + // Hammer reads and invalidations concurrently. Correctness bar: no 5xx, every read + // returns the exact payload, every invalidate returns its documented JSON shape. + var tasks = new List(); + for (var i = 0; i < 20; i++) + { + tasks.Add(Task.Run(async () => + { + using var response = await test.Client.GetAsync(readUrl); + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync()).Should().Be($"r:{key}"); + })); + tasks.Add(Task.Run(async () => + { + using var response = await test.Client.GetAsync(invalidateUrl); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(); + // Depending on interleaving the entry may or may not exist at invalidation time. + body.Should().BeOneOf("{\"invalidated\":true}", "{\"invalidated\":false}"); + })); + } + await Task.WhenAll(tasks); + + // The cache must still work after the storm: two reads, at most one new execution between them. + var before = await CountCallsAsync($"inv:{key}"); + var r1 = await test.Client.GetStringAsync(readUrl); + var r2 = await test.Client.GetStringAsync(readUrl); + r1.Should().Be($"r:{key}"); + r2.Should().Be($"r:{key}"); + var after = await CountCallsAsync($"inv:{key}"); + (after - before).Should().BeInRange(0, 1, "the second read must be served from cache"); + } +} diff --git a/NpgsqlRestTests/SseTests/SseConcurrencyTests.cs b/NpgsqlRestTests/SseTests/SseConcurrencyTests.cs new file mode 100644 index 00000000..f5c4a379 --- /dev/null +++ b/NpgsqlRestTests/SseTests/SseConcurrencyTests.cs @@ -0,0 +1,133 @@ +namespace NpgsqlRestTests.SseTests; + +using NpgsqlRestTests.Setup; + +/// +/// Concurrency and lifecycle coverage for the SSE broadcaster: multiple simultaneous subscribers, +/// exactly-once delivery per subscriber, per-stream ordering, and subscriber disconnect not +/// affecting the remaining streams. Reuses the sset_* routines from +/// (same fixture/collection, so tests run sequentially against one broadcaster). +/// +[Collection("SseAnnotationTestFixture")] +public class SseConcurrencyTests(SseAnnotationTestFixture test) +{ + private const string SubscribeUrl = "/api/sset-subscribe-user-events/info"; + private const string PublishUrl = "/api/sset-publish-message"; + + private static StringContent Message(string msg) => + new($"{{\"msg\":\"{msg}\"}}", System.Text.Encoding.UTF8, "application/json"); + + [Fact] + public async Task Multiple_Concurrent_Subscribers_Each_Receive_The_Event_Exactly_Once() + { + using var client = test.CreateClient(); + + const int subscriberCount = 5; + var subscribers = new List(); + try + { + for (var i = 0; i < subscriberCount; i++) + { + subscribers.Add(await SseTestClient.OpenAsync(client, SubscribeUrl)); + } + foreach (var s in subscribers) + { + await s.WaitForRegisteredAsync(); + } + + using var publish = await client.PostAsync(PublishUrl, Message("fanout-1")); + publish.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent); + + // Every subscriber gets the event... + foreach (var s in subscribers) + { + var data = await s.ReadDataLineAsync(TimeSpan.FromSeconds(10)); + data.Should().Be("fanout-1", "every concurrent subscriber must receive the broadcast"); + } + + // ...exactly once: the next data line each subscriber sees is the NEXT event, not a duplicate. + using var publish2 = await client.PostAsync(PublishUrl, Message("fanout-2")); + publish2.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent); + + foreach (var s in subscribers) + { + var data = await s.ReadDataLineAsync(TimeSpan.FromSeconds(10)); + data.Should().Be("fanout-2", "no duplication: the second read must be the second event"); + } + } + finally + { + foreach (var s in subscribers) + { + await s.DisposeAsync(); + } + } + } + + [Fact] + public async Task Events_Arrive_In_Publish_Order_On_Each_Stream() + { + using var client = test.CreateClient(); + await using var sse = await SseTestClient.OpenAsync(client, SubscribeUrl); + await sse.WaitForRegisteredAsync(); + + // Publish sequentially (each POST completes before the next starts) - per-stream + // delivery order must match publish order. + for (var i = 0; i < 5; i++) + { + using var publish = await client.PostAsync(PublishUrl, Message($"ordered-{i}")); + publish.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent); + } + + for (var i = 0; i < 5; i++) + { + var data = await sse.ReadDataLineAsync(TimeSpan.FromSeconds(10)); + data.Should().Be($"ordered-{i}", "events must arrive in publish order on a single stream"); + } + } + + [Fact] + public async Task Disconnected_Subscriber_Does_Not_Affect_Remaining_Subscribers() + { + using var client = test.CreateClient(); + + var early = await SseTestClient.OpenAsync(client, SubscribeUrl); + await early.WaitForRegisteredAsync(); + await using var survivor = await SseTestClient.OpenAsync(client, SubscribeUrl); + await survivor.WaitForRegisteredAsync(); + + // Both receive the first event. + using (var publish = await client.PostAsync(PublishUrl, Message("before-disconnect"))) + { + publish.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent); + } + (await early.ReadDataLineAsync(TimeSpan.FromSeconds(10))).Should().Be("before-disconnect"); + (await survivor.ReadDataLineAsync(TimeSpan.FromSeconds(10))).Should().Be("before-disconnect"); + + // Drop one subscriber, then publish twice more - the survivor must keep receiving, + // and publishing must not error against the dead connection. + await early.DisposeAsync(); + + using (var publish = await client.PostAsync(PublishUrl, Message("after-disconnect-1"))) + { + publish.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent); + } + (await survivor.ReadDataLineAsync(TimeSpan.FromSeconds(10))).Should().Be("after-disconnect-1"); + + using (var publish = await client.PostAsync(PublishUrl, Message("after-disconnect-2"))) + { + publish.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent); + } + (await survivor.ReadDataLineAsync(TimeSpan.FromSeconds(10))).Should().Be("after-disconnect-2"); + + // A fresh subscriber after the disconnect still works (broadcaster registry stays healthy). + await using var late = await SseTestClient.OpenAsync(client, SubscribeUrl); + await late.WaitForRegisteredAsync(); + using (var publish = await client.PostAsync(PublishUrl, Message("late-joiner"))) + { + publish.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent); + } + (await late.ReadDataLineAsync(TimeSpan.FromSeconds(10))).Should().Be("late-joiner"); + (await survivor.ReadDataLineAsync(TimeSpan.FromSeconds(10))).Should().Be("late-joiner"); + } +} diff --git a/RELEASING.md b/RELEASING.md index 622ad98c..80548ea7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -29,7 +29,12 @@ The complete release procedure. Written so that someone who has never cut a rele - builds + pushes Docker images: `vbilopav/npgsqlrest:{v,latest}{,-aot,-jit,-arm,-bun}`, - publishes the npm package (version synced from `version.txt`, OIDC trusted publishing). 6. **After the release**: verify the GitHub release page, `docker pull vbilopav/npgsqlrest:latest`, `npm view npgsqlrest version`, and NuGet listing. -7. **Docs**: update the docs site (separate repo, `npgsqlrest-docs`) — changelog page + any feature pages. +7. **Docs**: update the docs site (separate repo, `npgsqlrest-docs`): + - create `docs/guide/changelog/v.md` from this repo's `changelog/v.md`, + - add it to the sidebar in `docs/.vitepress/config.ts` and move the "(Latest)" marker, + - update `docs/guide/changelog/index.md` ("Version X.Y (Latest)" heading), + - regenerate `docs/config/latest.md` from the released `NpgsqlRestClient/appsettings.json` (update its version references and download links), + - update any feature pages affected by the release. ## Required repository secrets From 54ff9d132b899dcf56cacab6774cbad514a27aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Fri, 12 Jun 2026 14:13:22 +0200 Subject: [PATCH 33/38] feat: bare @mcp with no HTTP tag is MCP-only (no public HTTP route) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTTP tag controls the REST route; @mcp controls the tool — independently. Previously an MCP-only tool required HTTP GET + @internal + @mcp (declare a route, then hide it) for SQL files, and bare @mcp on a function silently kept a public HTTP route. - Core: an endpoint created SOLELY by a plugin request (no HTTP tag, under OnlyAnnotated/OnlyWithHttpTag) defaults to InternalOnly — the plugin asked for a projection (an MCP tool), not a route, so opting into MCP never silently widens the HTTP surface. An explicit HTTP tag keeps dual exposure; @internal remains the explicit way to hide a declared route. A debug log explains the defaulting at startup. ParseAll mode unchanged. - IEndpointCreateHandler.EndpointRequestingAnnotations (new default-interface property, default empty): the annotation keywords for which a handler requests endpoints. Mcp advertises mcp/mcp_name/mcp_description/mcp_desc. - SqlFileSource: the textual pre-gate now passes files whose comment carries an HTTP tag OR an endpoint-requesting plugin annotation, so a bare-@mcp .sql file becomes an MCP-only tool; scripts with neither are still skipped without ever being described (retires the in-code 'revisit' note). Tests: function case flipped (bare @mcp -> InternalOnly), SQL-file bare-@mcp case (tool in catalog, callable over MCP, REST 404), annotation-less script exposed nowhere. Full suite green; AOT publish clean. Verified live in the docs-repo example: inventory_report.sql reduced to a single @mcp line. --- NpgsqlRest/Defaults/DefaultCommentParser.cs | 10 +++ NpgsqlRest/IEndpointCreateHandler.cs | 13 ++++ NpgsqlRest/Log.cs | 3 + .../McpTests/McpPluginAnnotationTests.cs | 14 +++-- NpgsqlRestTests/McpTests/McpSqlFileTests.cs | 24 +++++++- .../Setup/McpSqlFileTestFixture.cs | 14 +++++ changelog/v3.17.0.md | 5 +- plugins/NpgsqlRest.Mcp/Mcp.cs | 15 ++++- .../NpgsqlRest.SqlFileSource/SqlFileSource.cs | 61 +++++++++++++++++-- 9 files changed, 143 insertions(+), 16 deletions(-) diff --git a/NpgsqlRest/Defaults/DefaultCommentParser.cs b/NpgsqlRest/Defaults/DefaultCommentParser.cs index 43338744..fe83fffa 100644 --- a/NpgsqlRest/Defaults/DefaultCommentParser.cs +++ b/NpgsqlRest/Defaults/DefaultCommentParser.cs @@ -554,6 +554,16 @@ internal static partial class DefaultCommentParser { return null; } + // An endpoint that exists ONLY because a plugin requested it (no HTTP tag under a gating + // mode) gets no public HTTP route: the plugin asked for a projection (e.g. an MCP tool), + // not a route — `mcp` alone must not silently widen the HTTP surface. An explicit HTTP tag + // opts into dual exposure; `internal` remains the explicit override when an HTTP tag exists. + if (Options.CommentsMode is CommentsMode.OnlyWithHttpTag or CommentsMode.OnlyAnnotated + && !hasHttpTag && anyHandlerRequestedEndpoint && routineEndpoint.InternalOnly is false) + { + routineEndpoint.InternalOnly = true; + Logger?.PluginRequestedEndpointInternalOnly(description); + } // Detect proxy response parameters after all annotations are processed. // This must run after param renames so that renamed parameters (e.g., $1 → _proxy_body) diff --git a/NpgsqlRest/IEndpointCreateHandler.cs b/NpgsqlRest/IEndpointCreateHandler.cs index 78061c26..637a990f 100644 --- a/NpgsqlRest/IEndpointCreateHandler.cs +++ b/NpgsqlRest/IEndpointCreateHandler.cs @@ -33,6 +33,19 @@ void Setup(IApplicationBuilder builder, NpgsqlRestOptions options) { } /// CommentLineResult? HandleCommentLine(RoutineEndpoint endpoint, string line, string[] words, string[] wordsLower) => null; + /// + /// Annotation keywords (first word of a comment line, lower-case, without the optional @ + /// prefix) for which this handler's returns + /// = true (e.g. mcp, mcp_name). + /// + /// Used by endpoint sources that need a cheap, textual pre-check for exposure intent before the + /// comment parser runs — e.g. the SQL file source skips files with no HTTP tag, but a file whose + /// comment carries one of these keywords is an endpoint candidate (an MCP-only tool) and must + /// not be skipped. Default: empty (this handler never requests endpoints). + /// + /// + string[] EndpointRequestingAnnotations => []; + /// /// After successful endpoint creation. /// diff --git a/NpgsqlRest/Log.cs b/NpgsqlRest/Log.cs index 764be303..2fe44391 100644 --- a/NpgsqlRest/Log.cs +++ b/NpgsqlRest/Log.cs @@ -128,6 +128,9 @@ public static partial class Log [LoggerMessage(Level = LogLevel.Warning, Message = "{description} uses a parameter placeholder '{placeholder}' in an annotation value, but no routine parameter matches it (checked converted and original names). The placeholder will be left as literal text — check for a typo.")] public static partial void CommentUnknownPlaceholder(this ILogger logger, string description, string placeholder); + [LoggerMessage(Level = LogLevel.Debug, Message = "{description} was created by a plugin annotation without an HTTP tag — defaulting to internal-only (no public HTTP route). Add an HTTP tag (e.g. HTTP GET) to also expose it as an HTTP endpoint.")] + public static partial void PluginRequestedEndpointInternalOnly(this ILogger logger, string description); + [LoggerMessage(Level = LogLevel.Trace, Message = "{description} has set CACHED with parameters {cachedParams} by the comment annotation.")] public static partial void CommentCached(this ILogger logger, string description, IEnumerable cachedParams); diff --git a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs index 02d43440..cde3c508 100644 --- a/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs +++ b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs @@ -33,13 +33,14 @@ HTTP GET @mcp @mcp_name cancel_booking'; --- MCP-only: @mcp + @internal, NO HTTP tag -> created (mcp requests endpoint) + InternalOnly +-- @mcp + @internal, NO HTTP tag -> created (mcp requests endpoint) + InternalOnly (explicit) create function mcp.tool_mcp_only() returns text language sql as 'select ''o'''; comment on function mcp.tool_mcp_only() is ' @mcp @internal'; --- @mcp alone, no HTTP tag, no @internal -> created (mcp requests endpoint), HTTP route kept +-- bare @mcp, no HTTP tag -> MCP-ONLY: created (mcp requests endpoint) and InternalOnly by default. +-- A plugin-requested endpoint with no HTTP tag must not silently open a public HTTP route. create function mcp.tool_mcp_no_http() returns text language sql as 'select ''nh'''; comment on function mcp.tool_mcp_no_http() is ' @mcp'; @@ -162,11 +163,14 @@ public void Mcp_plus_internal_is_mcp_only_no_http_route() } [Fact] - public void Mcp_alone_creates_endpoint_and_keeps_http() + public void Mcp_alone_without_http_tag_is_mcp_only() { - var e = test.Endpoints["tool_mcp_no_http"]; // created despite no HTTP tag (mcp requests it) + // Bare @mcp, no HTTP tag: the endpoint exists only because the plugin requested it, so it + // defaults to internal-only — opting into MCP must not silently open a public HTTP route. + // (`HTTP GET` + @mcp = dual exposure; tool_basic covers that.) + var e = test.Endpoints["tool_mcp_no_http"]; Info(e)!.Enabled.Should().BeTrue(); - e.InternalOnly.Should().BeFalse(); // HTTP+MCP (no @internal) + e.InternalOnly.Should().BeTrue(); } [Fact] diff --git a/NpgsqlRestTests/McpTests/McpSqlFileTests.cs b/NpgsqlRestTests/McpTests/McpSqlFileTests.cs index 1c607ddd..d68ea9bd 100644 --- a/NpgsqlRestTests/McpTests/McpSqlFileTests.cs +++ b/NpgsqlRestTests/McpTests/McpSqlFileTests.cs @@ -20,7 +20,29 @@ private async Task CallAsync(string tool) } [Fact] - public void SqlFile_tools_are_in_the_catalog() => string.Join(",", test.Tools.Keys.OrderBy(k => k)).Should().Be("mcp_sql_multi,mcp_sql_single"); + public void SqlFile_tools_are_in_the_catalog() + // mcp_sql_mcp_only is the bare-@mcp file (no HTTP tag); not_an_endpoint (no annotations) is absent. + => string.Join(",", test.Tools.Keys.OrderBy(k => k)).Should().Be("mcp_sql_mcp_only,mcp_sql_multi,mcp_sql_single"); + + [Fact] + public async Task Bare_mcp_sql_file_without_http_tag_is_an_mcp_only_tool() + { + // Callable over MCP... + (await CallAsync("mcp_sql_mcp_only")).Should().Be( + """{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"items\":[7]}"}],"isError":false,"structuredContent":{"items":[7]}}}"""); + + // ...but with NO public REST route (internal-only by default — no HTTP tag was declared). + using var rest = await test.Client.GetAsync("/api/mcp-sql-mcp-only"); + rest.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Sql_file_without_any_annotation_is_not_exposed_anywhere() + { + // Not a tool (asserted in the catalog test) and not a REST endpoint. + using var rest = await test.Client.GetAsync("/api/not-an-endpoint"); + rest.StatusCode.Should().Be(HttpStatusCode.NotFound); + } [Fact] public async Task Single_command_sql_file_tool_executes() diff --git a/NpgsqlRestTests/Setup/McpSqlFileTestFixture.cs b/NpgsqlRestTests/Setup/McpSqlFileTestFixture.cs index 1eace27c..486fdf6f 100644 --- a/NpgsqlRestTests/Setup/McpSqlFileTestFixture.cs +++ b/NpgsqlRestTests/Setup/McpSqlFileTestFixture.cs @@ -45,6 +45,20 @@ public McpSqlFileTestFixture() select 2 as b; """); + // MCP-ONLY: bare @mcp, NO HTTP tag. The file must pass the SqlFileSource pre-gate (the `mcp` + // annotation is endpoint-requesting), become a tool, and default to internal-only (no REST route). + File.WriteAllText(Path.Combine(_sqlDir, "mcp_sql_mcp_only.sql"), """ + -- @mcp MCP-only SQL file tool. + select 7 as lucky; + """); + + // No HTTP tag, no endpoint-requesting annotation: a utility script matching the glob. Must be + // skipped by the pre-gate — no endpoint, no tool. + File.WriteAllText(Path.Combine(_sqlDir, "not_an_endpoint.sql"), """ + -- just a utility script, not an endpoint + select 'should never be exposed'; + """); + var builder = WebApplication.CreateBuilder(); builder.WebHost.UseUrls("http://127.0.0.1:0"); _app = builder.Build(); diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index 26f46f6d..26b61150 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -18,7 +18,7 @@ NpgsqlRest can now expose opted-in PostgreSQL routines as **MCP tools**, so an A - `mcp ` — expose, with `` as an inline (explicit) description. - `mcp_description ` (alias `mcp_desc`) — explicit, authoritative description. - `mcp_name ` — override the tool name (default: the routine name). -- `mcp` + `internal` — expose as an MCP tool with **no** HTTP route. All other annotations (`authorize`, parameter handling, …) apply unchanged. +- **A bare `mcp` with no HTTP tag is MCP-only** — the tool exists with **no** public HTTP route. The HTTP tag controls the REST route and `mcp` controls the tool, independently: `HTTP GET` + `mcp` = both interfaces; `mcp` alone = tool only (an endpoint that exists solely because a plugin requested it defaults to internal-only, so opting into MCP never silently widens the HTTP surface); `internal` remains the explicit way to hide a declared HTTP route. Works identically for SQL file endpoints (a `.sql` file with `mcp` and no HTTP tag becomes an MCP-only tool; files with neither annotation are skipped as non-endpoint scripts, as before). All other annotations (`authorize`, parameter handling, …) apply unchanged. Description precedence — the highest-priority source that is present wins, **regardless of the order the lines appear** in the comment; an explicit description **suppresses** the comment-prose fallback, so unrelated comment lines never leak in: `mcp_description` › inline `mcp ` › comment prose › routine name. @@ -54,7 +54,8 @@ Neutral, plugin-facing hooks were added so a plugin can own its comment annotati - **`IEndpointCreateHandler.HandleCommentLine(...)`** (new default-interface method) — core offers each unrecognized comment line to handlers within its single parse pass; a handler claims it by returning a `CommentLineResult` (a log label + `RequestsEndpoint`). Tokens are pre-split by core. Non-breaking. - **`RoutineEndpoint.Items`** (lazy `IDictionary`) + **`TryGetItem`** — a per-endpoint property bag for plugin metadata (the `HttpContext.Items` pattern), namespaced by key. - **`RoutineEndpoint.UnhandledCommentLines`** (`string[]?`) — comment prose that neither core nor any handler claimed. -- **`CommentsMode.OnlyAnnotated`** (new) — creates an endpoint when the comment has an HTTP tag **or** a plugin requests one (so an `mcp`-only routine can exist with no HTTP route). The **client now defaults to `OnlyAnnotated`**; existing `OnlyWithHttpTag` configs are unaffected — it is kept as an identical-behavior alias. +- **`CommentsMode.OnlyAnnotated`** (new) — creates an endpoint when the comment has an HTTP tag **or** a plugin requests one. An endpoint created **solely** by a plugin request (no HTTP tag) defaults to **internal-only** — the plugin asked for a projection (an MCP tool), not a route — so a bare `mcp` is MCP-only (a debug log notes the defaulting). The **client now defaults to `OnlyAnnotated`**; existing `OnlyWithHttpTag` configs are unaffected — it is kept as an identical-behavior alias. +- **`IEndpointCreateHandler.EndpointRequestingAnnotations`** (new default-interface property, default empty) — the annotation keywords for which the handler requests endpoints (Mcp: `mcp`, `mcp_name`, `mcp_description`, `mcp_desc`). Lets sources with a cheap textual pre-gate recognize endpoint candidates: the SQL file source passes a file whose comment carries an HTTP tag **or** one of these keywords, so a bare-`mcp` `.sql` file becomes an MCP-only tool while scripts with neither are still skipped without ever being described. ### `{name}` annotation substitution can resolve allowlisted environment variables diff --git a/plugins/NpgsqlRest.Mcp/Mcp.cs b/plugins/NpgsqlRest.Mcp/Mcp.cs index 99f88bc2..79f2ec7e 100644 --- a/plugins/NpgsqlRest.Mcp/Mcp.cs +++ b/plugins/NpgsqlRest.Mcp/Mcp.cs @@ -24,9 +24,11 @@ namespace NpgsqlRest.Mcp; /// an explicit description suppresses the comment-prose fallback, so unrelated comment lines never leak in: /// mcp_description > inline mcp <text> > comment prose > routine name. /// -/// MCP-only (tool exposed, no public HTTP route) is composed with the core `internal` annotation: -/// `mcp` + `internal`. (With , `mcp` alone creates the -/// endpoint even without an HTTP tag.) +/// Exposure model: the HTTP tag controls the REST route, `mcp` controls the tool — independently. +/// Under (or its alias OnlyWithHttpTag), a bare `mcp` with +/// no HTTP tag is MCP-ONLY: core creates the endpoint because the plugin requested it and defaults it +/// to internal-only, so opting into MCP never silently opens a public HTTP route. An explicit HTTP tag +/// gives dual exposure; `internal` remains the explicit way to hide a declared HTTP route. /// public partial class Mcp(McpOptions options) : IEndpointCreateHandler { @@ -47,6 +49,13 @@ public partial class Mcp(McpOptions options) : IEndpointCreateHandler /// public IReadOnlyDictionary Tools => _tools; + /// + /// All mcp* annotations opt the routine in as a tool, i.e. they request endpoint creation. + /// Lets sources with a textual pre-gate (SqlFileSource) recognize an MCP-only file (bare mcp, + /// no HTTP tag) as an endpoint candidate instead of skipping it as a non-endpoint script. + /// + public string[] EndpointRequestingAnnotations => ["mcp", "mcp_name", "mcp_description", "mcp_desc"]; + public CommentLineResult? HandleCommentLine(RoutineEndpoint endpoint, string line, string[] words, string[] wordsLower) { if (wordsLower.Length == 0) diff --git a/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs b/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs index b8daf2b8..493f53b0 100644 --- a/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs +++ b/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs @@ -96,12 +96,13 @@ private static (Routine, IRoutineSourceParameterFormatter)? ProcessFile( // When CommentsMode gates endpoint creation (OnlyAnnotated, or its back-compat alias // OnlyWithHttpTag), skip files without an HTTP tag BEFORE attempting to describe — avoids - // errors on non-endpoint SQL files (migrations, utility scripts, etc.). - // NOTE: this pre-filter is HTTP-tag-only, so a SQL file exposed solely via a non-HTTP plugin - // annotation (e.g. `mcp` with no HTTP tag) is currently skipped here. Revisit if/when SQL-file - // routines need to be MCP-only. + // errors on non-endpoint SQL files (migrations, utility scripts, etc.). A file whose comment + // carries a plugin endpoint-requesting annotation (e.g. a bare `mcp` — an MCP-only tool) is an + // endpoint candidate too, so it passes the gate; the check stays textual and cheap, so scripts + // with neither are never described. if (options.CommentsMode is NpgsqlRest.CommentsMode.OnlyWithHttpTag or NpgsqlRest.CommentsMode.OnlyAnnotated - && !HasHttpTag(parseResult.Comment)) + && !HasHttpTag(parseResult.Comment) + && !HasEndpointRequestingAnnotation(parseResult.Comment)) { return null; } @@ -736,6 +737,56 @@ private static bool HasHttpTag(string comment) return false; } + /// + /// Check whether the parsed comment contains a plugin endpoint-requesting annotation — a line whose + /// first word (optional @ prefix stripped) matches a keyword any registered + /// advertises via + /// (e.g. a bare mcp). + /// Such a file is an endpoint candidate (an MCP-only tool) even without an HTTP tag. + /// + private static bool HasEndpointRequestingAnnotation(string comment) + { + if (string.IsNullOrEmpty(comment)) + { + return false; + } + var handlers = NpgsqlRestOptions.Options?.EndpointCreateHandlers; + if (handlers is null) + { + return false; + } + foreach (var line in comment.Split('\n')) + { + var trimmed = line.Trim(); + if (trimmed.Length == 0) + { + continue; + } + var span = trimmed.AsSpan(); + if (span[0] == '@') + { + span = span[1..]; + } + var wordEnd = span.IndexOfAny(' ', '\t'); + var word = wordEnd < 0 ? span : span[..wordEnd]; + if (word.Length == 0) + { + continue; + } + foreach (var handler in handlers) + { + foreach (var key in handler.EndpointRequestingAnnotations) + { + if (word.Equals(key, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + } + return false; + } + /// /// Derive a module name from the file's position relative to the base scan directory. /// Stored in Routine.Metadata as the raw directory name. From 4987719031f421e0bac215501cda09a5175af6eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Fri, 12 Jun 2026 14:21:03 +0200 Subject: [PATCH 34/38] =?UTF-8?q?fix:=20/stats/indexes=20500=20on=20Postgr?= =?UTF-8?q?eSQL=2015=20=E2=80=94=20last=5Fidx=5Fscan=20exists=20only=20on?= =?UTF-8?q?=2016+?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Indexes stats query selected pg_stat_user_indexes.last_idx_scan, a column added in PostgreSQL 16, so the endpoint returned 500 (column does not exist) on PostgreSQL 15 — caught by the CI matrix (15/16/17); local PG 16 passed. Read the column via a row->jsonb key lookup instead: (to_jsonb(s) ->> 'last_idx_scan')::timestamptz — on 16+ it yields the same value as the native column (verified), on 15 the key is absent and it yields NULL instead of erroring. One portable query, no server-version branching. Verified against a real postgres:15 container and PG 16; the remaining stats queries were audited and use only pre-15 catalog columns (no pg_stat_bgwriter / pg_stat_io usage, so 17 is unaffected). --- NpgsqlRestClient/StatsEndpoints.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/NpgsqlRestClient/StatsEndpoints.cs b/NpgsqlRestClient/StatsEndpoints.cs index 44b30581..2a68d2b3 100644 --- a/NpgsqlRestClient/StatsEndpoints.cs +++ b/NpgsqlRestClient/StatsEndpoints.cs @@ -69,7 +69,9 @@ from pg_stat_user_tables indisunique as is_unique, indisprimary as is_primary, idx_scan AS scans, - last_idx_scan as last_scan, + -- last_idx_scan exists only on PostgreSQL 16+. Reading it via a row->jsonb key lookup + -- yields NULL (instead of a column-does-not-exist error) on 15, keeping one portable query. + (to_jsonb(s) ->> 'last_idx_scan')::timestamptz as last_scan, idx_tup_fetch AS tuples_fetched, pg_get_indexdef(indexrelid) as definition from pg_stat_user_indexes s From 35fd10f86ed759b94898bb28e3a34e9d8f8f3cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Fri, 12 Jun 2026 14:37:01 +0200 Subject: [PATCH 35/38] =?UTF-8?q?chore:=20warning-free=20from-source=20bui?= =?UTF-8?q?lds=20=E2=80=94=20null-forgiveness=20at=20guarded=20sites,=20st?= =?UTF-8?q?ale=20doc=20paramref?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dotnet build/run from source emitted 9 long-standing warnings (invisible to users of the published binary): - 8x CS8602/CS8604 in NpgsqlRestEndpoint.cs: queryCollection (4) and bodyDict (4) dereferences inside the parameter-processing loops. Both variables are guaranteed non-null by early-return guards at the top of their respective RequestParamType blocks (404 when queryCollection is null, 400 when bodyDict is null), but the compiler's flow analysis loses the narrowing inside the large loops. Annotated the 8 sites with the null-forgiving operator (the existing house idiom — commandTextBuilder!/cmdLog!) and documented the guarantee at both guards. No behavior change. - 1x CS1734 in RoutineInvoker.cs: the class-level doc used , but paramref is only valid on the member that declares the parameter — reworded to plain text. Builds of core, client, and plugins are now warning-clean; full suite green. --- NpgsqlRest/NpgsqlRestEndpoint.cs | 20 ++++++++++++-------- NpgsqlRest/RoutineInvoker.cs | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/NpgsqlRest/NpgsqlRestEndpoint.cs b/NpgsqlRest/NpgsqlRestEndpoint.cs index a12be203..85ab7914 100644 --- a/NpgsqlRest/NpgsqlRestEndpoint.cs +++ b/NpgsqlRest/NpgsqlRestEndpoint.cs @@ -310,6 +310,8 @@ public async Task InvokeAsync(HttpContext context, IServiceProvider serviceProvi // start query string parameters if (endpoint.RequestParamType == RequestParamType.QueryString) { + // Early-return guard: queryCollection is non-null for the rest of this block (later + // dereferences use `!` where the compiler's flow analysis loses the narrowing). if (queryCollection is null) { shouldCommit = false; @@ -391,7 +393,7 @@ public async Task InvokeAsync(HttpContext context, IServiceProvider serviceProvi if (parameter.HashOf is not null) { - var hashValueQueryCollection = queryCollection.TryGetValue(parameter.HashOf.ConvertedName, out var hashQsValue) ? hashQsValue.ToString() : null; + var hashValueQueryCollection = queryCollection!.TryGetValue(parameter.HashOf.ConvertedName, out var hashQsValue) ? hashQsValue.ToString() : null; if (string.IsNullOrEmpty(hashValueQueryCollection) is true) { parameter.Value = DBNull.Value; @@ -620,7 +622,7 @@ parameter.TypeDescriptor.HasDefault is true && ) ) { - if (queryCollection.ContainsKey(parameter.ConvertedName) is false) + if (queryCollection!.ContainsKey(parameter.ConvertedName) is false) { if (headers is null) { @@ -801,7 +803,7 @@ await Options.ValidateParametersAsync(new ParameterValidationValues( } } - if (queryCollection.TryGetValue(parameter.ConvertedName, out var qsValue) is false) + if (queryCollection!.TryGetValue(parameter.ConvertedName, out var qsValue) is false) { if (parameter.Value is null) { @@ -969,7 +971,7 @@ await Options.ValidateParametersAsync(new ParameterValidationValues( // Skip query string validation for passthrough proxy endpoints - query will be forwarded as-is if (!(endpoint.IsProxy && Options.ProxyOptions.Enabled && !endpoint.HasProxyResponseParameters)) { - foreach (var queryKey in queryCollection.Keys) + foreach (var queryKey in queryCollection!.Keys) { if (routine.ParamsHash.Contains(queryKey) is false) { @@ -985,6 +987,8 @@ await Options.ValidateParametersAsync(new ParameterValidationValues( // start json body parameters else if (endpoint.RequestParamType == RequestParamType.BodyJson) { + // Early-return guard: bodyDict is non-null for the rest of this block (later + // dereferences use `!` where the compiler's flow analysis loses the narrowing). if (bodyDict is null) { // Body was present but is not a parseable JSON object - this is a client error, @@ -1069,7 +1073,7 @@ await Options.ValidateParametersAsync(new ParameterValidationValues( if (parameter.HashOf is not null) { - var hashValueBodyDict = bodyDict.GetValueOrDefault(parameter.HashOf.ConvertedName)?.ToString(); + var hashValueBodyDict = bodyDict!.GetValueOrDefault(parameter.HashOf.ConvertedName)?.ToString(); if (string.IsNullOrEmpty(hashValueBodyDict) is true) { parameter.Value = DBNull.Value; @@ -1123,7 +1127,7 @@ parameter.TypeDescriptor.HasDefault is true && ) ) { - if (bodyDict.ContainsKey(parameter.ConvertedName) is false) + if (bodyDict!.ContainsKey(parameter.ConvertedName) is false) { if (headers is null) { @@ -1304,7 +1308,7 @@ await Options.ValidateParametersAsync(new ParameterValidationValues( } } - if (bodyDict.TryGetValue(parameter.ConvertedName, out var value) is false) + if (bodyDict!.TryGetValue(parameter.ConvertedName, out var value) is false) { if (parameter.Value is null) { @@ -1471,7 +1475,7 @@ await Options.ValidateParametersAsync(new ParameterValidationValues( // Skip body validation for passthrough proxy endpoints - body will be forwarded as-is if (!(endpoint.IsProxy && Options.ProxyOptions.Enabled && !endpoint.HasProxyResponseParameters)) { - foreach (var bodyKey in bodyDict.Keys) + foreach (var bodyKey in bodyDict!.Keys) { if (routine.ParamsHash.Contains(bodyKey) is false) { diff --git a/NpgsqlRest/RoutineInvoker.cs b/NpgsqlRest/RoutineInvoker.cs index 0d9ba516..86938da0 100644 --- a/NpgsqlRest/RoutineInvoker.cs +++ b/NpgsqlRest/RoutineInvoker.cs @@ -15,7 +15,7 @@ namespace NpgsqlRest; /// access to core internals. /// /// Available only after UseNpgsqlRest has built the endpoints (see ). -/// Pass to run as a specific principal — execution-level authorization +/// Pass the user argument to run as a specific principal — execution-level authorization /// (`authorize`) and claims-to-parameter binding then apply as for a real authenticated request. /// /// From b143131ae567750cd2dc27dba1d6f148d0106d21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Sat, 13 Jun 2026 11:05:16 +0200 Subject: [PATCH 36/38] fix: exclude internal-only endpoints from generated client artifacts and API docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endpoints marked internal (no public HTTP route — proxy/HTTP-type-callable, or a bare-@mcp MCP-only routine) were still emitted into the generated TypeScript client (a fetch wrapper), the .http file (a request line), and the OpenAPI document (a path entry), all targeting a route that 404s. Surfaced by the new MCP-only mode: a bare @mcp routine has no HTTP route, yet sqlApi.ts shipped an inventoryReport() function, sql.http a GET line, and the OpenAPI doc a path — dead artifacts advertising a missing endpoint. TsClient.Run, HttpFile.Handle, and OpenApi.Handle now skip endpoint.InternalOnly. Tests: the TS client / .http file omit an internal endpoint (present a visible sibling); the OpenAPI document omits an internal-only path. Plugin versions bumped for the changes shipped this release: OpenApi 1.1.0 -> 1.2.0 (annotation parsing moved out of core + this guard) SqlFileSource 1.0.0 -> 1.1.0 (bare-@mcp pre-gate via EndpointRequestingAnnotations) TsClient 1.36.0 -> 1.36.1 (internal-only exclusion fix) HttpFiles 1.4.2 -> 1.4.3 (internal-only exclusion fix) Full suite green (2260); AOT publish clean. --- .../OpenApiTests/OpenApiFilterTests.cs | 21 ++++++++ .../InternalEndpointExclusionTests.cs | 53 +++++++++++++++++++ changelog/v3.17.0.md | 4 ++ plugins/NpgsqlRest.HttpFiles/HttpFile.cs | 6 +++ .../NpgsqlRest.HttpFiles.csproj | 8 +-- .../NpgsqlRest.OpenApi.csproj | 8 +-- plugins/NpgsqlRest.OpenApi/OpenApi.cs | 6 +++ .../NpgsqlRest.SqlFileSource.csproj | 8 +-- .../NpgsqlRest.TsClient.csproj | 8 +-- plugins/NpgsqlRest.TsClient/TsClient.cs | 4 +- 10 files changed, 109 insertions(+), 17 deletions(-) create mode 100644 NpgsqlRestTests/TsClientTests/InternalEndpointExclusionTests.cs diff --git a/NpgsqlRestTests/OpenApiTests/OpenApiFilterTests.cs b/NpgsqlRestTests/OpenApiTests/OpenApiFilterTests.cs index 6f68d4b5..9a3476f7 100644 --- a/NpgsqlRestTests/OpenApiTests/OpenApiFilterTests.cs +++ b/NpgsqlRestTests/OpenApiTests/OpenApiFilterTests.cs @@ -75,6 +75,7 @@ private static RoutineEndpoint MakeEndpoint( string path, bool requiresAuthorization = false, bool openApiHide = false, + bool internalOnly = false, string[]? openApiTags = null) { var routine = MakeRoutine(schema, name); @@ -91,6 +92,8 @@ private static RoutineEndpoint MakeEndpoint( bodyParameterName: null, textResponseNullHandling: TextResponseNullHandling.EmptyString, queryStringNullHandling: QueryStringNullHandling.EmptyString); + // An endpoint with no public HTTP route (proxy / HTTP-type-callable / bare-@mcp MCP-only). + endpoint.InternalOnly = internalOnly; // openapi hide/tags are parsed by the plugin's HandleCommentLine hook (which core invokes // during its single parse pass) and stashed in endpoint.Items. Drive that hook directly so // the test exercises the real parse path rather than pre-seeding Items. @@ -157,6 +160,24 @@ public void OpenApiHide_skips_endpoint_from_document() DocHasPath(doc, "/api/hidden").Should().BeFalse("OpenApiHide=true must exclude the endpoint"); } + // ------------------------------------------------------------------------ + // InternalOnly (no public HTTP route — proxy / HTTP-type-callable / bare-@mcp MCP-only) + // ------------------------------------------------------------------------ + + [Fact] + public void InternalOnly_endpoint_is_not_documented() + { + // An internal-only endpoint has no public HTTP route, so documenting it would advertise a + // path that 404s — the same leak fixed for the TS client and .http file generators. + var doc = RunHandler(new OpenApiOptions(), + MakeEndpoint("public", "visible_fn", "/api/visible"), + MakeEndpoint("public", "mcp_only_fn", "/api/mcp-only", internalOnly: true)); + + DocHasPath(doc, "/api/visible").Should().BeTrue("a public-route endpoint must be documented"); + DocHasPath(doc, "/api/mcp-only").Should().BeFalse( + "an internal-only endpoint has no public route, so it must not appear in the OpenAPI document"); + } + // ------------------------------------------------------------------------ // RequiresAuthorizationOnly // ------------------------------------------------------------------------ diff --git a/NpgsqlRestTests/TsClientTests/InternalEndpointExclusionTests.cs b/NpgsqlRestTests/TsClientTests/InternalEndpointExclusionTests.cs new file mode 100644 index 00000000..96945320 --- /dev/null +++ b/NpgsqlRestTests/TsClientTests/InternalEndpointExclusionTests.cs @@ -0,0 +1,53 @@ +namespace NpgsqlRestTests +{ + public static partial class Database + { + public static void TsClientInternalExclusionTests() + { + script.Append(""" +create schema if not exists tsclient_test; + +-- internal-only endpoint: has no public HTTP route, so it must NOT appear in the generated +-- TypeScript client or the .http file (a function/request line for it would 404). +create function tsclient_test.internal_widget(_x int) returns int language sql as 'select _x'; +comment on function tsclient_test.internal_widget(int) is ' +HTTP GET +internal +tsclient_module=internal_widget'; + +-- visible sibling: anchors that generation ran for this schema (its artifacts are present). +create function tsclient_test.visible_widget(_x int) returns int language sql as 'select _x'; +comment on function tsclient_test.visible_widget(int) is ' +HTTP GET +tsclient_module=visible_widget'; +"""); + } + } +} + +namespace NpgsqlRestTests.TsClientTests +{ + [Collection("TestFixture")] + public class InternalEndpointExclusionTests + { + [Fact] + public void Internal_endpoint_is_excluded_from_generated_ts_client() + { + var visible = Path.Combine(Setup.Program.TsClientOutputPath, "visible_widget.ts"); + var internalOnly = Path.Combine(Setup.Program.TsClientOutputPath, "internal_widget.ts"); + + File.Exists(visible).Should().BeTrue("the visible endpoint anchors that TS generation ran"); + File.ReadAllText(visible).Should().Contain("/api/tsclient-test/visible-widget"); + + File.Exists(internalOnly).Should().BeFalse("an internal-only endpoint has no public route, so no client function is generated"); + } + + [Fact] + public void Internal_endpoint_is_excluded_from_generated_http_file() + { + var http = File.ReadAllText(Path.Combine(Setup.Program.HttpFilesOutputPath, "npgsqlrest.http")); + http.Should().Contain("/api/tsclient-test/visible-widget"); + http.Should().NotContain("/api/tsclient-test/internal-widget"); + } + } +} diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index 26b61150..4bdcc5fd 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -89,6 +89,10 @@ The public properties **`RoutineEndpoint.OpenApiHide` and `RoutineEndpoint.OpenA ## Fixes +### Internal-only endpoints are excluded from generated client artifacts and API docs + +Endpoints marked `internal` (no public HTTP route — proxy/HTTP-type-callable, or now a bare-`mcp` MCP-only routine) were still emitted into the generated TypeScript client (a `fetch` wrapper), the generated `.http` file (a request line), and the generated **OpenAPI document** (a path entry). All target a route that returns `404`, so the generated artifacts advertised endpoints that don't exist. The `TsClient`, `HttpFiles`, and `OpenApi` plugins now skip `InternalOnly` endpoints. (Surfaced by the new MCP-only mode, where a bare `@mcp` routine has no HTTP route.) + ### 🔴 Security: SSE scope hints were not enforced — hint-scoped events were delivered to every subscriber Events published with a per-event scope override — `RAISE INFO ... USING HINT = 'authorize'` or `USING HINT = 'authorize ...'` — were delivered to **all** connected SSE subscribers, including subscribers without the named role **and unauthenticated subscribers**. The hint was parsed correctly, but a control-flow bug (`else if` chaining) skipped the authorization checks whenever a hint was present. Endpoint-level scoping via the `sse_scope` annotation (no hint) was NOT affected. diff --git a/plugins/NpgsqlRest.HttpFiles/HttpFile.cs b/plugins/NpgsqlRest.HttpFiles/HttpFile.cs index 6fff5cb5..207eae06 100644 --- a/plugins/NpgsqlRest.HttpFiles/HttpFile.cs +++ b/plugins/NpgsqlRest.HttpFiles/HttpFile.cs @@ -27,6 +27,12 @@ public void Handle(RoutineEndpoint endpoint) { return; } + // Internal-only endpoints have no public HTTP route (a request would 404), so an .http request + // line for one would be dead — e.g. a bare-`@mcp` MCP-only routine. Skip them. + if (endpoint.InternalOnly) + { + return; + } _endpoint = httpFileOptions.Option is HttpFileOption.Endpoint or HttpFileOption.Both; _file = httpFileOptions.Option is HttpFileOption.File or HttpFileOption.Both; diff --git a/plugins/NpgsqlRest.HttpFiles/NpgsqlRest.HttpFiles.csproj b/plugins/NpgsqlRest.HttpFiles/NpgsqlRest.HttpFiles.csproj index 953ad6b6..74b949e5 100644 --- a/plugins/NpgsqlRest.HttpFiles/NpgsqlRest.HttpFiles.csproj +++ b/plugins/NpgsqlRest.HttpFiles/NpgsqlRest.HttpFiles.csproj @@ -28,10 +28,10 @@ true README.MD bin\$(Configuration)\$(AssemblyName).xml - 1.4.2 - 1.4.2 - 1.4.2 - 1.4.2 + 1.4.3 + 1.4.3 + 1.4.3 + 1.4.3 14 diff --git a/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj b/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj index 33e69fbc..9baebb2b 100644 --- a/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj +++ b/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj @@ -28,10 +28,10 @@ true README.MD bin\$(Configuration)\$(AssemblyName).xml - 1.1.0 - 1.1.0 - 1.1.0 - 1.1.0 + 1.2.0 + 1.2.0 + 1.2.0 + 1.2.0 14 diff --git a/plugins/NpgsqlRest.OpenApi/OpenApi.cs b/plugins/NpgsqlRest.OpenApi/OpenApi.cs index 5b56cdd6..c29d9399 100644 --- a/plugins/NpgsqlRest.OpenApi/OpenApi.cs +++ b/plugins/NpgsqlRest.OpenApi/OpenApi.cs @@ -124,6 +124,12 @@ public void Handle(RoutineEndpoint endpoint) // Filter gate — applied in order of decreasing specificity. First match short-circuits. // The endpoint itself remains registered with NpgsqlRest; only its documentation is skipped. + // Internal-only endpoints have no public HTTP route (proxy/HTTP-type-callable, or a bare-@mcp + // MCP-only routine), so documenting them would advertise a path that 404s. + if (endpoint.InternalOnly) + { + return; + } // Per-endpoint opt-out from comment annotation (`openapi hide`). if (openApiHide) { diff --git a/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj b/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj index 5dfced83..f304e879 100644 --- a/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj +++ b/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj @@ -26,10 +26,10 @@ true true bin\$(Configuration)\$(AssemblyName).xml - 1.0.0 - 1.0.0 - 1.0.0 - 1.0.0 + 1.1.0 + 1.1.0 + 1.1.0 + 1.1.0 14 diff --git a/plugins/NpgsqlRest.TsClient/NpgsqlRest.TsClient.csproj b/plugins/NpgsqlRest.TsClient/NpgsqlRest.TsClient.csproj index 8b7d95bb..3c0a3531 100644 --- a/plugins/NpgsqlRest.TsClient/NpgsqlRest.TsClient.csproj +++ b/plugins/NpgsqlRest.TsClient/NpgsqlRest.TsClient.csproj @@ -26,10 +26,10 @@ true README.MD bin\$(Configuration)\$(AssemblyName).xml - 1.36.0 - 1.36.0 - 1.36.0 - 1.36.0 + 1.36.1 + 1.36.1 + 1.36.1 + 1.36.1 14 diff --git a/plugins/NpgsqlRest.TsClient/TsClient.cs b/plugins/NpgsqlRest.TsClient/TsClient.cs index 3a8ab335..3dccc2d6 100644 --- a/plugins/NpgsqlRest.TsClient/TsClient.cs +++ b/plugins/NpgsqlRest.TsClient/TsClient.cs @@ -103,7 +103,9 @@ private void Run(RoutineEndpoint[] endpoints, string? fileName) return; } - RoutineEndpoint[] filtered = [.. endpoints.Where(e => e.CustomParameters.ParameterEnabled(Enabled) is not false)]; + // Internal-only endpoints have no public HTTP route (404), so a generated client function for one + // would be dead — e.g. a bare-`@mcp` MCP-only routine. Exclude them from the REST client. + RoutineEndpoint[] filtered = [.. endpoints.Where(e => e.InternalOnly is false && e.CustomParameters.ParameterEnabled(Enabled) is not false)]; Dictionary modelsDict = []; Dictionary names = []; From 32271f43380bb034b7ebde5dee3dab1943568d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Sat, 13 Jun 2026 11:56:54 +0200 Subject: [PATCH 37/38] feat(tsclient): ExportTypes option to emit importable export interfaces Adds NpgsqlRest.TsClient ExportTypes option (default false). When true, request/response/composite interfaces are emitted as `export interface`. With CreateSeparateTypeFile=true the separate type file becomes an importable module {name}Types.ts (instead of ambient {name}Types.d.ts) and the client file imports the named types from it; inline mode exports them in the same file. No effect when SkipTypes=true. Default keeps existing output byte-for-byte unchanged. Wired through the client config (appsettings, defaults, template, schema) and covered by a separate-module generation test. --- NpgsqlRestClient/App.cs | 1 + NpgsqlRestClient/ConfigDefaults.cs | 1 + NpgsqlRestClient/ConfigSchemaGenerator.cs | 1 + NpgsqlRestClient/ConfigTemplate.cs | 4 + NpgsqlRestClient/appsettings.json | 4 + NpgsqlRestTests/Setup/Program.cs | 19 ++++ .../TsClientTests/ExportTypesTests.cs | 101 ++++++++++++++++++ changelog/v3.17.0.md | 9 ++ plugins/NpgsqlRest.TsClient/TsClient.cs | 52 +++++++-- .../NpgsqlRest.TsClient/TsClientOptions.cs | 8 ++ 10 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 NpgsqlRestTests/TsClientTests/ExportTypesTests.cs diff --git a/NpgsqlRestClient/App.cs b/NpgsqlRestClient/App.cs index a00a93ce..25d5c636 100644 --- a/NpgsqlRestClient/App.cs +++ b/NpgsqlRestClient/App.cs @@ -515,6 +515,7 @@ public List CreateCodeGenHandlers(string connectionStrin BySchema = _config.GetConfigBool("BySchema", tsClientCfg, true), IncludeStatusCode = _config.GetConfigBool("IncludeStatusCode", tsClientCfg, true), CreateSeparateTypeFile = _config.GetConfigBool("CreateSeparateTypeFile", tsClientCfg, true), + ExportTypes = _config.GetConfigBool("ExportTypes", tsClientCfg), ImportBaseUrlFrom = _config.GetConfigStr("ImportBaseUrlFrom", tsClientCfg), ImportParseQueryFrom = _config.GetConfigStr("ImportParseQueryFrom", tsClientCfg), IncludeParseUrlParam = _config.GetConfigBool("IncludeParseUrlParam", tsClientCfg), diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index 4d10165e..4c4f30c9 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -1009,6 +1009,7 @@ private static JsonObject GetClientCodeGenDefaults() ["BySchema"] = true, ["IncludeStatusCode"] = true, ["CreateSeparateTypeFile"] = true, + ["ExportTypes"] = false, ["ImportBaseUrlFrom"] = null, ["ImportParseQueryFrom"] = null, ["IncludeParseUrlParam"] = false, diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index 220368e6..d58b0882 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -463,6 +463,7 @@ public static partial class ConfigSchemaGenerator ["NpgsqlRest:ClientCodeGen:BySchema"] = "Create files by PostgreSQL schema. File name will use formatted FilePath where {0} is the schema name in pascal case.", ["NpgsqlRest:ClientCodeGen:IncludeStatusCode"] = "Set to true to include status code in response: {status: response.status, response: model}", ["NpgsqlRest:ClientCodeGen:CreateSeparateTypeFile"] = "Create separate file with global types {name}Types.d.ts", + ["NpgsqlRest:ClientCodeGen:ExportTypes"] = "Emit interfaces with the `export` keyword so they can be imported by other modules. When true and CreateSeparateTypeFile is true, the separate type file becomes an importable module ({name}Types.ts) instead of an ambient {name}Types.d.ts, and the client file imports the named types from it. No effect when SkipTypes is true.", ["NpgsqlRest:ClientCodeGen:ImportBaseUrlFrom"] = "Module name to import \"baseUrl\" constant, instead of defining it in a module.", ["NpgsqlRest:ClientCodeGen:ImportParseQueryFrom"] = "Module name to import \"parseQuery\" function, instead of defining it in a module.", ["NpgsqlRest:ClientCodeGen:IncludeParseUrlParam"] = "Include optional parameter `parseUrl: (url: string) => string = url=>url` that will parse the constructed URL.", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index ba3bbebc..aaeafd15 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -2679,6 +2679,10 @@ public static partial class ConfigSchemaGenerator // "CreateSeparateTypeFile": true, // + // Emit interfaces with the `export` keyword so they can be imported by other modules. When true and CreateSeparateTypeFile is true, the separate type file becomes an importable module ({name}Types.ts) instead of an ambient {name}Types.d.ts, and the client file imports the named types from it. No effect when SkipTypes is true. + // + "ExportTypes": false, + // // Module name to import "baseUrl" constant, instead of defining it in a module. // "ImportBaseUrlFrom": null, diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index 382ea589..14ba99a4 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -2670,6 +2670,10 @@ // "CreateSeparateTypeFile": true, // + // Emit interfaces with the `export` keyword so they can be imported by other modules. When true and CreateSeparateTypeFile is true, the separate type file becomes an importable module ({name}Types.ts) instead of an ambient {name}Types.d.ts, and the client file imports the named types from it. No effect when SkipTypes is true. + // + "ExportTypes": false, + // // Module name to import "baseUrl" constant, instead of defining it in a module. // "ImportBaseUrlFrom": null, diff --git a/NpgsqlRestTests/Setup/Program.cs b/NpgsqlRestTests/Setup/Program.cs index 10756de7..d1784a98 100644 --- a/NpgsqlRestTests/Setup/Program.cs +++ b/NpgsqlRestTests/Setup/Program.cs @@ -33,6 +33,11 @@ public class Program /// public static string TsClientJsOutputPath { get; } = Path.Combine(Path.GetTempPath(), "NpgsqlRestTests", "TsClientJs"); + /// + /// Output path for TsClient generated files with ExportTypes=true + separate type file (used by tests) + /// + public static string TsClientExportOutputPath { get; } = Path.Combine(Path.GetTempPath(), "NpgsqlRestTests", "TsClientExport"); + /// /// Output path for HttpFiles generated files (used by tests) /// @@ -230,6 +235,20 @@ public static void Main() IncludeStatusCode = false, SkipTypes = true }), + // TsClient configuration for ExportTypes testing - exported interfaces in a separate importable module + new TsClient(new TsClientOptions + { + FilePath = Path.Combine(TsClientExportOutputPath, "{0}.ts"), + FileOverwrite = true, + BySchema = true, + IncludeHost = false, + CreateSeparateTypeFile = true, + ExportTypes = true, + CommentHeader = CommentHeader.None, + HeaderLines = [], + SkipSchemas = ["public", "custom_param_schema", "my_schema", "custom_table_param_schema", ""], + IncludeStatusCode = false + }), // HttpFiles configuration for testing path parameters new HttpFile(new HttpFileOptions { diff --git a/NpgsqlRestTests/TsClientTests/ExportTypesTests.cs b/NpgsqlRestTests/TsClientTests/ExportTypesTests.cs new file mode 100644 index 00000000..13a9c399 --- /dev/null +++ b/NpgsqlRestTests/TsClientTests/ExportTypesTests.cs @@ -0,0 +1,101 @@ +namespace NpgsqlRestTests +{ + public static partial class Database + { + public static void TsClientExportTypesTests() + { + script.Append(""" +create function tsclient_test.search_products(_query text default null, _max_price numeric default null) +returns table (id int, name text, price numeric) +language sql +as $$ +select 1, 'x'::text, 1.0::numeric; +$$; +comment on function tsclient_test.search_products(text, numeric) is ' +HTTP GET +tsclient_module=search_products +'; +"""); + } + } +} + +namespace NpgsqlRestTests.TsClientTests +{ + [Collection("TestFixture")] + public class ExportTypesTests + { + // ExportTypes = true with CreateSeparateTypeFile = true: interfaces live in an importable + // module {name}Types.ts (not an ambient .d.ts), emitted as `export interface`, and the + // client file imports them by name. + private const string ExpectedClient = """ +import type { ITsclientTestSearchProductsRequest, ITsclientTestSearchProductsResponse } from "./search_productsTypes"; +const baseUrl = ""; +const parseQuery = (query: Record) => "?" + Object.keys(query ? query : {}) + .map(key => { + const value = (query[key] != null ? query[key] : "") as string; + if (Array.isArray(value)) { + return value.map((s: string) => s ? `${key}=${encodeURIComponent(s)}` : `${key}=`).join("&"); + } + return `${key}=${encodeURIComponent(value)}`; + }) + .join("&"); + +/** +* +* @param request - Object containing request parameters. +* @returns {ITsclientTestSearchProductsResponse[]} +* +* @see FUNCTION tsclient_test.search_products +*/ +export async function tsclientTestSearchProducts( + request: ITsclientTestSearchProductsRequest +) : Promise { + const response = await fetch(baseUrl + "/api/tsclient-test/search-products" + parseQuery(request), { + method: "GET", + headers: { + "Content-Type": "application/json" + }, + }); + return await response.json() as ITsclientTestSearchProductsResponse[]; +} + +"""; + + private const string ExpectedTypes = """ +export interface ITsclientTestSearchProductsRequest { + query?: string | null; + maxPrice?: number | null; +} + +export interface ITsclientTestSearchProductsResponse { + id: number | null; + name: string | null; + price: number | null; +} + + +"""; + + [Fact] + public void Test_ExportTypes_ClientFile() + { + var filePath = Path.Combine(Setup.Program.TsClientExportOutputPath, "search_products.ts"); + File.Exists(filePath).Should().BeTrue($"Expected file at {filePath}"); + var content = File.ReadAllText(filePath); + content.Should().Be(ExpectedClient); + } + + [Fact] + public void Test_ExportTypes_TypeFile_IsImportableModule() + { + // Importable module: {name}Types.ts, not the ambient {name}Types.d.ts. + var tsPath = Path.Combine(Setup.Program.TsClientExportOutputPath, "search_productsTypes.ts"); + var dtsPath = Path.Combine(Setup.Program.TsClientExportOutputPath, "search_productsTypes.d.ts"); + File.Exists(tsPath).Should().BeTrue($"Expected importable type module at {tsPath}"); + File.Exists(dtsPath).Should().BeFalse($"Ambient declaration file should not be produced at {dtsPath}"); + var content = File.ReadAllText(tsPath); + content.Should().Be(ExpectedTypes); + } + } +} diff --git a/changelog/v3.17.0.md b/changelog/v3.17.0.md index 4bdcc5fd..f5b52704 100644 --- a/changelog/v3.17.0.md +++ b/changelog/v3.17.0.md @@ -70,6 +70,15 @@ Authorization: Bearer {WEATHER_API_KEY}'; - Resolved **once at startup**, matched **case-insensitively**, injected as the **raw** value. A **routine parameter of the same name takes precedence**. - **Security:** a value substituted into a *response* header is sent to the client — reserve secrets for outbound HTTP-type calls / custom parameters, and use response headers only for non-secret values (e.g. server/environment name). +### TsClient: `ExportTypes` — emit request/response interfaces with the `export` keyword + +The TypeScript client generator (`NpgsqlRest.TsClient`) previously emitted its request/response (and composite) interfaces as plain `interface` declarations: module-private when inlined into the client file (`CreateSeparateTypeFile: false`), or ambient/global in the separate `{name}Types.d.ts` file (the default). Neither form could be imported by other modules. The new **`ExportTypes`** option (config `NpgsqlRest:ClientCodeGen:ExportTypes`, default `false`) emits them as `export interface` so they can be imported: + +- **Inline** (`CreateSeparateTypeFile: false`) — interfaces are emitted as `export interface` in the same file as the functions. +- **Separate file** (`CreateSeparateTypeFile: true`) — the type file becomes an importable module **`{name}Types.ts`** (`export interface …`) instead of an ambient **`{name}Types.d.ts`**, and the generated client file gets an `import type { … } from "./{name}Types";` referencing the named types. + +Has no effect when `SkipTypes` is `true`. Defaulting to `false` keeps existing output byte-for-byte unchanged. + ## Breaking Changes ### ⚠️ Safer configuration defaults: CORS credentials, passkey requirements, connection testing diff --git a/plugins/NpgsqlRest.TsClient/TsClient.cs b/plugins/NpgsqlRest.TsClient/TsClient.cs index 3dccc2d6..b3e02d71 100644 --- a/plugins/NpgsqlRest.TsClient/TsClient.cs +++ b/plugins/NpgsqlRest.TsClient/TsClient.cs @@ -117,6 +117,8 @@ private void Run(RoutineEndpoint[] endpoints, string? fileName) StringBuilder interfaces = new(); StringBuilder compositeInterfaces = new(); bool needsStatusTypes = false; + // Plain `interface` (module-private inline / ambient in .d.ts) vs exported `export interface` (importable module). + var interfaceDecl = options.ExportTypes ? "export interface" : "interface"; foreach (var import in options.CustomImports) { @@ -298,7 +300,18 @@ options.ImportBaseUrlFrom is not null ? { if (!options.SkipTypes) { - var typeFile = fileName.Replace(".ts", "Types.d.ts"); + // ExportTypes turns the separate file into an importable module (`{name}Types.ts` with `export interface`); + // otherwise it stays an ambient global declaration file (`{name}Types.d.ts`) referenced without an import. + var typeFile = fileName.Replace(".ts", options.ExportTypes ? "Types.ts" : "Types.d.ts"); + if (options.ExportTypes) + { + var typeNames = ExtractExportedTypeNames(interfaces.ToString()); + if (typeNames.Count > 0) + { + var moduleName = Path.GetFileNameWithoutExtension(typeFile); + contentHeader.Insert(0, $"import type {{ {string.Join(", ", typeNames)} }} from \"./{moduleName}\";{Environment.NewLine}"); + } + } AddHeader(interfaces); File.WriteAllText(typeFile, interfaces.ToString()); Logger?.LogTrace("Created Typescript type file: {typeFile}", typeFile); @@ -497,7 +510,7 @@ bool Handle(RoutineEndpoint endpoint) { modelsDict.Add(req.ToString(), requestName); } - req.Insert(0, $"interface {requestName} {{{Environment.NewLine}"); + req.Insert(0, $"{interfaceDecl} {requestName} {{{Environment.NewLine}"); req.AppendLine("}"); req.AppendLine(); interfaces.Append(req); @@ -547,7 +560,7 @@ string GetReturnExp(string responseExp) responseName = $"I{pascal}Response"; StringBuilder mcResp = new(); - mcResp.AppendLine($"interface {responseName} {{"); + mcResp.AppendLine($"{interfaceDecl} {responseName} {{"); foreach (var cmdInfo in routine.MultiCommandInfo) { if (cmdInfo.IsSkipped) continue; @@ -600,7 +613,7 @@ string GetReturnExp(string responseExp) responseName = $"I{pascal}Response"; StringBuilder resp = new(); - resp.AppendLine($"interface {responseName} {{"); + resp.AppendLine($"{interfaceDecl} {responseName} {{"); resp.AppendLine(" type: string;"); resp.AppendLine(" fileName: string;"); resp.AppendLine(" contentType: string;"); @@ -823,7 +836,7 @@ descriptor.CompositeFieldNames is not null && { modelsDict.Add(resp.ToString(), responseName); } - resp.Insert(0, $"interface {responseName} {{{Environment.NewLine}"); + resp.Insert(0, $"{interfaceDecl} {responseName} {{{Environment.NewLine}"); resp.AppendLine("}"); resp.AppendLine(); interfaces.Append(resp); @@ -1913,7 +1926,7 @@ private string GetOrCreateCompositeInterface( // Build the interface StringBuilder interfaceBuilder = new(); - interfaceBuilder.AppendLine($"interface {interfaceName} {{"); + interfaceBuilder.AppendLine($"{(options.ExportTypes ? "export interface" : "interface")} {interfaceName} {{"); for (var i = 0; i < fieldNames.Length; i++) { @@ -1960,4 +1973,31 @@ private string GetOrCreateCompositeInterface( return interfaceName; } + + /// + /// Extracts the names of `export interface {name} {` declarations from generated type-file content, + /// so the client file can import them. Top-level declarations only — indented field lines and inline + /// object types never start with the keyword, so they are not matched. + /// + private static List ExtractExportedTypeNames(string typeFileContent) + { + const string prefix = "export interface "; + var names = new List(); + foreach (var rawLine in typeFileContent.Split('\n')) + { + var line = rawLine.TrimStart(); + if (!line.StartsWith(prefix, StringComparison.Ordinal)) + { + continue; + } + var rest = line.Substring(prefix.Length); + var brace = rest.IndexOf('{'); + var name = (brace >= 0 ? rest.Substring(0, brace) : rest).Trim(); + if (name.Length > 0 && !names.Contains(name)) + { + names.Add(name); + } + } + return names; + } } \ No newline at end of file diff --git a/plugins/NpgsqlRest.TsClient/TsClientOptions.cs b/plugins/NpgsqlRest.TsClient/TsClientOptions.cs index 0564339e..028eaae5 100644 --- a/plugins/NpgsqlRest.TsClient/TsClientOptions.cs +++ b/plugins/NpgsqlRest.TsClient/TsClientOptions.cs @@ -50,6 +50,14 @@ public class TsClientOptions /// public bool CreateSeparateTypeFile { get; set; } = true; + /// + /// Emit request/response (and composite) interfaces with the `export` keyword so they can be imported by other modules. + /// When false (default), interfaces are emitted as plain `interface` declarations (module-private when inline, ambient/global in the separate `.d.ts` file). + /// When true and CreateSeparateTypeFile is true, the separate type file becomes an importable module (`{name}Types.ts` with `export interface`) instead of an ambient `{name}Types.d.ts`, and the generated client file imports the named types from it. + /// Has no effect when SkipTypes is true. + /// + public bool ExportTypes { get; set; } = false; + /// /// Lines to add to each header. {0} format placeholder is current timestamp /// From 79ed18049ab049c531bdd0f2090b9c312e88c2b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Sat, 13 Jun 2026 12:30:58 +0200 Subject: [PATCH 38/38] feat(mcp): add a JSON body to 401/403 tool-authorization challenges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP server signalled an unauthorized/forbidden tools/call with the spec-required 401/403 + WWW-Authenticate challenge but an empty body. That is valid (the challenge lives in the header), but clients that surface the response body — MCP Inspector, curl — showed only a blank/truncated transport error with no hint of what failed. WriteUnauthorized/WriteForbidden now also write a small RFC 6750-shaped JSON body (error + error_description) via a shared WriteChallengeBodyAsync. Status code and the WWW-Authenticate header are unchanged, so OAuth clients behave identically; only the human/diagnostic detail improves. 401 -> {"error":"invalid_token","error_description":"This tool requires authentication. …"} 403 -> {"error":"insufficient_scope","error_description":"This tool requires a role …"} Extended the existing 401 gate and 403 role tests to assert the content type and exact body. Full suite green (2262); AOT publish clean. --- NpgsqlRestTests/McpTests/McpAuthGateTests.cs | 5 ++++ NpgsqlRestTests/McpTests/McpAuthRoleTests.cs | 4 +++ plugins/NpgsqlRest.Mcp/McpServer.cs | 27 +++++++++++++++----- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/NpgsqlRestTests/McpTests/McpAuthGateTests.cs b/NpgsqlRestTests/McpTests/McpAuthGateTests.cs index 84a86474..6058bf4b 100644 --- a/NpgsqlRestTests/McpTests/McpAuthGateTests.cs +++ b/NpgsqlRestTests/McpTests/McpAuthGateTests.cs @@ -18,6 +18,11 @@ public async Task Unauthenticated_request_is_rejected_with_401_and_prm_challenge // RFC 9728 §5.1: point the client at the Protected Resource Metadata document for this resource. challenge.Should().Contain("resource_metadata="); challenge.Should().Contain("/.well-known/oauth-protected-resource/mcp"); + // Supplementary RFC 6750-shaped body so clients that surface the response body (and humans with + // curl) get an actionable message instead of an empty 401 — the formal challenge stays in the header. + response.Content.Headers.ContentType!.MediaType.Should().Be("application/json"); + (await response.Content.ReadAsStringAsync()).Should().Be( + """{"error":"invalid_token","error_description":"This tool requires authentication. Provide a valid bearer token."}"""); } [Fact] diff --git a/NpgsqlRestTests/McpTests/McpAuthRoleTests.cs b/NpgsqlRestTests/McpTests/McpAuthRoleTests.cs index a7b2a090..7a6bb788 100644 --- a/NpgsqlRestTests/McpTests/McpAuthRoleTests.cs +++ b/NpgsqlRestTests/McpTests/McpAuthRoleTests.cs @@ -37,6 +37,10 @@ public async Task Authenticated_with_the_wrong_role_is_rejected_with_403_insuffi challenge.Should().Contain("error=\"insufficient_scope\""); challenge.Should().Contain("scope=\"mcp.read\""); challenge.Should().Contain("resource_metadata="); + // Supplementary RFC 6750-shaped body (the formal challenge stays in the WWW-Authenticate header). + response.Content.Headers.ContentType!.MediaType.Should().Be("application/json"); + (await response.Content.ReadAsStringAsync()).Should().Be( + """{"error":"insufficient_scope","error_description":"This tool requires a role your token does not have."}"""); } // ---- tools/list role filtering (FilterToolsByRole = true) ------------- diff --git a/plugins/NpgsqlRest.Mcp/McpServer.cs b/plugins/NpgsqlRest.Mcp/McpServer.cs index f5afd761..ae54bdc4 100644 --- a/plugins/NpgsqlRest.Mcp/McpServer.cs +++ b/plugins/NpgsqlRest.Mcp/McpServer.cs @@ -177,20 +177,35 @@ private async Task HandleProtectedResourceMetadataAsync(HttpContext context) /// /// 401: authentication is required or the token is invalid (RFC 9728 §5.1 challenge). /// - private void WriteUnauthorized(HttpContext context) + private Task WriteUnauthorized(HttpContext context) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.Headers.Append("WWW-Authenticate", BearerChallenge(context, error: null)); + return WriteChallengeBodyAsync(context, "invalid_token", "This tool requires authentication. Provide a valid bearer token."); } /// /// 403: the principal is authenticated but lacks the permission the tool's authorize requires /// (RFC 6750 §3.1 error="insufficient_scope"). /// - private void WriteForbidden(HttpContext context) + private Task WriteForbidden(HttpContext context) { context.Response.StatusCode = StatusCodes.Status403Forbidden; context.Response.Headers.Append("WWW-Authenticate", BearerChallenge(context, error: "insufficient_scope")); + return WriteChallengeBodyAsync(context, "insufficient_scope", "This tool requires a role your token does not have."); + } + + /// + /// Writes a small RFC 6750-shaped JSON body (error + error_description) for a 401/403. + /// The formal OAuth challenge is the status code plus the WWW-Authenticate header (set by the + /// caller, unchanged); this body is supplementary so clients that surface the response body — and + /// humans debugging with curl — get an actionable message instead of an empty response. + /// + private static Task WriteChallengeBodyAsync(HttpContext context, string error, string description) + { + context.Response.ContentType = "application/json"; + var body = new JsonObject { ["error"] = error, ["error_description"] = description }; + return context.Response.WriteAsync(body.ToJsonString(JsonOutput), context.RequestAborted); } /// @@ -263,7 +278,7 @@ private async Task HandleAsync(HttpContext context) // point the client at the Protected Resource Metadata (RFC 9728) so it can discover the AS. if (_options.Authorization.RequireAuthorization && context.User?.Identity?.IsAuthenticated != true) { - WriteUnauthorized(context); + await WriteUnauthorized(context); return; } @@ -276,7 +291,7 @@ private async Task HandleAsync(HttpContext context) string.Equals(c.Type, "aud", StringComparison.Ordinal) && string.Equals(c.Value, _options.Authorization.Audience, StringComparison.Ordinal))) { - WriteUnauthorized(context); + await WriteUnauthorized(context); return; } @@ -373,12 +388,12 @@ private async Task HandleToolCallAsync(HttpContext context, JsonNode? request, J // needs authentication; 403 = authenticated but insufficient permission (RFC 9728/6750). if (invoke.StatusCode == StatusCodes.Status401Unauthorized) { - WriteUnauthorized(context); + await WriteUnauthorized(context); return; } if (invoke.StatusCode == StatusCodes.Status403Forbidden) { - WriteForbidden(context); + await WriteForbidden(context); return; }