Repository navigation
feat(mcp): map /mcp for connected apps, with role and scope gates on tools - #1195
Merged
Merged
Conversation
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
marked this pull request as ready for review
October 10, 2026 22:06
This was referenced Oct 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An assistant can now connect to a farm over MCP.
/mcpaccepts 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 /mcpwith no token returns 401 withWWW-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 fromOAuth:Issuer, never the request's Host. A client can follow it to the issuer's own discovery document.WWW-Authenticate: Bearer error="invalid_token", resource_metadata="...".<issuer>/mcp(RFC 8707). The server registers it as its only resource, so a request naming another resource getsinvalid_target. Consent stamps the named resource on the code, or/mcpwhen the client names none. Validation requires that audience, so a token minted before this change is refused.Bearer error="insufficient_scope", scope="farm:read daily-entries:write", resource_metadata="...".tools/listshows a tool only when its role policy and its scope policy both pass. Calling a hidden tool returns a JSON-RPCInvalidRequesterror. A flock-scoped Worker's tool call sees exactly its assigned flocks./mcpwith only the issuer set.Idempotency-Key.GETandHEAD /mcpanswer 405 withAllow: 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.Retry-Afterheader.Reading order
src/Cluckwork.Api/Hosting/CluckworkMcp.csregisters the SDK, maps the endpoint, and builds the challenge header.src/Cluckwork.Infrastructure/Modules/Access/OAuth/OAuthServerRegistration.csdoes the audience binding:RegisterResources, the sign-in handler that stamps the resource, andAddAudiences.src/Cluckwork.Api/Modules/Access/OAuth/OAuthScopeRequirement.csandAuthPolicies.csadd the scope requirement that backsAcceptOAuthTokensand the two tool policies.ForbiddenProblemResultHandler.csadds the insufficient-scope answer, andCredentialEpochMiddleware.csadds the challenge to its 401s on/mcp.IdempotencyMiddleware.csexempts endpoints by metadata, and the rate-limit files carry the budget and the 429 body.docs/decisions/806-mcp-endpoint.mdrecords 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
AcceptOAuthTokensendpoint gets the resource-metadata challenge. Only/mcpexists in production, and the audience binding already makes every OAuth token an/mcptoken./mcptoken. The decision on EPIC: MCP server support #789 says to accept aresourceonly when it equals/mcp. Refusing an omitted one would lock out older clients for no gain, since/mcpis the only resource. Say if you want omission refused instead.DirectRepositorytoContractOnly, and its reason is rewritten. CW1004 holds tool classes to module contracts, so the old name granted nothing. That touchesModuleLedger.csand two scanner test fixtures.TenantResolutionMiddleware's bodiless 401s get no challenge. They refuse a principal with noaccount_idorsub, 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.
POST server/discoverPOST tools/listPOST tools/calltools/callPOST initializeGET(standalone stream probe)POST notifications/initializedPOST tools/listPOST tools/calltools/callA 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
AdapterClaimrows inRealModuleLedger.Adaptersto add yet.insufficient_scope.SDK facts re-checked on 2.2.0
SessionMode = Stateless,StreamableHttpHandler.CreateSessionAsyncsets the server's services tocontext.RequestServicesandScopeRequests = falseitself.PerSessionExecutionContextis[Obsolete]with diagnosticMCP9006.WithHttpTransportalways registersIdleTrackingBackgroundService, and itsStartAsyncreturns early under Stateless.MapMcpmaps onlyPOSTunder Stateless. The issue expectedBodyReadingEndpointTeststo go red withoutReadsRequestBodyAttribute, and it does not.MapMcpbinds aRequestDelegate, which carries noMethodInfometadata, so that guard never walks/mcp. A mutant proved it.McpEndpointTestspins the marker directly, and02-guards.mdrecords the gap.AddAuthorizationFilters()removes unauthorized tools fromtools/list. A tool with no authorization metadata, or with[AllowAnonymous], runs for any caller.WithToolsregisters 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 usedDeclaredOnlyand missed such a method.McpToolSurfacenow enumerates as the SDK does.Tests
New, in
tests/Cluckwork.Api.IntegrationTests/Mcp/:McpEndpointTestschecks 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/mcpwithout an issuer.ToolWalk_ReachesEveryToolTheSdkRegistersfails if the tool walks and the SDK's registrations count differently. The probe tools make that count non-zero today.McpAuthorizationTestsruns 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/listfiltering by role and by scope, the hidden-tool refusal, a flock-scoped Worker, and the 429 shape. It also trips each shared check through/mcpitself and asserts the problem title and the challenge header: farm switch, disabled user, suspended farm, superseded credential, Disconnect and a pending password change.McpToolSurfaceTestsgains the role-and-scope gate walk, an inherited-method fixture, and a floor over the fixture set.OAuthServerProductionTests.Production_ServesTheConnectFlownow ends by calling/mcpwith the token it obtained.Local runs at the head (filtered; CI runs the full suite):
Mcp.,OAuth,RateLimit,CredentialEpoch,BodyReading,MustChangePassword,ClientMetadata: 464 passed.Architecture,TenantBypass,Documentation: 467 passed.Fixes made while running guards:
TenantBypassRealTreeTestsfailed because the exemption changedIdempotencyMiddleware.InvokeAsync's token hash. The raw SQL it allow-lists is unchanged, so I updated the row's hash.inotify instances has been reached. That is the shared host's 128-instance limit, not this change. Reruns useDOTNET_USE_POLLING_FILE_WATCHER=true.Mutation results, from
tools/oauth/mutation-check.shat6f71cfc4. 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.authenticate-at-authorizationOAuthFailClosedTests.DisabledUser_IsRefused_OnTheNextRequestauthenticate-at-authorization-flocksOAuthFailClosedTests.Worker_IsFlockScopedscope-gate-removedOAuthFailClosedTests.ScopeAndRole_AreBothRequiredmcp-statefulMcp.McpEndpointTests.Transport_IsStateless_WithNoSessionHookmcp-stateful-sweep-runsMcp.McpEndpointTests.IdleSessionSweep_IsRegistered_ButNeverStartsmcp-session-hookMcp.McpEndpointTests.Transport_IsStateless_WithNoSessionHookmcp-body-unclassifiedMcp.McpEndpointTests.McpPost_CarriesTheBodyCap_TheBudget_AndTheOAuthGatemcp-body-uncappedMcp.McpEndpointTests.OversizedBody_IsRefusedmcp-idempotency-unmarkedMcp.McpEndpointTests.McpMessages_WithoutAnIdempotencyKey_AreServedmcp-idempotency-second-bearerMcp.McpEndpointTests.IdempotencyExemption_IsCarriedByTheMcpPostAlonemcp-idempotency-exemption-ignoredMcp.McpEndpointTests.McpMessages_WithoutAnIdempotencyKey_AreServedmcp-tools-list-unfilteredMcp.McpAuthorizationTests.ToolsList_ShowsOnlyToolsTheRoleAndScopesAllowmcp-read-scope-policy-wrongMcp.McpAuthorizationTests.ToolsList_ShowsOnlyToolsTheRoleAndScopesAllowmcp-get-falls-to-spaMcp.McpEndpointTests.GetAndHead_Get405shared-check-unchallengedMcp.McpAuthorizationTests.SharedCheck_RefusesTheConnection_WithAChallengerpc-429-no-retry-afterMcp.McpAuthorizationTests.OverTheBudget_Gets429_AsAJsonRpcErrortool-walk-declared-onlyMcp.McpToolSurfaceTests.InheritedToolMethod_IsGatedByItsDeclaringTypetool-walk-reflected-type-gatesMcp.McpToolSurfaceTests.InheritedToolMethod_IsGatedByItsDeclaringTypetool-walk-finds-nothingMcp.McpEndpointTests.ToolWalk_ReachesEveryToolTheSdkRegistersmcp-mapped-without-issuerMcp.McpEndpointTests.WithoutAnIssuer_McpIsNotMappedaudience-uncheckedMcp.McpAuthorizationTests.TokenIssuedForAnotherResource_IsRefusedaudience-unchecked-unboundMcp.McpAuthorizationTests.TokenWithoutTheMcpAudience_IsRefusedresource-not-stampedMcp.McpAuthorizationTests.TokenForMcp_IsAccepteddefault-resource-droppedMcp.McpAuthorizationTests.TokenForMcp_IsAcceptedresource-unregisteredMcp.McpAuthorizationTests.TokenForMcp_IsAcceptedchallenge-without-metadataMcp.McpAuthorizationTests.NoToken_Gets401_NamingTheProtectedResourceMetadatametadata-from-request-hostMcp.McpAuthorizationTests.Challenge_NamesTheIssuer_NotTheRequestHostinsufficient-scope-unannouncedMcp.McpAuthorizationTests.TokenWithoutAnMcpScope_Gets403InsufficientScoperpc-429-as-problemMcp.McpAuthorizationTests.OverTheBudget_Gets429_AsAJsonRpcErrorNotes on the runs:
38801b35moved the endpoint check intoCluckworkMcp.AddChallenge, because CodeQL'scs/user-controlled-bypassread theAcceptsOAuthTokenscall inCredentialEpochMiddlewareas a sensitive action under the existing logout-path exemption. A rerun ofshared-check-unchallenged(re-anchored) andinsufficient-scope-unannouncedat that head killed both, with baseline and restore 359/359.rpc-429-no-retry-afterdid not compile in the full run, because the limiter's lambda names itsCancellationTokenparameter_. Commit6f71cfc4fixes the replacement, and a rerun killed it with baseline and restore 359/359.mcp-tools-list-unfilteredis killed by an exception. WithoutAddAuthorizationFilters()the SDK refusestools/listoutright rather than listing unfiltered tools, which is a fail-closed refusal. The mutant declares that error text.mcp-body-unclassifiedfirst survived againstBodyReadingEndpointTests, which is the gap described under SDK facts. It now targetsMcpEndpointTests.OAuthPurgeTests.Sweep_RunsOnlyOnTheLeaderfailed 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:Replace the MCP bullet (line 52) with: