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.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.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/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/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/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/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/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/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..fe83fffa 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 @@ -18,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; @@ -27,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; } @@ -45,6 +42,13 @@ 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. + List? unhandledCommentLines = null; for (var i = 0; i < lines.Length; i++) { string line = lines[i].Trim(); @@ -78,6 +82,7 @@ internal static partial class DefaultCommentParser else { HandleCustomParameter(routineEndpoint, customParamName, customParamValue, description); + WarnUnknownPlaceholders(routine, customParamValue, description); } TrackAnnotation(line); } @@ -87,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); } @@ -115,16 +121,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 @@ -512,16 +508,62 @@ 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. + CommentLineResult? handled = null; + foreach (var handler in Options.EndpointCreateHandlers) + { + handled = handler.HandleCommentLine(routineEndpoint, line, words, wordsLower); + if (handled is not null) + { + break; + } + } + 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; + } + } + else + { + (unhandledCommentLines ??= []).Add(line); + } + } } + + routineEndpoint.UnhandledCommentLines = unhandledCommentLines?.ToArray(); + if (disabled) { Logger?.CommentDisabled(description); return null; } - if (Options.CommentsMode == CommentsMode.OnlyWithHttpTag && !hasHttpTag) + if (Options.CommentsMode is CommentsMode.OnlyWithHttpTag or CommentsMode.OnlyAnnotated + && !hasHttpTag && !anyHandlerRequestedEndpoint) { 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/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/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/IEndpointCreateHandler.cs b/NpgsqlRest/IEndpointCreateHandler.cs index 055ed3eb..637a990f 100644 --- a/NpgsqlRest/IEndpointCreateHandler.cs +++ b/NpgsqlRest/IEndpointCreateHandler.cs @@ -9,6 +9,43 @@ 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 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. + /// + /// + 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. /// @@ -24,4 +61,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/NpgsqlRest/Log.cs b/NpgsqlRest/Log.cs index cf1ae6d9..2fe44391 100644 --- a/NpgsqlRest/Log.cs +++ b/NpgsqlRest/Log.cs @@ -125,6 +125,12 @@ 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.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/NpgsqlRest/NpgsqlRest.csproj b/NpgsqlRest/NpgsqlRest.csproj index db97b44f..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 @@ -62,8 +62,23 @@ - + + + + + Common\%(Filename)%(Extension) + + + + + + + + diff --git a/NpgsqlRest/NpgsqlRestEndpoint.cs b/NpgsqlRest/NpgsqlRestEndpoint.cs index cd90cd63..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,11 +987,15 @@ 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, + // 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; } @@ -1067,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; @@ -1121,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) { @@ -1302,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) { @@ -1469,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) { @@ -1532,32 +1538,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; } } @@ -1638,7 +1627,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/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/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/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/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/NpgsqlRest/RoutineEndpoint.cs b/NpgsqlRest/RoutineEndpoint.cs index c259c31c..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; @@ -137,6 +139,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. /// @@ -158,21 +196,6 @@ public string? BodyParameterName /// 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. @@ -199,6 +222,49 @@ public string? BodyParameterName /// 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/NpgsqlRest/RoutineInvoker.cs b/NpgsqlRest/RoutineInvoker.cs new file mode 100644 index 00000000..86938da0 --- /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 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. +/// +/// +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/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/App.cs b/NpgsqlRestClient/App.cs index 64caad08..25d5c636 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; @@ -514,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), @@ -576,6 +578,34 @@ 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) + { + var mcpAuthCfg = mcpCfg.GetSection("Authorization"); + 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), + ToolDescriptionSuffix = _config.GetConfigStr("ToolDescriptionSuffix", mcpCfg), + RateLimiterPolicy = _config.GetConfigStr("RateLimiterPolicy", mcpCfg), + AllowedOrigins = [.. _config.GetConfigEnumerable("AllowedOrigins", 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), + FilterToolsByRole = _config.GetConfigBool("FilterToolsByRole", mcpAuthCfg), + }, + })); + _builder.ClientLogger?.LogDebug("MCP server enabled. UrlPath={UrlPath}", + _config.GetConfigStr("UrlPath", mcpCfg) ?? "/mcp"); + } + return handlers; } diff --git a/NpgsqlRestClient/Builder.cs b/NpgsqlRestClient/Builder.cs index e6c677ab..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 @@ -1860,6 +1867,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(); @@ -2083,7 +2113,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/Config.cs b/NpgsqlRestClient/Config.cs index ff37af38..e6badbaa 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; @@ -25,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) @@ -133,9 +131,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 +151,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 +228,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 +238,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 +255,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 +291,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 +366,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 +378,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 +392,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; @@ -456,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 = ""; @@ -960,9 +1023,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 +1158,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/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index 2c7c4dc9..4c4f30c9 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 }; } @@ -765,7 +765,7 @@ private static JsonObject GetNpgsqlRestDefaults() ["NameNotSimilarTo"] = null, ["IncludeNames"] = null, ["ExcludeNames"] = null, - ["CommentsMode"] = "OnlyWithHttpTag", + ["CommentsMode"] = "OnlyAnnotated", ["UrlPathPrefix"] = "/api", ["KebabCaseUrls"] = true, ["CamelCaseNames"] = true, @@ -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(), @@ -821,6 +822,7 @@ private static JsonObject GetNpgsqlRestDefaults() ["AuthenticationOptions"] = GetAuthenticationOptionsDefaults(), ["HttpFileOptions"] = GetHttpFileOptionsDefaults(), ["OpenApiOptions"] = GetOpenApiOptionsDefaults(), + ["McpOptions"] = GetMcpOptionsDefaults(), ["ClientCodeGen"] = GetClientCodeGenDefaults(), ["HttpClientOptions"] = GetHttpClientOptionsDefaults(), ["ProxyOptions"] = GetProxyOptionsDefaults(), @@ -969,6 +971,30 @@ 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, + ["RateLimiterPolicy"] = null, + ["AllowedOrigins"] = new JsonArray(), + ["Authorization"] = new JsonObject + { + ["RequireAuthorization"] = false, + ["AuthorizationServers"] = new JsonArray(), + ["ScopesSupported"] = new JsonArray(), + ["Audience"] = null, + ["ProtectedResourceMetadataPath"] = null, + ["FilterToolsByRole"] = false + } + }; + } + private static JsonObject GetClientCodeGenDefaults() { return new JsonObject @@ -983,6 +1009,7 @@ private static JsonObject GetClientCodeGenDefaults() ["BySchema"] = true, ["IncludeStatusCode"] = true, ["CreateSeparateTypeFile"] = true, + ["ExportTypes"] = false, ["ImportBaseUrlFrom"] = null, ["ImportParseQueryFrom"] = null, ["IncludeParseUrlParam"] = false, @@ -1048,7 +1075,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..d58b0882 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.", @@ -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.", @@ -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.", @@ -437,6 +438,21 @@ 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: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.", + ["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: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.", @@ -447,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.", @@ -494,7 +511,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 +555,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..aaeafd15 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). // @@ -1884,9 +1886,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. // @@ -2016,6 +2018,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", @@ -2564,7 +2570,70 @@ 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, + // + // 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": [], + // + // 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, + // + // 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 + } + }, + // // Enable or disable the generation of TypeScript/Javascript client source code files for NpgsqlRest endpoints. // @@ -2610,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, @@ -2814,10 +2887,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/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/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/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/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/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/NpgsqlRestClient/NpgsqlRestClient.csproj b/NpgsqlRestClient/NpgsqlRestClient.csproj index b3a11edb..03e0a02b 100644 --- a/NpgsqlRestClient/NpgsqlRestClient.csproj +++ b/NpgsqlRestClient/NpgsqlRestClient.csproj @@ -10,17 +10,17 @@ true true full - 3.16.3 + $(NpgsqlRestProductVersion) - + - - + + @@ -31,6 +31,7 @@ + diff --git a/NpgsqlRestClient/Program.cs b/NpgsqlRestClient/Program.cs index 0d141a06..f4096532 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", @@ -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/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 diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index 42251d47..14ba99a4 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). // @@ -1875,9 +1877,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. // @@ -2007,6 +2009,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", @@ -2555,7 +2561,70 @@ // "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, + // + // 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": [], + // + // 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, + // + // 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 + } + }, + // // Enable or disable the generation of TypeScript/Javascript client source code files for NpgsqlRest endpoints. // @@ -2601,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, @@ -2805,10 +2878,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/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/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/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/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/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/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 new file mode 100644 index 00000000..6058bf4b --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpAuthGateTests.cs @@ -0,0 +1,44 @@ +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"); + // 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] + 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); + } + + [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 new file mode 100644 index 00000000..7a6bb788 --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpAuthRoleTests.cs @@ -0,0 +1,85 @@ +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); + // 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] + 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="); + // 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) ------------- + + 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/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 new file mode 100644 index 00000000..8b32a7cc --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpFeatureWarnTests.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.Logging; +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.'; + +-- @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'; +"); + } +} + +[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"); + } + + [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 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() + { + // 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 new file mode 100644 index 00000000..cde3c508 --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs @@ -0,0 +1,272 @@ +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 + @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'; + +-- 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'; + +-- 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'; + +-- 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'; + +-- 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'; + +-- 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. +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); +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.'; +"); + } +} + +[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_recorded_as_the_inline_description() + { + var info = Info(test.Endpoints["tool_inline_desc"])!; + info.Enabled.Should().BeTrue(); + info.InlineText.Should().Be("Cancel a booking and release the room."); + info.Description.Should().BeNull(); // no explicit @mcp_description + } + + [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_without_http_tag_is_mcp_only() + { + // 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().BeTrue(); + } + + [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) + } +} + +[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 + } + + // 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_inline_mcp_text() + { + 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 Description_falls_back_to_the_routine_name_when_there_is_no_text() + { + 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 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_is_the_description_and_suppresses_comment_prose() + => test.Tools["tool_inline_and_prose"]!.ToJsonString().Should().Be( + """{"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() + { + // 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 InputSchema_lists_params_and_marks_only_non_default_as_required() + { + // 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 Server_resolved_params_are_excluded_from_inputSchema() + { + // 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/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/McpTests/McpServerTests.cs b/NpgsqlRestTests/McpTests/McpServerTests.cs new file mode 100644 index 00000000..bcf84741 --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpServerTests.cs @@ -0,0 +1,110 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +[Collection("McpPluginFixture")] +public class McpServerTests(McpPluginTestFixture test) +{ + /// 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); + return await response.Content.ReadAsStringAsync(); + } + + [Fact] + public async Task Initialize_returns_protocol_capabilities_and_serverinfo() + { + // 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() + { + // 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 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 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] + 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); + (await response.Content.ReadAsStringAsync()).Should().BeEmpty(); + } + + [Fact] + public async Task Tools_call_executes_the_routine_and_returns_text_and_structured_content() + { + // 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'); 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 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] + 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() + { + // 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"); + + // 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..d68ea9bd --- /dev/null +++ b/NpgsqlRestTests/McpTests/McpSqlFileTests.cs @@ -0,0 +1,61 @@ +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() + // 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() + => (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/NpgsqlRestTests.csproj b/NpgsqlRestTests/NpgsqlRestTests.csproj index 44af181c..fcd52b6b 100644 --- a/NpgsqlRestTests/NpgsqlRestTests.csproj +++ b/NpgsqlRestTests/NpgsqlRestTests.csproj @@ -16,8 +16,8 @@ - - + + @@ -38,6 +38,7 @@ + diff --git a/NpgsqlRestTests/OpenApiTests/OpenApiFilterTests.cs b/NpgsqlRestTests/OpenApiTests/OpenApiFilterTests.cs index 07b82e8b..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,8 +92,21 @@ private static RoutineEndpoint MakeEndpoint( bodyParameterName: null, textResponseNullHandling: TextResponseNullHandling.EmptyString, queryStringNullHandling: QueryStringNullHandling.EmptyString); - endpoint.OpenApiHide = openApiHide; - endpoint.OpenApiTags = openApiTags; + // 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. + 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; } @@ -146,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/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/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/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/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/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/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 new file mode 100644 index 00000000..d36dfe3c --- /dev/null +++ b/NpgsqlRestTests/Setup/McpAuthGateTestFixture.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +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; + 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(); + var connectionString = Database.CreateAdditional("mcp_auth_gate_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"], + CommentsMode = CommentsMode.OnlyAnnotated, + EndpointCreateHandlers = + [ + new Mcp(new McpOptions + { + Enabled = true, + Authorization = new McpAuthorizationOptions + { + RequireAuthorization = true, + AuthorizationServers = ["https://as.example.com"], + } + }) + ] + }); + + _app.StartAsync().GetAwaiter().GetResult(); + StartupLogs = _logCollector.Snapshot(); + _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/McpAuthRoleTestFixture.cs b/NpgsqlRestTests/Setup/McpAuthRoleTestFixture.cs new file mode 100644 index 00000000..880c1364 --- /dev/null +++ b/NpgsqlRestTests/Setup/McpAuthRoleTestFixture.cs @@ -0,0 +1,81 @@ +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"], + // tools/list hides tools the caller can't run (tool_authorized requires `admin`). + FilterToolsByRole = true, + } + }) + ] + }); + + _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/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/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/NpgsqlRestTests/Setup/McpPluginTestFixture.cs b/NpgsqlRestTests/Setup/McpPluginTestFixture.cs new file mode 100644 index 00000000..fe83cbd5 --- /dev/null +++ b/NpgsqlRestTests/Setup/McpPluginTestFixture.cs @@ -0,0 +1,86 @@ +using System.Text.Json.Nodes; +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; + 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; + + /// 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(); + 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 = [_mcp, 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/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/NpgsqlRestTests/Setup/McpSqlFileTestFixture.cs b/NpgsqlRestTests/Setup/McpSqlFileTestFixture.cs new file mode 100644 index 00000000..486fdf6f --- /dev/null +++ b/NpgsqlRestTests/Setup/McpSqlFileTestFixture.cs @@ -0,0 +1,93 @@ +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; + """); + + // 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(); + + _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/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/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/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(); + } +} 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/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/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/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/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/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..80548ea7 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,54 @@ +# 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`): + - 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 + +| 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/changelog/v3.17.0.md b/changelog/v3.17.0.md new file mode 100644 index 00000000..f5b52704 --- /dev/null +++ b/changelog/v3.17.0.md @@ -0,0 +1,168 @@ +# Changelog v3.17.0 + +## 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.17.0) + +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. + +## New Features + +### MCP (Model Context Protocol) server — new `NpgsqlRest.Mcp` plugin + +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. + +**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 an inline (explicit) description. +- `mcp_description ` (alias `mcp_desc`) — explicit, authoritative description. +- `mcp_name ` — override the tool name (default: the routine name). +- **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. + +**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: + +- **`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. + +**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`: + +- **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. + +**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`). + +### Plugin extension points on `RoutineEndpoint` + +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): + +- **`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. 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 + +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). + +### 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 + +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`). + +- **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 + +### 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. + +**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. + +### 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: + +- **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. + +### 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. + +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; 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 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 +``` + +## Tests + +- 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. +- 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). 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/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.Mcp/Mcp.cs b/plugins/NpgsqlRest.Mcp/Mcp.cs new file mode 100644 index 00000000..79f2ec7e --- /dev/null +++ b/plugins/NpgsqlRest.Mcp/Mcp.cs @@ -0,0 +1,281 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using NpgsqlRest.Common; +using static NpgsqlRest.NpgsqlRestOptions; + +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 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. +/// +/// 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 +{ + /// Key under which is stored in . + public const string ItemsKey = "mcp"; + + private readonly McpOptions _options = options; + + 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 + /// (name, description, inputSchema, annotations). + /// + 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) + { + 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_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; + var text = RemainderAfterFirstWord(line); + 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); + } + + 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(); + WarnIfNonApplicableFeature(endpoint, name); + 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). + Logger?.LogWarning("MCP tool name '{Name}' is already in use ({Schema}.{Routine} skipped).", + name, endpoint.Routine.Schema, endpoint.Routine.Name); + } + } + + /// + /// 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); + } + + // 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) + { + var routine = endpoint.Routine; + var name = string.IsNullOrWhiteSpace(info.ToolName) ? routine.Name : info.ToolName!; + + // 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 `, `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)) + { + description = $"{description} {_options.ToolDescriptionSuffix.Trim()}"; + } + + 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; + } + + 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; + } + + /// + /// 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) + { + 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/McpAuthorizationOptions.cs b/plugins/NpgsqlRest.Mcp/McpAuthorizationOptions.cs new file mode 100644 index 00000000..59ab41c7 --- /dev/null +++ b/plugins/NpgsqlRest.Mcp/McpAuthorizationOptions.cs @@ -0,0 +1,43 @@ +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; + + /// + /// 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/McpOptions.cs b/plugins/NpgsqlRest.Mcp/McpOptions.cs new file mode 100644 index 00000000..5f401ff8 --- /dev/null +++ b/plugins/NpgsqlRest.Mcp/McpOptions.cs @@ -0,0 +1,52 @@ +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. 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; + + /// + /// 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(); + + /// + /// 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 new file mode 100644 index 00000000..ae54bdc4 --- /dev/null +++ b/plugins/NpgsqlRest.Mcp/McpServer.cs @@ -0,0 +1,681 @@ +using System.Security.Claims; +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.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlRest.Auth; +using NpgsqlRest.Common; +using static NpgsqlRest.NpgsqlRestOptions; + +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"; + + // 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; + private NpgsqlRestAuthenticationOptions _authOptions = new(); + + public void Setup(IApplicationBuilder builder, NpgsqlRestOptions options) + { + _builder = builder; + _connectionString = options.ConnectionString; + _authOptions = options.AuthenticationOptions; + } + + public void Cleanup() + { + if (!_options.Enabled) + { + 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. + 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)) + { + await HandleProtectedResourceMetadataAsync(context); + return; + } + if (string.Equals(context.Request.Path, path, StringComparison.Ordinal)) + { + await HandleAsync(context); + return; + } + await next(context); + }); + } + + /// + /// 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 + ?? "/.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(doc.ToJsonString(JsonOutput), context.RequestAborted); + } + + /// + /// 401: authentication is required or the token is invalid (RFC 9728 §5.1 challenge). + /// + 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 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); + } + + /// + /// 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) + { + parts.Add($"scope=\"{string.Join(' ', _options.Authorization.ScopesSupported)}\""); + } + if (_options.Authorization.AuthorizationServers.Length > 0) + { + var prm = $"{context.Request.Scheme}://{context.Request.Host}{ProtectedResourceMetadataPath()}"; + parts.Add($"resource_metadata=\"{prm}\""); + } + 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. + if (_options.Authorization.RequireAuthorization && context.User?.Identity?.IsAuthenticated != true) + { + await WriteUnauthorized(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))) + { + await 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)) + { + 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; + } + + // 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)) + { + context.Response.StatusCode = StatusCodes.Status202Accepted; + return; + } + + if (method == "tools/call") + { + await HandleToolCallAsync(context, request, id); + return; + } + + JsonObject? result = method switch + { + "initialize" => BuildInitializeResult(), + "ping" => new JsonObject(), + "tools/list" => BuildToolsListResult(context.User), + _ => 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 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); + + // 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) + { + await WriteUnauthorized(context); + return; + } + if (invoke.StatusCode == StatusCodes.Status403Forbidden) + { + await WriteForbidden(context); + return; + } + + // 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"] = text }); + + var toolResult = new JsonObject + { + ["content"] = content, + ["isError"] = !invoke.IsSuccess, + }; + if (structured is not null) + { + toolResult["structuredContent"] = structured; + } + + await WriteResponseAsync(context, new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["result"] = toolResult, + }); + } + + /// + /// 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). + /// + 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 + { + ["protocolVersion"] = ProtocolVersion, + ["capabilities"] = new JsonObject { ["tools"] = new JsonObject() }, + ["serverInfo"] = new JsonObject + { + ["name"] = GetServerName(), + ["version"] = string.IsNullOrWhiteSpace(_options.ServerVersion) ? "1.0.0" : _options.ServerVersion, + }, + }; + if (!string.IsNullOrWhiteSpace(_options.Instructions)) + { + result["instructions"] = _options.Instructions; + } + 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(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 (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 }; + } + + 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(payload.ToJsonString(JsonOutput), context.RequestAborted); + } +} diff --git a/plugins/NpgsqlRest.Mcp/McpToolInfo.cs b/plugins/NpgsqlRest.Mcp/McpToolInfo.cs new file mode 100644 index 00000000..fe828df8 --- /dev/null +++ b/plugins/NpgsqlRest.Mcp/McpToolInfo.cs @@ -0,0 +1,24 @@ +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, 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; } +} 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) + + + diff --git a/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj b/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj index 52957486..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 @@ -61,4 +61,13 @@ + + + + + Common\%(Filename)%(Extension) + + diff --git a/plugins/NpgsqlRest.OpenApi/OpenApi.cs b/plugins/NpgsqlRest.OpenApi/OpenApi.cs index ccdd94d8..c29d9399 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,22 @@ 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. + // 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 (endpoint.OpenApiHide) + if (openApiHide) { return; } @@ -179,7 +191,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) @@ -219,7 +231,7 @@ public void Handle(RoutineEndpoint endpoint) if (routineParam != null) { - pathParameter["schema"] = GetSchemaForType(routineParam.TypeDescriptor); + pathParameter["schema"] = SchemaMapper.GetSchemaForType(routineParam.TypeDescriptor); } else { @@ -269,7 +281,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); @@ -308,7 +320,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)); @@ -360,7 +372,7 @@ public void Handle(RoutineEndpoint endpoint) { ["text/plain"] = new JsonObject { - ["schema"] = GetSchemaForType(bodyParam.TypeDescriptor) + ["schema"] = SchemaMapper.GetSchemaForType(bodyParam.TypeDescriptor) } } }; @@ -453,6 +465,53 @@ 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 result when claimed (RequestsEndpoint stays false — these are document-only + /// modifiers and must NOT by themselves create an endpoint), null otherwise. + /// + public CommentLineResult? 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 new CommentLineResult("openapi hide"); + } + + var sub = wordsLower[1]; + if (CommentPrimitives.StrEqualsToArray(sub, "hide", "hidden", "ignore")) + { + endpoint.Items[ItemHide] = true; + return new CommentLineResult("openapi hide"); + } + + if (CommentPrimitives.StrEqualsToArray(sub, "tag", "tags")) + { + if (wordsLower.Length < 3) + { + return new CommentLineResult("openapi tag (ignored: no tag value)"); + } + var tags = words[2..]; // original casing for tag values + endpoint.Items[ItemTags] = 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 new CommentLineResult($"openapi (ignored: unknown sub-command '{sub}')"); + } + public void Cleanup() { if (openApiOptions.FileName is null && openApiOptions.UrlPath is null) @@ -506,86 +565,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) @@ -600,7 +579,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 @@ -621,7 +600,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; @@ -629,7 +608,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 diff --git a/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj b/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj index 7b6d7cb3..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 @@ -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; diff --git a/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs b/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs index de780d1a..493f53b0 100644 --- a/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs +++ b/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs @@ -94,9 +94,15 @@ 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.). 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) + && !HasEndpointRequestingAnnotation(parseResult.Comment)) { return null; } @@ -731,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. 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..b3e02d71 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 = []; @@ -115,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) { @@ -296,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); @@ -495,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); @@ -545,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; @@ -598,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;"); @@ -821,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); @@ -1911,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++) { @@ -1958,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 /// 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