Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
requires Microsoft.OpenApi [2.7.5, 3.0.0). Reason also noted at the
reference in Cluckwork.Api.csproj. -->
<PackageVersion Include="Microsoft.OpenApi" Version="2.12.2" />
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="2.2.0" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.*" />
<PackageVersion Include="OpenIddict.EntityFrameworkCore" Version="7.7.1" />
<PackageVersion Include="OpenIddict.Server.AspNetCore" Version="7.7.1" />
Expand Down
10 changes: 8 additions & 2 deletions docs/decisions/796-oauth-fail-closed.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ logs) or a form body (they would dodge the per-token rate-limit key).
|---|---|---|---|
| `oauth-token` | `POST /api/v1/oauth/token` | client IP | 20 / 60 s |
| `oauth-authorize` | `GET /api/v1/oauth/authorize` | client IP | 20 / 60 s |
| `oauth-api` | every `AcceptOAuthTokens` endpoint | SHA-256 of the bearer | 120 / 60 s |
| `oauth-api` | every `AcceptOAuthTokens` endpoint | SHA-256 of the bearer | 300 / 60 s (#806; 120 before) |

OpenIddict also accepts a POSTed authorization request, which would match no endpoint and
so no policy or body cap. A server handler refuses any non-GET authorization request
Expand All @@ -83,7 +83,13 @@ ignores any ambient session bearer, which would otherwise resolve a tenant and d
lookup. A per-IP ceiling beside the per-token key is not built.
- **Response shape for OAuth callers.** The chain's 401s carry no `WWW-Authenticate:
Bearer error="invalid_token"`, and a scope denial uses the role-denial body. #806 decides
what an MCP client needs.
what an MCP client needs. **Decided in #806:** on `/mcp`, a missing, revoked or
wrong-audience token gets the MCP scheme's challenge naming the protected-resource
metadata, and a token with neither MCP scope gets `403` with `error="insufficient_scope"`.
The shared middleware's own 401s (disabled user, suspended farm, superseded credentials,
connected apps off) still carry no header; an MCP client then falls back to the
well-known metadata URL, which the MCP authorization spec requires it to try. A `429` on
`/mcp` is a JSON-RPC error.
- **Audit attribution** of the acting client (#788 plans first-class columns).

## How it is enforced
Expand Down
8 changes: 8 additions & 0 deletions docs/decisions/843-adapter-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,11 @@ When #806 lands `/mcp` and its tool classes under `Cluckwork.Api.Mcp`: add each
tool class's repository reach as an `adapters` row exactly like an endpoint's,
and drop `AdapterTierRealTreeTests.McpTierRow_IsDormantToday` — its own failure
is the signal that the row it pins is no longer dormant.

**Amended by #806.** `/mcp` is mapped, so the row is active and
`McpTierRow_IsDormantToday` is gone. The privilege is renamed from
`DirectRepository` to `ContractsOnly`, because since #1123 CW1004 and the reach
guard hold a tool to module contracts like any endpoint: no scanner ever read
`DirectRepository` as an exemption, and a tool cannot inject a repository. The
row's reason now says so, and its `reviewBy` is the epic, #789. #806 ships no
tool class, so no `adapters` row is added yet; the first tools (#807) add theirs.
19 changes: 11 additions & 8 deletions docs/plans/770-mcp-server/01-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,21 @@ harnesses; #684/#146 packaging.

### Connecting

No new process and nothing to install. The server is the API already running.
No new process and nothing to install. The server is the API already running, and the
client needs only its URL.

```jsonc
{ "mcpServers": { "cluckwork": {
"type": "http",
"url": "https://farm.example/mcp",
"headers": { "Authorization": "Bearer ${CLUCKWORK_TOKEN}" } } } }
{ "mcpServers": { "cluckwork": { "type": "http", "url": "https://farm.example/mcp" } } }
```

The bearer is an ordinary Cluckwork access token from `POST /api/v1/auth/login` with a farm
code — the same one the SPA carries. Farm, role and flock assignments all come from it. There
is no MCP-specific identity, no API key, and **no new server config key**.
The client connects through OAuth, not with a session token. (Corrected in #806: this section
first said a token from `POST /api/v1/auth/login` connects, and `/mcp` refuses every session
JWT.) An unauthenticated request gets a 401 whose `WWW-Authenticate` names
`/.well-known/oauth-protected-resource/mcp`. That document names the farm's authorization
server (#788), where the client registers, the user approves it, and the client receives an
access token for the resource `https://farm.example/mcp`. Farm, role and flock assignments come
from the approving user. The resource URL is derived from `OAuth:Issuer`, so there is **no new
server config key**.

### Discovery is role-filtered, not merely enforced

Expand Down
10 changes: 10 additions & 0 deletions docs/plans/770-mcp-server/02-guards.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ matters more than count: prefer "walk everything, exclude deliberately".

## Transport and blast radius

#806 implements rows 8 to 12, 16 to 18, 29 and 30 in `tests/Cluckwork.Api.IntegrationTests/Mcp/McpEndpointTests.cs`,
under different names, behind the real pipeline and with the SDK's own client. Row 10's premise is false:
`MapMcp` maps a `RequestDelegate`, which carries no `MethodInfo`, so `BodyReadingEndpointTests` skips `/mcp`
and removing the marker leaves it green (checked by mutation). `/mcp` keeps `ReadsRequestBodyAttribute`
because it does read its body, but no guard holds it. Row 11 is `OAuthServerProductionTests`, which reaches
`/mcp` under Production configuration with no added key. Row 18's exemption is the `McpEndpoint` endpoint metadata rather than a separate
`HandlesOwnIdempotencyAttribute`. Rows 13 to 15 wait for the first tool (#807): with no tool in the tree,
a parity walk has nothing to check. #806 adds the rule rows 13 to 15 build on: every tool names a scope
policy and a role policy (`McpToolSurfaceTests`).

| # | Guard | Invariant | Red mutation |
|---|---|---|---|
| 8 | `SessionMode_IsStateless` | the actual load-bearing setting | set `SessionMode = Stateful` or `StatefulForInitializeClients`. **Note what is deliberately NOT guarded:** `ScopeRequests`. The SDK forces it under Stateless, so a guard on it reddens on a mutation that leaves the product safe — a false alarm, which all three candidates proposed. |
Expand Down
8 changes: 8 additions & 0 deletions src/Cluckwork.Api/AuthPolicies.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Cluckwork.Application.Modules.Access.Contracts;
using Cluckwork.Domain.Modules.Farm.Contracts;
using OpenIddict.Abstractions;

namespace Cluckwork.Api;

Expand Down Expand Up @@ -56,6 +58,12 @@ public static void AddCluckworkPolicies(this Microsoft.AspNetCore.Authorization.
// ReadOnly exist precisely to fence their holders off production.
opts.AddPolicy(ProductionWrite, p => p.RequireAuthenticatedUser().RequireAssertion(ctx =>
EffectiveRole(ctx.User) is Roles.Owner or Roles.Manager or WorkerRole));

// #806 — one policy per OAuth scope, named for it. An MCP tool carries a scope
// policy beside its role policy; stacked [Authorize] attributes AND together, so
// the tool admits role ∩ scope and tools/list hides it from everyone else.
foreach (var scope in OAuthScopes.All)
opts.AddPolicy(scope, p => p.RequireAuthenticatedUser().RequireAssertion(ctx => ctx.User.HasScope(scope)));
}

private const string WorkerRole = "Worker";
Expand Down
1 change: 1 addition & 0 deletions src/Cluckwork.Api/Cluckwork.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
graph transitively, so removing this line leaves the suite green. -->
<PackageReference Include="Serilog.Formatting.Compact" />
<PackageReference Include="FluentValidation" />
<PackageReference Include="ModelContextProtocol.AspNetCore" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using ModelContextProtocol.AspNetCore.Authentication;
using OpenIddict.Validation.AspNetCore;

namespace Cluckwork.Api.Hosting;
Expand Down Expand Up @@ -188,13 +189,20 @@ public static CluckworkIdentityRegistration AddCluckworkIdentity(
configuration.GetSection("OAuth:ClientMetadata:PrivateHosts").Get<string[]>()?.ToHashSet(StringComparer.OrdinalIgnoreCase)));
// #796 — one handler per endpoint: OpenIddict validation where the endpoint
// opted in through AcceptOAuthTokens, the session JWT scheme everywhere else.
// Neither token is ever handed to the other's handler.
// Neither token is ever handed to the other's handler. #806 — /mcp goes through
// the MCP scheme, which forwards authentication to OpenIddict and adds the
// protected-resource metadata to its challenge.
services.AddAuthentication(options => options.DefaultScheme = BearerSelectorScheme)
.AddPolicyScheme(BearerSelectorScheme, displayName: null, options =>
options.ForwardDefaultSelector = context =>
OAuthEndpoints.AcceptsOAuthTokens(context.GetEndpoint())
? OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme
: JwtBearerDefaults.AuthenticationScheme);
options.ForwardDefaultSelector = context => context.GetEndpoint() switch
{
{ } endpoint when endpoint.Metadata.GetMetadata<McpEndpoint>() is not null =>
McpAuthenticationDefaults.AuthenticationScheme,
{ } endpoint when OAuthEndpoints.AcceptsOAuthTokens(endpoint) =>
OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme,
_ => JwtBearerDefaults.AuthenticationScheme,
})
.AddCluckworkMcp(oauthIssuer);
}

return new(OAuthServer: oauthIssuer is not null);
Expand Down
72 changes: 72 additions & 0 deletions src/Cluckwork.Api/Hosting/CluckworkMcp.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using Cluckwork.Api.Mcp;
using Cluckwork.Api.Modules.Access.OAuth;
using Cluckwork.Application.Modules.Access.Contracts;
using Cluckwork.Infrastructure.Modules.Access.OAuth;
using Microsoft.AspNetCore.Authentication;
using ModelContextProtocol.AspNetCore;
using ModelContextProtocol.Authentication;
using OpenIddict.Validation.AspNetCore;

namespace Cluckwork.Api.Hosting;

// #806 — endpoint metadata marking /mcp. The bearer selector sends its challenge through
// the MCP scheme, IdempotencyMiddleware lets its POSTs through without an Idempotency-Key,
// and its 403 and 429 responses take the shape an MCP client reads.
public sealed record McpEndpoint(Uri ResourceMetadata)
{
public string InsufficientScopeChallenge =>
$"Bearer error=\"insufficient_scope\", scope=\"{OAuthScopes.ReadFarm}\", resource_metadata=\"{ResourceMetadata}\"";

public static McpEndpoint? Of(HttpContext context) => context.GetEndpoint()?.Metadata.GetMetadata<McpEndpoint>();
}

internal static class CluckworkMcp
{
private const string Path = "/mcp";

// Every MCP message is one small JSON-RPC object; the largest planned is a daily entry.
private const long MaxRequestBodyBytes = 64 * 1024;

public static void AddCluckworkMcp(this AuthenticationBuilder authentication, Uri issuer)
{
var resource = OAuthServerRegistration.McpResource(issuer);
// RFC 9728 §3.1: the well-known segment goes between the host and the resource's path.
var resourceMetadata = new Uri(
resource.GetLeftPart(UriPartial.Authority) + "/.well-known/oauth-protected-resource" + resource.AbsolutePath);

// Stateless is the SDK default; pinned here because the SessionMode is what keeps a
// tool in the HTTP request's DI scope, which McpCallContext checks (#805).
authentication.Services.AddMcpServer()
.WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateless)
.AddAuthorizationFilters()
.WithToolsFromAssembly(typeof(McpCallContext).Assembly);

// Serves the protected-resource metadata and writes /mcp's 401 challenge. The
// token itself is validated by OpenIddict, as on every AcceptOAuthTokens endpoint.
authentication.AddMcp(options =>
{
options.ForwardAuthenticate = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.ResourceMetadataUri = resourceMetadata;
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = resource.AbsoluteUri,
AuthorizationServers = [issuer.AbsoluteUri],
ScopesSupported = [.. OAuthScopes.All],
ResourceName = "Cluckwork",
};
});

authentication.Services.AddSingleton(new McpEndpoint(resourceMetadata));
}

// A tool narrows this with its own role and scope policies; tools/list hides a tool
// whose policies the caller fails. The SDK reads the body itself, so the marker reports
// a failed read as a body error (#398). No guard holds it: the SDK maps a
// RequestDelegate, which BodyReadingEndpointTests cannot see.
public static void MapCluckworkMcp(this WebApplication app) =>
app.MapMcp(Path)
.WithMetadata(app.Services.GetRequiredService<McpEndpoint>(), new ReadsRequestBodyAttribute())
.WithMaxRequestBodyBytes(MaxRequestBodyBytes)
.AcceptOAuthTokens(OAuthScopes.ReadFarm, OAuthScopes.WriteDailyEntries)
.RequireAuthorization();
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,25 @@ public static CluckworkRateLimitingRegistration AddCluckworkRateLimiting(
context.HttpContext.Request.Path.Value);
}

// #806 — an MCP client reads JSON-RPC, and oauth-api counts per token, not per
// address. The request's id is unknown here, so the error carries none.
if (McpEndpoint.Of(context.HttpContext) is not null)
{
await Results.Json(new
{
jsonrpc = "2.0",
id = (object?)null,
error = new { code = -32000, message = "Too many requests on this connection. Try again later." },
}, statusCode: StatusCodes.Status429TooManyRequests)
.ExecuteAsync(context.HttpContext);
return;
}

await Results.Problem(
title: "Too many requests",
detail:
"Too many requests from this address. Try again later.",
detail: policyName == RateLimitingOptions.OAuthApiPolicyName
? "Too many requests with this access token. Try again later."
: "Too many requests from this address. Try again later.",
statusCode: StatusCodes.Status429TooManyRequests)
.ExecuteAsync(context.HttpContext);
};
Expand Down
14 changes: 13 additions & 1 deletion src/Cluckwork.Api/Middleware/ForbiddenProblemResultHandler.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using Cluckwork.Api.Hosting;
using Cluckwork.Application.Modules.Access.Contracts;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;

namespace Cluckwork.Api.Middleware;

Expand All @@ -16,11 +19,20 @@ public async Task HandleAsync(
{
if (authorizeResult.Forbidden)
{
// #806 — RFC 6750 §3.1: a token holding none of /mcp's scopes is told which to
// ask for, so an MCP client can reauthorize instead of giving up.
var mcp = McpEndpoint.Of(context) is { } endpoint && !OAuthScopes.All.Any(context.User.HasScope)
? endpoint
: null;
if (mcp is not null)
context.Response.Headers.WWWAuthenticate = mcp.InsufficientScopeChallenge;
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Title = "Forbidden",
Detail = "This action requires the Admin role.",
Detail = mcp is null
? "This action requires the Admin role."
: "This connection was not granted access to farm data.",
Status = StatusCodes.Status403Forbidden
});
return;
Expand Down
9 changes: 9 additions & 0 deletions src/Cluckwork.Api/Middleware/IdempotencyMiddleware.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using System.Text;
using Cluckwork.Api.Hosting;
using Cluckwork.Infrastructure.Persistence;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.EntityFrameworkCore;
Expand Down Expand Up @@ -134,6 +135,14 @@ public async Task InvokeAsync(
return;
}

// #806 — MCP sends every message as a POST, initialize and tools/list included,
// and no MCP client sends an Idempotency-Key. A write tool keys its own retries.
if (McpEndpoint.Of(context) is not null)
{
await next(context);
return;
}

if (ResponseNotCacheable.Any(p =>
context.Request.Path.StartsWithSegments(p, StringComparison.OrdinalIgnoreCase)))
{
Expand Down
4 changes: 4 additions & 0 deletions src/Cluckwork.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -473,10 +473,14 @@
.MapExportEndpoints();

// #795 — OpenIddict answers the token endpoint itself; only authorization reaches an endpoint.
// #806 — /mcp accepts only OAuth tokens, so it exists only beside the server that issues them.
if (identity.OAuthServer)
{
app.MapGroup("/api/v1/oauth")
.WithTags("OAuth")
.MapOAuthEndpoints();
app.MapCluckworkMcp();
}

// #217 — browser error reports. Anonymous (the login screen can crash too);
// the endpoint carries its own per-IP rate limit and size cap inside.
Expand Down
3 changes: 2 additions & 1 deletion src/Cluckwork.Api/RateLimiting/RateLimitingOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ public sealed class RateLimitingOptions
public FixedWindow OAuthToken { get; init; } = new() { PermitLimit = 20, WindowSeconds = 60 };
public FixedWindow OAuthAuthorize { get; init; } = new() { PermitLimit = 20, WindowSeconds = 60 };
// An assistant calls far faster than a person clicks (#796), so this is per minute.
public FixedWindow OAuthApi { get; init; } = new() { PermitLimit = 120, WindowSeconds = 60 };
// #806 — /mcp is its only endpoint, and every MCP message is one request.
public FixedWindow OAuthApi { get; init; } = new() { PermitLimit = 300, WindowSeconds = 60 };
public FixedWindow OAuthRegister { get; init; } = new() { PermitLimit = 10, WindowSeconds = 3600 };
// Small on purpose: a report query is a bounded-range aggregate (#311), not
// a hot path — a genuine user rarely has more than one or two in flight at
Expand Down
Loading