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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions 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
7 changes: 4 additions & 3 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 (120 until #806) |

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 @@ -82,8 +82,9 @@ ignores any ambient session bearer, which would otherwise resolve a tenant and d
- **Junk bearers.** Each invalid token gets its own `oauth-api` bucket and costs one token
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.
Bearer error="invalid_token"`. #806 decided what an MCP client needs: a challenge naming
the protected-resource metadata, and `insufficient_scope` on a scope denial
([`806-mcp-endpoint.md`](806-mcp-endpoint.md)).
- **Audit attribution** of the acting client (#788 plans first-class columns).

## How it is enforced
Expand Down
106 changes: 106 additions & 0 deletions docs/decisions/806-mcp-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# The MCP endpoint: OAuth only, bound to /mcp, stateless (#806)

> The design is [`docs/plans/770-mcp-server/`](../plans/770-mcp-server/01-design.md); its
> guard table is [`02-guards.md`](../plans/770-mcp-server/02-guards.md). This file records
> what #806 decided while mapping the endpoint.

**Status:** accepted
**Date:** 2026-10-10

## What happened

No incident. Milestone 10 (#788) built the authorization server and left four MCP-shaped
questions to this slice: which tokens `/mcp` accepts, how a client discovers where to
authenticate, how scopes reach individual tools, and what the per-token rate limit should
be. The decisions on #789 (2026-10-10) settled the protocol version, audience binding and
the rate figure. The rest is recorded here.

## The rule

**`/mcp` exists only beside the authorization server.** `Program.cs` maps it inside
`if (identity.OAuthServer)`. Without an issuer the OAuth scheme is not registered, and
`/mcp` accepts no other token. The SDK is `ModelContextProtocol.AspNetCore` 2.2.0, and one
endpoint serves MCP 2026-07-28 and 2025-11-25 clients.

**Stateless, pinned.** `SessionMode = Stateless` makes the SDK run each tool in the HTTP
request's own services, so `McpCallContext` (#805) sees the request's tenant, actor and flock
scope, and each tool call is one authenticated request that `CredentialEpochMiddleware`
re-checks (#364). The SDK's idle-session sweep is registered but never starts. Stateless
serves no GET stream, so `GET` and `HEAD /mcp` answer 405, as the MCP spec asks; otherwise
the SPA fallback would answer them with the app's HTML.

**Tokens are bound to `/mcp` (RFC 8707).** The resource is `<OAuth:Issuer>/mcp`, never the
request's Host. The authorization server registers it as its only resource, so an authorize
or token request naming another resource gets `invalid_target`. Consent stamps the named
resource on the code, and so on the token; a client that names none gets `/mcp`, the only
resource there is. Validation adds `/mcp` as the required audience, so a token minted before
this change, which carries none, is refused.

**Discovery is the SDK's `McpAuthenticationHandler`.** The bearer selector's OAuth branch is
now the MCP scheme. That scheme authenticates through OpenIddict validation, answers a
challenge with `WWW-Authenticate: Bearer resource_metadata="<issuer>/.well-known/oauth-protected-resource/mcp"`,
and serves that document (RFC 9728), naming the issuer as the authorization server and the
two scopes. Both URLs come from `OAuth:Issuer`. The handler only answers a metadata request
whose Host matches the issuer's.

**Scopes reach tools as policies.** `OAuthScopeRequirement` replaces the inline assertion in
`AcceptOAuthTokens` and backs two named policies, `AuthPolicies.FarmReadScope` and
`AuthPolicies.DailyEntriesWriteScope`. Each tool carries a role `[Authorize]` and a scope
`[Authorize]`; the SDK's `AddAuthorizationFilters()` ANDs them, so `tools/list` shows a tool
only for role ∩ scope and `tools/call` refuses the rest. `McpToolSurfaceTests` fails a tool
missing either gate, and one carrying `[AllowAnonymous]`, because the SDK runs such a tool
for anyone. `AcceptOAuthTokens(ReadFarm, WriteDailyEntries)` stays on the mapping as the
outer gate.

**Every refusal of an OAuth token carries a challenge.** RFC 6750 §3 asks for a bearer
challenge whenever the credentials do not grant access, and an MCP client re-authorizes from
it. `CluckworkMcp.AddChallenge` writes `Bearer <error>, resource_metadata="..."`, the same
header the SDK's handler emits. `CredentialEpochMiddleware` adds it with
`error="invalid_token"` beside its problem body when a disabled user, a suspended farm, a
superseded credential or the farm switch refuses a token on an `AcceptOAuthTokens` endpoint.
`TenantResolutionMiddleware`'s bodiless 401s are left alone: they refuse a principal with no
`account_id` or `sub`, and consent copies both from the session into every token.

**A missing scope says which one.** When only scope requirements failed,
`ForbiddenProblemResultHandler` answers 403 with
`WWW-Authenticate: Bearer error="insufficient_scope", scope="...", resource_metadata="..."`
(RFC 6750 §3.1). When the role failed too, a new token would not help, and the role body
answers as before.

**Idempotency is exempt by endpoint metadata.** Every MCP message is a POST and no MCP
client sends an `Idempotency-Key`, so `IdempotencyMiddleware` skips an endpoint carrying
`HandlesOwnIdempotencyAttribute`. Only `/mcp` carries it, pinned by
`McpEndpointTests.IdempotencyExemption_IsCarriedByTheMcpPostAlone`. The write tool keys its
own claim (#809).

**Budget: 300 messages per minute per token.** `oauth-api` rose from 120. Traced with the
SDK's own client against this endpoint, a session spends 3 POSTs to its first tool result on
2026-07-28 (`server/discover`, `tools/list`, `tools/call`) and 4 on 2025-11-25 (`initialize`,
`notifications/initialized`, `tools/list`, `tools/call`, plus a `GET` the limiter does not
count); each later call is one POST. In process a call took 4 to 6 ms, so only a runaway loop
reaches the limit; a model spends seconds between calls. No account-wide
ceiling: each token costs a step-up password entry (#798), which the password limiter
already bounds. A 429 on `oauth-api` is a JSON-RPC error with a null id, because the limiter
runs before the body is read, and its text names the connection, not an address.

**The body cap is 32 KiB** (`WithMaxRequestBodyBytes`), beside `ReadsRequestBodyAttribute`,
which only classifies binding errors.

**The adapter tier row grants nothing.** `Cluckwork.Api.Mcp`'s privilege is now
`ContractOnly`: CW1004 holds tool classes to module contracts as it holds endpoints. The old
`DirectRepository` name and its reason ("inject repositories by design") were wrong.

## What this does NOT cover

- **A tool-level scope denial is a JSON-RPC error, not an HTTP 403.** The SDK refuses a
`tools/call` for a tool the caller cannot see with `InvalidRequest` inside a 200. Only the
endpoint gate answers `insufficient_scope`. A client that follows `tools/list` never calls
a hidden tool.
- **Route parity for tools** (guard rows 13 to 15) arrives with the first tool (#807).

## How it is enforced

`tests/Cluckwork.Api.IntegrationTests/Mcp/`: `McpEndpointTests` reads the endpoint table and
services (rows 8 to 12, 17, 18, 29, 30), and `McpAuthorizationTests` drives the real OAuth
flow and the SDK's client against `/mcp` with probe tools that exist only in the test host.
`tools/oauth/mutation-check.sh` proves each test can fail; the #806 mutants are listed there.
12 changes: 7 additions & 5 deletions docs/decisions/843-adapter-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ namespace, tied to the surface that will map it and a review issue that gives
the exemption an end date. `RealModuleLedger.AdapterTiers` in
`tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Adapters.cs` is an
array of `AdapterTier(Namespace, Privilege, Surface, Reason, ReviewBy)` rows; today it
holds one, `Cluckwork.Api.Mcp` → `DirectRepository`, surfaced by `MapMcp`,
holds one, `Cluckwork.Api.Mcp` → `ContractOnly`, surfaced by `MapMcp`,
reviewed by `#806`. `AdapterTierScanner` walks every `.cs` under `src/` for a
type carrying `[McpServerToolType]` (simple, qualified, or `Attribute`-suffixed,
or reached through a `using X = ...;` alias — a file-level or namespace-block
Expand All @@ -38,9 +38,9 @@ is red, and the failure prints the C# row to add. A tier row whose surface is
never invoked and whose namespace holds no type is `Dormant` — informational,
never red, because the row is meant to be committed before the code exists.
`privilege` and `surface` are each a closed set, both read from
`AdapterTier.KnownSurfaces` beside `AdapterTier.DirectRepositoryPrivilege` — a
`AdapterTier.KnownSurfaces` beside `AdapterTier.ContractOnlyPrivilege` — a
map from each supported surface to the privilege it grants, today just
`MapMcp` → `DirectRepository`. `AdapterTierScanner` walks invocations of the
`MapMcp` → `ContractOnly`. `AdapterTierScanner` walks invocations of the
same map's keys, so a row naming a surface the scanner does not walk is a
registry error rather than a silently inert exemption. `reviewBy` must match
`^#[0-9]+$`, so an exemption with no end date is a registry error, alongside a
Expand All @@ -49,8 +49,10 @@ blank or missing field and a duplicated `namespace` or `surface`.
A tier is also an adapter root with a privilege. `AdapterReachScanner` unions
every `adapterTiers[].namespace` into its own namespace roots — so a tool
class's repository parameters are walked for module reach exactly like an
endpoint's — and into its persistence-forbidden set, because `DirectRepository`
authorizes a repository, not a direct `AppDbContext`. A tool class reaching an
endpoint's — and into its persistence-forbidden set, so a tool class never takes a
direct `AppDbContext`. **Amended by #806:** the privilege was named `DirectRepository`
until #806 mapped `/mcp`. Since #1123 CW1004 holds tool classes to module contracts, so the
row grants no repository reach, and the name now says so. A tool class reaching an
undeclared module still needs an `adapters` row; one that takes `AppDbContext`
still fails.

Expand Down
17 changes: 10 additions & 7 deletions docs/plans/770-mcp-server/01-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,18 @@ harnesses; #684/#146 packaging.
No new process and nothing to install. The server is the API already running.

```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 assistant connects with OAuth, not a pasted token. **Corrected by #806:** the original text
said a session token from `POST /api/v1/auth/login` connects, and `/mcp` refuses session JWTs.
An unauthenticated request gets a 401 whose `WWW-Authenticate` names
`/.well-known/oauth-protected-resource/mcp` (RFC 9728). That document names the authorization
server (`OAuth:Issuer`, #795), where the assistant registers and a person approves it on the
consent screen (#797, #798). The resulting token is bound to the `/mcp` resource (RFC 8707) and
carries the approver's farm, role and flock assignments, so the assistant acts as that person.
There is no API key and **no new server config key**. See
[`docs/decisions/806-mcp-endpoint.md`](../../decisions/806-mcp-endpoint.md).

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

Expand Down
29 changes: 26 additions & 3 deletions docs/plans/770-mcp-server/02-guards.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ matters more than count: prefer "walk everything, exclude deliberately".
| 11 | `McpBoots_UnderProductionShapedConfig` | #370/#565 stay untouched — no new required config key | add any required config key without teaching `bootstrap.sh`, `docker-compose.sim.yml`, `verify-harness.sh` and the AppHost |
| 12 | `Mcp_AddsNoLiveHostedServiceUnderStateless` | #271's blocker list is not extended | set `SessionMode = Stateful`, which makes `IdleTrackingBackgroundService.StartAsync` run its prune timer instead of returning early. **Asserts inertness, not absence** — the service IS registered unconditionally by `WithHttpTransport`; a guard asserting zero `IHostedService` descriptors would be red on the correct configuration. Candidate 1 proposed exactly that wrong guard. |

#806 implements rows 8 to 12 in `tests/Cluckwork.Api.IntegrationTests/Mcp/McpEndpointTests.cs`.
Row 10's premise does not hold on 2.2.0: `MapMcp` maps a `RequestDelegate`, which carries no
`MethodInfo` metadata, so `BodyReadingEndpointTests` never walks `/mcp` (a mutant removing the
marker left it green). `McpPost_CarriesTheBodyCap_TheBudget_AndTheOAuthGate` pins the marker instead.
Row 11 is `OAuthServerProductionTests.Production_ServesTheConnectFlow`: a Production host with
only `OAuth:Issuer` set connects an app and calls `/mcp` with its token. (`ProcessRoleGuardTests`
only proves that each serving guard fails a boot, not that `/mcp` works.)

## RBAC

| # | Guard | Invariant | Red mutation |
Expand All @@ -40,6 +48,12 @@ matters more than count: prefer "walk everything, exclude deliberately".
| 15 | `MirrorsRoute_NamesResolve` | the registry stays honest | rename a route's `WithName()` and leave the attribute stale |
| 16 | `ToolsList_IsFilteredByRole` (integration, real request) | unauthorized tools are invisible, not merely refused | remove `AddAuthorizationFilters()`. Single point of proof for SDK list-filtering behaviour — flagged as a risk in [`01-design.md`](01-design.md). |

#806 implements row 16 as `McpAuthorizationTests.ToolsList_ShowsOnlyToolsTheRoleAndScopesAllow`,
over probe tools that exist only in its test host, for role and for scope. Rows 13 to 15 wait
for the first tool (#807): with no tool there is no route to mirror. #806 adds a walk the
table did not have, `McpToolSurfaceTests.RealTree_EveryToolCarriesARolePolicyAndAScopePolicy`:
the SDK runs a tool with no `[Authorize]`, or with `[AllowAnonymous]`, for any caller.

## Idempotency

| # | Guard | Invariant | Red mutation |
Expand All @@ -53,6 +67,9 @@ matters more than count: prefer "walk everything, exclude deliberately".
| 23 | `Replay_WritesNoSecondAuditRow` | replay is a true no-op | drop the publish guard. **Asserts the absence of a second `DailyEntryUpdate` audit row, NOT the entry count** — the handler upserts on `(account, farm, house, flock, date)`, so a count assertion passes with idempotency entirely removed. Graft from candidate 1. |
| 24 | `AtomicIdempotencyProtocolTests` + the other #307 suites | the extraction changed no behaviour | **these must pass UNEDITED.** An edit to a #307 test in the implementing PR is a stop-and-review, not a fix. This is the mitigation for the design's largest risk. |

#806 implements rows 17 and 18 as `McpEndpointTests.IdempotencyExemption_IsCarriedByTheMcpPostAlone`
and `McpMessages_WithoutAnIdempotencyKey_AreServed`.

## Domain surface

| # | Guard | Invariant | Red mutation |
Expand All @@ -68,6 +85,11 @@ matters more than count: prefer "walk everything, exclude deliberately".
| 31b | `BalanceReads_AreBoundedInTheDATABASE` (row-consumption assertion) | the **query** is bounded, not just the reply | keep the reply's `Take(cap)` and remove the bound from the query. Row 31 stays GREEN — every response is correctly capped with correct continuation metadata — while `ListCustomerBalancesAsync` materializes the entire farm's balance book first. `PaymentRepository` takes no paging arguments and materializes both grouped queries before building its list, so **reusing it at all is the defect**; this needs a paged balance query, and the test must assert rows consumed from the database, not items returned. A signature walk cannot establish this. **Measure rows CONSUMED, not SQL shape.** `ReportQueryBoundingTests`' `SqlCaptureInterceptor` is the right harness shape and the right precedent (#311 was the identical "loaded the whole ledger, walked it in memory" defect), but it records `(Sql, Parameters)` only — so an implementation that fetches every page in a loop and accumulates the book emits perfectly paged SQL and passes. Keep SQL inspection as *supporting* evidence and count consumption across the whole operation: wrap the returned `DbDataReader` and count successful `Read`/`ReadAsync` calls, covering both aggregate queries and any page-fetch loop. (Round-3 finding 3 — the round-2 fix named a mechanism that cannot measure what the row requires.) (Round-2 finding 2 — row 31 alone claimed an invariant its mutation did not test.) |
| 32 | `NameResolution_IsExactlyOneAccessibleMatch` | a name-addressed write cannot hit the wrong row | make resolution take the first match. **Duplicate flock names are legal** (`FlockRepository.cs` says so, and its `ThenBy(f => f.Id)` tie-break exists because of it), so first-match records production against an arbitrary flock and `Single` throws on valid farm data. Zero matches → not-found; two or more → an ambiguity error listing candidate ids and distinguishing fields, and the follow-up write must pass an id. Test two same-name flocks in one accessible scope. (#770 adversarial review, finding 3.) |

#806 implements rows 29 and 30 as `McpEndpointTests.McpPost_CarriesTheBodyCap_TheBudget_AndTheOAuthGate`
and `OversizedBody_IsRefused`. `/mcp` takes its budget from `AcceptOAuthTokens`, the
`oauth-api` policy, which `OAuthFailClosedTests.OAuthLimits_AreDecidedByTheSharedCounter`
proves is decided by the shared counter.

## Where these guards stop, stated plainly

Row 7b **bounds** the secondary-scope hazard; it does not eliminate it. A closed registration model
Expand All @@ -85,9 +107,10 @@ of the residue, which is why slice 6 is blocked on it — but a secondary-scope
- A `ReviewedFactory` row approves a service type and the type that registers it. If the delegate's
body changes later, the row still approves it. A second factory for the same service in another
type, and a row the walk no longer reaches, both fail.
- `McpToolSurface` finds tool methods with `BindingFlags.DeclaredOnly`. A tool method inherited from
a base class is not walked. Whether the SDK serves inherited attributed methods is unproven until
#806 picks the SDK version.
- Since #806, `McpToolSurface` finds tool types and methods the way SDK 2.2.0's `WithTools` does,
including a public method a tool type inherits, whose policies come from the class declaring it.
`McpEndpointTests.ToolWalk_ReachesEveryToolTheSdkRegisters` fails if the walk and the SDK's
registrations ever count differently, for example after an SDK upgrade.
- A `ReviewedRequestReader` row is keyed by its consumer only. A changed body, an added reader
parameter, or a member returning a provider on a reviewed reader is not detected, the same kind
of drift as a `ReviewedFactory` row.
Expand Down
Loading