Skip to content

Commit 2ec90ff

Browse files
committed
feat: v3.17.0 — MCP per-tool authorization on tools/call (401/403)
Phase 4b: tools/call translates the execution pipeline's authorization outcome into spec HTTP challenges, reusing core's authorize/role check (no security logic duplicated in the plugin): - Tool needs authentication, called anonymously -> 401 with WWW-Authenticate: Bearer resource_metadata="..." (RFC 9728 5.1). - Authenticated but insufficient role -> 403 with WWW-Authenticate: Bearer error="insufficient_scope" (RFC 6750 3.1), adding scope/resource_metadata when configured. tools/list still lists every opted-in tool (the spec's per-principal filtering is a SHOULD) so tools stay discoverable; authorization is enforced on call. Tests: new mcp.tool_authorized (@mcp + @authorize admin); anonymous tools/call -> 401 (McpServerTests); new McpAuthRoleTestFixture (cookie auth + /login-as?role=) with admin->success and guest->403 insufficient_scope (McpAuthRoleTests).
1 parent 622c43d commit 2ec90ff

6 files changed

Lines changed: 177 additions & 1 deletion

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using NpgsqlRestTests.Setup;
2+
3+
namespace NpgsqlRestTests;
4+
5+
[Collection("McpAuthRoleFixture")]
6+
public class McpAuthRoleTests(McpAuthRoleTestFixture test)
7+
{
8+
private const string CallAuthorized =
9+
"""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"tool_authorized","arguments":{}}}""";
10+
11+
[Fact]
12+
public async Task Authenticated_with_the_required_role_executes_the_tool()
13+
{
14+
using var client = test.CreateClient();
15+
(await client.GetAsync("/login-as?role=admin")).EnsureSuccessStatusCode();
16+
17+
using var content = new StringContent(CallAuthorized, Encoding.UTF8, "application/json");
18+
using var response = await client.PostAsync("/mcp", content);
19+
20+
response.StatusCode.Should().Be(HttpStatusCode.OK);
21+
var r = JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
22+
r["result"]!["isError"]!.GetValue<bool>().Should().BeFalse();
23+
r["result"]!["content"]!.AsArray()[0]!["text"]!.GetValue<string>().Should().Be("secret");
24+
}
25+
26+
[Fact]
27+
public async Task Authenticated_with_the_wrong_role_is_rejected_with_403_insufficient_scope()
28+
{
29+
using var client = test.CreateClient();
30+
(await client.GetAsync("/login-as?role=guest")).EnsureSuccessStatusCode();
31+
32+
using var content = new StringContent(CallAuthorized, Encoding.UTF8, "application/json");
33+
using var response = await client.PostAsync("/mcp", content);
34+
35+
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
36+
var challenge = response.Headers.WwwAuthenticate.ToString();
37+
challenge.Should().Contain("error=\"insufficient_scope\"");
38+
challenge.Should().Contain("scope=\"mcp.read\"");
39+
challenge.Should().Contain("resource_metadata=");
40+
}
41+
}

NpgsqlRestTests/McpTests/McpPluginAnnotationTests.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ HTTP GET
7878
comment on function mcp.tool_nodesc() is '
7979
HTTP GET
8080
@mcp';
81+
82+
-- role-restricted tool: exercises the tools/call auth translation (401 anonymous, 403 wrong role)
83+
create function mcp.tool_authorized() returns text language sql as 'select ''secret''';
84+
comment on function mcp.tool_authorized() is '
85+
HTTP GET
86+
@mcp Admin-only data.
87+
@authorize admin';
8188
");
8289
}
8390
}

NpgsqlRestTests/McpTests/McpServerTests.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,20 @@ public async Task Tools_call_unknown_tool_is_a_jsonrpc_error()
8989
r["error"]!["code"]!.GetValue<int>().Should().Be(-32602);
9090
}
9191

92+
[Fact]
93+
public async Task Tools_call_on_an_authorized_tool_by_anonymous_caller_returns_401()
94+
{
95+
// tool_authorized has `@authorize admin`; the fixture has no auth middleware, so the forwarded
96+
// principal is anonymous and the execution pipeline returns 401 → translated to an HTTP challenge.
97+
using var content = new StringContent(
98+
"""{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"tool_authorized","arguments":{}}}""",
99+
Encoding.UTF8, "application/json");
100+
using var response = await test.Client.PostAsync("/mcp", content);
101+
102+
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
103+
response.Headers.WwwAuthenticate.ToString().Should().Contain("resource_metadata=");
104+
}
105+
92106
[Fact]
93107
public async Task Protected_resource_metadata_is_served_at_the_well_known_path()
94108
{
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using System.Security.Claims;
2+
using Microsoft.AspNetCore.Authentication.Cookies;
3+
using Microsoft.AspNetCore.Builder;
4+
using Microsoft.AspNetCore.Hosting;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using NpgsqlRest.Mcp;
8+
9+
namespace NpgsqlRestTests.Setup;
10+
11+
[CollectionDefinition("McpAuthRoleFixture")]
12+
public class McpAuthRoleFixtureCollection : ICollectionFixture<McpAuthRoleTestFixture> { }
13+
14+
/// <summary>
15+
/// Fixture for MCP Layer-2 authorization (per-tool roles). Cookie auth is wired with a "/login-as"
16+
/// endpoint that signs in a principal carrying a single role claim. The MCP server runs with
17+
/// RequireAuthorization=false (anonymous discovery allowed) but the <c>tool_authorized</c> tool carries
18+
/// <c>@authorize admin</c>, so a wrong-role caller is rejected on tools/call with 403 insufficient_scope.
19+
/// </summary>
20+
public class McpAuthRoleTestFixture : IDisposable
21+
{
22+
private readonly WebApplication _app;
23+
24+
public string ServerAddress { get; }
25+
26+
public McpAuthRoleTestFixture()
27+
{
28+
Database.Create();
29+
var connectionString = Database.CreateAdditional("mcp_auth_role_test");
30+
31+
var builder = WebApplication.CreateBuilder();
32+
builder.WebHost.UseUrls("http://127.0.0.1:0");
33+
builder.Services.AddAuthentication().AddCookie();
34+
_app = builder.Build();
35+
36+
// Sign the caller in with a single "role" claim taken from the query string.
37+
_app.MapGet("/login-as", (string role) => Results.SignIn(new ClaimsPrincipal(new ClaimsIdentity(
38+
claims: [new Claim("role", role)],
39+
authenticationType: CookieAuthenticationDefaults.AuthenticationScheme))));
40+
41+
_app.UseNpgsqlRest(new NpgsqlRestOptions(connectionString)
42+
{
43+
IncludeSchemas = ["mcp"],
44+
CommentsMode = CommentsMode.OnlyAnnotated,
45+
AuthenticationOptions = new() { DefaultRoleClaimType = "role" },
46+
EndpointCreateHandlers =
47+
[
48+
new Mcp(new McpOptions
49+
{
50+
Enabled = true,
51+
Authorization = new McpAuthorizationOptions
52+
{
53+
// Gate off — anonymous discovery is allowed; per-tool `authorize` still applies.
54+
RequireAuthorization = false,
55+
AuthorizationServers = ["https://as.example.com"],
56+
ScopesSupported = ["mcp.read"],
57+
}
58+
})
59+
]
60+
});
61+
62+
_app.StartAsync().GetAwaiter().GetResult();
63+
ServerAddress = _app.Urls.First();
64+
}
65+
66+
public HttpClient CreateClient()
67+
{
68+
var handler = new HttpClientHandler { UseCookies = true, CookieContainer = new System.Net.CookieContainer() };
69+
return new HttpClient(handler) { BaseAddress = new Uri(ServerAddress), Timeout = TimeSpan.FromMinutes(5) };
70+
}
71+
72+
#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize
73+
public void Dispose()
74+
#pragma warning restore CA1816 // Dispose methods should call SuppressFinalize
75+
{
76+
_app.StopAsync().GetAwaiter().GetResult();
77+
_app.DisposeAsync().GetAwaiter().GetResult();
78+
}
79+
}

changelog/v3.17.0.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ NpgsqlRest can now project explicitly opted-in PostgreSQL routines as **MCP tool
2727
- **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`:
2828
- **Protected Resource Metadata (RFC 9728)** is served at `/.well-known/oauth-protected-resource{UrlPath}` (advertising `resource`, `authorization_servers`, optional `scopes_supported`, `bearer_methods_supported`) whenever an Authorization Server is configured.
2929
- **`RequireAuthorization`** gates the endpoint: an unauthenticated request gets **HTTP 401** with `WWW-Authenticate: Bearer resource_metadata="…"` (RFC 9728 §5.1) so the client can discover the AS. The PRM document itself stays anonymous. The `resource` value defaults to the request origin + `UrlPath` (overridable via `Audience`, RFC 8707).
30+
- **Per-tool authorization** on `tools/call`: the forwarded principal runs the routine's `authorize`/role check in the execution pipeline. A tool that needs authentication called anonymously → **HTTP 401** (PRM challenge); authenticated but lacking the role → **HTTP 403** `WWW-Authenticate: Bearer error="insufficient_scope"` (RFC 6750 §3.1, with `scope`/`resource_metadata` when configured). No authorization logic is duplicated in the plugin — it reuses core's check.
3031
- **Config (`NpgsqlRest:McpOptions`, disabled by default):** `Enabled` (default `false`), `UrlPath` (default `/mcp`), `ServerName` (initialize `serverInfo.name`; null → database name from the connection string, falling back to `"NpgsqlRest"`), `ServerVersion` (default `"1.0.0"`), `Instructions` (optional server-level guidance returned at `initialize`), `ToolDescriptionSuffix` (optional short text appended to every tool description — complements `Instructions` for context the agent should see per-tool), and the `Authorization` object (`RequireAuthorization`, `AuthorizationServers`, `ScopesSupported`, `Audience`, `ProtectedResourceMetadataPath`). Surfaced in `--config`, `--config-schema`, and the JSON-schema description set.
3132

32-
> Internal (Layer-2) authorization — `403 insufficient_scope` wire shaping and `tools/list` filtering to the caller's permitted tools — is a follow-up within this release line. Per-tool `authorize` role checks already apply on `tools/call` via the forwarded principal.
33+
> `tools/list` lists every opted-in tool and does not filter to the caller's permitted subset (a SHOULD in the spec); authorization is enforced on `tools/call`. Listing all tools keeps them discoverable so an agent can prompt for authentication. Per-principal filtering may be added later.
3334
3435
### Plugin extension points on `RoutineEndpoint` (new) + OpenAPI annotation handling (⚠️ breaking API)
3536

plugins/NpgsqlRest.Mcp/McpServer.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,26 @@ private void WriteUnauthorized(HttpContext context)
115115
context.Response.Headers.Append("WWW-Authenticate", challenge);
116116
}
117117

118+
/// <summary>
119+
/// 403 with a <c>WWW-Authenticate: Bearer error="insufficient_scope"</c> challenge (RFC 6750 §3.1):
120+
/// the principal is authenticated but lacks the permission the tool's <c>authorize</c> requires.
121+
/// </summary>
122+
private void WriteForbidden(HttpContext context)
123+
{
124+
context.Response.StatusCode = StatusCodes.Status403Forbidden;
125+
var challenge = "Bearer error=\"insufficient_scope\"";
126+
if (_options.Authorization.ScopesSupported.Length > 0)
127+
{
128+
challenge += $", scope=\"{string.Join(' ', _options.Authorization.ScopesSupported)}\"";
129+
}
130+
if (_options.Authorization.AuthorizationServers.Length > 0)
131+
{
132+
var prm = $"{context.Request.Scheme}://{context.Request.Host}{ProtectedResourceMetadataPath()}";
133+
challenge += $", resource_metadata=\"{prm}\"";
134+
}
135+
context.Response.Headers.Append("WWW-Authenticate", challenge);
136+
}
137+
118138
private async Task HandleAsync(HttpContext context)
119139
{
120140
// Transport authorization gate (OAuth 2.1 Resource Server). When RequireAuthorization is on, the
@@ -206,6 +226,20 @@ private async Task HandleToolCallAsync(HttpContext context, JsonNode? request, J
206226
httpMethod, path, headers: null, body: body, contentType: contentType,
207227
user: context.User, cancellationToken: context.RequestAborted);
208228

229+
// Authorization outcome from the execution pipeline (core ran the tool's `authorize`/role check
230+
// against the forwarded principal) → spec-level HTTP challenges, not a tool result. 401 = the tool
231+
// needs authentication; 403 = authenticated but insufficient permission (RFC 9728/6750).
232+
if (invoke.StatusCode == StatusCodes.Status401Unauthorized)
233+
{
234+
WriteUnauthorized(context);
235+
return;
236+
}
237+
if (invoke.StatusCode == StatusCodes.Status403Forbidden)
238+
{
239+
WriteForbidden(context);
240+
return;
241+
}
242+
209243
// Business/execution outcome → a normal result with isError; the routine's response is the
210244
// text content verbatim. (Only structural problems above use a JSON-RPC error.)
211245
var content = new JsonArray();

0 commit comments

Comments
 (0)