Skip to content

feat(mcp): map /mcp for connected apps, with role and scope gates on tools - #1195

Merged
mforce merged 7 commits into
mainfrom
feat/806-map-mcp-b
Oct 10, 2026
Merged

mforce merged 7 commits into
mainfrom
feat/806-map-mcp-b

Conversation

@mforce

@mforce mforce commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

An assistant can now connect to a farm over MCP. /mcp accepts only OAuth tokens from the connected-apps server. It tells a client with no token, or a token that stopped working, where to authenticate. It lists and runs a tool only when the approving person's role and the connection's scopes both allow it. No production tool exists yet. #807 to #809 add them, and this PR gives each one the gates it must carry.

Closes #806

What changes for a caller

  • POST /mcp with no token returns 401 with WWW-Authenticate: Bearer resource_metadata="<issuer>/.well-known/oauth-protected-resource/mcp". That document names the issuer as the authorization server and lists the two scopes. Both URLs come from OAuth:Issuer, never the request's Host. A client can follow it to the issuer's own discovery document.
  • When a token stops granting access, the refusal still names that URL. A disabled user, a suspended farm, a superseded credential or the farm switch answers with its usual problem body plus WWW-Authenticate: Bearer error="invalid_token", resource_metadata="...".
  • Tokens are bound to <issuer>/mcp (RFC 8707). The server registers it as its only resource, so a request naming another resource gets invalid_target. Consent stamps the named resource on the code, or /mcp when the client names none. Validation requires that audience, so a token minted before this change is refused.
  • A token holding neither MCP scope gets 403 with Bearer error="insufficient_scope", scope="farm:read daily-entries:write", resource_metadata="...".
  • tools/list shows a tool only when its role policy and its scope policy both pass. Calling a hidden tool returns a JSON-RPC InvalidRequest error. A flock-scoped Worker's tool call sees exactly its assigned flocks.
  • MCP 2026-07-28 and 2025-11-25 clients both connect. The SDK's own client was tested on each, and a Production host serves /mcp with only the issuer set.
  • No MCP message needs an Idempotency-Key.
  • GET and HEAD /mcp answer 405 with Allow: POST, as the MCP spec asks of a server with no GET stream. The SDK's 2025-11-25 client sends that GET. Without the 405, the SPA fallback would answer it with the app's HTML in Production.
  • The per-token budget rises from 120 to 300 messages per minute. A 429 is now a JSON-RPC error with a null id and a Retry-After header.

Reading order

  1. src/Cluckwork.Api/Hosting/CluckworkMcp.cs registers the SDK, maps the endpoint, and builds the challenge header.
  2. src/Cluckwork.Infrastructure/Modules/Access/OAuth/OAuthServerRegistration.cs does the audience binding: RegisterResources, the sign-in handler that stamps the resource, and AddAudiences.
  3. src/Cluckwork.Api/Modules/Access/OAuth/OAuthScopeRequirement.cs and AuthPolicies.cs add the scope requirement that backs AcceptOAuthTokens and the two tool policies.
  4. ForbiddenProblemResultHandler.cs adds the insufficient-scope answer, and CredentialEpochMiddleware.cs adds the challenge to its 401s on /mcp.
  5. IdempotencyMiddleware.cs exempts endpoints by metadata, and the rate-limit files carry the budget and the 429 body.
  6. docs/decisions/806-mcp-endpoint.md records the reasons. The other doc edits amend OAuth slice 2: fail-closed checks on the OAuth path (disabled, suspended, must-change-password, flock scope, rate limit) #796, [A] #514 slice 2: adapter-tier rows for the MCP and OpenIddict surfaces #843 and the spike: assess adding MCP server support to Cluckwork (feasibility, pros/cons, phasing) #770 plan to match.

Decisions a reviewer should check

  • The OAuth side of the bearer selector is now the SDK's MCP scheme. It forwards authentication to OpenIddict validation and handles the challenge itself. Every AcceptOAuthTokens endpoint gets the resource-metadata challenge. Only /mcp exists in production, and the audience binding already makes every OAuth token an /mcp token.
  • A client that names no resource still gets an /mcp token. The decision on EPIC: MCP server support #789 says to accept a resource only when it equals /mcp. Refusing an omitted one would lock out older clients for no gain, since /mcp is the only resource. Say if you want omission refused instead.
  • The tier row's privilege is renamed from DirectRepository to ContractOnly, and its reason is rewritten. CW1004 holds tool classes to module contracts, so the old name granted nothing. That touches ModuleLedger.cs and two scanner test fixtures.
  • TenantResolutionMiddleware's bodiless 401s get no challenge. They refuse a principal with no account_id or sub, and consent copies both from the session into every token, so no issued token reaches them.

The 300-per-minute check

This is the SDK's own client against this endpoint, in the test host, traced request by request. Times are in-process, so they show the server's cost and leave out network and model time.

Revision Request Status Time
2026-07-28 POST server/discover 200 121 ms (first request on a cold host)
2026-07-28 POST tools/list 200 21 ms
2026-07-28 POST tools/call 200 31 ms
2026-07-28 each of 10 more tools/call 200 4 to 6 ms
2025-11-25 POST initialize 200 14 ms
2025-11-25 GET (standalone stream probe) 405 2 ms
2025-11-25 POST notifications/initialized 202 7 ms
2025-11-25 POST tools/list 200 10 ms
2025-11-25 POST tools/call 200 4 ms
2025-11-25 each of 10 more tools/call 200 4 ms

A session spends 3 counted POSTs to its first tool result on 2026-07-28 and 4 on 2025-11-25. The GET is not counted, because only the POST carries the policy. Each later call is one POST. The first result took 244 ms and 50 ms in total, and 10 more calls took 58 ms and 51 ms. So 300 per minute leaves about 296 calls in a session's first minute, and only a runaway loop reaches it. A model spends seconds between calls. I did not record a third-party client such as Claude Desktop.

What this does not do

SDK facts re-checked on 2.2.0

  • Under SessionMode = Stateless, StreamableHttpHandler.CreateSessionAsync sets the server's services to context.RequestServices and ScopeRequests = false itself.
  • PerSessionExecutionContext is [Obsolete] with diagnostic MCP9006.
  • WithHttpTransport always registers IdleTrackingBackgroundService, and its StartAsync returns early under Stateless.
  • MapMcp maps only POST under Stateless. The issue expected BodyReadingEndpointTests to go red without ReadsRequestBodyAttribute, and it does not. MapMcp binds a RequestDelegate, which carries no MethodInfo metadata, so that guard never walks /mcp. A mutant proved it. McpEndpointTests pins the marker directly, and 02-guards.md records the gap.
  • AddAuthorizationFilters() removes unauthorized tools from tools/list. A tool with no authorization metadata, or with [AllowAnonymous], runs for any caller.
  • WithTools registers every public method a tool type inherits, and reads its policies from the class that declares the method. The MCP slice 2: the identity bridge (McpCallContext) and the dependency-graph walk #805 walk used DeclaredOnly and missed such a method. McpToolSurface now enumerates as the SDK does.

Tests

New, in tests/Cluckwork.Api.IntegrationTests/Mcp/:

  • McpEndpointTests checks guard rows 8 to 12, 17, 18, 29 and 30 against the running endpoint table and services. It covers each mapping guard, the stateless transport, the idle sweep that never starts, the 413 cap, the 405 on GET and HEAD, and the absence of /mcp without an issuer. ToolWalk_ReachesEveryToolTheSdkRegisters fails if the tool walks and the SDK's registrations count differently. The probe tools make that count non-zero today.
  • McpAuthorizationTests runs the real OAuth flow and the SDK's client against /mcp, with probe tools that exist only in its test host. It covers the challenge, the discovery chain to the authorization server, the audience refusals, invalid_target, insufficient scope, tools/list filtering by role and by scope, the hidden-tool refusal, a flock-scoped Worker, and the 429 shape. It also trips each shared check through /mcp itself and asserts the problem title and the challenge header: farm switch, disabled user, suspended farm, superseded credential, Disconnect and a pending password change.
  • McpToolSurfaceTests gains the role-and-scope gate walk, an inherited-method fixture, and a floor over the fixture set.

OAuthServerProductionTests.Production_ServesTheConnectFlow now ends by calling /mcp with the token it obtained.

Local runs at the head (filtered; CI runs the full suite):

  • Integration Mcp., OAuth, RateLimit, CredentialEpoch, BodyReading, MustChangePassword, ClientMetadata: 464 passed.
  • Application Architecture, TenantBypass, Documentation: 467 passed.

Fixes made while running guards:

  • TenantBypassRealTreeTests failed because the exemption changed IdempotencyMiddleware.InvokeAsync's token hash. The raw SQL it allow-lists is unchanged, so I updated the row's hash.
  • The first wide run failed 37 tests with inotify instances has been reached. That is the shared host's 128-instance limit, not this change. Reruns use DOTNET_USE_POLLING_FILE_WATCHER=true.

Mutation results, from tools/oauth/mutation-check.sh at 6f71cfc4. This PR adds 26 #806 mutants and updates 3 existing anchors for the new scope requirement. Baseline and restore were 359/359 green, and every mutant was killed by its named test with its declared failure text.

Mutant Test Verdict
authenticate-at-authorization OAuthFailClosedTests.DisabledUser_IsRefused_OnTheNextRequest killed
authenticate-at-authorization-flocks OAuthFailClosedTests.Worker_IsFlockScoped killed
scope-gate-removed OAuthFailClosedTests.ScopeAndRole_AreBothRequired killed
mcp-stateful Mcp.McpEndpointTests.Transport_IsStateless_WithNoSessionHook killed
mcp-stateful-sweep-runs Mcp.McpEndpointTests.IdleSessionSweep_IsRegistered_ButNeverStarts killed
mcp-session-hook Mcp.McpEndpointTests.Transport_IsStateless_WithNoSessionHook killed
mcp-body-unclassified Mcp.McpEndpointTests.McpPost_CarriesTheBodyCap_TheBudget_AndTheOAuthGate killed
mcp-body-uncapped Mcp.McpEndpointTests.OversizedBody_IsRefused killed
mcp-idempotency-unmarked Mcp.McpEndpointTests.McpMessages_WithoutAnIdempotencyKey_AreServed killed
mcp-idempotency-second-bearer Mcp.McpEndpointTests.IdempotencyExemption_IsCarriedByTheMcpPostAlone killed
mcp-idempotency-exemption-ignored Mcp.McpEndpointTests.McpMessages_WithoutAnIdempotencyKey_AreServed killed
mcp-tools-list-unfiltered Mcp.McpAuthorizationTests.ToolsList_ShowsOnlyToolsTheRoleAndScopesAllow killed
mcp-read-scope-policy-wrong Mcp.McpAuthorizationTests.ToolsList_ShowsOnlyToolsTheRoleAndScopesAllow killed
mcp-get-falls-to-spa Mcp.McpEndpointTests.GetAndHead_Get405 killed
shared-check-unchallenged Mcp.McpAuthorizationTests.SharedCheck_RefusesTheConnection_WithAChallenge killed
rpc-429-no-retry-after Mcp.McpAuthorizationTests.OverTheBudget_Gets429_AsAJsonRpcError killed
tool-walk-declared-only Mcp.McpToolSurfaceTests.InheritedToolMethod_IsGatedByItsDeclaringType killed
tool-walk-reflected-type-gates Mcp.McpToolSurfaceTests.InheritedToolMethod_IsGatedByItsDeclaringType killed
tool-walk-finds-nothing Mcp.McpEndpointTests.ToolWalk_ReachesEveryToolTheSdkRegisters killed
mcp-mapped-without-issuer Mcp.McpEndpointTests.WithoutAnIssuer_McpIsNotMapped killed
audience-unchecked Mcp.McpAuthorizationTests.TokenIssuedForAnotherResource_IsRefused killed
audience-unchecked-unbound Mcp.McpAuthorizationTests.TokenWithoutTheMcpAudience_IsRefused killed
resource-not-stamped Mcp.McpAuthorizationTests.TokenForMcp_IsAccepted killed
default-resource-dropped Mcp.McpAuthorizationTests.TokenForMcp_IsAccepted killed
resource-unregistered Mcp.McpAuthorizationTests.TokenForMcp_IsAccepted killed
challenge-without-metadata Mcp.McpAuthorizationTests.NoToken_Gets401_NamingTheProtectedResourceMetadata killed
metadata-from-request-host Mcp.McpAuthorizationTests.Challenge_NamesTheIssuer_NotTheRequestHost killed
insufficient-scope-unannounced Mcp.McpAuthorizationTests.TokenWithoutAnMcpScope_Gets403InsufficientScope killed
rpc-429-as-problem Mcp.McpAuthorizationTests.OverTheBudget_Gets429_AsAJsonRpcError killed

Notes on the runs:

  • 38801b35 moved the endpoint check into CluckworkMcp.AddChallenge, because CodeQL's cs/user-controlled-bypass read the AcceptsOAuthTokens call in CredentialEpochMiddleware as a sensitive action under the existing logout-path exemption. A rerun of shared-check-unchallenged (re-anchored) and insufficient-scope-unannounced at that head killed both, with baseline and restore 359/359.
  • rpc-429-no-retry-after did not compile in the full run, because the limiter's lambda names its CancellationToken parameter _. Commit 6f71cfc4 fixes the replacement, and a rerun killed it with baseline and restore 359/359.
  • mcp-tools-list-unfiltered is killed by an exception. Without AddAuthorizationFilters() the SDK refuses tools/list outright rather than listing unfiltered tools, which is a fail-closed refusal. The mutant declares that error text.
  • In round 0, mcp-body-unclassified first survived against BodyReadingEndpointTests, which is the gap described under SDK facts. It now targets McpEndpointTests.
  • In round 0's first restore pass, OAuthPurgeTests.Sweep_RunsOnlyOnTheLeader failed once on code identical to the green baseline, then passed 3 of 3 reruns. This PR does not touch it. I read it as a pre-existing race.

Rerun one mutant with sg docker -c 'MUTANT_FILTER="^audience-unchecked$" bash tools/oauth/mutation-check.sh'.

Instruction file changes needed

src/AGENTS.md, "Auth and credentials". Append to the OAuth bullet (line 47), before its links:

OAuth tokens are bound to <OAuth:Issuer>/mcp; a second endpoint that accepts them needs its own resource and audience. → 806

Replace the MCP bullet (line 52) with:

An MCP tool learns its caller only from McpCallContext, and never injects TenantContext, CurrentUserContext, FlockScope, AppDbContext, IServiceProvider, IServiceScopeFactory, HttpContext or IHttpContextAccessor. Every tool method, inherited ones included, carries a role [Authorize] and a scope [Authorize(Policy = AuthPolicies.<Scope>Scope)] on itself or its declaring class, and never [AllowAnonymous]. The tests in tests/Cluckwork.Api.IntegrationTests/Mcp/ enforce both; docs/plans/770-mcp-server/02-guards.md lists what they cannot see. → 806

Adds ModelContextProtocol.AspNetCore 2.2.0 and maps /mcp beside the
authorization server, stateless, serving MCP 2026-07-28 and 2025-11-25.

- Tokens are bound to <OAuth:Issuer>/mcp (RFC 8707): the server registers
  it as its only resource, consent stamps it, validation requires it.
- The SDK's McpAuthenticationHandler serves the protected-resource
  metadata and answers a challenge with its URL (RFC 9728).
- OAuthScopeRequirement backs AcceptOAuthTokens and two scope policies.
  A tool carries a role policy and a scope policy, so tools/list and
  tools/call follow role and scope together. A scope-only denial answers
  403 insufficient_scope.
- IdempotencyMiddleware skips endpoints marked HandlesOwnIdempotency
  (only /mcp). /mcp has a 32 KiB body cap.
- oauth-api rises to 300 per minute, and its 429 is a JSON-RPC error.
- The Cluckwork.Api.Mcp tier privilege is renamed ContractOnly.
…nnot see it

MapMcp maps a RequestDelegate, which carries no MethodInfo metadata, so BodyReadingEndpointTests never walks /mcp. A mutant removing ReadsRequestBodyAttribute left it green. The mutant now targets McpEndpointTests, and the guard table says why.
A stateless MCP server serves no GET stream, and the MCP spec then asks for 405. In Production the SPA fallback takes every GET and HEAD, so a client probing for the stream got the app's HTML with 200.
…l methods

Review round 1 on #1195.

- A disabled user, a suspended farm, a superseded credential or the farm
  switch now answers /mcp with WWW-Authenticate: Bearer
  error="invalid_token", resource_metadata="..." beside its problem body
  (RFC 6750 §3). CluckworkMcp.AddChallenge builds it for these 401s and
  for the insufficient_scope 403.
- McpToolSurface finds tool types and methods the way SDK 2.2.0 does,
  including a public method a tool type inherits, gated by its declaring
  type's attributes. McpEndpointTests checks the walk reaches every tool
  the SDK registers, which is non-zero with the probe tools.
- The 429 test asserts JSON-RPC field types and Retry-After instead of an
  English phrase.
- A Production host calls /mcp with only the issuer set, a client follows
  the challenge to the authorization server's discovery document, and a
  flock-scoped Worker's tool call sees exactly its assigned flock.
- 02-guards.md names the Production proof for row 11; mutation-check.sh
  gains five mutants for the new guards.
The rate limiter's OnRejected lambda names its CancellationToken parameter _, so assigning to _ was not a discard.
CodeQL's cs/user-controlled-bypass read the AcceptsOAuthTokens call in CredentialEpochMiddleware as a sensitive action under the existing logout-path exemption. The check moves into the helper, which every caller wants anyway.
@mforce
mforce marked this pull request as ready for review October 10, 2026 22:06
@mforce
mforce merged commit 6502603 into main Oct 10, 2026
22 checks passed
@mforce
mforce deleted the feat/806-map-mcp-b branch October 10, 2026 23:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP slice 3: map /mcp, wire RBAC and tools/list filtering

1 participant